@flighthq/physics2d 0.3.0-next.1032.e2b99fc → 0.3.0-next.1220.6982c21

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/src/step.test.ts CHANGED
@@ -2,6 +2,8 @@ import { createUniformGridSpatialBackend } from '@flighthq/spatial/contract';
2
2
  import type { Physics2DWorld, RigidBody2D, SpatialIndexBackend, SpatialPair } from '@flighthq/types/contract';
3
3
  import { describe, expect, it } from 'vitest';
4
4
 
5
+ import { addPhysics2DJoint, registerPhysics2DJointSolver } from './jointRegistry';
6
+ import { physics2DDistanceJointSolver } from './joints';
5
7
  import { stepPhysics2D } from './step';
6
8
  import { addPhysics2DBody, createPhysics2DCollider, createPhysics2DWorld, createRigidBody2D } from './world';
7
9
 
@@ -64,6 +66,167 @@ function runSteps(world: Physics2DWorld, count: number): void {
64
66
  for (let i = 0; i < count; i++) stepPhysics2D(world, 1 / 60);
65
67
  }
66
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
+ it('resolves nothing for a static sensor pair — reporting is not colliding', () => {
208
+ const world = createPhysics2DWorld(0, 0);
209
+ const sensor = immovable(world, 0, true);
210
+ const scenery = immovable(world, 0, false);
211
+ stepPhysics2D(world, 1 / 60);
212
+ expect(sensor.x).toBe(0);
213
+ expect(scenery.x).toBe(0);
214
+ expect(sensor.velocityX).toBe(0);
215
+ });
216
+
217
+ it('ends a static sensor contact when the overlap stops', () => {
218
+ const world = createPhysics2DWorld(0, 0);
219
+ const sensor = immovable(world, 0, true);
220
+ immovable(world, 0, false);
221
+ stepPhysics2D(world, 1 / 60);
222
+ expect(world.events.began).toHaveLength(1);
223
+ sensor.x = 100;
224
+ stepPhysics2D(world, 1 / 60);
225
+ expect(world.events.ended).toHaveLength(1);
226
+ expect(world.contacts).toHaveLength(0);
227
+ });
228
+ });
229
+
67
230
  describe('stepPhysics2D', () => {
68
231
  it('rests a box on the ground instead of sinking through it', () => {
69
232
  const world = createPhysics2DWorld();
@@ -234,3 +397,138 @@ describe('stepPhysics2D', () => {
234
397
  expect(crate.y).toBe(2);
235
398
  });
236
399
  });
400
+
401
+ describe('stepPhysics2D contact events', () => {
402
+ it('reports a contact beginning and ending, read off the cache transitions', () => {
403
+ const world = createPhysics2DWorld();
404
+ ground(world);
405
+ const crate = box(world, 0, 3);
406
+ runSteps(world, 1);
407
+ expect(world.events.began).toHaveLength(0);
408
+
409
+ // Fall until it lands: the step that creates the contact is the begin.
410
+ let began = 0;
411
+ for (let i = 0; i < 200 && began === 0; i++) {
412
+ stepPhysics2D(world, 1 / 60);
413
+ began += world.events.began.length;
414
+ }
415
+ expect(began).toBe(1);
416
+
417
+ // Teleport it away: the step that drops the contact is the end.
418
+ crate.y = 50;
419
+ crate.velocityY = 0;
420
+ stepPhysics2D(world, 1 / 60);
421
+ expect(world.events.ended).toHaveLength(1);
422
+ expect(world.contacts).toHaveLength(0);
423
+ });
424
+
425
+ it('clears its event buffers each step rather than accumulating', () => {
426
+ const world = createPhysics2DWorld();
427
+ ground(world);
428
+ box(world, 0, 0.4);
429
+ runSteps(world, 5);
430
+ expect(world.events.began).toHaveLength(0);
431
+ expect(world.events.ended).toHaveLength(0);
432
+ });
433
+ });
434
+
435
+ describe('stepPhysics2D with joints', () => {
436
+ const DISTANCE = 'Distance';
437
+
438
+ function jointedWorld(index?: SpatialIndexBackend) {
439
+ const world = createPhysics2DWorld(0, -9.81, index);
440
+ registerPhysics2DJointSolver(world, DISTANCE, physics2DDistanceJointSolver);
441
+ ground(world);
442
+ // Created static, not mutated to static after the fact: mass properties are derived at insertion, so
443
+ // flipping the type afterwards leaves a nonzero inverse mass on a body that never integrates its
444
+ // position — it accumulates velocity forever and drags whatever is jointed to it out of the world.
445
+ const anchorBody = createRigidBody2D('static', 0, 4);
446
+ anchorBody.colliders.push(
447
+ createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE),
448
+ );
449
+ const anchor = addPhysics2DBody(world, anchorBody);
450
+ const left = box(world, -1, 2);
451
+ const right = box(world, 1, 2);
452
+ for (const bob of [left, right]) {
453
+ addPhysics2DJoint(world, {
454
+ kind: DISTANCE,
455
+ bodyA: anchor.index,
456
+ bodyB: bob.index,
457
+ localAnchorAX: 0,
458
+ localAnchorAY: 0,
459
+ localAnchorBX: 0,
460
+ localAnchorBY: 0,
461
+ collideConnected: false,
462
+ impulse0: 0,
463
+ impulse1: 0,
464
+ impulse2: 0,
465
+ rAX: 0,
466
+ rAY: 0,
467
+ rBX: 0,
468
+ rBY: 0,
469
+ length: 2,
470
+ stiffness: 0,
471
+ damping: 0,
472
+ } as never);
473
+ }
474
+ return { left, right, world };
475
+ }
476
+
477
+ it('produces the same trace when the broadphase reports its pairs in the opposite order', () => {
478
+ // OBLIGATION 2 EXTENDED TO P2. Joints share the contact list's iteration loop, so the contact sort has
479
+ // to keep holding once joints are also constraining the same bodies — a scene whose contacts reorder
480
+ // now perturbs the joint solve too.
481
+ const plain = jointedWorld();
482
+ const reversed = jointedWorld(createReversedPairBackend());
483
+ runSteps(plain.world, 120);
484
+ runSteps(reversed.world, 120);
485
+ expect(traceWorld(reversed.world)).toBe(traceWorld(plain.world));
486
+ });
487
+
488
+ it('orders every contact pair by body index with joints present', () => {
489
+ // OBLIGATION 1 EXTENDED TO P2.
490
+ const { world } = jointedWorld(createSwappedPairBackend());
491
+ runSteps(world, 120);
492
+ for (const contact of world.contacts) expect(contact.bodyA).toBeLessThan(contact.bodyB);
493
+ for (const joint of world.joints) expect(joint.bodyA).toBeLessThan(joint.bodyB);
494
+ });
495
+
496
+ it('is bit-for-bit repeatable with joints in the solve list', () => {
497
+ const first = jointedWorld();
498
+ const second = jointedWorld();
499
+ runSteps(first.world, 90);
500
+ runSteps(second.world, 90);
501
+ expect(traceWorld(second.world)).toBe(traceWorld(first.world));
502
+ });
503
+
504
+ it('suppresses the contact between jointed bodies unless the joint asks for it', () => {
505
+ // A jointed pair almost always overlaps at the anchor, and resolving that contact fights the
506
+ // constraint holding them together.
507
+ const world = createPhysics2DWorld(0, 0);
508
+ registerPhysics2DJointSolver(world, DISTANCE, physics2DDistanceJointSolver);
509
+ const a = box(world, 0, 0);
510
+ const b = box(world, 0.2, 0);
511
+ addPhysics2DJoint(world, {
512
+ kind: DISTANCE,
513
+ bodyA: a.index,
514
+ bodyB: b.index,
515
+ localAnchorAX: 0,
516
+ localAnchorAY: 0,
517
+ localAnchorBX: 0,
518
+ localAnchorBY: 0,
519
+ collideConnected: false,
520
+ impulse0: 0,
521
+ impulse1: 0,
522
+ impulse2: 0,
523
+ rAX: 0,
524
+ rAY: 0,
525
+ rBX: 0,
526
+ rBY: 0,
527
+ length: 0.2,
528
+ stiffness: 0,
529
+ damping: 0,
530
+ } as never);
531
+ runSteps(world, 10);
532
+ expect(world.contacts).toHaveLength(0);
533
+ });
534
+ });