← Commit history

v0.4.3: fix board-download 3D model paths (#6 — rewrite to ${KIPRJMOD}/3d + bundle STEPs); version-selector fixes (query parse + content-addressed cache-bust + instant loading); shell-permission hints on browse/library timeouts

barrett-land ·0d34061836 ·3mo ago ·parent f722f2b
2 files changed +134−48
src/main.rs+109−44
@@ -310,7 +310,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             // The board GLB's declared up-axis (from step2glb's stamp). Version-aware.             // Fetched once per board by the frontend, which caches it; not in the polled             // board-media path. Absent stamp → null → frontend applies no rotation.-            let (path, ver) = split_ver(p);+            let (path, ver) = split_ver(url);             let slug = &path["/api/board-upaxis/".len()..];             let glb = ver                 .and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))@@ -357,7 +357,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {         }         (Method::Get, p) if p.starts_with("/board-svg/") => {             // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>  [?v=<version>]-            let (path, ver) = split_ver(p);+            let (path, ver) = split_ver(url);             let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect();             let slug = segs.first().copied().unwrap_or("");             let sub = |dir: &str| match segs.get(1).copied() {@@ -382,7 +382,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             }         }         (Method::Get, p) if p.starts_with("/board-glb/") => {-            let (path, ver) = split_ver(p);+            let (path, ver) = split_ver(url);             let slug = &path["/board-glb/".len()..];             // A selected PAST version → its archived render/<version>/board.glb. Otherwise             // serve the SAME 3D the wiki page shows: the page's model_3d GLB (Drew's@@ -1469,13 +1469,34 @@ fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String,         Err(_) => return (dest, got),     };     let files = v.get("files").and_then(|f| f.as_array()).cloned().unwrap_or_default();+    let file_names: Vec<String> = files.iter()+        .filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))+        .collect();+    let shapes_dir = file_names.iter().find(|n| n.ends_with(".3dshapes")).cloned();     let mut names = vec![];-    for f in &files {-        let name = f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).unwrap_or("");+    let mut models: std::collections::HashSet<String> = std::collections::HashSet::new();+    let mut proj_models: std::collections::HashSet<String> = std::collections::HashSet::new();+    for name in &file_names {         if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {             if let Some(bytes) = wiki_bytes(board, name) {                 let base = name.rsplit('/').next().unwrap_or(name).to_string();-                if std::fs::write(format!("{work}/{base}"), bytes).is_ok() {+                // Rewrite the PCB's custom (model "…") refs to portable ${KIPRJMOD}/3d/<base>+                // so the recipient's KiCad finds the bundled STEPs, not the uploader's+                // absolute paths (issue #6). Collect what to download.+                let data: Vec<u8> = if name.ends_with(".kicad_pcb") {+                    match String::from_utf8(bytes) {+                        Ok(pcb) => {+                            let (out, m, pm) = normalize_pcb_models(&pcb);+                            models.extend(m);+                            proj_models.extend(pm);+                            out.into_bytes()+                        }+                        Err(e) => e.into_bytes(),+                    }+                } else {+                    bytes+                };+                if std::fs::write(format!("{work}/{base}"), data).is_ok() {                     names.push(base);                 }             }@@ -1484,6 +1505,9 @@ fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String,     if names.is_empty() {         return (dest, got);     }+    // Download the 3D models the board references into work/3d/ so they ship with the+    // project and the ${KIPRJMOD}/3d/<base> refs resolve on the recipient's machine.+    stage_board_models(board, &work, &models, &proj_models, shapes_dir.as_deref());     ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));      if project_only {@@ -1519,6 +1543,7 @@ fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String,                     }                 }                 got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();+                got.extend(deliver_models(&work, &dest)); // ship the STEPs the board references                 return (dest, got);             }         }@@ -1527,6 +1552,8 @@ fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String,     // personal mode (or self-contain failed): deliver the files as fetched.     let locals: Vec<String> = names.iter().map(|n| format!("{work}/{n}")).collect();     ad_send(&locals, &dest);+    let mut names = names;+    names.extend(deliver_models(&work, &dest)); // ship the STEPs the board references     (dest, names) } @@ -1574,6 +1601,27 @@ fn ad_send(files: &[String], dest: &str) -> bool {         .unwrap_or(false) } +/// Ship the board's bundled 3D models (staged into `work/3d/` by stage_board_models)+/// to `<dest>/3d/`, so the delivered project's rewritten `${KIPRJMOD}/3d/<base>` model+/// refs resolve on the recipient's machine (issue #6). Returns the delivered filenames.+fn deliver_models(work: &str, dest: &str) -> Vec<String> {+    let m3 = format!("{work}/3d");+    let paths: Vec<String> = match std::fs::read_dir(&m3) {+        Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path().to_string_lossy().to_string()).collect(),+        Err(_) => return vec![],+    };+    if paths.is_empty() {+        return vec![];+    }+    let d3 = format!("{dest}/3d");+    ad_shell(&format!("mkdir -p {}", sh_quote(&d3)));+    if ad_send(&paths, &d3) {+        paths.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(|n| format!("3d/{n}"))).collect()+    } else {+        vec![]+    }+}+ fn ad_shell(cmd: &str) {     let args = json!({"command": cmd}).to_string();     let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();@@ -1732,20 +1780,20 @@ fn render_sch_project(slug: &str) -> Vec<(String, String)> { /// models are bundled on the wiki page's `.3dshapes`; fetch them, rewrite the /// absolute refs to `${KIPRJMOD}/3d/<name>`, tar the PCB + models, and POST to /// service-kicad's glb-package endpoint (which sets KIPRJMOD + --subst-models).-fn render_board_glb(slug: &str) -> Option<Vec<u8>> {-    let files = list_page_files(slug);-    let pcb_name = files.iter().find(|n| n.ends_with(".kicad_pcb"))?;-    let shapes_dir = files.iter().find(|n| n.ends_with(".3dshapes")).cloned();-    let pcb = String::from_utf8(wiki_bytes(slug, pcb_name)?).ok()?;-    // Normalize every custom (model "…") ref to ${KIPRJMOD}/3d/<name> and collect-    // the names. Custom = anything NOT a ${KICAD*_3DMODEL_DIR} standard ref: a-    // machine-absolute Unix path (/home/…), a Windows path (C:\Users\…), OR a-    // project-relative path (<Board>.3dshapes/…). Handling all three means the 3D-    // renders regardless of how the source .kicad_pcb wrote its paths.-    let mut models: std::collections::HashSet<String> = std::collections::HashSet::new(); // custom refs → 3d/<basename>-    let mut proj_models: std::collections::HashSet<String> = std::collections::HashSet::new(); // ${KIPRJMOD}/<rel> → fetched from the page at <rel>+/// Rewrite every custom `(model "…")` ref in a .kicad_pcb to a portable+/// `${KIPRJMOD}/3d/<basename>` and collect what to stage. Custom = anything NOT a+/// `${KICAD*_3DMODEL_DIR}` standard ref: a machine-absolute Unix path (/home/…), a+/// Windows path (C:\Users\…), OR a project-relative path (<Board>.3dshapes/…). This+/// makes the board machine-independent — the same fix both the GLB render and the+/// delivered project need (issue #6: uploader absolute paths leak to the recipient).+/// Returns (rewritten_pcb, custom-model basenames, `${KIPRJMOD}/<rel>` project models).+fn normalize_pcb_models(+    pcb: &str,+) -> (String, std::collections::HashSet<String>, std::collections::HashSet<String>) {+    let mut models = std::collections::HashSet::new();+    let mut proj_models = std::collections::HashSet::new();     let mut out = String::with_capacity(pcb.len());-    let mut rest = pcb.as_str();+    let mut rest = pcb;     while let Some(pos) = rest.find("(model \"") {         out.push_str(&rest[..pos + 8]);         let after = &rest[pos + 8..];@@ -1753,10 +1801,6 @@ fn render_board_glb(slug: &str) -> Option<Vec<u8>> {             Some(end) => {                 let path = &after[..end];                 if path.starts_with("${") {-                    // ${KIPRJMOD}/<rel> is PROJECT-LOCAL (a vendor STEP in the page's-                    // 3d/ etc.), not a stock model — the file lives in the page repo, so-                    // stage it. Other ${KICAD*_3DMODEL_DIR} refs are stock and resolve on-                    // the render service.                     if let Some(rel) = path.replace('\\', "/").strip_prefix("${KIPRJMOD}/") {                         proj_models.insert(rel.to_string());                     }@@ -1772,42 +1816,63 @@ fn render_board_glb(slug: &str) -> Option<Vec<u8>> {         }     }     out.push_str(rest);-    // Show DNP (Do-Not-Populate) parts in the 3D view. KiCad's 3D export omits DNP-    // footprints by default, so a part left/mis-flagged DNP (e.g. a main IC) renders-    // blank with no body — surprising in a design-browsing tool. Drop the `dnp` attr-    // token so every placed footprint gets its model.-    let out = out.replace(" dnp)", ")").replace(" dnp ", " ");-    // assemble the bundle: board.kicad_pcb (rewritten) + 3d/<custom models>-    let key = slug.replace('/', "_");-    let bundle = format!("{SCRATCH}/glb-{key}");-    let _ = std::fs::remove_dir_all(&bundle);-    std::fs::create_dir_all(format!("{bundle}/3d")).ok()?;-    std::fs::write(format!("{bundle}/board.kicad_pcb"), &out).ok()?;-    // custom refs (absolute/relative) were rewritten to ${KIPRJMOD}/3d/<base>; find the-    // model on the page — it may live in a `.3dshapes` folder, a `3d/` folder, or at the-    // referenced relative path — and stage it under 3d/<base>.-    for base in &models {-        let candidates: Vec<String> = shapes_dir.iter().map(|sd| format!("{sd}/{base}"))+    (out, models, proj_models)+}++/// Download a board's custom + project-relative 3D models from its wiki page into+/// `<dir>` (custom → `<dir>/3d/<base>`, project-relative → `<dir>/<rel>`), so a+/// `${KIPRJMOD}`-rewritten .kicad_pcb resolves its models locally. A model may live in+/// the page's `.3dshapes` folder, a `3d/` folder, or at the referenced relative path.+fn stage_board_models(+    slug: &str,+    dir: &str,+    models: &std::collections::HashSet<String>,+    proj_models: &std::collections::HashSet<String>,+    shapes_dir: Option<&str>,+) {+    let _ = std::fs::create_dir_all(format!("{dir}/3d"));+    for base in models {+        let candidates: Vec<String> = shapes_dir+            .iter()+            .map(|sd| format!("{sd}/{base}"))             .chain([format!("3d/{base}"), base.clone()])             .collect();         for cand in candidates {             if let Some(mb) = wiki_bytes(slug, &cand) {-                let _ = std::fs::write(format!("{bundle}/3d/{base}"), mb);+                let _ = std::fs::write(format!("{dir}/3d/{base}"), mb);                 break;             }         }     }-    // project-local ${KIPRJMOD}/<rel> models: fetch each from the page repo at <rel> and-    // stage it at the same relative path so ${KIPRJMOD} resolves inside the bundle.-    for rel in &proj_models {+    for rel in proj_models {         if let Some(mb) = wiki_bytes(slug, rel) {-            let dest = format!("{bundle}/{rel}");+            let dest = format!("{dir}/{rel}");             if let Some(parent) = std::path::Path::new(&dest).parent() {                 let _ = std::fs::create_dir_all(parent);             }             let _ = std::fs::write(&dest, mb);         }     }+}++fn render_board_glb(slug: &str) -> Option<Vec<u8>> {+    let files = list_page_files(slug);+    let pcb_name = files.iter().find(|n| n.ends_with(".kicad_pcb"))?;+    let shapes_dir = files.iter().find(|n| n.ends_with(".3dshapes")).cloned();+    let pcb = String::from_utf8(wiki_bytes(slug, pcb_name)?).ok()?;+    let (out, models, proj_models) = normalize_pcb_models(&pcb);+    // Show DNP (Do-Not-Populate) parts in the 3D view. KiCad's 3D export omits DNP+    // footprints by default, so a part left/mis-flagged DNP (e.g. a main IC) renders+    // blank with no body — surprising in a design-browsing tool. Drop the `dnp` attr+    // token so every placed footprint gets its model.+    let out = out.replace(" dnp)", ")").replace(" dnp ", " ");+    // assemble the bundle: board.kicad_pcb (rewritten) + 3d/<custom models>+    let key = slug.replace('/', "_");+    let bundle = format!("{SCRATCH}/glb-{key}");+    let _ = std::fs::remove_dir_all(&bundle);+    std::fs::create_dir_all(format!("{bundle}/3d")).ok()?;+    std::fs::write(format!("{bundle}/board.kicad_pcb"), &out).ok()?;+    stage_board_models(slug, &bundle, &models, &proj_models, shapes_dir.as_deref());     let targz = format!("{SCRATCH}/glb-{key}.tar.gz");     if !Command::new("tar").args(["-czf", &targz, "-C", &bundle, "."]).status().map(|s| s.success()).unwrap_or(false) {         return None;
src/manage.html+25−4
@@ -486,6 +486,9 @@ function openFolderBrowser(start,onPick){   pathEl.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); load(pathEl.value.trim()); } });   async function load(p){     listEl.innerHTML='<div class="fb-loading">loading…</div>';+    // If the folder listing hasn't come back in 5s, the desktop bridge probably can't+    // run shell commands — tell the user what to check instead of spinning forever.+    const warn=setTimeout(()=>{ if(listEl.querySelector('.fb-loading')) listEl.innerHTML='<div class="fb-loading">Still loading…<br><span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span></div>'; },5000);     let d;     try{       // use the listing prefetched when the modal opened, so the first open is instant@@ -493,7 +496,8 @@ function openFolderBrowser(start,onPick){       if((!p||p==='') && fbPrefetch){ d=await fbPrefetch; fbPrefetch=null; }       if(!d) d=await fetch('api/list-dirs?p='+encodeURIComponent(p||'')).then(r=>r.json());     }-    catch(e){ listEl.innerHTML='<div class="fb-empty">couldn’t read that folder</div>'; return; }+    catch(e){ clearTimeout(warn); listEl.innerHTML='<div class="fb-empty">couldn’t read that folder</div>'; return; }+    clearTimeout(warn);     cur=d.path||p||''; pathEl.value=cur;     let html='';     if(d.parent) html+=`<div class="fb-row fb-up" data-p="${esc(d.parent)}">⬆ ..</div>`;@@ -676,7 +680,15 @@ async function loadBoardMedia(slug){ } // Version selector state: boardVersion = the selected PAST version (null = current). let boardVersion=null, curBoardMedia=null;-function verQ(){ const m=curBoardMedia||{}; return (boardVersion && m.current && boardVersion!==m.current) ? '?v='+encodeURIComponent(boardVersion) : ''; }+function verQ(){+  const m=curBoardMedia||{};+  if(!(boardVersion && m.current && boardVersion!==m.current)) return '';+  // Append the version's content hash so the URL is content-addressed — the browser (and+  // model-viewer) can't serve a stale cache entry for this ?v from a different render.+  const e=(m.versions||[]).find(x=>x.version===boardVersion);+  const h=(e&&e.hash)?'&h='+encodeURIComponent(e.hash):'';+  return '?v='+encodeURIComponent(boardVersion)+h;+} // Mount the 3D / PCB / schematic views for the selected version. Re-called when the // version pill changes — each asset fetch carries ?v=<version> for a past version. async function mountBoardAssets(slug){@@ -689,6 +701,12 @@ async function mountBoardAssets(slug){   const schOpt=fusion?{light:true,bg:'#ffffff'}:{light:true};   const pcbOpt=fusion?{bg:'#282828'}:{dimFills:0.4};   const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');+  // Instant feedback on a version switch: blank each pane to the loading spinner NOW,+  // before any await, so the previously-selected version's view doesn't linger while+  // the new version's assets fetch.+  if(d3 && m.has_3d){ d3.style.position='relative'; d3.style.background='#0d0f14'; showViewerLoader(d3); }+  if(sch && (m.sheets||[]).length) showViewerLoader(sch);+  if(pcb && m.has_pcb) showViewerLoader(pcb);   if(d3){     if(m.has_3d){       const up=await fetchUpAxis(slug, boardVersion);   // GLB up-axis stamp → per-board orientation@@ -1065,9 +1083,12 @@ function setLibStatus(){ const el=document.getElementById('modal-status'); if(!e   else if(libChecked){ const n=Object.keys(inLib).length; el.className='done'; el.textContent='✓ Done — '+(n?n+' already in your libraries':'none already present'); }   else { el.className=''; el.textContent=''; } } function checkInLib(slugs,cb){ inLibLoading=true; libChecked=false; setLibStatus(); if(cb)cb();+  // The library scan runs shell commands on the user's machine via the bridge; if it's+  // still going after 10s the bridge likely lacks shell permissions — surface that.+  const warn=setTimeout(()=>{ const el=document.getElementById('modal-status'); if(el&&inLibLoading){ el.className='loading'; el.innerHTML='Searching your libraries… <span style="color:#f0a81c">Ensure Adom Desktop has shell permissions.</span>'; } },10000);   fetch('api/in-library?slugs='+encodeURIComponent(slugs.join(','))).then(r=>r.json())-    .then(m=>{ inLib=m||{}; curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}}); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); })-    .catch(()=>{ inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); }); }+    .then(m=>{ clearTimeout(warn); inLib=m||{}; curPlan.forEach(p=>{const h=inLib[p.slug]; if(h){if(h.lib)p.symLib=h.lib; if(h.fpLib)p.fpLib=h.fpLib;}}); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); })+    .catch(()=>{ clearTimeout(warn); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); }); } // project-only mode: diff the board against the project library on disk (add/keep/remove) let projDiff={}, projLoading=false, projChecked=false; function setProjStatus(){ const el=document.getElementById('modal-status'); if(!el)return;