@flighthq/physics2d 0.3.0-edge.1458.0c01b63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/colliderTransform.d.ts +10 -0
  2. package/dist/colliderTransform.d.ts.map +1 -0
  3. package/dist/colliderTransform.js +137 -0
  4. package/dist/colliderTransform.js.map +1 -0
  5. package/dist/contract.d.ts +9 -0
  6. package/dist/contract.d.ts.map +1 -0
  7. package/dist/contract.js +9 -0
  8. package/dist/contract.js.map +1 -0
  9. package/dist/index.d.ts +9 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +9 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/islands.d.ts +38 -0
  14. package/dist/islands.d.ts.map +1 -0
  15. package/dist/islands.js +159 -0
  16. package/dist/islands.js.map +1 -0
  17. package/dist/jointRegistry.d.ts +6 -0
  18. package/dist/jointRegistry.d.ts.map +1 -0
  19. package/dist/jointRegistry.js +78 -0
  20. package/dist/jointRegistry.js.map +1 -0
  21. package/dist/joints.d.ts +39 -0
  22. package/dist/joints.d.ts.map +1 -0
  23. package/dist/joints.js +561 -0
  24. package/dist/joints.js.map +1 -0
  25. package/dist/massProperties.d.ts +4 -0
  26. package/dist/massProperties.d.ts.map +1 -0
  27. package/dist/massProperties.js +146 -0
  28. package/dist/massProperties.js.map +1 -0
  29. package/dist/solver.d.ts +7 -0
  30. package/dist/solver.d.ts.map +1 -0
  31. package/dist/solver.js +129 -0
  32. package/dist/solver.js.map +1 -0
  33. package/dist/step.d.ts +3 -0
  34. package/dist/step.d.ts.map +1 -0
  35. package/dist/step.js +414 -0
  36. package/dist/step.js.map +1 -0
  37. package/dist/world.d.ts +10 -0
  38. package/dist/world.d.ts.map +1 -0
  39. package/dist/world.js +133 -0
  40. package/dist/world.js.map +1 -0
  41. package/package.json +49 -0
  42. package/src/colliderTransform.test.ts +115 -0
  43. package/src/islands.test.ts +262 -0
  44. package/src/jointRegistry.test.ts +205 -0
  45. package/src/joints.test.ts +951 -0
  46. package/src/massProperties.test.ts +169 -0
  47. package/src/solver.test.ts +186 -0
  48. package/src/step.test.ts +745 -0
  49. package/src/world.test.ts +142 -0
