@flighthq/physics2d 0.3.0-next.1021.f5f1ee0

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.
@@ -0,0 +1,185 @@
1
+ import type { CollisionShape, Physics2DCollider, Physics2DMassData, RigidBody2D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { computePhysics2DColliderMassData, updateRigidBody2DMassData } from './massProperties';
5
+
6
+ function collider(local: CollisionShape, density = 1): Physics2DCollider {
7
+ return {
8
+ local,
9
+ world: local,
10
+ material: { density, friction: 0.2, restitution: 0 },
11
+ sensor: false,
12
+ };
13
+ }
14
+
15
+ function body(colliders: Physics2DCollider[], type: RigidBody2D['type'] = 'dynamic'): RigidBody2D {
16
+ return {
17
+ index: 0,
18
+ type,
19
+ x: 0,
20
+ y: 0,
21
+ angle: 0,
22
+ velocityX: 0,
23
+ velocityY: 0,
24
+ angularVelocity: 0,
25
+ forceX: 0,
26
+ forceY: 0,
27
+ torque: 0,
28
+ mass: 0,
29
+ inverseMass: 0,
30
+ inertia: 0,
31
+ inverseInertia: 0,
32
+ centerX: 0,
33
+ centerY: 0,
34
+ linearDamping: 0,
35
+ angularDamping: 0,
36
+ gravityScale: 1,
37
+ colliders,
38
+ };
39
+ }
40
+
41
+ function massData(): Physics2DMassData {
42
+ return { mass: 0, inertia: 0, centerX: 0, centerY: 0 };
43
+ }
44
+
45
+ describe('computePhysics2DColliderMassData', () => {
46
+ it('derives a disc from its area and half its mass-radius-squared', () => {
47
+ const out = massData();
48
+ computePhysics2DColliderMassData(collider({ kind: 'circle', x: 3, y: -2, radius: 2 }, 4), out);
49
+ expect(out.mass).toBeCloseTo(Math.PI * 4 * 4);
50
+ expect(out.inertia).toBeCloseTo(0.5 * Math.PI * 4 * 4 * 4);
51
+ expect(out.centerX).toBe(3);
52
+ expect(out.centerY).toBe(-2);
53
+ });
54
+
55
+ it('derives a box from its extents and centres it on the box', () => {
56
+ const out = massData();
57
+ computePhysics2DColliderMassData(collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 4, maxY: 2 }, 3), out);
58
+ expect(out.mass).toBeCloseTo(24);
59
+ expect(out.inertia).toBeCloseTo((24 * (16 + 4)) / 12);
60
+ expect(out.centerX).toBeCloseTo(2);
61
+ expect(out.centerY).toBeCloseTo(1);
62
+ });
63
+
64
+ it('gives an oriented box the same inertia as the axis-aligned box of equal extents', () => {
65
+ // Rotation about z does not change a rectangle's second moment about its own centre. Pinned
66
+ // because assuming otherwise is a natural mistake, and it would make a tilted crate swing wrong.
67
+ const upright = massData();
68
+ const tilted = massData();
69
+ computePhysics2DColliderMassData(collider({ kind: 'aabb', minX: -2, minY: -1, maxX: 2, maxY: 1 }), upright);
70
+ computePhysics2DColliderMassData(collider({ kind: 'obb', x: 0, y: 0, halfW: 2, halfH: 1, rotation: 0.7 }), tilted);
71
+ expect(tilted.mass).toBeCloseTo(upright.mass);
72
+ expect(tilted.inertia).toBeCloseTo(upright.inertia);
73
+ });
74
+
75
+ it('agrees with the box formula when the same square is given as a polygon', () => {
76
+ // Cross-validation between two independent derivations: the closed-form rectangle expression and
77
+ // the general polygon accumulation. Agreement is evidence neither is quietly wrong.
78
+ const asBox = massData();
79
+ const asPolygon = massData();
80
+ computePhysics2DColliderMassData(
81
+ collider({ kind: 'aabb', minX: -1.5, minY: -0.5, maxX: 1.5, maxY: 0.5 }, 2),
82
+ asBox,
83
+ );
84
+ computePhysics2DColliderMassData(
85
+ collider({ kind: 'polygon', points: [-1.5, -0.5, 1.5, -0.5, 1.5, 0.5, -1.5, 0.5] }, 2),
86
+ asPolygon,
87
+ );
88
+ expect(asPolygon.mass).toBeCloseTo(asBox.mass);
89
+ expect(asPolygon.inertia).toBeCloseTo(asBox.inertia);
90
+ expect(asPolygon.centerX).toBeCloseTo(asBox.centerX);
91
+ expect(asPolygon.centerY).toBeCloseTo(asBox.centerY);
92
+ });
93
+
94
+ it('gives a polygon the same mass whichever winding it is given in', () => {
95
+ const counterClockwise = massData();
96
+ const clockwise = massData();
97
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [0, 0, 4, 0, 4, 2, 0, 2] }), counterClockwise);
98
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [0, 0, 0, 2, 4, 2, 4, 0] }), clockwise);
99
+ expect(clockwise.mass).toBeCloseTo(counterClockwise.mass);
100
+ expect(clockwise.inertia).toBeCloseTo(counterClockwise.inertia);
101
+ expect(clockwise.centerX).toBeCloseTo(counterClockwise.centerX);
102
+ expect(clockwise.centerY).toBeCloseTo(counterClockwise.centerY);
103
+ });
104
+
105
+ it('offsets a polygon centroid without changing its inertia about that centroid', () => {
106
+ // The parallel-axis subtraction is the step most easily got wrong, and the wrongness is invisible
107
+ // in a shape centred on the origin. Translating the same square must move the centroid and leave
108
+ // the inertia untouched.
109
+ const atOrigin = massData();
110
+ const shifted = massData();
111
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [-1, -1, 1, -1, 1, 1, -1, 1] }), atOrigin);
112
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [9, 19, 11, 19, 11, 21, 9, 21] }), shifted);
113
+ expect(shifted.centerX).toBeCloseTo(10);
114
+ expect(shifted.centerY).toBeCloseTo(20);
115
+ expect(shifted.inertia).toBeCloseTo(atOrigin.inertia);
116
+ });
117
+
118
+ it('gives area-less and degenerate shapes no mass', () => {
119
+ const out = massData();
120
+ computePhysics2DColliderMassData(collider({ kind: 'segment', x0: 0, y0: 0, x1: 5, y1: 5 }), out);
121
+ expect(out.mass).toBe(0);
122
+ computePhysics2DColliderMassData(collider({ kind: 'point', x: 1, y: 1 }), out);
123
+ expect(out.mass).toBe(0);
124
+ // Collinear vertices enclose no area; dividing the centroid by it would be a NaN body.
125
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [0, 0, 1, 1, 2, 2] }), out);
126
+ expect(out.mass).toBe(0);
127
+ expect(Number.isNaN(out.centerX)).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe('updateRigidBody2DMassData', () => {
132
+ it('combines two colliders into one centre of mass and shifts their inertia onto it', () => {
133
+ const left = collider({ kind: 'aabb', minX: -3, minY: -1, maxX: -1, maxY: 1 });
134
+ const right = collider({ kind: 'aabb', minX: 1, minY: -1, maxX: 3, maxY: 1 });
135
+ const target = body([left, right]);
136
+ updateRigidBody2DMassData(target);
137
+
138
+ expect(target.mass).toBeCloseTo(8);
139
+ expect(target.centerX).toBeCloseTo(0);
140
+ expect(target.centerY).toBeCloseTo(0);
141
+ // Each 2x2 box contributes its own inertia plus mass times the square of its 2-unit offset.
142
+ const own = (4 * (4 + 4)) / 12;
143
+ expect(target.inertia).toBeCloseTo(2 * (own + 4 * 4));
144
+ expect(target.inverseMass).toBeCloseTo(1 / 8);
145
+ });
146
+
147
+ it('gives a static body zero inverse mass and inertia while keeping its centre', () => {
148
+ // Zero inverse mass is the arithmetic that makes a static body immovable without a branch in the
149
+ // solver, so it must hold even though the shape has real area.
150
+ const ground = body([collider({ kind: 'aabb', minX: -5, minY: -1, maxX: 5, maxY: 0 })], 'static');
151
+ updateRigidBody2DMassData(ground);
152
+ expect(ground.inverseMass).toBe(0);
153
+ expect(ground.inverseInertia).toBe(0);
154
+ expect(ground.mass).toBe(0);
155
+ expect(ground.centerX).toBeCloseTo(0);
156
+ expect(ground.centerY).toBeCloseTo(-0.5);
157
+ });
158
+
159
+ it('gives a kinematic body zero inverse mass even though it moves', () => {
160
+ const platform = body([collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 4, maxY: 1 })], 'kinematic');
161
+ updateRigidBody2DMassData(platform);
162
+ expect(platform.inverseMass).toBe(0);
163
+ expect(platform.inverseInertia).toBe(0);
164
+ });
165
+
166
+ it('leaves a dynamic body with no area finite rather than dividing by its zero mass', () => {
167
+ // A body whose only collider is a sensor point has no mass. Inverting it would seed NaN into the
168
+ // velocity of everything it later touches, which is unrecoverable rather than merely wrong.
169
+ const ghost = body([collider({ kind: 'point', x: 0, y: 0 })]);
170
+ updateRigidBody2DMassData(ghost);
171
+ expect(ghost.inverseMass).toBe(0);
172
+ expect(ghost.inverseInertia).toBe(0);
173
+ expect(Number.isFinite(ghost.centerX)).toBe(true);
174
+ });
175
+
176
+ it('scales mass with density but leaves the centre of mass where it was', () => {
177
+ const light = body([collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 2, maxY: 2 }, 1)]);
178
+ const heavy = body([collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 2, maxY: 2 }, 7)]);
179
+ updateRigidBody2DMassData(light);
180
+ updateRigidBody2DMassData(heavy);
181
+ expect(heavy.mass).toBeCloseTo(light.mass * 7);
182
+ expect(heavy.inertia).toBeCloseTo(light.inertia * 7);
183
+ expect(heavy.centerX).toBeCloseTo(light.centerX);
184
+ });
185
+ });
@@ -0,0 +1,167 @@
1
+ import type { RigidBody2D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ applyPhysics2DImpulse,
6
+ relativeNormalVelocity,
7
+ solvePhysics2DContacts,
8
+ warmStartPhysics2DContacts,
9
+ } from './solver';
10
+ import { stepPhysics2D } from './step';
11
+ import { addPhysics2DBody, createPhysics2DCollider, createPhysics2DWorld, createRigidBody2D } from './world';
12
+
13
+ const STONE = { density: 1, friction: 0.3, restitution: 0 };
14
+
15
+ function body(type: RigidBody2D['type'], x: number, y: number): RigidBody2D {
16
+ const made = createRigidBody2D(type, x, y);
17
+ made.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE));
18
+ return made;
19
+ }
20
+
21
+ function restingWorld() {
22
+ const world = createPhysics2DWorld();
23
+ const floor = createRigidBody2D('static', 0, 0);
24
+ floor.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -50, minY: -1, maxX: 50, maxY: 0 }, STONE));
25
+ addPhysics2DBody(world, floor);
26
+ const crate = addPhysics2DBody(world, body('dynamic', 0, 0.499));
27
+ return { world, crate };
28
+ }
29
+
30
+ describe('applyPhysics2DImpulse', () => {
31
+ it('pushes A along the impulse and B against it, matching the manifold normal direction', () => {
32
+ // The normal separates A out of B, so a positive impulse must move A along it. Reversed, the solver
33
+ // drives bodies together and a resting box settles below the floor rather than on it.
34
+ const world = createPhysics2DWorld();
35
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
36
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
37
+ applyPhysics2DImpulse(a, b, 0, 0, 0, 0, 0, 1);
38
+ expect(a.velocityY).toBeGreaterThan(0);
39
+ expect(b.velocityY).toBeLessThan(0);
40
+ });
41
+
42
+ it('spins a body when the impulse acts off its centre of mass', () => {
43
+ const world = createPhysics2DWorld();
44
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
45
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
46
+ applyPhysics2DImpulse(a, b, 0.5, 0, 0, 0, 0, 1);
47
+ expect(a.angularVelocity).not.toBe(0);
48
+ });
49
+
50
+ it('leaves a static body untouched through its zero inverse mass, with no branch', () => {
51
+ const world = createPhysics2DWorld();
52
+ const floor = addPhysics2DBody(world, body('static', 0, 0));
53
+ const crate = addPhysics2DBody(world, body('dynamic', 0, 1));
54
+ applyPhysics2DImpulse(floor, crate, 0.3, 0.2, 0, 0, 5, 7);
55
+ expect(floor.velocityX).toBe(0);
56
+ expect(floor.velocityY).toBe(0);
57
+ expect(floor.angularVelocity).toBe(0);
58
+ });
59
+ });
60
+
61
+ describe('relativeNormalVelocity', () => {
62
+ const point = {
63
+ x: 0,
64
+ y: 0,
65
+ depth: 0,
66
+ featureId: 0,
67
+ rAX: 0,
68
+ rAY: 0,
69
+ rBX: 0,
70
+ rBY: 0,
71
+ normalImpulse: 0,
72
+ tangentImpulse: 0,
73
+ normalMass: 0,
74
+ tangentMass: 0,
75
+ bias: 0,
76
+ };
77
+
78
+ it('is negative while the pair is closing along the separating normal', () => {
79
+ const world = createPhysics2DWorld();
80
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
81
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
82
+ b.velocityY = -1; // B falling toward A
83
+ expect(relativeNormalVelocity(a, b, point, 0, 1)).toBeGreaterThan(0);
84
+ b.velocityY = 1; // B moving away
85
+ expect(relativeNormalVelocity(a, b, point, 0, 1)).toBeLessThan(0);
86
+ });
87
+
88
+ it('includes the angular contribution at the lever arm', () => {
89
+ const world = createPhysics2DWorld();
90
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
91
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
92
+ const spinning = { ...point, rAX: 1, rAY: 0 };
93
+ a.angularVelocity = 2;
94
+ expect(relativeNormalVelocity(a, b, spinning, 0, 1)).toBeCloseTo(2);
95
+ });
96
+ });
97
+
98
+ describe('solvePhysics2DContacts', () => {
99
+ it('removes the closing velocity at a resting contact', () => {
100
+ const { world, crate } = restingWorld();
101
+ crate.velocityY = -5;
102
+ stepPhysics2D(world, 1 / 60);
103
+ expect(crate.velocityY).toBeGreaterThan(-1);
104
+ });
105
+
106
+ it('accumulates a non-negative normal impulse rather than pulling the pair together', () => {
107
+ // The clamp is on the ACCUMULATED impulse, not the increment: a contact may never pull.
108
+ const { world } = restingWorld();
109
+ for (let i = 0; i < 30; i++) stepPhysics2D(world, 1 / 60);
110
+ for (const contact of world.contacts) {
111
+ for (let i = 0; i < contact.pointCount; i++) {
112
+ expect(contact.points[i].normalImpulse).toBeGreaterThanOrEqual(0);
113
+ }
114
+ }
115
+ });
116
+
117
+ it('bounds friction by the Coulomb limit against the normal impulse', () => {
118
+ const { world, crate } = restingWorld();
119
+ crate.velocityX = 4;
120
+ for (let i = 0; i < 20; i++) stepPhysics2D(world, 1 / 60);
121
+ for (const contact of world.contacts) {
122
+ for (let i = 0; i < contact.pointCount; i++) {
123
+ const point = contact.points[i];
124
+ expect(Math.abs(point.tangentImpulse)).toBeLessThanOrEqual(contact.friction * point.normalImpulse + 1e-9);
125
+ }
126
+ }
127
+ });
128
+
129
+ it('slows a sliding box through friction instead of letting it glide forever', () => {
130
+ const { world, crate } = restingWorld();
131
+ crate.velocityX = 4;
132
+ for (let i = 0; i < 60; i++) stepPhysics2D(world, 1 / 60);
133
+ expect(crate.velocityX).toBeLessThan(4);
134
+ expect(crate.velocityX).toBeGreaterThan(0);
135
+ });
136
+
137
+ it('does nothing for a world with no contacts', () => {
138
+ const world = createPhysics2DWorld();
139
+ expect(() => solvePhysics2DContacts(world)).not.toThrow();
140
+ });
141
+ });
142
+
143
+ describe('warmStartPhysics2DContacts', () => {
144
+ it('reapplies the cached impulse, leaving a converged resting contact deeper than a cold start', () => {
145
+ // The measurable effect of warm starting: a settled stack keeps its impulses and stays put, where a
146
+ // cold-started one gives them up each step and sinks into its own slop.
147
+ const warm = createPhysics2DWorld();
148
+ const cold = createPhysics2DWorld();
149
+ cold.config.warmStarting = false;
150
+ for (const world of [warm, cold]) {
151
+ const floor = createRigidBody2D('static', 0, 0);
152
+ floor.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -50, minY: -1, maxX: 50, maxY: 0 }, STONE));
153
+ addPhysics2DBody(world, floor);
154
+ addPhysics2DBody(world, body('dynamic', 0, 0.5));
155
+ addPhysics2DBody(world, body('dynamic', 0, 1.5));
156
+ addPhysics2DBody(world, body('dynamic', 0, 2.5));
157
+ for (let i = 0; i < 120; i++) stepPhysics2D(world, 1 / 60);
158
+ }
159
+ const warmTop = warm.bodies[3].y;
160
+ const coldTop = cold.bodies[3].y;
161
+ expect(warmTop).toBeGreaterThanOrEqual(coldTop - 1e-9);
162
+ });
163
+
164
+ it('does nothing for a world with no contacts', () => {
165
+ expect(() => warmStartPhysics2DContacts(createPhysics2DWorld())).not.toThrow();
166
+ });
167
+ });
@@ -0,0 +1,236 @@
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 { stepPhysics2D } from './step';
6
+ import { addPhysics2DBody, createPhysics2DCollider, createPhysics2DWorld, createRigidBody2D } from './world';
7
+
8
+ const STONE = { density: 1, friction: 0.3, restitution: 0 };
9
+
10
+ function ground(world: Physics2DWorld): RigidBody2D {
11
+ const body = createRigidBody2D('static', 0, 0);
12
+ body.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -50, minY: -1, maxX: 50, maxY: 0 }, STONE));
13
+ return addPhysics2DBody(world, body);
14
+ }
15
+
16
+ function box(world: Physics2DWorld, x: number, y: number, half = 0.5): RigidBody2D {
17
+ const body = createRigidBody2D('dynamic', x, y);
18
+ body.colliders.push(
19
+ createPhysics2DCollider({ kind: 'aabb', minX: -half, minY: -half, maxX: half, maxY: half }, STONE),
20
+ );
21
+ return addPhysics2DBody(world, body);
22
+ }
23
+
24
+ // A hash of every body's full state, which is what a determinism claim has to be made against — comparing
25
+ // only positions would miss a divergence that has entered the velocities and not yet moved anything.
26
+ function traceWorld(world: Readonly<Physics2DWorld>): string {
27
+ return world.bodies
28
+ .slice()
29
+ .sort((left, right) => left.index - right.index)
30
+ .map((b) => [b.index, b.x, b.y, b.angle, b.velocityX, b.velocityY, b.angularVelocity].join(':'))
31
+ .join('|');
32
+ }
33
+
34
+ // A broadphase that returns its pairs in reverse order, and one that swaps each pair's two ids. Both
35
+ // wrap the real grid, so the candidate SET is identical and only its presentation differs — which is
36
+ // what makes them isolate ordering rather than change the simulation.
37
+ function createReversedPairBackend(): SpatialIndexBackend {
38
+ const inner = createUniformGridSpatialBackend(1);
39
+ return {
40
+ ...inner,
41
+ querySpatialPairs(out: SpatialPair[]): void {
42
+ inner.querySpatialPairs(out);
43
+ out.reverse();
44
+ },
45
+ };
46
+ }
47
+
48
+ function createSwappedPairBackend(): SpatialIndexBackend {
49
+ const inner = createUniformGridSpatialBackend(1);
50
+ return {
51
+ ...inner,
52
+ querySpatialPairs(out: SpatialPair[]): void {
53
+ inner.querySpatialPairs(out);
54
+ for (const pair of out) {
55
+ const a = pair.a;
56
+ pair.a = pair.b;
57
+ pair.b = a;
58
+ }
59
+ },
60
+ };
61
+ }
62
+
63
+ function runSteps(world: Physics2DWorld, count: number): void {
64
+ for (let i = 0; i < count; i++) stepPhysics2D(world, 1 / 60);
65
+ }
66
+
67
+ describe('stepPhysics2D', () => {
68
+ it('rests a box on the ground instead of sinking through it', () => {
69
+ const world = createPhysics2DWorld();
70
+ ground(world);
71
+ const crate = box(world, 0, 2);
72
+ runSteps(world, 180);
73
+
74
+ // Half-extent 0.5 above a ground surface at y=0: the resting centre is y=0.5, less the solver's
75
+ // deliberate penetration slop.
76
+ expect(crate.y).toBeGreaterThan(0.48);
77
+ expect(crate.y).toBeLessThan(0.52);
78
+ expect(Math.abs(crate.velocityY)).toBeLessThan(0.05);
79
+ });
80
+
81
+ it('keeps a stack standing rather than letting the lower boxes be compressed through each other', () => {
82
+ // The case warm starting exists for. Without it the solver restarts from zero impulse every step, so
83
+ // the bottom box never converges against the weight above it and the stack visibly sinks.
84
+ const world = createPhysics2DWorld();
85
+ ground(world);
86
+ const bottom = box(world, 0, 0.5);
87
+ const middle = box(world, 0, 1.5);
88
+ const top = box(world, 0, 2.5);
89
+ runSteps(world, 240);
90
+
91
+ expect(bottom.y).toBeGreaterThan(0.45);
92
+ expect(middle.y).toBeGreaterThan(1.4);
93
+ expect(top.y).toBeGreaterThan(2.35);
94
+ expect(middle.y - bottom.y).toBeGreaterThan(0.9);
95
+ expect(top.y - middle.y).toBeGreaterThan(0.9);
96
+ });
97
+
98
+ it('produces a bitwise-identical trace for the same scene stepped twice', () => {
99
+ // The golden-trace harness. Determinism for a fixed engine and input order is exact, not approximate:
100
+ // every operation on this path is IEEE-754 exact (+ - * / and sqrt), so anything short of bitwise
101
+ // equality is a real divergence rather than accumulated noise.
102
+ const first = createPhysics2DWorld();
103
+ ground(first);
104
+ box(first, 0.1, 2);
105
+ box(first, -0.3, 3.2);
106
+ runSteps(first, 120);
107
+
108
+ const second = createPhysics2DWorld();
109
+ ground(second);
110
+ box(second, 0.1, 2);
111
+ box(second, -0.3, 3.2);
112
+ runSteps(second, 120);
113
+
114
+ expect(traceWorld(second)).toBe(traceWorld(first));
115
+ });
116
+
117
+ it('produces the same trace when the broadphase reports its pairs in the opposite order', () => {
118
+ // ORDER-INDEPENDENCE, OBLIGATION 2 — the contact LIST sort.
119
+ //
120
+ // The harness injects a broadphase that reverses its pair list, leaving the bodies and their indices
121
+ // untouched. That isolates the variable that matters: `querySpatialPairs` walks a Map of Sets, so its
122
+ // order follows insertion and movement history, and a sequential-impulse solver applies each impulse
123
+ // against the velocities the previous ones left. Without the canonical sort the answer would depend
124
+ // on the broadphase's history.
125
+ //
126
+ // Note what this does NOT test, and what an insertion-order shuffle would wrongly claim: reordering
127
+ // INSERTION changes the body indices, hence the canonical solve order, hence — legitimately — the
128
+ // result. Canonical ordering buys DETERMINISM (same input, same output), not invariance to how the
129
+ // scene was built. A harness asserting the latter asserts something false about Gauss-Seidel.
130
+ const plain = createPhysics2DWorld();
131
+ ground(plain);
132
+ const plainLeft = box(plain, -0.9, 0.5);
133
+ const plainRight = box(plain, 0.9, 0.5);
134
+ const plainTop = box(plain, 0, 1.6);
135
+
136
+ const reversed = createPhysics2DWorld(0, -9.81, createReversedPairBackend());
137
+ ground(reversed);
138
+ const reversedLeft = box(reversed, -0.9, 0.5);
139
+ const reversedRight = box(reversed, 0.9, 0.5);
140
+ const reversedTop = box(reversed, 0, 1.6);
141
+
142
+ runSteps(plain, 90);
143
+ runSteps(reversed, 90);
144
+
145
+ expect(traceWorld(reversed)).toBe(traceWorld(plain));
146
+ expect(reversedLeft.y).toBe(plainLeft.y);
147
+ expect(reversedRight.y).toBe(plainRight.y);
148
+ expect(reversedTop.y).toBe(plainTop.y);
149
+ });
150
+
151
+ it('orders every contact pair by body index however the broadphase hands it over', () => {
152
+ // ORDER-INDEPENDENCE, OBLIGATION 1 — the per-pair BODY sort, which the harness above cannot see.
153
+ // Reversing the pair LIST does not change which body of a pair reaches the narrow phase first; that
154
+ // follows the pair's own field order. This backend swaps `a` and `b` within every pair, which is the
155
+ // only thing that exercises it. Unordered, collision would resolve contact points on the opposite
156
+ // surface and renumber their feature ids, silently discarding the warm-start cache every step.
157
+ const swapped = createPhysics2DWorld(0, -9.81, createSwappedPairBackend());
158
+ ground(swapped);
159
+ const crate = box(swapped, 0.2, 1.4);
160
+ runSteps(swapped, 120);
161
+
162
+ for (const contact of swapped.contacts) expect(contact.bodyA).toBeLessThan(contact.bodyB);
163
+
164
+ const plain = createPhysics2DWorld();
165
+ ground(plain);
166
+ const plainCrate = box(plain, 0.2, 1.4);
167
+ runSteps(plain, 120);
168
+
169
+ expect(crate.y).toBe(plainCrate.y);
170
+ expect(crate.x).toBe(plainCrate.x);
171
+ expect(crate.angle).toBe(plainCrate.angle);
172
+ });
173
+
174
+ it('keeps the contact list in a canonical order after a step', () => {
175
+ const world = createPhysics2DWorld();
176
+ ground(world);
177
+ box(world, -0.9, 0.5);
178
+ box(world, 0.9, 0.5);
179
+ box(world, 0, 1.6);
180
+ runSteps(world, 60);
181
+
182
+ expect(world.contacts.length).toBeGreaterThan(1);
183
+ for (let i = 1; i < world.contacts.length; i++) {
184
+ const previous = world.contacts[i - 1];
185
+ const current = world.contacts[i];
186
+ const ordered =
187
+ previous.bodyA < current.bodyA ||
188
+ (previous.bodyA === current.bodyA &&
189
+ (previous.bodyB < current.bodyB ||
190
+ (previous.bodyB === current.bodyB && previous.colliderA <= current.colliderA)));
191
+ expect(ordered).toBe(true);
192
+ }
193
+ });
194
+
195
+ it('tips a box that overhangs a ledge instead of sliding off level', () => {
196
+ // The proof that contact points carry torque. With only a minimum-translation vector and no point,
197
+ // the lever arm is zero, the angular term vanishes, and an overhanging box slides off perfectly
198
+ // level — which is what the whole contact-manifold lane exists to prevent.
199
+ const world = createPhysics2DWorld();
200
+ const ledge = createRigidBody2D('static', 0, 0);
201
+ ledge.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -5, minY: -1, maxX: 0, maxY: 0 }, STONE));
202
+ addPhysics2DBody(world, ledge);
203
+ // The centre of mass must be BEYOND the support edge at x=0 for there to be a tipping moment at
204
+ // all; a box whose centre still sits over the ledge is supported and correctly stays level.
205
+ const crate = box(world, 0.2, 0.5);
206
+ runSteps(world, 120);
207
+
208
+ expect(Math.abs(crate.angle)).toBeGreaterThan(0.05);
209
+ });
210
+
211
+ it('leaves a sensor collider overlapping without pushing anything out of it', () => {
212
+ const world = createPhysics2DWorld();
213
+ const trigger = createRigidBody2D('static', 0, 0);
214
+ trigger.colliders.push(
215
+ createPhysics2DCollider({ kind: 'aabb', minX: -2, minY: -2, maxX: 2, maxY: 2 }, STONE, true),
216
+ );
217
+ addPhysics2DBody(world, trigger);
218
+ const crate = box(world, 0, 0);
219
+ runSteps(world, 30);
220
+
221
+ expect(world.contacts.some((contact) => contact.sensor)).toBe(true);
222
+ // Gravity keeps pulling it: a sensor reports the overlap and applies no impulse.
223
+ expect(crate.velocityY).toBeLessThan(-0.1);
224
+ });
225
+
226
+ it('ignores a non-positive timestep rather than integrating backwards', () => {
227
+ const world = createPhysics2DWorld();
228
+ ground(world);
229
+ const crate = box(world, 0, 2);
230
+ const before = traceWorld(world);
231
+ stepPhysics2D(world, 0);
232
+ stepPhysics2D(world, -1 / 60);
233
+ expect(traceWorld(world)).toBe(before);
234
+ expect(crate.y).toBe(2);
235
+ });
236
+ });