@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.
@@ -60,7 +60,7 @@ import {
60
60
  } from '@forgeax/engine-physics';
61
61
  import { ChildOf } from '@forgeax/engine-scene';
62
62
  import { err, ok, type Result } from '@forgeax/engine-types';
63
- import type { Rapier3DModule } from './wasm-loader';
63
+ import type { Rapier3DModule } from './wasm-loader.js';
64
64
 
65
65
  interface Rapier3DKinematicControllerState {
66
66
  readonly entity: number;
@@ -231,6 +231,12 @@ interface DerivedCandidateRecord {
231
231
  readonly bytes: number;
232
232
  state: DerivedPhysicsCandidateState;
233
233
  commitGeometry?: () => Result<void, Error>;
234
+ batch?: DerivedAdmissionBatch;
235
+ }
236
+
237
+ interface DerivedAdmissionBatch {
238
+ readonly records: readonly DerivedCandidateRecord[];
239
+ readonly commitGeometry?: () => Result<void, Error>;
234
240
  }
235
241
 
236
242
  interface DerivedConstraintRecord {
@@ -536,8 +542,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
536
542
 
537
543
  step(deltaTime: number): void {
538
544
  this.assertActive('step');
539
- void deltaTime;
545
+ if (!Number.isFinite(deltaTime) || deltaTime <= 0 || deltaTime > PHYSICS_DT_MAX) return;
540
546
  try {
547
+ // Native integration must consume the same fixed delta as force controllers.
548
+ (this.raw as { timestep: number }).timestep = deltaTime;
541
549
  this.processDerivedCandidates();
542
550
  // A failed native rollback is an explicit rebuild boundary. Physics does
543
551
  // not advance or publish a mixed state after this point.
@@ -625,6 +633,8 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
625
633
  ): void {
626
634
  let point: PhysicsVector | undefined;
627
635
  let normal: PhysicsVector | undefined;
636
+ let geometricPoint: PhysicsVector | undefined;
637
+ let geometricNormal: PhysicsVector | undefined;
628
638
  try {
629
639
  const colliderA = (this.raw as RapierWorld).getCollider(handleA);
630
640
  const colliderB = (this.raw as RapierWorld).getCollider(handleB);
@@ -633,6 +643,44 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
633
643
  colliderA,
634
644
  colliderB,
635
645
  (manifold: RapierWorld, flipped: boolean) => {
646
+ // A contact manifold can retain geometric points without solver
647
+ // points. Composite local points are subshape-local, so only
648
+ // transform points from the ordinary primitive collider surface.
649
+ if (
650
+ manifold.numSolverContacts() === 0 &&
651
+ geometricPoint === undefined &&
652
+ manifold.numContacts() > 0
653
+ ) {
654
+ const first = flipped ? colliderB : colliderA;
655
+ const second = flipped ? colliderA : colliderB;
656
+ const types = this.rapierModule.ShapeType;
657
+ for (const [collider, local] of [
658
+ [first, manifold.localContactPoint1(0)],
659
+ [second, manifold.localContactPoint2(0)],
660
+ ]) {
661
+ if (
662
+ local == null ||
663
+ ![types.Ball, types.Cuboid, types.Capsule].includes(collider.shapeType())
664
+ )
665
+ continue;
666
+ const rotation = collider.rotation();
667
+ const rotated = quat.transformVec3(
668
+ vec3.create(),
669
+ [rotation.x, rotation.y, rotation.z, rotation.w],
670
+ [local.x, local.y, local.z],
671
+ );
672
+ const position = collider.translation();
673
+ geometricPoint = [
674
+ rotated[0] + position.x,
675
+ rotated[1] + position.y,
676
+ rotated[2] + position.z,
677
+ ];
678
+ const n = manifold.normal();
679
+ const direction = flipped ? -1 : 1;
680
+ geometricNormal = [n.x * direction, n.y * direction, n.z * direction];
681
+ break;
682
+ }
683
+ }
636
684
  if (manifold.numSolverContacts?.() > 0) {
637
685
  const contact = manifold.solverContactPoint(0);
638
686
  const n = manifold.normal();
@@ -651,6 +699,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
651
699
  // Contact events remain useful without optional manifold sampling. The
652
700
  // public type makes point/normal optional rather than inventing values.
653
701
  }
702
+ if (point === undefined && geometricPoint !== undefined) {
703
+ point = geometricPoint;
704
+ normal = geometricNormal;
705
+ }
654
706
  this.derivedContacts.push({
655
707
  ...observation,
656
708
  ...(point === undefined ? {} : { point }),
@@ -906,14 +958,85 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
906
958
  return admitted;
907
959
  }
908
960
 
961
+ admitDerivedShapeCandidates(
962
+ candidates: readonly DerivedPhysicsCandidate[],
963
+ commitGeometry?: () => Result<void, Error>,
964
+ ): ReturnType<NonNullable<PhysicsWorld['admitDerivedShapeCandidates']>> {
965
+ this.assertActive('admitDerivedShapeCandidates');
966
+ const records: DerivedCandidateRecord[] = [];
967
+ const sources = new Map<number, DerivedBodySource>();
968
+ const constraints = new Set<string>();
969
+ const invalid = (reason: string) =>
970
+ new DerivedPhysicsError(
971
+ 'derived-candidate-invalid',
972
+ 'one bounded admission contains distinct prepared bodies and constraint updates',
973
+ 'prepare one candidate per body and submit the complete replacement together',
974
+ { reason },
975
+ );
976
+ if (candidates.length === 0 || candidates.length > DERIVED_PHYSICS_LIMITS.maxCandidates)
977
+ return err(invalid('invalid batch size'));
978
+ // Resolve credentials before mutating the queue. Never use caller input as
979
+ // the source projection or invalidate another owner's matching token ID.
980
+ for (const candidate of candidates) {
981
+ const record = this.derivedCandidates.get(candidate.candidateId);
982
+ if (
983
+ record === undefined ||
984
+ candidate.owner !== this.physicsOwner ||
985
+ candidate.generation !== this.backendGeneration ||
986
+ record.state !== 'ready'
987
+ )
988
+ return err(invalid('batch member is not a current prepared candidate'));
989
+ if (sources.has(record.input.entity)) return err(invalid('duplicate body'));
990
+ for (const constraint of record.input.constraints ?? []) {
991
+ if (constraints.has(constraint.id)) return err(invalid('duplicate constraint update'));
992
+ constraints.add(constraint.id);
993
+ }
994
+ records.push(record);
995
+ sources.set(record.input.entity, {
996
+ sourceKey: record.input.sourceKey,
997
+ revision: record.input.revision,
998
+ });
999
+ }
1000
+ for (const candidate of candidates) {
1001
+ const checked = this.admitDerivedShapeCandidateInternal(candidate, sources, true);
1002
+ if (!checked.ok) {
1003
+ for (const record of records) {
1004
+ if (this.derivedCandidates.has(record.token.candidateId))
1005
+ this.rejectPreparedCandidate(record, checked.error);
1006
+ }
1007
+ return checked;
1008
+ }
1009
+ }
1010
+ const batch: DerivedAdmissionBatch = Object.freeze({
1011
+ records: Object.freeze(records),
1012
+ ...(commitGeometry === undefined ? {} : { commitGeometry }),
1013
+ });
1014
+ for (const record of records) record.batch = batch;
1015
+ const queued: DerivedPhysicsCandidate[] = [];
1016
+ for (const candidate of candidates) {
1017
+ const admitted = this.admitDerivedShapeCandidateInternal(candidate, sources);
1018
+ if (!admitted.ok) {
1019
+ const first = records[0];
1020
+ if (first !== undefined) this.rejectPreparedCandidate(first, admitted.error);
1021
+ return admitted;
1022
+ }
1023
+ queued.push(admitted.value);
1024
+ }
1025
+ return ok(Object.freeze(queued));
1026
+ }
1027
+
909
1028
  getDerivedAdmission(
910
1029
  entity?: number,
911
1030
  ):
912
1031
  | { readonly entity: number; readonly revision: number; readonly fixedStep: number }
913
1032
  | undefined {
914
- const record = this.activeDerivedAdmission;
915
- if (record === undefined || (entity !== undefined && record.input.entity !== entity))
916
- return undefined;
1033
+ const active = this.activeDerivedAdmission;
1034
+ const record =
1035
+ entity === undefined
1036
+ ? active
1037
+ : (active?.batch?.records.find((item) => item.input.entity === entity) ??
1038
+ (active?.input.entity === entity ? active : undefined));
1039
+ if (record === undefined) return undefined;
917
1040
  return {
918
1041
  entity: record.input.entity,
919
1042
  revision: record.input.revision,
@@ -924,6 +1047,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
924
1047
  private admitDerivedShapeCandidateInternal(
925
1048
  candidate: DerivedPhysicsCandidate,
926
1049
  sourceOverrides?: ReadonlyMap<number, DerivedBodySource>,
1050
+ validateOnly = false,
927
1051
  ): ReturnType<NonNullable<PhysicsWorld['admitDerivedShapeCandidate']>> {
928
1052
  this.assertActive('admitDerivedShapeCandidate');
929
1053
  const record = this.derivedCandidates.get(candidate.candidateId);
@@ -1012,6 +1136,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1012
1136
  this.rejectPreparedCandidate(record, stale);
1013
1137
  return err(stale);
1014
1138
  }
1139
+ if (validateOnly) return ok(record.token);
1015
1140
  record.state = 'queued';
1016
1141
  this.pendingDerivedCandidates.add(candidate.candidateId);
1017
1142
  const queued = Object.freeze({ ...record.token, state: 'queued' as const });
@@ -1084,10 +1209,12 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1084
1209
  ),
1085
1210
  );
1086
1211
  }
1087
- this.pendingDerivedCandidates.delete(candidate.candidateId);
1088
- for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1089
- record.state = 'cancelled';
1090
- this.releaseDerivedCandidate(candidate.candidateId);
1212
+ for (const member of record.batch?.records ?? [record]) {
1213
+ this.pendingDerivedCandidates.delete(member.token.candidateId);
1214
+ for (const collider of member.nativeColliders) this.removeNativeCollider(collider.handle);
1215
+ member.state = 'cancelled';
1216
+ this.releaseDerivedCandidate(member.token.candidateId);
1217
+ }
1091
1218
  return ok(undefined);
1092
1219
  }
1093
1220
 
@@ -1143,13 +1270,83 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1143
1270
  const com = body.worldCom();
1144
1271
  const linear = body.linvel();
1145
1272
  const angular = body.angvel();
1273
+ const rotation = body.rotation();
1146
1274
  return Object.freeze({
1147
1275
  centerOfMass: [com.x, com.y, com.z] as PhysicsVector,
1148
1276
  linearVelocity: [linear.x, linear.y, linear.z] as PhysicsVector,
1149
1277
  angularVelocity: [angular.x, angular.y, angular.z] as PhysicsVector,
1278
+ rotation: [rotation.x, rotation.y, rotation.z, rotation.w] as PhysicsQuaternion,
1150
1279
  });
1151
1280
  }
1152
1281
 
1282
+ applyDerivedImpulse(
1283
+ input: Parameters<NonNullable<PhysicsWorld['applyDerivedImpulse']>>[0],
1284
+ ): Result<void, DerivedPhysicsError> {
1285
+ this.assertActive('applyDerivedImpulse');
1286
+ const refuse = (code: DerivedPhysicsError['code'], expected: string) =>
1287
+ err(
1288
+ new DerivedPhysicsError(
1289
+ code,
1290
+ expected,
1291
+ 'read the committed dynamic body and submit a finite impulse outside pending admission',
1292
+ { entity: input.entity },
1293
+ ),
1294
+ );
1295
+ if (this.recoveryBlocked())
1296
+ return refuse('derived-recovery-invalid', 'PhysicsWorld is healthy');
1297
+ const record = this.derivedBodies.get(input.entity);
1298
+ const body = this.bodyForEntity(input.entity);
1299
+ if (record === undefined || body === undefined)
1300
+ return refuse('derived-body-not-found', 'a committed derived body exists');
1301
+ if (record.sourceKey !== input.sourceKey || record.revision !== input.revision)
1302
+ return refuse(
1303
+ 'derived-candidate-stale',
1304
+ 'impulse identity matches the committed body revision',
1305
+ );
1306
+ if (
1307
+ this.derivedPublicationPending ||
1308
+ [...this.pendingDerivedCandidates].some(
1309
+ (id) => this.derivedCandidates.get(id)?.input.entity === input.entity,
1310
+ )
1311
+ )
1312
+ return refuse('derived-candidate-pending', 'the body has no unpublished native mutation');
1313
+ if (
1314
+ rapierBodyTypeToString(this.rapierModule, body.bodyType()) !== 'dynamic' ||
1315
+ ![input.impulse, input.point].every(
1316
+ (vector) =>
1317
+ Array.isArray(vector) &&
1318
+ vector.length === 3 &&
1319
+ vector.every((value) => Number.isFinite(value) && Number.isFinite(Math.fround(value))),
1320
+ )
1321
+ )
1322
+ return refuse(
1323
+ 'derived-candidate-invalid',
1324
+ 'a dynamic body receives finite Float32 world vectors',
1325
+ );
1326
+ try {
1327
+ body.applyImpulseAtPoint(
1328
+ { x: input.impulse[0], y: input.impulse[1], z: input.impulse[2] },
1329
+ { x: input.point[0], y: input.point[1], z: input.point[2] },
1330
+ true,
1331
+ );
1332
+ const linear = body.linvel(),
1333
+ angular = body.angvel();
1334
+ if (![linear.x, linear.y, linear.z, angular.x, angular.y, angular.z].every(Number.isFinite))
1335
+ throw new Error('native impulse produced non-finite motion');
1336
+ return ok(undefined);
1337
+ } catch (cause) {
1338
+ this.derivedPoisonedEntities.add(input.entity);
1339
+ return err(
1340
+ new DerivedPhysicsError(
1341
+ 'derived-backend-failed',
1342
+ 'native impulse completes with finite motion',
1343
+ 'rebuild the World from a previously committed snapshot',
1344
+ { entity: input.entity, reason: cause instanceof Error ? cause.message : String(cause) },
1345
+ ),
1346
+ );
1347
+ }
1348
+ }
1349
+
1153
1350
  getDerivedRecoveryState(): 'ready' | 'rebuild-required' {
1154
1351
  return this.recoveryBlocked() ? 'rebuild-required' : 'ready';
1155
1352
  }
@@ -1251,20 +1448,8 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1251
1448
  }
1252
1449
  preparedCandidates.push(prepared.value);
1253
1450
  }
1254
- const candidates: DerivedPhysicsCandidate[] = [];
1255
- for (const prepared of preparedCandidates) {
1256
- const admitted = this.admitDerivedShapeCandidateInternal(prepared, snapshotSources);
1257
- if (!admitted.ok) {
1258
- // Admission may have created disabled native colliders before a later
1259
- // body/constraint row fails. Cancel every prepared credential, not
1260
- // only rows that reached the local `candidates` list: the rejected
1261
- // row and any rows after it also own native staging state.
1262
- for (const candidate of preparedCandidates) this.cancelDerivedShapeCandidate(candidate);
1263
- return admitted;
1264
- }
1265
- candidates.push(admitted.value);
1266
- }
1267
- return ok(candidates);
1451
+ if (preparedCandidates.length === 0) return ok([]);
1452
+ return this.admitDerivedShapeCandidates(preparedCandidates);
1268
1453
  }
1269
1454
 
1270
1455
  createDerivedConstraint(
@@ -1424,8 +1609,11 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1424
1609
  record: DerivedCandidateRecord,
1425
1610
  error: DerivedPhysicsError,
1426
1611
  ): void {
1427
- for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1428
- this.rememberDerivedFailure(record, error, 'old-state-retained');
1612
+ for (const member of record.batch?.records ?? [record]) {
1613
+ if (!this.derivedCandidates.has(member.token.candidateId)) continue;
1614
+ for (const collider of member.nativeColliders) this.removeNativeCollider(collider.handle);
1615
+ this.rememberDerivedFailure(member, error, 'old-state-retained');
1616
+ }
1429
1617
  }
1430
1618
 
1431
1619
  private processDerivedCandidates(): void {
@@ -1444,389 +1632,432 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1444
1632
  // endpoint. This turns a queue-time source projection into a final
1445
1633
  // committed-source check: if the endpoint fails natively, its dependent
1446
1634
  // candidate sees the old source on the next iteration and is rejected.
1447
- const ordered: DerivedCandidateRecord[] = [];
1635
+ const ordered: (readonly DerivedCandidateRecord[])[] = [];
1448
1636
  const visiting = new Set<string>();
1449
1637
  const visited = new Set<string>();
1450
1638
  const visit = (record: DerivedCandidateRecord): void => {
1451
- if (visited.has(record.token.candidateId)) return;
1452
- if (visiting.has(record.token.candidateId)) return;
1453
- visiting.add(record.token.candidateId);
1454
- for (const constraint of record.input.constraints ?? []) {
1455
- for (const endpoint of [
1456
- [constraint.bodyA, constraint.bodyASource],
1457
- [constraint.bodyB, constraint.bodyBSource],
1458
- ] as const) {
1459
- const dependency = endpoint[1];
1460
- const target = pendingByEntity.get(endpoint[0]);
1461
- if (
1462
- target !== undefined &&
1463
- target.input.sourceKey === dependency.sourceKey &&
1464
- target.input.revision === dependency.revision
1465
- ) {
1466
- visit(target);
1467
- }
1468
- }
1469
- }
1470
- visiting.delete(record.token.candidateId);
1471
- visited.add(record.token.candidateId);
1472
- ordered.push(record);
1473
- };
1474
- for (const record of pendingRecords) visit(record);
1475
-
1476
- for (const record of ordered) {
1477
- const id = record.token.candidateId;
1478
- if (!this.pendingDerivedCandidates.has(id) || record.state !== 'queued') continue;
1479
- const pendingSources = this.pendingDerivedSources();
1480
- const projected = pendingSources.get(record.input.entity);
1481
- if (projected !== undefined && record.input.revision < projected.revision) {
1639
+ const group = record.batch?.records ?? [record];
1640
+ const key = group[0]?.token.candidateId ?? record.token.candidateId;
1641
+ if (visited.has(key)) return;
1642
+ if (visiting.has(key)) {
1482
1643
  this.rejectPreparedCandidate(
1483
1644
  record,
1484
1645
  new DerivedPhysicsError(
1485
- 'derived-candidate-stale',
1486
- 'fixed-step admission publishes only the newest queued body revision',
1487
- 'discard the older queued candidate and submit the latest complete input',
1488
- {
1489
- entity: record.input.entity,
1490
- candidateId: record.token.candidateId,
1491
- expected: `>=${projected.revision}`,
1492
- actual: record.input.revision,
1493
- },
1646
+ 'derived-constraint-invalid',
1647
+ 'cyclic endpoint updates share one batch',
1648
+ 'submit mutually dependent body replacements together',
1649
+ { candidateId: key },
1494
1650
  ),
1495
1651
  );
1496
- continue;
1652
+ return;
1497
1653
  }
1498
- const admissionError = this.validateDerivedAdmission(record.input, pendingSources);
1499
- if (admissionError !== undefined) {
1500
- this.rejectPreparedCandidate(record, admissionError);
1501
- continue;
1654
+ visiting.add(key);
1655
+ for (const member of group) {
1656
+ for (const constraint of member.input.constraints ?? []) {
1657
+ for (const [entity, dependency] of [
1658
+ [constraint.bodyA, constraint.bodyASource],
1659
+ [constraint.bodyB, constraint.bodyBSource],
1660
+ ] as const) {
1661
+ const target = pendingByEntity.get(entity);
1662
+ if (
1663
+ target !== undefined &&
1664
+ !group.includes(target) &&
1665
+ target.input.sourceKey === dependency.sourceKey &&
1666
+ target.input.revision === dependency.revision
1667
+ )
1668
+ visit(target);
1669
+ }
1670
+ }
1502
1671
  }
1503
- const old = this.derivedBodies.get(record.input.entity);
1504
- if (this.derivedPoisonedEntities.has(record.input.entity)) {
1505
- for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1506
- this.rememberDerivedFailure(
1507
- record,
1508
- new DerivedPhysicsError(
1509
- 'derived-recovery-invalid',
1510
- 'the entity is stopped after an unrecoverable native admission failure',
1511
- 'rebuild the PhysicsWorld from the last portable snapshot before retrying',
1512
- { entity: record.input.entity, candidateId: record.token.candidateId },
1513
- ),
1514
- 'rebuild-required',
1515
- );
1672
+ visiting.delete(key);
1673
+ visited.add(key);
1674
+ ordered.push(group);
1675
+ };
1676
+ for (const record of pendingRecords) visit(record);
1677
+
1678
+ for (const group of ordered) {
1679
+ if (this.derivedPoisonedEntities.size > 0) break;
1680
+ const first = group[0];
1681
+ if (first === undefined) continue;
1682
+ if (
1683
+ group.some(
1684
+ (record) =>
1685
+ record.state !== 'queued' ||
1686
+ !this.pendingDerivedCandidates.has(record.token.candidateId),
1687
+ )
1688
+ )
1516
1689
  continue;
1517
- }
1518
- const body = this.bodyForEntity(record.input.entity);
1519
- if (body === undefined) {
1520
- for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1521
- this.rememberDerivedFailure(
1522
- record,
1523
- new DerivedPhysicsError(
1690
+ // Validate the whole final projection before any native body changes.
1691
+ const sources = this.pendingDerivedSources();
1692
+ let rejected: DerivedPhysicsError | undefined;
1693
+ for (const record of group) {
1694
+ const projected = sources.get(record.input.entity);
1695
+ if (projected !== undefined && record.input.revision < projected.revision) {
1696
+ rejected = new DerivedPhysicsError(
1697
+ 'derived-candidate-stale',
1698
+ 'fixed-step admission publishes the final queued body revisions',
1699
+ 'discard the older group and prepare a complete replacement',
1700
+ { entity: record.input.entity },
1701
+ );
1702
+ } else if (this.derivedPoisonedEntities.has(record.input.entity)) {
1703
+ rejected = new DerivedPhysicsError(
1704
+ 'derived-recovery-invalid',
1705
+ 'every batch body has recoverable native state',
1706
+ 'rebuild the PhysicsWorld before retrying',
1707
+ { entity: record.input.entity },
1708
+ );
1709
+ } else if (this.bodyForEntity(record.input.entity) === undefined) {
1710
+ rejected = new DerivedPhysicsError(
1524
1711
  'derived-body-not-found',
1525
- 'candidate entity remains a live native body at fixed-step admission',
1526
- 'reconcile the entity and submit a fresh candidate',
1527
- { entity: record.input.entity, candidateId: record.token.candidateId },
1528
- ),
1529
- 'old-state-retained',
1530
- );
1712
+ 'every batch body remains live through admission',
1713
+ 'reconcile entities and prepare again',
1714
+ { entity: record.input.entity },
1715
+ );
1716
+ } else rejected = this.validateDerivedAdmission(record.input, sources);
1717
+ if (rejected !== undefined) break;
1718
+ }
1719
+ if (rejected !== undefined) {
1720
+ this.rejectPreparedCandidate(first, rejected);
1531
1721
  continue;
1532
1722
  }
1533
- const oldBodyType = body.bodyType();
1534
- const oldBodyEnabled = body.isEnabled();
1535
- const oldVelocity = body.linvel();
1536
- const oldAngularVelocity = body.angvel();
1537
- const oldTranslation = body.translation();
1538
- const oldRotation = body.rotation();
1539
- const oldCom = body.worldCom();
1540
- const oldMass = body.mass();
1541
- const oldAutomaticAdditionalMass =
1542
- this.entityMap.get(record.input.entity)?.automaticAdditionalMass ?? 0;
1543
- const oldAutomaticRecordMass = this.entityMap.get(
1544
- record.input.entity,
1545
- )?.automaticAdditionalMass;
1546
- const oldSource = this.derivedBodySources.get(record.input.entity);
1547
- const oldPublication = this.derivedPublications.get(record.input.entity);
1548
- const oldDensities = this.bodyColliders(body).map((collider) => ({
1549
- collider,
1550
- density: typeof collider.density === 'function' ? collider.density() : undefined,
1551
- enabled: typeof collider.isEnabled === 'function' ? collider.isEnabled() : true,
1552
- }));
1553
- const oldConstraints = new Map(this.derivedConstraints);
1554
- const stagedConstraints = new Map<string, DerivedConstraintRecord>();
1555
- let geometryCommitUncertain = false;
1556
- try {
1557
- for (const constraint of record.input.constraints ?? []) {
1558
- const created = this.createNativeConstraint(constraint);
1559
- if (!created.ok) throw created.error;
1560
- stagedConstraints.set(constraint.id, {
1561
- input: { ...constraint },
1562
- handle: created.value.handle,
1563
- });
1564
- }
1565
- if (record.input.bodyType !== undefined) {
1566
- const RAPIER = this.rapierModule as RapierWorld;
1567
- const bodyType =
1568
- record.input.bodyType === 'static'
1569
- ? RAPIER.RigidBodyType.Fixed
1570
- : record.input.bodyType === 'kinematic'
1571
- ? RAPIER.RigidBodyType.KinematicPositionBased
1572
- : RAPIER.RigidBodyType.Dynamic;
1573
- body.setBodyType(bodyType, true);
1574
- }
1575
- for (const native of record.nativeColliders) {
1576
- const collider = (this.raw as RapierWorld).getCollider(native.handle);
1577
- if (collider === null || collider === undefined)
1578
- throw new Error('candidate collider disappeared');
1579
- collider.setEnabled(true);
1580
- }
1581
- const nativeById = new Map(
1582
- record.input.shapes.map((shape: VoxelShapeInput, index: number) => [
1583
- shape.id,
1584
- record.nativeColliders[index]?.handle as number,
1585
- ]),
1586
- );
1587
- for (const seam of record.input.seams ?? []) {
1588
- const firstHandle = nativeById.get(seam.shapeA);
1589
- const secondHandle = nativeById.get(seam.shapeB);
1590
- const first =
1591
- firstHandle === undefined
1592
- ? undefined
1593
- : (this.raw as RapierWorld).getCollider(firstHandle);
1594
- const second =
1595
- secondHandle === undefined
1596
- ? undefined
1597
- : (this.raw as RapierWorld).getCollider(secondHandle);
1598
- if (first === undefined || second === undefined || first === null || second === null) {
1599
- throw new Error(`derived seam references missing shape ${seam.shapeA}`);
1600
- }
1601
- first.combineVoxelStates(second, seam.offset[0], seam.offset[1], seam.offset[2]);
1602
- }
1603
- this.applyDerivedMass(
1604
- body,
1605
- record.input.massProperties,
1606
- oldCom,
1607
- record.input.velocityPolicy ?? 'preserve',
1608
- this.entityMap.get(record.input.entity)?.additionalMass ?? 0,
1723
+ const undos: (() => boolean)[] = [];
1724
+ for (const record of group) {
1725
+ const id = record.token.candidateId;
1726
+ const old = this.derivedBodies.get(record.input.entity);
1727
+ const body = this.bodyForEntity(record.input.entity);
1728
+ const oldBodyType = body.bodyType();
1729
+ const oldBodyEnabled = body.isEnabled();
1730
+ const oldVelocity = body.linvel();
1731
+ const oldAngularVelocity = body.angvel();
1732
+ const oldTranslation = body.translation();
1733
+ const oldRotation = body.rotation();
1734
+ const oldCom = body.worldCom();
1735
+ const oldMass = body.mass();
1736
+ const oldAutomaticAdditionalMass =
1737
+ this.entityMap.get(record.input.entity)?.automaticAdditionalMass ?? 0;
1738
+ const oldAutomaticRecordMass = this.entityMap.get(
1609
1739
  record.input.entity,
1610
- record.input.shapes,
1611
- record.nativeColliders,
1612
- );
1613
- if (record.input.motion !== undefined) {
1614
- const currentCom = body.worldCom();
1615
- const targetCom = record.input.motion.centerOfMass;
1616
- const translation = body.translation();
1617
- body.setTranslation(
1618
- {
1619
- x: translation.x + targetCom[0] - currentCom.x,
1620
- y: translation.y + targetCom[1] - currentCom.y,
1621
- z: translation.z + targetCom[2] - currentCom.z,
1622
- },
1623
- true,
1624
- );
1625
- body.setLinvel(
1626
- {
1627
- x: record.input.motion.linearVelocity[0],
1628
- y: record.input.motion.linearVelocity[1],
1629
- z: record.input.motion.linearVelocity[2],
1630
- },
1631
- true,
1632
- );
1633
- body.setAngvel(
1634
- {
1635
- x: record.input.motion.angularVelocity[0],
1636
- y: record.input.motion.angularVelocity[1],
1637
- z: record.input.motion.angularVelocity[2],
1638
- },
1639
- true,
1640
- );
1641
- }
1642
- // A body revision invalidates any committed joint that still names
1643
- // that body's previous source/revision. Keep this tied to the body
1644
- // that is actually committing: a different queued endpoint may still
1645
- // fail, in which case its old joint remains valid and must not be
1646
- // removed speculatively.
1647
- const committedSources = new Map(this.derivedBodySources);
1648
- committedSources.set(record.input.entity, {
1649
- sourceKey: record.input.sourceKey,
1650
- revision: record.input.revision,
1651
- });
1652
- const replacementConstraintIds = new Set(
1653
- (record.input.constraints ?? []).map((constraint) => constraint.id),
1654
- );
1655
- for (const [constraintId, current] of [...this.derivedConstraints]) {
1656
- if (
1657
- replacementConstraintIds.has(constraintId) ||
1658
- this.constraintDependenciesMatch(current.input, committedSources)
1659
- )
1660
- continue;
1661
- this.removeNativeConstraint(current.handle);
1662
- this.derivedConstraints.delete(constraintId);
1663
- }
1664
- if (old !== undefined) {
1665
- for (const shape of old.shapes) {
1666
- const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
1740
+ )?.automaticAdditionalMass;
1741
+ const oldSource = this.derivedBodySources.get(record.input.entity);
1742
+ const oldPublication = this.derivedPublications.get(record.input.entity);
1743
+ const oldDensities = this.bodyColliders(body).map((collider) => ({
1744
+ collider,
1745
+ density: typeof collider.density === 'function' ? collider.density() : undefined,
1746
+ enabled: typeof collider.isEnabled === 'function' ? collider.isEnabled() : true,
1747
+ }));
1748
+ const oldConstraints = new Map(this.derivedConstraints);
1749
+ const stagedConstraints = new Map<string, DerivedConstraintRecord>();
1750
+ let geometryCommitUncertain = false;
1751
+ const rollback = (): boolean => {
1752
+ for (const staged of stagedConstraints.values())
1753
+ this.removeNativeConstraint(staged.handle);
1754
+ // The commit path may already have removed/replaced a native joint
1755
+ // before a later body mutation fails. Clear every current native joint
1756
+ // now; the old records are recreated after the body state is restored
1757
+ // instead of leaving a stale JS handle that no longer exists in
1758
+ // Rapier.
1759
+ for (const current of this.derivedConstraints.values())
1760
+ this.removeNativeConstraint(current.handle);
1761
+ this.derivedConstraints.clear();
1762
+ if (old !== undefined) {
1763
+ for (const shape of old.shapes) {
1764
+ const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
1765
+ if (collider !== null && collider !== undefined) collider.setEnabled(true);
1766
+ }
1767
+ const retiredIndex = this.retiredDerivedBodies.indexOf(old);
1768
+ if (retiredIndex >= 0) this.retiredDerivedBodies.splice(retiredIndex, 1);
1769
+ this.derivedBodies.set(record.input.entity, old);
1770
+ this.derivedBodySources.set(record.input.entity, {
1771
+ sourceKey: old.sourceKey,
1772
+ revision: old.revision,
1773
+ });
1774
+ }
1775
+ for (const native of record.nativeColliders) {
1776
+ const collider = (this.raw as RapierWorld).getCollider(native.handle);
1667
1777
  if (collider !== null && collider !== undefined) collider.setEnabled(false);
1668
1778
  }
1669
- this.retiredDerivedBodies.push(old);
1670
- }
1671
- for (const constraint of record.input.constraints ?? []) {
1672
- const previous = this.derivedConstraints.get(constraint.id);
1673
- if (previous !== undefined) this.removeNativeConstraint(previous.handle);
1674
- const staged = stagedConstraints.get(constraint.id);
1675
- if (staged !== undefined) this.derivedConstraints.set(constraint.id, staged);
1676
- }
1677
- const shapes: DerivedShapeRecord[] = record.input.shapes.map(
1678
- (shape: VoxelShapeInput, index: number) => ({
1679
- input: shape as DerivedShapeRecord['input'],
1680
- colliderHandle: record.nativeColliders[index]?.handle as number,
1681
- }),
1682
- );
1683
- const committed: DerivedBodyRecord = {
1684
- entity: record.input.entity,
1685
- sourceKey: record.input.sourceKey,
1686
- generation: this.backendGeneration,
1687
- revision: record.input.revision,
1688
- bodyType: record.input.bodyType,
1689
- velocityPolicy: record.input.velocityPolicy,
1690
- candidateId: record.token.candidateId,
1691
- shapes,
1692
- seams: [...(record.input.seams ?? [])],
1693
- massProperties: record.input.massProperties,
1694
- constraints: [...(record.input.constraints ?? [])],
1779
+ // Remove staged colliders before restoring mass. Rapier defers some
1780
+ // mass-property recomputation until a collider mutation; restoring
1781
+ // while a disabled candidate is still attached can leave an explicit
1782
+ // override behind for the next fixed step.
1783
+ for (const native of record.nativeColliders) this.removeNativeCollider(native.handle);
1784
+ for (const { collider, density, enabled } of oldDensities) {
1785
+ if (this.raw.getCollider(collider.handle) === null) continue;
1786
+ if (density !== undefined && typeof collider.setDensity === 'function')
1787
+ collider.setDensity(density);
1788
+ if (typeof collider.setEnabled === 'function') collider.setEnabled(enabled);
1789
+ }
1790
+ let restored = true;
1791
+ try {
1792
+ body.setBodyType(oldBodyType, true);
1793
+ this.restoreCommittedMass(body, old, oldAutomaticAdditionalMass);
1794
+ body.setTranslation(oldTranslation, true);
1795
+ body.setRotation(oldRotation, true);
1796
+ body.setLinvel(oldVelocity, true);
1797
+ body.setAngvel(oldAngularVelocity, true);
1798
+ body.setEnabled(oldBodyEnabled);
1799
+ } catch {
1800
+ restored = false;
1801
+ }
1802
+ if (restored && !this.restoreNativeConstraints(oldConstraints)) restored = false;
1803
+ if (restored) {
1804
+ const currentMass = body.mass();
1805
+ const currentCom = body.worldCom();
1806
+ const currentVelocity = body.linvel();
1807
+ const currentAngularVelocity = body.angvel();
1808
+ const currentRotation = body.rotation();
1809
+ restored =
1810
+ Number.isFinite(currentMass) &&
1811
+ Math.abs(currentMass - oldMass) <= 1e-6 * Math.max(1, Math.abs(oldMass)) &&
1812
+ Math.abs(currentCom.x - oldCom.x) <= 1e-6 &&
1813
+ Math.abs(currentCom.y - oldCom.y) <= 1e-6 &&
1814
+ Math.abs(currentCom.z - oldCom.z) <= 1e-6 &&
1815
+ Math.abs(currentVelocity.x - oldVelocity.x) <= 1e-6 &&
1816
+ Math.abs(currentVelocity.y - oldVelocity.y) <= 1e-6 &&
1817
+ Math.abs(currentVelocity.z - oldVelocity.z) <= 1e-6 &&
1818
+ Math.abs(currentAngularVelocity.x - oldAngularVelocity.x) <= 1e-6 &&
1819
+ Math.abs(currentAngularVelocity.y - oldAngularVelocity.y) <= 1e-6 &&
1820
+ Math.abs(currentAngularVelocity.z - oldAngularVelocity.z) <= 1e-6 &&
1821
+ Math.abs(
1822
+ currentRotation.x * oldRotation.x +
1823
+ currentRotation.y * oldRotation.y +
1824
+ currentRotation.z * oldRotation.z +
1825
+ currentRotation.w * oldRotation.w,
1826
+ ) >=
1827
+ 1 - 1e-6 &&
1828
+ body.bodyType() === oldBodyType &&
1829
+ body.isEnabled() === oldBodyEnabled;
1830
+ }
1831
+ if (old === undefined) this.derivedBodies.delete(record.input.entity);
1832
+ else this.derivedBodies.set(record.input.entity, old);
1833
+ if (oldSource === undefined) this.derivedBodySources.delete(record.input.entity);
1834
+ else this.derivedBodySources.set(record.input.entity, oldSource);
1835
+ if (oldPublication === undefined) this.derivedPublications.delete(record.input.entity);
1836
+ else this.derivedPublications.set(record.input.entity, oldPublication);
1837
+ const entityRecord = this.entityMap.get(record.input.entity);
1838
+ if (entityRecord !== undefined && oldAutomaticRecordMass !== undefined)
1839
+ entityRecord.automaticAdditionalMass = oldAutomaticRecordMass;
1840
+ return restored;
1695
1841
  };
1696
- // The sole cross-domain observation boundary: no native operation
1697
- // follows a successful geometry commit before recording this body.
1698
- // A refused commit takes the same complete native rollback below.
1699
- if (record.commitGeometry !== undefined) {
1700
- this.activeDerivedAdmission = record;
1842
+ const restore = (undo: () => boolean): boolean => {
1701
1843
  try {
1702
- geometryCommitUncertain = true;
1703
- const geometry = record.commitGeometry();
1704
- geometryCommitUncertain = false;
1705
- if (!geometry.ok) throw geometry.error;
1706
- } finally {
1707
- this.activeDerivedAdmission = undefined;
1844
+ return undo();
1845
+ } catch {
1846
+ return false;
1708
1847
  }
1709
- delete record.commitGeometry;
1710
- }
1711
- this.derivedBodies.set(record.input.entity, committed);
1712
- const entityRecord = this.entityMap.get(record.input.entity);
1713
- if (entityRecord !== undefined) {
1714
- entityRecord.automaticAdditionalMass =
1715
- record.input.massProperties?.mode === 'explicit' ? 0 : entityRecord.additionalMass;
1716
- }
1717
- this.derivedBodySources.set(record.input.entity, {
1718
- sourceKey: record.input.sourceKey,
1719
- revision: record.input.revision,
1720
- });
1721
- this.derivedFailures.delete(record.input.entity);
1722
- record.state = 'queued';
1723
- this.pendingDerivedCandidates.delete(id);
1724
- } catch (cause) {
1725
- for (const staged of stagedConstraints.values()) this.removeNativeConstraint(staged.handle);
1726
- // The commit path may already have removed/replaced a native joint
1727
- // before a later body mutation fails. Clear every current native joint
1728
- // now; the old records are recreated after the body state is restored
1729
- // instead of leaving a stale JS handle that no longer exists in
1730
- // Rapier.
1731
- for (const current of this.derivedConstraints.values())
1732
- this.removeNativeConstraint(current.handle);
1733
- this.derivedConstraints.clear();
1734
- if (old !== undefined) {
1735
- for (const shape of old.shapes) {
1736
- const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
1737
- if (collider !== null && collider !== undefined) collider.setEnabled(true);
1848
+ };
1849
+ try {
1850
+ for (const constraint of record.input.constraints ?? []) {
1851
+ const created = this.createNativeConstraint(constraint);
1852
+ if (!created.ok) throw created.error;
1853
+ stagedConstraints.set(constraint.id, {
1854
+ input: { ...constraint },
1855
+ handle: created.value.handle,
1856
+ });
1857
+ }
1858
+ if (record.input.bodyType !== undefined) {
1859
+ const RAPIER = this.rapierModule as RapierWorld;
1860
+ const bodyType =
1861
+ record.input.bodyType === 'static'
1862
+ ? RAPIER.RigidBodyType.Fixed
1863
+ : record.input.bodyType === 'kinematic'
1864
+ ? RAPIER.RigidBodyType.KinematicPositionBased
1865
+ : RAPIER.RigidBodyType.Dynamic;
1866
+ body.setBodyType(bodyType, true);
1867
+ }
1868
+ for (const native of record.nativeColliders) {
1869
+ const collider = (this.raw as RapierWorld).getCollider(native.handle);
1870
+ if (collider === null || collider === undefined)
1871
+ throw new Error('candidate collider disappeared');
1872
+ collider.setEnabled(true);
1873
+ }
1874
+ const nativeById = new Map(
1875
+ record.input.shapes.map((shape: VoxelShapeInput, index: number) => [
1876
+ shape.id,
1877
+ record.nativeColliders[index]?.handle as number,
1878
+ ]),
1879
+ );
1880
+ for (const seam of record.input.seams ?? []) {
1881
+ const firstHandle = nativeById.get(seam.shapeA);
1882
+ const secondHandle = nativeById.get(seam.shapeB);
1883
+ const first =
1884
+ firstHandle === undefined
1885
+ ? undefined
1886
+ : (this.raw as RapierWorld).getCollider(firstHandle);
1887
+ const second =
1888
+ secondHandle === undefined
1889
+ ? undefined
1890
+ : (this.raw as RapierWorld).getCollider(secondHandle);
1891
+ if (first === undefined || second === undefined || first === null || second === null) {
1892
+ throw new Error(`derived seam references missing shape ${seam.shapeA}`);
1893
+ }
1894
+ first.combineVoxelStates(second, seam.offset[0], seam.offset[1], seam.offset[2]);
1895
+ }
1896
+ if (record.input.motion?.rotation !== undefined) {
1897
+ const [x, y, z, w] = record.input.motion.rotation;
1898
+ body.setRotation({ x, y, z, w }, true);
1899
+ }
1900
+ this.applyDerivedMass(
1901
+ body,
1902
+ record.input.massProperties,
1903
+ oldCom,
1904
+ record.input.velocityPolicy ?? 'preserve',
1905
+ this.entityMap.get(record.input.entity)?.additionalMass ?? 0,
1906
+ record.input.entity,
1907
+ record.input.shapes,
1908
+ record.nativeColliders,
1909
+ );
1910
+ if (record.input.motion !== undefined) {
1911
+ const currentCom = body.worldCom();
1912
+ const targetCom = record.input.motion.centerOfMass;
1913
+ const translation = body.translation();
1914
+ body.setTranslation(
1915
+ {
1916
+ x: translation.x + targetCom[0] - currentCom.x,
1917
+ y: translation.y + targetCom[1] - currentCom.y,
1918
+ z: translation.z + targetCom[2] - currentCom.z,
1919
+ },
1920
+ true,
1921
+ );
1922
+ body.setLinvel(
1923
+ {
1924
+ x: record.input.motion.linearVelocity[0],
1925
+ y: record.input.motion.linearVelocity[1],
1926
+ z: record.input.motion.linearVelocity[2],
1927
+ },
1928
+ true,
1929
+ );
1930
+ body.setAngvel(
1931
+ {
1932
+ x: record.input.motion.angularVelocity[0],
1933
+ y: record.input.motion.angularVelocity[1],
1934
+ z: record.input.motion.angularVelocity[2],
1935
+ },
1936
+ true,
1937
+ );
1938
+ }
1939
+ // A body revision invalidates any committed joint that still names
1940
+ // that body's previous source/revision. Keep this tied to the body
1941
+ // that is actually committing: a different queued endpoint may still
1942
+ // fail, in which case its old joint remains valid and must not be
1943
+ // removed speculatively.
1944
+ const committedSources = new Map(this.derivedBodySources);
1945
+ committedSources.set(record.input.entity, {
1946
+ sourceKey: record.input.sourceKey,
1947
+ revision: record.input.revision,
1948
+ });
1949
+ const replacementConstraintIds = new Set(
1950
+ (record.input.constraints ?? []).map((constraint) => constraint.id),
1951
+ );
1952
+ for (const [constraintId, current] of [...this.derivedConstraints]) {
1953
+ if (
1954
+ replacementConstraintIds.has(constraintId) ||
1955
+ this.constraintDependenciesMatch(current.input, committedSources)
1956
+ )
1957
+ continue;
1958
+ this.removeNativeConstraint(current.handle);
1959
+ this.derivedConstraints.delete(constraintId);
1960
+ }
1961
+ if (old !== undefined) {
1962
+ for (const shape of old.shapes) {
1963
+ const collider = (this.raw as RapierWorld).getCollider(shape.colliderHandle);
1964
+ if (collider !== null && collider !== undefined) collider.setEnabled(false);
1965
+ }
1966
+ this.retiredDerivedBodies.push(old);
1967
+ }
1968
+ for (const constraint of record.input.constraints ?? []) {
1969
+ const previous = this.derivedConstraints.get(constraint.id);
1970
+ if (previous !== undefined) this.removeNativeConstraint(previous.handle);
1971
+ const staged = stagedConstraints.get(constraint.id);
1972
+ if (staged !== undefined) this.derivedConstraints.set(constraint.id, staged);
1973
+ }
1974
+ const shapes: DerivedShapeRecord[] = record.input.shapes.map(
1975
+ (shape: VoxelShapeInput, index: number) => ({
1976
+ input: shape as DerivedShapeRecord['input'],
1977
+ colliderHandle: record.nativeColliders[index]?.handle as number,
1978
+ }),
1979
+ );
1980
+ const committed: DerivedBodyRecord = {
1981
+ entity: record.input.entity,
1982
+ sourceKey: record.input.sourceKey,
1983
+ generation: this.backendGeneration,
1984
+ revision: record.input.revision,
1985
+ bodyType: record.input.bodyType,
1986
+ velocityPolicy: record.input.velocityPolicy,
1987
+ candidateId: record.token.candidateId,
1988
+ shapes,
1989
+ seams: [...(record.input.seams ?? [])],
1990
+ massProperties: record.input.massProperties,
1991
+ constraints: [...(record.input.constraints ?? [])],
1992
+ };
1993
+ // The sole cross-domain observation boundary: no native operation
1994
+ // follows a successful geometry commit before recording this body.
1995
+ // A refused commit takes the same complete native rollback below.
1996
+ const commitGeometry =
1997
+ record === group[group.length - 1]
1998
+ ? (record.batch?.commitGeometry ?? record.commitGeometry)
1999
+ : undefined;
2000
+ if (commitGeometry !== undefined) {
2001
+ this.activeDerivedAdmission = record;
2002
+ try {
2003
+ geometryCommitUncertain = true;
2004
+ const geometry = commitGeometry();
2005
+ geometryCommitUncertain = false;
2006
+ if (!geometry.ok) throw geometry.error;
2007
+ } finally {
2008
+ this.activeDerivedAdmission = undefined;
2009
+ }
2010
+ delete record.commitGeometry;
2011
+ }
2012
+ this.derivedBodies.set(record.input.entity, committed);
2013
+ const entityRecord = this.entityMap.get(record.input.entity);
2014
+ if (entityRecord !== undefined) {
2015
+ entityRecord.automaticAdditionalMass =
2016
+ record.input.massProperties?.mode === 'explicit' ? 0 : entityRecord.additionalMass;
1738
2017
  }
1739
- const retiredIndex = this.retiredDerivedBodies.indexOf(old);
1740
- if (retiredIndex >= 0) this.retiredDerivedBodies.splice(retiredIndex, 1);
1741
- this.derivedBodies.set(record.input.entity, old);
1742
2018
  this.derivedBodySources.set(record.input.entity, {
1743
- sourceKey: old.sourceKey,
1744
- revision: old.revision,
2019
+ sourceKey: record.input.sourceKey,
2020
+ revision: record.input.revision,
1745
2021
  });
2022
+ this.derivedFailures.delete(record.input.entity);
2023
+ record.state = 'queued';
2024
+ this.pendingDerivedCandidates.delete(id);
2025
+ undos.push(rollback);
2026
+ } catch (cause) {
2027
+ let restored = restore(rollback);
2028
+ for (const undo of undos.reverse()) {
2029
+ if (!restore(undo)) restored = false;
2030
+ }
2031
+ const error =
2032
+ cause instanceof DerivedPhysicsError
2033
+ ? cause
2034
+ : new DerivedPhysicsError(
2035
+ 'derived-backend-failed',
2036
+ 'derived admission either commits completely or preserves the prior body state',
2037
+ 'inspect the failure receipt and rebuild the PhysicsWorld if recovery is required',
2038
+ {
2039
+ entity: record.input.entity,
2040
+ candidateId: record.token.candidateId,
2041
+ reason: cause instanceof Error ? cause.message : String(cause),
2042
+ },
2043
+ );
2044
+ // A thrown consumer callback can have changed ECS state. Native
2045
+ // rollback alone cannot certify any member of that combined group.
2046
+ if (geometryCommitUncertain) restored = false;
2047
+ for (const member of group) {
2048
+ if (!restored) {
2049
+ this.derivedPoisonedEntities.add(member.input.entity);
2050
+ this.derivedBodies.delete(member.input.entity);
2051
+ this.derivedPublications.delete(member.input.entity);
2052
+ }
2053
+ this.rememberDerivedFailure(
2054
+ member,
2055
+ error,
2056
+ restored ? 'old-state-retained' : 'rebuild-required',
2057
+ );
2058
+ }
2059
+ break;
1746
2060
  }
1747
- for (const native of record.nativeColliders) {
1748
- const collider = (this.raw as RapierWorld).getCollider(native.handle);
1749
- if (collider !== null && collider !== undefined) collider.setEnabled(false);
1750
- }
1751
- // Remove staged colliders before restoring mass. Rapier defers some
1752
- // mass-property recomputation until a collider mutation; restoring
1753
- // while a disabled candidate is still attached can leave an explicit
1754
- // override behind for the next fixed step.
1755
- for (const native of record.nativeColliders) this.removeNativeCollider(native.handle);
1756
- for (const { collider, density, enabled } of oldDensities) {
1757
- if (this.raw.getCollider(collider.handle) === null) continue;
1758
- if (density !== undefined && typeof collider.setDensity === 'function')
1759
- collider.setDensity(density);
1760
- if (typeof collider.setEnabled === 'function') collider.setEnabled(enabled);
1761
- }
1762
- let restored = true;
1763
- try {
1764
- body.setBodyType(oldBodyType, true);
1765
- this.restoreCommittedMass(body, old, oldAutomaticAdditionalMass);
1766
- body.setTranslation(oldTranslation, true);
1767
- body.setRotation(oldRotation, true);
1768
- body.setLinvel(oldVelocity, true);
1769
- body.setAngvel(oldAngularVelocity, true);
1770
- body.setEnabled(oldBodyEnabled);
1771
- } catch {
1772
- restored = false;
1773
- }
1774
- if (restored && !this.restoreNativeConstraints(oldConstraints)) restored = false;
1775
- if (restored) {
1776
- const currentMass = body.mass();
1777
- const currentCom = body.worldCom();
1778
- const currentVelocity = body.linvel();
1779
- const currentAngularVelocity = body.angvel();
1780
- restored =
1781
- Number.isFinite(currentMass) &&
1782
- Math.abs(currentMass - oldMass) <= 1e-6 * Math.max(1, Math.abs(oldMass)) &&
1783
- Math.abs(currentCom.x - oldCom.x) <= 1e-6 &&
1784
- Math.abs(currentCom.y - oldCom.y) <= 1e-6 &&
1785
- Math.abs(currentCom.z - oldCom.z) <= 1e-6 &&
1786
- Math.abs(currentVelocity.x - oldVelocity.x) <= 1e-6 &&
1787
- Math.abs(currentVelocity.y - oldVelocity.y) <= 1e-6 &&
1788
- Math.abs(currentVelocity.z - oldVelocity.z) <= 1e-6 &&
1789
- Math.abs(currentAngularVelocity.x - oldAngularVelocity.x) <= 1e-6 &&
1790
- Math.abs(currentAngularVelocity.y - oldAngularVelocity.y) <= 1e-6 &&
1791
- Math.abs(currentAngularVelocity.z - oldAngularVelocity.z) <= 1e-6 &&
1792
- body.bodyType() === oldBodyType &&
1793
- body.isEnabled() === oldBodyEnabled;
1794
- }
1795
- if (old === undefined) this.derivedBodies.delete(record.input.entity);
1796
- else this.derivedBodies.set(record.input.entity, old);
1797
- if (oldSource === undefined) this.derivedBodySources.delete(record.input.entity);
1798
- else this.derivedBodySources.set(record.input.entity, oldSource);
1799
- if (oldPublication === undefined) this.derivedPublications.delete(record.input.entity);
1800
- else this.derivedPublications.set(record.input.entity, oldPublication);
1801
- const entityRecord = this.entityMap.get(record.input.entity);
1802
- if (entityRecord !== undefined && oldAutomaticRecordMass !== undefined)
1803
- entityRecord.automaticAdditionalMass = oldAutomaticRecordMass;
1804
- const error =
1805
- cause instanceof DerivedPhysicsError
1806
- ? cause
1807
- : new DerivedPhysicsError(
1808
- 'derived-backend-failed',
1809
- 'derived admission either commits completely or preserves the prior body state',
1810
- 'inspect the failure receipt and rebuild the PhysicsWorld if recovery is required',
1811
- {
1812
- entity: record.input.entity,
1813
- candidateId: record.token.candidateId,
1814
- reason: cause instanceof Error ? cause.message : String(cause),
1815
- },
1816
- );
1817
- // A thrown consumer callback may already have changed its ECS domain.
1818
- // Native rollback alone cannot certify the combined state in that case.
1819
- if (geometryCommitUncertain) restored = false;
1820
- if (!restored) {
1821
- this.derivedPoisonedEntities.add(record.input.entity);
1822
- this.derivedBodies.delete(record.input.entity);
1823
- this.derivedPublications.delete(record.input.entity);
1824
- }
1825
- this.rememberDerivedFailure(
1826
- record,
1827
- error,
1828
- restored ? 'old-state-retained' : 'rebuild-required',
1829
- );
1830
2061
  }
1831
2062
  }
1832
2063
  }
@@ -1847,6 +2078,9 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1847
2078
  }),
1848
2079
  );
1849
2080
  candidate.state = 'published';
2081
+ // Admission membership is transient. A long-lived sibling must not keep
2082
+ // a replaced member's private shape buffers or callback closure alive.
2083
+ delete candidate.batch;
1850
2084
  }
1851
2085
  }
1852
2086
 
@@ -1893,6 +2127,9 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1893
2127
  { x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
1894
2128
  true,
1895
2129
  );
2130
+ // Rapier defers additional mass updates. Admission reads worldCom below
2131
+ // before stepping, so refresh now or COM restoration shifts the body twice.
2132
+ body.recomputeMassPropertiesFromColliders();
1896
2133
  } else {
1897
2134
  this.restoreAutomaticDensities(
1898
2135
  entity,
@@ -1942,6 +2179,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1942
2179
  { x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
1943
2180
  true,
1944
2181
  );
2182
+ body.recomputeMassPropertiesFromColliders();
1945
2183
  return;
1946
2184
  }
1947
2185
  this.restoreAutomaticMass(body, automaticAdditionalMass);
@@ -2958,6 +3196,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
2958
3196
  );
2959
3197
  if (collider === undefined) {
2960
3198
  const record = this.entityMap.get(entity);
3199
+ // A new fragment can admit derived shapes in this same fixed step.
3200
+ // Resolve Rapier's deferred descriptor mass before rollback captures the
3201
+ // baseline, rather than comparing an uninitialized zero to restored mass.
3202
+ this.restoreAutomaticMass(body, record?.additionalMass ?? 0);
2961
3203
  if (record !== undefined) record.automaticAdditionalMass = record.additionalMass;
2962
3204
  return;
2963
3205
  }
@@ -3190,6 +3432,23 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
3190
3432
  removeEntity(entity: number): void {
3191
3433
  const record = this.entityMap.get(entity);
3192
3434
  if (!record) return;
3435
+ for (const candidate of this.derivedCandidates.values()) {
3436
+ if (
3437
+ candidate.input.entity !== entity ||
3438
+ candidate.batch === undefined ||
3439
+ (candidate.state !== 'ready' && candidate.state !== 'queued')
3440
+ )
3441
+ continue;
3442
+ this.rejectPreparedCandidate(
3443
+ candidate,
3444
+ new DerivedPhysicsError(
3445
+ 'derived-body-not-found',
3446
+ 'all grouped bodies remain live until admission',
3447
+ 'prepare a new complete group after entity reconciliation',
3448
+ { entity },
3449
+ ),
3450
+ );
3451
+ }
3193
3452
  const derived = this.derivedBodies.get(entity);
3194
3453
  if (derived !== undefined) {
3195
3454
  for (const shape of derived.shapes) this.removeNativeCollider(shape.colliderHandle);