@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.
- package/dist/contacts.d.ts +4 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +55 -0
- package/dist/contacts.js.map +1 -0
- package/dist/contract.d.ts +17 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +17 -0
- package/dist/contract.js.map +1 -0
- package/dist/enablePhysics3DGuards.d.ts +4 -0
- package/dist/enablePhysics3DGuards.d.ts.map +1 -0
- package/dist/enablePhysics3DGuards.js +68 -0
- package/dist/enablePhysics3DGuards.js.map +1 -0
- package/dist/explainPhysics3DJoints.d.ts +3 -0
- package/dist/explainPhysics3DJoints.d.ts.map +1 -0
- package/dist/explainPhysics3DJoints.js +32 -0
- package/dist/explainPhysics3DJoints.js.map +1 -0
- package/dist/explainPhysics3DStep.d.ts +3 -0
- package/dist/explainPhysics3DStep.d.ts.map +1 -0
- package/dist/explainPhysics3DStep.js +48 -0
- package/dist/explainPhysics3DStep.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/integrate.d.ts +6 -0
- package/dist/integrate.d.ts.map +1 -0
- package/dist/integrate.js +154 -0
- package/dist/integrate.js.map +1 -0
- package/dist/islands.d.ts +5 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +316 -0
- package/dist/islands.js.map +1 -0
- package/dist/jointCollisionSuppression.d.ts +4 -0
- package/dist/jointCollisionSuppression.d.ts.map +1 -0
- package/dist/jointCollisionSuppression.js +40 -0
- package/dist/jointCollisionSuppression.js.map +1 -0
- package/dist/jointFactories.d.ts +8 -0
- package/dist/jointFactories.d.ts.map +1 -0
- package/dist/jointFactories.js +126 -0
- package/dist/jointFactories.js.map +1 -0
- package/dist/jointMath.d.ts +17 -0
- package/dist/jointMath.d.ts.map +1 -0
- package/dist/jointMath.js +361 -0
- package/dist/jointMath.js.map +1 -0
- package/dist/jointRegistry.d.ts +8 -0
- package/dist/jointRegistry.d.ts.map +1 -0
- package/dist/jointRegistry.js +144 -0
- package/dist/jointRegistry.js.map +1 -0
- package/dist/jointRows.d.ts +32 -0
- package/dist/jointRows.d.ts.map +1 -0
- package/dist/jointRows.js +308 -0
- package/dist/jointRows.js.map +1 -0
- package/dist/joints.d.ts +14 -0
- package/dist/joints.d.ts.map +1 -0
- package/dist/joints.js +758 -0
- package/dist/joints.js.map +1 -0
- package/dist/massProperties.d.ts +8 -0
- package/dist/massProperties.d.ts.map +1 -0
- package/dist/massProperties.js +169 -0
- package/dist/massProperties.js.map +1 -0
- package/dist/ownership.d.ts +5 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +16 -0
- package/dist/ownership.js.map +1 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.d.ts +3 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.d.ts.map +1 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.js +14 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.js.map +1 -0
- package/dist/solver.d.ts +8 -0
- package/dist/solver.d.ts.map +1 -0
- package/dist/solver.js +383 -0
- package/dist/solver.js.map +1 -0
- package/dist/step.d.ts +5 -0
- package/dist/step.d.ts.map +1 -0
- package/dist/step.js +250 -0
- package/dist/step.js.map +1 -0
- package/dist/stepValidation.d.ts +12 -0
- package/dist/stepValidation.d.ts.map +1 -0
- package/dist/stepValidation.js +201 -0
- package/dist/stepValidation.js.map +1 -0
- package/dist/symmetricTensor.d.ts +11 -0
- package/dist/symmetricTensor.d.ts.map +1 -0
- package/dist/symmetricTensor.js +150 -0
- package/dist/symmetricTensor.js.map +1 -0
- package/dist/world.d.ts +19 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +370 -0
- package/dist/world.js.map +1 -0
- package/package.json +49 -0
- package/src/contacts.test.ts +64 -0
- package/src/enablePhysics3DGuards.test.ts +105 -0
- package/src/explainPhysics3DJoints.test.ts +101 -0
- package/src/explainPhysics3DStep.test.ts +123 -0
- package/src/integrate.test.ts +249 -0
- package/src/islands.test.ts +357 -0
- package/src/jointCollisionSuppression.test.ts +94 -0
- package/src/jointFactories.test.ts +195 -0
- package/src/jointMath.test.ts +643 -0
- package/src/jointRegistry.test.ts +229 -0
- package/src/jointRows.test.ts +484 -0
- package/src/joints.test.ts +677 -0
- package/src/massProperties.test.ts +305 -0
- package/src/ownership.test.ts +36 -0
- package/src/registerBuiltInPhysics3DJointSolvers.test.ts +48 -0
- package/src/solver.test.ts +481 -0
- package/src/step.test.ts +390 -0
- package/src/stepValidation.test.ts +244 -0
- package/src/symmetricTensor.test.ts +166 -0
- package/src/world.test.ts +512 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applySymmetricTensor,
|
|
5
|
+
inverseSymmetricTensor,
|
|
6
|
+
rotateSymmetricTensor,
|
|
7
|
+
translateSymmetricTensor,
|
|
8
|
+
TENSOR_XX,
|
|
9
|
+
TENSOR_XY,
|
|
10
|
+
TENSOR_XZ,
|
|
11
|
+
TENSOR_YY,
|
|
12
|
+
TENSOR_YZ,
|
|
13
|
+
TENSOR_ZZ,
|
|
14
|
+
} from './symmetricTensor';
|
|
15
|
+
|
|
16
|
+
describe('applySymmetricTensor', () => {
|
|
17
|
+
it('multiplies a diagonal tensor componentwise', () => {
|
|
18
|
+
const out = [0, 0, 0];
|
|
19
|
+
applySymmetricTensor([2, 3, 4, 0, 0, 0], 1, 1, 1, out);
|
|
20
|
+
expect(out).toEqual([2, 3, 4]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('mixes components through the off-diagonal terms', () => {
|
|
24
|
+
const out = [0, 0, 0];
|
|
25
|
+
applySymmetricTensor([1, 1, 1, 2, 0, 0], 1, 0, 0, out);
|
|
26
|
+
expect(out).toEqual([1, 2, 0]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('is symmetric: the xy term feeds x from y as it feeds y from x', () => {
|
|
30
|
+
const fromX = [0, 0, 0];
|
|
31
|
+
const fromY = [0, 0, 0];
|
|
32
|
+
applySymmetricTensor([1, 1, 1, 2, 0, 0], 1, 0, 0, fromX);
|
|
33
|
+
applySymmetricTensor([1, 1, 1, 2, 0, 0], 0, 1, 0, fromY);
|
|
34
|
+
expect(fromX[1]).toBe(fromY[0]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('inverseSymmetricTensor', () => {
|
|
39
|
+
it('inverts a diagonal tensor by reciprocal', () => {
|
|
40
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
41
|
+
expect(inverseSymmetricTensor([2, 4, 8, 0, 0, 0], out)).toBe(true);
|
|
42
|
+
expect(out[TENSOR_XX]).toBeCloseTo(0.5, 12);
|
|
43
|
+
expect(out[TENSOR_YY]).toBeCloseTo(0.25, 12);
|
|
44
|
+
expect(out[TENSOR_ZZ]).toBeCloseTo(0.125, 12);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('round-trips a general tensor back to the identity', () => {
|
|
48
|
+
const tensor = [4, 5, 6, 1, 2, 3];
|
|
49
|
+
const inverse = [0, 0, 0, 0, 0, 0];
|
|
50
|
+
expect(inverseSymmetricTensor(tensor, inverse)).toBe(true);
|
|
51
|
+
|
|
52
|
+
// tensor * inverse applied to each basis vector must return that basis vector.
|
|
53
|
+
const out = [0, 0, 0];
|
|
54
|
+
applySymmetricTensor(inverse, tensor[TENSOR_XX], tensor[TENSOR_XY], tensor[TENSOR_XZ], out);
|
|
55
|
+
expect(out[0]).toBeCloseTo(1, 10);
|
|
56
|
+
expect(out[1]).toBeCloseTo(0, 10);
|
|
57
|
+
expect(out[2]).toBeCloseTo(0, 10);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('reports a singular tensor and writes zeros, the infinite-inertia sentinel', () => {
|
|
61
|
+
const out = [9, 9, 9, 9, 9, 9];
|
|
62
|
+
expect(inverseSymmetricTensor([0, 0, 0, 0, 0, 0], out)).toBe(false);
|
|
63
|
+
expect(out).toEqual([0, 0, 0, 0, 0, 0]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reports a rank-deficient tensor as singular rather than inverting it', () => {
|
|
67
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
68
|
+
// Two equal rows: determinant is exactly zero.
|
|
69
|
+
expect(inverseSymmetricTensor([1, 1, 0, 1, 0, 0], out)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is safe when out aliases the input', () => {
|
|
73
|
+
const tensor = [2, 4, 8, 0, 0, 0];
|
|
74
|
+
expect(inverseSymmetricTensor(tensor, tensor)).toBe(true);
|
|
75
|
+
expect(tensor[TENSOR_XX]).toBeCloseTo(0.5, 12);
|
|
76
|
+
expect(tensor[TENSOR_ZZ]).toBeCloseTo(0.125, 12);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('rotateSymmetricTensor', () => {
|
|
81
|
+
it('leaves a tensor unchanged under the identity quaternion', () => {
|
|
82
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
83
|
+
rotateSymmetricTensor([1, 2, 3, 0.5, 0.25, 0.125], 0, 0, 0, 1, out);
|
|
84
|
+
expect(out[TENSOR_XX]).toBeCloseTo(1, 12);
|
|
85
|
+
expect(out[TENSOR_YY]).toBeCloseTo(2, 12);
|
|
86
|
+
expect(out[TENSOR_ZZ]).toBeCloseTo(3, 12);
|
|
87
|
+
expect(out[TENSOR_XY]).toBeCloseTo(0.5, 12);
|
|
88
|
+
expect(out[TENSOR_XZ]).toBeCloseTo(0.25, 12);
|
|
89
|
+
expect(out[TENSOR_YZ]).toBeCloseTo(0.125, 12);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('swaps the x and y moments under a quarter turn about z', () => {
|
|
93
|
+
const half = Math.SQRT1_2;
|
|
94
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
95
|
+
rotateSymmetricTensor([1, 5, 9, 0, 0, 0], 0, 0, half, half, out);
|
|
96
|
+
expect(out[TENSOR_XX]).toBeCloseTo(5, 10);
|
|
97
|
+
expect(out[TENSOR_YY]).toBeCloseTo(1, 10);
|
|
98
|
+
expect(out[TENSOR_ZZ]).toBeCloseTo(9, 10);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('leaves an isotropic tensor unchanged under any rotation', () => {
|
|
102
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
103
|
+
const n = 1 / Math.sqrt(3);
|
|
104
|
+
const angle = 0.7;
|
|
105
|
+
const s = Math.sin(angle / 2);
|
|
106
|
+
rotateSymmetricTensor([4, 4, 4, 0, 0, 0], n * s, n * s, n * s, Math.cos(angle / 2), out);
|
|
107
|
+
expect(out[TENSOR_XX]).toBeCloseTo(4, 10);
|
|
108
|
+
expect(out[TENSOR_YY]).toBeCloseTo(4, 10);
|
|
109
|
+
expect(out[TENSOR_ZZ]).toBeCloseTo(4, 10);
|
|
110
|
+
expect(out[TENSOR_XY]).toBeCloseTo(0, 10);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('preserves the trace, which rotation cannot change', () => {
|
|
114
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
115
|
+
const angle = 1.1;
|
|
116
|
+
rotateSymmetricTensor([2, 5, 11, 0.3, -0.4, 0.6], Math.sin(angle / 2), 0, 0, Math.cos(angle / 2), out);
|
|
117
|
+
expect(out[TENSOR_XX] + out[TENSOR_YY] + out[TENSOR_ZZ]).toBeCloseTo(2 + 5 + 11, 10);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('is safe when out aliases the input', () => {
|
|
121
|
+
const half = Math.SQRT1_2;
|
|
122
|
+
const tensor = [1, 5, 9, 0, 0, 0];
|
|
123
|
+
rotateSymmetricTensor(tensor, 0, 0, half, half, tensor);
|
|
124
|
+
expect(tensor[TENSOR_XX]).toBeCloseTo(5, 10);
|
|
125
|
+
expect(tensor[TENSOR_YY]).toBeCloseTo(1, 10);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('translateSymmetricTensor', () => {
|
|
130
|
+
it('adds the point-mass term along the offset axes', () => {
|
|
131
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
132
|
+
translateSymmetricTensor([0, 0, 0, 0, 0, 0], 2, 0, 3, 0, out);
|
|
133
|
+
// Offset purely in y: xx and zz gain mass * dy^2, yy is untouched.
|
|
134
|
+
expect(out[TENSOR_XX]).toBeCloseTo(18, 12);
|
|
135
|
+
expect(out[TENSOR_YY]).toBeCloseTo(0, 12);
|
|
136
|
+
expect(out[TENSOR_ZZ]).toBeCloseTo(18, 12);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('produces negative off-diagonal terms for a diagonal offset', () => {
|
|
140
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
141
|
+
translateSymmetricTensor([0, 0, 0, 0, 0, 0], 1, 2, 3, 0, out);
|
|
142
|
+
expect(out[TENSOR_XY]).toBeCloseTo(-6, 12);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('is even in the offset — negating it gives the same tensor', () => {
|
|
146
|
+
const positive = [0, 0, 0, 0, 0, 0];
|
|
147
|
+
const negative = [0, 0, 0, 0, 0, 0];
|
|
148
|
+
translateSymmetricTensor([1, 1, 1, 0, 0, 0], 3, 1, 2, 3, positive);
|
|
149
|
+
translateSymmetricTensor([1, 1, 1, 0, 0, 0], 3, -1, -2, -3, negative);
|
|
150
|
+
expect(negative[TENSOR_XX]).toBeCloseTo(positive[TENSOR_XX], 12);
|
|
151
|
+
expect(negative[TENSOR_XY]).toBeCloseTo(positive[TENSOR_XY], 12);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('is a no-op at zero offset', () => {
|
|
155
|
+
const out = [0, 0, 0, 0, 0, 0];
|
|
156
|
+
translateSymmetricTensor([1, 2, 3, 4, 5, 6], 7, 0, 0, 0, out);
|
|
157
|
+
expect(out).toEqual([1, 2, 3, 4, 5, 6]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('is safe when out aliases the input', () => {
|
|
161
|
+
const tensor = [1, 1, 1, 0, 0, 0];
|
|
162
|
+
translateSymmetricTensor(tensor, 2, 0, 3, 0, tensor);
|
|
163
|
+
expect(tensor[TENSOR_XX]).toBeCloseTo(19, 12);
|
|
164
|
+
expect(tensor[TENSOR_YY]).toBeCloseTo(1, 12);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import type { Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { isPhysics3DPairJointSuppressed } from './jointCollisionSuppression';
|
|
5
|
+
import { createPhysics3DBallAndSocketJoint } from './jointFactories';
|
|
6
|
+
import { addPhysics3DJoint } from './jointRegistry';
|
|
7
|
+
import { computePhysics3DSphereMassData, createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
|
|
8
|
+
import { registerBuiltInPhysics3DJointSolvers } from './registerBuiltInPhysics3DJointSolvers';
|
|
9
|
+
import {
|
|
10
|
+
addPhysics3DBody,
|
|
11
|
+
applyPhysics3DForce,
|
|
12
|
+
applyPhysics3DForceAtPoint,
|
|
13
|
+
applyPhysics3DLinearImpulse,
|
|
14
|
+
applyPhysics3DTorque,
|
|
15
|
+
createPhysics3DSequentialImpulseConfig,
|
|
16
|
+
createPhysics3DSolverConfig,
|
|
17
|
+
createPhysics3DWorld,
|
|
18
|
+
createRigidBody3D,
|
|
19
|
+
findPhysics3DBody,
|
|
20
|
+
Physics3DWorldVersion,
|
|
21
|
+
removePhysics3DBody,
|
|
22
|
+
setPhysics3DBodyFixedRotation,
|
|
23
|
+
setPhysics3DBodyTransform,
|
|
24
|
+
setPhysics3DBodyType,
|
|
25
|
+
wakePhysics3DBody,
|
|
26
|
+
writeRigidBody3DWorldCenter,
|
|
27
|
+
} from './world';
|
|
28
|
+
|
|
29
|
+
describe('addPhysics3DBody', () => {
|
|
30
|
+
it('assigns a persistent index and registers the body', () => {
|
|
31
|
+
const world = createPhysics3DWorld();
|
|
32
|
+
const body = createRigidBody3D();
|
|
33
|
+
|
|
34
|
+
const index = addPhysics3DBody(world, body);
|
|
35
|
+
|
|
36
|
+
expect(index).toBe(0);
|
|
37
|
+
expect(world.bodies).toContain(body);
|
|
38
|
+
expect(findPhysics3DBody(world, index)).toBe(body);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('never reissues an index a removed body held', () => {
|
|
42
|
+
const world = createPhysics3DWorld();
|
|
43
|
+
const first = createRigidBody3D();
|
|
44
|
+
addPhysics3DBody(world, first);
|
|
45
|
+
removePhysics3DBody(world, first);
|
|
46
|
+
|
|
47
|
+
const second = createRigidBody3D();
|
|
48
|
+
const index = addPhysics3DBody(world, second);
|
|
49
|
+
|
|
50
|
+
expect(index).not.toBe(first.index);
|
|
51
|
+
expect(findPhysics3DBody(world, first.index)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('is idempotent for a body already in the world', () => {
|
|
55
|
+
const world = createPhysics3DWorld();
|
|
56
|
+
const body = createRigidBody3D();
|
|
57
|
+
const first = addPhysics3DBody(world, body);
|
|
58
|
+
|
|
59
|
+
const second = addPhysics3DBody(world, body);
|
|
60
|
+
|
|
61
|
+
expect(second).toBe(first);
|
|
62
|
+
expect(world.bodies).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('applyPhysics3DForce', () => {
|
|
67
|
+
it('accumulates onto the force vector and wakes the body', () => {
|
|
68
|
+
const body = sphere();
|
|
69
|
+
body.sleeping = true;
|
|
70
|
+
|
|
71
|
+
applyPhysics3DForce(body, 1, 2, 3);
|
|
72
|
+
|
|
73
|
+
expect(body.forceY).toBe(2);
|
|
74
|
+
expect(body.sleeping).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('ignores a static body', () => {
|
|
78
|
+
const body = createRigidBody3D('static');
|
|
79
|
+
applyPhysics3DForce(body, 1, 2, 3);
|
|
80
|
+
expect(body.forceY).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('applyPhysics3DForceAtPoint', () => {
|
|
85
|
+
it('produces torque from the lever arm', () => {
|
|
86
|
+
const body = sphere();
|
|
87
|
+
|
|
88
|
+
// Push along +x at a point one unit along +y: the torque is about -z.
|
|
89
|
+
applyPhysics3DForceAtPoint(body, 1, 0, 0, 0, 1, 0);
|
|
90
|
+
|
|
91
|
+
expect(body.forceX).toBe(1);
|
|
92
|
+
expect(body.torqueZ).toBeCloseTo(-1, 12);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('produces no torque for a force through the centre of mass', () => {
|
|
96
|
+
const body = sphere();
|
|
97
|
+
|
|
98
|
+
applyPhysics3DForceAtPoint(body, 5, 0, 0, 0, 0, 0);
|
|
99
|
+
|
|
100
|
+
expect(body.torqueX).toBeCloseTo(0, 12);
|
|
101
|
+
expect(body.torqueY).toBeCloseTo(0, 12);
|
|
102
|
+
expect(body.torqueZ).toBeCloseTo(0, 12);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('measures the lever arm from the centre of mass, not the origin', () => {
|
|
106
|
+
const body = sphere();
|
|
107
|
+
body.centerY = 1;
|
|
108
|
+
|
|
109
|
+
// The same point is now AT the centre of mass, so it can produce no torque.
|
|
110
|
+
applyPhysics3DForceAtPoint(body, 1, 0, 0, 0, 1, 0);
|
|
111
|
+
|
|
112
|
+
expect(body.torqueZ).toBeCloseTo(0, 12);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('applyPhysics3DLinearImpulse', () => {
|
|
117
|
+
it('changes velocity immediately through the inverse mass', () => {
|
|
118
|
+
const body = sphere();
|
|
119
|
+
|
|
120
|
+
applyPhysics3DLinearImpulse(body, body.mass * 3, 0, 0);
|
|
121
|
+
|
|
122
|
+
expect(body.velocityX).toBeCloseTo(3, 10);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('ignores an immovable body', () => {
|
|
126
|
+
const body = createRigidBody3D('static');
|
|
127
|
+
applyPhysics3DLinearImpulse(body, 100, 0, 0);
|
|
128
|
+
expect(body.velocityX).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('applyPhysics3DTorque', () => {
|
|
133
|
+
it('accumulates onto the torque vector', () => {
|
|
134
|
+
const body = sphere();
|
|
135
|
+
applyPhysics3DTorque(body, 0, 0, 4);
|
|
136
|
+
expect(body.torqueZ).toBe(4);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('createPhysics3DSequentialImpulseConfig', () => {
|
|
141
|
+
it('defaults to warm starting on', () => {
|
|
142
|
+
expect(createPhysics3DSequentialImpulseConfig().warmStarting).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('gives more velocity iterations than position iterations', () => {
|
|
146
|
+
const config = createPhysics3DSequentialImpulseConfig();
|
|
147
|
+
expect(config.velocityIterations).toBeGreaterThan(config.positionIterations);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('createPhysics3DSolverConfig', () => {
|
|
152
|
+
it('defaults substeps to one, reproducing a single discrete step', () => {
|
|
153
|
+
expect(createPhysics3DSolverConfig().substeps).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('carries the sequential-impulse block as a named sibling rather than flattened', () => {
|
|
157
|
+
const config = createPhysics3DSolverConfig();
|
|
158
|
+
expect(config.sequentialImpulse.velocityIterations).toBeGreaterThan(0);
|
|
159
|
+
expect((config as unknown as Record<string, unknown>).velocityIterations).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('createPhysics3DWorld', () => {
|
|
164
|
+
it('starts empty at the current version', () => {
|
|
165
|
+
const world = createPhysics3DWorld();
|
|
166
|
+
expect(world.version).toBe(Physics3DWorldVersion);
|
|
167
|
+
expect(world.bodies).toHaveLength(0);
|
|
168
|
+
expect(world.contacts).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('defaults gravity to earth along -Y', () => {
|
|
172
|
+
const world = createPhysics3DWorld();
|
|
173
|
+
expect(world.gravityY).toBeLessThan(0);
|
|
174
|
+
expect(world.gravityX).toBe(0);
|
|
175
|
+
expect(world.gravityZ).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('gives each world its own joint solver registry', () => {
|
|
179
|
+
const a = createPhysics3DWorld();
|
|
180
|
+
const b = createPhysics3DWorld();
|
|
181
|
+
a.jointSolvers.set('acme.Test', { prepare: () => {}, solve: () => {} });
|
|
182
|
+
expect(b.jointSolvers.has('acme.Test')).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('createRigidBody3D', () => {
|
|
187
|
+
it('starts at rest at the origin with an identity orientation', () => {
|
|
188
|
+
const body = createRigidBody3D();
|
|
189
|
+
expect(body.orientationW).toBe(1);
|
|
190
|
+
expect(body.velocityX).toBe(0);
|
|
191
|
+
expect(body.x).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('is immovable until given mass', () => {
|
|
195
|
+
expect(createRigidBody3D().inverseMass).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('defaults to dynamic', () => {
|
|
199
|
+
expect(createRigidBody3D().type).toBe('dynamic');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('findPhysics3DBody', () => {
|
|
204
|
+
it('returns null for an unknown index rather than throwing', () => {
|
|
205
|
+
expect(findPhysics3DBody(createPhysics3DWorld(), 99)).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('removePhysics3DBody', () => {
|
|
210
|
+
it('releases the ownership of every joint it drops', () => {
|
|
211
|
+
const world = createPhysics3DWorld();
|
|
212
|
+
registerBuiltInPhysics3DJointSolvers(world);
|
|
213
|
+
const anchor = createRigidBody3D('dynamic');
|
|
214
|
+
const hanging = createRigidBody3D('dynamic');
|
|
215
|
+
addPhysics3DBody(world, anchor);
|
|
216
|
+
addPhysics3DBody(world, hanging);
|
|
217
|
+
const joint = addPhysics3DJoint(
|
|
218
|
+
world,
|
|
219
|
+
createPhysics3DBallAndSocketJoint({ bodyA: anchor.index, bodyB: hanging.index }),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
removePhysics3DBody(world, hanging);
|
|
223
|
+
|
|
224
|
+
// A joint dropped here leaves by the same exits `removePhysics3DJoint` uses. Splicing it out of the
|
|
225
|
+
// array alone leaves a joint that no world holds but that every world still refuses to accept.
|
|
226
|
+
expect(world.joints).toEqual([]);
|
|
227
|
+
expect(() => addPhysics3DJoint(world, joint)).not.toThrow();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('stops suppressing a pair whose joint it just dropped', () => {
|
|
231
|
+
const world = createPhysics3DWorld();
|
|
232
|
+
registerBuiltInPhysics3DJointSolvers(world);
|
|
233
|
+
const first = createRigidBody3D('dynamic');
|
|
234
|
+
const second = createRigidBody3D('dynamic');
|
|
235
|
+
addPhysics3DBody(world, first);
|
|
236
|
+
addPhysics3DBody(world, second);
|
|
237
|
+
addPhysics3DJoint(world, createPhysics3DBallAndSocketJoint({ bodyA: first.index, bodyB: second.index }));
|
|
238
|
+
expect(isPhysics3DPairJointSuppressed(world, first.index, second.index)).toBe(true);
|
|
239
|
+
|
|
240
|
+
removePhysics3DBody(world, second);
|
|
241
|
+
|
|
242
|
+
// Leaving the index behind suppresses a pair against a joint that no longer exists, which reads as a
|
|
243
|
+
// contact that silently never reports.
|
|
244
|
+
expect(isPhysics3DPairJointSuppressed(world, first.index, second.index)).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('reports false for a body that is not in the world', () => {
|
|
248
|
+
expect(removePhysics3DBody(createPhysics3DWorld(), createRigidBody3D())).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('drops contacts that named the removed body', () => {
|
|
252
|
+
const world = createPhysics3DWorld();
|
|
253
|
+
const a = createRigidBody3D();
|
|
254
|
+
const b = createRigidBody3D();
|
|
255
|
+
addPhysics3DBody(world, a);
|
|
256
|
+
addPhysics3DBody(world, b);
|
|
257
|
+
world.contacts.push(contact(a.index, b.index));
|
|
258
|
+
|
|
259
|
+
removePhysics3DBody(world, a);
|
|
260
|
+
|
|
261
|
+
expect(world.contacts).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('drops joints that named the removed body', () => {
|
|
265
|
+
const world = createPhysics3DWorld();
|
|
266
|
+
const a = createRigidBody3D();
|
|
267
|
+
const b = createRigidBody3D();
|
|
268
|
+
addPhysics3DBody(world, a);
|
|
269
|
+
addPhysics3DBody(world, b);
|
|
270
|
+
world.joints.push({
|
|
271
|
+
kind: 'Physics3DBallJoint',
|
|
272
|
+
bodyA: a.index,
|
|
273
|
+
bodyB: b.index,
|
|
274
|
+
localAnchorAX: 0,
|
|
275
|
+
localAnchorAY: 0,
|
|
276
|
+
localAnchorAZ: 0,
|
|
277
|
+
localAnchorBX: 0,
|
|
278
|
+
localAnchorBY: 0,
|
|
279
|
+
localAnchorBZ: 0,
|
|
280
|
+
collideConnected: false,
|
|
281
|
+
impulse0: 0,
|
|
282
|
+
impulse1: 0,
|
|
283
|
+
impulse2: 0,
|
|
284
|
+
impulse3: 0,
|
|
285
|
+
impulse4: 0,
|
|
286
|
+
impulse5: 0,
|
|
287
|
+
rAX: 0,
|
|
288
|
+
rAY: 0,
|
|
289
|
+
rAZ: 0,
|
|
290
|
+
rBX: 0,
|
|
291
|
+
rBY: 0,
|
|
292
|
+
rBZ: 0,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
removePhysics3DBody(world, b);
|
|
296
|
+
|
|
297
|
+
expect(world.joints).toHaveLength(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('leaves unrelated contacts in place', () => {
|
|
301
|
+
const world = createPhysics3DWorld();
|
|
302
|
+
const a = createRigidBody3D();
|
|
303
|
+
const b = createRigidBody3D();
|
|
304
|
+
const c = createRigidBody3D();
|
|
305
|
+
addPhysics3DBody(world, a);
|
|
306
|
+
addPhysics3DBody(world, b);
|
|
307
|
+
addPhysics3DBody(world, c);
|
|
308
|
+
world.contacts.push(contact(b.index, c.index));
|
|
309
|
+
|
|
310
|
+
removePhysics3DBody(world, a);
|
|
311
|
+
|
|
312
|
+
expect(world.contacts).toHaveLength(1);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('setPhysics3DBodyFixedRotation', () => {
|
|
317
|
+
it('zeroes the inverse inertia and clears angular velocity while keeping inverse mass', () => {
|
|
318
|
+
const body = sphere();
|
|
319
|
+
body.angularVelocityZ = 5;
|
|
320
|
+
|
|
321
|
+
setPhysics3DBodyFixedRotation(body, true);
|
|
322
|
+
|
|
323
|
+
expect(body.inverseInertiaXX).toBe(0);
|
|
324
|
+
expect(body.angularVelocityZ).toBe(0);
|
|
325
|
+
expect(body.inverseMass).toBeGreaterThan(0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('restores the inverse inertia from the stored forward tensor when released', () => {
|
|
329
|
+
const body = sphere();
|
|
330
|
+
const before = body.inverseInertiaXX;
|
|
331
|
+
setPhysics3DBodyFixedRotation(body, true);
|
|
332
|
+
|
|
333
|
+
setPhysics3DBodyFixedRotation(body, false);
|
|
334
|
+
|
|
335
|
+
expect(body.inverseInertiaXX).toBeCloseTo(before, 10);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('setPhysics3DBodyTransform', () => {
|
|
340
|
+
it('moves the body and refreshes the world inertia', () => {
|
|
341
|
+
const body = sphere();
|
|
342
|
+
|
|
343
|
+
setPhysics3DBodyTransform(body, 1, 2, 3, 0, 0, 0, 1);
|
|
344
|
+
|
|
345
|
+
expect(body.y).toBe(2);
|
|
346
|
+
expect(body.inverseInertiaWorldXX).toBeCloseTo(body.inverseInertiaXX, 10);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('normalizes a quaternion it is handed unnormalized', () => {
|
|
350
|
+
const body = sphere();
|
|
351
|
+
|
|
352
|
+
setPhysics3DBodyTransform(body, 0, 0, 0, 0, 0, 3, 3);
|
|
353
|
+
|
|
354
|
+
const length = Math.hypot(body.orientationX, body.orientationY, body.orientationZ, body.orientationW);
|
|
355
|
+
expect(length).toBeCloseTo(1, 12);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('falls back to identity for a zero quaternion rather than dividing by zero', () => {
|
|
359
|
+
const body = sphere();
|
|
360
|
+
|
|
361
|
+
setPhysics3DBodyTransform(body, 0, 0, 0, 0, 0, 0, 0);
|
|
362
|
+
|
|
363
|
+
expect(body.orientationW).toBe(1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('wakes the body', () => {
|
|
367
|
+
const body = sphere();
|
|
368
|
+
body.sleeping = true;
|
|
369
|
+
|
|
370
|
+
setPhysics3DBodyTransform(body, 1, 0, 0, 0, 0, 0, 1);
|
|
371
|
+
|
|
372
|
+
expect(body.sleeping).toBe(false);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('setPhysics3DBodyType', () => {
|
|
377
|
+
it('makes a dynamic body immovable when it becomes static', () => {
|
|
378
|
+
const body = sphere();
|
|
379
|
+
body.velocityX = 5;
|
|
380
|
+
|
|
381
|
+
setPhysics3DBodyType(body, 'static');
|
|
382
|
+
|
|
383
|
+
expect(body.inverseMass).toBe(0);
|
|
384
|
+
expect(body.velocityX).toBe(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('restores the inverse mass and inertia when a static body becomes dynamic again', () => {
|
|
388
|
+
const body = sphere();
|
|
389
|
+
const mass = body.inverseMass;
|
|
390
|
+
const inertia = body.inverseInertiaXX;
|
|
391
|
+
setPhysics3DBodyType(body, 'static');
|
|
392
|
+
|
|
393
|
+
setPhysics3DBodyType(body, 'dynamic');
|
|
394
|
+
|
|
395
|
+
expect(body.inverseMass).toBeCloseTo(mass, 10);
|
|
396
|
+
expect(body.inverseInertiaXX).toBeCloseTo(inertia, 10);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('keeps velocity when a kinematic body becomes dynamic', () => {
|
|
400
|
+
const body = createRigidBody3D('kinematic');
|
|
401
|
+
body.velocityX = 4;
|
|
402
|
+
|
|
403
|
+
setPhysics3DBodyType(body, 'dynamic');
|
|
404
|
+
|
|
405
|
+
expect(body.velocityX).toBe(4);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('clears the force accumulators on any change', () => {
|
|
409
|
+
const body = sphere();
|
|
410
|
+
body.forceX = 9;
|
|
411
|
+
|
|
412
|
+
setPhysics3DBodyType(body, 'kinematic');
|
|
413
|
+
|
|
414
|
+
expect(body.forceX).toBe(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('is a no-op for the type it already has', () => {
|
|
418
|
+
const body = sphere();
|
|
419
|
+
body.forceX = 9;
|
|
420
|
+
|
|
421
|
+
setPhysics3DBodyType(body, 'dynamic');
|
|
422
|
+
|
|
423
|
+
expect(body.forceX).toBe(9);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('wakePhysics3DBody', () => {
|
|
428
|
+
it('clears the sleep flag and timer', () => {
|
|
429
|
+
const body = sphere();
|
|
430
|
+
body.sleeping = true;
|
|
431
|
+
body.sleepTimer = 3;
|
|
432
|
+
|
|
433
|
+
wakePhysics3DBody(body);
|
|
434
|
+
|
|
435
|
+
expect(body.sleeping).toBe(false);
|
|
436
|
+
expect(body.sleepTimer).toBe(0);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('is a no-op for a static body, which is neither awake nor asleep', () => {
|
|
440
|
+
const body = createRigidBody3D('static');
|
|
441
|
+
body.sleepTimer = 3;
|
|
442
|
+
|
|
443
|
+
wakePhysics3DBody(body);
|
|
444
|
+
|
|
445
|
+
expect(body.sleepTimer).toBe(3);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('writeRigidBody3DWorldCenter', () => {
|
|
450
|
+
it('returns the position when the centre of mass is the origin', () => {
|
|
451
|
+
const body = sphere();
|
|
452
|
+
body.x = 1;
|
|
453
|
+
body.y = 2;
|
|
454
|
+
body.z = 3;
|
|
455
|
+
const out = [0, 0, 0];
|
|
456
|
+
|
|
457
|
+
writeRigidBody3DWorldCenter(body, out);
|
|
458
|
+
|
|
459
|
+
expect(out).toEqual([1, 2, 3]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('offsets by the local centre at identity orientation', () => {
|
|
463
|
+
const body = sphere();
|
|
464
|
+
body.x = 1;
|
|
465
|
+
body.centerY = 2;
|
|
466
|
+
const out = [0, 0, 0];
|
|
467
|
+
|
|
468
|
+
writeRigidBody3DWorldCenter(body, out);
|
|
469
|
+
|
|
470
|
+
expect(out[0]).toBeCloseTo(1, 12);
|
|
471
|
+
expect(out[1]).toBeCloseTo(2, 12);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('rotates the local centre by the body orientation', () => {
|
|
475
|
+
const body = sphere();
|
|
476
|
+
body.centerX = 1;
|
|
477
|
+
// A quarter turn about z carries local +x onto world +y.
|
|
478
|
+
body.orientationZ = Math.SQRT1_2;
|
|
479
|
+
body.orientationW = Math.SQRT1_2;
|
|
480
|
+
const out = [0, 0, 0];
|
|
481
|
+
|
|
482
|
+
writeRigidBody3DWorldCenter(body, out);
|
|
483
|
+
|
|
484
|
+
expect(out[0]).toBeCloseTo(0, 10);
|
|
485
|
+
expect(out[1]).toBeCloseTo(1, 10);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
function contact(bodyA: number, bodyB: number): Physics3DWorld['contacts'][number] {
|
|
490
|
+
return {
|
|
491
|
+
bodyA,
|
|
492
|
+
bodyB,
|
|
493
|
+
normalX: 0,
|
|
494
|
+
normalY: 1,
|
|
495
|
+
normalZ: 0,
|
|
496
|
+
pointCount: 0,
|
|
497
|
+
points: [],
|
|
498
|
+
friction: 0.2,
|
|
499
|
+
restitution: 0,
|
|
500
|
+
enabled: true,
|
|
501
|
+
sensor: false,
|
|
502
|
+
touching: true,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function sphere(): RigidBody3D {
|
|
507
|
+
const body = createRigidBody3D();
|
|
508
|
+
const data = createPhysics3DMassData();
|
|
509
|
+
computePhysics3DSphereMassData(1, 1, data);
|
|
510
|
+
setRigidBody3DMassData(body, data);
|
|
511
|
+
return body;
|
|
512
|
+
}
|