@leverege/tpi-viz 0.2.0

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/src/main.js ADDED
@@ -0,0 +1,2616 @@
1
+ // tpi-viz scene viewer entry point — THIN BOOTSTRAP.
2
+ //
3
+ // Bundled by Vite into a single self-contained dist/index.html via
4
+ // vite-plugin-singlefile. Python (tpi.viz.templates.bundle_html_template) reads
5
+ // dist/index.html and substitutes the scene JSON into the SCENE_DATA placeholder.
6
+ //
7
+ // All THREE/WebGL rendering + kinematics live in @leverege/tpi-viz-renderer
8
+ // (the SceneRenderer over the pure, Node-testable scene model). This file:
9
+ // - parses ?scene=<name>, sets <base>, fetches /scenes/<name>/scene.json
10
+ // - calls renderer.applyScene(data) to hydrate
11
+ // - wires the existing DOM UI (toggles, Views/Projection/POV controls,
12
+ // bookmarks, flow playback, image panel) to renderer methods.
13
+
14
+ import './styles/viewer_common.css';
15
+ import './styles/scene_viewer.css';
16
+
17
+ import * as THREE from 'three';
18
+ import { STLLoader } from 'three/addons/loaders/STLLoader.js';
19
+ import { createSceneRenderer, AXIS_Q6135LE_SENSOR, computeKinematics } from '@leverege/tpi-viz-renderer';
20
+ import { assembleShellScene, createHttpFetcher } from '@leverege/tpi-viz-renderer/loader';
21
+
22
+ import { VIEWER_SKELETON } from './skeleton.js';
23
+
24
+ // ═══════════════════════════════════════════════════════════════
25
+ // State
26
+ // ═══════════════════════════════════════════════════════════════
27
+
28
+ let sceneData = null;
29
+ let renderer = null; // SceneRenderer instance
30
+ let raycaster = null;
31
+ let mouse = null;
32
+
33
+ // Mount lifecycle (so the viewer can be embedded in a host app — e.g. a React
34
+ // route — and torn down cleanly on unmount). `_alive` gates the self-perpetuating
35
+ // rAF loops; `_teardown` collects window/document listener removers; `_opts`
36
+ // replaces URL parsing as the scene source.
37
+ let _alive = false;
38
+ let _opts = {};
39
+ let _container = null;
40
+ const _teardown = [];
41
+ // Register a window/document listener AND remember how to remove it on destroy.
42
+ // (Element listeners inside the injected skeleton are freed when the container
43
+ // is cleared, so only window/document ones need tracking.)
44
+ function on( target, ev, fn, opts ) {
45
+ target.addEventListener( ev, fn, opts );
46
+ _teardown.push( () => target.removeEventListener( ev, fn, opts ) );
47
+ }
48
+
49
+ // Convenience getters proxying the renderer's pure model + THREE state. Keeping
50
+ // these as functions (not cached) means UI code always reads live values after
51
+ // a camera/flow mutation.
52
+ const cam = () => renderer.camera;
53
+ const controls = () => renderer.controls;
54
+ const layerGroups = () => renderer.layerGroups;
55
+ const cameraEntries = () => renderer.cameras; // model camera descriptors
56
+ const flowData = () => renderer.flowData;
57
+ const flowsByUseCase = () => renderer.flowsByUseCase;
58
+ const cameraColors = () => renderer.cameras.map(c => c.color);
59
+ const cameraImagePlanes = () => renderer.cameraImagePlanes;
60
+ const cameraFrustumGroups = () => renderer.cameraFrustumGroups;
61
+ const defectEntries = () => renderer.defects;
62
+ const projectionMode = () => renderer.projectionMode;
63
+
64
+ let selectedCameraIdx = -1;
65
+ let povCameraIdx = -1; // which camera is in POV mode (-1 = none)
66
+ let prevCameraState = null; // saved camera state for restoring after POV
67
+ let syncProjectionUI = null; // set by buildPovControls: (mode) => highlight active button
68
+
69
+ function switchProjection(mode) {
70
+ const result = renderer.switchProjection(mode);
71
+ return result;
72
+ }
73
+
74
+ // Flow state
75
+ let activeFlowName = null;
76
+ let activeFlowSteps = [];
77
+ let currentStepIndex = 0;
78
+
79
+ // Per-flow play state: flowName → { intervalId, stepIndex }
80
+ const playingFlows = {};
81
+
82
+ // Image state (for step images carousel)
83
+ let currentImages = [];
84
+ let currentLaserMasks = [];
85
+ let currentProjectedContour = null;
86
+ let currentAlignedContours = []; // parallel to images
87
+ let currentLaserScores = []; // parallel to images
88
+ let currentImageIdx = 0;
89
+ let laserOverlayEnabled = false;
90
+ let scoreOverlayEnabled = false;
91
+ let projectedOverlayEnabled = false;
92
+ let alignedOverlayEnabled = false;
93
+
94
+ // Lightbox state
95
+ let lightboxOpen = false;
96
+
97
+ // PTZ slider references (so flow steps can update slider DOM)
98
+ let activePTZSliders = null; // { pan: {input, valueSpan}, tilt: ..., zoom: ... }
99
+
100
+ // ═══════════════════════════════════════════════════════════════
101
+ // Init
102
+ // ═══════════════════════════════════════════════════════════════
103
+
104
+ async function loadSceneData() {
105
+ // Scene source comes from mount opts (set by mountViewer), not the URL —
106
+ // the standalone entry derives opts from the URL and passes them in.
107
+ // Live mode: build the scene DYNAMICALLY from a shell id, with no
108
+ // precomputed scene.json. assembleShellScene returns the exact
109
+ // {layers, flows, viewPresets} shape applyScene + the panels consume, so
110
+ // the rest of init() is unchanged. The http fetcher talks to same-origin
111
+ // Imaginarium proxy routes (CSP-safe); configId selects the Imagine system.
112
+ const shellId = _opts.shellId;
113
+ if (shellId) {
114
+ try {
115
+ const fetcher = createHttpFetcher({ configId: _opts.configId || null });
116
+ return await assembleShellScene({ shellId, fetcher, STLLoader });
117
+ } catch (err) {
118
+ document.getElementById('loading').textContent =
119
+ `Failed to build shell "${shellId}": ${err.message}`;
120
+ return null;
121
+ }
122
+ }
123
+ // Embedded mode: a host app (e.g. Imaginarium) passes an explicit, possibly
124
+ // authenticated scene URL. Fetch it directly with credentials; resolve flow
125
+ // images against the URL's parent path via <base>.
126
+ const sceneUrl = _opts.sceneUrl;
127
+ if (sceneUrl) {
128
+ try {
129
+ const resp = await fetch(sceneUrl, { credentials: 'include' });
130
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${sceneUrl}`);
131
+ const sb = document.getElementById('scene-base');
132
+ if (sb) sb.href = sceneUrl.replace(/[^/]*$/, '');
133
+ return await resp.json();
134
+ } catch (err) {
135
+ document.getElementById('loading').textContent =
136
+ `Failed to load scene: ${err.message}`;
137
+ return null;
138
+ }
139
+ }
140
+ const name = _opts.scene;
141
+ if (!name) {
142
+ document.getElementById('loading').classList.add('hidden');
143
+ document.getElementById('no-scene').classList.remove('hidden');
144
+ await populateAvailableScenes();
145
+ return null;
146
+ }
147
+ // Resolve relative paths in scene.json (images/foo.jpg, …) against the
148
+ // scene's directory by setting <base href>.
149
+ { const sb = document.getElementById('scene-base'); if (sb) sb.href = `/scenes/${name}/`; }
150
+ const url = `/scenes/${encodeURIComponent(name)}/scene.json`;
151
+ try {
152
+ const resp = await fetch(url);
153
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
154
+ return await resp.json();
155
+ } catch (err) {
156
+ document.getElementById('loading').textContent =
157
+ `Failed to load scene "${name}": ${err.message}`;
158
+ return null;
159
+ }
160
+ }
161
+
162
+ async function populateAvailableScenes() {
163
+ // Dev server's /scenes endpoint returns a JSON array of scene names.
164
+ try {
165
+ const resp = await fetch('/scenes');
166
+ if (!resp.ok) return;
167
+ const names = await resp.json();
168
+ if (!Array.isArray(names) || !names.length) return;
169
+
170
+ // Visualizer scripts prefix scene names by type (shell-, flow-, plylayers-, …).
171
+ // Group by prefix and label so the list is browsable instead of a wall.
172
+ const GROUP_LABELS = {
173
+ shell: 'Shells',
174
+ flow: 'Flows',
175
+ plylayers: 'Ply layers',
176
+ defects: 'Defects',
177
+ calibration_observations: 'Calibration',
178
+ flow_data: 'Flow data',
179
+ };
180
+ // Type → accent color (CSS custom property name). Drives the left-bar
181
+ // tint on each row + the group-header pill.
182
+ const GROUP_ACCENT = {
183
+ shell: '--accent-blue',
184
+ flow: '--accent-orange',
185
+ plylayers: '--accent-green',
186
+ defects: '--accent-pink',
187
+ calibration_observations: '--accent-amber',
188
+ flow_data: '--accent-blue',
189
+ other: '--text-3',
190
+ };
191
+ const groups = new Map(); // label → { prefix, items }
192
+ for (const name of names.slice().sort()) {
193
+ const prefix = name.split('-', 1)[0];
194
+ const label = GROUP_LABELS[prefix] || 'Other';
195
+ const key = GROUP_LABELS[prefix] ? prefix : 'other';
196
+ if (!groups.has(label)) groups.set(label, { prefix: key, items: [] });
197
+ groups.get(label).items.push(name);
198
+ }
199
+
200
+ const container = document.getElementById('available-scenes');
201
+ container.innerHTML = '';
202
+ for (const [label, { prefix, items }] of groups) {
203
+ const accent = `var(${GROUP_ACCENT[prefix] || '--text-3'})`;
204
+ const group = document.createElement('div');
205
+ group.className = 'landing-scene-group';
206
+ group.style.setProperty('--group-accent', accent);
207
+
208
+ const head = document.createElement('div');
209
+ head.className = 'landing-group-label';
210
+ head.innerHTML = `<span class="landing-group-name">${label}</span>`
211
+ + `<span class="landing-group-count">${items.length}</span>`;
212
+ group.appendChild(head);
213
+
214
+ for (const name of items) {
215
+ // Strip the type prefix from the visible label (it's
216
+ // redundant — the row is already inside that group).
217
+ const dashIdx = name.indexOf('-');
218
+ const identifier = dashIdx >= 0 ? name.slice(dashIdx + 1) : name;
219
+
220
+ const a = document.createElement('a');
221
+ a.className = 'landing-scene-link';
222
+ a.href = `?scene=${encodeURIComponent(name)}`;
223
+ a.dataset.sceneName = name.toLowerCase();
224
+ a.innerHTML =
225
+ `<span class="landing-scene-id">${identifier || name}</span>`
226
+ + `<span class="landing-scene-arrow">→</span>`;
227
+ group.appendChild(a);
228
+ }
229
+ container.appendChild(group);
230
+ }
231
+
232
+ document.getElementById('scenes-count').textContent = `· ${names.length}`;
233
+
234
+ const filter = document.getElementById('scenes-filter');
235
+ filter.addEventListener('input', () => {
236
+ const q = filter.value.trim().toLowerCase();
237
+ for (const group of container.querySelectorAll('.landing-scene-group')) {
238
+ let visible = 0;
239
+ for (const link of group.querySelectorAll('.landing-scene-link')) {
240
+ const match = !q || link.dataset.sceneName.includes(q);
241
+ link.style.display = match ? '' : 'none';
242
+ if (match) visible++;
243
+ }
244
+ group.style.display = visible ? '' : 'none';
245
+ }
246
+ });
247
+
248
+ document.getElementById('available-scenes-wrap').classList.remove('hidden');
249
+ } catch (e) {
250
+ // No listing available; that's fine.
251
+ }
252
+ }
253
+
254
+ async function init() {
255
+ sceneData = await loadSceneData();
256
+ if (!sceneData) return;
257
+
258
+ const canvas = document.getElementById('threeCanvas');
259
+ renderer = createSceneRenderer(canvas);
260
+
261
+ raycaster = new THREE.Raycaster();
262
+ mouse = new THREE.Vector2();
263
+
264
+ // Hydrate the whole scene through the renderer (the Python path). This
265
+ // parses cameras/flows/presets into the pure model and builds all THREE
266
+ // layers + projections.
267
+ renderer.applyScene(sceneData);
268
+
269
+ const layers = sceneData.layers || [];
270
+ buildLayerToggles(layers);
271
+ buildPovControls(renderer.viewPresets || {});
272
+ buildCameraTree();
273
+
274
+ // Update contour projections when any contour layer toggle changes
275
+ document.getElementById('layer-toggles').addEventListener('change', (e) => {
276
+ if (e.target.type === 'checkbox') {
277
+ requestAnimationFrame(() => {
278
+ renderer.updateContourProjections();
279
+ updateImagePanel();
280
+ });
281
+ }
282
+ });
283
+
284
+ // Apply default view preset to initialize camera at the right position
285
+ const defaultPreset = (renderer.viewPresets || {}).default;
286
+ if (defaultPreset) {
287
+ cam().position.set(...defaultPreset.position);
288
+ controls().target.set(...defaultPreset.target);
289
+ controls().update();
290
+ }
291
+
292
+ canvas.addEventListener('click', onCanvasClick);
293
+
294
+ on( document, 'keydown',(e) => {
295
+ if (e.target.tagName === 'INPUT') return;
296
+ if (!activeFlowName) return;
297
+ if (e.key === 'ArrowRight') { nextStep(); e.preventDefault(); }
298
+ if (e.key === 'ArrowLeft') { prevStep(); e.preventDefault(); }
299
+ if (e.key === 'l' || e.key === 'L') { laserOverlayEnabled = !laserOverlayEnabled; updateImagePanel(); updateLightbox(); e.preventDefault(); }
300
+ if (e.key === 's' || e.key === 'S') { scoreOverlayEnabled = !scoreOverlayEnabled; updateImagePanel(); updateLightbox(); e.preventDefault(); }
301
+ if (e.key === 'p' || e.key === 'P') { projectedOverlayEnabled = !projectedOverlayEnabled; updateImagePanel(); updateLightbox(); e.preventDefault(); }
302
+ if (e.key === ';' || e.key === ':') { alignedOverlayEnabled = !alignedOverlayEnabled; updateImagePanel(); updateLightbox(); e.preventDefault(); }
303
+ if (e.key === ' ') { togglePlay(); e.preventDefault(); }
304
+ });
305
+
306
+ // ── Scene-derived scale: the default preset ships with camera and
307
+ // target at sensible positions for the scene; their distance is a
308
+ // natural unit for "how big this scene is." All nav speeds and the
309
+ // gizmo size derive from it.
310
+ const SCENE_SCALE = controls().target.distanceTo(cam().position) || 30000;
311
+
312
+ // ── WASD / QE camera fly. W/S forward, A/D strafe, Q/E up/down.
313
+ // Camera AND orbit target translate together so the gizmo (orbit
314
+ // pivot) follows the camera at fixed offset.
315
+ const FLY = {
316
+ step: SCENE_SCALE * 0.005, // mm per frame, regardless of zoom
317
+ stepFast: SCENE_SCALE * 0.02, // with shift held
318
+ };
319
+ const _flyKeys = new Set();
320
+ const _fwd = new THREE.Vector3();
321
+ const _right = new THREE.Vector3();
322
+ const _worldUp = new THREE.Vector3(0, 0, 1);
323
+ controls().enableZoom = false;
324
+ on( document, 'keydown',(e) => {
325
+ if (e.target.tagName === 'INPUT') return;
326
+ const k = e.key.toLowerCase();
327
+ if (['w','a','s','d','q','e'].includes(k)) {
328
+ if (k === 'a' && activeFlowName) { prevImage(); e.preventDefault(); return; }
329
+ if (k === 'd' && activeFlowName) { nextImage(); e.preventDefault(); return; }
330
+ _flyKeys.add(k);
331
+ e.preventDefault();
332
+ }
333
+ });
334
+ on( document, 'keyup', (e) => {
335
+ const k = e.key.toLowerCase();
336
+ if (['w','a','s','d','q','e'].includes(k)) _flyKeys.delete(k);
337
+ });
338
+ on( window, 'blur', () => _flyKeys.clear());
339
+
340
+ // Keep near/far in lock-step with orbit-distance so zooming in/out
341
+ // doesn't clip the scene. Recomputed every frame via rAF.
342
+ function _updateClipPlanes() {
343
+ if (!_alive) return;
344
+ requestAnimationFrame(_updateClipPlanes);
345
+ const c = cam();
346
+ const dist = controls().target.distanceTo(c.position);
347
+ const near = Math.max(1, dist * 0.001);
348
+ const far = Math.max(200000, dist * 100);
349
+ if (Math.abs(c.near - near) > 0.5 || Math.abs(c.far - far) > 1) {
350
+ c.near = near;
351
+ c.far = far;
352
+ c.updateProjectionMatrix();
353
+ }
354
+ }
355
+ _updateClipPlanes();
356
+
357
+ function _translate(move) {
358
+ // Translate both camera and orbit target by the same vector so
359
+ // the gizmo follows the camera as you fly.
360
+ cam().position.add(move);
361
+ controls().target.add(move);
362
+ }
363
+
364
+ function _flyTick() {
365
+ if (!_alive) return;
366
+ requestAnimationFrame(_flyTick);
367
+ if (_flyKeys.size === 0) return;
368
+ const step = _flyKeys.has('shift') ? FLY.stepFast : FLY.step;
369
+ cam().getWorldDirection(_fwd);
370
+ _right.crossVectors(_fwd, _worldUp);
371
+ if (_right.lengthSq() < 1e-9) _right.set(1, 0, 0); else _right.normalize();
372
+ const move = new THREE.Vector3();
373
+ if (_flyKeys.has('w')) move.addScaledVector(_fwd, step);
374
+ if (_flyKeys.has('s')) move.addScaledVector(_fwd, -step);
375
+ if (_flyKeys.has('d')) move.addScaledVector(_right, step);
376
+ if (_flyKeys.has('a')) move.addScaledVector(_right, -step);
377
+ if (_flyKeys.has('e')) move.addScaledVector(_worldUp, step);
378
+ if (_flyKeys.has('q')) move.addScaledVector(_worldUp, -step);
379
+ if (move.lengthSq() > 0) _translate(move);
380
+ }
381
+
382
+ // ── Orbit dolly + gizmo config. Two knobs for the gizmo: size +
383
+ // opacity. Everything else (ring tube, spoke radius, arrow size,
384
+ // hub size) is a fixed ratio of `size`.
385
+ const ORBIT = {
386
+ distMin: SCENE_SCALE * 0.005,
387
+ distMax: SCENE_SCALE * 1000,
388
+ factor: 1.07, // exponential per wheel tick
389
+ factorFast: 1.25, // with shift
390
+ };
391
+ const GIZMO = {
392
+ size: SCENE_SCALE * 0.006, // outer ring radius
393
+ opacity: 0.9, // peak opacity while interacting
394
+ };
395
+
396
+ function _buildGizmo({ size, opacity }) {
397
+ const group = new THREE.Group();
398
+ const mats = [];
399
+ const _Y = new THREE.Vector3(0, 1, 0);
400
+ const _Z = new THREE.Vector3(0, 0, 1);
401
+ const flatMat = (color) => {
402
+ const m = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.0, depthTest: false });
403
+ mats.push(m);
404
+ return m;
405
+ };
406
+ const add = (mesh) => { mesh.renderOrder = 999; group.add(mesh); };
407
+
408
+ const ringGeo = new THREE.TorusGeometry(size, size * 0.036, 10, 56);
409
+ const spokeGeo = new THREE.CylinderGeometry(size * 0.024, size * 0.024, size, 12);
410
+ const arrowGeo = new THREE.ConeGeometry(size * 0.072, size * 0.18, 16);
411
+ const hubGeo = new THREE.SphereGeometry(size * 0.09, 16, 12);
412
+
413
+ const axes = [
414
+ { axis: [1, 0, 0], color: 0xff3366 },
415
+ { axis: [0, 1, 0], color: 0x33dd66 },
416
+ { axis: [0, 0, 1], color: 0x3399ff },
417
+ ];
418
+ for (const { axis, color } of axes) {
419
+ const a = new THREE.Vector3().fromArray(axis).normalize();
420
+ const qSpoke = new THREE.Quaternion().setFromUnitVectors(_Y, a);
421
+ const qRing = new THREE.Quaternion().setFromUnitVectors(_Z, a);
422
+
423
+ const ring = new THREE.Mesh(ringGeo, flatMat(color));
424
+ ring.quaternion.copy(qRing);
425
+ add(ring);
426
+
427
+ const spoke = new THREE.Mesh(spokeGeo, flatMat(color));
428
+ spoke.position.copy(a).multiplyScalar(size / 2);
429
+ spoke.quaternion.copy(qSpoke);
430
+ add(spoke);
431
+
432
+ const arrow = new THREE.Mesh(arrowGeo, flatMat(color));
433
+ arrow.position.set(size, 0, 0).applyQuaternion(qRing);
434
+ arrow.quaternion.copy(qRing);
435
+ add(arrow);
436
+ }
437
+
438
+ add(new THREE.Mesh(hubGeo, flatMat(0xcccccc)));
439
+ return { group, mats, opacity };
440
+ }
441
+
442
+ const _gizmo = _buildGizmo(GIZMO);
443
+ renderer.scene.add(_gizmo.group);
444
+
445
+ // ── Visibility: full opacity while interacting, fades shortly after.
446
+ const GIZMO_HOLD_MS = 400;
447
+ const GIZMO_FADE_MS = 600;
448
+ let _gizmoFadeAt = 0;
449
+ let _gizmoHolding = false;
450
+ function _pingGizmo() { _gizmoFadeAt = performance.now() + GIZMO_HOLD_MS; }
451
+ canvas.addEventListener('pointerdown', () => { _gizmoHolding = true; _pingGizmo(); });
452
+ on( window, 'pointerup', () => { _gizmoHolding = false; _pingGizmo(); });
453
+
454
+ (function _gizmoTick() {
455
+ if (!_alive) return;
456
+ requestAnimationFrame(_gizmoTick);
457
+ // In POV the orbit controls are disabled — the pivot can't move, so the
458
+ // anchor gizmo is meaningless. Keep it fully hidden there.
459
+ if (povCameraIdx >= 0) {
460
+ if (_gizmo.group.visible) _gizmo.group.visible = false;
461
+ return;
462
+ }
463
+ _gizmo.group.position.copy(controls().target);
464
+ const now = performance.now();
465
+ let opacity;
466
+ if (_gizmoHolding || now <= _gizmoFadeAt) {
467
+ opacity = _gizmo.opacity;
468
+ } else {
469
+ const t = (now - _gizmoFadeAt) / GIZMO_FADE_MS;
470
+ opacity = Math.max(0, _gizmo.opacity * (1 - t));
471
+ }
472
+ for (const m of _gizmo.mats) m.opacity = opacity;
473
+ _gizmo.group.visible = opacity > 0.001;
474
+ })();
475
+
476
+ // ── Scroll = exponential dolly. Camera moves toward/away pivot;
477
+ // pivot stays put.
478
+ canvas.addEventListener('wheel', (e) => {
479
+ e.preventDefault();
480
+ const factor = e.shiftKey ? ORBIT.factorFast : ORBIT.factor;
481
+ const c = cam();
482
+ const offset = c.position.clone().sub(controls().target);
483
+ let dist = Math.max(1, offset.length());
484
+ offset.divideScalar(dist);
485
+ dist = e.deltaY < 0 ? dist / factor : dist * factor; // wheel up = dolly in
486
+ dist = Math.max(ORBIT.distMin, Math.min(ORBIT.distMax, dist));
487
+ c.position.copy(controls().target).addScaledVector(offset, dist);
488
+ _pingGizmo();
489
+ }, { passive: false });
490
+ on( document, 'keydown',(e) => { if (e.key === 'Shift') _flyKeys.add('shift'); });
491
+ on( document, 'keyup', (e) => { if (e.key === 'Shift') _flyKeys.delete('shift'); });
492
+ _flyTick();
493
+
494
+ document.getElementById('close-right-panel').addEventListener('click', () => {
495
+ deselectFlow();
496
+ selectCamera(-1);
497
+ });
498
+
499
+ document.getElementById('btn-prev-step').addEventListener('click', () => { stopPlay(); prevStep(); });
500
+ document.getElementById('btn-next-step').addEventListener('click', () => { stopPlay(); nextStep(); });
501
+ document.getElementById('btn-play-stop').addEventListener('click', togglePlay);
502
+ document.getElementById('btn-prev-img').addEventListener('click', prevImage);
503
+ document.getElementById('btn-next-img').addEventListener('click', nextImage);
504
+
505
+ document.getElementById('step-image').addEventListener('click', toggleLightbox);
506
+ document.getElementById('step-image-canvas').addEventListener('click', toggleLightbox);
507
+ document.getElementById('btn-laser-overlay').addEventListener('click', () => {
508
+ laserOverlayEnabled = !laserOverlayEnabled;
509
+ updateImagePanel(); updateLightbox();
510
+ });
511
+ document.getElementById('btn-score-overlay').addEventListener('click', () => {
512
+ scoreOverlayEnabled = !scoreOverlayEnabled;
513
+ updateImagePanel(); updateLightbox();
514
+ });
515
+ document.getElementById('btn-projected-overlay').addEventListener('click', () => {
516
+ projectedOverlayEnabled = !projectedOverlayEnabled;
517
+ updateImagePanel(); updateLightbox();
518
+ });
519
+ document.getElementById('btn-aligned-overlay').addEventListener('click', () => {
520
+ alignedOverlayEnabled = !alignedOverlayEnabled;
521
+ updateImagePanel(); updateLightbox();
522
+ });
523
+ document.getElementById('close-lightbox').addEventListener('click', closeLightbox);
524
+ document.getElementById('lightbox-prev').addEventListener('click', prevImage);
525
+ document.getElementById('lightbox-next').addEventListener('click', nextImage);
526
+ setupLightboxResize();
527
+
528
+ document.getElementById('loading').classList.add('hidden');
529
+ document.getElementById('app').classList.remove('hidden');
530
+ renderer.resize();
531
+ }
532
+
533
+ // updateCameraVisuals — delegate the rebuild to the renderer, then re-apply
534
+ // flow image + POV hide (this bootstrap owns flow + POV state).
535
+ function updateCameraVisuals(idx) {
536
+ renderer.updateCameraVisuals(idx, {
537
+ afterRebuild: (camIdx, frustumGroup) => {
538
+ // Re-apply image to new plane if one is active for this camera.
539
+ const activeFlow = activeFlowName ? flowData()[activeFlowName] : null;
540
+ if (activeFlow && renderer.findCameraIdx(activeFlow.cameraId) === camIdx && currentImages.length > 0) {
541
+ setFrustumImage(camIdx, currentImages[currentImageIdx]);
542
+ }
543
+ // Keep POV in sync when PTZ changes. The frustum was rebuilt with all
544
+ // children visible — re-apply the POV hide (wireframe off, textured
545
+ // image plane on) or it flashes back into the POV view.
546
+ if (povCameraIdx === camIdx) {
547
+ applyPOV(camIdx);
548
+ frustumGroup?.children.forEach(child => {
549
+ if (child.name === 'frustum_image_plane') renderer.refreshFrustumImageVisibility(child);
550
+ else child.visible = false;
551
+ });
552
+ }
553
+ },
554
+ });
555
+ }
556
+
557
+ // ═══════════════════════════════════════════════════════════════
558
+ // UI: Layer toggles
559
+ // ═══════════════════════════════════════════════════════════════
560
+
561
+ function buildLayerToggles(layers) {
562
+ const container = document.getElementById('layer-toggles');
563
+ container.innerHTML = '';
564
+
565
+ // Separate contour layers from other layers
566
+ const contourLayers = layers.filter(l => l.type.startsWith('contours'));
567
+ const otherLayers = layers.filter(l => !l.type.startsWith('contours'));
568
+
569
+ // Render non-contour, non-camera layers as simple toggles
570
+ for (const layer of otherLayers) {
571
+ if (layer.type === 'cameras') {
572
+ buildCameraLayerTree(container, layer);
573
+ } else {
574
+ container.appendChild(buildToggle(layer.type, layer.label, layer.visible, layer.data));
575
+ }
576
+ }
577
+
578
+ // Render contour layers as a collapsible tree
579
+ if (contourLayers.length > 0) {
580
+ buildContourTree(container, contourLayers);
581
+ }
582
+ }
583
+
584
+ function buildToggle(key, labelText, checked, data) {
585
+ const div = document.createElement('div');
586
+ div.className = 'layer-toggle';
587
+
588
+ const checkbox = document.createElement('input');
589
+ checkbox.type = 'checkbox';
590
+ checkbox.checked = checked;
591
+ checkbox.id = `layer-${key}`;
592
+
593
+ const label = document.createElement('label');
594
+ label.className = 'layer-label';
595
+ label.textContent = labelText;
596
+ label.htmlFor = checkbox.id;
597
+
598
+ const count = document.createElement('span');
599
+ count.className = 'layer-count';
600
+ if (Array.isArray(data)) count.textContent = data.length;
601
+
602
+ checkbox.addEventListener('change', () => {
603
+ const group = layerGroups()[key];
604
+ if (group) group.visible = checkbox.checked;
605
+ });
606
+
607
+ div.appendChild(checkbox);
608
+ div.appendChild(label);
609
+ div.appendChild(count);
610
+ return div;
611
+ }
612
+
613
+ function buildSubToggle(key, labelText, checked, opts = {}) {
614
+ const div = document.createElement('div');
615
+ div.className = 'layer-toggle layer-sub-toggle';
616
+
617
+ const cb = document.createElement('input');
618
+ cb.type = 'checkbox';
619
+ cb.className = 'vis-cb';
620
+ cb.checked = checked;
621
+ cb.id = `layer-${key}`;
622
+ cb.title = 'Show / hide';
623
+
624
+ cb.addEventListener('change', () => {
625
+ const group = layerGroups()[key];
626
+ if (group) group.visible = cb.checked;
627
+ });
628
+
629
+ div.appendChild(cb);
630
+
631
+ const lbl = document.createElement('label');
632
+ lbl.className = 'layer-label';
633
+ lbl.textContent = labelText;
634
+ lbl.htmlFor = cb.id;
635
+ div.appendChild(lbl);
636
+
637
+ // Colour toggle sits at the far right of the row (label flexes between it
638
+ // and the show-box), mirroring the flow tree's play button.
639
+ if (opts.withHighlight) {
640
+ const hl = document.createElement('input');
641
+ hl.type = 'checkbox';
642
+ hl.className = 'hl-cb';
643
+ hl.checked = opts.highlightChecked ?? true;
644
+ hl.id = `hl-${key}`;
645
+ hl.title = 'Bright colour / dim';
646
+ hl.addEventListener('change', () => {
647
+ if (opts.onHighlightChange) opts.onHighlightChange(hl.checked);
648
+ });
649
+ div.appendChild(hl);
650
+ }
651
+
652
+ return div;
653
+ }
654
+
655
+ function buildTreeParent(container, key, labelText, checked, children, onToggleAll, opts = {}) {
656
+ // Parent row with expand arrow + checkbox
657
+ const div = document.createElement('div');
658
+ div.className = 'layer-toggle';
659
+
660
+ const arrow = document.createElement('span');
661
+ arrow.className = 'tree-arrow';
662
+ arrow.textContent = '▶';
663
+
664
+ const checkbox = document.createElement('input');
665
+ checkbox.type = 'checkbox';
666
+ checkbox.className = 'vis-cb';
667
+ checkbox.checked = checked;
668
+ checkbox.id = `layer-${key}`;
669
+ checkbox.title = 'Show / hide all';
670
+
671
+ const label = document.createElement('label');
672
+ label.className = 'layer-label';
673
+ label.textContent = labelText;
674
+ label.htmlFor = checkbox.id;
675
+
676
+ const count = document.createElement('span');
677
+ count.className = 'layer-count';
678
+ count.textContent = children.length;
679
+
680
+ // Children container (hidden by default)
681
+ const childContainer = document.createElement('div');
682
+ childContainer.className = 'tree-children';
683
+ childContainer.style.display = 'none';
684
+
685
+ // Expand/collapse
686
+ arrow.addEventListener('click', (e) => {
687
+ e.stopPropagation();
688
+ const open = childContainer.style.display !== 'none';
689
+ childContainer.style.display = open ? 'none' : 'block';
690
+ arrow.textContent = open ? '▶' : '▼';
691
+ });
692
+
693
+ // Visibility cascade — fires only on `.vis-cb` descendants so the
694
+ // sibling highlight cascade stays independent.
695
+ checkbox.addEventListener('change', () => {
696
+ childContainer.querySelectorAll('input.vis-cb').forEach(cb => {
697
+ cb.checked = checkbox.checked;
698
+ cb.dispatchEvent(new Event('change'));
699
+ });
700
+ if (onToggleAll) onToggleAll(checkbox.checked);
701
+ });
702
+
703
+ div.appendChild(arrow);
704
+ div.appendChild(checkbox);
705
+ div.appendChild(label);
706
+ div.appendChild(count);
707
+
708
+ // Colour toggle pinned to the far right of the row (after the count),
709
+ // matching the per-row swatch position in the children below.
710
+ if (opts.withHighlight) {
711
+ const hl = document.createElement('input');
712
+ hl.type = 'checkbox';
713
+ hl.className = 'hl-cb';
714
+ hl.checked = opts.highlightChecked ?? true;
715
+ hl.id = `hl-${key}`;
716
+ hl.title = 'Bright colour / dim all';
717
+ hl.addEventListener('change', () => {
718
+ childContainer.querySelectorAll('input.hl-cb').forEach(c => {
719
+ c.checked = hl.checked;
720
+ c.dispatchEvent(new Event('change'));
721
+ });
722
+ if (opts.onHighlightToggleAll) opts.onHighlightToggleAll(hl.checked);
723
+ });
724
+ div.appendChild(hl);
725
+ }
726
+
727
+ container.appendChild(div);
728
+ container.appendChild(childContainer);
729
+
730
+ return childContainer;
731
+ }
732
+
733
+ // Highlight = bright HSL hue baked in by renderContours; dim = dark gray.
734
+ // Each contour line carries `userData.highlight` (default true). The tree
735
+ // rows expose a second checkbox per row that cascades from parent → children
736
+ // just like visibility, but you can flip an individual ply afterwards.
737
+ const DIM_CONTOUR_COLOR = 0x3a3a3a;
738
+
739
+ function paintContourLine(line) {
740
+ if (!line.material || typeof line.userData?.hue !== 'number') return;
741
+ const hue = line.userData.hue;
742
+ if (line.userData.highlight !== false) {
743
+ line.material.color.setHSL(hue, 0.8, 0.6);
744
+ } else {
745
+ line.material.color.setHex(DIM_CONTOUR_COLOR);
746
+ }
747
+ }
748
+
749
+ // Update every line whose (layerType, lpdLayer, group) matches the given
750
+ // scope. ``null`` means "any" — so passing only layerType repaints the
751
+ // whole drawing, etc. Mirrors the cascade direction of visibility events.
752
+ function applyHighlightToScope(layerType, lpdLayer, group, highlight) {
753
+ const root = layerGroups()[layerType];
754
+ if (!root) return;
755
+ root.traverse((obj) => {
756
+ if (typeof obj.userData?.hue !== 'number') return;
757
+ if (lpdLayer != null && obj.userData.layer !== lpdLayer) return;
758
+ if (group != null && obj.userData.group !== group) return;
759
+ obj.userData.highlight = highlight;
760
+ paintContourLine(obj);
761
+ });
762
+ }
763
+
764
+ function buildContourTree(container, contourLayers) {
765
+ const anyVisible = contourLayers.some(l => l.visible);
766
+ const childContainer = buildTreeParent(
767
+ container, 'contours_all', 'Laser Projections', anyVisible, contourLayers,
768
+ undefined,
769
+ { withHighlight: true, highlightChecked: true },
770
+ );
771
+
772
+ for (const layer of contourLayers) {
773
+ const data = Array.isArray(layer.data) ? layer.data : [];
774
+ const lpdLayerNames = [...new Set(data.map(c => c.layer).filter(Boolean))];
775
+
776
+ if (lpdLayerNames.length <= 1 && new Set(data.map(c => c.group)).size <= 1) {
777
+ // Single layer, single group — simple toggle
778
+ childContainer.appendChild(
779
+ buildSubToggle(layer.type, layer.label, layer.visible, {
780
+ withHighlight: true,
781
+ onHighlightChange: (checked) =>
782
+ applyHighlightToScope(layer.type, null, null, checked),
783
+ })
784
+ );
785
+ continue;
786
+ }
787
+
788
+ // Drawing with multiple layers/groups — expandable
789
+ const drawingChildren = buildTreeParent(
790
+ childContainer, layer.type, layer.label, layer.visible, lpdLayerNames,
791
+ (checked) => {
792
+ const grp = layerGroups()[layer.type];
793
+ if (grp) grp.visible = checked;
794
+ },
795
+ { withHighlight: true },
796
+ );
797
+
798
+ for (const lpdLayerName of lpdLayerNames) {
799
+ // Get groups within this layer
800
+ const layerContours = data.filter(c => c.layer === lpdLayerName);
801
+ const grpNames = [...new Set(layerContours.map(c => c.group).filter(Boolean))];
802
+
803
+ if (grpNames.length > 1) {
804
+ // Layer has multiple groups — expandable with group sub-toggles
805
+ const layerChildren = buildTreeParent(
806
+ drawingChildren, `${layer.type}__${lpdLayerName}`, lpdLayerName, layer.visible, grpNames,
807
+ (checked) => {
808
+ const drawingGroup = layerGroups()[layer.type];
809
+ if (!drawingGroup) return;
810
+ const layerGrp = drawingGroup.userData?.layerSubGroups?.[lpdLayerName];
811
+ if (layerGrp) layerGrp.visible = checked;
812
+ if (checked) drawingGroup.visible = true;
813
+ },
814
+ { withHighlight: true },
815
+ );
816
+
817
+ for (const grpName of grpNames) {
818
+ const grpKey = `${layer.type}__${lpdLayerName}__${grpName}`;
819
+ const sub = buildSubToggle(grpKey, grpName, layer.visible, {
820
+ withHighlight: true,
821
+ onHighlightChange: (checked) =>
822
+ applyHighlightToScope(layer.type, lpdLayerName, grpName, checked),
823
+ });
824
+ const cb = sub.querySelector('input.vis-cb');
825
+ cb.addEventListener('change', () => {
826
+ const drawingGroup = layerGroups()[layer.type];
827
+ if (!drawingGroup) return;
828
+ const layerGrp = drawingGroup.userData?.layerSubGroups?.[lpdLayerName];
829
+ const grpGrp = layerGrp?.userData?.groupSubGroups?.[grpName];
830
+ if (grpGrp) grpGrp.visible = cb.checked;
831
+ if (cb.checked) {
832
+ if (layerGrp) layerGrp.visible = true;
833
+ drawingGroup.visible = true;
834
+ }
835
+ });
836
+ layerChildren.appendChild(sub);
837
+ }
838
+ } else {
839
+ // Layer has one group — simple toggle
840
+ const subKey = `${layer.type}__${lpdLayerName}`;
841
+ const sub = buildSubToggle(subKey, lpdLayerName, layer.visible, {
842
+ withHighlight: true,
843
+ onHighlightChange: (checked) =>
844
+ applyHighlightToScope(layer.type, lpdLayerName, null, checked),
845
+ });
846
+ const cb = sub.querySelector('input.vis-cb');
847
+ cb.addEventListener('change', () => {
848
+ const drawingGroup = layerGroups()[layer.type];
849
+ if (!drawingGroup) return;
850
+ const layerGrp = drawingGroup.userData?.layerSubGroups?.[lpdLayerName];
851
+ if (layerGrp) layerGrp.visible = cb.checked;
852
+ if (cb.checked) drawingGroup.visible = true;
853
+ });
854
+ drawingChildren.appendChild(sub);
855
+ }
856
+ }
857
+ }
858
+ }
859
+
860
+ function buildCameraLayerTree(container, layer) {
861
+ const cameraData = Array.isArray(layer.data) ? layer.data : [];
862
+ const childContainer = buildTreeParent(
863
+ container, 'cameras', 'Cameras', layer.visible,
864
+ cameraData,
865
+ (checked) => {
866
+ const grp = layerGroups()['cameras'];
867
+ if (grp) grp.visible = checked;
868
+ },
869
+ );
870
+
871
+ // Per-camera expandable entries
872
+ cameraData.forEach((camData, idx) => {
873
+ const camLabel = camData.label || camData.id || 'Camera';
874
+ const camKey = `camera_${idx}`;
875
+
876
+ // Each camera is an expandable tree node
877
+ const camChildren = buildTreeParent(
878
+ childContainer, camKey, camLabel, true,
879
+ ['Shell', 'Internals', 'Frustums', 'Projections'],
880
+ (checked) => {
881
+ // Toggle the entire per-camera group
882
+ const camerasGroup = layerGroups()['cameras'];
883
+ const perCam = camerasGroup?.userData?.perCamera?.[idx];
884
+ if (perCam) perCam.camGroup.visible = checked;
885
+ },
886
+ );
887
+
888
+ // Sub-component toggles for this camera
889
+ const subItems = [
890
+ ['shell', 'Shell'],
891
+ ['internals', 'Internals'],
892
+ ['frustum', 'Frustums'],
893
+ ['projection', 'Projections'],
894
+ ['imagePlane', 'Image Projection'],
895
+ ];
896
+ for (const [subField, subText] of subItems) {
897
+ const subKey = `${camKey}_${subField}`;
898
+ const sub = buildSubToggle(subKey, subText, true);
899
+ const cb = sub.querySelector('input');
900
+ cb.addEventListener('change', () => {
901
+ if (subField === 'imagePlane') {
902
+ const plane = cameraImagePlanes()[idx];
903
+ if (plane) {
904
+ // Record intent; actual visibility still requires a
905
+ // loaded texture (no gray placeholder when imageless).
906
+ plane.userData.projectionEnabled = cb.checked;
907
+ renderer.refreshFrustumImageVisibility(plane);
908
+ }
909
+ return;
910
+ }
911
+ const camerasGroup = layerGroups()['cameras'];
912
+ const perCam = camerasGroup?.userData?.perCamera?.[idx];
913
+ if (perCam && perCam[subField]) perCam[subField].visible = cb.checked;
914
+ });
915
+ camChildren.appendChild(sub);
916
+ }
917
+ });
918
+ }
919
+
920
+ // ═══════════════════════════════════════════════════════════════
921
+ // Views + bookmarks: smooth camera transitions and saved scene states
922
+ // ═══════════════════════════════════════════════════════════════
923
+
924
+ let _viewFlyId = null; // RAF id for an in-progress view transition
925
+ let _flyDamping = null; // orbit dampingFactor saved while a fly suppresses it
926
+ let builtInPresets = {}; // from scene.json viewPresets (Default/Top/...)
927
+
928
+ // Cleanly end any in-progress view fly: stop the RAF, restore orbit damping,
929
+ // re-enable input. Idempotent — safe to call when no fly is running.
930
+ function _endViewFly() {
931
+ if (_viewFlyId) { cancelAnimationFrame(_viewFlyId); _viewFlyId = null; }
932
+ if (_flyDamping !== null) { controls().dampingFactor = _flyDamping; _flyDamping = null; }
933
+ controls().enabled = true;
934
+ }
935
+ let bookmarks = []; // user-saved {name, state} for this scene
936
+
937
+ function _easeInOutCubic(p) {
938
+ return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
939
+ }
940
+
941
+ // Snapshot the current camera viewpoint (pose + projection-specific framing).
942
+ function captureView() {
943
+ return renderer.captureView();
944
+ }
945
+
946
+ // Smoothly fly the camera to a target view. Eases position + orbit target (the
947
+ // render loop's controls.update() enforces look-at-target, so interpolating the
948
+ // target IS the orientation interpolation). Projection can't morph, so we switch
949
+ // it instantly up front, then animate.
950
+ function flyToView(view, { duration = 900 } = {}) {
951
+ _endViewFly(); // cancel + restore any prior fly's controls state first
952
+ if (view.projectionMode && view.projectionMode !== projectionMode()) {
953
+ switchProjection(view.projectionMode);
954
+ }
955
+ const camera = cam();
956
+ const ctrls = controls();
957
+ if (view.up) camera.up.fromArray(view.up); // constant (0,0,1) in this scene; harmless
958
+
959
+ const startPos = camera.position.clone();
960
+ const endPos = new THREE.Vector3().fromArray(view.position);
961
+ const startTgt = ctrls.target.clone();
962
+ const endTgt = new THREE.Vector3().fromArray(view.target);
963
+ const startQuat = camera.quaternion.clone();
964
+
965
+ // Probe the EXACT orientation OrbitControls settles into at the destination,
966
+ // so the slerp lands precisely there with no end-snap — even at a pole.
967
+ camera.position.copy(endPos);
968
+ ctrls.target.copy(endTgt);
969
+ ctrls.update();
970
+ const endQuat = camera.quaternion.clone();
971
+ camera.position.copy(startPos);
972
+ ctrls.target.copy(startTgt);
973
+ camera.quaternion.copy(startQuat);
974
+
975
+ const isOrtho = camera.isOrthographicCamera;
976
+ const startFov = camera.isPerspectiveCamera ? camera.fov : null;
977
+ const endFov = (camera.isPerspectiveCamera && view.fov != null) ? view.fov : startFov;
978
+ const startZoom = camera.zoom;
979
+ const endZoom = (isOrtho && view.zoom != null) ? view.zoom : startZoom;
980
+ const haveBounds = isOrtho && view.left != null;
981
+ const sb = haveBounds ? { l: camera.left, r: camera.right, t: camera.top, b: camera.bottom } : null;
982
+ const eb = haveBounds ? { l: view.left, r: view.right, t: view.top, b: view.bottom } : null;
983
+
984
+ // Disable controls so the render loop won't force look-at-target and clobber
985
+ // the slerp; suppress damping so residual drag-inertia can't perturb it.
986
+ ctrls.enabled = false;
987
+ _flyDamping = ctrls.dampingFactor;
988
+ ctrls.dampingFactor = 0;
989
+ const t0 = performance.now();
990
+ function tick(now) {
991
+ const p = Math.min(1, (now - t0) / duration);
992
+ const k = _easeInOutCubic(p);
993
+ camera.position.lerpVectors(startPos, endPos, k);
994
+ ctrls.target.lerpVectors(startTgt, endTgt, k); // end-state bookkeeping
995
+ camera.quaternion.copy(startQuat).slerp(endQuat, k);
996
+ if (isOrtho) {
997
+ camera.zoom = startZoom + (endZoom - startZoom) * k;
998
+ if (haveBounds) {
999
+ camera.left = sb.l + (eb.l - sb.l) * k;
1000
+ camera.right = sb.r + (eb.r - sb.r) * k;
1001
+ camera.top = sb.t + (eb.t - sb.t) * k;
1002
+ camera.bottom = sb.b + (eb.b - sb.b) * k;
1003
+ }
1004
+ camera.updateProjectionMatrix();
1005
+ } else if (endFov != null) {
1006
+ camera.fov = startFov + (endFov - startFov) * k;
1007
+ camera.updateProjectionMatrix();
1008
+ }
1009
+ if (p < 1) {
1010
+ _viewFlyId = requestAnimationFrame(tick);
1011
+ } else {
1012
+ _viewFlyId = null;
1013
+ camera.position.copy(endPos);
1014
+ ctrls.target.copy(endTgt);
1015
+ camera.quaternion.copy(endQuat);
1016
+ if (_flyDamping !== null) { ctrls.dampingFactor = _flyDamping; _flyDamping = null; }
1017
+ ctrls.enabled = true;
1018
+ ctrls.update(); // reproduces endQuat (we probed it), now live
1019
+ }
1020
+ }
1021
+ _viewFlyId = requestAnimationFrame(tick);
1022
+ }
1023
+
1024
+ // Capture / restore the toggle state of the whole layers panel (visibility +
1025
+ // bright/dim highlight, cameras' sub-toggles, etc.) by checkbox id. Restoring
1026
+ // dispatches change events so the existing handlers apply the 3D effect and the
1027
+ // UI stays in sync.
1028
+ function captureToggleState() {
1029
+ const state = {};
1030
+ document.querySelectorAll('#layer-toggles input[type="checkbox"]').forEach(cb => {
1031
+ if (cb.id) state[cb.id] = cb.checked;
1032
+ });
1033
+ return state;
1034
+ }
1035
+
1036
+ function applyToggleState(state) {
1037
+ if (!state) return;
1038
+ // DOM order = parents before children, so a parent's cascade is corrected
1039
+ // when we reach each child and set it to its captured value.
1040
+ document.querySelectorAll('#layer-toggles input[type="checkbox"]').forEach(cb => {
1041
+ if (!cb.id || !(cb.id in state)) return;
1042
+ if (cb.checked !== state[cb.id]) {
1043
+ cb.checked = state[cb.id];
1044
+ cb.dispatchEvent(new Event('change'));
1045
+ }
1046
+ });
1047
+ }
1048
+
1049
+ function captureSceneState() {
1050
+ return { view: captureView(), toggles: captureToggleState() };
1051
+ }
1052
+
1053
+ function applySceneState(state, { animate = true } = {}) {
1054
+ if (povCameraIdx >= 0) exitPOV(); // a view change leaves POV
1055
+ if (state.toggles) applyToggleState(state.toggles);
1056
+ if (!state.view) return;
1057
+ // Restore projection on BOTH paths (flyToView also handles it, but the
1058
+ // instant path must too).
1059
+ if (state.view.projectionMode && state.view.projectionMode !== projectionMode()) {
1060
+ switchProjection(state.view.projectionMode);
1061
+ }
1062
+ if (animate) flyToView(state.view);
1063
+ else {
1064
+ cam().position.fromArray(state.view.position);
1065
+ controls().target.fromArray(state.view.target);
1066
+ controls().update();
1067
+ }
1068
+ }
1069
+
1070
+ // ── Bookmark persistence (localStorage, keyed per scene) ──
1071
+ function _bookmarkKey() {
1072
+ const scene = new URLSearchParams(location.search).get('scene') || '_';
1073
+ return `tpiViz.bookmarks.${scene}`;
1074
+ }
1075
+ function loadBookmarks() {
1076
+ try { return JSON.parse(localStorage.getItem(_bookmarkKey()) || '[]'); }
1077
+ catch (e) { console.warn('[bookmarks] load failed:', e); return []; }
1078
+ }
1079
+ function persistBookmarks() {
1080
+ try { localStorage.setItem(_bookmarkKey(), JSON.stringify(bookmarks)); }
1081
+ catch (e) { console.warn('[bookmarks] save failed:', e); }
1082
+ }
1083
+
1084
+ // In-app name prompt (not window.prompt). A small floating popup anchored under
1085
+ // `anchorEl` with a focused input + Save/Cancel. Resolves to the trimmed name,
1086
+ // or null on cancel/Escape/outside-click. Enter saves.
1087
+ function promptName(defaultValue, anchorEl) {
1088
+ return new Promise((resolve) => {
1089
+ const pop = document.createElement('div');
1090
+ pop.className = 'name-popup';
1091
+
1092
+ const input = document.createElement('input');
1093
+ input.type = 'text';
1094
+ input.className = 'name-popup-input';
1095
+ input.value = defaultValue;
1096
+ input.placeholder = 'View name';
1097
+ input.maxLength = 60;
1098
+
1099
+ const row = document.createElement('div');
1100
+ row.className = 'name-popup-row';
1101
+ const cancel = document.createElement('button');
1102
+ cancel.className = 'btn';
1103
+ cancel.textContent = 'Cancel';
1104
+ const save = document.createElement('button');
1105
+ save.className = 'btn btn-primary';
1106
+ save.textContent = 'Save';
1107
+ row.appendChild(cancel);
1108
+ row.appendChild(save);
1109
+
1110
+ pop.appendChild(input);
1111
+ pop.appendChild(row);
1112
+ document.body.appendChild(pop);
1113
+
1114
+ // Anchor under the trigger, right-aligned to it.
1115
+ const r = anchorEl.getBoundingClientRect();
1116
+ pop.style.top = `${Math.round(r.bottom + 6)}px`;
1117
+ pop.style.right = `${Math.round(window.innerWidth - r.right)}px`;
1118
+
1119
+ input.focus();
1120
+ input.select();
1121
+
1122
+ let done = false;
1123
+ function close(value) {
1124
+ if (done) return;
1125
+ done = true;
1126
+ document.removeEventListener('mousedown', onOutside, true);
1127
+ pop.remove();
1128
+ resolve(value);
1129
+ }
1130
+ function commit() { close(input.value.trim() || null); }
1131
+ function onOutside(e) { if (!pop.contains(e.target)) close(null); }
1132
+
1133
+ save.addEventListener('click', commit);
1134
+ cancel.addEventListener('click', () => close(null));
1135
+ input.addEventListener('keydown', (e) => {
1136
+ if (e.key === 'Enter') { e.preventDefault(); commit(); }
1137
+ else if (e.key === 'Escape') { e.preventDefault(); close(null); }
1138
+ });
1139
+ // Defer so the click that opened the popup doesn't immediately close it.
1140
+ setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0);
1141
+ });
1142
+ }
1143
+
1144
+ function buildPovControls(presets) {
1145
+ const container = document.getElementById('pov-controls');
1146
+ container.innerHTML = '';
1147
+ builtInPresets = presets || {};
1148
+ bookmarks = loadBookmarks();
1149
+
1150
+ // ── Views group: built-in presets + saved bookmarks + a "+" to save one ──
1151
+ const viewsGroup = document.createElement('div');
1152
+ viewsGroup.className = 'ctrl-group';
1153
+ const viewsLabel = document.createElement('span');
1154
+ viewsLabel.className = 'ctrl-label';
1155
+ viewsLabel.textContent = 'Views';
1156
+ viewsGroup.appendChild(viewsLabel);
1157
+ const viewsRow = document.createElement('div');
1158
+ viewsRow.className = 'ctrl-row';
1159
+ viewsGroup.appendChild(viewsRow);
1160
+ container.appendChild(viewsGroup);
1161
+
1162
+ function renderViews() {
1163
+ viewsRow.innerHTML = '';
1164
+ // Built-in presets (camera-only viewpoints from scene.json)
1165
+ for (const name of Object.keys(builtInPresets)) {
1166
+ const btn = document.createElement('button');
1167
+ btn.className = 'btn';
1168
+ btn.textContent = name.charAt(0).toUpperCase() + name.slice(1);
1169
+ btn.addEventListener('click', () => {
1170
+ if (povCameraIdx >= 0) exitPOV();
1171
+ flyToView(builtInPresets[name]);
1172
+ });
1173
+ viewsRow.appendChild(btn);
1174
+ }
1175
+ // User bookmarks (full scene state). Chip with a delete ×.
1176
+ bookmarks.forEach((bm, i) => {
1177
+ const chip = document.createElement('span');
1178
+ chip.className = 'bookmark-chip';
1179
+ const go = document.createElement('button');
1180
+ go.className = 'btn bookmark-btn';
1181
+ go.textContent = bm.name;
1182
+ go.title = 'Go to saved view (restores scene state)';
1183
+ go.addEventListener('click', () => applySceneState(bm.state, { animate: true }));
1184
+ const del = document.createElement('button');
1185
+ del.className = 'bookmark-del';
1186
+ del.innerHTML = '&times;';
1187
+ del.title = 'Delete this bookmark';
1188
+ del.addEventListener('click', (e) => {
1189
+ e.stopPropagation();
1190
+ bookmarks.splice(i, 1);
1191
+ persistBookmarks();
1192
+ renderViews();
1193
+ });
1194
+ chip.appendChild(go);
1195
+ chip.appendChild(del);
1196
+ viewsRow.appendChild(chip);
1197
+ });
1198
+ // "+" — save current view + scene state as a new bookmark
1199
+ const add = document.createElement('button');
1200
+ add.className = 'btn btn-add';
1201
+ add.innerHTML = '&plus;';
1202
+ add.title = 'Save current view + scene state as a bookmark';
1203
+ add.addEventListener('click', async () => {
1204
+ const name = await promptName(`View ${bookmarks.length + 1}`, add);
1205
+ if (!name) return;
1206
+ bookmarks.push({ name, state: captureSceneState() });
1207
+ persistBookmarks();
1208
+ renderViews();
1209
+ });
1210
+ viewsRow.appendChild(add);
1211
+ }
1212
+ renderViews();
1213
+
1214
+ // ── Projection group: separate from views; a segmented control with both
1215
+ // modes spelled out. The setupScene helper keeps both cameras in sync —
1216
+ // flipping rebinds OrbitControls + updates our cached camera. ──
1217
+ if (renderer.threeSetup && typeof renderer.threeSetup.setProjection === 'function') {
1218
+ const projGroup = document.createElement('div');
1219
+ projGroup.className = 'ctrl-group';
1220
+ const projLabel = document.createElement('span');
1221
+ projLabel.className = 'ctrl-label';
1222
+ projLabel.textContent = 'Projection';
1223
+ projGroup.appendChild(projLabel);
1224
+
1225
+ const seg = document.createElement('div');
1226
+ seg.className = 'segmented';
1227
+ const modes = [['perspective', 'Perspective'], ['orthographic', 'Orthographic']];
1228
+ const segBtns = {};
1229
+ for (const [mode, text] of modes) {
1230
+ const b = document.createElement('button');
1231
+ b.className = 'seg-btn' + (projectionMode() === mode ? ' active' : '');
1232
+ b.textContent = text;
1233
+ b.addEventListener('click', () => {
1234
+ if (mode === projectionMode()) return;
1235
+ // Cancel any in-flight transition so it can't keep driving the
1236
+ // (now rebound) camera after the projection switch.
1237
+ if (povAnimationId) { cancelAnimationFrame(povAnimationId); povAnimationId = null; }
1238
+ _endViewFly();
1239
+ switchProjection(mode);
1240
+ if (povCameraIdx >= 0) applyPOV(povCameraIdx);
1241
+ });
1242
+ segBtns[mode] = b;
1243
+ seg.appendChild(b);
1244
+ }
1245
+ // Let switchProjection (incl. exitPOV's restore) highlight the active mode.
1246
+ syncProjectionUI = (m) => {
1247
+ for (const [mode, b] of Object.entries(segBtns)) b.classList.toggle('active', mode === m);
1248
+ };
1249
+ renderer.setProjectionUISync(syncProjectionUI);
1250
+ projGroup.appendChild(seg);
1251
+
1252
+ // Projection lives top-LEFT (its own overlay), separate from the
1253
+ // top-right Views overlay.
1254
+ let leftControls = document.getElementById('projection-controls');
1255
+ if (!leftControls) {
1256
+ leftControls = document.createElement('div');
1257
+ leftControls.id = 'projection-controls';
1258
+ leftControls.className = 'controls controls-left';
1259
+ container.parentElement.appendChild(leftControls);
1260
+ }
1261
+ leftControls.innerHTML = '';
1262
+ leftControls.appendChild(projGroup);
1263
+ }
1264
+ }
1265
+
1266
+ // ═══════════════════════════════════════════════════════════════
1267
+ // UI: Camera tree (left sidebar) — cameras with nested flows
1268
+ // ═══════════════════════════════════════════════════════════════
1269
+
1270
+ function buildCameraTree() {
1271
+ const container = document.getElementById('camera-tree');
1272
+ container.innerHTML = '';
1273
+
1274
+ const cams = cameraEntries();
1275
+ if (cams.length === 0) {
1276
+ document.getElementById('camera-tree-panel').classList.add('hidden');
1277
+ return;
1278
+ }
1279
+
1280
+ // Per camera, group its flows by useCase: { camId: { useCase: [flow, …] } }
1281
+ const flowsByCamera = {};
1282
+ for (const [name, flow] of Object.entries(flowData())) {
1283
+ const camId = flow.cameraId;
1284
+ const uc = flow.useCase || 'uncategorized';
1285
+ if (!flowsByCamera[camId]) flowsByCamera[camId] = {};
1286
+ if (!flowsByCamera[camId][uc]) flowsByCamera[camId][uc] = [];
1287
+ flowsByCamera[camId][uc].push({ name, ...flow });
1288
+ }
1289
+ const totalForCam = (byUc) =>
1290
+ Object.values(byUc || {}).reduce((n, arr) => n + arr.length, 0);
1291
+
1292
+ cams.forEach((camDesc, idx) => {
1293
+ const item = mkEl('div', 'cam-tree-item');
1294
+
1295
+ // Camera header
1296
+ const header = mkEl('div', 'cam-tree-header');
1297
+ header.dataset.idx = idx;
1298
+
1299
+ const camFlowsByUc = flowsByCamera[camDesc.id] || {};
1300
+ const camFlowCount = totalForCam(camFlowsByUc);
1301
+
1302
+ const arrow = mkEl('span', 'tree-arrow');
1303
+ arrow.textContent = camFlowCount > 0 ? '▶' : ' ';
1304
+
1305
+ const dot = mkEl('span', 'cam-dot');
1306
+ dot.style.background = `#${camDesc.color.toString(16).padStart(6, '0')}`;
1307
+
1308
+ const nameSpan = mkEl('span', 'cam-name');
1309
+ nameSpan.textContent = camDesc.label || camDesc.id;
1310
+
1311
+ header.appendChild(arrow);
1312
+ header.appendChild(dot);
1313
+ header.appendChild(nameSpan);
1314
+
1315
+ if (camFlowCount > 0) {
1316
+ const countSpan = mkEl('span', 'cam-flow-count');
1317
+ countSpan.textContent = `${camFlowCount}`;
1318
+ header.appendChild(countSpan);
1319
+ }
1320
+
1321
+ header.addEventListener('click', () => selectCamera(idx));
1322
+ item.appendChild(header);
1323
+
1324
+ // Camera → useCase → flows. Camera collapsed by default;
1325
+ // useCase subgroups inside expand by default when the camera opens.
1326
+ if (camFlowCount > 0) {
1327
+ const ucContainer = mkEl('div', 'cam-tree-flows collapsed');
1328
+
1329
+ arrow.addEventListener('click', (e) => {
1330
+ e.stopPropagation();
1331
+ ucContainer.classList.toggle('collapsed');
1332
+ arrow.textContent = ucContainer.classList.contains('collapsed') ? '▶' : '▼';
1333
+ });
1334
+
1335
+ const useCases = Object.keys(camFlowsByUc).sort((a, b) => a.localeCompare(b));
1336
+ for (const uc of useCases) {
1337
+ const ucFlows = camFlowsByUc[uc];
1338
+ ucFlows.sort((a, b) => a.name.localeCompare(b.name));
1339
+
1340
+ const ucGroup = mkEl('div', 'cam-tree-uc-group');
1341
+
1342
+ const ucHeader = mkEl('div', 'cam-tree-uc-header');
1343
+ const ucArrow = mkEl('span', 'tree-arrow');
1344
+ ucArrow.textContent = '▼';
1345
+ const ucLabel = mkEl('span', 'uc-name');
1346
+ ucLabel.textContent = uc;
1347
+ const ucCount = mkEl('span', 'uc-flow-count');
1348
+ ucCount.textContent = `${ucFlows.length}`;
1349
+ ucHeader.appendChild(ucArrow);
1350
+ ucHeader.appendChild(ucLabel);
1351
+ ucHeader.appendChild(ucCount);
1352
+
1353
+ const flowList = mkEl('div', 'cam-tree-uc-flows');
1354
+ ucHeader.addEventListener('click', (e) => {
1355
+ e.stopPropagation();
1356
+ flowList.classList.toggle('collapsed');
1357
+ ucArrow.textContent = flowList.classList.contains('collapsed') ? '▶' : '▼';
1358
+ });
1359
+
1360
+ for (const flow of ucFlows) {
1361
+ const flowEl = mkEl('div', 'cam-tree-flow');
1362
+ flowEl.dataset.flowName = flow.name;
1363
+
1364
+ const icon = mkEl('span', 'flow-icon');
1365
+ icon.textContent = '▶';
1366
+
1367
+ const flowName = mkEl('span', 'flow-name');
1368
+ flowName.textContent = flow.name;
1369
+
1370
+ const stepCount = mkEl('span', 'flow-step-count');
1371
+ stepCount.textContent = `${flow.steps.length}`;
1372
+
1373
+ const playBtn = mkEl('button', 'flow-play-btn');
1374
+ playBtn.innerHTML = '&#9654;';
1375
+ playBtn.title = 'Play/Stop this flow';
1376
+ playBtn.addEventListener('click', (e) => {
1377
+ e.stopPropagation();
1378
+ toggleFlowPlay(flow.name, playBtn);
1379
+ });
1380
+
1381
+ flowEl.appendChild(icon);
1382
+ flowEl.appendChild(flowName);
1383
+ flowEl.appendChild(stepCount);
1384
+ flowEl.appendChild(playBtn);
1385
+
1386
+ flowEl.addEventListener('click', (e) => {
1387
+ e.stopPropagation();
1388
+ selectFlow(flow.name);
1389
+ });
1390
+
1391
+ flowList.appendChild(flowEl);
1392
+ }
1393
+
1394
+ ucGroup.appendChild(ucHeader);
1395
+ ucGroup.appendChild(flowList);
1396
+ ucContainer.appendChild(ucGroup);
1397
+ }
1398
+
1399
+ item.appendChild(ucContainer);
1400
+ }
1401
+
1402
+ container.appendChild(item);
1403
+ });
1404
+ }
1405
+
1406
+ // ═══════════════════════════════════════════════════════════════
1407
+ // Camera POV mode
1408
+ // ═══════════════════════════════════════════════════════════════
1409
+
1410
+ function applyPOV(idx) {
1411
+ const desc = renderer.model.cameraAt(idx);
1412
+ const params = desc.params;
1413
+ const ptz = desc.ptz;
1414
+ // Compute kinematics via the shared pure model path.
1415
+ const kinematics = computeKinematics(params, ptz.pan, ptz.tilt);
1416
+ const R = kinematics.R_full;
1417
+ const camPos = kinematics.cam_pos;
1418
+
1419
+ // Vertical FOV from zoom + sensor geometry
1420
+ const fw = params.focal_wide_mm || 4.3;
1421
+ const ft = params.focal_tele_mm || 137.6;
1422
+ const t = Math.max(0, Math.min(1, (ptz.zoom - 1) / 9998));
1423
+ const focal = fw + t * (ft - fw);
1424
+ const sh = AXIS_Q6135LE_SENSOR.height_mm;
1425
+ const sw = AXIS_Q6135LE_SENSOR.width_mm;
1426
+ const vfovRad = 2 * Math.atan(sh / (2 * focal));
1427
+ const aspect = sw / sh; // native PTZ camera aspect ratio
1428
+
1429
+ const camera = cam();
1430
+ // Projection-aware: POV works in BOTH perspective and orthographic.
1431
+ if (camera.isOrthographicCamera) {
1432
+ const dist = desc.frustumDistance || 8000;
1433
+ const halfH = dist * Math.tan(vfovRad / 2);
1434
+ const halfW = halfH * aspect;
1435
+ camera.left = -halfW;
1436
+ camera.right = halfW;
1437
+ camera.top = halfH;
1438
+ camera.bottom = -halfH;
1439
+ camera.zoom = 1;
1440
+ camera.near = 10;
1441
+ } else {
1442
+ camera.fov = vfovRad * 180 / Math.PI;
1443
+ camera.aspect = aspect;
1444
+ camera.near = 10;
1445
+ }
1446
+ camera.updateProjectionMatrix();
1447
+
1448
+ // Set camera matrix directly from R_full — no lookAt().
1449
+ // OpenCV: R columns = [right, down, forward]
1450
+ // Three.js camera: columns = [right, up, -forward] (looks down -Z)
1451
+ const m = new THREE.Matrix4();
1452
+ m.set(
1453
+ R[0][0], -R[0][1], -R[0][2], camPos[0],
1454
+ R[1][0], -R[1][1], -R[1][2], camPos[1],
1455
+ R[2][0], -R[2][1], -R[2][2], camPos[2],
1456
+ 0, 0, 0, 1
1457
+ );
1458
+ camera.matrix.copy(m);
1459
+ camera.matrixAutoUpdate = false;
1460
+ camera.updateMatrixWorld(true);
1461
+ }
1462
+
1463
+ let povAnimationId = null; // requestAnimationFrame ID for POV transition
1464
+ let povVisibilitySnapshot = null; // per-camera {shell,internals,frustum} visibility saved on POV enter
1465
+
1466
+ function enterPOV(idx) {
1467
+ const camera = cam();
1468
+ prevCameraState = {
1469
+ mode: projectionMode(), // restore the projection we came from
1470
+ position: camera.position.clone(),
1471
+ target: controls().target.clone(),
1472
+ up: camera.up.clone(),
1473
+ fov: camera.fov, // perspective only (undefined on ortho)
1474
+ aspect: camera.aspect, // perspective only
1475
+ zoom: camera.zoom, // orthographic (also exists on persp)
1476
+ near: camera.near,
1477
+ };
1478
+ // Orthographic scale is set by BOTH zoom AND the frustum bounds — applyPOV
1479
+ // overwrites the bounds, so we must capture them to restore on exit.
1480
+ if (camera.isOrthographicCamera) {
1481
+ prevCameraState.left = camera.left;
1482
+ prevCameraState.right = camera.right;
1483
+ prevCameraState.top = camera.top;
1484
+ prevCameraState.bottom = camera.bottom;
1485
+ }
1486
+ povCameraIdx = idx;
1487
+ controls().enabled = false;
1488
+
1489
+ // Looking through a camera, hide all camera hardware across every camera —
1490
+ // only the POV camera's own textured image plane stays. Snapshot prior
1491
+ // visibility so manual tree toggles are restored on exit.
1492
+ const perCameras = renderer.perCamera();
1493
+ const frustums = cameraFrustumGroups();
1494
+ povVisibilitySnapshot = perCameras.map((pc, i) => ({
1495
+ shell: pc.shell.visible,
1496
+ internals: pc.internals.visible,
1497
+ frustum: pc.frustum.visible,
1498
+ frustumChildren: frustums[i]
1499
+ ? frustums[i].children.map(c => c.visible) : null,
1500
+ }));
1501
+ perCameras.forEach((pc, i) => {
1502
+ pc.shell.visible = false;
1503
+ pc.internals.visible = false;
1504
+ if (i === idx) {
1505
+ pc.frustum.visible = true;
1506
+ frustums[i]?.children.forEach(child => {
1507
+ if (child.name === 'frustum_image_plane') renderer.refreshFrustumImageVisibility(child);
1508
+ else child.visible = false;
1509
+ });
1510
+ } else {
1511
+ pc.frustum.visible = false;
1512
+ }
1513
+ });
1514
+
1515
+ document.getElementById(`pov-btn-${idx}`)?.classList.add('active');
1516
+ animateToPOV(idx);
1517
+ }
1518
+
1519
+ function animateToPOV(idx) {
1520
+ if (povAnimationId) { cancelAnimationFrame(povAnimationId); povAnimationId = null; }
1521
+
1522
+ const desc = renderer.model.cameraAt(idx);
1523
+ const params = desc.params;
1524
+ const ptz = desc.ptz;
1525
+ const kin = computeKinematics(params, ptz.pan, ptz.tilt);
1526
+ const targetPos = new THREE.Vector3(...kin.cam_pos);
1527
+
1528
+ const R = kin.R_full;
1529
+ // Forward direction from R (OpenCV forward = column 2)
1530
+ const fwd = new THREE.Vector3(R[0][2], R[1][2], R[2][2]);
1531
+ const targetLookAt = targetPos.clone().add(fwd.multiplyScalar(1000));
1532
+
1533
+ // Target FOV / frustum extent
1534
+ const fw = params.focal_wide_mm || 4.3;
1535
+ const ft = params.focal_tele_mm || 137.6;
1536
+ const t = Math.max(0, Math.min(1, (ptz.zoom - 1) / 9998));
1537
+ const focal = fw + t * (ft - fw);
1538
+ const sh = AXIS_Q6135LE_SENSOR.height_mm;
1539
+ const sw = AXIS_Q6135LE_SENSOR.width_mm;
1540
+ const targetFov = 2 * Math.atan(sh / (2 * focal)) * 180 / Math.PI;
1541
+
1542
+ const camera = cam();
1543
+ const isOrtho = camera.isOrthographicCamera;
1544
+ const dist = desc.frustumDistance || 8000;
1545
+ const targetHalfH = dist * Math.tan((2 * Math.atan(sh / (2 * focal))) / 2);
1546
+ const targetHalfW = targetHalfH * (sw / sh);
1547
+
1548
+ // Start state
1549
+ const startPos = camera.position.clone();
1550
+ const startTarget = controls().target.clone();
1551
+ const startFov = camera.fov;
1552
+ const startTop = camera.top;
1553
+ const startBottom = camera.bottom;
1554
+ const startLeft = camera.left;
1555
+ const startRight = camera.right;
1556
+
1557
+ const duration = 1200; // ms
1558
+ const startTime = performance.now();
1559
+
1560
+ function tick(now) {
1561
+ const elapsed = now - startTime;
1562
+ const p = Math.min(1, elapsed / duration);
1563
+ const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
1564
+
1565
+ camera.position.lerpVectors(startPos, targetPos, ease);
1566
+ controls().target.lerpVectors(startTarget, targetLookAt, ease);
1567
+ if (isOrtho) {
1568
+ camera.top = startTop + (targetHalfH - startTop) * ease;
1569
+ camera.bottom = startBottom + (-targetHalfH - startBottom) * ease;
1570
+ camera.right = startRight + (targetHalfW - startRight) * ease;
1571
+ camera.left = startLeft + (-targetHalfW - startLeft) * ease;
1572
+ } else {
1573
+ camera.fov = startFov + (targetFov - startFov) * ease;
1574
+ }
1575
+ camera.updateProjectionMatrix();
1576
+ camera.lookAt(controls().target);
1577
+
1578
+ if (p < 1) {
1579
+ povAnimationId = requestAnimationFrame(tick);
1580
+ } else {
1581
+ povAnimationId = null;
1582
+ applyPOV(idx);
1583
+ }
1584
+ }
1585
+ povAnimationId = requestAnimationFrame(tick);
1586
+ }
1587
+
1588
+ function exitPOV() {
1589
+ if (povAnimationId) { cancelAnimationFrame(povAnimationId); povAnimationId = null; }
1590
+ if (prevCameraState) {
1591
+ // First return to the projection we entered POV from.
1592
+ if (prevCameraState.mode && prevCameraState.mode !== projectionMode()) {
1593
+ switchProjection(prevCameraState.mode);
1594
+ }
1595
+ const camera = cam();
1596
+ camera.matrixAutoUpdate = true;
1597
+ camera.position.copy(prevCameraState.position);
1598
+ controls().target.copy(prevCameraState.target);
1599
+ camera.up.copy(prevCameraState.up);
1600
+ camera.near = prevCameraState.near;
1601
+ if (camera.isOrthographicCamera) {
1602
+ if (prevCameraState.zoom !== undefined) camera.zoom = prevCameraState.zoom;
1603
+ if (prevCameraState.left !== undefined) {
1604
+ camera.left = prevCameraState.left;
1605
+ camera.right = prevCameraState.right;
1606
+ camera.top = prevCameraState.top;
1607
+ camera.bottom = prevCameraState.bottom;
1608
+ }
1609
+ } else {
1610
+ if (prevCameraState.fov !== undefined) camera.fov = prevCameraState.fov;
1611
+ if (prevCameraState.aspect !== undefined) camera.aspect = prevCameraState.aspect;
1612
+ }
1613
+ camera.updateProjectionMatrix();
1614
+ prevCameraState = null;
1615
+ } else {
1616
+ cam().matrixAutoUpdate = true;
1617
+ }
1618
+ // Restore every camera's hardware visibility to the snapshot taken on enter.
1619
+ const perCameras = renderer.perCamera();
1620
+ const frustums = cameraFrustumGroups();
1621
+ if (povVisibilitySnapshot) {
1622
+ perCameras.forEach((pc, i) => {
1623
+ const s = povVisibilitySnapshot[i];
1624
+ if (!s) return;
1625
+ pc.shell.visible = s.shell;
1626
+ pc.internals.visible = s.internals;
1627
+ pc.frustum.visible = s.frustum;
1628
+ if (s.frustumChildren && frustums[i]) {
1629
+ frustums[i].children.forEach((child, ci) => {
1630
+ if (s.frustumChildren[ci] !== undefined) child.visible = s.frustumChildren[ci];
1631
+ });
1632
+ }
1633
+ });
1634
+ povVisibilitySnapshot = null;
1635
+ }
1636
+ // The POV camera's image plane is governed by texture + intent, NOT the
1637
+ // pre-POV snapshot — an image may have loaded during POV (flow playing).
1638
+ if (povCameraIdx >= 0) renderer.refreshFrustumImageVisibility(cameraImagePlanes()[povCameraIdx]);
1639
+ document.getElementById(`pov-btn-${povCameraIdx}`)?.classList.remove('active');
1640
+ controls().enabled = true;
1641
+ controls().update();
1642
+ povCameraIdx = -1;
1643
+ }
1644
+
1645
+ function selectCamera(idx) {
1646
+ // Exit POV when switching cameras
1647
+ if (povCameraIdx >= 0 && povCameraIdx !== idx) exitPOV();
1648
+
1649
+ selectedCameraIdx = idx;
1650
+
1651
+ // Update camera tree highlights
1652
+ document.querySelectorAll('.cam-tree-header').forEach(el => {
1653
+ el.classList.toggle('selected', parseInt(el.dataset.idx) === idx);
1654
+ });
1655
+
1656
+ const rightSidebar = document.getElementById('right-sidebar');
1657
+ const detail = document.getElementById('camera-detail');
1658
+
1659
+ const cams = cameraEntries();
1660
+ if (idx < 0 || idx >= cams.length) {
1661
+ rightSidebar.classList.add('hidden');
1662
+ activePTZSliders = null;
1663
+ setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
1664
+ return;
1665
+ }
1666
+
1667
+ // Show right sidebar
1668
+ rightSidebar.classList.remove('hidden');
1669
+ document.getElementById('right-sidebar-title').textContent =
1670
+ cams[idx].label || cams[idx].id;
1671
+
1672
+ // Build camera edit panel
1673
+ detail.innerHTML = '';
1674
+ buildCameraEditPanel(idx, detail);
1675
+
1676
+ // Trigger canvas resize after sidebar shows
1677
+ setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
1678
+ }
1679
+
1680
+ // ═══════════════════════════════════════════════════════════════
1681
+ // Camera edit panel (PTZ sliders + kinematic params)
1682
+ // ═══════════════════════════════════════════════════════════════
1683
+
1684
+ function buildCameraEditPanel(idx, container) {
1685
+ const desc = renderer.model.cameraAt(idx);
1686
+ const params = desc.params;
1687
+ const ptz = desc.ptz;
1688
+
1689
+ // POV toggle button
1690
+ const povSection = mkEl('div', 'pov-section');
1691
+ const povBtn = document.createElement('button');
1692
+ povBtn.className = 'btn btn-pov' + (povCameraIdx === idx ? ' active' : '');
1693
+ povBtn.id = `pov-btn-${idx}`;
1694
+ povBtn.textContent = 'Camera POV';
1695
+ povBtn.addEventListener('click', () => {
1696
+ if (povCameraIdx === idx) {
1697
+ exitPOV();
1698
+ } else {
1699
+ if (povCameraIdx >= 0) exitPOV();
1700
+ enterPOV(idx);
1701
+ }
1702
+ });
1703
+ povSection.appendChild(povBtn);
1704
+ container.appendChild(povSection);
1705
+
1706
+ // PTZ sliders
1707
+ const ptzSection = mkEl('div', 'ptz-edit-section');
1708
+ const panSlider = createSlider('Pan', 'pan', ptz.pan, -180, 180, 0.5, '°', idx);
1709
+ const tiltSlider = createSlider('Tilt', 'tilt', ptz.tilt, -90, 20, 0.5, '°', idx);
1710
+ const zoomSlider = createSlider('Zoom', 'zoom', ptz.zoom, 1, 9999, 10, '', idx);
1711
+ ptzSection.appendChild(panSlider);
1712
+ ptzSection.appendChild(tiltSlider);
1713
+ ptzSection.appendChild(zoomSlider);
1714
+ container.appendChild(ptzSection);
1715
+
1716
+ // Store slider refs for flow step updates
1717
+ activePTZSliders = {
1718
+ pan: panSlider._sliderRef,
1719
+ tilt: tiltSlider._sliderRef,
1720
+ zoom: zoomSlider._sliderRef,
1721
+ };
1722
+
1723
+ // Kinematic parameter edit sections (matches PTZCamera.to_dict wire format)
1724
+ container.appendChild(paramEditSection('Mount (world)', 'Position (mm) [X, Y, Z]', 'mount_t', params.mount_t, 100, 0, idx));
1725
+ container.appendChild(paramEditSection(null, 'Rotation (deg) [Rx, Ry, Rz]', 'mount_r', params.mount_r, 0.01, 3, idx));
1726
+
1727
+ container.appendChild(paramEditSection('T_mount_to_pan', 'Offset (mm) [X, Y, Z]', 'pan_t', params.pan_t, 10, 0, idx));
1728
+ container.appendChild(paramEditSection(null, 'Rotation (deg) [Rx, Ry, Rz]', 'pan_r', params.pan_r, 0.01, 3, idx));
1729
+
1730
+ container.appendChild(paramEditSection('T_pan_to_tilt', 'Offset (mm) [X, Y, Z]', 'tilt_t', params.tilt_t, 10, 0, idx));
1731
+ container.appendChild(paramEditSection(null, 'Rotation (deg) [Rx, Ry, Rz]', 'tilt_r', params.tilt_r, 0.01, 3, idx));
1732
+
1733
+ container.appendChild(paramEditSection('T_tilt_to_sensor', 'Offset (mm) [X, Y, Z]', 'sensor_t', params.sensor_t, 10, 0, idx));
1734
+ container.appendChild(paramEditSection(null, 'Rotation (deg) [Rx, Ry, Rz]', 'sensor_r', params.sensor_r, 0.01, 3, idx));
1735
+
1736
+ // Lens offset
1737
+ container.appendChild(paramEditSection('Lens Offset', 'CX, CY (mm)', 'lens',
1738
+ [params.cx_offset_mm || 0, params.cy_offset_mm || 0], 0.001, 3, idx));
1739
+
1740
+ // Read-only optics
1741
+ const opticsDiv = mkEl('div', 'param-section');
1742
+ opticsDiv.innerHTML = `
1743
+ <div class="param-section-title">Optics</div>
1744
+ <div class="param-row"><span class="param-name">Focal Wide</span><span class="param-value">${fmtN(params.focal_wide_mm, 3)} mm</span></div>
1745
+ <div class="param-row"><span class="param-name">Focal Tele</span><span class="param-value">${fmtN(params.focal_tele_mm, 3)} mm</span></div>
1746
+ <div class="param-row"><span class="param-name">Image</span><span class="param-value">${params.image_width} × ${params.image_height} px</span></div>
1747
+ `;
1748
+ container.appendChild(opticsDiv);
1749
+ }
1750
+
1751
+ function createSlider(label, key, value, min, max, step, unit, camIdx) {
1752
+ const div = mkEl('div', 'slider-group');
1753
+
1754
+ const labelDiv = mkEl('div', 'slider-label');
1755
+ const nameSpan = mkEl('span', 'name');
1756
+ nameSpan.textContent = `${label}${unit ? ' (' + unit + ')' : ''}`;
1757
+ const valueSpan = mkEl('span', 'value');
1758
+ valueSpan.textContent = key === 'zoom' ? Math.round(value) : value.toFixed(1);
1759
+ labelDiv.appendChild(nameSpan);
1760
+ labelDiv.appendChild(valueSpan);
1761
+
1762
+ const input = document.createElement('input');
1763
+ input.type = 'range';
1764
+ input.min = min;
1765
+ input.max = max;
1766
+ input.step = step;
1767
+ input.value = value;
1768
+ input.addEventListener('input', () => {
1769
+ const v = parseFloat(input.value);
1770
+ renderer.model.cameraAt(camIdx).ptz[key] = v;
1771
+ valueSpan.textContent = key === 'zoom' ? Math.round(v) : v.toFixed(1);
1772
+ updateCameraVisuals(camIdx);
1773
+ });
1774
+
1775
+ div.appendChild(labelDiv);
1776
+ div.appendChild(input);
1777
+
1778
+ // Attach ref for external updates (flow step navigation)
1779
+ div._sliderRef = { input, valueSpan };
1780
+
1781
+ return div;
1782
+ }
1783
+
1784
+ function paramEditSection(title, label, paramKey, values, step, decimals, camIdx) {
1785
+ const section = mkEl('div', 'param-section');
1786
+ if (title) {
1787
+ const titleDiv = mkEl('div', 'param-section-title');
1788
+ titleDiv.textContent = title;
1789
+ section.appendChild(titleDiv);
1790
+ }
1791
+
1792
+ const row = mkEl('div', 'param-edit-row');
1793
+ const labelEl = mkEl('label', 'param-label-small');
1794
+ labelEl.textContent = label;
1795
+ row.appendChild(labelEl);
1796
+
1797
+ const vecDiv = mkEl('div', 'vector-input');
1798
+ values.forEach((v, i) => {
1799
+ const input = document.createElement('input');
1800
+ input.type = 'number';
1801
+ input.step = step;
1802
+ input.value = decimals > 0 ? v.toFixed(decimals) : Math.round(v);
1803
+ input.addEventListener('input', () => {
1804
+ const newVal = parseFloat(input.value) || 0;
1805
+ const params = renderer.model.cameraAt(camIdx).params;
1806
+ if (paramKey === 'lens') {
1807
+ if (i === 0) params.cx_offset_mm = newVal;
1808
+ else params.cy_offset_mm = newVal;
1809
+ } else {
1810
+ params[paramKey][i] = newVal;
1811
+ }
1812
+ updateCameraVisuals(camIdx);
1813
+ });
1814
+ vecDiv.appendChild(input);
1815
+ });
1816
+
1817
+ row.appendChild(vecDiv);
1818
+ section.appendChild(row);
1819
+ return section;
1820
+ }
1821
+
1822
+ // ═══════════════════════════════════════════════════════════════
1823
+ // Flows: select, deselect, step navigation
1824
+ // ═══════════════════════════════════════════════════════════════
1825
+
1826
+ function findCameraIdx(cameraId) {
1827
+ return renderer.findCameraIdx(cameraId);
1828
+ }
1829
+
1830
+ function selectFlow(name) {
1831
+ const flow = flowData()[name];
1832
+ if (!flow) return;
1833
+
1834
+ // Toggle off if clicking same flow
1835
+ if (activeFlowName === name) {
1836
+ deselectFlow();
1837
+ return;
1838
+ }
1839
+
1840
+ activeFlowName = name;
1841
+ activeFlowSteps = flow.steps;
1842
+ currentStepIndex = 0;
1843
+
1844
+ // Highlight active flow in tree
1845
+ document.querySelectorAll('.cam-tree-flow').forEach(el => {
1846
+ el.classList.toggle('active', el.dataset.flowName === name);
1847
+ });
1848
+
1849
+ // Select associated camera (opens right sidebar)
1850
+ const camIdx = findCameraIdx(flow.cameraId);
1851
+ if (camIdx >= 0 && camIdx !== selectedCameraIdx) {
1852
+ selectCamera(camIdx);
1853
+ }
1854
+
1855
+ // Update contour highlighting
1856
+ updateContourHighlights();
1857
+
1858
+ // Show flow step nav
1859
+ document.getElementById('flow-nav').classList.remove('hidden');
1860
+ document.getElementById('flow-nav-title').textContent = name;
1861
+
1862
+ // Apply first step
1863
+ applyFlowStep();
1864
+ }
1865
+
1866
+ function deselectFlow() {
1867
+ stopPlay();
1868
+ activeFlowName = null;
1869
+ activeFlowSteps = [];
1870
+ currentStepIndex = 0;
1871
+
1872
+ // Update contour highlighting (may still have playing flows)
1873
+ updateContourHighlights();
1874
+
1875
+ document.querySelectorAll('.cam-tree-flow').forEach(el => el.classList.remove('active'));
1876
+ document.getElementById('flow-nav').classList.add('hidden');
1877
+ currentImages = [];
1878
+ document.getElementById('image-panel').classList.add('hidden');
1879
+ cameraImagePlanes().forEach((_, idx) => clearFrustumImage(idx));
1880
+ closeLightbox();
1881
+ }
1882
+
1883
+ function applyFlowStep() {
1884
+ const step = activeFlowSteps[currentStepIndex];
1885
+ if (!step) return;
1886
+
1887
+ const flow = flowData()[activeFlowName];
1888
+ if (!flow) return;
1889
+
1890
+ const camIdx = findCameraIdx(flow.cameraId);
1891
+ if (camIdx < 0) return;
1892
+
1893
+ const desc = renderer.model.cameraAt(camIdx);
1894
+ // Update camera PTZ state from flow step
1895
+ if (step.pan != null) desc.ptz.pan = step.pan;
1896
+ if (step.tilt != null) desc.ptz.tilt = step.tilt;
1897
+ if (step.zoom != null) desc.ptz.zoom = step.zoom;
1898
+
1899
+ // Update frustum distance from step location if available
1900
+ if (step.location && step.location.x != null) {
1901
+ const params = desc.params;
1902
+ const dx = step.location.x - params.mount_t[0];
1903
+ const dy = step.location.y - params.mount_t[1];
1904
+ const dz = step.location.z - params.mount_t[2];
1905
+ desc.frustumDistance = Math.sqrt(dx * dx + dy * dy + dz * dz) * 0.95;
1906
+ }
1907
+
1908
+ // Update 3D visuals
1909
+ updateCameraVisuals(camIdx);
1910
+ renderer.updateContourProjections();
1911
+
1912
+ // Update PTZ slider DOM if this camera's panel is open
1913
+ if (camIdx === selectedCameraIdx && activePTZSliders) {
1914
+ const ptz = desc.ptz;
1915
+ for (const key of ['pan', 'tilt', 'zoom']) {
1916
+ const slider = activePTZSliders[key];
1917
+ if (slider) {
1918
+ slider.input.value = ptz[key];
1919
+ slider.valueSpan.textContent = key === 'zoom'
1920
+ ? Math.round(ptz[key])
1921
+ : ptz[key].toFixed(1);
1922
+ }
1923
+ }
1924
+ }
1925
+
1926
+ // Update step counter and info
1927
+ document.getElementById('step-counter').textContent =
1928
+ `Step ${currentStepIndex + 1} / ${activeFlowSteps.length}`;
1929
+ updateStepInfo();
1930
+
1931
+ // Update image panel
1932
+ currentImages = step.images || [];
1933
+ currentLaserMasks = step.laser_masks || [];
1934
+ currentLaserScores = step.laser_scores || [];
1935
+ currentProjectedContour = step.projected_contour || null;
1936
+ currentAlignedContours = step.aligned_contours || [];
1937
+ currentImageIdx = 0;
1938
+ updateImagePanel();
1939
+ }
1940
+
1941
+ // Load image as a safe (non-tainted) source for canvas/WebGL use.
1942
+ // On file://, XHR and fetch are blocked, so we fall back gracefully.
1943
+ const isFileProtocol = location.protocol === 'file:';
1944
+
1945
+ function loadSafeImage(url, callback) {
1946
+ // A missing overlay image shouldn't crash the viewer, but it must NOT be
1947
+ // silent — every failure path warns so a 404 / decode error is diagnosable.
1948
+ if (url.startsWith('data:') || url.startsWith('blob:')) {
1949
+ const img = new Image();
1950
+ img.onload = () => callback(img);
1951
+ img.onerror = () => console.warn(`[image] failed to decode data/blob URL: ${url.slice(0, 64)}…`);
1952
+ img.src = url;
1953
+ return;
1954
+ }
1955
+ if (isFileProtocol) {
1956
+ const img = new Image();
1957
+ img.onload = () => callback(img);
1958
+ img.onerror = () => console.warn(`[image] failed to load (file://, likely CORS-tainted): ${url}`);
1959
+ img.src = url;
1960
+ return;
1961
+ }
1962
+ const xhr = new XMLHttpRequest();
1963
+ xhr.open('GET', url, true);
1964
+ xhr.responseType = 'blob';
1965
+ xhr.onload = () => {
1966
+ if (xhr.status === 200) {
1967
+ const img = new Image();
1968
+ const objUrl = URL.createObjectURL(xhr.response);
1969
+ img.onload = () => { URL.revokeObjectURL(objUrl); callback(img); };
1970
+ img.onerror = () => { URL.revokeObjectURL(objUrl); console.warn(`[image] decoded-but-invalid blob for ${url}`); };
1971
+ img.src = objUrl;
1972
+ } else {
1973
+ console.warn(`[image] HTTP ${xhr.status} fetching ${url}`);
1974
+ }
1975
+ };
1976
+ xhr.onerror = () => console.warn(`[image] network error fetching ${url}`);
1977
+ xhr.send();
1978
+ }
1979
+
1980
+ // Set / clear the textured frustum image plane via the renderer (which owns the
1981
+ // THREE texture lifecycle). We load the image safely here, then hand the decoded
1982
+ // HTMLImageElement to the renderer.
1983
+ function setFrustumImage(camIdx, imageUrl) {
1984
+ const plane = cameraImagePlanes()[camIdx];
1985
+ if (!plane) return;
1986
+ loadSafeImage(imageUrl, (img) => {
1987
+ try {
1988
+ renderer.setFrustumImage(camIdx, img);
1989
+ } catch (e) {
1990
+ // file:// tainted image — no usable texture, so keep it hidden.
1991
+ plane.visible = false;
1992
+ }
1993
+ });
1994
+ }
1995
+
1996
+ function clearFrustumImage(camIdx) {
1997
+ renderer.clearFrustumImage(camIdx);
1998
+ }
1999
+
2000
+ function updateImagePanel() {
2001
+ const panel = document.getElementById('image-panel');
2002
+ const img = document.getElementById('step-image');
2003
+ const canvas = document.getElementById('step-image-canvas');
2004
+ const counter = document.getElementById('image-counter');
2005
+ const laserBtn = document.getElementById('btn-laser-overlay');
2006
+ const scoreBtn = document.getElementById('btn-score-overlay');
2007
+ const projBtn = document.getElementById('btn-projected-overlay');
2008
+ const alignBtn = document.getElementById('btn-aligned-overlay');
2009
+
2010
+ if (currentImages.length === 0) {
2011
+ panel.classList.add('hidden');
2012
+ const flow = activeFlowName ? flowData()[activeFlowName] : null;
2013
+ if (flow) clearFrustumImage(findCameraIdx(flow.cameraId));
2014
+ return;
2015
+ }
2016
+ panel.classList.remove('hidden');
2017
+
2018
+ // Show/hide overlay buttons based on data availability
2019
+ const hasLaserMask = currentLaserMasks.length > currentImageIdx && currentLaserMasks[currentImageIdx];
2020
+ const hasLaserScore = currentLaserScores.length > currentImageIdx && currentLaserScores[currentImageIdx];
2021
+ if (laserBtn) {
2022
+ laserBtn.style.display = currentLaserMasks.length > 0 ? '' : 'none';
2023
+ laserBtn.style.opacity = laserOverlayEnabled ? '1' : '0.5';
2024
+ }
2025
+ if (scoreBtn) {
2026
+ scoreBtn.style.display = currentLaserScores.length > 0 ? '' : 'none';
2027
+ scoreBtn.style.opacity = scoreOverlayEnabled ? '1' : '0.5';
2028
+ }
2029
+ if (projBtn) {
2030
+ projBtn.style.display = currentProjectedContour ? '' : 'none';
2031
+ projBtn.style.opacity = projectedOverlayEnabled ? '1' : '0.5';
2032
+ }
2033
+ if (alignBtn) {
2034
+ alignBtn.style.display = currentAlignedContours[currentImageIdx] ? '' : 'none';
2035
+ alignBtn.style.opacity = alignedOverlayEnabled ? '1' : '0.5';
2036
+ }
2037
+
2038
+ // Check if any contour layers are visible (for projection overlay)
2039
+ let hasVisibleContours = false;
2040
+ for (const [key, grp] of Object.entries(layerGroups())) {
2041
+ if (key.startsWith('contours') && grp.visible) { hasVisibleContours = true; break; }
2042
+ }
2043
+
2044
+ // Determine if we need canvas compositing
2045
+ const needsOverlay = (laserOverlayEnabled && hasLaserMask) ||
2046
+ (scoreOverlayEnabled && hasLaserScore) ||
2047
+ (projectedOverlayEnabled && currentProjectedContour) ||
2048
+ (alignedOverlayEnabled && currentAlignedContours[currentImageIdx]) ||
2049
+ hasVisibleContours;
2050
+
2051
+ if (needsOverlay) {
2052
+ compositeOverlays(
2053
+ currentImages[currentImageIdx],
2054
+ laserOverlayEnabled ? (currentLaserMasks[currentImageIdx] || null) : null,
2055
+ projectedOverlayEnabled ? currentProjectedContour : null,
2056
+ alignedOverlayEnabled ? currentAlignedContours[currentImageIdx] : null,
2057
+ canvas, img,
2058
+ scoreOverlayEnabled ? (currentLaserScores[currentImageIdx] || null) : null,
2059
+ );
2060
+ } else {
2061
+ canvas.style.display = 'none';
2062
+ img.style.display = '';
2063
+ img.src = currentImages[currentImageIdx];
2064
+ }
2065
+
2066
+ counter.textContent = `${currentImageIdx + 1} / ${currentImages.length}`;
2067
+ document.getElementById('btn-prev-img').disabled = currentImageIdx === 0;
2068
+ document.getElementById('btn-next-img').disabled = currentImageIdx === currentImages.length - 1;
2069
+
2070
+ // Update frustum image plane
2071
+ const flow = activeFlowName ? flowData()[activeFlowName] : null;
2072
+ if (flow) setFrustumImage(findCameraIdx(flow.cameraId), currentImages[currentImageIdx]);
2073
+
2074
+ // Sync lightbox if open
2075
+ updateLightbox();
2076
+ }
2077
+
2078
+ // Monotonic token guarding compositeOverlays against out-of-order async loads.
2079
+ let _compositeSeq = 0;
2080
+
2081
+ function compositeOverlays(baseUrl, laserMaskUrl, projUrl, alignUrl, canvas, imgEl, scoreUrl) {
2082
+ const mySeq = ++_compositeSeq;
2083
+
2084
+ // Collect all URLs to load
2085
+ const urls = { base: baseUrl };
2086
+ if (laserMaskUrl) urls.laser = laserMaskUrl;
2087
+ if (projUrl) urls.proj = projUrl;
2088
+ if (alignUrl) urls.align = alignUrl;
2089
+ if (scoreUrl) urls.score = scoreUrl;
2090
+
2091
+ const images = {};
2092
+ const total = Object.keys(urls).length;
2093
+ let loaded = 0;
2094
+
2095
+ function onAllLoaded() {
2096
+ const base = images.base;
2097
+ const w = base.naturalWidth;
2098
+ const h = base.naturalHeight;
2099
+ canvas.width = w;
2100
+ canvas.height = h;
2101
+ canvas.style.display = '';
2102
+ canvas.style.width = '100%';
2103
+ canvas.style.cursor = 'pointer';
2104
+ imgEl.style.display = 'none';
2105
+
2106
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
2107
+ ctx.drawImage(base, 0, 0, w, h);
2108
+ const od = ctx.getImageData(0, 0, w, h);
2109
+ const d = od.data;
2110
+
2111
+ // Helper: read overlay as grayscale
2112
+ function readOverlay(img) {
2113
+ const c = document.createElement('canvas');
2114
+ c.width = w; c.height = h;
2115
+ const cx = c.getContext('2d', { willReadFrequently: true });
2116
+ cx.drawImage(img, 0, 0, w, h);
2117
+ return cx.getImageData(0, 0, w, h).data;
2118
+ }
2119
+
2120
+ // Laser mask: yellow tint
2121
+ if (images.laser) {
2122
+ const md = readOverlay(images.laser);
2123
+ for (let i = 0; i < md.length; i += 4) {
2124
+ const brightness = (md[i] + md[i+1] + md[i+2]) / 3;
2125
+ if (brightness > 30) {
2126
+ const a = Math.min(brightness / 255, 0.7);
2127
+ d[i] = Math.min(255, d[i] + 200 * a | 0);
2128
+ d[i + 1] = Math.min(255, d[i + 1] + 200 * a | 0);
2129
+ d[i + 2] = Math.max(0, d[i + 2] - 50 * a | 0);
2130
+ }
2131
+ }
2132
+ }
2133
+
2134
+ // Laser score: cyan/magenta heatmap blend (proportional to brightness)
2135
+ if (images.score) {
2136
+ const sd = readOverlay(images.score);
2137
+ for (let i = 0; i < sd.length; i += 4) {
2138
+ const brightness = (sd[i] + sd[i+1] + sd[i+2]) / 3;
2139
+ if (brightness > 30) {
2140
+ const a = Math.min(brightness / 255, 0.7);
2141
+ d[i] = Math.max(0, d[i] - 50 * a | 0);
2142
+ d[i + 1] = Math.min(255, d[i + 1] + 150 * a | 0);
2143
+ d[i + 2] = Math.min(255, d[i + 2] + 200 * a | 0);
2144
+ }
2145
+ }
2146
+ }
2147
+
2148
+ // Projected contour: red overlay
2149
+ if (images.proj) {
2150
+ const pd = readOverlay(images.proj);
2151
+ for (let i = 0; i < pd.length; i += 4) {
2152
+ if (pd[i] + pd[i+1] + pd[i+2] > 30) {
2153
+ d[i] = 255; d[i+1] = 50; d[i+2] = 50;
2154
+ }
2155
+ }
2156
+ }
2157
+
2158
+ // Aligned contour: yellow, dilated 4x for thickness
2159
+ if (images.align) {
2160
+ const ad = readOverlay(images.align);
2161
+ const radius = 4;
2162
+ const contourPixels = [];
2163
+ for (let y = 0; y < h; y++) {
2164
+ for (let x = 0; x < w; x++) {
2165
+ const i = (y * w + x) * 4;
2166
+ if (ad[i] + ad[i+1] + ad[i+2] > 30) {
2167
+ contourPixels.push(x, y);
2168
+ }
2169
+ }
2170
+ }
2171
+ for (let k = 0; k < contourPixels.length; k += 2) {
2172
+ const cx = contourPixels[k], cy = contourPixels[k+1];
2173
+ for (let dy = -radius; dy <= radius; dy++) {
2174
+ const py = cy + dy;
2175
+ if (py < 0 || py >= h) continue;
2176
+ for (let dx = -radius; dx <= radius; dx++) {
2177
+ const px = cx + dx;
2178
+ if (px < 0 || px >= w) continue;
2179
+ const i = (py * w + px) * 4;
2180
+ d[i] = 255; d[i+1] = 220; d[i+2] = 0;
2181
+ }
2182
+ }
2183
+ }
2184
+ }
2185
+
2186
+ ctx.putImageData(od, 0, 0);
2187
+
2188
+ // Draw contour projections as 2D lines on top.
2189
+ const flow = activeFlowName ? flowData()[activeFlowName] : null;
2190
+ if (flow) {
2191
+ const camIdx = findCameraIdx(flow.cameraId);
2192
+ renderer.drawContourProjectionsOnCanvas(ctx, w, h, camIdx);
2193
+ }
2194
+ }
2195
+
2196
+ function tryComposite() {
2197
+ if (mySeq !== _compositeSeq) return;
2198
+ try { onAllLoaded(); } catch (e) {
2199
+ canvas.style.display = 'none';
2200
+ imgEl.style.display = '';
2201
+ imgEl.src = baseUrl;
2202
+ }
2203
+ }
2204
+
2205
+ for (const [key, url] of Object.entries(urls)) {
2206
+ loadSafeImage(url, (img) => {
2207
+ images[key] = img;
2208
+ if (++loaded === total) tryComposite();
2209
+ });
2210
+ }
2211
+ }
2212
+
2213
+ function nextImage() {
2214
+ if (currentImageIdx < currentImages.length - 1) {
2215
+ currentImageIdx++;
2216
+ updateImagePanel();
2217
+ }
2218
+ }
2219
+
2220
+ function prevImage() {
2221
+ if (currentImageIdx > 0) {
2222
+ currentImageIdx--;
2223
+ updateImagePanel();
2224
+ }
2225
+ }
2226
+
2227
+ function nextStep() {
2228
+ if (!activeFlowSteps.length) return;
2229
+ currentStepIndex = (currentStepIndex + 1) % activeFlowSteps.length;
2230
+ applyFlowStep();
2231
+ }
2232
+
2233
+ function prevStep() {
2234
+ if (!activeFlowSteps.length) return;
2235
+ currentStepIndex = (currentStepIndex - 1 + activeFlowSteps.length) % activeFlowSteps.length;
2236
+ applyFlowStep();
2237
+ }
2238
+
2239
+ // Collect all laser groups that should be highlighted (playing + selected)
2240
+ // and update the contour display.
2241
+ function updateContourHighlights() {
2242
+ const groups = new Set();
2243
+ // Add groups from all playing flows
2244
+ for (const flowName of Object.keys(playingFlows)) {
2245
+ const lg = flowData()[flowName]?.laserGroup;
2246
+ if (lg) groups.add(lg);
2247
+ }
2248
+ // Add group from selected flow
2249
+ if (activeFlowName) {
2250
+ const lg = flowData()[activeFlowName]?.laserGroup;
2251
+ if (lg) groups.add(lg);
2252
+ }
2253
+ renderer.updateContourHighlights(groups);
2254
+ }
2255
+
2256
+ // Apply a flow step for any flow (not just the active one).
2257
+ function applyFlowStepFor(flowName, stepIndex) {
2258
+ const flow = flowData()[flowName];
2259
+ if (!flow) return;
2260
+ const step = flow.steps[stepIndex];
2261
+ if (!step) return;
2262
+
2263
+ const camIdx = findCameraIdx(flow.cameraId);
2264
+ if (camIdx < 0) return;
2265
+
2266
+ const desc = renderer.model.cameraAt(camIdx);
2267
+ if (step.pan != null) desc.ptz.pan = step.pan;
2268
+ if (step.tilt != null) desc.ptz.tilt = step.tilt;
2269
+ if (step.zoom != null) desc.ptz.zoom = step.zoom;
2270
+
2271
+ if (step.location && step.location.x != null) {
2272
+ const params = desc.params;
2273
+ const dx = step.location.x - params.mount_t[0];
2274
+ const dy = step.location.y - params.mount_t[1];
2275
+ const dz = step.location.z - params.mount_t[2];
2276
+ desc.frustumDistance = Math.sqrt(dx * dx + dy * dy + dz * dz) * 0.95;
2277
+ }
2278
+
2279
+ updateCameraVisuals(camIdx);
2280
+
2281
+ // Always update frustum image for the playing flow's camera
2282
+ const stepImages = step.images || [];
2283
+ if (stepImages.length > 0) {
2284
+ setFrustumImage(camIdx, stepImages[0]);
2285
+ } else {
2286
+ clearFrustumImage(camIdx);
2287
+ }
2288
+
2289
+ // If this is also the active flow, update the UI panels
2290
+ if (flowName === activeFlowName) {
2291
+ currentStepIndex = stepIndex;
2292
+ if (camIdx === selectedCameraIdx && activePTZSliders) {
2293
+ const ptz = desc.ptz;
2294
+ for (const key of ['pan', 'tilt', 'zoom']) {
2295
+ const slider = activePTZSliders[key];
2296
+ if (slider) {
2297
+ slider.input.value = ptz[key];
2298
+ slider.valueSpan.textContent = key === 'zoom'
2299
+ ? Math.round(ptz[key])
2300
+ : ptz[key].toFixed(1);
2301
+ }
2302
+ }
2303
+ }
2304
+ document.getElementById('step-counter').textContent =
2305
+ `Step ${stepIndex + 1} / ${flow.steps.length}`;
2306
+ updateStepInfo();
2307
+ currentImages = stepImages;
2308
+ currentLaserMasks = step.laser_masks || [];
2309
+ currentLaserScores = step.laser_scores || [];
2310
+ currentProjectedContour = step.projected_contour || null;
2311
+ currentAlignedContours = step.aligned_contours || [];
2312
+ currentImageIdx = 0;
2313
+ updateImagePanel();
2314
+ }
2315
+ }
2316
+
2317
+ function toggleFlowPlay(flowName, btn) {
2318
+ if (playingFlows[flowName]) {
2319
+ clearInterval(playingFlows[flowName].intervalId);
2320
+ delete playingFlows[flowName];
2321
+ if (btn) btn.innerHTML = '&#9654;';
2322
+ // Update global button if this is the active flow
2323
+ if (flowName === activeFlowName) {
2324
+ const gBtn = document.getElementById('btn-play-stop');
2325
+ if (gBtn) gBtn.innerHTML = '&#9654; Play';
2326
+ }
2327
+ } else {
2328
+ const flow = flowData()[flowName];
2329
+ if (!flow || !flow.steps.length) return;
2330
+ let stepIdx = flowName === activeFlowName ? currentStepIndex : 0;
2331
+ playingFlows[flowName] = {
2332
+ intervalId: setInterval(() => {
2333
+ stepIdx = (stepIdx + 1) % flow.steps.length;
2334
+ playingFlows[flowName].stepIndex = stepIdx;
2335
+ applyFlowStepFor(flowName, stepIdx);
2336
+ }, 500),
2337
+ stepIndex: stepIdx,
2338
+ };
2339
+ if (btn) btn.innerHTML = '&#9632;';
2340
+ if (flowName === activeFlowName) {
2341
+ const gBtn = document.getElementById('btn-play-stop');
2342
+ if (gBtn) gBtn.innerHTML = '&#9632; Stop';
2343
+ }
2344
+ }
2345
+ updateContourHighlights();
2346
+ }
2347
+
2348
+ function togglePlay() {
2349
+ if (!activeFlowName) return;
2350
+ const treeBtn = document.querySelector(`.cam-tree-flow[data-flow-name="${activeFlowName}"] .flow-play-btn`);
2351
+ toggleFlowPlay(activeFlowName, treeBtn);
2352
+ }
2353
+
2354
+ function stopPlay() {
2355
+ if (!activeFlowName) return;
2356
+ if (playingFlows[activeFlowName]) {
2357
+ const treeBtn = document.querySelector(`.cam-tree-flow[data-flow-name="${activeFlowName}"] .flow-play-btn`);
2358
+ toggleFlowPlay(activeFlowName, treeBtn);
2359
+ }
2360
+ }
2361
+
2362
+ function updateStepInfo() {
2363
+ const container = document.getElementById('step-info');
2364
+ const step = activeFlowSteps[currentStepIndex];
2365
+ if (!step) { container.innerHTML = ''; return; }
2366
+
2367
+ let html = '<div class="step-info-grid">';
2368
+ if (step.pan != null) html += `<div class="si-row"><span class="si-label">Pan</span><span class="si-value">${step.pan.toFixed(2)}°</span></div>`;
2369
+ if (step.tilt != null) html += `<div class="si-row"><span class="si-label">Tilt</span><span class="si-value">${step.tilt.toFixed(2)}°</span></div>`;
2370
+ if (step.zoom != null) html += `<div class="si-row"><span class="si-label">Zoom</span><span class="si-value">${Math.round(step.zoom)}</span></div>`;
2371
+
2372
+ if (step.location && step.location.x != null) {
2373
+ const l = step.location;
2374
+ html += `<div class="si-row"><span class="si-label">Target</span><span class="si-value">${l.x.toFixed(0)}, ${l.y.toFixed(0)}, ${l.z.toFixed(0)}</span></div>`;
2375
+ }
2376
+
2377
+ if (step.cornerInfo && step.cornerInfo.isCorner) {
2378
+ html += `<div class="si-row"><span class="si-label">Corner</span><span class="si-value">${step.cornerInfo.cornerType || 'Yes'}</span></div>`;
2379
+ }
2380
+
2381
+ html += '</div>';
2382
+ container.innerHTML = html;
2383
+ }
2384
+
2385
+ // ═══════════════════════════════════════════════════════════════
2386
+ // Defect info panel (right sidebar)
2387
+ // ═══════════════════════════════════════════════════════════════
2388
+
2389
+ function showDefectInfo(defect) {
2390
+ const rightSidebar = document.getElementById('right-sidebar');
2391
+ const detail = document.getElementById('camera-detail');
2392
+ const flowNav = document.getElementById('flow-nav');
2393
+
2394
+ selectedCameraIdx = -1;
2395
+ document.querySelectorAll('.cam-tree-header').forEach(el => el.classList.remove('selected'));
2396
+
2397
+ rightSidebar.classList.remove('hidden');
2398
+ document.getElementById('right-sidebar-title').textContent = defect.name || defect.id;
2399
+ flowNav.classList.add('hidden');
2400
+ activePTZSliders = null;
2401
+
2402
+ const sev = (defect.severity || 'unknown');
2403
+ const SCOLORS = { critical: '#f44', high: '#f88', medium: '#fa4', low: '#ff4', unknown: '#888' };
2404
+ const sevColor = SCOLORS[sev.toLowerCase()] || SCOLORS.unknown;
2405
+
2406
+ detail.innerHTML = `
2407
+ <div class="param-section">
2408
+ <div class="param-section-title">Defect</div>
2409
+ <div class="param-row"><span class="param-name">Type</span><span class="param-value">${defect.type || '—'}</span></div>
2410
+ <div class="param-row"><span class="param-name">Severity</span><span class="param-value" style="color:${sevColor};font-weight:600">${sev}</span></div>
2411
+ ${defect.confidence != null ? `<div class="param-row"><span class="param-name">Confidence</span><span class="param-value">${(defect.confidence * 100).toFixed(1)}%</span></div>` : ''}
2412
+ <div class="param-section-title" style="margin-top:8px">Position (mm)</div>
2413
+ <div class="param-row"><span class="param-name">X</span><span class="param-value">${defect.x.toFixed(0)}</span></div>
2414
+ <div class="param-row"><span class="param-name">Y</span><span class="param-value">${defect.y.toFixed(0)}</span></div>
2415
+ <div class="param-row"><span class="param-name">Z</span><span class="param-value">${defect.z.toFixed(0)}</span></div>
2416
+ </div>
2417
+ `;
2418
+
2419
+ setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
2420
+ }
2421
+
2422
+ // ═══════════════════════════════════════════════════════════════
2423
+ // Canvas click: camera + defect picking
2424
+ // ═══════════════════════════════════════════════════════════════
2425
+
2426
+ function onCanvasClick(event) {
2427
+ const canvas = event.target;
2428
+ const rect = canvas.getBoundingClientRect();
2429
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
2430
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
2431
+
2432
+ raycaster.setFromCamera(mouse, cam());
2433
+
2434
+ // Check cameras — raycast shell + internals (avoids false hits on frustum/image plane)
2435
+ const perCameraList = renderer.perCamera();
2436
+ const rayTargets = [];
2437
+ for (const pc of perCameraList) {
2438
+ rayTargets.push(...pc.shell.children, ...pc.internals.children);
2439
+ }
2440
+ if (rayTargets.length > 0) {
2441
+ const intersects = raycaster.intersectObjects(rayTargets, true);
2442
+ if (intersects.length > 0) {
2443
+ let obj = intersects[0].object;
2444
+ while (obj && obj.userData.cameraIdx === undefined) obj = obj.parent;
2445
+ if (obj && obj.userData.cameraIdx !== undefined) {
2446
+ selectCamera(obj.userData.cameraIdx);
2447
+ return;
2448
+ }
2449
+ }
2450
+ }
2451
+
2452
+ // Check defects
2453
+ const defectGroup = layerGroups()['defects'];
2454
+ if (defectGroup) {
2455
+ const intersects = raycaster.intersectObjects(defectGroup.children, false);
2456
+ if (intersects.length > 0) {
2457
+ const obj = intersects[0].object;
2458
+ const idx = obj.userData.defectIdx;
2459
+ if (idx !== undefined && defectEntries()[idx]) {
2460
+ showDefectInfo(defectEntries()[idx]);
2461
+ }
2462
+ }
2463
+ }
2464
+ }
2465
+
2466
+ // ═══════════════════════════════════════════════════════════════
2467
+ // Lightbox: larger bottom panel on image click
2468
+ // ═══════════════════════════════════════════════════════════════
2469
+
2470
+ function toggleLightbox() {
2471
+ if (currentImages.length === 0) return;
2472
+ if (lightboxOpen) closeLightbox();
2473
+ else openLightbox();
2474
+ }
2475
+
2476
+ function openLightbox() {
2477
+ lightboxOpen = true;
2478
+ document.getElementById('image-lightbox').classList.remove('hidden');
2479
+ updateLightbox();
2480
+ window.dispatchEvent(new Event('resize'));
2481
+ }
2482
+
2483
+ function closeLightbox() {
2484
+ if (!lightboxOpen) return;
2485
+ lightboxOpen = false;
2486
+ document.getElementById('image-lightbox').classList.add('hidden');
2487
+ window.dispatchEvent(new Event('resize'));
2488
+ }
2489
+
2490
+ function updateLightbox() {
2491
+ if (!lightboxOpen || currentImages.length === 0) return;
2492
+ const lbImg = document.getElementById('lightbox-img');
2493
+ const lbCanvas = document.getElementById('lightbox-canvas');
2494
+ const hasLaserMask = currentLaserMasks.length > currentImageIdx && currentLaserMasks[currentImageIdx];
2495
+ const hasLaserScore = currentLaserScores.length > currentImageIdx && currentLaserScores[currentImageIdx];
2496
+
2497
+ let hasVisibleContoursLb = false;
2498
+ for (const [key, grp] of Object.entries(layerGroups())) {
2499
+ if (key.startsWith('contours') && grp.visible) { hasVisibleContoursLb = true; break; }
2500
+ }
2501
+
2502
+ const needsOverlay = (laserOverlayEnabled && hasLaserMask) ||
2503
+ (scoreOverlayEnabled && hasLaserScore) ||
2504
+ (projectedOverlayEnabled && currentProjectedContour) ||
2505
+ (alignedOverlayEnabled && currentAlignedContours[currentImageIdx]) ||
2506
+ hasVisibleContoursLb;
2507
+
2508
+ if (needsOverlay) {
2509
+ compositeOverlays(
2510
+ currentImages[currentImageIdx],
2511
+ laserOverlayEnabled ? (currentLaserMasks[currentImageIdx] || null) : null,
2512
+ projectedOverlayEnabled ? currentProjectedContour : null,
2513
+ alignedOverlayEnabled ? currentAlignedContours[currentImageIdx] : null,
2514
+ lbCanvas, lbImg,
2515
+ scoreOverlayEnabled ? (currentLaserScores[currentImageIdx] || null) : null,
2516
+ );
2517
+ lbCanvas.style.objectFit = 'contain';
2518
+ lbCanvas.style.maxWidth = '100%';
2519
+ lbCanvas.style.maxHeight = '100%';
2520
+ } else {
2521
+ lbCanvas.style.display = 'none';
2522
+ lbImg.style.display = '';
2523
+ lbImg.src = currentImages[currentImageIdx];
2524
+ }
2525
+ document.getElementById('lightbox-counter').textContent =
2526
+ `${currentImageIdx + 1} / ${currentImages.length}`;
2527
+ document.getElementById('lightbox-prev').disabled = currentImageIdx === 0;
2528
+ document.getElementById('lightbox-next').disabled = currentImageIdx === currentImages.length - 1;
2529
+ }
2530
+
2531
+ function setupLightboxResize() {
2532
+ const handle = document.getElementById('lightbox-resize-handle');
2533
+ const lightbox = document.getElementById('image-lightbox');
2534
+ let dragging = false, startY = 0, startH = 0;
2535
+ handle.addEventListener('mousedown', (e) => {
2536
+ dragging = true;
2537
+ startY = e.clientY;
2538
+ startH = lightbox.offsetHeight;
2539
+ e.preventDefault();
2540
+ });
2541
+ on( document, 'mousemove', (e) => {
2542
+ if (!dragging) return;
2543
+ const delta = startY - e.clientY; // drag up = taller
2544
+ lightbox.style.height = Math.max(120, Math.min(700, startH + delta)) + 'px';
2545
+ window.dispatchEvent(new Event('resize'));
2546
+ });
2547
+ on( document, 'mouseup', () => { dragging = false; });
2548
+ }
2549
+
2550
+ // ═══════════════════════════════════════════════════════════════
2551
+ // Utility
2552
+ // ═══════════════════════════════════════════════════════════════
2553
+
2554
+ function mkEl(tag, cls) {
2555
+ const e = document.createElement(tag);
2556
+ if (cls) e.className = cls;
2557
+ return e;
2558
+ }
2559
+
2560
+ function fmtN(v, d) {
2561
+ if (v === null || v === undefined) return '—';
2562
+ return Number(v).toFixed(d);
2563
+ }
2564
+
2565
+ // ═══════════════════════════════════════════════════════════════
2566
+ // Mount API (library entry)
2567
+ // ═══════════════════════════════════════════════════════════════
2568
+ //
2569
+ // The viewer is consumed two ways:
2570
+ // - Standalone (/viz/, dev server, Python wheel): src/standalone.js reads the
2571
+ // URL into opts and calls mountViewer(document.body, opts).
2572
+ // - Embedded (Imaginarium React <ShellVisualizer>): the host calls
2573
+ // mountViewer(divRef, { shellId, configId }) and destroy() on unmount.
2574
+ //
2575
+ // mountViewer injects the DOM skeleton into the container (so the existing
2576
+ // getElementById wiring works), runs init() against `_opts`, and returns a
2577
+ // handle. destroy() stops every loop/interval/listener and frees the GPU so a
2578
+ // React route can mount/unmount it repeatedly without leaks or double-handling.
2579
+
2580
+ export async function mountViewer( container, opts = {} ) {
2581
+ if ( _alive ) { destroy(); } // idempotent re-mount
2582
+ _container = container || document.body;
2583
+ if ( !_container.querySelector( '#app' ) ) { _container.innerHTML = VIEWER_SKELETON; }
2584
+ _opts = opts || {};
2585
+ _alive = true;
2586
+ try {
2587
+ await init();
2588
+ } catch ( err ) {
2589
+ console.error( 'Viewer init failed:', err );
2590
+ const el = document.getElementById( 'loading' );
2591
+ if ( el ) { el.textContent = `Init failed: ${err.message}`; }
2592
+ throw err;
2593
+ }
2594
+ return { destroy };
2595
+ }
2596
+
2597
+ export function destroy() {
2598
+ if ( !_alive && !renderer ) { return; }
2599
+ _alive = false; // the rAF loops bail on their next tick
2600
+ while ( _teardown.length ) { _teardown.pop()(); } // remove window/document listeners
2601
+ for ( const f of Object.keys( playingFlows ) ) { // stop flow-playback intervals
2602
+ clearInterval( playingFlows[f].intervalId );
2603
+ delete playingFlows[f];
2604
+ }
2605
+ if ( povAnimationId ) { cancelAnimationFrame( povAnimationId ); povAnimationId = null; }
2606
+ if ( _viewFlyId ) { cancelAnimationFrame( _viewFlyId ); _viewFlyId = null; }
2607
+ if ( renderer ) { renderer.dispose(); }
2608
+ renderer = null;
2609
+ sceneData = null;
2610
+ raycaster = null;
2611
+ mouse = null;
2612
+ selectedCameraIdx = -1;
2613
+ povCameraIdx = -1;
2614
+ activeFlowName = null;
2615
+ if ( _container ) { _container.innerHTML = ''; _container = null; }
2616
+ }