@flighthq/physics3d 0.4.0-next.1694.598ef6f

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 (109) hide show
  1. package/dist/contacts.d.ts +4 -0
  2. package/dist/contacts.d.ts.map +1 -0
  3. package/dist/contacts.js +55 -0
  4. package/dist/contacts.js.map +1 -0
  5. package/dist/contract.d.ts +17 -0
  6. package/dist/contract.d.ts.map +1 -0
  7. package/dist/contract.js +17 -0
  8. package/dist/contract.js.map +1 -0
  9. package/dist/enablePhysics3DGuards.d.ts +4 -0
  10. package/dist/enablePhysics3DGuards.d.ts.map +1 -0
  11. package/dist/enablePhysics3DGuards.js +68 -0
  12. package/dist/enablePhysics3DGuards.js.map +1 -0
  13. package/dist/explainPhysics3DJoints.d.ts +3 -0
  14. package/dist/explainPhysics3DJoints.d.ts.map +1 -0
  15. package/dist/explainPhysics3DJoints.js +32 -0
  16. package/dist/explainPhysics3DJoints.js.map +1 -0
  17. package/dist/explainPhysics3DStep.d.ts +3 -0
  18. package/dist/explainPhysics3DStep.d.ts.map +1 -0
  19. package/dist/explainPhysics3DStep.js +48 -0
  20. package/dist/explainPhysics3DStep.js.map +1 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +14 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/integrate.d.ts +6 -0
  26. package/dist/integrate.d.ts.map +1 -0
  27. package/dist/integrate.js +154 -0
  28. package/dist/integrate.js.map +1 -0
  29. package/dist/islands.d.ts +5 -0
  30. package/dist/islands.d.ts.map +1 -0
  31. package/dist/islands.js +316 -0
  32. package/dist/islands.js.map +1 -0
  33. package/dist/jointCollisionSuppression.d.ts +4 -0
  34. package/dist/jointCollisionSuppression.d.ts.map +1 -0
  35. package/dist/jointCollisionSuppression.js +40 -0
  36. package/dist/jointCollisionSuppression.js.map +1 -0
  37. package/dist/jointFactories.d.ts +8 -0
  38. package/dist/jointFactories.d.ts.map +1 -0
  39. package/dist/jointFactories.js +126 -0
  40. package/dist/jointFactories.js.map +1 -0
  41. package/dist/jointMath.d.ts +17 -0
  42. package/dist/jointMath.d.ts.map +1 -0
  43. package/dist/jointMath.js +361 -0
  44. package/dist/jointMath.js.map +1 -0
  45. package/dist/jointRegistry.d.ts +8 -0
  46. package/dist/jointRegistry.d.ts.map +1 -0
  47. package/dist/jointRegistry.js +144 -0
  48. package/dist/jointRegistry.js.map +1 -0
  49. package/dist/jointRows.d.ts +32 -0
  50. package/dist/jointRows.d.ts.map +1 -0
  51. package/dist/jointRows.js +308 -0
  52. package/dist/jointRows.js.map +1 -0
  53. package/dist/joints.d.ts +14 -0
  54. package/dist/joints.d.ts.map +1 -0
  55. package/dist/joints.js +758 -0
  56. package/dist/joints.js.map +1 -0
  57. package/dist/massProperties.d.ts +8 -0
  58. package/dist/massProperties.d.ts.map +1 -0
  59. package/dist/massProperties.js +169 -0
  60. package/dist/massProperties.js.map +1 -0
  61. package/dist/ownership.d.ts +5 -0
  62. package/dist/ownership.d.ts.map +1 -0
  63. package/dist/ownership.js +16 -0
  64. package/dist/ownership.js.map +1 -0
  65. package/dist/registerBuiltInPhysics3DJointSolvers.d.ts +3 -0
  66. package/dist/registerBuiltInPhysics3DJointSolvers.d.ts.map +1 -0
  67. package/dist/registerBuiltInPhysics3DJointSolvers.js +14 -0
  68. package/dist/registerBuiltInPhysics3DJointSolvers.js.map +1 -0
  69. package/dist/solver.d.ts +8 -0
  70. package/dist/solver.d.ts.map +1 -0
  71. package/dist/solver.js +383 -0
  72. package/dist/solver.js.map +1 -0
  73. package/dist/step.d.ts +5 -0
  74. package/dist/step.d.ts.map +1 -0
  75. package/dist/step.js +250 -0
  76. package/dist/step.js.map +1 -0
  77. package/dist/stepValidation.d.ts +12 -0
  78. package/dist/stepValidation.d.ts.map +1 -0
  79. package/dist/stepValidation.js +201 -0
  80. package/dist/stepValidation.js.map +1 -0
  81. package/dist/symmetricTensor.d.ts +11 -0
  82. package/dist/symmetricTensor.d.ts.map +1 -0
  83. package/dist/symmetricTensor.js +150 -0
  84. package/dist/symmetricTensor.js.map +1 -0
  85. package/dist/world.d.ts +19 -0
  86. package/dist/world.d.ts.map +1 -0
  87. package/dist/world.js +370 -0
  88. package/dist/world.js.map +1 -0
  89. package/package.json +49 -0
  90. package/src/contacts.test.ts +64 -0
  91. package/src/enablePhysics3DGuards.test.ts +105 -0
  92. package/src/explainPhysics3DJoints.test.ts +101 -0
  93. package/src/explainPhysics3DStep.test.ts +123 -0
  94. package/src/integrate.test.ts +249 -0
  95. package/src/islands.test.ts +357 -0
  96. package/src/jointCollisionSuppression.test.ts +94 -0
  97. package/src/jointFactories.test.ts +195 -0
  98. package/src/jointMath.test.ts +643 -0
  99. package/src/jointRegistry.test.ts +229 -0
  100. package/src/jointRows.test.ts +484 -0
  101. package/src/joints.test.ts +677 -0
  102. package/src/massProperties.test.ts +305 -0
  103. package/src/ownership.test.ts +36 -0
  104. package/src/registerBuiltInPhysics3DJointSolvers.test.ts +48 -0
  105. package/src/solver.test.ts +481 -0
  106. package/src/step.test.ts +390 -0
  107. package/src/stepValidation.test.ts +244 -0
  108. package/src/symmetricTensor.test.ts +166 -0
  109. package/src/world.test.ts +512 -0
@@ -0,0 +1,229 @@
1
+ import type { Physics3DJointSolver, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createPhysics3DBallAndSocketJoint, createPhysics3DHingeJoint } from './jointFactories';
5
+ import {
6
+ addPhysics3DJoint,
7
+ getPhysics3DJointSolver,
8
+ invalidatePhysics3DJoint,
9
+ isPhysics3DPairOrdered,
10
+ registerPhysics3DJointSolver,
11
+ removePhysics3DJoint,
12
+ } from './jointRegistry';
13
+ import { physics3DBallAndSocketJointSolver, physics3DHingeJointSolver, Physics3DHingeJointKind } from './joints';
14
+ import { steppingPhysics3DWorlds } from './ownership';
15
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D } from './world';
16
+
17
+ describe('addPhysics3DJoint', () => {
18
+ it('returns the joint it was given and records it on the world', () => {
19
+ const world = createTestWorld();
20
+ const joint = createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 });
21
+
22
+ expect(addPhysics3DJoint(world, joint)).toBe(joint);
23
+ expect(world.joints).toEqual([joint]);
24
+ });
25
+
26
+ it('refuses a joint that already belongs to a world', () => {
27
+ const world = createTestWorld();
28
+ const joint = createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 });
29
+ addPhysics3DJoint(world, joint);
30
+
31
+ expect(() => addPhysics3DJoint(world, joint)).toThrow();
32
+ expect(() => addPhysics3DJoint(createTestWorld(), joint)).toThrow();
33
+ });
34
+
35
+ it('exchanges out-of-order ends when the kind has a solver to consult', () => {
36
+ const world = createTestWorld();
37
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
38
+ const joint = createPhysics3DHingeJoint({
39
+ bodyA: 1,
40
+ bodyB: 0,
41
+ localAnchorAX: 5,
42
+ localAnchorBX: 7,
43
+ lowerAngle: -0.25,
44
+ upperAngle: 1.5,
45
+ });
46
+
47
+ addPhysics3DJoint(world, joint);
48
+
49
+ expect(joint.bodyA).toBe(0);
50
+ expect(joint.bodyB).toBe(1);
51
+ expect(joint.localAnchorAX).toBe(7);
52
+ expect(joint.localAnchorBX).toBe(5);
53
+ expect(joint.lowerAngle).toBe(-1.5);
54
+ });
55
+
56
+ it('leaves an unknown kind in its authored order', () => {
57
+ const world = createTestWorld();
58
+ const joint = createPhysics3DHingeJoint({ bodyA: 1, bodyB: 0, lowerAngle: -0.25, upperAngle: 1.5 });
59
+
60
+ addPhysics3DJoint(world, joint);
61
+
62
+ // Exchanging the ends without the kind's consent would move the bodies and anchors while the limit
63
+ // interval stayed as authored — the generic half of a swap with the kind's half missing, which no later
64
+ // registration repairs.
65
+ expect(joint.bodyA).toBe(1);
66
+ expect(joint.lowerAngle).toBe(-0.25);
67
+ });
68
+
69
+ it('wakes both bodies when the constraint is live', () => {
70
+ const world = createTestWorld();
71
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
72
+ world.bodies[0].sleeping = true;
73
+ world.bodies[1].sleeping = true;
74
+
75
+ addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
76
+
77
+ expect(world.bodies[0].sleeping).toBe(false);
78
+ expect(world.bodies[1].sleeping).toBe(false);
79
+ });
80
+
81
+ it('leaves a sleeping pair asleep for a kind that constrains nothing yet', () => {
82
+ const world = createTestWorld();
83
+ world.bodies[0].sleeping = true;
84
+
85
+ addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
86
+
87
+ expect(world.bodies[0].sleeping).toBe(true);
88
+ });
89
+
90
+ it('rejects a mutation while the world is stepping', () => {
91
+ const world = createTestWorld();
92
+ steppingPhysics3DWorlds.add(world);
93
+
94
+ expect(() => addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }))).toThrow();
95
+
96
+ steppingPhysics3DWorlds.delete(world);
97
+ });
98
+ });
99
+
100
+ describe('getPhysics3DJointSolver', () => {
101
+ it('returns null for a kind nobody registered', () => {
102
+ expect(getPhysics3DJointSolver(createTestWorld(), 'acme.Suspension')).toBeNull();
103
+ });
104
+
105
+ it('returns the registered solver', () => {
106
+ const world = createTestWorld();
107
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
108
+
109
+ expect(getPhysics3DJointSolver(world, Physics3DHingeJointKind)).toBe(physics3DHingeJointSolver);
110
+ });
111
+ });
112
+
113
+ describe('invalidatePhysics3DJoint', () => {
114
+ it('clears every common accumulator and the kind-specific ones', () => {
115
+ const world = createTestWorld();
116
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
117
+ const joint = addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
118
+ joint.impulse0 = 1;
119
+ joint.impulse5 = 2;
120
+ (joint as ReturnType<typeof createPhysics3DHingeJoint>).motorImpulse = 3;
121
+
122
+ expect(invalidatePhysics3DJoint(world, joint)).toBe(true);
123
+ expect(joint.impulse0).toBe(0);
124
+ expect(joint.impulse5).toBe(0);
125
+ expect((joint as ReturnType<typeof createPhysics3DHingeJoint>).motorImpulse).toBe(0);
126
+ });
127
+
128
+ it('returns false for a joint this world does not hold', () => {
129
+ const world = createTestWorld();
130
+ const joint = createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 });
131
+
132
+ expect(invalidatePhysics3DJoint(world, joint)).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe('isPhysics3DPairOrdered', () => {
137
+ it('orders by persistent index and admits a pair already in order', () => {
138
+ expect(isPhysics3DPairOrdered(0, 1)).toBe(true);
139
+ expect(isPhysics3DPairOrdered(1, 1)).toBe(true);
140
+ expect(isPhysics3DPairOrdered(1, 0)).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe('registerPhysics3DJointSolver', () => {
145
+ it('canonicalizes joints that were added before their solver existed', () => {
146
+ const world = createTestWorld();
147
+ const joint = addPhysics3DJoint(
148
+ world,
149
+ createPhysics3DHingeJoint({ bodyA: 1, bodyB: 0, lowerAngle: -0.25, upperAngle: 1.5 }),
150
+ );
151
+
152
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
153
+
154
+ // The outcome no longer depends on whether the scene or the solver arrived first.
155
+ expect(joint.bodyA).toBe(0);
156
+ expect(joint.lowerAngle).toBe(-1.5);
157
+ expect(joint.upperAngle).toBe(0.25);
158
+ });
159
+
160
+ it('lets a later registration replace an earlier one', () => {
161
+ const world = createTestWorld();
162
+ const replacement: Physics3DJointSolver = { prepare(): void {}, solve(): void {} };
163
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
164
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, replacement);
165
+
166
+ expect(getPhysics3DJointSolver(world, Physics3DHingeJointKind)).toBe(replacement);
167
+ });
168
+
169
+ it('wakes the bodies of a joint the registration just brought to life', () => {
170
+ const world = createTestWorld();
171
+ addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
172
+ world.bodies[0].sleeping = true;
173
+ world.bodies[1].sleeping = true;
174
+
175
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
176
+
177
+ expect(world.bodies[0].sleeping).toBe(false);
178
+ expect(world.bodies[1].sleeping).toBe(false);
179
+ });
180
+ });
181
+
182
+ describe('removePhysics3DJoint', () => {
183
+ it('drops the joint and reports it', () => {
184
+ const world = createTestWorld();
185
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
186
+
187
+ expect(removePhysics3DJoint(world, joint)).toBe(true);
188
+ expect(world.joints).toEqual([]);
189
+ });
190
+
191
+ it('returns false for a joint it never held', () => {
192
+ const world = createTestWorld();
193
+
194
+ expect(removePhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }))).toBe(false);
195
+ });
196
+
197
+ it('releases ownership so the joint can be added again', () => {
198
+ const world = createTestWorld();
199
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
200
+ removePhysics3DJoint(world, joint);
201
+
202
+ expect(() => addPhysics3DJoint(world, joint)).not.toThrow();
203
+ });
204
+
205
+ it('wakes both bodies when a live constraint is taken away', () => {
206
+ const world = createTestWorld();
207
+ registerPhysics3DJointSolver(world, Physics3DHingeJointKind, physics3DHingeJointSolver);
208
+ const joint = addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
209
+ world.bodies[0].sleeping = true;
210
+
211
+ removePhysics3DJoint(world, joint);
212
+
213
+ expect(world.bodies[0].sleeping).toBe(false);
214
+ });
215
+ });
216
+
217
+ function createTestWorld(): Physics3DWorld {
218
+ const world = createPhysics3DWorld();
219
+ addPhysics3DBody(world, createBody());
220
+ addPhysics3DBody(world, createBody());
221
+ // The ball-and-socket solver is imported so this file exercises a kind with no `swapEnds` of its own; the
222
+ // registry has to reach the generic swap for it without consulting one.
223
+ expect(physics3DBallAndSocketJointSolver.swapEnds).toBeUndefined();
224
+ return world;
225
+ }
226
+
227
+ function createBody(): RigidBody3D {
228
+ return createRigidBody3D('dynamic');
229
+ }
@@ -0,0 +1,484 @@
1
+ import type { Physics3DFixedJoint, Physics3DJoint, RigidBody3D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { refreshRigidBody3DWorldInertia } from './integrate';
5
+ import { createPhysics3DFixedJoint } from './jointFactories';
6
+ import {
7
+ applyRow,
8
+ beginJointSolve,
9
+ clearJointSolve,
10
+ frameABasis,
11
+ frameARotation,
12
+ frameBBasis,
13
+ frameBRotation,
14
+ getJointSolveState,
15
+ getRowMass,
16
+ getRowVelocity,
17
+ POINT_BIAS,
18
+ POINT_LENGTH,
19
+ POINT_MASS,
20
+ prepareAngularBlock,
21
+ preparePointBlock,
22
+ readFrameBases,
23
+ readFrameRotations,
24
+ readJointImpulse,
25
+ ROW_LENGTH,
26
+ solveAngularBlock,
27
+ solveEqualityRow,
28
+ solveLowerLimitRow,
29
+ solveMotorRow,
30
+ solvePointBlock,
31
+ solveUpperLimitRow,
32
+ warmStartAngularBlock,
33
+ warmStartPointBlock,
34
+ writeAngularRow,
35
+ writeJointImpulse,
36
+ writeRow,
37
+ } from './jointRows';
38
+ import { createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
39
+ import { createRigidBody3D } from './world';
40
+
41
+ describe('applyRow', () => {
42
+ it('drives a row toward rest when scaled by the row mass', () => {
43
+ const bodyA = createUnitBody();
44
+ const bodyB = createUnitBody();
45
+ bodyB.velocityX = 2;
46
+ const state = newState();
47
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
48
+
49
+ applyRow(bodyA, bodyB, state, 0, -getRowVelocity(bodyA, bodyB, state, 0) * getRowMass(bodyA, bodyB, state, 0));
50
+
51
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeCloseTo(0, 12);
52
+ });
53
+
54
+ it('does nothing for a zero impulse', () => {
55
+ const bodyA = createUnitBody();
56
+ const bodyB = createUnitBody();
57
+ const state = newState();
58
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
59
+
60
+ applyRow(bodyA, bodyB, state, 0, 0);
61
+
62
+ expect(bodyB.velocityX).toBe(0);
63
+ });
64
+ });
65
+
66
+ describe('beginJointSolve', () => {
67
+ it('sizes the scratch and hands the same array back next time', () => {
68
+ const joint = createTestJoint();
69
+
70
+ const first = beginJointSolve(joint, 5);
71
+ first[0] = 9;
72
+ const second = beginJointSolve(joint, 8);
73
+
74
+ expect(second).toBe(first);
75
+ expect(second.length).toBe(8);
76
+ });
77
+
78
+ it('establishes accumulators a reconstructed joint reached the solver without', () => {
79
+ const joint = createTestJoint();
80
+ // A joint deserialized from a saved world satisfies the required type at compile time only; at runtime the
81
+ // fields are simply absent, and the first `undefined + x` would send NaN out through both bodies.
82
+ (joint as Partial<Physics3DJoint>).impulse0 = undefined;
83
+ (joint as Partial<Physics3DJoint>).impulse5 = undefined;
84
+
85
+ beginJointSolve(joint, 4);
86
+
87
+ expect(joint.impulse0).toBe(0);
88
+ expect(joint.impulse5).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe('clearJointSolve', () => {
93
+ it('leaves a solve with nothing to read', () => {
94
+ const joint = createTestJoint();
95
+ beginJointSolve(joint, 4);
96
+
97
+ clearJointSolve(joint);
98
+
99
+ expect(getJointSolveState(joint)).toBeUndefined();
100
+ });
101
+ });
102
+
103
+ describe('frameABasis', () => {
104
+ it('holds nine numbers, three per axis', () => {
105
+ expect(frameABasis.length).toBe(9);
106
+ expect(frameBBasis.length).toBe(9);
107
+ });
108
+ });
109
+
110
+ describe('frameARotation', () => {
111
+ it('holds a quaternion, and starts as the identity', () => {
112
+ expect(frameARotation.length).toBe(4);
113
+ expect(frameBRotation.length).toBe(4);
114
+ });
115
+ });
116
+
117
+ describe('frameBBasis', () => {
118
+ it('is a distinct array from the frame A basis', () => {
119
+ expect(frameBBasis).not.toBe(frameABasis);
120
+ });
121
+ });
122
+
123
+ describe('frameBRotation', () => {
124
+ it('is a distinct array from the frame A rotation', () => {
125
+ expect(frameBRotation).not.toBe(frameARotation);
126
+ });
127
+ });
128
+
129
+ describe('getJointSolveState', () => {
130
+ it('is undefined until a prepare has run', () => {
131
+ expect(getJointSolveState(createTestJoint())).toBeUndefined();
132
+ });
133
+ });
134
+
135
+ describe('getRowMass', () => {
136
+ it('reads the row at the given offset rather than the head of the state', () => {
137
+ const bodyA = createUnitBody();
138
+ const bodyB = createUnitBody();
139
+ const state = newState();
140
+ writeRow(state, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
141
+ writeRow(state, ROW_LENGTH, 1, 0, 0, 0, 0, 0, 0, 0, 0);
142
+
143
+ expect(getRowMass(bodyA, bodyB, state, 0)).toBe(0);
144
+ expect(getRowMass(bodyA, bodyB, state, ROW_LENGTH)).toBeCloseTo(0.5, 12);
145
+ });
146
+ });
147
+
148
+ describe('getRowVelocity', () => {
149
+ it('measures B relative to A along the row', () => {
150
+ const bodyA = createUnitBody();
151
+ const bodyB = createUnitBody();
152
+ bodyB.velocityY = 3;
153
+ const state = newState();
154
+ writeRow(state, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0);
155
+
156
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeCloseTo(3, 12);
157
+ });
158
+ });
159
+
160
+ describe('POINT_BIAS', () => {
161
+ it('follows the six-number mass block', () => {
162
+ expect(POINT_BIAS).toBe(POINT_MASS + 6);
163
+ expect(POINT_LENGTH).toBe(POINT_BIAS + 3);
164
+ });
165
+ });
166
+
167
+ describe('POINT_LENGTH', () => {
168
+ it('covers the whole point block', () => {
169
+ expect(POINT_LENGTH).toBe(9);
170
+ });
171
+ });
172
+
173
+ describe('POINT_MASS', () => {
174
+ it('sits at the head of the per-step state', () => {
175
+ expect(POINT_MASS).toBe(0);
176
+ });
177
+ });
178
+
179
+ describe('prepareAngularBlock', () => {
180
+ it('reads the frames left by readFrameRotations', () => {
181
+ const bodyA = createUnitBody();
182
+ const bodyB = createUnitBody();
183
+ setAxisAngle(bodyB, 0, 0, 1, 0.4);
184
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
185
+ const state = beginJointSolve(joint, 32);
186
+
187
+ readFrameRotations(bodyA, bodyB, joint);
188
+ prepareAngularBlock(bodyA, bodyB, state, 0, 6, 1 / 60);
189
+
190
+ // The bias is the rotation error scaled by BAUMGARTE / dt: 0.4 * 0.2 * 60.
191
+ expect(state[8]).toBeCloseTo(0.4 * 0.2 * 60, 9);
192
+ });
193
+ });
194
+
195
+ describe('preparePointBlock', () => {
196
+ it('writes the lever arms and the inverted mass block', () => {
197
+ const bodyA = createUnitBody();
198
+ const bodyB = createUnitBody();
199
+ bodyB.x = 3;
200
+ const joint = createTestJoint();
201
+ joint.localAnchorAX = 1;
202
+ const state = newState();
203
+
204
+ preparePointBlock(bodyA, bodyB, joint, state, 1 / 60);
205
+
206
+ expect(joint.rAX).toBeCloseTo(1, 12);
207
+ // Both anchors are on their centres for B and one unit out on A, and the inverse of the diagonal 2 that a
208
+ // zero-arm pair would give is 0.5.
209
+ expect(state[POINT_MASS]).toBeCloseTo(0.5, 12);
210
+ expect(state[POINT_BIAS]).toBeCloseTo(2 * 0.2 * 60, 9);
211
+ });
212
+ });
213
+
214
+ describe('readFrameBases', () => {
215
+ it('expands both frames into world axes', () => {
216
+ const bodyA = createUnitBody();
217
+ setAxisAngle(bodyA, 0, 0, 1, Math.PI / 2);
218
+ const bodyB = createUnitBody();
219
+
220
+ readFrameBases(bodyA, bodyB, createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 }));
221
+
222
+ expect(frameABasis[0]).toBeCloseTo(0, 12);
223
+ expect(frameABasis[1]).toBeCloseTo(1, 12);
224
+ expect(frameBBasis[0]).toBeCloseTo(1, 12);
225
+ });
226
+ });
227
+
228
+ describe('readFrameRotations', () => {
229
+ it('establishes an absent frame as the identity', () => {
230
+ const bodyA = createUnitBody();
231
+ const bodyB = createUnitBody();
232
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
233
+ (joint as Partial<Physics3DFixedJoint>).localRotationAW = undefined;
234
+ (joint as Partial<Physics3DFixedJoint>).localRotationAX = undefined;
235
+
236
+ readFrameRotations(bodyA, bodyB, joint);
237
+
238
+ expect(joint.localRotationAW).toBe(1);
239
+ expect(frameARotation[3]).toBeCloseTo(1, 12);
240
+ });
241
+ });
242
+
243
+ describe('readJointImpulse', () => {
244
+ it('reads each of the six slots', () => {
245
+ const joint = createTestJoint();
246
+ joint.impulse0 = 10;
247
+ joint.impulse3 = 13;
248
+ joint.impulse5 = 15;
249
+
250
+ expect(readJointImpulse(joint, 0)).toBe(10);
251
+ expect(readJointImpulse(joint, 3)).toBe(13);
252
+ expect(readJointImpulse(joint, 5)).toBe(15);
253
+ });
254
+ });
255
+
256
+ describe('ROW_LENGTH', () => {
257
+ it('is a direction plus two angular arms', () => {
258
+ expect(ROW_LENGTH).toBe(9);
259
+ });
260
+ });
261
+
262
+ describe('solveAngularBlock', () => {
263
+ it('cancels relative rotation and accumulates into the upper three slots', () => {
264
+ const bodyA = createUnitBody();
265
+ const bodyB = createUnitBody();
266
+ bodyB.angularVelocityY = 2;
267
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
268
+ const state = beginJointSolve(joint, 32);
269
+ readFrameRotations(bodyA, bodyB, joint);
270
+ prepareAngularBlock(bodyA, bodyB, state, 0, 6, 1 / 60);
271
+
272
+ solveAngularBlock(bodyA, bodyB, joint, state, 0, 6);
273
+
274
+ expect(bodyB.angularVelocityY - bodyA.angularVelocityY).toBeCloseTo(0, 12);
275
+ expect(joint.impulse4).not.toBe(0);
276
+ });
277
+ });
278
+
279
+ describe('solveEqualityRow', () => {
280
+ it('drives the row to the bias velocity and accumulates into its slot', () => {
281
+ const bodyA = createUnitBody();
282
+ const bodyB = createUnitBody();
283
+ bodyB.velocityX = 2;
284
+ const joint = createTestJoint();
285
+ const state = newState();
286
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
287
+
288
+ solveEqualityRow(bodyA, bodyB, joint, 2, state, 0, getRowMass(bodyA, bodyB, state, 0), 0, 0);
289
+
290
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeCloseTo(0, 12);
291
+ expect(joint.impulse2).toBeCloseTo(-1, 12);
292
+ });
293
+ });
294
+
295
+ describe('solveLowerLimitRow', () => {
296
+ it('pushes a crossed bound back up', () => {
297
+ const bodyA = createUnitBody();
298
+ const bodyB = createUnitBody();
299
+ const state = newState();
300
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
301
+
302
+ solveLowerLimitRow(bodyA, bodyB, state, 0, getRowMass(bodyA, bodyB, state, 0), -1, 60, ROW_LENGTH);
303
+
304
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeGreaterThan(0);
305
+ });
306
+
307
+ it('never pulls a coordinate down through its bound', () => {
308
+ const bodyA = createUnitBody();
309
+ const bodyB = createUnitBody();
310
+ bodyB.velocityX = 5;
311
+ const state = newState();
312
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
313
+
314
+ solveLowerLimitRow(bodyA, bodyB, state, 0, getRowMass(bodyA, bodyB, state, 0), 3, 60, ROW_LENGTH);
315
+
316
+ expect(bodyB.velocityX).toBe(5);
317
+ });
318
+ });
319
+
320
+ describe('solveMotorRow', () => {
321
+ it('returns the accumulated impulse clamped to its budget', () => {
322
+ const bodyA = createUnitBody();
323
+ const bodyB = createUnitBody();
324
+ const state = newState();
325
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
326
+
327
+ const total = solveMotorRow(bodyA, bodyB, state, 0, getRowMass(bodyA, bodyB, state, 0), 100, 0.25, 0);
328
+
329
+ expect(total).toBeCloseTo(0.25, 12);
330
+ });
331
+
332
+ it('reaches its target speed when the budget allows', () => {
333
+ const bodyA = createUnitBody();
334
+ const bodyB = createUnitBody();
335
+ const state = newState();
336
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
337
+
338
+ solveMotorRow(bodyA, bodyB, state, 0, getRowMass(bodyA, bodyB, state, 0), 4, 1000, 0);
339
+
340
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeCloseTo(4, 12);
341
+ });
342
+ });
343
+
344
+ describe('solvePointBlock', () => {
345
+ it('cancels the velocity separating the anchors', () => {
346
+ const bodyA = createUnitBody();
347
+ const bodyB = createUnitBody();
348
+ bodyB.velocityX = 2;
349
+ bodyB.velocityZ = -1;
350
+ const joint = createTestJoint();
351
+ const state = newState();
352
+ preparePointBlock(bodyA, bodyB, joint, state, 1 / 60);
353
+
354
+ solvePointBlock(bodyA, bodyB, joint, state);
355
+
356
+ expect(bodyB.velocityX - bodyA.velocityX).toBeCloseTo(0, 12);
357
+ expect(bodyB.velocityZ - bodyA.velocityZ).toBeCloseTo(0, 12);
358
+ });
359
+ });
360
+
361
+ describe('solveUpperLimitRow', () => {
362
+ it('pushes a crossed bound back down', () => {
363
+ const bodyA = createUnitBody();
364
+ const bodyB = createUnitBody();
365
+ const state = newState();
366
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
367
+
368
+ solveUpperLimitRow(bodyA, bodyB, state, 0, getRowMass(bodyA, bodyB, state, 0), -1, 60, ROW_LENGTH);
369
+
370
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeLessThan(0);
371
+ });
372
+
373
+ it('never pushes a coordinate up through its bound', () => {
374
+ const bodyA = createUnitBody();
375
+ const bodyB = createUnitBody();
376
+ bodyB.velocityX = -5;
377
+ const state = newState();
378
+ writeRow(state, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
379
+
380
+ solveUpperLimitRow(bodyA, bodyB, state, 0, getRowMass(bodyA, bodyB, state, 0), 3, 60, ROW_LENGTH);
381
+
382
+ expect(bodyB.velocityX).toBe(-5);
383
+ });
384
+ });
385
+
386
+ describe('warmStartAngularBlock', () => {
387
+ it('reapplies the angular accumulators', () => {
388
+ const bodyA = createUnitBody();
389
+ const bodyB = createUnitBody();
390
+ const joint = createTestJoint();
391
+ joint.impulse4 = 2;
392
+
393
+ warmStartAngularBlock(bodyA, bodyB, joint);
394
+
395
+ expect(bodyB.angularVelocityY).toBeCloseTo(2, 12);
396
+ expect(bodyA.angularVelocityY).toBeCloseTo(-2, 12);
397
+ });
398
+ });
399
+
400
+ describe('warmStartPointBlock', () => {
401
+ it('reapplies the point accumulators', () => {
402
+ const bodyA = createUnitBody();
403
+ const bodyB = createUnitBody();
404
+ const joint = createTestJoint();
405
+ joint.impulse0 = 3;
406
+
407
+ warmStartPointBlock(bodyA, bodyB, joint);
408
+
409
+ expect(bodyB.velocityX).toBeCloseTo(3, 12);
410
+ expect(bodyA.velocityX).toBeCloseTo(-3, 12);
411
+ });
412
+ });
413
+
414
+ describe('writeAngularRow', () => {
415
+ it('writes a row with no linear direction and the axis as both arms', () => {
416
+ const state = newState();
417
+
418
+ writeAngularRow(state, 0, 0, 1, 0);
419
+
420
+ expect(state.slice(0, 9)).toEqual([0, 0, 0, 0, 1, 0, 0, 1, 0]);
421
+ });
422
+
423
+ it('measures the relative spin about its axis', () => {
424
+ const bodyA = createUnitBody();
425
+ const bodyB = createUnitBody();
426
+ bodyB.angularVelocityZ = 1.5;
427
+ const state = newState();
428
+
429
+ writeAngularRow(state, 0, 0, 0, 1);
430
+
431
+ expect(getRowVelocity(bodyA, bodyB, state, 0)).toBeCloseTo(1.5, 12);
432
+ });
433
+ });
434
+
435
+ describe('writeJointImpulse', () => {
436
+ it('writes each of the six slots', () => {
437
+ const joint = createTestJoint();
438
+
439
+ for (let slot = 0; slot < 6; slot += 1) writeJointImpulse(joint, slot, slot + 1);
440
+
441
+ expect([joint.impulse0, joint.impulse1, joint.impulse2, joint.impulse3, joint.impulse4, joint.impulse5]).toEqual([
442
+ 1, 2, 3, 4, 5, 6,
443
+ ]);
444
+ });
445
+ });
446
+
447
+ describe('writeRow', () => {
448
+ it('lays the direction and the two arms out in one block', () => {
449
+ const state = newState();
450
+
451
+ writeRow(state, ROW_LENGTH, 1, 2, 3, 4, 5, 6, 7, 8, 9);
452
+
453
+ expect(state.slice(ROW_LENGTH, ROW_LENGTH * 2)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
454
+ });
455
+ });
456
+
457
+ function createTestJoint(): Physics3DJoint {
458
+ return createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
459
+ }
460
+
461
+ function createUnitBody(): RigidBody3D {
462
+ const body = createRigidBody3D('dynamic');
463
+ const data = createPhysics3DMassData();
464
+ data.mass = 1;
465
+ data.inertiaXX = 1;
466
+ data.inertiaYY = 1;
467
+ data.inertiaZZ = 1;
468
+ setRigidBody3DMassData(body, data);
469
+ refreshRigidBody3DWorldInertia(body);
470
+ return body;
471
+ }
472
+
473
+ function newState(): number[] {
474
+ return new Array<number>(32).fill(0);
475
+ }
476
+
477
+ function setAxisAngle(body: RigidBody3D, x: number, y: number, z: number, angle: number): void {
478
+ const sine = Math.sin(angle / 2);
479
+ body.orientationX = x * sine;
480
+ body.orientationY = y * sine;
481
+ body.orientationZ = z * sine;
482
+ body.orientationW = Math.cos(angle / 2);
483
+ refreshRigidBody3DWorldInertia(body);
484
+ }