← Commit history

Add hover tooltips and a floating draggable Layers panel to the interactive viewer. Tooltips follow the upstream viewer's tooltip contract and styling, and report datasheet-backed or live-measured values per mesh: FOV cone 2-theta-half 130 degrees, solder fillet radius and height, pad dimensions, carrier-tape and reel specs including pocket Ao Bo Ko, board thickness, and LED electricals. The Layers panel now floats under the legend and can be dragged without orbiting the camera.

AravK ·1d72f22b4d ·3mo ago ·parent 4a6af18
2 files changed +720−10
IN-S42ATR-viewer.html+360−5
@@ -32,15 +32,44 @@     border-radius:16px; font-size:12px; cursor:pointer; font-family:inherit; box-shadow:0 2px 8px #0006; }   .btn:hover{ border-color:var(--accent); }   .btn.on{ background:rgba(0,184,176,0.16); border-color:var(--accent); color:var(--accent); }-  #layer-menu{ position:fixed; bottom:56px; left:50%; transform:translateX(-50%); z-index:10; background:var(--panel);-    border:1px solid var(--border); border-radius:10px; padding:10px 14px; min-width:190px; display:none; box-shadow:0 2px 10px #0007; }+  /* Floating, draggable Layers panel. Positioned in JS (left/top, px) — default sits+     directly under the #legend window, top-right. Dragged by its title bar. */+  #layer-menu{ position:fixed; top:0; left:0; z-index:14; background:var(--panel);+    border:1px solid var(--border); border-radius:10px; padding:0 0 8px; min-width:200px; display:none;+    box-shadow:0 4px 16px #0008; user-select:none; -webkit-user-select:none; }   #layer-menu.open{ display:block; }-  #layer-menu h2{ font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:#8b949e; margin:0 0 8px; font-weight:600; }+  #layer-menu.dragging{ box-shadow:0 8px 26px #000b; border-color:var(--accent); }+  #layer-menu h2{ font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:#8b949e; margin:0;+    font-weight:600; padding:7px 12px 6px; border-bottom:1px solid var(--border); cursor:grab;+    display:flex; align-items:center; gap:7px; touch-action:none; }+  #layer-menu.dragging h2{ cursor:grabbing; color:var(--accent); }+  #layer-menu h2 .grip{ font-size:11px; letter-spacing:0; color:#586069; }+  #layer-list{ padding:6px 14px 0; }   #layer-menu label{ display:flex; align-items:center; gap:8px; padding:4px 0;     font:12px 'JetBrains Mono',ui-monospace,monospace; cursor:pointer; }   #layer-menu label.disabled{ opacity:.4; cursor:not-allowed; }   #layer-menu input{ accent-color:var(--accent); }   #layer-menu .grp{ margin-top:6px; border-top:1px solid var(--border); padding-top:6px; }++  /* Hover tooltip — same look/placement/behaviour as Colby's ViewCube tooltip+     (ThreeDViewer.svelte .vcube-tooltip: rgba(30,34,38,.92), 12px system font, 5px 10px,+     radius 4, nowrap, anchored at pointer + (-12,+12) with translateX(-100%)).+     We keep his shell and add title / key-value rows / source line inside it. */+  #hover-tip{ position:fixed; pointer-events:none; transform:translateX(-100%);+    background:rgba(30,34,38,0.92); color:#e0e0e0;+    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; font-size:12px; line-height:1;+    padding:5px 10px; border-radius:4px; white-space:nowrap; z-index:20; display:none; max-width:none; }+  #hover-tip.show{ display:block; }+  #hover-tip.flip{ transform:translateX(0); }         /* pointer near the left edge — open rightwards */+  #hover-tip .t{ font-weight:600; color:#fff; }+  #hover-tip .s{ color:#9fb4c8; font-size:11px; margin-left:6px; }+  #hover-tip .r{ display:flex; gap:14px; justify-content:space-between; margin-top:3px;+    font:11px/1.35 'JetBrains Mono',ui-monospace,monospace; }+  #hover-tip .r i{ color:#8b949e; font-style:normal; }+  #hover-tip .r b{ color:#e6edf3; font-weight:500; }+  #hover-tip .src{ margin-top:5px; padding-top:4px; border-top:1px solid rgba(255,255,255,.14);+    color:#7d8590; font-size:10px; letter-spacing:.01em; }+  #hover-tip .rows{ margin-top:4px; }   #status{ position:fixed; top:52px; left:16px; z-index:10; color:#a9b6c2;     font:11px 'JetBrains Mono',ui-monospace,monospace; text-shadow:0 1px 3px #0009; } </style>@@ -59,7 +88,8 @@   <div class="row"><span class="sw" style="background:#bd964e"></span> Carrier tape (to datasheet)</div>   <div class="row"><span class="sw" style="background:#d8d0c4"></span> LED in tape (real model)</div> </div>-<div id="layer-menu"><h2>Layers</h2><div id="layer-list"></div></div>+<div id="layer-menu"><h2 id="layer-drag"><span class="grip">⠿</span>Layers</h2><div id="layer-list"></div></div>+<div id="hover-tip"></div> <div class="btn-row">   <button class="btn on" id="btn-ground">Ground</button>   <button class="btn" id="btn-bottom">Bottom light</button>@@ -358,6 +388,327 @@   });   const lockLayer = (key,locked)=>{ rows[key].row.classList.toggle('disabled',locked); rows[key].cb.disabled=locked; }; +  // ============================================================================+  // 5b) HOVER TOOLTIPS+  // Colby's viewer tooltips (ViewCubeBuilder.ts -> ThreeDViewer.svelte .vcube-tooltip):+  //   scene.onPointerMove -> pick -> uiCallbacks.onTooltip(text, evt.offsetX, evt.offsetY),+  //   rendered as an absolutely-positioned div at (x-12, y+12) with translateX(-100%),+  //   cleared (onTooltip(null,0,0)) as soon as the pointer leaves the hovered mesh.+  // We keep that pick->position->clear contract and his exact styling, and enrich the+  // BODY with datasheet numbers + numbers measured off the real scene geometry.+  // ============================================================================++  // ---- geometry measurement (never invent a dimension) -----------------------+  // World units are metres; the datasheet talks mm.+  const M2MM = 1000;+  const f2 = v => (Math.round(v*100)/100).toFixed(2);+  const f3 = v => (Math.round(v*1000)/1000).toString();+  const _meas = {};+  const meshesOf = (node) => {+    if (!node) return [];+    const out = [];+    if (node.getTotalVertices && node.getTotalVertices() > 0 && node.getVerticesData) out.push(node);+    if (node.getChildMeshes) node.getChildMeshes().forEach(m => { if (m.getTotalVertices && m.getTotalVertices() > 0) out.push(m); });+    return out;+  };+  // Measure a node in WORLD space (mm) and, when the geometry is two disjoint blobs+  // (the two copper pads, the two solder fillets), split it along its longest axis so we+  // can quote per-feature sizes instead of the combined span. Cached per node name.+  function measure(nameOrNode){+    const node = typeof nameOrNode === 'string' ? scene.getNodeByName(nameOrNode) : nameOrNode;+    const key  = typeof nameOrNode === 'string' ? nameOrNode : (node && node.name);+    if (key && _meas[key] !== undefined) return _meas[key];+    const ms = meshesOf(node);+    if (!ms.length) { if (key) _meas[key] = null; return null; }+    const pts = [];+    ms.forEach(m => {+      const d = m.getVerticesData('position'); if (!d) return;+      const wm = m.computeWorldMatrix(true);+      const v = new B.Vector3(0,0,0);+      for (let i=0;i<d.length;i+=3){+        v.set(d[i], d[i+1], d[i+2]);+        const w = B.Vector3.TransformCoordinates(v, wm);+        pts.push([w.x*M2MM, w.y*M2MM, w.z*M2MM]);+      }+    });+    if (!pts.length) { if (key) _meas[key] = null; return null; }+    const bbox = (P) => {+      const mn=[Infinity,Infinity,Infinity], mx=[-Infinity,-Infinity,-Infinity];+      P.forEach(p => { for (let a=0;a<3;a++){ if(p[a]<mn[a])mn[a]=p[a]; if(p[a]>mx[a])mx[a]=p[a]; } });+      return { min:mn, max:mx, size:[mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]],+               center:[(mn[0]+mx[0])/2,(mn[1]+mx[1])/2,(mn[2]+mx[2])/2] };+    };+    const all = bbox(pts);+    // split along the longest axis at the largest gap (only if it's a real gap, >20% of the span)+    const ax = all.size.indexOf(Math.max(...all.size));+    const cs = Array.from(new Set(pts.map(p => Math.round(p[ax]*1e4)/1e4))).sort((a,b)=>a-b);+    let bestGap = 0, cut = null;+    for (let i=0;i<cs.length-1;i++){ const g = cs[i+1]-cs[i]; if (g > bestGap){ bestGap = g; cut = (cs[i]+cs[i+1])/2; } }+    let parts = [all];+    if (cut !== null && bestGap > 0.2*all.size[ax]) {+      const A = pts.filter(p => p[ax] < cut), Bp = pts.filter(p => p[ax] >= cut);+      if (A.length && Bp.length) parts = [bbox(A), bbox(Bp)];+    }+    const out = { all, parts, gap:parts.length>1?bestGap:0, axis:'xyz'[ax], verts:pts.length };+    if (key) _meas[key] = out;+    return out;+  }++  // ---- NOZZLE SPEC — EXTENSION POINT ----------------------------------------+  // Placeholder until Arav gets the real nozzle data from Drew. To fill in: set+  // tbd:false, add [label, value] pairs to rows, and set src to the nozzle datasheet.+  // Nothing else needs to change — the tooltip renders whatever is here.+  //   e.g. rows: [['type','Juki 501'], ['tip Ø','0.40 mm'], ['vacuum','-60 kPa'],+  //               ['max part','1.0 × 0.5 mm'], ['placement force','1.5 N']]+  const NOZZLE_SPEC = {+    tbd: true,+    rows: [],+    src: 'nozzle spec TBD — pending vendor data (Drew)'+  };++  // ---- datasheet constants (Inolux IN-S42AT series, V1.1, 25 Apr 2017) -------+  // Electricals/optics: p.4 table (IN-S42ATR row) — IF 20 mA, VF 2.1 typ / 2.6 max,+  // λD 631 nm, λP 645 nm, 2θ½ 130°, IV 90 mcd. Package: "0402 0.45 mm", 1.0 × 0.5 × 0.45.+  // Tape/reel: p.9 packing figure (image — values transcribed, see SKILL note).+  const DS      = 'Inolux IN-S42AT V1.1 — p.4 electro-optical table';+  const DS_PKG  = 'Inolux IN-S42AT V1.1 — p.1 package dimensions';+  const DS_TAPE = 'Inolux IN-S42AT V1.1 — p.9 tape & reel (figure, transcribed)';+  const MEAS    = 'measured from the loaded model geometry';+  const REEL    = 'Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 wide · 5000 pcs';++  // ---- THE TOOLTIP TABLE ----------------------------------------------------+  // Keyed by mesh-name pattern; first match wins (walking the picked node up its+  // parents). `rows` is either an array of [label, value] or a function returning one.+  // Adding a new entry is a one-liner. Numbers are datasheet-sourced (src: DS*) or+  // measured live off the mesh (src: MEAS) — never invented.+  const TIP_TABLE = [+    // ---------- optics ----------+    { re:/^fov_cone/, title:'fov_cone', sub:'emission cone', src:DS,+      rows:()=>{ const r = [['viewing angle 2θ½','130°'], ['half-angle','±65°'], ['λD / λP','631 / 645 nm']];+        const m = measure('fov_cone');+        if (m){ const h = m.all.size[2], rad = Math.max(m.all.size[0], m.all.size[1])/2;+          r.push(['cone (model)', 'Ø'+f2(rad*2)+' mm @ '+f2(h)+' mm']);+          r.push(['half-angle (measured)', (Math.atan2(rad,h)*180/Math.PI).toFixed(1)+'°']); }+        return r; } },+    { re:/^LED_lit/, title:'LED_lit', sub:'emitter glow layer', src:DS,+      rows:()=>{ const r = [['λD (dominant)','631 nm — red'], ['IV','90 mcd @ IF 20 mA'],+                            ['VF','2.1 V typ · 2.6 V max'], ['2θ½','130°']];+        const m = measure('LED_lit');+        if (m) r.push(['emissive plane (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm']);+        return r; } },++    // ---------- the part ----------+    { re:/^IN-S42ATR/, title:'IN-S42ATR', sub:'Inolux 0402 red LED', src:DS+' + '+DS_PKG,+      rows:()=>{ const r = [['package','0402 · 1.0 × 0.5 × 0.45 mm'], ['λD / λP','631 / 645 nm'],+                            ['VF @ 20 mA','2.1 V typ · 2.6 V max'], ['IV','90 mcd'], ['2θ½','130°']];+        const m = measure('IN-S42ATR');+        if (m) r.push(['body (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' × '+f2(m.all.size[2])+' mm']);+        return r; } },++    // ---------- board / footprint (all measured off the composite geometry) ----------+    { re:/^fr4_board/, title:'fr4_board', sub:'FR-4 substrate', src:MEAS+' (1.6 mm = standard FR-4 stack)',+      rows:()=>{ const m = measure('fr4_board'); if (!m) return [['thickness','1.6 mm']];+        return [['thickness', f2(m.all.size[2])+' mm'],+                ['board size', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm'],+                ['material','FR-4, 1.6 mm standard']]; } },+    { re:/^pad_top/, title:'pad_top', sub:'copper pads', src:MEAS+' (built from the IN-S42ATR footprint)',+      rows:()=>{ const m = measure('pad_top'); if (!m) return [];+        const p = m.parts[0], r = [['pads', String(m.parts.length)],+                                   ['pad size', f2(p.size[0])+' × '+f2(p.size[1])+' mm'],+                                   ['copper thickness', f3(p.size[2])+' mm (≈1 oz)']];+        if (m.parts.length === 2) { r.push(['gap between pads', f2(m.gap)+' mm']);+          r.push(['pitch (centres)', f2(Math.abs(m.parts[1].center[0]-m.parts[0].center[0]))+' mm']); }+        return r; } },+    { re:/^solder_top/, title:'solder_top', sub:'solder fillets', src:MEAS,+      rows:()=>{ const m = measure('solder_top'); if (!m) return [];+        const p = m.parts[0], d = Math.max(p.size[0], p.size[1]);+        const pad = measure('pad_top');+        const r = [['fillets', String(m.parts.length)],+                   ['radius', f3(d/2)+' mm (Ø'+f3(d)+')'],+                   ['height', f3(p.size[2])+' mm'],+                   ['z range', f3(p.min[2])+' → '+f3(p.max[2])+' mm']];+        if (pad) r.push(['above pad top', f3(p.max[2]-pad.parts[0].max[2])+' mm']);+        if (m.parts.length === 2) r.push(['pitch (centres)', f2(Math.abs(m.parts[1].center[0]-m.parts[0].center[0]))+' mm']);+        return r; } },+    { re:/^silk/, title:'silk', sub:'silkscreen cathode marker', src:MEAS,+      rows:()=>{ const m = measure('silk'); if (!m) return [];+        return [['size', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm'],+                ['thickness', f3(m.all.size[2])+' mm'],+                ['marks','cathode (pin 1) side']]; } },++    // ---------- carrier tape & reel (datasheet p.9) ----------+    { re:/^(pw\d|pf\d)/, title:'embossed pocket', sub:'carrier-tape cavity', src:DS_TAPE,+      rows:[['Ao (pocket W)','0.57 mm'], ['Bo (pocket L)','1.12 mm'], ['Ko (pocket depth)','0.55 mm'],+            ['P1 (pocket pitch)','4.00 mm'], ['F (pocket→sprocket row)','3.50 mm'],+            ['part seating','0.45 mm body in 0.55 deep → top 0.10 mm below tape face']] },+    { re:/^tape_led_/, title:'LED in pocket', sub:'IN-S42ATR, as reeled', src:DS_TAPE+' + '+DS,+      rows:[['part','IN-S42ATR · 0402 · 1.0 × 0.5 × 0.45 mm'], ['pocket Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],+            ['orientation','cathode toward the sprocket holes'], ['P1 (pitch)','4.00 mm'], ['reel', REEL]] },+    { re:/^tape_top/, title:'tape_top', sub:'sprocket band', src:DS_TAPE,+      rows:[['Do (sprocket hole Ø)','1.55 mm'], ['Po (sprocket pitch)','4.00 mm'],+            ['E (hole centre→edge)','1.75 mm'], ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],+            ['reel', REEL]] },+    { re:/^tape_bot/, title:'tape_bot', sub:'carrier tape body', src:DS_TAPE,+      rows:[['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],+            ['F (pocket→sprocket row)','3.50 mm'], ['P1 (pocket pitch)','4.00 mm'], ['reel', REEL]] },+    { re:/^tape_band/, title:'tape_band', sub:'carrier band between pockets', src:DS_TAPE,+      rows:[['P1 (pocket pitch)','4.00 mm'], ['P2 (pocket→hole offset)','2.00 mm'],+            ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'], ['Bo (band height)','1.12 mm']] },+    { re:/^cover_tape/, title:'cover_tape', sub:'heat-seal cover film', src:DS_TAPE,+      rows:[['covers','8.00 mm wide carrier tape'], ['Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],+            ['P1 (pocket pitch)','4.00 mm'], ['packing','5000 pcs per reel'], ['reel', REEL]] },+    { re:/^tape_reel$/, title:'tape & reel', sub:'Inolux packing', src:DS_TAPE,+      rows:[['tape','W 8.00 · T 0.20 · P1 4.00 · P2 2.00'], ['sprockets','Do 1.55 · Po 4.00 · E 1.75'],+            ['pocket','Ao 0.57 × Bo 1.12 × Ko 0.55 mm'], ['row offset','F 3.50 mm'], ['reel', REEL]] },++    // ---------- pick & place ----------+    { re:/^(pnp_nozzle|nozzle_)/, title:'pnp_nozzle', sub:'placement nozzle (ghost)',+      src:()=>NOZZLE_SPEC.src,+      rows:()=>{ const r = NOZZLE_SPEC.rows.slice();+        if (NOZZLE_SPEC.tbd) r.unshift(['nozzle','spec TBD']);+        const m = measure('pnp_nozzle');+        if (m) r.push(['ghost geometry (model)', 'Ø'+f2(Math.max(m.all.size[0],m.all.size[1]))+' × '+f2(m.all.size[2])+' mm']);+        return r; } },+    { re:/^pnp_ghosts$/, title:'pick & place', sub:'ghost assembly', src:DS_TAPE,+      rows:[['scene','nozzle + carrier tape + reeled parts'], ['tape','8 mm, P1 4.00 mm'], ['reel', REEL]] },+  ];++  // Resolve a picked mesh (or a bare name, for tests) to a tooltip entry: walk the node+  // up its parents so glTF `_primitiveN` children and cloned in-tape LEDs resolve too.+  function tipEntry(nodeOrName){+    let n = nodeOrName;+    for (let hop=0; hop<8 && n; hop++){+      const nm = (typeof n === 'string') ? n : n.name;+      if (nm) { const e = TIP_TABLE.find(t => t.re.test(nm)); if (e) return { entry:e, name:nm }; }+      if (typeof n === 'string') break;+      n = n.parent;+    }+    return null;+  }+  // Public resolver: name/mesh -> { title, sub, rows:[[k,v]…], src } or null.+  function tipData(nodeOrName){+    const hit = tipEntry(nodeOrName);+    const nm = (typeof nodeOrName === 'string') ? nodeOrName : (nodeOrName && nodeOrName.name) || '';+    if (!hit) return nm ? { title:nm, sub:'', rows:[], src:'no spec mapped for this mesh' } : null;+    const e = hit.entry;+    return { title:e.title, sub:e.sub || '', mesh:nm,+             rows:(typeof e.rows === 'function' ? e.rows(nodeOrName) : e.rows) || [],+             src:(typeof e.src === 'function' ? e.src() : e.src) || '' };+  }++  // ---- the tooltip element (Colby's placement contract) ---------------------+  const tipEl = document.getElementById('hover-tip');+  const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));+  function showTip(data, x, y){                     // x,y = pointer offset in the canvas, as his onTooltip+    if (!data) { tipEl.classList.remove('show'); return; }+    tipEl.innerHTML =+      '<span class="t">'+esc(data.title)+'</span>' + (data.sub ? '<span class="s">'+esc(data.sub)+'</span>' : '') ++      (data.rows.length ? '<div class="rows">'+data.rows.map(r => '<div class="r"><i>'+esc(r[0])+'</i><b>'+esc(r[1])+'</b></div>').join('')+'</div>' : '') ++      (data.src ? '<div class="src">'+esc(data.src)+'</div>' : '');+    tipEl.classList.add('show');+    const w = tipEl.offsetWidth, h = tipEl.offsetHeight;+    const flip = (x - 12 - w) < 8;                  // his default opens leftwards; flip near the left edge+    tipEl.classList.toggle('flip', flip);+    const left = flip ? Math.min(x + 14, window.innerWidth - w - 8) : Math.min(x - 12, window.innerWidth - 8);+    tipEl.style.left = left + 'px';+    tipEl.style.top  = Math.max(8, Math.min(y + 12, window.innerHeight - h - 8)) + 'px';+  }+  const hideTip = () => { tipEl.classList.remove('show'); };++  // Meshes that are viewer furniture, not content — never tooltipped (same set we+  // exclude from shadows, plus the ground).+  const TIP_SKIP = /skyBox|^Background|viewCube|axis|gizmo|shadowGround|^ground$/i;+  // See-through meshes that shouldn't steal the tooltip from what's behind them.+  const TIP_XRAY = /^cover_tape/;+  // His ViewCube owns the top-right viewport (min 120 px, 15% of the canvas, centred at+  // 0.925/0.925 — ViewCubeBuilder.ts). Don't fight his tooltip inside it.+  function inViewCube(x, y){+    const cw = window.innerWidth, ch = window.innerHeight;+    const w = Math.min(Math.max(cw*0.15, 120), cw*0.3), h = Math.min(Math.max(ch*0.15, 120), ch*0.3);+    const x0 = Math.max(0, Math.min(cw - w, cw*0.925 - w/2)), y0 = Math.max(0, Math.min(ch - h, ch*0.075 - h/2));+    return x >= x0 && x <= x0 + w && y >= y0 && y <= y0 + h;+  }+  const canvasEl = scene.getEngine().getRenderingCanvas();+  let tipLast = 0, tipDown = false;+  canvasEl.addEventListener('pointerdown', () => { tipDown = true; hideTip(); });+  window.addEventListener('pointerup',   () => { tipDown = false; });+  canvasEl.addEventListener('pointerleave', hideTip);+  canvasEl.addEventListener('pointermove', (evt) => {+    if (tipDown) { hideTip(); return; }                         // orbiting — no tooltip (his behaviour)+    const now = performance.now(); if (now - tipLast < 30) return; tipLast = now;   // ~33 Hz picks+    const x = evt.offsetX, y = evt.offsetY;+    if (inViewCube(x, y)) { hideTip(); return; }+    const pred = (m) => m.isEnabled() && m.isVisible && !TIP_SKIP.test(m.name);+    // The clear cover film sits over the whole tape; hovering "through" it should report+    // the pocket / reeled part underneath. Take the nearest hit that isn't see-through,+    // and fall back to the film itself when that's genuinely all there is.+    let mesh = null;+    const hits = (scene.multiPick ? scene.multiPick(scene.pointerX, scene.pointerY, pred) : null) || [];+    hits.sort((a,b) => a.distance - b.distance);+    for (const h of hits) { if (h.pickedMesh && !TIP_XRAY.test(h.pickedMesh.name)) { mesh = h.pickedMesh; break; } }+    if (!mesh && hits.length && hits[0].pickedMesh) mesh = hits[0].pickedMesh;+    if (!mesh) {                                                                     // multiPick unavailable → single pick+      const pick = scene.pick(scene.pointerX, scene.pointerY, pred);+      mesh = pick && pick.hit ? pick.pickedMesh : null;+    }+    if (!mesh) { hideTip(); return; }                                                // cleared on leave, like onTooltip(null)+    showTip(tipData(mesh), x, y);+  });+  // debug/verification hook: window._tip.data('solder_top') etc.+  window._tip = { data:tipData, table:TIP_TABLE, measure, nozzle:NOZZLE_SPEC, show:showTip, hide:hideTip };++  // ============================================================================+  // 5c) FLOATING, DRAGGABLE LAYERS PANEL+  // Default position: directly under the #legend window (top-right), right edges+  // aligned. Dragged by its title bar; clamped to the viewport. All pointer events+  // inside the panel are stopped so the camera underneath never orbits.+  // ============================================================================+  const menuEl = document.getElementById('layer-menu');+  const legendEl = document.getElementById('legend');+  const dragEl = document.getElementById('layer-drag');+  const PANEL_GAP = 8;+  let menuPos = null;                                  // {left, top} in px once placed/dragged+  const clampMenu = (l, t) => {+    const w = menuEl.offsetWidth || 200, h = menuEl.offsetHeight || 160;+    return { left: Math.max(6, Math.min(l, window.innerWidth  - w - 6)),+             top:  Math.max(6, Math.min(t, window.innerHeight - h - 6)) };+  };+  const applyMenuPos = () => { const p = clampMenu(menuPos.left, menuPos.top); menuPos = p;+    menuEl.style.left = p.left+'px'; menuEl.style.top = p.top+'px'; };+  function defaultMenuPos(){                            // directly UNDER the legend window+    const wasHidden = !legendEl.classList.contains('show');+    if (wasHidden) { legendEl.style.visibility='hidden'; legendEl.classList.add('show'); }+    const lr = legendEl.getBoundingClientRect();+    if (wasHidden) { legendEl.classList.remove('show'); legendEl.style.visibility=''; }+    const w = menuEl.offsetWidth || 200;+    return { left: lr.right - w, top: lr.bottom + PANEL_GAP };   // right edges aligned with the legend+  }+  const placeMenu = () => { if (!menuPos) menuPos = defaultMenuPos(); applyMenuPos(); };+  // Swallow pointer/wheel events so they never reach the canvas/camera underneath.+  ['pointerdown','mousedown','wheel','touchstart','dblclick'].forEach(ev =>+    menuEl.addEventListener(ev, e => e.stopPropagation(), { passive:ev === 'wheel' ? false : undefined }));+  let dragOff = null;+  dragEl.addEventListener('pointerdown', (e) => {+    e.preventDefault(); e.stopPropagation();+    placeMenu();+    dragOff = { x: e.clientX - menuPos.left, y: e.clientY - menuPos.top };+    menuEl.classList.add('dragging');+    try { dragEl.setPointerCapture(e.pointerId); } catch(_) {}+  });+  dragEl.addEventListener('pointermove', (e) => {+    if (!dragOff) return;+    e.preventDefault(); e.stopPropagation();+    menuPos = { left: e.clientX - dragOff.x, top: e.clientY - dragOff.y };+    applyMenuPos();+  });+  const endDrag = (e) => { if (!dragOff) return; dragOff = null; menuEl.classList.remove('dragging');+    try { dragEl.releasePointerCapture(e.pointerId); } catch(_) {} };+  dragEl.addEventListener('pointerup', endDrag);+  dragEl.addEventListener('pointercancel', endDrag);+  window.addEventListener('resize', () => { if (menuPos) applyMenuPos(); });+  window._layers = { el:menuEl, place:placeMenu, pos:()=>menuPos, defaultPos:defaultMenuPos,+                     setPos:(l,t)=>{ menuPos={left:l,top:t}; applyMenuPos(); } };+   // His frameModel() mis-frames in this embed (it framed the 5000-unit skybox → camera at   // radius ~2500 m, so the ~3mm part is a sub-pixel dot). Run it for its spotlight/ground   // positioning, then set a sane mm-scale camera explicitly.@@ -401,7 +752,11 @@   document.getElementById('mode-pnp').onclick    = () => setMode('pnp');   document.getElementById('btn-ground').onclick = function(){ this.classList.toggle('on'); viewer.setGroundVisible(this.classList.contains('on')); matchColby(); };   // setGroundVisible rebuilds the ground opaque — re-apply his fade circle   document.getElementById('btn-bottom').onclick = function(){ this.classList.toggle('on'); if (bottomFill) bottomFill.intensity = this.classList.contains('on') ? 0.6 : 0; };-  document.getElementById('btn-layers').onclick = function(){ document.getElementById('layer-menu').classList.toggle('open'); this.classList.toggle('on'); };+  document.getElementById('btn-layers').onclick = function(){+    const open = !menuEl.classList.contains('open');+    menuEl.classList.toggle('open', open); this.classList.toggle('on', open);+    if (open) placeMenu();          // first open lands under the legend; later opens keep where you dragged it+  };    // Close the Layers menu on any click outside it (and outside the Layers button).   document.addEventListener('click', (e) => {
README.html+360−5
@@ -66,15 +66,44 @@     border-radius:16px; font-size:12px; cursor:pointer; font-family:inherit; box-shadow:0 2px 8px #0006; }   .btn:hover{ border-color:var(--accent); }   .btn.on{ background:rgba(0,184,176,0.16); border-color:var(--accent); color:var(--accent); }-  #layer-menu{ position:fixed; bottom:56px; left:50%; transform:translateX(-50%); z-index:10; background:var(--panel);-    border:1px solid var(--border); border-radius:10px; padding:10px 14px; min-width:190px; display:none; box-shadow:0 2px 10px #0007; }+  /* Floating, draggable Layers panel. Positioned in JS (left/top, px) — default sits+     directly under the #legend window, top-right. Dragged by its title bar. */+  #layer-menu{ position:fixed; top:0; left:0; z-index:14; background:var(--panel);+    border:1px solid var(--border); border-radius:10px; padding:0 0 8px; min-width:200px; display:none;+    box-shadow:0 4px 16px #0008; user-select:none; -webkit-user-select:none; }   #layer-menu.open{ display:block; }-  #layer-menu h2{ font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:#8b949e; margin:0 0 8px; font-weight:600; }+  #layer-menu.dragging{ box-shadow:0 8px 26px #000b; border-color:var(--accent); }+  #layer-menu h2{ font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:#8b949e; margin:0;+    font-weight:600; padding:7px 12px 6px; border-bottom:1px solid var(--border); cursor:grab;+    display:flex; align-items:center; gap:7px; touch-action:none; }+  #layer-menu.dragging h2{ cursor:grabbing; color:var(--accent); }+  #layer-menu h2 .grip{ font-size:11px; letter-spacing:0; color:#586069; }+  #layer-list{ padding:6px 14px 0; }   #layer-menu label{ display:flex; align-items:center; gap:8px; padding:4px 0;     font:12px 'JetBrains Mono',ui-monospace,monospace; cursor:pointer; }   #layer-menu label.disabled{ opacity:.4; cursor:not-allowed; }   #layer-menu input{ accent-color:var(--accent); }   #layer-menu .grp{ margin-top:6px; border-top:1px solid var(--border); padding-top:6px; }++  /* Hover tooltip — same look/placement/behaviour as Colby's ViewCube tooltip+     (ThreeDViewer.svelte .vcube-tooltip: rgba(30,34,38,.92), 12px system font, 5px 10px,+     radius 4, nowrap, anchored at pointer + (-12,+12) with translateX(-100%)).+     We keep his shell and add title / key-value rows / source line inside it. */+  #hover-tip{ position:fixed; pointer-events:none; transform:translateX(-100%);+    background:rgba(30,34,38,0.92); color:#e0e0e0;+    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; font-size:12px; line-height:1;+    padding:5px 10px; border-radius:4px; white-space:nowrap; z-index:20; display:none; max-width:none; }+  #hover-tip.show{ display:block; }+  #hover-tip.flip{ transform:translateX(0); }         /* pointer near the left edge — open rightwards */+  #hover-tip .t{ font-weight:600; color:#fff; }+  #hover-tip .s{ color:#9fb4c8; font-size:11px; margin-left:6px; }+  #hover-tip .r{ display:flex; gap:14px; justify-content:space-between; margin-top:3px;+    font:11px/1.35 'JetBrains Mono',ui-monospace,monospace; }+  #hover-tip .r i{ color:#8b949e; font-style:normal; }+  #hover-tip .r b{ color:#e6edf3; font-weight:500; }+  #hover-tip .src{ margin-top:5px; padding-top:4px; border-top:1px solid rgba(255,255,255,.14);+    color:#7d8590; font-size:10px; letter-spacing:.01em; }+  #hover-tip .rows{ margin-top:4px; }   #status{ position:fixed; top:52px; left:16px; z-index:10; color:#a9b6c2;     font:11px 'JetBrains Mono',ui-monospace,monospace; text-shadow:0 1px 3px #0009; } </style>@@ -93,7 +122,8 @@   <div class=&quot;row&quot;><span class=&quot;sw&quot; style=&quot;background:#bd964e&quot;></span> Carrier tape (to datasheet)</div>   <div class=&quot;row&quot;><span class=&quot;sw&quot; style=&quot;background:#d8d0c4&quot;></span> LED in tape (real model)</div> </div>-<div id=&quot;layer-menu&quot;><h2>Layers</h2><div id=&quot;layer-list&quot;></div></div>+<div id=&quot;layer-menu&quot;><h2 id=&quot;layer-drag&quot;><span class=&quot;grip&quot;>⠿</span>Layers</h2><div id=&quot;layer-list&quot;></div></div>+<div id=&quot;hover-tip&quot;></div> <div class=&quot;btn-row&quot;>   <button class=&quot;btn on&quot; id=&quot;btn-ground&quot;>Ground</button>   <button class=&quot;btn&quot; id=&quot;btn-bottom&quot;>Bottom light</button>@@ -392,6 +422,327 @@   });   const lockLayer = (key,locked)=>{ rows[key].row.classList.toggle('disabled',locked); rows[key].cb.disabled=locked; }; +  // ============================================================================+  // 5b) HOVER TOOLTIPS+  // Colby's viewer tooltips (ViewCubeBuilder.ts -> ThreeDViewer.svelte .vcube-tooltip):+  //   scene.onPointerMove -> pick -> uiCallbacks.onTooltip(text, evt.offsetX, evt.offsetY),+  //   rendered as an absolutely-positioned div at (x-12, y+12) with translateX(-100%),+  //   cleared (onTooltip(null,0,0)) as soon as the pointer leaves the hovered mesh.+  // We keep that pick->position->clear contract and his exact styling, and enrich the+  // BODY with datasheet numbers + numbers measured off the real scene geometry.+  // ============================================================================++  // ---- geometry measurement (never invent a dimension) -----------------------+  // World units are metres; the datasheet talks mm.+  const M2MM = 1000;+  const f2 = v => (Math.round(v*100)/100).toFixed(2);+  const f3 = v => (Math.round(v*1000)/1000).toString();+  const _meas = {};+  const meshesOf = (node) => {+    if (!node) return [];+    const out = [];+    if (node.getTotalVertices &amp;&amp; node.getTotalVertices() > 0 &amp;&amp; node.getVerticesData) out.push(node);+    if (node.getChildMeshes) node.getChildMeshes().forEach(m => { if (m.getTotalVertices &amp;&amp; m.getTotalVertices() > 0) out.push(m); });+    return out;+  };+  // Measure a node in WORLD space (mm) and, when the geometry is two disjoint blobs+  // (the two copper pads, the two solder fillets), split it along its longest axis so we+  // can quote per-feature sizes instead of the combined span. Cached per node name.+  function measure(nameOrNode){+    const node = typeof nameOrNode === 'string' ? scene.getNodeByName(nameOrNode) : nameOrNode;+    const key  = typeof nameOrNode === 'string' ? nameOrNode : (node &amp;&amp; node.name);+    if (key &amp;&amp; _meas[key] !== undefined) return _meas[key];+    const ms = meshesOf(node);+    if (!ms.length) { if (key) _meas[key] = null; return null; }+    const pts = [];+    ms.forEach(m => {+      const d = m.getVerticesData('position'); if (!d) return;+      const wm = m.computeWorldMatrix(true);+      const v = new B.Vector3(0,0,0);+      for (let i=0;i<d.length;i+=3){+        v.set(d[i], d[i+1], d[i+2]);+        const w = B.Vector3.TransformCoordinates(v, wm);+        pts.push([w.x*M2MM, w.y*M2MM, w.z*M2MM]);+      }+    });+    if (!pts.length) { if (key) _meas[key] = null; return null; }+    const bbox = (P) => {+      const mn=[Infinity,Infinity,Infinity], mx=[-Infinity,-Infinity,-Infinity];+      P.forEach(p => { for (let a=0;a<3;a++){ if(p[a]<mn[a])mn[a]=p[a]; if(p[a]>mx[a])mx[a]=p[a]; } });+      return { min:mn, max:mx, size:[mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]],+               center:[(mn[0]+mx[0])/2,(mn[1]+mx[1])/2,(mn[2]+mx[2])/2] };+    };+    const all = bbox(pts);+    // split along the longest axis at the largest gap (only if it's a real gap, >20% of the span)+    const ax = all.size.indexOf(Math.max(...all.size));+    const cs = Array.from(new Set(pts.map(p => Math.round(p[ax]*1e4)/1e4))).sort((a,b)=>a-b);+    let bestGap = 0, cut = null;+    for (let i=0;i<cs.length-1;i++){ const g = cs[i+1]-cs[i]; if (g > bestGap){ bestGap = g; cut = (cs[i]+cs[i+1])/2; } }+    let parts = [all];+    if (cut !== null &amp;&amp; bestGap > 0.2*all.size[ax]) {+      const A = pts.filter(p => p[ax] < cut), Bp = pts.filter(p => p[ax] >= cut);+      if (A.length &amp;&amp; Bp.length) parts = [bbox(A), bbox(Bp)];+    }+    const out = { all, parts, gap:parts.length>1?bestGap:0, axis:'xyz'[ax], verts:pts.length };+    if (key) _meas[key] = out;+    return out;+  }++  // ---- NOZZLE SPEC — EXTENSION POINT ----------------------------------------+  // Placeholder until Arav gets the real nozzle data from Drew. To fill in: set+  // tbd:false, add [label, value] pairs to rows, and set src to the nozzle datasheet.+  // Nothing else needs to change — the tooltip renders whatever is here.+  //   e.g. rows: [['type','Juki 501'], ['tip Ø','0.40 mm'], ['vacuum','-60 kPa'],+  //               ['max part','1.0 × 0.5 mm'], ['placement force','1.5 N']]+  const NOZZLE_SPEC = {+    tbd: true,+    rows: [],+    src: 'nozzle spec TBD — pending vendor data (Drew)'+  };++  // ---- datasheet constants (Inolux IN-S42AT series, V1.1, 25 Apr 2017) -------+  // Electricals/optics: p.4 table (IN-S42ATR row) — IF 20 mA, VF 2.1 typ / 2.6 max,+  // λD 631 nm, λP 645 nm, 2θ½ 130°, IV 90 mcd. Package: &quot;0402 0.45 mm&quot;, 1.0 × 0.5 × 0.45.+  // Tape/reel: p.9 packing figure (image — values transcribed, see SKILL note).+  const DS      = 'Inolux IN-S42AT V1.1 — p.4 electro-optical table';+  const DS_PKG  = 'Inolux IN-S42AT V1.1 — p.1 package dimensions';+  const DS_TAPE = 'Inolux IN-S42AT V1.1 — p.9 tape &amp; reel (figure, transcribed)';+  const MEAS    = 'measured from the loaded model geometry';+  const REEL    = 'Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 wide · 5000 pcs';++  // ---- THE TOOLTIP TABLE ----------------------------------------------------+  // Keyed by mesh-name pattern; first match wins (walking the picked node up its+  // parents). `rows` is either an array of [label, value] or a function returning one.+  // Adding a new entry is a one-liner. Numbers are datasheet-sourced (src: DS*) or+  // measured live off the mesh (src: MEAS) — never invented.+  const TIP_TABLE = [+    // ---------- optics ----------+    { re:/^fov_cone/, title:'fov_cone', sub:'emission cone', src:DS,+      rows:()=>{ const r = [['viewing angle 2θ½','130°'], ['half-angle','±65°'], ['λD / λP','631 / 645 nm']];+        const m = measure('fov_cone');+        if (m){ const h = m.all.size[2], rad = Math.max(m.all.size[0], m.all.size[1])/2;+          r.push(['cone (model)', 'Ø'+f2(rad*2)+' mm @ '+f2(h)+' mm']);+          r.push(['half-angle (measured)', (Math.atan2(rad,h)*180/Math.PI).toFixed(1)+'°']); }+        return r; } },+    { re:/^LED_lit/, title:'LED_lit', sub:'emitter glow layer', src:DS,+      rows:()=>{ const r = [['λD (dominant)','631 nm — red'], ['IV','90 mcd @ IF 20 mA'],+                            ['VF','2.1 V typ · 2.6 V max'], ['2θ½','130°']];+        const m = measure('LED_lit');+        if (m) r.push(['emissive plane (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm']);+        return r; } },++    // ---------- the part ----------+    { re:/^IN-S42ATR/, title:'IN-S42ATR', sub:'Inolux 0402 red LED', src:DS+' + '+DS_PKG,+      rows:()=>{ const r = [['package','0402 · 1.0 × 0.5 × 0.45 mm'], ['λD / λP','631 / 645 nm'],+                            ['VF @ 20 mA','2.1 V typ · 2.6 V max'], ['IV','90 mcd'], ['2θ½','130°']];+        const m = measure('IN-S42ATR');+        if (m) r.push(['body (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' × '+f2(m.all.size[2])+' mm']);+        return r; } },++    // ---------- board / footprint (all measured off the composite geometry) ----------+    { re:/^fr4_board/, title:'fr4_board', sub:'FR-4 substrate', src:MEAS+' (1.6 mm = standard FR-4 stack)',+      rows:()=>{ const m = measure('fr4_board'); if (!m) return [['thickness','1.6 mm']];+        return [['thickness', f2(m.all.size[2])+' mm'],+                ['board size', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm'],+                ['material','FR-4, 1.6 mm standard']]; } },+    { re:/^pad_top/, title:'pad_top', sub:'copper pads', src:MEAS+' (built from the IN-S42ATR footprint)',+      rows:()=>{ const m = measure('pad_top'); if (!m) return [];+        const p = m.parts[0], r = [['pads', String(m.parts.length)],+                                   ['pad size', f2(p.size[0])+' × '+f2(p.size[1])+' mm'],+                                   ['copper thickness', f3(p.size[2])+' mm (≈1 oz)']];+        if (m.parts.length === 2) { r.push(['gap between pads', f2(m.gap)+' mm']);+          r.push(['pitch (centres)', f2(Math.abs(m.parts[1].center[0]-m.parts[0].center[0]))+' mm']); }+        return r; } },+    { re:/^solder_top/, title:'solder_top', sub:'solder fillets', src:MEAS,+      rows:()=>{ const m = measure('solder_top'); if (!m) return [];+        const p = m.parts[0], d = Math.max(p.size[0], p.size[1]);+        const pad = measure('pad_top');+        const r = [['fillets', String(m.parts.length)],+                   ['radius', f3(d/2)+' mm (Ø'+f3(d)+')'],+                   ['height', f3(p.size[2])+' mm'],+                   ['z range', f3(p.min[2])+' → '+f3(p.max[2])+' mm']];+        if (pad) r.push(['above pad top', f3(p.max[2]-pad.parts[0].max[2])+' mm']);+        if (m.parts.length === 2) r.push(['pitch (centres)', f2(Math.abs(m.parts[1].center[0]-m.parts[0].center[0]))+' mm']);+        return r; } },+    { re:/^silk/, title:'silk', sub:'silkscreen cathode marker', src:MEAS,+      rows:()=>{ const m = measure('silk'); if (!m) return [];+        return [['size', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm'],+                ['thickness', f3(m.all.size[2])+' mm'],+                ['marks','cathode (pin 1) side']]; } },++    // ---------- carrier tape &amp; reel (datasheet p.9) ----------+    { re:/^(pw\d|pf\d)/, title:'embossed pocket', sub:'carrier-tape cavity', src:DS_TAPE,+      rows:[['Ao (pocket W)','0.57 mm'], ['Bo (pocket L)','1.12 mm'], ['Ko (pocket depth)','0.55 mm'],+            ['P1 (pocket pitch)','4.00 mm'], ['F (pocket→sprocket row)','3.50 mm'],+            ['part seating','0.45 mm body in 0.55 deep → top 0.10 mm below tape face']] },+    { re:/^tape_led_/, title:'LED in pocket', sub:'IN-S42ATR, as reeled', src:DS_TAPE+' + '+DS,+      rows:[['part','IN-S42ATR · 0402 · 1.0 × 0.5 × 0.45 mm'], ['pocket Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],+            ['orientation','cathode toward the sprocket holes'], ['P1 (pitch)','4.00 mm'], ['reel', REEL]] },+    { re:/^tape_top/, title:'tape_top', sub:'sprocket band', src:DS_TAPE,+      rows:[['Do (sprocket hole Ø)','1.55 mm'], ['Po (sprocket pitch)','4.00 mm'],+            ['E (hole centre→edge)','1.75 mm'], ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],+            ['reel', REEL]] },+    { re:/^tape_bot/, title:'tape_bot', sub:'carrier tape body', src:DS_TAPE,+      rows:[['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'],+            ['F (pocket→sprocket row)','3.50 mm'], ['P1 (pocket pitch)','4.00 mm'], ['reel', REEL]] },+    { re:/^tape_band/, title:'tape_band', sub:'carrier band between pockets', src:DS_TAPE,+      rows:[['P1 (pocket pitch)','4.00 mm'], ['P2 (pocket→hole offset)','2.00 mm'],+            ['W (tape width)','8.00 mm'], ['T (tape thickness)','0.20 mm'], ['Bo (band height)','1.12 mm']] },+    { re:/^cover_tape/, title:'cover_tape', sub:'heat-seal cover film', src:DS_TAPE,+      rows:[['covers','8.00 mm wide carrier tape'], ['Ao × Bo × Ko','0.57 × 1.12 × 0.55 mm'],+            ['P1 (pocket pitch)','4.00 mm'], ['packing','5000 pcs per reel'], ['reel', REEL]] },+    { re:/^tape_reel$/, title:'tape &amp; reel', sub:'Inolux packing', src:DS_TAPE,+      rows:[['tape','W 8.00 · T 0.20 · P1 4.00 · P2 2.00'], ['sprockets','Do 1.55 · Po 4.00 · E 1.75'],+            ['pocket','Ao 0.57 × Bo 1.12 × Ko 0.55 mm'], ['row offset','F 3.50 mm'], ['reel', REEL]] },++    // ---------- pick &amp; place ----------+    { re:/^(pnp_nozzle|nozzle_)/, title:'pnp_nozzle', sub:'placement nozzle (ghost)',+      src:()=>NOZZLE_SPEC.src,+      rows:()=>{ const r = NOZZLE_SPEC.rows.slice();+        if (NOZZLE_SPEC.tbd) r.unshift(['nozzle','spec TBD']);+        const m = measure('pnp_nozzle');+        if (m) r.push(['ghost geometry (model)', 'Ø'+f2(Math.max(m.all.size[0],m.all.size[1]))+' × '+f2(m.all.size[2])+' mm']);+        return r; } },+    { re:/^pnp_ghosts$/, title:'pick &amp; place', sub:'ghost assembly', src:DS_TAPE,+      rows:[['scene','nozzle + carrier tape + reeled parts'], ['tape','8 mm, P1 4.00 mm'], ['reel', REEL]] },+  ];++  // Resolve a picked mesh (or a bare name, for tests) to a tooltip entry: walk the node+  // up its parents so glTF `_primitiveN` children and cloned in-tape LEDs resolve too.+  function tipEntry(nodeOrName){+    let n = nodeOrName;+    for (let hop=0; hop<8 &amp;&amp; n; hop++){+      const nm = (typeof n === 'string') ? n : n.name;+      if (nm) { const e = TIP_TABLE.find(t => t.re.test(nm)); if (e) return { entry:e, name:nm }; }+      if (typeof n === 'string') break;+      n = n.parent;+    }+    return null;+  }+  // Public resolver: name/mesh -> { title, sub, rows:[[k,v]…], src } or null.+  function tipData(nodeOrName){+    const hit = tipEntry(nodeOrName);+    const nm = (typeof nodeOrName === 'string') ? nodeOrName : (nodeOrName &amp;&amp; nodeOrName.name) || '';+    if (!hit) return nm ? { title:nm, sub:'', rows:[], src:'no spec mapped for this mesh' } : null;+    const e = hit.entry;+    return { title:e.title, sub:e.sub || '', mesh:nm,+             rows:(typeof e.rows === 'function' ? e.rows(nodeOrName) : e.rows) || [],+             src:(typeof e.src === 'function' ? e.src() : e.src) || '' };+  }++  // ---- the tooltip element (Colby's placement contract) ---------------------+  const tipEl = document.getElementById('hover-tip');+  const esc = s => String(s).replace(/[&amp;<>]/g, c => ({'&amp;':'&amp;amp;','<':'&amp;lt;','>':'&amp;gt;'}[c]));+  function showTip(data, x, y){                     // x,y = pointer offset in the canvas, as his onTooltip+    if (!data) { tipEl.classList.remove('show'); return; }+    tipEl.innerHTML =+      '<span class=&quot;t&quot;>'+esc(data.title)+'</span>' + (data.sub ? '<span class=&quot;s&quot;>'+esc(data.sub)+'</span>' : '') ++      (data.rows.length ? '<div class=&quot;rows&quot;>'+data.rows.map(r => '<div class=&quot;r&quot;><i>'+esc(r[0])+'</i><b>'+esc(r[1])+'</b></div>').join('')+'</div>' : '') ++      (data.src ? '<div class=&quot;src&quot;>'+esc(data.src)+'</div>' : '');+    tipEl.classList.add('show');+    const w = tipEl.offsetWidth, h = tipEl.offsetHeight;+    const flip = (x - 12 - w) < 8;                  // his default opens leftwards; flip near the left edge+    tipEl.classList.toggle('flip', flip);+    const left = flip ? Math.min(x + 14, window.innerWidth - w - 8) : Math.min(x - 12, window.innerWidth - 8);+    tipEl.style.left = left + 'px';+    tipEl.style.top  = Math.max(8, Math.min(y + 12, window.innerHeight - h - 8)) + 'px';+  }+  const hideTip = () => { tipEl.classList.remove('show'); };++  // Meshes that are viewer furniture, not content — never tooltipped (same set we+  // exclude from shadows, plus the ground).+  const TIP_SKIP = /skyBox|^Background|viewCube|axis|gizmo|shadowGround|^ground$/i;+  // See-through meshes that shouldn't steal the tooltip from what's behind them.+  const TIP_XRAY = /^cover_tape/;+  // His ViewCube owns the top-right viewport (min 120 px, 15% of the canvas, centred at+  // 0.925/0.925 — ViewCubeBuilder.ts). Don't fight his tooltip inside it.+  function inViewCube(x, y){+    const cw = window.innerWidth, ch = window.innerHeight;+    const w = Math.min(Math.max(cw*0.15, 120), cw*0.3), h = Math.min(Math.max(ch*0.15, 120), ch*0.3);+    const x0 = Math.max(0, Math.min(cw - w, cw*0.925 - w/2)), y0 = Math.max(0, Math.min(ch - h, ch*0.075 - h/2));+    return x >= x0 &amp;&amp; x <= x0 + w &amp;&amp; y >= y0 &amp;&amp; y <= y0 + h;+  }+  const canvasEl = scene.getEngine().getRenderingCanvas();+  let tipLast = 0, tipDown = false;+  canvasEl.addEventListener('pointerdown', () => { tipDown = true; hideTip(); });+  window.addEventListener('pointerup',   () => { tipDown = false; });+  canvasEl.addEventListener('pointerleave', hideTip);+  canvasEl.addEventListener('pointermove', (evt) => {+    if (tipDown) { hideTip(); return; }                         // orbiting — no tooltip (his behaviour)+    const now = performance.now(); if (now - tipLast < 30) return; tipLast = now;   // ~33 Hz picks+    const x = evt.offsetX, y = evt.offsetY;+    if (inViewCube(x, y)) { hideTip(); return; }+    const pred = (m) => m.isEnabled() &amp;&amp; m.isVisible &amp;&amp; !TIP_SKIP.test(m.name);+    // The clear cover film sits over the whole tape; hovering &quot;through&quot; it should report+    // the pocket / reeled part underneath. Take the nearest hit that isn't see-through,+    // and fall back to the film itself when that's genuinely all there is.+    let mesh = null;+    const hits = (scene.multiPick ? scene.multiPick(scene.pointerX, scene.pointerY, pred) : null) || [];+    hits.sort((a,b) => a.distance - b.distance);+    for (const h of hits) { if (h.pickedMesh &amp;&amp; !TIP_XRAY.test(h.pickedMesh.name)) { mesh = h.pickedMesh; break; } }+    if (!mesh &amp;&amp; hits.length &amp;&amp; hits[0].pickedMesh) mesh = hits[0].pickedMesh;+    if (!mesh) {                                                                     // multiPick unavailable → single pick+      const pick = scene.pick(scene.pointerX, scene.pointerY, pred);+      mesh = pick &amp;&amp; pick.hit ? pick.pickedMesh : null;+    }+    if (!mesh) { hideTip(); return; }                                                // cleared on leave, like onTooltip(null)+    showTip(tipData(mesh), x, y);+  });+  // debug/verification hook: window._tip.data('solder_top') etc.+  window._tip = { data:tipData, table:TIP_TABLE, measure, nozzle:NOZZLE_SPEC, show:showTip, hide:hideTip };++  // ============================================================================+  // 5c) FLOATING, DRAGGABLE LAYERS PANEL+  // Default position: directly under the #legend window (top-right), right edges+  // aligned. Dragged by its title bar; clamped to the viewport. All pointer events+  // inside the panel are stopped so the camera underneath never orbits.+  // ============================================================================+  const menuEl = document.getElementById('layer-menu');+  const legendEl = document.getElementById('legend');+  const dragEl = document.getElementById('layer-drag');+  const PANEL_GAP = 8;+  let menuPos = null;                                  // {left, top} in px once placed/dragged+  const clampMenu = (l, t) => {+    const w = menuEl.offsetWidth || 200, h = menuEl.offsetHeight || 160;+    return { left: Math.max(6, Math.min(l, window.innerWidth  - w - 6)),+             top:  Math.max(6, Math.min(t, window.innerHeight - h - 6)) };+  };+  const applyMenuPos = () => { const p = clampMenu(menuPos.left, menuPos.top); menuPos = p;+    menuEl.style.left = p.left+'px'; menuEl.style.top = p.top+'px'; };+  function defaultMenuPos(){                            // directly UNDER the legend window+    const wasHidden = !legendEl.classList.contains('show');+    if (wasHidden) { legendEl.style.visibility='hidden'; legendEl.classList.add('show'); }+    const lr = legendEl.getBoundingClientRect();+    if (wasHidden) { legendEl.classList.remove('show'); legendEl.style.visibility=''; }+    const w = menuEl.offsetWidth || 200;+    return { left: lr.right - w, top: lr.bottom + PANEL_GAP };   // right edges aligned with the legend+  }+  const placeMenu = () => { if (!menuPos) menuPos = defaultMenuPos(); applyMenuPos(); };+  // Swallow pointer/wheel events so they never reach the canvas/camera underneath.+  ['pointerdown','mousedown','wheel','touchstart','dblclick'].forEach(ev =>+    menuEl.addEventListener(ev, e => e.stopPropagation(), { passive:ev === 'wheel' ? false : undefined }));+  let dragOff = null;+  dragEl.addEventListener('pointerdown', (e) => {+    e.preventDefault(); e.stopPropagation();+    placeMenu();+    dragOff = { x: e.clientX - menuPos.left, y: e.clientY - menuPos.top };+    menuEl.classList.add('dragging');+    try { dragEl.setPointerCapture(e.pointerId); } catch(_) {}+  });+  dragEl.addEventListener('pointermove', (e) => {+    if (!dragOff) return;+    e.preventDefault(); e.stopPropagation();+    menuPos = { left: e.clientX - dragOff.x, top: e.clientY - dragOff.y };+    applyMenuPos();+  });+  const endDrag = (e) => { if (!dragOff) return; dragOff = null; menuEl.classList.remove('dragging');+    try { dragEl.releasePointerCapture(e.pointerId); } catch(_) {} };+  dragEl.addEventListener('pointerup', endDrag);+  dragEl.addEventListener('pointercancel', endDrag);+  window.addEventListener('resize', () => { if (menuPos) applyMenuPos(); });+  window._layers = { el:menuEl, place:placeMenu, pos:()=>menuPos, defaultPos:defaultMenuPos,+                     setPos:(l,t)=>{ menuPos={left:l,top:t}; applyMenuPos(); } };+   // His frameModel() mis-frames in this embed (it framed the 5000-unit skybox → camera at   // radius ~2500 m, so the ~3mm part is a sub-pixel dot). Run it for its spotlight/ground   // positioning, then set a sane mm-scale camera explicitly.@@ -435,7 +786,11 @@   document.getElementById('mode-pnp').onclick    = () => setMode('pnp');   document.getElementById('btn-ground').onclick = function(){ this.classList.toggle('on'); viewer.setGroundVisible(this.classList.contains('on')); matchColby(); };   // setGroundVisible rebuilds the ground opaque — re-apply his fade circle   document.getElementById('btn-bottom').onclick = function(){ this.classList.toggle('on'); if (bottomFill) bottomFill.intensity = this.classList.contains('on') ? 0.6 : 0; };-  document.getElementById('btn-layers').onclick = function(){ document.getElementById('layer-menu').classList.toggle('open'); this.classList.toggle('on'); };+  document.getElementById('btn-layers').onclick = function(){+    const open = !menuEl.classList.contains('open');+    menuEl.classList.toggle('open', open); this.classList.toggle('on', open);+    if (open) placeMenu();          // first open lands under the legend; later opens keep where you dragged it+  };    // Close the Layers menu on any click outside it (and outside the Layers button).   document.addEventListener('click', (e) => {