@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/README.md +62 -0
- package/package.json +30 -0
- package/src/main.js +2616 -0
- package/src/skeleton.js +99 -0
- package/src/standalone.js +18 -0
- package/src/styles/scene_viewer.css +926 -0
- package/src/styles/viewer_common.css +818 -0
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 = '×';
|
|
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 = '+';
|
|
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 = '▶';
|
|
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 = '▶';
|
|
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 = '▶ 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 = '■';
|
|
2340
|
+
if (flowName === activeFlowName) {
|
|
2341
|
+
const gBtn = document.getElementById('btn-play-stop');
|
|
2342
|
+
if (gBtn) gBtn.innerHTML = '■ 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
|
+
}
|