@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,481 @@
|
|
|
1
|
+
import type { Physics3DContact, Physics3DContactPoint, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { refreshRigidBody3DWorldInertia } from './integrate';
|
|
5
|
+
import { buildPhysics3DSolveIslands, updatePhysics3DSleep } from './islands';
|
|
6
|
+
import { computePhysics3DBoxMassData, createPhysics3DMassData } from './massProperties';
|
|
7
|
+
import { setRigidBody3DMassData } from './massProperties';
|
|
8
|
+
import {
|
|
9
|
+
createPhysics3DContactConstraint,
|
|
10
|
+
createPhysics3DContactConstraintPoint,
|
|
11
|
+
preparePhysics3DContactConstraints,
|
|
12
|
+
solvePhysics3DContactPositions,
|
|
13
|
+
solvePhysics3DContactVelocities,
|
|
14
|
+
warmStartPhysics3DContacts,
|
|
15
|
+
} from './solver';
|
|
16
|
+
import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D, setPhysics3DBodyType } from './world';
|
|
17
|
+
|
|
18
|
+
describe('createPhysics3DContactConstraint', () => {
|
|
19
|
+
it('allocates an unbound constraint with no points', () => {
|
|
20
|
+
const constraint = createPhysics3DContactConstraint();
|
|
21
|
+
expect(constraint.contact).toBe(-1);
|
|
22
|
+
expect(constraint.pointCount).toBe(0);
|
|
23
|
+
expect(constraint.points).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('createPhysics3DContactConstraintPoint', () => {
|
|
28
|
+
it('zeroes every accumulator so a new contact warm-starts from nothing', () => {
|
|
29
|
+
const point = createPhysics3DContactConstraintPoint();
|
|
30
|
+
expect(point.normalImpulse).toBe(0);
|
|
31
|
+
expect(point.tangentImpulse0).toBe(0);
|
|
32
|
+
expect(point.tangentImpulse1).toBe(0);
|
|
33
|
+
expect(point.bias).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('preparePhysics3DContactConstraints', () => {
|
|
38
|
+
it('builds a friction basis orthonormal to the contact normal', () => {
|
|
39
|
+
const world = createFallingBoxWorld();
|
|
40
|
+
preparePhysics3DContactConstraints(world);
|
|
41
|
+
|
|
42
|
+
const constraint = world.solver.constraints[0];
|
|
43
|
+
const contact = world.contacts[0];
|
|
44
|
+
const dot0 =
|
|
45
|
+
constraint.tangent0X * contact.normalX +
|
|
46
|
+
constraint.tangent0Y * contact.normalY +
|
|
47
|
+
constraint.tangent0Z * contact.normalZ;
|
|
48
|
+
const dot1 =
|
|
49
|
+
constraint.tangent1X * contact.normalX +
|
|
50
|
+
constraint.tangent1Y * contact.normalY +
|
|
51
|
+
constraint.tangent1Z * contact.normalZ;
|
|
52
|
+
const dotTangents =
|
|
53
|
+
constraint.tangent0X * constraint.tangent1X +
|
|
54
|
+
constraint.tangent0Y * constraint.tangent1Y +
|
|
55
|
+
constraint.tangent0Z * constraint.tangent1Z;
|
|
56
|
+
|
|
57
|
+
expect(dot0).toBeCloseTo(0, 12);
|
|
58
|
+
expect(dot1).toBeCloseTo(0, 12);
|
|
59
|
+
expect(dotTangents).toBeCloseTo(0, 12);
|
|
60
|
+
expect(Math.hypot(constraint.tangent0X, constraint.tangent0Y, constraint.tangent0Z)).toBeCloseTo(1, 12);
|
|
61
|
+
expect(Math.hypot(constraint.tangent1X, constraint.tangent1Y, constraint.tangent1Z)).toBeCloseTo(1, 12);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('produces a basis orthonormal to the normal for every axis-aligned normal', () => {
|
|
65
|
+
// The seed-axis choice flips at 1/sqrt(3); a normal aligned to the seed would cross to zero.
|
|
66
|
+
for (const [nx, ny, nz] of [
|
|
67
|
+
[1, 0, 0],
|
|
68
|
+
[-1, 0, 0],
|
|
69
|
+
[0, 1, 0],
|
|
70
|
+
[0, -1, 0],
|
|
71
|
+
[0, 0, 1],
|
|
72
|
+
[0, 0, -1],
|
|
73
|
+
]) {
|
|
74
|
+
const world = createFallingBoxWorld();
|
|
75
|
+
const contact = world.contacts[0];
|
|
76
|
+
contact.normalX = nx;
|
|
77
|
+
contact.normalY = ny;
|
|
78
|
+
contact.normalZ = nz;
|
|
79
|
+
preparePhysics3DContactConstraints(world);
|
|
80
|
+
|
|
81
|
+
const constraint = world.solver.constraints[0];
|
|
82
|
+
expect(Math.hypot(constraint.tangent0X, constraint.tangent0Y, constraint.tangent0Z)).toBeCloseTo(1, 12);
|
|
83
|
+
expect(Math.hypot(constraint.tangent1X, constraint.tangent1Y, constraint.tangent1Z)).toBeCloseTo(1, 12);
|
|
84
|
+
expect(constraint.tangent0X * nx + constraint.tangent0Y * ny + constraint.tangent0Z * nz).toBeCloseTo(0, 12);
|
|
85
|
+
expect(constraint.tangent1X * nx + constraint.tangent1Y * ny + constraint.tangent1Z * nz).toBeCloseTo(0, 12);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('sets the effective normal mass to the inverse mass sum for a centred contact', () => {
|
|
90
|
+
// Lever arm along the normal contributes no angular term, so the denominator is the mass sum alone.
|
|
91
|
+
const world = createFallingBoxWorld();
|
|
92
|
+
const contact = world.contacts[0];
|
|
93
|
+
contact.points[0].rAX = 0;
|
|
94
|
+
contact.points[0].rAY = -0.5;
|
|
95
|
+
contact.points[0].rAZ = 0;
|
|
96
|
+
preparePhysics3DContactConstraints(world);
|
|
97
|
+
|
|
98
|
+
const inverseMassSum = world.bodies[0].inverseMass + world.bodies[1].inverseMass;
|
|
99
|
+
expect(world.solver.constraints[0].points[0].normalMass).toBeCloseTo(1 / inverseMassSum, 9);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('captures restitution from the approach speed before any impulse is applied', () => {
|
|
103
|
+
const world = createFallingBoxWorld();
|
|
104
|
+
world.contacts[0].restitution = 0.5;
|
|
105
|
+
world.bodies[0].velocityY = -10;
|
|
106
|
+
preparePhysics3DContactConstraints(world);
|
|
107
|
+
|
|
108
|
+
expect(world.solver.constraints[0].points[0].bias).toBeCloseTo(5, 9);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('drops restitution below the threshold so a ball does not bounce forever', () => {
|
|
112
|
+
const world = createFallingBoxWorld();
|
|
113
|
+
world.contacts[0].restitution = 0.9;
|
|
114
|
+
world.config.sequentialImpulse.restitutionThreshold = 1;
|
|
115
|
+
world.bodies[0].velocityY = -0.1;
|
|
116
|
+
preparePhysics3DContactConstraints(world);
|
|
117
|
+
|
|
118
|
+
expect(world.solver.constraints[0].points[0].bias).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('skips a non-touching contact, which is its own filter to apply', () => {
|
|
122
|
+
// `touching` is prepare's to check, because it can change without anything the island workspace
|
|
123
|
+
// watches changing: the same pair stays in the same island while the narrow phase reports it apart.
|
|
124
|
+
const world = createFallingBoxWorld();
|
|
125
|
+
world.contacts[0].touching = false;
|
|
126
|
+
preparePhysics3DContactConstraints(world);
|
|
127
|
+
expect(world.solver.constraints).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips disabled and sensor contacts, which the island workspace excludes upstream', () => {
|
|
131
|
+
for (const mutate of [
|
|
132
|
+
(c: Physics3DContact) => {
|
|
133
|
+
c.enabled = false;
|
|
134
|
+
},
|
|
135
|
+
(c: Physics3DContact) => {
|
|
136
|
+
c.sensor = true;
|
|
137
|
+
},
|
|
138
|
+
]) {
|
|
139
|
+
const world = createFallingBoxWorld();
|
|
140
|
+
mutate(world.contacts[0]);
|
|
141
|
+
// Rebuilt because these two are read when the islands are built, not when prepare runs. A real
|
|
142
|
+
// step gets this for free: the pre-solve hook — the supported place to disable a contact — runs
|
|
143
|
+
// before `buildPhysics3DSolveIslands`, so a contact it turns off is gone from the slices.
|
|
144
|
+
buildSolveWorkspace(world);
|
|
145
|
+
preparePhysics3DContactConstraints(world);
|
|
146
|
+
expect(world.solver.constraints).toHaveLength(0);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('skips a contact between two bodies that can neither translate nor rotate', () => {
|
|
151
|
+
const world = createFallingBoxWorld();
|
|
152
|
+
setPhysics3DBodyType(world.bodies[0], 'static');
|
|
153
|
+
setPhysics3DBodyType(world.bodies[1], 'static');
|
|
154
|
+
preparePhysics3DContactConstraints(world);
|
|
155
|
+
expect(world.solver.constraints).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('carries accumulators across steps by featureId, not by point index', () => {
|
|
159
|
+
const world = createFallingBoxWorld();
|
|
160
|
+
preparePhysics3DContactConstraints(world);
|
|
161
|
+
world.solver.constraints[0].points[0].normalImpulse = 7;
|
|
162
|
+
|
|
163
|
+
// The narrow phase reports the same physical corner second this time.
|
|
164
|
+
const contact = world.contacts[0];
|
|
165
|
+
const moved = contact.points[0];
|
|
166
|
+
contact.points = [createContactPoint(99, 0.01), moved];
|
|
167
|
+
contact.pointCount = 2;
|
|
168
|
+
preparePhysics3DContactConstraints(world);
|
|
169
|
+
|
|
170
|
+
expect(world.solver.constraints[0].points[0].normalImpulse).toBe(0);
|
|
171
|
+
expect(world.solver.constraints[0].points[1].normalImpulse).toBe(7);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not carry accumulators when warm starting is disabled', () => {
|
|
175
|
+
const world = createFallingBoxWorld();
|
|
176
|
+
preparePhysics3DContactConstraints(world);
|
|
177
|
+
world.solver.constraints[0].points[0].normalImpulse = 7;
|
|
178
|
+
world.config.sequentialImpulse.warmStarting = false;
|
|
179
|
+
preparePhysics3DContactConstraints(world);
|
|
180
|
+
|
|
181
|
+
expect(world.solver.constraints[0].points[0].normalImpulse).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('solvePhysics3DContactPositions', () => {
|
|
186
|
+
it('pushes a penetrating body out along the normal', () => {
|
|
187
|
+
const world = createFallingBoxWorld();
|
|
188
|
+
world.contacts[0].points[0].depth = 0.1;
|
|
189
|
+
preparePhysics3DContactConstraints(world);
|
|
190
|
+
|
|
191
|
+
const before = world.bodies[0].y;
|
|
192
|
+
solvePhysics3DContactPositions(world);
|
|
193
|
+
expect(world.bodies[0].y).toBeGreaterThan(before);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('leaves the slop unresolved so a resting body does not twitch', () => {
|
|
197
|
+
const world = createFallingBoxWorld();
|
|
198
|
+
world.contacts[0].points[0].depth = world.config.sequentialImpulse.penetrationSlop;
|
|
199
|
+
preparePhysics3DContactConstraints(world);
|
|
200
|
+
|
|
201
|
+
const before = world.bodies[0].y;
|
|
202
|
+
solvePhysics3DContactPositions(world);
|
|
203
|
+
expect(world.bodies[0].y).toBe(before);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('reports the deepest remaining penetration so a caller can stop iterating', () => {
|
|
207
|
+
const world = createFallingBoxWorld();
|
|
208
|
+
world.contacts[0].points[0].depth = 0.25;
|
|
209
|
+
preparePhysics3DContactConstraints(world);
|
|
210
|
+
|
|
211
|
+
const deepest = solvePhysics3DContactPositions(world);
|
|
212
|
+
expect(deepest).toBeCloseTo(0.25 - world.config.sequentialImpulse.penetrationSlop, 12);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('converges toward the slop over repeated iterations', () => {
|
|
216
|
+
const world = createFallingBoxWorld();
|
|
217
|
+
world.contacts[0].points[0].depth = 0.2;
|
|
218
|
+
preparePhysics3DContactConstraints(world);
|
|
219
|
+
|
|
220
|
+
// Depth is an input the narrow phase would refresh; drive it from the body's own motion instead.
|
|
221
|
+
const startY = world.bodies[0].y;
|
|
222
|
+
for (let i = 0; i < 40; i += 1) {
|
|
223
|
+
world.contacts[0].points[0].depth = 0.2 - (world.bodies[0].y - startY);
|
|
224
|
+
solvePhysics3DContactPositions(world);
|
|
225
|
+
}
|
|
226
|
+
const remaining = 0.2 - (world.bodies[0].y - startY);
|
|
227
|
+
expect(remaining).toBeLessThan(0.2);
|
|
228
|
+
expect(remaining).toBeGreaterThan(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('never moves a static body', () => {
|
|
232
|
+
const world = createBoxOnGroundWorld();
|
|
233
|
+
world.contacts[0].points[0].depth = 0.1;
|
|
234
|
+
preparePhysics3DContactConstraints(world);
|
|
235
|
+
const ground = world.bodies[1];
|
|
236
|
+
solvePhysics3DContactPositions(world);
|
|
237
|
+
|
|
238
|
+
expect(ground.y).toBe(0);
|
|
239
|
+
expect(ground.orientationW).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('solvePhysics3DContactVelocities', () => {
|
|
244
|
+
it('removes the approach velocity of a body resting on static ground', () => {
|
|
245
|
+
const world = createBoxOnGroundWorld();
|
|
246
|
+
world.bodies[0].velocityY = -5;
|
|
247
|
+
preparePhysics3DContactConstraints(world);
|
|
248
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(world);
|
|
249
|
+
|
|
250
|
+
expect(world.bodies[0].velocityY).toBeCloseTo(0, 9);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('applies restitution so a bouncing body leaves at the expected fraction', () => {
|
|
254
|
+
const world = createBoxOnGroundWorld();
|
|
255
|
+
world.contacts[0].restitution = 0.5;
|
|
256
|
+
world.bodies[0].velocityY = -10;
|
|
257
|
+
preparePhysics3DContactConstraints(world);
|
|
258
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(world);
|
|
259
|
+
|
|
260
|
+
expect(world.bodies[0].velocityY).toBeCloseTo(5, 6);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('never pulls two bodies together — the normal impulse stays non-negative', () => {
|
|
264
|
+
const world = createBoxOnGroundWorld();
|
|
265
|
+
world.bodies[0].velocityY = 5; // already separating
|
|
266
|
+
preparePhysics3DContactConstraints(world);
|
|
267
|
+
for (let i = 0; i < 4; i += 1) solvePhysics3DContactVelocities(world);
|
|
268
|
+
|
|
269
|
+
expect(world.solver.constraints[0].points[0].normalImpulse).toBe(0);
|
|
270
|
+
expect(world.bodies[0].velocityY).toBeCloseTo(5, 12);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('leaves a frictionless body sliding at its full tangential speed', () => {
|
|
274
|
+
const world = createBoxOnGroundWorld();
|
|
275
|
+
world.contacts[0].friction = 0;
|
|
276
|
+
world.bodies[0].velocityX = 3;
|
|
277
|
+
world.bodies[0].velocityY = -1;
|
|
278
|
+
preparePhysics3DContactConstraints(world);
|
|
279
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(world);
|
|
280
|
+
|
|
281
|
+
expect(world.bodies[0].velocityX).toBeCloseTo(3, 9);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('opposes tangential motion when friction is present', () => {
|
|
285
|
+
const world = createBoxOnGroundWorld();
|
|
286
|
+
world.contacts[0].friction = 0.5;
|
|
287
|
+
world.bodies[0].velocityX = 3;
|
|
288
|
+
world.bodies[0].velocityY = -1;
|
|
289
|
+
preparePhysics3DContactConstraints(world);
|
|
290
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(world);
|
|
291
|
+
|
|
292
|
+
expect(world.bodies[0].velocityX).toBeLessThan(3);
|
|
293
|
+
expect(world.bodies[0].velocityX).toBeGreaterThan(0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('clamps the two tangents as a coupled cone, not independently', () => {
|
|
297
|
+
// The defect this guards: clamping each tangent to mu*normalImpulse on its own permits a combined
|
|
298
|
+
// magnitude of sqrt(2)*mu*normalImpulse, so a box slides faster on the diagonal than on either axis.
|
|
299
|
+
// Compare a diagonal slide against an axis-aligned one of the same speed.
|
|
300
|
+
const speed = 3;
|
|
301
|
+
const axisWorld = createBoxOnGroundWorld();
|
|
302
|
+
axisWorld.contacts[0].friction = 0.3;
|
|
303
|
+
axisWorld.bodies[0].velocityX = speed;
|
|
304
|
+
axisWorld.bodies[0].velocityY = -1;
|
|
305
|
+
preparePhysics3DContactConstraints(axisWorld);
|
|
306
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(axisWorld);
|
|
307
|
+
const axisRemaining = Math.hypot(axisWorld.bodies[0].velocityX, axisWorld.bodies[0].velocityZ);
|
|
308
|
+
|
|
309
|
+
const diagonalWorld = createBoxOnGroundWorld();
|
|
310
|
+
diagonalWorld.contacts[0].friction = 0.3;
|
|
311
|
+
diagonalWorld.bodies[0].velocityX = speed / Math.SQRT2;
|
|
312
|
+
diagonalWorld.bodies[0].velocityZ = speed / Math.SQRT2;
|
|
313
|
+
diagonalWorld.bodies[0].velocityY = -1;
|
|
314
|
+
preparePhysics3DContactConstraints(diagonalWorld);
|
|
315
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(diagonalWorld);
|
|
316
|
+
const diagonalRemaining = Math.hypot(diagonalWorld.bodies[0].velocityX, diagonalWorld.bodies[0].velocityZ);
|
|
317
|
+
|
|
318
|
+
expect(diagonalRemaining).toBeCloseTo(axisRemaining, 9);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('spins a body when the impulse acts off its centre of mass', () => {
|
|
322
|
+
const world = createBoxOnGroundWorld();
|
|
323
|
+
world.contacts[0].points[0].rAX = 0.5;
|
|
324
|
+
world.contacts[0].points[0].rAY = -0.5;
|
|
325
|
+
world.bodies[0].velocityY = -5;
|
|
326
|
+
preparePhysics3DContactConstraints(world);
|
|
327
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(world);
|
|
328
|
+
|
|
329
|
+
expect(Math.abs(world.bodies[0].angularVelocityZ)).toBeGreaterThan(1e-6);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('applies no impulse to a static body', () => {
|
|
333
|
+
const world = createBoxOnGroundWorld();
|
|
334
|
+
world.bodies[0].velocityY = -5;
|
|
335
|
+
preparePhysics3DContactConstraints(world);
|
|
336
|
+
const ground = world.bodies[1];
|
|
337
|
+
for (let i = 0; i < 8; i += 1) solvePhysics3DContactVelocities(world);
|
|
338
|
+
|
|
339
|
+
expect(ground.velocityX).toBe(0);
|
|
340
|
+
expect(ground.velocityY).toBe(0);
|
|
341
|
+
expect(ground.angularVelocityZ).toBe(0);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('shares the separation between two equal dynamic bodies', () => {
|
|
345
|
+
const world = createTwoDynamicBodyWorld();
|
|
346
|
+
world.bodies[0].velocityY = -4;
|
|
347
|
+
preparePhysics3DContactConstraints(world);
|
|
348
|
+
for (let i = 0; i < 12; i += 1) solvePhysics3DContactVelocities(world);
|
|
349
|
+
|
|
350
|
+
// Equal masses in a head-on contact end with the same normal velocity: momentum is conserved and
|
|
351
|
+
// the approach is removed.
|
|
352
|
+
expect(world.bodies[0].velocityY).toBeCloseTo(world.bodies[1].velocityY, 6);
|
|
353
|
+
expect(world.bodies[0].velocityY + world.bodies[1].velocityY).toBeCloseTo(-4, 6);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('warmStartPhysics3DContacts', () => {
|
|
358
|
+
it('replays a carried normal impulse onto the body', () => {
|
|
359
|
+
const world = createBoxOnGroundWorld();
|
|
360
|
+
preparePhysics3DContactConstraints(world);
|
|
361
|
+
world.solver.constraints[0].points[0].normalImpulse = 3;
|
|
362
|
+
|
|
363
|
+
warmStartPhysics3DContacts(world);
|
|
364
|
+
expect(world.bodies[0].velocityY).toBeCloseTo(3 * world.bodies[0].inverseMass, 12);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('does nothing when warm starting is disabled', () => {
|
|
368
|
+
const world = createBoxOnGroundWorld();
|
|
369
|
+
preparePhysics3DContactConstraints(world);
|
|
370
|
+
world.solver.constraints[0].points[0].normalImpulse = 3;
|
|
371
|
+
world.config.sequentialImpulse.warmStarting = false;
|
|
372
|
+
|
|
373
|
+
warmStartPhysics3DContacts(world);
|
|
374
|
+
expect(world.bodies[0].velocityY).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('reaches the resting impulse in fewer iterations than a cold solve', () => {
|
|
378
|
+
const converged = createBoxOnGroundWorld();
|
|
379
|
+
converged.bodies[0].velocityY = -5;
|
|
380
|
+
preparePhysics3DContactConstraints(converged);
|
|
381
|
+
for (let i = 0; i < 30; i += 1) solvePhysics3DContactVelocities(converged);
|
|
382
|
+
const target = converged.solver.constraints[0].points[0].normalImpulse;
|
|
383
|
+
|
|
384
|
+
const warm = createBoxOnGroundWorld();
|
|
385
|
+
warm.bodies[0].velocityY = -5;
|
|
386
|
+
preparePhysics3DContactConstraints(warm);
|
|
387
|
+
warm.solver.constraints[0].points[0].normalImpulse = target;
|
|
388
|
+
warmStartPhysics3DContacts(warm);
|
|
389
|
+
solvePhysics3DContactVelocities(warm);
|
|
390
|
+
|
|
391
|
+
const cold = createBoxOnGroundWorld();
|
|
392
|
+
cold.bodies[0].velocityY = -5;
|
|
393
|
+
preparePhysics3DContactConstraints(cold);
|
|
394
|
+
solvePhysics3DContactVelocities(cold);
|
|
395
|
+
|
|
396
|
+
expect(Math.abs(warm.bodies[0].velocityY)).toBeLessThanOrEqual(Math.abs(cold.bodies[0].velocityY));
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
function createContactPoint(featureId: number, depth: number): Physics3DContactPoint {
|
|
401
|
+
return {
|
|
402
|
+
depth,
|
|
403
|
+
featureId,
|
|
404
|
+
rAX: 0,
|
|
405
|
+
rAY: -0.5,
|
|
406
|
+
rAZ: 0,
|
|
407
|
+
rBX: 0,
|
|
408
|
+
rBY: 0.5,
|
|
409
|
+
rBZ: 0,
|
|
410
|
+
x: 0,
|
|
411
|
+
y: 0,
|
|
412
|
+
z: 0,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function createContact(bodyA: number, bodyB: number): Physics3DContact {
|
|
417
|
+
return {
|
|
418
|
+
bodyA,
|
|
419
|
+
bodyB,
|
|
420
|
+
enabled: true,
|
|
421
|
+
friction: 0,
|
|
422
|
+
normalX: 0,
|
|
423
|
+
normalY: 1,
|
|
424
|
+
normalZ: 0,
|
|
425
|
+
pointCount: 1,
|
|
426
|
+
points: [createContactPoint(1, 0.01)],
|
|
427
|
+
restitution: 0,
|
|
428
|
+
sensor: false,
|
|
429
|
+
touching: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function createUnitBox(world: Physics3DWorld): RigidBody3D {
|
|
434
|
+
const body = createRigidBody3D();
|
|
435
|
+
const mass = createPhysics3DMassData();
|
|
436
|
+
computePhysics3DBoxMassData(0.5, 0.5, 0.5, 1, mass);
|
|
437
|
+
setRigidBody3DMassData(body, mass);
|
|
438
|
+
refreshRigidBody3DWorldInertia(body);
|
|
439
|
+
addPhysics3DBody(world, body);
|
|
440
|
+
return body;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The solver reads the SOLVE ISLAND contact slices rather than `world.contacts`, so a world assembled
|
|
444
|
+
// by hand needs the same workspace `stepPhysics3D` builds before it reaches the solver. Without it every
|
|
445
|
+
// prepare finds nothing to do — which is the correct answer for a world with no awake islands, and the
|
|
446
|
+
// reason this is a factory step rather than something prepare rebuilds for itself.
|
|
447
|
+
//
|
|
448
|
+
// A test that changes what belongs in an island AFTER this — putting a body to sleep, disabling a
|
|
449
|
+
// contact, making a body static — has to call it again, exactly as a real step rebuilds every step.
|
|
450
|
+
function buildSolveWorkspace(world: Physics3DWorld): Physics3DWorld {
|
|
451
|
+
updatePhysics3DSleep(world, 1 / 60);
|
|
452
|
+
buildPhysics3DSolveIslands(world);
|
|
453
|
+
return world;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// One dynamic box whose single contact point references a body that is not in the world's contact
|
|
457
|
+
// partner slot — used for the basis and mass-denominator tests, where only body A matters.
|
|
458
|
+
function createFallingBoxWorld(): Physics3DWorld {
|
|
459
|
+
const world = createPhysics3DWorld();
|
|
460
|
+
const box = createUnitBox(world);
|
|
461
|
+
const other = createUnitBox(world);
|
|
462
|
+
world.contacts.push(createContact(box.index, other.index));
|
|
463
|
+
return buildSolveWorkspace(world);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function createBoxOnGroundWorld(): Physics3DWorld {
|
|
467
|
+
const world = createPhysics3DWorld();
|
|
468
|
+
const box = createUnitBox(world);
|
|
469
|
+
const ground = createUnitBox(world);
|
|
470
|
+
setPhysics3DBodyType(ground, 'static');
|
|
471
|
+
world.contacts.push(createContact(box.index, ground.index));
|
|
472
|
+
return buildSolveWorkspace(world);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function createTwoDynamicBodyWorld(): Physics3DWorld {
|
|
476
|
+
const world = createPhysics3DWorld();
|
|
477
|
+
const upper = createUnitBox(world);
|
|
478
|
+
const lower = createUnitBox(world);
|
|
479
|
+
world.contacts.push(createContact(upper.index, lower.index));
|
|
480
|
+
return buildSolveWorkspace(world);
|
|
481
|
+
}
|