123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BQ25792 Charger</title>
<script>
// Sandbox shim: sandboxed docs (no allow-same-origin) THROW on localStorage/sessionStorage
// access. The viewer bundle touches storage during init and the exception aborts its
// whole scene rig (lights/env/ViewCube). Provide in-memory stand-ins BEFORE the bundle.
(function(){
  function mem(){ var st={}; return {getItem:function(k){return k in st?st[k]:null;},setItem:function(k,v){st[k]=String(v);},removeItem:function(k){delete st[k];},clear:function(){st={};},key:function(i){return Object.keys(st)[i]||null;},get length(){return Object.keys(st).length;}}; }
  try{ window.localStorage; }catch(e){ try{ Object.defineProperty(window,"localStorage",{value:mem(),configurable:true}); }catch(x){} }
  try{ window.sessionStorage; }catch(e){ try{ Object.defineProperty(window,"sessionStorage",{value:mem(),configurable:true}); }catch(x){} }
})();
</script>
<link rel="stylesheet" href="/static/vendor/adom-3d-viewer-babylon9/style.css">
<!-- NEVER pin ?v= on the viewer bundle: it is a code-split stub that imports a hashed chunk.
     A pinned old version serves an old stub whose chunk was deleted on rebuild -> 404 -> the
     global never defines -> "viewer bundle failed to load". Always take the current build. -->
