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 ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -779,6 +780,19 @@ export class Quadtree<T = unknown> {
queryCircle(center: Point, radius: number): Array<Point & { data?: T }>;
}

/**
* 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<T = unknown> {
constructor(bounds: Box3, capacity?: number, depth?: number, maxDepth?: number);
insert(point: Point3D, data?: T): boolean;
query(range: Box3): Array<Point3D & { data?: T }>;
querySphere(center: Point3D, radius: number): Array<Point3D & { data?: T }>;
}

/**
* Axis-aligned bounding box helpers.
* Use for: broad collisions, viewport culling, layout math.
Expand Down Expand Up @@ -3613,6 +3627,10 @@ export interface Point {
y: number;
}

export interface Point3D extends Point {
z: number;
}

export interface Vector2D {
x: number;
y: number;
Expand All @@ -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 }>;
}
Expand Down
17 changes: 17 additions & 0 deletions examples/octree.ts
Original file line number Diff line number Diff line change
@@ -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));
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
{
"name": "bundle",
"path": "dist/index.js",
"limit": "42 KB"
"limit": "44 KB"
}
]
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
*
Expand Down
267 changes: 267 additions & 0 deletions src/spatial/octree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import type { Box3, Point3D } from '../types.js';

interface StoredPoint<T> extends Point3D {
data?: T;
}

/**
* Octree spatial partitioning structure for 3D point queries.
* Useful for: broad-phase culling, proximity searches, collision detection.
*/
export class Octree<T = unknown> {
private bounds: Box3;
private capacity: number;
private points: Array<StoredPoint<T>> = [];
private divided = false;
private depth: number;
private maxDepth: number;
private children:
| [
Octree<T>,
Octree<T>,
Octree<T>,
Octree<T>,
Octree<T>,
Octree<T>,
Octree<T>,
Octree<T>
]
| [] = [];

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<StoredPoint<T>> {
validateBox(range);
const found: Array<StoredPoint<T>> = [];
this.queryRange(range, found);
return found;
}

querySphere(center: Point3D, radius: number): Array<StoredPoint<T>> {
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<StoredPoint<T>>): 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;
}
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading