@flighthq/physics3d 0.4.0-next.1694.598ef6f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/contacts.d.ts +4 -0
  2. package/dist/contacts.d.ts.map +1 -0
  3. package/dist/contacts.js +55 -0
  4. package/dist/contacts.js.map +1 -0
  5. package/dist/contract.d.ts +17 -0
  6. package/dist/contract.d.ts.map +1 -0
  7. package/dist/contract.js +17 -0
  8. package/dist/contract.js.map +1 -0
  9. package/dist/enablePhysics3DGuards.d.ts +4 -0
  10. package/dist/enablePhysics3DGuards.d.ts.map +1 -0
  11. package/dist/enablePhysics3DGuards.js +68 -0
  12. package/dist/enablePhysics3DGuards.js.map +1 -0
  13. package/dist/explainPhysics3DJoints.d.ts +3 -0
  14. package/dist/explainPhysics3DJoints.d.ts.map +1 -0
  15. package/dist/explainPhysics3DJoints.js +32 -0
  16. package/dist/explainPhysics3DJoints.js.map +1 -0
  17. package/dist/explainPhysics3DStep.d.ts +3 -0
  18. package/dist/explainPhysics3DStep.d.ts.map +1 -0
  19. package/dist/explainPhysics3DStep.js +48 -0
  20. package/dist/explainPhysics3DStep.js.map +1 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +14 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/integrate.d.ts +6 -0
  26. package/dist/integrate.d.ts.map +1 -0
  27. package/dist/integrate.js +154 -0
  28. package/dist/integrate.js.map +1 -0
  29. package/dist/islands.d.ts +5 -0
  30. package/dist/islands.d.ts.map +1 -0
  31. package/dist/islands.js +316 -0
  32. package/dist/islands.js.map +1 -0
  33. package/dist/jointCollisionSuppression.d.ts +4 -0
  34. package/dist/jointCollisionSuppression.d.ts.map +1 -0
  35. package/dist/jointCollisionSuppression.js +40 -0
  36. package/dist/jointCollisionSuppression.js.map +1 -0
  37. package/dist/jointFactories.d.ts +8 -0
  38. package/dist/jointFactories.d.ts.map +1 -0
  39. package/dist/jointFactories.js +126 -0
  40. package/dist/jointFactories.js.map +1 -0
  41. package/dist/jointMath.d.ts +17 -0
  42. package/dist/jointMath.d.ts.map +1 -0
  43. package/dist/jointMath.js +361 -0
  44. package/dist/jointMath.js.map +1 -0
  45. package/dist/jointRegistry.d.ts +8 -0
  46. package/dist/jointRegistry.d.ts.map +1 -0
  47. package/dist/jointRegistry.js +144 -0
  48. package/dist/jointRegistry.js.map +1 -0
  49. package/dist/jointRows.d.ts +32 -0
  50. package/dist/jointRows.d.ts.map +1 -0
  51. package/dist/jointRows.js +308 -0
  52. package/dist/jointRows.js.map +1 -0
  53. package/dist/joints.d.ts +14 -0
  54. package/dist/joints.d.ts.map +1 -0
  55. package/dist/joints.js +758 -0
  56. package/dist/joints.js.map +1 -0
  57. package/dist/massProperties.d.ts +8 -0
  58. package/dist/massProperties.d.ts.map +1 -0
  59. package/dist/massProperties.js +169 -0
  60. package/dist/massProperties.js.map +1 -0
  61. package/dist/ownership.d.ts +5 -0
  62. package/dist/ownership.d.ts.map +1 -0
  63. package/dist/ownership.js +16 -0
  64. package/dist/ownership.js.map +1 -0
  65. package/dist/registerBuiltInPhysics3DJointSolvers.d.ts +3 -0
  66. package/dist/registerBuiltInPhysics3DJointSolvers.d.ts.map +1 -0
  67. package/dist/registerBuiltInPhysics3DJointSolvers.js +14 -0
  68. package/dist/registerBuiltInPhysics3DJointSolvers.js.map +1 -0
  69. package/dist/solver.d.ts +8 -0
  70. package/dist/solver.d.ts.map +1 -0
  71. package/dist/solver.js +383 -0
  72. package/dist/solver.js.map +1 -0
  73. package/dist/step.d.ts +5 -0
  74. package/dist/step.d.ts.map +1 -0
  75. package/dist/step.js +250 -0
  76. package/dist/step.js.map +1 -0
  77. package/dist/stepValidation.d.ts +12 -0
  78. package/dist/stepValidation.d.ts.map +1 -0
  79. package/dist/stepValidation.js +201 -0
  80. package/dist/stepValidation.js.map +1 -0
  81. package/dist/symmetricTensor.d.ts +11 -0
  82. package/dist/symmetricTensor.d.ts.map +1 -0
  83. package/dist/symmetricTensor.js +150 -0
  84. package/dist/symmetricTensor.js.map +1 -0
  85. package/dist/world.d.ts +19 -0
  86. package/dist/world.d.ts.map +1 -0
  87. package/dist/world.js +370 -0
  88. package/dist/world.js.map +1 -0
  89. package/package.json +49 -0
  90. package/src/contacts.test.ts +64 -0
  91. package/src/enablePhysics3DGuards.test.ts +105 -0
  92. package/src/explainPhysics3DJoints.test.ts +101 -0
  93. package/src/explainPhysics3DStep.test.ts +123 -0
  94. package/src/integrate.test.ts +249 -0
  95. package/src/islands.test.ts +357 -0
  96. package/src/jointCollisionSuppression.test.ts +94 -0
  97. package/src/jointFactories.test.ts +195 -0
  98. package/src/jointMath.test.ts +643 -0
  99. package/src/jointRegistry.test.ts +229 -0
  100. package/src/jointRows.test.ts +484 -0
  101. package/src/joints.test.ts +677 -0
  102. package/src/massProperties.test.ts +305 -0
  103. package/src/ownership.test.ts +36 -0
  104. package/src/registerBuiltInPhysics3DJointSolvers.test.ts +48 -0
  105. package/src/solver.test.ts +481 -0
  106. package/src/step.test.ts +390 -0
  107. package/src/stepValidation.test.ts +244 -0
  108. package/src/symmetricTensor.test.ts +166 -0
  109. package/src/world.test.ts +512 -0
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createPhysics3DContact, createPhysics3DContactPoint } from './contacts';
4
+
5
+ describe('createPhysics3DContact', () => {
6
+ it('orders the pair by index whichever way round it is given', () => {
7
+ // The order is an invariant of creation rather than a convention a caller is asked to respect: a
8
+ // narrow phase ties its contact points toward its first argument, so the reversed pair would move the
9
+ // points and renumber their feature ids — and the warm-start cache is keyed to those ids.
10
+ expect(createPhysics3DContact(7, 3).bodyA).toBe(3);
11
+ expect(createPhysics3DContact(7, 3).bodyB).toBe(7);
12
+ expect(createPhysics3DContact(3, 7).bodyA).toBe(3);
13
+ });
14
+
15
+ it('starts inert, with no points and nothing touching', () => {
16
+ const contact = createPhysics3DContact(0, 1);
17
+
18
+ expect(contact.pointCount).toBe(0);
19
+ expect(contact.points).toEqual([]);
20
+ expect(contact.touching).toBe(false);
21
+ expect([contact.normalX, contact.normalY, contact.normalZ]).toEqual([0, 0, 0]);
22
+ });
23
+
24
+ it('is enabled and solid by default', () => {
25
+ const contact = createPhysics3DContact(0, 1);
26
+
27
+ expect(contact.enabled).toBe(true);
28
+ expect(contact.sensor).toBe(false);
29
+ expect(contact.friction).toBe(0);
30
+ expect(contact.restitution).toBe(0);
31
+ });
32
+
33
+ it('allocates a distinct record each time', () => {
34
+ expect(createPhysics3DContact(0, 1).points).not.toBe(createPhysics3DContact(0, 1).points);
35
+ });
36
+ });
37
+
38
+ describe('createPhysics3DContactPoint', () => {
39
+ it('zeroes the geometry and both lever arms', () => {
40
+ const point = createPhysics3DContactPoint();
41
+
42
+ expect([point.x, point.y, point.z, point.depth, point.featureId]).toEqual([0, 0, 0, 0, 0]);
43
+ expect([point.rAX, point.rAY, point.rAZ]).toEqual([0, 0, 0]);
44
+ expect([point.rBX, point.rBY, point.rBZ]).toEqual([0, 0, 0]);
45
+ });
46
+
47
+ it('carries no solver state', () => {
48
+ // Solver accumulators live in `Physics3DContactConstraintPoint`, which the solver owns. A contact
49
+ // point carries geometry and identity only, so a second solver inherits no dead fields.
50
+ expect(Object.keys(createPhysics3DContactPoint()).sort()).toEqual([
51
+ 'depth',
52
+ 'featureId',
53
+ 'rAX',
54
+ 'rAY',
55
+ 'rAZ',
56
+ 'rBX',
57
+ 'rBY',
58
+ 'rBZ',
59
+ 'x',
60
+ 'y',
61
+ 'z',
62
+ ]);
63
+ });
64
+ });
@@ -0,0 +1,105 @@
1
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
+ import type { LogEntry, Physics3DWorld } from '@flighthq/types/contract';
3
+
4
+ import { arePhysics3DGuardsEnabled, disablePhysics3DGuards, enablePhysics3DGuards } from './enablePhysics3DGuards';
5
+ import { stepPhysics3D } from './step';
6
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D } from './world';
7
+
8
+ function captureLog(run: () => void): readonly LogEntry[] {
9
+ const sink = createMemoryLogSink(8);
10
+ addLogSink(sink.sink);
11
+ try {
12
+ run();
13
+ return getMemoryLogSinkEntries(sink);
14
+ } finally {
15
+ removeLogSink(sink.sink);
16
+ }
17
+ }
18
+
19
+ afterEach(() => {
20
+ disablePhysics3DGuards();
21
+ });
22
+
23
+ describe('arePhysics3DGuardsEnabled', () => {
24
+ it('reports the current module guard state', () => {
25
+ expect(arePhysics3DGuardsEnabled()).toBe(false);
26
+ enablePhysics3DGuards();
27
+ expect(arePhysics3DGuardsEnabled()).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('disablePhysics3DGuards', () => {
32
+ it('removes the seam so a declined step is silent again', () => {
33
+ enablePhysics3DGuards();
34
+ disablePhysics3DGuards();
35
+ const world = createTestWorld();
36
+ world.config.sequentialImpulse.positionIterations = -3;
37
+
38
+ const entries = captureLog(() => stepPhysics3D(world, 1 / 60));
39
+
40
+ expect(entries).toHaveLength(0);
41
+ });
42
+ });
43
+
44
+ describe('enablePhysics3DGuards', () => {
45
+ it('says nothing while the world steps normally', () => {
46
+ enablePhysics3DGuards();
47
+
48
+ const entries = captureLog(() => stepPhysics3D(createTestWorld(), 1 / 60));
49
+
50
+ // A guard that fired on a healthy step would be worse than none: the message is supposed to mean the
51
+ // simulation has stopped advancing.
52
+ expect(entries).toHaveLength(0);
53
+ });
54
+
55
+ it('names every failing precondition at once, not only the first', () => {
56
+ enablePhysics3DGuards();
57
+ const world = createTestWorld();
58
+ world.gravityY = Number.NaN;
59
+ world.config.substeps = 0;
60
+
61
+ const entries = captureLog(() => stepPhysics3D(world, 0));
62
+
63
+ expect(entries).toHaveLength(1);
64
+ const failing = readFailing(entries[0]);
65
+ // The step's own condition short-circuits, so one fault per frame would make a caller repair a
66
+ // three-fault world across three frames, each fix revealing the next.
67
+ expect([...failing].sort()).toEqual(['gravityValid', 'substepsValid', 'timestepValid']);
68
+ });
69
+
70
+ it('logs once for a repeating fault and again when a different one joins it', () => {
71
+ enablePhysics3DGuards();
72
+ const world = createTestWorld();
73
+ world.config.sequentialImpulse.velocityIterations = -1;
74
+
75
+ const repeated = captureLog(() => {
76
+ for (let frame = 0; frame < 5; frame += 1) stepPhysics3D(world, 1 / 60);
77
+ });
78
+ expect(repeated).toHaveLength(1);
79
+
80
+ // Keyed on the failing SET rather than on the world, so a frame loop costs one message while a world
81
+ // that develops a SECOND fault still reports it instead of being swallowed by the first key.
82
+ const widened = captureLog(() => {
83
+ world.gravityZ = Number.NaN;
84
+ stepPhysics3D(world, 1 / 60);
85
+ });
86
+ expect(widened).toHaveLength(1);
87
+ expect(readFailing(widened[0])).toContain('gravityValid');
88
+ });
89
+ });
90
+
91
+ // `LogData` is deliberately `string | Record<string, unknown>`, so a structured entry's fields are only
92
+ // reachable through a narrowing read rather than a property access.
93
+ function readFailing(entry: Readonly<LogEntry>): readonly string[] {
94
+ const data = entry.data;
95
+ if (typeof data === 'string' || data === undefined) return [];
96
+ return (data.failing as readonly string[] | undefined) ?? [];
97
+ }
98
+
99
+ // Steppable as built: every fault in these tests is one the test introduces, so a case that expects
100
+ // silence is asserting the guard's judgement rather than a world that happens to be fine.
101
+ function createTestWorld(): Physics3DWorld {
102
+ const world = createPhysics3DWorld();
103
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
104
+ return world;
105
+ }
@@ -0,0 +1,101 @@
1
+ import type { Physics3DJointSolver, Physics3DWorld } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { explainPhysics3DJoints } from './explainPhysics3DJoints';
5
+ import { createPhysics3DBallAndSocketJoint, createPhysics3DHingeJoint } from './jointFactories';
6
+ import { addPhysics3DJoint, registerPhysics3DJointSolver } from './jointRegistry';
7
+ import { Physics3DBallAndSocketJointKind } from './joints';
8
+ import { registerBuiltInPhysics3DJointSolvers } from './registerBuiltInPhysics3DJointSolvers';
9
+ import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D } from './world';
10
+
11
+ describe('explainPhysics3DJoints', () => {
12
+ it('reports nothing for a world with no joints', () => {
13
+ expect(explainPhysics3DJoints(createTestWorld())).toEqual([]);
14
+ });
15
+
16
+ it('calls a fully wired joint solvable', () => {
17
+ const world = createTestWorld();
18
+ registerBuiltInPhysics3DJointSolvers(world);
19
+ addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
20
+
21
+ const [explanation] = explainPhysics3DJoints(world);
22
+
23
+ expect(explanation.status).toBe('solvable');
24
+ expect(explanation.hasSolver).toBe(true);
25
+ expect(explanation.bodiesResolvable).toBe(true);
26
+ expect(explanation.index).toBe(0);
27
+ expect(explanation.kind).toBe('Hinge');
28
+ });
29
+
30
+ it('separates an unregistered kind from a broken one', () => {
31
+ const world = createTestWorld();
32
+ addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
33
+
34
+ const [explanation] = explainPhysics3DJoints(world);
35
+
36
+ // Not a fault: a scene deserialized ahead of the code that solves it is explicitly supported, and the
37
+ // joint is meant to sit inert until someone registers its solver.
38
+ expect(explanation.status).toBe('unregistered-kind');
39
+ expect(explanation.hasSolver).toBe(false);
40
+ });
41
+
42
+ it('reports a joint outliving one of its bodies', () => {
43
+ const world = createTestWorld();
44
+ registerBuiltInPhysics3DJointSolvers(world);
45
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
46
+ // Removing a body drops the joints that named it, so this is the state a world reconstructed by a
47
+ // format layer reaches rather than one the lifecycle produces: an endpoint that resolves to nothing.
48
+ joint.bodyB = 99;
49
+
50
+ const [explanation] = explainPhysics3DJoints(world);
51
+
52
+ expect(explanation.status).toBe('invalid-bodies');
53
+ expect(explanation.hasSolver).toBe(true);
54
+ expect(explanation.bodiesResolvable).toBe(false);
55
+ });
56
+
57
+ it('does not fault a one-body kind for its unused first end', () => {
58
+ const world = createTestWorld();
59
+ const oneBody: Physics3DJointSolver = { prepare(): void {}, solve(): void {}, usesBodyA: false };
60
+ registerPhysics3DJointSolver(world, Physics3DBallAndSocketJointKind, oneBody);
61
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
62
+ joint.bodyA = 404;
63
+
64
+ const [explanation] = explainPhysics3DJoints(world);
65
+
66
+ // The solver never reads bodyA, so a placeholder index there is not a fault. Asking the kind rather
67
+ // than assuming two endpoints is what keeps this honest for a kind the package does not own.
68
+ expect(explanation.status).toBe('solvable');
69
+ });
70
+
71
+ it('reports one entry per joint, indexed back into the world list', () => {
72
+ const world = createTestWorld();
73
+ registerBuiltInPhysics3DJointSolvers(world);
74
+ addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
75
+ addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
76
+
77
+ const explanations = explainPhysics3DJoints(world);
78
+
79
+ expect(explanations.map((entry) => entry.index)).toEqual([0, 1]);
80
+ expect(world.joints[explanations[1].index].kind).toBe(explanations[1].kind);
81
+ });
82
+
83
+ it('changes nothing it looks at', () => {
84
+ const world = createTestWorld();
85
+ registerBuiltInPhysics3DJointSolvers(world);
86
+ const joint = addPhysics3DJoint(world, createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 }));
87
+ joint.impulse0 = 7;
88
+
89
+ explainPhysics3DJoints(world);
90
+
91
+ expect(joint.impulse0).toBe(7);
92
+ expect(world.joints.length).toBe(1);
93
+ });
94
+ });
95
+
96
+ function createTestWorld(): Physics3DWorld {
97
+ const world = createPhysics3DWorld();
98
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
99
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
100
+ return world;
101
+ }
@@ -0,0 +1,123 @@
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 { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D } from './world';
10
+
11
+ describe('explainPhysics3DStep', () => {
12
+ it('reports a ready world', () => {
13
+ const explanation = explainPhysics3DStep(createTestWorld(), 1 / 60);
14
+
15
+ expect(explanation.status).toBe('ready');
16
+ expect(explanation.timestepValid).toBe(true);
17
+ expect(explanation.bodyStateValid).toBe(true);
18
+ });
19
+
20
+ it('names the timestep as the fault, and only it', () => {
21
+ const explanation = explainPhysics3DStep(createTestWorld(), 0);
22
+
23
+ expect(explanation.status).toBe('invalid-step');
24
+ expect(explanation.timestepValid).toBe(false);
25
+ expect(explanation.bodyStateValid).toBe(true);
26
+ expect(explanation.solverConfigValid).toBe(true);
27
+ });
28
+
29
+ it('names a body carrying a non-finite field', () => {
30
+ const world = createTestWorld();
31
+ world.bodies[0].angularVelocityZ = Number.NaN;
32
+
33
+ const explanation = explainPhysics3DStep(world, 1 / 60);
34
+
35
+ expect(explanation.bodyStateValid).toBe(false);
36
+ expect(explanation.status).toBe('invalid-step');
37
+ });
38
+
39
+ it('names a body the index map disagrees about', () => {
40
+ const world = createTestWorld();
41
+ world.bodyByIndex.delete(world.bodies[0].index);
42
+
43
+ expect(explainPhysics3DStep(world, 1 / 60).bodyStateValid).toBe(false);
44
+ });
45
+
46
+ it('names a contact whose point count runs past its points', () => {
47
+ const world = createTestWorld();
48
+ const contact = createPhysics3DContact(0, 1);
49
+ contact.pointCount = 3;
50
+ world.contacts.push(contact);
51
+
52
+ expect(explainPhysics3DStep(world, 1 / 60).contactStateValid).toBe(false);
53
+ });
54
+
55
+ it('names a joint carrying a non-finite field', () => {
56
+ const world = createTestWorld();
57
+ const joint = addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: 0, bodyB: 1 }));
58
+ joint.localAnchorAX = Number.POSITIVE_INFINITY;
59
+
60
+ expect(explainPhysics3DStep(world, 1 / 60).jointStateValid).toBe(false);
61
+ });
62
+
63
+ it('names gravity, substeps, and each iteration count separately', () => {
64
+ const gravity = createTestWorld();
65
+ gravity.gravityZ = Number.NaN;
66
+ expect(explainPhysics3DStep(gravity, 1 / 60).gravityValid).toBe(false);
67
+
68
+ const substeps = createTestWorld();
69
+ substeps.config.substeps = 0;
70
+ expect(explainPhysics3DStep(substeps, 1 / 60).substepsValid).toBe(false);
71
+
72
+ const velocity = createTestWorld();
73
+ velocity.config.sequentialImpulse.velocityIterations = -1;
74
+ expect(explainPhysics3DStep(velocity, 1 / 60).velocityIterationsValid).toBe(false);
75
+
76
+ const position = createTestWorld();
77
+ position.config.sequentialImpulse.positionIterations = 1.5;
78
+ expect(explainPhysics3DStep(position, 1 / 60).positionIterationsValid).toBe(false);
79
+ });
80
+
81
+ it('reports every fault at once rather than stopping at the first', () => {
82
+ const world = createTestWorld();
83
+ world.gravityY = Number.NaN;
84
+ world.config.substeps = -2;
85
+
86
+ const explanation = explainPhysics3DStep(world, -1);
87
+
88
+ // A world with several faults would otherwise be repaired one frame at a time, each fix revealing the
89
+ // next.
90
+ expect(explanation.timestepValid).toBe(false);
91
+ expect(explanation.gravityValid).toBe(false);
92
+ expect(explanation.substepsValid).toBe(false);
93
+ });
94
+
95
+ it('agrees with what the step actually does', () => {
96
+ const world = createTestWorld();
97
+ world.config.sequentialImpulse.positionCorrection = 5;
98
+
99
+ // The step declines silently, and this is the seam that makes that silence readable. The two asking
100
+ // the same predicates is what stops them disagreeing about what "steppable" means.
101
+ expect(explainPhysics3DStep(world, 1 / 60).status).toBe('invalid-step');
102
+ stepPhysics3D(world, 1 / 60);
103
+ expect(world.bodies[0].velocityY).toBe(0);
104
+ });
105
+
106
+ it('changes nothing it looks at', () => {
107
+ const world = createTestWorld();
108
+ world.bodies[0].velocityX = 3;
109
+
110
+ explainPhysics3DStep(world, 1 / 60);
111
+ stepPhysics3D(world, 1 / 60);
112
+
113
+ expect(world.bodies[0].velocityX).toBe(3);
114
+ expect(world.previousTimestep).toBeCloseTo(1 / 60, 12);
115
+ });
116
+ });
117
+
118
+ function createTestWorld(): Physics3DWorld {
119
+ const world = createPhysics3DWorld();
120
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
121
+ addPhysics3DBody(world, createRigidBody3D('dynamic'));
122
+ return world;
123
+ }
@@ -0,0 +1,249 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ clearRigidBody3DForces,
5
+ integrateRigidBody3DPose,
6
+ integrateRigidBody3DVelocity,
7
+ refreshRigidBody3DWorldInertia,
8
+ } from './integrate';
9
+ import {
10
+ computePhysics3DBoxMassData,
11
+ computePhysics3DSphereMassData,
12
+ createPhysics3DMassData,
13
+ setRigidBody3DMassData,
14
+ } from './massProperties';
15
+ import { createRigidBody3D } from './world';
16
+
17
+ describe('clearRigidBody3DForces', () => {
18
+ it('zeroes both accumulators', () => {
19
+ const body = createRigidBody3D();
20
+ body.forceX = 1;
21
+ body.torqueZ = 2;
22
+
23
+ clearRigidBody3DForces(body);
24
+
25
+ expect(body.forceX).toBe(0);
26
+ expect(body.torqueZ).toBe(0);
27
+ });
28
+ });
29
+
30
+ describe('integrateRigidBody3DPose', () => {
31
+ it('advances position by the current velocity', () => {
32
+ const body = dynamicSphere();
33
+ body.velocityX = 3;
34
+
35
+ integrateRigidBody3DPose(body, 0.5);
36
+
37
+ expect(body.x).toBeCloseTo(1.5, 12);
38
+ });
39
+
40
+ it('leaves a static body alone', () => {
41
+ const body = createRigidBody3D('static');
42
+ body.velocityX = 3;
43
+
44
+ integrateRigidBody3DPose(body, 0.5);
45
+
46
+ expect(body.x).toBe(0);
47
+ });
48
+
49
+ it('leaves a sleeping body alone', () => {
50
+ const body = dynamicSphere();
51
+ body.sleeping = true;
52
+ body.velocityX = 3;
53
+
54
+ integrateRigidBody3DPose(body, 0.5);
55
+
56
+ expect(body.x).toBe(0);
57
+ });
58
+
59
+ it('rotates about the angular velocity axis by its magnitude times dt', () => {
60
+ const body = dynamicSphere();
61
+ body.angularVelocityZ = Math.PI; // half a turn per second
62
+
63
+ // One second of five-millisecond steps is half a turn, so the z component reaches sin(pi/2) and w
64
+ // reaches cos(pi/2). The step size matters: normalizing after a tangent move rotates by
65
+ // `2*atan(h)` rather than `2h`, so each step under-rotates by about `2h^3/3` and a coarse dt turns
66
+ // a correct integrator into a visibly slow one.
67
+ for (let i = 0; i < 200; i++) integrateRigidBody3DPose(body, 0.005);
68
+
69
+ expect(body.orientationZ).toBeCloseTo(Math.sin(Math.PI / 2), 3);
70
+ expect(body.orientationW).toBeCloseTo(Math.cos(Math.PI / 2), 3);
71
+ });
72
+
73
+ it('keeps the quaternion normalized across many steps', () => {
74
+ const body = dynamicSphere();
75
+ body.angularVelocityX = 5;
76
+ body.angularVelocityY = -3;
77
+ body.angularVelocityZ = 2;
78
+
79
+ for (let i = 0; i < 2000; i++) integrateRigidBody3DPose(body, 1 / 60);
80
+
81
+ const length = Math.hypot(body.orientationX, body.orientationY, body.orientationZ, body.orientationW);
82
+ expect(length).toBeCloseTo(1, 10);
83
+ });
84
+
85
+ it('leaves the orientation untouched at zero angular velocity', () => {
86
+ const body = dynamicSphere();
87
+ body.velocityY = 1;
88
+
89
+ integrateRigidBody3DPose(body, 0.5);
90
+
91
+ expect(body.orientationW).toBe(1);
92
+ expect(body.orientationX).toBe(0);
93
+ });
94
+
95
+ it('holds the previous orientation rather than writing NaN for a diverged angular velocity', () => {
96
+ const body = dynamicSphere();
97
+ body.angularVelocityX = Number.POSITIVE_INFINITY;
98
+
99
+ integrateRigidBody3DPose(body, 1 / 60);
100
+
101
+ expect(Number.isNaN(body.orientationW)).toBe(false);
102
+ expect(body.orientationW).toBe(1);
103
+ });
104
+ });
105
+
106
+ describe('integrateRigidBody3DVelocity', () => {
107
+ it('accelerates under gravity', () => {
108
+ const body = dynamicSphere();
109
+
110
+ integrateRigidBody3DVelocity(body, 0, -10, 0, 0.5);
111
+
112
+ expect(body.velocityY).toBeCloseTo(-5, 12);
113
+ });
114
+
115
+ it('scales gravity per body', () => {
116
+ const body = dynamicSphere();
117
+ body.gravityScale = 0;
118
+
119
+ integrateRigidBody3DVelocity(body, 0, -10, 0, 0.5);
120
+
121
+ expect(body.velocityY).toBe(0);
122
+ });
123
+
124
+ it('converts force to velocity through the inverse mass', () => {
125
+ const body = dynamicSphere();
126
+ body.forceX = body.mass * 4;
127
+
128
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 0.5);
129
+
130
+ expect(body.velocityX).toBeCloseTo(2, 10);
131
+ });
132
+
133
+ it('leaves a kinematic body alone — it is driven by the caller', () => {
134
+ const body = createRigidBody3D('kinematic');
135
+ body.velocityY = 7;
136
+
137
+ integrateRigidBody3DVelocity(body, 0, -10, 0, 0.5);
138
+
139
+ expect(body.velocityY).toBe(7);
140
+ });
141
+
142
+ it('damps linear velocity toward zero without crossing it at a large step', () => {
143
+ const body = dynamicSphere();
144
+ body.velocityX = 10;
145
+ body.linearDamping = 50;
146
+
147
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 10);
148
+
149
+ expect(body.velocityX).toBeGreaterThan(0);
150
+ expect(body.velocityX).toBeLessThan(10);
151
+ });
152
+
153
+ it('leaves a sphere spinning about a fixed axis — its gyroscopic term vanishes', () => {
154
+ // A sphere's inertia is isotropic, so `omega x (I * omega)` is a vector crossed with a multiple of
155
+ // itself: exactly zero. Any drift here is the gyroscopic term being computed wrongly.
156
+ const body = dynamicSphere();
157
+ body.angularVelocityX = 2;
158
+ body.angularVelocityY = 3;
159
+
160
+ for (let i = 0; i < 100; i++) {
161
+ refreshRigidBody3DWorldInertia(body);
162
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 1 / 60);
163
+ integrateRigidBody3DPose(body, 1 / 60);
164
+ }
165
+
166
+ expect(body.angularVelocityX).toBeCloseTo(2, 8);
167
+ expect(body.angularVelocityY).toBeCloseTo(3, 8);
168
+ expect(body.angularVelocityZ).toBeCloseTo(0, 8);
169
+ });
170
+
171
+ it('tumbles an asymmetric body spun off-axis, which is what the gyroscopic term is for', () => {
172
+ // A long box spun about an axis that is not principal must transfer angular velocity between axes.
173
+ // A solver missing `omega x (I * omega)` holds all three components fixed forever.
174
+ const body = createRigidBody3D();
175
+ const data = createPhysics3DMassData();
176
+ computePhysics3DBoxMassData(4, 1, 0.5, 1, data);
177
+ setRigidBody3DMassData(body, data);
178
+ refreshRigidBody3DWorldInertia(body);
179
+ body.angularVelocityX = 5;
180
+ body.angularVelocityY = 0.2;
181
+
182
+ for (let i = 0; i < 200; i++) {
183
+ refreshRigidBody3DWorldInertia(body);
184
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 1 / 240);
185
+ integrateRigidBody3DPose(body, 1 / 240);
186
+ }
187
+
188
+ expect(Math.abs(body.angularVelocityZ)).toBeGreaterThan(1e-4);
189
+ });
190
+
191
+ it('applies torque about the axis it is given for an isotropic body', () => {
192
+ const body = dynamicSphere();
193
+ refreshRigidBody3DWorldInertia(body);
194
+ body.torqueZ = body.inertiaZZ * 4;
195
+
196
+ integrateRigidBody3DVelocity(body, 0, 0, 0, 0.5);
197
+
198
+ expect(body.angularVelocityZ).toBeCloseTo(2, 8);
199
+ expect(body.angularVelocityX).toBeCloseTo(0, 10);
200
+ });
201
+ });
202
+
203
+ describe('refreshRigidBody3DWorldInertia', () => {
204
+ it('matches the local tensor at identity orientation', () => {
205
+ const body = dynamicSphere();
206
+
207
+ refreshRigidBody3DWorldInertia(body);
208
+
209
+ expect(body.inverseInertiaWorldXX).toBeCloseTo(body.inverseInertiaXX, 12);
210
+ });
211
+
212
+ it('swaps the x and y world moments for a body turned a quarter turn about z', () => {
213
+ const body = createRigidBody3D();
214
+ const data = createPhysics3DMassData();
215
+ computePhysics3DBoxMassData(4, 1, 1, 1, data);
216
+ setRigidBody3DMassData(body, data);
217
+ const half = Math.SQRT1_2;
218
+ body.orientationZ = half;
219
+ body.orientationW = half;
220
+
221
+ refreshRigidBody3DWorldInertia(body);
222
+
223
+ expect(body.inverseInertiaWorldXX).toBeCloseTo(body.inverseInertiaYY, 8);
224
+ expect(body.inverseInertiaWorldYY).toBeCloseTo(body.inverseInertiaXX, 8);
225
+ });
226
+
227
+ it('leaves an immovable body world tensor at zero', () => {
228
+ const body = createRigidBody3D('static');
229
+ const data = createPhysics3DMassData();
230
+ computePhysics3DSphereMassData(1, 1, data);
231
+ setRigidBody3DMassData(body, data);
232
+ body.orientationZ = Math.SQRT1_2;
233
+ body.orientationW = Math.SQRT1_2;
234
+
235
+ refreshRigidBody3DWorldInertia(body);
236
+
237
+ expect(body.inverseInertiaWorldXX).toBe(0);
238
+ expect(body.inverseInertiaWorldYY).toBe(0);
239
+ });
240
+ });
241
+
242
+ function dynamicSphere(): ReturnType<typeof createRigidBody3D> {
243
+ const body = createRigidBody3D();
244
+ const data = createPhysics3DMassData();
245
+ computePhysics3DSphereMassData(1, 1, data);
246
+ setRigidBody3DMassData(body, data);
247
+ refreshRigidBody3DWorldInertia(body);
248
+ return body;
249
+ }