Exit Full View
Up

/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 Piece

The playing surface should be as smooth as possible. I have two ideas :

  1. Print the maze and the walls separately, with the floor of the maze on the print bed. Then glue them afterwards.

  2. Pause the print just before the walls, and use a clothes iron with a PTFE sheet. Practive on a piece of scrap first??? PTFE sheets can be found in kitchenware. Search for non-stick oven liner/baking sheet.

  3. A thin line, which is the solution though the maze Printed upside down, and glued. This will affect the game play, as the ball will roll off the strip, and into the hole.

FooCAD Source Code
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 : ModelWithSetup, PostProcessor {

    @Custom
    var ballDiameter = 6

    @Custom
    var gridSize = 10 // TODO Make this 11?

    @Custom
    var maze = "avagojo-final.svg"

    @Custom
    var solutionHeight = 0.4

    @Custom
    var bottomLettering = Text( "" ) // or rules!
        .hAlign(HAlignment.CENTER)

    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)

    val topBottomThickness = 1
    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)
    }    

    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) )

            walls += createPlainWall(Path2d(listOf<Vector2>(path[2], path[3]), false))
            walls += createPlainWall(Path2d(listOf<Vector2>(path[3], path[0]), false))
            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( "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" ) //(walls intersection( perimeter )).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")
    }

    // 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 )

        return (basic - baseCut - floorCut - glassCut)
    }

    // All four edge pieces. `edgeFront`, `edgeBack`, 2x edgeSide.
    @Piece
    fun edges() : Shape3d {
        val sides = edgeSide().frontTo(0)
            .backTo(-2).also()
        val front = edgeFront()
            .frontTo(0)
        val back = edgeBack()
            .frontTo(0)
            .backTo(-2).also()

        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)
                .bottomTo(edge.top)
        } else {
            null
        }
        return edge + text
    }

    // 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)

        val screwHoles = Cylinder(10,2).sides(8)
            .rotateY(90)
            .translateY( roomHeight/2 )
            .translateY( roomHeight + floorThickness + slack ).also()
            .rightTo( length/2+1 )
            .translateZ( solid.size.z * 0.5  )
            .mirrorX().also()

        return (solid - screwHoles).color("Orange").brighter()
    }
    
    @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()

        val screwHoles = (
            Cylinder( 30, 1.5 ).sides(8).translateZ(-1) +
            Cylinder( 30, 3 ).translateZ(solid.size.z * 0.25)
        )
            .translateX( length / 2 + solid.size.z*0.5 )
            .translateY( roomHeight/2 )
            .translateY( floorThickness + slack + roomHeight ).also()
            .mirrorX().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 )
            
        return (ramp + sides + guide).color("yellow").darker()
    }

    override fun postProcess( gcode : GCode ) {
    }

    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)

    }

}