@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,169 @@
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
+ import { createRigidBody2D } from './world';
6
+
7
+ function collider(local: CollisionShape, density = 1): Physics2DCollider {
8
+ return {
9
+ local,
10
+ world: local,
11
+ material: { density, friction: 0.2, restitution: 0 },
12
+ sensor: false,
13
+ };
14
+ }
15
+
16
+ // The constructor, not a literal that happens to match the fields: a body grows fields (sleep state, for
17
+ // one) and a literal silently goes stale the moment it does.
18
+ function body(colliders: Physics2DCollider[], type: RigidBody2D['type'] = 'dynamic'): RigidBody2D {
19
+ const created = createRigidBody2D(type, 0, 0);
20
+ created.index = 0;
21
+ created.colliders.push(...colliders);
22
+ return created;
23
+ }
24
+
25
+ function massData(): Physics2DMassData {
26
+ return { mass: 0, inertia: 0, centerX: 0, centerY: 0 };
27
+ }
28
+
29
+ describe('computePhysics2DColliderMassData', () => {
30
+ it('derives a disc from its area and half its mass-radius-squared', () => {
31
+ const out = massData();
32
+ computePhysics2DColliderMassData(collider({ kind: 'circle', x: 3, y: -2, radius: 2 }, 4), out);
33
+ expect(out.mass).toBeCloseTo(Math.PI * 4 * 4);
34
+ expect(out.inertia).toBeCloseTo(0.5 * Math.PI * 4 * 4 * 4);
35
+ expect(out.centerX).toBe(3);
36
+ expect(out.centerY).toBe(-2);
37
+ });
38
+
39
+ it('derives a box from its extents and centres it on the box', () => {
40
+ const out = massData();
41
+ computePhysics2DColliderMassData(collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 4, maxY: 2 }, 3), out);
42
+ expect(out.mass).toBeCloseTo(24);
43
+ expect(out.inertia).toBeCloseTo((24 * (16 + 4)) / 12);
44
+ expect(out.centerX).toBeCloseTo(2);
45
+ expect(out.centerY).toBeCloseTo(1);
46
+ });
47
+
48
+ it('gives an oriented box the same inertia as the axis-aligned box of equal extents', () => {
49
+ // Rotation about z does not change a rectangle's second moment about its own centre. Pinned
50
+ // because assuming otherwise is a natural mistake, and it would make a tilted crate swing wrong.
51
+ const upright = massData();
52
+ const tilted = massData();
53
+ computePhysics2DColliderMassData(collider({ kind: 'aabb', minX: -2, minY: -1, maxX: 2, maxY: 1 }), upright);
54
+ computePhysics2DColliderMassData(collider({ kind: 'obb', x: 0, y: 0, halfW: 2, halfH: 1, rotation: 0.7 }), tilted);
55
+ expect(tilted.mass).toBeCloseTo(upright.mass);
56
+ expect(tilted.inertia).toBeCloseTo(upright.inertia);
57
+ });
58
+
59
+ it('agrees with the box formula when the same square is given as a polygon', () => {
60
+ // Cross-validation between two independent derivations: the closed-form rectangle expression and
61
+ // the general polygon accumulation. Agreement is evidence neither is quietly wrong.
62
+ const asBox = massData();
63
+ const asPolygon = massData();
64
+ computePhysics2DColliderMassData(
65
+ collider({ kind: 'aabb', minX: -1.5, minY: -0.5, maxX: 1.5, maxY: 0.5 }, 2),
66
+ asBox,
67
+ );
68
+ computePhysics2DColliderMassData(
69
+ collider({ kind: 'polygon', points: [-1.5, -0.5, 1.5, -0.5, 1.5, 0.5, -1.5, 0.5] }, 2),
70
+ asPolygon,
71
+ );
72
+ expect(asPolygon.mass).toBeCloseTo(asBox.mass);
73
+ expect(asPolygon.inertia).toBeCloseTo(asBox.inertia);
74
+ expect(asPolygon.centerX).toBeCloseTo(asBox.centerX);
75
+ expect(asPolygon.centerY).toBeCloseTo(asBox.centerY);
76
+ });
77
+
78
+ it('gives a polygon the same mass whichever winding it is given in', () => {
79
+ const counterClockwise = massData();
80
+ const clockwise = massData();
81
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [0, 0, 4, 0, 4, 2, 0, 2] }), counterClockwise);
82
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [0, 0, 0, 2, 4, 2, 4, 0] }), clockwise);
83
+ expect(clockwise.mass).toBeCloseTo(counterClockwise.mass);
84
+ expect(clockwise.inertia).toBeCloseTo(counterClockwise.inertia);
85
+ expect(clockwise.centerX).toBeCloseTo(counterClockwise.centerX);
86
+ expect(clockwise.centerY).toBeCloseTo(counterClockwise.centerY);
87
+ });
88
+
89
+ it('offsets a polygon centroid without changing its inertia about that centroid', () => {
90
+ // The parallel-axis subtraction is the step most easily got wrong, and the wrongness is invisible
91
+ // in a shape centred on the origin. Translating the same square must move the centroid and leave
92
+ // the inertia untouched.
93
+ const atOrigin = massData();
94
+ const shifted = massData();
95
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [-1, -1, 1, -1, 1, 1, -1, 1] }), atOrigin);
96
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [9, 19, 11, 19, 11, 21, 9, 21] }), shifted);
97
+ expect(shifted.centerX).toBeCloseTo(10);
98
+ expect(shifted.centerY).toBeCloseTo(20);
99
+ expect(shifted.inertia).toBeCloseTo(atOrigin.inertia);
100
+ });
101
+
102
+ it('gives area-less and degenerate shapes no mass', () => {
103
+ const out = massData();
104
+ computePhysics2DColliderMassData(collider({ kind: 'segment', x0: 0, y0: 0, x1: 5, y1: 5 }), out);
105
+ expect(out.mass).toBe(0);
106
+ computePhysics2DColliderMassData(collider({ kind: 'point', x: 1, y: 1 }), out);
107
+ expect(out.mass).toBe(0);
108
+ // Collinear vertices enclose no area; dividing the centroid by it would be a NaN body.
109
+ computePhysics2DColliderMassData(collider({ kind: 'polygon', points: [0, 0, 1, 1, 2, 2] }), out);
110
+ expect(out.mass).toBe(0);
111
+ expect(Number.isNaN(out.centerX)).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('updateRigidBody2DMassData', () => {
116
+ it('combines two colliders into one centre of mass and shifts their inertia onto it', () => {
117
+ const left = collider({ kind: 'aabb', minX: -3, minY: -1, maxX: -1, maxY: 1 });
118
+ const right = collider({ kind: 'aabb', minX: 1, minY: -1, maxX: 3, maxY: 1 });
119
+ const target = body([left, right]);
120
+ updateRigidBody2DMassData(target);
121
+
122
+ expect(target.mass).toBeCloseTo(8);
123
+ expect(target.centerX).toBeCloseTo(0);
124
+ expect(target.centerY).toBeCloseTo(0);
125
+ // Each 2x2 box contributes its own inertia plus mass times the square of its 2-unit offset.
126
+ const own = (4 * (4 + 4)) / 12;
127
+ expect(target.inertia).toBeCloseTo(2 * (own + 4 * 4));
128
+ expect(target.inverseMass).toBeCloseTo(1 / 8);
129
+ });
130
+
131
+ it('gives a static body zero inverse mass and inertia while keeping its centre', () => {
132
+ // Zero inverse mass is the arithmetic that makes a static body immovable without a branch in the
133
+ // solver, so it must hold even though the shape has real area.
134
+ const ground = body([collider({ kind: 'aabb', minX: -5, minY: -1, maxX: 5, maxY: 0 })], 'static');
135
+ updateRigidBody2DMassData(ground);
136
+ expect(ground.inverseMass).toBe(0);
137
+ expect(ground.inverseInertia).toBe(0);
138
+ expect(ground.mass).toBe(0);
139
+ expect(ground.centerX).toBeCloseTo(0);
140
+ expect(ground.centerY).toBeCloseTo(-0.5);
141
+ });
142
+
143
+ it('gives a kinematic body zero inverse mass even though it moves', () => {
144
+ const platform = body([collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 4, maxY: 1 })], 'kinematic');
145
+ updateRigidBody2DMassData(platform);
146
+ expect(platform.inverseMass).toBe(0);
147
+ expect(platform.inverseInertia).toBe(0);
148
+ });
149
+
150
+ it('leaves a dynamic body with no area finite rather than dividing by its zero mass', () => {
151
+ // A body whose only collider is a sensor point has no mass. Inverting it would seed NaN into the
152
+ // velocity of everything it later touches, which is unrecoverable rather than merely wrong.
153
+ const ghost = body([collider({ kind: 'point', x: 0, y: 0 })]);
154
+ updateRigidBody2DMassData(ghost);
155
+ expect(ghost.inverseMass).toBe(0);
156
+ expect(ghost.inverseInertia).toBe(0);
157
+ expect(Number.isFinite(ghost.centerX)).toBe(true);
158
+ });
159
+
160
+ it('scales mass with density but leaves the centre of mass where it was', () => {
161
+ const light = body([collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 2, maxY: 2 }, 1)]);
162
+ const heavy = body([collider({ kind: 'aabb', minX: 0, minY: 0, maxX: 2, maxY: 2 }, 7)]);
163
+ updateRigidBody2DMassData(light);
164
+ updateRigidBody2DMassData(heavy);
165
+ expect(heavy.mass).toBeCloseTo(light.mass * 7);
166
+ expect(heavy.inertia).toBeCloseTo(light.inertia * 7);
167
+ expect(heavy.centerX).toBeCloseTo(light.centerX);
168
+ });
169
+ });
@@ -0,0 +1,186 @@
1
+ import type { RigidBody2D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ applyPhysics2DImpulse,
6
+ solvePhysics2DContactsOnce,
7
+ relativeNormalVelocity,
8
+ solvePhysics2DContacts,
9
+ warmStartPhysics2DContacts,
10
+ } from './solver';
11
+ import { stepPhysics2D } from './step';
12
+ import { addPhysics2DBody, createPhysics2DCollider, createPhysics2DWorld, createRigidBody2D } from './world';
13
+
14
+ const STONE = { density: 1, friction: 0.3, restitution: 0 };
15
+
16
+ function body(type: RigidBody2D['type'], x: number, y: number): RigidBody2D {
17
+ const made = createRigidBody2D(type, x, y);
18
+ made.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE));
19
+ return made;
20
+ }
21
+
22
+ function restingWorld() {
23
+ const world = createPhysics2DWorld();
24
+ const floor = createRigidBody2D('static', 0, 0);
25
+ floor.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -50, minY: -1, maxX: 50, maxY: 0 }, STONE));
26
+ addPhysics2DBody(world, floor);
27
+ const crate = addPhysics2DBody(world, body('dynamic', 0, 0.499));
28
+ return { world, crate };
29
+ }
30
+
31
+ describe('applyPhysics2DImpulse', () => {
32
+ it('pushes A along the impulse and B against it, matching the manifold normal direction', () => {
33
+ // The normal separates A out of B, so a positive impulse must move A along it. Reversed, the solver
34
+ // drives bodies together and a resting box settles below the floor rather than on it.
35
+ const world = createPhysics2DWorld();
36
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
37
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
38
+ applyPhysics2DImpulse(a, b, 0, 0, 0, 0, 0, 1);
39
+ expect(a.velocityY).toBeGreaterThan(0);
40
+ expect(b.velocityY).toBeLessThan(0);
41
+ });
42
+
43
+ it('spins a body when the impulse acts off its centre of mass', () => {
44
+ const world = createPhysics2DWorld();
45
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
46
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
47
+ applyPhysics2DImpulse(a, b, 0.5, 0, 0, 0, 0, 1);
48
+ expect(a.angularVelocity).not.toBe(0);
49
+ });
50
+
51
+ it('leaves a static body untouched through its zero inverse mass, with no branch', () => {
52
+ const world = createPhysics2DWorld();
53
+ const floor = addPhysics2DBody(world, body('static', 0, 0));
54
+ const crate = addPhysics2DBody(world, body('dynamic', 0, 1));
55
+ applyPhysics2DImpulse(floor, crate, 0.3, 0.2, 0, 0, 5, 7);
56
+ expect(floor.velocityX).toBe(0);
57
+ expect(floor.velocityY).toBe(0);
58
+ expect(floor.angularVelocity).toBe(0);
59
+ });
60
+ });
61
+
62
+ describe('relativeNormalVelocity', () => {
63
+ const point = {
64
+ x: 0,
65
+ y: 0,
66
+ depth: 0,
67
+ featureId: 0,
68
+ rAX: 0,
69
+ rAY: 0,
70
+ rBX: 0,
71
+ rBY: 0,
72
+ normalImpulse: 0,
73
+ tangentImpulse: 0,
74
+ normalMass: 0,
75
+ tangentMass: 0,
76
+ bias: 0,
77
+ };
78
+
79
+ it('is negative while the pair is closing along the separating normal', () => {
80
+ const world = createPhysics2DWorld();
81
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
82
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
83
+ b.velocityY = -1; // B falling toward A
84
+ expect(relativeNormalVelocity(a, b, point, 0, 1)).toBeGreaterThan(0);
85
+ b.velocityY = 1; // B moving away
86
+ expect(relativeNormalVelocity(a, b, point, 0, 1)).toBeLessThan(0);
87
+ });
88
+
89
+ it('includes the angular contribution at the lever arm', () => {
90
+ const world = createPhysics2DWorld();
91
+ const a = addPhysics2DBody(world, body('dynamic', 0, 0));
92
+ const b = addPhysics2DBody(world, body('dynamic', 0, 1));
93
+ const spinning = { ...point, rAX: 1, rAY: 0 };
94
+ a.angularVelocity = 2;
95
+ expect(relativeNormalVelocity(a, b, spinning, 0, 1)).toBeCloseTo(2);
96
+ });
97
+ });
98
+
99
+ describe('solvePhysics2DContacts', () => {
100
+ it('removes the closing velocity at a resting contact', () => {
101
+ const { world, crate } = restingWorld();
102
+ crate.velocityY = -5;
103
+ stepPhysics2D(world, 1 / 60);
104
+ expect(crate.velocityY).toBeGreaterThan(-1);
105
+ });
106
+
107
+ it('accumulates a non-negative normal impulse rather than pulling the pair together', () => {
108
+ // The clamp is on the ACCUMULATED impulse, not the increment: a contact may never pull.
109
+ const { world } = restingWorld();
110
+ for (let i = 0; i < 30; i++) stepPhysics2D(world, 1 / 60);
111
+ for (const contact of world.contacts) {
112
+ for (let i = 0; i < contact.pointCount; i++) {
113
+ expect(contact.points[i].normalImpulse).toBeGreaterThanOrEqual(0);
114
+ }
115
+ }
116
+ });
117
+
118
+ it('bounds friction by the Coulomb limit against the normal impulse', () => {
119
+ const { world, crate } = restingWorld();
120
+ crate.velocityX = 4;
121
+ for (let i = 0; i < 20; i++) stepPhysics2D(world, 1 / 60);
122
+ for (const contact of world.contacts) {
123
+ for (let i = 0; i < contact.pointCount; i++) {
124
+ const point = contact.points[i];
125
+ expect(Math.abs(point.tangentImpulse)).toBeLessThanOrEqual(contact.friction * point.normalImpulse + 1e-9);
126
+ }
127
+ }
128
+ });
129
+
130
+ it('slows a sliding box through friction instead of letting it glide forever', () => {
131
+ const { world, crate } = restingWorld();
132
+ crate.velocityX = 4;
133
+ for (let i = 0; i < 60; i++) stepPhysics2D(world, 1 / 60);
134
+ expect(crate.velocityX).toBeLessThan(4);
135
+ expect(crate.velocityX).toBeGreaterThan(0);
136
+ });
137
+
138
+ it('does nothing for a world with no contacts', () => {
139
+ const world = createPhysics2DWorld();
140
+ expect(() => solvePhysics2DContacts(world)).not.toThrow();
141
+ });
142
+ });
143
+
144
+ describe('solvePhysics2DContactsOnce', () => {
145
+ it('applies one pass, so the step can interleave contacts with joints inside a single iteration', () => {
146
+ // Joints and contacts constrain the same bodies. Giving either a whole pass to itself lets it undo
147
+ // what the other just corrected, which is why the step alternates them rather than running one solver
148
+ // to convergence and then the other.
149
+ const { world, crate } = restingWorld();
150
+ crate.velocityY = -5;
151
+ stepPhysics2D(world, 1 / 60);
152
+ const before = crate.velocityY;
153
+ solvePhysics2DContactsOnce(world);
154
+ expect(crate.velocityY).not.toBe(before);
155
+ });
156
+
157
+ it('does nothing for a world with no contacts', () => {
158
+ expect(() => solvePhysics2DContactsOnce(createPhysics2DWorld())).not.toThrow();
159
+ });
160
+ });
161
+
162
+ describe('warmStartPhysics2DContacts', () => {
163
+ it('reapplies the cached impulse, leaving a converged resting contact deeper than a cold start', () => {
164
+ // The measurable effect of warm starting: a settled stack keeps its impulses and stays put, where a
165
+ // cold-started one gives them up each step and sinks into its own slop.
166
+ const warm = createPhysics2DWorld();
167
+ const cold = createPhysics2DWorld();
168
+ cold.config.warmStarting = false;
169
+ for (const world of [warm, cold]) {
170
+ const floor = createRigidBody2D('static', 0, 0);
171
+ floor.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -50, minY: -1, maxX: 50, maxY: 0 }, STONE));
172
+ addPhysics2DBody(world, floor);
173
+ addPhysics2DBody(world, body('dynamic', 0, 0.5));
174
+ addPhysics2DBody(world, body('dynamic', 0, 1.5));
175
+ addPhysics2DBody(world, body('dynamic', 0, 2.5));
176
+ for (let i = 0; i < 120; i++) stepPhysics2D(world, 1 / 60);
177
+ }
178
+ const warmTop = warm.bodies[3].y;
179
+ const coldTop = cold.bodies[3].y;
180
+ expect(warmTop).toBeGreaterThanOrEqual(coldTop - 1e-9);
181
+ });
182
+
183
+ it('does nothing for a world with no contacts', () => {
184
+ expect(() => warmStartPhysics2DContacts(createPhysics2DWorld())).not.toThrow();
185
+ });
186
+ });