@flighthq/collision 0.4.0-next.1821.efb1cbc → 0.4.0-next.1836.147ae1e

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/collision",
3
- "version": "0.4.0-next.1821.efb1cbc",
3
+ "version": "0.4.0-next.1836.147ae1e",
4
4
  "author": "Joshua Granick and other contributors",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,9 +38,9 @@
38
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@flighthq/geometry": "0.4.0-next.1821.efb1cbc",
42
- "@flighthq/log": "0.4.0-next.1821.efb1cbc",
43
- "@flighthq/types": "0.4.0-next.1821.efb1cbc"
41
+ "@flighthq/geometry": "0.4.0-next.1836.147ae1e",
42
+ "@flighthq/log": "0.4.0-next.1836.147ae1e",
43
+ "@flighthq/types": "0.4.0-next.1836.147ae1e"
44
44
  },
45
45
  "devDependencies": {
46
46
  "typescript": "^5.3.0"
@@ -5,6 +5,16 @@ import { collideContactManifold2D } from './collideContactManifold2D';
5
5
  import { createCollisionContactManifold2D } from './contactManifold2D';
6
6
  import { createCollisionTimeOfImpact2D, sweepCollisionShape2D } from './sweepCollisionShape2D';
7
7
 
8
+ type CollisionCapsule2D = Extract<CollisionBuiltInShape2D, { kind: 'capsule' }>;
9
+ type CollisionSweepTarget2D = Extract<CollisionBuiltInShape2D, { kind: 'aabb' | 'capsule' | 'circle' | 'obb' }>;
10
+
11
+ const SEEDED_CAPSULE_SWEEP_BATCHES = [
12
+ { endTrial: 300, startTrial: 0 },
13
+ { endTrial: 600, startTrial: 300 },
14
+ { endTrial: 900, startTrial: 600 },
15
+ { endTrial: 1200, startTrial: 900 },
16
+ ] as const;
17
+
8
18
  describe('createCollisionTimeOfImpact2D', () => {
9
19
  it('creates a cleared reusable output record', () => {
10
20
  expect(createCollisionTimeOfImpact2D()).toEqual({ fraction: 0, x: 0, y: 0, normalX: 0, normalY: 0 });
@@ -176,94 +186,91 @@ describe('sweepCollisionShape2D', () => {
176
186
  });
177
187
 
178
188
  // The instrument is the DISCRETE manifold test, marched along the motion — a different function with a
179
- // different derivation, so agreement is evidence rather than restatement.
180
- it('agrees with a brute-force march of the discrete test over a seeded capsule sweep', () => {
181
- let state = 987654321;
182
- const next = (): number => {
183
- state ^= state << 13;
184
- state ^= state >>> 17;
185
- state ^= state << 5;
186
- return ((state >>> 0) % 100000) / 100000;
187
- };
188
- const shift = (shape: CollisionBuiltInShape2D, dx: number, dy: number): CollisionBuiltInShape2D => {
189
- switch (shape.kind) {
190
- case 'capsule':
191
- return { ...shape, x0: shape.x0 + dx, y0: shape.y0 + dy, x1: shape.x1 + dx, y1: shape.y1 + dy };
192
- case 'circle':
193
- return { ...shape, x: shape.x + dx, y: shape.y + dy };
194
- case 'aabb':
195
- return {
196
- ...shape,
197
- minX: shape.minX + dx,
198
- maxX: shape.maxX + dx,
199
- minY: shape.minY + dy,
200
- maxY: shape.maxY + dy,
201
- };
202
- case 'obb':
203
- return { ...shape, x: shape.x + dx, y: shape.y + dy };
204
- default:
205
- return shape;
206
- }
207
- };
208
-
209
- const toi = createCollisionTimeOfImpact2D();
210
- const manifold = createCollisionContactManifold2D();
211
- const SAMPLES = 6000;
212
- let compared = 0;
213
- let worst = 0;
214
-
215
- for (let trial = 0; trial < 1200; trial++) {
216
- const capsule: CollisionBuiltInShape2D = {
217
- kind: 'capsule',
218
- x0: (next() - 0.5) * 3,
219
- y0: (next() - 0.5) * 3,
220
- x1: (next() - 0.5) * 3,
221
- y1: (next() - 0.5) * 3,
222
- radius: 0.2 + next() * 0.8,
189
+ // different derivation, so agreement is evidence rather than restatement. Keep the original 1,200
190
+ // trials, but divide their millions of manifold calls into independently timed batches: under the full
191
+ // repository suite one monolithic test could exhaust Vitest's five-second allowance through contention.
192
+ it.each(SEEDED_CAPSULE_SWEEP_BATCHES)(
193
+ 'agrees with a brute-force march of the discrete test over seeded capsule sweep trials $startTrial–$endTrial',
194
+ ({ endTrial, startTrial }) => {
195
+ let state = 987654321;
196
+ const next = (): number => {
197
+ state ^= state << 13;
198
+ state ^= state >>> 17;
199
+ state ^= state << 5;
200
+ return ((state >>> 0) % 100000) / 100000;
223
201
  };
224
- const pick = Math.floor(next() * 4);
225
- const cx = 5 + next() * 3;
226
- const cy = (next() - 0.5) * 4;
227
- const other: CollisionBuiltInShape2D =
228
- pick === 0
229
- ? { kind: 'circle', x: cx, y: cy, radius: 0.3 + next() * 1.0 }
230
- : pick === 1
231
- ? { kind: 'aabb', minX: cx - 1, minY: cy - 0.8, maxX: cx + 1, maxY: cy + 0.8 }
232
- : pick === 2
233
- ? { kind: 'obb', x: cx, y: cy, halfW: 1, halfH: 0.6, rotation: next() * Math.PI }
234
- : {
235
- kind: 'capsule',
236
- x0: cx,
237
- y0: cy,
238
- x1: cx + (next() - 0.5) * 3,
239
- y1: cy + (next() - 0.5) * 3,
240
- radius: 0.2 + next() * 0.8,
241
- };
242
- const dx = cx - (capsule.x0 + capsule.x1) / 2 + (next() - 0.5) * 2;
243
- const dy = cy - (capsule.y0 + capsule.y1) / 2 + (next() - 0.5) * 2;
244
- // Already-overlapping is a different question with its own arm above.
245
- if (collideContactManifold2D(capsule, other, manifold)) continue;
202
+ const toi = createCollisionTimeOfImpact2D();
203
+ const manifold = createCollisionContactManifold2D();
204
+ const SAMPLES = 6000;
205
+ let compared = 0;
206
+ const comparedByKind: Record<CollisionSweepTarget2D['kind'], number> = { aabb: 0, capsule: 0, circle: 0, obb: 0 };
207
+ let worst = 0;
246
208
 
247
- const hit = sweepCollisionShape2D(capsule, dx, dy, other, 0, 0, toi);
209
+ for (let trial = 0; trial < endTrial; trial++) {
210
+ const capsule: CollisionCapsule2D = {
211
+ kind: 'capsule',
212
+ x0: (next() - 0.5) * 3,
213
+ y0: (next() - 0.5) * 3,
214
+ x1: (next() - 0.5) * 3,
215
+ y1: (next() - 0.5) * 3,
216
+ radius: 0.2 + next() * 0.8,
217
+ };
218
+ const pick = Math.floor(next() * 4);
219
+ const cx = 5 + next() * 3;
220
+ const cy = (next() - 0.5) * 4;
221
+ const other: CollisionSweepTarget2D =
222
+ pick === 0
223
+ ? { kind: 'circle', x: cx, y: cy, radius: 0.3 + next() * 1.0 }
224
+ : pick === 1
225
+ ? { kind: 'aabb', minX: cx - 1, minY: cy - 0.8, maxX: cx + 1, maxY: cy + 0.8 }
226
+ : pick === 2
227
+ ? { kind: 'obb', x: cx, y: cy, halfW: 1, halfH: 0.6, rotation: next() * Math.PI }
228
+ : {
229
+ kind: 'capsule',
230
+ x0: cx,
231
+ y0: cy,
232
+ x1: cx + (next() - 0.5) * 3,
233
+ y1: cy + (next() - 0.5) * 3,
234
+ radius: 0.2 + next() * 0.8,
235
+ };
236
+ const dx = cx - (capsule.x0 + capsule.x1) / 2 + (next() - 0.5) * 2;
237
+ const dy = cy - (capsule.y0 + capsule.y1) / 2 + (next() - 0.5) * 2;
238
+ // Replaying only the random draws before this batch preserves the original seeded 1,200-case
239
+ // sequence. Shape generation is cheap; the discrete march below is the work that needs batching.
240
+ if (trial < startTrial) continue;
241
+ // Already-overlapping is a different question with its own arm above.
242
+ if (collideContactManifold2D(capsule, other, manifold)) continue;
248
243
 
249
- let marched = -1;
250
- for (let sample = 0; sample <= SAMPLES; sample++) {
251
- const fraction = sample / SAMPLES;
252
- if (collideContactManifold2D(shift(capsule, dx * fraction, dy * fraction), other, manifold)) {
253
- marched = fraction;
254
- break;
244
+ const hit = sweepCollisionShape2D(capsule, dx, dy, other, 0, 0, toi);
245
+
246
+ let marched = -1;
247
+ // Reuse one probe shape for the millions of discrete samples. Allocation and GC are not part of
248
+ // this differential instrument, so they should not add another source of full-suite contention.
249
+ const marchedCapsule = { ...capsule };
250
+ for (let sample = 0; sample <= SAMPLES; sample++) {
251
+ const fraction = sample / SAMPLES;
252
+ marchedCapsule.x0 = capsule.x0 + dx * fraction;
253
+ marchedCapsule.y0 = capsule.y0 + dy * fraction;
254
+ marchedCapsule.x1 = capsule.x1 + dx * fraction;
255
+ marchedCapsule.y1 = capsule.y1 + dy * fraction;
256
+ if (collideContactManifold2D(marchedCapsule, other, manifold)) {
257
+ marched = fraction;
258
+ break;
259
+ }
255
260
  }
256
- }
257
261
 
258
- // Both directions matter, and they are not symmetric in consequence: a sweep that misses an impact
259
- // the march finds is a bullet passing through a wall.
260
- expect(hit, `trial ${String(trial)} hit/miss`).toBe(marched >= 0);
261
- if (!hit) continue;
262
- compared++;
263
- worst = Math.max(worst, Math.abs(toi.fraction - marched));
264
- }
262
+ // Both directions matter, and they are not symmetric in consequence: a sweep that misses an impact
263
+ // the march finds is a bullet passing through a wall.
264
+ expect(hit, `trial ${String(trial)} hit/miss`).toBe(marched >= 0);
265
+ if (!hit) continue;
266
+ compared++;
267
+ comparedByKind[other.kind]++;
268
+ worst = Math.max(worst, Math.abs(toi.fraction - marched));
269
+ }
265
270
 
266
- expect(compared).toBeGreaterThan(200);
267
- expect(worst).toBeLessThan(2 / SAMPLES);
268
- });
271
+ expect(compared).toBeGreaterThan(250);
272
+ for (const count of Object.values(comparedByKind)) expect(count).toBeGreaterThan(40);
273
+ expect(worst).toBeLessThan(2 / SAMPLES);
274
+ },
275
+ );
269
276
  });