@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.
- package/dist/broadphase.d.ts +4 -0
- package/dist/broadphase.d.ts.map +1 -0
- package/dist/broadphase.js +158 -0
- package/dist/broadphase.js.map +1 -0
- package/dist/colliderCollision.d.ts +5 -0
- package/dist/colliderCollision.d.ts.map +1 -0
- package/dist/colliderCollision.js +55 -0
- package/dist/colliderCollision.js.map +1 -0
- package/dist/colliderTransform.d.ts +5 -0
- package/dist/colliderTransform.d.ts.map +1 -0
- package/dist/colliderTransform.js +424 -0
- package/dist/colliderTransform.js.map +1 -0
- package/dist/contactIntake.d.ts +5 -0
- package/dist/contactIntake.d.ts.map +1 -0
- package/dist/contactIntake.js +237 -0
- package/dist/contactIntake.js.map +1 -0
- package/dist/contacts.d.ts +4 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +64 -0
- package/dist/contacts.js.map +1 -0
- package/dist/continuous.d.ts +5 -0
- package/dist/continuous.d.ts.map +1 -0
- package/dist/continuous.js +829 -0
- package/dist/continuous.js.map +1 -0
- package/dist/contract.d.ts +28 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +28 -0
- package/dist/contract.js.map +1 -0
- package/dist/debugGeometry.d.ts +4 -0
- package/dist/debugGeometry.d.ts.map +1 -0
- package/dist/debugGeometry.js +333 -0
- package/dist/debugGeometry.js.map +1 -0
- package/dist/enablePhysics3DGuards.d.ts +4 -0
- package/dist/enablePhysics3DGuards.d.ts.map +1 -0
- package/dist/enablePhysics3DGuards.js +155 -0
- package/dist/enablePhysics3DGuards.js.map +1 -0
- package/dist/explainPhysics3DCollision.d.ts +3 -0
- package/dist/explainPhysics3DCollision.d.ts.map +1 -0
- package/dist/explainPhysics3DCollision.js +30 -0
- package/dist/explainPhysics3DCollision.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 +51 -0
- package/dist/explainPhysics3DStep.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -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 +184 -0
- package/dist/integrate.js.map +1 -0
- package/dist/islands.d.ts +6 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +333 -0
- package/dist/islands.js.map +1 -0
- package/dist/jointBreakage.d.ts +5 -0
- package/dist/jointBreakage.d.ts.map +1 -0
- package/dist/jointBreakage.js +44 -0
- package/dist/jointBreakage.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 +9 -0
- package/dist/jointFactories.d.ts.map +1 -0
- package/dist/jointFactories.js +159 -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/jointReaction.d.ts +8 -0
- package/dist/jointReaction.d.ts.map +1 -0
- package/dist/jointReaction.js +65 -0
- package/dist/jointReaction.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 +33 -0
- package/dist/jointRows.d.ts.map +1 -0
- package/dist/jointRows.js +352 -0
- package/dist/jointRows.js.map +1 -0
- package/dist/joints.d.ts +16 -0
- package/dist/joints.d.ts.map +1 -0
- package/dist/joints.js +1060 -0
- package/dist/joints.js.map +1 -0
- package/dist/massProperties.d.ts +13 -0
- package/dist/massProperties.d.ts.map +1 -0
- package/dist/massProperties.js +462 -0
- package/dist/massProperties.js.map +1 -0
- package/dist/material.d.ts +3 -0
- package/dist/material.d.ts.map +1 -0
- package/dist/material.js +13 -0
- package/dist/material.js.map +1 -0
- package/dist/nodeSync.d.ts +3 -0
- package/dist/nodeSync.d.ts.map +1 -0
- package/dist/nodeSync.js +16 -0
- package/dist/nodeSync.js.map +1 -0
- package/dist/ownership.d.ts +8 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +26 -0
- package/dist/ownership.js.map +1 -0
- package/dist/physics3DBroadphasePublication.d.ts +5 -0
- package/dist/physics3DBroadphasePublication.d.ts.map +1 -0
- package/dist/physics3DBroadphasePublication.js +49 -0
- package/dist/physics3DBroadphasePublication.js.map +1 -0
- package/dist/physics3DSpatialIndexingGuards.d.ts +4 -0
- package/dist/physics3DSpatialIndexingGuards.d.ts.map +1 -0
- package/dist/physics3DSpatialIndexingGuards.js +11 -0
- package/dist/physics3DSpatialIndexingGuards.js.map +1 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.d.ts +3 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.d.ts.map +1 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.js +15 -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 +460 -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 +302 -0
- package/dist/step.js.map +1 -0
- package/dist/stepValidation.d.ts +13 -0
- package/dist/stepValidation.d.ts.map +1 -0
- package/dist/stepValidation.js +284 -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 +27 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +762 -0
- package/dist/world.js.map +1 -0
- package/dist/worldQueries.d.ts +11 -0
- package/dist/worldQueries.d.ts.map +1 -0
- package/dist/worldQueries.js +409 -0
- package/dist/worldQueries.js.map +1 -0
- package/package.json +55 -0
- package/src/broadphase.test.ts +265 -0
- package/src/colliderCollision.test.ts +57 -0
- package/src/colliderTransform.test.ts +221 -0
- package/src/contactIntake.test.ts +370 -0
- package/src/contacts.test.ts +64 -0
- package/src/continuous.test.ts +679 -0
- package/src/debugGeometry.test.ts +288 -0
- package/src/enablePhysics3DGuards.test.ts +206 -0
- package/src/explainPhysics3DCollision.test.ts +78 -0
- package/src/explainPhysics3DJoints.test.ts +101 -0
- package/src/explainPhysics3DStep.test.ts +142 -0
- package/src/integrate.test.ts +279 -0
- package/src/islands.test.ts +384 -0
- package/src/jointBreakage.test.ts +143 -0
- package/src/jointCollisionSuppression.test.ts +94 -0
- package/src/jointFactories.test.ts +244 -0
- package/src/jointMath.test.ts +646 -0
- package/src/jointReaction.test.ts +241 -0
- package/src/jointRegistry.test.ts +229 -0
- package/src/jointRows.test.ts +548 -0
- package/src/joints.test.ts +1066 -0
- package/src/massProperties.test.ts +611 -0
- package/src/material.test.ts +40 -0
- package/src/nodeSync.test.ts +50 -0
- package/src/ownership.test.ts +61 -0
- package/src/physics3DBroadphasePublication.test.ts +63 -0
- package/src/physics3DSpatialIndexingGuards.test.ts +43 -0
- package/src/registerBuiltInPhysics3DJointSolvers.test.ts +51 -0
- package/src/solver.test.ts +567 -0
- package/src/step.test.ts +490 -0
- package/src/stepValidation.test.ts +346 -0
- package/src/stress.test.ts +526 -0
- package/src/symmetricTensor.test.ts +166 -0
- package/src/world.test.ts +1127 -0
- package/src/worldQueries.test.ts +565 -0
package/src/step.test.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerBuiltInCollisionFaceQueries3D,
|
|
3
|
+
registerBuiltInCollisionSupports3D,
|
|
4
|
+
} from '@flighthq/collision/contract';
|
|
5
|
+
import type { Physics3DContact, Physics3DWorld, RigidBody3D } from '@flighthq/types/contract';
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { buildPhysics3DContacts } from './contactIntake';
|
|
9
|
+
import { refreshRigidBody3DWorldInertia } from './integrate';
|
|
10
|
+
import { createPhysics3DFixedJoint, createPhysics3DHingeJoint } from './jointFactories';
|
|
11
|
+
import { addPhysics3DJoint, removePhysics3DJoint } from './jointRegistry';
|
|
12
|
+
import { createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
|
|
13
|
+
import { registerBuiltInPhysics3DJointSolvers } from './registerBuiltInPhysics3DJointSolvers';
|
|
14
|
+
import { setPhysics3DStepGuard, stepPhysics3D, stepPhysics3DInterval } from './step';
|
|
15
|
+
import {
|
|
16
|
+
addPhysics3DBody,
|
|
17
|
+
addPhysics3DCollider,
|
|
18
|
+
applyPhysics3DForce,
|
|
19
|
+
createPhysics3DCollider,
|
|
20
|
+
createPhysics3DWorld,
|
|
21
|
+
createRigidBody3D,
|
|
22
|
+
removePhysics3DBody,
|
|
23
|
+
setPhysics3DBodyFixedRotation,
|
|
24
|
+
setPhysics3DBodyType,
|
|
25
|
+
} from './world';
|
|
26
|
+
|
|
27
|
+
// Contact generation dispatches through the collision registries, so a world whose supports were never
|
|
28
|
+
// registered detects nothing at all — see `explainPhysics3DCollision`.
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
registerBuiltInCollisionSupports3D();
|
|
31
|
+
registerBuiltInCollisionFaceQueries3D();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('setPhysics3DStepGuard', () => {
|
|
35
|
+
it('is consulted only when the step declines, and only while installed', () => {
|
|
36
|
+
const seen: number[] = [];
|
|
37
|
+
setPhysics3DStepGuard((_world, dt) => seen.push(dt));
|
|
38
|
+
try {
|
|
39
|
+
const world = createPhysics3DWorld();
|
|
40
|
+
addPhysics3DBody(world, createRigidBody3D('dynamic'));
|
|
41
|
+
|
|
42
|
+
// A step that runs must not reach the seam: the guard's message means the simulation stopped, so
|
|
43
|
+
// firing it on a healthy step would make the one signal it carries meaningless.
|
|
44
|
+
stepPhysics3D(world, 1 / 60);
|
|
45
|
+
expect(seen).toEqual([]);
|
|
46
|
+
|
|
47
|
+
stepPhysics3D(world, 0);
|
|
48
|
+
expect(seen).toEqual([0]);
|
|
49
|
+
|
|
50
|
+
setPhysics3DStepGuard(null);
|
|
51
|
+
stepPhysics3D(world, 0);
|
|
52
|
+
expect(seen).toEqual([0]);
|
|
53
|
+
} finally {
|
|
54
|
+
setPhysics3DStepGuard(null);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('stepPhysics3D', () => {
|
|
60
|
+
it('advances a free body by gravity over the interval', () => {
|
|
61
|
+
const world = createTestWorld();
|
|
62
|
+
const body = addUnitBody(world);
|
|
63
|
+
|
|
64
|
+
stepPhysics3D(world, 1 / 60);
|
|
65
|
+
|
|
66
|
+
expect(body.velocityY).toBeCloseTo(world.gravityY / 60, 12);
|
|
67
|
+
expect(body.y).toBeCloseTo((world.gravityY / 60) * (1 / 60), 12);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses to run inside itself', () => {
|
|
71
|
+
const world = createTestWorld();
|
|
72
|
+
const ground = addUnitBody(world);
|
|
73
|
+
addRestingContact(world, addUnitBody(world), ground);
|
|
74
|
+
world.contactHooks.preSolve = (inner) => stepPhysics3D(inner, 1 / 60);
|
|
75
|
+
|
|
76
|
+
expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/recursively/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('declines silently rather than throwing on an unusable timestep', () => {
|
|
80
|
+
const world = createTestWorld();
|
|
81
|
+
const body = addUnitBody(world);
|
|
82
|
+
|
|
83
|
+
expect(() => stepPhysics3D(world, 0)).not.toThrow();
|
|
84
|
+
expect(() => stepPhysics3D(world, Number.NaN)).not.toThrow();
|
|
85
|
+
expect(body.velocityY).toBe(0);
|
|
86
|
+
expect(body.y).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('declines on a body carrying a non-finite field', () => {
|
|
90
|
+
const world = createTestWorld();
|
|
91
|
+
const body = addUnitBody(world);
|
|
92
|
+
const other = addUnitBody(world);
|
|
93
|
+
other.velocityX = Number.POSITIVE_INFINITY;
|
|
94
|
+
|
|
95
|
+
stepPhysics3D(world, 1 / 60);
|
|
96
|
+
|
|
97
|
+
// One invalid body stops the whole step, because a NaN reaches every constraint its body touches.
|
|
98
|
+
expect(body.velocityY).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('declines before an invalid collider can generate a poisoned contact', () => {
|
|
102
|
+
const world = createTestWorld();
|
|
103
|
+
const body = addUnitBody(world);
|
|
104
|
+
const collider = createPhysics3DCollider({ kind: 'sphere', x: 0, y: 0, z: 0, radius: 1 });
|
|
105
|
+
addPhysics3DCollider(world, body, collider);
|
|
106
|
+
collider.material.friction = Number.NaN;
|
|
107
|
+
|
|
108
|
+
stepPhysics3D(world, 1 / 60);
|
|
109
|
+
|
|
110
|
+
expect(body.velocityY).toBe(0);
|
|
111
|
+
expect(world.contacts).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('declines when substeps would advance nothing', () => {
|
|
115
|
+
const world = createTestWorld();
|
|
116
|
+
const body = addUnitBody(world);
|
|
117
|
+
world.config.substeps = 0;
|
|
118
|
+
|
|
119
|
+
stepPhysics3D(world, 1 / 60);
|
|
120
|
+
|
|
121
|
+
expect(body.velocityY).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('clears the force accumulators so a force applied once acts once', () => {
|
|
125
|
+
const world = createTestWorld();
|
|
126
|
+
world.gravityY = 0;
|
|
127
|
+
const body = addUnitBody(world);
|
|
128
|
+
applyPhysics3DForce(body, 10, 0, 0);
|
|
129
|
+
|
|
130
|
+
stepPhysics3D(world, 1 / 60);
|
|
131
|
+
const afterOne = body.velocityX;
|
|
132
|
+
stepPhysics3D(world, 1 / 60);
|
|
133
|
+
|
|
134
|
+
expect(afterOne).toBeCloseTo(10 / 60, 12);
|
|
135
|
+
expect(body.velocityX).toBeCloseTo(afterOne, 12);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('records the SUB-interval as the previous timestep, not the whole step', () => {
|
|
139
|
+
const world = createTestWorld();
|
|
140
|
+
addUnitBody(world);
|
|
141
|
+
world.config.substeps = 4;
|
|
142
|
+
|
|
143
|
+
stepPhysics3D(world, 1 / 60);
|
|
144
|
+
|
|
145
|
+
// The warm-start cache is built by a sub-interval's iterations, so scaling it next step has to compare
|
|
146
|
+
// against that interval. Recording the whole step would rescale by the substep count.
|
|
147
|
+
expect(world.previousTimestep).toBeCloseTo(1 / 240, 12);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('integrates more accurately with more substeps over the same interval', () => {
|
|
151
|
+
const coarse = createTestWorld();
|
|
152
|
+
const coarseBody = addUnitBody(coarse);
|
|
153
|
+
stepPhysics3D(coarse, 1 / 60);
|
|
154
|
+
|
|
155
|
+
const fine = createTestWorld();
|
|
156
|
+
fine.config.substeps = 8;
|
|
157
|
+
const fineBody = addUnitBody(fine);
|
|
158
|
+
stepPhysics3D(fine, 1 / 60);
|
|
159
|
+
|
|
160
|
+
// Both reach the same velocity — constant acceleration over the same total time — but the finer
|
|
161
|
+
// division tracks the exact 0.5*g*t^2 displacement more closely.
|
|
162
|
+
const exact = 0.5 * fine.gravityY * (1 / 60) * (1 / 60);
|
|
163
|
+
expect(fineBody.velocityY).toBeCloseTo(coarseBody.velocityY, 12);
|
|
164
|
+
expect(Math.abs(fineBody.y - exact)).toBeLessThan(Math.abs(coarseBody.y - exact));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('refreshes contacts between substeps just like separate half-steps', () => {
|
|
168
|
+
function impactScene(): { readonly world: Physics3DWorld; readonly body: RigidBody3D } {
|
|
169
|
+
const world = createTestWorld();
|
|
170
|
+
const ground = addUnitBody(world);
|
|
171
|
+
ground.y = -0.5;
|
|
172
|
+
setPhysics3DBodyType(ground, 'static');
|
|
173
|
+
attachUnitBoxCollider(world, ground, 0);
|
|
174
|
+
const body = addUnitBody(world);
|
|
175
|
+
body.y = 0.51;
|
|
176
|
+
body.velocityY = -1;
|
|
177
|
+
attachUnitBoxCollider(world, body, 0);
|
|
178
|
+
setPhysics3DBodyFixedRotation(body, true);
|
|
179
|
+
return { world, body };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const combined = impactScene();
|
|
183
|
+
combined.world.config.substeps = 2;
|
|
184
|
+
stepPhysics3D(combined.world, 1 / 30);
|
|
185
|
+
|
|
186
|
+
const split = impactScene();
|
|
187
|
+
stepPhysics3D(split.world, 1 / 60);
|
|
188
|
+
stepPhysics3D(split.world, 1 / 60);
|
|
189
|
+
|
|
190
|
+
expect(combined.body.y).toBeCloseTo(split.body.y, 12);
|
|
191
|
+
expect(combined.body.velocityY).toBeCloseTo(split.body.velocityY, 12);
|
|
192
|
+
expect(combined.world.contacts).toHaveLength(1);
|
|
193
|
+
expect(combined.world.events.began).toHaveLength(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('resolves a generated contact so a falling body does not pass through the ground', () => {
|
|
197
|
+
const world = createTestWorld();
|
|
198
|
+
const ground = addUnitBody(world);
|
|
199
|
+
setPhysics3DBodyType(ground, 'static');
|
|
200
|
+
refreshRigidBody3DWorldInertia(ground);
|
|
201
|
+
const box = addUnitBody(world);
|
|
202
|
+
box.velocityY = -10;
|
|
203
|
+
addRestingContact(world, box, ground);
|
|
204
|
+
|
|
205
|
+
stepPhysics3D(world, 1 / 60);
|
|
206
|
+
|
|
207
|
+
expect(box.velocityY).toBeGreaterThan(-1e-6);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('holds two bodies together through a joint across many steps', () => {
|
|
211
|
+
const world = createTestWorld();
|
|
212
|
+
registerBuiltInPhysics3DJointSolvers(world);
|
|
213
|
+
const anchor = addUnitBody(world);
|
|
214
|
+
setPhysics3DBodyType(anchor, 'static');
|
|
215
|
+
refreshRigidBody3DWorldInertia(anchor);
|
|
216
|
+
const hanging = addUnitBody(world);
|
|
217
|
+
hanging.x = 1;
|
|
218
|
+
addPhysics3DJoint(
|
|
219
|
+
world,
|
|
220
|
+
createPhysics3DFixedJoint({ bodyA: anchor.index, bodyB: hanging.index, localAnchorBX: -1 }),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < 120; i += 1) stepPhysics3D(world, 1 / 60);
|
|
224
|
+
|
|
225
|
+
// Gravity pulls for two seconds; without the joint the body would be roughly 20 units down.
|
|
226
|
+
expect(Math.abs(hanging.y)).toBeLessThan(0.05);
|
|
227
|
+
expect(hanging.x).toBeCloseTo(1, 1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('lets a hinge motor drive a body it is anchored to', () => {
|
|
231
|
+
const world = createTestWorld();
|
|
232
|
+
world.gravityY = 0;
|
|
233
|
+
registerBuiltInPhysics3DJointSolvers(world);
|
|
234
|
+
const anchor = addUnitBody(world);
|
|
235
|
+
setPhysics3DBodyType(anchor, 'static');
|
|
236
|
+
refreshRigidBody3DWorldInertia(anchor);
|
|
237
|
+
const arm = addUnitBody(world);
|
|
238
|
+
addPhysics3DJoint(
|
|
239
|
+
world,
|
|
240
|
+
createPhysics3DHingeJoint({
|
|
241
|
+
bodyA: anchor.index,
|
|
242
|
+
bodyB: arm.index,
|
|
243
|
+
enableMotor: true,
|
|
244
|
+
motorSpeed: 2,
|
|
245
|
+
maxMotorTorque: 1000,
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < 30; i += 1) stepPhysics3D(world, 1 / 60);
|
|
250
|
+
|
|
251
|
+
expect(arm.angularVelocityX).toBeCloseTo(2, 4);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('stops integrating a settled island, even under gravity', () => {
|
|
255
|
+
const world = createTestWorld();
|
|
256
|
+
world.gravityY = 0;
|
|
257
|
+
const body = addUnitBody(world);
|
|
258
|
+
for (let i = 0; i < 60; i += 1) stepPhysics3D(world, 1 / 60);
|
|
259
|
+
expect(body.sleeping).toBe(true);
|
|
260
|
+
|
|
261
|
+
const restingY = body.y;
|
|
262
|
+
world.gravityY = -10;
|
|
263
|
+
stepPhysics3D(world, 1 / 60);
|
|
264
|
+
|
|
265
|
+
// The whole point of the sleeping skip: a settled pile costs no integration, so gravity cannot
|
|
266
|
+
// accelerate it and it does not drift.
|
|
267
|
+
expect(body.y).toBe(restingY);
|
|
268
|
+
expect(body.velocityY).toBe(0);
|
|
269
|
+
expect(body.sleeping).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('wakes a sleeping body the caller writes a velocity onto', () => {
|
|
273
|
+
const world = createTestWorld();
|
|
274
|
+
world.gravityY = 0;
|
|
275
|
+
const body = addUnitBody(world);
|
|
276
|
+
for (let i = 0; i < 60; i += 1) stepPhysics3D(world, 1 / 60);
|
|
277
|
+
expect(body.sleeping).toBe(true);
|
|
278
|
+
|
|
279
|
+
body.velocityY = 5;
|
|
280
|
+
stepPhysics3D(world, 1 / 60);
|
|
281
|
+
|
|
282
|
+
// Stillness is re-read from the CURRENT velocities at the top of every step, so writing one is enough
|
|
283
|
+
// to wake the island — a caller does not have to remember `wakePhysics3DBody` as well.
|
|
284
|
+
expect(body.sleeping).toBe(false);
|
|
285
|
+
expect(body.y).toBeCloseTo(5 / 60, 12);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('lets a pre-solve hook disable a contact for the step', () => {
|
|
289
|
+
const world = createTestWorld();
|
|
290
|
+
world.config.substeps = 2;
|
|
291
|
+
const ground = addUnitBody(world);
|
|
292
|
+
setPhysics3DBodyType(ground, 'static');
|
|
293
|
+
refreshRigidBody3DWorldInertia(ground);
|
|
294
|
+
const box = addUnitBody(world);
|
|
295
|
+
box.velocityY = -10;
|
|
296
|
+
addRestingContact(world, box, ground);
|
|
297
|
+
world.contactHooks.preSolve = (_inner, contact) => {
|
|
298
|
+
contact.enabled = false;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
stepPhysics3D(world, 1 / 60);
|
|
302
|
+
|
|
303
|
+
// The decision survives the between-interval geometry refresh; otherwise interval two silently
|
|
304
|
+
// re-enables the material contact and stops the box.
|
|
305
|
+
expect(box.velocityY).toBeLessThan(-9);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('runs the post-solve hook once the step has committed', () => {
|
|
309
|
+
const world = createTestWorld();
|
|
310
|
+
const ground = addUnitBody(world);
|
|
311
|
+
setPhysics3DBodyType(ground, 'static');
|
|
312
|
+
refreshRigidBody3DWorldInertia(ground);
|
|
313
|
+
const box = addUnitBody(world);
|
|
314
|
+
box.velocityY = -10;
|
|
315
|
+
addRestingContact(world, box, ground);
|
|
316
|
+
let observed = Number.NaN;
|
|
317
|
+
world.contactHooks.postSolve = () => {
|
|
318
|
+
observed = box.velocityY;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
stepPhysics3D(world, 1 / 60);
|
|
322
|
+
|
|
323
|
+
expect(observed).toBeCloseTo(box.velocityY, 12);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('rejects a lifecycle mutation attempted from inside a hook', () => {
|
|
327
|
+
const world = createTestWorld();
|
|
328
|
+
registerBuiltInPhysics3DJointSolvers(world);
|
|
329
|
+
const ground = addUnitBody(world);
|
|
330
|
+
const box = addUnitBody(world);
|
|
331
|
+
addRestingContact(world, box, ground);
|
|
332
|
+
// `collideConnected` matters here: a joint suppresses its own pair's contact by default, and this test
|
|
333
|
+
// needs BOTH a joint to remove and a contact for the hook to run over.
|
|
334
|
+
const joint = addPhysics3DJoint(
|
|
335
|
+
world,
|
|
336
|
+
createPhysics3DFixedJoint({ bodyA: ground.index, bodyB: box.index, collideConnected: true }),
|
|
337
|
+
);
|
|
338
|
+
world.contactHooks.preSolve = (inner) => removePhysics3DJoint(inner, joint);
|
|
339
|
+
|
|
340
|
+
expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/stepping/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('restores the fields a throwing hook touched before the failure propagates', () => {
|
|
344
|
+
const world = createTestWorld();
|
|
345
|
+
const ground = addUnitBody(world);
|
|
346
|
+
const box = addUnitBody(world);
|
|
347
|
+
const contact = addRestingContact(world, box, ground, 0.5);
|
|
348
|
+
world.contactHooks.preSolve = (_inner, edited) => {
|
|
349
|
+
edited.friction = 9;
|
|
350
|
+
throw new Error('hook failed');
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/hook failed/);
|
|
354
|
+
// Not exact: the pair's friction is the geometric mean of the two materials', and sqrt(0.5)*sqrt(0.5)
|
|
355
|
+
// lands one ulp off.
|
|
356
|
+
expect(contact.friction).toBeCloseTo(0.5, 12);
|
|
357
|
+
expect(ground.index).toBe(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('rejects a hook that leaves a contact the step cannot use', () => {
|
|
361
|
+
const world = createTestWorld();
|
|
362
|
+
const ground = addUnitBody(world);
|
|
363
|
+
const contact = addRestingContact(world, addUnitBody(world), ground);
|
|
364
|
+
world.contactHooks.preSolve = (_inner, edited) => {
|
|
365
|
+
edited.friction = Number.NaN;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
expect(() => stepPhysics3D(world, 1 / 60)).toThrow(/invalid contact state/);
|
|
369
|
+
expect(contact.friction).toBe(0);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('clears the stepping flag after a hook throws, so the next step still runs', () => {
|
|
373
|
+
const world = createTestWorld();
|
|
374
|
+
const body = addUnitBody(world);
|
|
375
|
+
addRestingContact(world, addUnitBody(world), body);
|
|
376
|
+
world.contactHooks.preSolve = () => {
|
|
377
|
+
throw new Error('hook failed');
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
expect(() => stepPhysics3D(world, 1 / 60)).toThrow();
|
|
381
|
+
world.contactHooks.preSolve = null;
|
|
382
|
+
|
|
383
|
+
expect(() => stepPhysics3D(world, 1 / 60)).not.toThrow();
|
|
384
|
+
expect(body.velocityY).toBeLessThan(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('survives a body removed while a joint still names it', () => {
|
|
388
|
+
const world = createTestWorld();
|
|
389
|
+
registerBuiltInPhysics3DJointSolvers(world);
|
|
390
|
+
const anchor = addUnitBody(world);
|
|
391
|
+
const hanging = addUnitBody(world);
|
|
392
|
+
addPhysics3DJoint(world, createPhysics3DFixedJoint({ bodyA: anchor.index, bodyB: hanging.index }));
|
|
393
|
+
removePhysics3DBody(world, hanging);
|
|
394
|
+
|
|
395
|
+
expect(() => stepPhysics3D(world, 1 / 60)).not.toThrow();
|
|
396
|
+
expect(Number.isFinite(anchor.y)).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('stepPhysics3DInterval', () => {
|
|
401
|
+
it('advances one sub-interval without touching the force accumulators', () => {
|
|
402
|
+
const world = createTestWorld();
|
|
403
|
+
const body = addUnitBody(world);
|
|
404
|
+
applyPhysics3DForce(body, 6, 0, 0);
|
|
405
|
+
// The island workspace is built by the step; a caller assembling its own loop builds it too.
|
|
406
|
+
stepPhysics3D(world, 1 / 60);
|
|
407
|
+
applyPhysics3DForce(body, 6, 0, 0);
|
|
408
|
+
const before = body.velocityX;
|
|
409
|
+
|
|
410
|
+
stepPhysics3DInterval(world, 1 / 60);
|
|
411
|
+
|
|
412
|
+
// The force is still there afterwards, because clearing it belongs to the step and not to the
|
|
413
|
+
// interval — a substep loop that cleared forces would apply them once and then integrate zeros.
|
|
414
|
+
expect(body.velocityX).toBeCloseTo(before + 6 / 60, 12);
|
|
415
|
+
expect(body.forceX).toBe(6);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// A resting contact between `box` and `ground`, with the normal oriented from the geometry rather than
|
|
420
|
+
// assumed.
|
|
421
|
+
//
|
|
422
|
+
// The normal points so that resolving pushes A out of B — and A is whichever body holds the LOWER INDEX,
|
|
423
|
+
// which the caller does not choose. Writing `normalY = 1` because "the ground is below" is right only when
|
|
424
|
+
// the box happens to have been added first; the other way round it pushes the GROUND up out of the box,
|
|
425
|
+
// which for a static ground is a no-op and reads as a solver that silently ignores the contact.
|
|
426
|
+
// Puts real geometry on both bodies, overlapping slightly, and lets the step's own intake generate the
|
|
427
|
+
// contact between them.
|
|
428
|
+
//
|
|
429
|
+
// Pushing a hand-built contact onto `world.contacts` USED to work here and must not be reintroduced:
|
|
430
|
+
// contact lifetime belongs to intake, which retires every contact it does not re-find. A supplied one is
|
|
431
|
+
// deleted by the very step it was meant to affect, and the hook under test never sees it.
|
|
432
|
+
function addRestingContact(
|
|
433
|
+
world: Physics3DWorld,
|
|
434
|
+
box: RigidBody3D,
|
|
435
|
+
ground: RigidBody3D,
|
|
436
|
+
friction = 0,
|
|
437
|
+
): Physics3DContact {
|
|
438
|
+
// A unit box spans half a unit either side of its centre, so this leaves the two overlapping by 0.01 —
|
|
439
|
+
// the same shallow resting penetration the hand-built fixture used to assert.
|
|
440
|
+
ground.y = box.y - 0.99;
|
|
441
|
+
attachUnitBoxCollider(world, box, friction);
|
|
442
|
+
attachUnitBoxCollider(world, ground, friction);
|
|
443
|
+
buildPhysics3DContacts(world);
|
|
444
|
+
return world.contacts[0];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Gives a body a unit box and then puts the UNIT mass properties back.
|
|
448
|
+
//
|
|
449
|
+
// The restore is the load-bearing half. Mass is derived from geometry, and a unit box of density 1 has
|
|
450
|
+
// mass 1 but inertia 1/6 — so leaving the derived tensor in place would move every angular expectation in
|
|
451
|
+
// this file for a reason that has nothing to do with what those tests check.
|
|
452
|
+
function attachUnitBoxCollider(world: Physics3DWorld, body: RigidBody3D, friction: number): void {
|
|
453
|
+
addPhysics3DCollider(
|
|
454
|
+
world,
|
|
455
|
+
body,
|
|
456
|
+
createPhysics3DCollider(
|
|
457
|
+
{ kind: 'aabb', minX: -0.5, minY: -0.5, minZ: -0.5, maxX: 0.5, maxY: 0.5, maxZ: 0.5 },
|
|
458
|
+
{
|
|
459
|
+
density: 1,
|
|
460
|
+
friction,
|
|
461
|
+
restitution: 0,
|
|
462
|
+
},
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
const data = createPhysics3DMassData();
|
|
466
|
+
data.mass = 1;
|
|
467
|
+
data.inertiaXX = 1;
|
|
468
|
+
data.inertiaYY = 1;
|
|
469
|
+
data.inertiaZZ = 1;
|
|
470
|
+
setRigidBody3DMassData(body, data);
|
|
471
|
+
refreshRigidBody3DWorldInertia(body);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function addUnitBody(world: Physics3DWorld): RigidBody3D {
|
|
475
|
+
const body = createRigidBody3D('dynamic');
|
|
476
|
+
const data = createPhysics3DMassData();
|
|
477
|
+
data.mass = 1;
|
|
478
|
+
data.inertiaXX = 1;
|
|
479
|
+
data.inertiaYY = 1;
|
|
480
|
+
data.inertiaZZ = 1;
|
|
481
|
+
setRigidBody3DMassData(body, data);
|
|
482
|
+
addPhysics3DBody(world, body);
|
|
483
|
+
return body;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function createTestWorld(): Physics3DWorld {
|
|
487
|
+
const world = createPhysics3DWorld();
|
|
488
|
+
world.gravityY = -10;
|
|
489
|
+
return world;
|
|
490
|
+
}
|