@genex-ai/cli-demo 0.11.0 → 0.14.2

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.
Files changed (42) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +203 -4
  3. package/package.json +7 -2
  4. package/templates/controllers/NOTICE.md +65 -0
  5. package/templates/controllers/assets/animation-library.glb +0 -0
  6. package/templates/controllers/assets/character.glb +0 -0
  7. package/templates/controllers/assets/default-avatar.vrm +0 -0
  8. package/templates/controllers/character/character-animations.ts +682 -0
  9. package/templates/controllers/character/character-controller.ts +1636 -0
  10. package/templates/controllers/character/follow-camera.ts +644 -0
  11. package/templates/controllers/character/keyboard-input.ts +277 -0
  12. package/templates/controllers/character/presets.ts +176 -0
  13. package/templates/controllers/character/touch-joystick.ts +387 -0
  14. package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
  15. package/templates/controllers/character/vrm/foot-ik.ts +341 -0
  16. package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
  17. package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
  18. package/templates/controllers/drone/drone-controller.ts +1073 -0
  19. package/templates/controllers/drone/presets.ts +225 -0
  20. package/templates/controllers/interact/enter-exit.ts +502 -0
  21. package/templates/controllers/shared/colliders.ts +456 -0
  22. package/templates/controllers/shared/math.ts +230 -0
  23. package/templates/controllers/shared/physics-world.ts +622 -0
  24. package/templates/controllers/vehicle/presets.ts +297 -0
  25. package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
  26. package/templates/controllers/vehicle/wheel.ts +1200 -0
  27. package/templates/skills/genex-getting-started/SKILL.md +5 -0
  28. package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
  29. package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
  30. package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
  31. package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
  32. package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
  33. package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
  34. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
  35. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  36. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  37. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  38. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  39. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  41. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  42. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,622 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TS port of the ecctrl controller (physics world glue: this file
4
+ // re-implements, as one plain class, the behavior the upstream React
5
+ // components received from their React physics wrapper (@react-three/rapier
6
+ // v2.2.0) plus ecctrl's own TimeControl — Rapier WASM init, fixed-timestep
7
+ // accumulator loop, rigid-body <-> Object3D registry with interpolated pose
8
+ // sync, collision/intersection event dispatch, and the debug-line renderer).
9
+ // Note: upstream's rigid-body userData key `ecctrl` is renamed to
10
+ // `controller` in this port (deliberate de-branding rename).
11
+
12
+ import * as THREE from "three";
13
+ import RAPIER from "@dimforge/rapier3d-compat";
14
+
15
+ /**
16
+ * Shape of `RigidBody.userData` the controllers understand. Runtime key is
17
+ * `controller` (upstream used `ecctrl`; renamed as part of de-branding).
18
+ *
19
+ * - `excludeRay`: body is ignored by ALL ground queries (character rays AND
20
+ * wheel shapecasts). Set it on bodies that should never count as ground.
21
+ * - `excludeCharacterRay`: ignored by the character's ground query only.
22
+ * - `excludeVehicleRay`: ignored by wheel shapecasts only. The character body
23
+ * itself should be created with `{ controller: { excludeVehicleRay: true } }`
24
+ * so car wheels do not treat the on-foot character as drivable ground.
25
+ */
26
+ export interface ControllerUserData {
27
+ controller?: {
28
+ excludeRay?: boolean;
29
+ excludeCharacterRay?: boolean;
30
+ excludeVehicleRay?: boolean;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * World creation options. Defaults mirror the upstream physics wrapper's
36
+ * defaults exactly; you rarely need to touch anything except `gravity`.
37
+ */
38
+ export interface PhysicsWorldOptions {
39
+ /** World gravity in m/s^2. Default `[0, -9.81, 0]`. */
40
+ gravity?: [number, number, number];
41
+ /**
42
+ * Fixed simulation step in seconds. Default `1/60`. Controllers read this
43
+ * via `world.timestep` — it is the ONLY dt physics code may use.
44
+ */
45
+ timeStep?: number;
46
+ /**
47
+ * Per-frame wall-clock clamp in seconds (spiral-of-death guard). Default
48
+ * `1/30`, i.e. at most 2 substeps per frame at the default step. Raise it
49
+ * if you want physics to catch up after long frame hitches.
50
+ */
51
+ maxDelta?: number;
52
+ /** Interpolate rendered poses between fixed steps. Default `true`. */
53
+ interpolate?: boolean;
54
+ /** Solver iterations. More = stiffer stacks, more CPU. Default 4. */
55
+ numSolverIterations?: number;
56
+ /** Internal PGS iterations. Default 1. */
57
+ numInternalPgsIterations?: number;
58
+ /** Allowed penetration (length units). Default 0.001. */
59
+ allowedLinearError?: number;
60
+ /** Contact prediction distance (length units). Default 0.002. */
61
+ predictionDistance?: number;
62
+ /** Minimum island size for parallelism. Default 128. */
63
+ minIslandSize?: number;
64
+ /** Max CCD substeps. Default 1. */
65
+ maxCcdSubsteps?: number;
66
+ /** Contact softness frequency (Hz). Default 30. */
67
+ contactNaturalFrequency?: number;
68
+ /** World length unit (units per meter). Default 1. */
69
+ lengthUnit?: number;
70
+ }
71
+
72
+ /** Options for {@link PhysicsWorld.createBody}. */
73
+ export interface RigidBodyOptions {
74
+ /** Body type. Default `"dynamic"`. */
75
+ type?: "dynamic" | "fixed" | "kinematicPosition" | "kinematicVelocity";
76
+ /** Initial translation. */
77
+ position?: [number, number, number];
78
+ /** Initial rotation: a quaternion, or `[x, y, z]` euler angles (XYZ, rad). */
79
+ rotation?: THREE.Quaternion | [number, number, number];
80
+ /** Allow the body to sleep when at rest. Default `true`. */
81
+ canSleep?: boolean;
82
+ /** Enable continuous collision detection (fast small bodies). */
83
+ ccd?: boolean;
84
+ /** Per-body gravity multiplier (0 disables gravity for this body). */
85
+ gravityScale?: number;
86
+ /** Linear velocity damping. */
87
+ linearDamping?: number;
88
+ /** Angular velocity damping. */
89
+ angularDamping?: number;
90
+ /** Lock all rotations. */
91
+ lockRotations?: boolean;
92
+ /** Enable rotation per axis `[x, y, z]`. */
93
+ enabledRotations?: [boolean, boolean, boolean];
94
+ /** Arbitrary user data; see {@link ControllerUserData} for the keys the controllers read. */
95
+ userData?: unknown;
96
+ }
97
+
98
+ /** One side of a collision/intersection event. */
99
+ export interface CollisionTarget {
100
+ collider: RAPIER.Collider;
101
+ rigidBody: RAPIER.RigidBody | null;
102
+ /** The registered Object3D of the collider's body, if any. */
103
+ object3d: THREE.Object3D | null;
104
+ }
105
+
106
+ /** Payload delivered to per-collider event handlers. */
107
+ export interface CollisionPayload {
108
+ target: CollisionTarget;
109
+ other: CollisionTarget;
110
+ }
111
+
112
+ /**
113
+ * Per-collider event handlers. Enter events are disambiguated: solid contacts
114
+ * fire `onCollisionEnter`, sensor overlaps fire `onIntersectionEnter`. Exit
115
+ * events fire BOTH `onCollisionExit` and `onIntersectionExit` — faithful
116
+ * upstream behavior, kept on purpose.
117
+ */
118
+ export interface ColliderEventHandlers {
119
+ onCollisionEnter?: (payload: CollisionPayload) => void;
120
+ onCollisionExit?: (payload: CollisionPayload) => void;
121
+ /** Sensor overlap began. */
122
+ onIntersectionEnter?: (payload: CollisionPayload) => void;
123
+ /** Sensor overlap ended. */
124
+ onIntersectionExit?: (payload: CollisionPayload) => void;
125
+ }
126
+
127
+ interface BodyState {
128
+ object: THREE.Object3D;
129
+ invertedWorldMatrix: THREE.Matrix4;
130
+ scale: THREE.Vector3;
131
+ }
132
+
133
+ interface PreviousPose {
134
+ position: THREE.Vector3;
135
+ rotation: THREE.Quaternion;
136
+ }
137
+
138
+ // Module-level shared init promise: RAPIER.init() loads the embedded WASM
139
+ // once, no matter how many worlds are created.
140
+ let rapierInitPromise: Promise<void> | null = null;
141
+
142
+ // Scratch objects for the per-frame mesh sync (never escape this module).
143
+ const _matrix4 = new THREE.Matrix4();
144
+ const _position = new THREE.Vector3();
145
+ const _rotation = new THREE.Quaternion();
146
+ const _scale = new THREE.Vector3();
147
+ const _bodyPos = new THREE.Vector3();
148
+ const _bodyRot = new THREE.Quaternion();
149
+ const _identityMatrix = new THREE.Matrix4();
150
+
151
+ /**
152
+ * Owns the Rapier world, the fixed-timestep accumulator loop, the
153
+ * body <-> Object3D registry, and collision-event dispatch.
154
+ *
155
+ * Canonical loop shape (once per rAF frame):
156
+ * ```ts
157
+ * const physics = await PhysicsWorld.create();
158
+ * physics.onBeforeStep(() => controller.update(physics.timeStep));
159
+ * renderer.setAnimationLoop(() => {
160
+ * physics.step(clock.getDelta()); // fixed substeps + mesh sync + events
161
+ * // ...camera + render (render-delta code lives OUT here, never inside)
162
+ * });
163
+ * ```
164
+ * Before-step callbacks fire once per fixed SUBSTEP (before `world.step()`),
165
+ * so controller impulses are consumed by the step immediately following —
166
+ * with `frameRateCorrection = 60 * world.timestep` this preserves the
167
+ * upstream impulse scaling exactly.
168
+ */
169
+ export class PhysicsWorld {
170
+ /** The raw Rapier world. Controllers receive this, not the wrapper. */
171
+ readonly world: RAPIER.World;
172
+ /** The fixed step in seconds; `world.timestep` is kept equal to it. */
173
+ readonly timeStep: number;
174
+ /** Pause gate — checked before any time accumulates, so no time banks up. */
175
+ paused = false;
176
+ /** Time dilation. 1 = realtime; 0.5 = half-speed slow-mo. */
177
+ timeScale = 1;
178
+ /** Per-frame wall-clock clamp in seconds (see {@link PhysicsWorldOptions.maxDelta}). */
179
+ maxDelta: number;
180
+ /** Interpolate rendered poses between fixed steps. */
181
+ interpolate: boolean;
182
+
183
+ private eventQueue: RAPIER.EventQueue;
184
+ private bodyStates = new Map<number, BodyState>();
185
+ private colliderEvents = new Map<number, ColliderEventHandlers>();
186
+ private beforeStepCallbacks = new Set<(world: RAPIER.World) => void>();
187
+ private afterStepCallbacks = new Set<(world: RAPIER.World) => void>();
188
+ private collisionEventTaps = new Set<
189
+ (handle1: number, handle2: number, started: boolean) => void
190
+ >();
191
+ private accumulator = 0;
192
+ private previousState = new Map<number, PreviousPose>();
193
+ private stepsExecuted = 0;
194
+ private debugLines: THREE.LineSegments | null = null;
195
+ private debugScene: THREE.Scene | null = null;
196
+
197
+ private constructor(options: PhysicsWorldOptions) {
198
+ const gravity = options.gravity ?? [0, -9.81, 0];
199
+ this.timeStep = options.timeStep ?? 1 / 60;
200
+ this.maxDelta = options.maxDelta ?? 1 / 30;
201
+ this.interpolate = options.interpolate ?? true;
202
+
203
+ this.world = new RAPIER.World(
204
+ new RAPIER.Vector3(gravity[0], gravity[1], gravity[2])
205
+ );
206
+ // Integration parameters — upstream physics-wrapper defaults.
207
+ this.world.integrationParameters.numSolverIterations =
208
+ options.numSolverIterations ?? 4;
209
+ this.world.integrationParameters.numInternalPgsIterations =
210
+ options.numInternalPgsIterations ?? 1;
211
+ this.world.integrationParameters.normalizedAllowedLinearError =
212
+ options.allowedLinearError ?? 0.001;
213
+ this.world.integrationParameters.minIslandSize =
214
+ options.minIslandSize ?? 128;
215
+ this.world.integrationParameters.maxCcdSubsteps =
216
+ options.maxCcdSubsteps ?? 1;
217
+ this.world.integrationParameters.normalizedPredictionDistance =
218
+ options.predictionDistance ?? 0.002;
219
+ this.world.lengthUnit = options.lengthUnit ?? 1;
220
+ this.world.integrationParameters.contact_natural_frequency =
221
+ options.contactNaturalFrequency ?? 30;
222
+
223
+ this.world.timestep = this.timeStep;
224
+ this.eventQueue = new RAPIER.EventQueue(false);
225
+ }
226
+
227
+ /**
228
+ * Create a physics world. Awaits `RAPIER.init()` (embedded WASM — no
229
+ * bundler config needed); multiple calls share a single init. Nothing may
230
+ * construct any `RAPIER.*` object before this promise resolves.
231
+ */
232
+ static async create(options: PhysicsWorldOptions = {}): Promise<PhysicsWorld> {
233
+ if (!rapierInitPromise) rapierInitPromise = RAPIER.init();
234
+ await rapierInitPromise;
235
+ return new PhysicsWorld(options);
236
+ }
237
+
238
+ // ---- body registry (replaces the JSX <RigidBody> mount/unmount) ----
239
+
240
+ /**
241
+ * Create a rigid body from plain options. If `object3d` is given it is
242
+ * registered for interpolated render sync (see {@link registerBody}).
243
+ */
244
+ createBody(
245
+ options: RigidBodyOptions = {},
246
+ object3d?: THREE.Object3D
247
+ ): RAPIER.RigidBody {
248
+ const desc = new RAPIER.RigidBodyDesc(
249
+ rigidBodyTypeFromString(options.type ?? "dynamic")
250
+ );
251
+ desc.canSleep = options.canSleep ?? true;
252
+ if (options.position) {
253
+ desc.setTranslation(
254
+ options.position[0],
255
+ options.position[1],
256
+ options.position[2]
257
+ );
258
+ }
259
+ if (options.rotation) {
260
+ const quat = Array.isArray(options.rotation)
261
+ ? new THREE.Quaternion().setFromEuler(
262
+ new THREE.Euler(
263
+ options.rotation[0],
264
+ options.rotation[1],
265
+ options.rotation[2],
266
+ "XYZ"
267
+ )
268
+ )
269
+ : options.rotation;
270
+ desc.setRotation({ x: quat.x, y: quat.y, z: quat.z, w: quat.w });
271
+ }
272
+
273
+ const body = this.world.createRigidBody(desc);
274
+ // Mutable options, applied in the upstream option-map order.
275
+ if (options.gravityScale !== undefined)
276
+ body.setGravityScale(options.gravityScale, true);
277
+ if (options.linearDamping !== undefined)
278
+ body.setLinearDamping(options.linearDamping);
279
+ if (options.angularDamping !== undefined)
280
+ body.setAngularDamping(options.angularDamping);
281
+ if (options.enabledRotations) {
282
+ body.setEnabledRotations(
283
+ options.enabledRotations[0],
284
+ options.enabledRotations[1],
285
+ options.enabledRotations[2],
286
+ true
287
+ );
288
+ }
289
+ if (options.lockRotations !== undefined)
290
+ body.lockRotations(options.lockRotations, true);
291
+ if (options.ccd !== undefined) body.enableCcd(options.ccd);
292
+ if (options.userData !== undefined) body.userData = options.userData;
293
+
294
+ if (object3d) this.registerBody(body, object3d);
295
+ return body;
296
+ }
297
+
298
+ /**
299
+ * Register an Object3D to follow `body` (interpolated, once per frame).
300
+ *
301
+ * The parent's inverse world matrix and the object's world scale are
302
+ * captured NOW and never refreshed — register scene-root-level groups
303
+ * (character root, chassis group) and add them to the scene FIRST; if the
304
+ * registered object's parent later moves or scales, the sync silently
305
+ * desyncs.
306
+ */
307
+ registerBody(body: RAPIER.RigidBody, object3d: THREE.Object3D): void {
308
+ object3d.updateWorldMatrix(true, false);
309
+ const invertedWorldMatrix = object3d.parent
310
+ ? object3d.parent.matrixWorld.clone().invert()
311
+ : _identityMatrix.clone();
312
+ this.bodyStates.set(body.handle, {
313
+ object: object3d,
314
+ invertedWorldMatrix,
315
+ scale: object3d.getWorldScale(new THREE.Vector3()).clone(),
316
+ });
317
+ }
318
+
319
+ /** Stop syncing the body's Object3D (does not remove the body). */
320
+ unregisterBody(body: RAPIER.RigidBody): void {
321
+ this.bodyStates.delete(body.handle);
322
+ }
323
+
324
+ /**
325
+ * Unregister the body's Object3D, drop event handlers for all of its
326
+ * colliders, and remove the body (and its colliders) from the world.
327
+ */
328
+ removeBody(body: RAPIER.RigidBody): void {
329
+ for (let i = 0; i < body.numColliders(); i++) {
330
+ this.colliderEvents.delete(body.collider(i).handle);
331
+ }
332
+ this.unregisterBody(body);
333
+ this.world.removeRigidBody(body);
334
+ }
335
+
336
+ /** The registered Object3D for `body`, or null. */
337
+ getObject3d(body: RAPIER.RigidBody): THREE.Object3D | null {
338
+ return this.bodyStates.get(body.handle)?.object ?? null;
339
+ }
340
+
341
+ // ---- step hooks ----
342
+
343
+ /**
344
+ * Register a callback fired once per fixed SUBSTEP, immediately before
345
+ * `world.step()`. Controllers' `update()` calls live here. Returns an
346
+ * unsubscribe function.
347
+ */
348
+ onBeforeStep(cb: (world: RAPIER.World) => void): () => void {
349
+ this.beforeStepCallbacks.add(cb);
350
+ return () => {
351
+ this.beforeStepCallbacks.delete(cb);
352
+ };
353
+ }
354
+
355
+ /** Like {@link onBeforeStep} but fired right after `world.step()`. */
356
+ onAfterStep(cb: (world: RAPIER.World) => void): () => void {
357
+ this.afterStepCallbacks.add(cb);
358
+ return () => {
359
+ this.afterStepCallbacks.delete(cb);
360
+ };
361
+ }
362
+
363
+ // ---- collision/intersection events ----
364
+
365
+ /**
366
+ * Attach enter/exit handlers to a collider. Also enables
367
+ * `ActiveEvents.COLLISION_EVENTS` on it — without that flag Rapier never
368
+ * reports the pair, so sensors would stay silent.
369
+ */
370
+ setColliderEvents(
371
+ collider: RAPIER.Collider,
372
+ handlers: ColliderEventHandlers
373
+ ): void {
374
+ collider.setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS);
375
+ this.colliderEvents.set(collider.handle, handlers);
376
+ }
377
+
378
+ /** Remove the handlers registered via {@link setColliderEvents}. */
379
+ clearColliderEvents(collider: RAPIER.Collider): void {
380
+ this.colliderEvents.delete(collider.handle);
381
+ }
382
+
383
+ /**
384
+ * Raw tap fired once per drained collision event (both solid and sensor),
385
+ * during the per-frame drain, BEFORE per-collider handler dispatch.
386
+ * Feed this to `EnterExitManager.handleIntersectionEvent`. Returns an
387
+ * unsubscribe function.
388
+ */
389
+ onCollisionEvent(
390
+ cb: (handle1: number, handle2: number, started: boolean) => void
391
+ ): () => void {
392
+ this.collisionEventTaps.add(cb);
393
+ return () => {
394
+ this.collisionEventTaps.delete(cb);
395
+ };
396
+ }
397
+
398
+ // ---- main loop ----
399
+
400
+ /**
401
+ * Advance the simulation. Call once per rAF with the render clock delta in
402
+ * SECONDS. Runs zero or more fixed substeps (before-step callbacks +
403
+ * `world.step()` + after-step callbacks each), then syncs registered
404
+ * Object3Ds (interpolated), drains collision events, and updates the debug
405
+ * lines.
406
+ */
407
+ step(delta: number): void {
408
+ this.stepsExecuted = 0;
409
+ // Pause gates BEFORE accumulation so unpausing never replays banked time.
410
+ if (this.paused) return;
411
+
412
+ const maxStep = Math.max(0, this.maxDelta);
413
+ const dt = THREE.MathUtils.clamp(delta, 0, maxStep);
414
+ if (this.timeScale <= 0 || dt <= 0) return;
415
+ // Hard safety clamp from the upstream wrapper, kept in addition to
416
+ // maxDelta (different layers; users may raise maxDelta).
417
+ const clampedDelta = THREE.MathUtils.clamp(dt * this.timeScale, 0, 0.5);
418
+
419
+ this.accumulator += clampedDelta;
420
+ while (this.accumulator >= this.timeStep) {
421
+ if (this.interpolate) {
422
+ // Snapshot previous poses — needed for accurate interpolation when
423
+ // the world steps more than once per frame. Values are COPIED out of
424
+ // the WASM-returned objects.
425
+ this.previousState.clear();
426
+ this.world.forEachRigidBody((body) => {
427
+ const t = body.translation();
428
+ const r = body.rotation();
429
+ this.previousState.set(body.handle, {
430
+ position: new THREE.Vector3(t.x, t.y, t.z),
431
+ rotation: new THREE.Quaternion(r.x, r.y, r.z, r.w),
432
+ });
433
+ });
434
+ }
435
+ this.beforeStepCallbacks.forEach((callback) => {
436
+ callback(this.world);
437
+ });
438
+ // Re-assert the fixed step every substep so user code that fiddled with
439
+ // world.timestep cannot break frameRateCorrection (= 60 * timestep).
440
+ this.world.timestep = this.timeStep;
441
+ this.world.step(this.eventQueue);
442
+ this.afterStepCallbacks.forEach((callback) => {
443
+ callback(this.world);
444
+ });
445
+ this.accumulator -= this.timeStep;
446
+ this.stepsExecuted++;
447
+ }
448
+
449
+ const interpolationAlpha = !this.interpolate
450
+ ? 1
451
+ : this.accumulator / this.timeStep;
452
+
453
+ // Mesh sync: rewind to the previous-tick pose, then lerp toward the
454
+ // current tick by alpha (the upstream interpolation scheme, exactly).
455
+ this.bodyStates.forEach((state, handle) => {
456
+ const body = this.world.getRigidBody(handle);
457
+ if (!body || body.isSleeping()) return;
458
+
459
+ const t = body.translation();
460
+ const r = body.rotation();
461
+ const prev = this.previousState.get(handle);
462
+ if (prev) {
463
+ _matrix4
464
+ .compose(prev.position, prev.rotation, state.scale)
465
+ .premultiply(state.invertedWorldMatrix)
466
+ .decompose(_position, _rotation, _scale);
467
+ state.object.position.copy(_position);
468
+ state.object.quaternion.copy(_rotation);
469
+ }
470
+
471
+ _bodyPos.set(t.x, t.y, t.z);
472
+ _bodyRot.set(r.x, r.y, r.z, r.w);
473
+ _matrix4
474
+ .compose(_bodyPos, _bodyRot, state.scale)
475
+ .premultiply(state.invertedWorldMatrix)
476
+ .decompose(_position, _rotation, _scale);
477
+ state.object.position.lerp(_position, interpolationAlpha);
478
+ state.object.quaternion.slerp(_rotation, interpolationAlpha);
479
+ });
480
+
481
+ // Drain collision events ONCE per frame, after the substep loop (the
482
+ // queue accumulates across substeps; per-substep draining would change
483
+ // enter/exit pairing).
484
+ this.eventQueue.drainCollisionEvents((handle1, handle2, started) => {
485
+ this.collisionEventTaps.forEach((tap) => {
486
+ tap(handle1, handle2, started);
487
+ });
488
+
489
+ const collider1 = this.world.getCollider(handle1);
490
+ const collider2 = this.world.getCollider(handle2);
491
+ if (!collider1 || !collider2) return;
492
+ const handlers1 = this.colliderEvents.get(handle1);
493
+ const handlers2 = this.colliderEvents.get(handle2);
494
+ if (!handlers1 && !handlers2) return;
495
+
496
+ const target1 = this.collisionTargetFor(collider1);
497
+ const target2 = this.collisionTargetFor(collider2);
498
+ const payload1: CollisionPayload = { target: target1, other: target2 };
499
+ const payload2: CollisionPayload = { target: target2, other: target1 };
500
+
501
+ if (started) {
502
+ // Enter fires INSIDE the contactPair callback: pure sensor overlaps
503
+ // (no contact manifold) do NOT fire onCollisionEnter.
504
+ this.world.contactPair(collider1, collider2, () => {
505
+ handlers1?.onCollisionEnter?.(payload1);
506
+ handlers2?.onCollisionEnter?.(payload2);
507
+ });
508
+ if (this.world.intersectionPair(collider1, collider2)) {
509
+ handlers1?.onIntersectionEnter?.(payload1);
510
+ handlers2?.onIntersectionEnter?.(payload2);
511
+ }
512
+ } else {
513
+ // An ending event fires BOTH handler families unconditionally —
514
+ // faithful upstream behavior, do not "fix".
515
+ handlers1?.onCollisionExit?.(payload1);
516
+ handlers2?.onCollisionExit?.(payload2);
517
+ handlers1?.onIntersectionExit?.(payload1);
518
+ handlers2?.onIntersectionExit?.(payload2);
519
+ }
520
+ });
521
+
522
+ this.renderDebug();
523
+ }
524
+
525
+ /**
526
+ * Number of fixed substeps executed by the most recent `step()` call.
527
+ * Wiring uses this to gate once-per-step camera work (e.g. platform turn).
528
+ */
529
+ get stepsLastFrame(): number {
530
+ return this.stepsExecuted;
531
+ }
532
+
533
+ // ---- debug ----
534
+
535
+ /**
536
+ * Add a wireframe rendering of every collider to `scene`. Costs CPU/GPU —
537
+ * keep it off in shipped games; great while tuning colliders.
538
+ */
539
+ enableDebug(scene: THREE.Scene): void {
540
+ if (this.debugLines) return;
541
+ const lines = new THREE.LineSegments(
542
+ new THREE.BufferGeometry(),
543
+ new THREE.LineBasicMaterial({ color: 0xffffff, vertexColors: true })
544
+ );
545
+ lines.frustumCulled = false;
546
+ scene.add(lines);
547
+ this.debugLines = lines;
548
+ this.debugScene = scene;
549
+ }
550
+
551
+ /** Remove and dispose the debug lines. */
552
+ disableDebug(): void {
553
+ if (!this.debugLines) return;
554
+ this.debugScene?.remove(this.debugLines);
555
+ this.debugLines.geometry.dispose();
556
+ const material = this.debugLines.material;
557
+ if (Array.isArray(material)) material.forEach((m) => m.dispose());
558
+ else material.dispose();
559
+ this.debugLines = null;
560
+ this.debugScene = null;
561
+ }
562
+
563
+ get debugEnabled(): boolean {
564
+ return this.debugLines !== null;
565
+ }
566
+
567
+ /** Free the Rapier world + event queue and clear every registry. */
568
+ dispose(): void {
569
+ this.disableDebug();
570
+ this.bodyStates.clear();
571
+ this.colliderEvents.clear();
572
+ this.beforeStepCallbacks.clear();
573
+ this.afterStepCallbacks.clear();
574
+ this.collisionEventTaps.clear();
575
+ this.previousState.clear();
576
+ this.eventQueue.free();
577
+ this.world.free();
578
+ }
579
+
580
+ // ---- internals ----
581
+
582
+ private collisionTargetFor(collider: RAPIER.Collider): CollisionTarget {
583
+ const rigidBody = collider.parent();
584
+ return {
585
+ collider,
586
+ rigidBody,
587
+ object3d: rigidBody
588
+ ? (this.bodyStates.get(rigidBody.handle)?.object ?? null)
589
+ : null,
590
+ };
591
+ }
592
+
593
+ private renderDebug(): void {
594
+ if (!this.debugLines) return;
595
+ // debugRender() allocates fresh buffers every call — dispose the previous
596
+ // geometry each frame or leak GPU memory.
597
+ const buffers = this.world.debugRender();
598
+ const geometry = new THREE.BufferGeometry();
599
+ geometry.setAttribute(
600
+ "position",
601
+ new THREE.BufferAttribute(buffers.vertices, 3)
602
+ );
603
+ geometry.setAttribute("color", new THREE.BufferAttribute(buffers.colors, 4));
604
+ this.debugLines.geometry.dispose();
605
+ this.debugLines.geometry = geometry;
606
+ }
607
+ }
608
+
609
+ function rigidBodyTypeFromString(
610
+ type: NonNullable<RigidBodyOptions["type"]>
611
+ ): RAPIER.RigidBodyType {
612
+ switch (type) {
613
+ case "dynamic":
614
+ return RAPIER.RigidBodyType.Dynamic;
615
+ case "fixed":
616
+ return RAPIER.RigidBodyType.Fixed;
617
+ case "kinematicPosition":
618
+ return RAPIER.RigidBodyType.KinematicPositionBased;
619
+ case "kinematicVelocity":
620
+ return RAPIER.RigidBodyType.KinematicVelocityBased;
621
+ }
622
+ }