@thatopen/services 0.3.0 → 0.3.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 +3 -0
  2. package/dist/built-in/index.d.ts +22 -2
  3. package/dist/cli.js +3 -3
  4. package/docs/ai-quickstart.md +86 -0
  5. package/docs/cli/create.md +4 -3
  6. package/package.json +3 -3
  7. package/src/built-in/index.ts +669 -649
  8. package/src/cli/templates/app/package.json +31 -31
  9. package/src/cli/templates/app/src/app.ts +37 -37
  10. package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -50
  11. package/src/cli/templates/app/src/main.ts +223 -340
  12. package/src/cli/templates/app/src/setups/camera-tools.ts +63 -63
  13. package/src/cli/templates/app/src/setups/clipper-tool.ts +20 -1
  14. package/src/cli/templates/app/src/setups/clipper.ts +75 -75
  15. package/src/cli/templates/app/src/setups/index.ts +23 -33
  16. package/src/cli/templates/app/src/setups/inspection.ts +136 -136
  17. package/src/cli/templates/app/src/setups/measurement-tool.ts +19 -1
  18. package/src/cli/templates/app/src/setups/measurements.ts +133 -133
  19. package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -88
  20. package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -166
  21. package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -31
  22. package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -51
  23. package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -1383
  24. package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -178
  25. package/src/cli/templates/app/src/setups/styles-panel.ts +110 -110
  26. package/src/cli/templates/app/src/setups/tool-mode-manager.ts +15 -9
  27. package/src/cli/templates/app/src/setups/tool-mode.ts +23 -23
  28. package/src/cli/templates/app/src/setups/active-tool-hud.ts +0 -69
  29. package/src/cli/templates/app/src/setups/clipper-panel.ts +0 -167
  30. package/src/cli/templates/app/src/setups/commands-panel.ts +0 -239
  31. package/src/cli/templates/app/src/setups/data-table-panel.ts +0 -2224
  32. package/src/cli/templates/app/src/setups/file-format-icons.ts +0 -95
  33. package/src/cli/templates/app/src/setups/files-panel.ts +0 -1503
  34. package/src/cli/templates/app/src/setups/fps-indicator.ts +0 -63
  35. package/src/cli/templates/app/src/setups/graphics-panel.ts +0 -490
  36. package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +0 -161
  37. package/src/cli/templates/app/src/setups/model-tree.ts +0 -797
  38. package/src/cli/templates/app/src/setups/objects-panel.ts +0 -127
  39. package/src/cli/templates/app/src/setups/properties-panel.ts +0 -1048
  40. package/src/cli/templates/app/src/setups/settings-panel.ts +0 -94
  41. package/src/cli/templates/app/src/setups/visibility-toolbar.ts +0 -498
  42. package/src/cli/templates/app/src/setups/walkthrough.ts +0 -528