@@ -0,0 +1,745 @@
1
+ import { createUniformGridSpatialBackend } from '@flighthq/spatial/contract';
2
+ import type { Physics2DWorld, RigidBody2D, SpatialIndexBackend, SpatialPair } from '@flighthq/types/contract';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { addPhysics2DJoint, registerPhysics2DJointSolver } from './jointRegistry';
6
+ import { physics2DDistanceJointSolver } from './joints';
7
+ import { stepPhysics2D } from './step';
8
+ import { addPhysics2DBody, createPhysics2DCollider, createPhysics2DWorld, createRigidBody2D } from './world';
9
+
10
+ const STONE = { density: 1, friction: 0.3, restitution: 0 };
11
+
12
+ function ground(world: Physics2DWorld): RigidBody2D {
13
+ const body = createRigidBody2D('static', 0, 0);
14
+ body.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -50, minY: -1, maxX: 50, maxY: 0 }, STONE));
15
+ return addPhysics2DBody(world, body);
16
+ }
17
+
18
+ function box(world: Physics2DWorld, x: number, y: number, half = 0.5): RigidBody2D {
19
+ const body = createRigidBody2D('dynamic', x, y);
20
+ body.colliders.push(
21
+ createPhysics2DCollider({ kind: 'aabb', minX: -half, minY: -half, maxX: half, maxY: half }, STONE),
22
+ );
23
+ return addPhysics2DBody(world, body);
24
+ }
25
+
26
+ // A hash of every body's full state, which is what a determinism claim has to be made against — comparing
27
+ // only positions would miss a divergence that has entered the velocities and not yet moved anything.
28
+ function traceWorld(world: Readonly<Physics2DWorld>): string {
29
+ return world.bodies
30
+ .slice()
31
+ .sort((left, right) => left.index - right.index)
32
+ .map((b) => [b.index, b.x, b.y, b.angle, b.velocityX, b.velocityY, b.angularVelocity].join(':'))
33
+ .join('|');
34
+ }
35
+
36
+ // A broadphase that returns its pairs in reverse order, and one that swaps each pair's two ids. Both
37
+ // wrap the real grid, so the candidate SET is identical and only its presentation differs — which is
38
+ // what makes them isolate ordering rather than change the simulation.
39
+ function createReversedPairBackend(): SpatialIndexBackend {
40
+ const inner = createUniformGridSpatialBackend(1);
41
+ return {
42
+ ...inner,
43
+ querySpatialPairs(out: SpatialPair[]): void {
44
+ inner.querySpatialPairs(out);
45
+ out.reverse();
46
+ },
47
+ };
48
+ }
49
+
50
+ function createSwappedPairBackend(): SpatialIndexBackend {
51
+ const inner = createUniformGridSpatialBackend(1);
52
+ return {
53
+ ...inner,
54
+ querySpatialPairs(out: SpatialPair[]): void {
55
+ inner.querySpatialPairs(out);
56
+ for (const pair of out) {
57
+ const a = pair.a;
58
+ pair.a = pair.b;
59
+ pair.b = a;
60
+ }
61
+ },
62
+ };
63
+ }
64
+
65
+ function runSteps(world: Physics2DWorld, count: number): void {
66
+ for (let i = 0; i < count; i++) stepPhysics2D(world, 1 / 60);
67
+ }
68
+
69
+ describe('a body the step declines leaves the broadphase', () => {
70
+ // The divergence filter said the declined body "stops colliding", but it only skipped the index
71
+ // UPDATE — whatever AABB the body had last step stayed indexed, so it kept producing pairs and
72
+ // holding live contacts from its last valid pose. Skipping an update is not withdrawing.
73
+ function dynamicBox(world: Physics2DWorld, x: number): RigidBody2D {
74
+ const body = createRigidBody2D('dynamic', x, 0);
75
+ body.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE));
76
+ return addPhysics2DBody(world, body);
77
+ }
78
+
79
+ function widenPastLimit(body: RigidBody2D): void {
80
+ const local = body.colliders[0].local as { maxX: number; maxY: number; minX: number; minY: number };
81
+ local.minX = -1e8;
82
+ local.minY = -1e8;
83
+ local.maxX = 1e8;
84
+ local.maxY = 1e8;
85
+ }
86
+
87
+ it('drops the contact of a body that diverges past the simulated extent', () => {
88
+ const world = createPhysics2DWorld(0, 0);
89
+ const diverging = dynamicBox(world, 0);
90
+ dynamicBox(world, 0.5);
91
+ stepPhysics2D(world, 1 / 60);
92
+ expect(world.contacts).toHaveLength(1);
93
+
94
+ widenPastLimit(diverging);
95
+ stepPhysics2D(world, 1 / 60);
96
+ expect(world.contacts).toHaveLength(0);
97
+ });
98
+
99
+ it('leaves the rest of the world simulating after one body diverges', () => {
100
+ // The filter's whole promise: one diverged body stops colliding, everything else carries on.
101
+ const world = createPhysics2DWorld(0, 0);
102
+ const diverging = dynamicBox(world, 0);
103
+ const near = dynamicBox(world, 0.5);
104
+ const other = dynamicBox(world, 20);
105
+ const alsoOther = dynamicBox(world, 20.5);
106
+ stepPhysics2D(world, 1 / 60);
107
+ widenPastLimit(diverging);
108
+ stepPhysics2D(world, 1 / 60);
109
+
110
+ // Only the untouched pair still has a contact; the diverged body's is gone.
111
+ const names = world.contacts.map((c) => `${c.bodyA}-${c.bodyB}`);
112
+ expect(names).toEqual([`${other.index}-${alsoOther.index}`]);
113
+ // And "still simulating" means still moving: `near` overlapped the diverging body on the first
114
+ // step and was pushed off it, so asserting it held position would have contradicted this title.
115
+ expect(near.x).toBeGreaterThan(0.5);
116
+ expect(Number.isFinite(near.x)).toBe(true);
117
+ });
118
+
119
+ it('withdraws a body whose colliders stop producing bounds from the index', () => {
120
+ // Asserted on the index rather than on contacts. A body with no colliders produces no manifold
121
+ // either way, so a contact-only assertion passes whether or not the withdrawal happens — it would
122
+ // have been a test that agreed with the bug. The index is where the difference actually shows.
123
+ const world = createPhysics2DWorld(0, 0);
124
+ const emptied = dynamicBox(world, 0);
125
+ dynamicBox(world, 0.5);
126
+ stepPhysics2D(world, 1 / 60);
127
+
128
+ const before: number[] = [];
129
+ world.index.querySpatialRegion({ minX: -1, minY: -1, maxX: 1, maxY: 1 }, before);
130
+ expect(before).toContain(emptied.index);
131
+
132
+ emptied.colliders.length = 0;
133
+ stepPhysics2D(world, 1 / 60);
134
+
135
+ const after: number[] = [];
136
+ world.index.querySpatialRegion({ minX: -1, minY: -1, maxX: 1, maxY: 1 }, after);
137
+ expect(after).not.toContain(emptied.index);
138
+ expect(world.contacts).toHaveLength(0);
139
+ });
140
+
141
+ it('withdraws a diverged body from the index, not merely from the update', () => {
142
+ const world = createPhysics2DWorld(0, 0);
143
+ const diverging = dynamicBox(world, 0);
144
+ dynamicBox(world, 0.5);
145
+ stepPhysics2D(world, 1 / 60);
146
+
147
+ widenPastLimit(diverging);
148
+ stepPhysics2D(world, 1 / 60);
149
+
150
+ const after: number[] = [];
151
+ world.index.querySpatialRegion({ minX: -1, minY: -1, maxX: 1, maxY: 1 }, after);
152
+ expect(after).not.toContain(diverging.index);
153
+ });
154
+
155
+ it('re-enters the broadphase when the body comes back inside the limit', () => {
156
+ const world = createPhysics2DWorld(0, 0);
157
+ const diverging = dynamicBox(world, 0);
158
+ dynamicBox(world, 0.5);
159
+ stepPhysics2D(world, 1 / 60);
160
+ widenPastLimit(diverging);
161
+ stepPhysics2D(world, 1 / 60);
162
+ expect(world.contacts).toHaveLength(0);
163
+
164
+ const local = diverging.colliders[0].local as { maxX: number; maxY: number; minX: number; minY: number };
165
+ local.minX = -0.5;
166
+ local.minY = -0.5;
167
+ local.maxX = 0.5;
168
+ local.maxY = 0.5;
169
+ stepPhysics2D(world, 1 / 60);
170
+ expect(world.contacts).toHaveLength(1);
171
+ });
172
+ });
173
+
174
+ describe('sensor reporting between immovable bodies', () => {
175
+ // A sensor is reported, never resolved — the solver already skips sensor contacts. The step's
176
+ // "two immovable bodies have no constraint to solve" shortcut ran before any collider was
177
+ // inspected, so it deleted every sensor overlap between immovable bodies as well. A static trigger
178
+ // volume over static scenery is an ordinary thing to build, and it reported nothing at all.
179
+ function immovable(world: Physics2DWorld, x: number, sensor: boolean): RigidBody2D {
180
+ const body = createRigidBody2D('static', x, 0);
181
+ body.colliders.push(
182
+ createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE, sensor),
183
+ );
184
+ return addPhysics2DBody(world, body);
185
+ }
186
+
187
+ it('reports a static sensor overlapping a static collider', () => {
188
+ const world = createPhysics2DWorld(0, 0);
189
+ immovable(world, 0, true);
190
+ immovable(world, 0, false);
191
+ stepPhysics2D(world, 1 / 60);
192
+ expect(world.events.began).toHaveLength(1);
193
+ expect(world.contacts).toHaveLength(1);
194
+ expect(world.contacts[0].sensor).toBe(true);
195
+ });
196
+
197
+ it('still skips two immovable bodies when neither senses', () => {
198
+ // The shortcut is right for the case it was written for, and must survive the fix.
199
+ const world = createPhysics2DWorld(0, 0);
200
+ immovable(world, 0, false);
201
+ immovable(world, 0, false);
202
+ stepPhysics2D(world, 1 / 60);
203
+ expect(world.events.began).toHaveLength(0);
204
+ expect(world.contacts).toHaveLength(0);
205
+ });
206
+
207
+ // Every existing case above gives each body exactly one collider, which is why the body-level guard
208
+ // looked sufficient. Owning a sensor ANYWHERE does not make a body's other colliders reportable:
209
+ // these two static bodies overlap solid-on-solid, and the disjoint trigger volume must not smuggle
210
+ // that pair past the immovable shortcut.
211
+ function immovableWithSensorAndSolid(world: Physics2DWorld, x: number): RigidBody2D {
212
+ const body = createRigidBody2D('static', x, 0);
213
+ // A trigger volume far away from the solid part, so it overlaps nothing.
214
+ body.colliders.push(
215
+ createPhysics2DCollider({ kind: 'aabb', minX: 99.5, minY: -0.5, maxX: 100.5, maxY: 0.5 }, STONE, true),
216
+ );
217
+ body.colliders.push(
218
+ createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE, false),
219
+ );
220
+ return addPhysics2DBody(world, body);
221
+ }
222
+
223
+ it('reports nothing when the overlapping colliders are both solid and both bodies are immovable', () => {
224
+ const world = createPhysics2DWorld(0, 0);
225
+ immovableWithSensorAndSolid(world, 0);
226
+ immovable(world, 0, false);
227
+
228
+ stepPhysics2D(world, 1 / 60);
229
+
230
+ expect(world.events.began).toHaveLength(0);
231
+ expect(world.contacts).toHaveLength(0);
232
+ });
233
+
234
+ // The other half of the same class: the sensor collider on that body must still report when it is
235
+ // the one actually overlapping, so the pair-level test is not just a blanket suppression.
236
+ it('still reports the sensor collider of a mixed body when that collider overlaps', () => {
237
+ const world = createPhysics2DWorld(0, 0);
238
+ immovableWithSensorAndSolid(world, 0);
239
+ immovable(world, 100, false);
240
+
241
+ stepPhysics2D(world, 1 / 60);
242
+
243
+ expect(world.contacts).toHaveLength(1);
244
+ expect(world.contacts[0].sensor).toBe(true);
245
+ });
246
+
247
+ // A movable body is unaffected by the immovable test, so its solid contacts still resolve even when
248
+ // the other body carries a sensor.
249
+ it('keeps a solid contact when one body can move', () => {
250
+ const world = createPhysics2DWorld(0, 0);
251
+ immovableWithSensorAndSolid(world, 0);
252
+ box(world, 0, 0);
253
+
254
+ stepPhysics2D(world, 1 / 60);
255
+
256
+ expect(world.contacts.some((contact) => !contact.sensor)).toBe(true);
257
+ });
258
+
259
+ it('resolves nothing for a static sensor pair — reporting is not colliding', () => {
260
+ const world = createPhysics2DWorld(0, 0);
261
+ const sensor = immovable(world, 0, true);
262
+ const scenery = immovable(world, 0, false);
263
+ stepPhysics2D(world, 1 / 60);
264
+ expect(sensor.x).toBe(0);
265
+ expect(scenery.x).toBe(0);
266
+ expect(sensor.velocityX).toBe(0);
267
+ });
268
+
269
+ it('ends a static sensor contact when the overlap stops', () => {
270
+ const world = createPhysics2DWorld(0, 0);
271
+ const sensor = immovable(world, 0, true);
272
+ immovable(world, 0, false);
273
+ stepPhysics2D(world, 1 / 60);
274
+ expect(world.events.began).toHaveLength(1);
275
+ sensor.x = 100;
276
+ stepPhysics2D(world, 1 / 60);
277
+ expect(world.events.ended).toHaveLength(1);
278
+ expect(world.contacts).toHaveLength(0);
279
+ });
280
+ });
281
+
282
+ describe('stepPhysics2D', () => {
283
+ it('rests a box on the ground instead of sinking through it', () => {
284
+ const world = createPhysics2DWorld();
285
+ ground(world);
286
+ const crate = box(world, 0, 2);
287
+ runSteps(world, 180);
288
+
289
+ // Half-extent 0.5 above a ground surface at y=0: the resting centre is y=0.5, less the solver's
290
+ // deliberate penetration slop.
291
+ expect(crate.y).toBeGreaterThan(0.48);
292
+ expect(crate.y).toBeLessThan(0.52);
293
+ expect(Math.abs(crate.velocityY)).toBeLessThan(0.05);
294
+ });
295
+
296
+ it('keeps a stack standing rather than letting the lower boxes be compressed through each other', () => {
297
+ // The case warm starting exists for. Without it the solver restarts from zero impulse every step, so
298
+ // the bottom box never converges against the weight above it and the stack visibly sinks.
299
+ const world = createPhysics2DWorld();
300
+ ground(world);
301
+ const bottom = box(world, 0, 0.5);
302
+ const middle = box(world, 0, 1.5);
303
+ const top = box(world, 0, 2.5);
304
+ runSteps(world, 240);
305
+
306
+ expect(bottom.y).toBeGreaterThan(0.45);
307
+ expect(middle.y).toBeGreaterThan(1.4);
308
+ expect(top.y).toBeGreaterThan(2.35);
309
+ expect(middle.y - bottom.y).toBeGreaterThan(0.9);
310
+ expect(top.y - middle.y).toBeGreaterThan(0.9);
311
+ });
312
+
313
+ it('produces a bitwise-identical trace for the same scene stepped twice', () => {
314
+ // The golden-trace harness. Determinism for a fixed engine and input order is exact, not approximate:
315
+ // every operation on this path is IEEE-754 exact (+ - * / and sqrt), so anything short of bitwise
316
+ // equality is a real divergence rather than accumulated noise.
317
+ const first = createPhysics2DWorld();
318
+ ground(first);
319
+ box(first, 0.1, 2);
320
+ box(first, -0.3, 3.2);
321
+ runSteps(first, 120);
322
+
323
+ const second = createPhysics2DWorld();
324
+ ground(second);
325
+ box(second, 0.1, 2);
326
+ box(second, -0.3, 3.2);
327
+ runSteps(second, 120);
328
+
329
+ expect(traceWorld(second)).toBe(traceWorld(first));
330
+ });
331
+
332
+ it('produces the same trace when the broadphase reports its pairs in the opposite order', () => {
333
+ // ORDER-INDEPENDENCE, OBLIGATION 2 — the contact LIST sort.
334
+ //
335
+ // The harness injects a broadphase that reverses its pair list, leaving the bodies and their indices
336
+ // untouched. That isolates the variable that matters: `querySpatialPairs` walks a Map of Sets, so its
337
+ // order follows insertion and movement history, and a sequential-impulse solver applies each impulse
338
+ // against the velocities the previous ones left. Without the canonical sort the answer would depend
339
+ // on the broadphase's history.
340
+ //
341
+ // Note what this does NOT test, and what an insertion-order shuffle would wrongly claim: reordering
342
+ // INSERTION changes the body indices, hence the canonical solve order, hence — legitimately — the
343
+ // result. Canonical ordering buys DETERMINISM (same input, same output), not invariance to how the
344
+ // scene was built. A harness asserting the latter asserts something false about Gauss-Seidel.
345
+ const plain = createPhysics2DWorld();
346
+ ground(plain);
347
+ const plainLeft = box(plain, -0.9, 0.5);
348
+ const plainRight = box(plain, 0.9, 0.5);
349
+ const plainTop = box(plain, 0, 1.6);
350
+
351
+ const reversed = createPhysics2DWorld(0, -9.81, createReversedPairBackend());
352
+ ground(reversed);
353
+ const reversedLeft = box(reversed, -0.9, 0.5);
354
+ const reversedRight = box(reversed, 0.9, 0.5);
355
+ const reversedTop = box(reversed, 0, 1.6);
356
+
357
+ runSteps(plain, 90);
358
+ runSteps(reversed, 90);
359
+
360
+ expect(traceWorld(reversed)).toBe(traceWorld(plain));
361
+ expect(reversedLeft.y).toBe(plainLeft.y);
362
+ expect(reversedRight.y).toBe(plainRight.y);
363
+ expect(reversedTop.y).toBe(plainTop.y);
364
+ });
365
+
366
+ it('orders every contact pair by body index however the broadphase hands it over', () => {
367
+ // ORDER-INDEPENDENCE, OBLIGATION 1 — the per-pair BODY sort, which the harness above cannot see.
368
+ // Reversing the pair LIST does not change which body of a pair reaches the narrow phase first; that
369
+ // follows the pair's own field order. This backend swaps `a` and `b` within every pair, which is the
370
+ // only thing that exercises it. Unordered, collision would resolve contact points on the opposite
371
+ // surface and renumber their feature ids, silently discarding the warm-start cache every step.
372
+ const swapped = createPhysics2DWorld(0, -9.81, createSwappedPairBackend());
373
+ ground(swapped);
374
+ const crate = box(swapped, 0.2, 1.4);
375
+ runSteps(swapped, 120);
376
+
377
+ for (const contact of swapped.contacts) expect(contact.bodyA).toBeLessThan(contact.bodyB);
378
+
379
+ const plain = createPhysics2DWorld();
380
+ ground(plain);
381
+ const plainCrate = box(plain, 0.2, 1.4);
382
+ runSteps(plain, 120);
383
+
384
+ expect(crate.y).toBe(plainCrate.y);
385
+ expect(crate.x).toBe(plainCrate.x);
386
+ expect(crate.angle).toBe(plainCrate.angle);
387
+ });
388
+
389
+ it('keeps the contact list in a canonical order after a step', () => {
390
+ const world = createPhysics2DWorld();
391
+ ground(world);
392
+ box(world, -0.9, 0.5);
393
+ box(world, 0.9, 0.5);
394
+ box(world, 0, 1.6);
395
+ runSteps(world, 60);
396
+
397
+ expect(world.contacts.length).toBeGreaterThan(1);
398
+ for (let i = 1; i < world.contacts.length; i++) {
399
+ const previous = world.contacts[i - 1];
400
+ const current = world.contacts[i];
401
+ const ordered =
402
+ previous.bodyA < current.bodyA ||
403
+ (previous.bodyA === current.bodyA &&
404
+ (previous.bodyB < current.bodyB ||
405
+ (previous.bodyB === current.bodyB && previous.colliderA <= current.colliderA)));
406
+ expect(ordered).toBe(true);
407
+ }
408
+ });
409
+
410
+ it('tips a box that overhangs a ledge instead of sliding off level', () => {
411
+ // The proof that contact points carry torque. With only a minimum-translation vector and no point,
412
+ // the lever arm is zero, the angular term vanishes, and an overhanging box slides off perfectly
413
+ // level — which is what the whole contact-manifold lane exists to prevent.
414
+ const world = createPhysics2DWorld();
415
+ const ledge = createRigidBody2D('static', 0, 0);
416
+ ledge.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -5, minY: -1, maxX: 0, maxY: 0 }, STONE));
417
+ addPhysics2DBody(world, ledge);
418
+ // The centre of mass must be BEYOND the support edge at x=0 for there to be a tipping moment at
419
+ // all; a box whose centre still sits over the ledge is supported and correctly stays level.
420
+ const crate = box(world, 0.2, 0.5);
421
+ runSteps(world, 120);
422
+
423
+ expect(Math.abs(crate.angle)).toBeGreaterThan(0.05);
424
+ });
425
+
426
+ it('leaves a sensor collider overlapping without pushing anything out of it', () => {
427
+ const world = createPhysics2DWorld();
428
+ const trigger = createRigidBody2D('static', 0, 0);
429
+ trigger.colliders.push(
430
+ createPhysics2DCollider({ kind: 'aabb', minX: -2, minY: -2, maxX: 2, maxY: 2 }, STONE, true),
431
+ );
432
+ addPhysics2DBody(world, trigger);
433
+ const crate = box(world, 0, 0);
434
+ runSteps(world, 30);
435
+
436
+ expect(world.contacts.some((contact) => contact.sensor)).toBe(true);
437
+ // Gravity keeps pulling it: a sensor reports the overlap and applies no impulse.
438
+ expect(crate.velocityY).toBeLessThan(-0.1);
439
+ });
440
+
441
+ it('ignores a non-positive timestep rather than integrating backwards', () => {
442
+ const world = createPhysics2DWorld();
443
+ ground(world);
444
+ const crate = box(world, 0, 2);
445
+ const before = traceWorld(world);
446
+ stepPhysics2D(world, 0);
447
+ stepPhysics2D(world, -1 / 60);
448
+ expect(traceWorld(world)).toBe(before);
449
+ expect(crate.y).toBe(2);
450
+ });
451
+ });
452
+
453
+ describe('stepPhysics2D contact events', () => {
454
+ it('reports a contact beginning and ending, read off the cache transitions', () => {
455
+ const world = createPhysics2DWorld();
456
+ ground(world);
457
+ const crate = box(world, 0, 3);
458
+ runSteps(world, 1);
459
+ expect(world.events.began).toHaveLength(0);
460
+
461
+ // Fall until it lands: the step that creates the contact is the begin.
462
+ let began = 0;
463
+ for (let i = 0; i < 200 && began === 0; i++) {
464
+ stepPhysics2D(world, 1 / 60);
465
+ began += world.events.began.length;
466
+ }
467
+ expect(began).toBe(1);
468
+
469
+ // Teleport it away: the step that drops the contact is the end.
470
+ crate.y = 50;
471
+ crate.velocityY = 0;
472
+ stepPhysics2D(world, 1 / 60);
473
+ expect(world.events.ended).toHaveLength(1);
474
+ expect(world.contacts).toHaveLength(0);
475
+ });
476
+
477
+ it('clears its event buffers each step rather than accumulating', () => {
478
+ const world = createPhysics2DWorld();
479
+ ground(world);
480
+ box(world, 0, 0.4);
481
+ runSteps(world, 5);
482
+ expect(world.events.began).toHaveLength(0);
483
+ expect(world.events.ended).toHaveLength(0);
484
+ });
485
+ });
486
+
487
+ describe('stepPhysics2D with joints', () => {
488
+ const DISTANCE = 'Distance';
489
+
490
+ function jointedWorld(index?: SpatialIndexBackend) {
491
+ const world = createPhysics2DWorld(0, -9.81, index);
492
+ registerPhysics2DJointSolver(world, DISTANCE, physics2DDistanceJointSolver);
493
+ ground(world);
494
+ // Created static, not mutated to static after the fact: mass properties are derived at insertion, so
495
+ // flipping the type afterwards leaves a nonzero inverse mass on a body that never integrates its
496
+ // position — it accumulates velocity forever and drags whatever is jointed to it out of the world.
497
+ const anchorBody = createRigidBody2D('static', 0, 4);
498
+ anchorBody.colliders.push(
499
+ createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE),
500
+ );
501
+ const anchor = addPhysics2DBody(world, anchorBody);
502
+ const left = box(world, -1, 2);
503
+ const right = box(world, 1, 2);
504
+ for (const bob of [left, right]) {
505
+ addPhysics2DJoint(world, {
506
+ kind: DISTANCE,
507
+ bodyA: anchor.index,
508
+ bodyB: bob.index,
509
+ localAnchorAX: 0,
510
+ localAnchorAY: 0,
511
+ localAnchorBX: 0,
512
+ localAnchorBY: 0,
513
+ collideConnected: false,
514
+ impulse0: 0,
515
+ impulse1: 0,
516
+ impulse2: 0,
517
+ rAX: 0,
518
+ rAY: 0,
519
+ rBX: 0,
520
+ rBY: 0,
521
+ length: 2,
522
+ stiffness: 0,
523
+ damping: 0,
524
+ } as never);
525
+ }
526
+ return { left, right, world };
527
+ }
528
+
529
+ it('produces the same trace when the broadphase reports its pairs in the opposite order', () => {
530
+ // OBLIGATION 2 EXTENDED TO P2. Joints share the contact list's iteration loop, so the contact sort has
531
+ // to keep holding once joints are also constraining the same bodies — a scene whose contacts reorder
532
+ // now perturbs the joint solve too.
533
+ const plain = jointedWorld();
534
+ const reversed = jointedWorld(createReversedPairBackend());
535
+ runSteps(plain.world, 120);
536
+ runSteps(reversed.world, 120);
537
+ expect(traceWorld(reversed.world)).toBe(traceWorld(plain.world));
538
+ });
539
+
540
+ it('orders every contact pair by body index with joints present', () => {
541
+ // OBLIGATION 1 EXTENDED TO P2.
542
+ const { world } = jointedWorld(createSwappedPairBackend());
543
+ runSteps(world, 120);
544
+ for (const contact of world.contacts) expect(contact.bodyA).toBeLessThan(contact.bodyB);
545
+ for (const joint of world.joints) expect(joint.bodyA).toBeLessThan(joint.bodyB);
546
+ });
547
+
548
+ it('is bit-for-bit repeatable with joints in the solve list', () => {
549
+ const first = jointedWorld();
550
+ const second = jointedWorld();
551
+ runSteps(first.world, 90);
552
+ runSteps(second.world, 90);
553
+ expect(traceWorld(second.world)).toBe(traceWorld(first.world));
554
+ });
555
+
556
+ it('suppresses the contact between jointed bodies unless the joint asks for it', () => {
557
+ // A jointed pair almost always overlaps at the anchor, and resolving that contact fights the
558
+ // constraint holding them together.
559
+ const world = createPhysics2DWorld(0, 0);
560
+ registerPhysics2DJointSolver(world, DISTANCE, physics2DDistanceJointSolver);
561
+ const a = box(world, 0, 0);
562
+ const b = box(world, 0.2, 0);
563
+ addPhysics2DJoint(world, {
564
+ kind: DISTANCE,
565
+ bodyA: a.index,
566
+ bodyB: b.index,
567
+ localAnchorAX: 0,
568
+ localAnchorAY: 0,
569
+ localAnchorBX: 0,
570
+ localAnchorBY: 0,
571
+ collideConnected: false,
572
+ impulse0: 0,
573
+ impulse1: 0,
574
+ impulse2: 0,
575
+ rAX: 0,
576
+ rAY: 0,
577
+ rBX: 0,
578
+ rBY: 0,
579
+ length: 0.2,
580
+ stiffness: 0,
581
+ damping: 0,
582
+ } as never);
583
+ runSteps(world, 10);
584
+ expect(world.contacts).toHaveLength(0);
585
+ });
586
+ });
587
+
588
+ describe('stepPhysics2D with sleeping', () => {
589
+ it('settles a resting crate to sleep and then holds its pose exactly', () => {
590
+ const world = createPhysics2DWorld();
591
+ ground(world);
592
+ const crate = box(world, 0, 2);
593
+ runSteps(world, 240);
594
+ expect(crate.sleeping).toBe(true);
595
+
596
+ const restingY = crate.y;
597
+ runSteps(world, 120);
598
+
599
+ // Not "close to" — a sleeping body is skipped by integration outright, so its pose is bit-identical.
600
+ // Even the residual sink of a solved-but-still contact is gone.
601
+ expect(crate.y).toBe(restingY);
602
+ expect(crate.velocityY).toBe(0);
603
+ });
604
+
605
+ it('wakes a sleeping crate when another body lands on it', () => {
606
+ const world = createPhysics2DWorld();
607
+ ground(world);
608
+ const settled = box(world, 0, 2);
609
+ runSteps(world, 240);
610
+ expect(settled.sleeping).toBe(true);
611
+
612
+ box(world, 0, 4);
613
+ runSteps(world, 60);
614
+
615
+ expect(settled.sleeping).toBe(false);
616
+ });
617
+
618
+ it('wakes a sleeping crate in the same step a force is applied to it', () => {
619
+ // The zero-latency wake is what the sleep update's placement inside the step buys. Deciding sleep
620
+ // after integration instead would skip this step and start moving one frame late — and because the
621
+ // step clears forces at the end, a single-step push would be swallowed entirely.
622
+ const world = createPhysics2DWorld();
623
+ ground(world);
624
+ const crate = box(world, 0, 2);
625
+ runSteps(world, 240);
626
+ expect(crate.sleeping).toBe(true);
627
+
628
+ crate.forceX = 500;
629
+ stepPhysics2D(world, 1 / 60);
630
+
631
+ expect(crate.sleeping).toBe(false);
632
+ expect(crate.velocityX).toBeGreaterThan(0);
633
+ });
634
+
635
+ it('holds a jointed body still once its island sleeps', () => {
636
+ // Covers the joint half of the solver skip, which the contact tests cannot reach. A joint keeps a
637
+ // converged impulse across steps like a contact does, so warm-starting a pair that is asleep hands
638
+ // a sleeper velocity it will never integrate — and the next stillness test reads that as motion.
639
+ // The pendulum then twitches itself awake every step and never rests.
640
+ const world = createPhysics2DWorld();
641
+ registerPhysics2DJointSolver(world, 'Distance', physics2DDistanceJointSolver);
642
+ const anchorBody = createRigidBody2D('static', 0, 4);
643
+ const anchor = addPhysics2DBody(world, anchorBody);
644
+ const bob = box(world, 0, 2);
645
+ addPhysics2DJoint(world, {
646
+ kind: 'Distance',
647
+ bodyA: anchor.index,
648
+ bodyB: bob.index,
649
+ localAnchorAX: 0,
650
+ localAnchorAY: 0,
651
+ localAnchorBX: 0,
652
+ localAnchorBY: 0,
653
+ collideConnected: false,
654
+ impulse0: 0,
655
+ impulse1: 0,
656
+ impulse2: 0,
657
+ rAX: 0,
658
+ rAY: 0,
659
+ rBX: 0,
660
+ rBY: 0,
661
+ length: 2,
662
+ stiffness: 0,
663
+ damping: 0,
664
+ } as never);
665
+ runSteps(world, 600);
666
+ expect(bob.sleeping).toBe(true);
667
+
668
+ const restingY = bob.y;
669
+ runSteps(world, 120);
670
+
671
+ expect(bob.y).toBe(restingY);
672
+ expect(bob.sleeping).toBe(true);
673
+ });
674
+
675
+ it('never leaves a sleeping body holding velocity, however it came to rest', () => {
676
+ // The invariant that licenses skipping a sleeper's integration. Sleep is only safe to implement as
677
+ // "do not integrate" if a sleeping body has nothing left to integrate; a sleeper carrying residual
678
+ // velocity would be motion the simulation has silently paused rather than resolved.
679
+ //
680
+ // Sampled across a scene that settles, is landed on, and settles again, so it covers the sleep,
681
+ // wake-on-new-contact, and re-sleep transitions rather than a single moment.
682
+ const world = createPhysics2DWorld();
683
+ ground(world);
684
+ const settled = box(world, 0, 2);
685
+ runSteps(world, 240);
686
+ expect(settled.sleeping).toBe(true);
687
+ box(world, 0.1, 4);
688
+
689
+ let sleepingSamples = 0;
690
+ for (let i = 0; i < 600; i++) {
691
+ stepPhysics2D(world, 1 / 60);
692
+ for (const body of world.bodies) {
693
+ if (!body.sleeping) continue;
694
+ sleepingSamples++;
695
+ expect(body.velocityX).toBe(0);
696
+ expect(body.velocityY).toBe(0);
697
+ expect(body.angularVelocity).toBe(0);
698
+ }
699
+ }
700
+
701
+ // Guards the guard: assertions inside a conditional prove nothing if the condition never held.
702
+ expect(sleepingSamples).toBeGreaterThan(0);
703
+ });
704
+
705
+ it('invokes no joint solver callback for a pair whose every end is asleep', () => {
706
+ // The joint half of the skip as WIRING rather than as physics. Whether a particular solver's maths
707
+ // happens to produce a zero impulse at rest is that solver's business; the contract is that a joint
708
+ // with no movable end is not consulted at all, which is what a custom kind can assert directly.
709
+ const calls: string[] = [];
710
+ const world = createPhysics2DWorld();
711
+ registerPhysics2DJointSolver(world, 'Recording', {
712
+ prepare: () => calls.push('prepare'),
713
+ warmStart: () => calls.push('warmStart'),
714
+ solve: () => calls.push('solve'),
715
+ clearAccumulatedImpulses: () => calls.push('clear'),
716
+ });
717
+ ground(world);
718
+ const left = box(world, -1, 2);
719
+ const right = box(world, 1, 2);
720
+ addPhysics2DJoint(world, {
721
+ kind: 'Recording',
722
+ bodyA: left.index,
723
+ bodyB: right.index,
724
+ collideConnected: true,
725
+ } as never);
726
+ runSteps(world, 240);
727
+ expect(left.sleeping).toBe(true);
728
+ expect(right.sleeping).toBe(true);
729
+
730
+ calls.length = 0;
731
+ runSteps(world, 30);
732
+
733
+ expect(calls).toEqual([]);
734
+ });
735
+
736
+ it('never sleeps a resting crate when allowSleeping is off', () => {
737
+ const world = createPhysics2DWorld();
738
+ world.config.allowSleeping = false;
739
+ ground(world);
740
+ const crate = box(world, 0, 2);
741
+ runSteps(world, 240);
742
+
743
+ expect(crate.sleeping).toBe(false);
744
+ });
745
+ });