From 111b479afe379d179fb14b4be4787f1d7f71e97a Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 17 Oct 2025 18:10:48 +0900 Subject: [PATCH] feat(spatial): add octree partitioning --- ROADMAP.md | 2 +- docs/index.d.ts | 27 +++++ examples/octree.ts | 17 +++ package.json | 2 +- src/index.ts | 8 ++ src/spatial/octree.ts | 267 ++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 9 ++ tests/index.test.ts | 1 + tests/octree.test.ts | 69 +++++++++++ 9 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 examples/octree.ts create mode 100644 src/spatial/octree.ts create mode 100644 tests/octree.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 931fd75..920ce00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ - [x] Strongly connected components (Tarjan/Kosaraju) - [x] Maximum flow (Dinic preferred; Edmonds–Karp fallback) **Spatial & collision expansion** - - [ ] Octree partitioning for 3D space + - [x] Octree partitioning for 3D space - [x] Circle collision helpers - [x] Raycasting utilities - [ ] Bounding volume hierarchy (BVH) builder diff --git a/docs/index.d.ts b/docs/index.d.ts index a86721a..8806088 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -62,6 +62,7 @@ export const examples: { }; readonly spatial: { readonly Quadtree: 'examples/sat.ts'; + readonly Octree: 'examples/octree.ts'; readonly aabbCollision: 'examples/sat.ts'; readonly aabbIntersection: 'examples/sat.ts'; readonly satCollision: 'examples/sat.ts'; @@ -779,6 +780,19 @@ export class Quadtree { queryCircle(center: Point, radius: number): Array; } +/** + * Octree for 3D spatial partitioning. + * Use for: broad-phase culling, proximity queries, volumetric indexing. + * Performance: O(log n) typical query. + * Import: spatial/octree.ts + */ +export class Octree { + constructor(bounds: Box3, capacity?: number, depth?: number, maxDepth?: number); + insert(point: Point3D, data?: T): boolean; + query(range: Box3): Array; + querySphere(center: Point3D, radius: number): Array; +} + /** * Axis-aligned bounding box helpers. * Use for: broad collisions, viewport culling, layout math. @@ -3613,6 +3627,10 @@ export interface Point { y: number; } +export interface Point3D extends Point { + z: number; +} + export interface Vector2D { x: number; y: number; @@ -3625,6 +3643,15 @@ export interface Rect { height: number; } +export interface Box3 { + x: number; + y: number; + z: number; + width: number; + height: number; + depth: number; +} + export interface Graph { [key: string]: Array<{ node: string; weight?: number }>; } diff --git a/examples/octree.ts b/examples/octree.ts new file mode 100644 index 0000000..1506c32 --- /dev/null +++ b/examples/octree.ts @@ -0,0 +1,17 @@ +import { Octree } from '../src/index.js'; + +const tree = new Octree<{ id: string }>({ + x: 0, + y: 0, + z: 0, + width: 64, + height: 64, + depth: 64, +}, 4); + +tree.insert({ x: 4, y: 8, z: 2 }, { id: 'player' }); +tree.insert({ x: 30, y: 32, z: 20 }, { id: 'npc' }); +tree.insert({ x: 16, y: 12, z: 40 }, { id: 'pickup' }); + +const nearby = tree.querySphere({ x: 6, y: 9, z: 4 }, 6); +console.log(nearby.map((point) => point.data?.id)); diff --git a/package.json b/package.json index d54ba37..73b8c0e 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ { "name": "bundle", "path": "dist/index.js", - "limit": "42 KB" + "limit": "44 KB" } ] } diff --git a/src/index.ts b/src/index.ts index 592918b..600d819 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ export const examples = { }, spatial: { Quadtree: 'examples/sat.ts', + Octree: 'examples/octree.ts', aabbCollision: 'examples/sat.ts', aabbIntersection: 'examples/sat.ts', satCollision: 'examples/sat.ts', @@ -400,6 +401,13 @@ export { generateRecursiveDivisionMaze } from './procedural/maze.js'; */ export { Quadtree } from './spatial/quadtree.js'; +/** + * 3D octree spatial partitioning structure. + * + * Example file: examples/octree.ts + */ +export { Octree } from './spatial/octree.js'; + /** * Axis-aligned bounding box collision detection helpers. * diff --git a/src/spatial/octree.ts b/src/spatial/octree.ts new file mode 100644 index 0000000..f8dc5ce --- /dev/null +++ b/src/spatial/octree.ts @@ -0,0 +1,267 @@ +import type { Box3, Point3D } from '../types.js'; + +interface StoredPoint extends Point3D { + data?: T; +} + +/** + * Octree spatial partitioning structure for 3D point queries. + * Useful for: broad-phase culling, proximity searches, collision detection. + */ +export class Octree { + private bounds: Box3; + private capacity: number; + private points: Array> = []; + private divided = false; + private depth: number; + private maxDepth: number; + private children: + | [ + Octree, + Octree, + Octree, + Octree, + Octree, + Octree, + Octree, + Octree + ] + | [] = []; + + constructor(bounds: Box3, capacity = 8, depth = 0, maxDepth = 8) { + validateBox(bounds); + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new Error('capacity must be a positive integer.'); + } + if (!Number.isInteger(maxDepth) || maxDepth < 0) { + throw new Error('maxDepth must be a non-negative integer.'); + } + + this.bounds = { ...bounds }; + this.capacity = capacity; + this.depth = depth; + this.maxDepth = maxDepth; + } + + insert(point: Point3D, data?: T): boolean { + validatePoint(point); + if (!containsPoint(this.bounds, point)) { + return false; + } + + if (this.points.length < this.capacity || this.depth >= this.maxDepth) { + this.points.push({ ...point, data }); + return true; + } + + if (!this.divided) { + this.subdivide(); + } + + for (const child of this.children) { + if (child.insert(point, data)) { + return true; + } + } + + return false; + } + + query(range: Box3): Array> { + validateBox(range); + const found: Array> = []; + this.queryRange(range, found); + return found; + } + + querySphere(center: Point3D, radius: number): Array> { + validatePoint(center); + if (typeof radius !== 'number' || Number.isNaN(radius) || radius < 0) { + throw new TypeError('radius must be a non-negative number.'); + } + + const range: Box3 = { + x: center.x - radius, + y: center.y - radius, + z: center.z - radius, + width: radius * 2, + height: radius * 2, + depth: radius * 2, + }; + const candidates = this.query(range); + const radiusSquared = radius * radius; + + return candidates.filter( + (point) => distanceSquared(point, center) <= radiusSquared + ); + } + + private subdivide(): void { + const { x, y, z, width, height, depth } = this.bounds; + const halfWidth = width / 2; + const halfHeight = height / 2; + const halfDepth = depth / 2; + const nextDepth = this.depth + 1; + + const octants: Box3[] = [ + { x, y, z, width: halfWidth, height: halfHeight, depth: halfDepth }, + { + x: x + halfWidth, + y, + z, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + { + x, + y: y + halfHeight, + z, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + { + x: x + halfWidth, + y: y + halfHeight, + z, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + { + x, + y, + z: z + halfDepth, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + { + x: x + halfWidth, + y, + z: z + halfDepth, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + { + x, + y: y + halfHeight, + z: z + halfDepth, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + { + x: x + halfWidth, + y: y + halfHeight, + z: z + halfDepth, + width: halfWidth, + height: halfHeight, + depth: halfDepth, + }, + ]; + + this.children = octants.map( + (childBounds) => + new Octree(childBounds, this.capacity, nextDepth, this.maxDepth) + ) as typeof this.children; + + this.divided = true; + const existingPoints = this.points; + this.points = []; + + for (const point of existingPoints) { + this.insert(point, point.data); + } + } + + private queryRange(range: Box3, found: Array>): void { + if (!boxesIntersect(this.bounds, range)) { + return; + } + + for (const point of this.points) { + if (containsPoint(range, point)) { + found.push(point); + } + } + + if (this.divided) { + for (const child of this.children) { + child.queryRange(range, found); + } + } + } +} + +function validatePoint(point: Point3D): void { + if ( + typeof point?.x !== 'number' || + typeof point?.y !== 'number' || + typeof point?.z !== 'number' || + Number.isNaN(point.x) || + Number.isNaN(point.y) || + Number.isNaN(point.z) + ) { + throw new TypeError('Point must contain numeric x, y, and z values.'); + } +} + +function validateBox(box: Box3): void { + if ( + typeof box?.x !== 'number' || + typeof box?.y !== 'number' || + typeof box?.z !== 'number' || + typeof box?.width !== 'number' || + typeof box?.height !== 'number' || + typeof box?.depth !== 'number' + ) { + throw new TypeError( + 'Box must contain numeric x, y, z, width, height, and depth.' + ); + } + if ( + !Number.isFinite(box.x) || + !Number.isFinite(box.y) || + !Number.isFinite(box.z) || + !Number.isFinite(box.width) || + !Number.isFinite(box.height) || + !Number.isFinite(box.depth) + ) { + throw new TypeError('Box values must be finite numbers.'); + } + if (box.width < 0 || box.height < 0 || box.depth < 0) { + throw new Error('Box width, height, and depth must be non-negative.'); + } +} + +function containsPoint(box: Box3, point: Point3D): boolean { + return ( + point.x >= box.x && + point.x < box.x + box.width && + point.y >= box.y && + point.y < box.y + box.height && + point.z >= box.z && + point.z < box.z + box.depth + ); +} + +function boxesIntersect(a: Box3, b: Box3): boolean { + return !( + a.x + a.width < b.x || + b.x + b.width < a.x || + a.y + a.height < b.y || + b.y + b.height < a.y || + a.z + a.depth < b.z || + b.z + b.depth < a.z + ); +} + +function distanceSquared(a: Point3D, b: Point3D): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return dx * dx + dy * dy + dz * dz; +} diff --git a/src/types.ts b/src/types.ts index cf1496c..219cd5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,15 @@ export interface Rect { height: number; } +export interface Box3 { + x: number; + y: number; + z: number; + width: number; + height: number; + depth: number; +} + export interface Circle { x: number; y: number; diff --git a/tests/index.test.ts b/tests/index.test.ts index 7513636..71a009c 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -47,6 +47,7 @@ describe('package entry point', () => { expect(examples.gameplay.computeFieldOfView).toBe('examples/shadowcasting.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); + expect(examples.spatial.Octree).toBe('examples/octree.ts'); }); it('provides strong typing for example categories and names', () => { diff --git a/tests/octree.test.ts b/tests/octree.test.ts new file mode 100644 index 0000000..9be9db0 --- /dev/null +++ b/tests/octree.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { Octree } from '../src/spatial/octree.js'; + +describe('Octree', () => { + it('stores and queries points within a bounding box', () => { + const octree = new Octree<{ id: string }>( + { x: 0, y: 0, z: 0, width: 32, height: 32, depth: 32 }, + 2 + ); + + octree.insert({ x: 4, y: 4, z: 4 }, { id: 'a' }); + octree.insert({ x: 8, y: 8, z: 8 }, { id: 'b' }); + octree.insert({ x: 20, y: 20, z: 20 }, { id: 'c' }); + + const results = octree.query({ x: 0, y: 0, z: 0, width: 12, height: 12, depth: 12 }); + expect(results.map((point) => point.data?.id).sort()).toEqual(['a', 'b']); + + expect(octree.insert({ x: 40, y: 40, z: 40 }, { id: 'outside' })).toBe(false); + }); + + it('subdivides when at capacity and maintains stored payloads', () => { + const octree = new Octree<{ id: number }>( + { x: 0, y: 0, z: 0, width: 16, height: 16, depth: 16 }, + 1, + 0, + 4 + ); + + octree.insert({ x: 1, y: 1, z: 1 }, { id: 1 }); + octree.insert({ x: 9, y: 1, z: 1 }, { id: 2 }); + octree.insert({ x: 1, y: 9, z: 9 }, { id: 3 }); + octree.insert({ x: 9, y: 9, z: 9 }, { id: 4 }); + + const results = octree.query({ x: 0, y: 0, z: 0, width: 16, height: 16, depth: 16 }); + expect(results).toHaveLength(4); + expect(results.map((point) => point.data?.id).sort()).toEqual([1, 2, 3, 4]); + }); + + it('supports spherical queries for proximity checks', () => { + const octree = new Octree( + { x: 0, y: 0, z: 0, width: 50, height: 50, depth: 50 }, + 4 + ); + + octree.insert({ x: 5, y: 5, z: 5 }); + octree.insert({ x: 7, y: 4, z: 4 }); + octree.insert({ x: 20, y: 20, z: 20 }); + + const results = octree.querySphere({ x: 4, y: 4, z: 4 }, 4); + expect(results.length).toBe(2); + }); + + it('validates inputs', () => { + expect( + () => + new Octree({ x: 0, y: 0, z: 0, width: -1, height: 1, depth: 1 }) + ).toThrow('Box width, height, and depth must be non-negative.'); + + const octree = new Octree({ x: 0, y: 0, z: 0, width: 10, height: 10, depth: 10 }); + expect(() => octree.insert({ x: 1, y: 1, z: Number.NaN })).toThrow(TypeError); + expect(() => + octree.query({ x: 0, y: 0, z: 0, width: Number.NaN, height: 1, depth: 1 }) + ).toThrow(TypeError); + expect(() => octree.querySphere({ x: 0, y: 0, z: 0 }, -1)).toThrow( + 'radius must be a non-negative number.' + ); + }); +});