← Commit history

cascade v2: upward-only cascading explode (5mm/step, nothing below ground), layers panel with live z + visibility toggles + hover highlight, realtime order check, scrub slider with 5s auto-resume

John Lauer ·1d1ec089d0 ·3mo ago ·parent de27365
3 files changed +174−133
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "bq25792-charger",   "title": "BQ25792 Charger",   "type": "component",-  "version": "0.1.32",+  "version": "0.1.33",   "description": "BQ25792 Charger - buck-boost 1S-4S battery charger board by Adonis, with real 3D copper traces and measured stackup.",   "tags": [     "hardware"
page.json+10−3
@@ -4,9 +4,16 @@   "slug": "bq25792-charger",   "title": "BQ25792 Charger",   "brief": "Buck-boost 1S-4S battery charger board (TI BQ25792) with 14 Adom machine pins, by Adonis",-  "version": "0.1.32",+  "version": "0.1.33",   "tags": [-    "hardware"+    "adom",+    "adom-modular",+    "component",+    "charger",+    "battery",+    "bq25792",+    "power",+    "usb-c"   ],   "license": "proprietary",   "component": {@@ -49,7 +56,7 @@     "email": "[email protected]"   },   "visibility": "public",-  "description": "BQ25792 Charger - buck-boost 1S-4S battery charger board by Adonis, with real 3D copper traces and measured stackup.",+  "description": "BQ25792 Charger - a buck-boost 1S-4S battery charger board (Texas Instruments BQ25792) designed by Adonis in Fusion 360, with 14 Adom machine pins for the modular stack. Interactive colored 3D model, schematic and layout views.",   "dependencies": {},   "files": [     "README.md",
readme.html+163−129
@@ -43,6 +43,21 @@   .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:14px; right:14px; z-index:30; width:212px;+    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; }+  .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%; 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 { width:100%; accent-color:#00b8b1; } </style> </head> <body>@@ -50,11 +65,13 @@ <div id="viewer-card">   <div id="viewer-wrap"></div>   <div id="floaty">-    <button class="fbtn active" id="btn-anim" type="button" title="Animate explode/assemble">Animate</button>+    <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>     <button class="fbtn" id="btn-fs" type="button" title="Fullscreen">Fullscreen</button>   </div>+  <div id="layers-panel"><div class="lp-title">Layers</div><div id="lp-rows"></div><div id="lp-order">order: -</div></div>+  <div id="scrub-wrap"><input id="scrub" type="range" min="0" max="1000" value="0"></div>   <span id="vstatus">starting viewer...</span> </div> </div>@@ -122,9 +139,12 @@ exported bodies + design rules.</p>   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; };   const R="/blob/component/bq25792-charger/";-  const mm=1e-3;+  const mm=1e-3, STEP=5*mm;    function loadPngBytes(url){     return new Promise(function(resolve,reject){@@ -145,197 +165,210 @@ exported bodies + design rules.</p>     });   } -  // CSP shim: img-src has no blob:, so stamp small Blobs with a data: URI and return-  // that from createObjectURL (Babylon wraps glTF textures in blob object-URLs).-  (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;-  })();+  // sandbox storage shim already ran in <head>.   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: [element, assembled z-center (m), exploded z-center (m)]-  const LAYERS=[]; // {meshes:[], delta}-  function reg(meshes, delta){ meshes.forEach(m=>{ m.__z0=m.position.z; }); LAYERS.push({meshes:meshes, delta:delta}); }+  // LAYERS top->bottom. z = nominal assembled plane (mm) for the readout + order check.+  const LAYERS=[+    {key:"components", label:"Components",       z:1.582},+    {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},+  ];+  const N=LAYERS.length;+  LAYERS.forEach(L=>{ L.meshes=[]; L.visible=true; });+  const byKey={}; LAYERS.forEach(L=>byKey[L.key]=L);+  function attach(key,meshes){ meshes.forEach(m=>{ m.__z0=m.position.z; }); byKey[key].meshes=byKey[key].meshes.concat(meshes); }    async function build(){     const sc=viewer.getScene();-    const eng=viewer.getEngine();-     set("loading board...");-    // 1) components via the bundle's OWN loadModel (FilesInputStore + file:)     const compBytes=await loadPngBytes(R+"grp-components.glb.png");     B().FilesInputStore.FilesToLoad["components.glb"]=new File([compBytes],"components.glb",{type:"model/gltf-binary"});     let pre=sc.meshes.length;     await viewer.loadModel("file:components.glb");-    viewer.frameModel();     try{ if(viewer.toggleAxes) viewer.toggleAxes("world",false); }catch(e){}-    const compMeshes=sc.meshes.slice(pre);+    attach("components", sc.meshes.slice(pre)); -    // 2) outer copper groups appended into the same scene-    for (const g of [["grp-cu1.glb.png", 18*mm-1.5335*mm, "cu1"],["grp-cu16.glb.png", -18*mm+0.0185*mm, "cu16"]]){+    for (const g of [["grp-cu1.glb.png","cu1"],["grp-cu16.glb.png","cu16"]]){       const bytes=await loadPngBytes(R+g[0]);-      const f=new File([bytes],g[2]+".glb",{type:"model/gltf-binary"});       pre=sc.meshes.length;-      await B().SceneLoader.AppendAsync("", f, sc, null, ".glb");-      reg(sc.meshes.slice(pre), g[1]);+      await B().SceneLoader.AppendAsync("", new File([bytes],g[1]+".glb",{type:"model/gltf-binary"}), sc, null, ".glb");+      attach(g[1], sc.meshes.slice(pre));     }-    reg(compMeshes, 45*mm-1.582*mm);  // +7.5 slot: pins hang 7.8mm below seats and must clear the 30mm silk plane -    // ONE shiny copper material for ALL four copper layers (outer 3D bodies + inner-    // gerber layers) so every copper renders identically.+    // ONE shiny copper for all four copper layers     const copperMat=new (B().StandardMaterial)("copper_shared",sc);     copperMat.diffuseColor=new (B().Color3)(0.72,0.34,0.10);     copperMat.specularColor=new (B().Color3)(1.0,0.78,0.45);-    copperMat.specularPower=48;-    copperMat.backFaceCulling=false;-    LAYERS.slice(0,2).forEach(L=>L.meshes.forEach(m=>{ if(m.material) m.material=copperMat; }));+    copperMat.specularPower=48; copperMat.backFaceCulling=false;+    byKey.cu1.meshes.concat(byKey.cu16.meshes).forEach(m=>{ if(m.material) m.material=copperMat; }); -    // 3) FR4 sublayers: clones of the REAL Board body (actual drill holes), z-scaled to-    // each sublayer's thickness. Split fitted to the measured 1.516mm (Fusion design-    // rules hold 2-layer defaults); matches the standard JLC 4-layer stack.+    // 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(zc, th, delta, src){-      const clones=src.map(function(m,i){-        const c=m.clone("fr4_"+zc+"_"+i);+    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("fr4m"+zc); c.material.alpha=0.8; }+        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;       });-      reg(clones, delta);+      attach(key, clones);     }-    fr4Layer(1.416*mm, 0.200*mm, 12*mm-1.416*mm, boardMeshes);-    fr4Layer(0.758*mm, 1.046*mm,  0-0.758*mm,   boardMeshes);-    fr4Layer(0.100*mm, 0.200*mm, -12*mm-0.100*mm, boardMeshes);-    boardMeshes.forEach(m=>{ m.setEnabled(false); });+    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)); -    // 4) gerber quads: silk, mask, inner copper-    function quad(name, tex, zc, delta, mirror, copper){-      const p=B().MeshBuilder.CreatePlane(name,{width:50.4*mm,height:26.4*mm},sc);+    // 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;+      const t=new (B().Texture)(R+tex,sc,false,false); t.hasAlpha=true;       let mat;-      if(copper){-        mat=copperMat.clone(name+"_cu");        // identical shiny copper, gerber = cutout only-        mat.opacityTexture=t; t.getAlphaFromRGB=false;-      }else{-        mat=new (B().StandardMaterial)(name+"_m",sc);-        mat.diffuseTexture=t; mat.opacityTexture=t; mat.emissiveTexture=t;-      }-      mat.backFaceCulling=false;-      p.material=mat;-      if(mirror){ p.scaling.x=-1; }-      reg([p], delta);-      return p;+      if(copper){ mat=copperMat.clone(key+"_cu"); mat.opacityTexture=t; t.getAlphaFromRGB=false; }+      else{ mat=new (B().StandardMaterial)(key+"_m",sc); mat.diffuseTexture=t; mat.opacityTexture=t; mat.emissiveTexture=t; }+      mat.backFaceCulling=false; p.material=mat;+      if(mirror) p.scaling.x=-1;+      attach(key,[p]);     }-    quad("silk_top","layer-silk-top.png", 1.600*mm, 30*mm-1.600*mm,false);-    quad("mask_top","layer-mask-top.png", 1.592*mm, 24*mm-1.592*mm,false);-    quad("cu_l2","layer-cu-l2.png",       1.2985*mm, 6*mm-1.2985*mm,false,true);-    quad("cu_l15","layer-cu-l15.png",     0.2175*mm,-6*mm-0.2175*mm,false,true);-    quad("mask_bot","layer-mask-bot.png",-0.075*mm,-24*mm+0.075*mm,true);-    quad("silk_bot","layer-silk-bot.png",-0.083*mm,-30*mm+0.083*mm,true);+    quad("silk_top","layer-silk-top.png", 1.600*mm,false,false);+    quad("mask_top","layer-mask-top.png", 1.592*mm,false,false);+    quad("cu_l2","layer-cu-l2.png",       1.2985*mm,false,true);+    quad("cu_l15","layer-cu-l15.png",     0.2175*mm,false,true);+    quad("mask_bot","layer-mask-bot.png",-0.075*mm,true,false);+    quad("silk_bot","layer-silk-bot.png",-0.083*mm,true,false); -    // 5) the deployed bundle's light rig does not materialize in this sandbox - add a-    // matching rig (hemi + key/fill) and a shadow generator; ground receives below.+    // fallback rig only if the bundle's own rig is absent     let sg=null;     if(sc.lights.length===0){-      // fallback rig only if the bundle's own rig (hemi+bottom+spot, PCF shadows) is absent       new (B().HemisphericLight)("hemi",new (B().Vector3)(0.2,0.2,1),sc).intensity=1.0;       const key=new (B().DirectionalLight)("key",new (B().Vector3)(-0.4,-0.5,-1),sc);       key.intensity=1.3; key.position=new (B().Vector3)(0.1,0.1,0.3);-      new (B().DirectionalLight)("fill",new (B().Vector3)(0.7,0.4,-0.5),sc).intensity=0.5;-      sg=new (B().ShadowGenerator)(2048,key);-      sg.useBlurExponentialShadowMap=true; sg.blurKernel=16;+      sg=new (B().ShadowGenerator)(2048,key); sg.useBlurExponentialShadowMap=true;     }else{       sc.lights.forEach(l=>{ const g=l.getShadowGenerator&&l.getShadowGenerator(); if(g&&!sg) sg=g; });     }     if(sg) LAYERS.forEach(L=>L.meshes.forEach(m=>{ try{ sg.addShadowCaster(m,true);}catch(e){} }));-    // keep a render loop alive without replacing the bundle's (viewcube scene safety)-    var eng2=viewer.getEngine();-    if(!eng2._activeRenderLoops || eng2._activeRenderLoops.length===0){ eng2.runRenderLoop(function(){ sc.render(); }); }-    // 6) ground: 80% transparent + receives shadows-    sc.meshes.forEach(m=>{-      if(/ground/i.test(m.name||"")){-        try{ if(m.material) m.material.alpha=0.2; m.receiveShadows=true; }catch(e){}-      }-    }); -    // 7) absolute framing from world extents (proven; bundle frameModel unreliable here)+    // ground: 80% transparent, receives shadows+    sc.meshes.forEach(m=>{ if(/ground/i.test(m.name||"")){ try{ if(m.material) m.material.alpha=0.2; m.receiveShadows=true; }catch(e){} } });++    // frame: assembled view + headroom for the +55mm cascade     setTimeout(function(){       try{-        const ext=sc.getWorldExtends();-        const c=new (B().Vector3)((ext.min.x+ext.max.x)/2,(ext.min.y+ext.max.y)/2,(ext.min.z+ext.max.z)/2);+        const ext=sc.getWorldExtends(m=>!/ground/i.test(m.name||""));+        const c=new (B().Vector3)((ext.min.x+ext.max.x)/2,(ext.min.y+ext.max.y)/2,(ext.min.z+ext.max.z)/2+18*mm);         const dx=ext.max.x-ext.min.x, dy=ext.max.y-ext.min.y, dz=ext.max.z-ext.min.z;         const diag=Math.sqrt(dx*dx+dy*dy+dz*dz);         const cam=sc.activeCamera;-        cam.setTarget(c); cam.radius=diag*1.5; cam.beta=1.15; cam.alpha=-1.2;-        cam.minZ=diag*0.005; cam.maxZ=diag*300;+        cam.setTarget(c); cam.radius=diag*2.6; cam.beta=1.2; cam.alpha=-1.2;+        cam.minZ=diag*0.005; cam.maxZ=diag*400;         if(typeof cam.wheelPrecision==="number") cam.wheelPrecision=3/diag;       }catch(e){}     },400);++    buildPanel();     set("animating");   } -  // ---- animation engine: assembled -> explode -> hold -> reassemble, looping -----  let mode="anim";           // anim | t0 | t1-  let t=0;                   // 0 assembled, 1 exploded-  let phase=0, phaseT=0;     // 0 hold assembled,1 explode,2 hold exploded,3 collapse-  const HOLD_A=1.6, MOVE=3.2, HOLD_E=2.2;-  function ease(x){ return x<0.5 ? 2*x*x : 1-Math.pow(-2*x+2,2)/2; }-  function apply(){-    const k=ease(Math.min(Math.max(t,0),1));-    for(const L of LAYERS) for(const m of L.meshes) m.position.z=m.__z0+k*L.delta;+  // ---- 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 tick(dt){-    if(mode==="t0"){ if(t>0){ t=Math.max(0,t-dt/1.2); apply(); } return; }-    if(mode==="t1"){ if(t<1){ t=Math.min(1,t+dt/1.2); apply(); } return; }-    phaseT+=dt;-    if(phase===0){ t=0; if(phaseT>HOLD_A){phase=1;phaseT=0;} }-    else if(phase===1){ t=phaseT/MOVE; if(t>=1){t=1;phase=2;phaseT=0;} }-    else if(phase===2){ t=1; if(phaseT>HOLD_E){phase=3;phaseT=0;} }-    else { t=1-phaseT/MOVE; if(t<=0){t=0;phase=0;phaseT=0;} }-    apply();+  function apply(u){+    for(let i=0;i<N;i++){+      const off=offsetFor(i,u);+      LAYERS[i].curOff=off;+      for(const m of LAYERS[i].meshes) m.position.z=m.__z0+off;+    }+    if(scrub && mode!=="manual") scrub.value=String(Math.round(u*1000));   }-  function setMode(m,btn){-    mode=m;-    [btnAnim,btnA,btnE].forEach(b=>b.classList.remove("active"));-    btn.classList.add("active");-    set(m==="anim"?"animating":(m==="t0"?"assembled":"exploded"));-    if(m==="anim"){ phase=(t>=0.5)?2:0; phaseT=0; }++  // ---- layers panel ----+  function buildPanel(){+    lpRows.innerHTML="";+    LAYERS.forEach(function(L,i){+      const row=document.createElement("div"); row.className="lp-row";+      const cb=document.createElement("input"); cb.type="checkbox"; cb.checked=true;+      cb.addEventListener("change",function(){ L.visible=cb.checked; L.meshes.forEach(m=>m.setEnabled(cb.checked)); });+      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(cb); row.appendChild(nm); row.appendChild(z);+      row.addEventListener("mouseenter",function(){ hilite(L,true); });+      row.addEventListener("mouseleave",function(){ hilite(L,false); });+      lpRows.appendChild(row);+    });   }-  btnAnim.addEventListener("click",()=>setMode("anim",btnAnim));-  btnA.addEventListener("click",()=>setMode("t0",btnA));-  btnE.addEventListener("click",()=>setMode("t1",btnE));+  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=9.0, HOLD=2.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"); });   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");-    });+    (card.requestFullscreen?card.requestFullscreen():Promise.reject()).catch?.(function(){ set("fullscreen blocked in the embed - open the readme URL directly"); });+  });+  scrub.addEventListener("input",function(){+    mode="manual"; setBtns(null); set("scrubbing");+    u=Number(scrub.value)/1000; apply(u);+    if(resumeTimer) clearTimeout(resumeTimer);+    resumeTimer=setTimeout(function(){ mode="anim"; dir=(u>=1)?-1:1; holdT=0; setBtns(btnAnim); set("animating"); },5000);   });    build().then(function(){@@ -343,8 +376,9 @@ exported bodies + design rules.</p>     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);+      tick(dt); updatePanel();     });+    apply(0);   }).catch(function(e){ set("load failed: "+((e&&e.message)||"unknown")); }); })(); </script>