@spearwolf/twopoint5d
Version:
Create 2.5D realtime graphics and pixelart with WebGL and three.js
446 lines • 22 kB
JavaScript
import { describe, expect, it } from 'vitest';
import { AABB2 } from '../AABB2.js';
import { ChunkQuadTreeNode } from './ChunkQuadTreeNode.js';
import { StringDataChunk2D } from './StringDataChunk2D.js';
const sortedNames = (chunks) => chunks.map((c) => c.toString()).sort();
const grid4x4 = () => ({
A: new StringDataChunk2D({ x: -10, y: -10, width: 5, height: 5, data: 'A' }),
B: new StringDataChunk2D({ x: -5, y: -10, width: 5, height: 5, data: 'B' }),
C: new StringDataChunk2D({ x: 0, y: -10, width: 5, height: 5, data: 'C' }),
D: new StringDataChunk2D({ x: 5, y: -10, width: 5, height: 5, data: 'D' }),
E: new StringDataChunk2D({ x: -10, y: -5, width: 5, height: 5, data: 'E' }),
F: new StringDataChunk2D({ x: -5, y: -5, width: 5, height: 5, data: 'F' }),
G: new StringDataChunk2D({ x: 0, y: -5, width: 5, height: 5, data: 'G' }),
H: new StringDataChunk2D({ x: 5, y: -5, width: 5, height: 5, data: 'H' }),
I: new StringDataChunk2D({ x: -10, y: 0, width: 5, height: 5, data: 'I' }),
J: new StringDataChunk2D({ x: -5, y: 0, width: 5, height: 5, data: 'J' }),
K: new StringDataChunk2D({ x: 0, y: 0, width: 5, height: 5, data: 'K' }),
L: new StringDataChunk2D({ x: 5, y: 0, width: 5, height: 5, data: 'L' }),
M: new StringDataChunk2D({ x: -10, y: 5, width: 5, height: 5, data: 'M' }),
N: new StringDataChunk2D({ x: -5, y: 5, width: 5, height: 5, data: 'N' }),
O: new StringDataChunk2D({ x: 0, y: 5, width: 5, height: 5, data: 'O' }),
P: new StringDataChunk2D({ x: 5, y: 5, width: 5, height: 5, data: 'P' }),
});
describe('ChunkQuadTreeNode (extended)', () => {
describe('constructor', () => {
it('without args creates an empty leaf', () => {
const n = new ChunkQuadTreeNode();
expect(n.isLeaf).toBe(true);
expect(n.chunks).toEqual([]);
expect(n.originX).toBeNull();
expect(n.originY).toBeNull();
expect(n.nodes.northEast).toBeNull();
expect(n.nodes.northWest).toBeNull();
expect(n.nodes.southEast).toBeNull();
expect(n.nodes.southWest).toBeNull();
});
it('accepts a single chunk and wraps it', () => {
const c = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'X' });
const n = new ChunkQuadTreeNode(c);
expect(n.chunks).toEqual([c]);
expect(n.isLeaf).toBe(true);
});
it('accepts an array of chunks', () => {
const a = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'A' });
const b = new StringDataChunk2D({ x: 2, y: 2, width: 1, height: 1, data: 'B' });
const n = new ChunkQuadTreeNode([a, b]);
expect(n.chunks).toEqual([a, b]);
});
it('static factor defaults are PI', () => {
expect(ChunkQuadTreeNode.IntersectDistanceFactor).toBe(Math.PI);
expect(ChunkQuadTreeNode.BeforeAfterDeltaFactor).toBe(Math.PI);
});
});
describe('canSubdivide()', () => {
it('false on empty leaf', () => {
expect(new ChunkQuadTreeNode().canSubdivide()).toBe(false);
});
it('false on single-chunk leaf', () => {
const c = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'X' });
expect(new ChunkQuadTreeNode(c).canSubdivide()).toBe(false);
});
it('true on multi-chunk leaf', () => {
const a = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'A' });
const b = new StringDataChunk2D({ x: 2, y: 2, width: 1, height: 1, data: 'B' });
expect(new ChunkQuadTreeNode([a, b]).canSubdivide()).toBe(true);
});
it('false once node is no longer a leaf', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
expect(n.isLeaf).toBe(false);
expect(n.canSubdivide()).toBe(false);
});
});
describe('subdivide()', () => {
it('is a no-op on an empty node', () => {
const n = new ChunkQuadTreeNode();
n.subdivide();
expect(n.isLeaf).toBe(true);
expect(n.originX).toBeNull();
expect(n.originY).toBeNull();
});
it('is a no-op on a leaf with chunks.length <= maxChunkNodes', () => {
const a = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'A' });
const b = new StringDataChunk2D({ x: 2, y: 2, width: 1, height: 1, data: 'B' });
const n = new ChunkQuadTreeNode([a, b]);
n.subdivide(2);
expect(n.isLeaf).toBe(true);
});
it('produces non-null child nodes for grid-aligned chunks', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
expect(n.isLeaf).toBe(false);
expect(n.nodes.northEast).not.toBeNull();
expect(n.nodes.northWest).not.toBeNull();
expect(n.nodes.southEast).not.toBeNull();
expect(n.nodes.southWest).not.toBeNull();
});
it('partitions grid-aligned chunks correctly across quadrants', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const collect = (root) => {
const out = [];
const walk = (node) => {
if (!node)
return;
for (const c of node.chunks)
out.push(c.toString());
walk(node.nodes.northEast);
walk(node.nodes.northWest);
walk(node.nodes.southEast);
walk(node.nodes.southWest);
};
walk(root);
return out.sort();
};
expect(collect(n)).toEqual(Object.keys(chunks).sort());
});
it('does nothing when no axis is splittable (all collinear / overlapping)', () => {
const a = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'A' });
const b = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'B' });
const c = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'C' });
const d = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'D' });
const n = new ChunkQuadTreeNode([a, b, c, d]);
n.subdivide();
expect(n.isLeaf).toBe(true);
expect(n.chunks.length).toBe(4);
});
it('respects custom maxChunkNodes', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide(8);
expect(n.isLeaf).toBe(false);
});
it('idempotent (calling twice yields same shape)', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const ox = n.originX;
const oy = n.originY;
n.subdivide();
expect(n.originX).toBe(ox);
expect(n.originY).toBe(oy);
});
it('picks an origin that cleanly separates two columns', () => {
const chunks = [
new StringDataChunk2D({ x: 0, y: 0, width: 5, height: 5, data: 'A' }),
new StringDataChunk2D({ x: 0, y: 6, width: 5, height: 5, data: 'B' }),
new StringDataChunk2D({ x: 8, y: 0, width: 5, height: 5, data: 'C' }),
new StringDataChunk2D({ x: 8, y: 6, width: 5, height: 5, data: 'D' }),
];
const n = new ChunkQuadTreeNode(chunks);
n.subdivide(1);
expect(n.originX).not.toBeNull();
expect(n.originX).toBeGreaterThanOrEqual(5);
expect(n.originX).toBeLessThanOrEqual(8);
expect(n.chunks.length).toBe(0);
});
it('partition is exhaustive — every original chunk is reachable from the root', () => {
const chunks = [];
for (let i = 0; i < 30; i++) {
chunks.push(new StringDataChunk2D({
x: ((i * 17) % 100) - 50,
y: ((i * 31) % 100) - 50,
width: 4 + (i % 3),
height: 4 + (i % 5),
data: `c${i}`,
}));
}
const n = new ChunkQuadTreeNode(chunks);
n.subdivide(1);
const seen = [];
const walk = (node) => {
if (!node)
return;
for (const c of node.chunks)
seen.push(c.toString());
walk(node.nodes.northEast);
walk(node.nodes.northWest);
walk(node.nodes.southEast);
walk(node.nodes.southWest);
};
walk(n);
expect(seen.sort()).toEqual(chunks.map((c) => c.toString()).sort());
});
});
describe('appendChunk()', () => {
it('appends to chunks while leaf', () => {
const n = new ChunkQuadTreeNode();
const a = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'A' });
n.appendChunk(a);
expect(n.chunks).toEqual([a]);
expect(n.isLeaf).toBe(true);
});
it('routes purely-east chunks into the east subtrees', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const newChunk = new StringDataChunk2D({ x: 100, y: -50, width: 5, height: 5, data: 'NEW' });
n.appendChunk(newChunk);
const eastChunks = [];
const walk = (node) => {
if (!node)
return;
for (const c of node.chunks)
eastChunks.push(c.toString());
walk(node.nodes.northEast);
walk(node.nodes.southEast);
};
walk(n.nodes.northEast);
walk(n.nodes.southEast);
expect(eastChunks).toContain('NEW');
});
it('keeps cross-axis chunks at the current node', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const straddler = new StringDataChunk2D({ x: -2, y: -2, width: 5, height: 5, data: 'XX' });
n.appendChunk(straddler);
expect(n.chunks.map((c) => c.toString())).toContain('XX');
});
it('lazily creates a child node when the target quadrant is empty', () => {
const a = new StringDataChunk2D({ x: -10, y: -10, width: 5, height: 5, data: 'A' });
const b = new StringDataChunk2D({ x: 10, y: 10, width: 5, height: 5, data: 'B' });
const c = new StringDataChunk2D({ x: -10, y: 10, width: 5, height: 5, data: 'C' });
const n = new ChunkQuadTreeNode([a, b, c]);
n.subdivide();
expect(n.isLeaf).toBe(false);
const ne = new StringDataChunk2D({ x: 10, y: -10, width: 5, height: 5, data: 'NE' });
n.appendChunk(ne);
expect(n.nodes.northEast).not.toBeNull();
expect(n.nodes.northEast.chunks.map((c) => c.toString())).toContain('NE');
});
});
describe('findChunks()', () => {
it('returns [] on empty leaf', () => {
const n = new ChunkQuadTreeNode();
expect(n.findChunks(new AABB2(0, 0, 10, 10))).toEqual([]);
});
it('returns intersecting chunks from a non-subdivided leaf', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
const hit = sortedNames(n.findChunks(new AABB2(2, 4, 6, 4)));
expect(hit).toEqual(['K', 'L', 'O', 'P'].sort());
});
it('returns chunks across multiple quadrants when AABB straddles origin', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const hit = sortedNames(n.findChunks(new AABB2(-3, -3, 6, 6)));
expect(hit).toEqual(['F', 'G', 'J', 'K'].sort());
});
it('returns axis-spanning chunks stored at non-leaf root', () => {
const chunks = {
A: new StringDataChunk2D({ x: -10, y: -10, width: 5, height: 5, data: 'A' }),
B: new StringDataChunk2D({ x: 5, y: -10, width: 5, height: 5, data: 'B' }),
C: new StringDataChunk2D({ x: -10, y: 5, width: 5, height: 5, data: 'C' }),
D: new StringDataChunk2D({ x: 5, y: 5, width: 5, height: 5, data: 'D' }),
S: new StringDataChunk2D({ x: -3, y: -3, width: 6, height: 6, data: 'S' }),
};
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide(1);
const hit = sortedNames(n.findChunks(new AABB2(-1, -1, 2, 2)));
expect(hit).toContain('S');
});
it('returns [] for AABB completely outside any data', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
expect(n.findChunks(new AABB2(1000, 1000, 5, 5))).toEqual([]);
});
it('appends into a caller-supplied output array (no allocation in hot path)', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const out = [];
const ret = n.findChunks(new AABB2(2, 4, 6, 4), out);
expect(ret).toBe(out);
expect(sortedNames(out)).toEqual(['K', 'L', 'O', 'P'].sort());
});
it('preserves pre-existing entries in the output array (caller controls reset)', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const sentinel = new StringDataChunk2D({ x: 999, y: 999, width: 1, height: 1, data: 'Z' });
const out = [sentinel];
n.findChunks(new AABB2(2, 4, 6, 4), out);
expect(sortedNames(out)).toEqual(['K', 'L', 'O', 'P', 'Z'].sort());
});
});
describe('isNorthWest / NE / SE / SW', () => {
const build = () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
return n;
};
it('returns falsy when the corresponding child node is missing', () => {
const a = new StringDataChunk2D({ x: -10, y: -10, width: 5, height: 5, data: 'A' });
const b = new StringDataChunk2D({ x: 10, y: 10, width: 5, height: 5, data: 'B' });
const n = new ChunkQuadTreeNode([a, b]);
n.subdivide(1);
const aabb = new AABB2(0, 0, 1, 1);
const flags = [
n.isNorthEast(aabb) || false,
n.isNorthWest(aabb) || false,
n.isSouthEast(aabb) || false,
n.isSouthWest(aabb) || false,
];
expect(flags.some((f) => f === false)).toBe(true);
});
it('returns true for an AABB clearly inside the quadrant', () => {
const n = build();
expect(!!n.isNorthWest(new AABB2(-9, -9, 1, 1))).toBe(true);
expect(!!n.isNorthEast(new AABB2(8, -9, 1, 1))).toBe(true);
expect(!!n.isSouthWest(new AABB2(-9, 8, 1, 1))).toBe(true);
expect(!!n.isSouthEast(new AABB2(8, 8, 1, 1))).toBe(true);
});
});
describe('findChunksAt()', () => {
it('returns matching chunks on a non-subdivided leaf', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
expect(sortedNames(n.findChunksAt(-7, -7))).toEqual(['A']);
});
it('returns matching chunks on a subdivided tree', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
expect(sortedNames(n.findChunksAt(-7, -7))).toEqual(['A']);
expect(sortedNames(n.findChunksAt(2, 2))).toEqual(['K']);
expect(sortedNames(n.findChunksAt(-3, 6))).toEqual(['N']);
expect(sortedNames(n.findChunksAt(8, -2))).toEqual(['H']);
});
it('returns [] when the point hits no chunk', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
expect(n.findChunksAt(1000, 1000)).toEqual([]);
});
it('descends through a missing-quadrant slot without throwing', () => {
const a = new StringDataChunk2D({ x: -10, y: -10, width: 5, height: 5, data: 'A' });
const b = new StringDataChunk2D({ x: -10, y: 5, width: 5, height: 5, data: 'B' });
const c = new StringDataChunk2D({ x: 5, y: 5, width: 5, height: 5, data: 'C' });
const n = new ChunkQuadTreeNode([a, b, c]);
n.subdivide(1);
const missing = ['northEast', 'northWest', 'southEast', 'southWest'].find((q) => n.nodes[q] == null);
if (missing != null) {
const pt = missing === 'northEast' ? [5, -5]
: missing === 'northWest' ? [-5, -5]
: missing === 'southEast' ? [5, 5]
: [-5, 5];
expect(() => n.findChunksAt(pt[0], pt[1])).not.toThrow();
expect(n.findChunksAt(pt[0], pt[1])).toEqual([]);
}
});
it('returns straddling chunks stored at non-leaf nodes', () => {
const A = new StringDataChunk2D({ x: -10, y: -10, width: 5, height: 5, data: 'A' });
const B = new StringDataChunk2D({ x: 5, y: -10, width: 5, height: 5, data: 'B' });
const C = new StringDataChunk2D({ x: -10, y: 5, width: 5, height: 5, data: 'C' });
const D = new StringDataChunk2D({ x: 5, y: 5, width: 5, height: 5, data: 'D' });
const S = new StringDataChunk2D({ x: -3, y: -3, width: 6, height: 6, data: 'S' });
const n = new ChunkQuadTreeNode([A, B, C, D, S]);
n.subdivide(1);
expect(sortedNames(n.findChunksAt(0, 0))).toContain('S');
});
});
describe('clear()', () => {
it('resets a non-leaf tree to a fresh empty leaf', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
expect(n.isLeaf).toBe(false);
n.clear();
expect(n.isLeaf).toBe(true);
expect(n.chunks).toEqual([]);
expect(n.originX).toBeNull();
expect(n.originY).toBeNull();
expect(n.nodes.northEast).toBeNull();
expect(n.nodes.northWest).toBeNull();
expect(n.nodes.southEast).toBeNull();
expect(n.nodes.southWest).toBeNull();
});
it('is a no-op on an already-empty leaf', () => {
const n = new ChunkQuadTreeNode();
n.clear();
expect(n.isLeaf).toBe(true);
expect(n.chunks).toEqual([]);
});
it('leaves the cleared node reusable (re-populate via appendChunk)', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
n.clear();
const fresh = new StringDataChunk2D({ x: 0, y: 0, width: 1, height: 1, data: 'NEW' });
n.appendChunk(fresh);
expect(n.chunks).toEqual([fresh]);
expect(n.isLeaf).toBe(true);
});
it('drops all child references so the subtree is GC-eligible', () => {
const chunks = grid4x4();
const n = new ChunkQuadTreeNode(Object.values(chunks));
n.subdivide();
const formerNW = n.nodes.northWest;
expect(formerNW).not.toBeNull();
n.clear();
expect(n.nodes.northWest).toBeNull();
});
});
describe('stress / performance smoke', () => {
it('subdivides 1000 random chunks within reasonable time', () => {
const chunks = [];
for (let i = 0; i < 1000; i++) {
const x = ((i * 9301 + 49297) % 233280) / 233280 * 1000 - 500;
const y = ((i * 49297 + 9301) % 233280) / 233280 * 1000 - 500;
chunks.push(new StringDataChunk2D({ x, y, width: 10, height: 10, data: `c${i}` }));
}
const n = new ChunkQuadTreeNode(chunks);
const t0 = performance.now();
n.subdivide(8);
const dt = performance.now() - t0;
expect(dt).toBeLessThan(1000);
});
it('findChunks on subdivided tree returns only intersecting chunks', () => {
const chunks = [];
for (let gx = 0; gx < 20; gx++) {
for (let gy = 0; gy < 20; gy++) {
chunks.push(new StringDataChunk2D({
x: gx * 10 - 100,
y: gy * 10 - 100,
width: 10,
height: 10,
data: `${gx},${gy}`,
}));
}
}
const n = new ChunkQuadTreeNode(chunks);
n.subdivide(4);
const hits = n.findChunks(new AABB2(-5, -5, 10, 10));
expect(sortedNames(hits)).toEqual(['10,10', '10,9', '9,10', '9,9'].sort());
});
});
});
//# sourceMappingURL=ChunkQuadTreeNode.extended.spec.js.map