@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,142 @@
1
+ import type { Physics3DWorld } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createPhysics3DContact } from './contacts';
5
+ import { explainPhysics3DStep } from './explainPhysics3DStep';
6
+ import { createPhysics3DBallAndSocketJoint } from './jointFactories';
7
+ import { addPhysics3DJoint } from './jointRegistry';
8
+ import { stepPhysics3D } from './step';
9
+ import {
10
+ addPhysics3DBody,
11
+ addPhysics3DCollider,
12
+ createPhysics3DCollider,
13
+ createPhysics3DWorld,
14
+ createRigidBody3D,
15
+ } from './world';
16
+
17
+ describe('explainPhysics3DStep', () => {
18
+ it('reports a ready world', () => {
19
+ const explanation = explainPhysics3DStep(createTestWorld(), 1 / 60);
20
+
21
+ expect(explanation.status).toBe('ready');
22
+ expect(explanation.timestepValid).toBe(true);
23
+ expect(explanation.bodyStateValid).toBe(true);
24
+ });
25
+
26
+ it('names the timestep as the fault, and only it', () => {
27
+ const explanation = explainPhysics3DStep(createTestWorld(), 0);
28
+
29
+ expect(explanation.status).toBe('invalid-step');
30
+ expect(explanation.timestepValid).toBe(false);
31
+ expect(explanation.bodyStateValid).toBe(true);
32
+ expect(explanation.solverConfigValid).toBe(true);
33
+ });
34
+
35
+ it('names a body carrying a non-finite field', () => {
36
+ const world = createTestWorld();
37
+ world.bodies[0].angularVelocityZ = Number.NaN;
38
+
39
+ const explanation = explainPhysics3DStep(world, 1 / 60);
40
+
41
+ expect(explanation.bodyStateValid).toBe(false);
42
+ expect(explanation.status).toBe('invalid-step');
43
+ });
44
+
45
+ it('names an invalid collider independently from body state', () => {
46
+ const world = createTestWorld();
47
+ const collider = createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 1 });
48
+ addPhysics3DCollider(world, world.bodies[0], collider);
49
+ collider.material.friction = Number.NaN;
50
+
51
+ const explanation = explainPhysics3DStep(world, 1 / 60);
52
+
53
+ expect(explanation.bodyStateValid).toBe(true);
54
+ expect(explanation.colliderStateValid).toBe(false);
55
+ expect(explanation.status).toBe('invalid-step');
56
+ });
57
+
58
+ it('names a body the index map disagrees about', () => {
59
+ const world = createTestWorld();
60
+ world.bodyByIndex.delete(world.bodies[0].index);
61
+
62
+ expect(explainPhysics3DStep(world, 1 / 60).bodyStateValid).toBe(false);
63
+ });
64
+
65
+ it('names a contact whose point count runs past its points', () => {
66
+ const world = createTestWorld();
67
+ const contact = createPhysics3DContact(0, 1);
68
+ contact.pointCount = 3;
69
+ world.contacts.push(contact);
70
+
71
+ expect(explainPhysics3DStep(world, 1 / 60).contactStateValid).toBe(false);
72
+ });
73
+
74
+ it('names a joint carrying a non-finite field', () => {
75
+ const world = createTestWorld();
76
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
77
+ joint.localAnchorAX = Number.POSITIVE_INFINITY;
78
+
79
+ expect(explainPhysics3DStep(world, 1 / 60).jointStateValid).toBe(false);
80
+ });
81
+
82
+ it('names gravity, substeps, and each iteration count separately', () => {
83
+ const gravity = createTestWorld();
84
+ gravity.gravityZ = Number.NaN;
85
+ expect(explainPhysics3DStep(gravity, 1 / 60).gravityValid).toBe(false);
86
+
87
+ const substeps = createTestWorld();
88
+ substeps.config.substeps = 0;
89
+ expect(explainPhysics3DStep(substeps, 1 / 60).substepsValid).toBe(false);
90
+
91
+ const velocity = createTestWorld();
92
+ velocity.config.sequentialImpulse.velocityIterations = -1;
93
+ expect(explainPhysics3DStep(velocity, 1 / 60).velocityIterationsValid).toBe(false);
94
+
95
+ const position = createTestWorld();
96
+ position.config.sequentialImpulse.positionIterations = 1.5;
97
+ expect(explainPhysics3DStep(position, 1 / 60).positionIterationsValid).toBe(false);
98
+ });
99
+
100
+ it('reports every fault at once rather than stopping at the first', () => {
101
+ const world = createTestWorld();
102
+ world.gravityY = Number.NaN;
103
+ world.config.substeps = -2;
104
+
105
+ const explanation = explainPhysics3DStep(world, -1);
106
+
107
+ // A world with several faults would otherwise be repaired one frame at a time, each fix revealing the
108
+ // next.
109
+ expect(explanation.timestepValid).toBe(false);
110
+ expect(explanation.gravityValid).toBe(false);
111
+ expect(explanation.substepsValid).toBe(false);
112
+ });
113
+
114
+ it('agrees with what the step actually does', () => {
115
+ const world = createTestWorld();
116
+ world.config.sequentialImpulse.positionCorrection = 5;
117
+
118
+ // The step declines silently, and this is the seam that makes that silence readable. The two asking
119
+ // the same predicates is what stops them disagreeing about what "steppable" means.
120
+ expect(explainPhysics3DStep(world, 1 / 60).status).toBe('invalid-step');
121
+ stepPhysics3D(world, 1 / 60);
122
+ expect(world.bodies[0].velocityY).toBe(0);
123
+ });
124
+
125
+ it('changes nothing it looks at', () => {
126
+ const world = createTestWorld();
127
+ world.bodies[0].velocityX = 3;
128
+
129
+ explainPhysics3DStep(world, 1 / 60);
130
+ stepPhysics3D(world, 1 / 60);
131
+
132
+ expect(world.bodies[0].velocityX).toBe(3);
133
+ expect(world.previousTimestep).toBeCloseTo(1 / 60, 12);
134
+ });
135
+ });
136
+
137
+ function createTestWorld(): Physics3DWorld {
138
+ const world = createPhysics3DWorld();
139
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
140
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
141
+ return world;
142
+ }
@@ -0,0 +1,279 @@
1
+ import { createQuaternion, createVector3, rotateVector3ByQuaternion } from '@flighthq/geometry/contract';
2
+ import type { RigidBody3D } from '@flighthq/types/contract';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ clearRigidBody3DForces,
7
+ integrateRigidBody3DPose,
8
+ integrateRigidBody3DVelocity,
9
+ refreshRigidBody3DWorldInertia,
10
+ } from './integrate';
11
+ import {
12
+ computePhysics3DBoxMassData,
13
+ computePhysics3DSphereMassData,
14
+ createPhysics3DMassData,
15
+ setRigidBody3DMassData,
16
+ } from './massProperties';
17
+ import { createRigidBody3D } from './world';
18
+
19
+ describe('clearRigidBody3DForces', () => {
20
+ it('zeroes both accumulators', () => {
21
+ const body = createRigidBody3D();
22
+ body.forceX = 1;
23
+ body.torqueZ = 2;
24
+
25
+ clearRigidBody3DForces(body);
26
+
27
+ expect(body.forceX).toBe(0);
28
+ expect(body.torqueZ).toBe(0);
29
+ });
30
+ });
31
+
32
+ describe('integrateRigidBody3DPose', () => {
33
+ it('advances position by the current velocity', () => {
34
+ const body = dynamicSphere();
35
+ body.velocityX = 3;
36
+
37
+ integrateRigidBody3DPose(body, 0.5);
38
+
39
+ expect(body.x).toBeCloseTo(1.5, 12);
40
+ });
41
+
42
+ it('keeps an offset centre of mass stationary during pure rotation', () => {
43
+ const body = dynamicSphere();
44
+ body.centerX = 2;
45
+ body.centerY = -1;
46
+ body.centerZ = 0.5;
47
+ body.angularVelocityX = 1;
48
+ body.angularVelocityY = -2;
49
+ body.angularVelocityZ = 3;
50
+ const before = getWorldCenter(body);
51
+
52
+ integrateRigidBody3DPose(body, 0.25);
53
+
54
+ const after = getWorldCenter(body);
55
+ expect(after.x).toBeCloseTo(before.x, 12);
56
+ expect(after.y).toBeCloseTo(before.y, 12);
57
+ expect(after.z).toBeCloseTo(before.z, 12);
58
+ });
59
+
60
+ it('leaves a static body alone', () => {
61
+ const body = createRigidBody3D('static');
62
+ body.velocityX = 3;
63
+
64
+ integrateRigidBody3DPose(body, 0.5);
65
+
66
+ expect(body.x).toBe(0);
67
+ });
68
+
69
+ it('leaves a sleeping body alone', () => {
70
+ const body = dynamicSphere();
71
+ body.sleeping = true;
72
+ body.velocityX = 3;
73
+
74
+ integrateRigidBody3DPose(body, 0.5);
75
+
76
+ expect(body.x).toBe(0);
77
+ });
78
+
79
+ it('rotates about the angular velocity axis by its magnitude times dt', () => {
80
+ const body = dynamicSphere();
81
+ body.angularVelocityZ = Math.PI; // half a turn per second
82
+
83
+ // One second of five-millisecond steps is half a turn, so the z component reaches sin(pi/2) and w
84
+ // reaches cos(pi/2). The step size matters: normalizing after a tangent move rotates by
85
+ // `2*atan(h)` rather than `2h`, so each step under-rotates by about `2h^3/3` and a coarse dt turns
86
+ // a correct integrator into a visibly slow one.
87
+ for (let i = 0; i < 200; i++) integrateRigidBody3DPose(body, 0.005);
88
+
89
+ expect(body.orientationZ).toBeCloseTo(Math.sin(Math.PI / 2), 3);
90
+ expect(body.orientationW).toBeCloseTo(Math.cos(Math.PI / 2), 3);
91
+ });
92
+
93
+ it('keeps the quaternion normalized across many steps', () => {
94
+ const body = dynamicSphere();
95
+ body.angularVelocityX = 5;
96
+ body.angularVelocityY = -3;
97
+ body.angularVelocityZ = 2;
98
+
99
+ for (let i = 0; i < 2000; i++) integrateRigidBody3DPose(body, 1 / 60);
100
+
101
+ const length = Math.hypot(body.orientationX, body.orientationY, body.orientationZ, body.orientationW);
102
+ expect(length).toBeCloseTo(1, 10);
103
+ });
104
+
105
+ it('leaves the orientation untouched at zero angular velocity', () => {
106
+ const body = dynamicSphere();
107
+ body.velocityY = 1;
108
+
109
+ integrateRigidBody3DPose(body, 0.5);
110
+
111
+ expect(body.orientationW).toBe(1);
112
+ expect(body.orientationX).toBe(0);
113
+ });
114
+
115
+ it('holds the previous orientation rather than writing NaN for a diverged angular velocity', () => {
116
+ const body = dynamicSphere();
117
+ body.angularVelocityX = Number.POSITIVE_INFINITY;
118
+
119
+ integrateRigidBody3DPose(body, 1 / 60);
120
+
121
+ expect(Number.isNaN(body.orientationW)).toBe(false);
122
+ expect(body.orientationW).toBe(1);
123
+ });
124
+ });
125
+
126
+ describe('integrateRigidBody3DVelocity', () => {
127
+ it('accelerates under gravity', () => {
128
+ const body = dynamicSphere();
129
+
130
+ integrateRigidBody3DVelocity(body, 0, -10, 0, 0.5);
131
+
132
+ expect(body.velocityY).toBeCloseTo(-5, 12);
133
+ });
134
+
135
+ it('scales gravity per body', () => {
136
+ const body = dynamicSphere();
137
+ body.gravityScale = 0;
138
+
139
+ integrateRigidBody3DVelocity(body, 0, -10, 0, 0.5);
140
+
141
+ expect(body.velocityY).toBe(0);
142
+ });
143
+
144
+ it('converts force to velocity through the inverse mass', () => {
145
+ const body = dynamicSphere();
146
+ body.forceX = body.mass * 4;
147
+
148
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 0.5);
149
+
150
+ expect(body.velocityX).toBeCloseTo(2, 10);
151
+ });
152
+
153
+ it('leaves a kinematic body alone — it is driven by the caller', () => {
154
+ const body = createRigidBody3D('kinematic');
155
+ body.velocityY = 7;
156
+
157
+ integrateRigidBody3DVelocity(body, 0, -10, 0, 0.5);
158
+
159
+ expect(body.velocityY).toBe(7);
160
+ });
161
+
162
+ it('damps linear velocity toward zero without crossing it at a large step', () => {
163
+ const body = dynamicSphere();
164
+ body.velocityX = 10;
165
+ body.linearDamping = 50;
166
+
167
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 10);
168
+
169
+ expect(body.velocityX).toBeGreaterThan(0);
170
+ expect(body.velocityX).toBeLessThan(10);
171
+ });
172
+
173
+ it('leaves a sphere spinning about a fixed axis — its gyroscopic term vanishes', () => {
174
+ // A sphere's inertia is isotropic, so `omega x (I * omega)` is a vector crossed with a multiple of
175
+ // itself: exactly zero. Any drift here is the gyroscopic term being computed wrongly.
176
+ const body = dynamicSphere();
177
+ body.angularVelocityX = 2;
178
+ body.angularVelocityY = 3;
179
+
180
+ for (let i = 0; i < 100; i++) {
181
+ refreshRigidBody3DWorldInertia(body);
182
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 1 / 60);
183
+ integrateRigidBody3DPose(body, 1 / 60);
184
+ }
185
+
186
+ expect(body.angularVelocityX).toBeCloseTo(2, 8);
187
+ expect(body.angularVelocityY).toBeCloseTo(3, 8);
188
+ expect(body.angularVelocityZ).toBeCloseTo(0, 8);
189
+ });
190
+
191
+ it('tumbles an asymmetric body spun off-axis, which is what the gyroscopic term is for', () => {
192
+ // A long box spun about an axis that is not principal must transfer angular velocity between axes.
193
+ // A solver missing `omega x (I * omega)` holds all three components fixed forever.
194
+ const body = createRigidBody3D();
195
+ const data = createPhysics3DMassData();
196
+ computePhysics3DBoxMassData(4, 1, 0.5, 1, data);
197
+ setRigidBody3DMassData(body, data);
198
+ refreshRigidBody3DWorldInertia(body);
199
+ body.angularVelocityX = 5;
200
+ body.angularVelocityY = 0.2;
201
+
202
+ for (let i = 0; i < 200; i++) {
203
+ refreshRigidBody3DWorldInertia(body);
204
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 1 / 240);
205
+ integrateRigidBody3DPose(body, 1 / 240);
206
+ }
207
+
208
+ expect(Math.abs(body.angularVelocityZ)).toBeGreaterThan(1e-4);
209
+ });
210
+
211
+ it('applies torque about the axis it is given for an isotropic body', () => {
212
+ const body = dynamicSphere();
213
+ refreshRigidBody3DWorldInertia(body);
214
+ body.torqueZ = body.inertiaZZ * 4;
215
+
216
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 0.5);
217
+
218
+ expect(body.angularVelocityZ).toBeCloseTo(2, 8);
219
+ expect(body.angularVelocityX).toBeCloseTo(0, 10);
220
+ });
221
+ });
222
+
223
+ describe('refreshRigidBody3DWorldInertia', () => {
224
+ it('matches the local tensor at identity orientation', () => {
225
+ const body = dynamicSphere();
226
+
227
+ refreshRigidBody3DWorldInertia(body);
228
+
229
+ expect(body.inverseInertiaWorldXX).toBeCloseTo(body.inverseInertiaXX, 12);
230
+ });
231
+
232
+ it('swaps the x and y world moments for a body turned a quarter turn about z', () => {
233
+ const body = createRigidBody3D();
234
+ const data = createPhysics3DMassData();
235
+ computePhysics3DBoxMassData(4, 1, 1, 1, data);
236
+ setRigidBody3DMassData(body, data);
237
+ const half = Math.SQRT1_2;
238
+ body.orientationZ = half;
239
+ body.orientationW = half;
240
+
241
+ refreshRigidBody3DWorldInertia(body);
242
+
243
+ expect(body.inverseInertiaWorldXX).toBeCloseTo(body.inverseInertiaYY, 8);
244
+ expect(body.inverseInertiaWorldYY).toBeCloseTo(body.inverseInertiaXX, 8);
245
+ });
246
+
247
+ it('leaves an immovable body world tensor at zero', () => {
248
+ const body = createRigidBody3D('static');
249
+ const data = createPhysics3DMassData();
250
+ computePhysics3DSphereMassData(1, 1, data);
251
+ setRigidBody3DMassData(body, data);
252
+ body.orientationZ = Math.SQRT1_2;
253
+ body.orientationW = Math.SQRT1_2;
254
+
255
+ refreshRigidBody3DWorldInertia(body);
256
+
257
+ expect(body.inverseInertiaWorldXX).toBe(0);
258
+ expect(body.inverseInertiaWorldYY).toBe(0);
259
+ });
260
+ });
261
+
262
+ function getWorldCenter(body: Readonly<RigidBody3D>): { x: number; y: number; z: number } {
263
+ const center = createVector3(body.centerX, body.centerY, body.centerZ);
264
+ const orientation = createQuaternion(body.orientationX, body.orientationY, body.orientationZ, body.orientationW);
265
+ rotateVector3ByQuaternion(center, center, orientation);
266
+ center.x += body.x;
267
+ center.y += body.y;
268
+ center.z += body.z;
269
+ return center;
270
+ }
271
+
272
+ function dynamicSphere(): ReturnType<typeof createRigidBody3D> {
273
+ const body = createRigidBody3D();
274
+ const data = createPhysics3DMassData();
275
+ computePhysics3DSphereMassData(1, 1, data);
276
+ setRigidBody3DMassData(body, data);
277
+ refreshRigidBody3DWorldInertia(body);
278
+ return body;
279
+ }