/Games/Avagojo.foocad

SVG Rules
You can create mazes by drawing an SVG diagram of the maze, using Inkscape.
I strongly recommend you copy
an existing maze, rather than starting from scratch,
because it has sensible document properties, such as a grid.
All objects must be ungrouped, and in the "default" layer.
Every object must have an ID which specifies what type of object it is. Use Ctrl+Shift+O in Inkscape to edit an object's ID. There are two pieces which MUST be present :
playingArea
ramp
: A 4x1 rectangle.
Optional items with fixed IDs :
solution
: A path, showing the solution of the maze.center
: A 3x3 circle for the center of the maze. This may have a suffix of "N", "S", "E" or "W" to indicate the direction of the exit.
The remaing IDs must be prefixed as follows :
hole
: The text afterhole
may be used to print the hole numbers on the floor???path
A wall (where X is anypost
A post to support the playing area and the glass.
All wall nodes must be at a grid intersection. (Enable Snapping -> Nodes -> Cusp nodes)
All other objects must have their bounding box corners at a grid intersection. (Use
Alt
while dragging, and enable Snapping -> Bounding Boxes -> Corners).Walls may be a path containing many line segments. However, I find it easier to make each wall a single line (either horizontal or vertical). Warning, multi-line paths can cause issues when using a large wall chamfer.
All other attributes, such as color, line thickness etc. are ignored.
Printing the Maze
I've given the option of printing the floor and the maze walls as one piece, but I prefer to print them separately. The floor is printed playing surface downwards, and is therefore very smooth.
Hole Numbers
The piece named "holes" shows the positions of the holes (as a 3D model), but it also prints an SVG document to the log. Copy/paste it into a new file (holes.svg), and then print it using a 2D printer.
import static uk.co.nickthecoder.foocad.extras.v1.Extras.* import static uk.co.nickthecoder.foocad.extras.v1.Extras.* import uk.co.nickthecoder.foocad.smartextrusion.v1.SmartExtrusion import static uk.co.nickthecoder.foocad.smartextrusion.v1.SmartExtrusion.* import uk.co.nickthecoder.foocad.threaded.v2.* import static uk.co.nickthecoder.foocad.threaded.v2.Thread.* import static uk.co.nickthecoder.foocad.chamferedextrude.v1.ChamferedExtrude.* class Avagojo : AbstractModel(), ModelWithSetup, PostProcessor { @Custom var ballDiameter = 6 @Custom var gridSize = 10 @Custom var maze = "avagojo-final.svg" @Custom var solutionHeight = 0.4 @Custom var bottomLettering = Text( "" ) // or rules! .hAlign(HAlignment.CENTER) @Custom var doubleHoles = false var floorMargin = 4 val glassThickness = 2.0 var floorThickness = 2.0 // As a proportion of gridSize val holeRadius = 0.38 val wallThickness = 0.15 val wallHeight = 4 val roomHeight = 7 val solutionRatio = 0.95 val wallChamferTop = gridSize * wallThickness * 0.3 val wallChamferBottom = gridSize * wallThickness * 0.3 val postDiameter = 6 val columnDiameter = postDiameter // For postThreaded only var across = 10 var down = 10 var offset = Vector2(0,0) @Custom var topBottomThickness = 1.6 @Custom( about="For the side pieces" ) var grooveChamfer = 0.6 val slack = 0.3 var svgDocument : SVGDocument = null override fun setup() { svgDocument = SVGDocument( maze ) if (! svgDocument.shapes.containsKey( "playingArea" ) ) { throw Exception( "ERROR. SVG document $maze must contain a shape with ID 'playingArea'" ) return } val playingArea = svgDocument["playingArea"] //println( "Playing area size = ${playingArea.size}" ) across = playingArea.size.x / 10 down = playingArea.size.y / 10 offset = playingArea.corner } fun svgToWorld( svg : Vector2 ) : Vector2 = (svg - offset)/10 * gridSize fun svgToGrid( svg : Vector2 ) : Vector2 = (svg - offset)/10 var mazeFloor : Shape3d var mazeWalls : Shape3d // The maze and walls printed as 1 piece @Piece fun maze() : Shape3d { generate() return mazeFloor + mazeWalls } // Just the maze walls. Also print `mazeFloor` and glue them together. @Piece fun mazeWalls() : Shape3d { generate() return mazeWalls.bottomTo(0) } // Just the maze floor, printed upside down, so that the play surface is // smooth (against the printer's bed). @Piece fun mazeFloor() : Shape3d { generate() return mazeFloor.rotateX(180).bottomTo(0) } // Prints an SVG document to the log. Copy/Paste into a file to print it on a 2D printer. @Piece( printable = false ) fun holes() : Shape3d { var floor : Shape2d = Square( across * gridSize, down * gridSize ) var result : Shape3d = floor.extrude(0.1).color("White") val width = floor.size.x val height = floor.size.y Log.println( "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" ) Log.println( "<svg width=\"${width}mm\" height=\"${height}mm\" viewBox=\"0 ${-height} ${width} ${height}\" version=\"1.1\">" ) Log.println( "<rect style=\"fill:none;stroke:#000000;stroke-width:0.5;stroke-opacity:1;\" width=\"${width-1}\" height=\"${height-1}\" x=\"0\" y=\"${-height}\" />" ) Log.println( "<g id=\"holes\">" ) for ( key in svgDocument.shapes.keySet() ) { val position = svgToWorld( svgDocument[key].middle ) if (key.startsWith( "hole" ) ) { var name = key.substring( 4 ) if (name.startsWith("_")) name = name.substring(1) val label = Text( name, 3 ).centerTo( position.x, position.y ).color("Black") result += label.extrude(1) Log.println( "<text x=\"${position.x}\" y=\"${-position.y}\" style=\"font-size:5px;font-family:'Liberation Sans';\" dominant-baseline=\"middle\" text-anchor=\"middle\">${name}</text>" ) } } Log.println( "</g>" ) Log.println( "</svg>" ) return result } fun generate() { val margin = floorMargin + gridSize * wallThickness/2 var floor : Shape2d = Square( across * gridSize + margin*2, down * gridSize + margin*2 ) .roundAllCorners(5,1) .translate(-margin, -margin) var walls : Shape3d = Union3d() val playingArea = svgDocument["playingArea"].paths[0] var outerWalls : Shape3d = createPlainWall( Path2d( listOf<Vector2>(playingArea[0], playingArea[1]), false ) ) outerWalls += createPlainWall( Path2d( listOf<Vector2>(playingArea[1], playingArea[2]), false ) ) outerWalls += createPlainWall( Path2d( listOf<Vector2>(playingArea[2], playingArea[3]), false ) ) outerWalls += createPlainWall( Path2d( listOf<Vector2>(playingArea[3], playingArea[0]), false ) ) if (svgDocument.shapes.containsKey("ramp")) { val ramp = svgDocument["ramp"] val path = ramp.paths[0] val rect = if (ramp.size.x > ramp.size.y ) { Square( gridSize * 4 - wallThickness*gridSize, gridSize - wallThickness*gridSize ) } else { Square( gridSize - wallThickness*gridSize, gridSize * 4 - wallThickness*gridSize ) } .center() .translate( svgToWorld(ramp.middle) ) floor -= rect } val post = extrudeWall( Square( gridSize + gridSize*wallThickness ) .center() .roundAllCorners( gridSize * wallThickness/2 ) ) .bottomTo( floorThickness ) .color( "lightBlue" ) - Cylinder( 100, postDiameter/2 + slack ).center() for ( key in svgDocument.shapes.keySet() ) { val position = svgToWorld( svgDocument[key].middle ) // Check that the object is within the bounds of the playing area. // This will filter out text/graphics for edges or bottom of the case. if (position.x < floor.left || position.x > floor.right || position.y < floor.front || position.y > floor.back ) { continue } if (key.startsWith( "hole" ) ) { // Punch a hole in the floor floor -= Circle( gridSize*holeRadius ).translate( position ) } else if (key.startsWith( "post" ) ) { // Add a post to support the playing surface and the glass walls += post.translate( position.x, position.y, 0 ) floor -= Circle( postDiameter / 2 + slack ).translate( position.x, position.y ) } else if (key.startsWith("path") ) { val path = svgDocument[key].paths[0] val wall = createWall( path ) walls += wall } else if (key.startsWith("plain") ) { val path = svgDocument[key].paths[0] val wall = createPlainWall( path ) walls += wall } else if (key.startsWith( "center" ) ) { val center2d : Shape2d = Square( gridSize*3 ) .center() - Circle( gridSize*3/2 - gridSize*wallThickness ) - Square( gridSize ).center().translate( 0, gridSize*1.5 ) val rotated = if (key == "centerS") { center2d.rotate(180) } else if (key == "centerE") { center2d.rotate(-90) } else if (key == "centerW") { center2d.rotate(90) } else { center2d } val center3d = rotated .translate( position ) .chamferedExtrude( wallHeight, -wallChamferBottom, wallChamferTop ) .bottomTo( floorThickness ) .color( "lightBlue" ) walls += center3d } } walls += outerWalls val perimeter = Square( gridSize * (across + wallThickness), gridSize * (down + wallThickness) ) .roundAllCorners(gridSize * wallThickness/2) .translate( -wallThickness*gridSize/2, -wallThickness*gridSize/2 ) .extrude( roomHeight ) mazeFloor = floor.chamferedExtrude( floorThickness, 0.5, 0 ) mazeWalls = walls.color( "lightBlue" ) } fun endCap( svgPoint : Vector2 ) : Shape2d { val grid = svgToGrid( svgPoint ) // Is it touching the perimeter? return if (grid.x < 0.1 || grid.y < 0.1 || grid.x > across - 0.1 || grid.y > down - 0.1 ) { Square(0) } else { // Is it touching the ramp? if ( grid.y < 1.1 && grid.x >= across - 5.1) { Square(0) } else { Circle( gridSize * wallThickness/2 ).sides(16) .translate( svgToWorld(svgPoint) ) } } } // Extrudes like this : // / \ } wallTopChamfer // | | \ // | | } wallHeight - wallTopChamfer - wallBottomChamfer * 2 // | | / // / \ } wallBottomChamfer // |___| } wallBottomChamfer fun extrudeWall( wall : Shape2d ) : Shape3d { return ExtrusionBuilder().apply { crossSection( wall.offset( wallChamferBottom ) ) forward(wallChamferBottom) crossSection() forward(wallChamferBottom) crossSection( - wallChamferBottom ) forward( wallHeight - wallChamferBottom*2 - wallChamferTop ) crossSection() forward( wallChamferTop ) crossSection( -wallChamferTop ) }.build() .bottomTo( floorThickness ) } fun createWall( path : Path2d ) : Shape3d { val endCaps = endCap( path[0] ) + endCap(path[-1] ) val wall2d = path.thickness( gridSize * wallThickness ) .translate(-offset) .scale( gridSize / 10 ) return extrudeWall( wall2d + endCaps ) } // Used for the outside, and the walls around the ramp. // Has no bottom chamfer. fun createPlainWall( path : Path2d ) : Shape3d { val endCaps = Circle( gridSize * wallThickness/2 ).sides(16) .translate( svgToWorld(path[0]) ) .translate( svgToWorld(path[-1]) - svgToWorld(path[0]) ).also() val wall2d = path.thickness( gridSize * wallThickness ) .translate(-offset) .scale( gridSize / 10 ) return (wall2d + endCaps) .chamferedExtrude( wallHeight, 0, wallChamferTop ) .bottomTo( floorThickness ) } // Optional. The path through the maze printed upside-down (thin). // Can be glued onto piece `maze` to give a smooth playing suface. // Not needed when using piece `mazeFloor` and `mazeWalls`, as these will already // give a smooth 2 colour finish. @Piece fun solution() : Shape3d { if (! svgDocument.shapes.containsKey( "solution" )) { return Text( "No solution in SVG document" ).extrude(0.2) } val solution = svgDocument["solution"] val path = solution .scale( gridSize/10 ) .toOrigin() .translate( svgToWorld( solution.corner ) ) .paths[0] // TODO New Feather feature. Specify rounded corners, and end caps. .thickness( (gridSize - gridSize*wallThickness - wallChamferBottom*2) * solutionRatio ) .roundAllCorners( 1 ) return path.extrude( solutionHeight ) .rotateY(180).bottomTo(0) } // A Single (simple) post. // Consider this deprecated, as I prefer pairs of `postThreaded` and `postBase`. @Piece fun post() : Shape3d { return ExtrusionBuilder().apply { crossSection( Square(gridSize*0.9).center().roundAllCorners(2) ) forward( roomHeight ) crossSection() crossSection( Circle( postDiameter/2 ) ) forward( floorThickness + roomHeight + slack ) crossSection() }.build().color( "lightGrey") } @Piece fun postPair() = postThreaded().backTo(-1) + postBase().frontTo(1) // The top part of a post, which support the glass. // Screws into `postBase`. @Piece fun postThreaded() : Shape3d { val thread = Thread( postDiameter ) val extra = 1 val base = 1 val thinner = postDiameter/2 + extra - columnDiameter/2 val post = ExtrusionBuilder().apply { crossSection( Circle( columnDiameter/2 ) ) forward( roomHeight - wallHeight - extra ) crossSection( extra ) forward(base) crossSection() }.build() val threaded = thread.threadedRod( roomHeight + wallHeight + floorThickness - 0.5 ) .chamferBottom( false ) .bottomTo( post.top ) return post + threaded } // The bottom half of `postThreaded`. This supports the playing area, and // `postThreaded` supports the glass. @Piece fun postBase() : Shape3d { val thread = Thread( postDiameter ) return Square(gridSize*0.9).center().roundAllCorners(2) .extrude( roomHeight ) - thread.threadedHole( roomHeight ) } // A 2D shape for the edges of the game. // Contains 3 grooves for the glass, the mazeFloor and the base. fun edgeProfile() : Shape2d { val basic = Square( 10, topBottomThickness*2 + floorThickness*2 + roomHeight*2 + glassThickness + slack ) .roundCorner(3,4) .roundCorner(0,4) .frontTo( -floorThickness - topBottomThickness - slack ) val baseCut = Square( 10, floorThickness + slack ) .leftTo( basic.right - floorMargin - slack ) .frontTo( basic.front + topBottomThickness ) val glassCut = Square ( 10, glassThickness + slack ) .leftTo( basic.right - floorMargin - slack ) .backTo( basic.back - topBottomThickness ) val floorCut = Square( 10, floorThickness + slack ) .leftTo( basic.right - floorMargin-slack ) .centerYTo( (glassCut.front + baseCut.back)/2 ) val chamfer = Triangle( grooveChamfer, grooveChamfer ).mirrorX().rightTo( basic.right + 0.01 ) val chamfers : Shape2d = if (grooveChamfer > 0) { chamfer.frontTo( baseCut.back ).mirrorY().backTo( baseCut.front ).also(2) + chamfer.frontTo( glassCut.back ).mirrorY().backTo( glassCut.front ).also(2) + chamfer.frontTo( floorCut.back ).mirrorY().backTo( floorCut.front ).also(2) } else { Square(0) } return (basic - baseCut - floorCut - glassCut - chamfers) } @Piece fun edgesFrontAndBack() = edgeFront().backTo(-1) + edgeBack().frontTo(1) // All four edge pieces. `edgeFront`, `edgeBack`, 2x edgeSide. @Piece fun edges() : Shape3d { val sides = edgeSide().frontTo(0) .backTo(-2).also() val front = edgeFront() .backTo(sides.front-1) val back = edgeBack() .backTo(front.front-2) return sides + front.backTo( sides.front -2 ) + back } // Decorates `edgeFrontOrBack` with a protruding pattern svg ID `front`. @Piece fun edgeFront() : Shape3d { val edge = edgeFrontOrBack().centerXY() val text = if ( svgDocument.shapes.containsKey( "front" ) ) { svgDocument["front"] .center() .extrude(0.6) //.topTo(edge.top+ 0.1) .bottomTo(edge.top) } else { null } return edge + text } fun withEars( edge : Shape3d ) : Shape3d { val ears = Square( edge.size.y, edge.size.y*1.5 ) .roundAllCorners(5) .leftTo( edge.right - 4 ) .centerYTo( edge.middle.y ) .margin( -edge.size.y ) .extrude(0.2) .mirrorX().also() .color( "White" ).opacity(0.3) return edge + ears } fun edgeBackWithEars() = withEars( edgeBack() ) // Decorates `edgeFrontOrBack` with a protruding pattern svg ID `back`. @Piece fun edgeBack() : Shape3d { val edge = edgeFrontOrBack().centerXY() val text = if ( svgDocument.shapes.containsKey( "back" ) ) { svgDocument["back"] .center() .extrude(0.6) .bottomTo(edge.top) } else { null } return edge + text } // The front or back edges without text or other decoration. fun edgeFrontOrBack() : Shape3d { val length = gridSize * across + wallThickness*gridSize + slack val solid = edgeProfile() .extrude( length ) .rotateY(90) .centerX() .bottomTo(0) var screwHoles : Shape3d = Cylinder(10,2.3).sides(8) .rotateY(90) .translateY( roomHeight/2 + roomHeight + floorThickness + slack ) .rightTo( length/2+1 ) .translateZ( solid.size.z * 0.5 ) .mirrorX().also() if (doubleHoles) { screwHoles = screwHoles.translateY( - roomHeight - floorThickness - slack ).also() } return (solid - screwHoles ).color("Orange").brighter() } @Piece fun edgeSizeWithEars() = withEars( edgeSide() ) @Piece fun edgeSide() : Shape3d { val length = gridSize * down + wallThickness*gridSize + slack val profile = edgeProfile().rightTo(0) val solid = ExtrusionBuilder().apply { crossSection( profile ) for (i in 0 until 9) { turnY(10) crossSection() } forward(length/2) crossSection() }.build().rightTo(0).mirrorX().also() var screwHoles : Shape3d = ( Cylinder( 30, 1.8 ).translateZ(-1) + Cylinder( 30, 3.5 ).translateZ(solid.size.z * 0.45) ) .translateX( length / 2 + solid.size.z*0.5 ) .translateY( roomHeight/2 + floorThickness + slack + roomHeight) .mirrorX().also() if ( doubleHoles ) { screwHoles = screwHoles.translateY( -floorThickness - slack - roomHeight ).also() } return (solid - screwHoles).color("Orange").darker() } // The flat bottom of the game. @Piece fun base() : Shape3d { val margin = floorMargin + gridSize * wallThickness/2 var floor = Square( across * gridSize + margin*2, down * gridSize + margin*2 ) .roundAllCorners(5,1) .center() .chamferedExtrude( floorThickness, 0.2 ) val extra = Square( across * gridSize + wallThickness*gridSize -2, down * gridSize + wallThickness*gridSize -2 ) .center() .extrude( topBottomThickness ) .bottomTo( floorThickness ) val text = bottomLettering .center() .mirrorX() .extrude(0.6).topTo( extra.top ) return (floor + extra - text).color("darkGrey").darker() } // An extra piece to move the ball from the basement to the playing surface. // Glue to the bottom of `maze` (or `mazeFloor`). @Piece fun ramp() : Shape3d { val ramp = Triangle( gridSize * 4, roomHeight + floorThickness + slack) .extrude( gridSize - wallThickness*gridSize - slack*2 ) .rotateX(90) val side = Cube( ramp.size.x, wallThickness*gridSize + slack, roomHeight ) .backTo( ramp.front ) val sides = side.frontTo( ramp.back ).also() val guide = Triangle( gridSize, side.size.y ) .extrude( side.size.z ) .leftTo( side.right ) .frontTo( side.front ) // Prevents the ball being "caught" in the right angled corner. val angle = Triangle( sides.size.y*1.5, sides.size.y ) .mirrorX() .extrude( roomHeight ) .rightTo( ramp.left ) .backTo( sides.back ) return (ramp + sides + guide + angle).color("yellow").darker() } override fun postProcess( gcode : GCode ) { } @Piece( printable = false ) override fun build() : Shape3d { val maze = maze() .centerXY() .bottomTo( topBottomThickness + floorThickness + roomHeight + slack/2 ) val sides = edgeSide() .rotateX(90).rotateZ(-90) .bottomTo(0) .rightTo( maze.left + floorMargin - slack/2 ) .mirrorX().also() val front = edgeFront() .rotateX(90) .bottomTo( 0 ) .frontTo( sides.front -0.6) val back = edgeBack() .rotateX(90).rotateZ(180) .bottomTo( 0 ) .backTo( sides.back +0.6 ) val base = base().mirrorZ().bottomTo( slack/2 ) val glass = Square( maze.size.x, maze.size.y ) .center() .roundAllCorners(3) .extrude( glassThickness ) .topTo( front.top - topBottomThickness - slack/2 ) .previewOnly() val ramp = ramp() .centerYTo( maze.front + floorMargin + wallThickness*gridSize/2 + gridSize/2 ) .rightTo( maze.right - floorMargin ) .bottomTo( base.top ) val solution = solution() .rotateY(180).bottomTo( maze.bottom + floorThickness ) .translate( -gridSize*across/2, -gridSize*down/2, 0 ) .color("purple") val ball = Sphere( ballDiameter/2 ) .bottomTo( solution.top ) .translate( -2.5*gridSize, 0*gridSize, 0 ) .color( "Silver" ) .previewOnly() val ballBasement = ball.bottomTo( base.top ) val postThreaded = postThreaded() .rotateY(180) .center().topTo(glass.bottom) val postBase = postBase() .bottomTo( base.top ) return Cube(0) + maze + //ball + ballBasement + sides + front + back + //postThreaded + //.translateX(50) + //postBase + //.translateX(40) + base + ramp + //solution + //glass + Cube(0) } }