← All Pull Requests

embed: decoder-free live 3D (de-Draco + Babylon orbit viewer) #1

Closed opened by barrett-land 2026-08-21

Restores decoder-free live 3D in the embed output. This work was written on 2026-07-16, never reached the page repo, and is rebased here onto current HEAD (2.7.1) — packaging, docs and hero are untouched, only the three src files move.

Why

The wiki readme sandbox serves connect-src 'none'. Babylon's Draco decoder lives on a CDN, so a Draco-compressed GLB cannot load there at all — KHR_draco_mesh_compression lands in extensionsRequired, which is a hard failure rather than a degraded render.

KHR_mesh_quantization needs no decoder, is understood natively, and on chip-sized meshes comes out smaller than Draco.

What changed

  • src/dedraco.mjs (new) — re-encodes a Draco GLB decoder-free via gltf-transform. Deliberately avoids optimize(): its default enables EXT_mesh_gpu_instancing, which the babylon9 bundle renders as one giant mis-scaled instance (adom/wiki#180).
  • src/main.rs — glb_json / glb_needs_draco / dedraco_glb / dirs_cache. The 3D column now inlines a real .glb as base64 when one is present, transcoding first if it is Draco. Every failure path falls back to the existing static iso render and prints the reason.
  • src/embed.html — orbit camera for the 3D column: Z-up correction (our GLBs come from STEP via step2glb), world-bbox framing, poster overlay until the scene is live, and a status line.

The model rides inline as base64 and is decoded to a File in-page, so nothing is ever fetched and connect-src 'none' cannot bite. The bundle itself is a <script> import, which script-src allows.

Notes

  • No new Rust dependencies; Cargo.toml is unchanged. cargo check is clean.
  • The de-Draco path shells out to node + a one-time npm i into the cache dir, and is best-effort by design — if node is missing, the embed still builds and shows the static render.
  • Not addressed here: src/manage.rs:513-515 and src/manage.html:328 still hardcode /home/barrett-land/..., and VERSION (2.6.3) disagrees with page.json (2.7.1). Both predate this branch; happy to split them into a separate PR.

Diff Skip to comments (1)

--- a/src/dedraco.mjs+++ b/src/dedraco.mjs@@ -0,0 +1,38 @@+// Re-encode a Draco-compressed GLB into a decoder-free one.+//+// Why this exists: the wiki's readme sandbox serves `connect-src 'none'`, so a+// viewer can never download Babylon's Draco decoder (it lives on a CDN) and a+// KHR_draco_mesh_compression asset fails to load outright — the extension is in+// extensionsRequired, so there is no partial render. KHR_mesh_quantization needs+// no decoder and is understood natively, and on chip-sized meshes it also comes+// out smaller than Draco.+//+// usage: node dedraco.mjs <in.glb> <out.glb>   -> prints one JSON line+import { NodeIO } from '@gltf-transform/core';+import { KHRDracoMeshCompression, KHRMeshQuantization } from '@gltf-transform/extensions';+import { quantize } from '@gltf-transform/functions';+import draco3d from 'draco3dgltf';+import fs from 'fs';++const [, , inp, outp] = process.argv;++try {+  const io = new NodeIO()+    .registerExtensions([KHRDracoMeshCompression, KHRMeshQuantization])+    .registerDependencies({ 'draco3d.decoder': await draco3d.createDecoderModule() });++  const doc = await io.read(inp);+  // Drop Draco, then re-encode decoder-free. Deliberately NOT gltf-transform's+  // `optimize`: its default enables EXT_mesh_gpu_instancing, which the babylon9+  // bundle renders as one giant mis-scaled instance (adom/wiki#180).+  doc.createExtension(KHRDracoMeshCompression).dispose();+  await doc.transform(quantize());++  const out = await io.writeBinary(doc);+  fs.writeFileSync(outp, out);+  console.log(JSON.stringify({ ok: true, in: fs.statSync(inp).size, out: out.length }));+} catch (e) {+  console.log(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));+  process.exit(1);+}+--- a/src/main.rs+++ b/src/main.rs@@ -1,1908 +1,2039 @@⋯ 1783 unchanged lines ⋯     s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") } +const NODE_DEDRACO: &str = include_str!("dedraco.mjs");++/// The glTF JSON chunk of a .glb, if it parses.+fn glb_json(bytes: &[u8]) -> Option<serde_json::Value> {+    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+        return None;+    }+    let clen = u32::from_le_bytes(bytes[12..16].try_into().ok()?) as usize;+    if &bytes[16..20] != b"JSON" || 20 + clen > bytes.len() {+        return None;+    }+    serde_json::from_slice(&bytes[20..20 + clen]).ok()+}++/// Does this .glb *require* the Draco extension? If so no decoder-less viewer can+/// open it at all — KHR_draco_mesh_compression lands in extensionsRequired, so it+/// is a hard failure rather than a degraded render.+fn glb_needs_draco(bytes: &[u8]) -> bool {+    glb_json(bytes)+        .and_then(|j| {+            let hit = |k: &str| {+                j.get(k).and_then(|v| v.as_array()).is_some_and(|a| {+                    a.iter().any(|x| x.as_str() == Some("KHR_draco_mesh_compression"))+                })+            };+            Some(hit("extensionsRequired") || hit("extensionsUsed"))+        })+        .unwrap_or(false)+}++/// Re-encode a Draco .glb as a decoder-free one via node + gltf-transform.+///+/// Best-effort on purpose: this is a bridge for GLBs that were Draco-compressed+/// upstream (see adom/adom-step2glb#1). Every failure path returns Err with a+/// reason the caller shows to the user, and the embed falls back to the static+/// render rather than shipping a 3D column that cannot load.+fn dedraco_glb(src: &std::path::Path) -> Result<Vec<u8>, String> {+    let cache = dirs_cache().join("adom-lbr").join("gltf");+    fs::create_dir_all(&cache).map_err(|e| format!("cache dir: {e}"))?;++    if process::Command::new("node").arg("--version").output().map(|o| !o.status.success()).unwrap_or(true) {+        return Err("node not found on PATH".into());+    }+    // One-time dependency fetch, kept beside the script so node resolves it.+    if !cache.join("node_modules/@gltf-transform/core").exists() {+        eprintln!("adom-lbr: fetching gltf-transform (one time) to de-Draco the 3D model...");+        let _ = fs::write(cache.join("package.json"), "{\"name\":\"adom-lbr-gltf\",\"private\":true,\"type\":\"module\"}");+        let o = process::Command::new("npm")+            .args(["i", "--silent", "--no-fund", "--no-audit",+                   "@gltf-transform/core@4", "@gltf-transform/extensions@4",+                   "@gltf-transform/functions@4", "draco3dgltf"])+            .current_dir(&cache)+            .output()+            .map_err(|e| format!("npm: {e}"))?;+        if !o.status.success() {+            return Err(format!("npm install failed: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("")));+        }+    }+    let script = cache.join("dedraco.mjs");+    fs::write(&script, NODE_DEDRACO).map_err(|e| format!("writing helper: {e}"))?;+    let out_glb = cache.join("out.glb");+    let _ = fs::remove_file(&out_glb);++    let o = process::Command::new("node")+        .arg(&script)+        .arg(src)+        .arg(&out_glb)+        .output()+        .map_err(|e| format!("node: {e}"))?;+    if !o.status.success() {+        let last = String::from_utf8_lossy(&o.stdout).lines().last().unwrap_or("").to_string();+        return Err(format!("de-Draco failed: {last}"));+    }+    fs::read(&out_glb).map_err(|e| format!("reading transcoded glb: {e}"))+}++fn dirs_cache() -> PathBuf {+    std::env::var("XDG_CACHE_HOME")+        .map(PathBuf::from)+        .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())).join(".cache"))+}+ /// (human label, hex color) for a ds2sf/kicad pin type token. fn pin_type_style(t: &str) -> (&'static str, &'static str) {     match t.to_lowercase().replace('-', "_").as_str() {⋯ 55 unchanged lines ⋯         .or_else(|| find_by_suffix(dir, "-footprint.authoritative.svg").and_then(|p| fs::read_to_string(p).ok()))         .or_else(|| find_by_suffix(dir, "-footprint.svg").and_then(|p| fs::read_to_string(p).ok()))         .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());-    let threed = {+    // Static iso render: the poster the 3D column shows until the live scene is+    // up, and what it keeps showing if the viewer can't start (offline).+    let poster = {         let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]             .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())             .or_else(|| ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]                 .iter().find_map(|s| find_by_suffix(dir, s)));-        match cands.and_then(|p| fs::read(&p).ok()) {-            Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),-            None => "<div class=\"ph\">no 3D render in this directory</div>".into(),+        cands.and_then(|p| fs::read(&p).ok()).map(|b| format!("<img src=\"data:image/png;base64,{}\">", b64(&b)))+    };++    // A real .glb beats any render: inline it and let the 3D column orbit it.+    let glb_path = dir.join(format!("{mpn}.glb")).exists()+        .then(|| dir.join(format!("{mpn}.glb")))+        .or_else(|| find_by_suffix(dir, ".glb"));+    let mut glb_b64 = String::new();+    let mut glb_note = String::new();+    if let Some(p) = glb_path {+        match fs::read(&p) {+            Ok(bytes) => {+                let ready = if glb_needs_draco(&bytes) {+                    // Inlining Draco would ship a 3D column that cannot load: the+                    // readme sandbox is connect-src 'none', so the decoder download+                    // is blocked (adom/wiki#180). Transcode or don't offer 3D.+                    match dedraco_glb(&p) {+                        Ok(b) => Some(b),+                        Err(e) => {+                            glb_note = format!("Draco .glb could not be transcoded ({e}); showing the static render");+                            None+                        }+                    }+                } else {+                    Some(bytes)+                };+                if let Some(b) = ready {+                    glb_b64 = b64(&b);+                }+            }+            Err(e) => glb_note = format!("could not read {}: {e}", p.display()),         }+    }++    let threed = if !glb_b64.is_empty() {+        format!("<canvas data-c3d></canvas><div class=\"poster\" data-poster>{}</div><div class=\"vstat\" data-vstat>starting 3D...</div>",+            poster.clone().unwrap_or_default())+    } else {+        poster.clone().unwrap_or_else(|| "<div class=\"ph\">no 3D render in this directory</div>".into())     };     // optional pin→pad connects from a .lbr     let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();⋯ 38 unchanged lines ⋯         .replace("__SYM__", &sym_svg)         .replace("__FP__", &fp_svg)         .replace("__THREED__", &threed)+        .replace("__GLB__", &glb_b64)         .replace("__PINS__", &pins_json)         .replace("__PADN__", &pad_count.to_string())         .replace("__PINN__", &pin_count.to_string());     let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));     fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;-    println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());+    let three_kind = if !glb_b64.is_empty() {+        format!("live 3D, {} KB glb", glb_b64.len() / 4 * 3 / 1024)+    } else if poster.is_some() {+        "static render".into()+    } else {+        "no 3D".into()+    };+    println!("OK: 3-column interactive embed ({pin_count} pins, {three_kind}) → {}", out.display());+    if !glb_note.is_empty() {+        println!("note: {glb_note}");+    }     Ok(()) }+--- a/src/embed.html+++ b/src/embed.html@@ -1,85 +1,153 @@⋯ 34 unchanged lines ⋯   td.d{color:#aeb6c2;line-height:1.45;max-width:520px}   tr:hover td{background:#15202e}   .ph{display:grid;place-items:center;height:100%;color:#4b5666;font-size:12px;text-align:center;padding:18px}+  .vp canvas{position:absolute;inset:0;width:100%;height:100%;display:block;outline:none;touch-action:none}+  .vp[data-vp3d]{cursor:default}+  .vp .poster{position:absolute;inset:0;transition:opacity .25s}+  .vp .poster img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}+  .vp .poster.gone{opacity:0;pointer-events:none}+  .vp .vstat{position:absolute;left:0;right:0;bottom:8px;text-align:center;font-size:11px;color:#5b6673;pointer-events:none}+  .vp .vstat.err{color:#8b6b3d} </style></head> <body>   <header><span class="dot"></span><span class="b">adom-lbr <span>· library viewer</span></span><span class="nm">__NAME__</span></header>⋯ 1 unchanged line ⋯     <div class="col"><div class="ch">Symbol</div><div class="vp" data-vp>__SYM__</div></div>     <div class="col"><div class="ch">Footprint <span class="n">__PADN__ pads</span></div><div class="vp" data-vp>__FP__</div></div>     <div class="col"><div class="ch">3D</div><div class="vp" data-vp>__THREED__</div></div>+    <script>var GLB_B64="__GLB__";</script>   </div>   <div class="map">     <div class="mh">Pin → pad mapping <span style="color:#7e8a99">· __PINN__ pins, with datasheet descriptions</span></div>⋯ 3 unchanged lines ⋯ <script> var PINS=__PINS__; (function(){-  // each viewport: pan/zoom on its <svg> or <img>+  // each viewport: pan/zoom on its <svg> or <img>. The 3D column opts out —+  // panning a projection is not a 3D view; it gets an orbit camera instead.   document.querySelectorAll('[data-vp]').forEach(function(vp){+    if(vp.querySelector('[data-c3d]')){ vp.setAttribute('data-vp3d',''); return; }     var el=vp.querySelector('svg,img'); if(!el) return;     if(el.tagName.toLowerCase()==='svg'){ el.removeAttribute('width'); el.removeAttribute('height'); }     var s=1,tx=0,ty=0;⋯ 11 unchanged lines ⋯     vp.addEventListener('pointerup',function(){ d=false;vp.classList.remove('grab'); });     ap();   });+  // ---- 3D column: a real orbitable scene when a .glb was inlined -------------+  // Constraints this satisfies (measured on the wiki's readme sandbox, whose CSP+  // is `script-src 'unsafe-inline' https:; connect-src 'none'`):+  //   * the bundle is a <script> import (script-src), not a fetch — allowed;+  //   * the model rides inline as base64 and is decoded to a File in-page, so+  //     nothing is ever fetched — connect-src 'none' cannot bite;+  //   * the .glb is decoder-free by then (adom-lbr transcodes Draco at build+  //     time) because Babylon's Draco decoder lives on a CDN it cannot reach.+  // If the bundle can't load (offline), the static render stays up.+  (function(){+    var vp=document.querySelector('[data-vp3d]'); if(!vp) return;+    var cv=vp.querySelector('canvas'), stat=vp.querySelector('[data-vstat]'), poster=vp.querySelector('[data-poster]');+    if(!cv || !window.GLB_B64) return;+    function fail(m){ if(stat){ stat.textContent=m; stat.className='vstat err'; } }+    var BUNDLE='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js';+    var wait=function(ms){ return new Promise(function(r){ setTimeout(r,ms); }); };+    import(BUNDLE).then(async function(){+      for(var i=0;i<120 && !window.BABYLON;i++){ await wait(50); }+      var B=window.BABYLON; if(!B) return fail('3D unavailable');+      var bin=atob(window.GLB_B64), u8=new Uint8Array(bin.length);+      for(var j=0;j<bin.length;j++){ u8[j]=bin.charCodeAt(j); }+      var eng=new B.Engine(cv,true,{preserveDrawingBuffer:true,stencil:true});+      var sc=new B.Scene(eng); sc.clearColor=new B.Color4(0,0,0,0);+      await B.SceneLoader.AppendAsync('', new File([u8],'m.glb',{type:'model/gltf-binary'}), sc, null, '.glb');+      // Our GLBs come out of STEP via step2glb and are Z-up; glTF is Y-up. The+      // wiki's own component viewer assumes the same and corrects it.+      var root=new B.TransformNode('zup',sc);+      root.rotation.x=-Math.PI/2;+      sc.meshes.forEach(function(m){ if(!m.parent && m!==root){ m.parent=root; } });+      var h=new B.HemisphericLight('h',new B.Vector3(0.4,1,0.3),sc); h.intensity=0.9;+      var d=new B.DirectionalLight('d',new B.Vector3(-0.5,-1,-0.6),sc); d.intensity=1.2;+      // Frame the model: bake a world bbox over everything with geometry.+      var lo=null,hi=null;+      sc.meshes.forEach(function(m){+        if(!m.getTotalVertices||!m.getTotalVertices()) return;+        m.computeWorldMatrix(true);+        var b=m.getBoundingInfo().boundingBox;+        lo=lo?B.Vector3.Minimize(lo,b.minimumWorld):b.minimumWorld.clone();+        hi=hi?B.Vector3.Maximize(hi,b.maximumWorld):b.maximumWorld.clone();+      });+      var ctr=lo&&hi?B.Vector3.Center(lo,hi):B.Vector3.Zero();+      var diag=lo&&hi?hi.subtract(lo).length():1;+      var cam=new B.ArcRotateCamera('c',-Math.PI/3,Math.PI/3.1,diag*1.9||1,ctr,sc);+      cam.attachControl(cv,true);+      cam.lowerRadiusLimit=diag*0.35; cam.upperRadiusLimit=diag*8;+      cam.wheelDeltaPercentage=0.02; cam.pinchDeltaPercentage=0.02; cam.minZ=diag/500;+      eng.runRenderLoop(function(){ sc.render(); });+      if(window.ResizeObserver){ new ResizeObserver(function(){ eng.resize(); }).observe(vp); }+      if(poster) poster.classList.add('gone');+      if(stat) stat.textContent='drag to orbit · scroll to zoom';+      window.__LBR3D={ok:true,meshes:sc.meshes.length};+    }).catch(function(e){+      fail('3D needs a connection; showing the static render');+      window.__LBR3D={ok:false,error:String(e&&e.message||e)};+    });+  })();+   function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }   var rows=document.getElementById('rows');   PINS.forEach(function(p){⋯ 7 unchanged lines ⋯ })(); </script> </body></html>+

Comments

John Lauer · 1mo ago

Closing with an apology and full credit: this PR sat unnoticed while I independently rebuilt the same feature (shipped 2.12.0, issue #4 closed) — I checked the Issues tab and never the PRs. barrett has left for the summer, so rather than rebase a 2.7.1-based branch across the 2.8→2.12 rewrites (manage.rs, embed.html and main.rs have all changed shape underneath it), I reviewed it line by line against what shipped and landed everything it had that 2.12.0 lacked, as 2.12.1:

  • The Z-up correction — this PR was RIGHT and 2.12.0 was wrong. A raw step2glb model rendered standing on its edge in my implementation (verified, screenshot on the shotlog record). 2.12.1 reads the GLB's accessor bounds and pitches -90° when the thinnest bbox axis is Z (chips are flat, so the thinnest axis is the height); verified flat on both a raw step2glb model and this page's own republished BQ model.
  • The hardcoded /home/barrett-land delivery folders his notes flagged: now generic ~ paths.
  • Already equivalent in 2.12.0, arrived at independently but his design first (2026-07-16): de-Draco via gltf-transform at build time with the EXT_mesh_gpu_instancing trap avoided, model inline as base64 so connect-src 'none' never bites, per-failure-mode static-render fallbacks with printed reasons. One deliberate difference: 2.12.0 inlines Google's model-viewer (the pattern proven live in adom/stm32f103c8t6's 3d.html) instead of the Babylon vendor path, so there is no view-time script fetch at all.

The version-drift note in his PR was separately fixed by build.sh (2.8.0). Thanks barrett — the CSP forensics and the Z-up catch in here saved this feature twice.

Log in to comment.