← Commit history

2.12.0: interactive 3D embed (inline model-viewer + data-URI GLB, Draco transcoded)

John Lauer ·dff4af72b9 ·1mo ago ·parent 10ab726
8 files changed +143−17
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.11.10"+version = "2.12.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.11.10"+version = "2.12.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
README.md+7
@@ -67,6 +67,13 @@ skill documents every piece of it for your AI.  ## The 3-column library viewer, with an interactive embed +The embed's 3D column is a REAL interactive viewer, not a picture: when the+part's directory holds a `.glb`, the model rides inline (Draco models are+transcoded to quantized at build time, measured ~35% smaller) with the+model-viewer runtime inlined beside it, so the finished readme.html renders an+orbitable 3D chip even inside the wiki's no-network readme sandbox. No `.glb`+means the static render, unchanged.+ ![The Fusion-style 3-column viewer: symbol, footprint, 3D, and the pin→pad mapping table](docs/viewer3col.png)  ▶ **[Open the live ADS1115 3-column viewer](https://wiki.adom.inc/api/v1/pages/john/adom-lbr/files/demo/ads1115-viewer.html)**: the exact self-contained file `adom-lbr embed` produces, served straight from this page (pan/zoom each panel; the pin→pad table is fully populated from the datasheet). It is built to sit in an `<iframe>` once the wiki enables component-page iframe embedding.
VERSION+1−1
@@ -1 +1 @@-2.11.10+2.12.0
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.11.10",+  "version": "2.12.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad 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.",   "dependencies": {},
page.json+9−12
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.11.10",+  "version": "2.12.0",   "type": "app",-  "description": "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.",+  "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad 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.",   "dependencies": {},   "tags": [     "eda",@@ -52,24 +52,21 @@   ],   "files": [     "bin/**",-    "assets/**",     "SKILL.md",-    "skills/**",     "README.md",     "install.sh",     "uninstall.sh",-    "screenshots/**",-    "dockbar.json"+    "screenshots/**"   ],-  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",+  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools \u2014 KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [     {       "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",-      "label": "KiCad → Altium"+      "label": "KiCad \u2192 Altium"     },     {       "prompt": "Import this Altium footprint into the adom-lbr JSON",-      "label": "Altium → JSON"+      "label": "Altium \u2192 JSON"     },     {       "prompt": "Bundle this part into a single Altium IntLib",@@ -77,15 +74,15 @@     },     {       "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",-      "label": "Altium → KiCad"+      "label": "Altium \u2192 KiCad"     },     {       "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",-      "label": "JSON → KiCad"+      "label": "JSON \u2192 KiCad"     },     {       "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup",       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
src/embed.html+3
@@ -53,6 +53,9 @@ var PINS=__PINS__; (function(){   // each viewport: pan/zoom on its <svg> or <img>   document.querySelectorAll('[data-vp]').forEach(function(vp){+    // The 3D pane is a real viewer with its own orbit/zoom - panning a+    // projection was the bug (adom-lbr#4), so it opts out of this loop.+    if(vp.querySelector('model-viewer')) 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;
src/main.rs+120−1
@@ -2323,6 +2323,111 @@ fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {     None } +/// Find the directory's model, transcode Draco away when present, and emit a+/// self-contained interactive 3D pane (inlined model-viewer + data-URI GLB ++/// the static iso render as the poster). Err(reason) falls back to the PNG.+fn build_3d_viewer(dir: &std::path::Path, mpn: &str) -> Result<String, String> {+    // prefer <MPN>.glb (any case), else the first .glb in the directory+    let mut glb_path: Option<PathBuf> = None;+    if let Ok(rd) = fs::read_dir(dir) {+        let mut all: Vec<PathBuf> = rd.flatten().map(|e| e.path())+            .filter(|p| p.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("glb")).unwrap_or(false))+            .collect();+        all.sort();+        glb_path = all.iter().find(|p| {+            p.file_stem().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case(mpn)).unwrap_or(false)+        }).cloned().or_else(|| all.first().cloned());+    }+    let glb_path = glb_path.ok_or("no .glb in this directory")?;+    let mut bytes = fs::read(&glb_path).map_err(|e| format!("read {}: {e}", glb_path.display()))?;+    let orig_len = bytes.len();++    if glb_is_draco(&bytes) {+        let tool = ensure_gltf_transform()?;+        let out_tmp = std::env::temp_dir().join(format!("adom-lbr-{mpn}-quantized.glb"));+        let st = process::Command::new(&tool)+            .args(["quantize", glb_path.to_str().unwrap_or_default(), out_tmp.to_str().unwrap_or_default()])+            .output()+            .map_err(|e| format!("gltf-transform did not run: {e}"))?;+        if !st.status.success() {+            return Err(format!("gltf-transform quantize failed: {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));+        }+        bytes = fs::read(&out_tmp).map_err(|e| format!("read transcode output: {e}"))?;+        if glb_is_draco(&bytes) {+            return Err("transcode output still requires the Draco decoder".into());+        }+        println!("HINT: 3D model transcoded Draco -> quantized for the sandbox: {} -> {} bytes", orig_len, bytes.len());+    }++    let viewer_js = ensure_model_viewer()?;+    let poster = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]+        .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())+        .and_then(|p| fs::read(&p).ok())+        .map(|b| format!(" poster=\"data:image/png;base64,{}\"", b64(&b)))+        .unwrap_or_default();+    Ok(format!(+        "<script type=\"module\">{viewer_js}</script>\n\+         <model-viewer src=\"data:model/gltf-binary;base64,{model}\"{poster} camera-controls auto-rotate shadow-intensity=\"1\" style=\"position:absolute;inset:0;width:100%;height:100%;background:#161c27\"></model-viewer>",+        model = b64(&bytes)+    ))+}++/// GLB magic + JSON chunk scan for the Draco extension requirement.+fn glb_is_draco(bytes: &[u8]) -> bool {+    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+        return false;+    }+    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;+    let end = (20 + json_len).min(bytes.len());+    let json = String::from_utf8_lossy(&bytes[20..end]);+    json.contains("KHR_draco_mesh_compression")+}++/// The gltf-transform CLI, installed once into the adom-lbr cache.+fn ensure_gltf_transform() -> Result<PathBuf, String> {+    let home = std::env::var("HOME").map_err(|_| "no HOME")?;+    let prefix = std::path::Path::new(&home).join(".cache/adom-lbr/gltf");+    let bin = prefix.join("node_modules/.bin/gltf-transform");+    if bin.exists() {+        return Ok(bin);+    }+    process::Command::new("node").arg("-v").output().map_err(|_| "node is not installed (needed once to transcode a Draco .glb)")?;+    println!("HINT: fetching @gltf-transform/cli into {} (once)…", prefix.display());+    let _ = fs::create_dir_all(&prefix);+    let st = process::Command::new("npm")+        .args(["install", "--prefix", prefix.to_str().unwrap_or_default(), "--no-fund", "--no-audit", "@gltf-transform/cli@3", "draco3dgltf"])+        .output()+        .map_err(|e| format!("npm did not run: {e}"))?;+    if !st.status.success() || !bin.exists() {+        return Err(format!("npm install @gltf-transform/cli@3 failed (v3 pinned: v4 needs Node 20+): {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));+    }+    Ok(bin)+}++/// The model-viewer bundle, fetched once into the cache and inlined thereafter+/// (the readme sandbox may fetch nothing at view time, so it must ride along).+fn ensure_model_viewer() -> Result<String, String> {+    let home = std::env::var("HOME").map_err(|_| "no HOME")?;+    let path = std::path::Path::new(&home).join(".cache/adom-lbr/model-viewer-3.5.0.min.js");+    if !path.exists() {+        let _ = fs::create_dir_all(path.parent().unwrap());+        let st = process::Command::new("curl")+            .args(["-sL", "--max-time", "60", "-o", path.to_str().unwrap_or_default(),+                   "https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"])+            .status()+            .map_err(|e| format!("curl did not run: {e}"))?;+        if !st.success() {+            return Err("could not fetch the model-viewer bundle (offline?); the static render stands in".into());+        }+    }+    let js = fs::read_to_string(&path).map_err(|e| format!("read model-viewer bundle: {e}"))?;+    if js.len() < 100_000 {+        let _ = fs::remove_file(&path);+        return Err("model-viewer bundle looks truncated; refetch next run".into());+    }+    Ok(js)+}+ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {     let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();     // renders@@ -2331,7 +2436,14 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String>         .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());     let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])         .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());-    let threed = {+    // The 3D column: a real interactive viewer when the directory has a .glb+    // (adom-lbr#4, barrett's design carried forward). The model rides INLINE as+    // a data URI and the model-viewer bundle is inlined too, because the wiki+    // serves readmes with connect-src 'none': nothing may be fetched at view+    // time. Draco models are transcoded to KHR_mesh_quantization at build time+    // (three.js decodes that natively; the Draco decoder is a CDN fetch the+    // sandbox blocks - and quantized is measured ~40% smaller anyway).+    let static_3d = || {         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());         match cands.and_then(|p| fs::read(&p).ok()) {@@ -2339,6 +2451,13 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String>             None => "<div class=\"ph\">no 3D render in this directory</div>".into(),         }     };+    let threed = match build_3d_viewer(dir, &mpn) {+        Ok(html) => html,+        Err(reason) => {+            eprintln!("HINT: 3D column falls back to the static render: {reason}");+            static_3d()+        }+    };     // optional pin→pad connects from a .lbr     let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();     if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {