@forgeax/engine-physics-rapier3d 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -2,9 +2,9 @@ import { defineSystem, FixedTime, componentDefinition, Disabled, FixedUpdate } f
2
2
  import { componentId } from '@forgeax/engine-ecs/internal';
3
3
  import { readStructuralEvidence } from '@forgeax/engine-ecs/projection';
4
4
  import { vec3, quat, mat4 } from '@forgeax/engine-math';
5
- import { CollidingEntities, PhysicsError, PHYSICS_ERROR_HINTS, CharacterController, rigidBodyTypeFromF32, RigidBody, Collider, colliderShapeFromF32, RIGID_BODY_TYPE_STATIC, registerPhysicsComponents, PhysicsSet } from '@forgeax/engine-physics';
5
+ import { CollidingEntities, DerivedPhysicsError, cloneDerivedPhysicsInput, validateMassProperties, DERIVED_PHYSICS_LIMITS, estimateDerivedPhysicsInputBytes, preserveCenterOfMassVelocity, PhysicsError, PHYSICS_ERROR_HINTS, CharacterController, rigidBodyTypeFromF32, RigidBody, Collider, colliderShapeFromF32, RIGID_BODY_TYPE_STATIC, registerPhysicsComponents, PhysicsSet } from '@forgeax/engine-physics';
6
6
  import { ChildOf } from '@forgeax/engine-scene';
7
- import { PhysicsError as PhysicsError$1 } from '@forgeax/engine-types';
7
+ import { err, ok, PhysicsError as PhysicsError$1 } from '@forgeax/engine-types';
8
8
 
9
9
  // src/rapier-physics-world-3d.ts
