diff --git a/src/common/Graph.js b/src/common/Graph.js index 822f4dd8..4834b225 100644 --- a/src/common/Graph.js +++ b/src/common/Graph.js @@ -1,4 +1,4 @@ -import { PriorityQueue } from "./PriorityQueue.js" +import { PriorityQueue } from "./PriorityQueue" import Victor from "victor" export const mix = (v1, v2, s) => { @@ -37,7 +37,9 @@ export const edgeKey = (node1, node2) => { return [node1Key, node2Key].sort().toString() } -// note: requires string-based nodes to work properly +// Note: requires string-based nodes to work properly. +// Path cache is not invalidated on addNode/addEdge for performance. +// If modifying graph after computing paths, call clearCachedPaths() manually. export default class Graph { constructor() { this.nodeMap = {} @@ -58,7 +60,6 @@ export default class Graph { this.nodeKeys.add(key) this.nodeMap[key] = node this.adjacencyList[key] = [] - this.clearCachedPaths() } } @@ -72,7 +73,6 @@ export default class Graph { this.adjacencyList[node2Key].push({ node: node1, weight }) this.edgeKeys.add(edge12Key) this.edgeMap[edge12Key] = [node1.toString(), node2.toString()] - this.clearCachedPaths() } } @@ -93,18 +93,61 @@ export default class Graph { return Object.values(this.nodeMap).find(fn) } + // BFS-based shortest path - optimal for uniform edge weights + bfsShortestPath(startNode, endNode) { + let shortest = this.getCachedShortestPath(startNode, endNode) + + if (shortest === undefined) { + const backtrace = {} + const visited = new Set() + const queue = [startNode] + + visited.add(startNode) + + while (queue.length > 0) { + const currentNode = queue.shift() + + if (currentNode === endNode) { + break + } + + for (const neighbor of this.adjacencyList[currentNode]) { + const neighborKey = neighbor.node.toString() + + if (!visited.has(neighborKey)) { + visited.add(neighborKey) + backtrace[neighborKey] = currentNode + queue.push(neighborKey) + } + } + } + + let path = [endNode.toString()] + let lastStep = endNode + + while (lastStep !== startNode) { + path.unshift(backtrace[lastStep].toString()) + lastStep = backtrace[lastStep] + } + + shortest = path.map((node) => this.nodeMap[node]) + this.cacheShortestPath(startNode, endNode, shortest) + } + + return shortest + } + + // Dijkstra's algorithm with binary heap - O((V+E) log V) dijkstraShortestPath(startNode, endNode) { let shortest = this.getCachedShortestPath(startNode, endNode) if (shortest === undefined) { - let times = {} - let backtrace = {} - let pq = new PriorityQueue() - let nodes = this.nodeKeys + const times = {} + const backtrace = {} + const pq = new PriorityQueue() times[startNode] = 0 - - nodes.forEach((node) => { + this.nodeKeys.forEach((node) => { if (node !== startNode) { times[node] = Infinity } @@ -113,17 +156,18 @@ export default class Graph { pq.enqueue([startNode, 0]) while (!pq.isEmpty()) { - let shortestStep = pq.dequeue() - let currentNode = shortestStep[0] - this.adjacencyList[currentNode.toString()].forEach((neighbor) => { - let time = times[currentNode] + neighbor.weight - - if (time < times[neighbor.node]) { - times[neighbor.node] = time - backtrace[neighbor.node] = currentNode - pq.enqueue([neighbor.node, time]) + const [currentNode] = pq.dequeue() + + for (const neighbor of this.adjacencyList[currentNode]) { + const neighborKey = neighbor.node.toString() + const time = times[currentNode] + neighbor.weight + + if (time < times[neighborKey]) { + times[neighborKey] = time + backtrace[neighborKey] = currentNode + pq.enqueue([neighborKey, time]) } - }) + } } let path = [endNode.toString()] diff --git a/src/common/PriorityQueue.js b/src/common/PriorityQueue.js index 89e96fa6..35359b27 100644 --- a/src/common/PriorityQueue.js +++ b/src/common/PriorityQueue.js @@ -1,33 +1,72 @@ +// Binary min-heap priority queue +// Elements are [value, priority] tuples; lower priority = dequeued first export class PriorityQueue { constructor() { - this.collection = [] + this.heap = [] } enqueue(element) { - if (this.isEmpty()) { - this.collection.push(element) - } else { - let added = false - for (let i = 1; i <= this.collection.length; i++) { - if (element[1] < this.collection[i - 1][1]) { - this.collection.splice(i - 1, 0, element) - added = true - break - } - } - - if (!added) { - this.collection.push(element) - } - } + this.heap.push(element) + this.bubbleUp(this.heap.length - 1) } dequeue() { - let value = this.collection.shift() - return value + if (this.heap.length === 0) return undefined + if (this.heap.length === 1) return this.heap.pop() + + const min = this.heap[0] + + this.heap[0] = this.heap.pop() + this.bubbleDown(0) + + return min } isEmpty() { - return this.collection.length === 0 + return this.heap.length === 0 + } + + bubbleUp(index) { + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + + if (this.heap[parentIndex][1] <= this.heap[index][1]) break + this.swap(parentIndex, index) + index = parentIndex + } + } + + bubbleDown(index) { + const length = this.heap.length + + while (true) { + const leftChild = 2 * index + 1 + const rightChild = 2 * index + 2 + let smallest = index + + if ( + leftChild < length && + this.heap[leftChild][1] < this.heap[smallest][1] + ) { + smallest = leftChild + } + if ( + rightChild < length && + this.heap[rightChild][1] < this.heap[smallest][1] + ) { + smallest = rightChild + } + + if (smallest === index) break + this.swap(index, smallest) + index = smallest + } + } + + swap(i, j) { + const temp = this.heap[i] + + this.heap[i] = this.heap[j] + this.heap[j] = temp } } diff --git a/src/common/PriorityQueue.spec.js b/src/common/PriorityQueue.spec.js new file mode 100644 index 00000000..11f0edd8 --- /dev/null +++ b/src/common/PriorityQueue.spec.js @@ -0,0 +1,38 @@ +import { PriorityQueue } from "./PriorityQueue" + +describe("PriorityQueue", () => { + it("dequeues elements in priority order", () => { + const pq = new PriorityQueue() + pq.enqueue(["c", 3]) + pq.enqueue(["a", 1]) + pq.enqueue(["b", 2]) + + expect(pq.dequeue()).toEqual(["a", 1]) + expect(pq.dequeue()).toEqual(["b", 2]) + expect(pq.dequeue()).toEqual(["c", 3]) + }) + + it("handles empty queue", () => { + const pq = new PriorityQueue() + expect(pq.isEmpty()).toBe(true) + expect(pq.dequeue()).toBeUndefined() + }) + + it("handles single element", () => { + const pq = new PriorityQueue() + pq.enqueue(["only", 1]) + expect(pq.isEmpty()).toBe(false) + expect(pq.dequeue()).toEqual(["only", 1]) + expect(pq.isEmpty()).toBe(true) + }) + + it("handles duplicate priorities", () => { + const pq = new PriorityQueue() + pq.enqueue(["first", 1]) + pq.enqueue(["second", 1]) + + const results = [pq.dequeue(), pq.dequeue()] + expect(results).toContainEqual(["first", 1]) + expect(results).toContainEqual(["second", 1]) + }) +}) diff --git a/src/common/lindenmayer.js b/src/common/lindenmayer.js index 9911087d..71df1389 100644 --- a/src/common/lindenmayer.js +++ b/src/common/lindenmayer.js @@ -17,8 +17,8 @@ const shortestPath = (nodes) => { if (visited[edge12Key]) { const result = nearestUnvisitedNode(i + 1, nodes, visited, graph) - if (result) { - const shortestSubPath = graph.dijkstraShortestPath( + if (result != null) { + const shortestSubPath = graph.bfsShortestPath( node1Key, result.node.toString(), ) diff --git a/src/common/voronoi.js b/src/common/voronoi.js index f8acd08e..e87ea2e6 100644 --- a/src/common/voronoi.js +++ b/src/common/voronoi.js @@ -5,6 +5,7 @@ import seedrandom from "seedrandom" import { Delaunay } from "d3-delaunay" import PoissonDiskSampling from "poisson-disk-sampling" import Victor from "victor" +import KDBush from "kdbush" function linearWeight(i, seed, numPoints, options) { return 1 + (2 * i) / (numPoints * options.voronoiZoom) @@ -117,17 +118,33 @@ function wavePatternWeight(points, width, height, options) { } function densityWeight(points, width, height, options) { - return points.map((point, i) => { - const [x, y] = point + // Build spatial index O(n log n) + const index = new KDBush(points.length) + points.forEach(([x, y]) => index.add(x, y)) + index.finish() + + // Estimate initial search radius based on average spacing + const area = width * height + const avgSpacing = Math.sqrt(area / points.length) + let searchRadius = avgSpacing * 2 + + return points.map(([x, y], i) => { let nearestDistance = Infinity - points.forEach((otherPoint, j) => { + // Find nearest neighbor using spatial index O(log n) average + let neighbors = index.within(x, y, searchRadius) + while (neighbors.length < 2 && searchRadius < Math.max(width, height)) { + searchRadius *= 2 + neighbors = index.within(x, y, searchRadius) + } + + for (const j of neighbors) { if (i !== j) { - const [x2, y2] = otherPoint + const [x2, y2] = points[j] const distance = Math.sqrt((x - x2) ** 2 + (y - y2) ** 2) nearestDistance = Math.min(nearestDistance, distance) } - }) + } const weight = nearestDistance // larger distances (sparser areas) get higher weight @@ -210,7 +227,7 @@ export class VoronoiMixin { if (!visited) { if (prev) { - const path = this.graph.dijkstraShortestPath( + const path = this.graph.bfsShortestPath( prev.toString(), neighbor.toString(), ) @@ -234,6 +251,7 @@ export class VoronoiMixin { voronoiMinDistance, voronoiMaxDistance, voronoiZoom, + voronoiUniformity = 0, } = options const width = voronoiPlacement == "poisson disk sampling" @@ -270,6 +288,10 @@ export class VoronoiMixin { }) } + if (voronoiUniformity > 0) { + points = this.relaxPoints(points, width, height, voronoiUniformity) + } + return points } @@ -337,4 +359,44 @@ export class VoronoiMixin { return graph.findNode(onEdge) || closestNode } + + // Lloyd relaxation: move each point to the centroid of its Voronoi cell + relaxPoints(points, width, height, iterations) { + let relaxedPoints = points + + for (let i = 0; i < iterations; i++) { + const delaunay = Delaunay.from(relaxedPoints) + const voronoi = delaunay.voronoi([0, 0, width, height]) + + relaxedPoints = relaxedPoints.map((point, idx) => { + const cell = voronoi.cellPolygon(idx) + if (!cell || cell.length < 3) return point + + // Calculate centroid of the cell polygon + let cx = 0 + let cy = 0 + let area = 0 + + for (let j = 0; j < cell.length - 1; j++) { + const [x0, y0] = cell[j] + const [x1, y1] = cell[j + 1] + const cross = x0 * y1 - x1 * y0 + + area += cross + cx += (x0 + x1) * cross + cy += (y0 + y1) * cross + } + + area /= 2 + if (Math.abs(area) < 1e-10) return point + + cx /= 6 * area + cy /= 6 * area + + return [cx, cy] + }) + } + + return relaxedPoints + } } diff --git a/src/features/effects/Voronoi.js b/src/features/effects/Voronoi.js index 386f842e..97b29de8 100644 --- a/src/features/effects/Voronoi.js +++ b/src/features/effects/Voronoi.js @@ -23,6 +23,12 @@ export const voronoiOptions = { type: "togglebutton", choices: ["voronoi", "delaunay"], }, + voronoiUniformity: { + title: "Uniformity", + type: "slider", + min: 0, + max: 20, + }, } export default class Voronoi extends Effect { @@ -51,14 +57,16 @@ export default class Voronoi extends Effect { ...super.getInitialState(), ...{ voronoiPolygon: "voronoi", + voronoiUniformity: 0, seed: 1, }, } } getVertices(effect, layer, vertices) { - const { seed, voronoiPolygon } = effect + const { seed, voronoiPolygon, voronoiUniformity = 0 } = effect const { width, height } = dimensions(vertices) + this.rng = seedrandom(seed) noise.seed(seed) @@ -68,7 +76,12 @@ export default class Voronoi extends Effect { ), (vertex) => vertex.toString(), ) - const points = this.generatePointsFromVertices(mappedVertices) + let points = this.generatePointsFromVertices(mappedVertices) + + if (voronoiUniformity > 0) { + points = this.relaxPoints(points, width, height, voronoiUniformity) + } + this.graph = this.buildGraph(points, voronoiPolygon, width, height) this.vertices = [] diff --git a/src/features/effects/effectFactory.js b/src/features/effects/effectFactory.js index 9626ea21..811abb96 100644 --- a/src/features/effects/effectFactory.js +++ b/src/features/effects/effectFactory.js @@ -9,7 +9,7 @@ import ProgramCode from "./ProgramCode" import Track from "./Track" import Transformer from "./Transformer" import Warp from "./Warp" -// import Voronoi from "./Voronoi" +import Voronoi from "./Voronoi" export const effectFactory = { loop: Loop, @@ -21,8 +21,7 @@ export const effectFactory = { noise: Noise, track: Track, warp: Warp, - // too slow; disabling until we implement worker-based vertex computation - // voronoi: Voronoi + voronoi: Voronoi, } export const getEffect = (type, ...args) => { diff --git a/src/features/shapes/Voronoi.js b/src/features/shapes/Voronoi.js index 55b9d4ea..bfd95be2 100644 --- a/src/features/shapes/Voronoi.js +++ b/src/features/shapes/Voronoi.js @@ -77,6 +77,12 @@ export const voronoiOptions = { ) }, }, + voronoiUniformity: { + title: "Uniformity", + type: "slider", + min: 0, + max: 20, + }, seed: { title: "Random seed", min: 1, @@ -107,6 +113,7 @@ export default class Voronoi extends Shape { voronoiMinDistance: 30, voronoiMaxDistance: 50, voronoiFrequency: 5, + voronoiUniformity: 0, seed: 1, }, } diff --git a/src/features/shapes/tessellation_twist/TessellationTwist.js b/src/features/shapes/tessellation_twist/TessellationTwist.js index c497e901..208dd70b 100644 --- a/src/features/shapes/tessellation_twist/TessellationTwist.js +++ b/src/features/shapes/tessellation_twist/TessellationTwist.js @@ -200,7 +200,7 @@ export default class TessellationTwist extends Shape { if (prevKey) { if (!graph.hasEdge(key, prevKey)) { // non-eulerian move, so we'll walk the shortest valid path between them - let path = graph.dijkstraShortestPath(prevKey, key) + let path = graph.bfsShortestPath(prevKey, key) path.shift() path.forEach((node) => walkedVertices.push(node)) walkedVertices.push(vertex)