@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.
@@ -22,21 +22,44 @@ import { defineSystem } from '@forgeax/engine-ecs';
22
22
  import { componentId } from '@forgeax/engine-ecs/internal';
23
23
  import { readStructuralEvidence } from '@forgeax/engine-ecs/projection';
24
24
  import { mat4, quat, type Vec3, vec3 } from '@forgeax/engine-math';
25
- import type { PhysicsWorld, RaycastHit } from '@forgeax/engine-physics';
26
25
  import {
27
26
  CharacterController,
28
27
  Collider,
29
28
  CollidingEntities,
29
+ cloneDerivedPhysicsInput,
30
30
  colliderShapeFromF32,
31
+ DERIVED_PHYSICS_LIMITS,
32
+ type DerivedPhysicsCandidate,
33
+ type DerivedPhysicsCandidateInput,
34
+ type DerivedPhysicsCandidateState,
35
+ DerivedPhysicsError,
36
+ type DerivedPhysicsFailure,
37
+ type DerivedPhysicsMotion,
38
+ type DerivedPhysicsPublication,
39
+ type DerivedPhysicsSnapshot,
40
+ type DerivedShapeSeamInput,
41
+ type DerivedShapeState,
42
+ estimateDerivedPhysicsInputBytes,
31
43
  PHYSICS_ERROR_HINTS,
44
+ type PhysicsConstraintInput,
45
+ type PhysicsContactObservation,
32
46
  PhysicsError,
47
+ type PhysicsMassProperties,
48
+ type PhysicsQuaternion,
33
49
  PhysicsSet,
50
+ type PhysicsVector,
51
+ type PhysicsWorld,
52
+ preserveCenterOfMassVelocity,
53
+ type RaycastHit,
34
54
  RIGID_BODY_TYPE_STATIC,
35
55
  RigidBody,
36
56
  registerPhysicsComponents,
37
57
  rigidBodyTypeFromF32,
58
+ type VoxelShapeInput,
59
+ validateMassProperties,
38
60
  } from '@forgeax/engine-physics';
39
61
  import { ChildOf } from '@forgeax/engine-scene';
62
+ import { err, ok, type Result } from '@forgeax/engine-types';
40
63
  import type { Rapier3DModule } from './wasm-loader';
41
64
 