10
10
  function createPhysicsChangeQueries(world, components) {
@@ -55,6 +55,41 @@ function rapierBodyTypeToString(rapier, bodyType) {
55
55
  if (bodyType === rapier.RigidBodyType.Fixed) return "static";
56
56
  return "kinematic";
57
57
  }
58
+ function validateConstraintInput(input) {
59
+ const finiteVector = (value) => value.length === 3 && value.every(Number.isFinite);
60
+ if (typeof input.id !== "string" || input.id.trim().length === 0 || !Number.isInteger(input.revision) || input.revision < 0 || !Number.isInteger(input.bodyA) || !Number.isInteger(input.bodyB) || input.bodyA === input.bodyB || !finiteVector(input.anchorA) || !finiteVector(input.anchorB)) {
61
+ return err(
62
+ new DerivedPhysicsError(
63
+ "derived-constraint-invalid",
64
+ "constraint identity, revision, endpoint bodies, and anchors are valid",
65
+ "supply two distinct live bodies and finite local anchors",
66
+ { constraintId: input.id }
67
+ )
68
+ );
69
+ }
70
+ if (input.kind === "spring") {
71
+ if (!Number.isFinite(input.restLength) || input.restLength < 0 || !Number.isFinite(input.stiffness) || input.stiffness < 0 || !Number.isFinite(input.damping) || input.damping < 0) {
72
+ return err(
73
+ new DerivedPhysicsError(
74
+ "derived-constraint-invalid",
75
+ "spring rest length, stiffness, and damping are finite and non-negative",
76
+ "repair spring tuning before native creation",
77
+ { constraintId: input.id }
78
+ )
79
+ );
80
+ }
81
+ } else if (!finiteVector(input.axis) || Math.hypot(input.axis[0], input.axis[1], input.axis[2]) < 1e-6 || input.limits !== void 0 && (!Number.isFinite(input.limits[0]) || !Number.isFinite(input.limits[1]) || input.limits[0] > input.limits[1])) {
82
+ return err(
83
+ new DerivedPhysicsError(
84
+ "derived-constraint-invalid",
85
+ "hinge axis is non-zero and optional limits are ordered finite values",
86
+ "normalize the hinge axis and set minLimit <= maxLimit",
87
+ { constraintId: input.id }
88
+ )
89
+ );
90
+ }
91
+ return ok(input);
92
+ }
58
93
  var RapierPhysicsWorld3D = class {
59
94
  /** Rapier 3D World instance owning all bodies, colliders, and pipeline. */
60
95
  raw;
@@ -75,6 +110,26 @@ var RapierPhysicsWorld3D = class {
75
110
  collisionPairs = /* @__PURE__ */ new Map();
76
111
  pendingCollisionEvents = [];
77
112
  collisionEventHistory = [];
113
+ /** One backend owns every derived shape; these maps are not a second world. */
114
+ derivedBodies = /* @__PURE__ */ new Map();
115
+ derivedCandidates = /* @__PURE__ */ new Map();
116
+ pendingDerivedCandidates = /* @__PURE__ */ new Set();
117
+ retiredDerivedBodies = [];
118
+ derivedColliderToShape = /* @__PURE__ */ new Map();
119
+ derivedConstraints = /* @__PURE__ */ new Map();
120
+ derivedBodySources = /* @__PURE__ */ new Map();
121
+ derivedPublications = /* @__PURE__ */ new Map();
122
+ derivedFailures = /* @__PURE__ */ new Map();
123
+ derivedContacts = [];
124
+ derivedPoisonedEntities = /* @__PURE__ */ new Set();
125
+ physicsOwner = {};
126
+ candidateSequence = 0;
127
+ derivedCandidateBytes = 0;
128
+ backendGeneration = 1;
129
+ fixedStep = 0;
130
+ derivedPublicationPending = false;
131
+ worldIdentity;
132
+ activeDerivedAdmission;
78
133
  currentGravity;
79
134
  /**
80
135
  * Lazily-built Rapier KinematicCharacterController per character entity
@@ -117,6 +172,14 @@ var RapierPhysicsWorld3D = class {
117
172
  }
118
173
  raycast(origin, direction, maxDist, filterMask) {
119
174
  this.assertActive("raycast");
175
+ if (this.recoveryBlocked()) {
176
+ throw new DerivedPhysicsError(
177
+ "derived-recovery-invalid",
178
+ "raycasts observe a complete healthy physics state",
179
+ "rebuild the PhysicsWorld before querying after an unrecoverable admission",
180
+ {}
181
+ );
182
+ }
120
183
  const RAPIER = this.rapierModule;
121
184
  const RayCtor = RAPIER.Ray;
122
185
  const ray = new RayCtor(
@@ -151,8 +214,37 @@ var RapierPhysicsWorld3D = class {
151
214
  }
152
215
  step(deltaTime) {
153
216
  this.assertActive("step");
154
- this.raw.step(this.eventQueue);
155
- this.drainRapierCollisionEvents();
217
+ try {
218
+ this.processDerivedCandidates();
219
+ if (this.derivedPoisonedEntities.size > 0) return;
220
+ this.raw.step(this.eventQueue);
221
+ this.fixedStep = this.syncState?.world.getResource(FixedTime).tick ?? this.fixedStep + 1;
222
+ this.drainRapierCollisionEvents();
223
+ this.derivedPublicationPending = true;
224
+ if (this.syncState === void 0) this.finalizeDerivedFixedStep();
225
+ } catch (cause) {
226
+ const error = new DerivedPhysicsError(
227
+ "derived-backend-failed",
228
+ "native fixed-step execution and publication complete together",
229
+ "rebuild the World and PhysicsWorld from the last committed snapshot",
230
+ { reason: cause instanceof Error ? cause.message : String(cause) }
231
+ );
232
+ for (const entity of this.entityMap.keys()) this.derivedPoisonedEntities.add(entity);
233
+ this.derivedPublicationPending = false;
234
+ this.derivedPublications.clear();
235
+ for (const record of this.derivedCandidates.values()) {
236
+ this.rememberDerivedFailure(record, error, "rebuild-required");
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ /** Publish only after the fixed-step ECS writeback/contact boundary. */
242
+ finalizeDerivedFixedStep() {
243
+ if (!this.derivedPublicationPending) return;
244
+ this.derivedPublicationPending = false;
245
+ if (this.derivedPoisonedEntities.size > 0) return;
246
+ this.publishDerivedCandidates();
247
+ this.retireDerivedBodies();
156
248
  }
157
249
  /**
158
250
  * Drain the Rapier event queue into `collisionPairs`. Each event names two
@@ -166,15 +258,67 @@ var RapierPhysicsWorld3D = class {
166
258
  const a = this.colliderHandleToEntity(handle1);
167
259
  const b = this.colliderHandleToEntity(handle2);
168
260
  if (a === void 0 || b === void 0) return;
261
+ const shapeA = this.derivedColliderToShape.get(handle1);
262
+ const shapeB = this.derivedColliderToShape.get(handle2);
263
+ this.recordContactObservation(
264
+ {
265
+ phase: started ? "started" : "stopped",
266
+ fixedStep: this.fixedStep,
267
+ entityA: a,
268
+ entityB: b,
269
+ ...shapeA === void 0 ? {} : { shapeA: shapeA.id },
270
+ ...shapeB === void 0 ? {} : { shapeB: shapeB.id }
271
+ },
272
+ handle1,
273
+ handle2
274
+ );
169
275
  const changed = started ? this.addPair(a, b) : this.removePair(a, b);
170
276
  if (!changed) return;
171
277
  this.pushCollisionEvent({
172
278
  type: started ? "started" : "stopped",
173
279
  entityA: a,
174
- entityB: b
280
+ entityB: b,
281
+ fixedStep: this.fixedStep,
282
+ ...shapeA === void 0 ? {} : { shapeA: shapeA.id },
283
+ ...shapeB === void 0 ? {} : { shapeB: shapeB.id }
175
284
  });
176
285
  });
177
286
  }
287
+ recordContactObservation(observation, handleA, handleB) {
288
+ let point;
289
+ let normal;
290
+ try {
291
+ const colliderA = this.raw.getCollider(handleA);
292
+ const colliderB = this.raw.getCollider(handleB);
293
+ if (colliderA !== null && colliderB !== null) {
294
+ this.raw.contactPair(
295
+ colliderA,
296
+ colliderB,
297
+ (manifold, flipped) => {
298
+ if (manifold.numSolverContacts?.() > 0) {
299
+ const contact = manifold.solverContactPoint(0);
300
+ const n = manifold.normal();
301
+ if (contact !== null && contact !== void 0) {
302
+ point = [contact.x, contact.y, contact.z];
303
+ }
304
+ if (n !== null && n !== void 0) {
305
+ const direction = flipped ? -1 : 1;
306
+ normal = [n.x * direction, n.y * direction, n.z * direction];
307
+ }
308
+ }
309
+ }
310
+ );
311
+ }
312
+ } catch {
313
+ }
314
+ this.derivedContacts.push({
315
+ ...observation,
316
+ ...point === void 0 ? {} : { point },
317
+ ...normal === void 0 ? {} : { normal }
318
+ });
319
+ if (this.derivedContacts.length > 256)
320
+ this.derivedContacts.splice(0, this.derivedContacts.length - 256);
321
+ }
178
322
  /** Resolve a Rapier collider handle to its owning ECS entity, or undefined. */
179
323
  colliderHandleToEntity(colliderHandle) {
180
324
  const collider = this.raw.getCollider(colliderHandle);
@@ -205,7 +349,14 @@ var RapierPhysicsWorld3D = class {
205
349
  return removedA || removedB;
206
350
  }
207
351
  pushCollisionEvent(event) {
208
- const ordered = event.entityA <= event.entityB ? event : { ...event, entityA: event.entityB, entityB: event.entityA };
352
+ const ordered = event.entityA <= event.entityB ? event : {
353
+ type: event.type,
354
+ entityA: event.entityB,
355
+ entityB: event.entityA,
356
+ ...event.fixedStep === void 0 ? {} : { fixedStep: event.fixedStep },
357
+ ...event.shapeB === void 0 ? {} : { shapeA: event.shapeB },
358
+ ...event.shapeA === void 0 ? {} : { shapeB: event.shapeA }
359
+ };
209
360
  this.pendingCollisionEvents.push(ordered);
210
361
  this.collisionEventHistory.push(ordered);
211
362
  }
@@ -232,6 +383,1276 @@ var RapierPhysicsWorld3D = class {
232
383
  getCollisionEventHistory() {
233
384
  return [...this.collisionEventHistory];
234
385
  }
386
+ /** Detached fixed-step contact facts; no Rapier manifolds or handles escape. */
387
+ getContactObservations() {
388
+ if (this.recoveryBlocked()) return [];
389
+ return this.derivedContacts.map((contact) => ({
390
+ ...contact,
391
+ ...contact.point === void 0 ? {} : { point: [...contact.point] },
392
+ ...contact.normal === void 0 ? {} : { normal: [...contact.normal] }
393
+ }));
394
+ }
395
+ /** Prepare disabled native Voxels for one entity without changing queries. */
396
+ prepareDerivedShapeCandidate(input) {
397
+ this.assertActive("prepareDerivedShapeCandidate");
398
+ if (input.worldIdentity !== void 0 && input.worldIdentity !== this.worldIdentity) {
399
+ return err(
400
+ new DerivedPhysicsError(
401
+ "derived-world-mismatch",
402
+ "candidate belongs to the ECS World bound to this PhysicsWorld",
403
+ "submit the candidate to the PhysicsWorld that owns its entity",
404
+ { entity: input.entity }
405
+ )
406
+ );
407
+ }
408
+ if (!this.entityMap.has(input.entity)) {
409
+ return err(
410
+ new DerivedPhysicsError(
411
+ "derived-body-not-found",
412
+ "candidate entity has a committed body in this PhysicsWorld",
413
+ "wait for physics reconciliation before preparing derived shapes",
414
+ { entity: input.entity }
415
+ )
416
+ );
417
+ }
418
+ if (this.derivedPoisonedEntities.has(input.entity)) {
419
+ return err(
420
+ new DerivedPhysicsError(
421
+ "derived-recovery-invalid",
422
+ "the entity has a recoverable committed native PhysicsWorld state",
423
+ "rebuild the PhysicsWorld from the last portable snapshot before retrying",
424
+ { entity: input.entity }
425
+ )
426
+ );
427
+ }
428
+ const active = this.derivedBodies.get(input.entity);
429
+ if (active !== void 0 && input.revision <= active.revision) {
430
+ return err(
431
+ new DerivedPhysicsError(
432
+ "derived-candidate-stale",
433
+ "candidate revision is newer than the committed derived shape revision",
434
+ "read the latest publication and advance the consumer revision",
435
+ { entity: input.entity, actual: input.revision, expected: `>${active.revision}` }
436
+ )
437
+ );
438
+ }
439
+ for (const pendingId of this.pendingDerivedCandidates) {
440
+ const pending = this.derivedCandidates.get(pendingId);
441
+ if (pending?.input.entity === input.entity) {
442
+ return err(
443
+ new DerivedPhysicsError(
444
+ "derived-candidate-pending",
445
+ "one body has at most one queued derived-shape candidate",
446
+ "cancel or let the current candidate publish before preparing another",
447
+ { entity: input.entity, candidateId: pendingId }
448
+ )
449
+ );
450
+ }
451
+ }
452
+ const copied = cloneDerivedPhysicsInput(input);
453
+ if (!copied.ok) return copied;
454
+ const mass = validateMassProperties(copied.value.massProperties);
455
+ if (!mass.ok) return mass;
456
+ const body = this.bodyForEntity(input.entity);
457
+ if (body === void 0) {
458
+ return err(
459
+ new DerivedPhysicsError(
460
+ "derived-body-not-found",
461
+ "candidate entity resolves to a live native body",
462
+ "wait for the next ECS physics sync",
463
+ { entity: input.entity }
464
+ )
465
+ );
466
+ }
467
+ if (this.derivedCandidates.size >= DERIVED_PHYSICS_LIMITS.maxCandidates) {
468
+ return err(
469
+ new DerivedPhysicsError(
470
+ "derived-candidate-budget-exceeded",
471
+ `this PhysicsWorld keeps at most ${DERIVED_PHYSICS_LIMITS.maxCandidates} candidates`,
472
+ "cancel or publish an existing candidate before preparing another",
473
+ { entity: input.entity, actual: this.derivedCandidates.size }
474
+ )
475
+ );
476
+ }
477
+ const candidateInput = Object.freeze({
478
+ ...copied.value,
479
+ ...mass.value === void 0 ? {} : { massProperties: mass.value }
480
+ });
481
+ const privateInput = cloneDerivedPhysicsInput(candidateInput);
482
+ if (!privateInput.ok) return privateInput;
483
+ const publicInput = cloneDerivedPhysicsInput(privateInput.value);
484
+ if (!publicInput.ok) return publicInput;
485
+ const nativeColliders = [];
486
+ try {
487
+ for (const shape of privateInput.value.shapes) {
488
+ const collider = this.createDerivedCollider(body, shape);
489
+ nativeColliders.push({ handle: collider.handle });
490
+ }
491
+ } catch (cause) {
492
+ for (const collider of nativeColliders) this.removeNativeCollider(collider.handle);
493
+ return err(
494
+ new DerivedPhysicsError(
495
+ "derived-backend-failed",
496
+ "Rapier can create every candidate voxel collider while it remains disabled",
497
+ "reduce the candidate or rebuild the PhysicsWorld after a native failure",
498
+ { entity: input.entity, reason: cause instanceof Error ? cause.message : String(cause) }
499
+ )
500
+ );
501
+ }
502
+ const candidateBytes = estimateDerivedPhysicsInputBytes(privateInput.value);
503
+ if (this.derivedCandidateBytes + candidateBytes > DERIVED_PHYSICS_LIMITS.maxCandidateBytes) {
504
+ for (const collider of nativeColliders) this.removeNativeCollider(collider.handle);
505
+ return err(
506
+ new DerivedPhysicsError(
507
+ "derived-candidate-budget-exceeded",
508
+ `staged candidate bytes remain within ${DERIVED_PHYSICS_LIMITS.maxCandidateBytes}`,
509
+ "cancel or retire an in-flight candidate before retrying",
510
+ { entity: input.entity, actual: this.derivedCandidateBytes + candidateBytes }
511
+ )
512
+ );
513
+ }
514
+ const candidateId = `derived:${this.backendGeneration}:${input.entity}:${++this.candidateSequence}:${input.revision}`;
515
+ const token = Object.freeze({
516
+ candidateId,
517
+ generation: this.backendGeneration,
518
+ owner: this.physicsOwner,
519
+ input: publicInput.value,
520
+ state: "ready"
521
+ });
522
+ this.derivedCandidates.set(candidateId, {
523
+ token,
524
+ nativeColliders,
525
+ input: privateInput.value,
526
+ bytes: candidateBytes,
527
+ state: "ready"
528
+ });
529
+ this.derivedCandidateBytes += candidateBytes;
530
+ return ok(token);
531
+ }
532
+ /** Queue a prepared candidate for the next call to `step()`. */
533
+ admitDerivedShapeCandidate(candidate, commitGeometry) {
534
+ const admitted = this.admitDerivedShapeCandidateInternal(candidate);
535
+ if (admitted.ok && commitGeometry !== void 0) {
536
+ const record = this.derivedCandidates.get(candidate.candidateId);
537
+ if (record !== void 0) record.commitGeometry = commitGeometry;
538
+ }
539
+ return admitted;
540
+ }
541
+ getDerivedAdmission(entity) {
542
+ const record = this.activeDerivedAdmission;
543
+ if (record === void 0 || entity !== void 0 && record.input.entity !== entity)
544
+ return void 0;
545
+ return {
546
+ entity: record.input.entity,
547
+ revision: record.input.revision,
548
+ fixedStep: this.syncState?.world.getResource(FixedTime).tick ?? this.fixedStep + 1
549
+ };
550
+ }
551
+ admitDerivedShapeCandidateInternal(candidate, sourceOverrides) {
552
+ this.assertActive("admitDerivedShapeCandidate");
553
+ const record = this.derivedCandidates.get(candidate.candidateId);
554
+ if (record === void 0 || candidate.owner !== this.physicsOwner || record.token.owner !== candidate.owner || candidate.generation !== this.backendGeneration) {
555
+ return err(
556
+ new DerivedPhysicsError(
557
+ "derived-candidate-not-found",
558
+ "candidate belongs to the current PhysicsWorld generation",
559
+ "discard stale candidate credentials and prepare from committed input again",
560
+ { candidateId: candidate.candidateId }
561
+ )
562
+ );
563
+ }
564
+ if (this.derivedPoisonedEntities.has(record?.input.entity ?? -1)) {
565
+ return err(
566
+ new DerivedPhysicsError(
567
+ "derived-recovery-invalid",
568
+ "the candidate entity is stopped after an unrecoverable native admission failure",
569
+ "rebuild the PhysicsWorld from its portable snapshot before retrying",
570
+ { entity: record?.input.entity, candidateId: candidate.candidateId }
571
+ )
572
+ );
573
+ }
574
+ if (record.state === "cancelled" || record.state === "invalidated") {
575
+ return err(
576
+ new DerivedPhysicsError(
577
+ "derived-candidate-cancelled",
578
+ "candidate has not been cancelled or invalidated",
579
+ "prepare a new candidate from the latest committed revision",
580
+ { candidateId: candidate.candidateId }
581
+ )
582
+ );
583
+ }
584
+ if (record.state !== "ready") {
585
+ return err(
586
+ new DerivedPhysicsError(
587
+ "derived-candidate-pending",
588
+ "a prepared candidate is admitted at most once",
589
+ "retain the returned queued receipt and wait for fixed-step publication",
590
+ { candidateId: candidate.candidateId }
591
+ )
592
+ );
593
+ }
594
+ const pendingSources = this.pendingDerivedSources();
595
+ if (sourceOverrides !== void 0) {
596
+ for (const [entity, source] of sourceOverrides) pendingSources.set(entity, source);
597
+ }
598
+ pendingSources.set(record.input.entity, {
599
+ sourceKey: record.input.sourceKey,
600
+ revision: record.input.revision
601
+ });
602
+ const admissionError = this.validateDerivedAdmission(record.input, pendingSources);
603
+ if (admissionError !== void 0) {
604
+ this.rejectPreparedCandidate(record, admissionError);
605
+ return err(admissionError);
606
+ }
607
+ const active = this.derivedBodies.get(record.input.entity);
608
+ if (active !== void 0 && record.input.revision <= active.revision) {
609
+ const stale = new DerivedPhysicsError(
610
+ "derived-candidate-stale",
611
+ "candidate revision is newer than the committed shape revision",
612
+ "advance the consumer revision before admission",
613
+ { entity: record.input.entity, candidateId: candidate.candidateId }
614
+ );
615
+ this.rejectPreparedCandidate(record, stale);
616
+ return err(stale);
617
+ }
618
+ const newestPendingRevision = this.newestPendingRevision(record.input.entity);
619
+ if (newestPendingRevision !== void 0 && record.input.revision <= newestPendingRevision) {
620
+ const stale = new DerivedPhysicsError(
621
+ "derived-candidate-stale",
622
+ "candidate revision advances every already queued revision for the body",
623
+ "admit only the newest body revision at a fixed-step boundary",
624
+ {
625
+ entity: record.input.entity,
626
+ candidateId: candidate.candidateId,
627
+ expected: `>${newestPendingRevision}`,
628
+ actual: record.input.revision
629
+ }
630
+ );
631
+ this.rejectPreparedCandidate(record, stale);
632
+ return err(stale);
633
+ }
634
+ record.state = "queued";
635
+ this.pendingDerivedCandidates.add(candidate.candidateId);
636
+ const queued = Object.freeze({ ...record.token, state: "queued" });
637
+ record.token = queued;
638
+ let changed = true;
639
+ while (changed) {
640
+ changed = false;
641
+ const projectedSources = new Map(sourceOverrides ?? []);
642
+ for (const [entity, source] of this.pendingDerivedSources()) {
643
+ const current = projectedSources.get(entity);
644
+ if (current === void 0 || source.revision > current.revision)
645
+ projectedSources.set(entity, source);
646
+ }
647
+ for (const queuedId of [...this.pendingDerivedCandidates]) {
648
+ const queuedRecord = this.derivedCandidates.get(queuedId);
649
+ if (queuedRecord?.state !== "queued") continue;
650
+ const projected = projectedSources.get(queuedRecord.input.entity);
651
+ const staleRevision = projected !== void 0 && queuedRecord.input.revision < projected.revision;
652
+ const dependencyError = staleRevision ? new DerivedPhysicsError(
653
+ "derived-candidate-stale",
654
+ "queued candidates publish only the final revision submitted for an entity",
655
+ "discard the older queued candidate and submit one complete revision",
656
+ {
657
+ entity: queuedRecord.input.entity,
658
+ candidateId: queuedRecord.token.candidateId,
659
+ expected: `>=${projected.revision}`,
660
+ actual: queuedRecord.input.revision
661
+ }
662
+ ) : this.validateDerivedAdmission(queuedRecord.input, projectedSources);
663
+ if (dependencyError === void 0) continue;
664
+ this.rejectPreparedCandidate(queuedRecord, dependencyError);
665
+ changed = true;
666
+ if (queuedId === candidate.candidateId) return err(dependencyError);
667
+ }
668
+ }
669
+ return ok(queued);
670
+ }
671
+ /** Cancel candidate-native resources; the committed state remains untouched. */
672
+ cancelDerivedShapeCandidate(candidate) {
673
+ this.assertActive("cancelDerivedShapeCandidate");
674
+ const record = this.derivedCandidates.get(candidate.candidateId);
675
+ if (record === void 0 || candidate.owner !== this.physicsOwner) {
676
+ return err(
677
+ new DerivedPhysicsError(
678
+ "derived-candidate-not-found",
679
+ "candidate belongs to the current PhysicsWorld",
680
+ "ignore already-retired credentials and prepare again when needed",
681
+ { candidateId: candidate.candidateId }
682
+ )
683
+ );
684
+ }
685
+ if (record.state === "published") {
686
+ return err(
687
+ new DerivedPhysicsError(
688
+ "derived-candidate-cancelled",
689
+ "a published candidate remains the committed result until replaced",
690
+ "submit a newer candidate instead of cancelling committed state",
691
+ { candidateId: candidate.candidateId }
692
+ )
693
+ );
694
+ }
695
+ this.pendingDerivedCandidates.delete(candidate.candidateId);
696
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
697
+ record.state = "cancelled";
698
+ this.releaseDerivedCandidate(candidate.candidateId);
699
+ return ok(void 0);
700
+ }
701
+ /** Reject all in-flight derived work while preserving the last publication. */
702
+ invalidateDerivedShapeCandidates(reason = "consumer-invalidated") {
703
+ const committedCandidateIds = new Set(
704
+ [...this.derivedBodies.values()].map((body) => body.candidateId)
705
+ );
706
+ for (const [id, record] of this.derivedCandidates) {
707
+ if (record.state === "published" || committedCandidateIds.has(id)) continue;
708
+ record.state = "invalidated";
709
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
710
+ this.releaseDerivedCandidate(id);
711
+ }
712
+ this.pendingDerivedCandidates.clear();
713
+ }
714
+ getDerivedPublication(entity) {
715
+ this.assertActive("getDerivedPublication");
716
+ if (this.recoveryBlocked()) return void 0;
717
+ const publication = this.derivedPublications.get(entity);
718
+ return publication === void 0 ? void 0 : { ...publication, shapeIds: [...publication.shapeIds] };
719
+ }
720
+ getDerivedFailure(entity) {
721
+ const failure = this.derivedFailures.get(entity);
722
+ return failure === void 0 ? void 0 : { ...failure };
723
+ }
724
+ getDerivedBodyType(entity) {
725
+ this.assertActive("getDerivedBodyType");
726
+ if (this.recoveryBlocked()) return void 0;
727
+ const body = this.bodyForEntity(entity);
728
+ if (body === void 0) return void 0;
729
+ return rapierBodyTypeToString(this.rapierModule, body.bodyType());
730
+ }
731
+ getDerivedBodyMass(entity) {
732
+ this.assertActive("getDerivedBodyMass");
733
+ if (this.recoveryBlocked()) return void 0;
734
+ const body = this.bodyForEntity(entity);
735
+ return body === void 0 ? void 0 : body.mass();
736
+ }
737
+ getDerivedMotion(entity) {
738
+ this.assertActive("getDerivedMotion");
739
+ if (this.recoveryBlocked() || !this.derivedBodies.has(entity)) return void 0;
740
+ const body = this.bodyForEntity(entity);
741
+ if (body === void 0) return void 0;
742
+ const com = body.worldCom();
743
+ const linear = body.linvel();
744
+ const angular = body.angvel();
745
+ return Object.freeze({
746
+ centerOfMass: [com.x, com.y, com.z],
747
+ linearVelocity: [linear.x, linear.y, linear.z],
748
+ angularVelocity: [angular.x, angular.y, angular.z]
749
+ });
750
+ }
751
+ getDerivedRecoveryState() {
752
+ return this.recoveryBlocked() ? "rebuild-required" : "ready";
753
+ }
754
+ getDerivedShapes(entity) {
755
+ this.assertActive("getDerivedShapes");
756
+ if (this.recoveryBlocked()) return [];
757
+ const body = this.derivedBodies.get(entity);
758
+ if (body === void 0) return [];
759
+ return body.shapes.map((shape) => ({
760
+ id: shape.input.id,
761
+ revision: shape.input.revision,
762
+ entity,
763
+ voxelSize: [...shape.input.voxelSize],
764
+ origin: [...shape.input.origin],
765
+ rotation: [...shape.input.rotation],
766
+ generation: body.generation
767
+ }));
768
+ }
769
+ captureDerivedPhysicsState() {
770
+ this.assertActive("captureDerivedPhysicsState");
771
+ return Object.freeze({
772
+ generation: this.backendGeneration,
773
+ fixedStep: this.fixedStep,
774
+ bodies: Object.freeze(
775
+ [...this.derivedBodies.values()].map((body) => ({
776
+ entity: body.entity,
777
+ revision: body.revision,
778
+ sourceKey: body.sourceKey,
779
+ ...body.bodyType === void 0 ? {} : { bodyType: body.bodyType },
780
+ ...body.velocityPolicy === void 0 ? {} : { velocityPolicy: body.velocityPolicy },
781
+ shapes: Object.freeze(
782
+ body.shapes.map((shape) => ({
783
+ ...shape.input,
784
+ cells: new Int32Array(shape.input.cells),
785
+ voxelSize: [...shape.input.voxelSize],
786
+ origin: [...shape.input.origin],
787
+ rotation: [...shape.input.rotation]
788
+ }))
789
+ ),
790
+ seams: Object.freeze(
791
+ body.seams.map((seam) => ({ ...seam, offset: [...seam.offset] }))
792
+ ),
793
+ ...body.massProperties === void 0 ? {} : { massProperties: body.massProperties },
794
+ ...(() => {
795
+ const motion = this.getDerivedMotion(body.entity);
796
+ return motion === void 0 ? {} : { motion };
797
+ })(),
798
+ constraints: Object.freeze(this.constraintsForBody(body.entity))
799
+ }))
800
+ )
801
+ });
802
+ }
803
+ restoreDerivedPhysicsState(snapshot) {
804
+ this.assertActive("restoreDerivedPhysicsState");
805
+ const snapshotSources = /* @__PURE__ */ new Map();
806
+ for (const body of snapshot.bodies) {
807
+ if (snapshotSources.has(body.entity)) {
808
+ return err(
809
+ new DerivedPhysicsError(
810
+ "derived-candidate-invalid",
811
+ "a portable snapshot contains one committed body row per entity",
812
+ "capture the snapshot from one PhysicsWorld without duplicate entities",
813
+ { entity: body.entity }
814
+ )
815
+ );
816
+ }
817
+ snapshotSources.set(body.entity, {
818
+ sourceKey: body.sourceKey,
819
+ revision: body.revision
820
+ });
821
+ }
822
+ const preparedCandidates = [];
823
+ const restoredConstraintIds = /* @__PURE__ */ new Set();
824
+ for (const body of snapshot.bodies) {
825
+ const prepared = this.prepareDerivedShapeCandidate({
826
+ entity: body.entity,
827
+ revision: body.revision,
828
+ sourceKey: body.sourceKey,
829
+ shapes: body.shapes,
830
+ ...body.seams === void 0 || body.seams.length === 0 ? {} : { seams: body.seams },
831
+ ...body.bodyType === void 0 ? {} : { bodyType: body.bodyType },
832
+ ...body.velocityPolicy === void 0 ? {} : { velocityPolicy: body.velocityPolicy },
833
+ ...body.motion === void 0 ? {} : { motion: body.motion },
834
+ ...body.massProperties === void 0 ? {} : { massProperties: body.massProperties },
835
+ constraints: body.constraints.filter((constraint) => {
836
+ if (restoredConstraintIds.has(constraint.id)) return false;
837
+ restoredConstraintIds.add(constraint.id);
838
+ return true;
839
+ })
840
+ });
841
+ if (!prepared.ok) {
842
+ for (const candidate of preparedCandidates) this.cancelDerivedShapeCandidate(candidate);
843
+ return prepared;
844
+ }
845
+ preparedCandidates.push(prepared.value);
846
+ }
847
+ const candidates = [];
848
+ for (const prepared of preparedCandidates) {
849
+ const admitted = this.admitDerivedShapeCandidateInternal(prepared, snapshotSources);
850
+ if (!admitted.ok) {
851
+ for (const candidate of preparedCandidates) this.cancelDerivedShapeCandidate(candidate);
852
+ return admitted;
853
+ }
854
+ candidates.push(admitted.value);
855
+ }
856
+ return ok(candidates);
857
+ }
858
+ createDerivedConstraint(input) {
859
+ return this.installDerivedConstraint(input, false);
860
+ }
861
+ updateDerivedConstraint(input) {
862
+ return this.installDerivedConstraint(input, true);
863
+ }
864
+ removeDerivedConstraint(id) {
865
+ this.assertActive("removeDerivedConstraint");
866
+ const existing = this.derivedConstraints.get(id);
867
+ if (existing === void 0) {
868
+ return err(
869
+ new DerivedPhysicsError(
870
+ "derived-constraint-not-found",
871
+ "constraint identity is currently committed",
872
+ "ignore repeated cleanup or reconcile the owning constraint set",
873
+ { constraintId: id }
874
+ )
875
+ );
876
+ }
877
+ this.removeNativeConstraint(existing.handle);
878
+ this.derivedConstraints.delete(id);
879
+ return ok(void 0);
880
+ }
881
+ createDerivedCollider(body, shape) {
882
+ const RAPIER = this.rapierModule;
883
+ const desc = RAPIER.ColliderDesc.voxels(
884
+ shape.cells instanceof Int32Array ? new Int32Array(shape.cells) : new Int32Array(shape.cells.flat()),
885
+ { x: shape.voxelSize[0], y: shape.voxelSize[1], z: shape.voxelSize[2] }
886
+ ).setTranslation(shape.origin?.[0] ?? 0, shape.origin?.[1] ?? 0, shape.origin?.[2] ?? 0).setRotation({
887
+ x: shape.rotation?.[0] ?? 0,
888
+ y: shape.rotation?.[1] ?? 0,
889
+ z: shape.rotation?.[2] ?? 0,
890
+ w: shape.rotation?.[3] ?? 1
891
+ }).setFriction(shape.friction ?? 0.5).setRestitution(shape.restitution ?? 0).setDensity(0).setCollisionGroups(shape.collisionGroups ?? 4294967295).setSolverGroups(shape.solverGroups ?? 4294967295).setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS).setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL).setEnabled(false);
892
+ if (shape.isSensor === true) desc.setSensor(true);
893
+ const collider = this.raw.createCollider(desc, body);
894
+ this.derivedColliderToShape.set(collider.handle, { entity: body.userData, id: shape.id });
895
+ return collider;
896
+ }
897
+ removeNativeCollider(handle) {
898
+ this.derivedColliderToShape.delete(handle);
899
+ try {
900
+ const collider = this.raw.getCollider(handle);
901
+ if (collider !== null && collider !== void 0) {
902
+ this.raw.removeCollider(collider, false);
903
+ }
904
+ } catch {
905
+ }
906
+ }
907
+ createNativeConstraint(input) {
908
+ let native;
909
+ try {
910
+ const RAPIER = this.rapierModule;
911
+ const bodyA = this.bodyForEntity(input.bodyA);
912
+ const bodyB = this.bodyForEntity(input.bodyB);
913
+ if (bodyA === void 0 || bodyB === void 0) {
914
+ return err(
915
+ new DerivedPhysicsError(
916
+ "derived-body-not-found",
917
+ "both constraint endpoints have committed bodies in this PhysicsWorld",
918
+ "reconcile both entities before creating the constraint",
919
+ { constraintId: input.id }
920
+ )
921
+ );
922
+ }
923
+ const anchorA = { x: input.anchorA[0], y: input.anchorA[1], z: input.anchorA[2] };
924
+ const anchorB = { x: input.anchorB[0], y: input.anchorB[1], z: input.anchorB[2] };
925
+ const jointData = input.kind === "spring" ? RAPIER.JointData.spring(
926
+ input.restLength,
927
+ input.stiffness,
928
+ input.damping,
929
+ anchorA,
930
+ anchorB
931
+ ) : RAPIER.JointData.revolute(anchorA, anchorB, {
932
+ x: input.axis[0],
933
+ y: input.axis[1],
934
+ z: input.axis[2]
935
+ });
936
+ native = this.raw.createImpulseJoint(jointData, bodyA, bodyB, true);
937
+ if (input.kind === "hinge" && input.limits !== void 0)
938
+ native.setLimits(input.limits[0], input.limits[1]);
939
+ return ok({ handle: native.handle });
940
+ } catch (cause) {
941
+ if (native !== void 0) this.removeNativeConstraint(native.handle);
942
+ return err(
943
+ new DerivedPhysicsError(
944
+ "derived-backend-failed",
945
+ "the selected Rapier joint can be created for both endpoint bodies",
946
+ "repair endpoint state or use a supported spring/hinge input",
947
+ {
948
+ constraintId: input.id,
949
+ reason: cause instanceof Error ? cause.message : String(cause)
950
+ }
951
+ )
952
+ );
953
+ }
954
+ }
955
+ rememberDerivedFailure(record, error, recovery) {
956
+ record.state = "failed";
957
+ this.pendingDerivedCandidates.delete(record.token.candidateId);
958
+ this.derivedFailures.set(
959
+ record.input.entity,
960
+ Object.freeze({
961
+ candidateId: record.token.candidateId,
962
+ entity: record.input.entity,
963
+ revision: record.input.revision,
964
+ fixedStep: this.fixedStep,
965
+ error,
966
+ recovery
967
+ })
968
+ );
969
+ this.releaseDerivedCandidate(record.token.candidateId);
970
+ }
971
+ rejectPreparedCandidate(record, error) {
972
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
973
+ this.rememberDerivedFailure(record, error, "old-state-retained");
974
+ }
975
+ processDerivedCandidates() {
976
+ if (this.pendingDerivedCandidates.size === 0) return;
977
+ const pendingRecords = [...this.pendingDerivedCandidates].map((id) => this.derivedCandidates.get(id)).filter((record) => record?.state === "queued");
978
+ const pendingByEntity = /* @__PURE__ */ new Map();
979
+ for (const record of pendingRecords) {
980
+ const current = pendingByEntity.get(record.input.entity);
981
+ if (current === void 0 || record.input.revision > current.input.revision) {
982
+ pendingByEntity.set(record.input.entity, record);
983
+ }
984
+ }
985
+ const ordered = [];
986
+ const visiting = /* @__PURE__ */ new Set();
987
+ const visited = /* @__PURE__ */ new Set();
988
+ const visit = (record) => {
989
+ if (visited.has(record.token.candidateId)) return;
990
+ if (visiting.has(record.token.candidateId)) return;
991
+ visiting.add(record.token.candidateId);
992
+ for (const constraint of record.input.constraints ?? []) {
993
+ for (const endpoint of [
994
+ [constraint.bodyA, constraint.bodyASource],
995
+ [constraint.bodyB, constraint.bodyBSource]
996
+ ]) {
997
+ const dependency = endpoint[1];
998
+ const target = pendingByEntity.get(endpoint[0]);
999
+ if (target !== void 0 && target.input.sourceKey === dependency.sourceKey && target.input.revision === dependency.revision) {
1000
+ visit(target);
1001
+ }
1002
+ }
1003
+ }
1004
+ visiting.delete(record.token.candidateId);
1005
+ visited.add(record.token.candidateId);
1006
+ ordered.push(record);
1007
+ };
1008
+ for (const record of pendingRecords) visit(record);
1009
+ for (const record of ordered) {
1010
+ const id = record.token.candidateId;
1011
+ if (!this.pendingDerivedCandidates.has(id) || record.state !== "queued") continue;
1012
+ const pendingSources = this.pendingDerivedSources();
1013
+ const projected = pendingSources.get(record.input.entity);
1014
+ if (projected !== void 0 && record.input.revision < projected.revision) {
1015
+ this.rejectPreparedCandidate(
1016
+ record,
1017
+ new DerivedPhysicsError(
1018
+ "derived-candidate-stale",
1019
+ "fixed-step admission publishes only the newest queued body revision",
1020
+ "discard the older queued candidate and submit the latest complete input",
1021
+ {
1022
+ entity: record.input.entity,
1023
+ candidateId: record.token.candidateId,
1024
+ expected: `>=${projected.revision}`,
1025
+ actual: record.input.revision
1026
+ }
1027
+ )
1028
+ );
1029
+ continue;
1030
+ }
1031
+ const admissionError = this.validateDerivedAdmission(record.input, pendingSources);
1032
+ if (admissionError !== void 0) {
1033
+ this.rejectPreparedCandidate(record, admissionError);
1034
+ continue;
1035
+ }
1036
+ const old = this.derivedBodies.get(record.input.entity);
1037
+ if (this.derivedPoisonedEntities.has(record.input.entity)) {
1038
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1039
+ this.rememberDerivedFailure(
1040
+ record,
1041
+ new DerivedPhysicsError(
1042
+ "derived-recovery-invalid",
1043
+ "the entity is stopped after an unrecoverable native admission failure",
1044
+ "rebuild the PhysicsWorld from the last portable snapshot before retrying",
1045
+ { entity: record.input.entity, candidateId: record.token.candidateId }
1046
+ ),
1047
+ "rebuild-required"
1048
+ );
1049
+ continue;
1050
+ }
1051
+ const body = this.bodyForEntity(record.input.entity);
1052
+ if (body === void 0) {
1053
+ for (const collider of record.nativeColliders) this.removeNativeCollider(collider.handle);
1054
+ this.rememberDerivedFailure(
1055
+ record,
1056
+ new DerivedPhysicsError(
1057
+ "derived-body-not-found",
1058
+ "candidate entity remains a live native body at fixed-step admission",
1059
+ "reconcile the entity and submit a fresh candidate",
1060
+ { entity: record.input.entity, candidateId: record.token.candidateId }
1061
+ ),
1062
+ "old-state-retained"
1063
+ );
1064
+ continue;
1065
+ }
1066
+ const oldBodyType = body.bodyType();
1067
+ const oldBodyEnabled = body.isEnabled();
1068
+ const oldVelocity = body.linvel();
1069
+ const oldAngularVelocity = body.angvel();
1070
+ const oldTranslation = body.translation();
1071
+ const oldRotation = body.rotation();
1072
+ const oldCom = body.worldCom();
1073
+ const oldMass = body.mass();
1074
+ const oldAutomaticAdditionalMass = this.entityMap.get(record.input.entity)?.automaticAdditionalMass ?? 0;
1075
+ const oldAutomaticRecordMass = this.entityMap.get(
1076
+ record.input.entity
1077
+ )?.automaticAdditionalMass;
1078
+ const oldSource = this.derivedBodySources.get(record.input.entity);
1079
+ const oldPublication = this.derivedPublications.get(record.input.entity);
1080
+ const oldDensities = this.bodyColliders(body).map((collider) => ({
1081
+ collider,
1082
+ density: typeof collider.density === "function" ? collider.density() : void 0,
1083
+ enabled: typeof collider.isEnabled === "function" ? collider.isEnabled() : true
1084
+ }));
1085
+ const oldConstraints = new Map(this.derivedConstraints);
1086
+ const stagedConstraints = /* @__PURE__ */ new Map();
1087
+ let geometryCommitUncertain = false;
1088
+ try {
1089
+ for (const constraint of record.input.constraints ?? []) {
1090
+ const created = this.createNativeConstraint(constraint);
1091
+ if (!created.ok) throw created.error;
1092
+ stagedConstraints.set(constraint.id, {
1093
+ input: { ...constraint },
1094
+ handle: created.value.handle
1095
+ });
1096
+ }
1097
+ if (record.input.bodyType !== void 0) {
1098
+ const RAPIER = this.rapierModule;
1099
+ const bodyType = record.input.bodyType === "static" ? RAPIER.RigidBodyType.Fixed : record.input.bodyType === "kinematic" ? RAPIER.RigidBodyType.KinematicPositionBased : RAPIER.RigidBodyType.Dynamic;
1100
+ body.setBodyType(bodyType, true);
1101
+ }
1102
+ for (const native of record.nativeColliders) {
1103
+ const collider = this.raw.getCollider(native.handle);
1104
+ if (collider === null || collider === void 0)
1105
+ throw new Error("candidate collider disappeared");
1106
+ collider.setEnabled(true);
1107
+ }
1108
+ const nativeById = new Map(
1109
+ record.input.shapes.map((shape, index) => [
1110
+ shape.id,
1111
+ record.nativeColliders[index]?.handle
1112
+ ])
1113
+ );
1114
+ for (const seam of record.input.seams ?? []) {
1115
+ const firstHandle = nativeById.get(seam.shapeA);
1116
+ const secondHandle = nativeById.get(seam.shapeB);
1117
+ const first = firstHandle === void 0 ? void 0 : this.raw.getCollider(firstHandle);
1118
+ const second = secondHandle === void 0 ? void 0 : this.raw.getCollider(secondHandle);
1119
+ if (first === void 0 || second === void 0 || first === null || second === null) {
1120
+ throw new Error(`derived seam references missing shape ${seam.shapeA}`);
1121
+ }
1122
+ first.combineVoxelStates(second, seam.offset[0], seam.offset[1], seam.offset[2]);
1123
+ }
1124
+ this.applyDerivedMass(
1125
+ body,
1126
+ record.input.massProperties,
1127
+ oldCom,
1128
+ record.input.velocityPolicy ?? "preserve",
1129
+ this.entityMap.get(record.input.entity)?.additionalMass ?? 0,
1130
+ record.input.entity,
1131
+ record.input.shapes,
1132
+ record.nativeColliders
1133
+ );
1134
+ if (record.input.motion !== void 0) {
1135
+ const currentCom = body.worldCom();
1136
+ const targetCom = record.input.motion.centerOfMass;
1137
+ const translation = body.translation();
1138
+ body.setTranslation(
1139
+ {
1140
+ x: translation.x + targetCom[0] - currentCom.x,
1141
+ y: translation.y + targetCom[1] - currentCom.y,
1142
+ z: translation.z + targetCom[2] - currentCom.z
1143
+ },
1144
+ true
1145
+ );
1146
+ body.setLinvel(
1147
+ {
1148
+ x: record.input.motion.linearVelocity[0],
1149
+ y: record.input.motion.linearVelocity[1],
1150
+ z: record.input.motion.linearVelocity[2]
1151
+ },
1152
+ true
1153
+ );
1154
+ body.setAngvel(
1155
+ {
1156
+ x: record.input.motion.angularVelocity[0],
1157
+ y: record.input.motion.angularVelocity[1],
1158
+ z: record.input.motion.angularVelocity[2]
1159
+ },
1160
+ true
1161
+ );
1162
+ }
1163
+ const committedSources = new Map(this.derivedBodySources);
1164
+ committedSources.set(record.input.entity, {
1165
+ sourceKey: record.input.sourceKey,
1166
+ revision: record.input.revision
1167
+ });
1168
+ const replacementConstraintIds = new Set(
1169
+ (record.input.constraints ?? []).map((constraint) => constraint.id)
1170
+ );
1171
+ for (const [constraintId, current] of [...this.derivedConstraints]) {
1172
+ if (replacementConstraintIds.has(constraintId) || this.constraintDependenciesMatch(current.input, committedSources))
1173
+ continue;
1174
+ this.removeNativeConstraint(current.handle);
1175
+ this.derivedConstraints.delete(constraintId);
1176
+ }
1177
+ if (old !== void 0) {
1178
+ for (const shape of old.shapes) {
1179
+ const collider = this.raw.getCollider(shape.colliderHandle);
1180
+ if (collider !== null && collider !== void 0) collider.setEnabled(false);
1181
+ }
1182
+ this.retiredDerivedBodies.push(old);
1183
+ }
1184
+ for (const constraint of record.input.constraints ?? []) {
1185
+ const previous = this.derivedConstraints.get(constraint.id);
1186
+ if (previous !== void 0) this.removeNativeConstraint(previous.handle);
1187
+ const staged = stagedConstraints.get(constraint.id);
1188
+ if (staged !== void 0) this.derivedConstraints.set(constraint.id, staged);
1189
+ }
1190
+ const shapes = record.input.shapes.map(
1191
+ (shape, index) => ({
1192
+ input: shape,
1193
+ colliderHandle: record.nativeColliders[index]?.handle
1194
+ })
1195
+ );
1196
+ const committed = {
1197
+ entity: record.input.entity,
1198
+ sourceKey: record.input.sourceKey,
1199
+ generation: this.backendGeneration,
1200
+ revision: record.input.revision,
1201
+ bodyType: record.input.bodyType,
1202
+ velocityPolicy: record.input.velocityPolicy,
1203
+ candidateId: record.token.candidateId,
1204
+ shapes,
1205
+ seams: [...record.input.seams ?? []],
1206
+ massProperties: record.input.massProperties,
1207
+ constraints: [...record.input.constraints ?? []]
1208
+ };
1209
+ if (record.commitGeometry !== void 0) {
1210
+ this.activeDerivedAdmission = record;
1211
+ try {
1212
+ geometryCommitUncertain = true;
1213
+ const geometry = record.commitGeometry();
1214
+ geometryCommitUncertain = false;
1215
+ if (!geometry.ok) throw geometry.error;
1216
+ } finally {
1217
+ this.activeDerivedAdmission = void 0;
1218
+ }
1219
+ delete record.commitGeometry;
1220
+ }
1221
+ this.derivedBodies.set(record.input.entity, committed);
1222
+ const entityRecord = this.entityMap.get(record.input.entity);
1223
+ if (entityRecord !== void 0) {
1224
+ entityRecord.automaticAdditionalMass = record.input.massProperties?.mode === "explicit" ? 0 : entityRecord.additionalMass;
1225
+ }
1226
+ this.derivedBodySources.set(record.input.entity, {
1227
+ sourceKey: record.input.sourceKey,
1228
+ revision: record.input.revision
1229
+ });
1230
+ this.derivedFailures.delete(record.input.entity);
1231
+ record.state = "queued";
1232
+ this.pendingDerivedCandidates.delete(id);
1233
+ } catch (cause) {
1234
+ for (const staged of stagedConstraints.values()) this.removeNativeConstraint(staged.handle);
1235
+ for (const current of this.derivedConstraints.values())
1236
+ this.removeNativeConstraint(current.handle);
1237
+ this.derivedConstraints.clear();
1238
+ if (old !== void 0) {
1239
+ for (const shape of old.shapes) {
1240
+ const collider = this.raw.getCollider(shape.colliderHandle);
1241
+ if (collider !== null && collider !== void 0) collider.setEnabled(true);
1242
+ }
1243
+ const retiredIndex = this.retiredDerivedBodies.indexOf(old);
1244
+ if (retiredIndex >= 0) this.retiredDerivedBodies.splice(retiredIndex, 1);
1245
+ this.derivedBodies.set(record.input.entity, old);
1246
+ this.derivedBodySources.set(record.input.entity, {
1247
+ sourceKey: old.sourceKey,
1248
+ revision: old.revision
1249
+ });
1250
+ }
1251
+ for (const native of record.nativeColliders) {
1252
+ const collider = this.raw.getCollider(native.handle);
1253
+ if (collider !== null && collider !== void 0) collider.setEnabled(false);
1254
+ }
1255
+ for (const native of record.nativeColliders) this.removeNativeCollider(native.handle);
1256
+ for (const { collider, density, enabled } of oldDensities) {
1257
+ if (this.raw.getCollider(collider.handle) === null) continue;
1258
+ if (density !== void 0 && typeof collider.setDensity === "function")
1259
+ collider.setDensity(density);
1260
+ if (typeof collider.setEnabled === "function") collider.setEnabled(enabled);
1261
+ }
1262
+ let restored = true;
1263
+ try {
1264
+ body.setBodyType(oldBodyType, true);
1265
+ this.restoreCommittedMass(body, old, oldAutomaticAdditionalMass);
1266
+ body.setTranslation(oldTranslation, true);
1267
+ body.setRotation(oldRotation, true);
1268
+ body.setLinvel(oldVelocity, true);
1269
+ body.setAngvel(oldAngularVelocity, true);
1270
+ body.setEnabled(oldBodyEnabled);
1271
+ } catch {
1272
+ restored = false;
1273
+ }
1274
+ if (restored && !this.restoreNativeConstraints(oldConstraints)) restored = false;
1275
+ if (restored) {
1276
+ const currentMass = body.mass();
1277
+ const currentCom = body.worldCom();
1278
+ const currentVelocity = body.linvel();
1279
+ const currentAngularVelocity = body.angvel();
1280
+ restored = Number.isFinite(currentMass) && Math.abs(currentMass - oldMass) <= 1e-6 * Math.max(1, Math.abs(oldMass)) && Math.abs(currentCom.x - oldCom.x) <= 1e-6 && Math.abs(currentCom.y - oldCom.y) <= 1e-6 && Math.abs(currentCom.z - oldCom.z) <= 1e-6 && Math.abs(currentVelocity.x - oldVelocity.x) <= 1e-6 && Math.abs(currentVelocity.y - oldVelocity.y) <= 1e-6 && Math.abs(currentVelocity.z - oldVelocity.z) <= 1e-6 && Math.abs(currentAngularVelocity.x - oldAngularVelocity.x) <= 1e-6 && Math.abs(currentAngularVelocity.y - oldAngularVelocity.y) <= 1e-6 && Math.abs(currentAngularVelocity.z - oldAngularVelocity.z) <= 1e-6 && body.bodyType() === oldBodyType && body.isEnabled() === oldBodyEnabled;
1281
+ }
1282
+ if (old === void 0) this.derivedBodies.delete(record.input.entity);
1283
+ else this.derivedBodies.set(record.input.entity, old);
1284
+ if (oldSource === void 0) this.derivedBodySources.delete(record.input.entity);
1285
+ else this.derivedBodySources.set(record.input.entity, oldSource);
1286
+ if (oldPublication === void 0) this.derivedPublications.delete(record.input.entity);
1287
+ else this.derivedPublications.set(record.input.entity, oldPublication);
1288
+ const entityRecord = this.entityMap.get(record.input.entity);
1289
+ if (entityRecord !== void 0 && oldAutomaticRecordMass !== void 0)
1290
+ entityRecord.automaticAdditionalMass = oldAutomaticRecordMass;
1291
+ const error = cause instanceof DerivedPhysicsError ? cause : new DerivedPhysicsError(
1292
+ "derived-backend-failed",
1293
+ "derived admission either commits completely or preserves the prior body state",
1294
+ "inspect the failure receipt and rebuild the PhysicsWorld if recovery is required",
1295
+ {
1296
+ entity: record.input.entity,
1297
+ candidateId: record.token.candidateId,
1298
+ reason: cause instanceof Error ? cause.message : String(cause)
1299
+ }
1300
+ );
1301
+ if (geometryCommitUncertain) restored = false;
1302
+ if (!restored) {
1303
+ this.derivedPoisonedEntities.add(record.input.entity);
1304
+ this.derivedBodies.delete(record.input.entity);
1305
+ this.derivedPublications.delete(record.input.entity);
1306
+ }
1307
+ this.rememberDerivedFailure(
1308
+ record,
1309
+ error,
1310
+ restored ? "old-state-retained" : "rebuild-required"
1311
+ );
1312
+ }
1313
+ }
1314
+ }
1315
+ publishDerivedCandidates() {
1316
+ for (const body of this.derivedBodies.values()) {
1317
+ const candidate = this.derivedCandidates.get(body.candidateId);
1318
+ if (candidate?.state !== "queued") continue;
1319
+ this.derivedPublications.set(
1320
+ body.entity,
1321
+ Object.freeze({
1322
+ candidateId: body.candidateId,
1323
+ entity: body.entity,
1324
+ revision: body.revision,
1325
+ fixedStep: this.fixedStep,
1326
+ shapeIds: Object.freeze(body.shapes.map((shape) => shape.input.id)),
1327
+ generation: body.generation
1328
+ })
1329
+ );
1330
+ candidate.state = "published";
1331
+ }
1332
+ }
1333
+ retireDerivedBodies() {
1334
+ for (const body of this.retiredDerivedBodies.splice(0)) {
1335
+ for (const shape of body.shapes) this.removeNativeCollider(shape.colliderHandle);
1336
+ this.releaseDerivedCandidate(body.candidateId);
1337
+ }
1338
+ }
1339
+ applyDerivedMass(body, properties, previousWorldCom, velocityPolicy, authoredAdditionalMass, entity, candidateShapes, candidateColliders) {
1340
+ const oldVelocity = body.linvel();
1341
+ const oldAngularVelocity = body.angvel();
1342
+ if (properties?.mode === "explicit") {
1343
+ this.rememberAuthoredDensity(entity, body);
1344
+ for (const collider of this.bodyColliders(body)) {
1345
+ if (typeof collider.setDensity === "function") collider.setDensity(0);
1346
+ }
1347
+ const frame = properties.principalInertiaLocalFrame ?? [0, 0, 0, 1];
1348
+ body.setAdditionalMassProperties(
1349
+ properties.mass,
1350
+ {
1351
+ x: properties.centerOfMass[0],
1352
+ y: properties.centerOfMass[1],
1353
+ z: properties.centerOfMass[2]
1354
+ },
1355
+ {
1356
+ x: properties.principalInertia[0],
1357
+ y: properties.principalInertia[1],
1358
+ z: properties.principalInertia[2]
1359
+ },
1360
+ { x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
1361
+ true
1362
+ );
1363
+ } else {
1364
+ this.restoreAutomaticDensities(
1365
+ entity,
1366
+ body,
1367
+ properties?.mode === "automatic" ? properties.density : void 0,
1368
+ candidateShapes,
1369
+ candidateColliders
1370
+ );
1371
+ this.restoreAutomaticMass(body, authoredAdditionalMass);
1372
+ }
1373
+ if (velocityPolicy === "reset") {
1374
+ body.setLinvel({ x: 0, y: 0, z: 0 }, true);
1375
+ body.setAngvel({ x: 0, y: 0, z: 0 }, true);
1376
+ return;
1377
+ }
1378
+ const nextWorldCom = body.worldCom();
1379
+ const next = preserveCenterOfMassVelocity(
1380
+ [oldVelocity.x, oldVelocity.y, oldVelocity.z],
1381
+ [oldAngularVelocity.x, oldAngularVelocity.y, oldAngularVelocity.z],
1382
+ [previousWorldCom.x, previousWorldCom.y, previousWorldCom.z],
1383
+ [nextWorldCom.x, nextWorldCom.y, nextWorldCom.z]
1384
+ );
1385
+ body.setLinvel({ x: next[0], y: next[1], z: next[2] }, true);
1386
+ body.setAngvel(oldAngularVelocity, true);
1387
+ }
1388
+ /** Restore the complete committed mass policy after a failed admission. */
1389
+ restoreCommittedMass(body, previous, automaticAdditionalMass) {
1390
+ if (previous?.massProperties?.mode === "explicit") {
1391
+ const frame = previous.massProperties.principalInertiaLocalFrame ?? [0, 0, 0, 1];
1392
+ body.setAdditionalMassProperties(
1393
+ previous.massProperties.mass,
1394
+ {
1395
+ x: previous.massProperties.centerOfMass[0],
1396
+ y: previous.massProperties.centerOfMass[1],
1397
+ z: previous.massProperties.centerOfMass[2]
1398
+ },
1399
+ {
1400
+ x: previous.massProperties.principalInertia[0],
1401
+ y: previous.massProperties.principalInertia[1],
1402
+ z: previous.massProperties.principalInertia[2]
1403
+ },
1404
+ { x: frame[0], y: frame[1], z: frame[2], w: frame[3] },
1405
+ true
1406
+ );
1407
+ return;
1408
+ }
1409
+ this.restoreAutomaticMass(body, automaticAdditionalMass);
1410
+ }
1411
+ /**
1412
+ * Rapier keeps `setAdditionalMassProperties` as native state even after a
1413
+ * collider recompute. Clear that override explicitly before recomputing so
1414
+ * automatic candidates and rollback really return to the authored policy.
1415
+ */
1416
+ restoreAutomaticMass(body, additionalMass) {
1417
+ body.setAdditionalMass(Math.max(0, additionalMass), true);
1418
+ body.recomputeMassPropertiesFromColliders();
1419
+ }
1420
+ rememberAuthoredDensity(entity, body) {
1421
+ const record = this.entityMap.get(entity);
1422
+ if (record === void 0 || record.authoredDensity !== void 0) return;
1423
+ const authored = this.bodyColliders(body).find(
1424
+ (collider) => !this.derivedColliderToShape.has(collider.handle)
1425
+ );
1426
+ const density = authored !== void 0 && typeof authored.density === "function" ? authored.density() : void 0;
1427
+ if (density !== void 0 && Number.isFinite(density) && density >= 0)
1428
+ record.authoredDensity = density;
1429
+ }
1430
+ restoreAutomaticDensities(entity, body, overrideDensity, candidateShapes, candidateColliders) {
1431
+ const record = this.entityMap.get(entity);
1432
+ const candidateDensityByHandle = /* @__PURE__ */ new Map();
1433
+ for (const [index, collider] of candidateColliders.entries()) {
1434
+ const shape = candidateShapes[index];
1435
+ if (shape !== void 0) candidateDensityByHandle.set(collider.handle, shape.density ?? 1);
1436
+ }
1437
+ const authoredDensity = record?.authoredDensity ?? 1;
1438
+ for (const collider of this.bodyColliders(body)) {
1439
+ const candidateDensity = candidateDensityByHandle.get(collider.handle);
1440
+ const density = candidateDensity !== void 0 ? overrideDensity ?? candidateDensity : this.derivedColliderToShape.has(collider.handle) ? 0 : overrideDensity ?? authoredDensity;
1441
+ if (typeof collider.setDensity === "function") collider.setDensity(density);
1442
+ }
1443
+ }
1444
+ restoreNativeConstraints(previous) {
1445
+ for (const [constraintId, record] of previous) {
1446
+ const recreated = this.createNativeConstraint(record.input);
1447
+ if (!recreated.ok) {
1448
+ for (const current of this.derivedConstraints.values())
1449
+ this.removeNativeConstraint(current.handle);
1450
+ this.derivedConstraints.clear();
1451
+ return false;
1452
+ }
1453
+ this.derivedConstraints.set(constraintId, {
1454
+ input: { ...record.input },
1455
+ handle: recreated.value.handle
1456
+ });
1457
+ }
1458
+ return true;
1459
+ }
1460
+ bodyColliders(body) {
1461
+ const result = [];
1462
+ for (let index = 0; index < body.numColliders(); index += 1) {
1463
+ const collider = body.collider(index);
1464
+ if (collider !== null && collider !== void 0) result.push(collider);
1465
+ }
1466
+ return result;
1467
+ }
1468
+ releaseDerivedCandidate(candidateId) {
1469
+ const record = this.derivedCandidates.get(candidateId);
1470
+ if (record === void 0) return;
1471
+ this.derivedCandidateBytes = Math.max(0, this.derivedCandidateBytes - record.bytes);
1472
+ this.derivedCandidates.delete(candidateId);
1473
+ }
1474
+ sourceForEntity(entity) {
1475
+ return this.derivedBodySources.get(entity) ?? {
1476
+ sourceKey: `entity:${entity}`,
1477
+ revision: 0
1478
+ };
1479
+ }
1480
+ /**
1481
+ * Project the final source revision of every queued body. Constraint
1482
+ * dependencies are checked against this projection, not against whichever
1483
+ * queued candidate happens to be processed first.
1484
+ */
1485
+ pendingDerivedSources() {
1486
+ const sources = /* @__PURE__ */ new Map();
1487
+ for (const candidateId of this.pendingDerivedCandidates) {
1488
+ const record = this.derivedCandidates.get(candidateId);
1489
+ if (record === void 0 || record.state !== "queued") continue;
1490
+ const current = sources.get(record.input.entity);
1491
+ if (current === void 0 || record.input.revision > current.revision) {
1492
+ sources.set(record.input.entity, {
1493
+ sourceKey: record.input.sourceKey,
1494
+ revision: record.input.revision
1495
+ });
1496
+ }
1497
+ }
1498
+ return sources;
1499
+ }
1500
+ newestPendingRevision(entity) {
1501
+ return this.pendingDerivedSources().get(entity)?.revision;
1502
+ }
1503
+ recoveryBlocked() {
1504
+ return this.derivedPoisonedEntities.size > 0 || this.syncState?.world.execution.health === "poisoned";
1505
+ }
1506
+ validateConstraintDependencies(input, candidate, sourceOverrides) {
1507
+ const endpoints = [
1508
+ [input.bodyA, input.bodyASource],
1509
+ [input.bodyB, input.bodyBSource]
1510
+ ];
1511
+ for (const [entity, dependency] of endpoints) {
1512
+ if (this.derivedPoisonedEntities.has(entity)) {
1513
+ return new DerivedPhysicsError(
1514
+ "derived-recovery-invalid",
1515
+ "constraint endpoints belong to a healthy PhysicsWorld state",
1516
+ "rebuild the PhysicsWorld before recreating constraints",
1517
+ { constraintId: input.id, entity }
1518
+ );
1519
+ }
1520
+ if (!this.entityMap.has(entity)) {
1521
+ return new DerivedPhysicsError(
1522
+ "derived-body-not-found",
1523
+ "both constraint endpoint entities have committed bodies",
1524
+ "reconcile both endpoint entities before creating or migrating a constraint",
1525
+ { constraintId: input.id, entity }
1526
+ );
1527
+ }
1528
+ const expected = candidate !== void 0 && entity === candidate.entity ? { sourceKey: candidate.sourceKey, revision: candidate.revision } : sourceOverrides?.get(entity) ?? this.sourceForEntity(entity);
1529
+ if (dependency.sourceKey !== expected.sourceKey || dependency.revision !== expected.revision) {
1530
+ return new DerivedPhysicsError(
1531
+ "derived-constraint-stale",
1532
+ "constraint endpoint sourceKey and revision match the committed endpoint",
1533
+ "refresh both endpoint dependencies and retry the complete candidate",
1534
+ {
1535
+ constraintId: input.id,
1536
+ entity,
1537
+ expected: `${expected.sourceKey}@${expected.revision}`,
1538
+ actual: `${dependency.sourceKey}@${dependency.revision}`
1539
+ }
1540
+ );
1541
+ }
1542
+ }
1543
+ return void 0;
1544
+ }
1545
+ constraintDependenciesMatch(input, sourceOverrides) {
1546
+ for (const [entity, dependency] of [
1547
+ [input.bodyA, input.bodyASource],
1548
+ [input.bodyB, input.bodyBSource]
1549
+ ]) {
1550
+ if (!this.entityMap.has(entity)) return false;
1551
+ const expected = sourceOverrides.get(entity) ?? this.sourceForEntity(entity);
1552
+ if (dependency.sourceKey !== expected.sourceKey || dependency.revision !== expected.revision)
1553
+ return false;
1554
+ }
1555
+ return true;
1556
+ }
1557
+ validateDerivedAdmission(input, sourceOverrides) {
1558
+ if (input.bodyType !== void 0 && input.bodyType !== "static" && input.bodyType !== "dynamic" && input.bodyType !== "kinematic") {
1559
+ return new DerivedPhysicsError(
1560
+ "derived-candidate-invalid",
1561
+ "candidate bodyType is one of static, dynamic, or kinematic",
1562
+ "repair the motion type before admission",
1563
+ { entity: input.entity, actual: input.bodyType }
1564
+ );
1565
+ }
1566
+ const seen = /* @__PURE__ */ new Set();
1567
+ for (const constraint of input.constraints ?? []) {
1568
+ const validation = validateConstraintInput(constraint);
1569
+ if (!validation.ok) return validation.error;
1570
+ if (seen.has(constraint.id)) {
1571
+ return new DerivedPhysicsError(
1572
+ "derived-constraint-invalid",
1573
+ "candidate contains one constraint update per identity",
1574
+ "merge duplicate updates before admission",
1575
+ { entity: input.entity, constraintId: constraint.id }
1576
+ );
1577
+ }
1578
+ seen.add(constraint.id);
1579
+ const dependencyError = this.validateConstraintDependencies(
1580
+ constraint,
1581
+ input,
1582
+ sourceOverrides
1583
+ );
1584
+ if (dependencyError !== void 0) return dependencyError;
1585
+ const existing = this.derivedConstraints.get(constraint.id);
1586
+ if (existing !== void 0 && constraint.revision <= existing.input.revision) {
1587
+ return new DerivedPhysicsError(
1588
+ "derived-constraint-stale",
1589
+ "migrated constraint revision advances the committed revision",
1590
+ "submit both endpoint dependencies and a newer constraint revision",
1591
+ {
1592
+ entity: input.entity,
1593
+ constraintId: constraint.id,
1594
+ expected: `>${existing.input.revision}`,
1595
+ actual: constraint.revision
1596
+ }
1597
+ );
1598
+ }
1599
+ }
1600
+ return void 0;
1601
+ }
1602
+ constraintsForBody(entity) {
1603
+ return [...this.derivedConstraints.values()].filter(
1604
+ (constraint) => constraint.input.bodyA === entity || constraint.input.bodyB === entity
1605
+ ).map((constraint) => ({ ...constraint.input }));
1606
+ }
1607
+ installDerivedConstraint(input, updating) {
1608
+ this.assertActive(updating ? "updateDerivedConstraint" : "createDerivedConstraint");
1609
+ const validation = validateConstraintInput(input);
1610
+ if (!validation.ok) return validation;
1611
+ const dependencyError = this.validateConstraintDependencies(
1612
+ input,
1613
+ void 0,
1614
+ this.pendingDerivedSources()
1615
+ );
1616
+ if (dependencyError !== void 0) return err(dependencyError);
1617
+ const existing = this.derivedConstraints.get(input.id);
1618
+ if (existing !== void 0 && !updating) {
1619
+ return err(
1620
+ new DerivedPhysicsError(
1621
+ "derived-constraint-stale",
1622
+ "constraint identity is not already committed when creating it",
1623
+ "call updateDerivedConstraint with a newer revision",
1624
+ { constraintId: input.id }
1625
+ )
1626
+ );
1627
+ }
1628
+ if (existing !== void 0 && input.revision <= existing.input.revision) {
1629
+ return err(
1630
+ new DerivedPhysicsError(
1631
+ "derived-constraint-stale",
1632
+ "constraint revision advances monotonically",
1633
+ "submit a newer constraint revision",
1634
+ {
1635
+ constraintId: input.id,
1636
+ actual: input.revision,
1637
+ expected: `>${existing.input.revision}`
1638
+ }
1639
+ )
1640
+ );
1641
+ }
1642
+ const native = this.createNativeConstraint(input);
1643
+ if (!native.ok) return native;
1644
+ if (existing !== void 0) this.removeNativeConstraint(existing.handle);
1645
+ this.derivedConstraints.set(input.id, { input: { ...input }, handle: native.value.handle });
1646
+ return ok({ id: input.id, revision: input.revision });
1647
+ }
1648
+ removeNativeConstraint(handle) {
1649
+ try {
1650
+ const joint = this.raw.getImpulseJoint(handle);
1651
+ if (joint !== null && joint !== void 0)
1652
+ this.raw.removeImpulseJoint(joint, true);
1653
+ } catch {
1654
+ }
1655
+ }
235
1656
  getPendingTeleports() {
236
1657
  return [...this.pendingTeleports].map(([entity, target]) => [entity, { ...target }]);
237
1658
  }
@@ -240,6 +1661,22 @@ var RapierPhysicsWorld3D = class {
240
1661
  }
241
1662
  dispose() {
242
1663
  if (this.disposed) return;
1664
+ this.assertActive("dispose");
1665
+ this.invalidateDerivedShapeCandidates("physics-dispose");
1666
+ this.derivedCandidates.clear();
1667
+ this.pendingDerivedCandidates.clear();
1668
+ this.derivedBodies.clear();
1669
+ this.derivedBodySources.clear();
1670
+ this.derivedPublications.clear();
1671
+ this.derivedFailures.clear();
1672
+ this.derivedConstraints.clear();
1673
+ this.derivedContacts.length = 0;
1674
+ this.derivedPublicationPending = false;
1675
+ this.derivedPoisonedEntities.clear();
1676
+ this.derivedColliderToShape.clear();
1677
+ this.retiredDerivedBodies.length = 0;
1678
+ this.derivedCandidateBytes = 0;
1679
+ this.backendGeneration += 1;
243
1680
  this.syncState = void 0;
244
1681
  this.moveContext = void 0;
245
1682
  if (typeof this.raw.free === "function") this.raw.free();
@@ -267,17 +1704,27 @@ var RapierPhysicsWorld3D = class {
267
1704
  setMoveContext(world, transform, characterController) {
268
1705
  this.assertActive("setMoveContext");
269
1706
  this.moveContext = { world, transform, characterController };
1707
+ this.worldIdentity = world;
270
1708
  }
271
1709
  /** Release the persistent ECS readers owned by one system registration. */
272
1710
  clearEcsContext(world) {
273
1711
  if (this.syncState?.world === world) this.syncState = void 0;
274
1712
  if (this.moveContext?.world === world) this.moveContext = void 0;
1713
+ if (this.worldIdentity === world) this.worldIdentity = void 0;
275
1714
  }
276
1715
  moveAndSlide(entity, desiredDelta) {
277
1716
  this.assertActive("moveAndSlide");
278
1717
  return this.computeMove(entity, desiredDelta);
279
1718
  }
280
1719
  assertActive(operation) {
1720
+ if (this.activeDerivedAdmission !== void 0) {
1721
+ throw new DerivedPhysicsError(
1722
+ "derived-candidate-pending",
1723
+ "physics queries and mutations observe only complete fixed-step states",
1724
+ "finish the paired geometry commit before querying or mutating physics",
1725
+ { entity: this.activeDerivedAdmission.input.entity, reason: operation }
1726
+ );
1727
+ }
281
1728
  if (this.disposed) {
282
1729
  throw new Error(`RapierPhysicsWorld3D.${operation} cannot run on a disposed instance`);
283
1730
  }
@@ -426,18 +1873,20 @@ var RapierPhysicsWorld3D = class {
426
1873
  fullReconcilePhysicsState(state) {
427
1874
  const descriptors = [];
428
1875
  const transformlessStatic = /* @__PURE__ */ new Set();
429
- for (const row of state.query) {
430
- if (!row.has(state.transformComponent)) {
431
- if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
432
- continue;
1876
+ const committedDerived = this.derivedBodies.size > 0 ? this.captureDerivedPhysicsState() : void 0;
1877
+ for (const query of state.queries)
1878
+ for (const row of query) {
1879
+ if (!row.has(state.transformComponent)) {
1880
+ if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
1881
+ continue;
1882
+ }
1883
+ const descriptor = readPhysicsSyncDescriptor(
1884
+ row,
1885
+ state.transformComponent,
1886
+ state.globalTransformComponent
1887
+ );
1888
+ if (descriptor !== void 0) descriptors.push(descriptor);
433
1889
  }
434
- const descriptor = readPhysicsSyncDescriptor(
435
- row,
436
- state.transformComponent,
437
- state.globalTransformComponent
438
- );
439
- if (descriptor !== void 0) descriptors.push(descriptor);
440
- }
441
1890
  this.resetForFullReconcile(transformlessStatic);
442
1891
  for (const descriptor of descriptors) {
443
1892
  this.ensureBody(
@@ -447,13 +1896,28 @@ var RapierPhysicsWorld3D = class {
447
1896
  descriptor.collider
448
1897
  );
449
1898
  }
1899
+ if (committedDerived !== void 0) {
1900
+ const restorableBodies = committedDerived.bodies.filter(
1901
+ (body) => this.entityMap.has(body.entity) && !this.derivedBodies.has(body.entity)
1902
+ );
1903
+ if (restorableBodies.length > 0) {
1904
+ const restorable = Object.freeze({
1905
+ ...committedDerived,
1906
+ bodies: Object.freeze(restorableBodies)
1907
+ });
1908
+ const restored = this.restoreDerivedPhysicsState(restorable);
1909
+ if (!restored.ok) {
1910
+ this.invalidateDerivedShapeCandidates("full-reconcile-restore-failed");
1911
+ }
1912
+ }
1913
+ }
450
1914
  drainPhysicsChangeQueries(state.changeQueries);
451
1915
  state.structuralCursor = readStructuralEvidence(state.world, state.structuralCursor).cursor;
452
1916
  state.structureEpoch = state.world.getStructureEpoch();
453
1917
  state.initialized = true;
454
1918
  }
455
1919
  reconcilePhysicsDelta(state, entity, delta) {
456
- const row = state.query.at(entity);
1920
+ const row = state.queries.map((query) => query.at(entity)).find((entry) => entry !== void 0);
457
1921
  if (row === void 0) {
458
1922
  this.removeEntity(entity);
459
1923
  return;
@@ -476,7 +1940,11 @@ var RapierPhysicsWorld3D = class {
476
1940
  return;
477
1941
  }
478
1942
  if (this.hasBody(entity) && (delta.colliderChanged || delta.rigidBodyChanged)) {
479
- this.removeEntity(entity);
1943
+ if (this.derivedBodies.has(entity)) {
1944
+ this.syncDerivedCompatibleEcsMutation(entity, descriptor);
1945
+ } else {
1946
+ this.removeEntity(entity);
1947
+ }
480
1948
  }
481
1949
  if (!this.hasBody(entity)) {
482
1950
  this.ensureBody(entity, descriptor.transform, descriptor.rigidBody, descriptor.collider);
@@ -503,9 +1971,51 @@ var RapierPhysicsWorld3D = class {
503
1971
  this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, "kinematic");
504
1972
  }
505
1973
  }
1974
+ syncDerivedCompatibleEcsMutation(entity, descriptor) {
1975
+ const body = this.bodyForEntity(entity);
1976
+ if (body === void 0) return;
1977
+ const bodyType = rigidBodyTypeFromF32(descriptor.rigidBody.type);
1978
+ const RAPIER = this.rapierModule;
1979
+ for (const collider of this.bodyColliders(body)) {
1980
+ if (!this.derivedColliderToShape.has(collider.handle)) {
1981
+ this.raw.removeCollider(collider, true);
1982
+ }
1983
+ }
1984
+ const committed = this.derivedBodies.get(entity);
1985
+ const record = this.entityMap.get(entity);
1986
+ if (record !== void 0) record.authoredDensity = descriptor.collider?.density;
1987
+ if (descriptor.collider !== void 0) {
1988
+ this.createAuthoredCollider(body, descriptor.transform, {
1989
+ ...descriptor.collider,
1990
+ density: committed?.massProperties?.mode === "explicit" ? 0 : descriptor.collider.density
1991
+ });
1992
+ }
1993
+ if (bodyType === "static") {
1994
+ body.setBodyType(RAPIER.RigidBodyType.Fixed, true);
1995
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, "static");
1996
+ } else if (bodyType === "kinematic") {
1997
+ body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
1998
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, "kinematic");
1999
+ } else {
2000
+ body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
2001
+ if (record !== void 0) record.additionalMass = Math.max(0, descriptor.rigidBody.mass);
2002
+ if (committed?.massProperties?.mode !== "explicit") {
2003
+ this.restoreAutomaticMass(body, record?.additionalMass ?? 0);
2004
+ if (record !== void 0) record.automaticAdditionalMass = record.additionalMass;
2005
+ }
2006
+ body.setGravityScale(descriptor.rigidBody.gravityScale, true);
2007
+ body.setLinearDamping(descriptor.rigidBody.linearDamping);
2008
+ body.setAngularDamping(descriptor.rigidBody.angularDamping);
2009
+ }
2010
+ body.enableCcd(Boolean(descriptor.rigidBody.ccdEnabled));
2011
+ if (committed?.massProperties?.mode === "explicit") {
2012
+ this.restoreCommittedMass(body, committed, 0);
2013
+ }
2014
+ }
506
2015
  /** @internal ECS system bridge; consumers should register PhysicsSyncBackend. */
507
2016
  _syncFromEcs(world, transformComponent, globalTransformComponent = world.components.resolve("GlobalTransform")) {
508
2017
  this.assertActive("syncFromEcs");
2018
+ this.worldIdentity = world;
509
2019
  if (globalTransformComponent === void 0) return;
510
2020
  let state = this.syncState;
511
2021
  if (state === void 0 || state.world !== world || state.transformComponent !== transformComponent || state.globalTransformComponent !== globalTransformComponent) {
@@ -520,11 +2030,17 @@ var RapierPhysicsWorld3D = class {
520
2030
  ]
521
2031
  });
522
2032
  if (!queryResult.ok) return;
2033
+ const bodyQuery = world.query({
2034
+ read: [RigidBody],
2035
+ without: [Collider],
2036
+ optional: [transformComponent, globalTransformComponent, CharacterController, ChildOf]
2037
+ });
2038
+ if (!bodyQuery.ok) throw bodyQuery.error;
523
2039
  state = {
524
2040
  world,
525
2041
  transformComponent,
526
2042
  globalTransformComponent,
527
- query: queryResult.value,
2043
+ queries: [queryResult.value, bodyQuery.value],
528
2044
  changeQueries: createPhysicsChangeQueries(world, [
529
2045
  transformComponent,
530
2046
  globalTransformComponent,
@@ -665,7 +2181,21 @@ var RapierPhysicsWorld3D = class {
665
2181
  }
666
2182
  }
667
2183
  body.userData = entity;
668
- this.registerBody(entity, body.handle);
2184
+ this.registerBody(
2185
+ entity,
2186
+ body.handle,
2187
+ rbType === "dynamic" ? Math.max(0, rigidBody.mass) : 0,
2188
+ collider?.density
2189
+ );
2190
+ if (collider === void 0) {
2191
+ const record = this.entityMap.get(entity);
2192
+ if (record !== void 0) record.automaticAdditionalMass = record.additionalMass;
2193
+ return;
2194
+ }
2195
+ this.createAuthoredCollider(body, transform, collider);
2196
+ }
2197
+ createAuthoredCollider(body, transform, collider) {
2198
+ const RAPIER = this.rapierModule;
669
2199
  const scaleX = Math.abs(transform.scale.x);
670
2200
  const scaleY = Math.abs(transform.scale.y);
671
2201
  const scaleZ = Math.abs(transform.scale.z);
@@ -719,7 +2249,10 @@ var RapierPhysicsWorld3D = class {
719
2249
  body.setNextKinematicTranslation(transform.position);
720
2250
  body.setNextKinematicRotation(transform.rotation);
721
2251
  }
722
- const rapierCollider = body.collider(0);
2252
+ if (collider === void 0) return;
2253
+ const rapierCollider = this.bodyColliders(body).find(
2254
+ (shape) => !this.derivedColliderToShape.has(shape.handle)
2255
+ );
723
2256
  if (!rapierCollider) return;
724
2257
  const scaleX = Math.abs(transform.scale.x);
725
2258
  const scaleY = Math.abs(transform.scale.y);
@@ -745,8 +2278,23 @@ var RapierPhysicsWorld3D = class {
745
2278
  /**
746
2279
  * Register an ECS entity with its Rapier body handle.
747
2280
  */
748
- registerBody(entity, bodyHandle) {
749
- this.entityMap.set(entity, { bodyHandle });
2281
+ registerBody(entity, bodyHandle, additionalMass = 0, authoredDensity) {
2282
+ this.entityMap.set(entity, {
2283
+ bodyHandle,
2284
+ additionalMass: Math.max(0, additionalMass),
2285
+ // `ensureBody` registers before its authored collider is attached.
2286
+ // Rapier recomputes the body from that collider and clears the
2287
+ // descriptor-only additional mass, so the native baseline is zero.
2288
+ // A later automatic derived admission records the actual additional
2289
+ // contribution after it has been applied. Keeping this separate from
2290
+ // `additionalMass` lets rollback restore native state rather than an
2291
+ // authored value that Rapier has not applied yet.
2292
+ automaticAdditionalMass: 0,
2293
+ authoredDensity: authoredDensity !== void 0 && Number.isFinite(authoredDensity) && authoredDensity >= 0 ? authoredDensity : void 0
2294
+ });
2295
+ if (!this.derivedBodySources.has(entity)) {
2296
+ this.derivedBodySources.set(entity, { sourceKey: `entity:${entity}`, revision: 0 });
2297
+ }
750
2298
  }
751
2299
  /**
752
2300
  * Apply all pending teleports to their respective bodies.
@@ -798,6 +2346,26 @@ var RapierPhysicsWorld3D = class {
798
2346
  removeEntity(entity) {
799
2347
  const record = this.entityMap.get(entity);
800
2348
  if (!record) return;
2349
+ const derived = this.derivedBodies.get(entity);
2350
+ if (derived !== void 0) {
2351
+ for (const shape of derived.shapes) this.removeNativeCollider(shape.colliderHandle);
2352
+ this.derivedBodies.delete(entity);
2353
+ this.derivedPublications.delete(entity);
2354
+ }
2355
+ this.derivedFailures.delete(entity);
2356
+ this.derivedPoisonedEntities.delete(entity);
2357
+ for (const [id, constraint] of this.derivedConstraints) {
2358
+ if (constraint.input.bodyA === entity || constraint.input.bodyB === entity) {
2359
+ this.removeNativeConstraint(constraint.handle);
2360
+ this.derivedConstraints.delete(id);
2361
+ }
2362
+ }
2363
+ for (const [id, candidate] of this.derivedCandidates) {
2364
+ if (candidate.input.entity !== entity) continue;
2365
+ for (const collider of candidate.nativeColliders) this.removeNativeCollider(collider.handle);
2366
+ this.pendingDerivedCandidates.delete(id);
2367
+ this.releaseDerivedCandidate(id);
2368
+ }
801
2369
  const ownPairs = [...this.collisionPairs.get(entity) ?? []];
802
2370
  for (const other of ownPairs) {
803
2371
  if (this.removePair(entity, other)) {
@@ -807,6 +2375,7 @@ var RapierPhysicsWorld3D = class {
807
2375
  this.removeKccController(entity);
808
2376
  this.raw.removeRigidBody({ handle: record.bodyHandle });
809
2377
  this.entityMap.delete(entity);
2378
+ this.derivedBodySources.delete(entity);
810
2379
  const own = this.collisionPairs.get(entity);
811
2380
  if (own) {
812
2381
  for (const other of own) this.collisionPairs.get(other)?.delete(entity);
@@ -833,8 +2402,9 @@ function physicsRowIsStatic(row) {
833
2402
  function readPhysicsSyncDescriptor(row, transformComponent, globalTransformComponent) {
834
2403
  const transformData = row.get(transformComponent);
835
2404
  const globalTransformData = row.get(globalTransformComponent);
836
- const colliderData = row.get(Collider);
837
- if (transformData === void 0 || colliderData === void 0) return void 0;
2405
+ const colliderData = row.has(Collider) ? row.get(Collider) : void 0;
2406
+ if (transformData === void 0 || colliderData === void 0 && !row.has(RigidBody))
2407
+ return void 0;
838
2408
  const useWorldPose = row.has(ChildOf) && hasReadableWorldPose(globalTransformData?.world);
839
2409
  if (useWorldPose) {
840
2410
  poseScratchWorld.set(globalTransformData.world);
@@ -888,7 +2458,7 @@ function readPhysicsSyncDescriptor(row, transformComponent, globalTransformCompo
888
2458
  gravityScale: rigidBodyData.gravityScale,
889
2459
  ccdEnabled: Number(rigidBodyData.ccdEnabled)
890
2460
  },
891
- collider: {
2461
+ collider: colliderData === void 0 ? void 0 : {
892
2462
  shape: colliderData.shape,
893
2463
  halfExtents: [
894
2464
  colliderData.halfExtents[0] ?? 0,
@@ -984,6 +2554,7 @@ var PhysicsCollisionSync = defineSystem({
984
2554
  return;
985
2555
  }
986
2556
  pw.writebackCollidingEntities(world, CollidingEntities);
2557
+ pw.finalizeDerivedFixedStep();
987
2558
  }
988
2559
  });
989
2560
  function registerPhysicsSystems(world) {