@forgeax/engine-physics-rapier3d 0.1.26 → 0.1.28
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/__tests__/derived-physics.unit.test.d.ts +2 -0
- package/dist/__tests__/derived-physics.unit.test.d.ts.map +1 -0
- package/dist/index.mjs +1598 -27
- package/dist/index.mjs.map +1 -1
- package/dist/rapier-physics-world-3d.d.ts +98 -4
- package/dist/rapier-physics-world-3d.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/derived-physics.unit.test.ts +779 -0
- package/src/__tests__/physics-rapier3d.unit.test.ts +66 -1
- package/src/rapier-physics-world-3d.ts +2013 -50
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { vec3 } from '@forgeax/engine-math';
|
|
2
|
+
import type { DerivedPhysicsCandidateInput } from '@forgeax/engine-physics';
|
|
3
|
+
import { err, ok } from '@forgeax/engine-types';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { createRapier3DPhysicsWorld } from '../rapier-physics-world-3d';
|
|
6
|
+
import { loadRapier3D, type Rapier3DModule } from '../wasm-loader';
|
|
7
|
+
|
|
8
|
+
function voxelCandidate(entity: number, revision: number, worldIdentity?: object) {
|
|
9
|
+
return {
|
|
10
|
+
entity,
|
|
11
|
+
revision,
|
|
12
|
+
sourceKey: `body:${entity}`,
|
|
13
|
+
...(worldIdentity === undefined ? {} : { worldIdentity }),
|
|
14
|
+
shapes: [
|
|
15
|
+
{
|
|
16
|
+
id: 'left',
|
|
17
|
+
revision,
|
|
18
|
+
cells: new Int32Array([0, 0, 0, -1, 0, 0]),
|
|
19
|
+
voxelSize: [1, 1, 1] as const,
|
|
20
|
+
origin: [0, 0, 0] as const,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'right',
|
|
24
|
+
revision,
|
|
25
|
+
cells: [[0, 0, 0]] as const,
|
|
26
|
+
voxelSize: [1, 1, 1] as const,
|
|
27
|
+
origin: [2, 0, 0] as const,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
seams: [{ shapeA: 'left', shapeB: 'right', offset: [2, 0, 0] as const }],
|
|
31
|
+
bodyType: 'dynamic' as const,
|
|
32
|
+
massProperties: {
|
|
33
|
+
mode: 'explicit' as const,
|
|
34
|
+
mass: 2,
|
|
35
|
+
centerOfMass: [0.25, 0, 0] as const,
|
|
36
|
+
principalInertia: [1, 1, 1] as const,
|
|
37
|
+
},
|
|
38
|
+
velocityPolicy: 'preserve' as const,
|
|
39
|
+
constraints: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addBody(
|
|
44
|
+
pw: ReturnType<typeof createRapier3DPhysicsWorld>,
|
|
45
|
+
entity: number,
|
|
46
|
+
position: [number, number, number],
|
|
47
|
+
) {
|
|
48
|
+
pw.ensureBody(
|
|
49
|
+
entity,
|
|
50
|
+
{
|
|
51
|
+
position: { x: position[0], y: position[1], z: position[2] },
|
|
52
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
53
|
+
scale: { x: 1, y: 1, z: 1 },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 1,
|
|
57
|
+
mass: 1,
|
|
58
|
+
linearDamping: 0,
|
|
59
|
+
angularDamping: 0,
|
|
60
|
+
gravityScale: 0,
|
|
61
|
+
ccdEnabled: 0,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
shape: 0,
|
|
65
|
+
halfExtents: [0.25, 0.25, 0.25],
|
|
66
|
+
radius: 0.25,
|
|
67
|
+
halfHeight: 0.25,
|
|
68
|
+
friction: 0.5,
|
|
69
|
+
restitution: 0,
|
|
70
|
+
density: 1,
|
|
71
|
+
isSensor: 0,
|
|
72
|
+
collisionGroups: 0xffffffff,
|
|
73
|
+
solverGroups: 0xffffffff,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Public-module fixture: inject a failure only after native mass mutation. */
|
|
79
|
+
function rapierWithMassFailure(RAPIER: Rapier3DModule): Rapier3DModule {
|
|
80
|
+
const OriginalWorld = RAPIER.World;
|
|
81
|
+
function FaultWorld(gravity: unknown) {
|
|
82
|
+
const world = new OriginalWorld(gravity);
|
|
83
|
+
const createRigidBody = world.createRigidBody.bind(world);
|
|
84
|
+
world.createRigidBody = (descriptor: unknown) => {
|
|
85
|
+
const body = createRigidBody(descriptor);
|
|
86
|
+
const setAdditionalMassProperties = body.setAdditionalMassProperties.bind(body);
|
|
87
|
+
let inject = true;
|
|
88
|
+
body.setAdditionalMassProperties = (...args: unknown[]) => {
|
|
89
|
+
setAdditionalMassProperties(...args);
|
|
90
|
+
if (inject) {
|
|
91
|
+
inject = false;
|
|
92
|
+
throw new Error('injected failure after native mass mutation');
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
return body;
|
|
96
|
+
};
|
|
97
|
+
return world;
|
|
98
|
+
}
|
|
99
|
+
return { ...RAPIER, World: FaultWorld };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('Rapier 3D derived voxel candidates', () => {
|
|
103
|
+
it('stops publication and queries if native step throws after the geometry commit', async () => {
|
|
104
|
+
const RAPIER = await loadRapier3D();
|
|
105
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
106
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
107
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
108
|
+
pw.admitDerivedShapeCandidate(
|
|
109
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1)).unwrap(),
|
|
110
|
+
).unwrap();
|
|
111
|
+
pw.step(1 / 60);
|
|
112
|
+
let geometryRevision = 1;
|
|
113
|
+
pw.admitDerivedShapeCandidate(
|
|
114
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 2)).unwrap(),
|
|
115
|
+
() => {
|
|
116
|
+
geometryRevision = 2;
|
|
117
|
+
return ok(undefined);
|
|
118
|
+
},
|
|
119
|
+
).unwrap();
|
|
120
|
+
const nativeStep = pw.raw.step.bind(pw.raw);
|
|
121
|
+
pw.raw.step = (...args: unknown[]) => {
|
|
122
|
+
nativeStep(...args);
|
|
123
|
+
throw new Error('injected failure after native advancement');
|
|
124
|
+
};
|
|
125
|
+
expect(() => pw.step(1 / 60)).toThrow('derived-backend-failed');
|
|
126
|
+
expect(geometryRevision).toBe(2);
|
|
127
|
+
expect(pw.getDerivedRecoveryState()).toBe('rebuild-required');
|
|
128
|
+
expect(pw.getDerivedFailure(7)?.recovery).toBe('rebuild-required');
|
|
129
|
+
expect(pw.getDerivedPublication(7)).toBeUndefined();
|
|
130
|
+
expect(pw.getDerivedShapes(7)).toEqual([]);
|
|
131
|
+
expect(() => pw.raycast(vec3.create(0, 0, 4), vec3.create(0, 0, -1), 10)).toThrow(
|
|
132
|
+
'derived-recovery-invalid',
|
|
133
|
+
);
|
|
134
|
+
pw.raw.step = nativeStep;
|
|
135
|
+
pw.dispose();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps staged and retired shapes out of automatic mass and restores a refused replacement', async () => {
|
|
139
|
+
const RAPIER = await loadRapier3D();
|
|
140
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
141
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
142
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
143
|
+
const automatic = (revision: number) => ({
|
|
144
|
+
...voxelCandidate(7, revision),
|
|
145
|
+
massProperties: { mode: 'automatic' as const },
|
|
146
|
+
});
|
|
147
|
+
pw.admitDerivedShapeCandidate(pw.prepareDerivedShapeCandidate(automatic(1)).unwrap()).unwrap();
|
|
148
|
+
pw.step(1 / 60);
|
|
149
|
+
const mass = pw.getDerivedBodyMass(7);
|
|
150
|
+
const pending = pw.prepareDerivedShapeCandidate(automatic(2)).unwrap();
|
|
151
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(mass ?? NaN, 6);
|
|
152
|
+
pw.admitDerivedShapeCandidate(pending, () => err(new Error('binding refused'))).unwrap();
|
|
153
|
+
pw.step(1 / 60);
|
|
154
|
+
expect(pw.getDerivedFailure(7)?.recovery).toBe('old-state-retained');
|
|
155
|
+
expect(pw.getDerivedPublication(7)?.revision).toBe(1);
|
|
156
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(mass ?? NaN, 6);
|
|
157
|
+
const otherPending = pw.prepareDerivedShapeCandidate(automatic(3)).unwrap();
|
|
158
|
+
pw.admitDerivedShapeCandidate(pw.prepareDerivedShapeCandidate(automatic(4)).unwrap()).unwrap();
|
|
159
|
+
pw.step(1 / 60);
|
|
160
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(mass ?? NaN, 6);
|
|
161
|
+
pw.cancelDerivedShapeCandidate(otherPending).unwrap();
|
|
162
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(mass ?? NaN, 6);
|
|
163
|
+
pw.dispose();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('requires rebuild if a geometry callback throws after an unknown ECS write', async () => {
|
|
167
|
+
const RAPIER = await loadRapier3D();
|
|
168
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
169
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
170
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
171
|
+
pw.admitDerivedShapeCandidate(
|
|
172
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1)).unwrap(),
|
|
173
|
+
() => {
|
|
174
|
+
throw new Error('unknown failure after a possible geometry write');
|
|
175
|
+
},
|
|
176
|
+
).unwrap();
|
|
177
|
+
pw.step(1 / 60);
|
|
178
|
+
expect(pw.getDerivedFailure(7)?.recovery).toBe('rebuild-required');
|
|
179
|
+
expect(pw.getDerivedRecoveryState()).toBe('rebuild-required');
|
|
180
|
+
expect(pw.getDerivedPublication(7)).toBeUndefined();
|
|
181
|
+
expect(() => pw.raycast(vec3.create(0, 0, 4), vec3.create(0, 0, -1), 10)).toThrow(
|
|
182
|
+
'derived-recovery-invalid',
|
|
183
|
+
);
|
|
184
|
+
pw.dispose();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('keeps the old physics publication when the paired geometry commit refuses', async () => {
|
|
188
|
+
const RAPIER = await loadRapier3D();
|
|
189
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
190
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
191
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
192
|
+
pw.admitDerivedShapeCandidate(
|
|
193
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1)).unwrap(),
|
|
194
|
+
).unwrap();
|
|
195
|
+
pw.step(1 / 60);
|
|
196
|
+
let commits = 0;
|
|
197
|
+
const replacement = pw
|
|
198
|
+
.prepareDerivedShapeCandidate({
|
|
199
|
+
...voxelCandidate(7, 2),
|
|
200
|
+
massProperties: {
|
|
201
|
+
mode: 'explicit',
|
|
202
|
+
mass: 9,
|
|
203
|
+
centerOfMass: [0, 0, 0],
|
|
204
|
+
principalInertia: [1, 1, 1],
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
.unwrap();
|
|
208
|
+
pw.admitDerivedShapeCandidate(replacement, () => {
|
|
209
|
+
commits += 1;
|
|
210
|
+
expect(() => pw.raycast(vec3.create(0, 0, 4), vec3.create(0, 0, -1), 10)).toThrow();
|
|
211
|
+
return err(new Error('geometry admission refused'));
|
|
212
|
+
}).unwrap();
|
|
213
|
+
pw.step(1 / 60);
|
|
214
|
+
expect(commits).toBe(1);
|
|
215
|
+
expect(pw.getDerivedPublication(7)?.revision).toBe(1);
|
|
216
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(2);
|
|
217
|
+
expect(pw.getDerivedFailure(7)?.recovery).toBe('old-state-retained');
|
|
218
|
+
expect(pw.getDerivedRecoveryState()).toBe('ready');
|
|
219
|
+
pw.admitDerivedShapeCandidate(
|
|
220
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 3)).unwrap(),
|
|
221
|
+
() => {
|
|
222
|
+
expect(pw.getDerivedAdmission(7)).toMatchObject({ revision: 3, fixedStep: 3 });
|
|
223
|
+
return ok(undefined);
|
|
224
|
+
},
|
|
225
|
+
).unwrap();
|
|
226
|
+
pw.step(1 / 60);
|
|
227
|
+
expect(pw.getDerivedPublication(7)?.revision).toBe(3);
|
|
228
|
+
expect(pw.getDerivedAdmission(7)).toBeUndefined();
|
|
229
|
+
pw.dispose();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('stages multiple native voxel shapes and publishes them after one fixed step', async () => {
|
|
233
|
+
const RAPIER = await loadRapier3D();
|
|
234
|
+
if ('code' in RAPIER) {
|
|
235
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
239
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
240
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
241
|
+
const prepared = pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1));
|
|
242
|
+
expect(prepared.ok).toBe(true);
|
|
243
|
+
if (!prepared.ok) return;
|
|
244
|
+
expect(pw.getDerivedShapes(7)).toHaveLength(0);
|
|
245
|
+
const admitted = pw.admitDerivedShapeCandidate(prepared.value);
|
|
246
|
+
expect(admitted.ok).toBe(true);
|
|
247
|
+
expect(pw.getDerivedPublication(7)).toBeUndefined();
|
|
248
|
+
|
|
249
|
+
pw.step(1 / 60);
|
|
250
|
+
|
|
251
|
+
expect(pw.getDerivedPublication(7)).toMatchObject({
|
|
252
|
+
entity: 7,
|
|
253
|
+
revision: 1,
|
|
254
|
+
fixedStep: 1,
|
|
255
|
+
shapeIds: ['left', 'right'],
|
|
256
|
+
});
|
|
257
|
+
expect(pw.getDerivedShapes(7).map((entry) => entry.id)).toEqual(['left', 'right']);
|
|
258
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(2, 5);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('exposes committed COM and motion and restores that movement through a snapshot', async () => {
|
|
262
|
+
const RAPIER = await loadRapier3D();
|
|
263
|
+
if ('code' in RAPIER) {
|
|
264
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
268
|
+
addBody(pw, 16, [0, 0, 0]);
|
|
269
|
+
const prepared = pw.prepareDerivedShapeCandidate({
|
|
270
|
+
...voxelCandidate(16, 1),
|
|
271
|
+
massProperties: {
|
|
272
|
+
mode: 'explicit' as const,
|
|
273
|
+
mass: 2,
|
|
274
|
+
centerOfMass: [0, 0, 0] as const,
|
|
275
|
+
principalInertia: [1, 1, 1] as const,
|
|
276
|
+
},
|
|
277
|
+
motion: {
|
|
278
|
+
centerOfMass: [0.5, 0.25, 0] as const,
|
|
279
|
+
linearVelocity: [1, 2, 3] as const,
|
|
280
|
+
angularVelocity: [0.1, 0.2, 0.3] as const,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
expect(prepared.ok).toBe(true);
|
|
284
|
+
if (!prepared.ok) return;
|
|
285
|
+
expect(pw.admitDerivedShapeCandidate(prepared.value).ok).toBe(true);
|
|
286
|
+
pw.step(1 / 60);
|
|
287
|
+
const motion = pw.getDerivedMotion(16);
|
|
288
|
+
expect(motion?.centerOfMass[0]).toBeCloseTo(0.5 + 1 / 60, 4);
|
|
289
|
+
expect(motion?.centerOfMass[1]).toBeCloseTo(0.25 + 2 / 60, 4);
|
|
290
|
+
expect(motion?.centerOfMass[2]).toBeCloseTo(3 / 60, 4);
|
|
291
|
+
expect(motion?.linearVelocity).toEqual([1, 2, 3]);
|
|
292
|
+
expect(motion?.angularVelocity[0]).toBeCloseTo(0.1, 5);
|
|
293
|
+
expect(motion?.angularVelocity[1]).toBeCloseTo(0.2, 5);
|
|
294
|
+
expect(motion?.angularVelocity[2]).toBeCloseTo(0.3, 5);
|
|
295
|
+
const snapshot = pw.captureDerivedPhysicsState();
|
|
296
|
+
expect(snapshot.bodies[0]?.motion?.linearVelocity).toEqual([1, 2, 3]);
|
|
297
|
+
expect(snapshot.bodies[0]?.motion?.angularVelocity[0]).toBeCloseTo(0.1, 5);
|
|
298
|
+
pw.removeEntity(16);
|
|
299
|
+
addBody(pw, 16, [0, 0, 0]);
|
|
300
|
+
expect(pw.restoreDerivedPhysicsState(snapshot).ok).toBe(true);
|
|
301
|
+
pw.step(1 / 60);
|
|
302
|
+
const restoredMotion = pw.getDerivedMotion(16);
|
|
303
|
+
const snapshotMotion = snapshot.bodies[0]?.motion;
|
|
304
|
+
expect(snapshotMotion).toBeDefined();
|
|
305
|
+
expect(restoredMotion?.centerOfMass[0]).toBeCloseTo(
|
|
306
|
+
(snapshotMotion?.centerOfMass[0] ?? 0) + 1 / 60,
|
|
307
|
+
4,
|
|
308
|
+
);
|
|
309
|
+
expect(restoredMotion?.centerOfMass[1]).toBeCloseTo(
|
|
310
|
+
(snapshotMotion?.centerOfMass[1] ?? 0) + 2 / 60,
|
|
311
|
+
4,
|
|
312
|
+
);
|
|
313
|
+
expect(restoredMotion?.centerOfMass[2]).toBeCloseTo(
|
|
314
|
+
(snapshotMotion?.centerOfMass[2] ?? 0) + 3 / 60,
|
|
315
|
+
4,
|
|
316
|
+
);
|
|
317
|
+
expect(restoredMotion?.linearVelocity).toEqual([1, 2, 3]);
|
|
318
|
+
expect(restoredMotion?.angularVelocity[0]).toBeCloseTo(0.1, 5);
|
|
319
|
+
pw.dispose();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('keeps the committed shape set on cancellation and rejects stale revisions', async () => {
|
|
323
|
+
const RAPIER = await loadRapier3D();
|
|
324
|
+
if ('code' in RAPIER) {
|
|
325
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
329
|
+
addBody(pw, 8, [0, 0, 0]);
|
|
330
|
+
const first = pw.prepareDerivedShapeCandidate(voxelCandidate(8, 1));
|
|
331
|
+
expect(first.ok).toBe(true);
|
|
332
|
+
if (!first.ok) return;
|
|
333
|
+
expect(pw.admitDerivedShapeCandidate(first.value).ok).toBe(true);
|
|
334
|
+
pw.step(1 / 60);
|
|
335
|
+
const stale = pw.prepareDerivedShapeCandidate(voxelCandidate(8, 1));
|
|
336
|
+
expect(stale.ok).toBe(false);
|
|
337
|
+
if (!stale.ok) expect(stale.error.code).toBe('derived-candidate-stale');
|
|
338
|
+
|
|
339
|
+
const next = pw.prepareDerivedShapeCandidate(voxelCandidate(8, 2));
|
|
340
|
+
expect(next.ok).toBe(true);
|
|
341
|
+
if (!next.ok) return;
|
|
342
|
+
expect(pw.cancelDerivedShapeCandidate(next.value).ok).toBe(true);
|
|
343
|
+
expect(pw.getDerivedPublication(8)?.revision).toBe(1);
|
|
344
|
+
expect(pw.getDerivedShapes(8)).toHaveLength(2);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('retains the committed native state when an endpoint disappears before admission', async () => {
|
|
348
|
+
const RAPIER = await loadRapier3D();
|
|
349
|
+
if ('code' in RAPIER) {
|
|
350
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
354
|
+
addBody(pw, 12, [0, 0, 0]);
|
|
355
|
+
addBody(pw, 13, [0, 0, 0]);
|
|
356
|
+
const first = pw.prepareDerivedShapeCandidate(voxelCandidate(12, 1));
|
|
357
|
+
expect(first.ok).toBe(true);
|
|
358
|
+
if (!first.ok) return;
|
|
359
|
+
expect(pw.admitDerivedShapeCandidate(first.value).ok).toBe(true);
|
|
360
|
+
pw.step(1 / 60);
|
|
361
|
+
|
|
362
|
+
const next = pw.prepareDerivedShapeCandidate({
|
|
363
|
+
...voxelCandidate(12, 2),
|
|
364
|
+
bodyType: 'static',
|
|
365
|
+
constraints: [
|
|
366
|
+
{
|
|
367
|
+
id: 'disappearing-endpoint',
|
|
368
|
+
revision: 1,
|
|
369
|
+
kind: 'spring',
|
|
370
|
+
bodyA: 12,
|
|
371
|
+
bodyB: 13,
|
|
372
|
+
bodyASource: { sourceKey: 'body:12', revision: 2 },
|
|
373
|
+
bodyBSource: { sourceKey: 'entity:13', revision: 0 },
|
|
374
|
+
anchorA: [0, 0, 0],
|
|
375
|
+
anchorB: [0, 0, 0],
|
|
376
|
+
restLength: 0,
|
|
377
|
+
stiffness: 10,
|
|
378
|
+
damping: 1,
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
expect(next.ok).toBe(true);
|
|
383
|
+
if (!next.ok) return;
|
|
384
|
+
expect(pw.admitDerivedShapeCandidate(next.value).ok).toBe(true);
|
|
385
|
+
pw.removeEntity(13);
|
|
386
|
+
pw.step(1 / 60);
|
|
387
|
+
|
|
388
|
+
expect(pw.getDerivedBodyType(12)).toBe('dynamic');
|
|
389
|
+
expect(pw.getDerivedPublication(12)?.revision).toBe(1);
|
|
390
|
+
expect(pw.getDerivedShapes(12).map((shape) => shape.id)).toEqual(['left', 'right']);
|
|
391
|
+
expect(pw.getDerivedFailure(12)).toMatchObject({
|
|
392
|
+
candidateId: next.value.candidateId,
|
|
393
|
+
recovery: 'old-state-retained',
|
|
394
|
+
error: { code: 'derived-body-not-found' },
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('rejects an invalid body type before native mutation and releases the candidate', async () => {
|
|
399
|
+
const RAPIER = await loadRapier3D();
|
|
400
|
+
if ('code' in RAPIER) {
|
|
401
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
405
|
+
addBody(pw, 14, [0, 0, 0]);
|
|
406
|
+
const first = pw.prepareDerivedShapeCandidate(voxelCandidate(14, 1));
|
|
407
|
+
expect(first.ok).toBe(true);
|
|
408
|
+
if (!first.ok) return;
|
|
409
|
+
expect(pw.admitDerivedShapeCandidate(first.value).ok).toBe(true);
|
|
410
|
+
pw.step(1 / 60);
|
|
411
|
+
|
|
412
|
+
const invalid = pw.prepareDerivedShapeCandidate({
|
|
413
|
+
...voxelCandidate(14, 2),
|
|
414
|
+
bodyType: 'invalid',
|
|
415
|
+
} as unknown as DerivedPhysicsCandidateInput);
|
|
416
|
+
expect(invalid.ok).toBe(true);
|
|
417
|
+
if (!invalid.ok) return;
|
|
418
|
+
expect(pw.admitDerivedShapeCandidate(invalid.value)).toMatchObject({
|
|
419
|
+
ok: false,
|
|
420
|
+
error: { code: 'derived-candidate-invalid' },
|
|
421
|
+
});
|
|
422
|
+
expect(pw.cancelDerivedShapeCandidate(invalid.value)).toMatchObject({
|
|
423
|
+
ok: false,
|
|
424
|
+
error: { code: 'derived-candidate-not-found' },
|
|
425
|
+
});
|
|
426
|
+
expect(pw.getDerivedBodyType(14)).toBe('dynamic');
|
|
427
|
+
expect(pw.getDerivedPublication(14)?.revision).toBe(1);
|
|
428
|
+
expect(pw.getDerivedFailure(14)).toMatchObject({
|
|
429
|
+
candidateId: invalid.value.candidateId,
|
|
430
|
+
recovery: 'old-state-retained',
|
|
431
|
+
error: { code: 'derived-candidate-invalid' },
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('exposes stable constraint identity and real shape contact observations', async () => {
|
|
436
|
+
const RAPIER = await loadRapier3D();
|
|
437
|
+
if ('code' in RAPIER) {
|
|
438
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
442
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
443
|
+
addBody(pw, 9, [0, 0, 0]);
|
|
444
|
+
addBody(pw, 10, [0, 0, 0]);
|
|
445
|
+
const candidate = pw.prepareDerivedShapeCandidate(voxelCandidate(9, 1));
|
|
446
|
+
expect(candidate.ok).toBe(true);
|
|
447
|
+
if (!candidate.ok) return;
|
|
448
|
+
expect(pw.admitDerivedShapeCandidate(candidate.value).ok).toBe(true);
|
|
449
|
+
pw.step(1 / 60);
|
|
450
|
+
const constraint = pw.createDerivedConstraint({
|
|
451
|
+
id: 'grab',
|
|
452
|
+
revision: 1,
|
|
453
|
+
kind: 'spring',
|
|
454
|
+
bodyA: 9,
|
|
455
|
+
bodyB: 10,
|
|
456
|
+
bodyASource: { sourceKey: 'body:9', revision: 1 },
|
|
457
|
+
bodyBSource: { sourceKey: 'entity:10', revision: 0 },
|
|
458
|
+
anchorA: [0, 0, 0],
|
|
459
|
+
anchorB: [0, 0, 0],
|
|
460
|
+
restLength: 0,
|
|
461
|
+
stiffness: 20,
|
|
462
|
+
damping: 1,
|
|
463
|
+
});
|
|
464
|
+
expect(constraint.ok).toBe(true);
|
|
465
|
+
pw.step(1 / 60);
|
|
466
|
+
const observations = pw.getContactObservations();
|
|
467
|
+
expect(
|
|
468
|
+
observations.some(
|
|
469
|
+
(observation) =>
|
|
470
|
+
observation.fixedStep === 1 &&
|
|
471
|
+
(observation.shapeA === 'left' || observation.shapeB === 'left'),
|
|
472
|
+
),
|
|
473
|
+
).toBe(true);
|
|
474
|
+
expect(
|
|
475
|
+
pw.updateDerivedConstraint?.({
|
|
476
|
+
id: 'grab',
|
|
477
|
+
revision: 2,
|
|
478
|
+
kind: 'spring',
|
|
479
|
+
bodyA: 9,
|
|
480
|
+
bodyB: 10,
|
|
481
|
+
bodyASource: { sourceKey: 'body:9', revision: 1 },
|
|
482
|
+
bodyBSource: { sourceKey: 'entity:10', revision: 0 },
|
|
483
|
+
anchorA: [0, 0, 0],
|
|
484
|
+
anchorB: [0, 0, 0],
|
|
485
|
+
restLength: 0.1,
|
|
486
|
+
stiffness: 25,
|
|
487
|
+
damping: 1,
|
|
488
|
+
}).ok,
|
|
489
|
+
).toBe(true);
|
|
490
|
+
const snapshot = pw.captureDerivedPhysicsState();
|
|
491
|
+
expect(snapshot.bodies[0]?.constraints).toMatchObject([
|
|
492
|
+
{
|
|
493
|
+
id: 'grab',
|
|
494
|
+
bodyASource: { sourceKey: 'body:9', revision: 1 },
|
|
495
|
+
bodyBSource: { sourceKey: 'entity:10', revision: 0 },
|
|
496
|
+
},
|
|
497
|
+
]);
|
|
498
|
+
const bodyRevision = pw.prepareDerivedShapeCandidate(voxelCandidate(9, 2));
|
|
499
|
+
expect(bodyRevision.ok).toBe(true);
|
|
500
|
+
if (!bodyRevision.ok) return;
|
|
501
|
+
expect(pw.admitDerivedShapeCandidate(bodyRevision.value).ok).toBe(true);
|
|
502
|
+
pw.step(1 / 60);
|
|
503
|
+
expect(pw.captureDerivedPhysicsState().bodies[0]?.constraints).toEqual([]);
|
|
504
|
+
expect(pw.removeEntity(9)).toBeUndefined();
|
|
505
|
+
addBody(pw, 9, [0, 0, 0]);
|
|
506
|
+
expect(pw.restoreDerivedPhysicsState(snapshot).ok).toBe(true);
|
|
507
|
+
pw.step(1 / 60);
|
|
508
|
+
expect(pw.captureDerivedPhysicsState().bodies[0]?.constraints).toMatchObject([
|
|
509
|
+
{ id: 'grab', revision: 2 },
|
|
510
|
+
]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('restores committed input through a fresh native body without restoring handles', async () => {
|
|
514
|
+
const RAPIER = await loadRapier3D();
|
|
515
|
+
if ('code' in RAPIER) {
|
|
516
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
520
|
+
addBody(pw, 11, [0, 0, 0]);
|
|
521
|
+
const candidate = pw.prepareDerivedShapeCandidate(voxelCandidate(11, 1));
|
|
522
|
+
expect(candidate.ok).toBe(true);
|
|
523
|
+
if (!candidate.ok) return;
|
|
524
|
+
expect(pw.admitDerivedShapeCandidate(candidate.value).ok).toBe(true);
|
|
525
|
+
pw.step(1 / 60);
|
|
526
|
+
const snapshot = pw.captureDerivedPhysicsState();
|
|
527
|
+
pw.removeEntity(11);
|
|
528
|
+
addBody(pw, 11, [0, 0, 0]);
|
|
529
|
+
const restored = pw.restoreDerivedPhysicsState(snapshot);
|
|
530
|
+
expect(restored.ok).toBe(true);
|
|
531
|
+
expect(pw.getDerivedShapes(11)).toHaveLength(0);
|
|
532
|
+
pw.step(1 / 60);
|
|
533
|
+
expect(pw.getDerivedPublication(11)).toMatchObject({ revision: 1, fixedStep: 2 });
|
|
534
|
+
expect(pw.getDerivedShapes(11).map((entry) => entry.id)).toEqual(['left', 'right']);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('restores native mass policy after a public-entry failure injected after mutation', async () => {
|
|
538
|
+
const RAPIER = await loadRapier3D();
|
|
539
|
+
if ('code' in RAPIER) {
|
|
540
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const pw = createRapier3DPhysicsWorld(rapierWithMassFailure(RAPIER));
|
|
544
|
+
addBody(pw, 15, [0, 0, 0]);
|
|
545
|
+
const before = pw.getDerivedBodyMass(15);
|
|
546
|
+
const prepared = pw.prepareDerivedShapeCandidate({
|
|
547
|
+
...voxelCandidate(15, 1),
|
|
548
|
+
massProperties: {
|
|
549
|
+
mode: 'explicit',
|
|
550
|
+
mass: 10,
|
|
551
|
+
centerOfMass: [0, 0, 0],
|
|
552
|
+
principalInertia: [1, 1, 1],
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
expect(prepared.ok).toBe(true);
|
|
556
|
+
if (!prepared.ok) return;
|
|
557
|
+
expect(pw.admitDerivedShapeCandidate(prepared.value).ok).toBe(true);
|
|
558
|
+
pw.step(1 / 60);
|
|
559
|
+
|
|
560
|
+
expect(pw.getDerivedBodyMass(15)).toBeCloseTo(before ?? Number.NaN, 6);
|
|
561
|
+
expect(pw.getDerivedRecoveryState()).toBe('ready');
|
|
562
|
+
expect(pw.getDerivedPublication(15)).toBeUndefined();
|
|
563
|
+
expect(pw.getDerivedFailure(15)).toMatchObject({
|
|
564
|
+
entity: 15,
|
|
565
|
+
recovery: 'old-state-retained',
|
|
566
|
+
error: { code: 'derived-backend-failed' },
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('revalidates queued endpoint revisions at admission and fixed-step processing', async () => {
|
|
571
|
+
const RAPIER = await loadRapier3D();
|
|
572
|
+
if ('code' in RAPIER) {
|
|
573
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
577
|
+
addBody(pw, 20, [0, 0, 0]);
|
|
578
|
+
addBody(pw, 21, [0, 0, 0]);
|
|
579
|
+
for (const entity of [20, 21]) {
|
|
580
|
+
const initial = pw.prepareDerivedShapeCandidate(voxelCandidate(entity, 1));
|
|
581
|
+
expect(initial.ok).toBe(true);
|
|
582
|
+
if (!initial.ok) return;
|
|
583
|
+
expect(pw.admitDerivedShapeCandidate(initial.value).ok).toBe(true);
|
|
584
|
+
}
|
|
585
|
+
pw.step(1 / 60);
|
|
586
|
+
|
|
587
|
+
const body21Revision2 = pw.prepareDerivedShapeCandidate(voxelCandidate(21, 2));
|
|
588
|
+
expect(body21Revision2.ok).toBe(true);
|
|
589
|
+
if (!body21Revision2.ok) return;
|
|
590
|
+
expect(pw.admitDerivedShapeCandidate(body21Revision2.value).ok).toBe(true);
|
|
591
|
+
const staleAtAdmission = pw.prepareDerivedShapeCandidate({
|
|
592
|
+
...voxelCandidate(20, 2),
|
|
593
|
+
constraints: [
|
|
594
|
+
{
|
|
595
|
+
id: 'queued-stale',
|
|
596
|
+
revision: 1,
|
|
597
|
+
kind: 'spring',
|
|
598
|
+
bodyA: 20,
|
|
599
|
+
bodyB: 21,
|
|
600
|
+
bodyASource: { sourceKey: 'body:20', revision: 2 },
|
|
601
|
+
bodyBSource: { sourceKey: 'body:21', revision: 1 },
|
|
602
|
+
anchorA: [0, 0, 0],
|
|
603
|
+
anchorB: [0, 0, 0],
|
|
604
|
+
restLength: 0,
|
|
605
|
+
stiffness: 10,
|
|
606
|
+
damping: 1,
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
});
|
|
610
|
+
expect(staleAtAdmission.ok).toBe(true);
|
|
611
|
+
if (!staleAtAdmission.ok) return;
|
|
612
|
+
expect(pw.admitDerivedShapeCandidate(staleAtAdmission.value)).toMatchObject({
|
|
613
|
+
ok: false,
|
|
614
|
+
error: { code: 'derived-constraint-stale' },
|
|
615
|
+
});
|
|
616
|
+
pw.step(1 / 60);
|
|
617
|
+
expect(pw.getDerivedPublication(21)).toMatchObject({ revision: 2 });
|
|
618
|
+
expect(pw.getDerivedPublication(20)).toMatchObject({ revision: 1 });
|
|
619
|
+
|
|
620
|
+
const queuedBeforeDependency = pw.prepareDerivedShapeCandidate({
|
|
621
|
+
...voxelCandidate(20, 3),
|
|
622
|
+
constraints: [
|
|
623
|
+
{
|
|
624
|
+
id: 'process-stale',
|
|
625
|
+
revision: 1,
|
|
626
|
+
kind: 'spring',
|
|
627
|
+
bodyA: 20,
|
|
628
|
+
bodyB: 21,
|
|
629
|
+
bodyASource: { sourceKey: 'body:20', revision: 3 },
|
|
630
|
+
bodyBSource: { sourceKey: 'body:21', revision: 2 },
|
|
631
|
+
anchorA: [0, 0, 0],
|
|
632
|
+
anchorB: [0, 0, 0],
|
|
633
|
+
restLength: 0,
|
|
634
|
+
stiffness: 10,
|
|
635
|
+
damping: 1,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
});
|
|
639
|
+
expect(queuedBeforeDependency.ok).toBe(true);
|
|
640
|
+
if (!queuedBeforeDependency.ok) return;
|
|
641
|
+
expect(pw.admitDerivedShapeCandidate(queuedBeforeDependency.value).ok).toBe(true);
|
|
642
|
+
const body21Revision3 = pw.prepareDerivedShapeCandidate(voxelCandidate(21, 3));
|
|
643
|
+
expect(body21Revision3.ok).toBe(true);
|
|
644
|
+
if (!body21Revision3.ok) return;
|
|
645
|
+
expect(pw.admitDerivedShapeCandidate(body21Revision3.value).ok).toBe(true);
|
|
646
|
+
pw.step(1 / 60);
|
|
647
|
+
expect(pw.getDerivedPublication(20)).toMatchObject({ revision: 1 });
|
|
648
|
+
expect(pw.getDerivedPublication(21)).toMatchObject({ revision: 3 });
|
|
649
|
+
expect(pw.getDerivedFailure(20)).toMatchObject({
|
|
650
|
+
recovery: 'old-state-retained',
|
|
651
|
+
error: { code: 'derived-constraint-stale' },
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('keeps the newest prepared revision and rejects a descending admission', async () => {
|
|
656
|
+
const RAPIER = await loadRapier3D();
|
|
657
|
+
if ('code' in RAPIER) {
|
|
658
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
662
|
+
addBody(pw, 22, [0, 0, 0]);
|
|
663
|
+
const revision4 = pw.prepareDerivedShapeCandidate(voxelCandidate(22, 4));
|
|
664
|
+
const revision3 = pw.prepareDerivedShapeCandidate(voxelCandidate(22, 3));
|
|
665
|
+
expect(revision4.ok).toBe(true);
|
|
666
|
+
expect(revision3.ok).toBe(true);
|
|
667
|
+
if (!revision4.ok || !revision3.ok) return;
|
|
668
|
+
expect(pw.admitDerivedShapeCandidate(revision4.value).ok).toBe(true);
|
|
669
|
+
expect(pw.admitDerivedShapeCandidate(revision3.value)).toMatchObject({
|
|
670
|
+
ok: false,
|
|
671
|
+
error: { code: 'derived-candidate-stale' },
|
|
672
|
+
});
|
|
673
|
+
pw.step(1 / 60);
|
|
674
|
+
expect(pw.getDerivedPublication(22)).toMatchObject({ revision: 4 });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('restores a two-body constraint into a fresh world as one dependency-consistent batch', async () => {
|
|
678
|
+
const RAPIER = await loadRapier3D();
|
|
679
|
+
if ('code' in RAPIER) {
|
|
680
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const source = createRapier3DPhysicsWorld(RAPIER);
|
|
684
|
+
addBody(source, 30, [0, 0, 0]);
|
|
685
|
+
addBody(source, 31, [0, 0, 0]);
|
|
686
|
+
for (const entity of [30, 31]) {
|
|
687
|
+
const prepared = source.prepareDerivedShapeCandidate(voxelCandidate(entity, 1));
|
|
688
|
+
expect(prepared.ok).toBe(true);
|
|
689
|
+
if (!prepared.ok) return;
|
|
690
|
+
expect(source.admitDerivedShapeCandidate(prepared.value).ok).toBe(true);
|
|
691
|
+
}
|
|
692
|
+
source.step(1 / 60);
|
|
693
|
+
expect(
|
|
694
|
+
source.createDerivedConstraint({
|
|
695
|
+
id: 'fresh-restore-joint',
|
|
696
|
+
revision: 1,
|
|
697
|
+
kind: 'spring',
|
|
698
|
+
bodyA: 30,
|
|
699
|
+
bodyB: 31,
|
|
700
|
+
bodyASource: { sourceKey: 'body:30', revision: 1 },
|
|
701
|
+
bodyBSource: { sourceKey: 'body:31', revision: 1 },
|
|
702
|
+
anchorA: [0, 0, 0],
|
|
703
|
+
anchorB: [0, 0, 0],
|
|
704
|
+
restLength: 0,
|
|
705
|
+
stiffness: 10,
|
|
706
|
+
damping: 1,
|
|
707
|
+
}).ok,
|
|
708
|
+
).toBe(true);
|
|
709
|
+
const snapshot = source.captureDerivedPhysicsState();
|
|
710
|
+
|
|
711
|
+
const fresh = createRapier3DPhysicsWorld(RAPIER);
|
|
712
|
+
addBody(fresh, 30, [0, 0, 0]);
|
|
713
|
+
addBody(fresh, 31, [0, 0, 0]);
|
|
714
|
+
const restored = fresh.restoreDerivedPhysicsState(snapshot);
|
|
715
|
+
expect(restored.ok).toBe(true);
|
|
716
|
+
if (!restored.ok) return;
|
|
717
|
+
expect(restored.value).toHaveLength(2);
|
|
718
|
+
fresh.step(1 / 60);
|
|
719
|
+
expect(fresh.getDerivedPublication(30)).toMatchObject({ revision: 1, fixedStep: 1 });
|
|
720
|
+
expect(fresh.getDerivedPublication(31)).toMatchObject({ revision: 1, fixedStep: 1 });
|
|
721
|
+
const restoredConstraints = fresh
|
|
722
|
+
.captureDerivedPhysicsState()
|
|
723
|
+
.bodies.flatMap((body) => body.constraints);
|
|
724
|
+
expect(restoredConstraints).toHaveLength(2);
|
|
725
|
+
expect(restoredConstraints[0]).toMatchObject({
|
|
726
|
+
id: 'fresh-restore-joint',
|
|
727
|
+
bodyASource: { sourceKey: 'body:30', revision: 1 },
|
|
728
|
+
bodyBSource: { sourceKey: 'body:31', revision: 1 },
|
|
729
|
+
});
|
|
730
|
+
source.dispose();
|
|
731
|
+
fresh.dispose();
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('clears explicit mass residue when switching to automatic density policy', async () => {
|
|
735
|
+
const RAPIER = await loadRapier3D();
|
|
736
|
+
if ('code' in RAPIER) {
|
|
737
|
+
expect(RAPIER.code).toBe('wasm-load-failed');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
741
|
+
addBody(pw, 40, [0, 0, 0]);
|
|
742
|
+
const explicit = pw.prepareDerivedShapeCandidate({
|
|
743
|
+
...voxelCandidate(40, 1),
|
|
744
|
+
massProperties: {
|
|
745
|
+
mode: 'explicit',
|
|
746
|
+
mass: 10,
|
|
747
|
+
centerOfMass: [0, 0, 0],
|
|
748
|
+
principalInertia: [1, 1, 1],
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
expect(explicit.ok).toBe(true);
|
|
752
|
+
if (!explicit.ok) return;
|
|
753
|
+
expect(pw.admitDerivedShapeCandidate(explicit.value).ok).toBe(true);
|
|
754
|
+
pw.step(1 / 60);
|
|
755
|
+
const automatic = pw.prepareDerivedShapeCandidate({
|
|
756
|
+
...voxelCandidate(40, 2),
|
|
757
|
+
massProperties: { mode: 'automatic' },
|
|
758
|
+
});
|
|
759
|
+
expect(automatic.ok).toBe(true);
|
|
760
|
+
if (!automatic.ok) return;
|
|
761
|
+
expect(pw.admitDerivedShapeCandidate(automatic.value).ok).toBe(true);
|
|
762
|
+
pw.step(1 / 60);
|
|
763
|
+
const switchedMass = pw.getDerivedBodyMass(40);
|
|
764
|
+
|
|
765
|
+
const fresh = createRapier3DPhysicsWorld(RAPIER);
|
|
766
|
+
addBody(fresh, 41, [0, 0, 0]);
|
|
767
|
+
const freshAutomatic = fresh.prepareDerivedShapeCandidate({
|
|
768
|
+
...voxelCandidate(41, 1),
|
|
769
|
+
massProperties: { mode: 'automatic' },
|
|
770
|
+
});
|
|
771
|
+
expect(freshAutomatic.ok).toBe(true);
|
|
772
|
+
if (!freshAutomatic.ok) return;
|
|
773
|
+
expect(fresh.admitDerivedShapeCandidate(freshAutomatic.value).ok).toBe(true);
|
|
774
|
+
fresh.step(1 / 60);
|
|
775
|
+
expect(switchedMass).toBeCloseTo(fresh.getDerivedBodyMass(41) ?? Number.NaN, 6);
|
|
776
|
+
pw.dispose();
|
|
777
|
+
fresh.dispose();
|
|
778
|
+
});
|
|
779
|
+
});
|