@genex-ai/cli-demo 0.12.1 → 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 (40) 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-physics-rapier/SKILL.md +128 -0
  33. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  34. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  35. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  36. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  37. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  38. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  39. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,644 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TS port of the ecctrl controller's follow camera (standalone; no camera-controls dependency).
4
+
5
+ import * as THREE from "three";
6
+
7
+ /**
8
+ * Options for {@link FollowCamera}.
9
+ *
10
+ * Tuning cheat-sheet:
11
+ * - Camera feels laggy / rubber-bandy -> lower `smoothTime` (0.05 is snappy, 0.25 is cinematic).
12
+ * - Camera clips into walls -> add your static environment meshes to `colliderMeshes`.
13
+ * - Zoom range wrong for your scale -> `minDistance` / `maxDistance` (0.02 min enables first-person).
14
+ * - Vehicle camera swings behind the car too slowly/quickly -> `headingAlignGain` (default 5).
15
+ */
16
+ export type FollowCameraOptions = {
17
+ /** Input surface for pointer/wheel events (usually `renderer.domElement`). */
18
+ domElement: HTMLElement;
19
+ /**
20
+ * SmoothDamp time (seconds) for target + orbit angles + distance. Roughly "time to cover ~63%
21
+ * of the remaining gap"; the camera settles in ~2-3x this. Default 0.1 (demo-tuned).
22
+ */
23
+ smoothTime?: number;
24
+ /**
25
+ * SmoothDamp time while the user is controlling an axis, applied PER AXIS (upstream parity):
26
+ * orbit angles use it while a drag-rotate is in effect, distance while a wheel/pinch zoom is
27
+ * in effect (each flag persists until the next programmatic rotate()/dollyTo() call); the
28
+ * follow target always uses `smoothTime`. Default 0.125.
29
+ */
30
+ draggingSmoothTime?: number;
31
+ /** Per-frame lerp factor for `camera.up` toward the fed up-axis. Default 0.1. */
32
+ upLerpFactor?: number;
33
+ /** Polar (vertical orbit) clamp, radians. Defaults 0.1 and PI - 0.1 — never exactly 0/PI (lookAt degenerates). */
34
+ minPolarAngle?: number;
35
+ maxPolarAngle?: number;
36
+ /** Distance (zoom) clamp. Defaults: 0.02 (first-person capable) and 12. */
37
+ minDistance?: number;
38
+ maxDistance?: number;
39
+ /**
40
+ * Initial orbit pose. Defaults: distance 4, azimuth PI, polar PI/2 — camera starts at
41
+ * target + (0, 0, -4), level with the target and looking +Z (matches the upstream demo).
42
+ */
43
+ initialDistance?: number;
44
+ initialAzimuthAngle?: number;
45
+ initialPolarAngle?: number;
46
+ /** Drag speed multipliers; a full drag across the element HEIGHT = one full turn (2*PI rad). Defaults 1. */
47
+ azimuthRotateSpeed?: number;
48
+ polarRotateSpeed?: number;
49
+ /**
50
+ * Wheel zoom speed multiplier. Multiplicative zoom with upstream normalization:
51
+ * `0.95^(-deltaY/30)` per pixel-mode wheel event (~18.7% distance change per classic 100-unit
52
+ * notch; deltaY/10 on Mac, deltaY/3 for line-mode/ctrlKey trackpad events). Default 1.
53
+ */
54
+ dollySpeed?: number;
55
+ /**
56
+ * Gain for vehicle heading auto-alignment: azimuth rotates by `angle * headingAlignGain * dt`
57
+ * toward the vehicle's forward axis each frame. Default 5. Higher = camera snaps behind the
58
+ * vehicle faster; 0 disables auto-align.
59
+ */
60
+ headingAlignGain?: number;
61
+ /**
62
+ * Static environment meshes for collision pullback (ray tests from target toward camera).
63
+ * Only static LEAF meshes (intersection is non-recursive); NEVER include the character or
64
+ * vehicle meshes — the rays start at the character's head and would hit them every frame.
65
+ * Default []. Mutable after construction via the public `colliderMeshes` field.
66
+ */
67
+ colliderMeshes?: THREE.Mesh[];
68
+ };
69
+
70
+ /** Mutable scalar velocity slot for SmoothDamp (Unity-style ref param). */
71
+ type ScalarRef = { value: number };
72
+
73
+ /** Upstream approxEquals epsilon (used for the "currently collided" test in dollyTo). */
74
+ const EPSILON = 1e-5;
75
+
76
+ /** Upstream wheel normalization: Mac reports finer-grained deltaY, so it divides less. */
77
+ const IS_MAC = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
78
+
79
+ /**
80
+ * Unity-style critically damped spring (exact port of the smoothing the upstream camera uses).
81
+ * `smoothTime` = time to reach ~63% of the gap; settles in ~2-3x smoothTime. Module-private
82
+ * on purpose (not part of shared/math).
83
+ */
84
+ function smoothDamp(
85
+ current: number,
86
+ target: number,
87
+ velRef: ScalarRef,
88
+ smoothTime: number,
89
+ maxSpeed: number,
90
+ dt: number
91
+ ): number {
92
+ if (dt <= 0) return current; // numerical guard: dt=0 with current===target would NaN the velocity
93
+ smoothTime = Math.max(0.0001, smoothTime);
94
+ const omega = 2 / smoothTime;
95
+ const x = omega * dt;
96
+ const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
97
+ let change = current - target;
98
+ const originalTo = target;
99
+ const maxChange = maxSpeed * smoothTime;
100
+ change = THREE.MathUtils.clamp(change, -maxChange, maxChange);
101
+ target = current - change;
102
+ const temp = (velRef.value + omega * change) * dt;
103
+ velRef.value = (velRef.value - omega * temp) * exp;
104
+ let output = target + (change + temp) * exp;
105
+ // overshoot clamp
106
+ if (originalTo - current > 0 === output > originalTo) {
107
+ output = originalTo;
108
+ velRef.value = (output - originalTo) / dt;
109
+ }
110
+ return output;
111
+ }
112
+
113
+ /**
114
+ * Spring-damped third-person follow camera (vanilla-TS stand-in for the camera-controls-based
115
+ * rig the upstream demo uses; same imperative surface: `moveTo`, `setUp`, `rotate`, `dolly`,
116
+ * `distance`, `colliderMeshes`, `smoothTime`).
117
+ *
118
+ * Features: SmoothDamp'd target follow + orbit + zoom, pointer-drag orbit, wheel/pinch zoom,
119
+ * collision-aware pullback (4 near-plane-corner rays from the target toward the camera; the
120
+ * damped distance is clamped to the hit while the END distance is preserved, so the camera
121
+ * smoothly re-extends via SmoothDamp when the obstruction clears — upstream semantics),
122
+ * platform/vehicle heading compensation.
123
+ *
124
+ * Call order per RENDER frame (render delta, never the fixed physics step):
125
+ * 1. `moveTo(t.x, t.y, t.z, true)` with the pivot (controller `currPos + bodyYAxis * 0.5`)
126
+ * 2. `setUp(controller.upAxis)`
127
+ * 3. character on platform -> `applyPlatformTurn(controller.turnOnYQuat)` — ONLY on frames where
128
+ * at least one physics step ran (`turnOnYQuat` is a per-physics-step yaw delta; re-applying a
129
+ * stale delta on zero-step high-refresh frames over-rotates the camera)
130
+ * 4. vehicle active -> `alignHeading(vehicle.bodyZAxis, dt)`
131
+ * 5. `update(dt)`
132
+ *
133
+ * v1 assumes upAxis ~= +Y (no custom gravity): the orbit sphere is built in world-Y space;
134
+ * `camera.up` lerps toward the fed up-axis and the heading math projects onto it, but a far-from-Y
135
+ * up-axis will misbehave.
136
+ *
137
+ * Wiring tip: give the canvas `touch-action: none` so pointer drags aren't hijacked by scrolling.
138
+ */
139
+ export class FollowCamera {
140
+ /** Master switch: false freezes the camera entirely (no damping, input ignored). */
141
+ enabled: boolean;
142
+ /**
143
+ * Gates target-follow + up-lerp + heading compensation (the demo's "followPlayer" toggle).
144
+ * Manual orbit/zoom and damping keep working while false.
145
+ */
146
+ followEnabled: boolean;
147
+ /** SmoothDamp time for target/orbit/zoom; live-tunable. */
148
+ smoothTime: number;
149
+ /** Static environment meshes for collision pullback; mutate freely (e.g. after level load). */
150
+ colliderMeshes: THREE.Mesh[];
151
+
152
+ private _camera: THREE.PerspectiveCamera;
153
+ private _domElement: HTMLElement;
154
+
155
+ private _draggingSmoothTime: number;
156
+ private _upLerpFactor: number;
157
+ private _minPolarAngle: number;
158
+ private _maxPolarAngle: number;
159
+ private _minDistance: number;
160
+ private _maxDistance: number;
161
+ private _azimuthRotateSpeed: number;
162
+ private _polarRotateSpeed: number;
163
+ private _dollySpeed: number;
164
+ private _headingAlignGain: number;
165
+
166
+ // Damped state (current) + targets (end) + SmoothDamp velocities.
167
+ private _target: THREE.Vector3;
168
+ private _targetEnd: THREE.Vector3;
169
+ private _targetVelocity: THREE.Vector3;
170
+ private _azimuth: number;
171
+ private _azimuthEnd: number;
172
+ private _azimuthVel: ScalarRef;
173
+ private _polar: number;
174
+ private _polarEnd: number;
175
+ private _polarVel: ScalarRef;
176
+ private _distance: number;
177
+ private _distanceEnd: number;
178
+ private _distanceVel: ScalarRef;
179
+
180
+ private _upAxis: THREE.Vector3;
181
+
182
+ // Pointer state.
183
+ private _orbiting: boolean;
184
+ private _pinching: boolean;
185
+ private _pinchDistance: number;
186
+ private _pointers: Map<number, { x: number; y: number }>;
187
+
188
+ // Per-axis "user is controlling" flags (upstream _isUserControllingRotate/_isUserControllingDolly):
189
+ // set by drag-rotate / wheel+pinch respectively, and cleared ONLY by the next programmatic
190
+ // rotate()/dollyTo() call — they intentionally persist past pointerup so the in-flight damping
191
+ // keeps the dragging constant, exactly like upstream.
192
+ private _userDragRotate: boolean;
193
+ private _userDolly: boolean;
194
+
195
+ // Preallocated scratch (no allocations in the per-frame path).
196
+ private _sdRef: ScalarRef;
197
+ private _sphericalDir: THREE.Vector3;
198
+ private _camDir: THREE.Vector3;
199
+ private _finalDir: THREE.Vector3;
200
+ private _crossAxis: THREE.Vector3;
201
+ private _rayDir: THREE.Vector3;
202
+ private _corner: THREE.Vector3;
203
+ private _rayOrigin: THREE.Vector3;
204
+ private _zero: THREE.Vector3;
205
+ private _lookMatrix: THREE.Matrix4;
206
+ private _raycaster: THREE.Raycaster;
207
+
208
+ // Bound handlers (stored so dispose() removes the same references).
209
+ private _onPointerDown: (e: PointerEvent) => void;
210
+ private _onPointerMove: (e: PointerEvent) => void;
211
+ private _onPointerUp: (e: PointerEvent) => void;
212
+ private _onWheel: (e: WheelEvent) => void;
213
+ private _onContextMenu: (e: MouseEvent) => void;
214
+
215
+ constructor(camera: THREE.PerspectiveCamera, options: FollowCameraOptions) {
216
+ this._camera = camera;
217
+ this._domElement = options.domElement;
218
+
219
+ this.enabled = true;
220
+ this.followEnabled = true;
221
+ this.smoothTime = options.smoothTime ?? 0.1;
222
+ this.colliderMeshes = options.colliderMeshes ?? [];
223
+
224
+ this._draggingSmoothTime = options.draggingSmoothTime ?? 0.125;
225
+ this._upLerpFactor = options.upLerpFactor ?? 0.1;
226
+ this._minPolarAngle = options.minPolarAngle ?? 0.1;
227
+ this._maxPolarAngle = options.maxPolarAngle ?? Math.PI - 0.1;
228
+ this._minDistance = options.minDistance ?? 0.02;
229
+ this._maxDistance = options.maxDistance ?? 12;
230
+ this._azimuthRotateSpeed = options.azimuthRotateSpeed ?? 1;
231
+ this._polarRotateSpeed = options.polarRotateSpeed ?? 1;
232
+ this._dollySpeed = options.dollySpeed ?? 1;
233
+ this._headingAlignGain = options.headingAlignGain ?? 5;
234
+
235
+ this._target = new THREE.Vector3();
236
+ this._targetEnd = new THREE.Vector3();
237
+ this._targetVelocity = new THREE.Vector3();
238
+ this._azimuth = options.initialAzimuthAngle ?? Math.PI;
239
+ this._azimuthEnd = this._azimuth;
240
+ this._azimuthVel = { value: 0 };
241
+ this._polar = THREE.MathUtils.clamp(
242
+ options.initialPolarAngle ?? Math.PI / 2,
243
+ this._minPolarAngle,
244
+ this._maxPolarAngle
245
+ );
246
+ this._polarEnd = this._polar;
247
+ this._polarVel = { value: 0 };
248
+ this._distance = THREE.MathUtils.clamp(
249
+ options.initialDistance ?? 4,
250
+ this._minDistance,
251
+ this._maxDistance
252
+ );
253
+ this._distanceEnd = this._distance;
254
+ this._distanceVel = { value: 0 };
255
+
256
+ this._upAxis = new THREE.Vector3(0, 1, 0);
257
+
258
+ this._orbiting = false;
259
+ this._pinching = false;
260
+ this._pinchDistance = 0;
261
+ this._pointers = new Map();
262
+ this._userDragRotate = false;
263
+ this._userDolly = false;
264
+
265
+ this._sdRef = { value: 0 };
266
+ this._sphericalDir = new THREE.Vector3();
267
+ this._camDir = new THREE.Vector3();
268
+ this._finalDir = new THREE.Vector3();
269
+ this._crossAxis = new THREE.Vector3();
270
+ this._rayDir = new THREE.Vector3();
271
+ this._corner = new THREE.Vector3();
272
+ this._rayOrigin = new THREE.Vector3();
273
+ this._zero = new THREE.Vector3();
274
+ this._lookMatrix = new THREE.Matrix4();
275
+ this._raycaster = new THREE.Raycaster();
276
+
277
+ this._onPointerDown = (e: PointerEvent) => {
278
+ if (!this.enabled) return;
279
+ if (e.pointerType === "mouse" && e.button !== 0) return;
280
+ this._domElement.setPointerCapture(e.pointerId);
281
+ this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
282
+ if (this._pointers.size === 1) {
283
+ this._orbiting = true;
284
+ this._pinching = false;
285
+ } else if (this._pointers.size === 2) {
286
+ this._orbiting = false;
287
+ this._pinching = true;
288
+ this._pinchDistance = this._currentPinchDistance();
289
+ } else {
290
+ this._orbiting = false;
291
+ this._pinching = false;
292
+ }
293
+ };
294
+
295
+ this._onPointerMove = (e: PointerEvent) => {
296
+ if (!this.enabled) return;
297
+ const p = this._pointers.get(e.pointerId);
298
+ if (!p) return;
299
+ const dx = e.clientX - p.x;
300
+ const dy = e.clientY - p.y;
301
+ p.x = e.clientX;
302
+ p.y = e.clientY;
303
+ if (this._orbiting) {
304
+ // Full drag across the element HEIGHT (both axes) = one full turn. Drag right pans the
305
+ // view right; drag up looks up. Sign convention is the classic port bug — verified in
306
+ // the testbed; flip here (not in rotate()) if a scene ever disagrees.
307
+ const h = this._domElement.clientHeight || 1;
308
+ this.rotate(
309
+ -2 * Math.PI * this._azimuthRotateSpeed * (dx / h),
310
+ -2 * Math.PI * this._polarRotateSpeed * (dy / h),
311
+ true
312
+ );
313
+ this._userDragRotate = true; // AFTER rotate() — rotate() clears it (upstream lifecycle)
314
+ } else if (this._pinching && this._pointers.size >= 2) {
315
+ const d = this._currentPinchDistance();
316
+ if (this._pinchDistance > 1e-6 && d > 1e-6) {
317
+ // Fingers spread (d grows) -> distance shrinks (zoom in), multiplicatively.
318
+ this.dollyTo(this._distanceEnd * (this._pinchDistance / d), true);
319
+ this._userDolly = true; // AFTER dollyTo() — dollyTo() clears it (upstream lifecycle)
320
+ }
321
+ this._pinchDistance = d;
322
+ }
323
+ };
324
+
325
+ this._onPointerUp = (e: PointerEvent) => {
326
+ if (this._domElement.hasPointerCapture(e.pointerId)) {
327
+ this._domElement.releasePointerCapture(e.pointerId);
328
+ }
329
+ this._pointers.delete(e.pointerId);
330
+ if (this._pointers.size === 1) {
331
+ this._orbiting = true;
332
+ this._pinching = false;
333
+ } else if (this._pointers.size === 0) {
334
+ this._orbiting = false;
335
+ this._pinching = false;
336
+ }
337
+ };
338
+
339
+ this._onWheel = (e: WheelEvent) => {
340
+ if (!this.enabled) return;
341
+ e.preventDefault();
342
+ // Upstream wheel normalization (unclamped, proportional): pixel mode divides deltaY by
343
+ // deltaYFactor*10 (-30, or -10 on Mac); line mode (Firefox) or ctrlKey (trackpad pinch
344
+ // gesture) divides by deltaYFactor only. Then multiplicative zoom, dollyScale =
345
+ // 0.95^(-delta * dollySpeed) — ~18.7% per classic 100-unit notch. deltaY > 0 zooms out.
346
+ const deltaYFactor = IS_MAC ? -1 : -3;
347
+ const delta =
348
+ e.deltaMode === 1 || e.ctrlKey ? e.deltaY / deltaYFactor : e.deltaY / (deltaYFactor * 10);
349
+ this.dollyTo(this._distanceEnd * Math.pow(0.95, delta * this._dollySpeed), true);
350
+ this._userDolly = true; // AFTER dollyTo() — dollyTo() clears it (upstream lifecycle)
351
+ };
352
+
353
+ this._onContextMenu = (e: MouseEvent) => {
354
+ e.preventDefault();
355
+ };
356
+
357
+ this._domElement.addEventListener("pointerdown", this._onPointerDown);
358
+ this._domElement.addEventListener("pointermove", this._onPointerMove);
359
+ this._domElement.addEventListener("pointerup", this._onPointerUp);
360
+ this._domElement.addEventListener("pointercancel", this._onPointerUp);
361
+ this._domElement.addEventListener("wheel", this._onWheel, { passive: false });
362
+ this._domElement.addEventListener("contextmenu", this._onContextMenu);
363
+ }
364
+
365
+ // ---- follow feed (call before update(), every frame the controller is active) ----
366
+
367
+ /**
368
+ * Set the orbit pivot (world space). Feed the controller's head point every frame:
369
+ * `currPos + bodyYAxis * 0.5`. `transition=true` (default) damps toward it; `false` snaps.
370
+ * No-op while `followEnabled` is false.
371
+ */
372
+ moveTo(x: number, y: number, z: number, transition: boolean = true): void {
373
+ if (!this.followEnabled) return;
374
+ this._targetEnd.set(x, y, z);
375
+ if (!transition) {
376
+ this._target.copy(this._targetEnd);
377
+ this._targetVelocity.set(0, 0, 0);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Feed the controller's up-axis. v1: stored + normalized; `camera.up` lerps toward it inside
383
+ * `update()`, but the orbit space stays world +Y (custom-gravity orbit reorientation is a
384
+ * documented v1 cut). No-op while `followEnabled` is false.
385
+ */
386
+ setUp(up: THREE.Vector3): void {
387
+ if (!this.followEnabled) return;
388
+ this._upAxis.copy(up).normalize();
389
+ }
390
+
391
+ /**
392
+ * Character-on-platform compensation: rotates the orbit by the FULL signed yaw angle of
393
+ * `turnOnYQuat` (no gain, no dt — the quat is already a per-physics-step delta). Call only
394
+ * when the character controller reports `isOnPlatform`, and only on frames where at least one
395
+ * physics step ran. No-op while `followEnabled` is false.
396
+ */
397
+ applyPlatformTurn(turnOnYQuat: THREE.Quaternion): void {
398
+ if (!this.followEnabled) return;
399
+ this._camera.getWorldDirection(this._camDir).projectOnPlane(this._upAxis).normalize();
400
+ this._finalDir.copy(this._camDir).applyQuaternion(turnOnYQuat);
401
+ this.rotate(this._signedHeadingAngle(), 0, true);
402
+ }
403
+
404
+ /**
405
+ * Vehicle heading auto-align: eases the azimuth toward the vehicle's forward axis by
406
+ * `angle * headingAlignGain * dt` per frame (dt = RENDER delta). Skipped while the user is
407
+ * orbiting so manual look-around always wins. No-op while `followEnabled` is false.
408
+ */
409
+ alignHeading(bodyZAxis: THREE.Vector3, dt: number): void {
410
+ if (!this.followEnabled) return;
411
+ if (this.isUserOrbiting) return;
412
+ this._camera.getWorldDirection(this._camDir).projectOnPlane(this._upAxis).normalize();
413
+ this._finalDir.copy(bodyZAxis).projectOnPlane(this._upAxis).normalize();
414
+ this.rotate(this._signedHeadingAngle() * this._headingAlignGain * dt, 0, true);
415
+ }
416
+
417
+ // ---- imperative controls ----
418
+
419
+ /**
420
+ * Add deltas to the orbit angles (radians). Polar is clamped on the END value so damping never
421
+ * fights the clamp. `transition=false` snaps. Clears the drag-rotate damping flag (upstream
422
+ * rotateTo lifecycle) — platform/vehicle heading compensation goes through here, so it also
423
+ * restores the base `smoothTime` for the angle axes.
424
+ */
425
+ rotate(azimuthDelta: number, polarDelta: number, transition: boolean = true): void {
426
+ this._userDragRotate = false;
427
+ this._azimuthEnd += azimuthDelta;
428
+ this._polarEnd = THREE.MathUtils.clamp(
429
+ this._polarEnd + polarDelta,
430
+ this._minPolarAngle,
431
+ this._maxPolarAngle
432
+ );
433
+ if (!transition) {
434
+ this._azimuth = this._azimuthEnd;
435
+ this._polar = this._polarEnd;
436
+ this._azimuthVel.value = 0;
437
+ this._polarVel.value = 0;
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Move the zoom distance by `-delta` (positive delta dollies the camera toward the target).
443
+ * First-person recipe: `cam.dolly(cam.distance - 0.02, true)`.
444
+ */
445
+ dolly(delta: number, transition: boolean = true): void {
446
+ this.dollyTo(this._distanceEnd - delta, transition);
447
+ }
448
+
449
+ /**
450
+ * Set the absolute zoom distance, clamped to [minDistance, maxDistance]. Collision-aware
451
+ * (upstream _dollyToNoClamp): while the camera is pinned against geometry, dolly-OUT requests
452
+ * are refused (so zoom-out can't silently accumulate behind a wall), and the END distance is
453
+ * additionally clamped by the collision test. Clears the wheel/pinch damping flag (upstream
454
+ * dollyTo lifecycle).
455
+ */
456
+ dollyTo(distance: number, transition: boolean = true): void {
457
+ this._userDolly = false;
458
+ const clamped = THREE.MathUtils.clamp(distance, this._minDistance, this._maxDistance);
459
+ if (this.colliderMeshes.length > 0) {
460
+ const hit = this._collisionTest();
461
+ const isCollided = Math.abs(hit - this._distance) < EPSILON;
462
+ const isDollyIn = this._distanceEnd > clamped;
463
+ if (!isDollyIn && isCollided) return;
464
+ this._distanceEnd = Math.min(clamped, hit);
465
+ } else {
466
+ this._distanceEnd = clamped;
467
+ }
468
+ if (!transition) {
469
+ this._distance = this._distanceEnd;
470
+ this._distanceVel.value = 0;
471
+ }
472
+ }
473
+
474
+ // ---- readonly state ----
475
+
476
+ /** Damped (current) zoom distance. */
477
+ get distance(): number {
478
+ return this._distance;
479
+ }
480
+
481
+ /** Damped azimuth angle (radians, world-Y orbit space). */
482
+ get azimuthAngle(): number {
483
+ return this._azimuth;
484
+ }
485
+
486
+ /** Damped polar angle (radians). */
487
+ get polarAngle(): number {
488
+ return this._polar;
489
+ }
490
+
491
+ /** Damped pivot position. Returns the internal vector — read-only, do not mutate. */
492
+ get target(): THREE.Vector3 {
493
+ return this._target;
494
+ }
495
+
496
+ /** True while a pointer-drag orbit (or pinch zoom) is in progress. */
497
+ get isUserOrbiting(): boolean {
498
+ return this._orbiting || this._pinching;
499
+ }
500
+
501
+ // ---- per-frame ----
502
+
503
+ /**
504
+ * Render-phase update. Call ONCE per render frame, AFTER physics stepping + body->mesh sync,
505
+ * with the RENDER delta (never the fixed physics timestep).
506
+ */
507
+ update(dt: number): void {
508
+ if (!this.enabled) return;
509
+
510
+ // Up-lerp: naive per-frame lerp with factor 0.1 — intentionally frame-rate dependent for
511
+ // upstream parity (do NOT convert to 1 - exp(-k*dt)).
512
+ if (this.followEnabled) {
513
+ this._camera.up.lerp(this._upAxis, this._upLerpFactor).normalize();
514
+ }
515
+
516
+ // Damping: per-axis constants (upstream parity) — angles use the dragging constant while a
517
+ // drag-rotate is in effect, distance while a wheel/pinch zoom is in effect; the follow
518
+ // target ALWAYS uses the base smoothTime (upstream's truck flag is never set by orbit/zoom).
519
+ const rotSt = this._userDragRotate ? this._draggingSmoothTime : this.smoothTime;
520
+ const dollySt = this._userDolly ? this._draggingSmoothTime : this.smoothTime;
521
+ this._azimuth = smoothDamp(this._azimuth, this._azimuthEnd, this._azimuthVel, rotSt, Infinity, dt);
522
+ this._polar = smoothDamp(this._polar, this._polarEnd, this._polarVel, rotSt, Infinity, dt);
523
+ this._distance = smoothDamp(this._distance, this._distanceEnd, this._distanceVel, dollySt, Infinity, dt);
524
+ this._smoothDampVec3(this._target, this._targetEnd, this._targetVelocity, this.smoothTime, dt);
525
+
526
+ // Collision pullback (upstream: `_spherical.radius = min(_spherical.radius, collisionTest)`):
527
+ // the DAMPED distance itself is clamped to the hit; `_distanceEnd` (and the velocity) stay
528
+ // untouched, so when the obstruction clears, SmoothDamp eases the camera back out to the
529
+ // user's zoom instead of snapping.
530
+ if (this.colliderMeshes.length > 0) {
531
+ this._distance = Math.min(this._distance, this._collisionTest());
532
+ }
533
+
534
+ // Unit offset direction from spherical, world-Y orbit space (THREE.Spherical convention:
535
+ // azimuth 0 = +Z, increasing azimuth = right-hand rotation about +Y).
536
+ const dir = this._sphericalDir.set(
537
+ Math.sin(this._polar) * Math.sin(this._azimuth),
538
+ Math.cos(this._polar),
539
+ Math.sin(this._polar) * Math.cos(this._azimuth)
540
+ );
541
+
542
+ this._camera.position.copy(this._target).addScaledVector(dir, this._distance);
543
+ this._camera.lookAt(this._target);
544
+ }
545
+
546
+ // ---- lifecycle ----
547
+
548
+ /** Remove all DOM listeners. The camera object itself is left wherever it was. */
549
+ dispose(): void {
550
+ this._domElement.removeEventListener("pointerdown", this._onPointerDown);
551
+ this._domElement.removeEventListener("pointermove", this._onPointerMove);
552
+ this._domElement.removeEventListener("pointerup", this._onPointerUp);
553
+ this._domElement.removeEventListener("pointercancel", this._onPointerUp);
554
+ this._domElement.removeEventListener("wheel", this._onWheel);
555
+ this._domElement.removeEventListener("contextmenu", this._onContextMenu);
556
+ this._pointers.clear();
557
+ this._orbiting = false;
558
+ this._pinching = false;
559
+ this._userDragRotate = false;
560
+ this._userDolly = false;
561
+ }
562
+
563
+ // ---- private ----
564
+
565
+ /**
566
+ * Signed angle taking `_camDir` to `_finalDir` about `_upAxis` (both scratch vectors must be
567
+ * populated by the caller): `atan2(cross(cur, final) . up, clamp(cur . final, -1, 1))`, with
568
+ * the upstream `dot = -0` guard (atan2(y, -0) flips the branch and produces +-PI spikes).
569
+ */
570
+ private _signedHeadingAngle(): number {
571
+ this._crossAxis.crossVectors(this._camDir, this._finalDir);
572
+ let dot = THREE.MathUtils.clamp(this._camDir.dot(this._finalDir), -1, 1);
573
+ if (Math.abs(dot) < 1e-10) dot = 0; // prevent dot=-0
574
+ return Math.atan2(this._crossAxis.dot(this._upAxis), dot);
575
+ }
576
+
577
+ /**
578
+ * Collision test (upstream _collisionTest): 4 rays from the target's near-plane corners toward
579
+ * the camera along the DAMPED orbit direction (a single center ray would let the near plane
580
+ * clip through wall edges); ray reach is `_distance + 1`. Returns the nearest hit distance, or
581
+ * Infinity when nothing is hit / no colliders. fov/aspect/near are read fresh each call so
582
+ * window resizes keep working.
583
+ */
584
+ private _collisionTest(): number {
585
+ let distance = Infinity;
586
+ if (this.colliderMeshes.length === 0) return distance;
587
+ const dir = this._rayDir.set(
588
+ Math.sin(this._polar) * Math.sin(this._azimuth),
589
+ Math.cos(this._polar),
590
+ Math.sin(this._polar) * Math.cos(this._azimuth)
591
+ );
592
+ const nearH =
593
+ Math.tan(THREE.MathUtils.degToRad(this._camera.getEffectiveFOV()) / 2) * this._camera.near;
594
+ const nearW = nearH * this._camera.aspect;
595
+ this._lookMatrix.lookAt(this._zero, dir, this._camera.up);
596
+ for (let i = 0; i < 4; i++) {
597
+ const sx = (i & 1) === 0 ? 1 : -1;
598
+ const sy = (i & 2) === 0 ? 1 : -1;
599
+ this._corner.set(sx * nearW, sy * nearH, 0).applyMatrix4(this._lookMatrix);
600
+ this._rayOrigin.copy(this._target).add(this._corner);
601
+ this._raycaster.set(this._rayOrigin, dir);
602
+ this._raycaster.far = this._distance + 1;
603
+ const hits = this._raycaster.intersectObjects(this.colliderMeshes, false);
604
+ const first = hits[0];
605
+ if (first && first.distance < distance) distance = first.distance;
606
+ }
607
+ return distance;
608
+ }
609
+
610
+ /** Distance in CSS pixels between the first two tracked pointers (0 if fewer than two). */
611
+ private _currentPinchDistance(): number {
612
+ let first: { x: number; y: number } | null = null;
613
+ for (const p of this._pointers.values()) {
614
+ if (first === null) {
615
+ first = p;
616
+ } else {
617
+ return Math.hypot(p.x - first.x, p.y - first.y);
618
+ }
619
+ }
620
+ return 0;
621
+ }
622
+
623
+ /**
624
+ * Component-wise scalar SmoothDamp for the target vector (visually identical to a coupled
625
+ * vec3 SmoothDamp at smoothTime ~0.1; faithful simplification, noted per the port spec).
626
+ */
627
+ private _smoothDampVec3(
628
+ current: THREE.Vector3,
629
+ target: THREE.Vector3,
630
+ velocity: THREE.Vector3,
631
+ smoothTime: number,
632
+ dt: number
633
+ ): void {
634
+ this._sdRef.value = velocity.x;
635
+ current.x = smoothDamp(current.x, target.x, this._sdRef, smoothTime, Infinity, dt);
636
+ velocity.x = this._sdRef.value;
637
+ this._sdRef.value = velocity.y;
638
+ current.y = smoothDamp(current.y, target.y, this._sdRef, smoothTime, Infinity, dt);
639
+ velocity.y = this._sdRef.value;
640
+ this._sdRef.value = velocity.z;
641
+ current.z = smoothDamp(current.z, target.z, this._sdRef, smoothTime, Infinity, dt);
642
+ velocity.z = this._sdRef.value;
643
+ }
644
+ }