Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://poppyup/viewer/app.js

viewer/app.js

31 KB · 409 lines · javascript · Download from the repository page

viewer/app.js
import * as THREE from 'three';import { OrbitControls } from './vendor/three/OrbitControls.js';import { COMPONENTS, MODEL, DEFINITIONS, ASSEMBLY_ORDER } from './components.js';import { buildScene, toScene, disposeScene } from './geometry.js';import { explodedOffset } from './explode.js'; // ---------------------------------------------------------------- model indexconst byId = new Map(COMPONENTS.map((c) => [c.id, c]));const childrenOf = new Map();for (const c of COMPONENTS) { if (!childrenOf.has(c.parent)) childrenOf.set(c.parent, []); childrenOf.get(c.parent).push(c.id); }const ancestors = (id) => { const out = []; let p = byId.get(id)?.parent; while (p) { out.push(p); p = byId.get(p).parent; } return out; };const descendants = (id) => { const out = []; const stack = [...(childrenOf.get(id) || [])]; while (stack.length) { const n = stack.pop(); out.push(n); stack.push(...(childrenOf.get(n) || [])); } return out; };const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch])); // ---------------------------------------------------------------- stateconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;const defaultState = () => ({  selected: null, hovered: null, hidden: new Set(), isolated: null,  explode: 0, scope: null, expandChildren: false, animating: 0 /* 0 | +1 | -1 */,  labelMode: 'major', xrayLabels: false, showRef: true, sku: 'pro',  wire: false, ghost: false, section: { enabled: false, axis: 'z', pos: 0.5, flip: false },});let S = defaultState(); // ---------------------------------------------------------------- DOMconst $ = (id) => document.getElementById(id);const canvas = $('viewport'), labelsEl = $('labels'), treeEl = $('tree'), detailsEl = $('details');$('rev').textContent = MODEL.revision; $('status').textContent = MODEL.status; $('status').title = MODEL.status;$('explodeNote').textContent = ASSEMBLY_ORDER; $('explodeNote').title = ASSEMBLY_ORDER; // ---------------------------------------------------------------- rendererlet renderer;try {  renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });} catch (e) { showFallback(e); throw e; }renderer.setPixelRatio(Math.min(devicePixelRatio, 2));renderer.setClearColor(0xeef0f3);renderer.localClippingEnabled = false;const scene = new THREE.Scene();const camera = new THREE.PerspectiveCamera(38, 1, 1, 5000);scene.add(new THREE.HemisphereLight(0xffffff, 0x8a8f96, 1.1));const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(180, 220, 260); scene.add(key);const fill = new THREE.DirectionalLight(0xffffff, 0.6); fill.position.set(-200, 80, -160); scene.add(fill);const controls = new OrbitControls(camera, canvas);controls.enableDamping = !reducedMotion; controls.dampingFactor = 0.12; controls.screenSpacePanning = true;controls.addEventListener('change', requestRender); const { root, nodes, meshes } = buildScene(COMPONENTS);scene.add(root);for (const m of meshes) m.geometry.computeBoundingBox();const meshOf = new Map(); for (const m of meshes) { if (!meshOf.has(m.userData.componentId)) meshOf.set(m.userData.componentId, []); meshOf.get(m.userData.componentId).push(m); }const meshesUnder = (id) => [id, ...descendants(id)].flatMap((i) => meshOf.get(i) || []); // Selection highlight: cloned material with emissive + edge outline (cue beyond colour).const hiMat = new Map();const highlightMaterialFor = (base) => { if (!hiMat.has(base)) { const m = base.clone(); m.emissive = new THREE.Color(0x9c2f22); m.emissiveIntensity = 0.55; m.color.lerp(new THREE.Color(0xffffff), 0.25); hiMat.set(base, m); } return hiMat.get(base); };const ghostMat = new THREE.MeshStandardMaterial({ color: 0xa0a6ad, transparent: true, opacity: 0.12, depthWrite: false, side: THREE.DoubleSide });const edgeMat = new THREE.LineBasicMaterial({ color: 0xc0392b });const wireMat = new THREE.LineBasicMaterial({ color: 0x2b2f36, transparent: true, opacity: 0.35 });const edgesCache = new Map();const edgesFor = (mesh, mat) => { const k = mesh.uuid + mat.uuid; if (!edgesCache.has(k)) { const l = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 20), mat); l.raycast = () => {}; l.userData.helper = true; edgesCache.set(k, l); mesh.add(l); } return edgesCache.get(k); };for (const m of meshes) { m.userData.baseMaterial = m.material; edgesFor(m, wireMat).visible = false; } // Section plane (global clipping, uncapped).const secPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);const envelopeBox = new THREE.Box3(toScene(0, 96, 40), toScene(190, -4, -1)); // ---------------------------------------------------------------- visibility & transformsfunction skuOk(c) { return !c.sku || c.sku === 'both' || c.sku === S.sku; }function compVisible(id) {  const c = byId.get(id);  if (!skuOk(c)) return false;  if (!S.showRef && c.kind === 'reference') return false;  if (S.hidden.has(id) || ancestors(id).some((a) => S.hidden.has(a))) return false;  if (S.isolated) { const set = new Set([S.isolated, ...descendants(S.isolated)]); if (!set.has(id)) return false; }  return true;}function applyVisibility() {  for (const c of COMPONENTS) { const n = nodes.get(c.id); const v = compVisible(c.id); for (const m of meshOf.get(c.id) || []) m.visible = v; n.userData.visible = v; }}function applyExplode() {  const opts = { scope: S.scope, expandChildren: S.expandChildren };  for (const c of COMPONENTS) {    const n = nodes.get(c.id), a = n.userData.assembledPosition, o = explodedOffset(c, S.explode, opts);    n.position.set(a.x + o[0], a.y + o[1], a.z + o[2]);  }  root.updateMatrixWorld(true);}function applyMaterials() {  const selSet = S.selected ? new Set([S.selected, ...descendants(S.selected)]) : null;  for (const m of meshes) {    const id = m.userData.componentId, base = m.userData.baseMaterial, isSel = selSet && selSet.has(id);    m.material = isSel ? highlightMaterialFor(base) : (S.ghost && selSet ? ghostMat : base);    const sel = edgesCache.get(m.uuid + edgeMat.uuid); if (sel) sel.visible = !!isSel; else if (isSel) edgesFor(m, edgeMat);    edgesFor(m, wireMat).visible = S.wire;  }}function applySection() {  if (!S.section.enabled) { renderer.clippingPlanes = []; return; }  const ax = S.section.axis; const n = new THREE.Vector3(ax === 'x' ? 1 : 0, ax === 'y' ? 1 : 0, ax === 'z' ? 1 : 0);  const min = envelopeBox.min[ax], max = envelopeBox.max[ax]; const d = min + (max - min) * S.section.pos;  if (S.section.flip) { secPlane.normal.copy(n); secPlane.constant = -d; } else { secPlane.normal.copy(n).negate(); secPlane.constant = d; }  renderer.clippingPlanes = [secPlane];}function applyAll() { applyVisibility(); applyExplode(); applyMaterials(); applySection(); syncControlsUI(); requestRender(); } function visibleBounds(ids) {  const box = new THREE.Box3();  const list = ids ? ids.flatMap((i) => meshesUnder(i)) : meshes;  for (const m of list) { if (!m.visible) continue; const b = m.geometry.boundingBox.clone().applyMatrix4(m.matrixWorld); box.union(b); }  return box.isEmpty() ? envelopeBox.clone() : box;} // ---------------------------------------------------------------- cameralet camAnim = null;function fitBox(box, dir, animate = true) {  const sphere = box.getBoundingSphere(new THREE.Sphere());  const r = Math.max(sphere.radius, 5);  const dist = r / Math.sin(THREE.MathUtils.degToRad(camera.fov) / 2) * 1.12;  const d = (dir || camera.position.clone().sub(controls.target)).normalize();  const toPos = sphere.center.clone().add(d.multiplyScalar(dist)), toTgt = sphere.center.clone();  camera.near = Math.max(0.5, dist / 200); camera.far = dist * 20 + 2000; camera.updateProjectionMatrix();  if (!animate || reducedMotion) { camera.position.copy(toPos); controls.target.copy(toTgt); controls.update(); requestRender(); return; }  camAnim = { p0: camera.position.clone(), t0: controls.target.clone(), p1: toPos, t1: toTgt, start: performance.now(), dur: 380 };  requestRender();}const VIEW_DIRS = { front: [0, 0, 1], rear: [0, 0, -1], left: [-1, 0, 0], right: [1, 0, 0], top: [0, 1, 0], bottom: [0, -1, 0], iso: [1, 0.75, 1.15] };function setView(name, ids) { const v = VIEW_DIRS[name] || VIEW_DIRS.iso; if (name === 'top' || name === 'bottom') camera.up.set(0, 0, name === 'top' ? -1 : 1); else camera.up.set(0, 1, 0); fitBox(visibleBounds(ids), new THREE.Vector3(...v)); }function fitAll() { fitBox(visibleBounds()); }function focusOn(id) { if (!compVisible(id) && !descendants(id).some(compVisible)) return; fitBox(visibleBounds([id])); }function resetView() { camera.up.set(0, 1, 0); setView('iso'); } // ---------------------------------------------------------------- selection / actionsfunction select(id, opts = {}) {  S.selected = id && byId.has(id) ? id : null;  applyMaterials(); renderDetails(); syncTreeSelection(); requestRender();  if (opts.focus && S.selected) focusOn(S.selected);}function isolate(id) { S.isolated = id; applyAll(); if (id) fitBox(visibleBounds([id])); syncTree(); }function exitIsolation() { S.isolated = null; applyAll(); fitAll(); syncTree(); }function hide(id) { S.hidden.add(id); applyAll(); syncTree(); renderDetails(); }function unhide(id) { S.hidden.delete(id); for (const a of ancestors(id)) S.hidden.delete(a); applyAll(); syncTree(); renderDetails(); }function showAll() { S.hidden.clear(); applyAll(); syncTree(); renderDetails(); }function selectParent() { const p = S.selected && byId.get(S.selected).parent; if (p) select(p); }function setExplode(t, { fromSlider = false } = {}) { S.explode = Math.min(1, Math.max(0, t)); if (!fromSlider) $('explodeAmt').value = S.explode; $('explodeVal').textContent = Math.round(S.explode * 100) + '%'; applyExplode(); requestRender(); }function setScope(id) { S.scope = id || null; $('scope').value = S.scope || ''; applyExplode(); requestRender(); }function resetViewer() { const keep = S; S = defaultState(); S.animating = 0; $('explodeAmt').value = 0; $('explodeVal').textContent = '0%'; $('scope').value = ''; $('sku').value = S.sku; $('labelMode').value = S.labelMode; $('xrayLabels').checked = false; $('expandSub').checked = false; $('showRef').checked = true; $('secPos').value = 0.5; $('secAxis').value = 'z'; applyAll(); syncTree(); renderDetails(); resetView(); void keep; } // ---------------------------------------------------------------- treeconst treeItems = new Map();function buildTree() {  const mk = (ids) => { const ul = document.createElement('ul'); ul.setAttribute('role', ids.some((i) => byId.get(i).parent === null) ? 'tree' : 'group'); for (const id of ids) ul.appendChild(mkItem(id)); return ul; };  const mkItem = (id) => {    const c = byId.get(id), kids = childrenOf.get(id) || [], li = document.createElement('li');    li.setAttribute('role', 'treeitem'); li.dataset.id = id; li.tabIndex = -1; li.setAttribute('aria-selected', 'false');    if (kids.length) li.setAttribute('aria-expanded', c.parent === null ? 'false' : 'false');    const row = document.createElement('div'); row.className = 'row';    const tw = document.createElement('span'); tw.className = 'tw'; tw.textContent = kids.length ? '▸' : '·'; tw.setAttribute('aria-hidden', 'true');    const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = c.name; nm.title = c.name;    row.append(tw, nm);    if (c.kind === 'instance') { const t = document.createElement('span'); t.className = 'tag'; t.textContent = 'inst'; t.title = 'Instance of ' + DEFINITIONS[c.def].name; row.appendChild(t); }    if (c.kind === 'reference') { const t = document.createElement('span'); t.className = 'tag'; t.textContent = 'ref'; row.appendChild(t); }    if (c.sku === 'pro') { const t = document.createElement('span'); t.className = 'tag'; t.textContent = 'Pro'; row.appendChild(t); }    li.appendChild(row);    if (kids.length) li.appendChild(mk(kids));    row.addEventListener('click', (e) => { if (e.target === tw && kids.length) { toggle(li); } else { select(id); li.focus(); } });    row.addEventListener('dblclick', () => focusOn(id));    treeItems.set(id, li); return li;  };  const toggle = (li, force) => { const open = force ?? li.getAttribute('aria-expanded') !== 'true'; li.setAttribute('aria-expanded', open ? 'true' : 'false'); li.querySelector('.tw').textContent = open ? '▾' : '▸'; };  treeEl.innerHTML = ''; treeEl.appendChild(mk(childrenOf.get(null)));  treeEl.querySelector('[role=treeitem]').tabIndex = 0;  treeEl.addEventListener('keydown', onTreeKey);  treeEl._toggle = toggle;}function visibleTreeItems() { return [...treeEl.querySelectorAll('[role=treeitem]')].filter((li) => li.offsetParent !== null && !li.hidden); }function onTreeKey(e) {  const li = e.target.closest('[role=treeitem]'); if (!li) return; const id = li.dataset.id; const items = visibleTreeItems(); const i = items.indexOf(li);  const focus = (el) => { if (!el) return; items.forEach((x) => (x.tabIndex = -1)); el.tabIndex = 0; el.focus(); };  switch (e.key) {    case 'ArrowDown': e.preventDefault(); focus(items[i + 1]); break;    case 'ArrowUp': e.preventDefault(); focus(items[i - 1]); break;    case 'ArrowRight': e.preventDefault(); if (li.getAttribute('aria-expanded') === 'false') treeEl._toggle(li, true); else focus(li.querySelector('[role=treeitem]')); break;    case 'ArrowLeft': e.preventDefault(); if (li.getAttribute('aria-expanded') === 'true') treeEl._toggle(li, false); else focus(li.parentElement.closest('[role=treeitem]')); break;    case 'Enter': case ' ': e.preventDefault(); select(id); break;    case 'f': case 'F': e.preventDefault(); select(id); focusOn(id); break;    case 'i': case 'I': e.preventDefault(); select(id); isolate(id); break;    case 'h': case 'H': e.preventDefault(); hide(id); break;    case 'Home': e.preventDefault(); focus(items[0]); break;    case 'End': e.preventDefault(); focus(items[items.length - 1]); break;  }}function syncTreeSelection() {  for (const [id, li] of treeItems) li.setAttribute('aria-selected', id === S.selected ? 'true' : 'false');  if (S.selected) { for (const a of ancestors(S.selected)) treeEl._toggle(treeItems.get(a), true); const li = treeItems.get(S.selected); li.scrollIntoView({ block: 'nearest' }); }}function syncTree() {  for (const [id, li] of treeItems) { const c = byId.get(id), row = li.querySelector('.row'); row.classList.toggle('hidden-comp', S.hidden.has(id) || ancestors(id).some((a) => S.hidden.has(a))); row.classList.toggle('sku-off', !skuOk(c)); }}function applySearch(q) {  q = q.trim().toLowerCase();  for (const [, li] of treeItems) { li.hidden = false; li.querySelector('.nm').classList.remove('match'); }  if (!q) return;  const matches = new Set();  for (const c of COMPONENTS) { const hay = [c.name, c.id, c.partNumber, c.function, c.description, c.material, c.def && DEFINITIONS[c.def]?.partNumber].filter(Boolean).join(' ').toLowerCase(); if (hay.includes(q)) matches.add(c.id); }  const keep = new Set(); for (const m of matches) { keep.add(m); ancestors(m).forEach((a) => keep.add(a)); }  for (const [id, li] of treeItems) { li.hidden = !keep.has(id); if (matches.has(id)) li.querySelector('.nm').classList.add('match'); if (keep.has(id) && (childrenOf.get(id) || []).length) treeEl._toggle(li, true); }} // ---------------------------------------------------------------- detailsfunction renderDetails() {  const id = S.selected; if (!id) { detailsEl.innerHTML = '<div class="empty">Select a component in the scene or the tree.</div>'; return; }  const c = byId.get(id), def = c.def ? DEFINITIONS[c.def] : null, kids = childrenOf.get(id) || [];  const hiddenNow = S.hidden.has(id) || ancestors(id).some((a) => S.hidden.has(a));  const crumbs = ancestors(id).reverse().map((a) => `<a data-sel="${esc(a)}">${esc(byId.get(a).name)}</a>`).join(' › ');  const conf = c.confidence || def?.confidence || 'unknown';  const siblings = c.def ? COMPONENTS.filter((x) => x.def === c.def) : [];  const row = (k, v) => (v ? `<tr><th>${k}</th><td>${v}</td></tr>` : '');  detailsEl.innerHTML = `    <div class="crumbs">${crumbs || 'Top level'}</div>    <h3>${esc(c.name)}</h3>    <div class="id">${esc(c.id)} · ${esc(c.kind)}${def ? ` of <b>${esc(def.name)}</b> (×${def.qty})` : ''}${hiddenNow ? ' · <b>hidden</b>' : ''}${!skuOk(c) ? ` · not on ${esc(S.sku)} SKU` : ''}</div>    <div class="actions">      <button data-act="focus">Focus</button><button data-act="isolate">Isolate</button>      ${hiddenNow ? '<button data-act="unhide">Show</button>' : '<button data-act="hide">Hide</button>'}      ${c.parent ? '<button data-act="parent">Parent</button>' : ''}      ${kids.length ? '<button data-act="scope">Explode this only</button>' : ''}      ${S.isolated ? '<button data-act="exitIso">Exit isolation</button>' : ''}    </div>    <table>      ${row('Function', esc(c.function))}      ${row('Description', esc(c.description))}      ${row('Part number', esc(c.partNumber || def?.partNumber))}      ${row('Material', esc(c.material || def?.material))}      ${row('Dimensions', esc(c.dims || def?.dims))}      ${row('SKU', esc(c.sku && c.sku !== 'both' ? c.sku.toUpperCase() + ' only' : 'Base and Pro'))}      ${kids.length ? row('Children', kids.length + ' (' + kids.filter(compVisible).length + ' visible)') : ''}      ${siblings.length > 1 ? row('Instances', siblings.map((s) => `<a data-sel="${esc(s.id)}" style="cursor:pointer;color:var(--focus)">${esc(s.name)}</a>`).join('<br>')) : ''}      ${row('Explosion offset', c.explode ? esc(c.explode.join(', ')) + ' mm (X, Y↓, Z→rear) — illustrative' : '')}      ${row('Source', esc(c.source || def?.source))}      <tr><th>Confidence</th><td><span class="conf ${esc(conf)}">${esc(conf)}</span></td></tr>    </table>`;  detailsEl.querySelectorAll('[data-sel]').forEach((a) => a.addEventListener('click', () => select(a.dataset.sel)));  detailsEl.querySelector('.actions').addEventListener('click', (e) => {    const act = e.target.dataset.act; if (!act) return;    ({ focus: () => focusOn(id), isolate: () => isolate(id), hide: () => hide(id), unhide: () => unhide(id), parent: selectParent, scope: () => setScope(id), exitIso: exitIsolation })[act]();  });} // ---------------------------------------------------------------- labelsconst raycaster = new THREE.Raycaster();const labelPool = [];// Anchor policy: the point of the component's current visible bounds nearest the camera (so exploded and// isolated parts anchor on their facing surface), pulled 0.5 mm toward the camera for the occlusion test.function labelAnchor(id) {  const c = byId.get(id);  if (c.anchor && S.explode === 0 && !S.isolated) return toScene(...c.anchor);  const b = visibleBounds([id]); const p = camera.position.clone().clamp(b.min, b.max);  return p.add(camera.position.clone().sub(p).normalize().multiplyScalar(0.5));}const labelStats = { candidates: 0, occluded: 0, decluttered: 0, shown: 0 };function labelCandidates() {  if (S.labelMode === 'none') return [];  const vis = (id) => compVisible(id) || descendants(id).some(compVisible);  const far = camera.position.distanceTo(controls.target) > 900;  let ids;  if (S.labelMode === 'selected') ids = [];  else if (S.labelMode === 'major') ids = COMPONENTS.filter((c) => c.major && (far ? c.parent === null : c.kind !== 'assembly' || !(childrenOf.get(c.id) || []).some((k) => byId.get(k).major))).map((c) => c.id);  else ids = COMPONENTS.filter((c) => far ? c.parent === null : c.kind !== 'assembly').map((c) => c.id);  ids = ids.filter(vis);  const pri = new Map(ids.map((i) => [i, byId.get(i).major ? 1 : 0]));  if (S.hovered && vis(S.hovered)) pri.set(S.hovered, 2);  if (S.selected && vis(S.selected)) pri.set(S.selected, 3);  return [...pri.entries()].sort((a, b) => b[1] - a[1]).map(([i]) => i);}function updateLabels() {  const w = canvas.clientWidth, h = canvas.clientHeight, rects = []; let used = 0;  const cands = labelCandidates().slice(0, 80);  labelStats.candidates = cands.length; labelStats.occluded = 0; labelStats.decluttered = 0;  const visibleMeshes = meshes.filter((m) => m.visible);  for (const id of cands) {    const p = labelAnchor(id), v = p.clone().project(camera);    if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) continue;    // occlusion: first hit along the camera ray must belong to this component (or a descendant)    const dirv = p.clone().sub(camera.position); const dist = dirv.length(); raycaster.set(camera.position, dirv.normalize());    const hits = raycaster.intersectObjects(visibleMeshes, false);    const mine = new Set([id, ...descendants(id)]);    const occluded = hits.length > 0 && hits[0].distance < dist - 0.6 && !mine.has(hits[0].object.userData.componentId);    if (occluded) labelStats.occluded++;    if (occluded && !S.xrayLabels && id !== S.selected) continue;    const x = (v.x + 1) / 2 * w, y = (1 - v.y) / 2 * h - 10;    const text = byId.get(id).name; const lw = Math.min(260, text.length * 6.2 + 14), lh = 20;    const r = { x0: x - lw / 2, x1: x + lw / 2, y0: y - lh, y1: y };    if (id !== S.selected && rects.some((q) => !(r.x1 < q.x0 || r.x0 > q.x1 || r.y1 < q.y0 || r.y0 > q.y1))) { labelStats.decluttered++; continue; }    rects.push(r);    const el = labelPool[used] || (labelPool[used] = Object.assign(document.createElement('div'), { className: 'label' }));    if (!el.parentNode) labelsEl.appendChild(el);    el.textContent = text; el.className = 'label' + (id === S.selected ? ' sel' : '') + (occluded ? ' occluded' : '');    el.style.left = Math.max(lw / 2, Math.min(w - lw / 2, x)) + 'px'; el.style.top = Math.max(lh, Math.min(h - 4, y)) + 'px';    el.dataset.id = id; used++;  }  for (let i = used; i < labelPool.length; i++) if (labelPool[i].parentNode) labelPool[i].remove();  labelStats.shown = used;} // ---------------------------------------------------------------- picking (click vs drag)let down = null;canvas.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY, b: e.button, t: performance.now() }; });canvas.addEventListener('pointerup', (e) => {  if (!down || down.b !== 0) { down = null; return; }  const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y); down = null; if (moved > 5) return;  const hit = pick(e); select(hit ? hit.userData.componentId : S.selected);});let hoverT = 0;canvas.addEventListener('pointermove', (e) => { const now = performance.now(); if (now - hoverT < 60) return; hoverT = now; const hit = pick(e); const id = hit ? hit.userData.componentId : null; if (id !== S.hovered) { S.hovered = id; canvas.style.cursor = id ? 'pointer' : ''; requestRender(); } });function pick(e) {  const r = canvas.getBoundingClientRect(); const nd = new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);  raycaster.setFromCamera(nd, camera);  const hits = raycaster.intersectObjects(meshes.filter((m) => m.visible), false);  if (S.section.enabled) { const ok = hits.find((h) => secPlane.distanceToPoint(h.point) >= -0.01); return ok ? ok.object : null; }  return hits[0]?.object || null;} // ---------------------------------------------------------------- UI wiringdocument.querySelectorAll('[data-view]').forEach((b) => b.addEventListener('click', () => setView(b.dataset.view)));$('fitAll').onclick = fitAll; $('resetView').onclick = resetView; $('resetViewer').onclick = resetViewer;$('showAll').onclick = showAll; $('exitIso').onclick = exitIsolation; $('selParent').onclick = selectParent;$('modeWire').onclick = () => { S.wire = !S.wire; applyMaterials(); syncControlsUI(); requestRender(); };$('modeGhost').onclick = () => { S.ghost = !S.ghost; applyMaterials(); syncControlsUI(); requestRender(); };$('modeSection').onclick = () => { S.section.enabled = !S.section.enabled; applySection(); syncControlsUI(); requestRender(); };$('secAxis').onchange = (e) => { S.section.axis = e.target.value; applySection(); requestRender(); };$('secPos').oninput = (e) => { S.section.pos = +e.target.value; applySection(); requestRender(); };$('secFlip').onclick = () => { S.section.flip = !S.section.flip; applySection(); syncControlsUI(); requestRender(); };$('secReset').onclick = () => { S.section.pos = 0.5; S.section.flip = false; S.section.axis = 'z'; $('secPos').value = 0.5; $('secAxis').value = 'z'; applySection(); syncControlsUI(); requestRender(); };$('explodeAmt').oninput = (e) => { S.animating = 0; setExplode(+e.target.value, { fromSlider: true }); };$('explodeGo').onclick = () => { S.animating = reducedMotion ? 0 : 1; if (reducedMotion) setExplode(1); requestRender(); };$('explodeBack').onclick = () => { S.animating = reducedMotion ? 0 : -1; if (reducedMotion) setExplode(0); requestRender(); };$('explodePause').onclick = () => { S.animating = 0; };$('scope').onchange = (e) => setScope(e.target.value || null);$('sku').onchange = (e) => { S.sku = e.target.value; applyAll(); syncTree(); renderDetails(); };$('labelMode').onchange = (e) => { S.labelMode = e.target.value; requestRender(); };$('xrayLabels').onchange = (e) => { S.xrayLabels = e.target.checked; requestRender(); };$('expandSub').onchange = (e) => { S.expandChildren = e.target.checked; applyExplode(); requestRender(); };$('showRef').onchange = (e) => { S.showRef = e.target.checked; applyAll(); };$('search').addEventListener('input', (e) => applySearch(e.target.value));for (const c of COMPONENTS) if ((childrenOf.get(c.id) || []).length) { const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; $('scope').appendChild(o); }function syncControlsUI() { $('modeWire').setAttribute('aria-pressed', S.wire); $('modeGhost').setAttribute('aria-pressed', S.ghost); $('modeSection').setAttribute('aria-pressed', S.section.enabled); $('sectionCtl').classList.toggle('on', S.section.enabled); $('secFlip').setAttribute('aria-pressed', S.section.flip); }window.addEventListener('keydown', (e) => {  if (e.target.matches('input, select, textarea') || e.target.closest('.tree')) return;  switch (e.key) {    case 'f': case 'F': fitAll(); break;    case 'Home': resetView(); break;    case 'Escape': if (S.selected) select(null); else if (S.isolated) exitIsolation(); break;    case 'i': case 'I': if (S.selected) isolate(S.selected); break;    case 'h': case 'H': if (S.selected) hide(S.selected); break;    case 'Backspace': if (S.selected) { e.preventDefault(); selectParent(); } break;  }});canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); $('ctxlost').classList.remove('hidden'); });canvas.addEventListener('webglcontextrestored', () => { $('ctxlost').classList.add('hidden'); requestRender(); }); // ---------------------------------------------------------------- render loop (on demand)let dirty = true, lastT = performance.now();function requestRender() { dirty = true; }function resize() { const w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h) return; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); requestRender(); }new ResizeObserver(resize).observe(canvas.parentElement);function frame(now) {  requestAnimationFrame(frame);  const dt = Math.min(0.05, (now - lastT) / 1000); lastT = now;  if (S.animating) { const v = S.explode + S.animating * dt * 0.7; if (v >= 1 || v <= 0) S.animating = 0; setExplode(v); dirty = true; }  if (camAnim) { const k = Math.min(1, (now - camAnim.start) / camAnim.dur), e = 1 - Math.pow(1 - k, 3); camera.position.lerpVectors(camAnim.p0, camAnim.p1, e); controls.target.lerpVectors(camAnim.t0, camAnim.t1, e); if (k >= 1) camAnim = null; dirty = true; }  if (controls.enableDamping) { const c0 = camera.position.clone(); controls.update(); if (c0.distanceToSquared(camera.position) > 1e-6) dirty = true; } else controls.update();  if (!dirty) return;  dirty = false;  root.updateMatrixWorld(true);  renderer.render(scene, camera);  updateLabels();} // ---------------------------------------------------------------- fallbackfunction showFallback(err) {  $('app').hidden = true; const fb = $('fallback'); fb.hidden = false;  const rows = COMPONENTS.map((c) => `<tr><td>${esc(c.id)}</td><td>${'—'.repeat(ancestors(c.id).length)} ${esc(c.name)}</td><td>${esc(c.partNumber || (c.def && DEFINITIONS[c.def].partNumber) || '')}</td><td>${esc(c.material || '')}</td><td>${esc(c.dims || '')}</td><td>${esc(c.confidence || '')}</td></tr>`).join('');  $('fallbackTable').innerHTML = `<p><b>${esc(MODEL.title)}</b> — ${esc(MODEL.revision)}</p><p>${esc(MODEL.status)}</p><table><thead><tr><th>ID</th><th>Component</th><th>Part number</th><th>Material</th><th>Dimensions</th><th>Confidence</th></tr></thead><tbody>${rows}</tbody></table><p style="color:#888">${esc(String(err))}</p>`;} // ---------------------------------------------------------------- bootbuildTree(); applyAll(); resize(); resetView(); requestAnimationFrame(frame);$('loading').classList.add('hidden'); // Test / automation API (no secrets, read-mostly). Used by test/run.mjs.window.__poppyViewer = {  ready: true, componentCount: COMPONENTS.length,  state: () => ({ selected: S.selected, hidden: [...S.hidden], isolated: S.isolated, explode: S.explode, scope: S.scope, expandChildren: S.expandChildren, labelMode: S.labelMode, sku: S.sku, section: { ...S.section }, wire: S.wire, ghost: S.ghost }),  select, isolate, exitIsolation, hide, unhide, showAll, selectParent, fitAll, resetView, resetViewer, setView,  setExplode: (t) => { S.animating = 0; setExplode(t); }, setScope, setExpand: (b) => { S.expandChildren = b; $('expandSub').checked = b; applyExplode(); requestRender(); },  setLabelMode: (m) => { S.labelMode = m; $('labelMode').value = m; requestRender(); }, setXray: (b) => { S.xrayLabels = b; requestRender(); },  setSection: (o) => { Object.assign(S.section, o); $('secAxis').value = S.section.axis; $('secPos').value = S.section.pos; applySection(); syncControlsUI(); requestRender(); }, setSku: (s) => { S.sku = s; $('sku').value = s; applyAll(); syncTree(); renderDetails(); },  setGhost: (b) => { S.ghost = b; applyMaterials(); requestRender(); }, setWire: (b) => { S.wire = b; applyMaterials(); requestRender(); },  search: (q) => { $('search').value = q; applySearch(q); }, treeVisibleIds: () => visibleTreeItems().map((li) => li.dataset.id),  worldPosition: (id) => { root.updateMatrixWorld(true); return nodes.get(id).getWorldPosition(new THREE.Vector3()).toArray(); },  localPosition: (id) => nodes.get(id).position.toArray(), assembledPosition: (id) => nodes.get(id).userData.assembledPosition.toArray(),  isVisible: (id) => compVisible(id), bounds: () => { const b = visibleBounds(); return { min: b.min.toArray(), max: b.max.toArray() }; },  renderOnce: () => { root.updateMatrixWorld(true); renderer.render(scene, camera); updateLabels(); }, labelStats: () => ({ ...labelStats }), labelsShown: () => [...labelsEl.querySelectorAll('.label')].map((l) => l.dataset.id),  treeSelected: () => treeEl.querySelector('[aria-selected="true"]')?.dataset.id || null, detailsTitle: () => detailsEl.querySelector('h3')?.textContent || null,  cameraInfo: () => ({ pos: camera.position.toArray(), target: controls.target.toArray(), near: camera.near, far: camera.far }), rendererInfo: () => ({ calls: renderer.info.render.calls, triangles: renderer.info.render.triangles, geometries: renderer.info.memory.geometries }),  pickAt: (x, y) => { const o = pick({ clientX: x, clientY: y }); return o ? o.userData.componentId : null; }, dispose: () => disposeScene(root),};