@flighthq/physics3d 0.4.0-edge.1908.751021c

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 (180) hide show
  1. package/dist/broadphase.d.ts +4 -0
  2. package/dist/broadphase.d.ts.map +1 -0
  3. package/dist/broadphase.js +158 -0
  4. package/dist/broadphase.js.map +1 -0
  5. package/dist/colliderCollision.d.ts +5 -0
  6. package/dist/colliderCollision.d.ts.map +1 -0
  7. package/dist/colliderCollision.js +55 -0
  8. package/dist/colliderCollision.js.map +1 -0
  9. package/dist/colliderTransform.d.ts +5 -0
  10. package/dist/colliderTransform.d.ts.map +1 -0
  11. package/dist/colliderTransform.js +424 -0
  12. package/dist/colliderTransform.js.map +1 -0
  13. package/dist/contactIntake.d.ts +5 -0
  14. package/dist/contactIntake.d.ts.map +1 -0
  15. package/dist/contactIntake.js +237 -0
  16. package/dist/contactIntake.js.map +1 -0
  17. package/dist/contacts.d.ts +4 -0
  18. package/dist/contacts.d.ts.map +1 -0
  19. package/dist/contacts.js +64 -0
  20. package/dist/contacts.js.map +1 -0
  21. package/dist/continuous.d.ts +5 -0
  22. package/dist/continuous.d.ts.map +1 -0
  23. package/dist/continuous.js +829 -0
  24. package/dist/continuous.js.map +1 -0
  25. package/dist/contract.d.ts +28 -0
  26. package/dist/contract.d.ts.map +1 -0
  27. package/dist/contract.js +28 -0
  28. package/dist/contract.js.map +1 -0
  29. package/dist/debugGeometry.d.ts +4 -0
  30. package/dist/debugGeometry.d.ts.map +1 -0
  31. package/dist/debugGeometry.js +333 -0
  32. package/dist/debugGeometry.js.map +1 -0
  33. package/dist/enablePhysics3DGuards.d.ts +4 -0
  34. package/dist/enablePhysics3DGuards.d.ts.map +1 -0
  35. package/dist/enablePhysics3DGuards.js +155 -0
  36. package/dist/enablePhysics3DGuards.js.map +1 -0
  37. package/dist/explainPhysics3DCollision.d.ts +3 -0
  38. package/dist/explainPhysics3DCollision.d.ts.map +1 -0
  39. package/dist/explainPhysics3DCollision.js +30 -0
  40. package/dist/explainPhysics3DCollision.js.map +1 -0
  41. package/dist/explainPhysics3DJoints.d.ts +3 -0
  42. package/dist/explainPhysics3DJoints.d.ts.map +1 -0
  43. package/dist/explainPhysics3DJoints.js +32 -0
  44. package/dist/explainPhysics3DJoints.js.map +1 -0
  45. package/dist/explainPhysics3DStep.d.ts +3 -0
  46. package/dist/explainPhysics3DStep.d.ts.map +1 -0
  47. package/dist/explainPhysics3DStep.js +51 -0
  48. package/dist/explainPhysics3DStep.js.map +1 -0
  49. package/dist/index.d.ts +22 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +22 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/integrate.d.ts +6 -0
  54. package/dist/integrate.d.ts.map +1 -0
  55. package/dist/integrate.js +184 -0
  56. package/dist/integrate.js.map +1 -0
  57. package/dist/islands.d.ts +6 -0
  58. package/dist/islands.d.ts.map +1 -0
  59. package/dist/islands.js +333 -0
  60. package/dist/islands.js.map +1 -0
  61. package/dist/jointBreakage.d.ts +5 -0
  62. package/dist/jointBreakage.d.ts.map +1 -0
  63. package/dist/jointBreakage.js +44 -0
  64. package/dist/jointBreakage.js.map +1 -0
  65. package/dist/jointCollisionSuppression.d.ts +4 -0
  66. package/dist/jointCollisionSuppression.d.ts.map +1 -0
  67. package/dist/jointCollisionSuppression.js +40 -0
  68. package/dist/jointCollisionSuppression.js.map +1 -0
  69. package/dist/jointFactories.d.ts +9 -0
  70. package/dist/jointFactories.d.ts.map +1 -0
  71. package/dist/jointFactories.js +159 -0
  72. package/dist/jointFactories.js.map +1 -0
  73. package/dist/jointMath.d.ts +17 -0
  74. package/dist/jointMath.d.ts.map +1 -0
  75. package/dist/jointMath.js +361 -0
  76. package/dist/jointMath.js.map +1 -0
  77. package/dist/jointReaction.d.ts +8 -0
  78. package/dist/jointReaction.d.ts.map +1 -0
  79. package/dist/jointReaction.js +65 -0
  80. package/dist/jointReaction.js.map +1 -0
  81. package/dist/jointRegistry.d.ts +8 -0
  82. package/dist/jointRegistry.d.ts.map +1 -0
  83. package/dist/jointRegistry.js +144 -0
  84. package/dist/jointRegistry.js.map +1 -0
  85. package/dist/jointRows.d.ts +33 -0
  86. package/dist/jointRows.d.ts.map +1 -0
  87. package/dist/jointRows.js +352 -0
  88. package/dist/jointRows.js.map +1 -0
  89. package/dist/joints.d.ts +16 -0
  90. package/dist/joints.d.ts.map +1 -0
  91. package/dist/joints.js +1060 -0
  92. package/dist/joints.js.map +1 -0
  93. package/dist/massProperties.d.ts +13 -0
  94. package/dist/massProperties.d.ts.map +1 -0
  95. package/dist/massProperties.js +462 -0
  96. package/dist/massProperties.js.map +1 -0
  97. package/dist/material.d.ts +3 -0
  98. package/dist/material.d.ts.map +1 -0
  99. package/dist/material.js +13 -0
  100. package/dist/material.js.map +1 -0
  101. package/dist/nodeSync.d.ts +3 -0
  102. package/dist/nodeSync.d.ts.map +1 -0
  103. package/dist/nodeSync.js +16 -0
  104. package/dist/nodeSync.js.map +1 -0
  105. package/dist/ownership.d.ts +8 -0
  106. package/dist/ownership.d.ts.map +1 -0
  107. package/dist/ownership.js +26 -0
  108. package/dist/ownership.js.map +1 -0
  109. package/dist/physics3DBroadphasePublication.d.ts +5 -0
  110. package/dist/physics3DBroadphasePublication.d.ts.map +1 -0
  111. package/dist/physics3DBroadphasePublication.js +49 -0
  112. package/dist/physics3DBroadphasePublication.js.map +1 -0
  113. package/dist/physics3DSpatialIndexingGuards.d.ts +4 -0
  114. package/dist/physics3DSpatialIndexingGuards.d.ts.map +1 -0
  115. package/dist/physics3DSpatialIndexingGuards.js +11 -0
  116. package/dist/physics3DSpatialIndexingGuards.js.map +1 -0
  117. package/dist/registerBuiltInPhysics3DJointSolvers.d.ts +3 -0
  118. package/dist/registerBuiltInPhysics3DJointSolvers.d.ts.map +1 -0
  119. package/dist/registerBuiltInPhysics3DJointSolvers.js +15 -0
  120. package/dist/registerBuiltInPhysics3DJointSolvers.js.map +1 -0
  121. package/dist/solver.d.ts +8 -0
  122. package/dist/solver.d.ts.map +1 -0
  123. package/dist/solver.js +460 -0
  124. package/dist/solver.js.map +1 -0
  125. package/dist/step.d.ts +5 -0
  126. package/dist/step.d.ts.map +1 -0
  127. package/dist/step.js +302 -0
  128. package/dist/step.js.map +1 -0
  129. package/dist/stepValidation.d.ts +13 -0
  130. package/dist/stepValidation.d.ts.map +1 -0
  131. package/dist/stepValidation.js +284 -0
  132. package/dist/stepValidation.js.map +1 -0
  133. package/dist/symmetricTensor.d.ts +11 -0
  134. package/dist/symmetricTensor.d.ts.map +1 -0
  135. package/dist/symmetricTensor.js +150 -0
  136. package/dist/symmetricTensor.js.map +1 -0
  137. package/dist/world.d.ts +27 -0
  138. package/dist/world.d.ts.map +1 -0
  139. package/dist/world.js +762 -0
  140. package/dist/world.js.map +1 -0
  141. package/dist/worldQueries.d.ts +11 -0
  142. package/dist/worldQueries.d.ts.map +1 -0
  143. package/dist/worldQueries.js +409 -0
  144. package/dist/worldQueries.js.map +1 -0
  145. package/package.json +55 -0
  146. package/src/broadphase.test.ts +265 -0
  147. package/src/colliderCollision.test.ts +57 -0
  148. package/src/colliderTransform.test.ts +221 -0
  149. package/src/contactIntake.test.ts +370 -0
  150. package/src/contacts.test.ts +64 -0
  151. package/src/continuous.test.ts +679 -0
  152. package/src/debugGeometry.test.ts +288 -0
  153. package/src/enablePhysics3DGuards.test.ts +206 -0
  154. package/src/explainPhysics3DCollision.test.ts +78 -0
  155. package/src/explainPhysics3DJoints.test.ts +101 -0
  156. package/src/explainPhysics3DStep.test.ts +142 -0
  157. package/src/integrate.test.ts +279 -0
  158. package/src/islands.test.ts +384 -0
  159. package/src/jointBreakage.test.ts +143 -0
  160. package/src/jointCollisionSuppression.test.ts +94 -0
  161. package/src/jointFactories.test.ts +244 -0
  162. package/src/jointMath.test.ts +646 -0
  163. package/src/jointReaction.test.ts +241 -0
  164. package/src/jointRegistry.test.ts +229 -0
  165. package/src/jointRows.test.ts +548 -0
  166. package/src/joints.test.ts +1066 -0
  167. package/src/massProperties.test.ts +611 -0
  168. package/src/material.test.ts +40 -0
  169. package/src/nodeSync.test.ts +50 -0
  170. package/src/ownership.test.ts +61 -0
  171. package/src/physics3DBroadphasePublication.test.ts +63 -0
  172. package/src/physics3DSpatialIndexingGuards.test.ts +43 -0
  173. package/src/registerBuiltInPhysics3DJointSolvers.test.ts +51 -0
  174. package/src/solver.test.ts +567 -0
  175. package/src/step.test.ts +490 -0
  176. package/src/stepValidation.test.ts +346 -0
  177. package/src/stress.test.ts +526 -0
  178. package/src/symmetricTensor.test.ts +166 -0
  179. package/src/world.test.ts +1127 -0
  180. package/src/worldQueries.test.ts +565 -0