42
65
  interface Rapier3DKinematicControllerState {
@@ -50,6 +73,12 @@ interface Rapier3DKinematicControllerState {
50
73
  */
51
74
  interface PhysicsEntityRecord {
52
75
  bodyHandle: number;
76
+ /** Authored additional mass used to restore automatic derived policy. */
77
+ additionalMass: number;
78
+ /** Additional mass currently applied to Rapier's automatic mass policy. */
79
+ automaticAdditionalMass: number;
80
+ /** Base collider density before an explicit derived mass override. */
81
+ authoredDensity: number | undefined;
53
82
  }
54
83
 
55
84
  interface PhysicsTransform3D {
@@ -90,7 +119,7 @@ interface PhysicsSyncState {
90
119
  readonly world: World;
91
120
  readonly transformComponent: Component;
92
121
  readonly globalTransformComponent: Component;
93
- readonly query: PhysicsSyncQuery;
122
+ readonly queries: readonly PhysicsSyncQuery[];
94
123
  readonly changeQueries: readonly PhysicsChangeQuery[];
95
124
  structuralCursor: number;
96
125
  structureEpoch: number;
@@ -113,7 +142,7 @@ interface PhysicsSyncDescriptor {
113
142
  readonly gravityScale: number;
114
143
  readonly ccdEnabled: number;
115
144
  };
116
- readonly collider: PhysicsCollider3D;
145
+ readonly collider: PhysicsCollider3D | undefined;
117
146
  readonly hasCharacterController: boolean;
118
147
  readonly characterControllerOffset: number | undefined;
119
148
  }
@@ -167,6 +196,51 @@ export interface Rapier3DCollisionEvent {
167
196
  readonly type: 'started' | 'stopped';
168
197
  readonly entityA: number;
169
198
  readonly entityB: number;
199
+ readonly fixedStep?: number;
200
+ readonly shapeA?: string;
201
+ readonly shapeB?: string;
202
+ }
203
+
204
+ interface DerivedShapeRecord {
205
+ readonly input: VoxelShapeInput & {
206
+ readonly cells: Int32Array;
207
+ readonly origin: PhysicsVector;
208
+ readonly rotation: PhysicsQuaternion;
209
+ };
210
+ readonly colliderHandle: number;
211
+ }
212
+
213
+ interface DerivedBodyRecord {
214
+ readonly entity: number;
215
+ readonly sourceKey: string;
216
+ readonly generation: number;
217
+ readonly revision: number;
218
+ readonly bodyType: 'static' | 'dynamic' | 'kinematic' | undefined;
219
+ readonly velocityPolicy: 'preserve' | 'reset' | undefined;
220
+ readonly candidateId: string;
221
+ readonly shapes: readonly DerivedShapeRecord[];
222
+ readonly seams: readonly DerivedShapeSeamInput[];
223
+ readonly massProperties: PhysicsMassProperties | undefined;
224
+ readonly constraints: readonly PhysicsConstraintInput[];
225
+ }
226
+
227
+ interface DerivedCandidateRecord {
228
+ token: DerivedPhysicsCandidate;
229
+ readonly nativeColliders: readonly { readonly handle: number }[];
230
+ readonly input: DerivedPhysicsCandidateInput;
231
+ readonly bytes: number;
232
+ state: DerivedPhysicsCandidateState;
233
+ commitGeometry?: () => Result<void, Error>;
234
+ }
235
+
236
+ interface DerivedConstraintRecord {
237
+ readonly input: PhysicsConstraintInput;
238
+ readonly handle: number;
239
+ }
240
+
241
+ interface DerivedBodySource {
242
+ readonly sourceKey: string;
243
+ readonly revision: number;
170
244
  }
171
245
 
172
246
  // biome-ignore lint/suspicious/noExplicitAny: Rapier types from dynamically loaded module
@@ -215,13 +289,78 @@ function applyKccTuning(ctrl: any, cc: CharacterControllerTuning): void {
215
289
  * Map a Rapier RigidBodyType enum value to the engine's string union for the
216
290
  * `controller-requires-kinematic` error detail.
217
291
  */
218
- // biome-ignore lint/suspicious/noExplicitAny: Rapier module enum from dynamic module
219
- function rapierBodyTypeToString(rapier: any, bodyType: number): string {
292
+ function rapierBodyTypeToString(
293
+ rapier: { readonly RigidBodyType: { readonly Dynamic: number; readonly Fixed: number } },
294
+ bodyType: number,
295
+ ): 'static' | 'dynamic' | 'kinematic' {
220
296
  if (bodyType === rapier.RigidBodyType.Dynamic) return 'dynamic';
221
297
  if (bodyType === rapier.RigidBodyType.Fixed) return 'static';
222
298
  return 'kinematic';
223
299
  }
224
300
 
301
+ function validateConstraintInput(
302
+ input: PhysicsConstraintInput,
303
+ ): Result<PhysicsConstraintInput, DerivedPhysicsError> {
304
+ const finiteVector = (value: readonly number[]): boolean =>
305
+ value.length === 3 && value.every(Number.isFinite);
306
+ if (
307
+ typeof input.id !== 'string' ||
308
+ input.id.trim().length === 0 ||
309
+ !Number.isInteger(input.revision) ||
310
+ input.revision < 0 ||
311
+ !Number.isInteger(input.bodyA) ||
312
+ !Number.isInteger(input.bodyB) ||
313
+ input.bodyA === input.bodyB ||
314
+ !finiteVector(input.anchorA) ||
315
+ !finiteVector(input.anchorB)
316
+ ) {
317
+ return err(
318
+ new DerivedPhysicsError(
319
+ 'derived-constraint-invalid',
320
+ 'constraint identity, revision, endpoint bodies, and anchors are valid',
321
+ 'supply two distinct live bodies and finite local anchors',
322
+ { constraintId: input.id },
323
+ ),
324
+ );
325
+ }
326
+ if (input.kind === 'spring') {
327
+ if (
328
+ !Number.isFinite(input.restLength) ||
329
+ input.restLength < 0 ||
330
+ !Number.isFinite(input.stiffness) ||
331
+ input.stiffness < 0 ||
332
+ !Number.isFinite(input.damping) ||
333
+ input.damping < 0
334
+ ) {
335
+ return err(
336
+ new DerivedPhysicsError(
337
+ 'derived-constraint-invalid',
338
+ 'spring rest length, stiffness, and damping are finite and non-negative',
339
+ 'repair spring tuning before native creation',
340
+ { constraintId: input.id },
341
+ ),
342
+ );
343
+ }
344
+ } else if (
345
+ !finiteVector(input.axis) ||
346
+ Math.hypot(input.axis[0], input.axis[1], input.axis[2]) < 1e-6 ||
347
+ (input.limits !== undefined &&
348
+ (!Number.isFinite(input.limits[0]) ||
349
+ !Number.isFinite(input.limits[1]) ||
350
+ input.limits[0] > input.limits[1]))
351
+ ) {
352
+ return err(
353
+ new DerivedPhysicsError(
354
+ 'derived-constraint-invalid',
355
+ 'hinge axis is non-zero and optional limits are ordered finite values',
356
+ 'normalize the hinge axis and set minLimit <= maxLimit',
357
+ { constraintId: input.id },
358
+ ),
359
+ );
360
+ }
361
+ return ok(input);
362
+ }
363
+
225
364
  /**
226
365
  * RapierPhysicsWorld3D — Rapier 3D WASM backend implementing the PhysicsWorld
227
366
  * interface.
@@ -254,6 +393,27 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
254
393
 
255
394
  private readonly collisionEventHistory: Rapier3DCollisionEvent[] = [];
256
395
 
396
+ /** One backend owns every derived shape; these maps are not a second world. */
397
+ private readonly derivedBodies = new Map<number, DerivedBodyRecord>();
398
+ private readonly derivedCandidates = new Map<string, DerivedCandidateRecord>();
399
+ private readonly pendingDerivedCandidates = new Set<string>();
400
+ private readonly retiredDerivedBodies: DerivedBodyRecord[] = [];
401
+ private readonly derivedColliderToShape = new Map<number, { entity: number; id: string }>();
402
+ private readonly derivedConstraints = new Map<string, DerivedConstraintRecord>();
403
+ private readonly derivedBodySources = new Map<number, DerivedBodySource>();
404
+ private readonly derivedPublications = new Map<number, DerivedPhysicsPublication>();
405
+ private readonly derivedFailures = new Map<number, DerivedPhysicsFailure>();
406
+ private readonly derivedContacts: PhysicsContactObservation[] = [];
407
+ private readonly derivedPoisonedEntities = new Set<number>();
408
+ private readonly physicsOwner = {};
409
+ private candidateSequence = 0;
410
+ private derivedCandidateBytes = 0;
411
+ private backendGeneration = 1;
412
+ private fixedStep = 0;
413
+ private derivedPublicationPending = false;
414
+ private worldIdentity: object | undefined;
415
+ private activeDerivedAdmission: DerivedCandidateRecord | undefined;
416
+
257
417
  private currentGravity: { x: number; y: number; z: number };
258
418
 
259
419
  /**
@@ -315,6 +475,14 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
315
475
  filterMask?: number,
316
476
  ): RaycastHit | undefined {
317
477
  this.assertActive('raycast');
478
+ if (this.recoveryBlocked()) {
479
+ throw new DerivedPhysicsError(
480
+ 'derived-recovery-invalid',
481
+ 'raycasts observe a complete healthy physics state',
482
+ 'rebuild the PhysicsWorld before querying after an unrecoverable admission',
483
+ {},
484
+ );
485
+ }
318
486
  const RAPIER = this.rapierModule;
319
487
  // biome-ignore lint/suspicious/noExplicitAny: Rapier Ray constructor comes from a namespace module
320
488
  const RayCtor = (RAPIER as any).Ray as new (
@@ -369,9 +537,46 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
369
537
  step(deltaTime: number): void {
370
538
  this.assertActive('step');
371
539
  void deltaTime;
372
- // biome-ignore lint/suspicious/noExplicitAny: Rapier World.step
373
- (this.raw as any).step(this.eventQueue);
374
- this.drainRapierCollisionEvents();
540
+ try {
541
+ this.processDerivedCandidates();
542
+ // A failed native rollback is an explicit rebuild boundary. Physics does
543
+ // not advance or publish a mixed state after this point.
544
+ if (this.derivedPoisonedEntities.size > 0) return;
545
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier World.step
546
+ (this.raw as any).step(this.eventQueue);
547
+ this.fixedStep = this.syncState?.world.getResource(FixedTime).tick ?? this.fixedStep + 1;
548
+ this.drainRapierCollisionEvents();
549
+ this.derivedPublicationPending = true;
550
+ // Direct PhysicsWorld consumers do not have ECS writeback systems. Keep
551
+ // that public path useful while ECS-bound worlds finalize after writeback
552
+ // and collision component synchronization below.
553
+ if (this.syncState === undefined) this.finalizeDerivedFixedStep();
554
+ } catch (cause) {
555
+ // A native step or contact drain may have advanced before throwing.
556
+ // Geometry may already be committed; neither domain can claim LKG.
557
+ const error = new DerivedPhysicsError(
558
+ 'derived-backend-failed',
559
+ 'native fixed-step execution and publication complete together',
560
+ 'rebuild the World and PhysicsWorld from the last committed snapshot',
561
+ { reason: cause instanceof Error ? cause.message : String(cause) },
562
+ );
563
+ for (const entity of this.entityMap.keys()) this.derivedPoisonedEntities.add(entity);
564
+ this.derivedPublicationPending = false;
565
+ this.derivedPublications.clear();
566
+ for (const record of this.derivedCandidates.values()) {
567
+ this.rememberDerivedFailure(record, error, 'rebuild-required');
568
+ }
569
+ throw error;
570
+ }
571
+ }
572
+
573
+ /** Publish only after the fixed-step ECS writeback/contact boundary. */
574
+ finalizeDerivedFixedStep(): void {
575
+ if (!this.derivedPublicationPending) return;
576
+ this.derivedPublicationPending = false;
577
+ if (this.derivedPoisonedEntities.size > 0) return;
578
+ this.publishDerivedCandidates();
579
+ this.retireDerivedBodies();
375
580
  }
376
581
 
377
582
  /**
@@ -386,16 +591,75 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
386
591
  const a = this.colliderHandleToEntity(handle1);
387
592
  const b = this.colliderHandleToEntity(handle2);
388
593
  if (a === undefined || b === undefined) return;
594
+ const shapeA = this.derivedColliderToShape.get(handle1);
595
+ const shapeB = this.derivedColliderToShape.get(handle2);
596
+ this.recordContactObservation(
597
+ {
598
+ phase: started ? 'started' : 'stopped',
599
+ fixedStep: this.fixedStep,
600
+ entityA: a,
601
+ entityB: b,
602
+ ...(shapeA === undefined ? {} : { shapeA: shapeA.id }),
603
+ ...(shapeB === undefined ? {} : { shapeB: shapeB.id }),
604
+ },
605
+ handle1,
606
+ handle2,
607
+ );
389
608
  const changed = started ? this.addPair(a, b) : this.removePair(a, b);
390
609
  if (!changed) return;
391
610
  this.pushCollisionEvent({
392
611
  type: started ? 'started' : 'stopped',
393
612
  entityA: a,
394
613
  entityB: b,
614
+ fixedStep: this.fixedStep,
615
+ ...(shapeA === undefined ? {} : { shapeA: shapeA.id }),
616
+ ...(shapeB === undefined ? {} : { shapeB: shapeB.id }),
395
617
  });
396
618
  });
397
619
  }
398
620
 
621
+ private recordContactObservation(
622
+ observation: PhysicsContactObservation,
623
+ handleA: number,
624
+ handleB: number,
625
+ ): void {
626
+ let point: PhysicsVector | undefined;
627
+ let normal: PhysicsVector | undefined;
628
+ try {
629
+ const colliderA = (this.raw as RapierWorld).getCollider(handleA);
630
+ const colliderB = (this.raw as RapierWorld).getCollider(handleB);
631
+ if (colliderA !== null && colliderB !== null) {
632
+ (this.raw as RapierWorld).contactPair(
633
+ colliderA,
634
+ colliderB,
635
+ (manifold: RapierWorld, flipped: boolean) => {
636
+ if (manifold.numSolverContacts?.() > 0) {
637
+ const contact = manifold.solverContactPoint(0);
638
+ const n = manifold.normal();
639
+ if (contact !== null && contact !== undefined) {
640
+ point = [contact.x, contact.y, contact.z];
641
+ }
642
+ if (n !== null && n !== undefined) {
643
+ const direction = flipped ? -1 : 1;
644
+ normal = [n.x * direction, n.y * direction, n.z * direction];
645
+ }
646
+ }
647
+ },
648
+ );
649
+ }
650
+ } catch {
651
+ // Contact events remain useful without optional manifold sampling. The
652
+ // public type makes point/normal optional rather than inventing values.
653
+ }
654
+ this.derivedContacts.push({
655
+ ...observation,
656
+ ...(point === undefined ? {} : { point }),
657
+ ...(normal === undefined ? {} : { normal }),
658
+ });
659
+ if (this.derivedContacts.length > 256)
660
+ this.derivedContacts.splice(0, this.derivedContacts.length - 256);
661
+ }
662
+
399
663
  /** Resolve a Rapier collider handle to its owning ECS entity, or undefined. */
400
664
  private colliderHandleToEntity(colliderHandle: number): number | undefined {
401
665
  // getCollider(handle).parent() returns the owning RigidBody (compat build),
@@ -437,7 +701,14 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
437
701
  const ordered =
438
702
  event.entityA <= event.entityB
439
703
  ? event
440
- : { ...event, entityA: event.entityB, entityB: event.entityA };
704
+ : {
705
+ type: event.type,
706
+ entityA: event.entityB,
707
+ entityB: event.entityA,
708
+ ...(event.fixedStep === undefined ? {} : { fixedStep: event.fixedStep }),
709
+ ...(event.shapeB === undefined ? {} : { shapeA: event.shapeB }),
710
+ ...(event.shapeA === undefined ? {} : { shapeB: event.shapeA }),
711
+ };
441
712
  this.pendingCollisionEvents.push(ordered);
442
713
  this.collisionEventHistory.push(ordered);
443
714
  }
@@ -469,6 +740,1521 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
469
740
  return [...this.collisionEventHistory];
470
741
  }
471
742
 
743
+ /** Detached fixed-step contact facts; no Rapier manifolds or handles escape. */
744
+ getContactObservations(): readonly PhysicsContactObservation[] {
745
+ if (this.recoveryBlocked()) return [];
746
+ return this.derivedContacts.map((contact) => ({
747
+ ...contact,
748
+ ...(contact.point === undefined ? {} : { point: [...contact.point] as PhysicsVector }),
749
+ ...(contact.normal === undefined ? {} : { normal: [...contact.normal] as PhysicsVector }),
750
+ }));
751
+ }
752
+
753
+ /** Prepare disabled native Voxels for one entity without changing queries. */
754
+ prepareDerivedShapeCandidate(
755
+ input: DerivedPhysicsCandidateInput,
756
+ ): ReturnType<NonNullable<PhysicsWorld['prepareDerivedShapeCandidate']>> {
757
+ this.assertActive('prepareDerivedShapeCandidate');
758
+ if (input.worldIdentity !== undefined && input.worldIdentity !== this.worldIdentity) {
759
+ return err(
760
+ new DerivedPhysicsError(
761
+ 'derived-world-mismatch',
762
+ 'candidate belongs to the ECS World bound to this PhysicsWorld',
763
+ 'submit the candidate to the PhysicsWorld that owns its entity',
764
+ { entity: input.entity },
765
+ ),
766
+ );
767
+ }
768
+ if (!this.entityMap.has(input.entity)) {
769
+ return err(
770
+ new DerivedPhysicsError(
771
+ 'derived-body-not-found',
772
+ 'candidate entity has a committed body in this PhysicsWorld',
773
+ 'wait for physics reconciliation before preparing derived shapes',
774
+ { entity: input.entity },
775
+ ),
776
+ );
777
+ }
778
+ if (this.derivedPoisonedEntities.has(input.entity)) {
779
+ return err(
780
+ new DerivedPhysicsError(
781
+ 'derived-recovery-invalid',
782
+ 'the entity has a recoverable committed native PhysicsWorld state',
783
+ 'rebuild the PhysicsWorld from the last portable snapshot before retrying',
784
+ { entity: input.entity },
785
+ ),
786
+ );
787
+ }
788
+ const active = this.derivedBodies.get(input.entity);
789
+ if (active !== undefined && input.revision <= active.revision) {
790
+ return err(
791
+ new DerivedPhysicsError(
792
+ 'derived-candidate-stale',
793
+ 'candidate revision is newer than the committed derived shape revision',
794
+ 'read the latest publication and advance the consumer revision',
795
+ { entity: input.entity, actual: input.revision, expected: `>${active.revision}` },
796
+ ),
797
+ );
798
+ }
799
+ for (const pendingId of this.pendingDerivedCandidates) {
800
+ const pending = this.derivedCandidates.get(pendingId);
801
+ if (pending?.input.entity === input.entity) {
802
+ return err(
803
+ new DerivedPhysicsError(
804
+ 'derived-candidate-pending',
805
+ 'one body has at most one queued derived-shape candidate',
806
+ 'cancel or let the current candidate publish before preparing another',
807
+ { entity: input.entity, candidateId: pendingId },
808
+ ),
809
+ );
810
+ }
811
+ }
812
+ const copied = cloneDerivedPhysicsInput(input);
813
+ if (!copied.ok) return copied;
814
+ const mass = validateMassProperties(copied.value.massProperties);
815
+ if (!mass.ok) return mass;
816
+ const body = this.bodyForEntity(input.entity);
817
+ if (body === undefined) {
818
+ return err(
819
+ new DerivedPhysicsError(
820
+ 'derived-body-not-found',
821
+ 'candidate entity resolves to a live native body',
822
+ 'wait for the next ECS physics sync',
823
+ { entity: input.entity },
824
+ ),
825
+ );
826
+ }
827
+ if (this.derivedCandidates.size >= DERIVED_PHYSICS_LIMITS.maxCandidates) {
828
+ return err(
829
+ new DerivedPhysicsError(
830
+ 'derived-candidate-budget-exceeded',
831
+ `this PhysicsWorld keeps at most ${DERIVED_PHYSICS_LIMITS.maxCandidates} candidates`,
832
+ 'cancel or publish an existing candidate before preparing another',
833
+ { entity: input.entity, actual: this.derivedCandidates.size },
834
+ ),
835
+ );
836
+ }
837
+ const candidateInput = Object.freeze({
838
+ ...copied.value,
839
+ ...(mass.value === undefined ? {} : { massProperties: mass.value }),
840
+ });
841
+ // Keep the public token's copied POD separate from the private record. A
842
+ // readonly typed-array field is still writable at runtime; admission must
843
+ // never consume caller mutations made after prepare() returned.
844
+ const privateInput = cloneDerivedPhysicsInput(candidateInput);
845
+ if (!privateInput.ok) return privateInput;
846
+ const publicInput = cloneDerivedPhysicsInput(privateInput.value);
847
+ if (!publicInput.ok) return publicInput;
848
+ const nativeColliders: { handle: number }[] = [];
849
+ try {
850
+ for (const shape of privateInput.value.shapes) {
851
+ const collider = this.createDerivedCollider(body, shape);
852
+ nativeColliders.push({ handle: collider.handle });
853
+ }
854
+ } catch (cause) {
855
+ for (const collider of nativeColliders) this.removeNativeCollider(collider.handle);
856
+ return err(
857
+ new DerivedPhysicsError(
858
+ 'derived-backend-failed',
859
+ 'Rapier can create every candidate voxel collider while it remains disabled',
860
+ 'reduce the candidate or rebuild the PhysicsWorld after a native failure',
861
+ { entity: input.entity, reason: cause instanceof Error ? cause.message : String(cause) },
862
+ ),
863
+ );
864
+ }
865
+ const candidateBytes = estimateDerivedPhysicsInputBytes(privateInput.value);
866
+ if (this.derivedCandidateBytes + candidateBytes > DERIVED_PHYSICS_LIMITS.maxCandidateBytes) {
867
+ for (const collider of nativeColliders) this.removeNativeCollider(collider.handle);
868
+ return err(
869
+ new DerivedPhysicsError(
870
+ 'derived-candidate-budget-exceeded',
871
+ `staged candidate bytes remain within ${DERIVED_PHYSICS_LIMITS.maxCandidateBytes}`,
872
+ 'cancel or retire an in-flight candidate before retrying',
873
+ { entity: input.entity, actual: this.derivedCandidateBytes + candidateBytes },
874
+ ),
875
+ );
876
+ }
877
+ const candidateId = `derived:${this.backendGeneration}:${input.entity}:${++this.candidateSequence}:${input.revision}`;
878
+ const token: DerivedPhysicsCandidate = Object.freeze({
879
+ candidateId,
880
+ generation: this.backendGeneration,
881
+ owner: this.physicsOwner,
882
+ input: publicInput.value,
883
+ state: 'ready',
884
+ });
885
+ this.derivedCandidates.set(candidateId, {
886
+ token,
887
+ nativeColliders,
888
+ input: privateInput.value,
889
+ bytes: candidateBytes,
890
+ state: 'ready',
891
+ });
892
+ this.derivedCandidateBytes += candidateBytes;
893
+ return ok(token);
894
+ }
895
+
896
+ /** Queue a prepared candidate for the next call to `step()`. */
897
+ admitDerivedShapeCandidate(
898
+ candidate: DerivedPhysicsCandidate,
899
+ commitGeometry?: () => Result<void, Error>,
900
+ ): ReturnType<NonNullable<PhysicsWorld['admitDerivedShapeCandidate']>> {
901
+ const admitted = this.admitDerivedShapeCandidateInternal(candidate);
902
+ if (admitted.ok && commitGeometry !== undefined) {
903
+ const record = this.derivedCandidates.get(candidate.candidateId);
904
+ if (record !== undefined) record.commitGeometry = commitGeometry;
905
+ }
906
+ return admitted;
907
+ }
908
+
909
+ getDerivedAdmission(
910
+ entity?: number,
911
+ ):
912
+ | { readonly entity: number; readonly revision: number; readonly fixedStep: number }
913
+ | undefined {
914
+ const record = this.activeDerivedAdmission;
915
+ if (record === undefined || (entity !== undefined && record.input.entity !== entity))
916
+ return undefined;
917
+ return {
918
+ entity: record.input.entity,
919
+ revision: record.input.revision,
920
+ fixedStep: this.syncState?.world.getResource(FixedTime).tick ?? this.fixedStep + 1,
921
+ };
922
+ }
923
+
924
+ private admitDerivedShapeCandidateInternal(
925
+ candidate: DerivedPhysicsCandidate,
926
+ sourceOverrides?: ReadonlyMap<number, DerivedBodySource>,
927
+ ): ReturnType<NonNullable<PhysicsWorld['admitDerivedShapeCandidate']>> {
928
+ this.assertActive('admitDerivedShapeCandidate');
929
+ const record = this.derivedCandidates.get(candidate.candidateId);
930
+ if (
931
+ record === undefined ||
932
+ candidate.owner !== this.physicsOwner ||
933
+ record.token.owner !== candidate.owner ||
934
+ candidate.generation !== this.backendGeneration
935
+ ) {
936
+ return err(
937
+ new DerivedPhysicsError(
938
+ 'derived-candidate-not-found',
939
+ 'candidate belongs to the current PhysicsWorld generation',
940
+ 'discard stale candidate credentials and prepare from committed input again',
941
+ { candidateId: candidate.candidateId },
942
+ ),
943
+ );
944
+ }
945
+ if (this.derivedPoisonedEntities.has(record?.input.entity ?? -1)) {
946
+ return err(
947
+ new DerivedPhysicsError(
948
+ 'derived-recovery-invalid',
949
+ 'the candidate entity is stopped after an unrecoverable native admission failure',
950
+ 'rebuild the PhysicsWorld from its portable snapshot before retrying',
951
+ { entity: record?.input.entity, candidateId: candidate.candidateId },
952
+ ),
953
+ );
954
+ }
955
+ if (record.state === 'cancelled' || record.state === 'invalidated') {
956
+ return err(
957
+ new DerivedPhysicsError(
958
+ 'derived-candidate-cancelled',
959
+ 'candidate has not been cancelled or invalidated',
960
+ 'prepare a new candidate from the latest committed revision',
961
+ { candidateId: candidate.candidateId },
962
+ ),
963
+ );
964
+ }
965
+ if (record.state !== 'ready') {
966
+ return err(
967
+ new DerivedPhysicsError(
968
+ 'derived-candidate-pending',
969
+ 'a prepared candidate is admitted at most once',
970
+ 'retain the returned queued receipt and wait for fixed-step publication',
971
+ { candidateId: candidate.candidateId },
972
+ ),
973
+ );
974
+ }
975
+ const pendingSources = this.pendingDerivedSources();
976
+ if (sourceOverrides !== undefined) {
977
+ for (const [entity, source] of sourceOverrides) pendingSources.set(entity, source);
978
+ }
979
+ pendingSources.set(record.input.entity, {
980
+ sourceKey: record.input.sourceKey,
981
+ revision: record.input.revision,
982
+ });
983
+ const admissionError = this.validateDerivedAdmission(record.input, pendingSources);
984
+ if (admissionError !== undefined) {
985
+ this.rejectPreparedCandidate(record, admissionError);
986
+ return err(admissionError);
987
+ }
988
+ const active = this.derivedBodies.get(record.input.entity);
989
+ if (active !== undefined && record.input.revision <= active.revision) {
990
+ const stale = new DerivedPhysicsError(
991
+ 'derived-candidate-stale',
992
+ 'candidate revision is newer than the committed shape revision',
993
+ 'advance the consumer revision before admission',
994
+ { entity: record.input.entity, candidateId: candidate.candidateId },
995
+ );
996
+ this.rejectPreparedCandidate(record, stale);
997
+ return err(stale);
998
+ }
999
+ const newestPendingRevision = this.newestPendingRevision(record.input.entity);
1000
+ if (newestPendingRevision !== undefined && record.input.revision <= newestPendingRevision) {
1001
+ const stale = new DerivedPhysicsError(
1002
+ 'derived-candidate-stale',
1003
+ 'candidate revision advances every already queued revision for the body',
1004
+ 'admit only the newest body revision at a fixed-step boundary',
1005
+ {
1006
+ entity: record.input.entity,
1007
+ candidateId: candidate.candidateId,
1008
+ expected: `>${newestPendingRevision}`,
1009
+ actual: record.input.revision,
1010
+ },
1011
+ );
1012
+ this.rejectPreparedCandidate(record, stale);
1013
+ return err(stale);
1014
+ }
1015
+ record.state = 'queued';
1016
+ this.pendingDerivedCandidates.add(candidate.candidateId);
1017
+ const queued = Object.freeze({ ...record.token, state: 'queued' as const });
1018
+ record.token = queued;
1019
+ // Revalidate the complete queued dependency projection after every
1020
+ // admission. A dependent candidate may have been queued before a newer
1021
+ // endpoint revision arrived; allowing it to survive until process() would
1022
+ // make the result depend on queue order and could silently bind the old
1023
+ // endpoint source.
1024
+ let changed = true;
1025
+ while (changed) {
1026
+ changed = false;
1027
+ const projectedSources = new Map(sourceOverrides ?? []);
1028
+ for (const [entity, source] of this.pendingDerivedSources()) {
1029
+ const current = projectedSources.get(entity);
1030
+ if (current === undefined || source.revision > current.revision)
1031
+ projectedSources.set(entity, source);
1032
+ }
1033
+ for (const queuedId of [...this.pendingDerivedCandidates]) {
1034
+ const queuedRecord = this.derivedCandidates.get(queuedId);
1035
+ if (queuedRecord?.state !== 'queued') continue;
1036
+ const projected = projectedSources.get(queuedRecord.input.entity);
1037
+ const staleRevision =
1038
+ projected !== undefined && queuedRecord.input.revision < projected.revision;
1039
+ const dependencyError = staleRevision
1040
+ ? new DerivedPhysicsError(
1041
+ 'derived-candidate-stale',
1042
+ 'queued candidates publish only the final revision submitted for an entity',
1043
+ 'discard the older queued candidate and submit one complete revision',
1044
+ {
1045
+ entity: queuedRecord.input.entity,
1046
+ candidateId: queuedRecord.token.candidateId,
1047
+ expected: `>=${projected.revision}`,
1048
+ actual: queuedRecord.input.revision,
1049
+ },
1050
+ )
1051
+ : this.validateDerivedAdmission(queuedRecord.input, projectedSources);
1052
+ if (dependencyError === undefined) continue;
1053
+ this.rejectPreparedCandidate(queuedRecord, dependencyError);
1054
+ changed = true;
1055
+ if (queuedId === candidate.candidateId) return err(dependencyError);
1056
+ }
1057
+ }
1058
+ return ok(queued);
1059
+ }
1060
+
1061
+ /** Cancel candidate-native resources; the committed state remains untouched. */
1062
+ cancelDerivedShapeCandidate(
1063
+ candidate: DerivedPhysicsCandidate,
1064
+ ): ReturnType<NonNullable<PhysicsWorld['cancelDerivedShapeCandidate']>> {
1065
+ this.assertActive('cancelDerivedShapeCandidate');
1066
+ const record = this.derivedCandidates.get(candidate.candidateId);
1067
+ if (record === undefined || candidate.owner !== this.physicsOwner) {
1068
+ return err(
1069
+ new DerivedPhysicsError(
1070
+ 'derived-candidate-not-found',
1071
+ 'candidate belongs to the current PhysicsWorld',
1072
+ 'ignore already-retired credentials and prepare again when needed',
1073
+ { candidateId: candidate.candidateId },
1074
+ ),
1075
+ );
1076
+ }
1077
+ if (record.state === 'published') {
1078
+ return err(
1079
+ new DerivedPhysicsError(
1080
+ 'derived-candidate-cancelled',
1081
+ 'a published candidate remains the committed result until replaced',
1082
+ 'submit a newer candidate instead of cancelling committed state',
1083
+ { candidateId: candidate.candidateId },
1084
+ ),
1085
+ );
1086
+ }
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);
1091
+ return ok(undefined);
1092
+ }
1093
+
1094
+ /** Reject all in-flight derived work while preserving the last publication. */
1095
+ invalidateDerivedShapeCandidates(reason = 'consumer-invalidated'): void {
1096
+ const committedCandidateIds = new Set(
1097
+ [...this.derivedBodies.values()].map((body) => body.candidateId),
1098
+ );
1099
+ for (const [id, record] of this.derivedCandidates) {
1100
+ if (record.state === 'published' || committedCandidateIds.has(id)) continue;
1101
+ record.state = 'invalidated';
1102
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1103
+ this.releaseDerivedCandidate(id);
1104
+ }
1105
+ this.pendingDerivedCandidates.clear();
1106
+ void reason;
1107
+ }
1108
+
1109
+ getDerivedPublication(entity: number): DerivedPhysicsPublication | undefined {
1110
+ this.assertActive('getDerivedPublication');
1111
+ if (this.recoveryBlocked()) return undefined;
1112
+ const publication = this.derivedPublications.get(entity);
1113
+ return publication === undefined
1114
+ ? undefined
1115
+ : { ...publication, shapeIds: [...publication.shapeIds] };
1116
+ }
1117
+
1118
+ getDerivedFailure(entity: number): DerivedPhysicsFailure | undefined {
1119
+ const failure = this.derivedFailures.get(entity);
1120
+ return failure === undefined ? undefined : { ...failure };
1121
+ }
1122
+
1123
+ getDerivedBodyType(entity: number): 'static' | 'dynamic' | 'kinematic' | undefined {
1124
+ this.assertActive('getDerivedBodyType');
1125
+ if (this.recoveryBlocked()) return undefined;
1126
+ const body = this.bodyForEntity(entity);
1127
+ if (body === undefined) return undefined;
1128
+ return rapierBodyTypeToString(this.rapierModule, body.bodyType());
1129
+ }
1130
+
1131
+ getDerivedBodyMass(entity: number): number | undefined {
1132
+ this.assertActive('getDerivedBodyMass');
1133
+ if (this.recoveryBlocked()) return undefined;
1134
+ const body = this.bodyForEntity(entity);
1135
+ return body === undefined ? undefined : body.mass();
1136
+ }
1137
+
1138
+ getDerivedMotion(entity: number): DerivedPhysicsMotion | undefined {
1139
+ this.assertActive('getDerivedMotion');
1140
+ if (this.recoveryBlocked() || !this.derivedBodies.has(entity)) return undefined;
1141
+ const body = this.bodyForEntity(entity);
1142
+ if (body === undefined) return undefined;
1143
+ const com = body.worldCom();
1144
+ const linear = body.linvel();
1145
+ const angular = body.angvel();
1146
+ return Object.freeze({
1147
+ centerOfMass: [com.x, com.y, com.z] as PhysicsVector,
1148
+ linearVelocity: [linear.x, linear.y, linear.z] as PhysicsVector,
1149
+ angularVelocity: [angular.x, angular.y, angular.z] as PhysicsVector,
1150
+ });
1151
+ }
1152
+
1153
+ getDerivedRecoveryState(): 'ready' | 'rebuild-required' {
1154
+ return this.recoveryBlocked() ? 'rebuild-required' : 'ready';
1155
+ }
1156
+
1157
+ getDerivedShapes(entity: number): readonly DerivedShapeState[] {
1158
+ this.assertActive('getDerivedShapes');
1159
+ if (this.recoveryBlocked()) return [];
1160
+ const body = this.derivedBodies.get(entity);
1161
+ if (body === undefined) return [];
1162
+ return body.shapes.map((shape) => ({
1163
+ id: shape.input.id,
1164
+ revision: shape.input.revision,
1165
+ entity,
1166
+ voxelSize: [...shape.input.voxelSize] as PhysicsVector,
1167
+ origin: [...shape.input.origin] as PhysicsVector,
1168
+ rotation: [...shape.input.rotation] as PhysicsQuaternion,
1169
+ generation: body.generation,
1170
+ }));
1171
+ }
1172
+
1173
+ captureDerivedPhysicsState(): DerivedPhysicsSnapshot {
1174
+ this.assertActive('captureDerivedPhysicsState');
1175
+ return Object.freeze({
1176
+ generation: this.backendGeneration,
1177
+ fixedStep: this.fixedStep,
1178
+ bodies: Object.freeze(
1179
+ [...this.derivedBodies.values()].map((body) => ({
1180
+ entity: body.entity,
1181
+ revision: body.revision,
1182
+ sourceKey: body.sourceKey,
1183
+ ...(body.bodyType === undefined ? {} : { bodyType: body.bodyType }),
1184
+ ...(body.velocityPolicy === undefined ? {} : { velocityPolicy: body.velocityPolicy }),
1185
+ shapes: Object.freeze(
1186
+ body.shapes.map((shape) => ({
1187
+ ...shape.input,
1188
+ cells: new Int32Array(shape.input.cells),
1189
+ voxelSize: [...shape.input.voxelSize] as PhysicsVector,
1190
+ origin: [...shape.input.origin] as PhysicsVector,
1191
+ rotation: [...shape.input.rotation] as PhysicsQuaternion,
1192
+ })),
1193
+ ),
1194
+ seams: Object.freeze(
1195
+ body.seams.map((seam) => ({ ...seam, offset: [...seam.offset] as PhysicsVector })),
1196
+ ),
1197
+ ...(body.massProperties === undefined ? {} : { massProperties: body.massProperties }),
1198
+ ...(() => {
1199
+ const motion = this.getDerivedMotion(body.entity);
1200
+ return motion === undefined ? {} : { motion };
1201
+ })(),
1202
+ constraints: Object.freeze(this.constraintsForBody(body.entity)),
1203
+ })),
1204
+ ),
1205
+ });
1206
+ }
1207
+
1208
+ restoreDerivedPhysicsState(
1209
+ snapshot: DerivedPhysicsSnapshot,
1210
+ ): ReturnType<NonNullable<PhysicsWorld['restoreDerivedPhysicsState']>> {
1211
+ this.assertActive('restoreDerivedPhysicsState');
1212
+ const snapshotSources = new Map<number, DerivedBodySource>();
1213
+ for (const body of snapshot.bodies) {
1214
+ if (snapshotSources.has(body.entity)) {
1215
+ return err(
1216
+ new DerivedPhysicsError(
1217
+ 'derived-candidate-invalid',
1218
+ 'a portable snapshot contains one committed body row per entity',
1219
+ 'capture the snapshot from one PhysicsWorld without duplicate entities',
1220
+ { entity: body.entity },
1221
+ ),
1222
+ );
1223
+ }
1224
+ snapshotSources.set(body.entity, {
1225
+ sourceKey: body.sourceKey,
1226
+ revision: body.revision,
1227
+ });
1228
+ }
1229
+ const preparedCandidates: DerivedPhysicsCandidate[] = [];
1230
+ const restoredConstraintIds = new Set<string>();
1231
+ for (const body of snapshot.bodies) {
1232
+ const prepared = this.prepareDerivedShapeCandidate({
1233
+ entity: body.entity,
1234
+ revision: body.revision,
1235
+ sourceKey: body.sourceKey,
1236
+ shapes: body.shapes,
1237
+ ...(body.seams === undefined || body.seams.length === 0 ? {} : { seams: body.seams }),
1238
+ ...(body.bodyType === undefined ? {} : { bodyType: body.bodyType }),
1239
+ ...(body.velocityPolicy === undefined ? {} : { velocityPolicy: body.velocityPolicy }),
1240
+ ...(body.motion === undefined ? {} : { motion: body.motion }),
1241
+ ...(body.massProperties === undefined ? {} : { massProperties: body.massProperties }),
1242
+ constraints: body.constraints.filter((constraint) => {
1243
+ if (restoredConstraintIds.has(constraint.id)) return false;
1244
+ restoredConstraintIds.add(constraint.id);
1245
+ return true;
1246
+ }),
1247
+ });
1248
+ if (!prepared.ok) {
1249
+ for (const candidate of preparedCandidates) this.cancelDerivedShapeCandidate(candidate);
1250
+ return prepared;
1251
+ }
1252
+ preparedCandidates.push(prepared.value);
1253
+ }
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);
1268
+ }
1269
+
1270
+ createDerivedConstraint(
1271
+ input: PhysicsConstraintInput,
1272
+ ): ReturnType<NonNullable<PhysicsWorld['createDerivedConstraint']>> {
1273
+ return this.installDerivedConstraint(input, false);
1274
+ }
1275
+
1276
+ updateDerivedConstraint(
1277
+ input: PhysicsConstraintInput,
1278
+ ): ReturnType<NonNullable<PhysicsWorld['updateDerivedConstraint']>> {
1279
+ return this.installDerivedConstraint(input, true);
1280
+ }
1281
+
1282
+ removeDerivedConstraint(
1283
+ id: string,
1284
+ ): ReturnType<NonNullable<PhysicsWorld['removeDerivedConstraint']>> {
1285
+ this.assertActive('removeDerivedConstraint');
1286
+ const existing = this.derivedConstraints.get(id);
1287
+ if (existing === undefined) {
1288
+ return err(
1289
+ new DerivedPhysicsError(
1290
+ 'derived-constraint-not-found',
1291
+ 'constraint identity is currently committed',
1292
+ 'ignore repeated cleanup or reconcile the owning constraint set',
1293
+ { constraintId: id },
1294
+ ),
1295
+ );
1296
+ }
1297
+ this.removeNativeConstraint(existing.handle);
1298
+ this.derivedConstraints.delete(id);
1299
+ return ok(undefined);
1300
+ }
1301
+
1302
+ private createDerivedCollider(
1303
+ body: RapierRigidBody,
1304
+ shape: VoxelShapeInput,
1305
+ ): { readonly handle: number } {
1306
+ const RAPIER = this.rapierModule as RapierWorld;
1307
+ const desc = RAPIER.ColliderDesc.voxels(
1308
+ shape.cells instanceof Int32Array
1309
+ ? new Int32Array(shape.cells)
1310
+ : new Int32Array(shape.cells.flat()),
1311
+ { x: shape.voxelSize[0], y: shape.voxelSize[1], z: shape.voxelSize[2] },
1312
+ )
1313
+ .setTranslation(shape.origin?.[0] ?? 0, shape.origin?.[1] ?? 0, shape.origin?.[2] ?? 0)
1314
+ .setRotation({
1315
+ x: shape.rotation?.[0] ?? 0,
1316
+ y: shape.rotation?.[1] ?? 0,
1317
+ z: shape.rotation?.[2] ?? 0,
1318
+ w: shape.rotation?.[3] ?? 1,
1319
+ })
1320
+ .setFriction(shape.friction ?? 0.5)
1321
+ .setRestitution(shape.restitution ?? 0)
1322
+ // Disabled colliders still contribute native mass. Staging must be
1323
+ // massless; admission assigns density only to its committed shape set.
1324
+ .setDensity(0)
1325
+ .setCollisionGroups(shape.collisionGroups ?? 0xffffffff)
1326
+ .setSolverGroups(shape.solverGroups ?? 0xffffffff)
1327
+ .setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)
1328
+ .setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL)
1329
+ .setEnabled(false);
1330
+ if (shape.isSensor === true) desc.setSensor(true);
1331
+ const collider = this.raw.createCollider(desc, body);
1332
+ this.derivedColliderToShape.set(collider.handle, { entity: body.userData, id: shape.id });
1333
+ return collider;
1334
+ }
1335
+
1336
+ private removeNativeCollider(handle: number): void {
1337
+ this.derivedColliderToShape.delete(handle);
1338
+ try {
1339
+ const collider = (this.raw as RapierWorld).getCollider(handle);
1340
+ if (collider !== null && collider !== undefined) {
1341
+ (this.raw as RapierWorld).removeCollider(collider, false);
1342
+ }
1343
+ } catch {
1344
+ // Native removal is idempotent from the Engine lifecycle perspective.
1345
+ }
1346
+ }
1347
+
1348
+ private createNativeConstraint(
1349
+ input: PhysicsConstraintInput,
1350
+ ): Result<{ readonly handle: number }, DerivedPhysicsError> {
1351
+ let native: RapierWorld | undefined;
1352
+ try {
1353
+ const RAPIER = this.rapierModule as RapierWorld;
1354
+ const bodyA = this.bodyForEntity(input.bodyA);
1355
+ const bodyB = this.bodyForEntity(input.bodyB);
1356
+ if (bodyA === undefined || bodyB === undefined) {
1357
+ return err(
1358
+ new DerivedPhysicsError(
1359
+ 'derived-body-not-found',
1360
+ 'both constraint endpoints have committed bodies in this PhysicsWorld',
1361
+ 'reconcile both entities before creating the constraint',
1362
+ { constraintId: input.id },
1363
+ ),
1364
+ );
1365
+ }
1366
+ const anchorA = { x: input.anchorA[0], y: input.anchorA[1], z: input.anchorA[2] };
1367
+ const anchorB = { x: input.anchorB[0], y: input.anchorB[1], z: input.anchorB[2] };
1368
+ const jointData =
1369
+ input.kind === 'spring'
1370
+ ? RAPIER.JointData.spring(
1371
+ input.restLength,
1372
+ input.stiffness,
1373
+ input.damping,
1374
+ anchorA,
1375
+ anchorB,
1376
+ )
1377
+ : RAPIER.JointData.revolute(anchorA, anchorB, {
1378
+ x: input.axis[0],
1379
+ y: input.axis[1],
1380
+ z: input.axis[2],
1381
+ });
1382
+ native = this.raw.createImpulseJoint(jointData, bodyA, bodyB, true);
1383
+ if (input.kind === 'hinge' && input.limits !== undefined)
1384
+ native.setLimits(input.limits[0], input.limits[1]);
1385
+ return ok({ handle: native.handle });
1386
+ } catch (cause) {
1387
+ if (native !== undefined) this.removeNativeConstraint(native.handle);
1388
+ return err(
1389
+ new DerivedPhysicsError(
1390
+ 'derived-backend-failed',
1391
+ 'the selected Rapier joint can be created for both endpoint bodies',
1392
+ 'repair endpoint state or use a supported spring/hinge input',
1393
+ {
1394
+ constraintId: input.id,
1395
+ reason: cause instanceof Error ? cause.message : String(cause),
1396
+ },
1397
+ ),
1398
+ );
1399
+ }
1400
+ }
1401
+
1402
+ private rememberDerivedFailure(
1403
+ record: DerivedCandidateRecord,
1404
+ error: DerivedPhysicsError,
1405
+ recovery: DerivedPhysicsFailure['recovery'],
1406
+ ): void {
1407
+ record.state = 'failed';
1408
+ this.pendingDerivedCandidates.delete(record.token.candidateId);
1409
+ this.derivedFailures.set(
1410
+ record.input.entity,
1411
+ Object.freeze({
1412
+ candidateId: record.token.candidateId,
1413
+ entity: record.input.entity,
1414
+ revision: record.input.revision,
1415
+ fixedStep: this.fixedStep,
1416
+ error,
1417
+ recovery,
1418
+ }),
1419
+ );
1420
+ this.releaseDerivedCandidate(record.token.candidateId);
1421
+ }
1422
+
1423
+ private rejectPreparedCandidate(
1424
+ record: DerivedCandidateRecord,
1425
+ error: DerivedPhysicsError,
1426
+ ): void {
1427
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1428
+ this.rememberDerivedFailure(record, error, 'old-state-retained');
1429
+ }
1430
+
1431
+ private processDerivedCandidates(): void {
1432
+ if (this.pendingDerivedCandidates.size === 0) return;
1433
+ const pendingRecords = [...this.pendingDerivedCandidates]
1434
+ .map((id) => this.derivedCandidates.get(id))
1435
+ .filter((record): record is DerivedCandidateRecord => record?.state === 'queued');
1436
+ const pendingByEntity = new Map<number, DerivedCandidateRecord>();
1437
+ for (const record of pendingRecords) {
1438
+ const current = pendingByEntity.get(record.input.entity);
1439
+ if (current === undefined || record.input.revision > current.input.revision) {
1440
+ pendingByEntity.set(record.input.entity, record);
1441
+ }
1442
+ }
1443
+ // A candidate that names a queued endpoint must be processed after that
1444
+ // endpoint. This turns a queue-time source projection into a final
1445
+ // committed-source check: if the endpoint fails natively, its dependent
1446
+ // candidate sees the old source on the next iteration and is rejected.
1447
+ const ordered: DerivedCandidateRecord[] = [];
1448
+ const visiting = new Set<string>();
1449
+ const visited = new Set<string>();
1450
+ 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) {
1482
+ this.rejectPreparedCandidate(
1483
+ record,
1484
+ 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
+ },
1494
+ ),
1495
+ );
1496
+ continue;
1497
+ }
1498
+ const admissionError = this.validateDerivedAdmission(record.input, pendingSources);
1499
+ if (admissionError !== undefined) {
1500
+ this.rejectPreparedCandidate(record, admissionError);
1501
+ continue;
1502
+ }
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
+ );
1516
+ 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(
1524
+ '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
+ );
1531
+ continue;
1532
+ }
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,
1609
+ 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);
1667
+ if (collider !== null && collider !== undefined) collider.setEnabled(false);
1668
+ }
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 ?? [])],
1695
+ };
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;
1701
+ 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;
1708
+ }
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);
1738
+ }
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
+ this.derivedBodySources.set(record.input.entity, {
1743
+ sourceKey: old.sourceKey,
1744
+ revision: old.revision,
1745
+ });
1746
+ }
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
+ }
1831
+ }
1832
+ }
1833
+
1834
+ private publishDerivedCandidates(): void {
1835
+ for (const body of this.derivedBodies.values()) {
1836
+ const candidate = this.derivedCandidates.get(body.candidateId);
1837
+ if (candidate?.state !== 'queued') continue;
1838
+ this.derivedPublications.set(
1839
+ body.entity,
1840
+ Object.freeze({
1841
+ candidateId: body.candidateId,
1842
+ entity: body.entity,
1843
+ revision: body.revision,
1844
+ fixedStep: this.fixedStep,
1845
+ shapeIds: Object.freeze(body.shapes.map((shape) => shape.input.id)),
1846
+ generation: body.generation,
1847
+ }),
1848
+ );
1849
+ candidate.state = 'published';
1850
+ }
1851
+ }
1852
+
1853
+ private retireDerivedBodies(): void {
1854
+ for (const body of this.retiredDerivedBodies.splice(0)) {
1855
+ for (const shape of body.shapes) this.removeNativeCollider(shape.colliderHandle);
1856
+ this.releaseDerivedCandidate(body.candidateId);
1857
+ }
1858
+ }
1859
+
1860
+ private applyDerivedMass(
1861
+ body: RapierRigidBody,
1862
+ properties: PhysicsMassProperties | undefined,
1863
+ previousWorldCom: { x: number; y: number; z: number },
1864
+ velocityPolicy: 'preserve' | 'reset',
1865
+ authoredAdditionalMass: number,
1866
+ entity: number,
1867
+ candidateShapes: readonly VoxelShapeInput[],
1868
+ candidateColliders: readonly { readonly handle: number }[],
1869
+ ): void {
1870
+ const oldVelocity = body.linvel();
1871
+ const oldAngularVelocity = body.angvel();
1872
+ if (properties?.mode === 'explicit') {
1873
+ // Explicit mass is the sole source for this body. Zero every ordinary
1874
+ // and derived collider density first so Rapier does not add a hidden
1875
+ // density contribution to the authored value.
1876
+ this.rememberAuthoredDensity(entity, body);
1877
+ for (const collider of this.bodyColliders(body)) {
1878
+ if (typeof collider.setDensity === 'function') collider.setDensity(0);
1879
+ }
1880
+ const frame = properties.principalInertiaLocalFrame ?? [0, 0, 0, 1];
1881
+ body.setAdditionalMassProperties(
1882
+ properties.mass,
1883
+ {
1884
+ x: properties.centerOfMass[0],
1885
+ y: properties.centerOfMass[1],
1886
+ z: properties.centerOfMass[2],
1887
+ },
1888
+ {
1889
+ x: properties.principalInertia[0],
1890
+ y: properties.principalInertia[1],
1891
+ z: properties.principalInertia[2],
1892
+ },
1893
+ { x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
1894
+ true,
1895
+ );
1896
+ } else {
1897
+ this.restoreAutomaticDensities(
1898
+ entity,
1899
+ body,
1900
+ properties?.mode === 'automatic' ? properties.density : undefined,
1901
+ candidateShapes,
1902
+ candidateColliders,
1903
+ );
1904
+ this.restoreAutomaticMass(body, authoredAdditionalMass);
1905
+ }
1906
+ if (velocityPolicy === 'reset') {
1907
+ body.setLinvel({ x: 0, y: 0, z: 0 }, true);
1908
+ body.setAngvel({ x: 0, y: 0, z: 0 }, true);
1909
+ return;
1910
+ }
1911
+ const nextWorldCom = body.worldCom();
1912
+ const next = preserveCenterOfMassVelocity(
1913
+ [oldVelocity.x, oldVelocity.y, oldVelocity.z],
1914
+ [oldAngularVelocity.x, oldAngularVelocity.y, oldAngularVelocity.z],
1915
+ [previousWorldCom.x, previousWorldCom.y, previousWorldCom.z],
1916
+ [nextWorldCom.x, nextWorldCom.y, nextWorldCom.z],
1917
+ );
1918
+ body.setLinvel({ x: next[0], y: next[1], z: next[2] }, true);
1919
+ body.setAngvel(oldAngularVelocity, true);
1920
+ }
1921
+
1922
+ /** Restore the complete committed mass policy after a failed admission. */
1923
+ private restoreCommittedMass(
1924
+ body: RapierRigidBody,
1925
+ previous: DerivedBodyRecord | undefined,
1926
+ automaticAdditionalMass: number,
1927
+ ): void {
1928
+ if (previous?.massProperties?.mode === 'explicit') {
1929
+ const frame = previous.massProperties.principalInertiaLocalFrame ?? [0, 0, 0, 1];
1930
+ body.setAdditionalMassProperties(
1931
+ previous.massProperties.mass,
1932
+ {
1933
+ x: previous.massProperties.centerOfMass[0],
1934
+ y: previous.massProperties.centerOfMass[1],
1935
+ z: previous.massProperties.centerOfMass[2],
1936
+ },
1937
+ {
1938
+ x: previous.massProperties.principalInertia[0],
1939
+ y: previous.massProperties.principalInertia[1],
1940
+ z: previous.massProperties.principalInertia[2],
1941
+ },
1942
+ { x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
1943
+ true,
1944
+ );
1945
+ return;
1946
+ }
1947
+ this.restoreAutomaticMass(body, automaticAdditionalMass);
1948
+ }
1949
+
1950
+ /**
1951
+ * Rapier keeps `setAdditionalMassProperties` as native state even after a
1952
+ * collider recompute. Clear that override explicitly before recomputing so
1953
+ * automatic candidates and rollback really return to the authored policy.
1954
+ */
1955
+ private restoreAutomaticMass(body: RapierRigidBody, additionalMass: number): void {
1956
+ body.setAdditionalMass(Math.max(0, additionalMass), true);
1957
+ body.recomputeMassPropertiesFromColliders();
1958
+ }
1959
+
1960
+ private rememberAuthoredDensity(entity: number, body: RapierRigidBody): void {
1961
+ const record = this.entityMap.get(entity);
1962
+ if (record === undefined || record.authoredDensity !== undefined) return;
1963
+ const authored = this.bodyColliders(body).find(
1964
+ (collider) => !this.derivedColliderToShape.has(collider.handle),
1965
+ );
1966
+ const density =
1967
+ authored !== undefined && typeof authored.density === 'function'
1968
+ ? authored.density()
1969
+ : undefined;
1970
+ if (density !== undefined && Number.isFinite(density) && density >= 0)
1971
+ record.authoredDensity = density;
1972
+ }
1973
+
1974
+ private restoreAutomaticDensities(
1975
+ entity: number,
1976
+ body: RapierRigidBody,
1977
+ overrideDensity: number | undefined,
1978
+ candidateShapes: readonly VoxelShapeInput[],
1979
+ candidateColliders: readonly { readonly handle: number }[],
1980
+ ): void {
1981
+ const record = this.entityMap.get(entity);
1982
+ const candidateDensityByHandle = new Map<number, number>();
1983
+ for (const [index, collider] of candidateColliders.entries()) {
1984
+ const shape = candidateShapes[index];
1985
+ if (shape !== undefined) candidateDensityByHandle.set(collider.handle, shape.density ?? 1);
1986
+ }
1987
+ const authoredDensity = record?.authoredDensity ?? 1;
1988
+ for (const collider of this.bodyColliders(body)) {
1989
+ const candidateDensity = candidateDensityByHandle.get(collider.handle);
1990
+ const density =
1991
+ candidateDensity !== undefined
1992
+ ? (overrideDensity ?? candidateDensity)
1993
+ : this.derivedColliderToShape.has(collider.handle)
1994
+ ? 0
1995
+ : (overrideDensity ?? authoredDensity);
1996
+ if (typeof collider.setDensity === 'function') collider.setDensity(density);
1997
+ }
1998
+ }
1999
+
2000
+ private restoreNativeConstraints(
2001
+ previous: ReadonlyMap<string, DerivedConstraintRecord>,
2002
+ ): boolean {
2003
+ for (const [constraintId, record] of previous) {
2004
+ const recreated = this.createNativeConstraint(record.input);
2005
+ if (!recreated.ok) {
2006
+ for (const current of this.derivedConstraints.values())
2007
+ this.removeNativeConstraint(current.handle);
2008
+ this.derivedConstraints.clear();
2009
+ return false;
2010
+ }
2011
+ this.derivedConstraints.set(constraintId, {
2012
+ input: { ...record.input },
2013
+ handle: recreated.value.handle,
2014
+ });
2015
+ }
2016
+ return true;
2017
+ }
2018
+
2019
+ private bodyColliders(body: RapierRigidBody): RapierWorld[] {
2020
+ const result: RapierWorld[] = [];
2021
+ for (let index = 0; index < body.numColliders(); index += 1) {
2022
+ const collider = body.collider(index);
2023
+ if (collider !== null && collider !== undefined) result.push(collider);
2024
+ }
2025
+ return result;
2026
+ }
2027
+
2028
+ private releaseDerivedCandidate(candidateId: string): void {
2029
+ const record = this.derivedCandidates.get(candidateId);
2030
+ if (record === undefined) return;
2031
+ this.derivedCandidateBytes = Math.max(0, this.derivedCandidateBytes - record.bytes);
2032
+ this.derivedCandidates.delete(candidateId);
2033
+ }
2034
+
2035
+ private sourceForEntity(entity: number): DerivedBodySource {
2036
+ return (
2037
+ this.derivedBodySources.get(entity) ?? {
2038
+ sourceKey: `entity:${entity}`,
2039
+ revision: 0,
2040
+ }
2041
+ );
2042
+ }
2043
+
2044
+ /**
2045
+ * Project the final source revision of every queued body. Constraint
2046
+ * dependencies are checked against this projection, not against whichever
2047
+ * queued candidate happens to be processed first.
2048
+ */
2049
+ private pendingDerivedSources(): Map<number, DerivedBodySource> {
2050
+ const sources = new Map<number, DerivedBodySource>();
2051
+ for (const candidateId of this.pendingDerivedCandidates) {
2052
+ const record = this.derivedCandidates.get(candidateId);
2053
+ if (record === undefined || record.state !== 'queued') continue;
2054
+ const current = sources.get(record.input.entity);
2055
+ if (current === undefined || record.input.revision > current.revision) {
2056
+ sources.set(record.input.entity, {
2057
+ sourceKey: record.input.sourceKey,
2058
+ revision: record.input.revision,
2059
+ });
2060
+ }
2061
+ }
2062
+ return sources;
2063
+ }
2064
+
2065
+ private newestPendingRevision(entity: number): number | undefined {
2066
+ return this.pendingDerivedSources().get(entity)?.revision;
2067
+ }
2068
+
2069
+ private recoveryBlocked(): boolean {
2070
+ return (
2071
+ this.derivedPoisonedEntities.size > 0 || this.syncState?.world.execution.health === 'poisoned'
2072
+ );
2073
+ }
2074
+
2075
+ private validateConstraintDependencies(
2076
+ input: PhysicsConstraintInput,
2077
+ candidate?: DerivedPhysicsCandidateInput,
2078
+ sourceOverrides?: ReadonlyMap<number, DerivedBodySource>,
2079
+ ): DerivedPhysicsError | undefined {
2080
+ const endpoints = [
2081
+ [input.bodyA, input.bodyASource],
2082
+ [input.bodyB, input.bodyBSource],
2083
+ ] as const;
2084
+ for (const [entity, dependency] of endpoints) {
2085
+ if (this.derivedPoisonedEntities.has(entity)) {
2086
+ return new DerivedPhysicsError(
2087
+ 'derived-recovery-invalid',
2088
+ 'constraint endpoints belong to a healthy PhysicsWorld state',
2089
+ 'rebuild the PhysicsWorld before recreating constraints',
2090
+ { constraintId: input.id, entity },
2091
+ );
2092
+ }
2093
+ if (!this.entityMap.has(entity)) {
2094
+ return new DerivedPhysicsError(
2095
+ 'derived-body-not-found',
2096
+ 'both constraint endpoint entities have committed bodies',
2097
+ 'reconcile both endpoint entities before creating or migrating a constraint',
2098
+ { constraintId: input.id, entity },
2099
+ );
2100
+ }
2101
+ const expected =
2102
+ candidate !== undefined && entity === candidate.entity
2103
+ ? { sourceKey: candidate.sourceKey, revision: candidate.revision }
2104
+ : (sourceOverrides?.get(entity) ?? this.sourceForEntity(entity));
2105
+ if (
2106
+ dependency.sourceKey !== expected.sourceKey ||
2107
+ dependency.revision !== expected.revision
2108
+ ) {
2109
+ return new DerivedPhysicsError(
2110
+ 'derived-constraint-stale',
2111
+ 'constraint endpoint sourceKey and revision match the committed endpoint',
2112
+ 'refresh both endpoint dependencies and retry the complete candidate',
2113
+ {
2114
+ constraintId: input.id,
2115
+ entity,
2116
+ expected: `${expected.sourceKey}@${expected.revision}`,
2117
+ actual: `${dependency.sourceKey}@${dependency.revision}`,
2118
+ },
2119
+ );
2120
+ }
2121
+ }
2122
+ return undefined;
2123
+ }
2124
+
2125
+ private constraintDependenciesMatch(
2126
+ input: PhysicsConstraintInput,
2127
+ sourceOverrides: ReadonlyMap<number, DerivedBodySource>,
2128
+ ): boolean {
2129
+ for (const [entity, dependency] of [
2130
+ [input.bodyA, input.bodyASource],
2131
+ [input.bodyB, input.bodyBSource],
2132
+ ] as const) {
2133
+ if (!this.entityMap.has(entity)) return false;
2134
+ const expected = sourceOverrides.get(entity) ?? this.sourceForEntity(entity);
2135
+ if (dependency.sourceKey !== expected.sourceKey || dependency.revision !== expected.revision)
2136
+ return false;
2137
+ }
2138
+ return true;
2139
+ }
2140
+
2141
+ private validateDerivedAdmission(
2142
+ input: DerivedPhysicsCandidateInput,
2143
+ sourceOverrides?: ReadonlyMap<number, DerivedBodySource>,
2144
+ ): DerivedPhysicsError | undefined {
2145
+ if (
2146
+ input.bodyType !== undefined &&
2147
+ input.bodyType !== 'static' &&
2148
+ input.bodyType !== 'dynamic' &&
2149
+ input.bodyType !== 'kinematic'
2150
+ ) {
2151
+ return new DerivedPhysicsError(
2152
+ 'derived-candidate-invalid',
2153
+ 'candidate bodyType is one of static, dynamic, or kinematic',
2154
+ 'repair the motion type before admission',
2155
+ { entity: input.entity, actual: input.bodyType },
2156
+ );
2157
+ }
2158
+ const seen = new Set<string>();
2159
+ for (const constraint of input.constraints ?? []) {
2160
+ const validation = validateConstraintInput(constraint);
2161
+ if (!validation.ok) return validation.error;
2162
+ if (seen.has(constraint.id)) {
2163
+ return new DerivedPhysicsError(
2164
+ 'derived-constraint-invalid',
2165
+ 'candidate contains one constraint update per identity',
2166
+ 'merge duplicate updates before admission',
2167
+ { entity: input.entity, constraintId: constraint.id },
2168
+ );
2169
+ }
2170
+ seen.add(constraint.id);
2171
+ const dependencyError = this.validateConstraintDependencies(
2172
+ constraint,
2173
+ input,
2174
+ sourceOverrides,
2175
+ );
2176
+ if (dependencyError !== undefined) return dependencyError;
2177
+ const existing = this.derivedConstraints.get(constraint.id);
2178
+ if (existing !== undefined && constraint.revision <= existing.input.revision) {
2179
+ return new DerivedPhysicsError(
2180
+ 'derived-constraint-stale',
2181
+ 'migrated constraint revision advances the committed revision',
2182
+ 'submit both endpoint dependencies and a newer constraint revision',
2183
+ {
2184
+ entity: input.entity,
2185
+ constraintId: constraint.id,
2186
+ expected: `>${existing.input.revision}`,
2187
+ actual: constraint.revision,
2188
+ },
2189
+ );
2190
+ }
2191
+ }
2192
+ return undefined;
2193
+ }
2194
+
2195
+ private constraintsForBody(entity: number): readonly PhysicsConstraintInput[] {
2196
+ return [...this.derivedConstraints.values()]
2197
+ .filter(
2198
+ (constraint) => constraint.input.bodyA === entity || constraint.input.bodyB === entity,
2199
+ )
2200
+ .map((constraint) => ({ ...constraint.input }));
2201
+ }
2202
+
2203
+ private installDerivedConstraint(
2204
+ input: PhysicsConstraintInput,
2205
+ updating: boolean,
2206
+ ): ReturnType<NonNullable<PhysicsWorld['createDerivedConstraint']>> {
2207
+ this.assertActive(updating ? 'updateDerivedConstraint' : 'createDerivedConstraint');
2208
+ const validation = validateConstraintInput(input);
2209
+ if (!validation.ok) return validation;
2210
+ const dependencyError = this.validateConstraintDependencies(
2211
+ input,
2212
+ undefined,
2213
+ this.pendingDerivedSources(),
2214
+ );
2215
+ if (dependencyError !== undefined) return err(dependencyError);
2216
+ const existing = this.derivedConstraints.get(input.id);
2217
+ if (existing !== undefined && !updating) {
2218
+ return err(
2219
+ new DerivedPhysicsError(
2220
+ 'derived-constraint-stale',
2221
+ 'constraint identity is not already committed when creating it',
2222
+ 'call updateDerivedConstraint with a newer revision',
2223
+ { constraintId: input.id },
2224
+ ),
2225
+ );
2226
+ }
2227
+ if (existing !== undefined && input.revision <= existing.input.revision) {
2228
+ return err(
2229
+ new DerivedPhysicsError(
2230
+ 'derived-constraint-stale',
2231
+ 'constraint revision advances monotonically',
2232
+ 'submit a newer constraint revision',
2233
+ {
2234
+ constraintId: input.id,
2235
+ actual: input.revision,
2236
+ expected: `>${existing.input.revision}`,
2237
+ },
2238
+ ),
2239
+ );
2240
+ }
2241
+ const native = this.createNativeConstraint(input);
2242
+ if (!native.ok) return native;
2243
+ if (existing !== undefined) this.removeNativeConstraint(existing.handle);
2244
+ this.derivedConstraints.set(input.id, { input: { ...input }, handle: native.value.handle });
2245
+ return ok({ id: input.id, revision: input.revision });
2246
+ }
2247
+
2248
+ private removeNativeConstraint(handle: number): void {
2249
+ try {
2250
+ const joint = (this.raw as RapierWorld).getImpulseJoint(handle);
2251
+ if (joint !== null && joint !== undefined)
2252
+ (this.raw as RapierWorld).removeImpulseJoint(joint, true);
2253
+ } catch {
2254
+ // Cleanup is idempotent across backend teardown and entity removal.
2255
+ }
2256
+ }
2257
+
472
2258
  getPendingTeleports(): readonly [
473
2259
  number,
474
2260
  { readonly x: number; readonly y: number; readonly z: number },
@@ -484,6 +2270,22 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
484
2270
 
485
2271
  dispose(): void {
486
2272
  if (this.disposed) return;
2273
+ this.assertActive('dispose');
2274
+ this.invalidateDerivedShapeCandidates('physics-dispose');
2275
+ this.derivedCandidates.clear();
2276
+ this.pendingDerivedCandidates.clear();
2277
+ this.derivedBodies.clear();
2278
+ this.derivedBodySources.clear();
2279
+ this.derivedPublications.clear();
2280
+ this.derivedFailures.clear();
2281
+ this.derivedConstraints.clear();
2282
+ this.derivedContacts.length = 0;
2283
+ this.derivedPublicationPending = false;
2284
+ this.derivedPoisonedEntities.clear();
2285
+ this.derivedColliderToShape.clear();
2286
+ this.retiredDerivedBodies.length = 0;
2287
+ this.derivedCandidateBytes = 0;
2288
+ this.backendGeneration += 1;
487
2289
  this.syncState = undefined;
488
2290
  this.moveContext = undefined;
489
2291
  if (typeof this.raw.free === 'function') this.raw.free();
@@ -514,12 +2316,14 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
514
2316
  setMoveContext(world: World, transform: Component, characterController: Component): void {
515
2317
  this.assertActive('setMoveContext');
516
2318
  this.moveContext = { world, transform, characterController };
2319
+ this.worldIdentity = world;
517
2320
  }
518
2321
 
519
2322
  /** Release the persistent ECS readers owned by one system registration. */
520
2323
  clearEcsContext(world: World): void {
521
2324
  if (this.syncState?.world === world) this.syncState = undefined;
522
2325
  if (this.moveContext?.world === world) this.moveContext = undefined;
2326
+ if (this.worldIdentity === world) this.worldIdentity = undefined;
523
2327
  }
524
2328
 
525
2329
  moveAndSlide(entity: number, desiredDelta: Vec3): Vec3 {
@@ -528,6 +2332,14 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
528
2332
  }
529
2333
 
530
2334
  private assertActive(operation: string): void {
2335
+ if (this.activeDerivedAdmission !== undefined) {
2336
+ throw new DerivedPhysicsError(
2337
+ 'derived-candidate-pending',
2338
+ 'physics queries and mutations observe only complete fixed-step states',
2339
+ 'finish the paired geometry commit before querying or mutating physics',
2340
+ { entity: this.activeDerivedAdmission.input.entity, reason: operation },
2341
+ );
2342
+ }
531
2343
  if (this.disposed) {
532
2344
  throw new Error(`RapierPhysicsWorld3D.${operation} cannot run on a disposed instance`);
533
2345
  }
@@ -726,18 +2538,21 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
726
2538
  private fullReconcilePhysicsState(state: PhysicsSyncState): void {
727
2539
  const descriptors: PhysicsSyncDescriptor[] = [];
728
2540
  const transformlessStatic = new Set<number>();
729
- for (const row of state.query) {
730
- if (!row.has(state.transformComponent)) {
731
- if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
732
- continue;
2541
+ const committedDerived =
2542
+ this.derivedBodies.size > 0 ? this.captureDerivedPhysicsState() : undefined;
2543
+ for (const query of state.queries)
2544
+ for (const row of query) {
2545
+ if (!row.has(state.transformComponent)) {
2546
+ if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
2547
+ continue;
2548
+ }
2549
+ const descriptor = readPhysicsSyncDescriptor(
2550
+ row,
2551
+ state.transformComponent,
2552
+ state.globalTransformComponent,
2553
+ );
2554
+ if (descriptor !== undefined) descriptors.push(descriptor);
733
2555
  }
734
- const descriptor = readPhysicsSyncDescriptor(
735
- row,
736
- state.transformComponent,
737
- state.globalTransformComponent,
738
- );
739
- if (descriptor !== undefined) descriptors.push(descriptor);
740
- }
741
2556
 
742
2557
  this.resetForFullReconcile(transformlessStatic);
743
2558
  for (const descriptor of descriptors) {
@@ -748,6 +2563,25 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
748
2563
  descriptor.collider,
749
2564
  );
750
2565
  }
2566
+ if (committedDerived !== undefined) {
2567
+ const restorableBodies = committedDerived.bodies.filter(
2568
+ (body) => this.entityMap.has(body.entity) && !this.derivedBodies.has(body.entity),
2569
+ );
2570
+ if (restorableBodies.length > 0) {
2571
+ const restorable = Object.freeze({
2572
+ ...committedDerived,
2573
+ bodies: Object.freeze(restorableBodies),
2574
+ });
2575
+ const restored = this.restoreDerivedPhysicsState(restorable);
2576
+ if (!restored.ok) {
2577
+ // A full ECS rebuild has already replaced native bodies. Keep the
2578
+ // rebuilt ordinary state queryable, but do not expose a mixed derived
2579
+ // result: pending candidates are invalidated and the next consumer
2580
+ // submission is the explicit recovery boundary.
2581
+ this.invalidateDerivedShapeCandidates('full-reconcile-restore-failed');
2582
+ }
2583
+ }
2584
+ }
751
2585
  drainPhysicsChangeQueries(state.changeQueries);
752
2586
  state.structuralCursor = readStructuralEvidence(state.world, state.structuralCursor).cursor;
753
2587
  state.structureEpoch = state.world.getStructureEpoch();
@@ -759,7 +2593,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
759
2593
  entity: EntityHandle,
760
2594
  delta: PhysicsEntityDelta,
761
2595
  ): void {
762
- const row = state.query.at(entity);
2596
+ const row = state.queries.map((query) => query.at(entity)).find((entry) => entry !== undefined);
763
2597
  if (row === undefined) {
764
2598
  this.removeEntity(entity);
765
2599
  return;
@@ -787,10 +2621,14 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
787
2621
  }
788
2622
 
789
2623
  if (this.hasBody(entity) && (delta.colliderChanged || delta.rigidBodyChanged)) {
790
- // Collider/RigidBody lifecycle is reconciled by replacement from the final
791
- // ECS combination. This deliberately resets velocity/contact state for an
792
- // affected dynamic body; no descriptor mirror is introduced in M1.
793
- this.removeEntity(entity);
2624
+ // Derived shapes share this body and must survive ordinary authored
2625
+ // component changes. Update the base collider/body in place; entities
2626
+ // without a derived publication retain the legacy replacement path.
2627
+ if (this.derivedBodies.has(entity)) {
2628
+ this.syncDerivedCompatibleEcsMutation(entity, descriptor);
2629
+ } else {
2630
+ this.removeEntity(entity);
2631
+ }
794
2632
  }
795
2633
  if (!this.hasBody(entity)) {
796
2634
  this.ensureBody(entity, descriptor.transform, descriptor.rigidBody, descriptor.collider);
@@ -828,6 +2666,53 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
828
2666
  }
829
2667
  }
830
2668
 
2669
+ private syncDerivedCompatibleEcsMutation(
2670
+ entity: number,
2671
+ descriptor: PhysicsSyncDescriptor,
2672
+ ): void {
2673
+ const body = this.bodyForEntity(entity);
2674
+ if (body === undefined) return;
2675
+ const bodyType = rigidBodyTypeFromF32(descriptor.rigidBody.type);
2676
+ const RAPIER = this.rapierModule as RapierWorld;
2677
+ // The native body is shared, but authored and derived colliders have
2678
+ // distinct owners. Never address an authored collider by array position.
2679
+ for (const collider of this.bodyColliders(body)) {
2680
+ if (!this.derivedColliderToShape.has(collider.handle)) {
2681
+ (this.raw as RapierWorld).removeCollider(collider, true);
2682
+ }
2683
+ }
2684
+ const committed = this.derivedBodies.get(entity);
2685
+ const record = this.entityMap.get(entity);
2686
+ if (record !== undefined) record.authoredDensity = descriptor.collider?.density;
2687
+ if (descriptor.collider !== undefined) {
2688
+ this.createAuthoredCollider(body, descriptor.transform, {
2689
+ ...descriptor.collider,
2690
+ density: committed?.massProperties?.mode === 'explicit' ? 0 : descriptor.collider.density,
2691
+ });
2692
+ }
2693
+ if (bodyType === 'static') {
2694
+ body.setBodyType(RAPIER.RigidBodyType.Fixed, true);
2695
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, 'static');
2696
+ } else if (bodyType === 'kinematic') {
2697
+ body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
2698
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, 'kinematic');
2699
+ } else {
2700
+ body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
2701
+ if (record !== undefined) record.additionalMass = Math.max(0, descriptor.rigidBody.mass);
2702
+ if (committed?.massProperties?.mode !== 'explicit') {
2703
+ this.restoreAutomaticMass(body, record?.additionalMass ?? 0);
2704
+ if (record !== undefined) record.automaticAdditionalMass = record.additionalMass;
2705
+ }
2706
+ body.setGravityScale(descriptor.rigidBody.gravityScale, true);
2707
+ body.setLinearDamping(descriptor.rigidBody.linearDamping);
2708
+ body.setAngularDamping(descriptor.rigidBody.angularDamping);
2709
+ }
2710
+ body.enableCcd(Boolean(descriptor.rigidBody.ccdEnabled));
2711
+ if (committed?.massProperties?.mode === 'explicit') {
2712
+ this.restoreCommittedMass(body, committed, 0);
2713
+ }
2714
+ }
2715
+
831
2716
  /** @internal ECS system bridge; consumers should register PhysicsSyncBackend. */
832
2717
  _syncFromEcs(
833
2718
  world: World,
@@ -835,6 +2720,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
835
2720
  globalTransformComponent = world.components.resolve('GlobalTransform'),
836
2721
  ): void {
837
2722
  this.assertActive('syncFromEcs');
2723
+ this.worldIdentity = world;
838
2724
  if (globalTransformComponent === undefined) return;
839
2725
  let state = this.syncState;
840
2726
  if (
@@ -854,11 +2740,17 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
854
2740
  ],
855
2741
  });
856
2742
  if (!queryResult.ok) return;
2743
+ const bodyQuery = world.query({
2744
+ read: [RigidBody],
2745
+ without: [Collider],
2746
+ optional: [transformComponent, globalTransformComponent, CharacterController, ChildOf],
2747
+ });
2748
+ if (!bodyQuery.ok) throw bodyQuery.error;
857
2749
  state = {
858
2750
  world,
859
2751
  transformComponent,
860
2752
  globalTransformComponent,
861
- query: queryResult.value as unknown as PhysicsSyncQuery,
2753
+ queries: [queryResult.value, bodyQuery.value] as unknown as readonly PhysicsSyncQuery[],
862
2754
  changeQueries: createPhysicsChangeQueries(world, [
863
2755
  transformComponent,
864
2756
  globalTransformComponent,
@@ -1001,7 +2893,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1001
2893
  gravityScale: number;
1002
2894
  ccdEnabled: number;
1003
2895
  },
1004
- collider: PhysicsCollider3D,
2896
+ collider: PhysicsCollider3D | undefined,
1005
2897
  ): void {
1006
2898
  this.assertActive('ensureBody');
1007
2899
  if (this.entityMap.has(entity)) return; // M1 idempotent guard (D-2)
@@ -1058,8 +2950,27 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1058
2950
  }
1059
2951
 
1060
2952
  body.userData = entity;
1061
- this.registerBody(entity, body.handle);
2953
+ this.registerBody(
2954
+ entity,
2955
+ body.handle,
2956
+ rbType === 'dynamic' ? Math.max(0, rigidBody.mass) : 0,
2957
+ collider?.density,
2958
+ );
2959
+ if (collider === undefined) {
2960
+ const record = this.entityMap.get(entity);
2961
+ if (record !== undefined) record.automaticAdditionalMass = record.additionalMass;
2962
+ return;
2963
+ }
2964
+
2965
+ this.createAuthoredCollider(body, transform, collider);
2966
+ }
1062
2967
 
2968
+ private createAuthoredCollider(
2969
+ body: RapierRigidBody,
2970
+ transform: PhysicsTransform3D,
2971
+ collider: PhysicsCollider3D,
2972
+ ): void {
2973
+ const RAPIER = this.rapierModule;
1063
2974
  // ── Create ColliderDesc ──
1064
2975
  const scaleX = Math.abs(transform.scale.x);
1065
2976
  const scaleY = Math.abs(transform.scale.y);
@@ -1138,7 +3049,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1138
3049
  syncAuthoredPose(
1139
3050
  entity: number,
1140
3051
  transform: PhysicsTransform3D,
1141
- collider: PhysicsCollider3D,
3052
+ collider: PhysicsCollider3D | undefined,
1142
3053
  bodyType: 'static' | 'kinematic',
1143
3054
  ): void {
1144
3055
  this.assertActive('syncAuthoredPose');
@@ -1156,7 +3067,10 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1156
3067
  body.setNextKinematicRotation(transform.rotation);
1157
3068
  }
1158
3069
 
1159
- const rapierCollider = body.collider(0);
3070
+ if (collider === undefined) return;
3071
+ const rapierCollider = this.bodyColliders(body).find(
3072
+ (shape) => !this.derivedColliderToShape.has(shape.handle),
3073
+ );
1160
3074
  if (!rapierCollider) return;
1161
3075
  const scaleX = Math.abs(transform.scale.x);
1162
3076
  const scaleY = Math.abs(transform.scale.y);
@@ -1184,8 +3098,31 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1184
3098
  /**
1185
3099
  * Register an ECS entity with its Rapier body handle.
1186
3100
  */
1187
- registerBody(entity: number, bodyHandle: number): void {
1188
- this.entityMap.set(entity, { bodyHandle });
3101
+ registerBody(
3102
+ entity: number,
3103
+ bodyHandle: number,
3104
+ additionalMass = 0,
3105
+ authoredDensity?: number,
3106
+ ): void {
3107
+ this.entityMap.set(entity, {
3108
+ bodyHandle,
3109
+ additionalMass: Math.max(0, additionalMass),
3110
+ // `ensureBody` registers before its authored collider is attached.
3111
+ // Rapier recomputes the body from that collider and clears the
3112
+ // descriptor-only additional mass, so the native baseline is zero.
3113
+ // A later automatic derived admission records the actual additional
3114
+ // contribution after it has been applied. Keeping this separate from
3115
+ // `additionalMass` lets rollback restore native state rather than an
3116
+ // authored value that Rapier has not applied yet.
3117
+ automaticAdditionalMass: 0,
3118
+ authoredDensity:
3119
+ authoredDensity !== undefined && Number.isFinite(authoredDensity) && authoredDensity >= 0
3120
+ ? authoredDensity
3121
+ : undefined,
3122
+ });
3123
+ if (!this.derivedBodySources.has(entity)) {
3124
+ this.derivedBodySources.set(entity, { sourceKey: `entity:${entity}`, revision: 0 });
3125
+ }
1189
3126
  }
1190
3127
 
1191
3128
  /**
@@ -1253,6 +3190,26 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1253
3190
  removeEntity(entity: number): void {
1254
3191
  const record = this.entityMap.get(entity);
1255
3192
  if (!record) return;
3193
+ const derived = this.derivedBodies.get(entity);
3194
+ if (derived !== undefined) {
3195
+ for (const shape of derived.shapes) this.removeNativeCollider(shape.colliderHandle);
3196
+ this.derivedBodies.delete(entity);
3197
+ this.derivedPublications.delete(entity);
3198
+ }
3199
+ this.derivedFailures.delete(entity);
3200
+ this.derivedPoisonedEntities.delete(entity);
3201
+ for (const [id, constraint] of this.derivedConstraints) {
3202
+ if (constraint.input.bodyA === entity || constraint.input.bodyB === entity) {
3203
+ this.removeNativeConstraint(constraint.handle);
3204
+ this.derivedConstraints.delete(id);
3205
+ }
3206
+ }
3207
+ for (const [id, candidate] of this.derivedCandidates) {
3208
+ if (candidate.input.entity !== entity) continue;
3209
+ for (const collider of candidate.nativeColliders) this.removeNativeCollider(collider.handle);
3210
+ this.pendingDerivedCandidates.delete(id);
3211
+ this.releaseDerivedCandidate(id);
3212
+ }
1256
3213
  const ownPairs = [...(this.collisionPairs.get(entity) ?? [])];
1257
3214
  for (const other of ownPairs) {
1258
3215
  if (this.removePair(entity, other)) {
@@ -1263,6 +3220,7 @@ export class RapierPhysicsWorld3D implements PhysicsWorld {
1263
3220
  // biome-ignore lint/suspicious/noExplicitAny: Rapier World.removeRigidBody
1264
3221
  (this.raw as any).removeRigidBody({ handle: record.bodyHandle } as RapierRigidBody);
1265
3222
  this.entityMap.delete(entity);
3223
+ this.derivedBodySources.delete(entity);
1266
3224
  // Clear the despawned entity from every overlap set so a collected Core does
1267
3225
  // not linger in the player's CollidingEntities (Rapier emits no `stopped`
1268
3226
  // event when a collider is removed mid-overlap).
@@ -1313,7 +3271,7 @@ function readPhysicsSyncDescriptor(
1313
3271
  const globalTransformData = row.get(globalTransformComponent) as
1314
3272
  | { readonly world: Float32Array }
1315
3273
  | undefined;
1316
- const colliderData = row.get(Collider) as
3274
+ const colliderData = (row.has(Collider) ? row.get(Collider) : undefined) as
1317
3275
  | {
1318
3276
  readonly shape: number;
1319
3277
  readonly halfExtents: Float32Array;
@@ -1327,7 +3285,8 @@ function readPhysicsSyncDescriptor(
1327
3285
  readonly solverGroups: number;
1328
3286
  }
1329
3287
  | undefined;
1330
- if (transformData === undefined || colliderData === undefined) return undefined;
3288
+ if (transformData === undefined || (colliderData === undefined && !row.has(RigidBody)))
3289
+ return undefined;
1331
3290
 
1332
3291
  // Root-local TRS is already a world pose. A ChildOf row, however, must use
1333
3292
  // Scene's derived GlobalTransform world matrix. Matrix contents cannot be a
@@ -1404,22 +3363,25 @@ function readPhysicsSyncDescriptor(
1404
3363
  gravityScale: rigidBodyData.gravityScale,
1405
3364
  ccdEnabled: Number(rigidBodyData.ccdEnabled),
1406
3365
  },
1407
- collider: {
1408
- shape: colliderData.shape,
1409
- halfExtents: [
1410
- colliderData.halfExtents[0] ?? 0,
1411
- colliderData.halfExtents[1] ?? 0,
1412
- colliderData.halfExtents[2] ?? 0,
1413
- ],
1414
- radius: colliderData.radius,
1415
- halfHeight: colliderData.halfHeight,
1416
- friction: colliderData.friction,
1417
- restitution: colliderData.restitution,
1418
- density: colliderData.density,
1419
- isSensor: Number(colliderData.isSensor),
1420
- collisionGroups: colliderData.collisionGroups,
1421
- solverGroups: colliderData.solverGroups,
1422
- },
3366
+ collider:
3367
+ colliderData === undefined
3368
+ ? undefined
3369
+ : {
3370
+ shape: colliderData.shape,
3371
+ halfExtents: [
3372
+ colliderData.halfExtents[0] ?? 0,
3373
+ colliderData.halfExtents[1] ?? 0,
3374
+ colliderData.halfExtents[2] ?? 0,
3375
+ ],
3376
+ radius: colliderData.radius,
3377
+ halfHeight: colliderData.halfHeight,
3378
+ friction: colliderData.friction,
3379
+ restitution: colliderData.restitution,
3380
+ density: colliderData.density,
3381
+ isSensor: Number(colliderData.isSensor),
3382
+ collisionGroups: colliderData.collisionGroups,
3383
+ solverGroups: colliderData.solverGroups,
3384
+ },
1423
3385
  hasCharacterController: row.has(CharacterController),
1424
3386
  characterControllerOffset: characterControllerData?.offset,
1425
3387
  };
@@ -1549,6 +3511,7 @@ export const PhysicsCollisionSync: SystemHandle<readonly []> = defineSystem({
1549
3511
  return; // C-2: safe early out
1550
3512
  }
1551
3513
  pw.writebackCollidingEntities(world, CollidingEntities as unknown as Component);
3514
+ pw.finalizeDerivedFixedStep();
1552
3515
  },
1553
3516
  });
1554
3517