Exit Full View
ZipUp

/Games/Avagojo.foocad

Avagojo

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.

  1. All objects must be ungrouped, and in the "default" layer.

  2. 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 :

    1. playingArea
    2. ramp : A 4x1 rectangle.

    Optional items with fixed IDs :

    1. solution : A path, showing the solution of the maze.
    2. 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 :

    1. hole : The text after hole may be used to print the hole numbers on the floor???
    2. path A wall (where X is any
    3. post A post to support the playing area and the glass.
  3. All wall nodes must be at a grid intersection. (Enable Snapping -> Nodes -> Cusp nodes)

  4. All other objects must have their bounding box corners at a grid intersection. (Use Alt while dragging, and enable Snapping -> Bounding Boxes -> Corners).

  5. 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.

  6. 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.

FooCAD Source Code
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)

    }

}