@flighthq/spatial 0.3.1-next.975.01aea94 → 0.4.0-edge.1908.751021c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/bvh3D.d.ts +3 -0
  2. package/dist/bvh3D.d.ts.map +1 -0
  3. package/dist/bvh3D.js +575 -0
  4. package/dist/bvh3D.js.map +1 -0
  5. package/dist/contract.d.ts +6 -1
  6. package/dist/contract.d.ts.map +1 -1
  7. package/dist/contract.js +6 -1
  8. package/dist/contract.js.map +1 -1
  9. package/dist/explainSpatialIndexing2D.d.ts +3 -0
  10. package/dist/explainSpatialIndexing2D.d.ts.map +1 -0
  11. package/dist/{explainSpatialIndexing.js → explainSpatialIndexing2D.js} +2 -2
  12. package/dist/explainSpatialIndexing2D.js.map +1 -0
  13. package/dist/explainSpatialIndexing3D.d.ts +3 -0
  14. package/dist/explainSpatialIndexing3D.d.ts.map +1 -0
  15. package/dist/explainSpatialIndexing3D.js +15 -0
  16. package/dist/explainSpatialIndexing3D.js.map +1 -0
  17. package/dist/formatSpatialIndexingNotice.d.ts.map +1 -1
  18. package/dist/formatSpatialIndexingNotice.js +8 -2
  19. package/dist/formatSpatialIndexingNotice.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/spatialIndex.d.ts +10 -10
  25. package/dist/spatialIndex.d.ts.map +1 -1
  26. package/dist/spatialIndex.js +14 -14
  27. package/dist/spatialIndex.js.map +1 -1
  28. package/dist/spatialIndex3D.d.ts +13 -0
  29. package/dist/spatialIndex3D.d.ts.map +1 -0
  30. package/dist/spatialIndex3D.js +166 -0
  31. package/dist/spatialIndex3D.js.map +1 -0
  32. package/dist/spatialIndexingGuard.d.ts +4 -0
  33. package/dist/spatialIndexingGuard.d.ts.map +1 -0
  34. package/dist/spatialIndexingGuard.js +23 -0
  35. package/dist/spatialIndexingGuard.js.map +1 -0
  36. package/dist/uniformGrid.d.ts +2 -3
  37. package/dist/uniformGrid.d.ts.map +1 -1
  38. package/dist/uniformGrid.js +5 -15
  39. package/dist/uniformGrid.js.map +1 -1
  40. package/dist/uniformGrid3D.d.ts +3 -0
  41. package/dist/uniformGrid3D.d.ts.map +1 -0
  42. package/dist/uniformGrid3D.js +627 -0
  43. package/dist/uniformGrid3D.js.map +1 -0
  44. package/package.json +3 -3
  45. package/src/bvh3D.test.ts +294 -0
  46. package/src/explainSpatialIndexing2D.test.ts +81 -0
  47. package/src/explainSpatialIndexing3D.test.ts +59 -0
  48. package/src/formatSpatialIndexingNotice.test.ts +30 -4
  49. package/src/spatialIndex.test.ts +65 -65
  50. package/src/spatialIndex3D.test.ts +273 -0
  51. package/src/spatialIndexingGuard.test.ts +188 -0
  52. package/src/uniformGrid.test.ts +54 -182
  53. package/src/uniformGrid3D.test.ts +358 -0
  54. package/dist/explainSpatialIndexing.d.ts +0 -3
  55. package/dist/explainSpatialIndexing.d.ts.map +0 -1
  56. package/dist/explainSpatialIndexing.js.map +0 -1
  57. package/src/explainSpatialIndexing.test.ts +0 -76
@@ -1,7 +1,8 @@
1
- import type { SpatialAabb, SpatialIndexingNotice, SpatialObjectId, SpatialPair } from '@flighthq/types/contract';
1
+ import type { SpatialAabb2D, SpatialObjectId, SpatialPair } from '@flighthq/types/contract';
2
2
  import { afterEach, describe, expect, it } from 'vitest';
3
3
 
4
- import { MAX_INDEXED_CELLS_PER_OBJECT, createUniformGridSpatialBackend, setSpatialIndexingGuard } from './uniformGrid';
4
+ import { setSpatialIndexingGuard } from './spatialIndexingGuard';
5
+ import { MAX_INDEXED_CELLS_PER_OBJECT, createUniformGridSpatialBackend2D } from './uniformGrid';
5
6
 
