@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/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/jointRegistry.d.ts +6 -0
- package/dist/jointRegistry.d.ts.map +1 -0
- package/dist/jointRegistry.js +61 -0
- package/dist/jointRegistry.js.map +1 -0
- package/dist/joints.d.ts +39 -0
- package/dist/joints.d.ts.map +1 -0
- package/dist/joints.js +488 -0
- package/dist/joints.js.map +1 -0
- package/dist/solver.d.ts +1 -0
- package/dist/solver.d.ts.map +1 -1
- package/dist/solver.js +15 -10
- package/dist/solver.js.map +1 -1
- package/dist/step.d.ts.map +1 -1
- package/dist/step.js +111 -8
- package/dist/step.js.map +1 -1
- package/dist/world.d.ts.map +1 -1
- package/dist/world.js +10 -0
- package/dist/world.js.map +1 -1
- package/package.json +5 -5
- package/src/jointRegistry.test.ts +145 -0
- package/src/joints.test.ts +648 -0
- package/src/solver.test.ts +19 -0
- package/src/step.test.ts +298 -0
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
|
+
});
|