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