@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,679 @@
1
+ import {
2
+ createCollisionTriangleMesh3D,
3
+ registerBuiltInCollisionFaceQueries3D,
4
+ registerBuiltInCollisionSupports3D,
5
+ } from '@flighthq/collision/contract';
6
+ import type { CollisionBuiltInShape3D, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
7
+ import { beforeEach, describe, expect, it } from 'vitest';
8
+
9
+ import {
10
+ hasActivePhysics3DBullet,
11
+ integratePhysics3DContinuous,
12
+ writePhysics3DRotationalCcdEnvelope,
13
+ } from './continuous';
14
+ import { stepPhysics3D } from './step';
15
+ import {
16
+ addPhysics3DBody,
17
+ addPhysics3DCollider,
18
+ createPhysics3DCollider,
19
+ createPhysics3DWorld,
20
+ createRigidBody3D,
21
+ } from './world';
22
+
23
+ beforeEach(() => {
24
+ registerBuiltInCollisionSupports3D();
25
+ registerBuiltInCollisionFaceQueries3D();
26
+ });
27
+
28
+ // A wall thin enough that a fast body crosses it entirely within one step. This is the whole subject.
29
+ const THIN_WALL: CollisionBuiltInShape3D = {
30
+ kind: 'aabb',
31
+ minX: -0.05,
32
+ minY: -5,
33
+ minZ: -5,
34
+ maxX: 0.05,
35
+ maxY: 5,
36
+ maxZ: 5,
37
+ };
38
+
39
+ function addWall(world: Physics3DWorld): RigidBody3D {
40
+ const wall = createRigidBody3D('static');
41
+ addPhysics3DBody(world, wall);
42
+ addPhysics3DCollider(world, wall, createPhysics3DCollider(THIN_WALL));
43
+ return wall;
44
+ }
45
+
46
+ function addBullet(world: Physics3DWorld, x: number, speed: number, bullet = true): RigidBody3D {
47
+ const body = createRigidBody3D('dynamic');
48
+ body.x = x;
49
+ body.velocityX = speed;
50
+ body.gravityScale = 0;
51
+ body.bullet = bullet;
52
+ addPhysics3DBody(world, body);
53
+ addPhysics3DCollider(world, body, createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 0.1 }));
54
+ return body;
55
+ }
56
+
57
+ function continuousWorld(): Physics3DWorld {
58
+ const world = createPhysics3DWorld();
59
+ world.gravityY = 0;
60
+ world.config.continuousCollision = true;
61
+ return world;
62
+ }
63
+
64
+ describe('hasActivePhysics3DBullet', () => {
65
+ it('reports false for a world with no flagged body', () => {
66
+ const world = continuousWorld();
67
+ addWall(world);
68
+ addBullet(world, -5, 100, false);
69
+ expect(hasActivePhysics3DBullet(world)).toBe(false);
70
+ });
71
+
72
+ it('reports true once a dynamic body asks for it', () => {
73
+ const world = continuousWorld();
74
+ expect(hasActivePhysics3DBullet(world)).toBe(false);
75
+ addBullet(world, -5, 100);
76
+ expect(hasActivePhysics3DBullet(world)).toBe(true);
77
+ });
78
+
79
+ it('ignores a sleeping body, which is not going anywhere', () => {
80
+ const world = continuousWorld();
81
+ const body = addBullet(world, -5, 100);
82
+ body.sleeping = true;
83
+ expect(hasActivePhysics3DBullet(world)).toBe(false);
84
+ });
85
+
86
+ it('ignores a static body, which cannot be a bullet', () => {
87
+ const world = continuousWorld();
88
+ const wall = addWall(world);
89
+ wall.bullet = true;
90
+ expect(hasActivePhysics3DBullet(world)).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe('integratePhysics3DContinuous', () => {
95
+ it('stops a bullet at an accelerated static triangle mesh', () => {
96
+ const world = continuousWorld();
97
+ const wall = createRigidBody3D('static');
98
+ wall.colliders.push(
99
+ createPhysics3DCollider(
100
+ createCollisionTriangleMesh3D([0, -5, -5, 0, 5, -5, 0, 5, 5, 0, -5, 5], [0, 1, 2, 0, 2, 3]),
101
+ ),
102
+ );
103
+ addPhysics3DBody(world, wall);
104
+ const bullet = addBullet(world, -5, 600);
105
+
106
+ stepPhysics3D(world, 1 / 60);
107
+
108
+ expect(bullet.x).toBeLessThan(0);
109
+ });
110
+
111
+ it('deflects a spinning bullet that would cross a peg between its start and end orientations', () => {
112
+ const world = continuousWorld();
113
+ world.config.maxCcdRotationSubsteps = 128;
114
+ const peg = createRigidBody3D('static');
115
+ addPhysics3DBody(world, peg);
116
+ addPhysics3DCollider(world, peg, createPhysics3DCollider({ kind: 'sphere', x: 4, y: 0, z: 0, radius: 0.25 }));
117
+ const blade = createRigidBody3D('dynamic');
118
+ blade.orientationZ = -Math.sin(Math.PI / 8);
119
+ blade.orientationW = Math.cos(Math.PI / 8);
120
+ blade.angularVelocityZ = 120;
121
+ blade.gravityScale = 0;
122
+ blade.bullet = true;
123
+ addPhysics3DBody(world, blade);
124
+ addPhysics3DCollider(
125
+ world,
126
+ blade,
127
+ createPhysics3DCollider({ kind: 'aabb', minX: -5, minY: -0.1, minZ: -0.1, maxX: 5, maxY: 0.1, maxZ: 0.1 }),
128
+ );
129
+
130
+ stepPhysics3D(world, 1 / 60);
131
+
132
+ expect(Math.abs(blade.y)).toBeGreaterThan(0.1);
133
+ expect(Math.abs(blade.angularVelocityZ)).toBeLessThan(100);
134
+ });
135
+
136
+ it('allows the same rotational crossing when the angular CCD budget is zero', () => {
137
+ const world = continuousWorld();
138
+ world.config.maxCcdRotationSubsteps = 0;
139
+ const peg = createRigidBody3D('static');
140
+ addPhysics3DBody(world, peg);
141
+ addPhysics3DCollider(world, peg, createPhysics3DCollider({ kind: 'sphere', x: 4, y: 0, z: 0, radius: 0.25 }));
142
+ const blade = createRigidBody3D('dynamic');
143
+ blade.orientationZ = -Math.sin(Math.PI / 8);
144
+ blade.orientationW = Math.cos(Math.PI / 8);
145
+ blade.angularVelocityZ = 120;
146
+ blade.gravityScale = 0;
147
+ blade.bullet = true;
148
+ addPhysics3DBody(world, blade);
149
+ addPhysics3DCollider(
150
+ world,
151
+ blade,
152
+ createPhysics3DCollider({ kind: 'aabb', minX: -5, minY: -0.1, minZ: -0.1, maxX: 5, maxY: 0.1, maxZ: 0.1 }),
153
+ );
154
+
155
+ stepPhysics3D(world, 1 / 60);
156
+
157
+ expect(blade.y).toBe(0);
158
+ expect(blade.angularVelocityZ).toBeCloseTo(120, 9);
159
+ expect(blade.orientationZ).toBeGreaterThan(0.3);
160
+ });
161
+
162
+ it('keeps analytic linear CCD active when a fast bullet also has slight spin', () => {
163
+ const world = continuousWorld();
164
+ addWall(world);
165
+ const bullet = createRigidBody3D('dynamic');
166
+ bullet.x = -5;
167
+ bullet.velocityX = 600;
168
+ bullet.angularVelocityZ = 0.01;
169
+ bullet.gravityScale = 0;
170
+ bullet.bullet = true;
171
+ addPhysics3DBody(world, bullet);
172
+ addPhysics3DCollider(
173
+ world,
174
+ bullet,
175
+ createPhysics3DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, minZ: -0.5, maxX: 0.5, maxY: 0.5, maxZ: 0.5 }),
176
+ );
177
+
178
+ stepPhysics3D(world, 1 / 60);
179
+
180
+ expect(bullet.x).toBeLessThan(0);
181
+ });
182
+
183
+ it('STOPS A BULLET AT A WALL IT WOULD OTHERWISE PASS THROUGH', () => {
184
+ // The claim the whole path exists for. At 600 units/second and a 1/60 step, the body moves 10 units
185
+ // in one step and the wall is 0.1 thick: discretely it is on one side before and the other side after,
186
+ // overlapping at neither end.
187
+ const world = continuousWorld();
188
+ addWall(world);
189
+ const bullet = addBullet(world, -5, 600);
190
+
191
+ stepPhysics3D(world, 1 / 60);
192
+
193
+ expect(bullet.x).toBeLessThan(0);
194
+ expect(bullet.x).toBeGreaterThan(-1);
195
+ });
196
+
197
+ it('lets the same bullet through when continuous collision is off', () => {
198
+ // The control. Without this the test above could pass for a reason unrelated to CCD — a body that
199
+ // never moved would also satisfy it.
200
+ const world = continuousWorld();
201
+ world.config.continuousCollision = false;
202
+ addWall(world);
203
+ const bullet = addBullet(world, -5, 600);
204
+
205
+ stepPhysics3D(world, 1 / 60);
206
+
207
+ expect(bullet.x).toBeGreaterThan(4);
208
+ });
209
+
210
+ it('lets the bullet through when the BODY has not asked, even with the world configured', () => {
211
+ const world = continuousWorld();
212
+ addWall(world);
213
+ const ordinary = addBullet(world, -5, 600, false);
214
+
215
+ stepPhysics3D(world, 1 / 60);
216
+
217
+ expect(ordinary.x).toBeGreaterThan(4);
218
+ });
219
+
220
+ it('removes the approach velocity at the impact', () => {
221
+ const world = continuousWorld();
222
+ addWall(world);
223
+ const bullet = addBullet(world, -5, 600);
224
+
225
+ stepPhysics3D(world, 1 / 60);
226
+
227
+ expect(bullet.velocityX).toBeLessThan(1);
228
+ });
229
+
230
+ it('publishes a persistent contact and runs both hooks in the impact step', () => {
231
+ const world = continuousWorld();
232
+ addWall(world);
233
+ const bullet = addBullet(world, -5, 600);
234
+ const phases: string[] = [];
235
+ world.contactHooks.preSolve = (_world, contact) => {
236
+ phases.push('pre');
237
+ expect(contact.bodyA).toBeLessThan(contact.bodyB);
238
+ expect(contact.pointCount).toBe(1);
239
+ expect(contact.points[0].depth).toBe(0);
240
+ };
241
+ world.contactHooks.postSolve = (_world, contact) => {
242
+ phases.push('post');
243
+ expect(contact.touching).toBe(true);
244
+ };
245
+
246
+ stepPhysics3D(world, 1 / 60);
247
+
248
+ expect(phases).toEqual(['pre', 'post']);
249
+ expect(world.events.began).toHaveLength(1);
250
+ expect(world.events.ended).toHaveLength(0);
251
+ expect(world.contacts).toHaveLength(1);
252
+ expect(world.contacts[0]).toBe(world.events.began[0]);
253
+ expect(world.contacts[0].bodyA).not.toBe(bullet.index);
254
+ expect(world.contacts[0].bodyB).toBe(bullet.index);
255
+ });
256
+
257
+ it('lets an impact pre-solve hook disable resolution without losing its contact event', () => {
258
+ const world = continuousWorld();
259
+ addWall(world);
260
+ const bullet = addBullet(world, -5, 600);
261
+ world.contactHooks.preSolve = (_world, contact) => {
262
+ contact.enabled = false;
263
+ };
264
+
265
+ stepPhysics3D(world, 1 / 60);
266
+
267
+ expect(bullet.x).toBeGreaterThan(4);
268
+ expect(world.events.began).toHaveLength(1);
269
+ expect(world.contacts).toHaveLength(1);
270
+ expect(world.contacts[0].enabled).toBe(false);
271
+ });
272
+
273
+ it('uses restitution selected by the impact pre-solve hook', () => {
274
+ const world = continuousWorld();
275
+ addWall(world);
276
+ const bullet = addBullet(world, -5, 600);
277
+ world.contactHooks.preSolve = (_inner, contact) => {
278
+ contact.restitution = 1;
279
+ };
280
+
281
+ stepPhysics3D(world, 1 / 60);
282
+
283
+ expect(bullet.velocityX).toBeLessThan(-500);
284
+ });
285
+
286
+ it('retains the TOI contact and restores its overrides when a late pre-solve hook throws', () => {
287
+ const world = continuousWorld();
288
+ addWall(world);
289
+ const bullet = addBullet(world, -5, 600);
290
+ const error = new Error('impact hook failed');
291
+ world.contactHooks.preSolve = (_inner, contact) => {
292
+ contact.friction = 99;
293
+ throw error;
294
+ };
295
+
296
+ expect(() => stepPhysics3D(world, 1 / 60)).toThrow(error);
297
+ expect(bullet.x).toBeGreaterThan(-5);
298
+ expect(bullet.x).toBeLessThan(0);
299
+ expect(world.contacts).toHaveLength(1);
300
+ expect(world.events.began).toEqual([world.contacts[0]]);
301
+ expect(world.contacts[0].friction).not.toBe(99);
302
+
303
+ world.contactHooks.preSolve = null;
304
+ expect(() => stepPhysics3D(world, 1 / 60)).not.toThrow();
305
+ });
306
+
307
+ it('applies Coulomb friction at TOI instead of preserving all tangential speed', () => {
308
+ function run(friction: number): RigidBody3D {
309
+ const world = continuousWorld();
310
+ const material = { density: 1, friction, restitution: 0 };
311
+ const wall = createRigidBody3D('static');
312
+ addPhysics3DBody(world, wall);
313
+ addPhysics3DCollider(world, wall, createPhysics3DCollider(THIN_WALL, material));
314
+ const bullet = createRigidBody3D('dynamic');
315
+ bullet.x = -5;
316
+ bullet.velocityX = 600;
317
+ bullet.velocityY = 60;
318
+ bullet.gravityScale = 0;
319
+ bullet.bullet = true;
320
+ addPhysics3DBody(world, bullet);
321
+ addPhysics3DCollider(
322
+ world,
323
+ bullet,
324
+ createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 0.1 }, material),
325
+ );
326
+ stepPhysics3D(world, 1 / 60);
327
+ return bullet;
328
+ }
329
+
330
+ const frictionless = run(0);
331
+ const gripping = run(1);
332
+ // GJK's first-touch normal may contain a small tangential component at its tolerance boundary, so
333
+ // the zero-friction control is deliberately a behavioural bound rather than a bit-exact 60 m/s.
334
+ expect(Math.abs(frictionless.velocityY)).toBeGreaterThan(59);
335
+ expect(Math.abs(gripping.velocityY)).toBeLessThan(1);
336
+ expect(Math.abs(gripping.velocityY)).toBeLessThan(Math.abs(frictionless.velocityY) * 0.02);
337
+ });
338
+
339
+ it('reports the end transition on the step after a restitutive impact separates', () => {
340
+ const world = continuousWorld();
341
+ const material = { density: 1, friction: 0, restitution: 1 };
342
+ const wall = createRigidBody3D('static');
343
+ addPhysics3DBody(world, wall);
344
+ addPhysics3DCollider(world, wall, createPhysics3DCollider(THIN_WALL, material));
345
+ const bullet = createRigidBody3D('dynamic');
346
+ bullet.x = -5;
347
+ bullet.velocityX = 600;
348
+ bullet.gravityScale = 0;
349
+ bullet.bullet = true;
350
+ addPhysics3DBody(world, bullet);
351
+ addPhysics3DCollider(
352
+ world,
353
+ bullet,
354
+ createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 0.1 }, material),
355
+ );
356
+
357
+ stepPhysics3D(world, 1 / 60);
358
+ const impact = world.events.began[0];
359
+ stepPhysics3D(world, 1 / 60);
360
+
361
+ expect(world.events.began).toHaveLength(0);
362
+ expect(world.events.ended).toEqual([impact]);
363
+ expect(world.contacts).toHaveLength(0);
364
+ });
365
+
366
+ it('bounces a restitutive bullet back off the wall', () => {
367
+ const world = continuousWorld();
368
+ const wall = createRigidBody3D('static');
369
+ addPhysics3DBody(world, wall);
370
+ addPhysics3DCollider(
371
+ world,
372
+ wall,
373
+ createPhysics3DCollider(THIN_WALL, { density: 1, friction: 0.2, restitution: 0.8 }),
374
+ );
375
+ const bullet = createRigidBody3D('dynamic');
376
+ bullet.x = -5;
377
+ bullet.velocityX = 600;
378
+ bullet.gravityScale = 0;
379
+ bullet.bullet = true;
380
+ addPhysics3DBody(world, bullet);
381
+ addPhysics3DCollider(
382
+ world,
383
+ bullet,
384
+ createPhysics3DCollider(
385
+ { kind: 'sphere', x: 0, y: 0, z: 0, radius: 0.1 },
386
+ { density: 1, friction: 0.2, restitution: 0.8 },
387
+ ),
388
+ );
389
+
390
+ stepPhysics3D(world, 1 / 60);
391
+
392
+ expect(bullet.velocityX).toBeLessThan(0);
393
+ });
394
+
395
+ it('keeps a bullet on the near side over many steps', () => {
396
+ const world = continuousWorld();
397
+ addWall(world);
398
+ const bullet = addBullet(world, -5, 600);
399
+
400
+ for (let i = 0; i < 120; i += 1) stepPhysics3D(world, 1 / 60);
401
+
402
+ // Never tunnels on a later step either: the contact solver takes over once the pair is touching.
403
+ expect(bullet.x).toBeLessThan(0.2);
404
+ });
405
+
406
+ it('lets a bullet fly freely when nothing is in its way', () => {
407
+ const world = continuousWorld();
408
+ const bullet = addBullet(world, -5, 600);
409
+
410
+ stepPhysics3D(world, 1 / 60);
411
+
412
+ expect(bullet.x).toBeCloseTo(5, 6);
413
+ });
414
+
415
+ it('does not stop a bullet passing a wall it misses', () => {
416
+ // Offset far off the wall's face in Y, so the swept broadphase may still pair them but the sweep must
417
+ // not report an impact.
418
+ const world = continuousWorld();
419
+ addWall(world);
420
+ const bullet = addBullet(world, -5, 600);
421
+ bullet.y = 20;
422
+
423
+ stepPhysics3D(world, 1 / 60);
424
+
425
+ expect(bullet.x).toBeCloseTo(5, 6);
426
+ });
427
+
428
+ it('flies straight through a SENSOR rather than stopping on it', () => {
429
+ // A trigger volume reports overlaps and resolves nothing, so halting the world at one would stop a
430
+ // bullet that should pass.
431
+ const world = continuousWorld();
432
+ const trigger = createRigidBody3D('static');
433
+ addPhysics3DBody(world, trigger);
434
+ addPhysics3DCollider(world, trigger, createPhysics3DCollider(THIN_WALL, undefined, undefined, true));
435
+ const bullet = addBullet(world, -5, 600);
436
+ let preSolveCount = 0;
437
+ let postSolveCount = 0;
438
+ world.contactHooks.preSolve = () => {
439
+ preSolveCount += 1;
440
+ };
441
+ world.contactHooks.postSolve = () => {
442
+ postSolveCount += 1;
443
+ };
444
+
445
+ stepPhysics3D(world, 1 / 60);
446
+
447
+ expect(bullet.x).toBeGreaterThan(4);
448
+ expect(preSolveCount).toBe(0);
449
+ expect(postSolveCount).toBe(0);
450
+ expect(world.events.began).toHaveLength(1);
451
+ expect(world.events.ended).toHaveLength(0);
452
+ expect(world.contacts).toHaveLength(1);
453
+ expect(world.contacts[0]).toBe(world.events.began[0]);
454
+ expect(world.contacts[0].sensor).toBe(true);
455
+
456
+ const crossing = world.contacts[0];
457
+ stepPhysics3D(world, 1 / 60);
458
+
459
+ expect(world.events.began).toHaveLength(0);
460
+ expect(world.events.ended).toEqual([crossing]);
461
+ expect(world.contacts).toHaveLength(0);
462
+ });
463
+
464
+ it('pairs a sensor begin and end inside one public step when a later substep proves the crossing', () => {
465
+ const world = continuousWorld();
466
+ world.config.substeps = 2;
467
+ const trigger = createRigidBody3D('static');
468
+ addPhysics3DBody(world, trigger);
469
+ addPhysics3DCollider(world, trigger, createPhysics3DCollider(THIN_WALL, undefined, undefined, true));
470
+ addBullet(world, -5, 1200);
471
+
472
+ stepPhysics3D(world, 1 / 60);
473
+
474
+ expect(world.events.began).toHaveLength(1);
475
+ expect(world.events.ended).toHaveLength(1);
476
+ expect(world.events.ended[0]).toBe(world.events.began[0]);
477
+ expect(world.contacts).toHaveLength(0);
478
+ });
479
+
480
+ it('does not report a sensor behind a solid impact the bullet never reaches', () => {
481
+ const world = continuousWorld();
482
+ addWall(world);
483
+ const trigger = createRigidBody3D('static');
484
+ trigger.x = 3;
485
+ addPhysics3DBody(world, trigger);
486
+ addPhysics3DCollider(world, trigger, createPhysics3DCollider(THIN_WALL, undefined, undefined, true));
487
+ const bullet = addBullet(world, -5, 600);
488
+
489
+ stepPhysics3D(world, 1 / 60);
490
+
491
+ expect(bullet.x).toBeLessThan(0);
492
+ expect(world.events.began.some((contact) => contact.sensor)).toBe(false);
493
+ expect(world.contacts.every((contact) => !contact.sensor)).toBe(true);
494
+ });
495
+
496
+ it('ignores a wall the collision filter excludes', () => {
497
+ const world = continuousWorld();
498
+ const wall = createRigidBody3D('static');
499
+ addPhysics3DBody(world, wall);
500
+ addPhysics3DCollider(
501
+ world,
502
+ wall,
503
+ createPhysics3DCollider(THIN_WALL, undefined, { categoryBits: 2, maskBits: 2, groupIndex: 0 }),
504
+ );
505
+ const bullet = createRigidBody3D('dynamic');
506
+ bullet.x = -5;
507
+ bullet.velocityX = 600;
508
+ bullet.gravityScale = 0;
509
+ bullet.bullet = true;
510
+ addPhysics3DBody(world, bullet);
511
+ addPhysics3DCollider(
512
+ world,
513
+ bullet,
514
+ createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 0.1 }, undefined, {
515
+ categoryBits: 1,
516
+ maskBits: 1,
517
+ groupIndex: 0,
518
+ }),
519
+ );
520
+
521
+ stepPhysics3D(world, 1 / 60);
522
+
523
+ expect(bullet.x).toBeGreaterThan(4);
524
+ });
525
+
526
+ it('advances a non-bullet body normally alongside a bullet', () => {
527
+ // The continuous path integrates EVERY awake body, not only the flagged one, so an ordinary body in
528
+ // the same world must still move exactly as far as it would have.
529
+ const world = continuousWorld();
530
+ addWall(world);
531
+ addBullet(world, -5, 600);
532
+ const ordinary = createRigidBody3D('dynamic');
533
+ ordinary.y = 30;
534
+ ordinary.velocityY = 6;
535
+ ordinary.gravityScale = 0;
536
+ addPhysics3DBody(world, ordinary);
537
+ addPhysics3DCollider(world, ordinary, createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 1 }));
538
+
539
+ stepPhysics3D(world, 1 / 60);
540
+
541
+ expect(ordinary.y).toBeCloseTo(30 + 6 / 60, 6);
542
+ });
543
+
544
+ it('leaves the broadphase at CURRENT bounds after running, not swept ones', () => {
545
+ // The swept pass widens the index and must restore it. A query made after a step would otherwise
546
+ // report a body as a candidate across the whole volume it travelled.
547
+ const world = continuousWorld();
548
+ addWall(world);
549
+ addBullet(world, -5, 600);
550
+ stepPhysics3D(world, 1 / 60);
551
+
552
+ const ids: number[] = [];
553
+ world.index.querySpatialPoint(-4.9, 0, 0, ids);
554
+ expect(ids).toHaveLength(0);
555
+ });
556
+
557
+ it('GIVES A SQUARELY-STRUCK BODY NO SPIN', () => {
558
+ // The invariant behind resolving the impact linearly. A box driven straight into a flat wall is a
559
+ // symmetric collision and must come away with no rotation. The impact does carry a contact point, but
560
+ // where two faces meet it names a CORNER of the shared region, and using that as a lever arm would
561
+ // spin the box about it. If a future revision gives the impulse an angular term, this is what tells
562
+ // it that a single witness is not enough and the case needs a manifold.
563
+ const world = continuousWorld();
564
+ addWall(world);
565
+ const bullet = createRigidBody3D('dynamic');
566
+ bullet.x = -5;
567
+ bullet.velocityX = 600;
568
+ bullet.gravityScale = 0;
569
+ bullet.bullet = true;
570
+ addPhysics3DBody(world, bullet);
571
+ addPhysics3DCollider(
572
+ world,
573
+ bullet,
574
+ createPhysics3DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, minZ: -0.5, maxX: 0.5, maxY: 0.5, maxZ: 0.5 }),
575
+ );
576
+
577
+ stepPhysics3D(world, 1 / 60);
578
+
579
+ expect(bullet.x).toBeLessThan(0);
580
+ expect(Math.hypot(bullet.angularVelocityX, bullet.angularVelocityY, bullet.angularVelocityZ)).toBeCloseTo(0, 9);
581
+ });
582
+
583
+ it('runs directly without a step, for a caller assembling its own loop', () => {
584
+ const world = continuousWorld();
585
+ addWall(world);
586
+ const bullet = addBullet(world, -5, 600);
587
+
588
+ integratePhysics3DContinuous(world, 1 / 60);
589
+
590
+ expect(bullet.x).toBeLessThan(0);
591
+ });
592
+
593
+ it('keeps the world mutation boundary around a direct custom-loop impact hook', () => {
594
+ const world = continuousWorld();
595
+ addWall(world);
596
+ addBullet(world, -5, 600);
597
+ world.contactHooks.preSolve = (inner) => addPhysics3DBody(inner, createRigidBody3D('dynamic'));
598
+
599
+ expect(() => integratePhysics3DContinuous(world, 1 / 60)).toThrow(
600
+ 'Cannot mutate a physics world while it is stepping',
601
+ );
602
+ expect(world.bodies).toHaveLength(2);
603
+ });
604
+
605
+ it('rejects recursive continuous integration from an impact hook', () => {
606
+ const world = continuousWorld();
607
+ addWall(world);
608
+ addBullet(world, -5, 600);
609
+ world.contactHooks.preSolve = (inner) => integratePhysics3DContinuous(inner, 1 / 60);
610
+
611
+ expect(() => integratePhysics3DContinuous(world, 1 / 60)).toThrow(
612
+ 'Cannot integrate a physics world recursively while it is stepping',
613
+ );
614
+ });
615
+ });
616
+
617
+ describe('writePhysics3DRotationalCcdEnvelope', () => {
618
+ it('reports the one-degree target when the budget can cover the angular travel', () => {
619
+ const out = {
620
+ angularTravel: 0,
621
+ sampleCount: 0,
622
+ maxAngularIncrement: 0,
623
+ maxPointArcTravel: 0,
624
+ targetIncrementMet: false,
625
+ };
626
+ expect(writePhysics3DRotationalCcdEnvelope(120, 5, 1 / 60, 128, out)).toBe(true);
627
+ expect(out.angularTravel).toBeCloseTo(2, 12);
628
+ expect(out.sampleCount).toBe(115);
629
+ expect(out.maxAngularIncrement).toBeLessThanOrEqual(Math.PI / 180);
630
+ expect(out.maxPointArcTravel).toBeCloseTo((2 / 115) * 5, 12);
631
+ expect(out.targetIncrementMet).toBe(true);
632
+ });
633
+
634
+ it('publishes the wider angular and spatial gap when the hard budget binds', () => {
635
+ const out = {
636
+ angularTravel: 0,
637
+ sampleCount: 0,
638
+ maxAngularIncrement: 0,
639
+ maxPointArcTravel: 0,
640
+ targetIncrementMet: true,
641
+ };
642
+ expect(writePhysics3DRotationalCcdEnvelope(120, 5, 1 / 60, 10, out)).toBe(true);
643
+ expect(out.sampleCount).toBe(10);
644
+ expect(out.maxAngularIncrement).toBeCloseTo(0.2, 12);
645
+ expect(out.maxPointArcTravel).toBeCloseTo(1, 12);
646
+ expect(out.targetIncrementMet).toBe(false);
647
+ });
648
+
649
+ it('makes a disabled rotational lane explicit and rejects invalid authoring inputs', () => {
650
+ const out = {
651
+ angularTravel: 0,
652
+ sampleCount: 0,
653
+ maxAngularIncrement: 0,
654
+ maxPointArcTravel: 0,
655
+ targetIncrementMet: true,
656
+ };
657
+ expect(writePhysics3DRotationalCcdEnvelope(10, 2, 1 / 60, 0, out)).toBe(true);
658
+ expect(out.sampleCount).toBe(0);
659
+ expect(out.maxAngularIncrement).toBe(Number.POSITIVE_INFINITY);
660
+ expect(out.maxPointArcTravel).toBe(Number.POSITIVE_INFINITY);
661
+ expect(out.targetIncrementMet).toBe(false);
662
+ expect(writePhysics3DRotationalCcdEnvelope(-1, 2, 1 / 60, 10, out)).toBe(false);
663
+ expect(out).toEqual({
664
+ angularTravel: 0,
665
+ sampleCount: 0,
666
+ maxAngularIncrement: 0,
667
+ maxPointArcTravel: 0,
668
+ targetIncrementMet: false,
669
+ });
670
+ expect(writePhysics3DRotationalCcdEnvelope(Number.MAX_VALUE, 2, 2, 10, out)).toBe(false);
671
+ expect(out).toEqual({
672
+ angularTravel: 0,
673
+ sampleCount: 0,
674
+ maxAngularIncrement: 0,
675
+ maxPointArcTravel: 0,
676
+ targetIncrementMet: false,
677
+ });
678
+ });
679
+ });