@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,677 @@
1
+ import type {
2
+ Physics3DHingeJoint,
3
+ Physics3DJoint,
4
+ Physics3DJointSolver,
5
+ Physics3DWorld,
6
+ RigidBody3D,
7
+ } from '@flighthq/types/contract';
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import { refreshRigidBody3DWorldInertia } from './integrate';
11
+ import {
12
+ createPhysics3DConeTwistJoint,
13
+ createPhysics3DFixedJoint,
14
+ createPhysics3DGeneric6DofJoint,
15
+ createPhysics3DHingeJoint,
16
+ createPhysics3DSliderJoint,
17
+ } from './jointFactories';
18
+ import { writePhysics3DJointAnchorVelocity, writePhysics3DJointAnchors } from './jointMath';
19
+ import {
20
+ physics3DBallAndSocketJointSolver,
21
+ physics3DConeTwistJointSolver,
22
+ physics3DFixedJointSolver,
23
+ physics3DGeneric6DofJointSolver,
24
+ physics3DHingeJointSolver,
25
+ physics3DSliderJointSolver,
26
+ Physics3DBallAndSocketJointKind,
27
+ Physics3DConeTwistJointKind,
28
+ Physics3DFixedJointKind,
29
+ Physics3DGeneric6DofJointKind,
30
+ Physics3DHingeJointKind,
31
+ Physics3DSliderJointKind,
32
+ } from './joints';
33
+ import { createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
34
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D, setPhysics3DBodyType } from './world';
35
+
36
+ describe('Physics3DBallAndSocketJointKind', () => {
37
+ it('names every built-in kind uniquely and without a vendor prefix', () => {
38
+ const kinds = [
39
+ Physics3DBallAndSocketJointKind,
40
+ Physics3DConeTwistJointKind,
41
+ Physics3DFixedJointKind,
42
+ Physics3DGeneric6DofJointKind,
43
+ Physics3DHingeJointKind,
44
+ Physics3DSliderJointKind,
45
+ ];
46
+
47
+ expect(new Set(kinds).size).toBe(kinds.length);
48
+ expect(kinds.every((kind) => !kind.includes('.'))).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe('physics3DBallAndSocketJointSolver', () => {
53
+ it('cancels the velocity separating the two anchors', () => {
54
+ const scene = createScene();
55
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localAnchorAX: 1, localAnchorBX: -1 });
56
+ joint.kind = Physics3DBallAndSocketJointKind;
57
+ scene.bodyB.velocityX = 4;
58
+ scene.bodyB.velocityY = -2;
59
+
60
+ solveJoint(scene.world, joint, physics3DBallAndSocketJointSolver, 8);
61
+
62
+ expect(anchorSpeed(scene, joint)).toBeLessThan(1e-9);
63
+ });
64
+
65
+ it('leaves relative rotation completely free', () => {
66
+ const scene = createScene();
67
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
68
+ joint.kind = Physics3DBallAndSocketJointKind;
69
+ scene.bodyB.angularVelocityY = 3;
70
+
71
+ solveJoint(scene.world, joint, physics3DBallAndSocketJointSolver, 8);
72
+
73
+ // Both anchors sit on the centres of mass, so a pure spin moves neither and the joint has nothing to
74
+ // resist. A kind that quietly locked an axis would show up here.
75
+ expect(scene.bodyB.angularVelocityY).toBeCloseTo(3, 9);
76
+ });
77
+
78
+ it('pulls a separated pair back together', () => {
79
+ const scene = createScene();
80
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localAnchorAX: 1, localAnchorBX: -1 });
81
+ joint.kind = Physics3DBallAndSocketJointKind;
82
+ scene.bodyB.x = 4;
83
+
84
+ solveJoint(scene.world, joint, physics3DBallAndSocketJointSolver, 8);
85
+
86
+ // The anchors are 2 apart along +x, so the bias drives B back toward A.
87
+ expect(scene.bodyB.velocityX).toBeLessThan(0);
88
+ expect(scene.bodyA.velocityX).toBeGreaterThan(0);
89
+ });
90
+ });
91
+
92
+ describe('physics3DConeTwistJointSolver', () => {
93
+ it('leaves a swing inside the cone alone', () => {
94
+ const scene = createScene();
95
+ const joint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 1, swingLimitZ: 1 });
96
+ scene.bodyB.angularVelocityZ = 0.5;
97
+
98
+ solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 16);
99
+
100
+ // Both twist axes still coincide, so the joint is at the centre of its cone and has nothing to resist.
101
+ expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0.5, 6);
102
+ });
103
+
104
+ it('pushes back on a swing past the cone', () => {
105
+ const scene = createScene();
106
+ // Half a radian of tilt about Z, well past a cone of 0.2.
107
+ setAxisAngle(scene.bodyB, 0, 0, 1, 0.5);
108
+ const joint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 0.2, swingLimitZ: 0.2 });
109
+
110
+ solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 16);
111
+
112
+ // The swing axis for a tilt about +z is +z, so closing the cone means a negative relative spin about it.
113
+ expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeLessThan(0);
114
+ });
115
+
116
+ it('takes its cone from the axis the tilt is actually in', () => {
117
+ const wide = createScene();
118
+ setAxisAngle(wide.bodyB, 0, 0, 1, 0.5);
119
+ // A tilt about +z carries the twist axis toward frame Y, so swingLimitY is the bound that applies.
120
+ const wideJoint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 1.2, swingLimitZ: 0.05 });
121
+ solveJoint(wide.world, wideJoint, physics3DConeTwistJointSolver, 16);
122
+
123
+ const narrow = createScene();
124
+ setAxisAngle(narrow.bodyB, 0, 0, 1, 0.5);
125
+ const narrowJoint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 0.05, swingLimitZ: 1.2 });
126
+ solveJoint(narrow.world, narrowJoint, physics3DConeTwistJointSolver, 16);
127
+
128
+ expect(wide.bodyB.angularVelocityZ).toBeCloseTo(0, 9);
129
+ expect(narrow.bodyB.angularVelocityZ).toBeLessThan(0);
130
+ });
131
+
132
+ it('bounds the twist without touching the swing', () => {
133
+ const scene = createScene();
134
+ setAxisAngle(scene.bodyB, 1, 0, 0, 0.5);
135
+ const joint = createPhysics3DConeTwistJoint({
136
+ bodyA: 0,
137
+ bodyB: 1,
138
+ enableSwingLimit: false,
139
+ enableTwistLimit: true,
140
+ lowerTwistAngle: -0.1,
141
+ upperTwistAngle: 0.1,
142
+ });
143
+
144
+ solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 16);
145
+
146
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeLessThan(0);
147
+ });
148
+
149
+ it('refuses to exchange its ends', () => {
150
+ const joint = createPhysics3DConeTwistJoint({ bodyA: 1, bodyB: 0 });
151
+
152
+ expect(physics3DConeTwistJointSolver.swapEnds?.(joint)).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('physics3DFixedJointSolver', () => {
157
+ it('cancels relative rotation about every axis', () => {
158
+ const scene = createScene();
159
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
160
+ scene.bodyB.angularVelocityX = 1.5;
161
+ scene.bodyB.angularVelocityY = -0.8;
162
+ scene.bodyB.angularVelocityZ = 2.1;
163
+
164
+ solveJoint(scene.world, joint, physics3DFixedJointSolver, 8);
165
+
166
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0, 9);
167
+ expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(0, 9);
168
+ expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0, 9);
169
+ });
170
+
171
+ it('cancels relative anchor motion as well as rotation', () => {
172
+ const scene = createScene();
173
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localAnchorAX: 1, localAnchorBX: -1 });
174
+ scene.bodyB.velocityZ = 3;
175
+ scene.bodyB.angularVelocityY = 1;
176
+
177
+ solveJoint(scene.world, joint, physics3DFixedJointSolver, 16);
178
+
179
+ expect(anchorSpeed(scene, joint)).toBeLessThan(1e-6);
180
+ });
181
+
182
+ it('exchanges the frames when its ends are swapped', () => {
183
+ const joint = createPhysics3DFixedJoint({
184
+ bodyA: 0,
185
+ bodyB: 1,
186
+ localRotationAX: 1,
187
+ localRotationAW: 0,
188
+ });
189
+
190
+ expect(physics3DFixedJointSolver.swapEnds?.(joint)).toBe(true);
191
+ expect(joint.localRotationAX).toBe(0);
192
+ expect(joint.localRotationAW).toBe(1);
193
+ expect(joint.localRotationBX).toBe(1);
194
+ expect(joint.localRotationBW).toBe(0);
195
+ });
196
+ });
197
+
198
+ describe('physics3DGeneric6DofJointSolver', () => {
199
+ it('constrains nothing when every axis is free', () => {
200
+ const scene = createScene();
201
+ const joint = createPhysics3DGeneric6DofJoint({ bodyA: 0, bodyB: 1 });
202
+ scene.bodyB.velocityX = 1;
203
+ scene.bodyB.velocityY = 2;
204
+ scene.bodyB.angularVelocityZ = 3;
205
+
206
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
207
+
208
+ expect(scene.bodyB.velocityX).toBeCloseTo(1, 9);
209
+ expect(scene.bodyB.velocityY).toBeCloseTo(2, 9);
210
+ expect(scene.bodyB.angularVelocityZ).toBeCloseTo(3, 9);
211
+ });
212
+
213
+ it('locks the one axis whose bounds are equal and leaves the rest free', () => {
214
+ const scene = createScene();
215
+ // A is the anchored end, so the row's coordinate is B's own displacement along the axis and reads
216
+ // directly off B's velocity. With both ends dynamic the coordinate also carries A's rotation about its
217
+ // lever arm, and a plain velocity difference is then measuring something the joint never constrained.
218
+ makeStatic(scene.bodyA);
219
+ const joint = createPhysics3DGeneric6DofJoint({
220
+ bodyA: 0,
221
+ bodyB: 1,
222
+ lowerLinearY: 0,
223
+ upperLinearY: 0,
224
+ });
225
+ scene.bodyB.velocityX = 1;
226
+ scene.bodyB.velocityY = 2;
227
+
228
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
229
+
230
+ expect(scene.bodyB.velocityY - scene.bodyA.velocityY).toBeCloseTo(0, 6);
231
+ expect(scene.bodyB.velocityX).toBeCloseTo(1, 9);
232
+ });
233
+
234
+ it('lets a limited axis move within its interval', () => {
235
+ const scene = createScene();
236
+ // The anchors are 2 apart along +x, and the interval admits that.
237
+ const joint = createPhysics3DGeneric6DofJoint({
238
+ bodyA: 0,
239
+ bodyB: 1,
240
+ lowerLinearX: 1,
241
+ upperLinearX: 3,
242
+ });
243
+ scene.bodyB.velocityX = 0.5;
244
+
245
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
246
+
247
+ expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(0.5, 6);
248
+ });
249
+
250
+ it('stops a limited axis at the end of its interval', () => {
251
+ const scene = createScene();
252
+ const joint = createPhysics3DGeneric6DofJoint({
253
+ bodyA: 0,
254
+ bodyB: 1,
255
+ lowerLinearX: -1,
256
+ upperLinearX: 1,
257
+ });
258
+
259
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
260
+
261
+ // The separation is 2, past the upper bound of 1, so the pair is drawn back together.
262
+ expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeLessThan(0);
263
+ });
264
+
265
+ it('locks an angular axis without disturbing the other two', () => {
266
+ const scene = createScene();
267
+ const joint = createPhysics3DGeneric6DofJoint({
268
+ bodyA: 0,
269
+ bodyB: 1,
270
+ lowerAngularZ: 0,
271
+ upperAngularZ: 0,
272
+ });
273
+ scene.bodyB.angularVelocityY = 1;
274
+ scene.bodyB.angularVelocityZ = 2;
275
+
276
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
277
+
278
+ expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0, 6);
279
+ expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(1, 6);
280
+ });
281
+
282
+ it('refuses to exchange its ends', () => {
283
+ const joint = createPhysics3DGeneric6DofJoint({ bodyA: 1, bodyB: 0 });
284
+
285
+ expect(physics3DGeneric6DofJointSolver.swapEnds?.(joint)).toBe(false);
286
+ });
287
+
288
+ it('carries a limited axis accumulator but drops it when the axis stops being limited', () => {
289
+ const scene = createScene();
290
+ makeStatic(scene.bodyA);
291
+ const joint = createPhysics3DGeneric6DofJoint({
292
+ bodyA: 0,
293
+ bodyB: 1,
294
+ lowerAngularX: -0.05,
295
+ upperAngularX: 0.05,
296
+ });
297
+ setAxisAngle(scene.bodyB, 1, 0, 0, 0.2);
298
+
299
+ scene.bodyB.angularVelocityX = 1;
300
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 4);
301
+ expect(joint.upperLimitImpulses[3]).toBeGreaterThan(0);
302
+
303
+ // Widening the bounds past the pose makes the axis FREE, and an accumulator describing a bound the
304
+ // joint is no longer anywhere near must not survive into the next prepare.
305
+ joint.lowerAngularX = 1;
306
+ joint.upperAngularX = -1;
307
+ solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 1);
308
+ expect(joint.upperLimitImpulses[3]).toBe(0);
309
+ });
310
+ });
311
+
312
+ describe('physics3DHingeJointSolver', () => {
313
+ it('leaves rotation about its own axis free while cancelling the other two', () => {
314
+ const scene = createScene();
315
+ const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 });
316
+ scene.bodyB.angularVelocityX = 2;
317
+ scene.bodyB.angularVelocityY = 1.3;
318
+ scene.bodyB.angularVelocityZ = -0.9;
319
+
320
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
321
+
322
+ // The frame's X axis is the hinge line, and with identity frames it is world +x.
323
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(2, 6);
324
+ expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(0, 6);
325
+ expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0, 6);
326
+ });
327
+
328
+ it('holds the hinge line even when the frames rotate it off the body axes', () => {
329
+ const scene = createScene();
330
+ const half = Math.SQRT1_2;
331
+ // A quarter turn about Z carries the frame's X axis onto world +y for both bodies.
332
+ const joint = createPhysics3DHingeJoint({
333
+ bodyA: 0,
334
+ bodyB: 1,
335
+ localRotationAZ: half,
336
+ localRotationAW: half,
337
+ localRotationBZ: half,
338
+ localRotationBW: half,
339
+ });
340
+ scene.bodyB.angularVelocityX = 1.1;
341
+ scene.bodyB.angularVelocityY = 2;
342
+
343
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
344
+
345
+ expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(2, 6);
346
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0, 6);
347
+ });
348
+
349
+ it('drives the relative spin toward the motor speed', () => {
350
+ const scene = createScene();
351
+ const joint = createPhysics3DHingeJoint({
352
+ bodyA: 0,
353
+ bodyB: 1,
354
+ enableMotor: true,
355
+ motorSpeed: 3,
356
+ maxMotorTorque: 1000,
357
+ });
358
+
359
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
360
+
361
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(3, 6);
362
+ });
363
+
364
+ it('bounds the motor by its torque budget rather than by the iteration count', () => {
365
+ const fewer = createScene();
366
+ const jointFewer = createPhysics3DHingeJoint({
367
+ bodyA: 0,
368
+ bodyB: 1,
369
+ enableMotor: true,
370
+ motorSpeed: 50,
371
+ maxMotorTorque: 1,
372
+ });
373
+ solveJoint(fewer.world, jointFewer, physics3DHingeJointSolver, 4);
374
+
375
+ const more = createScene();
376
+ const jointMore = createPhysics3DHingeJoint({
377
+ bodyA: 0,
378
+ bodyB: 1,
379
+ enableMotor: true,
380
+ motorSpeed: 50,
381
+ maxMotorTorque: 1,
382
+ });
383
+ solveJoint(more.world, jointMore, physics3DHingeJointSolver, 32);
384
+
385
+ // A per-iteration clamp would make the motor eight times stronger in the second run — a motor that gets
386
+ // stronger when the solver is tuned for accuracy.
387
+ expect(more.bodyB.angularVelocityX).toBeCloseTo(fewer.bodyB.angularVelocityX, 9);
388
+ });
389
+
390
+ it('pushes back when the hinge angle is past its upper limit', () => {
391
+ const scene = createScene();
392
+ // A tenth of a radian about the hinge line, well past a limit of zero.
393
+ setAxisAngle(scene.bodyB, 1, 0, 0, 0.1);
394
+ const joint = createPhysics3DHingeJoint({
395
+ bodyA: 0,
396
+ bodyB: 1,
397
+ enableLimit: true,
398
+ lowerAngle: -0.05,
399
+ upperAngle: 0.05,
400
+ });
401
+
402
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
403
+
404
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeLessThan(0);
405
+ });
406
+
407
+ it('carries a limit impulse across steps and reapplies it before the first iteration', () => {
408
+ const { scene, joint } = createLoadedHingeStop();
409
+ scene.bodyB.angularVelocityX = 1;
410
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 4);
411
+ expect(joint.upperLimitImpulse).toBeGreaterThan(0);
412
+
413
+ // A whole step with ZERO velocity iterations, so nothing but warm starting can move the body. This is
414
+ // the assertion that the carried value is actually reapplied rather than merely stored — deliberately
415
+ // not a "converges in fewer iterations" comparison, because an isolated one-sided row has an exact
416
+ // effective mass and reaches the same answer from any starting point in a single iteration. Warm
417
+ // starting pays off where the limit competes with the point and lock rows, not here.
418
+ const before = scene.bodyB.angularVelocityX;
419
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 0);
420
+
421
+ expect(scene.bodyB.angularVelocityX).toBeLessThan(before);
422
+ });
423
+
424
+ it('drops a carried limit impulse when the limit is turned off', () => {
425
+ const { scene, joint } = createLoadedHingeStop();
426
+ scene.bodyB.angularVelocityX = 1;
427
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 4);
428
+ expect(joint.upperLimitImpulse).toBeGreaterThan(0);
429
+
430
+ // Same rule the motor already followed: a cached impulse is valid only while the row that produced it is
431
+ // still solved, or a disabled stop keeps pushing forever with nothing to cancel it.
432
+ joint.enableLimit = false;
433
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 1);
434
+ expect(joint.upperLimitImpulse).toBe(0);
435
+ });
436
+
437
+ it('exchanges rather than negates the limit accumulators when its ends are swapped', () => {
438
+ const joint = createPhysics3DHingeJoint({ bodyA: 1, bodyB: 0, enableLimit: true, lowerAngle: -1, upperAngle: 1 });
439
+ joint.lowerLimitImpulse = 3;
440
+ joint.upperLimitImpulse = 7;
441
+
442
+ physics3DHingeJointSolver.swapEnds?.(joint);
443
+
444
+ // Both stay non-negative: each is a magnitude whose direction is carried by the row that owns it, so the
445
+ // push that held the lower bound is the one that now holds the upper.
446
+ expect(joint.lowerLimitImpulse).toBe(7);
447
+ expect(joint.upperLimitImpulse).toBe(3);
448
+ });
449
+
450
+ it('rescales carried limit accumulators with the timestep', () => {
451
+ const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1, enableLimit: true });
452
+ joint.lowerLimitImpulse = 2;
453
+ joint.upperLimitImpulse = 5;
454
+
455
+ physics3DHingeJointSolver.scaleAccumulatedImpulses?.(joint, 0.5);
456
+
457
+ // An accumulated impulse has force-times-time units, so reusing one across a different interval applies
458
+ // the wrong force before the first iteration can correct it.
459
+ expect(joint.lowerLimitImpulse).toBe(1);
460
+ expect(joint.upperLimitImpulse).toBe(2.5);
461
+ });
462
+
463
+ it('does not brake a hinge that is nowhere near its limits', () => {
464
+ const scene = createScene();
465
+ const joint = createPhysics3DHingeJoint({
466
+ bodyA: 0,
467
+ bodyB: 1,
468
+ enableLimit: true,
469
+ lowerAngle: -1,
470
+ upperAngle: 1,
471
+ });
472
+ scene.bodyB.angularVelocityX = 0.5;
473
+
474
+ solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
475
+
476
+ // A limit whose bias clamped the signed error to zero on the inside would brake this to a standstill.
477
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0.5, 6);
478
+ });
479
+
480
+ it('reverses and exchanges the limit interval when its ends are swapped', () => {
481
+ const joint = createPhysics3DHingeJoint({
482
+ bodyA: 1,
483
+ bodyB: 0,
484
+ lowerAngle: -0.25,
485
+ upperAngle: 1.5,
486
+ motorSpeed: 4,
487
+ });
488
+
489
+ expect(physics3DHingeJointSolver.swapEnds?.(joint)).toBe(true);
490
+ expect(joint.lowerAngle).toBe(-1.5);
491
+ expect(joint.upperAngle).toBe(0.25);
492
+ expect(joint.motorSpeed).toBe(-4);
493
+ // Negating an absent accumulator has to produce zero, not NaN, which is what this catches.
494
+ expect(joint.motorImpulse).toBeCloseTo(0, 12);
495
+ });
496
+
497
+ it('scales the motor accumulator with a changed timestep', () => {
498
+ const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 });
499
+ joint.motorImpulse = 6;
500
+
501
+ physics3DHingeJointSolver.scaleAccumulatedImpulses?.(joint, 0.5);
502
+
503
+ expect(joint.motorImpulse).toBe(3);
504
+ });
505
+ });
506
+
507
+ describe('physics3DSliderJointSolver', () => {
508
+ it('leaves travel along its axis free while cancelling the other two', () => {
509
+ const scene = createScene();
510
+ // The rail is the anchored end — the ordinary way a slider is authored — which is also what makes the
511
+ // row's coordinate equal to B's velocity along it rather than to a difference that carries A's spin.
512
+ makeStatic(scene.bodyA);
513
+ const joint = createPhysics3DSliderJoint({ bodyA: 0, bodyB: 1 });
514
+ scene.bodyB.velocityX = 2;
515
+ scene.bodyB.velocityY = 1.5;
516
+ scene.bodyB.velocityZ = -1;
517
+
518
+ solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
519
+
520
+ expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(2, 6);
521
+ expect(scene.bodyB.velocityY - scene.bodyA.velocityY).toBeCloseTo(0, 6);
522
+ expect(scene.bodyB.velocityZ - scene.bodyA.velocityZ).toBeCloseTo(0, 6);
523
+ });
524
+
525
+ it('locks relative rotation about every axis', () => {
526
+ const scene = createScene();
527
+ const joint = createPhysics3DSliderJoint({ bodyA: 0, bodyB: 1 });
528
+ scene.bodyB.angularVelocityX = 1;
529
+ scene.bodyB.angularVelocityY = -2;
530
+
531
+ solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
532
+
533
+ expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0, 6);
534
+ expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(0, 6);
535
+ });
536
+
537
+ it('drives the travel toward the motor speed', () => {
538
+ const scene = createScene();
539
+ const joint = createPhysics3DSliderJoint({
540
+ bodyA: 0,
541
+ bodyB: 1,
542
+ enableMotor: true,
543
+ motorSpeed: 4,
544
+ maxMotorForce: 1000,
545
+ });
546
+
547
+ solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
548
+
549
+ expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(4, 6);
550
+ });
551
+
552
+ it('stops travel at the end of its interval', () => {
553
+ const scene = createScene();
554
+ const joint = createPhysics3DSliderJoint({
555
+ bodyA: 0,
556
+ bodyB: 1,
557
+ enableLimit: true,
558
+ lowerTranslation: -0.5,
559
+ upperTranslation: 0.5,
560
+ });
561
+
562
+ solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
563
+
564
+ // The anchors are 2 apart along the axis, past the upper bound.
565
+ expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeLessThan(0);
566
+ });
567
+
568
+ it('does not brake travel that is nowhere near its limits', () => {
569
+ const scene = createScene();
570
+ const joint = createPhysics3DSliderJoint({
571
+ bodyA: 0,
572
+ bodyB: 1,
573
+ enableLimit: true,
574
+ lowerTranslation: -5,
575
+ upperTranslation: 5,
576
+ });
577
+ scene.bodyB.velocityX = 1;
578
+
579
+ solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
580
+
581
+ expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(1, 6);
582
+ });
583
+
584
+ it('reverses and exchanges the travel interval when its ends are swapped', () => {
585
+ const joint = createPhysics3DSliderJoint({
586
+ bodyA: 1,
587
+ bodyB: 0,
588
+ lowerTranslation: -0.25,
589
+ upperTranslation: 1.5,
590
+ motorSpeed: 4,
591
+ });
592
+
593
+ expect(physics3DSliderJointSolver.swapEnds?.(joint)).toBe(true);
594
+ expect(joint.lowerTranslation).toBe(-1.5);
595
+ expect(joint.upperTranslation).toBe(0.25);
596
+ expect(joint.motorSpeed).toBe(-4);
597
+ });
598
+ });
599
+
600
+ function anchorSpeed(scene: Readonly<Scene>, joint: Physics3DJoint): number {
601
+ writePhysics3DJointAnchors(scene.bodyA, scene.bodyB, joint);
602
+ const velocity = [0, 0, 0];
603
+ writePhysics3DJointAnchorVelocity(scene.bodyA, scene.bodyB, joint, velocity);
604
+ return Math.sqrt(velocity[0] * velocity[0] + velocity[1] * velocity[1] + velocity[2] * velocity[2]);
605
+ }
606
+
607
+ interface Scene {
608
+ world: Physics3DWorld;
609
+ bodyA: RigidBody3D;
610
+ bodyB: RigidBody3D;
611
+ }
612
+
613
+ function createScene(): Scene {
614
+ const world = createPhysics3DWorld();
615
+ const bodyA = createUnitBody();
616
+ const bodyB = createUnitBody();
617
+ bodyB.x = 2;
618
+ addPhysics3DBody(world, bodyA);
619
+ addPhysics3DBody(world, bodyB);
620
+ return { world, bodyA, bodyB };
621
+ }
622
+
623
+ // A hinge held past its upper stop against a static end — the pose a warm-started limit is for. The angle
624
+ // never changes here because nothing integrates the pose, so the stop stays loaded step after step.
625
+ function createLoadedHingeStop(): { scene: Scene; joint: Physics3DHingeJoint } {
626
+ const scene = createScene();
627
+ makeStatic(scene.bodyA);
628
+ setAxisAngle(scene.bodyB, 1, 0, 0, 0.2);
629
+ const joint = createPhysics3DHingeJoint({
630
+ bodyA: 0,
631
+ bodyB: 1,
632
+ enableLimit: true,
633
+ lowerAngle: -0.05,
634
+ upperAngle: 0.05,
635
+ });
636
+ return { scene, joint };
637
+ }
638
+
639
+ function createUnitBody(): RigidBody3D {
640
+ const body = createRigidBody3D('dynamic');
641
+ const data = createPhysics3DMassData();
642
+ data.mass = 1;
643
+ data.inertiaXX = 1;
644
+ data.inertiaYY = 1;
645
+ data.inertiaZZ = 1;
646
+ setRigidBody3DMassData(body, data);
647
+ refreshRigidBody3DWorldInertia(body);
648
+ return body;
649
+ }
650
+
651
+ function makeStatic(body: RigidBody3D): void {
652
+ setPhysics3DBodyType(body, 'static');
653
+ refreshRigidBody3DWorldInertia(body);
654
+ }
655
+
656
+ function setAxisAngle(body: RigidBody3D, x: number, y: number, z: number, angle: number): void {
657
+ const sine = Math.sin(angle / 2);
658
+ body.orientationX = x * sine;
659
+ body.orientationY = y * sine;
660
+ body.orientationZ = z * sine;
661
+ body.orientationW = Math.cos(angle / 2);
662
+ refreshRigidBody3DWorldInertia(body);
663
+ }
664
+
665
+ // One substep of the sequential-impulse loop, in the order the step will call it: prepare, warm start, then
666
+ // the velocity iterations.
667
+ function solveJoint(
668
+ world: Physics3DWorld,
669
+ joint: Physics3DJoint,
670
+ solver: Readonly<Physics3DJointSolver>,
671
+ iterations: number,
672
+ dt = 1 / 60,
673
+ ): void {
674
+ solver.prepare(world, joint, dt);
675
+ solver.warmStart?.(world, joint);
676
+ for (let i = 0; i < iterations; i += 1) solver.solve(world, joint, dt);
677
+ }