Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
embed: decoder-free live 3D (de-Draco + Babylon orbit viewer) #1
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 avoidsoptimize(): its default enablesEXT_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.glbas 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.tomlis unchanged.cargo checkis clean. - The de-Draco path shells out to
node+ a one-timenpm iinto 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-515andsrc/manage.html:328still hardcode/home/barrett-land/..., andVERSION(2.6.3) disagrees withpage.json(2.7.1). Both predate this branch; happy to split them into a separate PR.
Diff Skip to comments (1)
@@ -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);+}+@@ -1,1908 +1,2039 @@⋯ 1783 unchanged lines ⋯ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } +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(()) }+@@ -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{'&':'&','<':'<','>':'>','"':'"'}[c];}); } var rows=document.getElementById('rows'); PINS.forEach(function(p){⋯ 7 unchanged lines ⋯ })(); </script> </body></html>+
Comments
Log in to comment.