<meta charset="utf-8">
<title>IN-S63BTR — Static &amp; 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); }
  #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; }
  #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 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; }
  #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-S63BTR <span class="sub">· Colby's viewer + Static/Pick&amp;Place additions · local</span></h1>
  <div class="seg">
    <button id="mode-static" class="on">Static</button>
    <button id="mode-pnp">Pick &amp; 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&amp;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>Layers</h2><div id="layer-list"></div></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-S63BTR.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-S63BTR');
  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.00060;   // 0603 body height ~0.6mm (datasheet)
  // 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-S63BT Series V1.0, p.10 "Tape Dimension", EIA-481, 8mm/4000pcs) ----
  // Numerically dimensioned on the datasheet: W 8.00 (+0.30/-0.10), T 0.23, P1/P0 4.00, E 1.75, F 3.50, Do 1.5 (+0.1/-0),
  // P2 2.00, Ko 0.70.  Ao/Bo (pocket W/L) are NOT numerically dimensioned on the sheet -> derived from the datasheet
  // component body (1.6 x 0.8mm, p.1 Package Dimensions) + EIA-481 clearance (part sits rotated: 1.6 long -> Y=Bo, 0.8 -> X=Ao).
  const mm = 0.001;
  const TW=8.00*mm, TT=0.23*mm, TP1=4.00*mm, TE=1.75*mm, TF=3.50*mm, TDo=1.5*mm, TP2=2.00*mm, TAo=0.95*mm, TBo=1.85*mm, TKo=0.70*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-S63BT Series V1.0, p.10), 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.70 deep, LED 0.60 tall -> top ~0.07mm 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.0018, NHI=0.0032, NLO=0.00060, CARRY=0.0027;   // LIFT = 3x scaled body height (0.6mm); NLO reaches the recessed 0603 LED top
  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 (120° 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; };

  // 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(){ document.getElementById('layer-menu').classList.toggle('open'); this.classList.toggle('on'); };

  // 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>