<script type="module" src="/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js"></script>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body { background:#0d1117; color:#c9d1d9; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif; padding:0 0 32px 0; }
  h2 { color:#e6edf3; font-size:18px; margin:28px 0 10px; }
  h3 { color:#e6edf3; font-size:15px; margin:20px 0 8px; }
  p  { font-size:14px; line-height:1.55; color:#9da7b3; margin:8px 0; max-width:900px; }
  strong { color:#c9d1d9; }
  code { background:#161b22; border:1px solid #21262d; border-radius:4px; padding:1px 5px; font-size:12.5px; }
  table { border-collapse:collapse; margin:10px 0 4px; font-size:13px; }
  th,td { border:1px solid #21262d; padding:5px 10px; text-align:left; }
  th { background:#161b22; color:#e6edf3; font-weight:600; }
  td { color:#9da7b3; }

  /* viewer block: floaty toolbar OVER the canvas */
  #viewer-card { position:relative; border:1px solid #21262d; border-radius:10px; overflow:hidden; background:#0a0d12; }
  #viewer-wrap { position:relative; width:100%; height:640px; }
  #viewer-card:fullscreen { border:none; border-radius:0; }
  #viewer-card:fullscreen #viewer-wrap { height:100vh; }
  #floaty { position:absolute; top:14px; left:14px; z-index:30; display:flex; gap:6px;
    background:rgba(13,17,23,0.55); backdrop-filter:blur(8px); border:1px solid rgba(48,54,61,0.8);
    border-radius:10px; padding:6px; }
  .fbtn { padding:7px; border-radius:8px; border:1px solid transparent; background:transparent; color:#c9d1d9; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; }
  .fbtn svg { pointer-events:none; width:16px; height:16px; flex:none; stroke:currentColor; fill:none; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
  .fbtn:hover { background:rgba(48,54,61,0.7); }
  .fbtn.active { background:#0b3d33; border-color:#00b8b1; color:#5ff5e8; }
  #vstatus { position:absolute; right:14px; bottom:10px; z-index:30; font-size:12px; color:#8b949e;
    background:rgba(13,17,23,0.5); padding:3px 8px; border-radius:6px; }
  #layers-panel { position:absolute; top:128px; right:14px; z-index:30; width:270px; min-width:210px; max-width:460px;
    resize:horizontal; overflow:auto; direction:rtl;  /* resize handle on the LEFT edge (below the ViewCube) */
    background:rgba(13,17,23,0.62); backdrop-filter:blur(8px); border:1px solid rgba(48,54,61,0.8);
    border-radius:10px; padding:8px 10px; font-size:11.5px; }
  #layers-panel > * { direction:ltr; }
  .lp-title { color:#e6edf3; font-weight:600; margin-bottom:4px; font-size:12px; }
  .lp-row { display:flex; align-items:center; gap:6px; padding:1.5px 4px; border-radius:5px; color:#c9d1d9; cursor:default; }
  .lp-row:hover { background:rgba(0,184,177,0.18); }
  .lp-row input { accent-color:#00b8b1; }
  .lp-name { flex:1; white-space:nowrap; overflow:hidden; }
  .lp-z { color:#8b949e; font-variant-numeric:tabular-nums; min-width:62px; text-align:right; }
  #lp-order { margin-top:5px; font-size:11px; color:#3fb950; }
  #lp-order.bad { color:#f85149; font-weight:600; }
  #scrub-wrap { position:absolute; left:50%; transform:translateX(-50%); bottom:12px; z-index:30;
    width:46%; display:flex; align-items:center; gap:8px; background:rgba(13,17,23,0.55); backdrop-filter:blur(8px);
    border:1px solid rgba(48,54,61,0.8); border-radius:10px; padding:7px 12px; }
  #scrub { flex:1; accent-color:#00b8b1; }
  #tt { position:fixed; z-index:100; pointer-events:none; opacity:0; transform:translateY(4px);
    transition:opacity .14s ease, transform .14s ease; background:rgba(13,17,23,0.96);
    color:#e6edf3; font-size:11.5px; font-weight:500; letter-spacing:.2px; padding:5px 9px;
    border:1px solid rgba(0,184,177,0.35); border-radius:7px; white-space:nowrap;
    box-shadow:0 6px 20px rgba(0,0,0,0.5); }
  #tt.show { opacity:1; transform:translateY(0); }
  #tt::after { content:""; position:absolute; top:-5px; left:var(--ax,50%); transform:translateX(-50%);
    border:5px solid transparent; border-bottom-color:rgba(0,184,177,0.35); border-top:0; }
  #tt.flip::after { top:auto; bottom:-5px; border-bottom:0; border-top-color:rgba(0,184,177,0.35); }
  .lp-iso { width:16px; height:16px; padding:0; margin-right:2px; border:1px solid #30363d; border-radius:4px;
    background:transparent; color:#6e7681; font-size:11px; line-height:1; cursor:pointer; flex:none; }
  .lp-iso:hover { color:#5ff5e8; border-color:#00b8b1; }
  .lp-iso.on { background:#0b3d33; border-color:#00b8b1; color:#5ff5e8; }
  #light-panel { display:none; position:absolute; top:70px; left:14px; z-index:30; width:292px; overflow:hidden;
    background:rgba(13,17,23,0.62); backdrop-filter:blur(8px); border:1px solid rgba(48,54,61,0.8);
    border-radius:10px; padding:8px 10px; font-size:11px; }
  .lt-row { display:flex; align-items:center; gap:6px; padding:1px 6px 1px 2px; color:#c9d1d9; }
  .lt-row label { width:86px; flex:none; white-space:nowrap; overflow:hidden; color:#8b949e; }
  .lt-row input[type=range] { flex:1; accent-color:#00b8b1; height:14px; }
  .lt-row .lt-v { flex:none; width:38px; text-align:right; font-size:10.5px; font-variant-numeric:tabular-nums; color:#c9d1d9; }
  .grip { cursor:grab; color:#6e7681; font-weight:700; letter-spacing:1px; padding:0 6px 0 2px; user-select:none; }
  .grip:active { cursor:grabbing; }
</style>
</head>
<body>

<div id="viewer-card">
  <div id="viewer-wrap"></div>
  <div id="floaty"><span class="grip" title="drag">::</span>
    <button class="fbtn active" id="btn-pivot" type="button" title="Click any point to orbit around it (Shift+Alt always on)">Click-pivot</button>
    <button class="fbtn active" id="btn-ground" type="button" title="Show/hide the ground plane">Ground</button>
    <button class="fbtn active" id="btn-shadows" type="button" title="Toggle shadows">Shadows</button>
    <span style="width:1px;background:rgba(255,255,255,0.12);margin:0 3px"></span>
    <button class="fbtn active" id="btn-anim" type="button" title="Auto-play cascade">Animate</button>
    <button class="fbtn" id="btn-assembled" type="button">Assembled</button>
    <button class="fbtn" id="btn-exploded" type="button">Exploded</button>
    <span style="width:1px;background:rgba(255,255,255,0.12);margin:0 3px"></span>
    <button class="fbtn" id="btn-lightpanel" type="button" title="Lighting controls">Light</button>
    <button class="fbtn active" id="btn-layerspanel" type="button" title="Layers panel">Layers</button>
    <button class="fbtn" id="btn-fs" type="button" title="Fullscreen">Fullscreen</button>
  </div>
  <div id="light-panel"><div class="lp-title"><span class="grip" title="drag">::</span> Lighting <button id="lt-helpers" class="lp-iso" style="width:auto;padding:0 6px" data-tip="Show light helper gizmo (where the spot sits and points)">helper</button> <button id="lt-snap" class="lp-iso" style="width:auto;padding:0 6px" data-tip="Snapshot current settings so the AI can bake them">snapshot</button></div><div id="lt-rows"></div></div>
  <div id="layers-panel"><div class="lp-title"><span class="grip" title="drag">::</span> Layers</div><div id="lp-rows"></div><div id="lp-order">order: -</div></div>
  <div id="scrub-wrap"><button id="btn-play" class="fbtn" type="button" title="Play/pause" style="min-width:34px;font-size:13px">&#10074;&#10074;</button><input id="scrub" type="range" min="0" max="1000" value="0"></div>
  <span id="vstatus">starting viewer...</span>
</div>
</div>

<p style="margin-top:10px"><strong>Exploded stackup</strong> separates every fabrication layer (6 mm per slot) so you can see the
whole build, including the inner copper layers 2 and 15 that no schematic-level 3D view shows.
<strong>Assembled</strong> is the real board: 3D copper traces visible through the translucent soldermask.
Drag to orbit, scroll to zoom. This viewer is the wiki's own Adom Babylon viewer with a model toggle.</p>

<h2>What this board is</h2>
<p>A buck-boost battery charger around the Texas Instruments <strong>BQ25792</strong> (1S to 4S, up to 5 A, USB-C + DC
input, I2C control, integrated power path and 16-bit ADC), designed by <strong>Adonis</strong> in Fusion 360
Electronics. It integrates 14 Adom machine pins for the modular stack. FR4 outline
<strong>50.40 x 26.40 mm</strong>, 109 components, 4-layer.</p>

<h2>Board stackup (exact physical fabrication stack)</h2>
<p>From the design rules: <code>layerSetup (1+2*15+16)</code> = 4 copper layers, <code>mtCopper</code> 0.035 mm each. A
4-layer board is fabricated as copper / prepreg / copper / core / copper / prepreg / copper: the FR4
is <strong>not one slab</strong>, it is prepreg + core + prepreg. Dielectric split fitted to the measured board
(1.516 mm total): outer prepregs 0.200 each, core 1.046. Exact z in mm:</p>
<table>
<tr><th>#</th><th>Layer</th><th>z bottom</th><th>z top</th><th>thickness</th></tr>
<tr><td>1</td><td>Components (seated on top mask)</td><td>1.582</td><td>varies</td><td>per part</td></tr>
<tr><td>2</td><td>Top silkscreen</td><td>1.582</td><td>1.582</td><td>ink</td></tr>
<tr><td>3</td><td>Top soldermask</td><td>1.516</td><td>1.582</td><td>0.066</td></tr>
<tr><td>4</td><td>Copper L1 (top, traces)</td><td>1.516</td><td>1.551</td><td>0.035</td></tr>
<tr><td>5</td><td>Top prepreg (FR4)</td><td>1.316</td><td>1.516</td><td>0.200</td></tr>
<tr><td>6</td><td>Copper L2 (inner)</td><td>1.281</td><td>1.316</td><td>0.035</td></tr>
<tr><td>7</td><td>Core (FR4)</td><td>0.235</td><td>1.281</td><td>1.046</td></tr>
<tr><td>8</td><td>Copper L15 (inner)</td><td>0.200</td><td>0.235</td><td>0.035</td></tr>
<tr><td>9</td><td>Bottom prepreg (FR4)</td><td>0.000</td><td>0.200</td><td>0.200</td></tr>
<tr><td>10</td><td>Copper L16 (bottom)</td><td>-0.035</td><td>0.000</td><td>0.035</td></tr>
<tr><td>11</td><td>Bottom soldermask</td><td>-0.065</td><td>0.000</td><td>0.065</td></tr>
<tr><td>12</td><td>Bottom silkscreen</td><td>-0.065</td><td>-0.065</td><td>ink</td></tr>
</table>
<p>Copper L1 and L16 are the real exported 3D bodies. Inner copper L2 and L15 are the real gerber
layers (<code>copper_l2.G1</code>, <code>copper_l3.G2</code>); Fusion does not export inner layers as bodies. Both
soldermasks come from their gerbers with real pad openings.</p>

<h2>Files (everything exportable is here)</h2>
<table>
<tr><th>File</th><th>What</th></tr>
<tr><td><code>bq25792-charger.f3d</code></td><td>Fusion 360 archive (native)</td></tr>
<tr><td><code>bq25792-charger.step</code></td><td>STEP 3D (full board)</td></tr>
<tr><td><code>bq25792-charger.usdz</code></td><td>USDZ 3D (PBR)</td></tr>
<tr><td><code>bq25792-charger.brd</code> / <code>.sch</code></td><td>EAGLE board + schematic source</td></tr>
<tr><td><code>bq25792-charger.fbrd</code> / <code>.fsch</code></td><td>Fusion-native board + schematic</td></tr>
<tr><td><code>bq25792-charger-gerbers.zip</code></td><td>Fabrication gerbers + drill (13 files)</td></tr>
<tr><td><code>bq25792-charger-bom.csv</code></td><td>Bill of materials</td></tr>
<tr><td><code>bq25792-charger-cpl.csv</code></td><td>Pick and place</td></tr>
<tr><td><code>bq25792-charger-exploded.glb</code> / <code>-assembled.glb</code></td><td>The two 3D models above</td></tr>
</table>

<p style="margin-top:16px;color:#6e7681;font-size:12.5px">Source of truth: the live Fusion 360 design (Main project, v222). Pipeline: Fusion exports (STEP,
gerbers, EAGLE source) processed with the Adom step2glb molecule optimizer; stackup measured from the
exported bodies + design rules.</p>

<script type="module">
(function(){
  const B=()=>window.BABYLON;
  const status=document.getElementById("vstatus");
  const wrap=document.getElementById("viewer-wrap");
  const card=document.getElementById("viewer-card");
  const btnAnim=document.getElementById("btn-anim");
  const btnA=document.getElementById("btn-assembled");
  const btnE=document.getElementById("btn-exploded");
  const btnFS=document.getElementById("btn-fs");
  const lpRows=document.getElementById("lp-rows");
  const lpOrder=document.getElementById("lp-order");
  const scrub=document.getElementById("scrub");
  const set=(t)=>{ if(status) status.textContent=t; };
  // Clean inline-SVG icons (no emoji) on each toolbar button, keeping the text label.
  (function(){
    const I={
      "btn-pivot":'<circle cx="8" cy="8" r="3.2"/><path d="M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15"/>',
      "btn-ground":'<path d="M2 11h12"/><path d="M5.5 11l1.2-4h2.6l1.2 4"/><ellipse cx="8" cy="13" rx="6" ry="1.4"/>',
      "btn-shadows":'<circle cx="6.5" cy="6.5" r="3.3"/><path d="M6.5 1.2v1M6.5 11.8v1M1.2 6.5h1M10.8 6.5h1M2.9 2.9l.7.7M9.4 9.4l.7.7M10.1 2.9l-.7.7"/><path d="M8.5 12.5h6"/>',
      "btn-anim":'<path d="M2 4h12M2 8h12M2 12h12"/><path d="M11.5 1.5L14 4l-2.5 2.5"/>',
      "btn-assembled":'<rect x="3" y="6" width="10" height="1.6"/><rect x="3" y="9" width="10" height="1.6"/><rect x="3" y="12" width="10" height="1.6"/>',
      "btn-exploded":'<rect x="3" y="2.5" width="10" height="1.6"/><rect x="3" y="7.2" width="10" height="1.6"/><rect x="3" y="11.9" width="10" height="1.6"/><path d="M8 5.2v1M8 9.9v1" stroke-dasharray="1 1"/>',
      "btn-fs":'<path d="M2 5.5V2h3.5M14 5.5V2h-3.5M2 10.5V14h3.5M14 10.5V14h-3.5"/>',
      "btn-lightpanel":'<circle cx="8" cy="6.5" r="3.4"/><path d="M6.6 9.5h2.8M6.9 11.5h2.2M8 1.2v1M3.3 3l.7.7M12.7 3l-.7.7M1.8 6.5h1M13.2 6.5h1"/>',
      "btn-layerspanel":'<path d="M8 2l6 3-6 3-6-3z"/><path d="M2 8.5l6 3 6-3M2 11.5l6 3 6-3"/>'
    };
    Object.keys(I).forEach(function(id){
      const b=document.getElementById(id); if(!b) return;
      const label=b.textContent.trim();
      const TIP={
        "btn-pivot":"Click-to-pivot: click anywhere and the camera orbits around exactly that point (always on)",
        "btn-ground":"Ground plane: show or hide the semi-transparent floor the shadow falls on",
        "btn-shadows":"Shadows: toggle the drop shadow that helps you read each layer's height",
        "btn-anim":"Animate: auto-play the cascade that lifts each layer in turn, then reassembles",
        "btn-assembled":"Assembled: jump to the finished, fully-stacked board",
        "btn-exploded":"Exploded: jump to the fully-separated stackup",
        "btn-fs":"Fullscreen: expand the 3D viewer to fill the browser window",
        "btn-lightpanel":"Lighting controls: tune the spotlight, environment, shadows and copper material, then snapshot your settings",
        "btn-layerspanel":"Layers panel: per-layer visibility, isolate, bounding box and live z heights"
      };
      b.setAttribute("data-tip",TIP[id]||label); b.removeAttribute("title");
      b.innerHTML='<svg viewBox="0 0 16 16" aria-hidden="true">'+I[id]+'</svg>';
    });
  })();
  const R="/blob/component/bq25792-charger/";
  const mm=1e-3, STEP=5*mm;

  function loadPngBytes(url){
    return new Promise(function(resolve,reject){
      var img=new Image(); img.crossOrigin="anonymous";
      img.onload=function(){
        try{
          var cv=document.createElement("canvas"); cv.width=img.width; cv.height=img.height;
          var ctx=cv.getContext("2d",{willReadFrequently:true}); ctx.drawImage(img,0,0);
          var d=ctx.getImageData(0,0,img.width,img.height).data;
          var total=img.width*img.height, rgb=new Uint8Array(total*3);
          for(var i=0,j=0;i<total;i++){ var k=i*4; rgb[j++]=d[k]; rgb[j++]=d[k+1]; rgb[j++]=d[k+2]; }
          var n=rgb[0]|(rgb[1]<<8)|(rgb[2]<<16)|(rgb[3]<<24);
          resolve(rgb.subarray(4,4+n));
        }catch(e){ reject(e); }
      };
      img.onerror=function(){ reject(new Error("image failed: "+url)); };
      img.src=url;
    });
  }

  // Blob->data-URI shim: img-src has no blob:, Babylon wraps glTF textures in blob
  // object-URLs. Stamp small Blobs with a data: URI and return that instead.
  (function(){
    var orig=URL.createObjectURL.bind(URL);
    URL.createObjectURL=function(b){ return (b&&b.__dataUri)||orig(b); };
    var OB=window.Blob;
    window.Blob=function(parts,opts){
      var bl=new OB(parts,opts);
      try{
        if(parts&&parts.length===1&&(parts[0] instanceof Uint8Array||parts[0] instanceof ArrayBuffer)){
          var u8=parts[0] instanceof Uint8Array?parts[0]:new Uint8Array(parts[0]);
          if(u8.length<3000000){
            var st=""; for(var i=0;i<u8.length;i+=0x8000) st+=String.fromCharCode.apply(null,u8.subarray(i,i+0x8000));
            bl.__dataUri="data:"+((opts&&opts.type)||"application/octet-stream")+";base64,"+btoa(st);
          }
        }
      }catch(e){}
      return bl;
    };
    window.Blob.prototype=OB.prototype;
  })();
  const Viewer=window.Adom3DViewerBabylon9;
  if(!Viewer){ set("viewer bundle failed to load"); return; }
  let viewer;
  try{ viewer=Viewer.init(wrap,{zUp:true,showViewCube:true,showGround:true}); }
  catch(e){ set("viewer failed to start: "+(e&&e.message)); return; }

  // LAYERS top->bottom. z = nominal assembled plane (mm) for the readout + order check.
  const LAYERS=[
    {key:"components", label:"Components", z:1.75},
    {key:"silk_top",   label:"Silkscreen (top)", z:1.600},
    {key:"mask_top",   label:"Soldermask (top)", z:1.592},
    {key:"cu1",        label:"Copper L1",        z:1.534},
    {key:"ppg_top",    label:"FR4 prepreg (top)",z:1.416},
    {key:"cu_l2",      label:"Copper L2",        z:1.299},
    {key:"core",       label:"FR4 core",         z:0.758},
    {key:"cu_l15",     label:"Copper L15",       z:0.218},
    {key:"ppg_bot",    label:"FR4 prepreg (bot)",z:0.100},
    {key:"cu16",       label:"Copper L16",       z:-0.019},
    {key:"mask_bot",   label:"Soldermask (bot)", z:-0.075},
    {key:"silk_bot",   label:"Silkscreen (bot)", z:-0.083},
    {key:"tht",        label:"Components (machine pins)", z:-0.090},
  ];
  const N=LAYERS.length;
  LAYERS.forEach(L=>{ L.meshes=[]; L.visible=true; });
  const byKey={}; LAYERS.forEach(L=>byKey[L.key]=L);
  let SC=null, GROUND_PLANE=null, SHADOW_GEN=null, SHADOW_LIGHT=null;
  function attach(key,meshes){
    meshes=meshes.filter(m=>!/ground|sky|env|viewcube|axis|grid|arcRotate|Background|__root__/i.test(m.name||""));
    const L=byKey[key];
    if(!L.wrap){ L.wrap=new (B().TransformNode)(key+"_wrap",SC); }   // identity node at SCENE ROOT
    meshes.forEach(m=>{ try{ m.setParent(L.wrap); }catch(e){} });    // preserves world transform
    L.meshes=L.meshes.concat(meshes);
  }

  async function build(){
    const sc=viewer.getScene(); SC=sc;
    set("loading board...");
    // PER-PART components (non-join export keeps each occupant as its own mesh). Classify by
    // XY against the 14 machine-pin positions from the CPL: a mesh whose footprint covers an
    // MP position is a machine pin -> anchored layer; everything else -> Components.
    const MP=[[1.2,1.2],[1.2,25.2],[49.2,25.2],[49.2,1.2]].map(p=>[p[0]*mm,p[1]*mm]);
    const compBytes=await loadPngBytes(R+"grp-components-parts.glb.png");
    let pre=sc.meshes.length;
    await B().SceneLoader.AppendAsync("", new File([compBytes],"components.glb",{type:"model/gltf-binary"}), sc, null, ".glb");
    try{ if(viewer.toggleAxes) viewer.toggleAxes("world",false); }catch(e){}
    {
      const all=sc.meshes.slice(pre).filter(m=>m.getTotalVertices&&m.getTotalVertices()>0);
      // group leaf meshes by their top-level OCCURRENCE node (the child of __root__ that is
      // their highest ancestor) so a whole component/pin is classified together, not per sub-part.
      function topOcc(m){ let n=m, p=m.parent; while(p && !/__root__/.test(p.name||"")){ n=p; p=p.parent; } return n; }
      const groups=new Map();
      all.forEach(function(m){ const k=topOcc(m); if(!groups.has(k)) groups.set(k,[]); groups.get(k).push(m); });
      const pins=[], comps=[];
      groups.forEach(function(meshes){
        // combined world bbox of the whole occurrence
        let mnx=1e9,mny=1e9,mnz=1e9,mxx=-1e9,mxy=-1e9,mxz=-1e9;
        meshes.forEach(function(m){ try{ m.computeWorldMatrix(true); const b=m.getBoundingInfo().boundingBox; mnx=Math.min(mnx,b.minimumWorld.x);mny=Math.min(mny,b.minimumWorld.y);mnz=Math.min(mnz,b.minimumWorld.z);mxx=Math.max(mxx,b.maximumWorld.x);mxy=Math.max(mxy,b.maximumWorld.y);mxz=Math.max(mxz,b.maximumWorld.z);}catch(e){} });
        const cx=(mnx+mxx)/2, cy=(mny+mxy)/2, tall=(mxz-mnz)>3*mm, thru=mnz< -0.3*mm;
        let isPin=false;
        for(const p of MP){ if(mnx-0.8*mm<=p[0]&&mxx+0.8*mm>=p[0]&&mny-0.8*mm<=p[1]&&mxy+0.8*mm>=p[1]){ isPin=true; break; } }

        (isPin?pins:comps).push.apply(isPin?pins:comps, meshes);
      });
      attach("components", comps);
      attach("tht", pins);
    }

    for (const g of [["grp-cu1.glb.png","cu1"],["grp-cu16.glb.png","cu16"]]){
      const bytes=await loadPngBytes(R+g[0]);
      pre=sc.meshes.length;
      await B().SceneLoader.AppendAsync("", new File([bytes],g[1]+".glb",{type:"model/gltf-binary"}), sc, null, ".glb");
      attach(g[1], sc.meshes.slice(pre));
    }

    // ONE shiny copper for all four copper layers
    const copperMat=new (B().PBRMaterial)("copper_shared",sc);
    copperMat.albedoColor=new (B().Color3)(0.95,0.60,0.42);  // copper
    copperMat.metallic=1.0; copperMat.roughness=0.28;         // glossy metal sheen from the scene env
    copperMat.backFaceCulling=false;
    copperMat.environmentIntensity=0.5;
    byKey.cu1.meshes.concat(byKey.cu16.meshes).forEach(m=>{ if(m.material) m.material=copperMat; });

    // FR4 sublayers = real Board body clones (drill holes)
    const boardBytes=await loadPngBytes(R+"grp-board.glb.png");
    pre=sc.meshes.length;
    await B().SceneLoader.AppendAsync("", new File([boardBytes],"board.glb",{type:"model/gltf-binary"}), sc, null, ".glb");
    const boardMeshes=sc.meshes.slice(pre).filter(m=>m.getTotalVertices&&m.getTotalVertices()>0);
    const FR4_TH=1.516*mm, FR4_C=0.758*mm;
    function fr4Layer(key, zc, th){
      const clones=boardMeshes.map(function(m,i){
        const c=m.clone(key+"_"+i);
        c.scaling=m.scaling.clone(); c.scaling.z=m.scaling.z*(th/FR4_TH);
        c.position=m.position.clone();
        c.position.z=(zc-FR4_C*(th/FR4_TH))+m.position.z;
        if(c.material){ c.material=c.material.clone(key+"_m"); c.material.alpha=0.8; }
        return c;
      });
      attach(key, clones);
    }
    fr4Layer("ppg_top", 1.416*mm, 0.200*mm);
    fr4Layer("core",    0.758*mm, 1.046*mm);
    fr4Layer("ppg_bot", 0.100*mm, 0.200*mm);
    boardMeshes.forEach(m=>m.setEnabled(false));

    // gerber quads
    function quad(key, tex, zc, mirror, copper){
      const p=B().MeshBuilder.CreatePlane(key,{width:50.4*mm,height:26.4*mm},sc);
      p.position=new (B().Vector3)(25.2*mm,13.2*mm,zc);
      const t=new (B().Texture)(R+tex,sc,false,false); t.hasAlpha=true;
      let mat;
      if(copper){
        mat=copperMat.clone(key+"_cu");                 // SAME copper material as the outer layers
        mat.opacityTexture=t; t.getAlphaFromRGB=false;  // gerber alpha = trace-shaped cutout only
        mat.useAlphaFromAlbedoTexture=false;
        mat.transparencyMode=1; mat.alphaCutOff=0.35;   // ALPHATEST: crisp trace edges, no color tint
      }
      else{
        mat=new (B().StandardMaterial)(key+"_m",sc);
        mat.diffuseTexture=t; mat.opacityTexture=t;
        mat.emissiveColor=new (B().Color3)(0.25,0.25,0.25);  // slight lift only - lit+shadowable, not emissive-flat
      }
      mat.backFaceCulling=false; p.material=mat;
      if(mirror) p.scaling.x=-1;
      attach(key,[p]);
    }
    // ALL-VECTOR silk + mask (real geometry; textured quads cast no/solid shadows).
    // Masks are gerber NEGATIVES (board outline minus pad openings); z baked in each GLB.
    for(const v of [["silk-top-vec.glb.png","silk_top"],["mask-top-vec.glb.png","mask_top"],
                    ["mask-bot-vec.glb.png","mask_bot"],["silk-bot-vec.glb.png","silk_bot"],
                    ["cu-l2-vec.glb.png","cu_l2"],["cu-l15-vec.glb.png","cu_l15"]]){
      const vb=await loadPngBytes(R+v[0]);
      let vpre=sc.meshes.length;
      await B().SceneLoader.AppendAsync("", new File([vb],v[1]+".glb",{type:"model/gltf-binary"}), sc, null, ".glb");
      const vm=sc.meshes.slice(vpre).filter(m=>m.getTotalVertices&&m.getTotalVertices()>0);
      vm.forEach(m=>{ m.position.z=0; if(v[1].indexOf("cu_")===0) m.material=copperMat; });
      attach(v[1], vm);
    }

    // USE THE BUNDLE'S OWN SHADOW RIG (proven: the built-in /viewer renders gorgeous crisp
    // shadows with it). SceneBuilder makes a SpotLight + 8192 PCF ShadowGenerator at init,
    // tuned for mm-scale scenes and repositioned by frameModel - which we bypass, so we
    // reposition/rescale the spot for our meter-scale board ourselves.
    if(sc.lights.length===0){ new (B().HemisphericLight)("rm_hemi",new (B().Vector3)(0.2,0.2,1),sc).intensity=0.9; }
    try{ sc.environmentIntensity=0.35; }catch(e){}   // IBL is unshadowable - keep it a fill, not the key
    const CENTER=new (B().Vector3)(0.0252,0.0132,0);
    SHADOW_LIGHT=sc.getLightByName("spotLight");
    SHADOW_GEN=(viewer.getShadowGenerator && viewer.getShadowGenerator()) ||
               (SHADOW_LIGHT && SHADOW_LIGHT.getShadowGenerator && SHADOW_LIGHT.getShadowGenerator());
    try{ const h=sc.getLightByName("light"); if(h) h.intensity=0.15; const bl=sc.getLightByName("bottomLight"); if(bl) bl.intensity=0; }catch(e){}
    if(SHADOW_LIGHT && SHADOW_GEN){
      // frameModel's job, done by hand: spot at the mm-rig's (600,-300,1500) ratio, scaled to meters
      SHADOW_LIGHT.position=new (B().Vector3)(CENTER.x+0.6, CENTER.y-0.3, 1.5);
      SHADOW_LIGHT.direction=CENTER.subtract(SHADOW_LIGHT.position).normalize();
      SHADOW_LIGHT.intensity=6.0;                    // DOMINANT direct light - shadows only darken direct light on PBR, so it must dominate
      SHADOW_LIGHT.shadowMinZ=0.5; SHADOW_LIGHT.shadowMaxZ=3.0;
      if("angle" in SHADOW_LIGHT) SHADOW_LIGHT.angle=0.45;
      try{
        SHADOW_GEN.forceBackFacesOnly=false;                       // thin quads/planes must cast
        // PCF, NOT PCSS: useContactHardeningShadow + enableSoftTransparentShadow render NO
        // shadows at all in this scene (ralph-tested: 0.0% pixel change; plain PCF = 26.7%).
        SHADOW_GEN.usePercentageCloserFiltering=true;
        SHADOW_GEN.filteringQuality=B().ShadowGenerator.QUALITY_MEDIUM;  // medium = softer edges than HIGH
        SHADOW_GEN.useContactHardeningShadow=false;
        SHADOW_GEN.transparencyShadow=true;                        // translucent layers cast their ARTWORK, not solid slabs
      }catch(e){}
    }else{
      // fallback: build a PCF spot matching the bundle's recipe
      SHADOW_LIGHT=new (B().SpotLight)("rm_spot",new (B().Vector3)(CENTER.x+0.6,CENTER.y-0.3,1.5),
        CENTER.subtract(new (B().Vector3)(CENTER.x+0.6,CENTER.y-0.3,1.5)).normalize().scale(-1).scale(-1),Math.PI*0.7,50,sc);
      SHADOW_LIGHT.direction=CENTER.subtract(SHADOW_LIGHT.position).normalize();
      SHADOW_LIGHT.intensity=2.2;
      SHADOW_GEN=new (B().ShadowGenerator)(4096,SHADOW_LIGHT);
      SHADOW_GEN.usePercentageCloserFiltering=true; SHADOW_GEN.filteringQuality=B().ShadowGenerator.QUALITY_HIGH;
      SHADOW_GEN.bias=0.0000002; SHADOW_GEN.normalBias=0.00008; SHADOW_GEN.darkness=-1;
      SHADOW_GEN.forceBackFacesOnly=true;
    }
    // Colby freezes the bundle's shadow map for perf; our layers MOVE, so refresh every frame.
    try{ SHADOW_GEN.getShadowMap().refreshRate=B().RenderTargetTexture.REFRESHRATE_RENDER_ONEVERYFRAME; }catch(e){}
    // every layer CASTS and RECEIVES: during the explosion each layer shadows the layers below it
    LAYERS.forEach(L=>L.meshes.forEach(m=>{ try{ SHADOW_GEN.addShadowCaster(m,true); m.receiveShadows=true; }catch(e){} }));

    // Semi-transparent ground that receives the bundle's shadow (like /viewer's ground).
    GROUND_PLANE=B().MeshBuilder.CreateGround("adomGround",{width:0.3,height:0.3},sc);
    GROUND_PLANE.position=new (B().Vector3)(CENTER.x,CENTER.y,-0.0015);
    GROUND_PLANE.rotation.x=Math.PI/2;           // face up in a z-up scene
    const gm=new (B().StandardMaterial)("adomGroundMat",sc);
    gm.diffuseColor=new (B().Color3)(0.62,0.64,0.68); gm.specularColor=new (B().Color3)(0,0,0);
    gm.alpha=0.35; gm.backFaceCulling=false;
    GROUND_PLANE.material=gm; GROUND_PLANE.receiveShadows=true;

    // ground: 80% transparent, receives shadows


    // frame: assembled view + headroom for the +55mm cascade
    setTimeout(function(){
      try{
        const cam=sc.activeCamera;
        cam.setTarget(new (B().Vector3)(25.2*mm,13.2*mm,18*mm));  // board center, raised for the cascade
        cam.radius=0.115;                 // ~115mm: fills the frame for a 50x26mm board + explode headroom
        cam.beta=1.15; cam.alpha=-1.15;
        cam.minZ=0.0008; cam.maxZ=4.0;
        if(typeof cam.wheelPrecision==="number") cam.wheelPrecision=45;
      }catch(e){}
    },400);

    buildPanel();
    // ── Lighting panel: live-tunable settings + helper gizmo + snapshot ──
    (function(){
      const rows=document.getElementById("lt-rows"); if(!rows) return;
      const sc2=sc;
      const bundleHemi=sc2.getLightByName("light");
      const defs=[
        ["Spot intensity", ()=>SHADOW_LIGHT.intensity, v=>SHADOW_LIGHT.intensity=v, 0, 20, 0.1],
        ["Spot angle",     ()=>SHADOW_LIGHT.angle||0.45, v=>{if("angle" in SHADOW_LIGHT)SHADOW_LIGHT.angle=v;}, 0.1, 1.4, 0.01],
        ["Spot X",         ()=>SHADOW_LIGHT.position.x, v=>{SHADOW_LIGHT.position.x=v;aim();}, -1, 1.2, 0.01],
        ["Spot Y",         ()=>SHADOW_LIGHT.position.y, v=>{SHADOW_LIGHT.position.y=v;aim();}, -1, 1.2, 0.01],
        ["Spot Z",         ()=>SHADOW_LIGHT.position.z, v=>{SHADOW_LIGHT.position.z=v;aim();}, 0.2, 3, 0.01],
        ["Env intensity",  ()=>sc2.environmentIntensity, v=>sc2.environmentIntensity=v, 0, 2, 0.01],
        ["Hemi intensity", ()=>bundleHemi?bundleHemi.intensity:0, v=>{if(bundleHemi)bundleHemi.intensity=v;}, 0, 1.5, 0.01],
        ["Shadow darkness",()=>SHADOW_GEN.getDarkness(), v=>SHADOW_GEN.setDarkness(v), 0, 1, 0.01],
        ["Cu metallic",    ()=>copperMat.metallic, v=>{copperMat.metallic=v;sc2.materials.forEach(m=>{if(/_cu$/.test(m.name||""))m.metallic=v;});}, 0, 1, 0.01],
        ["Cu roughness",   ()=>copperMat.roughness, v=>{copperMat.roughness=v;sc2.materials.forEach(m=>{if(/_cu$/.test(m.name||""))m.roughness=v;});}, 0, 1, 0.01],
      ];
      function aim(){ SHADOW_LIGHT.direction=CENTER.subtract(SHADOW_LIGHT.position).normalize(); if(HELPER_LINE) drawHelper(); }
      const state={};
      defs.forEach(function(d){
        const [label,get,set,min,max,step]=d;
        const row=document.createElement("div"); row.className="lt-row";
        const lb=document.createElement("label"); lb.textContent=label;
        const r=document.createElement("input"); r.type="range"; r.min=min; r.max=max; r.step=step; r.value=get();
        const v=document.createElement("span"); v.className="lt-v"; v.textContent=(+get()).toFixed(2);
        r.addEventListener("input",function(){ const val=+r.value; set(val); v.textContent=val.toFixed(2); state[label]=val; });
        state[label]=+get();
        row.appendChild(lb); row.appendChild(r); row.appendChild(v); rows.appendChild(row);
      });
      // helper gizmo: sphere at the light + a line to the board center
      let HELPER=null, HELPER_LINE=null;
      function drawHelper(){
        if(HELPER_LINE){ HELPER_LINE.dispose(); HELPER_LINE=null; }
        HELPER_LINE=B().MeshBuilder.CreateLines("lt_line",{points:[SHADOW_LIGHT.position.clone(),CENTER.clone()]},sc2);
        HELPER_LINE.color=new (B().Color3)(1,0.85,0.2);
        if(HELPER) HELPER.position.copyFrom(SHADOW_LIGHT.position);
      }
      document.getElementById("lt-helpers").addEventListener("click",function(){
        if(HELPER){ HELPER.dispose(); HELPER=null; if(HELPER_LINE){HELPER_LINE.dispose();HELPER_LINE=null;} this.classList.remove("on"); set("light helper off"); return; }
        HELPER=B().MeshBuilder.CreateSphere("lt_helper",{diameter:0.05},sc2);
        HELPER.position.copyFrom(SHADOW_LIGHT.position);
        const hm=new (B().StandardMaterial)("lt_hm",sc2); hm.emissiveColor=new (B().Color3)(1,0.85,0.2); hm.disableLighting=true;
        HELPER.material=hm; HELPER.isPickable=false;
        drawHelper(); this.classList.add("on");
        set("light helper ON (sphere = spot, line = aim)");
      });
      document.getElementById("lt-snap").addEventListener("click",function(){
        window.__lightSnapshot=JSON.parse(JSON.stringify(state));
        set("lighting snapshot taken - tell the AI to read it");
        console.log("LIGHT_SNAPSHOT",JSON.stringify(state));
      });
      makeDraggable(document.getElementById("light-panel"));
    })();
    try{ const cam0=viewer.getCamera(); if(cam0&&cam0.setClickToSetRotationPoint) cam0.setClickToSetRotationPoint(true,250); }catch(e){}
    set("animating");
  }

  // ---- cascading UP explode: u in [0,1]; step k lifts layers 0..k by +5mm ----
  // final offset(layer i) = (N-1-i)*5mm; every offset >= 0 (never below ground).
  function offsetFor(i,u){
    const steps=N-1;
    const pos=u*steps;                       // continuous step position
    const done=Math.floor(pos), p=pos-done;  // completed steps + partial
    let o=Math.max(0, done-i);               // completed contributions to layer i
    if(i<=done && done<steps) o+=p;          // current step lifts layers 0..done
    return o*STEP;
  }
  function apply(u){
    for(let i=0;i<N;i++){
      const off=offsetFor(i,u);
      LAYERS[i].curOff=off;
      if(LAYERS[i].wrap) LAYERS[i].wrap.position.z=off;   // wrapper at scene root: world-true
    }
    if(scrub && mode!=="manual") scrub.value=String(Math.round(u*1000));
  }

  // ---- layers panel ----
  let isolatedKey=null;
  function applyVisibility(){
    LAYERS.forEach(function(L){
      const on = isolatedKey ? (L.key===isolatedKey) : L.visible;
      if(L.wrap) L.wrap.setEnabled(on);
      if(L.cb) L.cb.disabled = !!isolatedKey;
    });
  }
  function buildPanel(){
    lpRows.innerHTML="";
    LAYERS.forEach(function(L,i){
      const row=document.createElement("div"); row.className="lp-row";
      const iso=document.createElement("button"); iso.className="lp-iso"; iso.type="button";
      iso.setAttribute("data-tip","Isolate: show ONLY this layer and hide the rest (click again to restore all)"); iso.textContent="\u25CE";
      iso.addEventListener("click",function(e){
        e.stopPropagation();
        isolatedKey = (isolatedKey===L.key) ? null : L.key;
        lpRows.querySelectorAll(".lp-iso").forEach(function(b){ b.classList.remove("on"); });
        if(isolatedKey) iso.classList.add("on");
        applyVisibility();
        set(isolatedKey ? ("isolated: "+L.label) : "all layers");
      });
      const bx=document.createElement("button"); bx.className="lp-iso lp-bbox"; bx.type="button";
      bx.setAttribute("data-tip","Bounding box: draw this layer's exact extents (X/Y/Z) to verify its position and height"); bx.textContent="\u25A1";
      bx.addEventListener("click",function(e){ e.stopPropagation(); toggleBBox(L,bx); });
      const cb=document.createElement("input"); cb.type="checkbox"; cb.checked=true; L.cb=cb;
      cb.setAttribute("data-tip","Show or hide this layer");
      cb.addEventListener("change",function(){ L.visible=cb.checked; applyVisibility(); });
      const nm=document.createElement("span"); nm.className="lp-name"; nm.textContent=L.label;
      const z=document.createElement("span"); z.className="lp-z"; L.zEl=z;
      row.appendChild(iso); row.appendChild(bx); row.appendChild(cb); row.appendChild(nm); row.appendChild(z);
      row.addEventListener("mouseenter",function(){ if(!isolatedKey) hilite(L,true); });
      row.addEventListener("mouseleave",function(){ hilite(L,false); });
      lpRows.appendChild(row);
    });
  }
  function toggleBBox(L,btn){
    if(L.boxMesh){ L.boxMesh.dispose(); L.boxMesh=null; btn.classList.remove("on"); set("bbox off: "+L.label); return; }
    const sc=viewer.getScene();
    let mnx=1e9,mny=1e9,mnz=1e9,mxx=-1e9,mxy=-1e9,mxz=-1e9;
    L.meshes.forEach(function(m){ m.computeWorldMatrix(true); const b=m.getBoundingInfo().boundingBox;
      mnx=Math.min(mnx,b.minimumWorld.x);mny=Math.min(mny,b.minimumWorld.y);mnz=Math.min(mnz,b.minimumWorld.z);
      mxx=Math.max(mxx,b.maximumWorld.x);mxy=Math.max(mxy,b.maximumWorld.y);mxz=Math.max(mxz,b.maximumWorld.z); });
    if(mnx>mxx) return;
    const dz=Math.max(mxz-mnz, 5e-5);
    const box=B().MeshBuilder.CreateBox("bbox_"+L.key,{width:mxx-mnx,height:mxy-mny,depth:dz},sc);
    box.position=new (B().Vector3)((mnx+mxx)/2,(mny+mxy)/2,(mnz+mxz)/2);
    const em=new (B().StandardMaterial)("bboxm_"+L.key,sc);
    em.wireframe=true; em.emissiveColor=new (B().Color3)(0.0,0.9,0.85); em.disableLighting=true;
    box.material=em; box.isPickable=false;
    L.boxMesh=box; btn.classList.add("on");
    set("bbox "+L.label+": z "+(mnz*1000).toFixed(2)+".."+(mxz*1000).toFixed(2)+"mm");
  }
  function hilite(L,on){
    L.meshes.forEach(function(m){
      try{ m.renderOverlay=on; if(on){ m.overlayColor=new (B().Color3)(0.0,0.9,0.85); m.overlayAlpha=0.45; } }catch(e){}
    });
  }
  let panelTick=0;
  function updatePanel(){
    if(++panelTick%6) return;   // ~10Hz
    let ok=true, prev=Infinity;
    for(let i=0;i<N;i++){
      const L=LAYERS[i];
      const zNow=L.z + (L.curOff||0)/mm;   // mm
      if(L.zEl) L.zEl.textContent="z "+zNow.toFixed(2);
      if(zNow>prev+1e-6){ ok=false; }      // each layer must be BELOW the one above
      prev=zNow;
    }
    if(lpOrder){ lpOrder.textContent=ok?"order: OK (top-down monotonic)":"order: VIOLATION"; lpOrder.className=ok?"":"bad"; }
  }

  // ---- modes: anim (autoplay cascade) | manual (slider, resumes after 5s) | t0 | t1 ----
  let mode="anim", u=0, dir=1, holdT=0, resumeTimer=null;
  const CASCADE_T=5.0, HOLD=1.0;
  function setBtns(b){ [btnAnim,btnA,btnE].forEach(x=>x.classList.remove("active")); if(b)b.classList.add("active"); }
  function tick(dt){
    if(mode==="t0"){ if(u>0){u=Math.max(0,u-dt/1.5); apply(u);} return; }
    if(mode==="t1"){ if(u<1){u=Math.min(1,u+dt/1.5); apply(u);} return; }
    if(mode==="manual") return;
    if(holdT>0){ holdT-=dt; return; }
    u+=dir*dt/CASCADE_T;
    if(u>=1){ u=1; dir=-1; holdT=HOLD; }
    if(u<=0){ u=0; dir=1; holdT=HOLD; }
    apply(u);
  }
  btnAnim.addEventListener("click",function(){ mode="anim"; dir=1; holdT=0; setBtns(btnAnim); set("animating"); });
  btnA.addEventListener("click",function(){ mode="t0"; setBtns(btnA); set("assembled"); });
  btnE.addEventListener("click",function(){ mode="t1"; setBtns(btnE); set("exploded"); });
  // Click-pivot: the viewer's Shift+Alt-click-to-set-orbit-point, made always-on
  let pivotOn=true;
  const btnPivot=document.getElementById("btn-pivot");
  btnPivot.addEventListener("click",function(){
    pivotOn=!pivotOn;
    try{ const cam=viewer.getCamera(); if(cam&&cam.setClickToSetRotationPoint) cam.setClickToSetRotationPoint(pivotOn,250); }catch(e){}
    btnPivot.classList.toggle("active",pivotOn);
    set(pivotOn?"click-pivot ON (click any point to orbit it)":"click-pivot off");
  });
  // Ground plane show/hide
  let groundOn=true;
  const btnGround=document.getElementById("btn-ground");
  btnGround.addEventListener("click",function(){
    groundOn=!groundOn;
    if(GROUND_PLANE) GROUND_PLANE.setEnabled(groundOn);
    btnGround.classList.toggle("active",groundOn);
    set(groundOn?"ground shown":"ground hidden");
  });
  // Shadows toggle (scene-wide). Ensure the layer meshes are casters + the ground receives.
  let shadowsOn=true;
  const btnShadows=document.getElementById("btn-shadows");
  btnShadows.addEventListener("click",function(){
    shadowsOn=!shadowsOn;
    const sc=viewer.getScene();
    sc.shadowsEnabled=shadowsOn;   // MASTER switch: kills/restores ALL shadows (freezing the map just kept them frozen-on)
    if(SHADOW_GEN && SHADOW_GEN.getShadowMap()) SHADOW_GEN.getShadowMap().refreshRate = shadowsOn ? window.BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONEVERYFRAME : 0;
    btnShadows.classList.toggle("active",shadowsOn);
    set(shadowsOn?"shadows on":"shadows off");
  });
  const btnLightPanel=document.getElementById("btn-lightpanel");
  const btnLayersPanel=document.getElementById("btn-layerspanel");
  btnLightPanel.addEventListener("click",function(){
    const p=document.getElementById("light-panel"); if(!p) return;
    const show=p.style.display!=="block";
    p.style.display=show?"block":"none";
    btnLightPanel.classList.toggle("active",show);
    set(show?"lighting controls shown":"lighting controls hidden");
  });
  btnLayersPanel.addEventListener("click",function(){
    const p=document.getElementById("layers-panel"); if(!p) return;
    const show=p.style.display==="none";
    p.style.display=show?"block":"none";
    btnLayersPanel.classList.toggle("active",show);
    set(show?"layers panel shown":"layers panel hidden");
  });
  btnFS.addEventListener("click",function(){
    if(document.fullscreenElement){ document.exitFullscreen(); return; }
    (card.requestFullscreen?card.requestFullscreen():Promise.reject()).catch?.(function(){ set("fullscreen blocked in the embed - open the readme URL directly"); });
  });
  const btnPlay=document.getElementById("btn-play");
  function play(){ mode="anim"; dir=(u>=1)?-1:1; holdT=0; setBtns(btnAnim); set("animating"); btnPlay.innerHTML="&#10074;&#10074;"; if(resumeTimer){clearTimeout(resumeTimer);resumeTimer=null;} }
  function pause(){ mode="manual"; setBtns(null); set("paused"); btnPlay.innerHTML="&#9654;"; }
  btnPlay.addEventListener("click",function(){ if(mode==="anim") pause(); else play(); });
  scrub.addEventListener("input",function(){
    pause(); set("scrubbing");
    u=Number(scrub.value)/1000; apply(u);
    if(resumeTimer) clearTimeout(resumeTimer);
    resumeTimer=setTimeout(play, 120000);   // auto-resume after 2 minutes idle
  });

  // draggable floaties (drag by the :: grip)
  function makeDraggable(el){
    const grip=el.querySelector(".grip"); if(!grip) return;
    grip.addEventListener("pointerdown",function(e){
      e.preventDefault();
      const r=el.getBoundingClientRect(), pr=el.parentElement.getBoundingClientRect();
      el.style.left=(r.left-pr.left)+"px"; el.style.top=(r.top-pr.top)+"px"; el.style.right="auto"; el.style.bottom="auto";
      const sx=e.clientX-r.left+pr.left, sy=e.clientY-r.top+pr.top;
      function mv(ev){ el.style.left=(ev.clientX-sx)+"px"; el.style.top=(ev.clientY-sy)+"px"; }
      function up(){ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }
      document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
    });
  }
  makeDraggable(document.getElementById("floaty"));
  makeDraggable(document.getElementById("layers-panel"));

  // Beautiful hover tooltips (800ms delay) for every control carrying data-tip.
  (function(){
    const tt=document.createElement("div"); tt.id="tt"; document.body.appendChild(tt);
    let timer=null;
    function place(el){
      const r=el.getBoundingClientRect();
      const M=8, vw=document.documentElement.clientWidth, vh=document.documentElement.clientHeight;
      const w=tt.offsetWidth, h=tt.offsetHeight;
      // CLAMP horizontally so the tooltip NEVER runs off either edge
      const anchorX=r.left+r.width/2;
      let left=anchorX - w/2;
      left=Math.max(M, Math.min(left, vw - w - M));
      // below the control by default; flip ABOVE if it would run off the bottom
      let top=r.bottom+9, below=true;
      if(top + h > vh - M){ top=r.top - h - 9; below=false; }
      top=Math.max(M, Math.min(top, vh - h - M));
      tt.style.left=left+"px"; tt.style.top=top+"px";
      // keep the arrow pointing at the control even after clamping
      const arrowX=Math.max(10, Math.min(anchorX-left, w-10));
      tt.style.setProperty("--ax", arrowX+"px");
      tt.classList.toggle("flip", !below);
    }
    function show(el){ const t=el.getAttribute("data-tip"); if(!t) return; tt.textContent=t; tt.style.left="-9999px"; tt.classList.add("show"); place(el); }
    function hide(){ tt.classList.remove("show"); }
    let curEl=null;
    document.addEventListener("mouseover",function(e){
      const el=e.target.closest("[data-tip]"); if(!el) return;
      if(el===curEl) return;                     // crossing into svg/path CHILDREN - not a new hover
      curEl=el;
      clearTimeout(timer); timer=setTimeout(function(){ show(el); },600);
    });
    document.addEventListener("mouseout",function(e){
      const el=e.target.closest("[data-tip]"); if(!el) return;
      if(el.contains(e.relatedTarget)) return;   // still inside the control - not a real leave
      curEl=null; clearTimeout(timer); hide();
    });
    document.addEventListener("click",hide,true);
    window.__ttReady=true;
  })();

  build().then(function(){
    const sc=viewer.getScene();
    let last=performance.now();
    sc.onBeforeRenderObservable.add(function(){
      const now=performance.now(); const dt=Math.min(0.05,(now-last)/1000); last=now;
      tick(dt); updatePanel();
    });
    apply(0);
  }).catch(function(e){ set("load failed: "+((e&&e.message)||"unknown")); });
})();
</script>
</body>
</html>