← Commit history

animated stackup viewer: assembled->explode->reassemble loop, floaty toolbar, fullscreen, 80% transparent ground, live shadows; per-layer runtime groups (3 GLB groups + JS quads/slabs), bundle loadModel via FilesInputStore

John Lauer ·e6338fa0c7 ·3mo ago ·parent cfeef88
13 files changed +189−120
grp-components.glb.pngadded
⋯ 1 unchanged line ⋯
grp-cu1.glb.pngadded
⋯ 1 unchanged line ⋯
grp-cu16.glb.pngadded
⋯ 1 unchanged line ⋯
layer-cu-l15.pngadded
⋯ 1 unchanged line ⋯
layer-cu-l2.pngadded
⋯ 1 unchanged line ⋯
layer-mask-bot.pngadded
⋯ 1 unchanged line ⋯
layer-mask-top.pngadded
⋯ 1 unchanged line ⋯
layer-silk-bot.pngadded
⋯ 1 unchanged line ⋯
layer-silk-top.pngadded
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "bq25792-charger",   "title": "BQ25792 Charger",   "type": "component",-  "version": "0.1.23",+  "version": "0.1.24",   "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.23",+  "version": "0.1.24",   "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+178−116
@@ -19,26 +19,34 @@   th { background:#161b22; color:#e6edf3; font-weight:600; }   td { color:#9da7b3; } -  /* viewer block */-  #viewer-card { border:1px solid #21262d; border-radius:10px; overflow:hidden; background:#0a0d12; }-  #toolbar { display:flex; align-items:center; gap:8px; padding:10px 12px; background:#161b22; border-bottom:1px solid #21262d; }-  #toolbar .label { font-size:13px; color:#8b949e; margin-right:4px; }-  .tbtn { font-size:13px; padding:6px 14px; border-radius:6px; border:1px solid #30363d; background:#21262d; color:#c9d1d9; cursor:pointer; }-  .tbtn:hover { background:#30363d; }-  .tbtn.active { background:#0b3d33; border-color:#00b8b1; color:#5ff5e8; }-  #vstatus { margin-left:auto; font-size:12px; color:#6e7681; }+  /* 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 { font-size:12.5px; padding:6px 12px; border-radius:7px; border:1px solid transparent;+    background:transparent; color:#c9d1d9; cursor:pointer; }+  .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; } </style> </head> <body>  <div id="viewer-card">-  <div id="toolbar">-    <span class="label">3D view:</span>-    <button class="tbtn active" id="btn-exploded" type="button">Exploded stackup</button>-    <button class="tbtn" id="btn-assembled" type="button">Assembled</button>-    <span id="vstatus">starting viewer...</span>+  <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" 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="viewer-wrap" style="position:relative;width:100%;height:560px"></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@@ -96,27 +104,25 @@ 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 btnE=document.getElementById("btn-exploded");+  const card=document.getElementById("viewer-card");+  const btnAnim=document.getElementById("btn-anim");   const btnA=document.getElementById("btn-assembled");-  const MODELS={-    exploded:"/blob/component/bq25792-charger/bq25792-charger-exploded.glb.png",-    assembled:"/blob/component/bq25792-charger/bq25792-charger-assembled.glb.png"-  };-  // The readme sandbox forbids ALL fetch (connect-src none) - but img-src allows https:,-  // and /blob serves PNG inline with CORS. So the full-quality GLBs ride in the repo as-  // LOSSLESS PNGs (bytes packed into RGB channels, 4-byte LE length header) and are read-  // back pixel-perfect through a CORS-clean canvas. No fetch, no size cap, no wasm.+  const btnE=document.getElementById("btn-exploded");+  const btnFS=document.getElementById("btn-fs");+  const set=(t)=>{ if(status) status.textContent=t; };+  const R="/blob/component/bq25792-charger/";+  const mm=1e-3;+   function loadPngBytes(url){     return new Promise(function(resolve,reject){-      var img=new Image();-      img.crossOrigin="anonymous";+      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 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]; }@@ -124,103 +130,159 @@ exported bodies + design rules.</p>           resolve(rgb.subarray(4,4+n));         }catch(e){ reject(e); }       };-      img.onerror=function(){ reject(new Error("image load failed: "+url)); };+      img.onerror=function(){ reject(new Error("image failed: "+url)); };       img.src=url;     });   }-  function setStatus(t){ if(status) status.textContent=t; }-  // CSP shim: the readme sandbox blocks blob: for both fetch (connect-src none) and-  // images (img-src data: https:). Babylon wraps glTF textures in Blob object-URLs, so-  // stamp small Blobs with an equivalent data: URI and hand THAT back from-  // createObjectURL - img-src data: is allowed. No effect outside this document.-  (function(){-    var orig=URL.createObjectURL.bind(URL);-    URL.createObjectURL=function(b){ return (b&&b.__dataUri)||orig(b); };-    var OrigBlob=window.Blob;-    window.Blob=function(parts,opts){-      var bl=new OrigBlob(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=OrigBlob.prototype;-  })();+   const Viewer=window.Adom3DViewerBabylon9;-  if(typeof Viewer==="undefined"){ setStatus("viewer bundle failed to load"); return; }+  if(!Viewer){ set("viewer bundle failed to load"); return; }   let viewer;-  try { viewer=Viewer.init(wrap,{zUp:true,showViewCube:true,showGround:true}); }-  catch(e){ setStatus("viewer failed to start: "+(e&&e.message)); return; }-  let busy=false;-  let appended=[];   // meshes we added - the bundle's clearScene doesn't know about them-  function show(which){-    if(busy) return; busy=true;-    setStatus("loading "+which+"...");-    btnE.classList.toggle("active",which==="exploded");-    btnA.classList.toggle("active",which==="assembled");-    appended.forEach(function(m){ try{m.dispose(false,true);}catch(e){} });-    appended=[];-    var preCount=viewer.getScene().meshes.length;-    Promise.resolve(viewer.clearScene&&viewer.clearScene()).then(function(){-      // Extend the Adom viewer: append into ITS scene via Babylon from a File object.-      // The readme CSP (connect-src none) blocks every fetch - even data: URIs - so the-      // geometry rides a File (FileReader, no network) and the GLB's textures are-      // data: URIs in its glTF JSON (img-src data: is allowed).-      return loadPngBytes(MODELS[which]).then(function(bin){-        var f=new File([bin],which+".glb",{type:"model/gltf-binary"});-        return window.BABYLON.SceneLoader.AppendAsync("", f, viewer.getScene(), null, ".glb");-      });-    }).then(function(){-      appended=viewer.getScene().meshes.slice(preCount);-      // The bundle only starts its render loop inside its own loadModel(), which we-      // bypass - start it ourselves (idempotent: stop first).-      var eng=viewer.getEngine(), sc=viewer.getScene();-      eng.stopRenderLoop();-      eng.runRenderLoop(function(){ sc.render(); });-      viewer.frameModel();-      // frameModel only frames the bundle's own registry - frame OUR meshes on the-      // LIVE activeCamera (viewer.getCamera() can be a different object), after a tick-      // so nothing internally reframes over us.-      setTimeout(function(){-        try{-          var sc2=viewer.getScene();-          var ext=sc2.getWorldExtends();  // full scene - quantized models rescale via node matrices, model dominates-          var c=new window.BABYLON.Vector3((ext.min.x+ext.max.x)/2,(ext.min.y+ext.max.y)/2,(ext.min.z+ext.max.z)/2);-          var dx=ext.max.x-ext.min.x, dy=ext.max.y-ext.min.y, dz=ext.max.z-ext.min.z;-          var diag=Math.sqrt(dx*dx+dy*dy+dz*dz);-          var cam=sc2.activeCamera;-          if(cam && isFinite(diag) && diag>0){-            if(cam.setTarget) cam.setTarget(c);-            if(typeof cam.radius==="number") cam.radius=diag*1.2;-            cam.minZ=Math.max(diag*0.005,0.0001); cam.maxZ=diag*200;-            if(typeof cam.wheelPrecision==="number") cam.wheelPrecision=3/diag;-          }-        }catch(e){}-      },200);-      // The bundle creates its lights/environment inside its own loadModel - light the-      // scene ourselves (PBR is black without any light or environment).+  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}); }++  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");+    const compMeshes=sc.meshes.slice(pre);+    // ensure a live render loop (bundle starts it in loadModel, keep ours as belt+suspenders)+    eng.stopRenderLoop(); eng.runRenderLoop(function(){ sc.render(); });++    // 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"]]){+      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]);+    }+    reg(compMeshes, 36*mm-1.582*mm);++    // 3) FR4 slabs (prepreg/core/prepreg) as translucent boxes at exact thickness+    const fr4mat=new (B().StandardMaterial)("fr4",sc);+    fr4mat.diffuseColor=new (B().Color3)(0.45,0.42,0.28); fr4mat.alpha=0.72; fr4mat.backFaceCulling=false;+    function slab(name, zc, th, delta){+      const bx=B().MeshBuilder.CreateBox(name,{width:50.4*mm,depth:26.4*mm,height:th},sc);+      bx.position=new (B().Vector3)(25.2*mm,13.2*mm,zc); bx.rotation.x=Math.PI/2;  // zUp scene: check axis below+      bx.material=fr4mat;+      reg([bx], delta);+      return bx;+    }+    // zUp:true scenes from this bundle keep Z as up; boxes are Y-up primitives -> rotate so height aligns Z+    slab("ppg_top", 1.416*mm, 0.200*mm, 12*mm-1.416*mm);+    slab("core",    0.758*mm, 1.046*mm,  0-0.758*mm);+    slab("ppg_bot", 0.100*mm, 0.200*mm, -12*mm-0.100*mm);++    // 4) gerber quads: silk, mask, inner copper+    function quad(name, tex, zc, delta, mirror){+      const p=B().MeshBuilder.CreatePlane(name,{width:50.4*mm,height:26.4*mm},sc);+      p.position=new (B().Vector3)(25.2*mm,13.2*mm,zc);+      p.rotation.x=0; // plane faces +Z in zUp after billboard? set below+      const mat=new (B().StandardMaterial)(name+"_m",sc);+      const t=new (B().Texture)(R+tex,sc,false,false);+      t.hasAlpha=true; mat.diffuseTexture=t; mat.opacityTexture=t; mat.emissiveTexture=t;+      mat.backFaceCulling=false; mat.disableLighting=false;+      p.material=mat;+      if(mirror){ p.scaling.x=-1; }+      reg([p], delta);+      return 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);+    quad("cu_l15","layer-cu-l15.png",     0.2175*mm,-6*mm-0.2175*mm,false);+    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);++    // 5) environment + lights + shadows+    try{+      const env=new (B().EquiRectangularCubeTexture)(R+"studio-env.png",sc,128);+      sc.environmentTexture=env; sc.environmentIntensity=0.9;+    }catch(e){}+    if(sc.lights.length===0){+      new (B().HemisphericLight)("hemi",new (B().Vector3)(0.2,0.2,1),sc).intensity=0.9;+    }+    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);+    const sg=new (B().ShadowGenerator)(2048,key);+    sg.useBlurExponentialShadowMap=true; sg.blurKernel=16;+    LAYERS.forEach(L=>L.meshes.forEach(m=>{ try{ sg.addShadowCaster(m,true);}catch(e){} }));++    // 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) frame generously for the exploded extents+    setTimeout(function(){       try{-        var sc3=viewer.getScene();-        if(sc3.lights.length===0){-          var B=window.BABYLON;-          var h=new B.HemisphericLight("rm_hemi",new B.Vector3(0.3,0.3,1),sc3); h.intensity=1.1;-          var d1=new B.DirectionalLight("rm_key",new B.Vector3(-0.5,-0.6,-1),sc3); d1.intensity=1.4;-          var d2=new B.DirectionalLight("rm_fill",new B.Vector3(0.7,0.4,-0.6),sc3); d2.intensity=0.6;-        }+        const cam=sc.activeCamera;+        cam.radius=cam.radius*1.9;+        cam.beta=1.15;+        cam.minZ=cam.radius*0.01; cam.maxZ=cam.radius*300;       }catch(e){}-      setStatus(which+" view");-      busy=false;-    }).catch(function(err){ setStatus("load failed: "+((err&&err.message)||"unknown")); busy=false; });+    },300);+    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;+  }+  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 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; }   }-  btnE.addEventListener("click",function(){show("exploded");});-  btnA.addEventListener("click",function(){show("assembled");});-  show("exploded");+  btnAnim.addEventListener("click",()=>setMode("anim",btnAnim));+  btnA.addEventListener("click",()=>setMode("t0",btnA));+  btnE.addEventListener("click",()=>setMode("t1",btnE));+  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");+    });+  });++  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);+    });+  }).catch(function(e){ set("load failed: "+((e&&e.message)||"unknown")); }); })(); </script> </body>
studio-env.pngadded
⋯ 1 unchanged line ⋯