diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 0683352..fd65060 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -36,7 +36,7 @@ npm run build | Need | Algorithm(s) | Module | Example | | ---- | ------------ | ------ | ------- | | Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | -| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts` | +| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | diff --git a/README.md b/README.md index 38d5615..a19cd82 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ CDN usage: | Goal | Algorithms | Import From | Example | | ---- | ---------- | ----------- | ------- | | Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | -| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts` | +| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts` | @@ -52,7 +52,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index 666ff2e..47e9de7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,7 @@ - [x] Wave Function Collapse tile solver with options + example - [x] Cellular automata cave/organic generator utilities - [x] Poisson disk sampling for even point distribution - - [ ] Voronoi diagram helpers for biome/territory generation + - [x] Voronoi diagram helpers for biome/territory generation - [ ] Diamond-square terrain height map generator - [ ] L-system generator for foliage and organic structures - [ ] Dungeon generation suite (BSP subdivision, rooms & corridors variants) diff --git a/docs/index.d.ts b/docs/index.d.ts index 8ce9cf0..5bc7da5 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -48,6 +48,7 @@ export const examples: { readonly waveFunctionCollapse: 'examples/waveFunctionCollapse.ts'; readonly cellularAutomataCave: 'examples/cellularAutomata.ts'; readonly poissonDiskSampling: 'examples/poissonDisk.ts'; + readonly computeVoronoiDiagram: 'examples/voronoi.ts'; }; readonly spatial: { readonly Quadtree: 'examples/sat.ts'; @@ -385,6 +386,58 @@ export interface PoissonDiskOptions { */ export function poissonDiskSampling(options: PoissonDiskOptions): Point[]; +/** + * Voronoi site definition. + * Use for: labelling regions, associating metadata to cells. + * Import: procedural/voronoi.ts + */ +export interface VoronoiSite extends Point { + id?: string; +} + +/** + * Bounding box constraining Voronoi cell clipping. + * Use for: enforcing finite diagram extents, map limits, UI layout boxes. + * Import: procedural/voronoi.ts + */ +export interface BoundingBox { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +/** + * Voronoi configuration options. + * Use for: padding the inferred bounds, providing explicit clipping boxes. + * Import: procedural/voronoi.ts + */ +export interface VoronoiOptions { + boundingBox?: BoundingBox; + padding?: number; +} + +/** + * Resulting Voronoi cell containing the generating site and polygon vertices. + * Use for: rendering territories, computing adjacency, spawning procedural content. + * Import: procedural/voronoi.ts + */ +export interface VoronoiCell { + site: VoronoiSite; + polygon: Point[]; +} + +/** + * Computes a Voronoi diagram via half-plane clipping. + * Use for: territory partitioning, biome assignment, gameplay regions. + * Performance: O(n^2 m) where m is retained polygon vertex count. + * Import: procedural/voronoi.ts + */ +export function computeVoronoiDiagram( + sites: ReadonlyArray, + options?: VoronoiOptions +): VoronoiCell[]; + /** * Simplex noise generator for smooth gradients without directional artifacts. * Use for: large terrain synthesis, animated textures, volumetric noise. diff --git a/examples/voronoi.ts b/examples/voronoi.ts new file mode 100644 index 0000000..a70c034 --- /dev/null +++ b/examples/voronoi.ts @@ -0,0 +1,16 @@ +import { computeVoronoiDiagram } from '../src/index.js'; + +const sites = [ + { id: 'red', x: 2, y: 2 }, + { id: 'blue', x: 8, y: 3 }, + { id: 'green', x: 5, y: 7 }, +]; + +const diagram = computeVoronoiDiagram(sites, { + boundingBox: { minX: 0, maxX: 10, minY: 0, maxY: 10 }, +}); + +for (const cell of diagram) { + console.log(`Site ${cell.site.id}:`); + console.log(cell.polygon.map(({ x, y }) => `(${x.toFixed(2)}, ${y.toFixed(2)})`).join(', ')); +} diff --git a/src/index.ts b/src/index.ts index 890907e..2c2c2d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export const examples = { waveFunctionCollapse: 'examples/waveFunctionCollapse.ts', cellularAutomataCave: 'examples/cellularAutomata.ts', poissonDiskSampling: 'examples/poissonDisk.ts', + computeVoronoiDiagram: 'examples/voronoi.ts', }, spatial: { Quadtree: 'examples/sat.ts', @@ -242,6 +243,13 @@ export { cellularAutomataCave } from './procedural/cellularAutomata.js'; */ export { poissonDiskSampling } from './procedural/poissonDisk.js'; +/** + * Voronoi diagram helper returning polygonal cells for each site. + * + * Example file: examples/voronoi.ts + */ +export { computeVoronoiDiagram } from './procedural/voronoi.js'; + // ============================================================================ // 🎯 SPATIAL & COLLISION // ============================================================================ diff --git a/src/procedural/voronoi.ts b/src/procedural/voronoi.ts new file mode 100644 index 0000000..1639596 --- /dev/null +++ b/src/procedural/voronoi.ts @@ -0,0 +1,179 @@ +import type { Point } from '../types.js'; + +export interface VoronoiSite extends Point { + id?: string; +} + +export interface BoundingBox { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +export interface VoronoiOptions { + boundingBox?: BoundingBox; + padding?: number; +} + +export interface VoronoiCell { + site: VoronoiSite; + polygon: Point[]; +} + +const EPSILON = 1e-9; + +/** + * Computes a 2D Voronoi diagram for the supplied sites using half-plane intersection. + * Useful for: territory partitioning, procedural biome assignment, spatial clustering. + * Performance: O(n^2 m) where m is number of polygon vertices retained during clipping. + */ +export function computeVoronoiDiagram( + sites: ReadonlyArray, + options: VoronoiOptions = {} +): VoronoiCell[] { + if (sites.length === 0) { + return []; + } + + const boundingBox = resolveBoundingBox(sites, options.boundingBox, options.padding ?? 0.1); + const initialPolygon: Point[] = [ + { x: boundingBox.minX, y: boundingBox.minY }, + { x: boundingBox.maxX, y: boundingBox.minY }, + { x: boundingBox.maxX, y: boundingBox.maxY }, + { x: boundingBox.minX, y: boundingBox.maxY }, + ]; + + const cells: VoronoiCell[] = []; + for (const site of sites) { + let polygon = initialPolygon.slice(); + for (const other of sites) { + if (other === site) { + continue; + } + polygon = clipPolygonWithBisector(polygon, site, other); + if (polygon.length === 0) { + break; + } + } + cells.push({ site, polygon }); + } + + return cells; +} + +function resolveBoundingBox( + sites: ReadonlyArray, + explicit?: BoundingBox, + paddingRatio: number = 0 +): BoundingBox { + if (explicit) { + return explicit; + } + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const { x, y } of sites) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + const width = maxX - minX; + const height = maxY - minY; + const paddingX = width * paddingRatio || 1; + const paddingY = height * paddingRatio || 1; + return { + minX: minX - paddingX, + maxX: maxX + paddingX, + minY: minY - paddingY, + maxY: maxY + paddingY, + }; +} + +function clipPolygonWithBisector(polygon: Point[], site: Point, other: Point): Point[] { + if (polygon.length === 0) { + return polygon; + } + + const a = other.x - site.x; + const b = other.y - site.y; + const c = (other.x * other.x + other.y * other.y - site.x * site.x - site.y * site.y) / 2; + + const output: Point[] = []; + const lastVertex = polygon[polygon.length - 1]; + if (!lastVertex) { + return []; + } + let prev = lastVertex; + let prevInside = isInside(prev, a, b, c); + + for (const current of polygon) { + const currentInside = isInside(current, a, b, c); + if (currentInside) { + if (!prevInside) { + const intersection = computeIntersection(prev, current, a, b, c); + if (intersection) { + output.push(intersection); + } + } + output.push(current); + } else if (prevInside) { + const intersection = computeIntersection(prev, current, a, b, c); + if (intersection) { + output.push(intersection); + } + } + prev = current; + prevInside = currentInside; + } + + return deduplicateVertices(output); +} + +function isInside(point: Point, a: number, b: number, c: number): boolean { + return a * point.x + b * point.y <= c + EPSILON; +} + +function computeIntersection(prev: Point, current: Point, a: number, b: number, c: number): Point | null { + const dx = current.x - prev.x; + const dy = current.y - prev.y; + const denominator = a * dx + b * dy; + if (Math.abs(denominator) < EPSILON) { + return null; + } + const t = (c - a * prev.x - b * prev.y) / denominator; + if (t < -EPSILON || t > 1 + EPSILON) { + return null; + } + return { + x: prev.x + t * dx, + y: prev.y + t * dy, + }; +} + +function deduplicateVertices(vertices: Point[]): Point[] { + if (vertices.length <= 1) { + return vertices; + } + const result: Point[] = []; + for (const vertex of vertices) { + const last = result[result.length - 1]; + if (!last || !pointsEqual(last, vertex)) { + result.push(vertex); + } + } + if (result.length > 1) { + const first = result[0]; + const last = result[result.length - 1]; + if (first && last && pointsEqual(first, last)) { + result.pop(); + } + } + return result; +} + +function pointsEqual(a: Point, b: Point): boolean { + return Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index e6e8118..70cc6e4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -10,6 +10,7 @@ describe('package entry point', () => { expect(examples.procedural.SimplexNoise).toBe('examples/simplex.ts'); expect(examples.procedural.cellularAutomataCave).toBe('examples/cellularAutomata.ts'); expect(examples.procedural.poissonDiskSampling).toBe('examples/poissonDisk.ts'); + expect(examples.procedural.computeVoronoiDiagram).toBe('examples/voronoi.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -50,6 +51,7 @@ describe('package entry point', () => { | 'waveFunctionCollapse' | 'cellularAutomataCave' | 'poissonDiskSampling' + | 'computeVoronoiDiagram' >(); }); }); diff --git a/tests/voronoi.test.ts b/tests/voronoi.test.ts new file mode 100644 index 0000000..5e30ff0 --- /dev/null +++ b/tests/voronoi.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { computeVoronoiDiagram } from '../src/index.js'; + +describe('computeVoronoiDiagram', () => { + it('splits space between two sites along the perpendicular bisector', () => { + const result = computeVoronoiDiagram( + [ + { id: 'left', x: 0, y: 0 }, + { id: 'right', x: 10, y: 0 }, + ], + { + boundingBox: { minX: -10, maxX: 10, minY: -5, maxY: 5 }, + } + ); + + const leftCell = result.find((cell) => cell.site.id === 'left'); + const rightCell = result.find((cell) => cell.site.id === 'right'); + + expect(leftCell).toBeDefined(); + expect(rightCell).toBeDefined(); + + // Boundary should lie at x = 5 (midpoint between sites) + expect(Math.max(...(leftCell?.polygon.map((point) => point.x) ?? []))).toBeLessThanOrEqual(5.001); + expect(Math.min(...(rightCell?.polygon.map((point) => point.x) ?? []))).toBeGreaterThanOrEqual(4.999); + }); + + it('produces bounded polygons for three non-collinear points', () => { + const cells = computeVoronoiDiagram( + [ + { id: 'a', x: 0, y: 0 }, + { id: 'b', x: 6, y: 0 }, + { id: 'c', x: 3, y: 5 }, + ], + { + boundingBox: { minX: -2, maxX: 8, minY: -2, maxY: 7 }, + } + ); + + expect(cells).toHaveLength(3); + for (const cell of cells) { + expect(cell.polygon.length).toBeGreaterThanOrEqual(3); + expect(cell.polygon.every((point) => point.x >= -2 && point.x <= 8)).toBe(true); + expect(cell.polygon.every((point) => point.y >= -2 && point.y <= 7)).toBe(true); + } + }); +});