@forgeax/engine-physics-rapier3d 0.0.0-dev.8d955ade1c79

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 ADDED
@@ -0,0 +1,977 @@
1
+ import { defineSystem, FixedTime, componentDefinition, Disabled, FixedUpdate } from '@forgeax/engine-ecs';
2
+ import { createWorldProjection } from '@forgeax/engine-ecs/projection';
3
+ import { vec3, quat, mat4 } from '@forgeax/engine-math';
4
+ import { CollidingEntities, PhysicsError, PHYSICS_ERROR_HINTS, CharacterController, rigidBodyTypeFromF32, RigidBody, Collider, colliderShapeFromF32, RIGID_BODY_TYPE_STATIC, registerPhysicsComponents, PhysicsSet } from '@forgeax/engine-physics';
5
+ import { ChildOf } from '@forgeax/engine-scene';
6
+ import { PhysicsError as PhysicsError$1 } from '@forgeax/engine-types';
7
+
8
+ // src/rapier-physics-world-3d.ts
9
+ var DEG_TO_RAD = Math.PI / 180;
10
+ function applyKccTuning(ctrl, cc) {
11
+ ctrl.setMaxSlopeClimbAngle(cc.maxSlopeClimbDeg * DEG_TO_RAD);
12
+ ctrl.setMinSlopeSlideAngle(cc.minSlopeSlideDeg * DEG_TO_RAD);
13
+ ctrl.setSlideEnabled(true);
14
+ if (cc.autoStepMaxHeight === 0) {
15
+ ctrl.disableAutostep();
16
+ } else {
17
+ ctrl.enableAutostep(cc.autoStepMaxHeight, cc.autoStepMinWidth, false);
18
+ }
19
+ if (cc.snapToGroundDist === 0) {
20
+ ctrl.disableSnapToGround();
21
+ } else {
22
+ ctrl.enableSnapToGround(cc.snapToGroundDist);
23
+ }
24
+ }
25
+ function rapierBodyTypeToString(rapier, bodyType) {
26
+ if (bodyType === rapier.RigidBodyType.Dynamic) return "dynamic";
27
+ if (bodyType === rapier.RigidBodyType.Fixed) return "static";
28
+ return "kinematic";
29
+ }
30
+ var RapierPhysicsWorld3D = class {
31
+ /** Rapier 3D World instance owning all bodies, colliders, and pipeline. */
32
+ raw;
33
+ rapierModule;
34
+ /** Entity (raw number) -> PhysicsEntityRecord mapping. */
35
+ entityMap = /* @__PURE__ */ new Map();
36
+ /** Pending teleports: entity -> target position, applied on next sync. */
37
+ pendingTeleports = /* @__PURE__ */ new Map();
38
+ /** Event queue for collision events. */
39
+ eventQueue;
40
+ /**
41
+ * Active overlap set per entity, maintained by draining the event queue each
42
+ * step. `started` events add the pair both ways; `stopped` events remove it.
43
+ * Read out into each entity's `CollidingEntities` component by
44
+ * `writebackCollidingEntities`. Covers both solid contacts and sensor
45
+ * intersections (Rapier emits CollisionEvent for both).
46
+ */
47
+ collisionPairs = /* @__PURE__ */ new Map();
48
+ pendingCollisionEvents = [];
49
+ collisionEventHistory = [];
50
+ currentGravity;
51
+ /**
52
+ * Lazily-built Rapier KinematicCharacterController per character entity
53
+ * (plan-strategy D-1/D-3). `moveAndSlide` creates one on first call; the
54
+ * `Collider.onRemove` hook (registerPhysicsSystems) clears it on despawn.
55
+ * Public so AC-11 despawn tests can assert `kccCache.size === 0`.
56
+ */
57
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
58
+ kccCache = /* @__PURE__ */ new Map();
59
+ kccOffsets = /* @__PURE__ */ new Map();
60
+ /**
61
+ * ECS World + components wired in by `registerPhysicsSystems`, so
62
+ * `moveAndSlide` can read CharacterController tuning and write Transform +
63
+ * grounded back. Undefined until systems are registered — the input-validation
64
+ * error paths (body / collider) fire before these are read, so direct
65
+ * `pw.moveAndSlide()` calls in error tests need no World.
66
+ */
67
+ moveContext;
68
+ /** Persistent ECS query + projection cursor for incremental backend sync. */
69
+ syncState;
70
+ disposed = false;
71
+ constructor(rapier) {
72
+ this.rapierModule = rapier;
73
+ this.raw = new rapier.World({ x: 0, y: -9.81, z: 0 });
74
+ this.eventQueue = new rapier.EventQueue(true);
75
+ this.currentGravity = { x: 0, y: -9.81, z: 0 };
76
+ }
77
+ // ─── PhysicsWorld interface ────────────────────────────────────────────
78
+ setGravity(gravity) {
79
+ this.assertActive("setGravity");
80
+ const x = gravity[0] ?? 0;
81
+ const y = gravity[1] ?? 0;
82
+ const z = gravity[2] ?? 0;
83
+ this.raw.gravity = { x, y, z };
84
+ this.currentGravity = { x, y, z };
85
+ }
86
+ getGravity() {
87
+ const { x, y, z } = this.currentGravity;
88
+ return vec3.create(x, y, z);
89
+ }
90
+ raycast(origin, direction, maxDist, filterMask) {
91
+ this.assertActive("raycast");
92
+ const RAPIER = this.rapierModule;
93
+ const RayCtor = RAPIER.Ray;
94
+ const ray = new RayCtor(
95
+ { x: origin[0] ?? 0, y: origin[1] ?? 0, z: origin[2] ?? 0 },
96
+ { x: direction[0] ?? 0, y: direction[1] ?? 0, z: direction[2] ?? 0 }
97
+ );
98
+ const hit = this.raw.castRayAndGetNormal(
99
+ ray,
100
+ maxDist,
101
+ true,
102
+ void 0,
103
+ filterMask
104
+ );
105
+ if (hit === null) return void 0;
106
+ const point = ray.pointAt(hit.timeOfImpact);
107
+ const colliderParentBody = hit.collider.parent();
108
+ const entity = colliderParentBody !== null ? colliderParentBody.userData : 0;
109
+ return {
110
+ entity,
111
+ point: vec3.create(point.x, point.y, point.z),
112
+ normal: vec3.create(hit.normal.x, hit.normal.y, hit.normal.z),
113
+ timeOfImpact: hit.timeOfImpact
114
+ };
115
+ }
116
+ teleport(entity, position) {
117
+ this.assertActive("teleport");
118
+ this.pendingTeleports.set(entity, {
119
+ x: position[0] ?? 0,
120
+ y: position[1] ?? 0,
121
+ z: position[2] ?? 0
122
+ });
123
+ }
124
+ step(deltaTime) {
125
+ this.assertActive("step");
126
+ this.raw.step(this.eventQueue);
127
+ this.drainRapierCollisionEvents();
128
+ }
129
+ /**
130
+ * Drain the Rapier event queue into `collisionPairs`. Each event names two
131
+ * collider handles + a `started` flag; we resolve each collider to its owning
132
+ * entity (collider.parent() -> body.userData) and add/remove the symmetric
133
+ * pair. This is what populates `CollidingEntities` for sensor pickup + contact
134
+ * queries (the queue is otherwise drained-on-overflow and never observed).
135
+ */
136
+ drainRapierCollisionEvents() {
137
+ this.eventQueue.drainCollisionEvents((handle1, handle2, started) => {
138
+ const a = this.colliderHandleToEntity(handle1);
139
+ const b = this.colliderHandleToEntity(handle2);
140
+ if (a === void 0 || b === void 0) return;
141
+ const changed = started ? this.addPair(a, b) : this.removePair(a, b);
142
+ if (!changed) return;
143
+ this.pushCollisionEvent({
144
+ type: started ? "started" : "stopped",
145
+ entityA: a,
146
+ entityB: b
147
+ });
148
+ });
149
+ }
150
+ /** Resolve a Rapier collider handle to its owning ECS entity, or undefined. */
151
+ colliderHandleToEntity(colliderHandle) {
152
+ const collider = this.raw.getCollider(colliderHandle);
153
+ if (collider === null || collider === void 0) return void 0;
154
+ const body = collider.parent();
155
+ if (body === null || body === void 0) return void 0;
156
+ return body.userData;
157
+ }
158
+ addPair(a, b) {
159
+ let setA = this.collisionPairs.get(a);
160
+ if (!setA) {
161
+ setA = /* @__PURE__ */ new Set();
162
+ this.collisionPairs.set(a, setA);
163
+ }
164
+ if (setA.has(b)) return false;
165
+ setA.add(b);
166
+ let setB = this.collisionPairs.get(b);
167
+ if (!setB) {
168
+ setB = /* @__PURE__ */ new Set();
169
+ this.collisionPairs.set(b, setB);
170
+ }
171
+ setB.add(a);
172
+ return true;
173
+ }
174
+ removePair(a, b) {
175
+ const removedA = this.collisionPairs.get(a)?.delete(b) ?? false;
176
+ const removedB = this.collisionPairs.get(b)?.delete(a) ?? false;
177
+ return removedA || removedB;
178
+ }
179
+ pushCollisionEvent(event) {
180
+ const ordered = event.entityA <= event.entityB ? event : { ...event, entityA: event.entityB, entityB: event.entityA };
181
+ this.pendingCollisionEvents.push(ordered);
182
+ this.collisionEventHistory.push(ordered);
183
+ }
184
+ /**
185
+ * Write the current overlap set into each entity's `CollidingEntities`
186
+ * component (entities that carry it). Called by the PhysicsCollisionSync
187
+ * system after writeback. Entities with no current overlaps get an empty set,
188
+ * so a Core that the player has left clears correctly. Only entities that own
189
+ * a CollidingEntities component are written (others are skipped).
190
+ */
191
+ writebackCollidingEntities(world, collidingComponent) {
192
+ for (const [entity, others] of this.collisionPairs) {
193
+ const handle = entity;
194
+ if (!world.get(handle, collidingComponent).ok) continue;
195
+ world.set(handle, collidingComponent, { entities: [...others] });
196
+ }
197
+ }
198
+ drainCollisionEvents() {
199
+ return this.pendingCollisionEvents.splice(0);
200
+ }
201
+ getCollisionPairs() {
202
+ return new Map([...this.collisionPairs].map(([entity, others]) => [entity, new Set(others)]));
203
+ }
204
+ getCollisionEventHistory() {
205
+ return [...this.collisionEventHistory];
206
+ }
207
+ getPendingTeleports() {
208
+ return [...this.pendingTeleports].map(([entity, target]) => [entity, { ...target }]);
209
+ }
210
+ getKinematicControllerStates() {
211
+ return [...this.kccOffsets].sort(([first], [second]) => first - second).map(([entity, offset]) => ({ entity, offset }));
212
+ }
213
+ dispose() {
214
+ if (this.disposed) return;
215
+ this.syncState = void 0;
216
+ this.moveContext = void 0;
217
+ if (typeof this.raw.free === "function") this.raw.free();
218
+ if (typeof this.eventQueue.free === "function") this.eventQueue.free();
219
+ this.entityMap.clear();
220
+ this.pendingTeleports.clear();
221
+ this.collisionPairs.clear();
222
+ this.pendingCollisionEvents.length = 0;
223
+ this.collisionEventHistory.length = 0;
224
+ this.kccCache.clear();
225
+ this.kccOffsets.clear();
226
+ this.disposed = true;
227
+ }
228
+ getBodyCount() {
229
+ return this.entityMap.size;
230
+ }
231
+ hasBody(entity) {
232
+ return this.entityMap.has(entity);
233
+ }
234
+ /**
235
+ * Wire the ECS World + Transform / CharacterController components needed by
236
+ * `moveAndSlide` to read tuning and write back pose + grounded. Called once by
237
+ * `registerPhysicsSystems` (plan-strategy D-1/D-7).
238
+ */
239
+ setMoveContext(world, transform, characterController) {
240
+ this.assertActive("setMoveContext");
241
+ this.moveContext = { world, transform, characterController };
242
+ }
243
+ /** Release the persistent ECS readers owned by one system registration. */
244
+ clearEcsContext(world) {
245
+ if (this.syncState?.world === world) this.syncState = void 0;
246
+ if (this.moveContext?.world === world) this.moveContext = void 0;
247
+ }
248
+ moveAndSlide(entity, desiredDelta) {
249
+ this.assertActive("moveAndSlide");
250
+ return this.computeMove(entity, desiredDelta);
251
+ }
252
+ assertActive(operation) {
253
+ if (this.disposed) {
254
+ throw new Error(`RapierPhysicsWorld3D.${operation} cannot run on a disposed instance`);
255
+ }
256
+ }
257
+ /**
258
+ * Shared moveAndSlide core (plan-strategy D-1/D-2/D-4/D-6/D-7).
259
+ *
260
+ * The three Fail-Fast entry checks (body / collider / kinematic) throw
261
+ * structured PhysicsError before the World is read, so error-path tests can
262
+ * call this without registered systems.
263
+ */
264
+ computeMove(entity, desiredDelta) {
265
+ const record = this.entityMap.get(entity);
266
+ if (!record) {
267
+ throw new PhysicsError({
268
+ code: "body-not-found",
269
+ expected: "a registered Rapier body for this entity",
270
+ hint: PHYSICS_ERROR_HINTS["body-not-found"],
271
+ detail: { code: "body-not-found", entity }
272
+ });
273
+ }
274
+ const body = this.raw.bodies.get(record.bodyHandle);
275
+ if (!body) {
276
+ throw new PhysicsError({
277
+ code: "body-not-found",
278
+ expected: "a registered Rapier body for this entity",
279
+ hint: PHYSICS_ERROR_HINTS["body-not-found"],
280
+ detail: { code: "body-not-found", entity }
281
+ });
282
+ }
283
+ if (body.numColliders() === 0) {
284
+ throw new PhysicsError({
285
+ code: "collider-not-found",
286
+ expected: "a Collider attached to this entity body",
287
+ hint: PHYSICS_ERROR_HINTS["collider-not-found"],
288
+ detail: { code: "collider-not-found", entity }
289
+ });
290
+ }
291
+ const RAPIER = this.rapierModule;
292
+ if (body.bodyType() !== RAPIER.RigidBodyType.KinematicPositionBased) {
293
+ throw new PhysicsError({
294
+ code: "controller-requires-kinematic",
295
+ expected: "RigidBody.type === 'kinematic'",
296
+ hint: PHYSICS_ERROR_HINTS["controller-requires-kinematic"],
297
+ detail: {
298
+ code: "controller-requires-kinematic",
299
+ entity,
300
+ bodyType: rapierBodyTypeToString(RAPIER, body.bodyType())
301
+ }
302
+ });
303
+ }
304
+ const collider = body.collider(0);
305
+ const cc = this.readCharacterController(entity);
306
+ const ctrl = this.ensureKcc(entity, cc.offset);
307
+ applyKccTuning(ctrl, cc);
308
+ const delta = { x: desiredDelta[0] ?? 0, y: desiredDelta[1] ?? 0, z: desiredDelta[2] ?? 0 };
309
+ ctrl.computeColliderMovement(
310
+ collider,
311
+ delta,
312
+ RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
313
+ void 0,
314
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier Collider in filter predicate
315
+ (other) => other.handle !== collider.handle
316
+ );
317
+ const movement = ctrl.computedMovement();
318
+ const grounded = ctrl.computedGrounded();
319
+ const t = body.translation();
320
+ const next = { x: t.x + movement.x, y: t.y + movement.y, z: t.z + movement.z };
321
+ body.setNextKinematicTranslation(next);
322
+ body.setTranslation(next, true);
323
+ this.raw.propagateModifiedBodyPositionsToColliders();
324
+ const ctx = this.moveContext;
325
+ if (ctx) {
326
+ ctx.world.set(entity, ctx.transform, {
327
+ pos: [next.x, next.y, next.z]
328
+ });
329
+ ctx.world.set(entity, ctx.characterController, { grounded });
330
+ }
331
+ return vec3.create(movement.x, movement.y, movement.z);
332
+ }
333
+ /**
334
+ * Read CharacterController tuning fields for an entity from the ECS World,
335
+ * falling back to schema defaults when the World is not wired (defensive;
336
+ * the kinematic check upstream means a valid character always has the World).
337
+ */
338
+ readCharacterController(entity) {
339
+ const ctx = this.moveContext;
340
+ if (ctx) {
341
+ const r = ctx.world.get(entity, ctx.characterController);
342
+ if (r.ok) {
343
+ const v = r.value;
344
+ return {
345
+ offset: v.offset,
346
+ maxSlopeClimbDeg: v.maxSlopeClimbDeg,
347
+ minSlopeSlideDeg: v.minSlopeSlideDeg,
348
+ autoStepMaxHeight: v.autoStepMaxHeight,
349
+ autoStepMinWidth: v.autoStepMinWidth,
350
+ snapToGroundDist: v.snapToGroundDist
351
+ };
352
+ }
353
+ }
354
+ return componentDefinition(CharacterController).defaults;
355
+ }
356
+ /**
357
+ * Lazily build a Rapier KinematicCharacterController for `entity` (cached).
358
+ */
359
+ // biome-ignore lint/suspicious/noExplicitAny: Rapier KinematicCharacterController from dynamic module
360
+ ensureKcc(entity, offset) {
361
+ const cached = this.kccCache.get(entity);
362
+ if (cached) return cached;
363
+ const ctrl = this.raw.createCharacterController(offset);
364
+ this.kccCache.set(entity, ctrl);
365
+ this.kccOffsets.set(entity, offset);
366
+ return ctrl;
367
+ }
368
+ /** Remove backend rows whose Collider disappeared from the World query. */
369
+ pruneMissingEntities(active) {
370
+ for (const entity of this.entityMap.keys()) {
371
+ if (!active.has(entity)) this.removeEntity(entity);
372
+ }
373
+ }
374
+ bodyForEntity(entity) {
375
+ const record = this.entityMap.get(entity);
376
+ if (!record) return void 0;
377
+ return this.raw.bodies.get(record.bodyHandle) ?? void 0;
378
+ }
379
+ isCommittedFixedBody(entity) {
380
+ return this.bodyForEntity(entity)?.bodyType() === this.rapierModule.RigidBodyType.Fixed;
381
+ }
382
+ reconcileTransformlessCompatibility(entity, staticByEcs) {
383
+ if (!staticByEcs || !this.isCommittedFixedBody(entity)) {
384
+ this.removeEntity(entity);
385
+ return;
386
+ }
387
+ this.removeKccController(entity);
388
+ }
389
+ resetForFullReconcile(transformlessStatic) {
390
+ for (const entity of [...this.entityMap.keys()]) {
391
+ if (transformlessStatic.has(entity) && this.isCommittedFixedBody(entity)) {
392
+ this.removeKccController(entity);
393
+ continue;
394
+ }
395
+ this.removeEntity(entity);
396
+ }
397
+ }
398
+ fullReconcilePhysicsState(state) {
399
+ const descriptors = [];
400
+ const transformlessStatic = /* @__PURE__ */ new Set();
401
+ for (const row of state.query) {
402
+ if (!row.has(state.transformComponent)) {
403
+ if (physicsRowIsStatic(row)) transformlessStatic.add(row.entity);
404
+ continue;
405
+ }
406
+ const descriptor = readPhysicsSyncDescriptor(row, state.transformComponent);
407
+ if (descriptor !== void 0) descriptors.push(descriptor);
408
+ }
409
+ this.resetForFullReconcile(transformlessStatic);
410
+ for (const descriptor of descriptors) {
411
+ this.ensureBody(
412
+ descriptor.entity,
413
+ descriptor.transform,
414
+ descriptor.rigidBody,
415
+ descriptor.collider
416
+ );
417
+ }
418
+ state.initialized = true;
419
+ }
420
+ reconcilePhysicsDelta(state, entity, delta) {
421
+ const row = state.query.at(entity);
422
+ if (row === void 0) {
423
+ this.removeEntity(entity);
424
+ return;
425
+ }
426
+ if (!row.has(state.transformComponent)) {
427
+ if (delta.colliderChanged || delta.rigidBodyChanged) {
428
+ this.removeEntity(entity);
429
+ return;
430
+ }
431
+ this.reconcileTransformlessCompatibility(entity, physicsRowIsStatic(row));
432
+ return;
433
+ }
434
+ const descriptor = readPhysicsSyncDescriptor(row, state.transformComponent);
435
+ if (descriptor === void 0) {
436
+ this.removeEntity(entity);
437
+ return;
438
+ }
439
+ if (this.hasBody(entity) && (delta.colliderChanged || delta.rigidBodyChanged)) {
440
+ this.removeEntity(entity);
441
+ }
442
+ if (!this.hasBody(entity)) {
443
+ this.ensureBody(entity, descriptor.transform, descriptor.rigidBody, descriptor.collider);
444
+ return;
445
+ }
446
+ if (delta.characterControllerChanged) {
447
+ const cachedOffset = this.kccOffsets.get(entity);
448
+ const hadCachedReceipt = this.kccCache.has(entity);
449
+ const finalOffset = descriptor.characterControllerOffset;
450
+ if (!descriptor.hasCharacterController) {
451
+ this.removeKccController(entity);
452
+ } else if (hadCachedReceipt && (delta.characterControllerRemoved || finalOffset === void 0 || !Object.is(cachedOffset, finalOffset))) {
453
+ this.removeKccController(entity);
454
+ if (finalOffset !== void 0) this.ensureKcc(entity, finalOffset);
455
+ }
456
+ }
457
+ if (!delta.transformChanged && !delta.characterControllerRemoved) return;
458
+ const bodyType = rigidBodyTypeFromF32(descriptor.rigidBody.type);
459
+ if (bodyType === "static") {
460
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, "static");
461
+ } else if (bodyType === "kinematic" && !descriptor.hasCharacterController) {
462
+ this.syncAuthoredPose(entity, descriptor.transform, descriptor.collider, "kinematic");
463
+ }
464
+ }
465
+ /** @internal ECS system bridge; consumers should register PhysicsSyncBackend. */
466
+ _syncFromEcs(world, transformComponent) {
467
+ this.assertActive("syncFromEcs");
468
+ let state = this.syncState;
469
+ if (state === void 0 || state.world !== world || state.transformComponent !== transformComponent) {
470
+ const queryResult = world.query({
471
+ read: [Collider],
472
+ optional: [transformComponent, RigidBody, CharacterController, ChildOf]
473
+ });
474
+ if (!queryResult.ok) return;
475
+ state = {
476
+ world,
477
+ transformComponent,
478
+ query: queryResult.value,
479
+ projection: createWorldProjection(world, {
480
+ components: [
481
+ transformComponent,
482
+ Collider,
483
+ RigidBody,
484
+ CharacterController,
485
+ ChildOf,
486
+ Disabled
487
+ ]
488
+ }),
489
+ initialized: false
490
+ };
491
+ this.syncState = state;
492
+ }
493
+ if (!state.initialized) {
494
+ this.fullReconcilePhysicsState(state);
495
+ return;
496
+ }
497
+ const evidence = state.projection.poll();
498
+ if (evidence.status === "rebuild") {
499
+ this.fullReconcilePhysicsState(state);
500
+ return;
501
+ }
502
+ if (evidence.changes.length === 0) return;
503
+ const deltas = /* @__PURE__ */ new Map();
504
+ for (const change of evidence.changes) {
505
+ let delta = deltas.get(change.entity);
506
+ if (delta === void 0) {
507
+ delta = {
508
+ transformChanged: false,
509
+ colliderChanged: false,
510
+ rigidBodyChanged: false,
511
+ characterControllerChanged: false,
512
+ characterControllerRemoved: false
513
+ };
514
+ deltas.set(change.entity, delta);
515
+ }
516
+ if (change.kind === "entity-removed") continue;
517
+ if (change.component === void 0) {
518
+ this.fullReconcilePhysicsState(state);
519
+ return;
520
+ }
521
+ if (change.component === transformComponent) {
522
+ delta.transformChanged = true;
523
+ } else if (change.component === Collider) {
524
+ delta.colliderChanged = true;
525
+ } else if (change.component === RigidBody) {
526
+ delta.rigidBodyChanged = true;
527
+ } else if (change.component === CharacterController) {
528
+ delta.characterControllerChanged = true;
529
+ if (change.kind === "component-removed") delta.characterControllerRemoved = true;
530
+ } else if (change.component === ChildOf) {
531
+ delta.transformChanged = true;
532
+ } else if (change.component !== Disabled) {
533
+ this.fullReconcilePhysicsState(state);
534
+ return;
535
+ }
536
+ }
537
+ for (const [entity, delta] of deltas) this.reconcilePhysicsDelta(state, entity, delta);
538
+ }
539
+ /**
540
+ * Remove an entity's cached KCC and unregister it from the Rapier world
541
+ * (plan-strategy D-3). Idempotent — safe for entities that never moved.
542
+ */
543
+ removeKccController(entity) {
544
+ const ctrl = this.kccCache.get(entity);
545
+ this.kccOffsets.delete(entity);
546
+ if (!ctrl) return;
547
+ this.raw.removeCharacterController(ctrl);
548
+ this.kccCache.delete(entity);
549
+ }
550
+ // ─── ECS→Rapier bridge (D-2) ──────────────────────────────────────────
551
+ /**
552
+ * Ensure a Rapier body and collider exist for an ECS entity (idempotent).
553
+ *
554
+ * When `entityMap` already contains the entity this returns immediately.
555
+ * Otherwise creates a Rapier RigidBody (dynamic / fixed / kinematic) +
556
+ * Collider (cuboid / ball / capsule) from the ECS component data, sets
557
+ * `body.userData = entity`, and registers the pairing via `registerBody`.
558
+ *
559
+ * @param entity Raw ECS entity number (stored in Rapier body.userData).
560
+ * @param transform ECS Transform fields: { posX, posY, posZ, ... }.
561
+ * @param rigidBody ECS RigidBody fields: { type (enum num), mass, ... }.
562
+ * @param collider ECS Collider fields: { shape (enum num), radius, ... }.
563
+ *
564
+ * Plan-strategy D-2 + D-3: enum→Rapier desc mapping consumes
565
+ * rigidBodyTypeFromF32 / colliderShapeFromF32 helpers; closed switch with
566
+ * no default — TypeScript enforces exhaustiveness on the string-union arms.
567
+ */
568
+ ensureBody(entity, transform, rigidBody, collider) {
569
+ this.assertActive("ensureBody");
570
+ if (this.entityMap.has(entity)) return;
571
+ const RAPIER = this.rapierModule;
572
+ const rbType = rigidBodyTypeFromF32(rigidBody.type);
573
+ let body;
574
+ switch (rbType) {
575
+ case "dynamic": {
576
+ const desc = RAPIER.RigidBodyDesc.dynamic().setTranslation(transform.position.x, transform.position.y, transform.position.z).setRotation(transform.rotation).setLinearDamping(rigidBody.linearDamping).setAngularDamping(rigidBody.angularDamping).setGravityScale(rigidBody.gravityScale);
577
+ if (rigidBody.mass > 0) {
578
+ desc.setAdditionalMass(rigidBody.mass);
579
+ }
580
+ if (rigidBody.ccdEnabled) {
581
+ desc.setCcdEnabled(true);
582
+ }
583
+ body = this.raw.createRigidBody(desc);
584
+ break;
585
+ }
586
+ case "static": {
587
+ const desc = RAPIER.RigidBodyDesc.fixed().setTranslation(transform.position.x, transform.position.y, transform.position.z).setRotation(transform.rotation);
588
+ body = this.raw.createRigidBody(desc);
589
+ break;
590
+ }
591
+ case "kinematic": {
592
+ const desc = RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(transform.position.x, transform.position.y, transform.position.z).setRotation(transform.rotation);
593
+ if (rigidBody.ccdEnabled) {
594
+ desc.setCcdEnabled(true);
595
+ }
596
+ body = this.raw.createRigidBody(desc);
597
+ break;
598
+ }
599
+ }
600
+ body.userData = entity;
601
+ this.registerBody(entity, body.handle);
602
+ const scaleX = Math.abs(transform.scale.x);
603
+ const scaleY = Math.abs(transform.scale.y);
604
+ const scaleZ = Math.abs(transform.scale.z);
605
+ const activeEvents = RAPIER.ActiveEvents.COLLISION_EVENTS;
606
+ const activeCollisionTypes = RAPIER.ActiveCollisionTypes.ALL;
607
+ const cShape = colliderShapeFromF32(collider.shape);
608
+ switch (cShape) {
609
+ case "cuboid": {
610
+ const desc = RAPIER.ColliderDesc.cuboid(
611
+ collider.halfExtents[0] * scaleX,
612
+ collider.halfExtents[1] * scaleY,
613
+ collider.halfExtents[2] * scaleZ
614
+ ).setFriction(collider.friction).setRestitution(collider.restitution).setDensity(collider.density).setCollisionGroups(collider.collisionGroups).setSolverGroups(collider.solverGroups).setActiveEvents(activeEvents).setActiveCollisionTypes(activeCollisionTypes);
615
+ if (collider.isSensor) desc.setSensor(true);
616
+ this.raw.createCollider(desc, body);
617
+ break;
618
+ }
619
+ case "sphere": {
620
+ const desc = RAPIER.ColliderDesc.ball(
621
+ collider.radius * Math.max(scaleX, scaleY, scaleZ)
622
+ ).setFriction(collider.friction).setRestitution(collider.restitution).setDensity(collider.density).setCollisionGroups(collider.collisionGroups).setSolverGroups(collider.solverGroups).setActiveEvents(activeEvents).setActiveCollisionTypes(activeCollisionTypes);
623
+ if (collider.isSensor) desc.setSensor(true);
624
+ this.raw.createCollider(desc, body);
625
+ break;
626
+ }
627
+ case "capsule": {
628
+ const desc = RAPIER.ColliderDesc.capsule(
629
+ collider.halfHeight * scaleY,
630
+ collider.radius * Math.max(scaleX, scaleZ)
631
+ ).setFriction(collider.friction).setRestitution(collider.restitution).setDensity(collider.density).setCollisionGroups(collider.collisionGroups).setSolverGroups(collider.solverGroups).setActiveEvents(activeEvents).setActiveCollisionTypes(activeCollisionTypes);
632
+ if (collider.isSensor) desc.setSensor(true);
633
+ this.raw.createCollider(desc, body);
634
+ break;
635
+ }
636
+ }
637
+ }
638
+ /**
639
+ * Synchronize a static or kinematic body's Rapier pose and collider shape from
640
+ * the resolved Transform pose. Dynamic bodies own their pose after creation.
641
+ */
642
+ syncAuthoredPose(entity, transform, collider, bodyType) {
643
+ this.assertActive("syncAuthoredPose");
644
+ const record = this.entityMap.get(entity);
645
+ if (!record) return;
646
+ const body = this.raw.bodies.get(record.bodyHandle);
647
+ if (!body) return;
648
+ if (bodyType === "static") {
649
+ body.setTranslation(transform.position, true);
650
+ body.setRotation(transform.rotation, true);
651
+ } else {
652
+ body.setNextKinematicTranslation(transform.position);
653
+ body.setNextKinematicRotation(transform.rotation);
654
+ }
655
+ const rapierCollider = body.collider(0);
656
+ if (!rapierCollider) return;
657
+ const scaleX = Math.abs(transform.scale.x);
658
+ const scaleY = Math.abs(transform.scale.y);
659
+ const scaleZ = Math.abs(transform.scale.z);
660
+ switch (colliderShapeFromF32(collider.shape)) {
661
+ case "cuboid":
662
+ rapierCollider.setHalfExtents({
663
+ x: collider.halfExtents[0] * scaleX,
664
+ y: collider.halfExtents[1] * scaleY,
665
+ z: collider.halfExtents[2] * scaleZ
666
+ });
667
+ break;
668
+ case "sphere":
669
+ rapierCollider.setRadius(collider.radius * Math.max(scaleX, scaleY, scaleZ));
670
+ break;
671
+ case "capsule":
672
+ rapierCollider.setHalfHeight(collider.halfHeight * scaleY);
673
+ rapierCollider.setRadius(collider.radius * Math.max(scaleX, scaleZ));
674
+ break;
675
+ }
676
+ }
677
+ // ─── ECS integration helpers ───────────────────────────────────────────
678
+ /**
679
+ * Register an ECS entity with its Rapier body handle.
680
+ */
681
+ registerBody(entity, bodyHandle) {
682
+ this.entityMap.set(entity, { bodyHandle });
683
+ }
684
+ /**
685
+ * Apply all pending teleports to their respective bodies.
686
+ */
687
+ applyPendingTeleports() {
688
+ for (const [entity, target] of this.pendingTeleports) {
689
+ const record = this.entityMap.get(entity);
690
+ if (!record) continue;
691
+ const body = this.raw.bodies.get(record.bodyHandle);
692
+ if (!body) continue;
693
+ body.setTranslation({ x: target.x, y: target.y, z: target.z }, true);
694
+ body.setLinvel({ x: 0, y: 0, z: 0 }, false);
695
+ body.setAngvel({ x: 0, y: 0, z: 0 }, false);
696
+ }
697
+ this.pendingTeleports.clear();
698
+ }
699
+ /**
700
+ * Set a kinematic body's next position from ECS transform.
701
+ */
702
+ setKinematicPosition(entity, pos) {
703
+ const record = this.entityMap.get(entity);
704
+ if (!record) return;
705
+ const body = this.raw.bodies.get(record.bodyHandle);
706
+ if (!body) return;
707
+ body.setNextKinematicTranslation({ x: pos.x, y: pos.y, z: pos.z });
708
+ }
709
+ /**
710
+ * Write Rapier dynamic body poses back.
711
+ */
712
+ writebackDynamicBodies() {
713
+ const results = [];
714
+ for (const [entity, record] of this.entityMap) {
715
+ const body = this.raw.bodies.get(record.bodyHandle);
716
+ if (!body) continue;
717
+ if (body.bodyType() !== this.rapierModule.RigidBodyType.Dynamic) continue;
718
+ const translation = body.translation();
719
+ const rotation = body.rotation();
720
+ results.push({
721
+ entity,
722
+ pos: { x: translation.x, y: translation.y, z: translation.z },
723
+ rotation: { x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w }
724
+ });
725
+ }
726
+ return results;
727
+ }
728
+ /**
729
+ * Remove a Rapier body and its colliders when the ECS entity is despawned.
730
+ */
731
+ removeEntity(entity) {
732
+ const record = this.entityMap.get(entity);
733
+ if (!record) return;
734
+ const ownPairs = [...this.collisionPairs.get(entity) ?? []];
735
+ for (const other of ownPairs) {
736
+ if (this.removePair(entity, other)) {
737
+ this.pushCollisionEvent({ type: "stopped", entityA: entity, entityB: other });
738
+ }
739
+ }
740
+ this.removeKccController(entity);
741
+ this.raw.removeRigidBody({ handle: record.bodyHandle });
742
+ this.entityMap.delete(entity);
743
+ const own = this.collisionPairs.get(entity);
744
+ if (own) {
745
+ for (const other of own) this.collisionPairs.get(other)?.delete(entity);
746
+ this.collisionPairs.delete(entity);
747
+ }
748
+ }
749
+ };
750
+ function createRapier3DPhysicsWorld(rapier) {
751
+ return new RapierPhysicsWorld3D(rapier);
752
+ }
753
+ function hasReadableWorldPose(world) {
754
+ return world !== void 0 && world.length >= 16;
755
+ }
756
+ var PHYSICS_DT_MAX = 0.1;
757
+ var poseScratchPosition = vec3.create();
758
+ var poseScratchRotation = quat.create();
759
+ var poseScratchScale = vec3.create();
760
+ var poseScratchWorld = new Float32Array(16);
761
+ function physicsRowIsStatic(row) {
762
+ if (!row.has(RigidBody)) return true;
763
+ const rigidBody = row.get(RigidBody);
764
+ return rigidBody !== void 0 && rigidBodyTypeFromF32(rigidBody.type) === "static";
765
+ }
766
+ function readPhysicsSyncDescriptor(row, transformComponent) {
767
+ const transformData = row.get(transformComponent);
768
+ const colliderData = row.get(Collider);
769
+ if (transformData === void 0 || colliderData === void 0) return void 0;
770
+ const useWorldPose = row.has(ChildOf) && hasReadableWorldPose(transformData.world);
771
+ if (useWorldPose) {
772
+ poseScratchWorld.set(transformData.world.subarray(0, 16));
773
+ mat4.decompose(poseScratchPosition, poseScratchRotation, poseScratchScale, poseScratchWorld);
774
+ } else {
775
+ poseScratchPosition[0] = transformData.pos[0] ?? 0;
776
+ poseScratchPosition[1] = transformData.pos[1] ?? 0;
777
+ poseScratchPosition[2] = transformData.pos[2] ?? 0;
778
+ poseScratchRotation[0] = transformData.quat[0] ?? 0;
779
+ poseScratchRotation[1] = transformData.quat[1] ?? 0;
780
+ poseScratchRotation[2] = transformData.quat[2] ?? 0;
781
+ poseScratchRotation[3] = transformData.quat[3] ?? 1;
782
+ poseScratchScale[0] = transformData.scale[0] ?? 1;
783
+ poseScratchScale[1] = transformData.scale[1] ?? 1;
784
+ poseScratchScale[2] = transformData.scale[2] ?? 1;
785
+ }
786
+ const rigidBodyData = row.has(RigidBody) ? row.get(RigidBody) : void 0;
787
+ const characterControllerData = row.has(CharacterController) ? row.get(CharacterController) : void 0;
788
+ return {
789
+ entity: row.entity,
790
+ transform: {
791
+ position: {
792
+ x: poseScratchPosition[0] ?? 0,
793
+ y: poseScratchPosition[1] ?? 0,
794
+ z: poseScratchPosition[2] ?? 0
795
+ },
796
+ rotation: {
797
+ x: poseScratchRotation[0] ?? 0,
798
+ y: poseScratchRotation[1] ?? 0,
799
+ z: poseScratchRotation[2] ?? 0,
800
+ w: poseScratchRotation[3] ?? 1
801
+ },
802
+ scale: {
803
+ x: poseScratchScale[0] ?? 1,
804
+ y: poseScratchScale[1] ?? 1,
805
+ z: poseScratchScale[2] ?? 1
806
+ }
807
+ },
808
+ rigidBody: rigidBodyData === void 0 ? {
809
+ type: RIGID_BODY_TYPE_STATIC,
810
+ mass: 0,
811
+ linearDamping: 0,
812
+ angularDamping: 0,
813
+ gravityScale: 1,
814
+ ccdEnabled: 0
815
+ } : {
816
+ type: rigidBodyData.type,
817
+ mass: rigidBodyData.mass,
818
+ linearDamping: rigidBodyData.linearDamping,
819
+ angularDamping: rigidBodyData.angularDamping,
820
+ gravityScale: rigidBodyData.gravityScale,
821
+ ccdEnabled: Number(rigidBodyData.ccdEnabled)
822
+ },
823
+ collider: {
824
+ shape: colliderData.shape,
825
+ halfExtents: [
826
+ colliderData.halfExtents[0] ?? 0,
827
+ colliderData.halfExtents[1] ?? 0,
828
+ colliderData.halfExtents[2] ?? 0
829
+ ],
830
+ radius: colliderData.radius,
831
+ halfHeight: colliderData.halfHeight,
832
+ friction: colliderData.friction,
833
+ restitution: colliderData.restitution,
834
+ density: colliderData.density,
835
+ isSensor: Number(colliderData.isSensor),
836
+ collisionGroups: colliderData.collisionGroups,
837
+ solverGroups: colliderData.solverGroups
838
+ },
839
+ hasCharacterController: row.has(CharacterController),
840
+ characterControllerOffset: characterControllerData?.offset
841
+ };
842
+ }
843
+ var PHYSICS_SYNC_BACKEND = "physicsSyncBackend";
844
+ var PHYSICS_STEP_SIMULATION = "physicsStepSimulation";
845
+ var PHYSICS_WRITEBACK = "physicsWriteback";
846
+ var PHYSICS_COLLISION_SYNC = "physicsCollisionSync";
847
+ function resolveTransform(world) {
848
+ return world.components.resolve("Transform");
849
+ }
850
+ var PhysicsSyncBackend = defineSystem({
851
+ name: PHYSICS_SYNC_BACKEND,
852
+ queries: [],
853
+ after: ["propagateTransformsFixed"],
854
+ fn: (world) => {
855
+ const transformComponent = resolveTransform(world);
856
+ if (transformComponent === void 0) return;
857
+ let pw;
858
+ try {
859
+ pw = world.getResource("PhysicsWorld");
860
+ } catch {
861
+ return;
862
+ }
863
+ pw.applyPendingTeleports();
864
+ pw._syncFromEcs(world, transformComponent);
865
+ }
866
+ });
867
+ var PhysicsStepSimulation = defineSystem({
868
+ name: PHYSICS_STEP_SIMULATION,
869
+ queries: [],
870
+ after: [PHYSICS_SYNC_BACKEND],
871
+ fn: (world) => {
872
+ let pw;
873
+ try {
874
+ pw = world.getResource("PhysicsWorld");
875
+ } catch {
876
+ return;
877
+ }
878
+ const dt = world.getResource(FixedTime).delta;
879
+ if (dt <= 0 || dt > PHYSICS_DT_MAX) return;
880
+ pw.step(dt);
881
+ }
882
+ });
883
+ var PhysicsWriteback = defineSystem({
884
+ name: PHYSICS_WRITEBACK,
885
+ queries: [],
886
+ after: [PHYSICS_STEP_SIMULATION],
887
+ fn: (world) => {
888
+ const transformComponent = resolveTransform(world);
889
+ if (transformComponent === void 0) return;
890
+ let pw;
891
+ try {
892
+ pw = world.getResource("PhysicsWorld");
893
+ } catch {
894
+ return;
895
+ }
896
+ const results = pw.writebackDynamicBodies();
897
+ for (const r of results) {
898
+ const entity = r.entity;
899
+ world.set(entity, transformComponent, {
900
+ pos: [r.pos.x, r.pos.y, r.pos.z],
901
+ quat: [r.rotation.x, r.rotation.y, r.rotation.z, r.rotation.w]
902
+ });
903
+ }
904
+ }
905
+ });
906
+ var PhysicsCollisionSync = defineSystem({
907
+ name: PHYSICS_COLLISION_SYNC,
908
+ queries: [],
909
+ after: [PHYSICS_WRITEBACK],
910
+ fn: (world) => {
911
+ let pw;
912
+ try {
913
+ pw = world.getResource("PhysicsWorld");
914
+ } catch {
915
+ return;
916
+ }
917
+ pw.writebackCollidingEntities(world, CollidingEntities);
918
+ }
919
+ });
920
+ function registerPhysicsSystems(world) {
921
+ const releaseComponents = registerPhysicsComponents(world);
922
+ const transformComponent = resolveTransform(world);
923
+ try {
924
+ const pw = world.getResource("PhysicsWorld");
925
+ if (transformComponent !== void 0) {
926
+ pw.setMoveContext(world, transformComponent, CharacterController);
927
+ }
928
+ } catch {
929
+ }
930
+ world.addSystems(FixedUpdate, PhysicsSet, [
931
+ PhysicsSyncBackend,
932
+ PhysicsStepSimulation,
933
+ PhysicsWriteback,
934
+ PhysicsCollisionSync
935
+ ]).unwrap();
936
+ return () => {
937
+ world.removeSystem(FixedUpdate, PHYSICS_COLLISION_SYNC);
938
+ world.removeSystem(FixedUpdate, PHYSICS_WRITEBACK);
939
+ world.removeSystem(FixedUpdate, PHYSICS_STEP_SIMULATION);
940
+ world.removeSystem(FixedUpdate, PHYSICS_SYNC_BACKEND);
941
+ try {
942
+ world.getResource("PhysicsWorld").clearEcsContext(world);
943
+ } catch {
944
+ }
945
+ releaseComponents();
946
+ };
947
+ }
948
+ var rapierInstance = null;
949
+ var loadingPromise = null;
950
+ async function loadRapier3D() {
951
+ if (rapierInstance !== null) return rapierInstance;
952
+ if (loadingPromise !== null) return loadingPromise;
953
+ loadingPromise = _doLoad();
954
+ return loadingPromise;
955
+ }
956
+ async function _doLoad() {
957
+ try {
958
+ const RAPIER = await import('@dimforge/rapier3d-compat');
959
+ await RAPIER.default.init();
960
+ rapierInstance = RAPIER.default;
961
+ loadingPromise = null;
962
+ return RAPIER.default;
963
+ } catch (cause) {
964
+ const reason = cause instanceof Error ? cause.message : String(cause);
965
+ loadingPromise = null;
966
+ return new PhysicsError$1({
967
+ code: "wasm-load-failed",
968
+ expected: "successful dynamic import and init of @dimforge/rapier3d-compat",
969
+ hint: `dynamic import or init() failed: ${reason}. Check network, file path, and that @dimforge/rapier3d-compat is installed.`,
970
+ detail: { code: "wasm-load-failed", reason }
971
+ });
972
+ }
973
+ }
974
+
975
+ export { RapierPhysicsWorld3D, createRapier3DPhysicsWorld, loadRapier3D, registerPhysicsSystems };
976
+ //# sourceMappingURL=index.mjs.map
977
+ //# sourceMappingURL=index.mjs.map