@forgeax/engine-physics-rapier3d 0.1.28 → 0.1.30
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__/contact-observation.unit.test.d.ts +2 -0
- package/dist/__tests__/contact-observation.unit.test.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +512 -321
- package/dist/index.mjs.map +1 -1
- package/dist/rapier-physics-world-3d.d.ts +4 -2
- package/dist/rapier-physics-world-3d.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/contact-observation.unit.test.ts +113 -0
- package/src/__tests__/derived-physics.unit.test.ts +482 -1
- package/src/index.ts +4 -4
- package/src/rapier-physics-world-3d.ts +643 -384
|
@@ -44,12 +44,13 @@ function addBody(
|
|
|
44
44
|
pw: ReturnType<typeof createRapier3DPhysicsWorld>,
|
|
45
45
|
entity: number,
|
|
46
46
|
position: [number, number, number],
|
|
47
|
+
rotation: readonly [number, number, number, number] = [0, 0, 0, 1],
|
|
47
48
|
) {
|
|
48
49
|
pw.ensureBody(
|
|
49
50
|
entity,
|
|
50
51
|
{
|
|
51
52
|
position: { x: position[0], y: position[1], z: position[2] },
|
|
52
|
-
rotation: { x: 0, y:
|
|
53
|
+
rotation: { x: rotation[0], y: rotation[1], z: rotation[2], w: rotation[3] },
|
|
53
54
|
scale: { x: 1, y: 1, z: 1 },
|
|
54
55
|
},
|
|
55
56
|
{
|
|
@@ -100,6 +101,408 @@ function rapierWithMassFailure(RAPIER: Rapier3DModule): Rapier3DModule {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
describe('Rapier 3D derived voxel candidates', () => {
|
|
104
|
+
it('applies a revision-bound world impulse at a point without teleporting the derived body', async () => {
|
|
105
|
+
const RAPIER = await loadRapier3D();
|
|
106
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
107
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
108
|
+
try {
|
|
109
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
110
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
111
|
+
pw.admitDerivedShapeCandidate(
|
|
112
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1)).unwrap(),
|
|
113
|
+
).unwrap();
|
|
114
|
+
pw.step(1 / 60);
|
|
115
|
+
const before = pw.getDerivedMotion(7);
|
|
116
|
+
if (before === undefined) throw new Error('missing committed motion');
|
|
117
|
+
const input = {
|
|
118
|
+
entity: 7,
|
|
119
|
+
sourceKey: 'body:7',
|
|
120
|
+
revision: 1,
|
|
121
|
+
impulse: [0, 2, 0] as const,
|
|
122
|
+
point: [
|
|
123
|
+
before.centerOfMass[0] + 1,
|
|
124
|
+
before.centerOfMass[1],
|
|
125
|
+
before.centerOfMass[2],
|
|
126
|
+
] as const,
|
|
127
|
+
};
|
|
128
|
+
pw.applyDerivedImpulse(input).unwrap();
|
|
129
|
+
const after = pw.getDerivedMotion(7);
|
|
130
|
+
if (after === undefined) throw new Error('missing impulse motion');
|
|
131
|
+
expect(after.centerOfMass).toEqual(before.centerOfMass);
|
|
132
|
+
expect(after.linearVelocity[1] - before.linearVelocity[1]).toBeCloseTo(1, 5);
|
|
133
|
+
expect(after.angularVelocity[2] - before.angularVelocity[2]).toBeCloseTo(2, 5);
|
|
134
|
+
expect(pw.applyDerivedImpulse({ ...input, revision: 0 }).ok).toBe(false);
|
|
135
|
+
expect(pw.applyDerivedImpulse({ ...input, sourceKey: 'another-owner' }).ok).toBe(false);
|
|
136
|
+
expect(pw.applyDerivedImpulse({ ...input, impulse: [0, Number.NaN, 0] }).ok).toBe(false);
|
|
137
|
+
expect(pw.getDerivedMotion(7)).toEqual(after);
|
|
138
|
+
const queued = pw.prepareDerivedShapeCandidate(voxelCandidate(7, 2)).unwrap();
|
|
139
|
+
pw.admitDerivedShapeCandidate(queued).unwrap();
|
|
140
|
+
expect(pw.applyDerivedImpulse(input).ok).toBe(false);
|
|
141
|
+
pw.cancelDerivedShapeCandidate(queued).unwrap();
|
|
142
|
+
expect(pw.applyDerivedImpulse(input).ok).toBe(true);
|
|
143
|
+
const moving = pw.getDerivedMotion(7);
|
|
144
|
+
if (moving === undefined) throw new Error('missing moving body');
|
|
145
|
+
pw.step(1 / 120);
|
|
146
|
+
const advanced = pw.getDerivedMotion(7);
|
|
147
|
+
if (advanced === undefined) throw new Error('missing stepped motion');
|
|
148
|
+
expect(advanced.centerOfMass[1] - moving.centerOfMass[1]).toBeCloseTo(
|
|
149
|
+
moving.linearVelocity[1] / 120,
|
|
150
|
+
5,
|
|
151
|
+
);
|
|
152
|
+
} finally {
|
|
153
|
+
pw.dispose();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('requires recovery after a native impulse mutates and then throws', async () => {
|
|
158
|
+
const RAPIER = await loadRapier3D();
|
|
159
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
160
|
+
const OriginalWorld = RAPIER.World;
|
|
161
|
+
function FaultWorld(gravity: unknown) {
|
|
162
|
+
const world = new OriginalWorld(gravity);
|
|
163
|
+
const create = world.createRigidBody.bind(world);
|
|
164
|
+
world.createRigidBody = (descriptor: unknown) => {
|
|
165
|
+
const body = create(descriptor);
|
|
166
|
+
const apply = body.applyImpulseAtPoint.bind(body);
|
|
167
|
+
body.applyImpulseAtPoint = (...args: unknown[]) => {
|
|
168
|
+
apply(...args);
|
|
169
|
+
throw new Error('partial native impulse');
|
|
170
|
+
};
|
|
171
|
+
return body;
|
|
172
|
+
};
|
|
173
|
+
return world;
|
|
174
|
+
}
|
|
175
|
+
const pw = createRapier3DPhysicsWorld({ ...RAPIER, World: FaultWorld });
|
|
176
|
+
try {
|
|
177
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
178
|
+
pw.admitDerivedShapeCandidate(
|
|
179
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1)).unwrap(),
|
|
180
|
+
).unwrap();
|
|
181
|
+
pw.step(1 / 60);
|
|
182
|
+
expect(
|
|
183
|
+
pw.applyDerivedImpulse({
|
|
184
|
+
entity: 7,
|
|
185
|
+
sourceKey: 'body:7',
|
|
186
|
+
revision: 1,
|
|
187
|
+
impulse: [1, 0, 0],
|
|
188
|
+
point: [0, 0, 0],
|
|
189
|
+
}).ok,
|
|
190
|
+
).toBe(false);
|
|
191
|
+
expect(pw.getDerivedRecoveryState()).toBe('rebuild-required');
|
|
192
|
+
expect(pw.getDerivedMotion(7)).toBeUndefined();
|
|
193
|
+
expect(() => pw.raycast(vec3.create(0, 0, 5), vec3.create(0, 0, -1), 10)).toThrow();
|
|
194
|
+
} finally {
|
|
195
|
+
pw.dispose();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('clears one derived shape set in the same group that replaces its successor', async () => {
|
|
200
|
+
const RAPIER = await loadRapier3D();
|
|
201
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
202
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
203
|
+
try {
|
|
204
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
205
|
+
for (const entity of [7, 8]) {
|
|
206
|
+
addBody(pw, entity, [(entity - 7) * 10, 0, 0]);
|
|
207
|
+
pw.admitDerivedShapeCandidate(
|
|
208
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(entity, 1)).unwrap(),
|
|
209
|
+
).unwrap();
|
|
210
|
+
}
|
|
211
|
+
pw.step(1 / 60);
|
|
212
|
+
const ray = () => pw.raycast(vec3.create(-0.5, 0.5, 3), vec3.create(0, 0, -1), 5);
|
|
213
|
+
expect(ray()?.entity).toBe(7);
|
|
214
|
+
const removed = pw
|
|
215
|
+
.prepareDerivedShapeCandidate({ entity: 7, revision: 2, sourceKey: 'body:7', shapes: [] })
|
|
216
|
+
.unwrap();
|
|
217
|
+
const successor = pw.prepareDerivedShapeCandidate(voxelCandidate(8, 2)).unwrap();
|
|
218
|
+
pw.admitDerivedShapeCandidates([removed, successor]).unwrap();
|
|
219
|
+
expect(ray()?.entity).toBe(7);
|
|
220
|
+
pw.step(1 / 60);
|
|
221
|
+
expect(ray()).toBeUndefined();
|
|
222
|
+
expect(pw.getDerivedPublication(7)).toMatchObject({ revision: 2, shapeIds: [] });
|
|
223
|
+
expect(pw.getDerivedPublication(8)?.revision).toBe(2);
|
|
224
|
+
expect(pw.hasBody(7)).toBe(true); // ECS still owns body removal.
|
|
225
|
+
} finally {
|
|
226
|
+
pw.dispose();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it.each([
|
|
231
|
+
'cancel',
|
|
232
|
+
'despawn',
|
|
233
|
+
] as const)('releases the whole queued group after one member %s', async (action) => {
|
|
234
|
+
const RAPIER = await loadRapier3D();
|
|
235
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
236
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
237
|
+
try {
|
|
238
|
+
for (const entity of [7, 8]) addBody(pw, entity, [(entity - 7) * 10, 0, 0]);
|
|
239
|
+
const candidates = [7, 8].map((entity) =>
|
|
240
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(entity, 1)).unwrap(),
|
|
241
|
+
);
|
|
242
|
+
pw.admitDerivedShapeCandidates(candidates).unwrap();
|
|
243
|
+
const first = candidates[0];
|
|
244
|
+
if (first === undefined) throw new Error('missing first fixture candidate');
|
|
245
|
+
if (action === 'cancel') pw.cancelDerivedShapeCandidate(first).unwrap();
|
|
246
|
+
else pw.removeEntity(7);
|
|
247
|
+
pw.step(1 / 60);
|
|
248
|
+
expect(pw.getDerivedPublication(8)).toBeUndefined();
|
|
249
|
+
// A stranded member would retain the per-body pending lock here.
|
|
250
|
+
const retry = pw.prepareDerivedShapeCandidate(voxelCandidate(8, 2)).unwrap();
|
|
251
|
+
pw.admitDerivedShapeCandidate(retry).unwrap();
|
|
252
|
+
pw.step(1 / 60);
|
|
253
|
+
expect(pw.getDerivedPublication(8)?.revision).toBe(2);
|
|
254
|
+
} finally {
|
|
255
|
+
pw.dispose();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it.each([
|
|
260
|
+
'refuse',
|
|
261
|
+
'throw',
|
|
262
|
+
] as const)('treats a batch geometry %s as a group-wide outcome', async (action) => {
|
|
263
|
+
const RAPIER = await loadRapier3D();
|
|
264
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
265
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
266
|
+
try {
|
|
267
|
+
for (const entity of [7, 8]) {
|
|
268
|
+
addBody(pw, entity, [(entity - 7) * 10, 0, 0]);
|
|
269
|
+
pw.admitDerivedShapeCandidate(
|
|
270
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(entity, 1)).unwrap(),
|
|
271
|
+
).unwrap();
|
|
272
|
+
}
|
|
273
|
+
pw.step(1 / 60);
|
|
274
|
+
const candidates = [7, 8].map((entity) =>
|
|
275
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(entity, 2)).unwrap(),
|
|
276
|
+
);
|
|
277
|
+
pw.admitDerivedShapeCandidates(candidates, () => {
|
|
278
|
+
if (action === 'throw') throw new Error('uncertain consumer write');
|
|
279
|
+
return err(new Error('consumer refused without writes'));
|
|
280
|
+
}).unwrap();
|
|
281
|
+
pw.step(1 / 60);
|
|
282
|
+
for (const entity of [7, 8]) {
|
|
283
|
+
expect(pw.getDerivedFailure(entity)?.recovery).toBe(
|
|
284
|
+
action === 'throw' ? 'rebuild-required' : 'old-state-retained',
|
|
285
|
+
);
|
|
286
|
+
expect(pw.getDerivedPublication(entity)?.revision).toBe(action === 'throw' ? undefined : 1);
|
|
287
|
+
}
|
|
288
|
+
expect(pw.getDerivedRecoveryState()).toBe(action === 'throw' ? 'rebuild-required' : 'ready');
|
|
289
|
+
} finally {
|
|
290
|
+
pw.dispose();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('publishes two derived bodies under one paired admission and fixed-step receipt', async () => {
|
|
295
|
+
const RAPIER = await loadRapier3D();
|
|
296
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
297
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
298
|
+
try {
|
|
299
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
300
|
+
addBody(pw, 7, [0, 0, 0]);
|
|
301
|
+
addBody(pw, 8, [10, 0, 0]);
|
|
302
|
+
const candidates = [7, 8].map((entity) =>
|
|
303
|
+
pw
|
|
304
|
+
.prepareDerivedShapeCandidate({
|
|
305
|
+
...voxelCandidate(entity, 1),
|
|
306
|
+
constraints: [
|
|
307
|
+
{
|
|
308
|
+
id: `coupled:${entity}`,
|
|
309
|
+
revision: 1,
|
|
310
|
+
kind: 'spring',
|
|
311
|
+
bodyA: 7,
|
|
312
|
+
bodyB: 8,
|
|
313
|
+
bodyASource: { sourceKey: 'body:7', revision: 1 },
|
|
314
|
+
bodyBSource: { sourceKey: 'body:8', revision: 1 },
|
|
315
|
+
anchorA: [0, 0, 0],
|
|
316
|
+
anchorB: [0, 0, 0],
|
|
317
|
+
restLength: 10,
|
|
318
|
+
stiffness: 2,
|
|
319
|
+
damping: 1,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
})
|
|
323
|
+
.unwrap(),
|
|
324
|
+
);
|
|
325
|
+
let commits = 0;
|
|
326
|
+
pw.admitDerivedShapeCandidates(candidates, () => {
|
|
327
|
+
commits += 1;
|
|
328
|
+
expect(pw.getDerivedAdmission(7)).toMatchObject({ entity: 7, revision: 1, fixedStep: 1 });
|
|
329
|
+
expect(pw.getDerivedAdmission(8)).toMatchObject({ entity: 8, revision: 1, fixedStep: 1 });
|
|
330
|
+
return ok(undefined);
|
|
331
|
+
}).unwrap();
|
|
332
|
+
expect(pw.getDerivedPublication(7)).toBeUndefined();
|
|
333
|
+
expect(pw.getDerivedPublication(8)).toBeUndefined();
|
|
334
|
+
pw.step(1 / 60);
|
|
335
|
+
expect(commits).toBe(1);
|
|
336
|
+
expect(pw.getDerivedPublication(7)).toMatchObject({ revision: 1, fixedStep: 1 });
|
|
337
|
+
expect(pw.getDerivedPublication(8)).toMatchObject({ revision: 1, fixedStep: 1 });
|
|
338
|
+
expect(pw.getDerivedAdmission(7)).toBeUndefined();
|
|
339
|
+
const snapshot = pw.captureDerivedPhysicsState();
|
|
340
|
+
const fresh = createRapier3DPhysicsWorld(RAPIER);
|
|
341
|
+
try {
|
|
342
|
+
fresh.setGravity(vec3.create(0, 0, 0));
|
|
343
|
+
addBody(fresh, 7, [0, 0, 0]);
|
|
344
|
+
addBody(fresh, 8, [10, 0, 0]);
|
|
345
|
+
fresh.restoreDerivedPhysicsState(snapshot).unwrap();
|
|
346
|
+
fresh.step(1 / 60);
|
|
347
|
+
expect(fresh.getDerivedPublication(7)?.revision).toBe(1);
|
|
348
|
+
expect(fresh.getDerivedPublication(8)?.revision).toBe(1);
|
|
349
|
+
const restored = fresh
|
|
350
|
+
.captureDerivedPhysicsState()
|
|
351
|
+
.bodies.flatMap((body) => body.constraints);
|
|
352
|
+
expect([...new Set(restored.map((joint) => joint.id))].sort()).toEqual([
|
|
353
|
+
'coupled:7',
|
|
354
|
+
'coupled:8',
|
|
355
|
+
]);
|
|
356
|
+
} finally {
|
|
357
|
+
fresh.dispose();
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
pw.dispose();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('rolls back the entire group when the second native mass mutation fails', async () => {
|
|
365
|
+
const RAPIER = await loadRapier3D();
|
|
366
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
367
|
+
const OriginalWorld = RAPIER.World;
|
|
368
|
+
let failSecond = false;
|
|
369
|
+
function FaultWorld(gravity: unknown) {
|
|
370
|
+
const world = new OriginalWorld(gravity);
|
|
371
|
+
const create = world.createRigidBody.bind(world);
|
|
372
|
+
world.createRigidBody = (descriptor: unknown) => {
|
|
373
|
+
const body = create(descriptor);
|
|
374
|
+
const setMass = body.setAdditionalMassProperties.bind(body);
|
|
375
|
+
body.setAdditionalMassProperties = (...args: unknown[]) => {
|
|
376
|
+
setMass(...args);
|
|
377
|
+
if (failSecond && args[0] === 13) {
|
|
378
|
+
failSecond = false;
|
|
379
|
+
throw new Error('second body failed after mass mutation');
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
return body;
|
|
383
|
+
};
|
|
384
|
+
return world;
|
|
385
|
+
}
|
|
386
|
+
const pw = createRapier3DPhysicsWorld({ ...RAPIER, World: FaultWorld });
|
|
387
|
+
try {
|
|
388
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
389
|
+
for (const entity of [7, 8]) {
|
|
390
|
+
addBody(pw, entity, [(entity - 7) * 10, 0, 0]);
|
|
391
|
+
pw.admitDerivedShapeCandidate(
|
|
392
|
+
pw.prepareDerivedShapeCandidate(voxelCandidate(entity, 1)).unwrap(),
|
|
393
|
+
).unwrap();
|
|
394
|
+
}
|
|
395
|
+
pw.step(1 / 60);
|
|
396
|
+
const before = [7, 8].map((entity) => ({
|
|
397
|
+
publication: pw.getDerivedPublication(entity),
|
|
398
|
+
motion: pw.getDerivedMotion(entity),
|
|
399
|
+
}));
|
|
400
|
+
const joint = {
|
|
401
|
+
id: 'batch-shared-joint',
|
|
402
|
+
revision: 1,
|
|
403
|
+
kind: 'spring' as const,
|
|
404
|
+
bodyA: 7,
|
|
405
|
+
bodyB: 8,
|
|
406
|
+
bodyASource: { sourceKey: 'body:7', revision: 1 },
|
|
407
|
+
bodyBSource: { sourceKey: 'body:8', revision: 1 },
|
|
408
|
+
anchorA: [0, 0, 0] as const,
|
|
409
|
+
anchorB: [0, 0, 0] as const,
|
|
410
|
+
restLength: 10,
|
|
411
|
+
stiffness: 2,
|
|
412
|
+
damping: 1,
|
|
413
|
+
};
|
|
414
|
+
pw.createDerivedConstraint(joint).unwrap();
|
|
415
|
+
const oldRay = () => pw.raycast(vec3.create(-0.5, 0.5, 3), vec3.create(0, 0, -1), 5);
|
|
416
|
+
const newRay = () => pw.raycast(vec3.create(4.5, 0.5, 3), vec3.create(0, 0, -1), 5);
|
|
417
|
+
expect(oldRay()?.entity).toBe(7);
|
|
418
|
+
expect(newRay()).toBeUndefined();
|
|
419
|
+
const candidates = [7, 8].map((entity) => {
|
|
420
|
+
const input = voxelCandidate(entity, 2);
|
|
421
|
+
return pw
|
|
422
|
+
.prepareDerivedShapeCandidate({
|
|
423
|
+
...input,
|
|
424
|
+
shapes: [{ id: 'replacement', revision: 2, cells: [[4, 0, 0]], voxelSize: [1, 1, 1] }],
|
|
425
|
+
seams: [],
|
|
426
|
+
constraints:
|
|
427
|
+
entity === 7
|
|
428
|
+
? [
|
|
429
|
+
{
|
|
430
|
+
...joint,
|
|
431
|
+
revision: 2,
|
|
432
|
+
bodyASource: { sourceKey: 'body:7', revision: 2 },
|
|
433
|
+
bodyBSource: { sourceKey: 'body:8', revision: 2 },
|
|
434
|
+
},
|
|
435
|
+
]
|
|
436
|
+
: [],
|
|
437
|
+
massProperties: { ...input.massProperties, mass: entity === 7 ? 5 : 13 },
|
|
438
|
+
})
|
|
439
|
+
.unwrap();
|
|
440
|
+
});
|
|
441
|
+
let commits = 0;
|
|
442
|
+
failSecond = true;
|
|
443
|
+
pw.admitDerivedShapeCandidates(candidates, () => {
|
|
444
|
+
commits += 1;
|
|
445
|
+
return ok(undefined);
|
|
446
|
+
}).unwrap();
|
|
447
|
+
pw.step(1 / 60);
|
|
448
|
+
expect(commits).toBe(0);
|
|
449
|
+
expect(oldRay()?.entity).toBe(7);
|
|
450
|
+
expect(newRay()).toBeUndefined();
|
|
451
|
+
expect(pw.captureDerivedPhysicsState().bodies.flatMap((body) => body.constraints)).toEqual([
|
|
452
|
+
joint,
|
|
453
|
+
joint,
|
|
454
|
+
]);
|
|
455
|
+
for (let i = 0; i < 2; i++) {
|
|
456
|
+
const entity = i + 7;
|
|
457
|
+
expect(pw.getDerivedPublication(entity)).toEqual(before[i]?.publication);
|
|
458
|
+
expect(pw.getDerivedMotion(entity)).toEqual(before[i]?.motion);
|
|
459
|
+
expect(pw.getDerivedFailure(entity)).toMatchObject({
|
|
460
|
+
candidateId: candidates[i]?.candidateId,
|
|
461
|
+
recovery: 'old-state-retained',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
expect(pw.getDerivedRecoveryState()).toBe('ready');
|
|
465
|
+
} finally {
|
|
466
|
+
pw.dispose();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('restores a world COM using the newly admitted nonzero local mass center', async () => {
|
|
471
|
+
const RAPIER = await loadRapier3D();
|
|
472
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
473
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
474
|
+
try {
|
|
475
|
+
pw.setGravity(vec3.create(0, 0, 0));
|
|
476
|
+
addBody(pw, 7, [10, 20, 0], [0, 0, Math.SQRT1_2, Math.SQRT1_2]);
|
|
477
|
+
pw.admitDerivedShapeCandidate(
|
|
478
|
+
pw
|
|
479
|
+
.prepareDerivedShapeCandidate({
|
|
480
|
+
...voxelCandidate(7, 1),
|
|
481
|
+
massProperties: {
|
|
482
|
+
mode: 'explicit',
|
|
483
|
+
mass: 6,
|
|
484
|
+
centerOfMass: [1.5, 0, 0.5],
|
|
485
|
+
principalInertia: [1, 1, 1],
|
|
486
|
+
},
|
|
487
|
+
motion: {
|
|
488
|
+
centerOfMass: [10, 21.5, 0.5],
|
|
489
|
+
linearVelocity: [0, 0, 0],
|
|
490
|
+
angularVelocity: [0, 0, 0],
|
|
491
|
+
},
|
|
492
|
+
})
|
|
493
|
+
.unwrap(),
|
|
494
|
+
).unwrap();
|
|
495
|
+
pw.step(1 / 60);
|
|
496
|
+
const motion = pw.getDerivedMotion(7);
|
|
497
|
+
expect(motion?.centerOfMass[0]).toBeCloseTo(10, 5);
|
|
498
|
+
expect(motion?.centerOfMass[1]).toBeCloseTo(21.5, 5);
|
|
499
|
+
expect(motion?.centerOfMass[2]).toBeCloseTo(0.5, 5);
|
|
500
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(6, 5);
|
|
501
|
+
} finally {
|
|
502
|
+
pw.dispose();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
103
506
|
it('stops publication and queries if native step throws after the geometry commit', async () => {
|
|
104
507
|
const RAPIER = await loadRapier3D();
|
|
105
508
|
if ('code' in RAPIER) throw RAPIER;
|
|
@@ -258,6 +661,84 @@ describe('Rapier 3D derived voxel candidates', () => {
|
|
|
258
661
|
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(2, 5);
|
|
259
662
|
});
|
|
260
663
|
|
|
664
|
+
it('retains a newly created colliderless body when its first paired admission is refused', async () => {
|
|
665
|
+
const RAPIER = await loadRapier3D();
|
|
666
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
667
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
668
|
+
pw.ensureBody(
|
|
669
|
+
7,
|
|
670
|
+
{
|
|
671
|
+
position: { x: 0, y: 0, z: 0 },
|
|
672
|
+
rotation: { x: 0, y: 0, z: 0, w: 1 },
|
|
673
|
+
scale: { x: 1, y: 1, z: 1 },
|
|
674
|
+
},
|
|
675
|
+
{ type: 1, mass: 1, linearDamping: 0, angularDamping: 0, gravityScale: 0, ccdEnabled: 0 },
|
|
676
|
+
undefined,
|
|
677
|
+
);
|
|
678
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(1);
|
|
679
|
+
const candidate = pw.prepareDerivedShapeCandidate(voxelCandidate(7, 1)).unwrap();
|
|
680
|
+
pw.admitDerivedShapeCandidates([candidate], () =>
|
|
681
|
+
err(new Error('reject new fragment')),
|
|
682
|
+
).unwrap();
|
|
683
|
+
pw.step(1 / 60);
|
|
684
|
+
expect(pw.getDerivedRecoveryState()).toBe('ready');
|
|
685
|
+
expect(pw.getDerivedFailure(7)?.recovery).toBe('old-state-retained');
|
|
686
|
+
expect(pw.getDerivedBodyMass(7)).toBeCloseTo(1);
|
|
687
|
+
expect(pw.getDerivedPublication(7)).toBeUndefined();
|
|
688
|
+
expect(pw.raycast(vec3.create(0.5, 0.5, 3), vec3.create(0, 0, -1), 5)).toBeUndefined();
|
|
689
|
+
pw.dispose();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('carries a successor world orientation through admission, rollback and snapshot recovery', async () => {
|
|
693
|
+
const RAPIER = await loadRapier3D();
|
|
694
|
+
if ('code' in RAPIER) throw RAPIER;
|
|
695
|
+
const pw = createRapier3DPhysicsWorld(RAPIER);
|
|
696
|
+
addBody(pw, 16, [0, 0, 0]);
|
|
697
|
+
const rotation: [number, number, number, number] = [0, Math.SQRT1_2, 0, Math.SQRT1_2];
|
|
698
|
+
const input = {
|
|
699
|
+
...voxelCandidate(16, 1),
|
|
700
|
+
motion: {
|
|
701
|
+
centerOfMass: [10, 0, 0] as const,
|
|
702
|
+
linearVelocity: [0, 0, 0] as const,
|
|
703
|
+
angularVelocity: [0, 0, 0] as const,
|
|
704
|
+
rotation,
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
const prepared = pw.prepareDerivedShapeCandidate(input).unwrap();
|
|
708
|
+
rotation[1] = 0;
|
|
709
|
+
rotation[3] = 1;
|
|
710
|
+
pw.admitDerivedShapeCandidate(prepared).unwrap();
|
|
711
|
+
pw.step(1 / 60);
|
|
712
|
+
expect(pw.getDerivedMotion(16)?.rotation?.[1]).toBeCloseTo(Math.SQRT1_2);
|
|
713
|
+
expect(pw.raycast(vec3.create(10.5, 4, -2.25), vec3.create(0, -1, 0), 5)?.entity).toBe(16);
|
|
714
|
+
const before = pw.getDerivedMotion(16);
|
|
715
|
+
pw.admitDerivedShapeCandidate(
|
|
716
|
+
pw
|
|
717
|
+
.prepareDerivedShapeCandidate({
|
|
718
|
+
...voxelCandidate(16, 2),
|
|
719
|
+
motion: { ...input.motion, rotation: [0, 0, 0, 1] },
|
|
720
|
+
})
|
|
721
|
+
.unwrap(),
|
|
722
|
+
() => err(new Error('keep the prior orientation')),
|
|
723
|
+
).unwrap();
|
|
724
|
+
pw.step(1 / 60);
|
|
725
|
+
expect(pw.getDerivedMotion(16)).toEqual(before);
|
|
726
|
+
const snapshot = pw.captureDerivedPhysicsState();
|
|
727
|
+
pw.removeEntity(16);
|
|
728
|
+
addBody(pw, 16, [0, 0, 0]);
|
|
729
|
+
pw.restoreDerivedPhysicsState(snapshot).unwrap();
|
|
730
|
+
pw.step(1 / 60);
|
|
731
|
+
expect(pw.getDerivedMotion(16)?.rotation?.[1]).toBeCloseTo(Math.SQRT1_2);
|
|
732
|
+
expect(pw.raycast(vec3.create(10.5, 4, -2.25), vec3.create(0, -1, 0), 5)?.entity).toBe(16);
|
|
733
|
+
expect(
|
|
734
|
+
pw.prepareDerivedShapeCandidate({
|
|
735
|
+
...voxelCandidate(16, 2),
|
|
736
|
+
motion: { ...input.motion, rotation: [0, 0, 0, 0] },
|
|
737
|
+
}).ok,
|
|
738
|
+
).toBe(false);
|
|
739
|
+
pw.dispose();
|
|
740
|
+
});
|
|
741
|
+
|
|
261
742
|
it('exposes committed COM and motion and restores that movement through a snapshot', async () => {
|
|
262
743
|
const RAPIER = await loadRapier3D();
|
|
263
744
|
if ('code' in RAPIER) {
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// Re-exports the RapierPhysicsWorld3D class, WASM loader, and three-phase tick
|
|
4
4
|
// systems.
|
|
5
5
|
|
|
6
|
-
export type { Rapier3DCollisionEvent } from './rapier-physics-world-3d';
|
|
6
|
+
export type { Rapier3DCollisionEvent } from './rapier-physics-world-3d.js';
|
|
7
7
|
export {
|
|
8
8
|
createRapier3DPhysicsWorld,
|
|
9
9
|
RapierPhysicsWorld3D,
|
|
10
10
|
registerPhysicsSystems,
|
|
11
|
-
} from './rapier-physics-world-3d';
|
|
12
|
-
export type { Rapier3DModule } from './wasm-loader';
|
|
13
|
-
export { loadRapier3D } from './wasm-loader';
|
|
11
|
+
} from './rapier-physics-world-3d.js';
|
|
12
|
+
export type { Rapier3DModule } from './wasm-loader.js';
|
|
13
|
+
export { loadRapier3D } from './wasm-loader.js';
|