@flighthq/spatial 0.3.0-next.906.07cea63 → 0.3.0

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.
@@ -1,7 +1,11 @@
1
- import type { SpatialAabb, SpatialObjectId, SpatialPair } from '@flighthq/types/contract';
2
- import { describe, expect, it } from 'vitest';
1
+ import type { SpatialAabb, SpatialIndexingNotice, SpatialObjectId, SpatialPair } from '@flighthq/types/contract';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
3
 
4
- import { createUniformGridSpatialBackend } from './uniformGrid';
4
+ import { MAX_INDEXED_CELLS_PER_OBJECT, createUniformGridSpatialBackend, setSpatialIndexingGuard } from './uniformGrid';
5
+
6
+ afterEach(() => {
7
+ setSpatialIndexingGuard(null);
8
+ });
5
9
 
6
10
  // A plain AABB-overlap confirmation used to turn broadphase candidate pairs into confirmed pairs (the
7
11
  // narrow-phase stand-in): exactly the check the caller would apply downstream.
@@ -13,6 +17,303 @@ function pairKeys(pairs: readonly SpatialPair[]): string[] {
13
17
  return pairs.map((p) => `${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`).sort();
14
18
  }
15
19
 
20
+ function bruteForcePairKeys(objects: ReadonlyMap<SpatialObjectId, Readonly<SpatialAabb>>, cellSize: number): string[] {
21
+ const entries = [...objects];
22
+ const pairs: string[] = [];
23
+ for (let i = 0; i < entries.length; i++) {
24
+ for (let j = i + 1; j < entries.length; j++) {
25
+ const [aId, a] = entries[i];
26
+ const [bId, b] = entries[j];
27
+ if (
28
+ isOverflowBounds(a, cellSize) || isOverflowBounds(b, cellSize)
29
+ ? boundsOverlap(a, b)
30
+ : boundsShareCell(a, b, cellSize)
31
+ ) {
32
+ pairs.push(`${Math.min(aId, bId)}-${Math.max(aId, bId)}`);
33
+ }
34
+ }
35
+ }
36
+ return pairs.sort();
37
+ }
38
+
39
+ function boundsContainPoint(bounds: Readonly<SpatialAabb>, x: number, y: number): boolean {
40
+ return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY;
41
+ }
42
+
43
+ function boundsIntersectRay(bounds: Readonly<SpatialAabb>, ox: number, oy: number, dx: number, dy: number): boolean {
44
+ let tMin = -Infinity;
45
+ let tMax = Infinity;
46
+ if (dx === 0) {
47
+ if (ox < bounds.minX || ox > bounds.maxX) return false;
48
+ } else {
49
+ const tx0 = (bounds.minX - ox) / dx;
50
+ const tx1 = (bounds.maxX - ox) / dx;
51
+ tMin = Math.max(tMin, Math.min(tx0, tx1));
52
+ tMax = Math.min(tMax, Math.max(tx0, tx1));
53
+ }
54
+ if (dy === 0) {
55
+ if (oy < bounds.minY || oy > bounds.maxY) return false;
56
+ } else {
57
+ const ty0 = (bounds.minY - oy) / dy;
58
+ const ty1 = (bounds.maxY - oy) / dy;
59
+ tMin = Math.max(tMin, Math.min(ty0, ty1));
60
+ tMax = Math.min(tMax, Math.max(ty0, ty1));
61
+ }
62
+ return tMax >= tMin && tMax >= 0;
63
+ }
64
+
65
+ function boundsShareCell(a: Readonly<SpatialAabb>, b: Readonly<SpatialAabb>, cellSize: number): boolean {
66
+ return (
67
+ Math.max(Math.floor(a.minX / cellSize), Math.floor(b.minX / cellSize)) <=
68
+ Math.min(Math.floor(a.maxX / cellSize), Math.floor(b.maxX / cellSize)) &&
69
+ Math.max(Math.floor(a.minY / cellSize), Math.floor(b.minY / cellSize)) <=
70
+ Math.min(Math.floor(a.maxY / cellSize), Math.floor(b.maxY / cellSize))
71
+ );
72
+ }
73
+
74
+ function createTestRandom(seed: number): () => number {
75
+ let state = seed >>> 0;
76
+ return () => {
77
+ state ^= state << 13;
78
+ state ^= state >>> 17;
79
+ state ^= state << 5;
80
+ return (state >>> 0) / 0x1_0000_0000;
81
+ };
82
+ }
83
+
84
+ function isOverflowBounds(bounds: Readonly<SpatialAabb>, cellSize: number): boolean {
85
+ const width = Math.floor(bounds.maxX / cellSize) - Math.floor(bounds.minX / cellSize) + 1;
86
+ const height = Math.floor(bounds.maxY / cellSize) - Math.floor(bounds.minY / cellSize) + 1;
87
+ return width * height > MAX_INDEXED_CELLS_PER_OBJECT;
88
+ }
89
+
90
+ function sortedIds(ids: readonly SpatialObjectId[]): SpatialObjectId[] {
91
+ return [...ids].sort((a, b) => a - b);
92
+ }
93
+
94
+ describe('brute-force property coverage', () => {
95
+ it('matches pair, region, point, and ray oracles through seeded cell and overflow churn', () => {
96
+ const cellSize = 4;
97
+ for (const seed of [0x1357_9bdf, 0x2468_ace0, 0x5eed_f00d, 0xc0ff_ee42]) {
98
+ const random = createTestRandom(seed);
99
+ const grid = createUniformGridSpatialBackend(cellSize);
100
+ const objects = new Map<SpatialObjectId, SpatialAabb>();
101
+
102
+ function randomBounds(overflow: boolean): SpatialAabb {
103
+ const minX = Math.floor(random() * 160) - 80 + random();
104
+ const minY = Math.floor(random() * 160) - 80 + random();
105
+ const width = overflow ? cellSize * (34 + Math.floor(random() * 12)) : 0.25 + random() * 12;
106
+ const height = overflow ? cellSize * (34 + Math.floor(random() * 12)) : 0.25 + random() * 12;
107
+ return { minX, minY, maxX: minX + width, maxY: minY + height };
108
+ }
109
+
110
+ function expectMatchesBruteForce(label: string): void {
111
+ const pairs: SpatialPair[] = [];
112
+ grid.querySpatialPairs(pairs);
113
+ expect(pairKeys(pairs), `${label}: pairs`).toEqual(bruteForcePairKeys(objects, cellSize));
114
+
115
+ const regionMinX = random() * 240 - 120;
116
+ const regionMinY = random() * 240 - 120;
117
+ const region: SpatialAabb = {
118
+ minX: regionMinX,
119
+ minY: regionMinY,
120
+ maxX: regionMinX + 1 + random() * 80,
121
+ maxY: regionMinY + 1 + random() * 80,
122
+ };
123
+ const regionActual: SpatialObjectId[] = [];
124
+ grid.querySpatialRegion(region, regionActual);
125
+ const regionExpected = [...objects].filter(([, bounds]) => boundsOverlap(bounds, region)).map(([id]) => id);
126
+ expect(sortedIds(regionActual), `${label}: region`).toEqual(sortedIds(regionExpected));
127
+
128
+ const pointX = random() * 320 - 160;
129
+ const pointY = random() * 320 - 160;
130
+ const pointActual: SpatialObjectId[] = [];
131
+ grid.querySpatialPoint(pointX, pointY, pointActual);
132
+ const pointExpected = [...objects]
133
+ .filter(([, bounds]) => boundsContainPoint(bounds, pointX, pointY))
134
+ .map(([id]) => id);
135
+ expect(sortedIds(pointActual), `${label}: point`).toEqual(sortedIds(pointExpected));
136
+
137
+ const rayX = random() * 400 - 200;
138
+ const rayY = random() * 400 - 200;
139
+ let rayDx = random() * 2 - 1;
140
+ let rayDy = random() * 2 - 1;
141
+ if (random() < 0.1) rayDx = 0;
142
+ if (random() < 0.1) rayDy = 0;
143
+ const rayActual: SpatialObjectId[] = [];
144
+ grid.querySpatialRay(rayX, rayY, rayDx, rayDy, rayActual);
145
+ const rayExpected = [...objects]
146
+ .filter(([, bounds]) => boundsIntersectRay(bounds, rayX, rayY, rayDx, rayDy))
147
+ .map(([id]) => id);
148
+ expect(sortedIds(rayActual), `${label}: ray`).toEqual(sortedIds(rayExpected));
149
+ }
150
+
151
+ const ordinary = randomBounds(false);
152
+ objects.set(0, ordinary);
153
+ grid.insertSpatialObject(0, ordinary);
154
+ expectMatchesBruteForce(`seed ${seed}, ordinary`);
155
+
156
+ const overflow = randomBounds(true);
157
+ objects.set(0, overflow);
158
+ grid.updateSpatialObject(0, overflow);
159
+ expect(grid.explainSpatialIndexing(0).mode).toBe('overflow');
160
+ expectMatchesBruteForce(`seed ${seed}, overflow`);
161
+
162
+ const ordinaryAgain = randomBounds(false);
163
+ objects.set(0, ordinaryAgain);
164
+ grid.updateSpatialObject(0, ordinaryAgain);
165
+ expect(grid.explainSpatialIndexing(0).mode).toBe('cells');
166
+ expectMatchesBruteForce(`seed ${seed}, cells again`);
167
+
168
+ for (let step = 0; step < 100; step++) {
169
+ const id = Math.floor(random() * 18);
170
+ if (random() < 0.72) {
171
+ const bounds = randomBounds(random() < 0.22);
172
+ if (objects.has(id)) grid.updateSpatialObject(id, bounds);
173
+ else if (random() < 0.5) grid.insertSpatialObject(id, bounds);
174
+ else grid.updateSpatialObject(id, bounds);
175
+ objects.set(id, bounds);
176
+ } else {
177
+ grid.removeSpatialObject(id);
178
+ objects.delete(id);
179
+ }
180
+ expectMatchesBruteForce(`seed ${seed}, step ${step}`);
181
+ }
182
+ }
183
+ });
184
+ });
185
+
186
+ describe('cell range across every transition that can strand it', () => {
187
+ // Chief's class-not-instance rule applied to the ray-hang defect. The bug was never "removal after
188
+ // overflow"; it was that a separately-maintained `empty` flag could disagree with the cells it
189
+ // described, and *every* transition that empties or re-seeds the cell set is a chance for that.
190
+ // The fix derives the fact from `cells.size`, so no flag can drift — and these enumerate the
191
+ // transitions so a future re-introduction is caught wherever it is introduced.
192
+ //
193
+ // Each case is asserted two ways: the ray returns the right ids, and it returns them without
194
+ // walking a stale range. The second is the one that matters — the results were already correct
195
+ // before the fix, and the whole defect was cost. Measured on this grid: a stranded range costs
196
+ // ~70 ns per cell, so the 5,000,000-cell range used here took ~343 ms unstranded-vs-stranded
197
+ // (1e12 as in the original report is hours, i.e. an uncatchable hang). Budget 150 ms leaves the
198
+ // fixed path — an early return plus a one-object overflow scan, well under a millisecond — a
199
+ // ~1500x margin, while still failing on a stranded range by ~2.3x.
200
+ const FAR = 5_000_000;
201
+ const RAY_BUDGET_MS = 150;
202
+
203
+ // Correctness only. Used where the occupied cell range is legitimately wide, so the walk is
204
+ // genuinely proportional to it — that is the documented conservative over-walk, not the defect.
205
+ function rayIds(grid: ReturnType<typeof createUniformGridSpatialBackend>): SpatialObjectId[] {
206
+ const out: SpatialObjectId[] = [];
207
+ grid.querySpatialRay(-5, 0.5, 1, 0, out);
208
+ return out.sort((a, b) => a - b);
209
+ }
210
+
211
+ // Correctness *and* the cost bound. Used only where the transition should have left the cell set
212
+ // empty or tight, so any millions-of-cells walk means the range was stranded. Keeping these two
213
+ // helpers apart is the point: a single budgeted helper made two honest cases fail, because their
214
+ // titles claimed a seeding/removal property while the assertion silently demanded O(1).
215
+ function rayIdsFast(grid: ReturnType<typeof createUniformGridSpatialBackend>): SpatialObjectId[] {
216
+ const started = performance.now();
217
+ const ids = rayIds(grid);
218
+ expect(performance.now() - started).toBeLessThan(RAY_BUDGET_MS);
219
+ return ids;
220
+ }
221
+
222
+ // 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);
225
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
226
+ grid.insertSpatialObject(2, { minX: FAR, minY: 0, maxX: FAR + 1, maxY: 1 });
227
+ return grid;
228
+ }
229
+
230
+ it('seeds the range on the first celled insert and widens it on the next', () => {
231
+ const grid = gridWithWideRange();
232
+ expect(rayIds(grid)).toEqual([1, 2]);
233
+ });
234
+
235
+ it('removes the last celled object while an overflowed object remains', () => {
236
+ // The reported case: overflow objects live in `bounds` but occupy no cells, so a flag derived
237
+ // from `bounds.size` stayed false here and left the range stranded.
238
+ const grid = gridWithWideRange();
239
+ grid.insertSpatialObject(3, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
240
+ grid.removeSpatialObject(1);
241
+ grid.removeSpatialObject(2);
242
+ expect(rayIdsFast(grid)).toEqual([3]);
243
+ });
244
+
245
+ it('removes the last celled object while a declined object remains', () => {
246
+ const grid = gridWithWideRange();
247
+ grid.insertSpatialObject(3, { minX: NaN, minY: 0, maxX: 1, maxY: 1 });
248
+ grid.removeSpatialObject(1);
249
+ grid.removeSpatialObject(2);
250
+ expect(rayIdsFast(grid)).toEqual([]);
251
+ });
252
+
253
+ it('removes the last celled object with nothing else in the index', () => {
254
+ const grid = gridWithWideRange();
255
+ grid.removeSpatialObject(1);
256
+ grid.removeSpatialObject(2);
257
+ expect(rayIdsFast(grid)).toEqual([]);
258
+ });
259
+
260
+ it('removes an overflowed object while celled objects remain, keeping them findable', () => {
261
+ const grid = gridWithWideRange();
262
+ grid.insertSpatialObject(3, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
263
+ grid.removeSpatialObject(3);
264
+ expect(rayIds(grid)).toEqual([1, 2]);
265
+ });
266
+
267
+ it('updates the last celled object into overflow, emptying the cells', () => {
268
+ // The overflow *transition* proper: no remove call, the object simply stops being celled.
269
+ const grid = createUniformGridSpatialBackend(1);
270
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
271
+ grid.insertSpatialObject(2, { minX: FAR, minY: 0, maxX: FAR + 1, maxY: 1 });
272
+ grid.updateSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
273
+ grid.updateSpatialObject(2, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
274
+ expect(rayIdsFast(grid)).toEqual([1, 2]);
275
+ });
276
+
277
+ it('updates an overflowed object back into cells, re-seeding rather than widening', () => {
278
+ const grid = createUniformGridSpatialBackend(1);
279
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
280
+ grid.insertSpatialObject(2, { minX: FAR, minY: 0, maxX: FAR + 1, maxY: 1 });
281
+ grid.updateSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
282
+ grid.updateSpatialObject(2, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
283
+ // Cells are empty here; coming back must seed a fresh tight range, not reuse the old wide one.
284
+ grid.updateSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
285
+ expect(rayIdsFast(grid)).toEqual([1, 2]);
286
+ });
287
+
288
+ it('clears while an overflowed object is present', () => {
289
+ const grid = gridWithWideRange();
290
+ grid.insertSpatialObject(3, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
291
+ grid.clearSpatialIndex();
292
+ expect(rayIdsFast(grid)).toEqual([]);
293
+ });
294
+
295
+ it('re-seeds a tight range after the index has been emptied and refilled', () => {
296
+ const grid = gridWithWideRange();
297
+ grid.clearSpatialIndex();
298
+ grid.insertSpatialObject(9, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
299
+ expect(rayIdsFast(grid)).toEqual([9]);
300
+ });
301
+
302
+ it('keeps region and point queries correct across the same overflow transition', () => {
303
+ // The range is the ray's concern, but the transition must not corrupt the other queries either.
304
+ const grid = gridWithWideRange();
305
+ grid.insertSpatialObject(3, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
306
+ grid.removeSpatialObject(1);
307
+ grid.removeSpatialObject(2);
308
+ const region: SpatialObjectId[] = [];
309
+ grid.querySpatialRegion({ minX: -1, minY: -1, maxX: 2, maxY: 2 }, region);
310
+ expect(region).toEqual([3]);
311
+ const point: SpatialObjectId[] = [];
312
+ grid.querySpatialPoint(0.5, 0.5, point);
313
+ expect(point).toEqual([3]);
314
+ });
315
+ });
316
+
16
317
  describe('createUniformGridSpatialBackend', () => {
17
318
  it('emits a pair spanning several shared cells exactly once', () => {
18
319
  const grid = createUniformGridSpatialBackend(10);
@@ -95,3 +396,412 @@ describe('createUniformGridSpatialBackend', () => {
95
396
  expect(coarse).toEqual(fine);
96
397
  });
97
398
  });
399
+
400
+ describe('MAX_INDEXED_CELLS_PER_OBJECT', () => {
401
+ it('is the per-object cell budget the oversized path is chosen by', () => {
402
+ const grid = createUniformGridSpatialBackend(1);
403
+ // A square block of exactly the budget stays on the ordinary path; one cell more does not. The
404
+ // budget is a count of cells, not an extent, so it is asserted through the count.
405
+ const side = Math.sqrt(MAX_INDEXED_CELLS_PER_OBJECT);
406
+ expect(Number.isInteger(side)).toBe(true);
407
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: side - 1, maxY: side - 1 });
408
+ expect(grid.explainSpatialIndexing(1)).toEqual({
409
+ bucketCount: MAX_INDEXED_CELLS_PER_OBJECT,
410
+ id: 1,
411
+ mode: 'cells',
412
+ reason: null,
413
+ });
414
+
415
+ grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: side, maxY: side - 1 });
416
+ expect(grid.explainSpatialIndexing(2).mode).toBe('overflow');
417
+ });
418
+ });
419
+
420
+ describe('non-finite bounds', () => {
421
+ it('declines rather than indexing, and answers with the false sentinel', () => {
422
+ const grid = createUniformGridSpatialBackend(10);
423
+ expect(grid.insertSpatialObject(1, { minX: NaN, minY: 0, maxX: 10, maxY: 10 })).toBe(false);
424
+ expect(grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: Infinity, maxY: 10 })).toBe(false);
425
+ expect(grid.insertSpatialObject(3, { minX: 0, minY: -Infinity, maxX: 10, maxY: 10 })).toBe(false);
426
+ expect(grid.explainSpatialIndexing(1)).toEqual({
427
+ bucketCount: 0,
428
+ id: 1,
429
+ mode: 'declined',
430
+ reason: 'non-finite-bounds',
431
+ });
432
+ });
433
+
434
+ it('returns true for finite bounds, so the sentinel distinguishes decline from success', () => {
435
+ const grid = createUniformGridSpatialBackend(10);
436
+ expect(grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
437
+ });
438
+
439
+ it('keeps a declined object out of every query rather than out of some', () => {
440
+ const grid = createUniformGridSpatialBackend(10);
441
+ grid.insertSpatialObject(1, { minX: NaN, minY: NaN, maxX: NaN, maxY: NaN });
442
+ grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
443
+
444
+ const region: SpatialObjectId[] = [];
445
+ grid.querySpatialRegion({ minX: -1e6, minY: -1e6, maxX: 1e6, maxY: 1e6 }, region);
446
+ expect(region).toEqual([2]);
447
+
448
+ const point: SpatialObjectId[] = [];
449
+ grid.querySpatialPoint(5, 5, point);
450
+ expect(point).toEqual([2]);
451
+
452
+ const ray: SpatialObjectId[] = [];
453
+ grid.querySpatialRay(-5, 5, 1, 0, ray);
454
+ expect(ray).toEqual([2]);
455
+
456
+ const pairs: SpatialPair[] = [];
457
+ grid.querySpatialPairs(pairs);
458
+ expect(pairs).toEqual([]);
459
+ });
460
+
461
+ it('drops an object that updates to non-finite bounds instead of stranding it at its old ones', () => {
462
+ const grid = createUniformGridSpatialBackend(10);
463
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
464
+ expect(grid.updateSpatialObject(1, { minX: NaN, minY: NaN, maxX: NaN, maxY: NaN })).toBe(false);
465
+ expect(grid.explainSpatialIndexing(1).mode).toBe('declined');
466
+ const out: SpatialObjectId[] = [];
467
+ grid.querySpatialPoint(5, 5, out);
468
+ expect(out).toEqual([]);
469
+ });
470
+
471
+ it('lets a declined object recover on a later finite update', () => {
472
+ const grid = createUniformGridSpatialBackend(10);
473
+ grid.insertSpatialObject(1, { minX: NaN, minY: NaN, maxX: NaN, maxY: NaN });
474
+ expect(grid.updateSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
475
+ expect(grid.explainSpatialIndexing(1).mode).toBe('cells');
476
+ const out: SpatialObjectId[] = [];
477
+ grid.querySpatialPoint(5, 5, out);
478
+ expect(out).toEqual([1]);
479
+ });
480
+ });
481
+
482
+ describe('oversized region query', () => {
483
+ // The same unbounded-walk hazard from the caller's side: the region is caller-supplied, so a query
484
+ // wider than the world would walk extent-squared cells against a grid holding almost nothing.
485
+ // Measured at 69 ms for a 1000x1000-cell region over a one-object grid before the bound.
486
+ it('answers a region far wider than the grid without walking it cell by cell', () => {
487
+ const grid = createUniformGridSpatialBackend(1);
488
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
489
+ const out: SpatialObjectId[] = [];
490
+ grid.querySpatialRegion({ minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 }, out);
491
+ expect(out).toEqual([1]);
492
+ });
493
+
494
+ it('returns the same objects either way it walks', () => {
495
+ const grid = createUniformGridSpatialBackend(1);
496
+ for (let i = 0; i < 40; i++) grid.insertSpatialObject(i, { minX: i, minY: 0, maxX: i + 0.5, maxY: 1 });
497
+ const wide: SpatialObjectId[] = [];
498
+ grid.querySpatialRegion({ minX: -1e9, minY: -1e9, maxX: 1e9, maxY: 1e9 }, wide);
499
+ const narrow: SpatialObjectId[] = [];
500
+ grid.querySpatialRegion({ minX: -1, minY: -1, maxX: 41, maxY: 2 }, narrow);
501
+ expect([...wide].sort((a, b) => a - b)).toEqual([...narrow].sort((a, b) => a - b));
502
+ expect(wide.length).toBe(40);
503
+ });
504
+
505
+ it('still excludes objects the wide region misses', () => {
506
+ const grid = createUniformGridSpatialBackend(1);
507
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
508
+ grid.insertSpatialObject(2, { minX: 5e11, minY: 0, maxX: 5e11 + 1, maxY: 1 });
509
+ const out: SpatialObjectId[] = [];
510
+ grid.querySpatialRegion({ minX: -1e12, minY: -1e12, maxX: 10, maxY: 1e12 }, out);
511
+ expect(out).toEqual([1]);
512
+ });
513
+ });
514
+
515
+ describe('oversized-extent bound', () => {
516
+ // THE REGRESSION GUARD for the unbounded insert walk.
517
+ //
518
+ // The extent here is deliberately modest — 200 units at one cell per unit, so 40,401 cells, which
519
+ // an unbounded build writes in about 28 ms. That is the point: this assertion FAILS on unbounded
520
+ // code rather than HANGING it, so it stays a usable test. A realistic reproduction (an AABB 1e12
521
+ // wide, which is what a diverging rigid-body simulation actually produces) would be 1e24 cells and
522
+ // would never return, which is exactly why the bound cannot be tested at its motivating scale.
523
+ it('holds an oversized object without writing a cell per unit of its extent', () => {
524
+ const grid = createUniformGridSpatialBackend(1);
525
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 200, maxY: 200 });
526
+ const explanation = grid.explainSpatialIndexing(1);
527
+ expect(explanation.mode).toBe('overflow');
528
+ expect(explanation.bucketCount).toBe(0);
529
+ });
530
+
531
+ it('indexes an AABB far past any walkable extent in constant time', () => {
532
+ // Unreachable for an unbounded build (1e24 cells), so this one cannot be written as a
533
+ // before/after assertion — it is the proof that the motivating case is now O(1). Safe to run only
534
+ // because the assertion above fails first if the bound is ever removed.
535
+ const grid = createUniformGridSpatialBackend(1);
536
+ expect(grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 })).toBe(true);
537
+ expect(grid.explainSpatialIndexing(1).mode).toBe('overflow');
538
+ });
539
+
540
+ it('keeps an oversized object queryable — the bound is a cost decision, not a dropped object', () => {
541
+ const grid = createUniformGridSpatialBackend(1);
542
+ grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
543
+ grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
544
+
545
+ const region: SpatialObjectId[] = [];
546
+ grid.querySpatialRegion({ minX: 100, minY: 100, maxX: 101, maxY: 101 }, region);
547
+ expect(region).toEqual([1]);
548
+
549
+ const point: SpatialObjectId[] = [];
550
+ grid.querySpatialPoint(500, 500, point);
551
+ expect(point).toEqual([1]);
552
+
553
+ const ray: SpatialObjectId[] = [];
554
+ grid.querySpatialRay(1e6, 1e6, 1, 0, ray);
555
+ expect(ray).toEqual([1]);
556
+
557
+ const pairs: SpatialPair[] = [];
558
+ grid.querySpatialPairs(pairs);
559
+ expect(pairKeys(pairs)).toEqual(['1-2']);
560
+ });
561
+
562
+ it('emits each overflow pair once, including overflow against overflow', () => {
563
+ const grid = createUniformGridSpatialBackend(1);
564
+ grid.insertSpatialObject(1, { minX: -1e9, minY: -1e9, maxX: 1e9, maxY: 1e9 });
565
+ grid.insertSpatialObject(2, { minX: -1e9, minY: -1e9, maxX: 1e9, maxY: 1e9 });
566
+ grid.insertSpatialObject(3, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
567
+ const pairs: SpatialPair[] = [];
568
+ grid.querySpatialPairs(pairs);
569
+ expect(pairKeys(pairs)).toEqual(['1-2', '1-3', '2-3']);
570
+ });
571
+
572
+ it('does not pair an overflow object with a disjoint object', () => {
573
+ const grid = createUniformGridSpatialBackend(1);
574
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 100000, maxY: 100000 });
575
+ grid.insertSpatialObject(2, { minX: -50, minY: -50, maxX: -40, maxY: -40 });
576
+ const pairs: SpatialPair[] = [];
577
+ grid.querySpatialPairs(pairs);
578
+ expect(pairs).toEqual([]);
579
+ });
580
+
581
+ it('removes an oversized object without walking its extent, and stops returning it', () => {
582
+ const grid = createUniformGridSpatialBackend(1);
583
+ grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
584
+ grid.removeSpatialObject(1);
585
+ expect(grid.explainSpatialIndexing(1).mode).toBe('absent');
586
+ const out: SpatialObjectId[] = [];
587
+ grid.querySpatialPoint(0, 0, out);
588
+ expect(out).toEqual([]);
589
+ });
590
+
591
+ it('moves an object between the celled and overflow paths as it grows and shrinks', () => {
592
+ const grid = createUniformGridSpatialBackend(1);
593
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 2, maxY: 2 });
594
+ expect(grid.explainSpatialIndexing(1).mode).toBe('cells');
595
+ grid.updateSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
596
+ expect(grid.explainSpatialIndexing(1).mode).toBe('overflow');
597
+ grid.updateSpatialObject(1, { minX: 0, minY: 0, maxX: 2, maxY: 2 });
598
+ expect(grid.explainSpatialIndexing(1).mode).toBe('cells');
599
+
600
+ // Back on the ordinary path the object must be findable through the cells again, not stranded.
601
+ const out: SpatialObjectId[] = [];
602
+ grid.querySpatialPoint(1, 1, out);
603
+ expect(out).toEqual([1]);
604
+ });
605
+
606
+ it('clears overflow with the rest of the index', () => {
607
+ const grid = createUniformGridSpatialBackend(1);
608
+ grid.insertSpatialObject(1, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
609
+ grid.clearSpatialIndex();
610
+ expect(grid.explainSpatialIndexing(1).mode).toBe('absent');
611
+ const out: SpatialObjectId[] = [];
612
+ grid.querySpatialPoint(0, 0, out);
613
+ expect(out).toEqual([]);
614
+ });
615
+
616
+ it('keeps an oversized object out of the ray-traversal cell range', () => {
617
+ // Without the bound the occupied cell range stretches to the oversized object's span, and every
618
+ // subsequent ray walks it. Two small objects far apart bound the range; the oversized one must
619
+ // not widen it, which shows up as the ray still resolving correctly and promptly.
620
+ const grid = createUniformGridSpatialBackend(1);
621
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 1, maxY: 1 });
622
+ grid.insertSpatialObject(2, { minX: -1e12, minY: -1e12, maxX: 1e12, maxY: 1e12 });
623
+ const out: SpatialObjectId[] = [];
624
+ grid.querySpatialRay(-5, 0.5, 1, 0, out);
625
+ expect(out.sort()).toEqual([1, 2]);
626
+ });
627
+ });
628
+
629
+ describe('ray edge cases', () => {
630
+ it('finds objects along a ray that passes exactly through cell corners', () => {
631
+ const grid = createUniformGridSpatialBackend(10);
632
+ grid.insertSpatialObject(1, { minX: 10, minY: 10, maxX: 12, maxY: 12 });
633
+ grid.insertSpatialObject(2, { minX: 20, minY: 20, maxX: 22, maxY: 22 });
634
+ const out: SpatialObjectId[] = [];
635
+ grid.querySpatialRay(-5, -5, 1, 1, out);
636
+ expect(sortedIds(out)).toEqual([1, 2]);
637
+ });
638
+
639
+ it('finds an object when the ray starts inside its bounds', () => {
640
+ const grid = createUniformGridSpatialBackend(10);
641
+ grid.insertSpatialObject(1, { minX: 5, minY: 5, maxX: 15, maxY: 15 });
642
+ const out: SpatialObjectId[] = [];
643
+ grid.querySpatialRay(10, 10, -1, 0, out);
644
+ expect(out).toEqual([1]);
645
+ });
646
+
647
+ it('clips a ray entering the occupied range from far outside', () => {
648
+ const grid = createUniformGridSpatialBackend(10);
649
+ grid.insertSpatialObject(1, { minX: 100, minY: 30, maxX: 110, maxY: 40 });
650
+ const out: SpatialObjectId[] = [];
651
+ grid.querySpatialRay(-1e9, 35, 1, 0, out);
652
+ expect(out).toEqual([1]);
653
+ });
654
+ });
655
+
656
+ describe('setSpatialIndexingGuard', () => {
657
+ it('reports a decline with its reason, and no span', () => {
658
+ const notices: SpatialIndexingNotice[] = [];
659
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
660
+ const grid = createUniformGridSpatialBackend(10);
661
+ grid.insertSpatialObject(7, { minX: NaN, minY: 0, maxX: 10, maxY: 10 });
662
+ expect(notices).toEqual([
663
+ {
664
+ cellSize: 10,
665
+ id: 7,
666
+ mode: 'declined',
667
+ operation: 'insert',
668
+ reason: 'non-finite-bounds',
669
+ wouldOccupyBucketCount: 0,
670
+ },
671
+ ]);
672
+ });
673
+
674
+ it('reports an overflow with the span the bound refused to walk', () => {
675
+ const notices: SpatialIndexingNotice[] = [];
676
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
677
+ const grid = createUniformGridSpatialBackend(1);
678
+ grid.insertSpatialObject(7, { minX: 0, minY: 0, maxX: 199, maxY: 199 });
679
+ expect(notices).toEqual([
680
+ {
681
+ cellSize: 1,
682
+ id: 7,
683
+ mode: 'overflow',
684
+ operation: 'insert',
685
+ reason: null,
686
+ wouldOccupyBucketCount: 40000,
687
+ },
688
+ ]);
689
+ });
690
+
691
+ it('reports an invalid cell size and keeps results correct through the bounded overflow path', () => {
692
+ const notices: SpatialIndexingNotice[] = [];
693
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
694
+ for (const cellSize of [0, -1]) {
695
+ const grid = createUniformGridSpatialBackend(cellSize);
696
+ expect(grid.insertSpatialObject(7, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
697
+ const point: SpatialObjectId[] = [];
698
+ grid.querySpatialPoint(5, 5, point);
699
+ expect(point).toEqual([7]);
700
+ }
701
+ expect(notices).toEqual(
702
+ [0, -1].map((cellSize) => ({
703
+ cellSize,
704
+ id: 7,
705
+ mode: 'overflow',
706
+ operation: 'insert',
707
+ reason: 'invalid-cell-size',
708
+ wouldOccupyBucketCount: 0,
709
+ })),
710
+ );
711
+ });
712
+
713
+ it('declines and reports inverted bounds', () => {
714
+ const notices: SpatialIndexingNotice[] = [];
715
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
716
+ const grid = createUniformGridSpatialBackend(10);
717
+ expect(grid.insertSpatialObject(7, { minX: 10, minY: 0, maxX: 0, maxY: 10 })).toBe(false);
718
+ expect(grid.explainSpatialIndexing(7)).toEqual({
719
+ bucketCount: 0,
720
+ id: 7,
721
+ mode: 'declined',
722
+ reason: 'inverted-bounds',
723
+ });
724
+ expect(notices).toEqual([
725
+ {
726
+ cellSize: 10,
727
+ id: 7,
728
+ mode: 'declined',
729
+ operation: 'insert',
730
+ reason: 'inverted-bounds',
731
+ wouldOccupyBucketCount: 0,
732
+ },
733
+ ]);
734
+ });
735
+
736
+ it('reports update and remove operations whose id was never inserted', () => {
737
+ const notices: SpatialIndexingNotice[] = [];
738
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
739
+ const grid = createUniformGridSpatialBackend(10);
740
+ expect(grid.updateSpatialObject(7, { minX: 0, minY: 0, maxX: 5, maxY: 5 })).toBe(true);
741
+ grid.removeSpatialObject(8);
742
+ expect(notices).toEqual([
743
+ {
744
+ cellSize: 10,
745
+ id: 7,
746
+ mode: 'cells',
747
+ operation: 'update',
748
+ reason: 'missing-id',
749
+ wouldOccupyBucketCount: 0,
750
+ },
751
+ {
752
+ cellSize: 10,
753
+ id: 8,
754
+ mode: 'absent',
755
+ operation: 'remove',
756
+ reason: 'missing-id',
757
+ wouldOccupyBucketCount: 0,
758
+ },
759
+ ]);
760
+ });
761
+
762
+ it('stays silent on the ordinary path', () => {
763
+ const notices: SpatialIndexingNotice[] = [];
764
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
765
+ const grid = createUniformGridSpatialBackend(10);
766
+ grid.insertSpatialObject(1, { minX: 0, minY: 0, maxX: 10, maxY: 10 });
767
+ grid.removeSpatialObject(1);
768
+ expect(notices).toEqual([]);
769
+ });
770
+
771
+ it('null uninstalls it', () => {
772
+ const notices: SpatialIndexingNotice[] = [];
773
+ setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
774
+ setSpatialIndexingGuard(null);
775
+ const grid = createUniformGridSpatialBackend(10);
776
+ grid.insertSpatialObject(1, { minX: NaN, minY: 0, maxX: 10, maxY: 10 });
777
+ expect(notices).toEqual([]);
778
+ });
779
+
780
+ it('does not change what insert returns', () => {
781
+ setSpatialIndexingGuard(() => {});
782
+ const grid = createUniformGridSpatialBackend(10);
783
+ expect(grid.insertSpatialObject(1, { minX: NaN, minY: 0, maxX: 10, maxY: 10 })).toBe(false);
784
+ expect(grid.insertSpatialObject(2, { minX: 0, minY: 0, maxX: 10, maxY: 10 })).toBe(true);
785
+ });
786
+ });
787
+
788
+ describe('updateSpatialObject', () => {
789
+ it('refreshes exact bounds without retaining the caller object when the covered cells stay unchanged', () => {
790
+ const grid = createUniformGridSpatialBackend(10);
791
+ grid.insertSpatialObject(1, { minX: 1, minY: 1, maxX: 18, maxY: 18 });
792
+ const updated = { minX: 4, minY: 4, maxX: 19, maxY: 19 };
793
+
794
+ expect(grid.updateSpatialObject(1, updated)).toBe(true);
795
+ updated.minX = -100;
796
+ updated.minY = -100;
797
+ updated.maxX = 100;
798
+ updated.maxY = 100;
799
+
800
+ const oldPoint: SpatialObjectId[] = [];
801
+ grid.querySpatialPoint(2, 2, oldPoint);
802
+ expect(oldPoint).toEqual([]);
803
+ const newPoint: SpatialObjectId[] = [];
804
+ grid.querySpatialPoint(5, 5, newPoint);
805
+ expect(newPoint).toEqual([1]);
806
+ });
807
+ });