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
2 changes: 1 addition & 1 deletion PROJECT_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<VoronoiSite>,
options?: VoronoiOptions
): VoronoiCell[];

/**
* Simplex noise generator for smooth gradients without directional artifacts.
* Use for: large terrain synthesis, animated textures, volumetric noise.
Expand Down
16 changes: 16 additions & 0 deletions examples/voronoi.ts
Original file line number Diff line number Diff line change
@@ -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(', '));
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
179 changes: 179 additions & 0 deletions src/procedural/voronoi.ts
Original file line number Diff line number Diff line change
@@ -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<VoronoiSite>,
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<VoronoiSite>,
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;
}
2 changes: 2 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -50,6 +51,7 @@ describe('package entry point', () => {
| 'waveFunctionCollapse'
| 'cellularAutomataCave'
| 'poissonDiskSampling'
| 'computeVoronoiDiagram'
>();
});
});
47 changes: 47 additions & 0 deletions tests/voronoi.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});