@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.
- package/dist/bvh3D.d.ts +3 -0
- package/dist/bvh3D.d.ts.map +1 -0
- package/dist/bvh3D.js +575 -0
- package/dist/bvh3D.js.map +1 -0
- package/dist/contract.d.ts +6 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +6 -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/explainSpatialIndexing3D.d.ts +3 -0
- package/dist/explainSpatialIndexing3D.d.ts.map +1 -0
- package/dist/explainSpatialIndexing3D.js +15 -0
- package/dist/explainSpatialIndexing3D.js.map +1 -0
- package/dist/formatSpatialIndexingNotice.d.ts.map +1 -1
- package/dist/formatSpatialIndexingNotice.js +8 -2
- 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/spatialIndex3D.d.ts +13 -0
- package/dist/spatialIndex3D.d.ts.map +1 -0
- package/dist/spatialIndex3D.js +166 -0
- package/dist/spatialIndex3D.js.map +1 -0
- package/dist/spatialIndexingGuard.d.ts +4 -0
- package/dist/spatialIndexingGuard.d.ts.map +1 -0
- package/dist/spatialIndexingGuard.js +23 -0
- package/dist/spatialIndexingGuard.js.map +1 -0
- package/dist/uniformGrid.d.ts +2 -3
- package/dist/uniformGrid.d.ts.map +1 -1
- package/dist/uniformGrid.js +5 -15
- package/dist/uniformGrid.js.map +1 -1
- package/dist/uniformGrid3D.d.ts +3 -0
- package/dist/uniformGrid3D.d.ts.map +1 -0
- package/dist/uniformGrid3D.js +627 -0
- package/dist/uniformGrid3D.js.map +1 -0
- package/package.json +3 -3
- package/src/bvh3D.test.ts +294 -0
- package/src/explainSpatialIndexing2D.test.ts +81 -0
- package/src/explainSpatialIndexing3D.test.ts +59 -0
- package/src/formatSpatialIndexingNotice.test.ts +30 -4
- package/src/spatialIndex.test.ts +65 -65
- package/src/spatialIndex3D.test.ts +273 -0
- package/src/spatialIndexingGuard.test.ts +188 -0
- package/src/uniformGrid.test.ts +54 -182
- package/src/uniformGrid3D.test.ts +358 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/spatial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-edge.1908.751021c",
|
|
4
4
|
"author": "Joshua Granick and other contributors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@flighthq/geometry": "0.
|
|
42
|
-
"@flighthq/types": "0.
|
|
41
|
+
"@flighthq/geometry": "0.4.0-edge.1908.751021c",
|
|
42
|
+
"@flighthq/types": "0.4.0-edge.1908.751021c"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"typescript": "^5.3.0"
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { SpatialAabb3D, SpatialIndexBackend3D, SpatialObjectId, SpatialPair } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createBvhSpatialBackend3D } from './bvh3D';
|
|
5
|
+
import { setSpatialIndexingGuard } from './spatialIndexingGuard';
|
|
6
|
+
import { createUniformGridSpatialBackend3D } from './uniformGrid3D';
|
|
7
|
+
|
|
8
|
+
// The BVH is tested against the UNIFORM GRID, not against itself. The grid is the proven incumbent and
|
|
9
|
+
// the two are meant to be interchangeable behind one seam, so any disagreement is a defect in one of
|
|
10
|
+
// them — which is a far stronger statement than "the tree returns something plausible".
|
|
11
|
+
//
|
|
12
|
+
// Sets are compared, never order: the seam promises which ids match, and two structures walk their
|
|
13
|
+
// contents in different orders by construction.
|
|
14
|
+
|
|
15
|
+
function createRandom(seed: number): () => number {
|
|
16
|
+
let state = seed | 0 || 1;
|
|
17
|
+
return () => {
|
|
18
|
+
state ^= state << 13;
|
|
19
|
+
state ^= state >>> 17;
|
|
20
|
+
state ^= state << 5;
|
|
21
|
+
return ((state >>> 0) % 100000) / 100000;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function box(x: number, y: number, z: number, size: number): SpatialAabb3D {
|
|
26
|
+
return { maxX: x + size, maxY: y + size, maxZ: z + size, minX: x, minY: y, minZ: z };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sorted(ids: readonly SpatialObjectId[]): SpatialObjectId[] {
|
|
30
|
+
return [...ids].sort((a, b) => a - b);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sortedPairs(pairs: readonly SpatialPair[]): string[] {
|
|
34
|
+
return pairs.map((pair) => `${String(Math.min(pair.a, pair.b))}-${String(Math.max(pair.a, pair.b))}`).sort();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('createBvhSpatialBackend3D', () => {
|
|
38
|
+
it('retains pair objects across a steady-topology query', () => {
|
|
39
|
+
const bvh = createBvhSpatialBackend3D(1);
|
|
40
|
+
bvh.insertSpatialObject(1, box(0, 0, 0, 2));
|
|
41
|
+
bvh.insertSpatialObject(2, box(1, 1, 1, 2));
|
|
42
|
+
const out: SpatialPair[] = [];
|
|
43
|
+
bvh.querySpatialPairs(out);
|
|
44
|
+
const pair = out[0];
|
|
45
|
+
|
|
46
|
+
bvh.querySpatialPairs(out);
|
|
47
|
+
|
|
48
|
+
expect(out[0]).toBe(pair);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('answers every query exactly as the uniform grid does, over a seeded scene', () => {
|
|
52
|
+
const random = createRandom(6112026);
|
|
53
|
+
const bvh = createBvhSpatialBackend3D(2);
|
|
54
|
+
const grid = createUniformGridSpatialBackend3D(16);
|
|
55
|
+
|
|
56
|
+
// Deliberately wide size variance — the case a uniform grid handles worst and a tree handles well.
|
|
57
|
+
// If the two agree here they agree anywhere.
|
|
58
|
+
for (let id = 0; id < 120; id += 1) {
|
|
59
|
+
const size = id % 12 === 0 ? 40 + random() * 30 : 0.5 + random() * 4;
|
|
60
|
+
const bounds = box((random() - 0.5) * 400, (random() - 0.5) * 400, (random() - 0.5) * 400, size);
|
|
61
|
+
expect(bvh.insertSpatialObject(id, bounds)).toBe(grid.insertSpatialObject(id, bounds));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const bvhOut: SpatialObjectId[] = [];
|
|
65
|
+
const gridOut: SpatialObjectId[] = [];
|
|
66
|
+
|
|
67
|
+
for (let probe = 0; probe < 60; probe += 1) {
|
|
68
|
+
const region = box((random() - 0.5) * 400, (random() - 0.5) * 400, (random() - 0.5) * 400, 10 + random() * 60);
|
|
69
|
+
bvh.querySpatialRegion(region, bvhOut);
|
|
70
|
+
grid.querySpatialRegion(region, gridOut);
|
|
71
|
+
expect(sorted(bvhOut), `region probe ${String(probe)}`).toEqual(sorted(gridOut));
|
|
72
|
+
|
|
73
|
+
const px = (random() - 0.5) * 400;
|
|
74
|
+
const py = (random() - 0.5) * 400;
|
|
75
|
+
const pz = (random() - 0.5) * 400;
|
|
76
|
+
bvh.querySpatialPoint(px, py, pz, bvhOut);
|
|
77
|
+
grid.querySpatialPoint(px, py, pz, gridOut);
|
|
78
|
+
expect(sorted(bvhOut), `point probe ${String(probe)}`).toEqual(sorted(gridOut));
|
|
79
|
+
|
|
80
|
+
const dx = random() - 0.5;
|
|
81
|
+
const dy = random() - 0.5;
|
|
82
|
+
const dz = random() - 0.5;
|
|
83
|
+
bvh.querySpatialRay(px, py, pz, dx, dy, dz, bvhOut);
|
|
84
|
+
grid.querySpatialRay(px, py, pz, dx, dy, dz, gridOut);
|
|
85
|
+
expect(sorted(bvhOut), `ray probe ${String(probe)}`).toEqual(sorted(gridOut));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports every TRULY overlapping pair after inserts, moves, and removals', () => {
|
|
90
|
+
const random = createRandom(778291);
|
|
91
|
+
const bvh = createBvhSpatialBackend3D(1.5);
|
|
92
|
+
const grid = createUniformGridSpatialBackend3D(12);
|
|
93
|
+
|
|
94
|
+
// The oracle's own record of where everything ended up, kept alongside rather than read back out of
|
|
95
|
+
// either index — a truth set derived from the thing under test would prove nothing.
|
|
96
|
+
const live = new Map<number, SpatialAabb3D>();
|
|
97
|
+
for (let id = 0; id < 60; id += 1) {
|
|
98
|
+
const bounds = box((random() - 0.5) * 80, (random() - 0.5) * 80, (random() - 0.5) * 80, 1 + random() * 5);
|
|
99
|
+
bvh.insertSpatialObject(id, bounds);
|
|
100
|
+
grid.insertSpatialObject(id, bounds);
|
|
101
|
+
live.set(id, bounds);
|
|
102
|
+
}
|
|
103
|
+
for (let id = 0; id < 60; id += 3) {
|
|
104
|
+
const bounds = box((random() - 0.5) * 80, (random() - 0.5) * 80, (random() - 0.5) * 80, 1 + random() * 5);
|
|
105
|
+
bvh.updateSpatialObject(id, bounds);
|
|
106
|
+
grid.updateSpatialObject(id, bounds);
|
|
107
|
+
live.set(id, bounds);
|
|
108
|
+
}
|
|
109
|
+
for (let id = 1; id < 60; id += 7) {
|
|
110
|
+
bvh.removeSpatialObject(id);
|
|
111
|
+
grid.removeSpatialObject(id);
|
|
112
|
+
live.delete(id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const bvhPairs: SpatialPair[] = [];
|
|
116
|
+
const gridPairs: SpatialPair[] = [];
|
|
117
|
+
bvh.querySpatialPairs(bvhPairs);
|
|
118
|
+
grid.querySpatialPairs(gridPairs);
|
|
119
|
+
|
|
120
|
+
// NOT set equality, and the difference is the contract rather than a defect. A pair is a CANDIDATE:
|
|
121
|
+
// the grid emits everything sharing a cell without testing bounds at all, so its answer is a strict
|
|
122
|
+
// superset, while the tree tests overlap at the leaf and returns the true set. Both are legal; what
|
|
123
|
+
// both owe is that no real overlap is missing. Asserting equality here would have been asserting that
|
|
124
|
+
// two backends make the same conservative approximation, which the seam never promised.
|
|
125
|
+
const truth = new Set<string>();
|
|
126
|
+
for (let a = 0; a < 60; a += 1) {
|
|
127
|
+
for (let b = a + 1; b < 60; b += 1) {
|
|
128
|
+
const boundsA = live.get(a);
|
|
129
|
+
const boundsB = live.get(b);
|
|
130
|
+
if (boundsA === undefined || boundsB === undefined) continue;
|
|
131
|
+
if (
|
|
132
|
+
boundsA.minX < boundsB.maxX &&
|
|
133
|
+
boundsA.maxX > boundsB.minX &&
|
|
134
|
+
boundsA.minY < boundsB.maxY &&
|
|
135
|
+
boundsA.maxY > boundsB.minY &&
|
|
136
|
+
boundsA.minZ < boundsB.maxZ &&
|
|
137
|
+
boundsA.maxZ > boundsB.minZ
|
|
138
|
+
) {
|
|
139
|
+
truth.add(`${String(a)}-${String(b)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const bvhSet = new Set(sortedPairs(bvhPairs));
|
|
145
|
+
const gridSet = new Set(sortedPairs(gridPairs));
|
|
146
|
+
expect(truth.size).toBeGreaterThan(0);
|
|
147
|
+
for (const pair of truth) {
|
|
148
|
+
expect(bvhSet.has(pair), `bvh missed ${pair}`).toBe(true);
|
|
149
|
+
expect(gridSet.has(pair), `grid missed ${pair}`).toBe(true);
|
|
150
|
+
}
|
|
151
|
+
// No self-pairs and no duplicates, which both backends do owe exactly.
|
|
152
|
+
expect(sortedPairs(bvhPairs)).toEqual([...bvhSet].sort());
|
|
153
|
+
expect(bvhPairs.every((pair) => pair.a !== pair.b)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps answering correctly while objects move within their margin', () => {
|
|
157
|
+
// The fat-bounds fast path: a small move leaves the tree untouched, so only the exact bounds are
|
|
158
|
+
// refreshed. If they were not, the query would answer from where the object USED to be — the exact
|
|
159
|
+
// failure the margin risks introducing.
|
|
160
|
+
const bvh = createBvhSpatialBackend3D(10);
|
|
161
|
+
bvh.insertSpatialObject(1, box(0, 0, 0, 1));
|
|
162
|
+
|
|
163
|
+
const out: SpatialObjectId[] = [];
|
|
164
|
+
bvh.querySpatialPoint(0.5, 0.5, 0.5, out);
|
|
165
|
+
expect(out).toEqual([1]);
|
|
166
|
+
|
|
167
|
+
// Well inside the 10-unit margin, so no reinsertion happens.
|
|
168
|
+
bvh.updateSpatialObject(1, box(4, 0, 0, 1));
|
|
169
|
+
bvh.querySpatialPoint(0.5, 0.5, 0.5, out);
|
|
170
|
+
expect(out).toEqual([]);
|
|
171
|
+
bvh.querySpatialPoint(4.5, 0.5, 0.5, out);
|
|
172
|
+
expect(out).toEqual([1]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('declines non-finite and inverted bounds with the same sentinel as the grid', () => {
|
|
176
|
+
const bvh = createBvhSpatialBackend3D();
|
|
177
|
+
const grid = createUniformGridSpatialBackend3D(16);
|
|
178
|
+
const nonFinite: SpatialAabb3D = { maxX: 1, maxY: 1, maxZ: 1, minX: NaN, minY: 0, minZ: 0 };
|
|
179
|
+
const inverted: SpatialAabb3D = { maxX: -1, maxY: 1, maxZ: 1, minX: 0, minY: 0, minZ: 0 };
|
|
180
|
+
|
|
181
|
+
expect(bvh.insertSpatialObject(1, nonFinite)).toBe(grid.insertSpatialObject(1, nonFinite));
|
|
182
|
+
expect(bvh.insertSpatialObject(2, inverted)).toBe(grid.insertSpatialObject(2, inverted));
|
|
183
|
+
expect(bvh.explainSpatialIndexing(1).mode).toBe('declined');
|
|
184
|
+
expect(bvh.explainSpatialIndexing(1).reason).toBe('non-finite-bounds');
|
|
185
|
+
expect(bvh.explainSpatialIndexing(2).reason).toBe('inverted-bounds');
|
|
186
|
+
|
|
187
|
+
const out: SpatialObjectId[] = [];
|
|
188
|
+
bvh.querySpatialRegion(box(-100, -100, -100, 200), out);
|
|
189
|
+
expect(out).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('removes an object whose update is declined rather than leaving it at its old bounds', () => {
|
|
193
|
+
// The seam's contract: a declined update must not read as a current position. Kept identical to the
|
|
194
|
+
// grid's behaviour, since a caller ignoring the sentinel must not see the two backends differ.
|
|
195
|
+
const bvh = createBvhSpatialBackend3D();
|
|
196
|
+
bvh.insertSpatialObject(1, box(0, 0, 0, 4));
|
|
197
|
+
expect(bvh.updateSpatialObject(1, { maxX: 1, maxY: 1, maxZ: 1, minX: Infinity, minY: 0, minZ: 0 })).toBe(false);
|
|
198
|
+
|
|
199
|
+
const out: SpatialObjectId[] = [];
|
|
200
|
+
bvh.querySpatialRegion(box(-10, -10, -10, 40), out);
|
|
201
|
+
expect(out).toEqual([]);
|
|
202
|
+
expect(bvh.explainSpatialIndexing(1).mode).toBe('declined');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('reports absent for an id it has never seen, and after a clear', () => {
|
|
206
|
+
const bvh = createBvhSpatialBackend3D();
|
|
207
|
+
expect(bvh.explainSpatialIndexing(99).mode).toBe('absent');
|
|
208
|
+
bvh.insertSpatialObject(99, box(0, 0, 0, 1));
|
|
209
|
+
expect(bvh.explainSpatialIndexing(99).mode).toBe('cells');
|
|
210
|
+
bvh.clearSpatialIndex();
|
|
211
|
+
expect(bvh.explainSpatialIndexing(99).mode).toBe('absent');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('stays balanced under sorted insertion, which is the case that degenerates a naive tree', () => {
|
|
215
|
+
// A run of monotonically increasing boxes is the worst input for an unbalanced tree: it builds a
|
|
216
|
+
// linked list and every query goes linear. Measured indirectly through the query still being exact
|
|
217
|
+
// over a large sorted set, which a broken rotation would break outright rather than merely slow.
|
|
218
|
+
const bvh = createBvhSpatialBackend3D(0);
|
|
219
|
+
const grid = createUniformGridSpatialBackend3D(8);
|
|
220
|
+
for (let id = 0; id < 400; id += 1) {
|
|
221
|
+
const bounds = box(id * 3, 0, 0, 2);
|
|
222
|
+
bvh.insertSpatialObject(id, bounds);
|
|
223
|
+
grid.insertSpatialObject(id, bounds);
|
|
224
|
+
}
|
|
225
|
+
const bvhOut: SpatialObjectId[] = [];
|
|
226
|
+
const gridOut: SpatialObjectId[] = [];
|
|
227
|
+
bvh.querySpatialRegion(box(300, -5, -5, 60), bvhOut);
|
|
228
|
+
grid.querySpatialRegion(box(300, -5, -5, 60), gridOut);
|
|
229
|
+
expect(sorted(bvhOut)).toEqual(sorted(gridOut));
|
|
230
|
+
expect(bvhOut.length).toBeGreaterThan(5);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('is interchangeable with the grid behind createSpatialIndex3D', () => {
|
|
234
|
+
// The seam's whole promise. Both are SpatialIndexBackend3D and neither is named in the call.
|
|
235
|
+
const backends: SpatialIndexBackend3D[] = [createBvhSpatialBackend3D(), createUniformGridSpatialBackend3D(16)];
|
|
236
|
+
const results = backends.map((backend) => {
|
|
237
|
+
backend.insertSpatialObject(1, box(0, 0, 0, 5));
|
|
238
|
+
backend.insertSpatialObject(2, box(100, 0, 0, 5));
|
|
239
|
+
const out: SpatialObjectId[] = [];
|
|
240
|
+
backend.querySpatialRegion(box(-1, -1, -1, 10), out);
|
|
241
|
+
return sorted(out);
|
|
242
|
+
});
|
|
243
|
+
expect(results[0]).toEqual(results[1]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// The queries were compared against the grid from the start; the NOTICES were not, which is how a
|
|
247
|
+
// backend that reported every ordinary success shipped. Same differential method, applied to the
|
|
248
|
+
// other half of the seam.
|
|
249
|
+
it('reports the same indexing notices as the grid, for the same operations', () => {
|
|
250
|
+
const script = (backend: SpatialIndexBackend3D): void => {
|
|
251
|
+
backend.insertSpatialObject(1, box(0, 0, 0, 1));
|
|
252
|
+
backend.updateSpatialObject(1, box(0.1, 0, 0, 1));
|
|
253
|
+
backend.updateSpatialObject(1, box(50, 0, 0, 1));
|
|
254
|
+
backend.removeSpatialObject(1);
|
|
255
|
+
backend.removeSpatialObject(404);
|
|
256
|
+
backend.updateSpatialObject(505, box(0, 0, 0, 1));
|
|
257
|
+
backend.insertSpatialObject(2, box(NaN, 0, 0, 1));
|
|
258
|
+
const inverted: SpatialAabb3D = { minX: 5, minY: 0, minZ: 0, maxX: 1, maxY: 1, maxZ: 1 };
|
|
259
|
+
backend.insertSpatialObject(3, inverted);
|
|
260
|
+
};
|
|
261
|
+
// Mode and bucket count are structural — a tree has no cells to count — so this compares the part
|
|
262
|
+
// the seam does promise: which operation on which id was worth telling the caller about, and why.
|
|
263
|
+
const record = (backend: SpatialIndexBackend3D): string[] => {
|
|
264
|
+
const seen: string[] = [];
|
|
265
|
+
setSpatialIndexingGuard((notice) => seen.push(`${notice.operation}:${notice.id}:${notice.reason ?? 'null'}`));
|
|
266
|
+
script(backend);
|
|
267
|
+
setSpatialIndexingGuard(null);
|
|
268
|
+
return seen;
|
|
269
|
+
};
|
|
270
|
+
const bvh = record(createBvhSpatialBackend3D());
|
|
271
|
+
expect(bvh).toEqual(record(createUniformGridSpatialBackend3D(16)));
|
|
272
|
+
// Pinned literally too, so a change that made BOTH backends noisy could not pass by agreeing.
|
|
273
|
+
expect(bvh).toEqual([
|
|
274
|
+
'remove:404:missing-id',
|
|
275
|
+
'update:505:missing-id',
|
|
276
|
+
'insert:2:non-finite-bounds',
|
|
277
|
+
'insert:3:inverted-bounds',
|
|
278
|
+
]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('says nothing at all while healthy objects are indexed and moved', () => {
|
|
282
|
+
// The cost of getting this wrong is not a wrong answer, it is a guard that fires once per object
|
|
283
|
+
// per step and buries the one notice that matters in thousands that do not.
|
|
284
|
+
const notices: string[] = [];
|
|
285
|
+
setSpatialIndexingGuard((notice) => notices.push(notice.operation));
|
|
286
|
+
const bvh = createBvhSpatialBackend3D();
|
|
287
|
+
for (let id = 0; id < 20; id += 1) bvh.insertSpatialObject(id, box(id * 4, 0, 0, 1));
|
|
288
|
+
for (let step = 0; step < 100; step += 1) {
|
|
289
|
+
for (let id = 0; id < 20; id += 1) bvh.updateSpatialObject(id, box(id * 4 + step * 0.5, 0, 0, 1));
|
|
290
|
+
}
|
|
291
|
+
setSpatialIndexingGuard(null);
|
|
292
|
+
expect(notices).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { SpatialObjectId } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { explainSpatialIndexing2D } from './explainSpatialIndexing2D';
|
|
5
|
+
import {
|
|
6
|
+
createSpatialIndex2D,
|
|
7
|
+
insertSpatialObject2D,
|
|
8
|
+
removeSpatialObject2D,
|
|
9
|
+
updateSpatialObject2D,
|
|
10
|
+
} from './spatialIndex';
|
|
11
|
+
import { MAX_INDEXED_CELLS_PER_OBJECT, createUniformGridSpatialBackend2D } from './uniformGrid';
|
|
12
|
+
|
|
13
|
+
describe('explainSpatialIndexing2D', () => {
|
|
14
|
+
it('reports absent for an id that was never inserted', () => {
|
|
15
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(10));
|
|
16
|
+
expect(explainSpatialIndexing2D(index, 42)).toEqual({ bucketCount: 0, id: 42, mode: 'absent', reason: null });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reports the bucket count of an ordinary object', () => {
|
|
20
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(10));
|
|
21
|
+
insertSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: 25, maxY: 25 });
|
|
22
|
+
// Covers cells 0..2 on each axis.
|
|
23
|
+
expect(explainSpatialIndexing2D(index, 1)).toEqual({ bucketCount: 9, id: 1, mode: 'cells', reason: null });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('reports overflow with no buckets for an oversized object', () => {
|
|
27
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(1));
|
|
28
|
+
insertSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: 1e6, maxY: 1e6 });
|
|
29
|
+
expect(explainSpatialIndexing2D(index, 1)).toEqual({ bucketCount: 0, id: 1, mode: 'overflow', reason: null });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports declined with the reason for non-finite bounds', () => {
|
|
33
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(10));
|
|
34
|
+
insertSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: Infinity, maxY: 10 });
|
|
35
|
+
expect(explainSpatialIndexing2D(index, 1)).toEqual({
|
|
36
|
+
bucketCount: 0,
|
|
37
|
+
id: 1,
|
|
38
|
+
mode: 'declined',
|
|
39
|
+
reason: 'non-finite-bounds',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns to absent after a remove', () => {
|
|
44
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(10));
|
|
45
|
+
insertSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: 5, maxY: 5 });
|
|
46
|
+
removeSpatialObject2D(index, 1);
|
|
47
|
+
expect(explainSpatialIndexing2D(index, 1).mode).toBe('absent');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('tracks the mode across updates', () => {
|
|
51
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(1));
|
|
52
|
+
insertSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: 2, maxY: 2 });
|
|
53
|
+
expect(explainSpatialIndexing2D(index, 1).mode).toBe('cells');
|
|
54
|
+
updateSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: 1e9, maxY: 1e9 });
|
|
55
|
+
expect(explainSpatialIndexing2D(index, 1).mode).toBe('overflow');
|
|
56
|
+
updateSpatialObject2D(index, 1, { minX: NaN, minY: 0, maxX: 2, maxY: 2 });
|
|
57
|
+
expect(explainSpatialIndexing2D(index, 1).mode).toBe('declined');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('never exceeds the per-object bucket budget it exists to measure', () => {
|
|
61
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(1));
|
|
62
|
+
const ids: SpatialObjectId[] = [];
|
|
63
|
+
for (let i = 0; i < 8; i++) {
|
|
64
|
+
const extent = 10 ** i;
|
|
65
|
+
insertSpatialObject2D(index, i, { minX: 0, minY: 0, maxX: extent, maxY: extent });
|
|
66
|
+
ids.push(i);
|
|
67
|
+
}
|
|
68
|
+
for (const id of ids) {
|
|
69
|
+
expect(explainSpatialIndexing2D(index, id).bucketCount).toBeLessThanOrEqual(MAX_INDEXED_CELLS_PER_OBJECT);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('mutates nothing — repeated calls report the same thing', () => {
|
|
74
|
+
const index = createSpatialIndex2D(createUniformGridSpatialBackend2D(10));
|
|
75
|
+
insertSpatialObject2D(index, 1, { minX: 0, minY: 0, maxX: 5, maxY: 5 });
|
|
76
|
+
const first = explainSpatialIndexing2D(index, 1);
|
|
77
|
+
expect(explainSpatialIndexing2D(index, 1)).toEqual(first);
|
|
78
|
+
expect(explainSpatialIndexing2D(index, 99).mode).toBe('absent');
|
|
79
|
+
expect(explainSpatialIndexing2D(index, 1)).toEqual(first);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SpatialAabb3D } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { explainSpatialIndexing3D } from './explainSpatialIndexing3D';
|
|
5
|
+
import { createSpatialIndex3D, insertSpatialObject3D, removeSpatialObject3D } from './spatialIndex3D';
|
|
6
|
+
import { MAX_INDEXED_CELLS_PER_OBJECT } from './uniformGrid';
|
|
7
|
+
import { createUniformGridSpatialBackend3D } from './uniformGrid3D';
|
|
8
|
+
|
|
9
|
+
function box(minX: number, minY: number, minZ: number, size: number): SpatialAabb3D {
|
|
10
|
+
return { minX, minY, minZ, maxX: minX + size, maxY: minY + size, maxZ: minZ + size };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('explainSpatialIndexing3D', () => {
|
|
14
|
+
it('reports absent for an id that was never inserted', () => {
|
|
15
|
+
const index = createSpatialIndex3D();
|
|
16
|
+
expect(explainSpatialIndexing3D(index, 42)).toEqual({
|
|
17
|
+
bucketCount: 0,
|
|
18
|
+
id: 42,
|
|
19
|
+
mode: 'absent',
|
|
20
|
+
reason: null,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('reports absent after removal', () => {
|
|
25
|
+
const index = createSpatialIndex3D();
|
|
26
|
+
insertSpatialObject3D(index, 1, box(0, 0, 0, 10));
|
|
27
|
+
removeSpatialObject3D(index, 1);
|
|
28
|
+
expect(explainSpatialIndexing3D(index, 1).mode).toBe('absent');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports declined with the reason the bounds were refused', () => {
|
|
32
|
+
const index = createSpatialIndex3D();
|
|
33
|
+
insertSpatialObject3D(index, 1, { minX: 0, minY: 0, minZ: NaN, maxX: 1, maxY: 1, maxZ: 1 });
|
|
34
|
+
insertSpatialObject3D(index, 2, { minX: 0, minY: 0, minZ: 9, maxX: 1, maxY: 1, maxZ: 0 });
|
|
35
|
+
expect(explainSpatialIndexing3D(index, 1).reason).toBe('non-finite-bounds');
|
|
36
|
+
expect(explainSpatialIndexing3D(index, 2).reason).toBe('inverted-bounds');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reports the cell count an ordinary object occupies', () => {
|
|
40
|
+
const index = createSpatialIndex3D(createUniformGridSpatialBackend3D(1));
|
|
41
|
+
insertSpatialObject3D(index, 1, box(0, 0, 0, 1.5));
|
|
42
|
+
// 0..1.5 spans cells 0 and 1 on each axis: a 2x2x2 block.
|
|
43
|
+
expect(explainSpatialIndexing3D(index, 1)).toEqual({
|
|
44
|
+
bucketCount: 8,
|
|
45
|
+
id: 1,
|
|
46
|
+
mode: 'cells',
|
|
47
|
+
reason: null,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('pins the cost bound: an oversized object overflows rather than occupying unbounded buckets', () => {
|
|
52
|
+
const index = createSpatialIndex3D(createUniformGridSpatialBackend3D(1));
|
|
53
|
+
insertSpatialObject3D(index, 1, box(0, 0, 0, 5000));
|
|
54
|
+
const explanation = explainSpatialIndexing3D(index, 1);
|
|
55
|
+
expect(explanation.mode).toBe('overflow');
|
|
56
|
+
expect(explanation.bucketCount).toBe(0);
|
|
57
|
+
expect(explanation.bucketCount).toBeLessThanOrEqual(MAX_INDEXED_CELLS_PER_OBJECT);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -2,7 +2,8 @@ import type { SpatialIndexingNotice } from '@flighthq/types/contract';
|
|
|
2
2
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { formatSpatialIndexingNotice } from './formatSpatialIndexingNotice';
|
|
5
|
-
import {
|
|
5
|
+
import { setSpatialIndexingGuard } from './spatialIndexingGuard';
|
|
6
|
+
import { MAX_INDEXED_CELLS_PER_OBJECT, createUniformGridSpatialBackend2D } from './uniformGrid';
|
|
6
7
|
|
|
7
8
|
afterEach(() => {
|
|
8
9
|
setSpatialIndexingGuard(null);
|
|
@@ -50,7 +51,7 @@ describe('formatSpatialIndexingNotice', () => {
|
|
|
50
51
|
reason: 'invalid-cell-size',
|
|
51
52
|
wouldOccupyBucketCount: 0,
|
|
52
53
|
}),
|
|
53
|
-
).toContain('cellSize must be a positive finite number
|
|
54
|
+
).toContain("the uniform grid's cellSize (0) must be a positive finite number");
|
|
54
55
|
expect(
|
|
55
56
|
formatSpatialIndexingNotice({
|
|
56
57
|
cellSize: 10,
|
|
@@ -60,7 +61,7 @@ describe('formatSpatialIndexingNotice', () => {
|
|
|
60
61
|
reason: 'inverted-bounds',
|
|
61
62
|
wouldOccupyBucketCount: 0,
|
|
62
63
|
}),
|
|
63
|
-
).toContain('
|
|
64
|
+
).toContain('no minimum may exceed its matching maximum');
|
|
64
65
|
expect(
|
|
65
66
|
formatSpatialIndexingNotice({
|
|
66
67
|
cellSize: 10,
|
|
@@ -73,6 +74,31 @@ describe('formatSpatialIndexingNotice', () => {
|
|
|
73
74
|
).toContain('removal was a no-op');
|
|
74
75
|
});
|
|
75
76
|
|
|
77
|
+
it('names no dimension, because a notice carries none to name', () => {
|
|
78
|
+
// One guard serves both dimensions, so nothing in a notice says which one produced it. A sentence
|
|
79
|
+
// naming `removeSpatialObject2D` was therefore misdescribing every 3D notice, and the unsuffixed
|
|
80
|
+
// backend method is the name that is true for both — and the one a caller greps.
|
|
81
|
+
const modes = ['cells', 'overflow', 'declined', 'absent'] as const;
|
|
82
|
+
const operations = ['insert', 'update', 'remove'] as const;
|
|
83
|
+
const reasons = [null, 'invalid-cell-size', 'inverted-bounds', 'missing-id', 'non-finite-bounds'] as const;
|
|
84
|
+
for (const mode of modes) {
|
|
85
|
+
for (const operation of operations) {
|
|
86
|
+
for (const reason of reasons) {
|
|
87
|
+
const message = formatSpatialIndexingNotice({
|
|
88
|
+
cellSize: 1,
|
|
89
|
+
id: 7,
|
|
90
|
+
mode,
|
|
91
|
+
operation,
|
|
92
|
+
reason,
|
|
93
|
+
wouldOccupyBucketCount: 2,
|
|
94
|
+
});
|
|
95
|
+
expect(message).not.toContain('2D');
|
|
96
|
+
expect(message).not.toContain('3D');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
76
102
|
it('says plainly that a mode it does not cover carries no advice', () => {
|
|
77
103
|
expect(
|
|
78
104
|
formatSpatialIndexingNotice({
|
|
@@ -90,7 +116,7 @@ describe('formatSpatialIndexingNotice', () => {
|
|
|
90
116
|
// Pins the two halves together: a record produced by a real insert, rendered by this formatter.
|
|
91
117
|
const notices: SpatialIndexingNotice[] = [];
|
|
92
118
|
setSpatialIndexingGuard((notice) => notices.push({ ...notice }));
|
|
93
|
-
const grid =
|
|
119
|
+
const grid = createUniformGridSpatialBackend2D(1);
|
|
94
120
|
grid.insertSpatialObject(9, { minX: 0, minY: 0, maxX: 199, maxY: 199 });
|
|
95
121
|
grid.insertSpatialObject(10, { minX: NaN, minY: 0, maxX: 1, maxY: 1 });
|
|
96
122
|
expect(notices.map(formatSpatialIndexingNotice)).toEqual([
|