by adom
0402 SMD Red LED · 2.1 V drop · 25 mA max
master
AravK
Add hover tooltips and a floating draggable Layers panel to the interactive viewer. Tooltips follow the upstream viewer…
1d72f22
3mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
<meta charset="utf-8">
<title>IN-S42ATR — Static & Pick-and-Place (Colby's viewer + additions)</title>
<style>
:root{ --accent:#00b8b0; --panel:#161b22; --border:#30363d; }
html,body{ margin:0; height:100%; overflow:hidden; background:#14171d; color:#e6edf3;
font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
/* Colby's viewer mounts into #app and fills the viewport; our chrome overlays it. */
#app{ position:fixed; inset:0; }
#app canvas{ width:100%; height:100%; display:block; outline:none; }
header{ position:fixed; top:0; left:0; right:0; display:flex; align-items:center; gap:16px;
padding:10px 16px; z-index:10; pointer-events:none; }
header h1{ font-size:14px; font-weight:700; margin:0; pointer-events:auto;
text-shadow:0 1px 3px #0009; }
header .sub{ color:#a9b6c2; font-size:11px; }
.seg{ margin-left:auto; display:flex; background:var(--panel); border:1px solid var(--border);
border-radius:20px; padding:3px; pointer-events:auto; box-shadow:0 2px 8px #0006; }
.seg button{ background:transparent; border:0; color:#c9d1d9; padding:6px 16px; border-radius:16px;
font-size:12px; font-weight:600; cursor:pointer; font-family:inherit; }
.seg button.on{ background:var(--accent); color:#04231f; }
#legend{ position:fixed; top:112px; right:16px; z-index:10; background:var(--panel); border:1px solid var(--border);
border-radius:8px; padding:8px 11px; font:11px 'JetBrains Mono',ui-monospace,monospace; color:#c9d1d9;
display:none; min-width:150px; box-shadow:0 2px 8px #0006; }
#legend.show{ display:block; }
#legend .row{ display:flex; align-items:center; gap:7px; padding:2px 0; }
#legend .sw{ width:11px; height:11px; border-radius:2px; display:inline-block; }
.btn-row{ position:fixed; bottom:16px; left:50%; transform:translateX(-50%);
display:flex; gap:8px; z-index:10; }
.btn{ background:var(--panel); border:1px solid var(--border); color:#e6edf3; padding:6px 14px;
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); }
/* 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.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>
<div id="app"></div>
<header>
<h1>IN-S42ATR <span class="sub">· Colby's viewer + Static/Pick&Place additions · local</span></h1>
<div class="seg">
<button id="mode-static" class="on">Static</button>
<button id="mode-pnp">Pick & Place</button>
</div>
</header>
<div id="status">loading Colby's viewer…</div>
<div id="legend">
<div class="row"><span class="sw" style="background:#9fb4c8"></span> P&P nozzle (ghost)</div>
<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 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>
<button class="btn" id="btn-layers">Layers</button>
</div>
<!-- Colby/Kyle's ACTUAL shipped viewer bundle (window.Adom3DViewer). Unmodified. -->
<script>
(async () => {
const statusEl = document.getElementById('status');
// Wiki blobs render SANDBOXED (no allow-same-origin): localStorage/sessionStorage throw a
// SecurityError that aborts the viewer's SceneBuilder (leaving no lights / IBL / shadows /
// skybox). Shim both with no-ops BEFORE his bundle loads so his own init completes normally.
for (const k of ['localStorage','sessionStorage']) {
try { window[k] && window[k].getItem('x'); }
catch (e) { try { Object.defineProperty(window, k, { configurable:true, value:{ getItem:()=>null, setItem:()=>{}, removeItem:()=>{}, clear:()=>{}, key:()=>null, length:0 } }); } catch(_) {} }
}
// Wiki blob storage serves .js as application/octet-stream + nosniff, so a <script src>
// of the bundle is refused by the browser. Fetch it as text and inject it as an inline
// script (which executes). Behaves the same when served locally.
if (!window.Adom3DViewer) {
try {
const code = await (await fetch('./adom-3d-viewer.min.js')).text();
const s = document.createElement('script'); s.textContent = code; document.head.appendChild(s);
} catch (e) { statusEl.textContent = 'viewer bundle fetch failed: ' + e.message; return; }
}
const V = window.Adom3DViewer;
if (!V) { statusEl.textContent = 'Adom3DViewer bundle failed to load'; return; }
const B = V.BABYLON; // his Babylon core instance
try {
// 1) His viewer — his camera, SpotLight+ShadowGenerator, studio IBL, skybox, view cube, axes.
const viewer = V.init(document.getElementById('app'), { zUp: true, showViewCube: true, showGround: true });
// 2) Load our composite through his pipeline (auto-framed + shadowed).
await viewer.loadModel('./IN-S42ATR.insertion.glb');
const scene = viewer.getScene();
scene.animationGroups.slice().forEach(g => g.dispose()); // we drive our own motion
// Match Colby's CURRENT viewer EXACTLY (native /viewer/3d/, build ?v=20260717e). The shipped bundle
// is older and differs two ways, both verified live against his native scene:
// SKY — ships a flat grey (clearColor + skybox primaryColor 0.75, environmentIntensity 1.5) plus a
// BackgroundSkybox mesh. His current build has NO skybox mesh: just clearColor = SKILL_BG_BOTTOM
// (#2a3340) + environmentIntensity 1.0 + the studio env for PBR reflections.
// GROUND — ships an opaque square shadowGround (the "grey rectangle"). His is the same square GroundMesh
// + PBR groundMat (baseColor 0.15) but with a 256px radial-fade baseTexture + alpha 0.2
// (ALPHABLEND), so it reads as a soft CIRCLE. We rebuild that fade texture here.
const bgBot = new B.Color3(0.165, 0.200, 0.251); // SKILL_BG_BOTTOM = #2a3340
const Color4 = scene.clearColor && scene.clearColor.constructor; // Color4 isn't on the bundle's B — harvest it
let _groundFade = null;
const groundFadeTex = () => { // his radial fade: opaque centre -> transparent rim
if (_groundFade) return _groundFade;
const t = new B.DynamicTexture('groundFadeTex', 256, scene, true);
const c = t.getContext();
const g = c.createRadialGradient(128,128,6, 128,128,128);
g.addColorStop(0.0, 'rgba(255,255,255,1)');
g.addColorStop(0.55,'rgba(255,255,255,0.6)');
g.addColorStop(1.0, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(0,0,256,256); t.update(); t.hasAlpha = true;
_groundFade = t; return t;
};
const matchColby = () => { // idempotent; also called after each re-frame
if (Color4) scene.clearColor = new Color4(bgBot.r, bgBot.g, bgBot.b, 1);
scene.environmentIntensity = 1.0;
scene.meshes.filter(m => /^Background/.test(m.name)).forEach(m => m.setEnabled(false)); // no skybox, like his native
const gnd = scene.getMeshByName('shadowGround');
if (gnd && gnd.material) {
const m = gnd.material;
if (!(m.baseTexture && m.baseTexture.name === 'groundFadeTex')) {
if ('baseTexture' in m) m.baseTexture = groundFadeTex();
m.alpha = 0.2; m.transparencyMode = 2; m.backFaceCulling = false; // his soft ALPHABLEND circle
}
gnd.receiveShadows = true;
}
};
matchColby();
const led = scene.getNodeByName('IN-S42ATR');
if (led) led.rotationQuaternion = null; // glTF sets a quaternion that would ignore rotation.z — use Euler
const ledLit = scene.getNodeByName('LED_lit');
const fovCone = scene.getNodeByName('fov_cone');
const ptLight = scene.lights.find(l => l.getClassName && l.getClassName() === 'PointLight');
const modelParent = (led && led.parent) || null; // share the model's coordinate space
// Bottom-fill light: the shipped bundle exposes no setBottomLight() and builds no bottomLight,
// so create our own (matching his design — a downward hemispheric that lights the part's
// underside), using his Babylon instance via the existing hemi's constructor. Toggled by the button.
let bottomFill = scene.getLightByName('bottomLight');
if (!bottomFill) {
const hemi = scene.getLightByName('light') || scene.lights.find(l => l.getClassName && l.getClassName() === 'HemisphericLight');
if (hemi) {
const HL = hemi.constructor;
bottomFill = new HL('our_bottom_fill', new B.Vector3(0,0,-1), scene);
bottomFill.diffuse = new B.Color3(1,1,1); bottomFill.groundColor = new B.Color3(0.5,0.5,0.5);
bottomFill.intensity = 0;
}
}
// Replicate his loadModel shadow step (ThreeDViewer.svelte ~159-166): register the loaded
// model as shadow casters/receivers. (His current source does this; the shipped bundle is
// older, so we do it here — same effect, no change to his code.) refreshRate=1 keeps the
// cast shadow updating as the LED animates.
const sg = viewer.getShadowGenerator && viewer.getShadowGenerator();
// Shadows for EVERYTHING: every content mesh casts AND receives; the ground only receives;
// skybox / view-cube / axes are environment helpers (neither). Called once after ALL geometry
// (model + tape/reel + ghosts) is built so nothing is missed. transparencyShadow lets the
// translucent ghosts (nozzle, cover tape) cast alpha-weighted shadows instead of hard ones.
const shadowed = new Set();
const registerShadows = () => {
if (!sg) return;
if (sg.setTransparencyShadow) sg.setTransparencyShadow(true); else sg.transparencyShadow = true;
scene.meshes.forEach(m => {
if (!m.getTotalVertices || m.getTotalVertices() === 0) return;
if (/skyBox|viewCube|axis|gizmo|fov/i.test(m.name)) return; // FOV cone is a light-field indicator, not a solid — no shadows
m.receiveShadows = true; // everything receives (incl. ground)
if (!/shadowGround|^ground$/i.test(m.name) && !shadowed.has(m)) { // ground receives but doesn't cast
sg.addShadowCaster(m, true); shadowed.add(m); // (Set guard avoids duplicate casters)
}
});
const smap = sg.getShadowMap(); if (smap) smap.refreshRate = 1;
};
// 3) Translucent ghosts (nozzle + carrier tape + component-in-tape), built with HIS Babylon.
const PX = 0.0065, LED_TOP = 0.00045;
// The shipped bundle exposes only a few BABYLON constructors; harvest TransformNode
// from his scene (ViewerRoot) and use StandardMaterial (exposed) for the translucent ghosts.
const TN = (scene.transformNodes[0] && scene.transformNodes[0].constructor) || null;
const makeGroup = (name) => TN ? new TN(name, scene) : B.MeshBuilder.CreateBox(name, { size:1e-6 }, scene);
function ghostMat(name, rgb, alpha){
const m = new B.StandardMaterial(name, scene);
m.diffuseColor = new B.Color3(rgb[0],rgb[1],rgb[2]);
m.emissiveColor = new B.Color3(rgb[0]*0.5, rgb[1]*0.5, rgb[2]*0.5); // self-lit so it reads as a translucent ghost
m.specularColor = new B.Color3(0.1,0.1,0.1);
m.alpha = alpha; m.backFaceCulling = false; m.disableLighting = false;
return m;
}
const ghostRoot = makeGroup('pnp_ghosts');
ghostRoot.parent = modelParent;
// ---- Datasheet carrier-tape constants (Inolux IN-S42AT V1.1, p.9 "Packing Size") ----
const mm = 0.001;
const TW=8.00*mm, TT=0.20*mm, TP1=4.00*mm, TE=1.75*mm, TF=3.50*mm, TDo=1.55*mm, TP2=2.00*mm, TAo=0.57*mm, TBo=1.12*mm, TKo=0.55*mm;
const NPOCK = 3, PKX = 7.0*mm; // pockets shown; active pocket X (nozzle picks here). Set clear of the FR4 board (board ends at +1.6mm; tape starts ~+4mm)
const ySpr = TW/2 - TE, PKY = ySpr - TF; // sprocket-hole row Y; component-pocket row Y (= pick Y)
const nozzle = makeGroup('pnp_nozzle'); nozzle.parent = ghostRoot;
const nozMat = ghostMat('nozMat', [0.62,0.70,0.78], 0.34, 0.6, 0.3);
const tip = B.MeshBuilder.CreateCylinder('nozzle_tip', { height:0.0006, diameterBottom:0.0004, diameterTop:0.0008, tessellation:40 }, scene);
tip.rotation.x = Math.PI/2; tip.position = new B.Vector3(0,0,0.0003);
const shaft = B.MeshBuilder.CreateCylinder('nozzle_shaft', { height:0.0032, diameter:0.0008, tessellation:40 }, scene);
shaft.rotation.x = Math.PI/2; shaft.position = new B.Vector3(0,0,0.0022);
[tip, shaft].forEach(m => { m.material = nozMat; m.parent = nozzle; });
nozzle.position = new B.Vector3(PKX, PKY, 0.0032);
// ---- CAD-accurate embossed carrier tape (Inolux IN-S42AT V1.1, p.9), lying FLAT ----
const pocketXs = Array.from({length:NPOCK}, (_,k)=> PKX + k*TP1);
// sprocket holes: pitch Po, offset P2 from the pockets; base extends 1mm past the outermost holes
const holeXs = pocketXs.map(x => x - TP2).concat([pocketXs[pocketXs.length-1] + (TP1 - TP2)]);
const tMinX = Math.min(...holeXs) - 0.001, tMaxX = Math.max(...holeXs) + 0.001, tLen = tMaxX - tMinX, tCx = (tMinX+tMaxX)/2;
const solidMat = (name,rgb) => { const m=new B.StandardMaterial(name,scene); m.diffuseColor=new B.Color3(rgb[0],rgb[1],rgb[2]); m.emissiveColor=new B.Color3(rgb[0]*0.55,rgb[1]*0.55,rgb[2]*0.55); m.specularColor=new B.Color3(0.1,0.1,0.1); m.backFaceCulling=false; return m; };
const tapeMat = solidMat('tapeMat', [0.90,0.85,0.73]); // opaque BEIGE carrier tape (realistic)
const wallMat = solidMat('wallMat', [0.62,0.56,0.45]); // embossed pocket cavity (darker beige)
const holeMat = solidMat('holeMat', [0.05,0.05,0.06]); // sprocket holes (dark)
const coverMat = ghostMat('coverMat', [0.62,0.76,0.92], 0.09); // clear cover-tape film (stays translucent)
const POCKET_LED_Z = -TKo + 0.00003; // LED seated on the pocket floor (fully recessed: Ko 0.55 deep, LED 0.45 tall -> top ~0.1mm below the tape surface, as in the reel)
const IN_TAPE_ROT = -Math.PI/2; // cathode faces +Y (toward sprockets), per datasheet
const WT = 0.00004; // pocket wall thickness
const tape = makeGroup('tape_reel'); tape.parent = ghostRoot;
// base film with the pocket band OPEN at each pocket (embossed pockets + LEDs show through, not covered)
const yPocT = PKY + TBo/2, yPocB = PKY - TBo/2;
const strip = (w,h,cx,cy,nm) => { const m=B.MeshBuilder.CreateBox(nm,{width:w,height:h,depth:TT},scene); m.position=new B.Vector3(cx,cy,-TT/2); m.material=tapeMat; m.parent=tape; };
// strip above the pockets — SMOOTH circular sprocket holes in a SOLID slab (thickness TT, matching the
// rest of the tape) so the beige part reads as a physical part, not zero-thickness film. Hand-built:
// radial-tessellated TOP + BOTTOM faces (clean vector circles), cylindrical WALLS through each hole,
// and outer EDGE walls around the strip. Buffers written directly (no earcut/CSG needed).
const HSEG = 72, holeR = TDo/2, holeCy = ySpr, zTop = 0, zBot = -TT;
const sortedHx = holeXs.slice().sort((a,b)=>a-b);
const bandB = [tMinX]; // vertical band boundaries = midpoints between holes
for (let i=0;i<sortedHx.length-1;i++) bandB.push((sortedHx[i]+sortedHx[i+1])/2);
bandB.push(tMaxX);
const P = [], NR = [], IDX = [];
const addV = (x,y,z,nx,ny,nz) => { P.push(x,y,z); NR.push(nx,ny,nz); return P.length/3 - 1; };
sortedHx.forEach((cx,hi) => {
const bx0 = bandB[hi], bx1 = bandB[hi+1];
const tIn=[], tOut=[], bIn=[], bOut=[], wT=[], wB=[]; // top/bottom face rings + hole-wall rings
for (let k=0;k<=HSEG;k++){
const th = 2*Math.PI*k/HSEG, dx = Math.cos(th), dy = Math.sin(th);
const ix = cx + holeR*dx, iy = holeCy + holeR*dy; // point ON the hole circle
let t = Infinity; // ray from hole centre to the band-rectangle edge
if (dx > 1e-12) t = Math.min(t,(bx1-cx)/dx); else if (dx < -1e-12) t = Math.min(t,(bx0-cx)/dx);
if (dy > 1e-12) t = Math.min(t,(TW/2-holeCy)/dy); else if (dy < -1e-12) t = Math.min(t,(yPocT-holeCy)/dy);
const ox = cx + t*dx, oy = holeCy + t*dy; // point on the rectangle edge
tIn.push(addV(ix,iy,zTop, 0,0,1)); tOut.push(addV(ox,oy,zTop, 0,0,1)); // TOP face ring
bIn.push(addV(ix,iy,zBot, 0,0,-1)); bOut.push(addV(ox,oy,zBot, 0,0,-1)); // BOTTOM face ring
wT.push(addV(ix,iy,zTop, -dx,-dy,0)); wB.push(addV(ix,iy,zBot, -dx,-dy,0)); // hole WALL ring (inward normal)
}
for (let k=0;k<HSEG;k++){
IDX.push(tIn[k], tIn[k+1], tOut[k], tOut[k], tIn[k+1], tOut[k+1]); // top face annulus
IDX.push(bIn[k], bOut[k], bIn[k+1], bOut[k], bOut[k+1], bIn[k+1]); // bottom face annulus (reversed)
IDX.push(wT[k], wB[k], wT[k+1], wT[k+1], wB[k], wB[k+1]); // cylindrical hole wall
}
});
// outer edge walls around the strip rectangle [tMinX,tMaxX] x [yPocT,TW/2] — gives the tape edges thickness
const edgeWall = (x0,y0,x1,y1,nx,ny) => { const a=addV(x0,y0,zTop,nx,ny,0), b=addV(x1,y1,zTop,nx,ny,0), c=addV(x0,y0,zBot,nx,ny,0), d=addV(x1,y1,zBot,nx,ny,0); IDX.push(a,c,b, b,c,d); };
edgeWall(tMinX,TW/2, tMaxX,TW/2, 0,1); // top edge (free edge of the tape)
edgeWall(tMaxX,yPocT, tMinX,yPocT, 0,-1); // bottom edge (meets the pocket band)
edgeWall(tMinX,yPocT, tMinX,TW/2, -1,0); // left end
edgeWall(tMaxX,TW/2, tMaxX,yPocT, 1,0); // right end
const topStrip = B.MeshBuilder.CreateGround('tape_top', { width:1, height:1, subdivisions:1 }, scene);
topStrip.setVerticesData('position', P);
topStrip.setVerticesData('normal', NR);
topStrip.setVerticesData('uv', new Array((P.length/3)*2).fill(0));
topStrip.setIndices(IDX);
topStrip.position = new B.Vector3(0,0,0); topStrip.material = tapeMat; topStrip.parent = tape; // coords baked into vertices
if (topStrip.refreshBoundingInfo) topStrip.refreshBoundingInfo();
strip(tLen, yPocB + TW/2, tCx, (-TW/2 + yPocB)/2, 'tape_bot'); // strip below the pockets
const openHalf = TAo/2 + WT; // pocket-band fillers between the open pockets
const edges = [tMinX, ...pocketXs.flatMap(x => [x-openHalf, x+openHalf]), tMaxX];
for (let i=0;i<edges.length;i+=2){ const a=edges[i], b=edges[i+1]; if (b-a>1e-6) strip(b-a, TBo, (a+b)/2, PKY, 'tape_band'+i); }
// (sprocket holes are real cut-outs in the top strip above — no dark discs)
pocketXs.forEach((x,k) => { // embossed pocket = 4 walls + floor (exact Ao x Bo x Ko, open top)
const w = (ww,hh,dd,dx,dy,dz,nm) => { const m=B.MeshBuilder.CreateBox(nm,{width:ww,height:hh,depth:dd},scene); m.position=new B.Vector3(x+dx,PKY+dy,dz); m.material=wallMat; m.parent=tape; };
w(WT, TBo, TKo, TAo/2, 0, -TKo/2, 'pw'+k+'A'); w(WT, TBo, TKo, -TAo/2, 0, -TKo/2, 'pw'+k+'B');
w(TAo, WT, TKo, 0, TBo/2, -TKo/2, 'pw'+k+'C'); w(TAo, WT, TKo, 0, -TBo/2, -TKo/2, 'pw'+k+'D');
w(TAo, TBo, WT, 0, 0, -TKo, 'pf'+k);
});
pocketXs.forEach((x,k) => { // actual LED models in NON-active pockets (cathode-correct rotation)
if (Math.abs(x - PKX) < 1e-9 || !led) return;
const c = led.clone('tape_led_'+k, tape);
if (!c) return;
c.getDescendants(false).forEach(d => { if (d.getClassName && /Light/.test(d.getClassName())) d.dispose(); });
c.rotationQuaternion = null; // same: use Euler so the 90° in-pocket rotation applies
c.position = new B.Vector3(x, PKY, POCKET_LED_Z); c.rotation = new B.Vector3(0, 0, IN_TAPE_ROT);
});
const cover = B.MeshBuilder.CreateBox('cover_tape', { width:tLen, height:TW*0.6, depth:0.00003 }, scene); // clear cover tape
cover.position = new B.Vector3(tCx, PKY + TBo*0.2, 0.00004); cover.material = coverMat; cover.parent = tape;
registerShadows(); // now that ALL geometry exists (model + tape/reel + nozzle + pocket LEDs + cover), enable shadows on everything
if (fovCone) [fovCone].concat(fovCone.getChildMeshes ? fovCone.getChildMeshes() : []).forEach(m => { if ('receiveShadows' in m) m.receiveShadows = false; if (sg && sg.removeShadowCaster) sg.removeShadowCaster(m); }); // FOV cone: no shadows (belt-and-suspenders vs the /fov/ exclusion)
// 4) Motion — manual per-frame interpolation (no baked groups); positions in the model's space.
const LIFT=0.00135, NHI=0.0032, NLO=0.00047, CARRY=0.0027;
function lerpKeys(keys, t){
for (let i=0;i<keys.length-1;i++){ const a=keys[i], b=keys[i+1];
if (t>=a[0] && t<=b[0]){ const u=(t-a[0])/(b[0]-a[0]); return [a[1]+(b[1]-a[1])*u, a[2]+(b[2]-a[2])*u, a[3]+(b[3]-a[3])*u]; } }
const l = keys[keys.length-1]; return [l[1],l[2],l[3]];
}
function lerp1(keys, t){ // scalar interpolation (LED pick rotation)
for (let i=0;i<keys.length-1;i++){ const a=keys[i], b=keys[i+1];
if (t>=a[0] && t<=b[0]){ const u=(t-a[0])/(b[0]-a[0]); return a[1]+(b[1]-a[1])*u; } }
return keys[keys.length-1][1];
}
const bobKeys = [[0,0,0,0],[1.2,0,0,0],[3.0,0,0,LIFT],[4.2,0,0,LIFT],[6.0,0,0,0]];
const nozKeys = [[0,PKX,PKY,NHI],[1.0,PKX,PKY,NLO],[1.9,PKX,PKY,NHI],[2.7,PKX,PKY,NHI],[3.6,0,0,NHI],[4.4,0,0,NLO],[5.2,0,0,NHI],[6.8,PKX,PKY,NHI],[8.0,PKX,PKY,NHI]];
const ledKeys = [[0,PKX,PKY,POCKET_LED_Z],[1.0,PKX,PKY,POCKET_LED_Z],[1.9,PKX,PKY,CARRY],[2.7,PKX,PKY,CARRY],[3.6,0,0,CARRY],[4.4,0,0,0],[6.8,0,0,0],[8.0,PKX,PKY,POCKET_LED_Z]];
// LED lifts STRAIGHT UP out of the pocket (still at the in-tape angle, so it never clips the pocket walls),
// THEN the nozzle rotates it to the board orientation (0°) in the air, then carries it over and places it.
// In-tape angle = IN_TAPE_ROT (cathode toward the sprocket holes, per datasheet); board = 0° (cathode −X).
const rotKeys = [[0,IN_TAPE_ROT],[1.9,IN_TAPE_ROT],[2.7,0],[8.0,0]];
let t0 = performance.now();
scene.onBeforeRenderObservable.add(() => {
// keep the shadow map re-rendering so the cast shadow follows the animated LED
if (sg) { const sm = sg.getShadowMap(); if (sm && sm.refreshRate !== 1) sm.refreshRate = 1; }
const t = (performance.now() - t0) / 1000;
if (mode === 'static') {
const p = lerpKeys(bobKeys, t % 6);
[led, ledLit, fovCone].forEach(n => { if (n) n.position.set(p[0], p[1], p[2]); });
if (led) led.rotation.z = 0;
} else {
const tt = t % 8;
const np = lerpKeys(nozKeys, tt); nozzle.position.set(np[0], np[1], np[2]);
const lp = lerpKeys(ledKeys, tt); if (led) { led.position.set(lp[0], lp[1], lp[2]); led.rotation.z = lerp1(rotKeys, tt); }
const show = tt <= 6.8; if (led && led.isEnabled() !== show) led.setEnabled(show); // in the pocket from t=0, hidden only during the loop reset
}
});
// 5) Layers — includes the two the wiki viewer lacks: Light (LED_lit) and FOV (fov_cone).
const layers = [
{ key:'light', label:'Light (LED lit)', get:()=>ledLit, extra:on=>{ if(ptLight) ptLight.setEnabled(on); } },
{ key:'fov', label:'FOV (130° cone)', get:()=>fovCone },
{ key:'body', label:'LED body', get:()=>led, grp:true },
{ key:'board', label:'fr4_board', get:()=>scene.getNodeByName('fr4_board') },
{ key:'pad', label:'pad_top', get:()=>scene.getNodeByName('pad_top') },
{ key:'silk', label:'silk', get:()=>scene.getNodeByName('silk') },
{ key:'solder', label:'solder_top', get:()=>scene.getNodeByName('solder_top') },
];
const state = { light:true, fov:true, body:true, board:true, pad:true, silk:true, solder:true };
const listEl = document.getElementById('layer-list'); const rows = {};
const setNodeEnabled = (node,on)=>{ if(!node) return; node.setEnabled(on); if(node.getChildMeshes) node.getChildMeshes().forEach(c=>c.setEnabled(on)); };
layers.forEach(L => {
const row = document.createElement('label'); if (L.grp) row.className='grp';
const cb = document.createElement('input'); cb.type='checkbox'; cb.checked=state[L.key];
cb.onchange = () => { state[L.key]=cb.checked; setNodeEnabled(L.get(),cb.checked); if(L.extra)L.extra(cb.checked); };
row.appendChild(cb); row.append(' '+L.label); listEl.appendChild(row); rows[L.key]={row,cb};
});
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.
function applyFrame(mode){
try { viewer.frameModel(); } catch (e) {}
const c = scene.activeCamera;
c.lowerRadiusLimit = 0.0015; c.upperRadiusLimit = 0.08; c.minZ = 0.00005; c.maxZ = 10;
c.alpha = 1.05; c.beta = 1.0;
if (mode === 'pnp') { c.target.copyFromFloats(0.003, 0, 0.0006); c.radius = 0.026; }
else { c.target.copyFromFloats(0, 0, -0.0002); c.radius = 0.0065; }
matchColby(); // frameModel may rebuild the ground/skybox — re-apply his ground fade + no-skybox
}
// 6) Mode switching — ghosts are a content root only in P&P (so Static frames just the part).
let mode = 'static';
function setMode(m){
mode = m; t0 = performance.now();
document.getElementById('mode-static').classList.toggle('on', m==='static');
document.getElementById('mode-pnp').classList.toggle('on', m==='pnp');
document.getElementById('legend').classList.toggle('show', m==='pnp');
if (m==='static'){
setNodeEnabled(ghostRoot, false); viewer.removeContentRoot(ghostRoot);
lockLayer('light',false); lockLayer('fov',false);
if (led) setNodeEnabled(led, true);
if (ledLit) setNodeEnabled(ledLit, state.light); if (ptLight) ptLight.setEnabled(state.light);
if (fovCone) setNodeEnabled(fovCone, state.fov);
applyFrame(m);
statusEl.textContent = 'STATIC · LED insertion (up/down) · toggle Light & FOV in Layers';
} else {
setNodeEnabled(ghostRoot, true); viewer.addContentRoot(ghostRoot, { castShadows:true, receiveShadows:true });
if (ledLit) setNodeEnabled(ledLit, false); if (fovCone) setNodeEnabled(fovCone, false); if (ptLight) ptLight.setEnabled(false);
lockLayer('light',true); lockLayer('fov',true);
if (led) setNodeEnabled(led, true);
applyFrame(m);
statusEl.textContent = 'PICK & PLACE · nozzle picks LED from tape → places on pads';
}
}
// 7) Buttons — Ground / Bottom light call HIS API; Layers is our menu.
document.getElementById('mode-static').onclick = () => setMode('static');
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(){
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) => {
const menu = document.getElementById('layer-menu'), btn = document.getElementById('btn-layers');
if (menu.classList.contains('open') && !menu.contains(e.target) && !btn.contains(e.target)) {
menu.classList.remove('open'); btn.classList.remove('on');
}
}, true);
setMode('static');
window._v = { viewer, scene, B, setMode }; // debug hook
} catch (e) { statusEl.textContent = 'init error: ' + e.message; window._err = (e.stack || e.message); console.error(e); }
})();
</script>