@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,357 @@
1
+ import type { Physics3DContact, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { refreshRigidBody3DWorldInertia } from './integrate';
5
+ import { buildPhysics3DSolveIslands, isRigidBody3DPairAwake, updatePhysics3DSleep } from './islands';
6
+ import { computePhysics3DBoxMassData, createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
7
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D, setPhysics3DBodyType } from './world';
8
+
9
+ describe('buildPhysics3DSolveIslands', () => {
10
+ it('admits every awake dynamic body', () => {
11
+ const world = createPhysics3DWorld();
12
+ createBox(world);
13
+ createBox(world);
14
+ updatePhysics3DSleep(world, 0);
15
+ buildPhysics3DSolveIslands(world);
16
+
17
+ expect(world.solveIslandBodyIndices).toHaveLength(2);
18
+ });
19
+
20
+ it('separates unconnected bodies into distinct islands', () => {
21
+ const world = createPhysics3DWorld();
22
+ createBox(world);
23
+ createBox(world);
24
+ updatePhysics3DSleep(world, 0);
25
+ buildPhysics3DSolveIslands(world);
26
+
27
+ expect(world.solveIslandRoots).toHaveLength(2);
28
+ expect(world.solveIslandBodyCounts).toEqual([1, 1]);
29
+ });
30
+
31
+ it('merges bodies joined by a contact into one island', () => {
32
+ const world = createPhysics3DWorld();
33
+ const a = createBox(world);
34
+ const b = createBox(world);
35
+ world.contacts.push(createContact(a.index, b.index));
36
+ updatePhysics3DSleep(world, 0);
37
+ buildPhysics3DSolveIslands(world);
38
+
39
+ expect(world.solveIslandRoots).toHaveLength(1);
40
+ expect(world.solveIslandBodyCounts).toEqual([2]);
41
+ expect(world.solveIslandContactCounts).toEqual([1]);
42
+ });
43
+
44
+ it('does not bridge two stacks through shared static ground', () => {
45
+ // The failure this guards: static bodies joining islands would merge every stack resting on the
46
+ // same floor into one world-sized island that can only sleep when the whole level stops.
47
+ const world = createPhysics3DWorld();
48
+ const ground = createBox(world);
49
+ setPhysics3DBodyType(ground, 'static');
50
+ const left = createBox(world);
51
+ const right = createBox(world);
52
+ world.contacts.push(createContact(ground.index, left.index));
53
+ world.contacts.push(createContact(ground.index, right.index));
54
+ updatePhysics3DSleep(world, 0);
55
+ buildPhysics3DSolveIslands(world);
56
+
57
+ expect(world.solveIslandRoots).toHaveLength(2);
58
+ });
59
+
60
+ it('excludes static and sleeping bodies from the workspace', () => {
61
+ const world = createPhysics3DWorld();
62
+ const dynamic = createBox(world);
63
+ const ground = createBox(world);
64
+ setPhysics3DBodyType(ground, 'static');
65
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
66
+ buildPhysics3DSolveIslands(world);
67
+
68
+ expect(dynamic.sleeping).toBe(true);
69
+ expect(world.solveIslandBodyIndices).toHaveLength(0);
70
+ expect(world.solveIslandRoots).toHaveLength(0);
71
+ });
72
+
73
+ it('gives each island a contiguous, non-overlapping slice', () => {
74
+ const world = createPhysics3DWorld();
75
+ const a = createBox(world);
76
+ const b = createBox(world);
77
+ createBox(world);
78
+ world.contacts.push(createContact(a.index, b.index));
79
+ updatePhysics3DSleep(world, 0);
80
+ buildPhysics3DSolveIslands(world);
81
+
82
+ const seen = new Set<number>();
83
+ for (let island = 0; island < world.solveIslandRoots.length; island += 1) {
84
+ const start = world.solveIslandBodyStarts[island];
85
+ for (let i = 0; i < world.solveIslandBodyCounts[island]; i += 1) {
86
+ const value = world.solveIslandBodyIndices[start + i];
87
+ expect(seen.has(value)).toBe(false);
88
+ seen.add(value);
89
+ }
90
+ }
91
+ expect(seen.size).toBe(3);
92
+ });
93
+
94
+ it('skips a contact whose bodies are all asleep or static', () => {
95
+ const world = createPhysics3DWorld();
96
+ const body = createBox(world);
97
+ const ground = createBox(world);
98
+ setPhysics3DBodyType(ground, 'static');
99
+ world.contacts.push(createContact(body.index, ground.index));
100
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
101
+ buildPhysics3DSolveIslands(world);
102
+
103
+ expect(world.solveIslandContactIndices).toHaveLength(0);
104
+ });
105
+
106
+ it('refills in place rather than growing across rebuilds', () => {
107
+ const world = createPhysics3DWorld();
108
+ createBox(world);
109
+ createBox(world);
110
+ updatePhysics3DSleep(world, 0);
111
+ buildPhysics3DSolveIslands(world);
112
+ const first = world.solveIslandBodyIndices.length;
113
+ buildPhysics3DSolveIslands(world);
114
+
115
+ expect(world.solveIslandBodyIndices).toHaveLength(first);
116
+ });
117
+ });
118
+
119
+ describe('isRigidBody3DPairAwake', () => {
120
+ it('is true while either end can still move', () => {
121
+ const world = createPhysics3DWorld();
122
+ const awake = createBox(world);
123
+ const asleep = createBox(world);
124
+ asleep.sleeping = true;
125
+
126
+ expect(isRigidBody3DPairAwake(awake, asleep)).toBe(true);
127
+ expect(isRigidBody3DPairAwake(asleep, awake)).toBe(true);
128
+ });
129
+
130
+ it('is false for two sleeping bodies', () => {
131
+ const world = createPhysics3DWorld();
132
+ const a = createBox(world);
133
+ const b = createBox(world);
134
+ a.sleeping = true;
135
+ b.sleeping = true;
136
+
137
+ expect(isRigidBody3DPairAwake(a, b)).toBe(false);
138
+ });
139
+
140
+ it('is false for a sleeper resting on static scenery', () => {
141
+ const world = createPhysics3DWorld();
142
+ const body = createBox(world);
143
+ const ground = createBox(world);
144
+ setPhysics3DBodyType(ground, 'static');
145
+ body.sleeping = true;
146
+
147
+ expect(isRigidBody3DPairAwake(body, ground)).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe('updatePhysics3DSleep', () => {
152
+ it('sleeps a still body once it has been settled for timeToSleep', () => {
153
+ const world = createPhysics3DWorld();
154
+ const body = createBox(world);
155
+ updatePhysics3DSleep(world, world.config.timeToSleep + 0.001);
156
+
157
+ expect(body.sleeping).toBe(true);
158
+ });
159
+
160
+ it('zeroes velocity on sleeping so a woken body does not resume stale motion', () => {
161
+ const world = createPhysics3DWorld();
162
+ const body = createBox(world);
163
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
164
+ body.velocityX = 0; // already zeroed; assert the angular axes too
165
+ updatePhysics3DSleep(world, 0);
166
+
167
+ expect(body.velocityX).toBe(0);
168
+ expect(body.velocityY).toBe(0);
169
+ expect(body.velocityZ).toBe(0);
170
+ expect(body.angularVelocityX).toBe(0);
171
+ expect(body.angularVelocityY).toBe(0);
172
+ expect(body.angularVelocityZ).toBe(0);
173
+ });
174
+
175
+ it('keeps a moving body awake', () => {
176
+ const world = createPhysics3DWorld();
177
+ const body = createBox(world);
178
+ body.velocityX = 10;
179
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
180
+
181
+ expect(body.sleeping).toBe(false);
182
+ expect(body.sleepTimer).toBe(0);
183
+ });
184
+
185
+ it('treats angular velocity as a magnitude, not per axis', () => {
186
+ // Three axes each just under the threshold is a body tumbling at sqrt(3) times it.
187
+ const world = createPhysics3DWorld();
188
+ const body = createBox(world);
189
+ const perAxis = world.config.sleepAngularThreshold * 0.9;
190
+ body.angularVelocityX = perAxis;
191
+ body.angularVelocityY = perAxis;
192
+ body.angularVelocityZ = perAxis;
193
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
194
+
195
+ expect(body.sleeping).toBe(false);
196
+ });
197
+
198
+ it('treats an applied force as motion even before it becomes velocity', () => {
199
+ // A sleeper skips the integration that would turn force into velocity, and the step clears forces
200
+ // at the end — so a body under continuous load would silently swallow every push.
201
+ const world = createPhysics3DWorld();
202
+ const body = createBox(world);
203
+ body.forceY = 100;
204
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
205
+
206
+ expect(body.sleeping).toBe(false);
207
+ });
208
+
209
+ it('treats an applied torque as motion', () => {
210
+ const world = createPhysics3DWorld();
211
+ const body = createBox(world);
212
+ body.torqueZ = 100;
213
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
214
+
215
+ expect(body.sleeping).toBe(false);
216
+ });
217
+
218
+ it('keeps a whole island awake while any one member still moves', () => {
219
+ // The failure this guards: a settled crate sleeping while the crate it rests on slides out.
220
+ const world = createPhysics3DWorld();
221
+ const settled = createBox(world);
222
+ const moving = createBox(world);
223
+ moving.velocityX = 10;
224
+ world.contacts.push(createContact(settled.index, moving.index));
225
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
226
+
227
+ expect(settled.sleeping).toBe(false);
228
+ expect(moving.sleeping).toBe(false);
229
+ });
230
+
231
+ it('sleeps an island only once every member has settled', () => {
232
+ const world = createPhysics3DWorld();
233
+ const a = createBox(world);
234
+ const b = createBox(world);
235
+ b.velocityX = 10;
236
+ world.contacts.push(createContact(a.index, b.index));
237
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
238
+ expect(a.sleeping).toBe(false);
239
+
240
+ b.velocityX = 0;
241
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
242
+ expect(a.sleeping).toBe(true);
243
+ expect(b.sleeping).toBe(true);
244
+ });
245
+
246
+ it('keeps a rider awake on a moving kinematic platform', () => {
247
+ // Kinematic bodies deliberately join islands. Excluding them the way static bodies are excluded
248
+ // would let this crate sleep while the lift is still travelling.
249
+ const world = createPhysics3DWorld();
250
+ const crate = createBox(world);
251
+ const lift = createBox(world);
252
+ setPhysics3DBodyType(lift, 'kinematic');
253
+ lift.velocityY = 5;
254
+ world.contacts.push(createContact(crate.index, lift.index));
255
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
256
+
257
+ expect(crate.sleeping).toBe(false);
258
+ });
259
+
260
+ it('wakes everything when sleeping is switched off', () => {
261
+ const world = createPhysics3DWorld();
262
+ const body = createBox(world);
263
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
264
+ expect(body.sleeping).toBe(true);
265
+
266
+ world.config.allowSleeping = false;
267
+ updatePhysics3DSleep(world, 0);
268
+ expect(body.sleeping).toBe(false);
269
+ expect(body.sleepTimer).toBe(0);
270
+ });
271
+
272
+ it('builds the island graph even when sleeping is switched off', () => {
273
+ // Returning early without the union-find would split every awake body into a singleton, and a
274
+ // two-body constraint would then be assigned to only one of those artificial components.
275
+ const world = createPhysics3DWorld();
276
+ const a = createBox(world);
277
+ const b = createBox(world);
278
+ world.contacts.push(createContact(a.index, b.index));
279
+ world.config.allowSleeping = false;
280
+ updatePhysics3DSleep(world, 0);
281
+ buildPhysics3DSolveIslands(world);
282
+
283
+ expect(world.solveIslandRoots).toHaveLength(1);
284
+ expect(world.solveIslandBodyCounts).toEqual([2]);
285
+ });
286
+
287
+ it('never sleeps a body whose sleepEnabled is false', () => {
288
+ const world = createPhysics3DWorld();
289
+ const body = createBox(world);
290
+ body.sleepEnabled = false;
291
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
292
+
293
+ expect(body.sleeping).toBe(false);
294
+ });
295
+
296
+ it('makes a woken island earn its rest from zero', () => {
297
+ const world = createPhysics3DWorld();
298
+ const body = createBox(world);
299
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
300
+ expect(body.sleeping).toBe(true);
301
+
302
+ body.velocityX = 10;
303
+ updatePhysics3DSleep(world, 0.001);
304
+ expect(body.sleeping).toBe(false);
305
+ expect(body.sleepTimer).toBe(0);
306
+ });
307
+
308
+ it('leaves static bodies neither awake nor asleep', () => {
309
+ const world = createPhysics3DWorld();
310
+ const ground = createBox(world);
311
+ setPhysics3DBodyType(ground, 'static');
312
+ updatePhysics3DSleep(world, world.config.timeToSleep + 1);
313
+
314
+ expect(ground.sleeping).toBe(false);
315
+ });
316
+ });
317
+
318
+ function createBox(world: Physics3DWorld): RigidBody3D {
319
+ const body = createRigidBody3D();
320
+ const mass = createPhysics3DMassData();
321
+ computePhysics3DBoxMassData(0.5, 0.5, 0.5, 1, mass);
322
+ setRigidBody3DMassData(body, mass);
323
+ refreshRigidBody3DWorldInertia(body);
324
+ addPhysics3DBody(world, body);
325
+ return body;
326
+ }
327
+
328
+ function createContact(bodyA: number, bodyB: number): Physics3DContact {
329
+ return {
330
+ bodyA,
331
+ bodyB,
332
+ enabled: true,
333
+ friction: 0,
334
+ normalX: 0,
335
+ normalY: 1,
336
+ normalZ: 0,
337
+ pointCount: 1,
338
+ points: [
339
+ {
340
+ depth: 0.01,
341
+ featureId: 1,
342
+ rAX: 0,
343
+ rAY: -0.5,
344
+ rAZ: 0,
345
+ rBX: 0,
346
+ rBY: 0.5,
347
+ rBZ: 0,
348
+ x: 0,
349
+ y: 0,
350
+ z: 0,
351
+ },
352
+ ],
353
+ restitution: 0,
354
+ sensor: false,
355
+ touching: true,
356
+ };
357
+ }
@@ -0,0 +1,94 @@
1
+ import type { Physics3DWorld } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ isPhysics3DPairJointSuppressed,
6
+ rebuildPhysics3DJointCollisionSuppressions,
7
+ } from './jointCollisionSuppression';
8
+ import { createPhysics3DBallAndSocketJoint } from './jointFactories';
9
+ import { addPhysics3DJoint, registerPhysics3DJointSolver, removePhysics3DJoint } from './jointRegistry';
10
+ import { physics3DBallAndSocketJointSolver, Physics3DBallAndSocketJointKind } from './joints';
11
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D } from './world';
12
+
13
+ describe('isPhysics3DPairJointSuppressed', () => {
14
+ it('reports a pair held by a joint that does not collide', () => {
15
+ const world = createTestWorld();
16
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
17
+
18
+ expect(isPhysics3DPairJointSuppressed(world, 0, 1)).toBe(true);
19
+ });
20
+
21
+ it('answers the same whichever way round the pair is given', () => {
22
+ const world = createTestWorld();
23
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
24
+
25
+ expect(isPhysics3DPairJointSuppressed(world, 1, 0)).toBe(true);
26
+ });
27
+
28
+ it('leaves a pair alone when the joint opts into colliding', () => {
29
+ const world = createTestWorld();
30
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1, collideConnected: true }));
31
+
32
+ expect(isPhysics3DPairJointSuppressed(world, 0, 1)).toBe(false);
33
+ });
34
+
35
+ it('leaves a pair alone while the joint kind has no solver', () => {
36
+ const world = createPhysics3DWorld();
37
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
38
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
39
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
40
+
41
+ // An unregistered joint holds nothing together, so hiding the pair's contact would hide a real collision.
42
+ expect(isPhysics3DPairJointSuppressed(world, 0, 1)).toBe(false);
43
+ });
44
+
45
+ it('reports false for an unrelated pair', () => {
46
+ const world = createTestWorld();
47
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
48
+
49
+ expect(isPhysics3DPairJointSuppressed(world, 0, 2)).toBe(false);
50
+ });
51
+
52
+ it('keeps a pair suppressed while a second joint still holds it', () => {
53
+ const world = createTestWorld();
54
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
55
+ const second = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
56
+
57
+ removePhysics3DJoint(world, second);
58
+
59
+ // The index counts rather than flags, so removing one of two suppressing joints cannot re-enable the pair.
60
+ expect(isPhysics3DPairJointSuppressed(world, 0, 1)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe('rebuildPhysics3DJointCollisionSuppressions', () => {
65
+ it('picks up an endpoint edited in place', () => {
66
+ const world = createTestWorld();
67
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
68
+ joint.bodyB = 2;
69
+
70
+ rebuildPhysics3DJointCollisionSuppressions(world);
71
+
72
+ expect(isPhysics3DPairJointSuppressed(world, 0, 1)).toBe(false);
73
+ expect(isPhysics3DPairJointSuppressed(world, 0, 2)).toBe(true);
74
+ });
75
+
76
+ it('drops every suppression when the joint list empties', () => {
77
+ const world = createTestWorld();
78
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
79
+ world.joints.splice(world.joints.indexOf(joint), 1);
80
+
81
+ rebuildPhysics3DJointCollisionSuppressions(world);
82
+
83
+ expect(isPhysics3DPairJointSuppressed(world, 0, 1)).toBe(false);
84
+ });
85
+ });
86
+
87
+ function createTestWorld(): Physics3DWorld {
88
+ const world = createPhysics3DWorld();
89
+ registerPhysics3DJointSolver(world, Physics3DBallAndSocketJointKind, physics3DBallAndSocketJointSolver);
90
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
91
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
92
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
93
+ return world;
94
+ }
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ createPhysics3DBallAndSocketJoint,
5
+ createPhysics3DConeTwistJoint,
6
+ createPhysics3DFixedJoint,
7
+ createPhysics3DGeneric6DofJoint,
8
+ createPhysics3DHingeJoint,
9
+ createPhysics3DSliderJoint,
10
+ } from './jointFactories';
11
+ import {
12
+ Physics3DBallAndSocketJointKind,
13
+ Physics3DConeTwistJointKind,
14
+ Physics3DFixedJointKind,
15
+ Physics3DGeneric6DofJointKind,
16
+ Physics3DHingeJointKind,
17
+ Physics3DSliderJointKind,
18
+ } from './joints';
19
+
20
+ describe('createPhysics3DBallAndSocketJoint', () => {
21
+ it('stamps its kind and zeroes every accumulator', () => {
22
+ const joint = createPhysics3DBallAndSocketJoint({ bodyA: 3, bodyB: 7 });
23
+
24
+ expect(joint.kind).toBe(Physics3DBallAndSocketJointKind);
25
+ expect(joint.bodyA).toBe(3);
26
+ expect(joint.bodyB).toBe(7);
27
+ expect([joint.impulse0, joint.impulse1, joint.impulse2, joint.impulse3, joint.impulse4, joint.impulse5]).toEqual([
28
+ 0, 0, 0, 0, 0, 0,
29
+ ]);
30
+ });
31
+
32
+ it('defaults both anchors to the body origin and the pair to non-colliding', () => {
33
+ const joint = createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 });
34
+
35
+ expect([joint.localAnchorAX, joint.localAnchorAY, joint.localAnchorAZ]).toEqual([0, 0, 0]);
36
+ expect(joint.collideConnected).toBe(false);
37
+ });
38
+
39
+ it('carries the anchors it was given', () => {
40
+ const joint = createPhysics3DBallAndSocketJoint({
41
+ bodyA: 0,
42
+ bodyB: 1,
43
+ localAnchorAX: 1,
44
+ localAnchorAY: 2,
45
+ localAnchorAZ: 3,
46
+ localAnchorBZ: -4,
47
+ collideConnected: true,
48
+ });
49
+
50
+ expect([joint.localAnchorAX, joint.localAnchorAY, joint.localAnchorAZ]).toEqual([1, 2, 3]);
51
+ expect(joint.localAnchorBZ).toBe(-4);
52
+ expect(joint.collideConnected).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('createPhysics3DConeTwistJoint', () => {
57
+ it('defaults to a 45 degree cone with the twist unbounded', () => {
58
+ const joint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1 });
59
+
60
+ expect(joint.kind).toBe(Physics3DConeTwistJointKind);
61
+ expect(joint.enableSwingLimit).toBe(true);
62
+ expect(joint.swingLimitY).toBeCloseTo(Math.PI / 4, 12);
63
+ expect(joint.swingLimitZ).toBeCloseTo(Math.PI / 4, 12);
64
+ expect(joint.enableTwistLimit).toBe(false);
65
+ });
66
+
67
+ it('carries an elliptical cone and a twist interval', () => {
68
+ const joint = createPhysics3DConeTwistJoint({
69
+ bodyA: 0,
70
+ bodyB: 1,
71
+ swingLimitY: 0.3,
72
+ swingLimitZ: 1.1,
73
+ enableTwistLimit: true,
74
+ lowerTwistAngle: -0.2,
75
+ upperTwistAngle: 0.9,
76
+ });
77
+
78
+ expect(joint.swingLimitY).toBe(0.3);
79
+ expect(joint.swingLimitZ).toBe(1.1);
80
+ expect(joint.lowerTwistAngle).toBe(-0.2);
81
+ expect(joint.upperTwistAngle).toBe(0.9);
82
+ });
83
+ });
84
+
85
+ describe('createPhysics3DFixedJoint', () => {
86
+ it('defaults both frames to the identity', () => {
87
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
88
+
89
+ expect(joint.kind).toBe(Physics3DFixedJointKind);
90
+ expect([joint.localRotationAX, joint.localRotationAY, joint.localRotationAZ, joint.localRotationAW]).toEqual([
91
+ 0, 0, 0, 1,
92
+ ]);
93
+ expect([joint.localRotationBX, joint.localRotationBY, joint.localRotationBZ, joint.localRotationBW]).toEqual([
94
+ 0, 0, 0, 1,
95
+ ]);
96
+ });
97
+
98
+ it('carries the frames it was given', () => {
99
+ const half = Math.SQRT1_2;
100
+ const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localRotationAZ: half, localRotationAW: half });
101
+
102
+ expect(joint.localRotationAZ).toBeCloseTo(half, 12);
103
+ expect(joint.localRotationAW).toBeCloseTo(half, 12);
104
+ });
105
+ });
106
+
107
+ describe('createPhysics3DGeneric6DofJoint', () => {
108
+ it('defaults every axis to free', () => {
109
+ const joint = createPhysics3DGeneric6DofJoint({ bodyA: 0, bodyB: 1 });
110
+
111
+ expect(joint.kind).toBe(Physics3DGeneric6DofJointKind);
112
+ // `lower > upper` is the encoding for free, so a joint built with no bounds constrains nothing. Defaulting
113
+ // to locked would make a partially configured joint silently rigid, which is the harder failure to see.
114
+ expect(joint.lowerLinearX).toBeGreaterThan(joint.upperLinearX);
115
+ expect(joint.lowerLinearY).toBeGreaterThan(joint.upperLinearY);
116
+ expect(joint.lowerLinearZ).toBeGreaterThan(joint.upperLinearZ);
117
+ expect(joint.lowerAngularX).toBeGreaterThan(joint.upperAngularX);
118
+ expect(joint.lowerAngularY).toBeGreaterThan(joint.upperAngularY);
119
+ expect(joint.lowerAngularZ).toBeGreaterThan(joint.upperAngularZ);
120
+ });
121
+
122
+ it('carries the bounds it was given', () => {
123
+ const joint = createPhysics3DGeneric6DofJoint({
124
+ bodyA: 0,
125
+ bodyB: 1,
126
+ lowerLinearY: -2,
127
+ upperLinearY: 5,
128
+ lowerAngularZ: 0,
129
+ upperAngularZ: 0,
130
+ });
131
+
132
+ expect(joint.lowerLinearY).toBe(-2);
133
+ expect(joint.upperLinearY).toBe(5);
134
+ expect(joint.lowerAngularZ).toBe(0);
135
+ expect(joint.upperAngularZ).toBe(0);
136
+ });
137
+ });
138
+
139
+ describe('createPhysics3DHingeJoint', () => {
140
+ it('defaults to a free hinge with no motor', () => {
141
+ const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 });
142
+
143
+ expect(joint.kind).toBe(Physics3DHingeJointKind);
144
+ expect(joint.enableLimit).toBe(false);
145
+ expect(joint.enableMotor).toBe(false);
146
+ expect(joint.motorImpulse).toBe(0);
147
+ });
148
+
149
+ it('carries its limits and motor settings', () => {
150
+ const joint = createPhysics3DHingeJoint({
151
+ bodyA: 0,
152
+ bodyB: 1,
153
+ enableLimit: true,
154
+ lowerAngle: -1,
155
+ upperAngle: 2,
156
+ enableMotor: true,
157
+ motorSpeed: 3,
158
+ maxMotorTorque: 40,
159
+ });
160
+
161
+ expect(joint.lowerAngle).toBe(-1);
162
+ expect(joint.upperAngle).toBe(2);
163
+ expect(joint.motorSpeed).toBe(3);
164
+ expect(joint.maxMotorTorque).toBe(40);
165
+ });
166
+ });
167
+
168
+ describe('createPhysics3DSliderJoint', () => {
169
+ it('defaults to free travel with no motor', () => {
170
+ const joint = createPhysics3DSliderJoint({ bodyA: 0, bodyB: 1 });
171
+
172
+ expect(joint.kind).toBe(Physics3DSliderJointKind);
173
+ expect(joint.enableLimit).toBe(false);
174
+ expect(joint.enableMotor).toBe(false);
175
+ expect(joint.motorImpulse).toBe(0);
176
+ });
177
+
178
+ it('carries its travel interval and motor settings', () => {
179
+ const joint = createPhysics3DSliderJoint({
180
+ bodyA: 0,
181
+ bodyB: 1,
182
+ enableLimit: true,
183
+ lowerTranslation: -3,
184
+ upperTranslation: 3,
185
+ enableMotor: true,
186
+ motorSpeed: 2,
187
+ maxMotorForce: 15,
188
+ });
189
+
190
+ expect(joint.lowerTranslation).toBe(-3);
191
+ expect(joint.upperTranslation).toBe(3);
192
+ expect(joint.motorSpeed).toBe(2);
193
+ expect(joint.maxMotorForce).toBe(15);
194
+ });
195
+ });