@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,390 @@
1
+ import type { Physics3DContact, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createPhysics3DContact, createPhysics3DContactPoint } from './contacts';
5
+ import { refreshRigidBody3DWorldInertia } from './integrate';
6
+ import { createPhysics3DFixedJoint, createPhysics3DHingeJoint } from './jointFactories';
7
+ import { addPhysics3DJoint, removePhysics3DJoint } from './jointRegistry';
8
+ import { createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
9
+ import { registerBuiltInPhysics3DJointSolvers } from './registerBuiltInPhysics3DJointSolvers';
10
+ import { setPhysics3DStepGuard, stepPhysics3D, stepPhysics3DInterval } from './step';
11
+ import {
12
+ addPhysics3DBody,
13
+ applyPhysics3DForce,
14
+ createPhysics3DWorld,
15
+ createRigidBody3D,
16
+ removePhysics3DBody,
17
+ setPhysics3DBodyType,
18
+ } from './world';
19
+
20
+ describe('setPhysics3DStepGuard', () => {
21
+ it('is consulted only when the step declines, and only while installed', () => {
22
+ const seen: number[] = [];
23
+ setPhysics3DStepGuard((_world, dt) => seen.push(dt));
24
+ try {
25
+ const world = createPhysics3DWorld();
26
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
27
+
28
+ // A step that runs must not reach the seam: the guard's message means the simulation stopped, so
29
+ // firing it on a healthy step would make the one signal it carries meaningless.
30
+ stepPhysics3D(world, 1 / 60);
31
+ expect(seen).toEqual([]);
32
+
33
+ stepPhysics3D(world, 0);
34
+ expect(seen).toEqual([0]);
35
+
36
+ setPhysics3DStepGuard(null);
37
+ stepPhysics3D(world, 0);
38
+ expect(seen).toEqual([0]);
39
+ } finally {
40
+ setPhysics3DStepGuard(null);
41
+ }
42
+ });
43
+ });
44
+
45
+ describe('stepPhysics3D', () => {
46
+ it('advances a free body by gravity over the interval', () => {
47
+ const world = createTestWorld();
48
+ const body = addUnitBody(world);
49
+
50
+ stepPhysics3D(world, 1 / 60);
51
+
52
+ expect(body.velocityY).toBeCloseTo(world.gravityY / 60, 12);
53
+ expect(body.y).toBeCloseTo((world.gravityY / 60) * (1 / 60), 12);
54
+ });
55
+
56
+ it('refuses to run inside itself', () => {
57
+ const world = createTestWorld();
58
+ const ground = addUnitBody(world);
59
+ addRestingContact(world, addUnitBody(world), ground);
60
+ world.contactHooks.preSolve = (inner) => stepPhysics3D(inner, 1 / 60);
61
+
62
+ expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/recursively/);
63
+ });
64
+
65
+ it('declines silently rather than throwing on an unusable timestep', () => {
66
+ const world = createTestWorld();
67
+ const body = addUnitBody(world);
68
+
69
+ expect(() => stepPhysics3D(world, 0)).not.toThrow();
70
+ expect(() => stepPhysics3D(world, Number.NaN)).not.toThrow();
71
+ expect(body.velocityY).toBe(0);
72
+ expect(body.y).toBe(0);
73
+ });
74
+
75
+ it('declines on a body carrying a non-finite field', () => {
76
+ const world = createTestWorld();
77
+ const body = addUnitBody(world);
78
+ const other = addUnitBody(world);
79
+ other.velocityX = Number.POSITIVE_INFINITY;
80
+
81
+ stepPhysics3D(world, 1 / 60);
82
+
83
+ // One invalid body stops the whole step, because a NaN reaches every constraint its body touches.
84
+ expect(body.velocityY).toBe(0);
85
+ });
86
+
87
+ it('declines when substeps would advance nothing', () => {
88
+ const world = createTestWorld();
89
+ const body = addUnitBody(world);
90
+ world.config.substeps = 0;
91
+
92
+ stepPhysics3D(world, 1 / 60);
93
+
94
+ expect(body.velocityY).toBe(0);
95
+ });
96
+
97
+ it('clears the force accumulators so a force applied once acts once', () => {
98
+ const world = createTestWorld();
99
+ world.gravityY = 0;
100
+ const body = addUnitBody(world);
101
+ applyPhysics3DForce(body, 10, 0, 0);
102
+
103
+ stepPhysics3D(world, 1 / 60);
104
+ const afterOne = body.velocityX;
105
+ stepPhysics3D(world, 1 / 60);
106
+
107
+ expect(afterOne).toBeCloseTo(10 / 60, 12);
108
+ expect(body.velocityX).toBeCloseTo(afterOne, 12);
109
+ });
110
+
111
+ it('records the SUB-interval as the previous timestep, not the whole step', () => {
112
+ const world = createTestWorld();
113
+ addUnitBody(world);
114
+ world.config.substeps = 4;
115
+
116
+ stepPhysics3D(world, 1 / 60);
117
+
118
+ // The warm-start cache is built by a sub-interval's iterations, so scaling it next step has to compare
119
+ // against that interval. Recording the whole step would rescale by the substep count.
120
+ expect(world.previousTimestep).toBeCloseTo(1 / 240, 12);
121
+ });
122
+
123
+ it('integrates more accurately with more substeps over the same interval', () => {
124
+ const coarse = createTestWorld();
125
+ const coarseBody = addUnitBody(coarse);
126
+ stepPhysics3D(coarse, 1 / 60);
127
+
128
+ const fine = createTestWorld();
129
+ fine.config.substeps = 8;
130
+ const fineBody = addUnitBody(fine);
131
+ stepPhysics3D(fine, 1 / 60);
132
+
133
+ // Both reach the same velocity — constant acceleration over the same total time — but the finer
134
+ // division tracks the exact 0.5*g*t^2 displacement more closely.
135
+ const exact = 0.5 * fine.gravityY * (1 / 60) * (1 / 60);
136
+ expect(fineBody.velocityY).toBeCloseTo(coarseBody.velocityY, 12);
137
+ expect(Math.abs(fineBody.y - exact)).toBeLessThan(Math.abs(coarseBody.y - exact));
138
+ });
139
+
140
+ it('resolves a supplied contact so a falling body does not pass through the ground', () => {
141
+ const world = createTestWorld();
142
+ const ground = addUnitBody(world);
143
+ setPhysics3DBodyType(ground, 'static');
144
+ refreshRigidBody3DWorldInertia(ground);
145
+ const box = addUnitBody(world);
146
+ box.velocityY = -10;
147
+ addRestingContact(world, box, ground);
148
+
149
+ stepPhysics3D(world, 1 / 60);
150
+
151
+ expect(box.velocityY).toBeGreaterThan(-1e-6);
152
+ });
153
+
154
+ it('holds two bodies together through a joint across many steps', () => {
155
+ const world = createTestWorld();
156
+ registerBuiltInPhysics3DJointSolvers(world);
157
+ const anchor = addUnitBody(world);
158
+ setPhysics3DBodyType(anchor, 'static');
159
+ refreshRigidBody3DWorldInertia(anchor);
160
+ const hanging = addUnitBody(world);
161
+ hanging.x = 1;
162
+ addPhysics3DJoint(
163
+ world,
164
+ createPhysics3DFixedJoint({ bodyA: anchor.index, bodyB: hanging.index, localAnchorBX: -1 }),
165
+ );
166
+
167
+ for (let i = 0; i < 120; i += 1) stepPhysics3D(world, 1 / 60);
168
+
169
+ // Gravity pulls for two seconds; without the joint the body would be roughly 20 units down.
170
+ expect(Math.abs(hanging.y)).toBeLessThan(0.05);
171
+ expect(hanging.x).toBeCloseTo(1, 1);
172
+ });
173
+
174
+ it('lets a hinge motor drive a body it is anchored to', () => {
175
+ const world = createTestWorld();
176
+ world.gravityY = 0;
177
+ registerBuiltInPhysics3DJointSolvers(world);
178
+ const anchor = addUnitBody(world);
179
+ setPhysics3DBodyType(anchor, 'static');
180
+ refreshRigidBody3DWorldInertia(anchor);
181
+ const arm = addUnitBody(world);
182
+ addPhysics3DJoint(
183
+ world,
184
+ createPhysics3DHingeJoint({
185
+ bodyA: anchor.index,
186
+ bodyB: arm.index,
187
+ enableMotor: true,
188
+ motorSpeed: 2,
189
+ maxMotorTorque: 1000,
190
+ }),
191
+ );
192
+
193
+ for (let i = 0; i < 30; i += 1) stepPhysics3D(world, 1 / 60);
194
+
195
+ expect(arm.angularVelocityX).toBeCloseTo(2, 4);
196
+ });
197
+
198
+ it('stops integrating a settled island, even under gravity', () => {
199
+ const world = createTestWorld();
200
+ world.gravityY = 0;
201
+ const body = addUnitBody(world);
202
+ for (let i = 0; i < 60; i += 1) stepPhysics3D(world, 1 / 60);
203
+ expect(body.sleeping).toBe(true);
204
+
205
+ const restingY = body.y;
206
+ world.gravityY = -10;
207
+ stepPhysics3D(world, 1 / 60);
208
+
209
+ // The whole point of the sleeping skip: a settled pile costs no integration, so gravity cannot
210
+ // accelerate it and it does not drift.
211
+ expect(body.y).toBe(restingY);
212
+ expect(body.velocityY).toBe(0);
213
+ expect(body.sleeping).toBe(true);
214
+ });
215
+
216
+ it('wakes a sleeping body the caller writes a velocity onto', () => {
217
+ const world = createTestWorld();
218
+ world.gravityY = 0;
219
+ const body = addUnitBody(world);
220
+ for (let i = 0; i < 60; i += 1) stepPhysics3D(world, 1 / 60);
221
+ expect(body.sleeping).toBe(true);
222
+
223
+ body.velocityY = 5;
224
+ stepPhysics3D(world, 1 / 60);
225
+
226
+ // Stillness is re-read from the CURRENT velocities at the top of every step, so writing one is enough
227
+ // to wake the island — a caller does not have to remember `wakePhysics3DBody` as well.
228
+ expect(body.sleeping).toBe(false);
229
+ expect(body.y).toBeCloseTo(5 / 60, 12);
230
+ });
231
+
232
+ it('lets a pre-solve hook disable a contact for the step', () => {
233
+ const world = createTestWorld();
234
+ const ground = addUnitBody(world);
235
+ setPhysics3DBodyType(ground, 'static');
236
+ refreshRigidBody3DWorldInertia(ground);
237
+ const box = addUnitBody(world);
238
+ box.velocityY = -10;
239
+ addRestingContact(world, box, ground);
240
+ world.contactHooks.preSolve = (_inner, contact) => {
241
+ contact.enabled = false;
242
+ };
243
+
244
+ stepPhysics3D(world, 1 / 60);
245
+
246
+ expect(box.velocityY).toBeLessThan(-9);
247
+ });
248
+
249
+ it('runs the post-solve hook once the step has committed', () => {
250
+ const world = createTestWorld();
251
+ const ground = addUnitBody(world);
252
+ setPhysics3DBodyType(ground, 'static');
253
+ refreshRigidBody3DWorldInertia(ground);
254
+ const box = addUnitBody(world);
255
+ box.velocityY = -10;
256
+ addRestingContact(world, box, ground);
257
+ let observed = Number.NaN;
258
+ world.contactHooks.postSolve = () => {
259
+ observed = box.velocityY;
260
+ };
261
+
262
+ stepPhysics3D(world, 1 / 60);
263
+
264
+ expect(observed).toBeCloseTo(box.velocityY, 12);
265
+ });
266
+
267
+ it('rejects a lifecycle mutation attempted from inside a hook', () => {
268
+ const world = createTestWorld();
269
+ registerBuiltInPhysics3DJointSolvers(world);
270
+ const ground = addUnitBody(world);
271
+ const box = addUnitBody(world);
272
+ addRestingContact(world, box, ground);
273
+ const joint = addPhysics3DJoint(world, createPhysics3DFixedJoint({ bodyA: ground.index, bodyB: box.index }));
274
+ world.contactHooks.preSolve = (inner) => removePhysics3DJoint(inner, joint);
275
+
276
+ expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/stepping/);
277
+ });
278
+
279
+ it('restores the fields a throwing hook touched before the failure propagates', () => {
280
+ const world = createTestWorld();
281
+ const ground = addUnitBody(world);
282
+ const box = addUnitBody(world);
283
+ const contact = addRestingContact(world, box, ground);
284
+ contact.friction = 0.5;
285
+ world.contactHooks.preSolve = (_inner, edited) => {
286
+ edited.friction = 9;
287
+ throw new Error('hook failed');
288
+ };
289
+
290
+ expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/hook failed/);
291
+ expect(contact.friction).toBe(0.5);
292
+ expect(ground.index).toBe(0);
293
+ });
294
+
295
+ it('rejects a hook that leaves a contact the step cannot use', () => {
296
+ const world = createTestWorld();
297
+ const ground = addUnitBody(world);
298
+ const contact = addRestingContact(world, addUnitBody(world), ground);
299
+ world.contactHooks.preSolve = (_inner, edited) => {
300
+ edited.friction = Number.NaN;
301
+ };
302
+
303
+ expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/invalid contact state/);
304
+ expect(contact.friction).toBe(0);
305
+ });
306
+
307
+ it('clears the stepping flag after a hook throws, so the next step still runs', () => {
308
+ const world = createTestWorld();
309
+ const body = addUnitBody(world);
310
+ addRestingContact(world, addUnitBody(world), body);
311
+ world.contactHooks.preSolve = () => {
312
+ throw new Error('hook failed');
313
+ };
314
+
315
+ expect(() => stepPhysics3D(world, 1 / 60)).toThrow();
316
+ world.contactHooks.preSolve = null;
317
+
318
+ expect(() => stepPhysics3D(world, 1 / 60)).not.toThrow();
319
+ expect(body.velocityY).toBeLessThan(0);
320
+ });
321
+
322
+ it('survives a body removed while a joint still names it', () => {
323
+ const world = createTestWorld();
324
+ registerBuiltInPhysics3DJointSolvers(world);
325
+ const anchor = addUnitBody(world);
326
+ const hanging = addUnitBody(world);
327
+ addPhysics3DJoint(world, createPhysics3DFixedJoint({ bodyA: anchor.index, bodyB: hanging.index }));
328
+ removePhysics3DBody(world, hanging);
329
+
330
+ expect(() => stepPhysics3D(world, 1 / 60)).not.toThrow();
331
+ expect(Number.isFinite(anchor.y)).toBe(true);
332
+ });
333
+ });
334
+
335
+ describe('stepPhysics3DInterval', () => {
336
+ it('advances one sub-interval without touching the force accumulators', () => {
337
+ const world = createTestWorld();
338
+ const body = addUnitBody(world);
339
+ applyPhysics3DForce(body, 6, 0, 0);
340
+ // The island workspace is built by the step; a caller assembling its own loop builds it too.
341
+ stepPhysics3D(world, 1 / 60);
342
+ applyPhysics3DForce(body, 6, 0, 0);
343
+ const before = body.velocityX;
344
+
345
+ stepPhysics3DInterval(world, 1 / 60);
346
+
347
+ // The force is still there afterwards, because clearing it belongs to the step and not to the
348
+ // interval — a substep loop that cleared forces would apply them once and then integrate zeros.
349
+ expect(body.velocityX).toBeCloseTo(before + 6 / 60, 12);
350
+ expect(body.forceX).toBe(6);
351
+ });
352
+ });
353
+
354
+ // A resting contact between `box` and `ground`, with the normal oriented from the geometry rather than
355
+ // assumed.
356
+ //
357
+ // The normal points so that resolving pushes A out of B — and A is whichever body holds the LOWER INDEX,
358
+ // which the caller does not choose. Writing `normalY = 1` because "the ground is below" is right only when
359
+ // the box happens to have been added first; the other way round it pushes the GROUND up out of the box,
360
+ // which for a static ground is a no-op and reads as a solver that silently ignores the contact.
361
+ function addRestingContact(world: Physics3DWorld, box: RigidBody3D, ground: RigidBody3D): Physics3DContact {
362
+ const contact = createPhysics3DContact(box.index, ground.index);
363
+ contact.normalY = contact.bodyA === box.index ? 1 : -1;
364
+ contact.touching = true;
365
+ const point = createPhysics3DContactPoint();
366
+ point.depth = 0.01;
367
+ point.featureId = 1;
368
+ contact.points.push(point);
369
+ contact.pointCount = 1;
370
+ world.contacts.push(contact);
371
+ return contact;
372
+ }
373
+
374
+ function addUnitBody(world: Physics3DWorld): RigidBody3D {
375
+ const body = createRigidBody3D('dynamic');
376
+ const data = createPhysics3DMassData();
377
+ data.mass = 1;
378
+ data.inertiaXX = 1;
379
+ data.inertiaYY = 1;
380
+ data.inertiaZZ = 1;
381
+ setRigidBody3DMassData(body, data);
382
+ addPhysics3DBody(world, body);
383
+ return body;
384
+ }
385
+
386
+ function createTestWorld(): Physics3DWorld {
387
+ const world = createPhysics3DWorld();
388
+ world.gravityY = -10;
389
+ return world;
390
+ }
@@ -0,0 +1,244 @@
1
+ import type { Physics3DWorld } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createPhysics3DContact, createPhysics3DContactPoint } from './contacts';
5
+ import { createPhysics3DBallAndSocketJoint } from './jointFactories';
6
+ import { addPhysics3DJoint } from './jointRegistry';
7
+ import {
8
+ isPhysics3DBodyStateValid,
9
+ isPhysics3DContactStateValid,
10
+ isPhysics3DContactValid,
11
+ isPhysics3DGravityValid,
12
+ isPhysics3DJointStateValid,
13
+ isPhysics3DPositionIterationsValid,
14
+ isPhysics3DSolverConfigValid,
15
+ isPhysics3DSubstepsValid,
16
+ isPhysics3DTimestepValid,
17
+ isPhysics3DVelocityIterationsValid,
18
+ } from './stepValidation';
19
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D } from './world';
20
+
21
+ describe('isPhysics3DBodyStateValid', () => {
22
+ it('accepts a freshly built world', () => {
23
+ expect(isPhysics3DBodyStateValid(createTestWorld())).toBe(true);
24
+ });
25
+
26
+ it('rejects a non-finite pose, velocity, or accumulator', () => {
27
+ for (const key of ['z', 'orientationW', 'angularVelocityY', 'torqueZ', 'centerX'] as const) {
28
+ const world = createTestWorld();
29
+ world.bodies[0][key] = Number.NaN;
30
+ expect(isPhysics3DBodyStateValid(world)).toBe(false);
31
+ }
32
+ });
33
+
34
+ it('rejects a non-finite WORLD inertia tensor', () => {
35
+ const world = createTestWorld();
36
+ world.bodies[0].inverseInertiaWorldXY = Number.POSITIVE_INFINITY;
37
+
38
+ // Derived, and included anyway: it is what every constraint row multiplies by, so a NaN there reaches
39
+ // both bodies of every contact and joint within one sub-interval.
40
+ expect(isPhysics3DBodyStateValid(world)).toBe(false);
41
+ });
42
+
43
+ it('rejects a body the index map has lost track of', () => {
44
+ const world = createTestWorld();
45
+ world.bodyByIndex.delete(world.bodies[0].index);
46
+
47
+ expect(isPhysics3DBodyStateValid(world)).toBe(false);
48
+ });
49
+
50
+ it('rejects an unknown body type and a negative mass', () => {
51
+ const type = createTestWorld();
52
+ (type.bodies[0] as { type: string }).type = 'ghost';
53
+ expect(isPhysics3DBodyStateValid(type)).toBe(false);
54
+
55
+ const mass = createTestWorld();
56
+ mass.bodies[0].mass = -1;
57
+ expect(isPhysics3DBodyStateValid(mass)).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe('isPhysics3DContactStateValid', () => {
62
+ it('accepts a world with no contacts', () => {
63
+ expect(isPhysics3DContactStateValid(createTestWorld())).toBe(true);
64
+ });
65
+
66
+ it('rejects the world when any one contact is unusable', () => {
67
+ const world = createTestWorld();
68
+ world.contacts.push(createPhysics3DContact(0, 1));
69
+ world.contacts.push(createPhysics3DContact(0, 1));
70
+ world.contacts[1].friction = Number.NaN;
71
+
72
+ expect(isPhysics3DContactStateValid(world)).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe('isPhysics3DContactValid', () => {
77
+ it('accepts a freshly allocated contact', () => {
78
+ expect(isPhysics3DContactValid(createPhysics3DContact(0, 1))).toBe(true);
79
+ });
80
+
81
+ it('rejects a point count that runs past the points it has', () => {
82
+ const contact = createPhysics3DContact(0, 1);
83
+ contact.pointCount = 2;
84
+
85
+ expect(isPhysics3DContactValid(contact)).toBe(false);
86
+ });
87
+
88
+ it('rejects a negative friction or restitution', () => {
89
+ const friction = createPhysics3DContact(0, 1);
90
+ friction.friction = -0.1;
91
+ expect(isPhysics3DContactValid(friction)).toBe(false);
92
+
93
+ const restitution = createPhysics3DContact(0, 1);
94
+ restitution.restitution = -1;
95
+ expect(isPhysics3DContactValid(restitution)).toBe(false);
96
+ });
97
+
98
+ it('rejects a non-finite field on a point it will actually read', () => {
99
+ const contact = createPhysics3DContact(0, 1);
100
+ const point = createPhysics3DContactPoint();
101
+ point.rBZ = Number.NaN;
102
+ contact.points.push(point);
103
+ contact.pointCount = 1;
104
+
105
+ expect(isPhysics3DContactValid(contact)).toBe(false);
106
+ });
107
+
108
+ it('ignores a point beyond the count, which the solver never reads', () => {
109
+ const contact = createPhysics3DContact(0, 1);
110
+ const point = createPhysics3DContactPoint();
111
+ point.depth = Number.NaN;
112
+ contact.points.push(point);
113
+
114
+ expect(isPhysics3DContactValid(contact)).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe('isPhysics3DGravityValid', () => {
119
+ it('accepts finite gravity on every axis and rejects any non-finite one', () => {
120
+ expect(isPhysics3DGravityValid(createTestWorld())).toBe(true);
121
+
122
+ for (const axis of ['gravityX', 'gravityY', 'gravityZ'] as const) {
123
+ const world = createTestWorld();
124
+ world[axis] = Number.NaN;
125
+ expect(isPhysics3DGravityValid(world)).toBe(false);
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('isPhysics3DJointStateValid', () => {
131
+ it('accepts a world with no joints', () => {
132
+ expect(isPhysics3DJointStateValid(createTestWorld())).toBe(true);
133
+ });
134
+
135
+ it('rejects a joint with an empty kind or a non-integer endpoint', () => {
136
+ const kind = createTestWorld();
137
+ addPhysics3DJoint(kind, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 })).kind = '';
138
+ expect(isPhysics3DJointStateValid(kind)).toBe(false);
139
+
140
+ const endpoint = createTestWorld();
141
+ addPhysics3DJoint(endpoint, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 })).bodyB = 1.5;
142
+ expect(isPhysics3DJointStateValid(endpoint)).toBe(false);
143
+ });
144
+
145
+ it('rejects a non-finite field a kind added, not only the common ones', () => {
146
+ const world = createTestWorld();
147
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
148
+ (joint as unknown as Record<string, number>).acmeSuspensionTravel = Number.NaN;
149
+
150
+ // Walking the object rather than a fixed key list is what makes this cover a user's own joint kind,
151
+ // which is the point of an open registry.
152
+ expect(isPhysics3DJointStateValid(world)).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('isPhysics3DPositionIterationsValid', () => {
157
+ it('accepts zero and rejects a negative or fractional count', () => {
158
+ const world = createTestWorld();
159
+ world.config.sequentialImpulse.positionIterations = 0;
160
+ expect(isPhysics3DPositionIterationsValid(world.config)).toBe(true);
161
+
162
+ world.config.sequentialImpulse.positionIterations = -1;
163
+ expect(isPhysics3DPositionIterationsValid(world.config)).toBe(false);
164
+
165
+ world.config.sequentialImpulse.positionIterations = 2.5;
166
+ expect(isPhysics3DPositionIterationsValid(world.config)).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe('isPhysics3DSolverConfigValid', () => {
171
+ it('accepts the default tuning', () => {
172
+ expect(isPhysics3DSolverConfigValid(createTestWorld().config)).toBe(true);
173
+ });
174
+
175
+ it('rejects a position correction outside zero to one', () => {
176
+ const world = createTestWorld();
177
+ world.config.sequentialImpulse.positionCorrection = 1.5;
178
+
179
+ expect(isPhysics3DSolverConfigValid(world.config)).toBe(false);
180
+ });
181
+
182
+ it('rejects negative sleep thresholds and a negative slop', () => {
183
+ const threshold = createTestWorld();
184
+ threshold.config.sleepAngularThreshold = -1;
185
+ expect(isPhysics3DSolverConfigValid(threshold.config)).toBe(false);
186
+
187
+ const slop = createTestWorld();
188
+ slop.config.sequentialImpulse.penetrationSlop = -0.01;
189
+ expect(isPhysics3DSolverConfigValid(slop.config)).toBe(false);
190
+ });
191
+
192
+ it('does not speak for the iteration counts or substeps', () => {
193
+ const world = createTestWorld();
194
+ world.config.substeps = 0;
195
+ world.config.sequentialImpulse.velocityIterations = -4;
196
+
197
+ // Those three are asked separately, because they are the ones a caller is most likely to have tuned by
198
+ // hand and the explanation names each of them.
199
+ expect(isPhysics3DSolverConfigValid(world.config)).toBe(true);
200
+ });
201
+ });
202
+
203
+ describe('isPhysics3DSubstepsValid', () => {
204
+ it('requires at least one whole sub-interval', () => {
205
+ const world = createTestWorld();
206
+ expect(isPhysics3DSubstepsValid(world.config)).toBe(true);
207
+
208
+ for (const substeps of [0, -1, 1.5, Number.NaN]) {
209
+ world.config.substeps = substeps;
210
+ expect(isPhysics3DSubstepsValid(world.config)).toBe(false);
211
+ }
212
+ });
213
+ });
214
+
215
+ describe('isPhysics3DTimestepValid', () => {
216
+ it('requires a finite positive interval', () => {
217
+ expect(isPhysics3DTimestepValid(1 / 60)).toBe(true);
218
+ expect(isPhysics3DTimestepValid(0)).toBe(false);
219
+ expect(isPhysics3DTimestepValid(-1)).toBe(false);
220
+ expect(isPhysics3DTimestepValid(Number.NaN)).toBe(false);
221
+ expect(isPhysics3DTimestepValid(Number.POSITIVE_INFINITY)).toBe(false);
222
+ });
223
+ });
224
+
225
+ describe('isPhysics3DVelocityIterationsValid', () => {
226
+ it('accepts zero and rejects a negative or fractional count', () => {
227
+ const world = createTestWorld();
228
+ world.config.sequentialImpulse.velocityIterations = 0;
229
+ expect(isPhysics3DVelocityIterationsValid(world.config)).toBe(true);
230
+
231
+ world.config.sequentialImpulse.velocityIterations = -1;
232
+ expect(isPhysics3DVelocityIterationsValid(world.config)).toBe(false);
233
+
234
+ world.config.sequentialImpulse.velocityIterations = 0.5;
235
+ expect(isPhysics3DVelocityIterationsValid(world.config)).toBe(false);
236
+ });
237
+ });
238
+
239
+ function createTestWorld(): Physics3DWorld {
240
+ const world = createPhysics3DWorld();
241
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
242
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
243
+ return world;
244
+ }