@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,1127 @@
1
+ import {
2
+ createCollisionHeightfield3D,
3
+ createCollisionTriangleMesh3D,
4
+ registerBuiltInCollisionFaceQueries3D,
5
+ registerBuiltInCollisionSupports3D,
6
+ } from '@flighthq/collision/contract';
7
+ import type { CollisionBuiltInShape3D, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
8
+ import { beforeEach, describe, expect, it } from 'vitest';
9
+
10
+ import { buildPhysics3DContacts } from './contactIntake';
11
+ import { isPhysics3DPairJointSuppressed } from './jointCollisionSuppression';
12
+ import { createPhysics3DBallAndSocketJoint } from './jointFactories';
13
+ import { addPhysics3DJoint } from './jointRegistry';
14
+ import { computePhysics3DSphereMassData, createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
15
+ import { registerBuiltInPhysics3DJointSolvers } from './registerBuiltInPhysics3DJointSolvers';
16
+ import {
17
+ addPhysics3DBody,
18
+ addPhysics3DCollider,
19
+ applyPhysics3DForce,
20
+ applyPhysics3DForceAtPoint,
21
+ applyPhysics3DLinearImpulse,
22
+ applyPhysics3DLinearImpulseAtPoint,
23
+ applyPhysics3DTorque,
24
+ createPhysics3DCollider,
25
+ createPhysics3DSequentialImpulseConfig,
26
+ createPhysics3DSolverConfig,
27
+ createPhysics3DWorld,
28
+ createRigidBody3D,
29
+ findPhysics3DBody,
30
+ hydratePhysics3DWorld,
31
+ invalidatePhysics3DCollider,
32
+ Physics3DWorldVersion,
33
+ removePhysics3DBody,
34
+ removePhysics3DCollider,
35
+ setPhysics3DBodyBullet,
36
+ setPhysics3DBodyFixedRotation,
37
+ setPhysics3DBodySleepEnabled,
38
+ setPhysics3DBodyTransform,
39
+ setPhysics3DBodyType,
40
+ wakePhysics3DBody,
41
+ writeRigidBody3DWorldCenter,
42
+ } from './world';
43
+
44
+ describe('addPhysics3DBody', () => {
45
+ it('rejects static-surface geometry on a movable body', () => {
46
+ const world = createPhysics3DWorld();
47
+ const body = createRigidBody3D('dynamic');
48
+ body.colliders.push(createPhysics3DCollider(createCollisionTriangleMesh3D([0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 1, 2])));
49
+
50
+ expect(() => addPhysics3DBody(world, body)).toThrow(/static rigid body/);
51
+ expect(world.bodies).toHaveLength(0);
52
+ });
53
+
54
+ it('assigns a persistent index and registers the body', () => {
55
+ const world = createPhysics3DWorld();
56
+ const body = createRigidBody3D();
57
+
58
+ const index = addPhysics3DBody(world, body);
59
+
60
+ expect(index).toBe(0);
61
+ expect(world.bodies).toContain(body);
62
+ expect(findPhysics3DBody(world, index)).toBe(body);
63
+ });
64
+
65
+ it('never reissues an index a removed body held', () => {
66
+ const world = createPhysics3DWorld();
67
+ const first = createRigidBody3D();
68
+ addPhysics3DBody(world, first);
69
+ const removedIndex = first.index;
70
+ removePhysics3DBody(world, first);
71
+
72
+ const second = createRigidBody3D();
73
+ const index = addPhysics3DBody(world, second);
74
+
75
+ expect(index).not.toBe(removedIndex);
76
+ expect(first.index).toBe(-1);
77
+ expect(findPhysics3DBody(world, removedIndex)).toBeNull();
78
+ });
79
+
80
+ it('permits a removed body to enter another world under a fresh index', () => {
81
+ const firstWorld = createPhysics3DWorld();
82
+ const secondWorld = createPhysics3DWorld();
83
+ const body = createRigidBody3D();
84
+ addPhysics3DBody(firstWorld, body);
85
+ removePhysics3DBody(firstWorld, body);
86
+
87
+ expect(() => addPhysics3DBody(secondWorld, body)).not.toThrow();
88
+ expect(secondWorld.bodies).toEqual([body]);
89
+ });
90
+
91
+ it('rejects duplicate and cross-world insertion without corrupting either world', () => {
92
+ const world = createPhysics3DWorld();
93
+ const otherWorld = createPhysics3DWorld();
94
+ const body = createRigidBody3D();
95
+ const index = addPhysics3DBody(world, body);
96
+
97
+ expect(() => addPhysics3DBody(world, body)).toThrow();
98
+ expect(() => addPhysics3DBody(otherWorld, body)).toThrow();
99
+ expect(world.bodies).toEqual([body]);
100
+ expect(world.bodyByIndex.get(index)).toBe(body);
101
+ expect(otherWorld.bodies).toHaveLength(0);
102
+ });
103
+
104
+ it('derives mass and claims colliders authored before insertion', () => {
105
+ const world = createPhysics3DWorld();
106
+ const body = createRigidBody3D();
107
+ const collider = createPhysics3DCollider(colliderUnitBox());
108
+ body.colliders.push(collider);
109
+
110
+ addPhysics3DBody(world, body);
111
+
112
+ expect(body.mass).toBeCloseTo(1, 12);
113
+ expect(body.inverseMass).toBeCloseTo(1, 12);
114
+
115
+ const second = createRigidBody3D();
116
+ second.colliders.push(collider);
117
+ expect(() => addPhysics3DBody(world, second)).toThrow(/share/);
118
+ expect(second.index).toBe(-1);
119
+ });
120
+
121
+ it('rejects the same pre-authored collider twice before mutating the world', () => {
122
+ const world = createPhysics3DWorld();
123
+ const body = createRigidBody3D();
124
+ const collider = createPhysics3DCollider(colliderUnitBox());
125
+ body.colliders.push(collider, collider);
126
+
127
+ expect(() => addPhysics3DBody(world, body)).toThrow(/same collider twice/);
128
+ expect(body.index).toBe(-1);
129
+ expect(world.bodies).toHaveLength(0);
130
+ });
131
+ });
132
+
133
+ describe('addPhysics3DCollider', () => {
134
+ it('rejects a heightfield on an already-owned movable body', () => {
135
+ const world = createPhysics3DWorld();
136
+ const body = createRigidBody3D('dynamic');
137
+ addPhysics3DBody(world, body);
138
+
139
+ expect(() =>
140
+ addPhysics3DCollider(world, body, createPhysics3DCollider(createCollisionHeightfield3D(2, 2, [0, 0, 0, 0]))),
141
+ ).toThrow(/static rigid body/);
142
+ });
143
+
144
+ it('attaches the collider and derives the body mass from its geometry', () => {
145
+ const world = createPhysics3DWorld();
146
+ const body = addColliderTestBody(world);
147
+ expect(body.mass).toBe(0);
148
+
149
+ const collider = addPhysics3DCollider(world, body, createPhysics3DCollider(colliderUnitBox()));
150
+
151
+ expect(body.colliders).toEqual([collider]);
152
+ // A unit box of density 1 has volume 1.
153
+ expect(body.mass).toBeCloseTo(1, 12);
154
+ expect(body.inverseMass).toBeCloseTo(1, 12);
155
+ });
156
+
157
+ it('returns the collider it was given', () => {
158
+ const world = createPhysics3DWorld();
159
+ const body = addColliderTestBody(world);
160
+ const collider = createPhysics3DCollider(colliderUnitBox());
161
+ expect(addPhysics3DCollider(world, body, collider)).toBe(collider);
162
+ });
163
+
164
+ it('moves the centre of mass to an offset collider', () => {
165
+ const world = createPhysics3DWorld();
166
+ const body = addColliderTestBody(world);
167
+
168
+ addPhysics3DCollider(
169
+ world,
170
+ body,
171
+ createPhysics3DCollider({ kind: 'aabb', minX: 1.5, minY: -0.5, minZ: -0.5, maxX: 2.5, maxY: 0.5, maxZ: 0.5 }),
172
+ );
173
+
174
+ expect(body.centerX).toBeCloseTo(2, 12);
175
+ });
176
+
177
+ it('balances a compound body between its pieces', () => {
178
+ const world = createPhysics3DWorld();
179
+ const body = addColliderTestBody(world);
180
+
181
+ addPhysics3DCollider(
182
+ world,
183
+ body,
184
+ createPhysics3DCollider({ kind: 'aabb', minX: -2.5, minY: -0.5, minZ: -0.5, maxX: -1.5, maxY: 0.5, maxZ: 0.5 }),
185
+ );
186
+ addPhysics3DCollider(
187
+ world,
188
+ body,
189
+ createPhysics3DCollider({ kind: 'aabb', minX: 1.5, minY: -0.5, minZ: -0.5, maxX: 2.5, maxY: 0.5, maxZ: 0.5 }),
190
+ );
191
+
192
+ expect(body.mass).toBeCloseTo(2, 12);
193
+ expect(body.centerX).toBeCloseTo(0, 12);
194
+ });
195
+
196
+ it('publishes the new geometry immediately rather than waiting for a step', () => {
197
+ const world = createPhysics3DWorld();
198
+ const floor = addColliderTestBody(world, -1);
199
+ addPhysics3DCollider(world, floor, createPhysics3DCollider(colliderUnitBox()));
200
+ const box = addColliderTestBody(world, -0.25);
201
+ addPhysics3DCollider(world, box, createPhysics3DCollider(colliderUnitBox()));
202
+
203
+ buildPhysics3DContacts(world);
204
+
205
+ expect(world.contacts).toHaveLength(1);
206
+ });
207
+
208
+ it('refuses the same collider twice on one body', () => {
209
+ const world = createPhysics3DWorld();
210
+ const body = addColliderTestBody(world);
211
+ const collider = createPhysics3DCollider(colliderUnitBox());
212
+ addPhysics3DCollider(world, body, collider);
213
+
214
+ expect(() => addPhysics3DCollider(world, body, collider)).toThrow(/twice/);
215
+ });
216
+
217
+ it('refuses to share one collider between two bodies', () => {
218
+ // A collider holds the world shape its body's pose is written into, so sharing would have each step
219
+ // overwrite the other body's transform.
220
+ const world = createPhysics3DWorld();
221
+ const collider = createPhysics3DCollider(colliderUnitBox());
222
+ addPhysics3DCollider(world, addColliderTestBody(world), collider);
223
+
224
+ expect(() => addPhysics3DCollider(world, addColliderTestBody(world), collider)).toThrow(/share/);
225
+ });
226
+
227
+ it('refuses to mutate a body through a world that does not own it', () => {
228
+ const owner = createPhysics3DWorld();
229
+ const foreign = createPhysics3DWorld();
230
+ const body = addColliderTestBody(owner);
231
+
232
+ expect(() => addPhysics3DCollider(foreign, body, createPhysics3DCollider(colliderUnitBox()))).toThrow(/own/);
233
+ expect(body.colliders).toHaveLength(0);
234
+ });
235
+ });
236
+
237
+ describe('applyPhysics3DForce', () => {
238
+ it('accumulates onto the force vector and wakes the body', () => {
239
+ const body = sphere();
240
+ body.sleeping = true;
241
+
242
+ applyPhysics3DForce(body, 1, 2, 3);
243
+
244
+ expect(body.forceY).toBe(2);
245
+ expect(body.sleeping).toBe(false);
246
+ });
247
+
248
+ it('ignores a static body', () => {
249
+ const body = createRigidBody3D('static');
250
+ expect(applyPhysics3DForce(body, 1, 2, 3)).toBe(false);
251
+ expect(body.forceY).toBe(0);
252
+ });
253
+
254
+ it('rejects a non-finite force without partially mutating or waking the body', () => {
255
+ const body = sphere();
256
+ body.sleeping = true;
257
+
258
+ expect(applyPhysics3DForce(body, 1, Number.NaN, 3)).toBe(false);
259
+
260
+ expect(body.forceX).toBe(0);
261
+ expect(body.forceY).toBe(0);
262
+ expect(body.sleeping).toBe(true);
263
+ });
264
+ });
265
+
266
+ describe('applyPhysics3DForceAtPoint', () => {
267
+ it('produces torque from the lever arm', () => {
268
+ const body = sphere();
269
+
270
+ // Push along +x at a point one unit along +y: the torque is about -z.
271
+ applyPhysics3DForceAtPoint(body, 1, 0, 0, 0, 1, 0);
272
+
273
+ expect(body.forceX).toBe(1);
274
+ expect(body.torqueZ).toBeCloseTo(-1, 12);
275
+ });
276
+
277
+ it('produces no torque for a force through the centre of mass', () => {
278
+ const body = sphere();
279
+
280
+ applyPhysics3DForceAtPoint(body, 5, 0, 0, 0, 0, 0);
281
+
282
+ expect(body.torqueX).toBeCloseTo(0, 12);
283
+ expect(body.torqueY).toBeCloseTo(0, 12);
284
+ expect(body.torqueZ).toBeCloseTo(0, 12);
285
+ });
286
+
287
+ it('measures the lever arm from the centre of mass, not the origin', () => {
288
+ const body = sphere();
289
+ body.centerY = 1;
290
+
291
+ // The same point is now AT the centre of mass, so it can produce no torque.
292
+ applyPhysics3DForceAtPoint(body, 1, 0, 0, 0, 1, 0);
293
+
294
+ expect(body.torqueZ).toBeCloseTo(0, 12);
295
+ });
296
+
297
+ it('rejects a non-finite point before accumulating any force', () => {
298
+ const body = sphere();
299
+
300
+ expect(applyPhysics3DForceAtPoint(body, 1, 2, 3, Number.POSITIVE_INFINITY, 0, 0)).toBe(false);
301
+
302
+ expect(body.forceX).toBe(0);
303
+ expect(body.torqueZ).toBe(0);
304
+ });
305
+ });
306
+
307
+ describe('applyPhysics3DLinearImpulse', () => {
308
+ it('changes velocity immediately through the inverse mass', () => {
309
+ const body = sphere();
310
+
311
+ applyPhysics3DLinearImpulse(body, body.mass * 3, 0, 0);
312
+
313
+ expect(body.velocityX).toBeCloseTo(3, 10);
314
+ });
315
+
316
+ it('ignores an immovable body', () => {
317
+ const body = createRigidBody3D('static');
318
+ expect(applyPhysics3DLinearImpulse(body, 100, 0, 0)).toBe(false);
319
+ expect(body.velocityX).toBe(0);
320
+ });
321
+
322
+ it('rejects a non-finite impulse without poisoning velocity', () => {
323
+ const body = sphere();
324
+
325
+ expect(applyPhysics3DLinearImpulse(body, Number.NaN, 0, 0)).toBe(false);
326
+
327
+ expect(body.velocityX).toBe(0);
328
+ });
329
+ });
330
+
331
+ describe('applyPhysics3DLinearImpulseAtPoint', () => {
332
+ it('changes linear and angular velocity through the world inverse inertia', () => {
333
+ const body = sphere();
334
+
335
+ expect(applyPhysics3DLinearImpulseAtPoint(body, 1, 0, 0, 0, 1, 0)).toBe(true);
336
+
337
+ expect(body.velocityX).toBeGreaterThan(0);
338
+ expect(body.angularVelocityZ).toBeLessThan(0);
339
+ });
340
+
341
+ it('uses the world centre of mass for the lever arm', () => {
342
+ const body = sphere();
343
+ body.centerY = 1;
344
+
345
+ applyPhysics3DLinearImpulseAtPoint(body, 1, 0, 0, 0, 1, 0);
346
+
347
+ expect(body.angularVelocityZ).toBeCloseTo(0, 12);
348
+ });
349
+
350
+ it('rejects invalid input atomically', () => {
351
+ const body = sphere();
352
+
353
+ expect(applyPhysics3DLinearImpulseAtPoint(body, 1, 0, 0, 0, Number.NaN, 0)).toBe(false);
354
+
355
+ expect(body.velocityX).toBe(0);
356
+ expect(body.angularVelocityZ).toBe(0);
357
+ });
358
+ });
359
+
360
+ describe('applyPhysics3DTorque', () => {
361
+ it('accumulates onto the torque vector', () => {
362
+ const body = sphere();
363
+ applyPhysics3DTorque(body, 0, 0, 4);
364
+ expect(body.torqueZ).toBe(4);
365
+ });
366
+
367
+ it('rejects non-finite torque and a body with fixed rotation', () => {
368
+ const body = sphere();
369
+ expect(applyPhysics3DTorque(body, 0, 0, Number.NaN)).toBe(false);
370
+ setPhysics3DBodyFixedRotation(body, true);
371
+ expect(applyPhysics3DTorque(body, 0, 0, 4)).toBe(false);
372
+ expect(body.torqueZ).toBe(0);
373
+ });
374
+ });
375
+
376
+ describe('createPhysics3DCollider', () => {
377
+ it('defaults to a solid, mildly frictional, non-bouncing surface that collides with everything', () => {
378
+ const collider = createPhysics3DCollider(colliderUnitBox());
379
+ expect(collider.material).toEqual({ density: 1, friction: 0.2, restitution: 0 });
380
+ expect(collider.filter).toEqual({ categoryBits: 1, maskBits: 0xffff, groupIndex: 0 });
381
+ expect(collider.sensor).toBe(false);
382
+ });
383
+
384
+ it('takes the material, filter, and sensor flag a caller supplies', () => {
385
+ const collider = createPhysics3DCollider(
386
+ colliderUnitBox(),
387
+ { density: 7, friction: 0.9, restitution: 0.5 },
388
+ { categoryBits: 4, maskBits: 8, groupIndex: -1 },
389
+ true,
390
+ );
391
+ expect(collider.material.density).toBe(7);
392
+ expect(collider.filter.groupIndex).toBe(-1);
393
+ expect(collider.sensor).toBe(true);
394
+ });
395
+
396
+ it('clones the authored shape, so a caller reusing one object does not link two colliders', () => {
397
+ const local = colliderUnitBox();
398
+ const first = createPhysics3DCollider(local);
399
+ const second = createPhysics3DCollider(local);
400
+
401
+ expect(first.local).not.toBe(local);
402
+ expect(first.local).not.toBe(second.local);
403
+ });
404
+
405
+ it('clones hull points rather than aliasing the array', () => {
406
+ const local: CollisionBuiltInShape3D = { kind: 'convex', points: [0, 0, 0, 1, 1, 1] };
407
+ const collider = createPhysics3DCollider(local);
408
+ expect(collider.local.kind === 'convex' && collider.local.points).not.toBe(local.points);
409
+ });
410
+
411
+ it('allocates the world shape ready to be written in place', () => {
412
+ const collider = createPhysics3DCollider(colliderUnitBox());
413
+ // An aabb promotes: rotate one and it is no longer axis-aligned.
414
+ expect(collider.world.kind).toBe('box');
415
+ });
416
+ });
417
+
418
+ describe('createPhysics3DSequentialImpulseConfig', () => {
419
+ it('defaults to warm starting on', () => {
420
+ expect(createPhysics3DSequentialImpulseConfig().warmStarting).toBe(true);
421
+ });
422
+
423
+ it('gives more velocity iterations than position iterations', () => {
424
+ const config = createPhysics3DSequentialImpulseConfig();
425
+ expect(config.velocityIterations).toBeGreaterThan(config.positionIterations);
426
+ });
427
+ });
428
+
429
+ describe('createPhysics3DSolverConfig', () => {
430
+ it('defaults substeps to one, reproducing a single discrete step', () => {
431
+ expect(createPhysics3DSolverConfig().substeps).toBe(1);
432
+ });
433
+
434
+ it('carries the sequential-impulse block as a named sibling rather than flattened', () => {
435
+ const config = createPhysics3DSolverConfig();
436
+ expect(config.sequentialImpulse.velocityIterations).toBeGreaterThan(0);
437
+ expect((config as unknown as Record<string, unknown>).velocityIterations).toBeUndefined();
438
+ });
439
+ });
440
+
441
+ describe('createPhysics3DWorld', () => {
442
+ it('starts empty at the current version', () => {
443
+ const world = createPhysics3DWorld();
444
+ expect(world.version).toBe(Physics3DWorldVersion);
445
+ expect(world.bodies).toHaveLength(0);
446
+ expect(world.contacts).toHaveLength(0);
447
+ });
448
+
449
+ it('defaults gravity to earth along -Y', () => {
450
+ const world = createPhysics3DWorld();
451
+ expect(world.gravityY).toBeLessThan(0);
452
+ expect(world.gravityX).toBe(0);
453
+ expect(world.gravityZ).toBe(0);
454
+ });
455
+
456
+ it('gives each world its own joint solver registry', () => {
457
+ const a = createPhysics3DWorld();
458
+ const b = createPhysics3DWorld();
459
+ a.jointSolvers.set('acme.Test', { prepare: () => {}, solve: () => {} });
460
+ expect(b.jointSolvers.has('acme.Test')).toBe(false);
461
+ });
462
+ });
463
+
464
+ describe('createRigidBody3D', () => {
465
+ it('starts at rest at the origin with an identity orientation', () => {
466
+ const body = createRigidBody3D();
467
+ expect(body.orientationW).toBe(1);
468
+ expect(body.velocityX).toBe(0);
469
+ expect(body.x).toBe(0);
470
+ });
471
+
472
+ it('is immovable until given mass', () => {
473
+ expect(createRigidBody3D().inverseMass).toBe(0);
474
+ });
475
+
476
+ it('defaults to dynamic', () => {
477
+ expect(createRigidBody3D().type).toBe('dynamic');
478
+ });
479
+ });
480
+
481
+ describe('findPhysics3DBody', () => {
482
+ it('returns null for an unknown index rather than throwing', () => {
483
+ expect(findPhysics3DBody(createPhysics3DWorld(), 99)).toBeNull();
484
+ });
485
+ });
486
+
487
+ describe('hydratePhysics3DWorld', () => {
488
+ it('upgrades the pre-collider world shape and clears unserializable solver caches', () => {
489
+ const world = createPhysics3DWorld();
490
+ const first = createRigidBody3D();
491
+ const second = createRigidBody3D();
492
+ addPhysics3DBody(world, first);
493
+ addPhysics3DBody(world, second);
494
+ world.contacts.push(contact(first.index, second.index));
495
+ const legacyWorld = world as unknown as {
496
+ version?: number;
497
+ index?: Physics3DWorld['index'];
498
+ jointEvents?: Physics3DWorld['jointEvents'];
499
+ solver: {
500
+ constraintByContact?: Physics3DWorld['solver']['constraintByContact'];
501
+ constraintByPair?: Map<number, unknown>;
502
+ };
503
+ };
504
+ const legacyBody = first as unknown as { colliders?: RigidBody3D['colliders'] };
505
+ const legacyContact = world.contacts[0] as unknown as { colliderA?: number; colliderB?: number };
506
+ legacyWorld.version = 1;
507
+ delete legacyWorld.index;
508
+ delete legacyWorld.jointEvents;
509
+ delete legacyWorld.solver.constraintByContact;
510
+ legacyWorld.solver.constraintByPair = new Map([[1, {}]]);
511
+ delete legacyBody.colliders;
512
+ delete legacyContact.colliderA;
513
+ delete legacyContact.colliderB;
514
+ delete (world.config as { maxCcdRotationSubsteps?: number }).maxCcdRotationSubsteps;
515
+
516
+ expect(hydratePhysics3DWorld(world)).toBe(true);
517
+
518
+ expect(world.version).toBe(Physics3DWorldVersion);
519
+ expect(world.index).toBeDefined();
520
+ expect(first.colliders).toEqual([]);
521
+ expect(world.contacts[0].colliderA).toBe(0);
522
+ expect(world.contacts[0].colliderB).toBe(0);
523
+ expect(world.jointEvents).toEqual({ broke: [] });
524
+ expect(world.solver.constraintByContact).toBeInstanceOf(Map);
525
+ expect(world.solver.constraintByContact.size).toBe(0);
526
+ expect(world.config.maxCcdRotationSubsteps).toBe(64);
527
+ expect(legacyWorld.solver.constraintByPair).toBeUndefined();
528
+ });
529
+
530
+ it('preserves current values and rejects an unknown future version', () => {
531
+ const current = createPhysics3DWorld();
532
+ current.config.maxCcdSubsteps = 19;
533
+ expect(hydratePhysics3DWorld(current)).toBe(true);
534
+ expect(current.config.maxCcdSubsteps).toBe(19);
535
+
536
+ const future = createPhysics3DWorld();
537
+ future.version = Physics3DWorldVersion + 1;
538
+ expect(hydratePhysics3DWorld(future)).toBe(false);
539
+ expect(future.version).toBe(Physics3DWorldVersion + 1);
540
+ });
541
+ });
542
+
543
+ describe('invalidatePhysics3DCollider', () => {
544
+ it('rebuilds the mass properties after the local shape is edited in place', () => {
545
+ const world = createPhysics3DWorld();
546
+ const body = addColliderTestBody(world);
547
+ const collider = addPhysics3DCollider(world, body, createPhysics3DCollider(colliderUnitBox()));
548
+ expect(body.mass).toBeCloseTo(1, 12);
549
+
550
+ const local = collider.local;
551
+ if (local.kind === 'aabb') {
552
+ local.maxX = 1.5;
553
+ local.minX = -1.5;
554
+ }
555
+ expect(invalidatePhysics3DCollider(world, body, collider)).toBe(true);
556
+
557
+ // Three times as wide, so three times the volume.
558
+ expect(body.mass).toBeCloseTo(3, 12);
559
+ });
560
+
561
+ it('resizes the world shape when the edit changed the hull point count', () => {
562
+ const world = createPhysics3DWorld();
563
+ const body = addColliderTestBody(world);
564
+ const collider = addPhysics3DCollider(world, body, createPhysics3DCollider({ kind: 'convex', points: [0, 0, 0] }));
565
+
566
+ const local = collider.local;
567
+ if (local.kind === 'convex') local.points.push(1, 1, 1);
568
+ invalidatePhysics3DCollider(world, body, collider);
569
+
570
+ expect(collider.world.kind === 'convex' && collider.world.points).toHaveLength(6);
571
+ });
572
+
573
+ it('returns false for a collider the body does not own', () => {
574
+ const world = createPhysics3DWorld();
575
+ expect(
576
+ invalidatePhysics3DCollider(world, addColliderTestBody(world), createPhysics3DCollider(colliderUnitBox())),
577
+ ).toBe(false);
578
+ });
579
+
580
+ it('drops the body contacts, whose lever arms were measured from the old centre of mass', () => {
581
+ const world = createPhysics3DWorld();
582
+ const floor = addColliderTestBody(world, -1);
583
+ addPhysics3DCollider(world, floor, createPhysics3DCollider(colliderUnitBox()));
584
+ const box = addColliderTestBody(world, -0.25);
585
+ const collider = addPhysics3DCollider(world, box, createPhysics3DCollider(colliderUnitBox()));
586
+ buildPhysics3DContacts(world);
587
+ expect(world.contacts).toHaveLength(1);
588
+
589
+ invalidatePhysics3DCollider(world, box, collider);
590
+
591
+ expect(world.contacts).toHaveLength(0);
592
+ });
593
+ });
594
+
595
+ describe('removePhysics3DBody', () => {
596
+ it('wakes a sleeping neighbour whose support was removed', () => {
597
+ const world = createPhysics3DWorld();
598
+ const support = createRigidBody3D('static');
599
+ const sleeper = createRigidBody3D('dynamic');
600
+ addPhysics3DBody(world, support);
601
+ addPhysics3DBody(world, sleeper);
602
+ world.contacts.push(contact(support.index, sleeper.index));
603
+ sleeper.sleeping = true;
604
+ sleeper.sleepTimer = 1;
605
+
606
+ removePhysics3DBody(world, support);
607
+
608
+ expect(sleeper.sleeping).toBe(false);
609
+ expect(sleeper.sleepTimer).toBe(0);
610
+ });
611
+
612
+ it('drops pending contact events that name the removed body', () => {
613
+ const world = createPhysics3DWorld();
614
+ const removed = createRigidBody3D();
615
+ const survivor = createRigidBody3D();
616
+ addPhysics3DBody(world, removed);
617
+ addPhysics3DBody(world, survivor);
618
+ const began = contact(removed.index, survivor.index);
619
+ const ended = contact(removed.index, survivor.index);
620
+ world.events.began.push(began);
621
+ world.events.ended.push(ended);
622
+
623
+ removePhysics3DBody(world, removed);
624
+
625
+ expect(world.events.began).toHaveLength(0);
626
+ expect(world.events.ended).toHaveLength(0);
627
+ });
628
+
629
+ it('releases the ownership of every joint it drops', () => {
630
+ const world = createPhysics3DWorld();
631
+ registerBuiltInPhysics3DJointSolvers(world);
632
+ const anchor = createRigidBody3D('dynamic');
633
+ const hanging = createRigidBody3D('dynamic');
634
+ addPhysics3DBody(world, anchor);
635
+ addPhysics3DBody(world, hanging);
636
+ const joint = addPhysics3DJoint(
637
+ world,
638
+ createPhysics3DBallAndSocketJoint({ bodyA: anchor.index, bodyB: hanging.index }),
639
+ );
640
+
641
+ removePhysics3DBody(world, hanging);
642
+
643
+ // A joint dropped here leaves by the same exits `removePhysics3DJoint` uses. Splicing it out of the
644
+ // array alone leaves a joint that no world holds but that every world still refuses to accept.
645
+ expect(world.joints).toEqual([]);
646
+ expect(() => addPhysics3DJoint(world, joint)).not.toThrow();
647
+ });
648
+
649
+ it('stops suppressing a pair whose joint it just dropped', () => {
650
+ const world = createPhysics3DWorld();
651
+ registerBuiltInPhysics3DJointSolvers(world);
652
+ const first = createRigidBody3D('dynamic');
653
+ const second = createRigidBody3D('dynamic');
654
+ addPhysics3DBody(world, first);
655
+ addPhysics3DBody(world, second);
656
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: first.index, bodyB: second.index }));
657
+ expect(isPhysics3DPairJointSuppressed(world, first.index, second.index)).toBe(true);
658
+
659
+ removePhysics3DBody(world, second);
660
+
661
+ // Leaving the index behind suppresses a pair against a joint that no longer exists, which reads as a
662
+ // contact that silently never reports.
663
+ expect(isPhysics3DPairJointSuppressed(world, first.index, second.index)).toBe(false);
664
+ });
665
+
666
+ it('reports false for a body that is not in the world', () => {
667
+ expect(removePhysics3DBody(createPhysics3DWorld(), createRigidBody3D())).toBe(false);
668
+ });
669
+
670
+ it('drops contacts that named the removed body', () => {
671
+ const world = createPhysics3DWorld();
672
+ const a = createRigidBody3D();
673
+ const b = createRigidBody3D();
674
+ addPhysics3DBody(world, a);
675
+ addPhysics3DBody(world, b);
676
+ world.contacts.push(contact(a.index, b.index));
677
+
678
+ removePhysics3DBody(world, a);
679
+
680
+ expect(world.contacts).toHaveLength(0);
681
+ });
682
+
683
+ it('drops joints that named the removed body', () => {
684
+ const world = createPhysics3DWorld();
685
+ const a = createRigidBody3D();
686
+ const b = createRigidBody3D();
687
+ addPhysics3DBody(world, a);
688
+ addPhysics3DBody(world, b);
689
+ world.joints.push({
690
+ kind: 'Physics3DBallJoint',
691
+ bodyA: a.index,
692
+ bodyB: b.index,
693
+ localAnchorAX: 0,
694
+ localAnchorAY: 0,
695
+ localAnchorAZ: 0,
696
+ localAnchorBX: 0,
697
+ localAnchorBY: 0,
698
+ localAnchorBZ: 0,
699
+ collideConnected: false,
700
+ breakForce: Number.POSITIVE_INFINITY,
701
+ breakTorque: Number.POSITIVE_INFINITY,
702
+ broken: false,
703
+ impulse0: 0,
704
+ impulse1: 0,
705
+ impulse2: 0,
706
+ impulse3: 0,
707
+ impulse4: 0,
708
+ impulse5: 0,
709
+ rAX: 0,
710
+ rAY: 0,
711
+ rAZ: 0,
712
+ rBX: 0,
713
+ rBY: 0,
714
+ rBZ: 0,
715
+ });
716
+
717
+ removePhysics3DBody(world, b);
718
+
719
+ expect(world.joints).toHaveLength(0);
720
+ });
721
+
722
+ it('leaves unrelated contacts in place', () => {
723
+ const world = createPhysics3DWorld();
724
+ const a = createRigidBody3D();
725
+ const b = createRigidBody3D();
726
+ const c = createRigidBody3D();
727
+ addPhysics3DBody(world, a);
728
+ addPhysics3DBody(world, b);
729
+ addPhysics3DBody(world, c);
730
+ world.contacts.push(contact(b.index, c.index));
731
+
732
+ removePhysics3DBody(world, a);
733
+
734
+ expect(world.contacts).toHaveLength(1);
735
+ });
736
+ });
737
+
738
+ describe('removePhysics3DCollider', () => {
739
+ it('detaches the collider and rederives the mass', () => {
740
+ const world = createPhysics3DWorld();
741
+ const body = addColliderTestBody(world);
742
+ const collider = addPhysics3DCollider(world, body, createPhysics3DCollider(colliderUnitBox()));
743
+
744
+ expect(removePhysics3DCollider(world, body, collider)).toBe(true);
745
+
746
+ expect(body.colliders).toEqual([]);
747
+ expect(body.mass).toBe(0);
748
+ expect(body.inverseMass).toBe(0);
749
+ });
750
+
751
+ it('returns false for a collider the body does not own', () => {
752
+ const world = createPhysics3DWorld();
753
+ expect(removePhysics3DCollider(world, addColliderTestBody(world), createPhysics3DCollider(colliderUnitBox()))).toBe(
754
+ false,
755
+ );
756
+ });
757
+
758
+ it('drops the body contacts, because the surviving colliders have been renumbered', () => {
759
+ // A contact stores collider INDICES. Keeping one across a removal would silently point it at whichever
760
+ // piece of geometry shifted into that slot.
761
+ const world = createPhysics3DWorld();
762
+ const floor = addColliderTestBody(world, -1);
763
+ addPhysics3DCollider(world, floor, createPhysics3DCollider(colliderUnitBox()));
764
+ const box = addColliderTestBody(world, -0.25);
765
+ const first = addPhysics3DCollider(world, box, createPhysics3DCollider(colliderUnitBox()));
766
+ addPhysics3DCollider(
767
+ world,
768
+ box,
769
+ createPhysics3DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, minZ: -0.5, maxX: 0.5, maxY: 0.5, maxZ: 0.5 }),
770
+ );
771
+ buildPhysics3DContacts(world);
772
+ expect(world.contacts.length).toBeGreaterThan(0);
773
+
774
+ removePhysics3DCollider(world, box, first);
775
+
776
+ expect(world.contacts).toHaveLength(0);
777
+ });
778
+
779
+ it('lets the same collider be attached to another body once detached', () => {
780
+ const world = createPhysics3DWorld();
781
+ const first = addColliderTestBody(world);
782
+ const collider = addPhysics3DCollider(world, first, createPhysics3DCollider(colliderUnitBox()));
783
+ removePhysics3DCollider(world, first, collider);
784
+
785
+ expect(() => addPhysics3DCollider(world, addColliderTestBody(world), collider)).not.toThrow();
786
+ });
787
+ });
788
+
789
+ describe('setPhysics3DBodyBullet', () => {
790
+ it('changes continuous-collision policy and wakes an owned body', () => {
791
+ const world = createPhysics3DWorld();
792
+ const body = sphere();
793
+ addPhysics3DBody(world, body);
794
+ body.sleeping = true;
795
+
796
+ setPhysics3DBodyBullet(body, true);
797
+
798
+ expect(body.bullet).toBe(true);
799
+ expect(body.sleeping).toBe(false);
800
+ });
801
+
802
+ it('rejects a non-boolean runtime value', () => {
803
+ const body = sphere();
804
+
805
+ expect(setPhysics3DBodyBullet(body, 1 as never)).toBe(false);
806
+ expect(body.bullet).toBe(false);
807
+ });
808
+ });
809
+
810
+ describe('setPhysics3DBodyFixedRotation', () => {
811
+ it('zeroes the inverse inertia and clears angular velocity while keeping inverse mass', () => {
812
+ const body = sphere();
813
+ body.angularVelocityZ = 5;
814
+
815
+ setPhysics3DBodyFixedRotation(body, true);
816
+
817
+ expect(body.inverseInertiaXX).toBe(0);
818
+ expect(body.angularVelocityZ).toBe(0);
819
+ expect(body.inverseMass).toBeGreaterThan(0);
820
+ });
821
+
822
+ it('restores the inverse inertia from the stored forward tensor when released', () => {
823
+ const body = sphere();
824
+ const before = body.inverseInertiaXX;
825
+ setPhysics3DBodyFixedRotation(body, true);
826
+
827
+ setPhysics3DBodyFixedRotation(body, false);
828
+
829
+ expect(body.inverseInertiaXX).toBeCloseTo(before, 10);
830
+ });
831
+
832
+ it('invalidates owned-body constraints whose effective mass just changed', () => {
833
+ const world = createPhysics3DWorld();
834
+ const body = sphere();
835
+ const neighbour = sphere();
836
+ addPhysics3DBody(world, body);
837
+ addPhysics3DBody(world, neighbour);
838
+ world.contacts.push(contact(body.index, neighbour.index));
839
+ neighbour.sleeping = true;
840
+
841
+ setPhysics3DBodyFixedRotation(body, true);
842
+
843
+ expect(world.contacts).toHaveLength(0);
844
+ expect(neighbour.sleeping).toBe(false);
845
+ });
846
+ });
847
+
848
+ describe('setPhysics3DBodySleepEnabled', () => {
849
+ it('wakes the body on either policy transition', () => {
850
+ const body = sphere();
851
+ body.sleeping = true;
852
+ body.sleepTimer = 2;
853
+
854
+ setPhysics3DBodySleepEnabled(body, false);
855
+
856
+ expect(body.sleepEnabled).toBe(false);
857
+ expect(body.sleeping).toBe(false);
858
+ expect(body.sleepTimer).toBe(0);
859
+ });
860
+
861
+ it('rejects a non-boolean runtime value', () => {
862
+ const body = sphere();
863
+
864
+ expect(setPhysics3DBodySleepEnabled(body, 'no' as never)).toBe(false);
865
+ expect(body.sleepEnabled).toBe(true);
866
+ });
867
+ });
868
+
869
+ function contact(bodyA: number, bodyB: number): Physics3DWorld['contacts'][number] {
870
+ return {
871
+ bodyA,
872
+ bodyB,
873
+ colliderA: 0,
874
+ colliderB: 0,
875
+ normalX: 0,
876
+ normalY: 1,
877
+ normalZ: 0,
878
+ pointCount: 0,
879
+ points: [],
880
+ friction: 0.2,
881
+ restitution: 0,
882
+ enabled: true,
883
+ sensor: false,
884
+ touching: true,
885
+ };
886
+ }
887
+
888
+ function sphere(): RigidBody3D {
889
+ const body = createRigidBody3D();
890
+ const data = createPhysics3DMassData();
891
+ computePhysics3DSphereMassData(1, 1, data);
892
+ setRigidBody3DMassData(body, data);
893
+ return body;
894
+ }
895
+
896
+ beforeEach(() => {
897
+ registerBuiltInCollisionSupports3D();
898
+ registerBuiltInCollisionFaceQueries3D();
899
+ });
900
+
901
+ function colliderUnitBox(): CollisionBuiltInShape3D {
902
+ return { kind: 'aabb', minX: -0.5, minY: -0.5, minZ: -0.5, maxX: 0.5, maxY: 0.5, maxZ: 0.5 };
903
+ }
904
+
905
+ function addColliderTestBody(world: Physics3DWorld, y = 0): RigidBody3D {
906
+ const body = createRigidBody3D('dynamic');
907
+ body.y = y;
908
+ addPhysics3DBody(world, body);
909
+ return body;
910
+ }
911
+
912
+ describe('setPhysics3DBodyTransform', () => {
913
+ it('moves the body and refreshes the world inertia', () => {
914
+ const body = sphere();
915
+
916
+ setPhysics3DBodyTransform(body, 1, 2, 3, 0, 0, 0, 1);
917
+
918
+ expect(body.y).toBe(2);
919
+ expect(body.inverseInertiaWorldXX).toBeCloseTo(body.inverseInertiaXX, 10);
920
+ });
921
+
922
+ it('normalizes a quaternion it is handed unnormalized', () => {
923
+ const body = sphere();
924
+
925
+ setPhysics3DBodyTransform(body, 0, 0, 0, 0, 0, 3, 3);
926
+
927
+ const length = Math.hypot(body.orientationX, body.orientationY, body.orientationZ, body.orientationW);
928
+ expect(length).toBeCloseTo(1, 12);
929
+ });
930
+
931
+ it('falls back to identity for a zero quaternion rather than dividing by zero', () => {
932
+ const body = sphere();
933
+
934
+ setPhysics3DBodyTransform(body, 0, 0, 0, 0, 0, 0, 0);
935
+
936
+ expect(body.orientationW).toBe(1);
937
+ });
938
+
939
+ it('rejects a non-finite transform without invalidating the current world state', () => {
940
+ const world = createPhysics3DWorld();
941
+ const body = sphere();
942
+ const neighbour = sphere();
943
+ addPhysics3DBody(world, body);
944
+ addPhysics3DBody(world, neighbour);
945
+ world.contacts.push(contact(body.index, neighbour.index));
946
+
947
+ expect(setPhysics3DBodyTransform(body, Number.NaN, 2, 3, 0, 0, 0, 1)).toBe(false);
948
+
949
+ expect(body.x).toBe(0);
950
+ expect(body.y).toBe(0);
951
+ expect(world.contacts).toHaveLength(1);
952
+ });
953
+
954
+ it('wakes the body', () => {
955
+ const body = sphere();
956
+ body.sleeping = true;
957
+
958
+ setPhysics3DBodyTransform(body, 1, 0, 0, 0, 0, 0, 1);
959
+
960
+ expect(body.sleeping).toBe(false);
961
+ });
962
+
963
+ it('invalidates contacts and joint caches and wakes the other endpoints', () => {
964
+ const world = createPhysics3DWorld();
965
+ registerBuiltInPhysics3DJointSolvers(world);
966
+ const body = sphere();
967
+ const neighbour = sphere();
968
+ addPhysics3DBody(world, body);
969
+ addPhysics3DBody(world, neighbour);
970
+ world.contacts.push(contact(body.index, neighbour.index));
971
+ const joint = addPhysics3DJoint(
972
+ world,
973
+ createPhysics3DBallAndSocketJoint({ bodyA: body.index, bodyB: neighbour.index }),
974
+ );
975
+ joint.impulse0 = 9;
976
+ neighbour.sleeping = true;
977
+
978
+ setPhysics3DBodyTransform(body, 10, 0, 0, 0, 0, 0, 1);
979
+
980
+ expect(world.contacts).toHaveLength(0);
981
+ expect(joint.impulse0).toBe(0);
982
+ expect(neighbour.sleeping).toBe(false);
983
+ });
984
+ });
985
+
986
+ describe('setPhysics3DBodyType', () => {
987
+ it('declines to make a static terrain body movable', () => {
988
+ const body = createRigidBody3D('static');
989
+ body.colliders.push(createPhysics3DCollider(createCollisionTriangleMesh3D([0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 1, 2])));
990
+
991
+ expect(setPhysics3DBodyType(body, 'dynamic')).toBe(false);
992
+ expect(body.type).toBe('static');
993
+ });
994
+
995
+ it('makes a dynamic body immovable when it becomes static', () => {
996
+ const body = sphere();
997
+ body.velocityX = 5;
998
+
999
+ setPhysics3DBodyType(body, 'static');
1000
+
1001
+ expect(body.inverseMass).toBe(0);
1002
+ expect(body.velocityX).toBe(0);
1003
+ });
1004
+
1005
+ it('restores the inverse mass and inertia when a static body becomes dynamic again', () => {
1006
+ const body = sphere();
1007
+ const mass = body.inverseMass;
1008
+ const inertia = body.inverseInertiaXX;
1009
+ setPhysics3DBodyType(body, 'static');
1010
+
1011
+ setPhysics3DBodyType(body, 'dynamic');
1012
+
1013
+ expect(body.inverseMass).toBeCloseTo(mass, 10);
1014
+ expect(body.inverseInertiaXX).toBeCloseTo(inertia, 10);
1015
+ });
1016
+
1017
+ it('keeps velocity when a kinematic body becomes dynamic', () => {
1018
+ const body = createRigidBody3D('kinematic');
1019
+ body.velocityX = 4;
1020
+
1021
+ setPhysics3DBodyType(body, 'dynamic');
1022
+
1023
+ expect(body.velocityX).toBe(4);
1024
+ });
1025
+
1026
+ it('clears the force accumulators on any change', () => {
1027
+ const body = sphere();
1028
+ body.forceX = 9;
1029
+
1030
+ setPhysics3DBodyType(body, 'kinematic');
1031
+
1032
+ expect(body.forceX).toBe(0);
1033
+ });
1034
+
1035
+ it('is a no-op for the type it already has', () => {
1036
+ const body = sphere();
1037
+ body.forceX = 9;
1038
+
1039
+ setPhysics3DBodyType(body, 'dynamic');
1040
+
1041
+ expect(body.forceX).toBe(9);
1042
+ });
1043
+
1044
+ it('rejects an unknown runtime body type', () => {
1045
+ const body = sphere();
1046
+
1047
+ expect(setPhysics3DBodyType(body, 'ghost' as never)).toBe(false);
1048
+ expect(body.type).toBe('dynamic');
1049
+ });
1050
+
1051
+ it('invalidates owned-body contacts whose mass participation just changed', () => {
1052
+ const world = createPhysics3DWorld();
1053
+ const body = sphere();
1054
+ const neighbour = sphere();
1055
+ addPhysics3DBody(world, body);
1056
+ addPhysics3DBody(world, neighbour);
1057
+ world.contacts.push(contact(body.index, neighbour.index));
1058
+ neighbour.sleeping = true;
1059
+
1060
+ setPhysics3DBodyType(body, 'static');
1061
+
1062
+ expect(world.contacts).toHaveLength(0);
1063
+ expect(neighbour.sleeping).toBe(false);
1064
+ });
1065
+ });
1066
+
1067
+ describe('wakePhysics3DBody', () => {
1068
+ it('clears the sleep flag and timer', () => {
1069
+ const body = sphere();
1070
+ body.sleeping = true;
1071
+ body.sleepTimer = 3;
1072
+
1073
+ wakePhysics3DBody(body);
1074
+
1075
+ expect(body.sleeping).toBe(false);
1076
+ expect(body.sleepTimer).toBe(0);
1077
+ });
1078
+
1079
+ it('is a no-op for a static body, which is neither awake nor asleep', () => {
1080
+ const body = createRigidBody3D('static');
1081
+ body.sleepTimer = 3;
1082
+
1083
+ wakePhysics3DBody(body);
1084
+
1085
+ expect(body.sleepTimer).toBe(3);
1086
+ });
1087
+ });
1088
+
1089
+ describe('writeRigidBody3DWorldCenter', () => {
1090
+ it('returns the position when the centre of mass is the origin', () => {
1091
+ const body = sphere();
1092
+ body.x = 1;
1093
+ body.y = 2;
1094
+ body.z = 3;
1095
+ const out = [0, 0, 0];
1096
+
1097
+ writeRigidBody3DWorldCenter(body, out);
1098
+
1099
+ expect(out).toEqual([1, 2, 3]);
1100
+ });
1101
+
1102
+ it('offsets by the local centre at identity orientation', () => {
1103
+ const body = sphere();
1104
+ body.x = 1;
1105
+ body.centerY = 2;
1106
+ const out = [0, 0, 0];
1107
+
1108
+ writeRigidBody3DWorldCenter(body, out);
1109
+
1110
+ expect(out[0]).toBeCloseTo(1, 12);
1111
+ expect(out[1]).toBeCloseTo(2, 12);
1112
+ });
1113
+
1114
+ it('rotates the local centre by the body orientation', () => {
1115
+ const body = sphere();
1116
+ body.centerX = 1;
1117
+ // A quarter turn about z carries local +x onto world +y.
1118
+ body.orientationZ = Math.SQRT1_2;
1119
+ body.orientationW = Math.SQRT1_2;
1120
+ const out = [0, 0, 0];
1121
+
1122
+ writeRigidBody3DWorldCenter(body, out);
1123
+
1124
+ expect(out[0]).toBeCloseTo(0, 10);
1125
+ expect(out[1]).toBeCloseTo(1, 10);
1126
+ });
1127
+ });