by adom
0402 SMD Red LED · 2.1 V drop · 25 mA max
← 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.
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 => ({'&':'&','<':'<','>':'>'}[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="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>@@ -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 && 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.@@ -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) => {