master
John Lauer Footprint per the manufacturer's land pattern (kept), derived viewers/lbr/hero rebuilt, lbr-hover on both viewers, READ… 5381890 17d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IN-S42ATR — Timeline · Static · Pick-and-Place (Colby's viewer + additions)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<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; }
  #status .manual{ color:var(--accent); }

  /* ---- Story caption card -------------------------------------------------
     A popup that explains whatever the reel is showing right now, then leaves.
     Bottom-left, clear of the ViewCube (top-right) and the button row (bottom-
     centre). Opacity/offset are driven per-frame from the timeline, so the fade
     is in lockstep with the geometry it describes — no CSS transitions. */
  #caption{ position:fixed; left:16px; bottom:60px; z-index:12; max-width:352px;
    background:linear-gradient(180deg, rgba(22,27,34,0.96), rgba(18,22,28,0.96));
    border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:0 8px 8px 0;
    padding:11px 15px 12px; box-shadow:0 6px 22px #0009; opacity:0; pointer-events:none; }
  #caption .eyebrow{ font:10px/1 'JetBrains Mono',ui-monospace,monospace; letter-spacing:.14em;
    text-transform:uppercase; color:var(--accent); }
  #caption .head{ font-size:17px; font-weight:700; color:#f0f6fc; margin-top:5px; letter-spacing:-.01em; }
  #caption .rows{ margin-top:8px; display:flex; flex-direction:column; gap:3px; }
  #caption .kv{ display:flex; justify-content:space-between; gap:16px;
    font:11.5px/1.35 'JetBrains Mono',ui-monospace,monospace; }
  #caption .kv i{ color:#8b949e; font-style:normal; }
  #caption .kv b{ color:#e6edf3; font-weight:500; text-align:right; }
  #caption .kv.mdl b{ color:#9db4c8; }                 /* model-measured, not datasheet — visually distinct */
  #caption .note{ font-size:12px; line-height:1.45; color:#b6c2ce; margin-top:7px; }
  #caption .src{ font:10px/1.3 'JetBrains Mono',ui-monospace,monospace; color:#6e7781; margin-top:8px;
    padding-top:6px; border-top:1px solid rgba(255,255,255,.08); }

  /* ---- Story transport ----------------------------------------------------
     A hairline scrub bar across the very bottom that grows on hover, with a tick
     per chapter and a floating label. Click anywhere to seek. */
  #scrub{ position:fixed; left:0; right:0; bottom:0; height:4px; z-index:13; cursor:pointer;
    background:rgba(255,255,255,.07); transition:height .14s ease; }
  #scrub:hover, #scrub.hot{ height:9px; }
  #scrub-fill{ position:absolute; inset:0 auto 0 0; width:0; background:var(--accent); opacity:.85; }
  #scrub .tick{ position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.35); }
  #scrub-label{ position:fixed; bottom:14px; z-index:13; transform:translateX(-50%);
    background:rgba(30,34,38,0.94); color:#e6edf3; font:11px 'JetBrains Mono',ui-monospace,monospace;
    padding:4px 9px; border-radius:4px; white-space:nowrap; display:none; pointer-events:none;
    box-shadow:0 2px 8px #0008; }
  #scrub-label.show{ display:block; }

  /* ---- Top transport bar --------------------------------------------------
     A full-width scrubber pinned to the very top. Click or drag anywhere on it
     (or on its knob) to move through the timeline. Grows on hover; carries a
     tick per chapter and a draggable knob at the playhead. */
  #topbar{ position:fixed; top:0; left:0; right:0; height:6px; z-index:16; cursor:pointer;
    background:rgba(255,255,255,.08); transition:height .12s ease; touch-action:none; }
  #topbar:hover, #topbar.hot{ height:12px; }
  #topbar-fill{ position:absolute; inset:0 auto 0 0; width:0; background:var(--accent); opacity:.9; }
  #topbar .tick{ position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,.4); }
  #topbar-knob{ position:absolute; top:50%; left:0; width:13px; height:13px; border-radius:50%;
    background:#fff; border:2px solid var(--accent); transform:translate(-50%,-50%);
    box-shadow:0 1px 5px #000a; pointer-events:none; }
  #topbar-time{ position:fixed; top:16px; z-index:16; transform:translateX(-50%);
    background:rgba(30,34,38,0.94); color:#e6edf3; font:11px 'JetBrains Mono',ui-monospace,monospace;
    padding:4px 9px; border-radius:4px; white-space:nowrap; display:none; pointer-events:none;
    box-shadow:0 2px 8px #0008; }
  #topbar-time.show{ display:block; }

  /* Timeline chrome only exists in Timeline mode. */
  body:not(.story) #scrub, body:not(.story) #scrub-label, body:not(.story) #topbar,
  body:not(.story) #topbar-time,
  body:not(.story) #caption, body:not(.story) #btn-play, body:not(.story) #btn-replay{ display:none; }
</style>

<div id="app"></div>
<header>
  <h1>IN-S42ATR <span class="sub">· Colby's viewer + Timeline/Static/Pick&amp;Place additions · local</span></h1>
  <div class="seg">
    <button id="mode-story" class="on">Timeline</button>
    <button id="mode-static">Static</button>
    <button id="mode-pnp">Pick &amp; Place</button>
  </div>
</header>
<div id="status">loading the 3D 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 id="layer-drag"><span class="grip">⠿</span>Layers</h2><div id="layer-list"></div></div>
<div id="hover-tip"></div>
<div id="caption"><div class="eyebrow"></div><div class="head"></div><div class="rows"></div><div class="note"></div><div class="src"></div></div>
<div class="btn-row">
  <button class="btn on" id="btn-play">⏸ Pause</button>
  <button class="btn" id="btn-replay">↺ Replay</button>
  <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>