6
7
  afterEach(() => {
7
8
  setSpatialIndexingGuard(null);
@@ -9,7 +10,7 @@ afterEach(() => {
9
10
 
10
11
  // A plain AABB-overlap confirmation used to turn broadphase candidate pairs into confirmed pairs (the
11
12
  // narrow-phase stand-in): exactly the check the caller would apply downstream.
12
- function boundsOverlap(a: Readonly<SpatialAabb>, b: Readonly<SpatialAabb>): boolean {
13
+ function boundsOverlap(a: Readonly<SpatialAabb2D>, b: Readonly<SpatialAabb2D>): boolean {
13
14
  return a.minX < b.maxX && a.maxX > b.minX && a.minY < b.maxY && a.maxY > b.minY;
14
15
  }
15
16
 
@@ -17,7 +18,10 @@ function pairKeys(pairs: readonly SpatialPair[]): string[] {
17
18
  return pairs.map((p) => `${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`).sort();
18
19
  }
19
20
 
20
- function bruteForcePairKeys(objects: ReadonlyMap<SpatialObjectId, Readonly<SpatialAabb>>, cellSize: number): string[] {
21
+ function bruteForcePairKeys(
22
+ objects: ReadonlyMap<SpatialObjectId, Readonly<SpatialAabb2D>>,
23
+ cellSize: number,
24
+ ): string[] {
21
25
  const entries = [...objects];
22
26
  const pairs: string[] = [];
23
27
  for (let i = 0; i < entries.length; i++) {
@@ -36,11 +40,11 @@ function bruteForcePairKeys(objects: ReadonlyMap<SpatialObjectId, Readonly<Spati
36
40
  return pairs.sort();
37
41
  }
38
42
 
39
- function boundsContainPoint(bounds: Readonly<SpatialAabb>, x: number, y: number): boolean {
43
+ function boundsContainPoint(bounds: Readonly<SpatialAabb2D>, x: number, y: number): boolean {
40
44
  return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY;
41
45
  }
42
46
 
43
- function boundsIntersectRay(bounds: Readonly<SpatialAabb>, ox: number, oy: number, dx: number, dy: number): boolean {
47
+ function boundsIntersectRay(bounds: Readonly<SpatialAabb2D>, ox: number, oy: number, dx: number, dy: number): boolean {
44
48
  let tMin = -Infinity;
45
49
  let tMax = Infinity;
46
50
  if (dx === 0) {
@@ -62,7 +66,7 @@ function boundsIntersectRay(bounds: Readonly<SpatialAabb>, ox: number, oy: numbe
62
66
  return tMax >= tMin && tMax >= 0;
63
67
  }
64
68
 
65
- function boundsShareCell(a: Readonly<SpatialAabb>, b: Readonly<SpatialAabb>, cellSize: number): boolean {
69
+ function boundsShareCell(a: Readonly<SpatialAabb2D>, b: Readonly<SpatialAabb2D>, cellSize: number): boolean {
66
70
  return (
67
71
  Math.max(Math.floor(a.minX / cellSize), Math.floor(b.minX / cellSize)) <=
68
72
  Math.min(Math.floor(a.maxX / cellSize), Math.floor(b.maxX / cellSize)) &&
@@ -81,7 +85,7 @@ function createTestRandom(seed: number): () => number {
81
85
  };
82
86
  }
83
87
 
84
- function isOverflowBounds(bounds: Readonly<SpatialAabb>, cellSize: number): boolean {
88
+ function isOverflowBounds(bounds: Readonly<SpatialAabb2D>, cellSize: number): boolean {
85
89
  const width = Math.floor(bounds.maxX / cellSize) - Math.floor(bounds.minX / cellSize) + 1;
86
90
  const height = Math.floor(bounds.maxY / cellSize) - Math.floor(bounds.minY / cellSize) + 1;
87
91
  return width * height > MAX_INDEXED_CELLS_PER_OBJECT;
@@ -96,10 +100,10 @@ describe('brute-force property coverage', () => {
96
100
  const cellSize = 4;
97
101
  for (const seed of [0x1357_9bdf, 0x2468_ace0, 0x5eed_f00d, 0xc0ff_ee42]) {
98
102
  const random = createTestRandom(seed);
99
- const grid = createUniformGridSpatialBackend(cellSize);
100
- const objects = new Map<SpatialObjectId, SpatialAabb>();
103
+ const grid = createUniformGridSpatialBackend2D(cellSize);
104
+ const objects = new Map<SpatialObjectId, SpatialAabb2D>();
101
105
 
102
- function randomBounds(overflow: boolean): SpatialAabb {
106
+ function randomBounds(overflow: boolean): SpatialAabb2D {
103
107
  const minX = Math.floor(random() * 160) - 80 + random();
104
108
  const minY = Math.floor(random() * 160) - 80 + random();
105
109
  const width = overflow ? cellSize * (34 + Math.floor(random() * 12)) : 0.25 + random() * 12;
@@ -114,7 +118,7 @@ describe('brute-force property coverage', () => {
114
118
 
115
119
  const regionMinX = random() * 240 - 120;
116
120
  const regionMinY = random() * 240 - 120;
117
- const region: SpatialAabb = {
121
+ const region: SpatialAabb2D = {
118
122
  minX: regionMinX,
119
123
  minY: regionMinY,
120
124
  maxX: regionMinX + 1 + random() * 80,
@@ -202,7 +206,7 @@ describe('cell range across every transition that can strand it', () => {
202
206
 
203
207
  // Correctness only. Used where the occupied cell range is legitimately wide, so the walk is
204
208
  // genuinely proportional to it — that is the documented conservative over-walk, not the defect.
205
- function rayIds(grid: ReturnType<typeof createUniformGridSpatialBackend>): SpatialObjectId[] {
209
+ function rayIds(grid: ReturnType<typeof createUniformGridSpatialBackend2D>): SpatialObjectId[] {
206
210
  const out: SpatialObjectId[] = [];
207
211
  grid.querySpatialRay(-5, 0.5, 1, 0, out);
208
212
  return out.sort((a, b) => a - b);
@@ -212,7 +216,7 @@ describe('cell range across every transition that can strand it', () => {
212
216
  // empty or tight, so any millions-of-cells walk means the range was stranded. Keeping these two
213
217
  // helpers apart is the point: a single budgeted helper made two honest cases fail, because their
214
218
  // titles claimed a seeding/removal property while the assertion silently demanded O(1).
215
- function rayIdsFast(grid: ReturnType<typeof createUniformGridSpatialBackend>): SpatialObjectId[] {
219
+ function rayIdsFast(grid: ReturnType<typeof createUniformGridSpatialBackend2D>): SpatialObjectId[] {
216
220
  const started = performance.now();
217
221
  const ids = rayIds(grid);
218
222
  expect(performance.now() - started).toBeLessThan(RAY_BUDGET_MS);
@@ -220,8 +224,8 @@ describe('cell range across every transition that can strand it', () => {
220
224
  }
221
225
 
222
226
  // Two small objects placed FAR apart, so any stranded range spans millions of cells.
223
- function gridWithWideRange(): ReturnType<typeof createUniformGridSpatialBackend> {
224
- const grid = createUniformGridSpatialBackend(1);
227
+ function gridWithWideRange(): ReturnType<typeof createUniformGridSpatialBackend2D> {
228
+ const grid = createUniformGridSpatialBackend2D(1);
225
229
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
226
230
  grid.insertSpatialObject(2, { minX: FAR, minY: 0, maxX: FAR + 1, maxY: 1 });
227
231
  return grid;
@@ -266,7 +270,7 @@ describe('cell range across every transition that can strand it', () => {
266
270
 
267
271
  it('updates the last celled object into overflow, emptying the cells', () => {
268
272
  // The overflow *transition* proper: no remove call, the object simply stops being celled.
269
- const grid = createUniformGridSpatialBackend(1);
273
+ const grid = createUniformGridSpatialBackend2D(1);
270
274
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
271
275
  grid.insertSpatialObject(2, { minX: FAR, minY: 0, maxX: FAR + 1, maxY: 1 });
272
276
  grid.updateSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
@@ -275,7 +279,7 @@ describe('cell range across every transition that can strand it', () => {
275
279
  });
276
280
 
277
281
  it('updates an overflowed object back into cells, re-seeding rather than widening', () => {
278
- const grid = createUniformGridSpatialBackend(1);
282
+ const grid = createUniformGridSpatialBackend2D(1);
279
283
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
280
284
  grid.insertSpatialObject(2, { minX: FAR, minY: 0, maxX: FAR + 1, maxY: 1 });
281
285
  grid.updateSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
@@ -314,14 +318,14 @@ describe('cell range across every transition that can strand it', () => {
314
318
  });
315
319
  });
316
320
 
317
- describe('createUniformGridSpatialBackend', () => {
321
+ describe('createUniformGridSpatialBackend2D', () => {
318
322
  it('keeps region comparisons reentrant when a bounds getter starts a nested query', () => {
319
- const grid = createUniformGridSpatialBackend(0);
323
+ const grid = createUniformGridSpatialBackend2D(0);
320
324
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
321
325
  grid.insertSpatialObject(2, { minX: 100, minY: 100, maxX: 101, maxY: 101 });
322
326
  let minXReads = 0;
323
327
  let nestedCalls = 0;
324
- const region: SpatialAabb = {
328
+ const region: SpatialAabb2D = {
325
329
  get minX() {
326
330
  minXReads++;
327
331
  if (minXReads === 2) {
@@ -343,7 +347,7 @@ describe('createUniformGridSpatialBackend', () => {
343
347
  });
344
348
 
345
349
  it('emits a pair spanning several shared cells exactly once', () => {
346
- const grid = createUniformGridSpatialBackend(10);
350
+ const grid = createUniformGridSpatialBackend2D(10);
347
351
  // Both objects cover the same 3x3 block of cells (0..2, 0..2), so they co-occupy nine cells.
348
352
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 25, maxY: 25 });
349
353
  grid.insertSpatialObject(2, { minX: 5, minY: 5, maxX: 29, maxY: 29 });
@@ -356,7 +360,7 @@ describe('createUniformGridSpatialBackend', () => {
356
360
  });
357
361
 
358
362
  it('never pairs an object with itself', () => {
359
- const grid = createUniformGridSpatialBackend(10);
363
+ const grid = createUniformGridSpatialBackend2D(10);
360
364
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 4, maxY: 4 });
361
365
 
362
366
  const pairs: SpatialPair[] = [];
@@ -365,7 +369,7 @@ describe('createUniformGridSpatialBackend', () => {
365
369
  });
366
370
 
367
371
  it('returns nothing from every query on an empty grid', () => {
368
- const grid = createUniformGridSpatialBackend(10);
372
+ const grid = createUniformGridSpatialBackend2D(10);
369
373
 
370
374
  const pairs: SpatialPair[] = [];
371
375
  grid.querySpatialPairs(pairs);
@@ -385,7 +389,7 @@ describe('createUniformGridSpatialBackend', () => {
385
389
  });
386
390
 
387
391
  it('indexes objects at negative coordinates', () => {
388
- const grid = createUniformGridSpatialBackend(10);
392
+ const grid = createUniformGridSpatialBackend2D(10);
389
393
  grid.insertSpatialObject(1, { minX: -8, minY: -8, maxX: -4, maxY: -4 });
390
394
  grid.insertSpatialObject(2, { minX: -6, minY: -6, maxX: -2, maxY: -2 });
391
395
 
@@ -399,7 +403,7 @@ describe('createUniformGridSpatialBackend', () => {
399
403
  });
400
404
 
401
405
  it('gives the same confirmed pairs across different cell sizes', () => {
402
- const objects: { id: SpatialObjectId; bounds: SpatialAabb }[] = [
406
+ const objects: { id: SpatialObjectId; bounds: SpatialAabb2D }[] = [
403
407
  { id: 1, bounds: { minX: 0, minY: 0, maxX: 10, maxY: 10 } },
404
408
  { id: 2, bounds: { minX: 5, minY: 5, maxX: 15, maxY: 15 } },
405
409
  { id: 3, bounds: { minX: 12, minY: 12, maxX: 20, maxY: 20 } },
@@ -408,7 +412,7 @@ describe('createUniformGridSpatialBackend', () => {
408
412
  const boundsOf = new Map(objects.map((o) => [o.id, o.bounds]));
409
413
 
410
414
  function confirmedPairs(cellSize: number): string[] {
411
- const grid = createUniformGridSpatialBackend(cellSize);
415
+ const grid = createUniformGridSpatialBackend2D(cellSize);
412
416
  for (const o of objects) grid.insertSpatialObject(o.id, o.bounds);
413
417
  const candidates: SpatialPair[] = [];
414
418
  grid.querySpatialPairs(candidates);
@@ -426,7 +430,7 @@ describe('createUniformGridSpatialBackend', () => {
426
430
 
427
431
  describe('MAX_INDEXED_CELLS_PER_OBJECT', () => {
428
432
  it('is the per-object cell budget the oversized path is chosen by', () => {
429
- const grid = createUniformGridSpatialBackend(1);
433
+ const grid = createUniformGridSpatialBackend2D(1);
430
434
  // A square block of exactly the budget stays on the ordinary path; one cell more does not. The
431
435
  // budget is a count of cells, not an extent, so it is asserted through the count.
432
436
  const side = Math.sqrt(MAX_INDEXED_CELLS_PER_OBJECT);
@@ -446,7 +450,7 @@ describe('MAX_INDEXED_CELLS_PER_OBJECT', () => {
446
450
 
447
451
  describe('non-finite bounds', () => {
448
452
  it('declines rather than indexing, and answers with the false sentinel', () => {
449
- const grid = createUniformGridSpatialBackend(10);
453
+ const grid = createUniformGridSpatialBackend2D(10);
450
454
  expect(grid.insertSpatialObject(1, { minX: NaN, minY: 0, maxX: 10, maxY: 10 })).toBe(false);
451
455
  expect(grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: Infinity, maxY: 10 })).toBe(false);
452
456
  expect(grid.insertSpatialObject(3, { minX: 0, minY: -Infinity, maxX: 10, maxY: 10 })).toBe(false);
@@ -459,12 +463,12 @@ describe('non-finite bounds', () => {
459
463
  });
460
464
 
461
465
  it('returns true for finite bounds, so the sentinel distinguishes decline from success', () => {
462
- const grid = createUniformGridSpatialBackend(10);
466
+ const grid = createUniformGridSpatialBackend2D(10);
463
467
  expect(grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
464
468
  });
465
469
 
466
470
  it('keeps a declined object out of every query rather than out of some', () => {
467
- const grid = createUniformGridSpatialBackend(10);
471
+ const grid = createUniformGridSpatialBackend2D(10);
468
472
  grid.insertSpatialObject(1, { minX: NaN, minY: NaN, maxX: NaN, maxY: NaN });
469
473
  grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
470
474
 
@@ -486,7 +490,7 @@ describe('non-finite bounds', () => {
486
490
  });
487
491
 
488
492
  it('drops an object that updates to non-finite bounds instead of stranding it at its old ones', () => {
489
- const grid = createUniformGridSpatialBackend(10);
493
+ const grid = createUniformGridSpatialBackend2D(10);
490
494
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
491
495
  expect(grid.updateSpatialObject(1, { minX: NaN, minY: NaN, maxX: NaN, maxY: NaN })).toBe(false);
492
496
  expect(grid.explainSpatialIndexing(1).mode).toBe('declined');
@@ -496,7 +500,7 @@ describe('non-finite bounds', () => {
496
500
  });
497
501
 
498
502
  it('lets a declined object recover on a later finite update', () => {
499
- const grid = createUniformGridSpatialBackend(10);
503
+ const grid = createUniformGridSpatialBackend2D(10);
500
504
  grid.insertSpatialObject(1, { minX: NaN, minY: NaN, maxX: NaN, maxY: NaN });
501
505
  expect(grid.updateSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
502
506
  expect(grid.explainSpatialIndexing(1).mode).toBe('cells');
@@ -511,7 +515,7 @@ describe('oversized region query', () => {
511
515
  // wider than the world would walk extent-squared cells against a grid holding almost nothing.
512
516
  // Measured at 69 ms for a 1000x1000-cell region over a one-object grid before the bound.
513
517
  it('answers a region far wider than the grid without walking it cell by cell', () => {
514
- const grid = createUniformGridSpatialBackend(1);
518
+ const grid = createUniformGridSpatialBackend2D(1);
515
519
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
516
520
  const out: SpatialObjectId[] = [];
517
521
  grid.querySpatialRegion({ minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 }, out);
@@ -519,7 +523,7 @@ describe('oversized region query', () => {
519
523
  });
520
524
 
521
525
  it('returns the same objects either way it walks', () => {
522
- const grid = createUniformGridSpatialBackend(1);
526
+ const grid = createUniformGridSpatialBackend2D(1);
523
527
  for (let i = 0; i < 40; i++) grid.insertSpatialObject(i, { minX: i, minY: 0, maxX: i + 0.5, maxY: 1 });
524
528
  const wide: SpatialObjectId[] = [];
525
529
  grid.querySpatialRegion({ minX: -1e9, minY: -1e9, maxX: 1e9, maxY: 1e9 }, wide);
@@ -530,7 +534,7 @@ describe('oversized region query', () => {
530
534
  });
531
535
 
532
536
  it('still excludes objects the wide region misses', () => {
533
- const grid = createUniformGridSpatialBackend(1);
537
+ const grid = createUniformGridSpatialBackend2D(1);
534
538
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
535
539
  grid.insertSpatialObject(2, { minX: 5e11, minY: 0, maxX: 5e11 + 1, maxY: 1 });
536
540
  const out: SpatialObjectId[] = [];
@@ -548,7 +552,7 @@ describe('oversized-extent bound', () => {
548
552
  // wide, which is what a diverging rigid-body simulation actually produces) would be 1e24 cells and
549
553
  // would never return, which is exactly why the bound cannot be tested at its motivating scale.
550
554
  it('holds an oversized object without writing a cell per unit of its extent', () => {
551
- const grid = createUniformGridSpatialBackend(1);
555
+ const grid = createUniformGridSpatialBackend2D(1);
552
556
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 200, maxY: 200 });
553
557
  const explanation = grid.explainSpatialIndexing(1);
554
558
  expect(explanation.mode).toBe('overflow');
@@ -559,13 +563,13 @@ describe('oversized-extent bound', () => {
559
563
  // Unreachable for an unbounded build (1e24 cells), so this one cannot be written as a
560
564
  // before/after assertion — it is the proof that the motivating case is now O(1). Safe to run only
561
565
  // because the assertion above fails first if the bound is ever removed.
562
- const grid = createUniformGridSpatialBackend(1);
566
+ const grid = createUniformGridSpatialBackend2D(1);
563
567
  expect(grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 })).toBe(true);
564
568
  expect(grid.explainSpatialIndexing(1).mode).toBe('overflow');
565
569
  });
566
570
 
567
571
  it('keeps an oversized object queryable — the bound is a cost decision, not a dropped object', () => {
568
- const grid = createUniformGridSpatialBackend(1);
572
+ const grid = createUniformGridSpatialBackend2D(1);
569
573
  grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
570
574
  grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
571
575
 
@@ -587,7 +591,7 @@ describe('oversized-extent bound', () => {
587
591
  });
588
592
 
589
593
  it('emits each overflow pair once, including overflow against overflow', () => {
590
- const grid = createUniformGridSpatialBackend(1);
594
+ const grid = createUniformGridSpatialBackend2D(1);
591
595
  grid.insertSpatialObject(1, { minX: -1e9, minY: -1e9, maxX: 1e9, maxY: 1e9 });
592
596
  grid.insertSpatialObject(2, { minX: -1e9, minY: -1e9, maxX: 1e9, maxY: 1e9 });
593
597
  grid.insertSpatialObject(3, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
@@ -597,7 +601,7 @@ describe('oversized-extent bound', () => {
597
601
  });
598
602
 
599
603
  it('does not pair an overflow object with a disjoint object', () => {
600
- const grid = createUniformGridSpatialBackend(1);
604
+ const grid = createUniformGridSpatialBackend2D(1);
601
605
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 100000, maxY: 100000 });
602
606
  grid.insertSpatialObject(2, { minX: -50, minY: -50, maxX: -40, maxY: -40 });
603
607
  const pairs: SpatialPair[] = [];
@@ -606,7 +610,7 @@ describe('oversized-extent bound', () => {
606
610
  });
607
611
 
608
612
  it('removes an oversized object without walking its extent, and stops returning it', () => {
609
- const grid = createUniformGridSpatialBackend(1);
613
+ const grid = createUniformGridSpatialBackend2D(1);
610
614
  grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
611
615
  grid.removeSpatialObject(1);
612
616
  expect(grid.explainSpatialIndexing(1).mode).toBe('absent');
@@ -616,7 +620,7 @@ describe('oversized-extent bound', () => {
616
620
  });
617
621
 
618
622
  it('moves an object between the celled and overflow paths as it grows and shrinks', () => {
619
- const grid = createUniformGridSpatialBackend(1);
623
+ const grid = createUniformGridSpatialBackend2D(1);
620
624
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 2, maxY: 2 });
621
625
  expect(grid.explainSpatialIndexing(1).mode).toBe('cells');
622
626
  grid.updateSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
@@ -631,7 +635,7 @@ describe('oversized-extent bound', () => {
631
635
  });
632
636
 
633
637
  it('clears overflow with the rest of the index', () => {
634
- const grid = createUniformGridSpatialBackend(1);
638
+ const grid = createUniformGridSpatialBackend2D(1);
635
639
  grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
636
640
  grid.clearSpatialIndex();
637
641
  expect(grid.explainSpatialIndexing(1).mode).toBe('absent');
@@ -644,7 +648,7 @@ describe('oversized-extent bound', () => {
644
648
  // Without the bound the occupied cell range stretches to the oversized object's span, and every
645
649
  // subsequent ray walks it. Two small objects far apart bound the range; the oversized one must
646
650
  // not widen it, which shows up as the ray still resolving correctly and promptly.
647
- const grid = createUniformGridSpatialBackend(1);
651
+ const grid = createUniformGridSpatialBackend2D(1);
648
652
  grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
649
653
  grid.insertSpatialObject(2, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
650
654
  const out: SpatialObjectId[] = [];
@@ -655,7 +659,7 @@ describe('oversized-extent bound', () => {
655
659
 
656
660
  describe('ray edge cases', () => {
657
661
  it('finds objects along a ray that passes exactly through cell corners', () => {
658
- const grid = createUniformGridSpatialBackend(10);
662
+ const grid = createUniformGridSpatialBackend2D(10);
659
663
  grid.insertSpatialObject(1, { minX: 10, minY: 10, maxX: 12, maxY: 12 });
660
664
  grid.insertSpatialObject(2, { minX: 20, minY: 20, maxX: 22, maxY: 22 });
661
665
  const out: SpatialObjectId[] = [];
@@ -664,7 +668,7 @@ describe('ray edge cases', () => {
664
668
  });
665
669
 
666
670
  it('finds an object when the ray starts inside its bounds', () => {
667
- const grid = createUniformGridSpatialBackend(10);
671
+ const grid = createUniformGridSpatialBackend2D(10);
668
672
  grid.insertSpatialObject(1, { minX: 5, minY: 5, maxX: 15, maxY: 15 });
669
673
  const out: SpatialObjectId[] = [];
670
674
  grid.querySpatialRay(10, 10, -1, 0, out);
@@ -672,7 +676,7 @@ describe('ray edge cases', () => {
672
676
  });
673
677
 
674
678
  it('clips a ray entering the occupied range from far outside', () => {
675
- const grid = createUniformGridSpatialBackend(10);
679
+ const grid = createUniformGridSpatialBackend2D(10);
676
680
  grid.insertSpatialObject(1, { minX: 100, minY: 30, maxX: 110, maxY: 40 });
677
681
  const out: SpatialObjectId[] = [];
678
682
  grid.querySpatialRay(-1e9, 35, 1, 0, out);
@@ -680,141 +684,9 @@ describe('ray edge cases', () => {
680
684
  });
681
685
  });
682
686
 
683
- describe('setSpatialIndexingGuard', () => {
684
- it('reports a decline with its reason, and no span', () => {
685
- const notices: SpatialIndexingNotice[] = [];
686
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
687
- const grid = createUniformGridSpatialBackend(10);
688
- grid.insertSpatialObject(7, { minX: NaN, minY: 0, maxX: 10, maxY: 10 });
689
- expect(notices).toEqual([
690
- {
691
- cellSize: 10,
692
- id: 7,
693
- mode: 'declined',
694
- operation: 'insert',
695
- reason: 'non-finite-bounds',
696
- wouldOccupyBucketCount: 0,
697
- },
698
- ]);
699
- });
700
-
701
- it('reports an overflow with the span the bound refused to walk', () => {
702
- const notices: SpatialIndexingNotice[] = [];
703
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
704
- const grid = createUniformGridSpatialBackend(1);
705
- grid.insertSpatialObject(7, { minX: 0, minY: 0, maxX: 199, maxY: 199 });
706
- expect(notices).toEqual([
707
- {
708
- cellSize: 1,
709
- id: 7,
710
- mode: 'overflow',
711
- operation: 'insert',
712
- reason: null,
713
- wouldOccupyBucketCount: 40000,
714
- },
715
- ]);
716
- });
717
-
718
- it('reports an invalid cell size and keeps results correct through the bounded overflow path', () => {
719
- const notices: SpatialIndexingNotice[] = [];
720
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
721
- for (const cellSize of [0, -1]) {
722
- const grid = createUniformGridSpatialBackend(cellSize);
723
- expect(grid.insertSpatialObject(7, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
724
- const point: SpatialObjectId[] = [];
725
- grid.querySpatialPoint(5, 5, point);
726
- expect(point).toEqual([7]);
727
- }
728
- expect(notices).toEqual(
729
- [0, -1].map((cellSize) => ({
730
- cellSize,
731
- id: 7,
732
- mode: 'overflow',
733
- operation: 'insert',
734
- reason: 'invalid-cell-size',
735
- wouldOccupyBucketCount: 0,
736
- })),
737
- );
738
- });
739
-
740
- it('declines and reports inverted bounds', () => {
741
- const notices: SpatialIndexingNotice[] = [];
742
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
743
- const grid = createUniformGridSpatialBackend(10);
744
- expect(grid.insertSpatialObject(7, { minX: 10, minY: 0, maxX: 0, maxY: 10 })).toBe(false);
745
- expect(grid.explainSpatialIndexing(7)).toEqual({
746
- bucketCount: 0,
747
- id: 7,
748
- mode: 'declined',
749
- reason: 'inverted-bounds',
750
- });
751
- expect(notices).toEqual([
752
- {
753
- cellSize: 10,
754
- id: 7,
755
- mode: 'declined',
756
- operation: 'insert',
757
- reason: 'inverted-bounds',
758
- wouldOccupyBucketCount: 0,
759
- },
760
- ]);
761
- });
762
-
763
- it('reports update and remove operations whose id was never inserted', () => {
764
- const notices: SpatialIndexingNotice[] = [];
765
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
766
- const grid = createUniformGridSpatialBackend(10);
767
- expect(grid.updateSpatialObject(7, { minX: 0, minY: 0, maxX: 5, maxY: 5 })).toBe(true);
768
- grid.removeSpatialObject(8);
769
- expect(notices).toEqual([
770
- {
771
- cellSize: 10,
772
- id: 7,
773
- mode: 'cells',
774
- operation: 'update',
775
- reason: 'missing-id',
776
- wouldOccupyBucketCount: 0,
777
- },
778
- {
779
- cellSize: 10,
780
- id: 8,
781
- mode: 'absent',
782
- operation: 'remove',
783
- reason: 'missing-id',
784
- wouldOccupyBucketCount: 0,
785
- },
786
- ]);
787
- });
788
-
789
- it('stays silent on the ordinary path', () => {
790
- const notices: SpatialIndexingNotice[] = [];
791
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
792
- const grid = createUniformGridSpatialBackend(10);
793
- grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
794
- grid.removeSpatialObject(1);
795
- expect(notices).toEqual([]);
796
- });
797
-
798
- it('null uninstalls it', () => {
799
- const notices: SpatialIndexingNotice[] = [];
800
- setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
801
- setSpatialIndexingGuard(null);
802
- const grid = createUniformGridSpatialBackend(10);
803
- grid.insertSpatialObject(1, { minX: NaN, minY: 0, maxX: 10, maxY: 10 });
804
- expect(notices).toEqual([]);
805
- });
806
-
807
- it('does not change what insert returns', () => {
808
- setSpatialIndexingGuard(() => {});
809
- const grid = createUniformGridSpatialBackend(10);
810
- expect(grid.insertSpatialObject(1, { minX: NaN, minY: 0, maxX: 10, maxY: 10 })).toBe(false);
811
- expect(grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
812
- });
813
- });
814
-
815
- describe('updateSpatialObject', () => {
687
+ describe('updateSpatialObject2D', () => {
816
688
  it('refreshes exact bounds without retaining the caller object when the covered cells stay unchanged', () => {
817
- const grid = createUniformGridSpatialBackend(10);
689
+ const grid = createUniformGridSpatialBackend2D(10);
818
690
  grid.insertSpatialObject(1, { minX: 1, minY: 1, maxX: 18, maxY: 18 });
819
691
  const updated = { minX: 4, minY: 4, maxX: 19, maxY: 19 };
820
692