UNPKG

@spearwolf/twopoint5d

Version:

Create 2.5D realtime graphics and pixelart with WebGL and three.js

446 lines 22 kB
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