diff --git a/src/common/chinesePostman.js b/src/common/chinesePostman.js new file mode 100644 index 00000000..16763bdf --- /dev/null +++ b/src/common/chinesePostman.js @@ -0,0 +1,185 @@ +// Chinese Postman (Route Inspection) Algorithm +// Finds the minimum-cost path that visits every edge at least once. +// Works with plain edge arrays; uses provided Dijkstra function for shortest paths. + +function buildAdjacencyList(edges) { + const adj = new Map() + + for (const [n1, n2] of edges) { + if (!adj.has(n1)) adj.set(n1, []) + if (!adj.has(n2)) adj.set(n2, []) + adj.get(n1).push(n2) + adj.get(n2).push(n1) + } + + return adj +} + +function findOddVertices(adjacencyList) { + const odd = [] + + for (const [nodeKey, neighbors] of adjacencyList) { + if (neighbors.length % 2 !== 0) { + odd.push(nodeKey) + } + } + + return odd +} + +function computePairwiseDistances(vertices, dijkstraFn) { + const distances = new Map() + + for (let i = 0; i < vertices.length; i++) { + for (let j = i + 1; j < vertices.length; j++) { + const v1 = vertices[i] + const v2 = vertices[j] + const path = dijkstraFn(v1, v2) + const distance = path ? path.length - 1 : Infinity + const key = [v1, v2].sort().join("|") + + distances.set(key, { distance, path, v1, v2 }) + } + } + + return distances +} + +function greedyMinimumMatching(vertices, distances) { + if (vertices.length === 0 || vertices.length % 2 !== 0) { + return [] + } + + const edges = Array.from(distances.values()) + + edges.sort((a, b) => a.distance - b.distance) + + const matched = new Set() + const matching = [] + + for (const edge of edges) { + if (!matched.has(edge.v1) && !matched.has(edge.v2)) { + matching.push(edge) + matched.add(edge.v1) + matched.add(edge.v2) + + if (matched.size === vertices.length) { + break + } + } + } + + return matching +} + +const MAX_ODD_VERTICES_FOR_FULL = 30 + +function nearestNeighborMatching(vertices, adjacencyList) { + if (vertices.length === 0 || vertices.length % 2 !== 0) { + return [] + } + + const matching = [] + const unmatched = new Set(vertices) + + while (unmatched.size > 0) { + const v1 = unmatched.values().next().value + + unmatched.delete(v1) + + // Find nearest unmatched vertex by graph distance (BFS) + let nearest = null + let nearestPath = null + const visited = new Set([v1]) + const queue = [[v1, [v1]]] + + while (queue.length > 0 && !nearest) { + const [current, path] = queue.shift() + const neighbors = adjacencyList.get(current) || [] + + for (const neighbor of neighbors) { + if (visited.has(neighbor)) continue + + visited.add(neighbor) + const newPath = [...path, neighbor] + + if (unmatched.has(neighbor)) { + nearest = neighbor + nearestPath = newPath + break + } + + queue.push([neighbor, newPath]) + } + } + + if (nearest) { + unmatched.delete(nearest) + matching.push({ v1, v2: nearest, pathKeys: nearestPath }) + } + } + + return matching +} + +/** + * Eulerize an edge array using Chinese Postman algorithm + * Returns a new edge array with duplicate edges added to make all vertices even-degree + */ +export function eulerizeEdges(edges, dijkstraFn, nodeMap = null) { + const adjacencyList = buildAdjacencyList(edges) + const oddVertices = findOddVertices(adjacencyList) + + if (oddVertices.length === 0 || oddVertices.length % 2 !== 0) { + return { + edges: [...edges], + oddVertices, + matching: [], + duplicateCount: 0, + } + } + + const verticesToMatch = oddVertices + + let matching + + if (verticesToMatch.length <= MAX_ODD_VERTICES_FOR_FULL) { + // Full pairwise Dijkstra for small vertex sets + const distances = computePairwiseDistances(verticesToMatch, dijkstraFn) + + matching = greedyMinimumMatching(verticesToMatch, distances) + } else { + // Fast nearest-neighbor for large vertex sets + matching = nearestNeighborMatching(verticesToMatch, adjacencyList) + } + + // Build new edge array with duplicates + const newEdges = [...edges] + let duplicateCount = 0 + + for (const match of matching) { + // Handle both formats: {path} from Dijkstra or {pathKeys} from BFS + const path = + match.path || + (match.pathKeys && nodeMap + ? match.pathKeys.map((k) => nodeMap[k] || { toString: () => k }) + : match.pathKeys?.map((k) => ({ toString: () => k }))) + + if (!path || path.length < 2) continue + + for (let i = 0; i < path.length - 1; i++) { + const n1 = path[i].toString() + const n2 = path[i + 1].toString() + + newEdges.push([n1, n2]) + duplicateCount++ + } + } + + return { + edges: newEdges, + oddVertices, + matching, + duplicateCount, + } +} diff --git a/src/common/geometry.js b/src/common/geometry.js index af84f183..e36733a6 100644 --- a/src/common/geometry.js +++ b/src/common/geometry.js @@ -551,6 +551,70 @@ export const annotateVertices = (vertices, attrs) => { return vertices } +// Check if 4 points are approximately collinear +const areCollinear = (p0, p1, p2, p3, tolerance = 0.01) => { + const dx = p3.x - p0.x + const dy = p3.y - p0.y + const len = Math.sqrt(dx * dx + dy * dy) + + if (len < tolerance) return true + + // Distance from p1 and p2 to the line through p0-p3 + const dist1 = Math.abs(dx * (p0.y - p1.y) - dy * (p0.x - p1.x)) / len + const dist2 = Math.abs(dx * (p0.y - p2.y) - dy * (p0.x - p2.x)) / len + + return dist1 < tolerance && dist2 < tolerance +} + +// Attempt to subdivide a path using Catmull-Rom spline interpolation +// Returns subdivided points that pass through all original points with smooth curves +// Preserves straight segments when points are collinear +export const catmullRomSpline = (points, segmentsPerCurve = 8) => { + if (points.length < 2) return points + if (points.length === 2) return points + + const result = [] + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)] + const p1 = points[i] + const p2 = points[i + 1] + const p3 = points[Math.min(points.length - 1, i + 2)] + + result.push(p1) + + // Skip interpolation for collinear segments (keep straight lines straight) + if (areCollinear(p0, p1, p2, p3)) { + continue + } + + for (let t = 1; t < segmentsPerCurve; t++) { + const s = t / segmentsPerCurve + const s2 = s * s + const s3 = s2 * s + + result.push({ + x: + 0.5 * + (2 * p1.x + + (-p0.x + p2.x) * s + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * s2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * s3), + y: + 0.5 * + (2 * p1.y + + (-p0.y + p2.y) * s + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * s2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * s3), + }) + } + } + + result.push(points[points.length - 1]) + + return result +} + // returns the intersection point of two line segments export const calculateIntersection = (p1, p2, p3, p4) => { var denominator = diff --git a/src/features/shapes/maze/LICENSE b/src/features/shapes/maze/LICENSE new file mode 100644 index 00000000..e91006ad --- /dev/null +++ b/src/features/shapes/maze/LICENSE @@ -0,0 +1,17 @@ +Maze Generator by Rob Dawson, licensed under MIT license. +https://github.com/codebox/mazes + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/features/shapes/maze/Maze.js b/src/features/shapes/maze/Maze.js new file mode 100644 index 00000000..2b1acc29 --- /dev/null +++ b/src/features/shapes/maze/Maze.js @@ -0,0 +1,608 @@ +/* global console */ +import Shape from "../Shape" +import seedrandom from "seedrandom" +import Graph from "@/common/Graph" +import { getMachine } from "@/features/machines/machineFactory" +import { eulerianTrail } from "@/common/eulerian_trail/eulerianTrail" +import { eulerizeEdges } from "@/common/chinesePostman" +import { + cloneVertices, + centerOnOrigin, + catmullRomSpline, + calculateIntersection, +} from "@/common/geometry" +import RectangularGrid from "./grids/RectangularGrid" +import PolarGrid from "./grids/PolarGrid" +import HexGrid from "./grids/HexGrid" +import TriangleGrid from "./grids/TriangleGrid" +import { wilson } from "./algorithms/wilson" +import { backtracker } from "./algorithms/backtracker" +import { division } from "./algorithms/division" +import { prim } from "./algorithms/prim" +import { kruskal } from "./algorithms/kruskal" +import { sidewinder } from "./algorithms/sidewinder" +import { eller } from "./algorithms/eller" + +const algorithms = { + wilson, + backtracker, + division, + prim, + kruskal, + sidewinder, + eller, +} + +// Set to true to debug maze generation +const DEBUG_MAZE = false + +const algorithmKeyByShape = { + Rectangle: "mazeType", + Circle: "mazeTypeCircle", + Hexagon: "mazeTypeHex", + Triangle: "mazeTypeTriangle", +} + +const getAlgorithm = (state) => state[algorithmKeyByShape[state.mazeShape]] + +const gridByShape = { + Rectangle: RectangularGrid, + Hexagon: HexGrid, + Triangle: TriangleGrid, +} + +const options = { + mazeShape: { + title: "Shape", + type: "togglebutton", + choices: ["Rectangle", "Circle", "Hexagon", "Triangle"], + onChange: (model, changes, state) => { + // Try to preserve the current algorithm when switching shapes + const oldAlgoKey = algorithmKeyByShape[state.mazeShape] + const newAlgoKey = algorithmKeyByShape[changes.mazeShape] + const currentAlgo = state[oldAlgoKey] + const newShapeChoices = options[newAlgoKey].choices + + if (newShapeChoices.includes(currentAlgo)) { + changes[newAlgoKey] = currentAlgo + } + + return changes + }, + }, + mazeType: { + title: "Algorithm", + type: "dropdown", + choices: [ + "Backtracker", + "Division", + "Eller", + "Kruskal", + "Prim", + "Sidewinder", + "Wilson", + ], + isVisible: (layer, state) => { + return state.mazeShape === "Rectangle" + }, + }, + mazeTypeCircle: { + title: "Algorithm", + type: "dropdown", + choices: ["Backtracker", "Kruskal", "Prim", "Wilson"], + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeTypeHex: { + title: "Algorithm", + type: "dropdown", + choices: ["Backtracker", "Kruskal", "Prim", "Wilson"], + isVisible: (layer, state) => { + return state.mazeShape === "Hexagon" + }, + }, + mazeTypeTriangle: { + title: "Algorithm", + type: "dropdown", + choices: ["Backtracker", "Kruskal", "Prim", "Wilson"], + isVisible: (layer, state) => { + return state.mazeShape === "Triangle" + }, + }, + mazeWidth: { + title: "Maze width", + min: 2, + max: 30, + isVisible: (layer, state) => { + return state.mazeShape !== "Circle" + }, + }, + mazeHeight: { + title: "Maze height", + min: 2, + max: 30, + isVisible: (layer, state) => { + return state.mazeShape !== "Circle" + }, + }, + mazeRingCount: { + title: "Rings", + min: 2, + max: 15, + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + onChange: (model, changes, state) => { + // Auto-adjust doubling interval to prevent overflow + const minDoubling = Math.ceil((changes.mazeRingCount - 1) / 5) + + if (state.mazeWedgeDoubling < minDoubling) { + changes.mazeWedgeDoubling = minDoubling + } + + return changes + }, + }, + mazeWedgeCount: { + title: "Wedges", + min: 4, + max: 16, + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeWedgeDoubling: { + title: "Doubling interval", + min: (state) => Math.ceil((state.mazeRingCount - 1) / 5), + max: 10, + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeWallType: { + title: "Wall type", + type: "togglebutton", + choices: ["Segment", "Arc"], + isVisible: (layer, state) => { + return state.mazeShape === "Circle" + }, + }, + mazeStraightness: { + title: "Straightness", + type: "slider", + min: 0, + max: 10, + step: 1, + isVisible: (layer, state) => { + if (state.mazeShape === "Circle") return false + const algo = getAlgorithm(state) + + return algo === "Backtracker" || algo === "Sidewinder" + }, + }, + mazeHorizontalBias: { + title: "Horizontal bias", + type: "slider", + min: 0, + max: 10, + step: 1, + isVisible: (layer, state) => { + return ( + state.mazeShape !== "Circle" && + (state.mazeType === "Division" || state.mazeType === "Eller") + ) + }, + }, + mazeBranchLevel: { + title: "Branch level", + type: "slider", + min: 0, + max: 10, + step: 1, + isVisible: (layer, state) => { + return getAlgorithm(state) === "Prim" + }, + }, + mazeShowExits: { + title: "Show entry/exit", + type: "checkbox", + }, + mazeShowSolution: { + title: "Show solution", + type: "checkbox", + isVisible: (layer, state) => { + return state.mazeShowExits + }, + }, + seed: { + title: "Random seed", + min: 1, + randomMax: 1000, + }, +} + +export default class Maze extends Shape { + constructor() { + super("maze") + this.label = "Maze" + this.stretch = true + } + + getInitialState() { + return { + ...super.getInitialState(), + ...{ + mazeShape: "Rectangle", + mazeType: "Backtracker", + mazeTypeCircle: "Backtracker", + mazeTypeHex: "Backtracker", + mazeTypeTriangle: "Backtracker", + mazeWidth: 20, + mazeHeight: 20, + mazeRingCount: 10, + mazeWedgeCount: 10, + mazeWedgeDoubling: 3, + mazeWallType: "Arc", + mazeStraightness: 0, + mazeHorizontalBias: 5, + mazeBranchLevel: 5, + mazeShowExits: true, + mazeShowSolution: false, + seed: 1, + }, + } + } + + // Size initial transformer to fit machine, using actual vertex aspect ratio + initialDimensions(props) { + if (!props) { + return { width: 0, height: 0, aspectRatio: 1.0 } + } + + const { width, height, aspectRatio } = super.initialDimensions(props) + const machine = getMachine(props.machine) + const maxSize = Math.min(machine.width, machine.height) * 0.6 + const scale = maxSize / Math.max(width, height) + + return { + width: width * scale, + height: height * scale, + aspectRatio, + } + } + + getMazeAspectRatio(state) { + if (state.mazeShape === "Rectangle") { + return state.mazeWidth / state.mazeHeight + } + + if (state.mazeShape === "Triangle") { + const triHeight = Math.sqrt(3) / 2 + const rawWidth = (state.mazeWidth + 1) * 0.5 + const rawHeight = state.mazeHeight * triHeight + + return rawWidth / rawHeight + } + + if (state.mazeShape === "Hexagon") { + const xOffset = Math.sin(Math.PI / 3) + const rawWidth = + state.mazeHeight >= 2 + ? (2 * state.mazeWidth + 1) * xOffset + : 2 * state.mazeWidth * xOffset + const rawHeight = 1.5 * state.mazeHeight + 0.5 + + return rawWidth / rawHeight + } + + return 1 + } + + handleUpdate(layer, changes) { + if (changes.mazeWidth !== undefined) { + changes.mazeWidth = Math.max(2, changes.mazeWidth) + } + if (changes.mazeHeight !== undefined) { + changes.mazeHeight = Math.max(2, changes.mazeHeight) + } + + // Scale transformer proportionally when maze dimensions change + // This preserves any manual distortion the user applied + if ( + changes.mazeWidth !== undefined && + changes.mazeWidth !== layer.mazeWidth + ) { + const scale = changes.mazeWidth / layer.mazeWidth + + changes.width = layer.width * scale + } + if ( + changes.mazeHeight !== undefined && + changes.mazeHeight !== layer.mazeHeight + ) { + const scale = changes.mazeHeight / layer.mazeHeight + + changes.height = layer.height * scale + } + + // When shape type changes, reset to natural aspect ratio + if (changes.mazeShape === undefined) { + return + } + + const oldRatio = this.getMazeAspectRatio(layer) + const newState = { ...layer, ...changes } + const newRatio = this.getMazeAspectRatio(newState) + + if (oldRatio === newRatio) { + return + } + + // Scale dimensions to match new ratio, keeping max dimension the same + const maxDim = Math.max(layer.width, layer.height) + + if (newRatio >= 1) { + changes.width = maxDim + changes.height = maxDim / newRatio + } else { + changes.width = maxDim * newRatio + changes.height = maxDim + } + } + + getVertices(state) { + const { + mazeStraightness, + mazeHorizontalBias, + mazeBranchLevel, + mazeShowExits, + mazeShowSolution, + seed, + } = state.shape + + const rng = seedrandom(seed) + const grid = this.createGrid(state.shape, rng) + const algorithmName = getAlgorithm(state.shape) + const algorithm = algorithms[algorithmName.toLowerCase()] + + algorithm(grid, { + rng, + straightness: mazeStraightness, + horizontalBias: mazeHorizontalBias, + branchLevel: mazeBranchLevel, + }) + + if (DEBUG_MAZE) { + this.debugMaze(algorithmName, state.shape.mazeShape, grid) + } + + return this.drawMaze( + grid, + this.setupExits(grid, mazeShowExits, mazeShowSolution), + ) + } + + createGrid(shape, rng) { + const { + mazeShape, + mazeWidth, + mazeHeight, + mazeRingCount, + mazeWedgeCount, + mazeWedgeDoubling, + mazeWallType, + } = shape + + if (mazeShape === "Circle") { + const rings = Math.max(2, mazeRingCount) + const minDoubling = Math.ceil((rings - 1) / 5) + + return new PolarGrid( + rings, + Math.max(4, mazeWedgeCount), + Math.max(minDoubling, mazeWedgeDoubling), + rng, + mazeWallType === "Arc", + ) + } + + const GridClass = gridByShape[mazeShape] + + return new GridClass(Math.max(2, mazeWidth), Math.max(2, mazeHeight), rng) + } + + debugMaze(algorithmName, mazeShape, grid) { + console.log(`\n=== ${algorithmName} on ${mazeShape} ===`) + grid.dump() + } + + setupExits(grid, showExits, showSolution) { + if (!showExits || !grid.findHardestExits) { + return null + } + + const exits = grid.findHardestExits() + + if (!exits) { + return null + } + + exits.startCell.exitType = "entrance" + exits.endCell.exitType = "exit" + + return showSolution && exits.path ? exits.path : null + } + + drawMaze(grid, solutionPath = null) { + const wallSegments = grid.extractWalls() + const graph = new Graph() + + wallSegments.forEach(([v1, v2]) => { + graph.addNode(v1) + graph.addNode(v2) + graph.addEdge(v1, v2) + }) + + const edges = Object.values(graph.edgeMap) + const dijkstraFn = (startKey, endKey) => { + return graph.dijkstraShortestPath(startKey, endKey) + } + const { edges: eulerizedEdges } = eulerizeEdges( + edges, + dijkstraFn, + graph.nodeMap, + ) + const trail = eulerianTrail({ edges: eulerizedEdges }) + const walkedVertices = trail.map((key) => graph.nodeMap[key]) + + if (solutionPath && solutionPath.length > 0) { + this.drawSolution(walkedVertices, graph, trail, grid, solutionPath) + } + + const vertices = cloneVertices(walkedVertices) + + centerOnOrigin(vertices) + + return vertices + } + + drawSolution(walkedVertices, graph, trail, grid, solutionPath) { + const startCell = solutionPath[0] + const endCell = solutionPath[solutionPath.length - 1] + + if (!startCell.arrowEdges || !endCell.arrowEdges) { + return + } + + const entranceTip = startCell.arrowEdges[0][0] + const exitBaseCenter = endCell.arrowEdges[1][1] + const entranceKey = entranceTip.toString() + const trailEndKey = trail[trail.length - 1] + + if (graph.nodeMap[entranceKey]) { + const pathKeys = graph.dijkstraShortestPath(trailEndKey, entranceKey) + + if (pathKeys && pathKeys.length > 1) { + for (let i = 1; i < pathKeys.length; i++) { + walkedVertices.push(graph.nodeMap[pathKeys[i]]) + } + } + } + + // Collect passage midpoints (shared edge centers between consecutive cells) + const passageMidpoints = [] + + for (let i = 0; i < solutionPath.length - 1; i++) { + const midpoint = grid.getSharedEdgeMidpoint( + solutionPath[i], + solutionPath[i + 1], + ) + + passageMidpoints.push(midpoint) + } + + // Build path using only passage midpoints (doorways between cells) + const solutionWaypoints = [entranceTip, ...passageMidpoints, exitBaseCenter] + + // Apply spline smoothing, then clip at arrow edges + let smoothed = catmullRomSpline(solutionWaypoints, 6) + + // Clip at entrance arrow and walk from tip to intersection + const entrance = this.clipAtArrow(smoothed, startCell.arrowEdges, true) + + if (entrance.hit) { + smoothed = [ + ...this.walkArrowToTip(entrance.edgeIndex, startCell.arrowEdges, true), + entrance.hit, + ...entrance.spline, + ] + } + + // Clip at exit arrow and walk from intersection to tip + const exit = this.clipAtArrow(smoothed, endCell.arrowEdges, false) + const exitTip = endCell.arrowEdges[0][0] + + if (exit.hit) { + smoothed = [ + ...exit.spline, + exit.hit, + ...this.walkArrowToTip(exit.edgeIndex, endCell.arrowEdges, false), + ] + } else { + // No intersection - walk arrow edge: baseCenter → baseRight → tip + const baseRight = endCell.arrowEdges[2][1] + + smoothed.push(baseRight) + smoothed.push(exitTip) + } + + for (const pt of smoothed) { + walkedVertices.push(pt) + } + } + + // Clip spline where it crosses an arrow edge + // Returns { spline, hit, edgeIndex } + clipAtArrow(spline, arrowEdges, fromStart) { + if (fromStart) { + for (let i = 0; i < spline.length - 1; i++) { + for (let e = 0; e < arrowEdges.length; e++) { + const [a, b] = arrowEdges[e] + const hit = calculateIntersection(spline[i], spline[i + 1], a, b) + + if (hit) { + return { spline: spline.slice(i + 1), hit, edgeIndex: e } + } + } + } + } else { + for (let i = spline.length - 1; i > 0; i--) { + for (let e = 0; e < arrowEdges.length; e++) { + const [a, b] = arrowEdges[e] + const hit = calculateIntersection(spline[i - 1], spline[i], a, b) + + if (hit) { + return { spline: spline.slice(0, i), hit, edgeIndex: e } + } + } + } + } + + return { spline, hit: null, edgeIndex: -1 } + } + + // Walk arrow edges from intersection to tip (or tip to intersection) + // Arrow edges: [tip→baseLeft, baseLeft→baseCenter, baseCenter→baseRight, baseRight→tip] + // Tip is at edges[0][0] (start of first edge) + walkArrowToTip(edgeIndex, arrowEdges, towardsTip) { + if (edgeIndex < 0) return [] + + const tip = arrowEdges[0][0] + const baseLeft = arrowEdges[0][1] + const baseRight = arrowEdges[3][0] + + // Which side of the arrow did we hit? + // edges 0,1 are left side (tip→baseLeft→baseCenter) + // edges 2,3 are right side (baseCenter→baseRight→tip) + if (towardsTip) { + // Walking TO the tip (prepend to path) + if (edgeIndex <= 1) { + return edgeIndex === 0 ? [tip] : [tip, baseLeft] + } else { + return edgeIndex === 3 ? [tip] : [tip, baseRight] + } + } else { + // Walking FROM the tip (append to path) + if (edgeIndex <= 1) { + return edgeIndex === 0 ? [tip] : [baseLeft, tip] + } else { + return edgeIndex === 3 ? [tip] : [baseRight, tip] + } + } + } + + getOptions() { + return options + } +} diff --git a/src/features/shapes/maze/README.md b/src/features/shapes/maze/README.md new file mode 100644 index 00000000..77e713c4 --- /dev/null +++ b/src/features/shapes/maze/README.md @@ -0,0 +1,86 @@ +# Maze + +Generates perfect mazes (no loops, exactly one path between any two cells) on various grid topologies, then converts them to continuous drawing paths for sand tables. + +## How It Works + +1. **Build grid**: Create a cell structure based on the selected shape (rectangular, polar, hexagonal, or triangular). + +2. **Generate maze**: Run a maze algorithm to carve passages between cells, producing a spanning tree. + +3. **Extract walls**: Collect the wall segments (edges between unconnected cells). + +4. **Eulerize**: Apply Chinese Postman to make all vertices even-degree, enabling a single continuous path. + +5. **Traverse**: An Eulerian trail visits every wall segment exactly once. + +## Algorithms + +| Algorithm | Description | Characteristics | +|-----------|-------------|-----------------| +| Backtracker | Depth-first search with random neighbor selection | Long, winding passages; configurable straightness bias | +| Wilson | Loop-erased random walks | Uniform spanning tree; unbiased but slower on large grids | +| Prim | Frontier-based growth from random edges | Short branches, "bushy" appearance; configurable branch level | +| Kruskal | Randomly merges disjoint sets via edges | Even texture; uses union-find for efficiency | +| Division | Recursively divides space with walls | Long straight corridors; configurable horizontal bias. Rectangle only. | +| Sidewinder | Row-by-row with horizontal runs | Strong horizontal bias; fast and memory-efficient. Rectangle only. | +| Eller | Row-by-row with disjoint set tracking | Memory-efficient streaming; configurable horizontal bias. Rectangle only. | + +## Grid Types + +| Grid | Cell Shape | Neighbors | Notes | +|------|------------|-----------|-------| +| Rectangular | Square | 4 (north/south/east/west) | Standard orthogonal maze | +| Polar | Wedge | 3-4 (inward/outward/clockwise/counter-clockwise) | Concentric rings; cells subdivide as radius increases | +| Hexagonal | Hexagon | 6 | Pointy-top orientation | +| Triangle | Triangle | 3 | Alternating up/down triangles | + +## Future Directions + +### New Algorithms + +- **Growing Tree Algorithm** + - Generalization of Prim/Backtracker + - Configurable cell selection strategy (newest, random, oldest, mixed) + - Would provide more control over maze characteristics +- **Eller for Polar/Hex** + - Adapt Eller's algorithm to work ring-by-ring for polar grids + - Would require rethinking "horizontal" merging in circular context + +### New Grid Types + +- **Sigma (Brick) Grid** + - Offset rectangular cells like brick pattern + - 6 neighbors (2 horizontal, 4 diagonal) +- **Upsilon Grid** + - Octagons with small squares at intersections + - 8 neighbors for octagons, 4 for squares + - Visually interesting tiling +- **Zeta Grid** + - Rectangular cells with 45-degree diagonal passages allowed + - 8 neighbors per cell instead of 4 + - Creates more organic-looking mazes + +### Polar Enhancements + +- **Radial Bias Parameter** + - Add bias toward inward/outward movement for spiral-like patterns + - Could apply to Backtracker and Prim for circular mazes +- **Adaptive Subdivision** + - Currently uses fixed `doublingInterval` + - Could auto-calculate based on maintaining uniform cell size + +### Hexagonal Enhancements + +- **Flat-top Orientation** + - Alternative hex orientation option + - Would require different neighbor and corner calculations +- **Directional Bias Along Hex Axes** + - Bias along the 3 hex directions rather than just horizontal/vertical + - Could create interesting flow patterns unique to hex topology + +### Other Ideas + +- **Wall thickness** - Configurable line weight +- **Straightness for Circle** - Adapt straightness bias for polar backtracker +- **Masks** - Remove cells to create shapes (hearts, text, holes) diff --git a/src/features/shapes/maze/algorithms/backtracker.js b/src/features/shapes/maze/algorithms/backtracker.js new file mode 100644 index 00000000..479c4b29 --- /dev/null +++ b/src/features/shapes/maze/algorithms/backtracker.js @@ -0,0 +1,68 @@ +// Recursive Backtracker algorithm for maze generation +// Creates long, winding passages using depth-first search +// Works with any grid type (RectangularGrid, PolarGrid, etc.) + +export const backtracker = (grid, { rng, straightness = 0 }) => { + const stack = [] + const startCell = grid.getRandomCell() + + grid.markVisited(startCell) + stack.push({ cell: startCell, lastNeighbor: null }) + + while (stack.length > 0) { + const { cell, lastNeighbor } = stack[stack.length - 1] + + // Get unvisited neighbors + const neighbors = grid.getNeighbors(cell) + const unvisitedNeighbors = neighbors.filter((n) => !grid.isVisited(n)) + + if (unvisitedNeighbors.length > 0) { + let chosenNeighbor + + // Apply straightness bias if we have a previous neighbor and straightness > 0 + // For straightness, we try to continue in a "similar direction" + // by preferring the neighbor that is furthest from the previous cell + if ( + lastNeighbor !== null && + straightness > 0 && + unvisitedNeighbors.length > 1 + ) { + const continueProb = straightness * 0.09 + + if (rng() < continueProb) { + // Find the neighbor most "opposite" to where we came from + // This approximates continuing straight + // For rectangular grids: opposite of lastNeighbor + // For polar grids: similar concept applies + const lastKey = grid.cellKey(lastNeighbor) + const oppositeNeighbor = unvisitedNeighbors.find((n) => { + // Check if this neighbor has lastNeighbor as a neighbor + // (meaning it's "continuing" past the current cell) + const nNeighbors = grid.getNeighbors(n) + return !nNeighbors.some((nn) => grid.cellKey(nn) === lastKey) + }) + + if (oppositeNeighbor) { + chosenNeighbor = oppositeNeighbor + } + } + } + + // If no bias applied or bias didn't trigger, choose randomly + if (!chosenNeighbor) { + const idx = Math.floor(rng() * unvisitedNeighbors.length) + chosenNeighbor = unvisitedNeighbors[idx] + } + + // Link current cell to chosen neighbor + grid.link(cell, chosenNeighbor) + grid.markVisited(chosenNeighbor) + + // Push neighbor onto stack, tracking where we came from + stack.push({ cell: chosenNeighbor, lastNeighbor: cell }) + } else { + // Backtrack + stack.pop() + } + } +} diff --git a/src/features/shapes/maze/algorithms/console.js b/src/features/shapes/maze/algorithms/console.js new file mode 100644 index 00000000..529c652e --- /dev/null +++ b/src/features/shapes/maze/algorithms/console.js @@ -0,0 +1,39 @@ +/* global console */ + +// Utility for debugging maze generation - displays ASCII art in console + +const S = 2 +const E = 4 + +export const consoleDisplay = (grid, { width, height }) => { + let mazeOutput = " " + "_".repeat(width * 2 - 1) + "\n" + + grid.forEach((row, y) => { + mazeOutput += "|" + + row.forEach((cell, x) => { + // determine if a south wall exists + if (cell === 0 && y + 1 < height && grid[y + 1][x] === 0) { + mazeOutput += " " + } else { + mazeOutput += (cell & S) !== 0 ? " " : "_" + } + + // determine if an east wall exists + if (cell === 0 && x + 1 < width && row[x + 1] === 0) { + mazeOutput += + y + 1 < height && (grid[y + 1][x] === 0 || grid[y + 1][x + 1] === 0) + ? " " + : "_" + } else if ((cell & E) !== 0) { + mazeOutput += ((cell | row[x + 1]) & S) !== 0 ? " " : "_" + } else { + mazeOutput += "|" + } + }) + + mazeOutput += "\n" + }) + + console.log(mazeOutput) +} diff --git a/src/features/shapes/maze/algorithms/division.js b/src/features/shapes/maze/algorithms/division.js new file mode 100644 index 00000000..da801c47 --- /dev/null +++ b/src/features/shapes/maze/algorithms/division.js @@ -0,0 +1,94 @@ +// Recursive Division algorithm for maze generation +// Divides space with walls, leaving passages - creates long straight corridors +// NOTE: Only works with rectangular grids + +const divide = (grid, cells, rng, horizontalBias) => { + if (cells.length < 2) return + + // Find bounds + const xs = cells.map((c) => c.x) + const ys = cells.map((c) => c.y) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + + const width = maxX - minX + 1 + const height = maxY - minY + 1 + + if (width < 2 && height < 2) return + + // Calculate horizontal probability based on horizontalBias + const horizontalProb = 0.1 + horizontalBias * 0.08 + const horizontal = + height > width || (height === width && rng() < horizontalProb) + + if (horizontal && height >= 2) { + // Divide horizontally - pick a y value to put wall below + const wallY = minY + Math.floor(rng() * (height - 1)) + + // Get cells in the wall row and the row below + const topCells = cells.filter((c) => c.y === wallY) + const bottomCells = cells.filter((c) => c.y === wallY + 1) + + // Pick one passage to leave open + const passageX = minX + Math.floor(rng() * width) + + // Unlink all except the passage + for (const topCell of topCells) { + if (topCell.x === passageX) continue + const bottomCell = bottomCells.find((c) => c.x === topCell.x) + if (bottomCell) { + grid.unlink(topCell, bottomCell) + } + } + + // Recursively divide top and bottom sections + const topSection = cells.filter((c) => c.y <= wallY) + const bottomSection = cells.filter((c) => c.y > wallY) + + divide(grid, topSection, rng, horizontalBias) + divide(grid, bottomSection, rng, horizontalBias) + } else if (width >= 2) { + // Divide vertically - pick an x value to put wall right of + const wallX = minX + Math.floor(rng() * (width - 1)) + + // Get cells in the wall column and the column to the right + const leftCells = cells.filter((c) => c.x === wallX) + const rightCells = cells.filter((c) => c.x === wallX + 1) + + // Pick one passage to leave open + const passageY = minY + Math.floor(rng() * height) + + // Unlink all except the passage + for (const leftCell of leftCells) { + if (leftCell.y === passageY) continue + const rightCell = rightCells.find((c) => c.y === leftCell.y) + if (rightCell) { + grid.unlink(leftCell, rightCell) + } + } + + // Recursively divide left and right sections + const leftSection = cells.filter((c) => c.x <= wallX) + const rightSection = cells.filter((c) => c.x > wallX) + + divide(grid, leftSection, rng, horizontalBias) + divide(grid, rightSection, rng, horizontalBias) + } +} + +export const division = (grid, { rng, horizontalBias = 5 }) => { + const allCells = grid.getAllCells() + + // Start with all cells fully connected + for (const cell of allCells) { + grid.markVisited(cell) + for (const neighbor of grid.getNeighbors(cell)) { + grid.link(cell, neighbor) + } + } + + // Recursively divide + divide(grid, allCells, rng, horizontalBias) +} diff --git a/src/features/shapes/maze/algorithms/eller.js b/src/features/shapes/maze/algorithms/eller.js new file mode 100644 index 00000000..0dbb7e81 --- /dev/null +++ b/src/features/shapes/maze/algorithms/eller.js @@ -0,0 +1,167 @@ +// Eller's algorithm for maze generation +// Processes one row at a time using disjoint sets - very memory efficient +// Creates horizontal bias similar to Sidewinder but more variety +// NOTE: Only works with rectangular grids (row-based processing) + +export const eller = (grid, { rng, horizontalBias = 5 }) => { + // Mark all cells as visited + const allCells = grid.getAllCells() + + for (const cell of allCells) { + grid.markVisited(cell) + } + + // Set management: track which set each cell belongs to + // Sets are identified by integers; cells in the same set are connected + let nextSetId = 0 + const cellToSet = new Map() // cellKey -> setId + const setToCells = new Map() // setId -> array of cells in current row + + // Helper to get or create a set for a cell + const getSet = (cell) => { + const key = grid.cellKey(cell) + + if (!cellToSet.has(key)) { + cellToSet.set(key, nextSetId) + setToCells.set(nextSetId, [cell]) + nextSetId++ + } + + return cellToSet.get(key) + } + + // Helper to merge two sets (move all cells from set2 into set1) + const mergeSets = (set1, set2) => { + if (set1 === set2) return + + const cells2 = setToCells.get(set2) || [] + const cells1 = setToCells.get(set1) || [] + + for (const cell of cells2) { + cellToSet.set(grid.cellKey(cell), set1) + cells1.push(cell) + } + + setToCells.delete(set2) + } + + // Helper to check if two cells are in the same set + const sameSet = (cell1, cell2) => { + return getSet(cell1) === getSet(cell2) + } + + // Calculate probabilities based on horizontalBias (centered on 5 = neutral) + // horizontalBias 0 = vertical feel, 5 = neutral, 10 = horizontal feel + const mergeProbability = 0.1 + horizontalBias * 0.08 // 0.1 at 0, 0.5 at 5, 0.9 at 10 + + // Vertical connection probability (per cell after the required first one) + const verticalProbability = 0.9 - horizontalBias * 0.08 // 0.9 at 0, 0.5 at 5, 0.1 at 10 + + // Process each row + for (let y = 0; y < grid.height; y++) { + const isLastRow = y === grid.height - 1 + + // Get all cells in this row + const rowCells = [] + for (let x = 0; x < grid.width; x++) { + rowCells.push(grid.getCell(x, y)) + } + + // Initialize sets for cells in this row (new cells get new sets) + for (const cell of rowCells) { + getSet(cell) + } + + // PHASE 1: Horizontal connections + // Randomly join adjacent cells if they're in different sets + for (let x = 0; x < grid.width - 1; x++) { + const cell = rowCells[x] + const eastCell = rowCells[x + 1] + + // On last row: must connect if in different sets + // Otherwise: randomly connect if in different sets + const shouldConnect = isLastRow + ? !sameSet(cell, eastCell) + : !sameSet(cell, eastCell) && rng() < mergeProbability + + if (shouldConnect) { + grid.link(cell, eastCell) + mergeSets(getSet(cell), getSet(eastCell)) + } + } + + // PHASE 2: Vertical connections (skip on last row) + if (!isLastRow) { + // Group cells by their current set + const setGroups = new Map() + + for (const cell of rowCells) { + const setId = getSet(cell) + if (!setGroups.has(setId)) { + setGroups.set(setId, []) + } + setGroups.get(setId).push(cell) + } + + // For each set, at least one cell must connect down + for (const [setId, cells] of setGroups) { + // Shuffle the cells in this set + const shuffled = [...cells] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + + // First cell always connects (required for maze connectivity) + // Additional cells connect based on verticalProbability + for (let i = 0; i < shuffled.length; i++) { + const isRequired = i === 0 + if (!isRequired && rng() >= verticalProbability) { + continue // Skip this cell + } + + const cell = shuffled[i] + const southCell = grid.getCell(cell.x, cell.y + 1) + + if (southCell) { + grid.link(cell, southCell) + + // South cell inherits this cell's set + const southKey = grid.cellKey(southCell) + cellToSet.set(southKey, setId) + + if (!setToCells.has(setId)) { + setToCells.set(setId, []) + } + setToCells.get(setId).push(southCell) + } + } + } + + // Clear set tracking for cells that didn't connect down + // (they'll get new sets when processing the next row) + for (const cell of rowCells) { + cellToSet.delete(grid.cellKey(cell)) + } + + // Also rebuild setToCells to only include next row's cells + const nextRowSets = new Map() + + for (let x = 0; x < grid.width; x++) { + const nextCell = grid.getCell(x, y + 1) + const nextKey = grid.cellKey(nextCell) + if (cellToSet.has(nextKey)) { + const setId = cellToSet.get(nextKey) + if (!nextRowSets.has(setId)) { + nextRowSets.set(setId, []) + } + nextRowSets.get(setId).push(nextCell) + } + } + setToCells.clear() + for (const [setId, cells] of nextRowSets) { + setToCells.set(setId, cells) + } + } + } +} diff --git a/src/features/shapes/maze/algorithms/kruskal.js b/src/features/shapes/maze/algorithms/kruskal.js new file mode 100644 index 00000000..546896f7 --- /dev/null +++ b/src/features/shapes/maze/algorithms/kruskal.js @@ -0,0 +1,92 @@ +// Kruskal's algorithm for maze generation +// Works on edges, creates a "random forest" that merges together +// Works with any grid type (RectangularGrid, PolarGrid, etc.) + +// Simple union-find data structure using cell keys +class UnionFind { + constructor() { + this.parent = new Map() + this.rank = new Map() + } + + makeSet(key) { + if (!this.parent.has(key)) { + this.parent.set(key, key) + this.rank.set(key, 0) + } + } + + find(key) { + if (this.parent.get(key) !== key) { + this.parent.set(key, this.find(this.parent.get(key))) // path compression + } + return this.parent.get(key) + } + + union(key1, key2) { + const root1 = this.find(key1) + const root2 = this.find(key2) + + if (root1 === root2) return false + + // Union by rank + const rank1 = this.rank.get(root1) + const rank2 = this.rank.get(root2) + + if (rank1 < rank2) { + this.parent.set(root1, root2) + } else if (rank1 > rank2) { + this.parent.set(root2, root1) + } else { + this.parent.set(root2, root1) + this.rank.set(root1, rank1 + 1) + } + + return true + } +} + +export const kruskal = (grid, { rng }) => { + const uf = new UnionFind() + const allCells = grid.getAllCells() + + // Initialize union-find with all cells + for (const cell of allCells) { + const key = grid.cellKey(cell) + uf.makeSet(key) + grid.markVisited(cell) + } + + // Collect all unique edges (cell pairs) + const edges = [] + const seenEdges = new Set() + + for (const cell of allCells) { + const cellKey = grid.cellKey(cell) + for (const neighbor of grid.getNeighbors(cell)) { + const neighborKey = grid.cellKey(neighbor) + // Create a canonical edge key to avoid duplicates + const edgeKey = [cellKey, neighborKey].sort().join("|") + if (!seenEdges.has(edgeKey)) { + seenEdges.add(edgeKey) + edges.push({ cell, neighbor }) + } + } + } + + // Shuffle edges + for (let i = edges.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[edges[i], edges[j]] = [edges[j], edges[i]] + } + + // Process edges - connect if in different sets + for (const { cell, neighbor } of edges) { + const cellKey = grid.cellKey(cell) + const neighborKey = grid.cellKey(neighbor) + + if (uf.union(cellKey, neighborKey)) { + grid.link(cell, neighbor) + } + } +} diff --git a/src/features/shapes/maze/algorithms/prim.js b/src/features/shapes/maze/algorithms/prim.js new file mode 100644 index 00000000..76288332 --- /dev/null +++ b/src/features/shapes/maze/algorithms/prim.js @@ -0,0 +1,47 @@ +// Prim's algorithm for maze generation +// Creates lots of short branches and dead ends - "bushy" appearance +// Works with any grid type (RectangularGrid, PolarGrid, etc.) + +export const prim = (grid, { rng, branchLevel = 5 }) => { + const frontier = [] // Array of { cell, parent } objects + const inFrontier = new Set() // Track cells already in frontier + + // Start with a random cell + const startCell = grid.getRandomCell() + + grid.markVisited(startCell) + + // Add neighbors to frontier + const addToFrontier = (cell) => { + for (const neighbor of grid.getNeighbors(cell)) { + const key = grid.cellKey(neighbor) + if (!grid.isVisited(neighbor) && !inFrontier.has(key)) { + frontier.push({ cell: neighbor, parent: cell }) + inFrontier.add(key) + } + } + } + + addToFrontier(startCell) + + // Process frontier + while (frontier.length > 0) { + // Pick cell from frontier based on branchLevel + // branchLevel 0 = bushy (random), 5 = balanced, 10 = winding (LIFO-like) + let idx + const t = branchLevel / 10 // 0 to 1 + const power = 2 - 1.5 * t // 2 (bushy) to 0.5 (winding) + idx = Math.floor(Math.pow(rng(), power) * frontier.length) + + const { cell, parent } = frontier[idx] + + frontier.splice(idx, 1) + + // Connect frontier cell to parent + grid.link(cell, parent) + grid.markVisited(cell) + + // Add new neighbors to frontier + addToFrontier(cell) + } +} diff --git a/src/features/shapes/maze/algorithms/sidewinder.js b/src/features/shapes/maze/algorithms/sidewinder.js new file mode 100644 index 00000000..4b401aa1 --- /dev/null +++ b/src/features/shapes/maze/algorithms/sidewinder.js @@ -0,0 +1,56 @@ +// Sidewinder algorithm for maze generation +// Works row by row, creates horizontal bias with long east-west corridors +// NOTE: Only works with rectangular grids + +export const sidewinder = (grid, { rng, straightness = 0 }) => { + // Mark all cells as visited + const allCells = grid.getAllCells() + + for (const cell of allCells) { + grid.markVisited(cell) + } + + // Calculate close probability based on straightness + // straightness 0 = 0.5 (default), straightness 10 = 0.1 (long corridors) + const closeProbability = 0.5 - straightness * 0.04 + + // Process each row + for (let y = 0; y < grid.height; y++) { + const run = [] + + for (let x = 0; x < grid.width; x++) { + const cell = grid.getCell(x, y) + + run.push(cell) + + // At east boundary or randomly decide to close out the run + const atEastBoundary = x === grid.width - 1 + const atNorthBoundary = y === 0 + const shouldCloseRun = + atEastBoundary || (!atNorthBoundary && rng() < closeProbability) + + if (shouldCloseRun) { + // Pick random cell from run and carve north (unless at north boundary) + if (!atNorthBoundary) { + const randomIdx = Math.floor(rng() * run.length) + const runCell = run[randomIdx] + const northCell = grid.getCell(runCell.x, runCell.y - 1) + + if (northCell) { + grid.link(runCell, northCell) + } + } + + // Clear the run + run.length = 0 + } else { + // Carve east + const eastCell = grid.getCell(x + 1, y) + + if (eastCell) { + grid.link(cell, eastCell) + } + } + } + } +} diff --git a/src/features/shapes/maze/algorithms/wilson.js b/src/features/shapes/maze/algorithms/wilson.js new file mode 100644 index 00000000..de6056b2 --- /dev/null +++ b/src/features/shapes/maze/algorithms/wilson.js @@ -0,0 +1,58 @@ +// Wilson's algorithm for maze generation +// Uses loop-erased random walks to generate uniform spanning trees +// Works with any grid type (RectangularGrid, PolarGrid, etc.) + +export const wilson = (grid, { rng }) => { + const allCells = grid.getAllCells() + + // Track visited cells (part of the maze tree) + const visited = new Set() + + // Pick a random cell to seed the maze tree + const seedCell = grid.getRandomCell() + + grid.markVisited(seedCell) + visited.add(grid.cellKey(seedCell)) + + // Keep going until all cells are in the maze + while (visited.size < allCells.length) { + // Find an unvisited cell to start the walk + let startCell + do { + startCell = grid.getRandomCell() + } while (visited.has(grid.cellKey(startCell))) + + // Perform loop-erased random walk + // path: array of cells representing the walk + const path = [startCell] + + let current = startCell + while (!visited.has(grid.cellKey(current))) { + const neighbors = grid.getNeighbors(current) + const next = neighbors[Math.floor(rng() * neighbors.length)] + + // Check if we've visited this cell in the current walk (loop) + const existingIndex = path.findIndex((c) => grid.cellEquals(c, next)) + + if (existingIndex >= 0) { + // Loop detected - erase everything after the first visit + path.splice(existingIndex + 1) + current = path[path.length - 1] + } else { + // Continue the walk + path.push(next) + current = next + } + } + + // Carve the path into the maze + for (let i = 0; i < path.length - 1; i++) { + const cell = path[i] + const next = path[i + 1] + + grid.link(cell, next) + grid.markVisited(cell) + visited.add(grid.cellKey(cell)) + } + } +} diff --git a/src/features/shapes/maze/grids/Grid.js b/src/features/shapes/maze/grids/Grid.js new file mode 100644 index 00000000..50147d4c --- /dev/null +++ b/src/features/shapes/maze/grids/Grid.js @@ -0,0 +1,298 @@ +// Base class for all maze grids +// Provides shared functionality like finding hardest exits + +import Victor from "victor" + +const ARROW_HEAD_WIDTH = 0.625 // Arrow head width as fraction of wall length (25% bigger) +const ARROW_HEAD_HEIGHT = 0.8 // Arrow head height relative to width + +export default class Grid { + // Subclasses must implement: + // - getEdgeCells() -> [{cell, direction, edge}, ...] + // - cellKey(cell) -> string + // - getNeighbors(cell) -> [cell, ...] + + link(cell1, cell2) { + cell1.links.add(this.cellKey(cell2)) + cell2.links.add(this.cellKey(cell1)) + } + + unlink(cell1, cell2) { + cell1.links.delete(this.cellKey(cell2)) + cell2.links.delete(this.cellKey(cell1)) + } + + isLinked(cell1, cell2) { + return cell1.links.has(this.cellKey(cell2)) + } + + markVisited(cell) { + cell.visited = true + } + + isVisited(cell) { + return cell.visited + } + + // Get the midpoint of the shared edge between two linked cells + // Subclasses should override this for accurate passage midpoints + getSharedEdgeMidpoint(cell1, cell2) { + // Default: average of cell centers (fallback) + const c1 = this.getCellCenter(cell1) + const c2 = this.getCellCenter(cell2) + + return { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + } + + // Factory to create a cached vertex function + // round=true rounds to 6 decimals (needed for non-integer coordinates) + createMakeVertex(vertexCache, round = true) { + return (x, y) => { + const rx = round ? Math.round(x * 1000000) / 1000000 : x + const ry = round ? Math.round(y * 1000000) / 1000000 : y + const key = `${rx},${ry}` + + if (!vertexCache.has(key)) { + vertexCache.set(key, new Victor(rx, ry)) + } + + return vertexCache.get(key) + } + } + + // Helper to add an exit arrow with wall splitting + // This handles the common pattern: split wall, scale coords, draw arrow, store edges + addExitWithArrow( + walls, + makeVertex, + cell, + x1, + y1, + x2, + y2, + inwardDx, + inwardDy, + arrowScale = 1.0, + ) { + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + // Split wall at midpoint so arrow connects to graph + walls.push([makeVertex(x1, y1), makeVertex(mx, my)]) + walls.push([makeVertex(mx, my), makeVertex(x2, y2)]) + + // Scale wall coords for arrow sizing + const sx1 = mx + (x1 - mx) * arrowScale + const sy1 = my + (y1 - my) * arrowScale + const sx2 = mx + (x2 - mx) * arrowScale + const sy2 = my + (y2 - my) * arrowScale + + // Add arrow (connects at midpoint) and store edges on cell + const arrow = this.addExitArrow( + walls, + makeVertex, + sx1, + sy1, + sx2, + sy2, + cell.exitType, + inwardDx, + inwardDy, + ) + + cell.arrowEdges = arrow.edges + } + + // Draw arrow marker inside the maze (no shaft) + // Exit: tip touches wall, arrow head extends inward (pointing out) + // Entrance: base touches wall, tip points into maze (pointing in) + addExitArrow( + walls, + makeVertex, + x1, + y1, + x2, + y2, + exitType, + inwardDx, + inwardDy, + ) { + const wallDx = x2 - x1 + const wallDy = y2 - y1 + const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy) + + const wallUnitX = wallDx / wallLen + const wallUnitY = wallDy / wallLen + const inLen = Math.sqrt(inwardDx * inwardDx + inwardDy * inwardDy) + const inUnitX = inwardDx / inLen + const inUnitY = inwardDy / inLen + const headWidth = wallLen * ARROW_HEAD_WIDTH + const headHeight = headWidth * ARROW_HEAD_HEIGHT + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + + let tipX, tipY, baseCenterX, baseCenterY + + let baseLeftX, baseLeftY, baseRightX, baseRightY + + if (exitType === "exit") { + // Exit: tip touches wall, base inside maze, pointing OUT + + // Tip on wall + tipX = mx + tipY = my + + // Base center inside maze (inward from tip) + baseCenterX = mx + inUnitX * headHeight + baseCenterY = my + inUnitY * headHeight + + // Base points + baseLeftX = baseCenterX - (wallUnitX * headWidth) / 2 + baseLeftY = baseCenterY - (wallUnitY * headWidth) / 2 + baseRightX = baseCenterX + (wallUnitX * headWidth) / 2 + baseRightY = baseCenterY + (wallUnitY * headWidth) / 2 + + // Draw arrow head - connected through tip (on wall) + walls.push([makeVertex(tipX, tipY), makeVertex(baseLeftX, baseLeftY)]) + walls.push([ + makeVertex(baseLeftX, baseLeftY), + makeVertex(baseCenterX, baseCenterY), + ]) + walls.push([ + makeVertex(baseCenterX, baseCenterY), + makeVertex(baseRightX, baseRightY), + ]) + walls.push([makeVertex(baseRightX, baseRightY), makeVertex(tipX, tipY)]) + } else { + // Entrance: base touches wall, tip inside maze, pointing IN + + // Base center on wall + baseCenterX = mx + baseCenterY = my + + // Base points (on wall) + baseLeftX = mx - (wallUnitX * headWidth) / 2 + baseLeftY = my - (wallUnitY * headWidth) / 2 + baseRightX = mx + (wallUnitX * headWidth) / 2 + baseRightY = my + (wallUnitY * headWidth) / 2 + + // Tip inside maze (inward from base) + tipX = mx + inUnitX * headHeight + tipY = my + inUnitY * headHeight + + // Draw arrow head - connected through baseCenter (on wall) + walls.push([ + makeVertex(baseCenterX, baseCenterY), + makeVertex(baseLeftX, baseLeftY), + ]) + walls.push([makeVertex(baseLeftX, baseLeftY), makeVertex(tipX, tipY)]) + walls.push([makeVertex(tipX, tipY), makeVertex(baseRightX, baseRightY)]) + walls.push([ + makeVertex(baseRightX, baseRightY), + makeVertex(baseCenterX, baseCenterY), + ]) + } + + // Return edges (Victor objects that go into walls/graph) + return { + edges: [ + [makeVertex(tipX, tipY), makeVertex(baseLeftX, baseLeftY)], + [ + makeVertex(baseLeftX, baseLeftY), + makeVertex(baseCenterX, baseCenterY), + ], + [ + makeVertex(baseCenterX, baseCenterY), + makeVertex(baseRightX, baseRightY), + ], + [makeVertex(baseRightX, baseRightY), makeVertex(tipX, tipY)], + ], + } + } + + // Find the two edge cells with maximum distance (hardest path) + // Returns { startCell, endCell, distance, path } where path is array of cells + findHardestExits() { + const edgeCells = this.getEdgeCells() + + if (edgeCells.length < 2) { + return null + } + + // BFS that tracks both distances and parent pointers for path reconstruction + const bfsWithParents = (startCell) => { + const distances = new Map() + const parents = new Map() + const queue = [startCell] + + distances.set(this.cellKey(startCell), 0) + parents.set(this.cellKey(startCell), null) + + while (queue.length > 0) { + const current = queue.shift() + const currentDist = distances.get(this.cellKey(current)) + + for (const neighbor of this.getNeighbors(current)) { + if (this.isLinked(current, neighbor)) { + const neighborKey = this.cellKey(neighbor) + + if (!distances.has(neighborKey)) { + distances.set(neighborKey, currentDist + 1) + parents.set(neighborKey, current) + queue.push(neighbor) + } + } + } + } + + return { distances, parents } + } + + let maxDistance = -1 + let bestStart = null + let bestEnd = null + let bestParents = null + + // Check all pairs of edge cells + for (let i = 0; i < edgeCells.length; i++) { + const startEdge = edgeCells[i] + const { distances, parents } = bfsWithParents(startEdge.cell) + + for (let j = i + 1; j < edgeCells.length; j++) { + const endEdge = edgeCells[j] + const dist = distances.get(this.cellKey(endEdge.cell)) + + if (dist !== undefined && dist > maxDistance) { + maxDistance = dist + bestStart = startEdge + bestEnd = endEdge + bestParents = parents + } + } + } + + if (!bestStart || !bestEnd) { + return null + } + + // Reconstruct path from start to end using parent pointers + const path = [] + let current = bestEnd.cell + + while (current !== null) { + path.unshift(current) + current = bestParents.get(this.cellKey(current)) + } + + // Mark the cells with their exit directions + bestStart.cell.exitDirection = bestStart.direction + bestEnd.cell.exitDirection = bestEnd.direction + + return { + startCell: bestStart.cell, + endCell: bestEnd.cell, + distance: maxDistance, + path, + } + } +} diff --git a/src/features/shapes/maze/grids/HexGrid.js b/src/features/shapes/maze/grids/HexGrid.js new file mode 100644 index 00000000..25ffa0f9 --- /dev/null +++ b/src/features/shapes/maze/grids/HexGrid.js @@ -0,0 +1,311 @@ +import Grid from "./Grid" + +// Hexagonal grid for hex mazes +// Uses pointy-top orientation with odd-r offset coordinates + +export default class HexGrid extends Grid { + constructor(width, height, rng) { + super() + this.width = width + this.height = height + this.rng = rng + + this.xOffset = Math.sin(Math.PI / 3) // ~0.866 + this.yOffset1 = Math.cos(Math.PI / 3) // ~0.5 + this.yOffset2 = 2 - this.yOffset1 // ~1.5 + + // Keep hexagons regular (no distortion) + // Maze.js getMazeAspectRatio() handles the actual grid aspect ratio + this.yScale = 1 + this.cells = [] + + for (let r = 0; r < height; r++) { + for (let q = 0; q < width; q++) { + this.cells.push(this.createCell(q, r)) + } + } + } + + createCell(q, r) { + return { + q, + r, + links: new Set(), + visited: false, + } + } + + getCell(q, r) { + if (q < 0 || q >= this.width || r < 0 || r >= this.height) return null + + return this.cells[r * this.width + q] + } + + getAllCells() { + return this.cells + } + + getRandomCell() { + return this.cells[Math.floor(this.rng() * this.cells.length)] + } + + getRowOffset(r) { + return (r + 1) % 2 + } + + getNeighbors(cell) { + const { q, r } = cell + const neighbors = [] + const rowOffset = this.getRowOffset(r) + + // East neighbor (same row, q+1) + const east = this.getCell(q + 1, r) + if (east) neighbors.push(east) + + // West neighbor (same row, q-1) + const west = this.getCell(q - 1, r) + if (west) neighbors.push(west) + + // Northeast neighbor (row above) + const ne = this.getCell(q + 1 - rowOffset, r - 1) + if (ne) neighbors.push(ne) + + // Northwest neighbor (row above) + const nw = this.getCell(q - rowOffset, r - 1) + if (nw) neighbors.push(nw) + + // Southeast neighbor (row below) + const se = this.getCell(q + 1 - rowOffset, r + 1) + if (se) neighbors.push(se) + + // Southwest neighbor (row below) + const sw = this.getCell(q - rowOffset, r + 1) + if (sw) neighbors.push(sw) + + return neighbors + } + + cellKey(cell) { + return `${cell.q},${cell.r}` + } + + cellEquals(cell1, cell2) { + return cell1.q === cell2.q && cell1.r === cell2.r + } + + // Get the center point of a cell (for solution path drawing) + getCellCenter(cell) { + const { q, r } = cell + const rowXOffset = Math.abs(r % 2) * this.xOffset + + return { + x: rowXOffset + (2 * q + 1) * this.xOffset, + y: (r * this.yOffset2 + 1) * this.yScale, + } + } + + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const corners = this.getHexCorners(cell1.q, cell1.r) + const dq = cell2.q - cell1.q + const dr = cell2.r - cell1.r + const rowOffset = this.getRowOffset(cell1.r) + + let p1, p2 + + if (dr === 0 && dq === 1) { + // East + p1 = corners[4] + p2 = corners[3] + } else if (dr === 0 && dq === -1) { + // West + p1 = corners[0] + p2 = corners[1] + } else if (dr === -1 && dq === 1 - rowOffset) { + // Northeast + p1 = corners[5] + p2 = corners[4] + } else if (dr === -1 && dq === -rowOffset) { + // Northwest + p1 = corners[0] + p2 = corners[5] + } else if (dr === 1 && dq === 1 - rowOffset) { + // Southeast + p1 = corners[3] + p2 = corners[2] + } else { + // Southwest + p1 = corners[2] + p2 = corners[1] + } + + return { + x: (p1[0] + p2[0]) / 2, + y: (p1[1] + p2[1]) / 2, + } + } + + // Get cells on the grid perimeter with their exit directions + getEdgeCells() { + const edgeCells = [] + + for (const cell of this.cells) { + const { q } = cell + + if (q === 0) { + edgeCells.push({ cell, direction: "w", edge: "w" }) + } + + if (q === this.width - 1) { + edgeCells.push({ cell, direction: "e", edge: "e" }) + } + } + + return edgeCells + } + + getHexCorners(q, r) { + const rowXOffset = Math.abs(r % 2) * this.xOffset + const ys = this.yScale + const p1x = rowXOffset + q * 2 * this.xOffset + const p1y = (this.yOffset1 + r * this.yOffset2) * ys + const p2x = p1x + const p2y = (r + 1) * this.yOffset2 * ys + const p3x = rowXOffset + (2 * q + 1) * this.xOffset + const p3y = (r * this.yOffset2 + 2) * ys + const p4x = p2x + 2 * this.xOffset + const p4y = p2y + const p5x = p4x + const p5y = p1y + const p6x = p3x + const p6y = r * this.yOffset2 * ys + + return [ + [p1x, p1y], // top-left + [p2x, p2y], // bottom-left + [p3x, p3y], // bottom + [p4x, p4y], // bottom-right + [p5x, p5y], // top-right + [p6x, p6y], // top + ] + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + const makeVertex = this.createMakeVertex(vertexCache) + + const arrowScale = 1.25 + + const addExit = (cell, x1, y1, x2, y2, direction) => { + // For pointy-top hex, west/east edges are vertical + // West (q=0): inward points right (+x) + // East (q=max): inward points left (-x) + const inwardDx = direction === "w" ? 1 : -1 + const inwardDy = 0 + + this.addExitWithArrow( + walls, + makeVertex, + cell, + x1, + y1, + x2, + y2, + inwardDx, + inwardDy, + arrowScale, + ) + } + + for (const cell of this.cells) { + const { q, r } = cell + const corners = this.getHexCorners(q, r) + const rowOffset = this.getRowOffset(r) + + // Edge between p1-p2 (west edge) + const west = this.getCell(q - 1, r) + + if (!west || !this.isLinked(cell, west)) { + if (cell.exitDirection === "w") { + addExit( + cell, + corners[0][0], + corners[0][1], + corners[1][0], + corners[1][1], + "w", + ) + } else { + walls.push([ + makeVertex(corners[0][0], corners[0][1]), + makeVertex(corners[1][0], corners[1][1]), + ]) + } + } + + // Edge between p2-p3 (southwest edge) + const sw = this.getCell(q - rowOffset, r + 1) + + if (!sw || !this.isLinked(cell, sw)) { + walls.push([ + makeVertex(corners[1][0], corners[1][1]), + makeVertex(corners[2][0], corners[2][1]), + ]) + } + + // Edge between p3-p4 (southeast edge) + const se = this.getCell(q + 1 - rowOffset, r + 1) + + if (!se || !this.isLinked(cell, se)) { + walls.push([ + makeVertex(corners[2][0], corners[2][1]), + makeVertex(corners[3][0], corners[3][1]), + ]) + } + + // Edge between p4-p5 (east edge) + const east = this.getCell(q + 1, r) + + if (!east || !this.isLinked(cell, east)) { + if (cell.exitDirection === "e") { + addExit( + cell, + corners[3][0], + corners[3][1], + corners[4][0], + corners[4][1], + "e", + ) + } else { + walls.push([ + makeVertex(corners[3][0], corners[3][1]), + makeVertex(corners[4][0], corners[4][1]), + ]) + } + } + + // Edge between p5-p6 (northeast edge) + const ne = this.getCell(q + 1 - rowOffset, r - 1) + + if (!ne || !this.isLinked(cell, ne)) { + walls.push([ + makeVertex(corners[4][0], corners[4][1]), + makeVertex(corners[5][0], corners[5][1]), + ]) + } + + // Edge between p6-p1 (northwest edge) + const nw = this.getCell(q - rowOffset, r - 1) + + if (!nw || !this.isLinked(cell, nw)) { + walls.push([ + makeVertex(corners[5][0], corners[5][1]), + makeVertex(corners[0][0], corners[0][1]), + ]) + } + } + + return walls + } +} diff --git a/src/features/shapes/maze/grids/PolarGrid.js b/src/features/shapes/maze/grids/PolarGrid.js new file mode 100644 index 00000000..41c82cc4 --- /dev/null +++ b/src/features/shapes/maze/grids/PolarGrid.js @@ -0,0 +1,455 @@ +/* global console */ +import Grid from "./Grid" + +// Polar grid for circular mazes +// Cells are arranged in concentric rings, with ring 0 being a single center cell +// Outer rings can have more wedges (doubling) to maintain proportional cell sizes + +export default class PolarGrid extends Grid { + constructor( + ringCount, + baseWedgeCount, + doublingInterval, + rng, + useArcs = false, + ) { + super() + this.ringCount = ringCount + this.baseWedgeCount = baseWedgeCount + this.doublingInterval = doublingInterval + this.rng = rng + this.useArcs = useArcs + + // Build the grid structure + // rings[r] = array of cells at ring r + // Each cell: { ring, wedge, links: Set of "ring,wedge" keys } + this.rings = [] + + // Ring 0: single center cell + this.rings[0] = [this.createCell(0, 0)] + + // Outer rings with wedge doubling + for (let r = 1; r <= ringCount; r++) { + const wedgeCount = this.getWedgeCount(r) + + this.rings[r] = [] + for (let w = 0; w < wedgeCount; w++) { + this.rings[r][w] = this.createCell(r, w) + } + } + } + + createCell(ring, wedge) { + return { + ring, + wedge, + links: new Set(), + visited: false, + } + } + + // Ring 0 always has 1 cell + // Wedge count doubles every `doublingInterval` rings + getWedgeCount(ring) { + if (ring === 0) return 1 + + const doublings = Math.floor((ring - 1) / this.doublingInterval) + + return this.baseWedgeCount * Math.pow(2, doublings) + } + + getCell(ring, wedge) { + if (ring < 0 || ring > this.ringCount) return null + + const cells = this.rings[ring] + + if (!cells || wedge < 0 || wedge >= cells.length) return null + + return cells[wedge] + } + + getAllCells() { + const cells = [] + + for (let r = 0; r <= this.ringCount; r++) { + for (const cell of this.rings[r]) { + cells.push(cell) + } + } + + return cells + } + + getRandomCell() { + const allCells = this.getAllCells() + + return allCells[Math.floor(this.rng() * allCells.length)] + } + + getNeighbors(cell) { + const { ring, wedge } = cell + const neighbors = [] + const wedgesInThisRing = this.rings[ring].length + + // Center cell (ring 0) only has outward neighbors + if (ring === 0) { + // All cells in ring 1 are neighbors of center + for (const outerCell of this.rings[1]) { + neighbors.push(outerCell) + } + + return neighbors + } + + // Clockwise neighbor (same ring, next wedge with wraparound) + const cwWedge = (wedge + 1) % wedgesInThisRing + + neighbors.push(this.rings[ring][cwWedge]) + + // Counter-clockwise neighbor (same ring, previous wedge with wraparound) + const ccwWedge = (wedge - 1 + wedgesInThisRing) % wedgesInThisRing + + neighbors.push(this.rings[ring][ccwWedge]) + + // Inward neighbor(s) + if (ring > 0) { + const innerWedgeCount = this.rings[ring - 1].length + + if (ring === 1) { + // Ring 1 cells all connect to center + neighbors.push(this.rings[0][0]) + } else { + // Map this wedge to inner ring wedge + const ratio = wedgesInThisRing / innerWedgeCount + const innerWedge = Math.floor(wedge / ratio) + + neighbors.push(this.rings[ring - 1][innerWedge]) + } + } + + // Outward neighbor(s) + if (ring < this.ringCount) { + const outerWedgeCount = this.rings[ring + 1].length + const ratio = outerWedgeCount / wedgesInThisRing + + if (ratio === 1) { + // 1-to-1 mapping + neighbors.push(this.rings[ring + 1][wedge]) + } else { + // 1-to-many mapping (this cell connects to multiple outer cells) + const firstOuterWedge = wedge * ratio + + for (let i = 0; i < ratio; i++) { + neighbors.push(this.rings[ring + 1][firstOuterWedge + i]) + } + } + } + + return neighbors + } + + cellKey(cell) { + return `${cell.ring},${cell.wedge}` + } + + cellEquals(cell1, cell2) { + return cell1.ring === cell2.ring && cell1.wedge === cell2.wedge + } + + // Get the center point of a cell (for solution path drawing) + getCellCenter(cell) { + const { ring, wedge } = cell + + // Ring 0 is the center cell + if (ring === 0) { + return { x: 0, y: 0 } + } + + const wedgeCount = this.rings[ring].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const radius = ring + 0.5 + const angle = (wedge + 0.5) * anglePerWedge + + return { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + } + } + + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const { ring: r1, wedge: w1 } = cell1 + const { ring: r2, wedge: w2 } = cell2 + + // Center cell to ring 1 + if (r1 === 0) { + const wedgeCount = this.rings[1].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const angle = (w2 + 0.5) * anglePerWedge + + return { x: Math.cos(angle), y: Math.sin(angle) } + } + + if (r2 === 0) { + const wedgeCount = this.rings[1].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const angle = (w1 + 0.5) * anglePerWedge + + return { x: Math.cos(angle), y: Math.sin(angle) } + } + + // Same ring (CW/CCW neighbors) - radial edge + if (r1 === r2) { + const wedgeCount = this.rings[r1].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const sharedAngle = Math.max(w1, w2) * anglePerWedge + + // Handle wraparound + const angle = Math.abs(w1 - w2) > 1 ? 0 : sharedAngle + const radius = r1 + 0.5 + + return { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + } + } + + // Different rings (inward/outward) - arc edge + const outerRing = Math.max(r1, r2) + const outerWedge = r1 > r2 ? w1 : w2 + const outerWedgeCount = this.rings[outerRing].length + const anglePerWedge = (Math.PI * 2) / outerWedgeCount + const angle = (outerWedge + 0.5) * anglePerWedge + const radius = outerRing + + return { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + } + } + + // Get cells on the outer ring (perimeter) with their exit directions + getEdgeCells() { + const edgeCells = [] + const outerRing = this.ringCount + + for (const cell of this.rings[outerRing]) { + edgeCells.push({ cell, direction: "out", edge: "out" }) + } + + return edgeCells + } + + // Debug: dump maze structure + dump() { + let output = "" + + for (let r = 0; r <= this.ringCount; r++) { + const wedgeCount = this.rings[r].length + + output += `Ring ${r} (${wedgeCount} wedge${wedgeCount > 1 ? "s" : ""}):\n` + + for (let w = 0; w < wedgeCount; w++) { + const cell = this.rings[r][w] + const links = Array.from(cell.links).sort().join(", ") + + output += ` [${r}:${w}] -> ${links || "(none)"}\n` + } + } + + console.log(output) + + return output + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + const makeVertexXY = this.createMakeVertex(vertexCache) + + // Helper to create/reuse vertices from polar coords + const makeVertex = (r, angle) => { + const x = r * Math.cos(angle) + const y = r * Math.sin(angle) + + return makeVertexXY(x, y) + } + + // 1. RADIAL WALLS (between adjacent wedges in the same ring) + for (let r = 1; r <= this.ringCount; r++) { + const wedgeCount = this.rings[r].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const innerRadius = r + const outerRadius = r + 1 + + for (let w = 0; w < wedgeCount; w++) { + const cell = this.rings[r][w] + const cwWedge = (w + 1) % wedgeCount + const cwNeighbor = this.rings[r][cwWedge] + + // Wall between this cell and clockwise neighbor + if (!this.isLinked(cell, cwNeighbor)) { + const angle = (w + 1) * anglePerWedge + walls.push([ + makeVertex(innerRadius, angle), + makeVertex(outerRadius, angle), + ]) + } + } + } + + // Add arc or segment wall based on useArcs setting + const addArcWall = (radius, startAngle, endAngle) => { + if (this.useArcs) { + const resolution = (Math.PI * 2.0) / 128.0 + const deltaAngle = endAngle - startAngle + const numSteps = Math.ceil(deltaAngle / resolution) + const points = [] + + for (let step = 0; step <= numSteps; step++) { + const t = step / numSteps + const angle = startAngle + t * deltaAngle + points.push(makeVertex(radius, angle)) + } + + for (let i = 0; i < points.length - 1; i++) { + walls.push([points[i], points[i + 1]]) + } + } else { + walls.push([ + makeVertex(radius, startAngle), + makeVertex(radius, endAngle), + ]) + } + } + + // 2. ARC WALLS (between rings) + // Inner arc of each cell (boundary with inward neighbor) + for (let r = 1; r <= this.ringCount; r++) { + const wedgeCount = this.rings[r].length + const anglePerWedge = (Math.PI * 2) / wedgeCount + const radius = r // Inner edge of ring r is at radius r + + for (let w = 0; w < wedgeCount; w++) { + const cell = this.rings[r][w] + const startAngle = w * anglePerWedge + const endAngle = (w + 1) * anglePerWedge + + let inwardNeighbor + + if (r === 1) { + inwardNeighbor = this.rings[0][0] + } else { + const innerWedgeCount = this.rings[r - 1].length + const ratio = wedgeCount / innerWedgeCount + const innerWedge = Math.floor(w / ratio) + + inwardNeighbor = this.rings[r - 1][innerWedge] + } + + // Wall if not linked to inward neighbor + if (!this.isLinked(cell, inwardNeighbor)) { + addArcWall(radius, startAngle, endAngle) + } + } + } + + const addExitArcWithArrow = (cell, radius, startAngle, endAngle) => { + const midAngle = (startAngle + endAngle) / 2 + + // Draw arc in two halves (split at midpoint) + if (this.useArcs) { + const resolution = (Math.PI * 2.0) / 128.0 + + // First half: startAngle to midAngle + const delta1 = midAngle - startAngle + const numSteps1 = Math.max(1, Math.ceil(delta1 / resolution)) + + for (let step = 0; step < numSteps1; step++) { + const t1 = step / numSteps1 + const t2 = (step + 1) / numSteps1 + const a1 = startAngle + t1 * delta1 + const a2 = startAngle + t2 * delta1 + + walls.push([makeVertex(radius, a1), makeVertex(radius, a2)]) + } + + // Second half: midAngle to endAngle + const delta2 = endAngle - midAngle + const numSteps2 = Math.max(1, Math.ceil(delta2 / resolution)) + + for (let step = 0; step < numSteps2; step++) { + const t1 = step / numSteps2 + const t2 = (step + 1) / numSteps2 + const a1 = midAngle + t1 * delta2 + const a2 = midAngle + t2 * delta2 + + walls.push([makeVertex(radius, a1), makeVertex(radius, a2)]) + } + } else { + // Straight segments split at midpoint + walls.push([ + makeVertex(radius, startAngle), + makeVertex(radius, midAngle), + ]) + walls.push([makeVertex(radius, midAngle), makeVertex(radius, endAngle)]) + } + + // Arrow at arc's angular midpoint + const arcMidX = radius * Math.cos(midAngle) + const arcMidY = radius * Math.sin(midAngle) + + // Tangent direction (perpendicular to radial, along the arc) + const tangentX = -Math.sin(midAngle) + const tangentY = Math.cos(midAngle) + + // Create virtual wall endpoints along tangent for arrow sizing + // We want headWidth = 0.5, and addExitArrow uses headWidth = wallLen * 0.625 + // So wallLen = 0.5 / 0.625 = 0.8, half on each side = 0.4 + const halfWall = 0.4 + const x1 = arcMidX - tangentX * halfWall + const y1 = arcMidY - tangentY * halfWall + const x2 = arcMidX + tangentX * halfWall + const y2 = arcMidY + tangentY * halfWall + + // Inward direction points toward center + const inwardDx = -Math.cos(midAngle) + const inwardDy = -Math.sin(midAngle) + + // Use base class arrow drawing (walls already split above, so just draw arrow) + const arrow = this.addExitArrow( + walls, + makeVertexXY, + x1, + y1, + x2, + y2, + cell.exitType, + inwardDx, + inwardDy, + ) + + cell.arrowEdges = arrow.edges + } + + // 3. OUTER PERIMETER (always walls, with exits) + const outerRing = this.ringCount + const outerWedgeCount = this.rings[outerRing].length + const outerAnglePerWedge = (Math.PI * 2) / outerWedgeCount + const outerRadius = this.ringCount + 1 + + for (let w = 0; w < outerWedgeCount; w++) { + const cell = this.rings[outerRing][w] + const startAngle = w * outerAnglePerWedge + const endAngle = (w + 1) * outerAnglePerWedge + + if (cell.exitDirection === "out") { + addExitArcWithArrow(cell, outerRadius, startAngle, endAngle) + } else { + addArcWall(outerRadius, startAngle, endAngle) + } + } + + return walls + } +} diff --git a/src/features/shapes/maze/grids/RectangularGrid.js b/src/features/shapes/maze/grids/RectangularGrid.js new file mode 100644 index 00000000..cbb13378 --- /dev/null +++ b/src/features/shapes/maze/grids/RectangularGrid.js @@ -0,0 +1,243 @@ +/* global console */ +import Grid from "./Grid" + +// Rectangular grid for standard mazes +// Implements the same interface as PolarGrid for algorithm compatibility + +export default class RectangularGrid extends Grid { + constructor(width, height, rng) { + super() + this.width = width + this.height = height + this.rng = rng + + this.cells = [] + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + this.cells.push(this.createCell(x, y)) + } + } + } + + createCell(x, y) { + return { + x, + y, + links: new Set(), + visited: false, + } + } + + // Get cell at position + getCell(x, y) { + if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null + + return this.cells[y * this.width + x] + } + + // Get all cells as flat array + getAllCells() { + return this.cells + } + + // Get a random cell + getRandomCell() { + return this.cells[Math.floor(this.rng() * this.cells.length)] + } + + // Get neighbors of a cell (N, S, E, W) + getNeighbors(cell) { + const { x, y } = cell + const neighbors = [] + + // North + if (y > 0) neighbors.push(this.getCell(x, y - 1)) + // South + if (y < this.height - 1) neighbors.push(this.getCell(x, y + 1)) + // East + if (x < this.width - 1) neighbors.push(this.getCell(x + 1, y)) + // West + if (x > 0) neighbors.push(this.getCell(x - 1, y)) + + return neighbors + } + + cellKey(cell) { + return `${cell.x},${cell.y}` + } + + cellEquals(cell1, cell2) { + return cell1.x === cell2.x && cell1.y === cell2.y + } + + // Get the center point of a cell (for solution path drawing) + getCellCenter(cell) { + return { + x: cell.x + 0.5, + y: cell.y + 0.5, + } + } + + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const dx = cell2.x - cell1.x + const dy = cell2.y - cell1.y + + if (dy === -1) { + // cell2 is north + return { x: cell1.x + 0.5, y: cell1.y } + } else if (dy === 1) { + // cell2 is south + return { x: cell1.x + 0.5, y: cell1.y + 1 } + } else if (dx === 1) { + // cell2 is east + return { x: cell1.x + 1, y: cell1.y + 0.5 } + } else { + // cell2 is west + return { x: cell1.x, y: cell1.y + 0.5 } + } + } + + // Get all cells on the grid perimeter with their exit directions + // edge property allows filtering to ensure exits are on opposite edges + getEdgeCells() { + const edgeCells = [] + + for (const cell of this.cells) { + const { x, y } = cell + + // Prefer corners: pick one direction (priority: N, S, E, W) + if (y === 0) { + edgeCells.push({ cell, direction: "n", edge: "n" }) + } else if (y === this.height - 1) { + edgeCells.push({ cell, direction: "s", edge: "s" }) + } else if (x === 0) { + edgeCells.push({ cell, direction: "w", edge: "w" }) + } else if (x === this.width - 1) { + edgeCells.push({ cell, direction: "e", edge: "e" }) + } + } + + return edgeCells + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + const makeVertex = this.createMakeVertex(vertexCache, false) + + // Inward directions for each edge (into the maze) + const inwardDir = { + n: { dx: 0, dy: 1 }, + s: { dx: 0, dy: -1 }, + w: { dx: 1, dy: 0 }, + e: { dx: -1, dy: 0 }, + } + + const addExit = (cell, x1, y1, x2, y2, direction) => { + const { dx, dy } = inwardDir[direction] + + this.addExitWithArrow(walls, makeVertex, cell, x1, y1, x2, y2, dx, dy) + } + + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const cell = this.getCell(x, y) + + // North wall (top of cell) + if (y === 0) { + if (cell.exitDirection === "n") { + addExit(cell, x, y, x + 1, y, "n") + } else { + walls.push([makeVertex(x, y), makeVertex(x + 1, y)]) + } + } else { + const northNeighbor = this.getCell(x, y - 1) + + if (!this.isLinked(cell, northNeighbor)) { + walls.push([makeVertex(x, y), makeVertex(x + 1, y)]) + } + } + + // West wall (left of cell) + if (x === 0) { + if (cell.exitDirection === "w") { + addExit(cell, x, y, x, y + 1, "w") + } else { + walls.push([makeVertex(x, y), makeVertex(x, y + 1)]) + } + } else { + const westNeighbor = this.getCell(x - 1, y) + + if (!this.isLinked(cell, westNeighbor)) { + walls.push([makeVertex(x, y), makeVertex(x, y + 1)]) + } + } + + // South wall (bottom edge only for last row) + if (y === this.height - 1) { + if (cell.exitDirection === "s") { + addExit(cell, x, y + 1, x + 1, y + 1, "s") + } else { + walls.push([makeVertex(x, y + 1), makeVertex(x + 1, y + 1)]) + } + } + + // East wall (right edge only for last column) + if (x === this.width - 1) { + if (cell.exitDirection === "e") { + addExit(cell, x + 1, y, x + 1, y + 1, "e") + } else { + walls.push([makeVertex(x + 1, y), makeVertex(x + 1, y + 1)]) + } + } + } + } + + return walls + } + + // Debug: dump maze as ASCII art (y=0 at bottom, with cell coords) + dump() { + let output = "" + + for (let y = this.height - 1; y >= 0; y--) { + let topLine = "" + + for (let x = 0; x < this.width; x++) { + const cell = this.getCell(x, y) + const southCell = this.getCell(x, y + 1) + const hasSouthLink = southCell && this.isLinked(cell, southCell) + + topLine += "+" + (hasSouthLink ? " " : "-----") + } + topLine += "+" + output += topLine + "\n" + + let cellLine = "" + + for (let x = 0; x < this.width; x++) { + const cell = this.getCell(x, y) + const westCell = this.getCell(x - 1, y) + const hasWestLink = westCell && this.isLinked(cell, westCell) + const coord = `${x},${y}`.padStart(3).padEnd(5) + + cellLine += (hasWestLink ? " " : "|") + coord + } + cellLine += "|" + output += cellLine + "\n" + } + + let bottomLine = "" + + for (let x = 0; x < this.width; x++) { + bottomLine += "+-----" + } + bottomLine += "+" + output += bottomLine + "\n" + + console.log(output) + + return output + } +} diff --git a/src/features/shapes/maze/grids/TriangleGrid.js b/src/features/shapes/maze/grids/TriangleGrid.js new file mode 100644 index 00000000..872f7daf --- /dev/null +++ b/src/features/shapes/maze/grids/TriangleGrid.js @@ -0,0 +1,309 @@ +import Grid from "./Grid" + +// Triangular grid for delta mazes +// Uses alternating up/down triangles based on coordinate parity + +export default class TriangleGrid extends Grid { + constructor(width, height, rng) { + super() + this.width = width + this.height = height + this.rng = rng + + this.triHeight = Math.sqrt(3) / 2 + + // Keep triangles regular (equilateral) - no distortion + // Maze.js getMazeAspectRatio() handles the actual grid aspect ratio + this.yScale = 1 + this.cells = [] + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + this.cells.push(this.createCell(x, y)) + } + } + } + + createCell(x, y) { + return { + x, + y, + upward: this.isUpward(x, y), + links: new Set(), + visited: false, + } + } + + // Parity check determines triangle orientation + // Even sum = DOWN (base on north), Odd sum = UP (base on south) + isUpward(x, y) { + return (x + y) % 2 === 1 + } + + getCell(x, y) { + if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null + + return this.cells[y * this.width + x] + } + + getAllCells() { + return this.cells + } + + getRandomCell() { + return this.cells[Math.floor(this.rng() * this.cells.length)] + } + + // 3 neighbors per cell: E, W, and either N (down) or S (up) + getNeighbors(cell) { + const { x, y, upward } = cell + const neighbors = [] + + // East neighbor (always) + const east = this.getCell(x + 1, y) + if (east) neighbors.push(east) + + // West neighbor (always) + const west = this.getCell(x - 1, y) + if (west) neighbors.push(west) + + // Vertical neighbor depends on orientation + if (upward) { + // UP triangle has base on south, connects to south neighbor + const south = this.getCell(x, y + 1) + if (south) neighbors.push(south) + } else { + // DOWN triangle has base on north, connects to north neighbor + const north = this.getCell(x, y - 1) + if (north) neighbors.push(north) + } + + return neighbors + } + + cellKey(cell) { + return `${cell.x},${cell.y}` + } + + cellEquals(cell1, cell2) { + return cell1.x === cell2.x && cell1.y === cell2.y + } + + // Get the center point of a cell (geometric center, not centroid) + // Using y + 0.5 ensures line-of-sight to all edges + getCellCenter(cell) { + const { x, y } = cell + const h = this.triHeight + const ys = this.yScale + const baseX = x * 0.5 + + return { + x: baseX + 0.5, + y: (y + 0.5) * h * ys, + } + } + + // Get midpoint of shared edge between two adjacent cells + getSharedEdgeMidpoint(cell1, cell2) { + const c1 = this.getTriangleCorners(cell1.x, cell1.y) + const dx = cell2.x - cell1.x + const dy = cell2.y - cell1.y + + let p1, p2 + + if (dx === 1) { + // cell2 is east + if (cell1.upward) { + p1 = c1.top + p2 = c1.bottomRight + } else { + p1 = c1.topRight + p2 = c1.bottom + } + } else if (dx === -1) { + // cell2 is west + if (cell1.upward) { + p1 = c1.top + p2 = c1.bottomLeft + } else { + p1 = c1.topLeft + p2 = c1.bottom + } + } else if (dy === 1) { + // cell2 is south (cell1 must be upward) + p1 = c1.bottomLeft + p2 = c1.bottomRight + } else { + // cell2 is north (cell1 must be downward) + p1 = c1.topLeft + p2 = c1.topRight + } + + return { + x: (p1[0] + p2[0]) / 2, + y: (p1[1] + p2[1]) / 2, + } + } + + // Get cells on the grid perimeter with their exit directions + // For triangles: top/bottom rows and left/right edges + getEdgeCells() { + const edgeCells = [] + + for (const cell of this.cells) { + const { y, upward } = cell + + // Top row: DOWN triangles have horizontal top edge + if (y === 0 && !upward) { + edgeCells.push({ cell, direction: "n", edge: "n" }) + } + + // Bottom row: UP triangles have horizontal bottom edge + if (y === this.height - 1 && upward) { + edgeCells.push({ cell, direction: "s", edge: "s" }) + } + } + + return edgeCells + } + + getTriangleCorners(x, y) { + const h = this.triHeight + const ys = this.yScale + const baseX = x * 0.5 + + if (this.isUpward(x, y)) { + // UP triangle: apex at top, base at bottom + return { + top: [baseX + 0.5, y * h * ys], + bottomLeft: [baseX, (y + 1) * h * ys], + bottomRight: [baseX + 1, (y + 1) * h * ys], + } + } else { + // DOWN triangle: base at top, apex at bottom + return { + topLeft: [baseX, y * h * ys], + topRight: [baseX + 1, y * h * ys], + bottom: [baseX + 0.5, (y + 1) * h * ys], + } + } + } + + extractWalls() { + const walls = [] + const vertexCache = new Map() + const makeVertex = this.createMakeVertex(vertexCache) + + const arrowScale = 0.6 + + const addExit = (cell, x1, y1, x2, y2, direction) => { + // For horizontal edges: n = inward down, s = inward up + const inwardDx = 0 + const inwardDy = direction === "n" ? 1 : -1 + + this.addExitWithArrow( + walls, + makeVertex, + cell, + x1, + y1, + x2, + y2, + inwardDx, + inwardDy, + arrowScale, + ) + } + + for (const cell of this.cells) { + const { x, y, upward } = cell + const corners = this.getTriangleCorners(x, y) + + // East neighbor + const east = this.getCell(x + 1, y) + + // West neighbor + const west = this.getCell(x - 1, y) + + if (upward) { + // UP triangle: top, bottomLeft, bottomRight + // Left edge: top to bottomLeft (shared with west) + if (!west || !this.isLinked(cell, west)) { + walls.push([ + makeVertex(corners.top[0], corners.top[1]), + makeVertex(corners.bottomLeft[0], corners.bottomLeft[1]), + ]) + } + + // Right edge: top to bottomRight (shared with east) + if (!east || !this.isLinked(cell, east)) { + walls.push([ + makeVertex(corners.top[0], corners.top[1]), + makeVertex(corners.bottomRight[0], corners.bottomRight[1]), + ]) + } + + // Bottom edge: bottomLeft to bottomRight (shared with south) + const south = this.getCell(x, y + 1) + + if (!south || !this.isLinked(cell, south)) { + if (cell.exitDirection === "s") { + addExit( + cell, + corners.bottomLeft[0], + corners.bottomLeft[1], + corners.bottomRight[0], + corners.bottomRight[1], + "s", + ) + } else { + walls.push([ + makeVertex(corners.bottomLeft[0], corners.bottomLeft[1]), + makeVertex(corners.bottomRight[0], corners.bottomRight[1]), + ]) + } + } + } else { + // DOWN triangle: topLeft, topRight, bottom + // Left edge: topLeft to bottom (shared with west) + if (!west || !this.isLinked(cell, west)) { + walls.push([ + makeVertex(corners.topLeft[0], corners.topLeft[1]), + makeVertex(corners.bottom[0], corners.bottom[1]), + ]) + } + + // Right edge: topRight to bottom (shared with east) + if (!east || !this.isLinked(cell, east)) { + walls.push([ + makeVertex(corners.topRight[0], corners.topRight[1]), + makeVertex(corners.bottom[0], corners.bottom[1]), + ]) + } + + // Top edge: topLeft to topRight (shared with north) + const north = this.getCell(x, y - 1) + + if (!north || !this.isLinked(cell, north)) { + if (cell.exitDirection === "n") { + addExit( + cell, + corners.topLeft[0], + corners.topLeft[1], + corners.topRight[0], + corners.topRight[1], + "n", + ) + } else { + walls.push([ + makeVertex(corners.topLeft[0], corners.topLeft[1]), + makeVertex(corners.topRight[0], corners.topRight[1]), + ]) + } + } + } + } + + return walls + } +} diff --git a/src/features/shapes/shapeFactory.js b/src/features/shapes/shapeFactory.js index 1e40df15..9cb11409 100644 --- a/src/features/shapes/shapeFactory.js +++ b/src/features/shapes/shapeFactory.js @@ -10,6 +10,7 @@ import Hypocycloid from "./Hypocycloid" import ImageImport from "./image_import/ImageImport" import InputText from "./input_text/InputText" import LSystem from "./lsystem/LSystem" +import Maze from "./maze/Maze" import Point from "./Point" import Polygon from "./Polygon" import Reuleaux from "./Reuleaux" @@ -38,6 +39,7 @@ export const shapeFactory = { lsystem: LSystem, fractalSpirograph: FractalSpirograph, tessellationTwist: TessellationTwist, + maze: Maze, voronoi: Voronoi, point: Point, circlePacker: CirclePacker,