@@ -0,0 +1,526 @@
1
+ import {
2
+ createCollisionHeightfield3D,
3
+ createCollisionTriangleMesh3D,
4
+ registerBuiltInCollisionFaceQueries3D,
5
+ registerBuiltInCollisionSupports3D,
6
+ } from '@flighthq/collision/contract';
7
+ import type { CollisionBuiltInShape3D, Physics3DMaterial, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
8
+ import { beforeEach, describe, expect, it } from 'vitest';
9
+
10
+ import { createPhysics3DBallAndSocketJoint } from './jointFactories';
11
+ import { addPhysics3DJoint } from './jointRegistry';
12
+ import { registerBuiltInPhysics3DJointSolvers } from './registerBuiltInPhysics3DJointSolvers';
13
+ import { stepPhysics3D } from './step';
14
+ import {
15
+ addPhysics3DBody,
16
+ addPhysics3DCollider,
17
+ createPhysics3DCollider,
18
+ createPhysics3DWorld,
19
+ createRigidBody3D,
20
+ setPhysics3DBodyFixedRotation,
21
+ } from './world';
22
+
23
+ // Long-horizon qualification. These do not test one function; they test that the ASSEMBLED step stays
24
+ // finite, stays deterministic, and reuses its storage over thousands of solves — the failures that only
25
+ // appear after hundreds of steps and that a per-function test cannot reach.
26
+
27
+ beforeEach(() => {
28
+ registerBuiltInCollisionSupports3D();
29
+ registerBuiltInCollisionFaceQueries3D();
30
+ });
31
+
32
+ const MATERIAL: Physics3DMaterial = { density: 1, friction: 0.4, restitution: 0 };
33
+
34
+ function boxShape(halfX: number, halfY: number, halfZ: number): CollisionBuiltInShape3D {
35
+ return { kind: 'aabb', minX: -halfX, minY: -halfY, minZ: -halfZ, maxX: halfX, maxY: halfY, maxZ: halfZ };
36
+ }
37
+
38
+ function addCuboid(
39
+ world: Physics3DWorld,
40
+ type: RigidBody3D['type'],
41
+ x: number,
42
+ y: number,
43
+ z: number,
44
+ halfX: number,
45
+ halfY: number,
46
+ halfZ: number,
47
+ material: Readonly<Physics3DMaterial> = MATERIAL,
48
+ ): RigidBody3D {
49
+ const body = createRigidBody3D(type);
50
+ body.x = x;
51
+ body.y = y;
52
+ body.z = z;
53
+ addPhysics3DBody(world, body);
54
+ addPhysics3DCollider(world, body, createPhysics3DCollider(boxShape(halfX, halfY, halfZ), material));
55
+ return body;
56
+ }
57
+
58
+ function addBox(
59
+ world: Physics3DWorld,
60
+ type: RigidBody3D['type'],
61
+ x: number,
62
+ y: number,
63
+ z: number,
64
+ half = 0.5,
65
+ material: Readonly<Physics3DMaterial> = MATERIAL,
66
+ ): RigidBody3D {
67
+ return addCuboid(world, type, x, y, z, half, half, half, material);
68
+ }
69
+
70
+ function addSphere(
71
+ world: Physics3DWorld,
72
+ type: RigidBody3D['type'],
73
+ x: number,
74
+ y: number,
75
+ z: number,
76
+ radius = 0.25,
77
+ ): RigidBody3D {
78
+ const body = createRigidBody3D(type);
79
+ body.x = x;
80
+ body.y = y;
81
+ body.z = z;
82
+ addPhysics3DBody(world, body);
83
+ addPhysics3DCollider(world, body, createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius }, MATERIAL));
84
+ return body;
85
+ }
86
+
87
+ function addSlab(world: Physics3DWorld, halfX: number, halfY: number, halfZ: number, y: number): RigidBody3D {
88
+ return addCuboid(world, 'static', 0, y, 0, halfX, halfY, halfZ);
89
+ }
90
+
91
+ function run(world: Physics3DWorld, steps: number): void {
92
+ for (let i = 0; i < steps; i += 1) stepPhysics3D(world, 1 / 60);
93
+ }
94
+
95
+ // Every field the integrator writes. A NaN in ONE of them spreads to the rest within a step, so
96
+ // checking a subset would report health a step before the world dies.
97
+ function expectFiniteBody(body: Readonly<RigidBody3D>): void {
98
+ expect([
99
+ body.x,
100
+ body.y,
101
+ body.z,
102
+ body.orientationX,
103
+ body.orientationY,
104
+ body.orientationZ,
105
+ body.orientationW,
106
+ body.velocityX,
107
+ body.velocityY,
108
+ body.velocityZ,
109
+ body.angularVelocityX,
110
+ body.angularVelocityY,
111
+ body.angularVelocityZ,
112
+ ]).toSatisfy((values: number[]) => values.every(Number.isFinite));
113
+ }
114
+
115
+ function snapshotWorld(world: Readonly<Physics3DWorld>): number[][] {
116
+ return world.bodies.map((body) => [
117
+ body.index,
118
+ body.x,
119
+ body.y,
120
+ body.z,
121
+ body.orientationX,
122
+ body.orientationY,
123
+ body.orientationZ,
124
+ body.orientationW,
125
+ body.velocityX,
126
+ body.velocityY,
127
+ body.velocityZ,
128
+ body.angularVelocityX,
129
+ body.angularVelocityY,
130
+ body.angularVelocityZ,
131
+ body.sleeping ? 1 : 0,
132
+ body.sleepTimer,
133
+ ]);
134
+ }
135
+
136
+ describe('physics3d stress qualification', () => {
137
+ it('settles a distributed load across large accelerated mesh and heightfield terrain', { timeout: 15_000 }, () => {
138
+ for (const surface of [
139
+ createGridTriangleMesh(17, 17),
140
+ createCollisionHeightfield3D(17, 17, new Array(17 * 17).fill(0)),
141
+ ]) {
142
+ const world = createPhysics3DWorld();
143
+ const terrain = createRigidBody3D('static');
144
+ terrain.colliders.push(createPhysics3DCollider(surface, MATERIAL));
145
+ addPhysics3DBody(world, terrain);
146
+ const bodies: RigidBody3D[] = [];
147
+ for (let row = 0; row < 4; row += 1) {
148
+ for (let column = 0; column < 4; column += 1) {
149
+ bodies.push(addBox(world, 'dynamic', 2.25 + column * 3.5, 2 + ((row + column) % 3), 2.25 + row * 3.5));
150
+ }
151
+ }
152
+
153
+ run(world, 900);
154
+
155
+ for (const body of bodies) {
156
+ expectFiniteBody(body);
157
+ expect(body.y).toBeGreaterThan(0.49);
158
+ expect(body.y).toBeLessThan(0.52);
159
+ expect(
160
+ body.sleeping,
161
+ JSON.stringify([body.y, body.velocityX, body.velocityY, body.velocityZ, body.sleepTimer]),
162
+ ).toBe(true);
163
+ }
164
+ }
165
+ });
166
+
167
+ it('keeps a tall pile finite, ordered, and supported over a long settle horizon', () => {
168
+ const world = createPhysics3DWorld();
169
+ world.gravityY = -10;
170
+ addSlab(world, 20, 1, 20, -1);
171
+ // Boxed in on four sides, so the pile cannot walk sideways out of the test over 900 steps. The 2D
172
+ // harness needs two walls; three dimensions need four.
173
+ for (const [x, z, halfX, halfZ] of [
174
+ [-1.02, 0, 0.5, 3],
175
+ [1.02, 0, 0.5, 3],
176
+ [0, -1.02, 3, 0.5],
177
+ [0, 1.02, 3, 0.5],
178
+ ]) {
179
+ const wall = createRigidBody3D('static');
180
+ wall.x = x;
181
+ wall.z = z;
182
+ wall.y = 10;
183
+ addPhysics3DBody(world, wall);
184
+ addPhysics3DCollider(world, wall, createPhysics3DCollider(boxShape(halfX, 11, halfZ), MATERIAL));
185
+ }
186
+ const pile: RigidBody3D[] = [];
187
+ for (let i = 0; i < 12; i += 1) pile.push(addBox(world, 'dynamic', 0, 0.5 + i, 0));
188
+
189
+ run(world, 900);
190
+
191
+ for (const body of pile) expectFiniteBody(body);
192
+ // Still stacked in the order they were built: nothing tunnelled past a neighbour.
193
+ for (let i = 1; i < pile.length; i += 1) expect(pile[i].y).toBeGreaterThan(pile[i - 1].y);
194
+ // The bottom box still rests on the floor rather than having sunk into it. The bound accommodates
195
+ // platform-dependent convergence rates (~0.47 locally, ~0.43 on CI) while still catching the stale-
196
+ // depth position correction bug that measured 0.23 of sink (pile[0].y ≈ 0.27).
197
+ expect(pile[0].y).toBeGreaterThan(0.4);
198
+ // And the pile has not collapsed: twelve unit boxes stand about twelve units tall.
199
+ expect(pile[pile.length - 1].y).toBeGreaterThan(11);
200
+ expect(pile.every((body) => body.sleeping)).toBe(true);
201
+ });
202
+
203
+ it('keeps a driven ball-and-socket chain bounded over thousands of constraint solves', () => {
204
+ const world = createPhysics3DWorld();
205
+ world.gravityY = -10;
206
+ registerBuiltInPhysics3DJointSolvers(world);
207
+ const links: RigidBody3D[] = [addSphere(world, 'static', 0, 8, 0)];
208
+ for (let i = 1; i <= 12; i += 1) {
209
+ const link = addSphere(world, 'dynamic', 0, 8 - i, 0);
210
+ links.push(link);
211
+ addPhysics3DJoint(
212
+ world,
213
+ createPhysics3DBallAndSocketJoint({
214
+ bodyA: links[i - 1].index,
215
+ bodyB: link.index,
216
+ localAnchorAY: -0.5,
217
+ localAnchorBY: 0.5,
218
+ }),
219
+ );
220
+ }
221
+ // Pushed sideways AND out of plane, so a chain that only stayed bounded in a plane would fail here.
222
+ links[links.length - 1].velocityX = 4;
223
+ links[links.length - 1].velocityZ = 3;
224
+
225
+ run(world, 1200);
226
+
227
+ for (const body of links) expectFiniteBody(body);
228
+ let maximumLinkError = 0;
229
+ for (let i = 1; i < links.length; i += 1) {
230
+ const dx = links[i].x - links[i - 1].x;
231
+ const dy = links[i].y - links[i - 1].y;
232
+ const dz = links[i].z - links[i - 1].z;
233
+ maximumLinkError = Math.max(maximumLinkError, Math.abs(Math.hypot(dx, dy, dz) - 1));
234
+ }
235
+ // Measured worst case is about 0.0214 world units at the driven end of the chain.
236
+ expect(maximumLinkError).toBeLessThan(0.025);
237
+ expect(Math.hypot(links[links.length - 1].x, links[links.length - 1].z)).toBeLessThan(15);
238
+ });
239
+
240
+ it('supports ordinary and adversarial mass ratios within a declared compression envelope', () => {
241
+ function stackedPair(ratio: number, substeps: number): readonly [RigidBody3D, RigidBody3D] {
242
+ const world = createPhysics3DWorld();
243
+ world.gravityY = -10;
244
+ world.config.substeps = substeps;
245
+ addSlab(world, 5, 1, 5, -1);
246
+ const lower = addBox(world, 'dynamic', 0, 0.5, 0);
247
+ const upper = addBox(world, 'dynamic', 0, 1.5, 0, 0.5, {
248
+ density: ratio,
249
+ friction: MATERIAL.friction,
250
+ restitution: 0,
251
+ });
252
+ // This is a mass-ratio test, not a balance test: removing rotation isolates the normal rows from
253
+ // the physically legitimate top-heavy stack tipping under microscopic lateral perturbations.
254
+ setPhysics3DBodyFixedRotation(lower, true);
255
+ setPhysics3DBodyFixedRotation(upper, true);
256
+ run(world, 600);
257
+ return [lower, upper];
258
+ }
259
+
260
+ // Default tuning owns the ordinary envelope; four substeps are the published lever for the 1000:1
261
+ // adversarial case. Both retain at least 99% of the one-unit centre separation and come to rest.
262
+ for (const [ratio, substeps] of [
263
+ [100, 1],
264
+ [1000, 4],
265
+ ] as const) {
266
+ const [lower, upper] = stackedPair(ratio, substeps);
267
+ expectFiniteBody(lower);
268
+ expectFiniteBody(upper);
269
+ expect(lower.y).toBeGreaterThan(0.49);
270
+ expect(upper.y - lower.y).toBeGreaterThan(0.99);
271
+ expect(lower.sleeping).toBe(true);
272
+ expect(upper.sleeping).toBe(true);
273
+ }
274
+ });
275
+
276
+ it('keeps restitution energy and a centred contact torque bounded over sixty seconds', () => {
277
+ const world = createPhysics3DWorld();
278
+ world.gravityY = 0;
279
+ const elastic: Physics3DMaterial = { density: 1, friction: 0, restitution: 1 };
280
+ addCuboid(world, 'static', -5.25, 0, 0, 0.25, 5, 5, elastic);
281
+ addCuboid(world, 'static', 5.25, 0, 0, 0.25, 5, 5, elastic);
282
+ const ball = createRigidBody3D('dynamic');
283
+ ball.sleepEnabled = false;
284
+ ball.velocityX = 6;
285
+ addPhysics3DBody(world, ball);
286
+ addPhysics3DCollider(
287
+ world,
288
+ ball,
289
+ createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 0.25 }, elastic),
290
+ );
291
+
292
+ run(world, 3600);
293
+
294
+ expectFiniteBody(ball);
295
+ expect(Math.abs(ball.x)).toBeLessThanOrEqual(4.81);
296
+ expect(Math.hypot(ball.velocityX, ball.velocityY, ball.velocityZ)).toBeCloseTo(6, 9);
297
+ // A centre-line hit has no torque. This bound caught the flat face's arbitrary support corner being
298
+ // averaged into the fallback point, which spun the sphere and let it escape the corridor.
299
+ expect(Math.hypot(ball.angularVelocityX, ball.angularVelocityY, ball.angularVelocityZ)).toBeLessThan(1e-9);
300
+ });
301
+
302
+ it('makes long-horizon Coulomb friction isotropic in the contact plane', () => {
303
+ function slide(diagonal: boolean): readonly [number, number, number] {
304
+ const world = createPhysics3DWorld();
305
+ world.gravityY = -10;
306
+ addSlab(world, 100, 1, 100, -1);
307
+ const body = addBox(world, 'dynamic', 0, 0.5, 0);
308
+ setPhysics3DBodyFixedRotation(body, true);
309
+ const component = diagonal ? 5 / Math.SQRT2 : 5;
310
+ body.velocityX = component;
311
+ body.velocityZ = diagonal ? component : 0;
312
+ run(world, 240);
313
+ expect(Math.hypot(body.velocityX, body.velocityZ)).toBeLessThan(1e-9);
314
+ return [body.x, body.z, Math.hypot(body.x, body.z)];
315
+ }
316
+
317
+ const axis = slide(false);
318
+ const diagonal = slide(true);
319
+ expect(Math.abs(axis[1])).toBeLessThan(0.001);
320
+ expect(Math.abs(diagonal[0] - diagonal[1])).toBeLessThan(0.001);
321
+ expect(Math.abs(axis[2] - diagonal[2])).toBeLessThan(0.005);
322
+ });
323
+
324
+ it('produces an exact repeat trace for a mixed pile, joint, and sleep scene', () => {
325
+ // EXACT equality, not approximate. Determinism is a property of the arithmetic and the iteration
326
+ // order; a tolerance here would pass with the contact sort removed, which is precisely the bug this
327
+ // is watching for.
328
+ function scene(): Physics3DWorld {
329
+ const world = createPhysics3DWorld();
330
+ world.gravityY = -10;
331
+ registerBuiltInPhysics3DJointSolvers(world);
332
+ addSlab(world, 10, 1, 10, -1);
333
+ const first = addBox(world, 'dynamic', -1, 0.5, 0);
334
+ const second = addBox(world, 'dynamic', -1, 1.5, 0);
335
+ addPhysics3DJoint(
336
+ world,
337
+ createPhysics3DBallAndSocketJoint({
338
+ bodyA: first.index,
339
+ bodyB: second.index,
340
+ localAnchorAY: 0.5,
341
+ localAnchorBY: -0.5,
342
+ }),
343
+ );
344
+ addBox(world, 'dynamic', 2, 0.5, 0);
345
+ addBox(world, 'dynamic', 2, 0.5, 2);
346
+ const thrown = addSphere(world, 'dynamic', -8, 3, 0);
347
+ thrown.gravityScale = 0;
348
+ thrown.velocityX = 20;
349
+ return world;
350
+ }
351
+
352
+ const first = scene();
353
+ const second = scene();
354
+ for (let i = 0; i < 480; i += 1) {
355
+ stepPhysics3D(first, 1 / 60);
356
+ stepPhysics3D(second, 1 / 60);
357
+ }
358
+
359
+ expect(snapshotWorld(first)).toEqual(snapshotWorld(second));
360
+ });
361
+
362
+ it('produces the same trace whichever order two independent piles were inserted in', () => {
363
+ // The other half of determinism, and the one an exact-repeat test cannot see: the contact LIST is
364
+ // sorted precisely so that insertion history stops mattering. Two disjoint piles built in opposite
365
+ // orders must settle identically.
366
+ function scene(reversed: boolean): number[] {
367
+ const world = createPhysics3DWorld();
368
+ world.gravityY = -10;
369
+ addSlab(world, 20, 1, 20, -1);
370
+ const columns = reversed ? [6, -6] : [-6, 6];
371
+ const tracked: RigidBody3D[] = [];
372
+ for (const x of columns) {
373
+ for (let i = 0; i < 3; i += 1) tracked.push(addBox(world, 'dynamic', x, 0.5 + i, 0));
374
+ }
375
+ run(world, 300);
376
+ // Reported by POSITION rather than by insertion index, since the two scenes number their bodies
377
+ // differently on purpose.
378
+ return tracked
379
+ .map((body) => [Math.round(body.x), body.y] as const)
380
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1])
381
+ .map(([, y]) => y);
382
+ }
383
+
384
+ const forward = scene(false);
385
+ const backward = scene(true);
386
+ expect(forward).toHaveLength(6);
387
+ for (let i = 0; i < forward.length; i += 1) expect(backward[i]).toBeCloseTo(forward[i], 9);
388
+ });
389
+
390
+ it('retains stable-topology contacts and every world-owned island workspace object', () => {
391
+ const world = createPhysics3DWorld();
392
+ world.gravityY = -10;
393
+ addSlab(world, 5, 1, 5, -1);
394
+ addBox(world, 'dynamic', 0, 0.5, 0);
395
+ run(world, 240);
396
+ expect(world.contacts).toHaveLength(1);
397
+ const contact = world.contacts[0];
398
+ const points = contact.points;
399
+ const workspace = [
400
+ world.islandParents,
401
+ world.islandSleepTimers,
402
+ world.solveIslandByRoot,
403
+ world.solveIslandRoots,
404
+ world.solveIslandBodyStarts,
405
+ world.solveIslandBodyCounts,
406
+ world.solveIslandContactStarts,
407
+ world.solveIslandContactCounts,
408
+ world.solveIslandJointStarts,
409
+ world.solveIslandJointCounts,
410
+ world.solveIslandBodyIndices,
411
+ world.solveIslandContactIndices,
412
+ world.solveIslandJointIndices,
413
+ world.solveIslandCursors,
414
+ ];
415
+
416
+ run(world, 600);
417
+
418
+ // Contact IDENTITY survives: the same record and the same point array, which is what the solver's
419
+ // warm-start accumulators are matched against. A contact rebuilt each step would settle far slower
420
+ // while every per-step assertion still passed.
421
+ expect(world.contacts[0]).toBe(contact);
422
+ expect(world.contacts[0].points).toBe(points);
423
+ const retained = [
424
+ world.islandParents,
425
+ world.islandSleepTimers,
426
+ world.solveIslandByRoot,
427
+ world.solveIslandRoots,
428
+ world.solveIslandBodyStarts,
429
+ world.solveIslandBodyCounts,
430
+ world.solveIslandContactStarts,
431
+ world.solveIslandContactCounts,
432
+ world.solveIslandJointStarts,
433
+ world.solveIslandJointCounts,
434
+ world.solveIslandBodyIndices,
435
+ world.solveIslandContactIndices,
436
+ world.solveIslandJointIndices,
437
+ world.solveIslandCursors,
438
+ ];
439
+ for (let i = 0; i < workspace.length; i += 1) expect(retained[i]).toBe(workspace[i]);
440
+ });
441
+
442
+ it('keeps a spinning asymmetric body finite, with a drift that halves as the timestep does', () => {
443
+ // The 3D-only failure mode, with no 2D counterpart: a body whose inertia tensor is not isotropic
444
+ // precesses, and the gyroscopic term that produces it is integrated EXPLICITLY. So the test is not
445
+ // that angular momentum is conserved — it is not, at a game timestep — but that the error behaves
446
+ // like discretization error and not like a wrong term. Halving the sub-interval must roughly halve
447
+ // the drift; a wrong term converges to the wrong answer instead, however fine the steps.
448
+ const drifts = [1, 2, 4].map((substeps) => {
449
+ const world = createPhysics3DWorld();
450
+ world.gravityY = 0;
451
+ world.config.substeps = substeps;
452
+ const body = createRigidBody3D('dynamic');
453
+ addPhysics3DBody(world, body);
454
+ addPhysics3DCollider(world, body, createPhysics3DCollider(boxShape(2, 0.5, 1), MATERIAL));
455
+ body.sleepEnabled = false;
456
+ body.angularVelocityX = 3;
457
+ body.angularVelocityY = 0.2;
458
+ body.angularVelocityZ = 0.1;
459
+
460
+ const initial = angularMomentumMagnitude(body);
461
+ run(world, 1200);
462
+
463
+ expectFiniteBody(body);
464
+ // The quaternion must stay on the unit sphere; renormalization drift shows up here first.
465
+ expect(Math.hypot(body.orientationX, body.orientationY, body.orientationZ, body.orientationW)).toBeCloseTo(1, 6);
466
+ return Math.abs(angularMomentumMagnitude(body) / initial - 1);
467
+ });
468
+
469
+ // Roughly first order: each halving of the sub-interval cuts the drift at least a third.
470
+ expect(drifts[1]).toBeLessThan(drifts[0] * 0.7);
471
+ expect(drifts[2]).toBeLessThan(drifts[1] * 0.7);
472
+ expect(drifts[2]).toBeLessThan(0.05);
473
+ });
474
+ });
475
+
476
+ function createGridTriangleMesh(columns: number, rows: number) {
477
+ const points = new Array<number>(columns * rows * 3);
478
+ for (let row = 0; row < rows; row += 1) {
479
+ for (let column = 0; column < columns; column += 1) {
480
+ const offset = (row * columns + column) * 3;
481
+ points[offset] = column;
482
+ points[offset + 1] = 0;
483
+ points[offset + 2] = row;
484
+ }
485
+ }
486
+ const indices: number[] = [];
487
+ for (let row = 0; row < rows - 1; row += 1) {
488
+ for (let column = 0; column < columns - 1; column += 1) {
489
+ const lowerLeft = row * columns + column;
490
+ const lowerRight = lowerLeft + 1;
491
+ const upperLeft = lowerLeft + columns;
492
+ const upperRight = upperLeft + 1;
493
+ indices.push(lowerLeft, upperRight, lowerRight, lowerLeft, upperLeft, upperRight);
494
+ }
495
+ }
496
+ return createCollisionTriangleMesh3D(points, indices);
497
+ }
498
+
499
+ // |I * omega|, which is conserved for torque-free motion.
500
+ //
501
+ // OMEGA IS ROTATED INTO THE BODY FRAME FIRST, and that step is the whole correctness of this function.
502
+ // `body.angularVelocity*` is world-frame while `body.inertia*` is the LOCAL tensor; multiplying them
503
+ // directly mixes two frames and produces a quantity that is conserved by nothing. It reads as a 40%
504
+ // growth that gets WORSE with more substeps — a signature that looks exactly like a wrong gyroscopic
505
+ // term and is really a wrong observable.
506
+ function angularMomentumMagnitude(body: Readonly<RigidBody3D>): number {
507
+ const qX = -body.orientationX;
508
+ const qY = -body.orientationY;
509
+ const qZ = -body.orientationZ;
510
+ const qW = body.orientationW;
511
+ const x = body.angularVelocityX;
512
+ const y = body.angularVelocityY;
513
+ const z = body.angularVelocityZ;
514
+ const tX = 2 * (qY * z - qZ * y);
515
+ const tY = 2 * (qZ * x - qX * z);
516
+ const tZ = 2 * (qX * y - qY * x);
517
+ const wX = x + qW * tX + qY * tZ - qZ * tY;
518
+ const wY = y + qW * tY + qZ * tX - qX * tZ;
519
+ const wZ = z + qW * tZ + qX * tY - qY * tX;
520
+
521
+ return Math.hypot(
522
+ body.inertiaXX * wX + body.inertiaXY * wY + body.inertiaXZ * wZ,
523
+ body.inertiaXY * wX + body.inertiaYY * wY + body.inertiaYZ * wZ,
524
+ body.inertiaXZ * wX + body.inertiaYZ * wY + body.inertiaZZ * wZ,
525
+ );
526
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ applySymmetricTensor,
5
+ inverseSymmetricTensor,
6
+ rotateSymmetricTensor,
7
+ translateSymmetricTensor,
8
+ TENSOR_XX,
9
+ TENSOR_XY,
10
+ TENSOR_XZ,
11
+ TENSOR_YY,
12
+ TENSOR_YZ,
13
+ TENSOR_ZZ,
14
+ } from './symmetricTensor';
15
+
16
+ describe('applySymmetricTensor', () => {
17
+ it('multiplies a diagonal tensor componentwise', () => {
18
+ const out = [0, 0, 0];
19
+ applySymmetricTensor([2, 3, 4, 0, 0, 0], 1, 1, 1, out);
20
+ expect(out).toEqual([2, 3, 4]);
21
+ });
22
+
23
+ it('mixes components through the off-diagonal terms', () => {
24
+ const out = [0, 0, 0];
25
+ applySymmetricTensor([1, 1, 1, 2, 0, 0], 1, 0, 0, out);
26
+ expect(out).toEqual([1, 2, 0]);
27
+ });
28
+
29
+ it('is symmetric: the xy term feeds x from y as it feeds y from x', () => {
30
+ const fromX = [0, 0, 0];
31
+ const fromY = [0, 0, 0];
32
+ applySymmetricTensor([1, 1, 1, 2, 0, 0], 1, 0, 0, fromX);
33
+ applySymmetricTensor([1, 1, 1, 2, 0, 0], 0, 1, 0, fromY);
34
+ expect(fromX[1]).toBe(fromY[0]);
35
+ });
36
+ });
37
+
38
+ describe('inverseSymmetricTensor', () => {
39
+ it('inverts a diagonal tensor by reciprocal', () => {
40
+ const out = [0, 0, 0, 0, 0, 0];
41
+ expect(inverseSymmetricTensor([2, 4, 8, 0, 0, 0], out)).toBe(true);
42
+ expect(out[TENSOR_XX]).toBeCloseTo(0.5, 12);
43
+ expect(out[TENSOR_YY]).toBeCloseTo(0.25, 12);
44
+ expect(out[TENSOR_ZZ]).toBeCloseTo(0.125, 12);
45
+ });
46
+
47
+ it('round-trips a general tensor back to the identity', () => {
48
+ const tensor = [4, 5, 6, 1, 2, 3];
49
+ const inverse = [0, 0, 0, 0, 0, 0];
50
+ expect(inverseSymmetricTensor(tensor, inverse)).toBe(true);
51
+
52
+ // tensor * inverse applied to each basis vector must return that basis vector.
53
+ const out = [0, 0, 0];
54
+ applySymmetricTensor(inverse, tensor[TENSOR_XX], tensor[TENSOR_XY], tensor[TENSOR_XZ], out);
55
+ expect(out[0]).toBeCloseTo(1, 10);
56
+ expect(out[1]).toBeCloseTo(0, 10);
57
+ expect(out[2]).toBeCloseTo(0, 10);
58
+ });
59
+
60
+ it('reports a singular tensor and writes zeros, the infinite-inertia sentinel', () => {
61
+ const out = [9, 9, 9, 9, 9, 9];
62
+ expect(inverseSymmetricTensor([0, 0, 0, 0, 0, 0], out)).toBe(false);
63
+ expect(out).toEqual([0, 0, 0, 0, 0, 0]);
64
+ });
65
+
66
+ it('reports a rank-deficient tensor as singular rather than inverting it', () => {
67
+ const out = [0, 0, 0, 0, 0, 0];
68
+ // Two equal rows: determinant is exactly zero.
69
+ expect(inverseSymmetricTensor([1, 1, 0, 1, 0, 0], out)).toBe(false);
70
+ });
71
+
72
+ it('is safe when out aliases the input', () => {
73
+ const tensor = [2, 4, 8, 0, 0, 0];
74
+ expect(inverseSymmetricTensor(tensor, tensor)).toBe(true);
75
+ expect(tensor[TENSOR_XX]).toBeCloseTo(0.5, 12);
76
+ expect(tensor[TENSOR_ZZ]).toBeCloseTo(0.125, 12);
77
+ });
78
+ });
79
+
80
+ describe('rotateSymmetricTensor', () => {
81
+ it('leaves a tensor unchanged under the identity quaternion', () => {
82
+ const out = [0, 0, 0, 0, 0, 0];
83
+ rotateSymmetricTensor([1, 2, 3, 0.5, 0.25, 0.125], 0, 0, 0, 1, out);
84
+ expect(out[TENSOR_XX]).toBeCloseTo(1, 12);
85
+ expect(out[TENSOR_YY]).toBeCloseTo(2, 12);
86
+ expect(out[TENSOR_ZZ]).toBeCloseTo(3, 12);
87
+ expect(out[TENSOR_XY]).toBeCloseTo(0.5, 12);
88
+ expect(out[TENSOR_XZ]).toBeCloseTo(0.25, 12);
89
+ expect(out[TENSOR_YZ]).toBeCloseTo(0.125, 12);
90
+ });
91
+
92
+ it('swaps the x and y moments under a quarter turn about z', () => {
93
+ const half = Math.SQRT1_2;
94
+ const out = [0, 0, 0, 0, 0, 0];
95
+ rotateSymmetricTensor([1, 5, 9, 0, 0, 0], 0, 0, half, half, out);
96
+ expect(out[TENSOR_XX]).toBeCloseTo(5, 10);
97
+ expect(out[TENSOR_YY]).toBeCloseTo(1, 10);
98
+ expect(out[TENSOR_ZZ]).toBeCloseTo(9, 10);
99
+ });
100
+
101
+ it('leaves an isotropic tensor unchanged under any rotation', () => {
102
+ const out = [0, 0, 0, 0, 0, 0];
103
+ const n = 1 / Math.sqrt(3);
104
+ const angle = 0.7;
105
+ const s = Math.sin(angle / 2);
106
+ rotateSymmetricTensor([4, 4, 4, 0, 0, 0], n * s, n * s, n * s, Math.cos(angle / 2), out);
107
+ expect(out[TENSOR_XX]).toBeCloseTo(4, 10);
108
+ expect(out[TENSOR_YY]).toBeCloseTo(4, 10);
109
+ expect(out[TENSOR_ZZ]).toBeCloseTo(4, 10);
110
+ expect(out[TENSOR_XY]).toBeCloseTo(0, 10);
111
+ });
112
+
113
+ it('preserves the trace, which rotation cannot change', () => {
114
+ const out = [0, 0, 0, 0, 0, 0];
115
+ const angle = 1.1;
116
+ rotateSymmetricTensor([2, 5, 11, 0.3, -0.4, 0.6], Math.sin(angle / 2), 0, 0, Math.cos(angle / 2), out);
117
+ expect(out[TENSOR_XX] + out[TENSOR_YY] + out[TENSOR_ZZ]).toBeCloseTo(2 + 5 + 11, 10);
118
+ });
119
+
120
+ it('is safe when out aliases the input', () => {
121
+ const half = Math.SQRT1_2;
122
+ const tensor = [1, 5, 9, 0, 0, 0];
123
+ rotateSymmetricTensor(tensor, 0, 0, half, half, tensor);
124
+ expect(tensor[TENSOR_XX]).toBeCloseTo(5, 10);
125
+ expect(tensor[TENSOR_YY]).toBeCloseTo(1, 10);
126
+ });
127
+ });
128
+
129
+ describe('translateSymmetricTensor', () => {
130
+ it('adds the point-mass term along the offset axes', () => {
131
+ const out = [0, 0, 0, 0, 0, 0];
132
+ translateSymmetricTensor([0, 0, 0, 0, 0, 0], 2, 0, 3, 0, out);
133
+ // Offset purely in y: xx and zz gain mass * dy^2, yy is untouched.
134
+ expect(out[TENSOR_XX]).toBeCloseTo(18, 12);
135
+ expect(out[TENSOR_YY]).toBeCloseTo(0, 12);
136
+ expect(out[TENSOR_ZZ]).toBeCloseTo(18, 12);
137
+ });
138
+
139
+ it('produces negative off-diagonal terms for a diagonal offset', () => {
140
+ const out = [0, 0, 0, 0, 0, 0];
141
+ translateSymmetricTensor([0, 0, 0, 0, 0, 0], 1, 2, 3, 0, out);
142
+ expect(out[TENSOR_XY]).toBeCloseTo(-6, 12);
143
+ });
144
+
145
+ it('is even in the offset — negating it gives the same tensor', () => {
146
+ const positive = [0, 0, 0, 0, 0, 0];
147
+ const negative = [0, 0, 0, 0, 0, 0];
148
+ translateSymmetricTensor([1, 1, 1, 0, 0, 0], 3, 1, 2, 3, positive);
149
+ translateSymmetricTensor([1, 1, 1, 0, 0, 0], 3, -1, -2, -3, negative);
150
+ expect(negative[TENSOR_XX]).toBeCloseTo(positive[TENSOR_XX], 12);
151
+ expect(negative[TENSOR_XY]).toBeCloseTo(positive[TENSOR_XY], 12);
152
+ });
153
+
154
+ it('is a no-op at zero offset', () => {
155
+ const out = [0, 0, 0, 0, 0, 0];
156
+ translateSymmetricTensor([1, 2, 3, 4, 5, 6], 7, 0, 0, 0, out);
157
+ expect(out).toEqual([1, 2, 3, 4, 5, 6]);
158
+ });
159
+
160
+ it('is safe when out aliases the input', () => {
161
+ const tensor = [1, 1, 1, 0, 0, 0];
162
+ translateSymmetricTensor(tensor, 2, 0, 3, 0, tensor);
163
+ expect(tensor[TENSOR_XX]).toBeCloseTo(19, 12);
164
+ expect(tensor[TENSOR_YY]).toBeCloseTo(1, 12);
165
+ });
166
+ });