@@ -1,528 +0,0 @@
1
- import * as THREE from "three";
2
- import * as OBC from "@thatopen/components";
3
- import * as OBF from "@thatopen/components-front";
4
-
5
- /**
6
- * WALKTHROUGH / first-person navigation — a headless CONTROLLER (no UI).
7
- *
8
- * FLY mode (no raycasts): WASD / arrow keys move the eye along the view
9
- * direction — forward/back on the full look vector (so looking up + W ascends),
10
- * strafe on the camera right — E/Q add explicit world up/down, and LEFT-button
11
- * drag turns the view. There is NO collision and NO floor-follow/gravity: it's
12
- * pure free flight (collision can be layered back on later as an option). The
13
- * orbit anchor (pivot dot) is off for the whole session, and the Hoverer is
14
- * GATED on stillness — off while moving/looking, on when standing still so you
15
- * can inspect elements from where you stand. `toggle()` enters/exits; entering
16
- * seeds the view from the current orbit pose, and exiting KEEPS the walk camera —
17
- * orbit controls are re-enabled from wherever the user walked to (no snap back).
18
- * Move speed (and an as-yet unused eye height, kept for the future collision
19
- * mode) are set via setters.
20
- *
21
- * This module owns ONLY the engine + state — it renders no button. It returns a
22
- * controller so a host (e.g. the bottom action toolbar) can drive it from a
23
- * single mdi:walk icon and reflect the active state via `onChange`.
24
- *
25
- * Movement is frame-rate independent (everything scales by dt). Defaults are in
26
- * MODEL UNITS (meters-ish); the speed setter adapts to other units.
27
- *
28
- * CAMERA DRIVE: drives the LIVE viewport camera. Orbit INPUT is disabled
29
- * (controls.enabled = false) AND the OBC camera component is paused
30
- * (camera.enabled = false) so its per-frame controls.update() can't revert us;
31
- * we then write `world.camera.three` (position + quaternion + matrix) directly
32
- * each frame — that's what the renderer reads, so movement is immediate.
33
- * Mouse-look is LEFT-button press-and-drag only (NO pointer lock — the platform
34
- * iframe is sandboxed without it, and requesting it only logs a "Blocked pointer
35
- * lock" error). On exit, orbit controls resume FROM the current walk pose (the
36
- * pivot is seated a short distance ahead of the eye); projection stays perspective.
37
- *
38
- * @param components engine components
39
- * @returns a {@link WalkthroughController}
40
- */
41
-
42
- export interface WalkthroughController {
43
- /** Enter walkthrough if inactive, exit if active. */
44
- toggle(): void;
45
- /** Explicitly enter (no-op if already active). */
46
- enter(): void;
47
- /** Explicitly exit (no-op if inactive). */
48
- exit(): void;
49
- /** Whether walkthrough mode is currently active. */
50
- isActive(): boolean;
51
- /** Subscribe to active-state changes. Returns an unsubscribe fn. */
52
- onChange(listener: (active: boolean) => void): () => void;
53
- /** Set the eye height (model units, > 0). */
54
- setEyeHeight(value: number): void;
55
- /** Set the horizontal move speed (model units/sec, > 0). */
56
- setSpeed(value: number): void;
57
- }
58
-
59
- // Defaults in model units (assume meters). Eye height + speed are user-adjustable.
60
- const DEFAULT_EYE = 1.7; // eye height above the floor
61
- const DEFAULT_SPEED = 3.5; // horizontal move speed, units/sec
62
- const RUN_MULT = 2.4; // Shift = run
63
- const EXIT_PIVOT_DIST = 5; // model units ahead to seat the orbit pivot on exit
64
- const MOUSE_SENS = 0.0022; // radians per pixel of mouse movement
65
- const PITCH_LIMIT = Math.PI / 2 - 0.05; // clamp look up/down just shy of straight
66
-
67
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
- type AnyWorld = any;
69
-
70
- export const walkthrough = (components: OBC.Components): WalkthroughController => {
71
- const fragments = components.get(OBC.FragmentsManager);
72
- const hoverer = components.get(OBF.Hoverer);
73
-
74
- const firstWorld = (): AnyWorld =>
75
- [...components.get(OBC.Worlds).list.values()][0];
76
-
77
- // ── State ──────────────────────────────────────────────────────
78
- let active = false;
79
- let eyeHeight = DEFAULT_EYE;
80
- let moveSpeed = DEFAULT_SPEED;
81
-
82
- // Active-state listeners (host UI reflects the toggle through these).
83
- const listeners = new Set<(active: boolean) => void>();
84
- const notify = () => {
85
- for (const cb of listeners) {
86
- try {
87
- cb(active);
88
- } catch (error) {
89
- console.warn("[walkthrough] onChange listener threw", error);
90
- }
91
- }
92
- };
93
-
94
- // First-person look (radians). Built into a YXZ euler each frame.
95
- let yaw = 0;
96
- let pitch = 0;
97
- const pos = new THREE.Vector3();
98
-
99
- const keys = new Set<string>();
100
- let raf = 0;
101
- let lastT = 0;
102
- let lookDirty = false;
103
- // Latches true on the FIRST real input (move or look). Until then the rendered
104
- // camera is left EXACTLY as it was on enter — so entering, and spamming the
105
- // toggle, never nudges/rotates the camera (the per-frame write is gated on it,
106
- // and exit only re-seats the orbit pivot when the camera was actually driven).
107
- let touched = false;
108
-
109
- // The OBC camera component's own `enabled` flag, paused during walkthrough so
110
- // its per-frame controls.update() can't revert our direct camera writes.
111
- let savedCamEnabled: boolean | null = null;
112
- // The Hoverer's original `enabled`. During walkthrough hover is GATED on
113
- // stillness (off while moving/looking, on when standing still); restored on exit.
114
- let savedHovererEnabled: boolean | null = null;
115
- // The world's `dynamicAnchor` (orbit pivot dot), disabled for the whole session
116
- // so no pivot appears while walking; restored on exit.
117
- let savedAnchor: boolean | null = null;
118
- let loggedKey = false; // one-shot: confirm WASD actually reaches the handler
119
-
120
- // Hover gate: enable the Hoverer only when STILL (no movement keys, not
121
- // dragging), and only if it was originally enabled. Guarded so we only write +
122
- // clear on a transition, not every frame.
123
- const applyHoverGate = (allowHover: boolean) => {
124
- if (savedHovererEnabled === null) return; // hoverer unavailable
125
- const want = allowHover ? savedHovererEnabled : false;
126
- try {
127
- if (hoverer.enabled !== want) {
128
- hoverer.enabled = want;
129
- if (!want) hoverer.clear?.();
130
- }
131
- } catch {
132
- /* non-fatal */
133
- }
134
- };
135
-
136
- // Reusable scratch (no per-frame allocation in the hot path).
137
- const _euler = new THREE.Euler(0, 0, 0, "YXZ");
138
- const _quat = new THREE.Quaternion();
139
- const _forward = new THREE.Vector3();
140
- const _right = new THREE.Vector3();
141
- const _step = new THREE.Vector3();
142
- const _WORLD_UP = new THREE.Vector3(0, 1, 0); // E/Q vertical axis
143
-
144
- // ── Direction vectors from yaw/pitch ───────────────────────────
145
- // FLY mode: forward is the FULL view direction (includes pitch), so looking up
146
- // + W ascends and looking down + S descends — true free flight, no raycasts.
147
- const updateDirs = () => {
148
- _euler.set(pitch, yaw, 0, "YXZ");
149
- _quat.setFromEuler(_euler);
150
- _forward.set(0, 0, -1).applyQuaternion(_quat);
151
- _right.set(1, 0, 0).applyQuaternion(_quat);
152
- };
153
-
154
- // ── Per-frame step ─────────────────────────────────────────────
155
- const tick = (now: number) => {
156
- if (!active) return;
157
- const world = firstWorld();
158
- const controls = world?.camera?.controls;
159
- if (!controls) {
160
- raf = requestAnimationFrame(tick);
161
- return;
162
- }
163
- const dt = Math.min((now - lastT) / 1000, 0.05); // clamp big gaps (tab switch)
164
- lastT = now;
165
- updateDirs();
166
-
167
- // FLY mode (no raycasts): WASD moves the eye along the view direction —
168
- // forward/back on _forward (full look dir, so up/down too), strafe on _right.
169
- // E / Q add explicit WORLD vertical (+Y / −Y) so the user can ascend/descend
170
- // without relying on pitch+W.
171
- let mf = 0;
172
- let mr = 0;
173
- let mv = 0;
174
- if (keys.has("w") || keys.has("arrowup")) mf += 1;
175
- if (keys.has("s") || keys.has("arrowdown")) mf -= 1;
176
- if (keys.has("d") || keys.has("arrowright")) mr += 1;
177
- if (keys.has("a") || keys.has("arrowleft")) mr -= 1;
178
- if (keys.has("e")) mv += 1;
179
- if (keys.has("q")) mv -= 1;
180
- let moved = false;
181
- if (mf !== 0 || mr !== 0 || mv !== 0) {
182
- const speed = (keys.has("shift") ? moveSpeed * RUN_MULT : moveSpeed) * dt;
183
- _step
184
- .set(0, 0, 0)
185
- .addScaledVector(_forward, mf)
186
- .addScaledVector(_right, mr)
187
- .addScaledVector(_WORLD_UP, mv);
188
- if (_step.lengthSq() > 0) {
189
- _step.normalize().multiplyScalar(speed);
190
- pos.add(_step);
191
- moved = true;
192
- }
193
- }
194
-
195
- // Hover only when STANDING STILL — off while moving (any WASD/E/Q held) or
196
- // looking (left-drag). Lets the user inspect elements from where they stand.
197
- applyHoverGate(mf === 0 && mr === 0 && mv === 0 && !dragging);
198
-
199
- // Drive the LIVE camera DIRECTLY every frame — but ONLY once the user has
200
- // actually moved or looked. The OBC camera component's update() is paused
201
- // (see enter), so camera-controls' update() can't run and revert us — we
202
- // own the camera. Gating on `touched` means entering (and spamming
203
- // enter/exit) leaves the rendered pose byte-for-byte untouched: no
204
- // yaw/pitch round-trip re-orientation, no drift. `_quat` (from yaw/pitch)
205
- // orients it; pos is the eye. Writing the THREE camera + its world matrix
206
- // is what the renderer reads, so movement is immediate and never fought.
207
- if (moved || lookDirty) touched = true;
208
- lookDirty = false;
209
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
- const cam = world?.camera?.three as any;
211
- if (cam && touched) {
212
- cam.position.set(pos.x, pos.y, pos.z);
213
- cam.quaternion.copy(_quat);
214
- cam.updateMatrixWorld(true);
215
- }
216
- raf = requestAnimationFrame(tick);
217
- };
218
-
219
- // ── Enter / exit ───────────────────────────────────────────────
220
- const onKeyDown = (e: KeyboardEvent) => {
221
- const k = e.key.toLowerCase();
222
- if (["w", "a", "s", "d", "e", "q", "arrowup", "arrowdown", "arrowleft", "arrowright", "shift"].includes(k)) {
223
- if (!loggedKey) {
224
- loggedKey = true;
225
- console.log("[walkthrough] movement key reached handler:", k); // confirms key capture
226
- }
227
- keys.add(k);
228
- if (k.startsWith("arrow")) e.preventDefault();
229
- }
230
- };
231
- const onKeyUp = (e: KeyboardEvent) => keys.delete(e.key.toLowerCase());
232
-
233
- // Mouse-look: only while a LEFT-button drag is in progress (see pointerdown).
234
- // movementX/Y are valid during a drag with no pointer lock needed.
235
- let dragging = false;
236
- const onMouseMove = (e: MouseEvent) => {
237
- if (!active || !dragging) return;
238
- yaw -= e.movementX * MOUSE_SENS;
239
- pitch -= e.movementY * MOUSE_SENS;
240
- pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch));
241
- lookDirty = true;
242
- };
243
- // Look = LEFT-button press-and-drag ONLY. We never request pointer lock — the
244
- // platform sandboxes the viewer in an iframe without `allow-pointer-lock`, so
245
- // requestPointerLock only logs a "Blocked pointer lock" error. movementX/Y are
246
- // valid during a drag regardless, so drag-look needs no lock.
247
- const onCanvasPointerDown = (e: PointerEvent) => {
248
- if (!active || e.button !== 0) return; // left button only
249
- dragging = true;
250
- hideHint();
251
- };
252
- const onWindowPointerUp = () => {
253
- dragging = false;
254
- };
255
-
256
- let canvas: HTMLCanvasElement | null = null;
257
-
258
- // ── Hint overlay (discoverability: how to drive walkthrough) ───
259
- let hintEl: HTMLElement | null = null;
260
- let hintTimer: number | undefined;
261
- const hideHint = () => {
262
- if (hintTimer !== undefined) {
263
- clearTimeout(hintTimer);
264
- hintTimer = undefined;
265
- }
266
- if (hintEl) {
267
- hintEl.remove();
268
- hintEl = null;
269
- }
270
- };
271
- const showHint = () => {
272
- if (!canvas) return;
273
- const host = canvas.parentElement;
274
- if (!host) return;
275
- hideHint();
276
- const el = document.createElement("div");
277
- el.textContent = "Left-drag to look · WASD move · E/Q up/down · click Walk to exit";
278
- el.style.cssText = [
279
- "position: absolute",
280
- "left: 50%",
281
- "bottom: 4.5rem",
282
- "transform: translateX(-50%)",
283
- "z-index: 30",
284
- "pointer-events: none",
285
- "padding: 0.35rem 0.7rem",
286
- "font-size: 0.75rem",
287
- "white-space: nowrap",
288
- "color: var(--bim-ui_bg-contrast-100, #e3e3e3)",
289
- "background: rgba(20,20,24,0.85)",
290
- "border: 1px solid var(--bim-ui_bg-contrast-40, rgba(255,255,255,0.2))",
291
- "border-radius: var(--bim-ui_size-2xs, 0.375rem)",
292
- "box-shadow: 0 2px 10px rgba(0,0,0,0.35)",
293
- ].join(";");
294
- if (getComputedStyle(host).position === "static") host.style.position = "relative";
295
- host.appendChild(el);
296
- hintEl = el;
297
- hintTimer = window.setTimeout(hideHint, 4500);
298
- };
299
-
300
- const enter = () => {
301
- const world = firstWorld();
302
- const controls = world?.camera?.controls;
303
- const projection = world?.camera?.projection;
304
- canvas = world?.renderer?.three?.domElement ?? null;
305
- if (!controls || !canvas) {
306
- console.warn("[walkthrough] no world camera/canvas to drive");
307
- return;
308
- }
309
- console.log(
310
- "[walkthrough] enter — driving live camera",
311
- world?.camera?.three?.type,
312
- "| controls bound to rendered camera; loop starting",
313
- );
314
- // Seed the first-person view from the ACTUAL rendered camera transform (world
315
- // position + quaternion-forward), NOT controls.getPosition/getTarget — so the
316
- // view doesn't move at all on enter (no eye-height reset, no re-orient). Same
317
- // ground-truth source exit uses, so enter and exit are symmetric.
318
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
319
- const cam = world?.camera?.three as any;
320
- const p = new THREE.Vector3();
321
- if (cam) {
322
- // Force the world matrix current first — reading a stale matrixWorld (e.g.
323
- // mid-frame right after the previous exit's setLookAt) would seed a pose
324
- // that's slightly off, and repeated enter/exit would compound it.
325
- cam.updateMatrixWorld(true);
326
- cam.getWorldPosition(p);
327
- _forward.set(0, 0, -1).applyQuaternion(cam.quaternion).normalize();
328
- } else {
329
- controls.getPosition(p);
330
- const t = new THREE.Vector3();
331
- controls.getTarget(t);
332
- _forward.copy(t).sub(p).normalize();
333
- }
334
-
335
- // First-person must be perspective. (Only an Orthographic orbit re-projects on
336
- // enter; a Perspective orbit keeps the exact same view.)
337
- if (projection?.current === "Orthographic") void projection.set?.("Perspective");
338
-
339
- // Reconstruct yaw/pitch from the live forward → the first tick re-applies the
340
- // identical orientation (orbit cameras have no roll, so this is exact).
341
- yaw = Math.atan2(_forward.x, -_forward.z);
342
- pitch = Math.asin(Math.max(-1, Math.min(1, _forward.y)));
343
- pos.copy(p);
344
- // FLY mode: enter exactly at the current eye point — no floor snap.
345
-
346
- controls.enabled = false; // stop orbit INPUT
347
- // Pause the OBC camera component's per-frame update so its controls.update()
348
- // can't re-apply the orbit pose and revert our direct camera writes.
349
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
350
- const camComp = world?.camera as any;
351
- // Snapshot the Hoverer's enabled state; the per-frame gate (applyHoverGate)
352
- // then toggles it by stillness. Clear any stuck overlay on entry.
353
- try {
354
- savedHovererEnabled = typeof hoverer?.enabled === "boolean" ? hoverer.enabled : null;
355
- hoverer?.clear?.();
356
- } catch {
357
- /* hoverer not ready — non-fatal */
358
- }
359
- // Disable the orbit anchor (pivot dot) for the whole walkthrough — no pivot
360
- // should appear while walking. Restored on exit.
361
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
- const w = world as any;
363
- if (w && typeof w.dynamicAnchor === "boolean") {
364
- savedAnchor = w.dynamicAnchor;
365
- w.dynamicAnchor = false;
366
- }
367
- savedCamEnabled = typeof camComp?.enabled === "boolean" ? camComp.enabled : null;
368
- if (savedCamEnabled !== null) camComp.enabled = false;
369
- active = true;
370
- dragging = false;
371
- loggedKey = false;
372
- // Start "untouched": the first tick must NOT write the camera (that would
373
- // re-orient via the lossy yaw/pitch round-trip). Only real input sets it.
374
- touched = false;
375
- lookDirty = false;
376
- window.addEventListener("keydown", onKeyDown);
377
- window.addEventListener("keyup", onKeyUp);
378
- window.addEventListener("pointerup", onWindowPointerUp);
379
- document.addEventListener("mousemove", onMouseMove);
380
- canvas.addEventListener("pointerdown", onCanvasPointerDown);
381
- // Start the movement loop + notify. Look is press-and-drag only — no
382
- // pointer-lock request (the sandboxed iframe blocks it and only logs an
383
- // error); WASD fly + drag-look need no lock.
384
- lastT = performance.now();
385
- raf = requestAnimationFrame(tick);
386
- notify();
387
- showHint();
388
- };
389
-
390
- const exit = () => {
391
- if (!active) return;
392
- active = false;
393
- dragging = false;
394
- keys.clear();
395
- hideHint();
396
- if (raf) cancelAnimationFrame(raf);
397
- raf = 0;
398
- window.removeEventListener("keydown", onKeyDown);
399
- window.removeEventListener("keyup", onKeyUp);
400
- window.removeEventListener("pointerup", onWindowPointerUp);
401
- document.removeEventListener("mousemove", onMouseMove);
402
- canvas?.removeEventListener("pointerdown", onCanvasPointerDown);
403
-
404
- const world = firstWorld();
405
- const controls = world?.camera?.controls;
406
- // Re-enable the OBC camera component so its update loop (and orbit) resumes.
407
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
408
- const camComp = world?.camera as any;
409
- if (savedCamEnabled !== null && camComp) camComp.enabled = savedCamEnabled;
410
- savedCamEnabled = null;
411
- // Restore the Hoverer's original enabled state and the orbit anchor.
412
- try {
413
- if (savedHovererEnabled !== null) hoverer.enabled = savedHovererEnabled;
414
- } catch {
415
- /* non-fatal */
416
- }
417
- savedHovererEnabled = null;
418
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
419
- const w = world as any;
420
- if (w && savedAnchor !== null) w.dynamicAnchor = savedAnchor;
421
- savedAnchor = null;
422
- if (controls) {
423
- controls.enabled = true;
424
- }
425
- // Only re-seat the orbit pivot if the user ACTUALLY drove the camera this
426
- // session. If nothing was touched (e.g. spamming the toggle), the camera is
427
- // exactly where orbit left it, its target is still valid, and re-posing would
428
- // only risk nudging it — so we leave it completely alone (true no-op exit).
429
- if (controls && touched) {
430
- // KEEP the walk camera: re-seed orbit controls FROM the ACTUAL rendered
431
- // first-person transform (ground truth — not our yaw/pitch state, in case
432
- // they ever diverge), with the orbit pivot a short distance ahead along the
433
- // look direction. No snap-back to the pre-walk pose, no re-frame. Stays in
434
- // perspective (the walk projection).
435
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
436
- const cam = world?.camera?.three as any;
437
- const eye = new THREE.Vector3();
438
- const fwd = new THREE.Vector3(0, 0, -1);
439
- if (cam) {
440
- cam.getWorldPosition(eye);
441
- fwd.applyQuaternion(cam.quaternion).normalize();
442
- } else {
443
- updateDirs();
444
- eye.copy(pos);
445
- fwd.copy(_forward);
446
- }
447
- // The orbit pivot sits `dist` ahead along the look dir. CRITICAL: the eye↔
448
- // target distance must stay within the controls' min/max, or setLookAt CLAMPS
449
- // it by MOVING the eye (the intermittent exit jump — only when dolly limits
450
- // are active). Clamp the pivot distance into [minDistance, maxDistance] so the
451
- // resting distance is valid and the eye stays EXACTLY where the user stood.
452
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
453
- const cc = controls as any;
454
- const minD = typeof cc.minDistance === "number" ? cc.minDistance : 0;
455
- const maxD = typeof cc.maxDistance === "number" ? cc.maxDistance : Infinity;
456
- let dist = EXIT_PIVOT_DIST;
457
- if (Number.isFinite(minD) && dist < minD) dist = minD;
458
- if (Number.isFinite(maxD) && dist > maxD) dist = maxD;
459
- const setPose = () =>
460
- controls.setLookAt(
461
- eye.x, eye.y, eye.z,
462
- eye.x + fwd.x * dist,
463
- eye.y + fwd.y * dist,
464
- eye.z + fwd.z * dist,
465
- false,
466
- );
467
- void setPose();
468
- // Apply immediately so the next render is already at the walk pose...
469
- try {
470
- controls.update(0);
471
- } catch {
472
- /* some controls builds reject delta 0 — harmless */
473
- }
474
- // ...and guard against any engine system that reframes the camera on the
475
- // orbit→resume transition (auto-anchor / aspect / fit). Re-assert the pose
476
- // for a few frames; stop as soon as it holds. Logs once if it ever drifts,
477
- // so a persistent reframer is visible instead of silently winning.
478
- if (cam) {
479
- let tries = 0;
480
- const hold = () => {
481
- if (active) return; // a new walkthrough started — stop
482
- const cur = new THREE.Vector3();
483
- cam.getWorldPosition(cur);
484
- if (cur.distanceToSquared(eye) > 1e-6) {
485
- if (tries === 0) {
486
- console.log("[walkthrough] exit pose drifted — re-asserting", cur.distanceTo(eye));
487
- }
488
- void setPose();
489
- // Cap tight: the transition reframe happens in the first frame(s);
490
- // a low cap avoids fighting a fast user orbit started right after.
491
- if (++tries < 3) requestAnimationFrame(hold);
492
- }
493
- };
494
- requestAnimationFrame(hold);
495
- }
496
- }
497
- notify();
498
- };
499
-
500
- const toggle = () => (active ? exit() : enter());
501
-
502
- // A model unload while walking → bail out safely (geometry it stood on is gone).
503
- fragments.list.onItemDeleted.add(() => {
504
- if (active) exit();
505
- });
506
-
507
- // ── Controller (no UI) ─────────────────────────────────────────
508
- return {
509
- toggle,
510
- enter: () => {
511
- if (!active) enter();
512
- },
513
- exit: () => {
514
- if (active) exit();
515
- },
516
- isActive: () => active,
517
- onChange: (listener) => {
518
- listeners.add(listener);
519
- return () => listeners.delete(listener);
520
- },
521
- setEyeHeight: (value) => {
522
- if (Number.isFinite(value) && value > 0) eyeHeight = value;
523
- },
524
- setSpeed: (value) => {
525
- if (Number.isFinite(value) && value > 0) moveSpeed = value;
526
- },
527
- };
528
- };