Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions src/common/chinesePostman.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
64 changes: 64 additions & 0 deletions src/common/geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
17 changes: 17 additions & 0 deletions src/features/shapes/maze/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading