@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,643 @@
1
+ import type { Physics3DJoint, Physics3DJointFrames, RigidBody3D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { refreshRigidBody3DWorldInertia } from './integrate';
5
+ import {
6
+ applyPhysics3DJointAngularImpulse,
7
+ applyPhysics3DJointImpulse,
8
+ applyPhysics3DJointRowImpulse,
9
+ getPhysics3DJointRowMass,
10
+ getPhysics3DJointRowVelocity,
11
+ swapPhysics3DJointFrames,
12
+ writePhysics3DJointAnchorVelocity,
13
+ writePhysics3DJointAnchors,
14
+ writePhysics3DJointAngularMass,
15
+ writePhysics3DJointFrameBasis,
16
+ writePhysics3DJointFrameRotation,
17
+ writePhysics3DJointPointMass,
18
+ writePhysics3DJointRelativeRotation,
19
+ writePhysics3DJointRotationError,
20
+ writePhysics3DJointSeparation,
21
+ } from './jointMath';
22
+ import { createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
23
+ import { createRigidBody3D } from './world';
24
+
25
+ describe('applyPhysics3DJointAngularImpulse', () => {
26
+ it('spins B forward and A backward by the same amount', () => {
27
+ const bodyA = createUnitBody();
28
+ const bodyB = createUnitBody();
29
+
30
+ applyPhysics3DJointAngularImpulse(bodyA, bodyB, 2, 0, 0);
31
+
32
+ expect(bodyA.angularVelocityX).toBeCloseTo(-2, 12);
33
+ expect(bodyB.angularVelocityX).toBeCloseTo(2, 12);
34
+ });
35
+
36
+ it('leaves linear velocity untouched', () => {
37
+ const bodyA = createUnitBody();
38
+ const bodyB = createUnitBody();
39
+
40
+ applyPhysics3DJointAngularImpulse(bodyA, bodyB, 1, 2, 3);
41
+
42
+ expect(bodyA.velocityX).toBe(0);
43
+ expect(bodyB.velocityZ).toBe(0);
44
+ });
45
+
46
+ it('applies nothing to a body with no rotational freedom', () => {
47
+ const bodyA = createUnitBody();
48
+ const bodyB = createRigidBody3D('static');
49
+
50
+ applyPhysics3DJointAngularImpulse(bodyA, bodyB, 1, 0, 0);
51
+
52
+ expect(bodyB.angularVelocityX).toBe(0);
53
+ expect(bodyA.angularVelocityX).toBeCloseTo(-1, 12);
54
+ });
55
+ });
56
+
57
+ describe('applyPhysics3DJointImpulse', () => {
58
+ it('pushes B along the impulse and A against it', () => {
59
+ const bodyA = createUnitBody();
60
+ const bodyB = createUnitBody();
61
+
62
+ applyPhysics3DJointImpulse(bodyA, bodyB, 0, 0, 0, 0, 0, 0, 3, 0, 0);
63
+
64
+ expect(bodyA.velocityX).toBeCloseTo(-3, 12);
65
+ expect(bodyB.velocityX).toBeCloseTo(3, 12);
66
+ });
67
+
68
+ it('turns a body whose lever arm is offset from its centre of mass', () => {
69
+ const bodyA = createUnitBody();
70
+ const bodyB = createUnitBody();
71
+
72
+ applyPhysics3DJointImpulse(bodyA, bodyB, 0, 1, 0, 0, 0, 0, 1, 0, 0);
73
+
74
+ // A receives -impulse at (0,1,0), so its torque is (0,1,0) x (-1,0,0) = (0,0,1).
75
+ expect(bodyA.angularVelocityZ).toBeCloseTo(1, 12);
76
+ expect(bodyA.velocityX).toBeCloseTo(-1, 12);
77
+ expect(bodyB.angularVelocityZ).toBe(0);
78
+ });
79
+ });
80
+
81
+ describe('applyPhysics3DJointRowImpulse', () => {
82
+ it('pushes B along the row direction and A against it', () => {
83
+ const bodyA = createUnitBody();
84
+ const bodyB = createUnitBody();
85
+
86
+ applyPhysics3DJointRowImpulse(bodyA, bodyB, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2);
87
+
88
+ expect(bodyA.velocityX).toBeCloseTo(-2, 12);
89
+ expect(bodyB.velocityX).toBeCloseTo(2, 12);
90
+ });
91
+
92
+ it('cancels the row velocity when scaled by the row mass', () => {
93
+ // The three row functions have to describe ONE constraint. Mass, velocity, and impulse agreeing is what
94
+ // makes `-velocity * mass` the impulse that stops the row; if any of the three used a different
95
+ // Jacobian the row would over- or under-correct here, and a solver built on them would converge to the
96
+ // wrong answer while every individual function still looked plausible.
97
+ const bodyA = createUnitBody();
98
+ const bodyB = createUnitBody();
99
+ bodyA.velocityY = -0.4;
100
+ bodyA.angularVelocityZ = 0.9;
101
+ bodyB.velocityX = 1.7;
102
+ bodyB.angularVelocityY = -1.3;
103
+ const direction = normalized(0.3, 0.5, -0.8);
104
+ const armA = [1.1, -0.2, 0.6];
105
+ const armB = [-0.4, 0.9, 0.3];
106
+
107
+ const mass = getPhysics3DJointRowMass(
108
+ bodyA,
109
+ bodyB,
110
+ direction[0],
111
+ direction[1],
112
+ direction[2],
113
+ armA[0],
114
+ armA[1],
115
+ armA[2],
116
+ armB[0],
117
+ armB[1],
118
+ armB[2],
119
+ );
120
+ const velocity = getPhysics3DJointRowVelocity(
121
+ bodyA,
122
+ bodyB,
123
+ direction[0],
124
+ direction[1],
125
+ direction[2],
126
+ armA[0],
127
+ armA[1],
128
+ armA[2],
129
+ armB[0],
130
+ armB[1],
131
+ armB[2],
132
+ );
133
+ applyPhysics3DJointRowImpulse(
134
+ bodyA,
135
+ bodyB,
136
+ direction[0],
137
+ direction[1],
138
+ direction[2],
139
+ armA[0],
140
+ armA[1],
141
+ armA[2],
142
+ armB[0],
143
+ armB[1],
144
+ armB[2],
145
+ -velocity * mass,
146
+ );
147
+
148
+ expect(velocity).not.toBeCloseTo(0, 3);
149
+ expect(
150
+ getPhysics3DJointRowVelocity(
151
+ bodyA,
152
+ bodyB,
153
+ direction[0],
154
+ direction[1],
155
+ direction[2],
156
+ armA[0],
157
+ armA[1],
158
+ armA[2],
159
+ armB[0],
160
+ armB[1],
161
+ armB[2],
162
+ ),
163
+ ).toBeCloseTo(0, 12);
164
+ });
165
+ });
166
+
167
+ describe('getPhysics3DJointRowMass', () => {
168
+ it('reduces to the pair mass for a linear row through both centres', () => {
169
+ const bodyA = createUnitBody();
170
+ const bodyB = createUnitBody();
171
+
172
+ expect(getPhysics3DJointRowMass(bodyA, bodyB, 1, 0, 0, 0, 0, 0, 0, 0, 0)).toBeCloseTo(0.5, 12);
173
+ });
174
+
175
+ it('drops the translational term for a purely angular row', () => {
176
+ const bodyA = createUnitBody();
177
+ const bodyB = createUnitBody();
178
+
179
+ // A zero direction is how a caller says "this row applies no linear impulse". With the pair's mass
180
+ // still in the denominator the result would be 1/3 rather than 1/2.
181
+ expect(getPhysics3DJointRowMass(bodyA, bodyB, 0, 0, 0, 1, 0, 0, 1, 0, 0)).toBeCloseTo(0.5, 12);
182
+ });
183
+
184
+ it('returns zero when neither body can respond', () => {
185
+ const bodyA = createRigidBody3D('static');
186
+ const bodyB = createRigidBody3D('static');
187
+
188
+ expect(getPhysics3DJointRowMass(bodyA, bodyB, 1, 0, 0, 1, 0, 0, 1, 0, 0)).toBe(0);
189
+ });
190
+ });
191
+
192
+ describe('getPhysics3DJointRowVelocity', () => {
193
+ it('agrees with the anchor velocity for a point row', () => {
194
+ // A point constraint's row along `direction` has arms `r x direction`. Reading the same quantity two
195
+ // ways is how the row form is pinned to the anchor form the point block uses.
196
+ const bodyA = createUnitBody();
197
+ const bodyB = createUnitBody();
198
+ bodyA.angularVelocityZ = 0.7;
199
+ bodyB.velocityY = -1.2;
200
+ bodyB.angularVelocityX = 0.5;
201
+ const joint = createTestJoint();
202
+ joint.rAX = 0.4;
203
+ joint.rAY = 1.1;
204
+ joint.rBZ = -0.6;
205
+ const anchorVelocity = [0, 0, 0];
206
+ writePhysics3DJointAnchorVelocity(bodyA, bodyB, joint, anchorVelocity);
207
+
208
+ const direction = normalized(0.2, -0.9, 0.4);
209
+ const armA = cross(joint.rAX, joint.rAY, joint.rAZ, direction[0], direction[1], direction[2]);
210
+ const armB = cross(joint.rBX, joint.rBY, joint.rBZ, direction[0], direction[1], direction[2]);
211
+
212
+ expect(
213
+ getPhysics3DJointRowVelocity(
214
+ bodyA,
215
+ bodyB,
216
+ direction[0],
217
+ direction[1],
218
+ direction[2],
219
+ armA[0],
220
+ armA[1],
221
+ armA[2],
222
+ armB[0],
223
+ armB[1],
224
+ armB[2],
225
+ ),
226
+ ).toBeCloseTo(
227
+ anchorVelocity[0] * direction[0] + anchorVelocity[1] * direction[1] + anchorVelocity[2] * direction[2],
228
+ 12,
229
+ );
230
+ });
231
+ });
232
+
233
+ describe('swapPhysics3DJointFrames', () => {
234
+ it('exchanges the two local rotations', () => {
235
+ const frames: Physics3DJointFrames = {
236
+ localRotationAX: 1,
237
+ localRotationAY: 2,
238
+ localRotationAZ: 3,
239
+ localRotationAW: 4,
240
+ localRotationBX: 5,
241
+ localRotationBY: 6,
242
+ localRotationBZ: 7,
243
+ localRotationBW: 8,
244
+ };
245
+
246
+ swapPhysics3DJointFrames(frames);
247
+
248
+ expect([frames.localRotationAX, frames.localRotationAY, frames.localRotationAZ, frames.localRotationAW]).toEqual([
249
+ 5, 6, 7, 8,
250
+ ]);
251
+ expect([frames.localRotationBX, frames.localRotationBY, frames.localRotationBZ, frames.localRotationBW]).toEqual([
252
+ 1, 2, 3, 4,
253
+ ]);
254
+ });
255
+ });
256
+
257
+ describe('writePhysics3DJointAnchors', () => {
258
+ it('copies the local anchor through for an unrotated body at its origin', () => {
259
+ const bodyA = createUnitBody();
260
+ const bodyB = createUnitBody();
261
+ const joint = createTestJoint();
262
+ joint.localAnchorAX = 1;
263
+ joint.localAnchorAY = 2;
264
+ joint.localAnchorAZ = 3;
265
+
266
+ writePhysics3DJointAnchors(bodyA, bodyB, joint);
267
+
268
+ expect(joint.rAX).toBeCloseTo(1, 12);
269
+ expect(joint.rAY).toBeCloseTo(2, 12);
270
+ expect(joint.rAZ).toBeCloseTo(3, 12);
271
+ });
272
+
273
+ it('rotates the local anchor by the body orientation', () => {
274
+ const bodyA = createUnitBody();
275
+ setAxisAngle(bodyA, 0, 0, 1, Math.PI / 2);
276
+ const bodyB = createUnitBody();
277
+ const joint = createTestJoint();
278
+ joint.localAnchorAX = 1;
279
+
280
+ writePhysics3DJointAnchors(bodyA, bodyB, joint);
281
+
282
+ expect(joint.rAX).toBeCloseTo(0, 12);
283
+ expect(joint.rAY).toBeCloseTo(1, 12);
284
+ expect(joint.rAZ).toBeCloseTo(0, 12);
285
+ });
286
+
287
+ it('measures the arm from the centre of mass, not from the body origin', () => {
288
+ const bodyA = createUnitBody();
289
+ bodyA.centerX = 0.5;
290
+ const bodyB = createUnitBody();
291
+ const joint = createTestJoint();
292
+ joint.localAnchorAX = 2;
293
+
294
+ writePhysics3DJointAnchors(bodyA, bodyB, joint);
295
+
296
+ expect(joint.rAX).toBeCloseTo(1.5, 12);
297
+ });
298
+ });
299
+
300
+ describe('writePhysics3DJointAnchorVelocity', () => {
301
+ it('measures B relative to A', () => {
302
+ const bodyA = createUnitBody();
303
+ const bodyB = createUnitBody();
304
+ bodyB.velocityX = 1;
305
+ const joint = createTestJoint();
306
+ const out = [0, 0, 0];
307
+
308
+ writePhysics3DJointAnchorVelocity(bodyA, bodyB, joint, out);
309
+
310
+ expect(out).toEqual([1, 0, 0]);
311
+ });
312
+
313
+ it('includes the rotational contribution at each lever arm', () => {
314
+ const bodyA = createUnitBody();
315
+ const bodyB = createUnitBody();
316
+ bodyB.angularVelocityZ = 1;
317
+ const joint = createTestJoint();
318
+ joint.rBX = 1;
319
+ const out = [0, 0, 0];
320
+
321
+ writePhysics3DJointAnchorVelocity(bodyA, bodyB, joint, out);
322
+
323
+ // (0,0,1) x (1,0,0) = (0,1,0): the anchor sweeps sideways though the centre is still.
324
+ expect(out[0]).toBeCloseTo(0, 12);
325
+ expect(out[1]).toBeCloseTo(1, 12);
326
+ expect(out[2]).toBeCloseTo(0, 12);
327
+ });
328
+ });
329
+
330
+ describe('writePhysics3DJointAngularMass', () => {
331
+ it('sums the two world inverse inertia tensors', () => {
332
+ const bodyA = createUnitBody();
333
+ const bodyB = createUnitBody();
334
+ bodyB.inverseInertiaWorldXY = 0.25;
335
+ const out = [0, 0, 0, 0, 0, 0];
336
+
337
+ writePhysics3DJointAngularMass(bodyA, bodyB, out);
338
+
339
+ expect(out).toEqual([2, 2, 2, 0.25, 0, 0]);
340
+ });
341
+ });
342
+
343
+ describe('writePhysics3DJointFrameBasis', () => {
344
+ it('produces the identity basis for an unrotated body and an identity frame', () => {
345
+ const body = createUnitBody();
346
+ const out = new Array<number>(9).fill(0);
347
+
348
+ writePhysics3DJointFrameBasis(body, 0, 0, 0, 1, out);
349
+
350
+ expect(out).toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1]);
351
+ });
352
+
353
+ it('carries the body orientation into the frame axes', () => {
354
+ const body = createUnitBody();
355
+ setAxisAngle(body, 0, 0, 1, Math.PI / 2);
356
+ const out = new Array<number>(9).fill(0);
357
+
358
+ writePhysics3DJointFrameBasis(body, 0, 0, 0, 1, out);
359
+
360
+ expect(out[0]).toBeCloseTo(0, 12);
361
+ expect(out[1]).toBeCloseTo(1, 12);
362
+ expect(out[3]).toBeCloseTo(-1, 12);
363
+ expect(out[4]).toBeCloseTo(0, 12);
364
+ });
365
+
366
+ it('applies the local rotation before the body orientation', () => {
367
+ const body = createUnitBody();
368
+ setAxisAngle(body, 0, 0, 1, Math.PI / 2);
369
+ const half = Math.SQRT1_2;
370
+ const out = new Array<number>(9).fill(0);
371
+
372
+ // Local frame is a quarter turn about X. Composed as bodyOrientation * localRotation, the frame's Y
373
+ // axis lands on world +Z; composed the other way round it would land on world -X.
374
+ writePhysics3DJointFrameBasis(body, half, 0, 0, half, out);
375
+
376
+ expect(out[3]).toBeCloseTo(0, 12);
377
+ expect(out[4]).toBeCloseTo(0, 12);
378
+ expect(out[5]).toBeCloseTo(1, 12);
379
+ });
380
+ });
381
+
382
+ describe('writePhysics3DJointFrameRotation', () => {
383
+ it('returns the body orientation when the frame is the identity', () => {
384
+ const body = createUnitBody();
385
+ setAxisAngle(body, 0, 1, 0, 0.7);
386
+ const out = [0, 0, 0, 0];
387
+
388
+ writePhysics3DJointFrameRotation(body, 0, 0, 0, 1, out);
389
+
390
+ expect(out[0]).toBeCloseTo(body.orientationX, 12);
391
+ expect(out[1]).toBeCloseTo(body.orientationY, 12);
392
+ expect(out[2]).toBeCloseTo(body.orientationZ, 12);
393
+ expect(out[3]).toBeCloseTo(body.orientationW, 12);
394
+ });
395
+
396
+ it('composes two quarter turns about the same axis into a half turn', () => {
397
+ const body = createUnitBody();
398
+ setAxisAngle(body, 0, 0, 1, Math.PI / 2);
399
+ const half = Math.SQRT1_2;
400
+ const out = [0, 0, 0, 0];
401
+
402
+ writePhysics3DJointFrameRotation(body, 0, 0, half, half, out);
403
+
404
+ expect(out[2]).toBeCloseTo(1, 12);
405
+ expect(out[3]).toBeCloseTo(0, 12);
406
+ });
407
+ });
408
+
409
+ describe('writePhysics3DJointPointMass', () => {
410
+ it('is the pair mass on the diagonal when both anchors sit on the centres of mass', () => {
411
+ const bodyA = createUnitBody();
412
+ const bodyB = createUnitBody();
413
+ const out = [0, 0, 0, 0, 0, 0];
414
+
415
+ writePhysics3DJointPointMass(bodyA, bodyB, 0, 0, 0, 0, 0, 0, out);
416
+
417
+ expect(out).toEqual([2, 2, 2, 0, 0, 0]);
418
+ });
419
+
420
+ it('adds the rotational resistance of an offset lever arm', () => {
421
+ const bodyA = createUnitBody();
422
+ const bodyB = createUnitBody();
423
+ const out = [0, 0, 0, 0, 0, 0];
424
+
425
+ // skew((0,0,1)) * identity * transpose(skew((0,0,1))) is diag(1,1,0): an impulse along the arm itself
426
+ // produces no torque, so the z row keeps the bare pair mass.
427
+ writePhysics3DJointPointMass(bodyA, bodyB, 0, 0, 1, 0, 0, 0, out);
428
+
429
+ expect(out[0]).toBeCloseTo(3, 12);
430
+ expect(out[1]).toBeCloseTo(3, 12);
431
+ expect(out[2]).toBeCloseTo(2, 12);
432
+ expect(out[3]).toBeCloseTo(0, 12);
433
+ expect(out[4]).toBeCloseTo(0, 12);
434
+ expect(out[5]).toBeCloseTo(0, 12);
435
+ });
436
+
437
+ it('stays symmetric for a lever arm off every axis', () => {
438
+ const bodyA = createUnitBody();
439
+ const bodyB = createUnitBody();
440
+ const out = [0, 0, 0, 0, 0, 0];
441
+
442
+ writePhysics3DJointPointMass(bodyA, bodyB, 0.3, -0.7, 1.1, 0, 0, 0, out);
443
+
444
+ // K = 2I + skew(r) skew(r)^T, whose off-diagonal (i,j) is -r_i r_j for a unit inverse inertia.
445
+ expect(out[0]).toBeCloseTo(2 + 0.7 * 0.7 + 1.1 * 1.1, 12);
446
+ expect(out[3]).toBeCloseTo(-0.3 * -0.7, 12);
447
+ expect(out[4]).toBeCloseTo(-0.3 * 1.1, 12);
448
+ expect(out[5]).toBeCloseTo(0.7 * 1.1, 12);
449
+ });
450
+ });
451
+
452
+ describe('writePhysics3DJointRelativeRotation', () => {
453
+ it('is the identity for coincident frames', () => {
454
+ const out = [0, 0, 0, 0];
455
+
456
+ writePhysics3DJointRelativeRotation(0, 0, 0, 1, 0, 0, 0, 1, out);
457
+
458
+ expect(out).toEqual([0, 0, 0, 1]);
459
+ });
460
+
461
+ it('expresses the relative rotation in frame A, not in world space', () => {
462
+ const quarter = Math.SQRT1_2;
463
+ const twist = 0.4;
464
+ const frameB = [0, 0, 0, 0];
465
+ multiply(0, 0, quarter, quarter, Math.sin(twist / 2), 0, 0, Math.cos(twist / 2), frameB);
466
+ const out = [0, 0, 0, 0];
467
+
468
+ writePhysics3DJointRelativeRotation(0, 0, quarter, quarter, frameB[0], frameB[1], frameB[2], frameB[3], out);
469
+
470
+ // A pure twist about A's local X, whatever A's world orientation is.
471
+ expect(out[0]).toBeCloseTo(Math.sin(twist / 2), 12);
472
+ expect(out[1]).toBeCloseTo(0, 12);
473
+ expect(out[2]).toBeCloseTo(0, 12);
474
+ });
475
+
476
+ it('returns the representative with a non-negative scalar part', () => {
477
+ const angle = 2 * Math.PI - 0.3;
478
+ const half = angle / 2;
479
+ const out = [0, 0, 0, 0];
480
+
481
+ writePhysics3DJointRelativeRotation(0, 0, 0, 1, 0, 0, Math.sin(half), Math.cos(half), out);
482
+
483
+ expect(out[3]).toBeGreaterThanOrEqual(0);
484
+ expect(out[2]).toBeCloseTo(-Math.sin(0.15), 12);
485
+ });
486
+ });
487
+
488
+ describe('writePhysics3DJointRotationError', () => {
489
+ it('is zero for coincident frames', () => {
490
+ const out = [1, 1, 1];
491
+
492
+ writePhysics3DJointRotationError(0, 0, 0, 1, 0, 0, 0, 1, out);
493
+
494
+ expect(out).toEqual([0, 0, 0]);
495
+ });
496
+
497
+ it('reports the true angle rather than its small-angle approximation', () => {
498
+ const out = [0, 0, 0];
499
+ const half = Math.SQRT1_2;
500
+
501
+ // A quarter turn about Y. The shortcut 2 * q.xyz would give 2 * sin(pi/4) = 1.4142, which is 10% short.
502
+ writePhysics3DJointRotationError(0, 0, 0, 1, 0, half, 0, half, out);
503
+
504
+ expect(out[0]).toBeCloseTo(0, 12);
505
+ expect(out[1]).toBeCloseTo(Math.PI / 2, 12);
506
+ expect(out[2]).toBeCloseTo(0, 12);
507
+ });
508
+
509
+ it('takes the shorter arc when the frames are nearly aligned the long way round', () => {
510
+ const out = [0, 0, 0];
511
+ const angle = 2 * Math.PI - 0.3;
512
+ const half = angle / 2;
513
+
514
+ writePhysics3DJointRotationError(0, 0, 0, 1, 0, 0, Math.sin(half), Math.cos(half), out);
515
+
516
+ expect(out[2]).toBeCloseTo(-0.3, 12);
517
+ });
518
+
519
+ it('expresses the error in world space, not in frame A', () => {
520
+ const out = [0, 0, 0];
521
+ const quarter = Math.SQRT1_2;
522
+ const twist = 0.4;
523
+ // frameB = frameA * (0.4 about local X), with frameA a quarter turn about Z. A's local X points along
524
+ // world +Y, so the world error is 0.4 about +Y.
525
+ const frameB = [0, 0, 0, 0];
526
+ multiply(0, 0, quarter, quarter, Math.sin(twist / 2), 0, 0, Math.cos(twist / 2), frameB);
527
+
528
+ writePhysics3DJointRotationError(0, 0, quarter, quarter, frameB[0], frameB[1], frameB[2], frameB[3], out);
529
+
530
+ expect(out[0]).toBeCloseTo(0, 12);
531
+ expect(out[1]).toBeCloseTo(twist, 12);
532
+ expect(out[2]).toBeCloseTo(0, 12);
533
+ });
534
+ });
535
+
536
+ describe('writePhysics3DJointSeparation', () => {
537
+ it('measures from the A anchor to the B anchor', () => {
538
+ const bodyA = createUnitBody();
539
+ const bodyB = createUnitBody();
540
+ bodyB.x = 5;
541
+ const joint = createTestJoint();
542
+ joint.localAnchorAX = 1;
543
+ joint.localAnchorBX = -1;
544
+ writePhysics3DJointAnchors(bodyA, bodyB, joint);
545
+ const out = [0, 0, 0];
546
+
547
+ writePhysics3DJointSeparation(bodyA, bodyB, joint, out);
548
+
549
+ expect(out[0]).toBeCloseTo(3, 12);
550
+ });
551
+
552
+ it('does not shift when a body carries an offset centre of mass', () => {
553
+ const bodyA = createUnitBody();
554
+ bodyA.centerX = 0.5;
555
+ const bodyB = createUnitBody();
556
+ bodyB.x = 5;
557
+ const joint = createTestJoint();
558
+ joint.localAnchorAX = 1;
559
+ joint.localAnchorBX = -1;
560
+ writePhysics3DJointAnchors(bodyA, bodyB, joint);
561
+ const out = [0, 0, 0];
562
+
563
+ writePhysics3DJointSeparation(bodyA, bodyB, joint, out);
564
+
565
+ // The anchors have not moved, only the point the lever arms are measured from. Adding the arm to the
566
+ // body's ORIGIN instead of its world centre would report 3.5 here.
567
+ expect(out[0]).toBeCloseTo(3, 12);
568
+ });
569
+ });
570
+
571
+ function cross(aX: number, aY: number, aZ: number, bX: number, bY: number, bZ: number): number[] {
572
+ return [aY * bZ - aZ * bY, aZ * bX - aX * bZ, aX * bY - aY * bX];
573
+ }
574
+
575
+ function createTestJoint(): Physics3DJoint {
576
+ return {
577
+ kind: 'Test',
578
+ bodyA: 0,
579
+ bodyB: 1,
580
+ localAnchorAX: 0,
581
+ localAnchorAY: 0,
582
+ localAnchorAZ: 0,
583
+ localAnchorBX: 0,
584
+ localAnchorBY: 0,
585
+ localAnchorBZ: 0,
586
+ collideConnected: false,
587
+ impulse0: 0,
588
+ impulse1: 0,
589
+ impulse2: 0,
590
+ impulse3: 0,
591
+ impulse4: 0,
592
+ impulse5: 0,
593
+ rAX: 0,
594
+ rAY: 0,
595
+ rAZ: 0,
596
+ rBX: 0,
597
+ rBY: 0,
598
+ rBZ: 0,
599
+ };
600
+ }
601
+
602
+ function createUnitBody(): RigidBody3D {
603
+ const body = createRigidBody3D('dynamic');
604
+ const data = createPhysics3DMassData();
605
+ data.mass = 1;
606
+ data.inertiaXX = 1;
607
+ data.inertiaYY = 1;
608
+ data.inertiaZZ = 1;
609
+ setRigidBody3DMassData(body, data);
610
+ refreshRigidBody3DWorldInertia(body);
611
+ return body;
612
+ }
613
+
614
+ function normalized(x: number, y: number, z: number): number[] {
615
+ const length = Math.sqrt(x * x + y * y + z * z);
616
+ return [x / length, y / length, z / length];
617
+ }
618
+
619
+ function multiply(
620
+ aX: number,
621
+ aY: number,
622
+ aZ: number,
623
+ aW: number,
624
+ bX: number,
625
+ bY: number,
626
+ bZ: number,
627
+ bW: number,
628
+ out: number[],
629
+ ): void {
630
+ out[0] = aW * bX + aX * bW + aY * bZ - aZ * bY;
631
+ out[1] = aW * bY - aX * bZ + aY * bW + aZ * bX;
632
+ out[2] = aW * bZ + aX * bY - aY * bX + aZ * bW;
633
+ out[3] = aW * bW - aX * bX - aY * bY - aZ * bZ;
634
+ }
635
+
636
+ function setAxisAngle(body: RigidBody3D, x: number, y: number, z: number, angle: number): void {
637
+ const sine = Math.sin(angle / 2);
638
+ body.orientationX = x * sine;
639
+ body.orientationY = y * sine;
640
+ body.orientationZ = z * sine;
641
+ body.orientationW = Math.cos(angle / 2);
642
+ refreshRigidBody3DWorldInertia(body);
643
+ }