molecule
BQ25792 Charger
Public Made by Adomby adom
Buck-boost 1S-4S battery charger board (TI BQ25792) with 14 Adom machine pins, by Adonis
← 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
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>