<div id="topbar"><div id="topbar-fill"></div><div id="topbar-knob"></div></div>
<div id="topbar-time"></div>
<div id="scrub"><div id="scrub-fill"></div></div>
<div id="scrub-label"></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('https://wiki.adom.inc/blob/component/in-s42atr/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 {

  // ---- config: captions, tooltips, chapters, and swappable models -------------
  // The Timeline text (captions), the hover tooltips, the chapter marks, and the tape/nozzle
  // model swaps all live in viewer-config.json, edited with ./viewer-cli. The hardcoded
  // defaults below (CAPS_DEFAULT / TIP_DEFAULT / CHAPTERS_DEFAULT) are the fallback when the
  // file is absent or a key is missing, so the viewer always works standalone. Merge rules:
  //   · captions / chapters : the config array, if present, REPLACES the default list.
  //   · tooltips : merged per id — config overrides title/sub/src (and rows, unless the
  //     tooltip's rows are computed from geometry); `enabled:false` drops it; new ids add.
  //   · assets : the tape/nozzle GLB swaps (was viewer-assets.json — still read as a fallback).
  let CFG = {};
  try { const r = await fetch('https://wiki.adom.inc/blob/component/in-s42atr/viewer-config.json', { cache:'no-store' }); if (r.ok) CFG = await r.json(); }
  catch(_) {}
  if (!CFG.assets) {                                // backward-compat: old viewer-assets.json
    try { const r = await fetch('https://wiki.adom.inc/blob/component/in-s42atr/viewer-assets.json', { cache:'no-store' }); if (r.ok) CFG.assets = await r.json(); }
    catch(_) {}
  }
  const _slug = s => String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
  // Merge a config tooltip over a default entry. Rows: a computed default keeps its function
  // (measurement provenance is not editable); a static default takes config rows if given.
  function mergeTip(def){
    const c = (CFG.tooltips || {})[def.id];
    if (!c) return def;
    if (c.enabled === false) return null;
    const out = { ...def };
    if (c.title != null) out.title = c.title;
    if (c.sub   != null) out.sub   = c.sub;
    if (c.src   != null && typeof def.src !== 'function') out.src = c.src;
    // config `rows` edits the DATASHEET rows only; the measured function is never overridden.
    if (Array.isArray(c.rows)) out.dsRows = c.rows;
    return out;
  }
  // Any config tooltips whose id isn't a default (user-added) — need a `match` regex + rows.
  function extraTips(defIds){
    return Object.entries(CFG.tooltips || {})
      .filter(([id,c]) => !defIds.has(id) && c && c.enabled !== false && c.match)
      .map(([id,c]) => ({ id, re:new RegExp(c.match), title:c.title||id, sub:c.sub||'',
                          src:c.src||'', dsRows:Array.isArray(c.rows)?c.rows:[] }));
  }

  // 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('https://wiki.adom.inc/blob/component/in-s42atr/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');
  // The composite ships the LED emitter as a POINT light — a full sphere, so it also throws red
  // onto the FR-4 below, which the opaque body would block in reality. Replace it with an
  // upward-facing SpotLight (≈ hemisphere): the die emits UP and out only, never down onto the
  // board. Harvest the SpotLight constructor from Colby's studio spot; keep the same position,
  // parent (so it follows the LED), colour, range and intensity so nothing else changes.
  let ptLight = scene.lights.find(l => l.getClassName && l.getClassName() === 'PointLight');
  if (ptLight) {
    const studioSpot = scene.lights.find(l => l.getClassName && l.getClassName() === 'SpotLight');
    const SpotCtor = studioSpot && studioSpot.constructor;
    if (SpotCtor) {
      const up = new B.Vector3(0, 0, 1);                                  // z-up scene → LED top is +Z
      const spot = new SpotCtor('led_emitter_up', ptLight.position.clone(), up, Math.PI * 0.92, 1.2, scene);  // ~166° cone ≈ upward hemisphere
      spot.diffuse  = (ptLight.diffuse  || new B.Color3(1, 0.03, 0.015)).clone();
      spot.specular = spot.diffuse.clone();
      spot.range     = ptLight.range || 0.004;
      spot.intensity = ptLight.intensity;                                 // PT_I reads this below → unchanged strength
      if (ptLight.parent) spot.parent = ptLight.parent;                   // follow the LED like the original
      spot.shadowEnabled = false;
      ptLight.dispose();                                                  // retire the full sphere
      ptLight = spot;
    }
  }
  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;

  // ---- self-contained GLB reader (no SceneLoader in the shipped bundle) -----
  // Reads a single-BIN-chunk .glb (or a .gltf with data: buffers), then builds each mesh
  // primitive with MeshBuilder + setVerticesData/setIndices — the same geometry path used
  // for the procedural tape above. glTF node transforms are NOT baked; the whole import is
  // placed by the manifest's scale/rotation/offset instead (predictable for CLI tuning).
  const _CT = { 5120:1, 5121:1, 5122:2, 5123:2, 5125:4, 5126:4 };
  const _NUM = { SCALAR:1, VEC2:2, VEC3:3, VEC4:4, MAT4:16 };
  async function loadGLB(url){
    const ab = await (await fetch(url, { cache:'no-store' })).arrayBuffer();
    const dv0 = new DataView(ab);
    let g = null, bin = null;
    if (dv0.getUint32(0, true) === 0x46546C67) {                 // 'glTF'
      let off = 12;
      while (off < ab.byteLength) {
        const len = dv0.getUint32(off, true), typ = dv0.getUint32(off+4, true); off += 8;
        const chunk = ab.slice(off, off+len); off += len;
        if (typ === 0x4E4F534A) g = JSON.parse(new TextDecoder().decode(chunk));      // JSON
        else if (typ === 0x004E4942) bin = chunk;                                     // BIN
      }
    } else { g = JSON.parse(new TextDecoder().decode(ab)); }
    const buffers = (g.buffers||[]).map(b => {
      if (!b.uri) return bin;
      if (/^data:/.test(b.uri)) { const s = atob(b.uri.split(',')[1]); const u = new Uint8Array(s.length);
        for (let i=0;i<s.length;i++) u[i]=s.charCodeAt(i); return u.buffer; }
      throw new Error('external buffer '+b.uri+' not supported — export a self-contained .glb');
    });
    const read = (ai, asIndex) => {
      const acc = g.accessors[ai], bv = g.bufferViews[acc.bufferView];
      const n = asIndex ? 1 : _NUM[acc.type], cs = _CT[acc.componentType];
      const base = (bv.byteOffset||0) + (acc.byteOffset||0), stride = bv.byteStride || cs*n;
      const dvb = new DataView(buffers[bv.buffer]); const out = new Array(acc.count*n);
      for (let i=0;i<acc.count;i++) for (let k=0;k<n;k++){ const o = base + i*stride + k*cs;
        out[i*n+k] = acc.componentType===5126 ? dvb.getFloat32(o,true)
                   : acc.componentType===5125 ? dvb.getUint32(o,true)
                   : acc.componentType===5123 ? dvb.getUint16(o,true)
                   : acc.componentType===5122 ? dvb.getInt16(o,true)
                   : acc.componentType===5120 ? dvb.getInt8(o) : dvb.getUint8(o); }
      return out;
    };
    const glMat = (mi, nm) => {
      const m = new B.StandardMaterial(nm, scene); let c = [0.80,0.80,0.83,1];
      if (mi!=null && g.materials && g.materials[mi]) {
        const pbr = g.materials[mi].pbrMetallicRoughness || {}; if (pbr.baseColorFactor) c = pbr.baseColorFactor; }
      m.diffuseColor = new B.Color3(c[0],c[1],c[2]);
      m.emissiveColor = new B.Color3(c[0]*0.25,c[1]*0.25,c[2]*0.25);
      m.specularColor = new B.Color3(0.10,0.10,0.10);
      if (c[3]!=null && c[3]<1) { m.alpha = c[3]; m.backFaceCulling = false; }
      return m;
    };
    const root = makeGroup('custom_asset_root'); let built = 0;
    (g.meshes||[]).forEach((mesh, mIdx) => (mesh.primitives||[]).forEach((prim, pIdx) => {
      if (prim.attributes.POSITION == null) return;
      const pos = read(prim.attributes.POSITION);
      let idx = prim.indices!=null ? read(prim.indices, true) : Array.from({length:pos.length/3}, (_,i)=>i);
      let nor = prim.attributes.NORMAL!=null ? read(prim.attributes.NORMAL) : null;
      if (!nor) {                                                // flat normals if the model omits them
        nor = new Array(pos.length).fill(0);
        for (let t=0;t<idx.length;t+=3){ const a=idx[t]*3,b=idx[t+1]*3,c=idx[t+2]*3;
          const ux=pos[b]-pos[a],uy=pos[b+1]-pos[a+1],uz=pos[b+2]-pos[a+2];
          const vx=pos[c]-pos[a],vy=pos[c+1]-pos[a+1],vz=pos[c+2]-pos[a+2];
          const nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx;
          [a,b,c].forEach(o=>{ nor[o]+=nx; nor[o+1]+=ny; nor[o+2]+=nz; }); }
      }
      const m = B.MeshBuilder.CreateGround('custom_'+mIdx+'_'+pIdx, { width:1, height:1, subdivisions:1 }, scene);
      m.setVerticesData('position', Array.from(pos));
      m.setVerticesData('normal', Array.from(nor));
      m.setVerticesData('uv', new Array((pos.length/3)*2).fill(0));
      m.setIndices(idx);
      if (m.refreshBoundingInfo) m.refreshBoundingInfo();
      m.material = glMat(prim.material, 'customMat_'+mIdx+'_'+pIdx); m.parent = root; built++;
    }));
    if (!built) throw new Error('no mesh primitives found in '+url);
    return root;
  }
  const applyXform = (node, spec) => {
    const s = spec.scale != null ? spec.scale : 0.001;           // default: model authored in mm
    node.scaling.set(s, s, s);
    const r = spec.rotDeg || [0,0,0]; node.rotationQuaternion = null;
    node.rotation.set(r[0]*Math.PI/180, r[1]*Math.PI/180, r[2]*Math.PI/180);
    const o = spec.offsetMM || [0,0,0]; node.position.set(o[0]*0.001, o[1]*0.001, o[2]*0.001);
  };
  // Swap a procedural node's geometry for an imported GLB, keeping the node (so existing
  // placement/animation still drives it). Returns true if a custom model replaced the default.
  const swapAsset = async (hostNode, spec, label) => {
    if (!spec || !spec.model) return false;
    const existing = hostNode.getChildMeshes ? hostNode.getChildMeshes().slice() : [];
    let grp;
    try { grp = await loadGLB('./' + spec.model); }
    catch (e) { statusEl.textContent = 'custom '+label+' failed: ' + e.message; console.warn(e); return false; }
    applyXform(grp, spec); grp.parent = hostNode;
    existing.forEach(m => m.setEnabled(false));                  // hide the procedural default
    return true;
  };
  window._assets = { active:{ tape:false, nozzle:false } };
  async function applyCustomAssets(tapeNode, nozzleNode){
    const manifest = CFG.assets;            // loaded up top (viewer-config.json, or legacy viewer-assets.json)
    if (!manifest) return;
    if (manifest.tape)   window._assets.active.tape   = await swapAsset(tapeNode,   manifest.tape,   'tape');
    if (manifest.nozzle) window._assets.active.nozzle = await swapAsset(nozzleNode, manifest.nozzle, 'nozzle');
  }

  // ---- CLI-swappable tape/reel + nozzle (viewer-assets.json) ----------------
  // The tape/reel and nozzle above are procedural and datasheet-exact — the DEFAULT. But a
  // user can drop in their own CAD via the `viewer-assets` CLI (writes viewer-assets.json +
  // copies the .glb next to this file). When the manifest names a model we parse the GLB
  // here (the shipped bundle exposes no SceneLoader, so we read the container ourselves — the
  // same MeshBuilder + setVerticesData path the tape above is built with) and swap it in:
  //   · tape:   procedural `tape` group hidden; custom meshes parented under it, so the reel
  //             sits in the same place the animation already reveals.
  //   · nozzle: procedural tip+shaft hidden; custom meshes parented under the `nozzle` node,
  //             so the EXISTING pick-and-place motion drives the new nozzle unchanged.
  // Each entry carries scale (scene-metres per model unit), rotation (deg) and offset (mm).
  await applyCustomAssets(tape, nozzle);

  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)

  // ---- 3b) FOV cone: beam-like falloff + an apex pivot so it GROWS out of the LED ----
  // The GLB ships the cone as bare geometry: apex on the LED's top face (z = +0.45 mm,
  // the datasheet body height) opening to Ø4.289 mm at z = 1.45 mm — atan(2.1445/1.0) =
  // 65.0°, i.e. exactly the datasheet 2θ½ = 130° half-angle. We keep that geometry
  // untouched (it IS the datasheet number) and change only how it READS:
  //   · flat 25%-opaque red slab  ->  a beam that thins with distance, because the
  //     further light travels from the die the more it has spread out. The primitive
  //     carries no UVs, so derive them from position: V = 0 at the apex, 1 at the base,
  //     and drive an alpha-ramp opacityTexture off V.
  //   · scaling a node scales about its ORIGIN (the model origin, on the board), which
  //     would grow the cone from the wrong end. Re-parent it under a pivot sitting AT
  //     the apex so scaling the pivot 0 -> 1 pushes the beam out of the LED itself.
  // Emission originates from the LED's top surface. Measure it rather than hard-coding:
  // the composite scales the body z by 0.9, so its top lands at the datasheet 0.45 mm
  // height (verified live: LED top = 0.45 mm world). The cone apex sits exactly there —
  // the light "starts from the LED", with no gap above it, in every mode.
  let FOV_EMIT = 0.00045;
  if (led) { let zt = -1e9; (led.getChildMeshes ? led.getChildMeshes() : []).forEach(m => {
      if (m.getTotalVertices && m.getTotalVertices() > 0) { m.computeWorldMatrix(true);
        const z = m.getBoundingInfo().boundingBox.maximumWorld.z; if (z > zt) zt = z; } });
    if (zt > -1e8) FOV_EMIT = zt; }                // world = model-space here (model at origin, unrotated)
  const FOV_ALPHA  = 0.30;                       // "70% transparent"
  let FOV_CHECK = null;
  if (fovCone) {
    const coneMeshes = [fovCone].concat(fovCone.getChildMeshes ? fovCone.getChildMeshes() : [])
                                .filter(m => m.getTotalVertices && m.getTotalVertices() > 0);
    // The shipped cone is a CAPPED solid with two rings of vertices. Capped, it reads as a
    // floating disc rather than a beam; two rings can only carry a straight-line falloff.
    // Rebuild it in place as an OPEN lateral surface with FOV_RINGS rings — same node, same
    // name, same datasheet geometry:
    //     base radius = FOV_LEN · tan(2θ½/2) = 1.0 mm · tan 65° = 2.1445 mm
    // which is the shipped cone's base radius to four decimals, so this is a re-derivation
    // of the datasheet number, not a new one. FOV_CHECK below asserts that at runtime.
    const FOV_2THETA = 130;                                   // datasheet 2θ½ (Inolux IN-S42AT p.4)
    const FOV_HALF   = (FOV_2THETA/2) * Math.PI/180;          // 65.0° half-angle
    const FOV_LEN    = 0.00100;                               // beam drawn 1.0 mm long, as the composite ships it
    const FOV_SEGS   = 96, FOV_RINGS = 26;
    const FOV_FLOOR  = 0.16, FOV_POW = 1.5;                   // how much beam is left at the rim, and the curve to it
    FOV_CHECK = FOV_LEN * Math.tan(FOV_HALF);                 // = 0.0021445 m — must equal the shipped cone's base radius
    coneMeshes.forEach(m => {
      const P = [], N = [], C = [], IDX = [];
      const ca = Math.cos(FOV_HALF), sa = Math.sin(FOV_HALF);
      for (let r=0; r<=FOV_RINGS; r++){
        const v  = r/FOV_RINGS;                               // 0 at the die, 1 at the rim
        // Apex at the mesh's LOCAL origin (0,0,0). We then position the node at the LED top
        // and uniformly scale it 0->1 to grow the beam, which scales about that local origin
        // — i.e. straight out of the die. No pivot node needed, so nothing can double the
        // offset (the old pivot + a bob that overwrote the node position put the apex 0.45 mm
        // too high in Static).
        const z  = FOV_LEN*v, rad = FOV_LEN*v*Math.tan(FOV_HALF);
        // Distance falloff baked into VERTEX ALPHA (not an opacity texture): a texture would
        // drag in DynamicTexture's invertY convention, and an RGB-luminance opacity map
        // REPLACES alpha rather than multiplying it, throwing away both material.alpha (the
        // 70% transparency) and mesh.visibility (the grow-in fade). Vertex alpha multiplies:
        //   final alpha = FOV_ALPHA · mesh.visibility · falloff(distance)
        const a = FOV_FLOOR + (1-FOV_FLOOR)*Math.pow(1-v, FOV_POW);
        for (let s=0; s<=FOV_SEGS; s++){
          const th = 2*Math.PI*s/FOV_SEGS, cx = Math.cos(th), cy = Math.sin(th);
          P.push(rad*cx, rad*cy, z);
          N.push(cx*ca, cy*ca, -sa);
          C.push(1, 1, 1, a);
        }
      }
      const W = FOV_SEGS + 1;
      for (let r=0; r<FOV_RINGS; r++) for (let s=0; s<FOV_SEGS; s++){
        const i0 = r*W+s, i1 = i0+1, i2 = i0+W, i3 = i2+1;
        IDX.push(i0, i2, i1,  i1, i2, i3);                    // ring 0 is degenerate (radius 0) — zero-area, harmless
      }
      m.setVerticesData('position', P, true);
      m.setVerticesData('normal', N, true);
      m.setVerticesData('color', C, true);
      m.setIndices(IDX);
      m.hasVertexAlpha = true;
      if (m.refreshBoundingInfo) m.refreshBoundingInfo();
      const fm = new B.StandardMaterial('fovBeamMat', scene);
      fm.diffuseColor  = new B.Color3(0,0,0);
      fm.emissiveColor = new B.Color3(1.55, 0.30, 0.24);                  // λD 631 nm red; over 1.0 to survive the scene's tone mapping
      fm.specularColor = new B.Color3(0,0,0);
      fm.disableLighting = true;                                          // a light field, not a lit surface
      fm.alpha = FOV_ALPHA;
      fm.backFaceCulling = false;                                         // near and far walls both draw -> denser at the silhouette, like a real beam
      fm.separateCullingPass = true;                                      // ...and in the right order
      m.material = fm;
      m.alphaIndex = 900;                                                 // draw after the solid part
    });
    // Apex now lives at the node's local origin; seat that origin on the LED top so the beam
    // emanates from the die. Both the Static bob and the Timeline reel set fovCone.position
    // to (ledX, ledY, ledZ + FOV_EMIT) and fovCone.scaling for the grow — see setConePose().
    fovCone.rotationQuaternion = null;
    fovCone.position = new B.Vector3(0, 0, FOV_EMIT);
    fovCone.scaling  = new B.Vector3(1, 1, 1);
  }
  // Single place that poses the cone: origin on the LED top (+ any body lift), uniform grow scale.
  const setConePose = (x, y, zBody, grow) => {
    if (!fovCone) return;
    fovCone.position.set(x, y, zBody + FOV_EMIT);
    const s = Math.max(grow, 1e-4); fovCone.scaling.set(s, s, s);
  };

  // 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;
  // The LED's underside terminations rest ON the pads — not on the bare board. Placing the body at
  // z=0 let the 0.035 mm pads (1 oz Cu, verified) and the 0.113 mm solder crowns poke up THROUGH it
  // (that's the clipping, in every mode). Seat the body just above the pad top instead. Because the
  // solder crown (0.113 mm) is HIGHER than this standoff, the fillets then rise around the body ends
  // — the solder wets / "melts into" the terminations — rather than the body perching on the domes.
  const STAND = 0.000039;   // 35 µm pad (1 oz) + ~4 µm clearance (no z-fight), in metres
  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,STAND],[1.2,0,0,STAND],[3.0,0,0,STAND+LIFT],[4.2,0,0,STAND+LIFT],[6.0,0,0,STAND]];   // rests ON the pad, bobs up from there
  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,STAND],[6.8,0,0,STAND],[8.0,PKX,PKY,POCKET_LED_Z]];   // places ON the pad (was 0 → clipped)
  // 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; }
    if (mode === 'story') { storyFrame(); return; }        // §5d — the explainer reel drives everything itself
    const t = (performance.now() - t0) / 1000;
    if (mode === 'static') {
      const p = lerpKeys(bobKeys, t % 6);
      [led, ledLit].forEach(n => { if (n) n.position.set(p[0], p[1], p[2]); });
      setConePose(p[0], p[1], p[2], 1);            // cone apex on the LED top (+bob), full size
      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) -------
  // EVERY number a tooltip or caption shows is either datasheet-cited (DS*) or measured
  // off the model (MEAS) — no exceptions, nothing invented. Verified page-by-page against
  // the source PDF (C:\Users\arav\Desktop\BasicPartsDatasheets\IN-S42AT Series_V1.1.pdf):
  //   p.3  Electrical Characteristics 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 typ.
  //   p.2  Absolute Max: IF 25 mA, IFP 100 mA, VR 5 V, Pd 78 mW.
  //   p.8  Label decode: "42A = 1.0 x 0.5 x 0.45mm", Top Mount, Clear lens.  p.1: 0402, AlInGaP.
  //   p.9  Packing Size table (READ OFF THE FIGURE, now verified): W 8.00, P1 4.00, E 1.75,
  //        F 3.50, Do 1.55, Po 4.00, P2 2.00, Ao 0.57, Bo 1.12, Ko 0.55, T 0.20; reel Ø178,
  //        hub Ø70, arbor Ø13.5, tape slot 9.0, width 12.0; 5000 pcs/reel.
  //   p.11 Reflow: tin-glue melt 178–192 °C.
  const DS      = 'Inolux IN-S42AT V1.1 · p.3 electro-optical table';
  const DS_MAX  = 'Inolux IN-S42AT V1.1 · p.2 absolute-max ratings';
  const DS_PKG  = 'Inolux IN-S42AT V1.1 · p.8 label + p.1 features';
  const DS_TAPE = 'Inolux IN-S42AT V1.1 · p.9 packing table (verified)';
  const DS_RFLW = 'Inolux IN-S42AT V1.1 · p.11 reflow profile';
  const MEAS    = 'measured from the model geometry';
  const REEL    = 'Ø178 reel · Ø70 hub · Ø13.5 arbor · 9.0 slot · 12.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_DEFAULT = [
    // ---------- optics ----------
    // Every entry = dsRows (the DATASHEET rows — the only editable rows, via viewer-cli) +
    // an optional `measured` function (rows computed live from the model geometry, never edited).
    // The tooltip shows dsRows first, then the measured rows. This keeps every stated spec
    // datasheet-cited and every measurement honest.
    { re:/^fov_cone/, title:'fov_cone', id:'fov-cone', sub:'emission cone', src:DS,
      dsRows:[['viewing angle 2θ½','130°'], ['half-angle','±65°'], ['λD / λP','631 / 645 nm']],
      measured:()=>{ const m = measure('fov_cone'); if (!m) return [];
        const h = m.all.size[2], rad = Math.max(m.all.size[0], m.all.size[1])/2;
        return [['cone (model)', 'Ø'+f2(rad*2)+' mm @ '+f2(h)+' mm'],
                ['half-angle (measured)', (Math.atan2(rad,h)*180/Math.PI).toFixed(1)+'°']]; } },
    { re:/^LED_lit/, title:'LED_lit', id:'led-lit', sub:'emitter glow layer', src:DS,
      dsRows:[['λD (dominant)','631 nm — red'], ['IV','90 mcd @ IF 20 mA'],
              ['VF','2.1 V typ · 2.6 V max'], ['2θ½','130°']],
      measured:()=>{ const m = measure('LED_lit'); return m ? [['emissive plane (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' mm']] : []; } },

    // ---------- the part ----------
    { re:/^IN-S42ATR/, title:'IN-S42ATR', id:'in-s42atr', sub:'Inolux 0402 red LED', src:DS+' + '+DS_PKG,
      dsRows:[['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°']],
      measured:()=>{ const m = measure('IN-S42ATR'); return m ? [['body (measured)', f2(m.all.size[0])+' × '+f2(m.all.size[1])+' × '+f2(m.all.size[2])+' mm']] : []; } },

    // ---------- board / footprint (rows all MEASURED off the composite geometry) ----------
    { re:/^fr4_board/, title:'fr4_board', id:'fr4-board', sub:'FR-4 substrate', src:MEAS+' (1.6 mm = standard FR-4 stack)',
      dsRows:[],
      measured:()=>{ 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', id:'pad-top', sub:'copper pads', src:MEAS+' (built from the IN-S42ATR footprint)',
      dsRows:[],
      measured:()=>{ 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', id:'solder-top', sub:'solder fillets', src:MEAS,
      dsRows:[],
      measured:()=>{ 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', id:'silk', sub:'silkscreen cathode marker', src:MEAS,
      dsRows:[],
      measured:()=>{ 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 (all DATASHEET rows, p.9) ----------
    { re:/^(pw\d|pf\d)/, title:'embossed pocket', id:'embossed-pocket', sub:'carrier-tape cavity', src:DS_TAPE,
      dsRows:[['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', id:'led-in-pocket', sub:'IN-S42ATR, as reeled', src:DS_TAPE+' + '+DS,
      dsRows:[['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', id:'tape-top', sub:'sprocket band', src:DS_TAPE,
      dsRows:[['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', id:'tape-bot', sub:'carrier tape body', src:DS_TAPE,
      dsRows:[['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', id:'tape-band', sub:'carrier band between pockets', src:DS_TAPE,
      dsRows:[['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', id:'cover-tape', sub:'heat-seal cover film', src:DS_TAPE,
      dsRows:[['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', id:'tape-reel', sub:'Inolux packing', src:DS_TAPE,
      dsRows:[['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', id:'pnp-nozzle', sub:'placement nozzle (ghost)',
      src:()=>NOZZLE_SPEC.src, dsRows:[],
      measured:()=>{ 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', id:'pick-place', sub:'ghost assembly', src:DS_TAPE,
      dsRows:[['scene','nozzle + carrier tape + reeled parts'], ['tape','8 mm, P1 4.00 mm'], ['reel', REEL]] },
  ];

  // Merge viewer-config.json over the defaults (see mergeTip / extraTips above).
  const _tipDefIds = new Set(TIP_DEFAULT.map(e => e.id));
  const TIP_TABLE = TIP_DEFAULT.map(mergeTip).filter(Boolean).concat(extraTips(_tipDefIds));

  // 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;
    // datasheet rows (editable via viewer-cli) first, then the live measured rows (computed).
    const ds = e.dsRows || (Array.isArray(e.rows) ? e.rows : []);
    const meas = e.measured ? (e.measured(nodeOrName) || [])
               : (typeof e.rows === 'function' ? (e.rows(nodeOrName) || []) : []);
    return { title:e.title, sub:e.sub || '', mesh:nm,
             rows: ds.concat(meas),
             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 };

  // ============================================================================
  // 5d) STORY — the explainer reel
  //
  // Replaces the old "rise and fall" bob with a narrated reel that actually
  // teaches the part: it lights up, shows you the beam it throws and tells you
  // what that cone means, lifts off the board to reveal the solder underneath,
  // sets back down onto it, then shows where the part came from (the carrier
  // tape) before returning to a still hero frame and holding.
  //
  // Everything the captions state is either a datasheet number or measured off
  // the loaded geometry — see BALL_* below, which read the real deposits rather
  // than quoting a figure.
  //
  // Camera: scripted keyframes + a slow drift so the frame is never dead. The
  // human always wins — any drag/zoom on the canvas hands the camera over for
  // CAM_YIELD_MS, and the reel keeps playing underneath; when the human stops
  // caring, the camera eases back onto its mark over CAM_BLEND_MS.
  // ============================================================================

  // ---- easing / interpolation ------------------------------------------------
  const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
  const smooth  = v => { v = clamp01(v); return v*v*(3-2*v); };                    // smoothstep
  const seg     = (t,a,b) => smooth((t-a)/(b-a));                                  // 0 -> 1 across [a,b]
  const win     = (t,a,b,f) => Math.min(smooth((t-a)/f), smooth((b-t)/f));         // fade in, hold, fade out
  const mix     = (a,b,u) => a + (b-a)*u;

  // ---- one fade path for every mesh in the scene -----------------------------
  // VISIBILITY ONLY. Babylon already routes a mesh through the transparent pass when
  // mesh.visibility < 1 (needAlphaBlendingForMesh), so a brief fade works even on an
  // OPAQUE material — and when visibility returns to 1 the mesh goes back to the opaque
  // pass, crisp and solid. The previous version also flipped material.transparencyMode,
  // which (a) left solid meshes in the transparent pass at full opacity → depth-sort
  // artifacts that read as "the body is see-through", and (b) leaked across the in-tape
  // LED clones, which share the hero's glTF materials, flipping the HERO translucent at
  // random. Both are gone: we never touch material state here, and the clones get their
  // own opaque materials at build time (see cloneOpaque below).
  function meshesUnder(node){
    const ms = [];
    if (node.getTotalVertices && node.getTotalVertices() > 0) ms.push(node);
    if (node.getChildMeshes) node.getChildMeshes().forEach(m => { if (m.getTotalVertices && m.getTotalVertices() > 0) ms.push(m); });
    return ms;
  }
  function fadeNode(node, a){
    if (!node) return;
    a = clamp01(a);
    node.setEnabled(a > 0.004);
    meshesUnder(node).forEach(m => { m.visibility = a; });
  }
  // The LED body ships as four glTF ALPHABLEND materials at alpha 1 — solid, but parked in
  // the transparent pass, which is what caused the see-through look. Force them OPAQUE so
  // the body is unambiguously solid; alpha 1 means no colour change, just correct depth.
  const solidifyBody = () => { if (!led) return; meshesUnder(led).forEach(m => {
    const mat = m.material; if (mat && 'transparencyMode' in mat) { mat.transparencyMode = 0; mat.alpha = 1; } }); };
  solidifyBody();

  // ---- measured facts (never typed in by hand) -------------------------------
  const solderNode = scene.getNodeByName('solder_top');
  const _ball = measure('solder_top'), _pad = measure('pad_top');
  const _bp   = _ball && _ball.parts[0];
  const BALL_D_UM   = _bp ? Math.round(Math.max(_bp.size[0], _bp.size[1]) * 1000) : null;   // mm -> µm
  const BALL_H_UM   = _bp ? Math.round(_bp.size[2] * 1000) : null;
  const BALL_TOP    = _ball ? _ball.all.max[2] / 1000 : 0.000113;                           // mm -> m (world)
  const BALL_PROUD  = (_bp && _pad) ? Math.round((_bp.max[2] - _pad.parts[0].max[2]) * 1000) : null;
  const PT_I        = ptLight ? ptLight.intensity : 0;
  // 5 mm off the board, per the storyboard. Note this is ~5× the part's longest dimension
  // and ~11× its height: to hold both the board and a part 5 mm above it, the frame has to
  // be ~6 mm tall, which leaves the 1 mm part at ~15% of frame. The lift-beat camera marks
  // below are DERIVED from this number, so changing it re-frames those shots automatically
  // (live: window._story.setLift(1.6)).
  let LIFT_M = 0.001;

  // ---- the beat sheet (seconds) ----------------------------------------------
  const SB = {
    hold:    { t: 0.0,  d: 1.5 },    // the part alone, dark: no glow, no cone, no solder
    zoom:    { t: 1.5,  d: 4.0 },    // slow pull back — 80% of frame -> 70%
    power:   { t: 6.5,  d: 1.1 },    // a second after the move settles, the die lights
    fovIn:   { t: 9.0,  d: 3.2 },    // the beam grows out of the LED
    fovHold: { t:12.2,  d: 3.3 },
    fovOut:  { t:15.5,  d: 2.1 },    // ...and retracts back into it
    lift:    { t:18.2,  d: 3.2 },    // body rises LIFT_M off the board
    ballIn:  { t:21.9,  d: 2.0 },    // solder deposits fade up on the pads
    lay:     { t:24.4,  d: 3.2 },    // body settles down ONTO the deposits; solder globs up under it
    seat:    { t:27.6,  d: 4.9 },    // hold — the part standing on its solder joints (they STAY, not fade)
    tapeIn:  { t:32.5,  d: 3.0 },    // carrier tape + nozzle arrive, camera opens up; solder clears off-frame
    pnp:     { t:35.5,  d: 7.6 },    // one full pick-and-place cycle (PNP_CYCLE of motion + a beat)
    tapeOut: { t:43.1,  d: 3.0 },    // tape leaves, camera sweeps back to the hero mark
    rest:    { t:46.1,  d:10.0 },    // 10 s of complete stillness, then loop
  };
  const end = k => SB[k].t + SB[k].d;
  const LEN = end('rest');
  const PNP_CYCLE = 6.8;             // the pick-place-release part of the 8 s loop; the tail is its reset, which we don't want here
  const T_HAND0 = SB.tapeIn.t + 0.2, T_HAND1 = SB.tapeIn.t + 0.9;   // hero LED dissolves off the board...
  const T_POCK0 = SB.tapeIn.t + 1.5, T_POCK1 = SB.tapeIn.t + 2.4;   // ...and re-forms in its tape pocket

  const CHAPTERS_DEFAULT = [
    { t: 0,           label:'The part'       },
    { t: SB.power.t,  label:'Powered on'     },
    { t: SB.fovIn.t,  label:'Viewing angle'  },
    { t: SB.lift.t,   label:'Lift off'       },
    { t: SB.ballIn.t, label:'Solder'         },
    { t: SB.tapeIn.t, label:'Tape & reel'    },
    { t: SB.rest.t,   label:'Rest'           },
  ];

  // config chapters REPLACE the defaults when present. Each: {t,label}.
  const CHAPTERS = (Array.isArray(CFG.chapters) && CFG.chapters.length)
    ? CFG.chapters.slice().sort((a,b)=>a.t-b.t) : CHAPTERS_DEFAULT;

  // ---- captions --------------------------------------------------------------
  // Data points, not prose. Each row is [label, value] and is EITHER datasheet-cited (the
  // caption's `src` names the page) or measured from the model — model rows carry mdl:true
  // and render in a distinct colour so a reader can always tell a spec from a measurement.
  // `note` is one optional clause of plain-language context for an engineer, never a story.
  const um = v => v == null ? '—' : v + ' µm';
  const CAPS_DEFAULT = [
    { t0:0.5,  t1:5.6,  eyebrow:'Part', head:'IN-S42ATR — Inolux 0402 red', src:DS_PKG,
      rows:[['package','0402 · 1.0 × 0.5 × 0.45 mm'], ['emitter','AlInGaP · clear lens'], ['λD','631 nm']] },
    { t0:6.7,  t1:8.9,  eyebrow:'Forward drive', head:'IF 20 mA', src:DS,
      rows:[['VF','2.1 V typ · 2.6 V max'], ['IV','90 mcd'], ['λD / λP','631 / 645 nm']] },
    { t0:9.2,  t1:17.5, eyebrow:'Viewing angle', head:'2θ½ = 130°', src:DS,
      rows:[['2θ½ (full)','130°'], ['half-angle','±65°'], ['λD','631 nm']],
      note:'Full-width half-power cone: on-axis intensity falls to 50 % at ±65°.' },
    { t0:18.3, t1:21.7, eyebrow:'Assembly', head:'Lifted '+(LIFT_M*1000).toFixed(1).replace(/\.0$/,'')+' mm to the joint', src:MEAS,
      rows:[['reveals','pads · cathode silk · solder'], ['pads','2 × (model)', true], ['lift','model view', true]] },
    { t0:22.0, t1:31.5, eyebrow:'Reflow', head:'Solder joints', src:DS_RFLW,
      rows:[['tin-glue melt','178–192 °C'],
            ['joint Ø', BALL_D_UM ? um(BALL_D_UM)+' (model)' : 'model', true],
            ['proud of pad', BALL_PROUD!=null ? um(BALL_PROUD)+' (model)' : 'model', true]],
      note:'One deposit per pad reflows and globs up; the body settles onto the joints.' },
    { t0:32.6, t1:43.0, eyebrow:'Tape & reel', head:'8 mm carrier · 4 mm pitch', src:DS_TAPE,
      rows:[['tape W','8.00 mm'], ['pitch P1','4.00 mm'], ['pocket','0.57 × 1.12 × 0.55 mm'],
            ['reel','Ø178 · 5000 pcs']] },
  ];

  // config captions REPLACE the defaults when present (the CLI writes the full list, seeded
  // from these defaults). Each: {id,t0,t1,eyebrow,head,src,rows:[[k,v,mdl?]],note}.
  const CAPS = (Array.isArray(CFG.captions) && CFG.captions.length)
    ? CFG.captions.map(c => ({ id:c.id, t0:c.t0, t1:c.t1, eyebrow:c.eyebrow, head:c.head,
        src:c.src||'', rows:(c.rows||[]).map(r => r[2] ? [r[0],r[1],true] : [r[0],r[1]]), note:c.note||'' }))
    : CAPS_DEFAULT.map((c,i) => ({ id:c.id || _slug(c.eyebrow) || ('cap'+i), ...c }));

  // ---- camera track ----------------------------------------------------------
  // Radii are for CAM_FOV; the LED's ~0.9 mm silhouette fills 80% of the frame at R80.
  //   visible height H = 2·r·tan(fov/2);  r = H / 0.4578 at fov 0.45.
  const CAM_FOV = 0.45;                                  // ~26° — telephoto, so a 1 mm part isn't lens-distorted
  const R80 = 0.00225, R70 = 0.00257, RFOV = 0.00720, RPNP = 0.02800;
  const A0 = 1.05, TAU = Math.PI*2;
  const VFRAC = 2 * Math.tan(CAM_FOV/2);                 // visible height = VFRAC · radius
  // The two lift-beat marks are derived from LIFT_M rather than typed, so the shot stays
  // composed if the lift changes: frame from the board (z=0) to the top of the raised part,
  // plus 8% headroom, centred between them.
  const liftMark = frac => { const span = frac*LIFT_M + 0.0006;      // board top -> part top at this point in the rise
                             return { r: span*1.08/VFRAC, tz: span*0.5 }; };
  // [ t, alpha, beta, radius, targetX, targetY, targetZ ]
  const buildCam = () => { const MK_TOP = liftMark(1.0), MK_MID = liftMark(0.71); return [
    [ 0.0,  A0,       0.98, R80,    0,      0, 0.00025],
    [ 1.5,  A0+0.03,  0.98, R80,    0,      0, 0.00025],
    [ 5.5,  1.40,     1.04, R70,    0,      0, 0.00025],   // the slow "Hollywood" pull-back lands here
    [ 7.6,  1.55,     1.06, R70,    0,      0, 0.00028],
    [ 9.0,  1.62,     1.16, 0.0032, 0,      0, 0.00030],
    // 2θ½ = 130° is a WIDE, shallow bowl: from a high angle you look down into it and it
    // reads as a floating disc. Drop the camera to ~11° above the board so the beam shows
    // its profile — which is also the more cinematic shot.
    [12.2,  1.95,     1.38, RFOV,   0,      0, 0.00082],   // opened up to hold the whole cone
    [15.5,  2.36,     1.40, RFOV,   0,      0, 0.00082],
    [17.6,  2.55,     1.16, 0.0042, 0,      0, 0.00045],
    [18.2,  2.60,     1.03, 0.0046, 0,      0, 0.00050],
    [21.4,  2.95,     1.12, MK_TOP.r, 0,    0, MK_TOP.tz],  // board + the raised part, as tight as both allow
    // A part lifted 5 mm and deposits 0.26 mm across cannot share a readable frame, so this
    // plays as three shots rather than one compromise: follow it up, push in on what's under
    // it (the part leaves frame overhead — deliberate), then pull back out to catch it landing.
    [22.3,  3.05,     1.28, 0.0060, 0,      0, 0.00060],   // push in to the pads
    [23.9,  3.16,     1.36, 0.0046, 0,      0, 0.00018],   // close + low: the deposits read as domes
    [24.9,  3.28,     1.30, 0.0052, 0,      0, 0.00025],
    [25.6,  3.38,     1.18, MK_MID.r, 0,    0, MK_MID.tz],  // back out to meet it on the way down
    [27.6,  3.62,     1.38, 0.0042, 0,      0, 0.00035],   // ride it down: the part standing on its solder
    [30.0,  3.86,     1.36, 0.0038, 0,      0, 0.00025],
    [32.0,  4.02,     1.25, 0.0060, 0,      0, 0.00025],
    [35.5,  4.30,     1.08, RPNP,   0.0080, 0, 0.00060],   // wide for the tape; target hands over to the part below
    [43.1,  5.20,     1.16, 0.0110, 0,      0, 0.00040],   // ...having crept in the whole way across
    [46.1,  A0+TAU,   0.98, R80,    0,      0, 0.00025],   // back on the opening mark, one full turn later
    [LEN,   A0+TAU,   0.98, R80,    0,      0, 0.00025],   // ...and dead still for the 10 s rest
  ]; };
  let CAM = buildCam();
  function camAt(t){
    let i = 0; while (i < CAM.length-2 && t > CAM[i+1][0]) i++;
    const a = CAM[i], b = CAM[i+1], u = smooth((t - a[0]) / ((b[0]-a[0]) || 1));   // ease at every key -> nothing snaps
    return { alpha:mix(a[1],b[1],u), beta:mix(a[2],b[2],u), radius:mix(a[3],b[3],u),
             tx:mix(a[4],b[4],u), ty:mix(a[5],b[5],u), tz:mix(a[6],b[6],u) };
  }
  // Drift envelope: alive everywhere except the opening beat and the closing rest,
  // where the storyboard asks for stillness. Ramping it (rather than switching it)
  // keeps the hand-off invisible.
  const driftEnv = t => Math.min(seg(t, 1.5, 3.5), 1 - seg(t, SB.tapeOut.t, end('tapeOut')));

  // ---- transport -------------------------------------------------------------
  let storyT = 0, storyPlaying = true, storyLast = performance.now(), scrubDrag = false;
  const capEl   = document.getElementById('caption');
  const scrubEl = document.getElementById('scrub');
  const fillEl  = document.getElementById('scrub-fill');
  const slabEl  = document.getElementById('scrub-label');
  CHAPTERS.forEach(c => { if (c.t <= 0) return;
    const d = document.createElement('div'); d.className = 'tick'; d.style.left = (c.t/LEN*100)+'%'; scrubEl.appendChild(d); });
  const chapterAt = t => { let c = CHAPTERS[0]; CHAPTERS.forEach(k => { if (t >= k.t) c = k; }); return c; };
  const seekTo = x => { storyT = Math.max(0, Math.min(LEN - 0.001, x/window.innerWidth*LEN)); storyLast = performance.now(); };
  scrubEl.addEventListener('pointerdown', e => { e.stopPropagation(); scrubDrag = true; scrubEl.classList.add('hot');
    seekTo(e.clientX); try { scrubEl.setPointerCapture(e.pointerId); } catch(_) {} });
  scrubEl.addEventListener('pointermove', e => {
    const ct = e.clientX/window.innerWidth*LEN;
    slabEl.textContent = chapterAt(ct).label + '   ' + ct.toFixed(1) + 's';
    slabEl.style.left = e.clientX + 'px'; slabEl.classList.add('show');
    if (scrubDrag) seekTo(e.clientX);
  });
  const scrubEnd = e => { scrubDrag = false; scrubEl.classList.remove('hot');
    try { scrubEl.releasePointerCapture(e.pointerId); } catch(_) {} };
  scrubEl.addEventListener('pointerup', scrubEnd);
  scrubEl.addEventListener('pointercancel', scrubEnd);
  scrubEl.addEventListener('pointerleave', () => slabEl.classList.remove('show'));

  // Top transport bar — click/drag anywhere on it to scrub. Same seek surface as the bottom
  // hairline, but full-height and knobbed so it reads as a proper timeline you can grab.
  const topEl   = document.getElementById('topbar');
  const topFill = document.getElementById('topbar-fill');
  const topKnob = document.getElementById('topbar-knob');
  const topTime = document.getElementById('topbar-time');
  CHAPTERS.forEach(c => { if (c.t <= 0) return;
    const d = document.createElement('div'); d.className = 'tick'; d.style.left = (c.t/LEN*100)+'%'; topEl.appendChild(d); });
  topEl.addEventListener('pointerdown', e => { e.stopPropagation(); scrubDrag = true; topEl.classList.add('hot');
    seekTo(e.clientX); try { topEl.setPointerCapture(e.pointerId); } catch(_) {} });
  topEl.addEventListener('pointermove', e => {
    const ct = e.clientX/window.innerWidth*LEN;
    topTime.textContent = chapterAt(ct).label + '   ' + ct.toFixed(1) + 's';
    topTime.style.left = e.clientX + 'px'; topTime.classList.add('show');
    if (scrubDrag) seekTo(e.clientX);
  });
  const topEnd = e => { scrubDrag = false; topEl.classList.remove('hot');
    try { topEl.releasePointerCapture(e.pointerId); } catch(_) {} };
  topEl.addEventListener('pointerup', topEnd);
  topEl.addEventListener('pointercancel', topEnd);
  topEl.addEventListener('pointerleave', () => topTime.classList.remove('show'));

  // ---- the camera director ---------------------------------------------------
  // The human always wins. Any drag / wheel / pinch on the canvas parks the scripted
  // camera for CAM_YIELD_MS; the reel keeps playing regardless. When the human goes
  // quiet, ease back onto the mark over CAM_BLEND_MS instead of snapping.
  const CAM_YIELD_MS = 5000, CAM_BLEND_MS = 1400;
  let userUntil = 0, blendFrom = null, blendT0 = 0;
  const noteUser = () => { userUntil = performance.now() + CAM_YIELD_MS; blendFrom = null; };
  ['pointerdown','wheel','touchstart'].forEach(ev => canvasEl.addEventListener(ev, noteUser, { passive:true }));
  function driveCamera(t, now){
    const c = scene.activeCamera; if (!c) return 0;
    const left = userUntil - now;
    if (left > 0) { blendFrom = null; return left; }            // hands off — they're driving
    const k = camAt(t), env = driftEnv(t);
    // Gentle life on top of the marks. Frequencies are whole cycles over LEN, and the
    // envelope is 0 at both ends of the reel, so the loop point stays seamless.
    k.alpha  += env * 0.045 * Math.sin(t * (3*TAU/LEN));
    k.beta   += env * 0.022 * Math.sin(t * (2*TAU/LEN) + 1.1);
    k.radius *= 1 + env * 0.015 * Math.sin(t * (2*TAU/LEN) + 0.4);
    if (!blendFrom) { blendFrom = { a:c.alpha, b:c.beta, r:c.radius, x:c.target.x, y:c.target.y, z:c.target.z }; blendT0 = now; }
    const w = smooth((now - blendT0) / CAM_BLEND_MS);
    const d = ((k.alpha - blendFrom.a + Math.PI) % TAU + TAU) % TAU - Math.PI;   // shortest way round
    c.alpha  = blendFrom.a + d*w;
    c.beta   = mix(blendFrom.b, k.beta,   w);
    c.radius = mix(blendFrom.r, k.radius, w);
    c.target.copyFromFloats(mix(blendFrom.x,k.tx,w), mix(blendFrom.y,k.ty,w), mix(blendFrom.z,k.tz,w));
    return 0;
  }

  // ---- the reel itself -------------------------------------------------------
  // Height of the body above the board through the assembly beats. It rests ON the pads (STAND);
  // it lifts, then settles back DOWN to STAND — INTO the solder, whose crown is higher — so the
  // fillets wrap the terminations rather than the body sitting on top of the domes. It holds there
  // (a placed part sits on its joints) and only returns to STAND for the bare-part hero at the loop.
  function bodyZ(t){
    if (t <  SB.lift.t)    return STAND;
    if (t <  end('lift'))  return mix(STAND, LIFT_M, seg(t, SB.lift.t, end('lift')));
    if (t <  SB.lay.t)     return LIFT_M;
    if (t <  end('lay'))   return mix(LIFT_M, STAND, seg(t, SB.lay.t, end('lay')));       // settles onto the pads, into the solder
    if (t <  SB.tapeIn.t)  return STAND;                                                  // seated on its joints, held
    return STAND;                                                                         // tape chapter overrides this; hero rests on the pad
  }
  // Solder "glob up": the deposits dome slightly as they reflow under the settling part
  // (z scaled about the board plane). Purely a melt cue — the fillet geometry itself is
  // the model's, measured, unchanged.
  function solderScaleZ(t){
    if (t < SB.ballIn.t) return 1;
    if (t < SB.lay.t)    return mix(1.0, 1.15, seg(t, SB.ballIn.t, SB.lay.t));   // build up as it melts
    return 1.15;                                                                  // stays globbed
  }
  function applyStory(t){
    // --- the part ---------------------------------------------------------
    let lx = 0, ly = 0, lz = bodyZ(t), lrot = 0, lvis = 1;
    const inTape = t >= T_HAND0 && t < end('tapeOut');
    if (inTape) {
      lvis = t < T_HAND1 ? 1 - seg(t, T_HAND0, T_HAND1)
           : t < T_POCK0 ? 0
           : t < T_POCK1 ? seg(t, T_POCK0, T_POCK1) : 1;
      if (t >= T_HAND1) {                                   // now living in the tape's timeline
        const tt = Math.max(0, Math.min(PNP_CYCLE, t - SB.pnp.t));
        const p = lerpKeys(ledKeys, tt);
        lx = p[0]; ly = p[1]; lz = p[2]; lrot = lerp1(rotKeys, tt);
      }
    }
    // --- the glow ---------------------------------------------------------
    let lit = t >= SB.power.t ? seg(t, SB.power.t, end('power')) : 0;
    if (t >= SB.lift.t) lit *= 1 - seg(t, SB.lift.t, SB.lift.t + 1.0);   // powers down as it leaves the board
    // --- the beam ---------------------------------------------------------
    let coneS = 0;
    if (t >= SB.fovIn.t && t < end('fovOut'))
      coneS = t < end('fovIn')  ? seg(t, SB.fovIn.t, end('fovIn'))
            : t < SB.fovOut.t   ? 1
            : 1 - seg(t, SB.fovOut.t, end('fovOut'));
    const coneV = Math.min(1, coneS * 2.5);                 // opacity leads the growth a hair so it never pops
    // --- the solder -------------------------------------------------------
    // Fades in on the pads, globs up, then STAYS as the joint through the whole board
    // section. It only clears during the tape chapter, off-frame while the camera has
    // moved to the reel — so it never appears to evaporate in front of you.
    let sol = 0;
    if (t >= SB.ballIn.t && t < end('tapeIn'))
      sol = t < end('ballIn') ? seg(t, SB.ballIn.t, end('ballIn'))
          : t < SB.tapeIn.t   ? 1
          : 1 - seg(t, SB.tapeIn.t, end('tapeIn'));       // clears with the board scene, off-frame
    // --- the tape ---------------------------------------------------------
    let tap = 0;
    if (t >= SB.tapeIn.t && t < end('tapeOut'))
      tap = t < end('tapeIn')  ? seg(t, SB.tapeIn.t, end('tapeIn'))
          : t < SB.tapeOut.t   ? 1
          : 1 - seg(t, SB.tapeOut.t, end('tapeOut'));

    if (led)      { led.position.set(lx, ly, lz); led.rotation.z = lrot; fadeNode(led, lvis); }
    if (ledLit)   { ledLit.position.set(lx, ly, lz); fadeNode(ledLit, lit); }
    if (ptLight)  { ptLight.setEnabled(lit > 0.01); ptLight.intensity = PT_I * lit; }
    setConePose(lx, ly, lz, coneS);                       // apex on the LED top, grows from the die
    if (fovCone)  fadeNode(fovCone, coneV);
    if (solderNode) { fadeNode(solderNode, sol); solderNode.scaling.z = solderScaleZ(t); }
    fadeNode(ghostRoot, tap);
    if (tap > 0.004) {
      const nt = Math.max(0, Math.min(PNP_CYCLE, t - SB.pnp.t));
      const np = lerpKeys(nozKeys, nt); nozzle.position.set(np[0], np[1], np[2]);
    }
    return { lx, ly, lz, lvis };
  }

  // --- caption ------------------------------------------------------------
  let capCur = -2;
  function updateCaption(t){
    let idx = -1, a = 0;
    for (let i=0;i<CAPS.length;i++) if (t >= CAPS[i].t0 && t <= CAPS[i].t1) { idx = i; a = win(t, CAPS[i].t0, CAPS[i].t1, 0.55); break; }
    if (idx !== capCur) {
      capCur = idx;
      if (idx >= 0) { const c = CAPS[idx];
        capEl.querySelector('.eyebrow').textContent = c.eyebrow;
        capEl.querySelector('.head').textContent    = c.head;
        capEl.querySelector('.rows').innerHTML = (c.rows || []).map(r =>
          '<div class="kv'+(r[2]?' mdl':'')+'"><i>'+esc(r[0])+'</i><b>'+esc(r[1])+'</b></div>').join('');
        const noteEl = capEl.querySelector('.note');
        noteEl.textContent = c.note || ''; noteEl.style.display = c.note ? 'block' : 'none';
        capEl.querySelector('.src').textContent     = c.src; }
    }
    capEl.style.opacity   = idx < 0 ? '0' : a.toFixed(3);
    capEl.style.transform = 'translateY(' + ((1 - (idx < 0 ? 0 : a)) * 10).toFixed(1) + 'px)';
  }

  // --- the per-frame entry point (called from the render observable) ------
  let uiLast = 0;
  function storyFrame(){
    const now = performance.now();
    let dt = (now - storyLast) / 1000; storyLast = now;
    if (dt > 0.25) dt = 0.25;                                  // backgrounded tab — don't jump the reel
    if (storyPlaying && !scrubDrag) storyT = (storyT + dt) % LEN;
    const t = storyT;
    const st = applyStory(t);
    updateCaption(t);
    // During the pick-and-place beat the camera keeps the moving part centred, which
    // is what the storyboard asks for and what the scripted target can't do on its own.
    const trackW = Math.min(seg(t, SB.pnp.t, SB.pnp.t + 1.2), 1 - seg(t, SB.pnp.t + PNP_CYCLE - 0.6, SB.pnp.t + PNP_CYCLE));
    const manual = driveCamera(t, now);
    if (!manual && trackW > 0.001) {
      const c = scene.activeCamera;
      c.target.copyFromFloats(mix(c.target.x, st.lx, trackW), mix(c.target.y, st.ly, trackW), mix(c.target.z, st.lz, trackW));
    }
    if (now - uiLast > 110) {
      uiLast = now;
      const pct = (t/LEN*100);
      fillEl.style.width = pct.toFixed(2) + '%';
      topFill.style.width = pct.toFixed(2) + '%';
      topKnob.style.left = pct.toFixed(2) + '%';
      statusEl.innerHTML = 'TIMELINE · ' + esc(chapterAt(t).label) +
        (manual > 0 ? ' · <span class="manual">camera: yours · ' + Math.ceil(manual/1000) + 's</span>' : '');
    }
  }

  const STORY_LOCKED = ['light','fov','body','solder'];        // the reel drives these — don't let the panel fight it
  function storyEnter(){
    storyT = 0; storyLast = performance.now(); storyPlaying = true;
    blendFrom = null; userUntil = 0; capCur = -2;
    STORY_LOCKED.forEach(k => lockLayer(k, true));
    const c = scene.activeCamera;
    c.fov = CAM_FOV; c.lowerRadiusLimit = 0.0015; c.upperRadiusLimit = 0.09; c.minZ = 0.00005; c.maxZ = 10;
    applyStory(0);
    playBtn.textContent = '⏸ Pause'; playBtn.classList.add('on');
  }
  function storyExit(){
    STORY_LOCKED.forEach(k => lockLayer(k, false));
    [led, ledLit, fovCone, solderNode, ghostRoot].forEach(n => fadeNode(n, 1));
    setConePose(0, 0, 0, 1);
    if (solderNode) solderNode.scaling.z = 1;
    if (led)     { led.position.set(0,0,0); led.rotation.z = 0; }
    if (ledLit)  ledLit.position.set(0,0,0);
    if (ptLight) ptLight.intensity = PT_I;
    capEl.style.opacity = '0';
  }
  const playBtn = document.getElementById('btn-play');
  playBtn.onclick = function(){ storyPlaying = !storyPlaying; storyLast = performance.now();
    this.textContent = storyPlaying ? '⏸ Pause' : '▶ Play'; this.classList.toggle('on', storyPlaying); };
  document.getElementById('btn-replay').onclick = () => { storyT = 0; storyLast = performance.now();
    storyPlaying = true; blendFrom = null; userUntil = 0;
    playBtn.textContent = '⏸ Pause'; playBtn.classList.add('on'); };
  // debug/authoring hook: window._story.seek(21.9) to jump straight to a beat
  window._story = { beats:SB, caps:CAPS, cam:CAM, chapters:CHAPTERS, len:()=>LEN,
                    seek:s => { storyT = Math.max(0, Math.min(LEN-0.001, s)); storyLast = performance.now(); },
                    at:()=>storyT, play:v => { storyPlaying = v!==false; storyLast = performance.now(); },
                    // try a different lift height without a reload: window._story.setLift(1.6)
                    setLift:mm => { LIFT_M = mm/1000; CAM = buildCam();
                                    // find the lift caption by id, not index — captions may be reordered via config
                                    const lc = CAPS.find(c => c.id === 'assembly'); if (lc) lc.head = 'Lifted ' + mm + ' mm to the joint';
                                    capCur = -2; return LIFT_M; },
                    measured:{ BALL_D_UM, BALL_H_UM, BALL_PROUD, BALL_TOP, FOV_CHECK } };

  // ============================================================================
  // 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.fov = CAM_FOV;   // the whole viewer runs on the ~26° telephoto the story is framed for;
                       // radii below are the old fov-0.8 ones scaled by 0.8456/0.4578 = 1.847
    c.lowerRadiusLimit = 0.0015; c.upperRadiusLimit = 0.09; 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.048; }
    else { c.target.copyFromFloats(0, 0, -0.0002); c.radius = 0.0120; }
    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){
    const was = mode;
    mode = m; t0 = performance.now();
    if (was === 'story' && m !== 'story') storyExit();
    document.getElementById('mode-story').classList.toggle('on', m==='story');
    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');
    document.body.classList.toggle('story', m==='story');
    if (m==='story'){
      // The reel owns every content node, so give it the whole scene: ghosts enabled
      // (it fades them itself), model visible, and the camera under its director.
      setNodeEnabled(ghostRoot, true);
      if (fovCone) setNodeEnabled(fovCone, true);
      if (solderNode) setNodeEnabled(solderNode, true);
      storyEnter();
      statusEl.textContent = 'TIMELINE · The part';
    } else 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-story').onclick  = () => setMode('story');
  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('story');
  window._v = { viewer, scene, B, setMode, mode:()=>mode };   // debug hook

  // Frame grab for authoring/verification. The engine has no preserveDrawingBuffer, so
  // canvas.toDataURL() and headless screenshots both come back blank — read the pixels
  // straight out of the GL buffer immediately after an explicit render instead.
  window._shot = (scale) => {
    const eng = scene.getEngine(), gl = eng._gl;
    scene.render();
    const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
    const px = new Uint8Array(w*h*4);
    gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
    const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
    const cx = cv.getContext('2d'), img = cx.createImageData(w, h);
    for (let y=0; y<h; y++) img.data.set(px.subarray((h-1-y)*w*4, (h-y)*w*4), y*w*4);   // GL origin is bottom-left
    cx.putImageData(img, 0, 0);
    const s = scale || 1;
    if (s === 1) return cv.toDataURL('image/png');
    const cv2 = document.createElement('canvas'); cv2.width = Math.round(w*s); cv2.height = Math.round(h*s);
    cv2.getContext('2d').drawImage(cv, 0, 0, cv2.width, cv2.height);
    return cv2.toDataURL('image/png');
  };
  } catch (e) { statusEl.textContent = 'init error: ' + e.message; window._err = (e.stack || e.message); console.error(e); }
})();
</script>

<style>html,body{overscroll-behavior:none}</style>
<script>
/* Keep the wheel inside the viewer. Capture phase + passive:false so this runs BEFORE
   Babylon's own handler and is actually allowed to cancel. Registered in its own script
   tag: if the viewer's init throws, this still binds. */
(function () {
  addEventListener('wheel', function (e) {
    // Let a genuinely scrollable panel scroll itself; only cancel once it has nowhere left
    // to go (which is what would otherwise chain out to the page).
    for (var n = e.target; n && n !== document.documentElement; n = n.parentElement) {
      var oy = getComputedStyle(n).overflowY;
      if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) {
        var atTop = n.scrollTop <= 0;
        var atEnd = n.scrollTop + n.clientHeight >= n.scrollHeight - 1;
        if (!(e.deltaY < 0 && atTop) && !(e.deltaY > 0 && atEnd)) return;
        break;
      }
    }
    e.preventDefault();
  }, { passive: false, capture: true });
})();
</script>

</html>