← Commit history

v0.4.2: Fusion PCB+schematic render (EAGLE colors), upAxis-stamp 3D orientation, embedded adom-symbol/footprint viewers (card/fullscreen)

barrett-land ·3fba31a59b ·3mo ago ·parent 1e24ab2
2 files changed +148−28
src/main.rs+101
@@ -279,6 +279,17 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             let slug = &p["/api/part/".len()..];             json_resp(&get_part(slug).to_string())         }+        (Method::Get, p) if p.starts_with("/part-embed/") => {+            // Interactive symbol/footprint viewer from adom-symbol/adom-footprint `embed`,+            // served for an <iframe>. Forms: /part-embed/<slug>/symbol | /part-embed/<slug>/footprint+            let rest = &p["/part-embed/".len()..];+            let (slug, kind) = rest.rsplit_once('/').unwrap_or((rest, ""));+            match part_embed(slug, kind) {+                Some(html) => Response::from_string(html).with_header(hdr("text/html; charset=utf-8")),+                None => Response::from_string("<p style='font:13px system-ui;color:#8b93a3;padding:20px'>viewer not available</p>")+                    .with_header(hdr("text/html; charset=utf-8")).with_status_code(404),+            }+        }         (Method::Get, p) if p.starts_with("/api/board/") => {             let slug = &p["/api/board/".len()..];             json_resp(&board_deps(slug).to_string())@@ -295,6 +306,19 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             let slug = &p["/api/board-eda/".len()..];             json_resp(&json!({"eda": board_eda_cached(slug)}).to_string())         }+        (Method::Get, p) if p.starts_with("/api/board-upaxis/") => {+            // 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 slug = &path["/api/board-upaxis/".len()..];+            let glb = ver+                .and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))+                .or_else(|| wiki_bytes(slug, "render/board.glb"))+                .or_else(|| page_model_glb(slug));+            let up = glb.as_deref().and_then(glb_up_axis);+            json_resp(&json!({"up_axis": up}).to_string())+        }         (Method::Get, p) if p.starts_with("/api/board-media/") => {             let slug = &p["/api/board-media/".len()..];             json_resp(&board_media(slug).to_string())@@ -454,6 +478,24 @@ fn page_model_glb(slug: &str) -> Option<Vec<u8>> {     wiki_bytes(slug, &format!("{slug}.glb")) } +// Read the up-axis a molecule GLB declares in its glTF header. step2glb --molecule+// stamps asset.extras.adomMolecule.upAxis ("z") on anchored/normalized GLBs; raw+// (un-pipelined) Fusion exports carry nothing. The 3D viewer uses this to decide the+// Z-up→Y-up rotation per board instead of guessing by EDA. Returns e.g. Some("z").+fn glb_up_axis(bytes: &[u8]) -> Option<String> {+    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+        return None;+    }+    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;+    let end = 20usize.checked_add(json_len)?;+    if bytes.len() < end {+        return None;+    }+    let j: Value = serde_json::from_slice(&bytes[20..end]).ok()?;+    j.get("asset")?.get("extras")?.get("adomMolecule")?+        .get("upAxis")?.as_str().map(|s| s.to_lowercase())+}+ /// Split a request path into (path_without_query, the `?v=<version>` value if present). /// Used by the board asset routes to serve a past version from `render/<version>/`. fn split_ver(p: &str) -> (&str, Option<String>) {@@ -551,6 +593,65 @@ fn list_components() -> Value { }  /// Fetch a component and build its adom-lbr part FRESH on every call (nothing is+static EMBED_CACHE: Mutex<Option<std::collections::HashMap<String, String>>> = Mutex::new(None);++/// Self-contained INTERACTIVE symbol/footprint viewer HTML from `adom-symbol embed` /+/// `adom-footprint embed` (pan/zoom + pin-info / layers panel). We export the part's+/// KiCad source to a temp file and run the embed CLI. Cached per (slug, kind) since the+/// CLI takes a beat and the HTML is stable for a given part.+fn part_embed(slug: &str, kind: &str) -> Option<String> {+    if kind != "symbol" && kind != "footprint" {+        return None;+    }+    let key = format!("{slug}/{kind}");+    if let Some(m) = EMBED_CACHE.lock().unwrap().as_ref() {+        if let Some(h) = m.get(&key) {+            return Some(h.clone());+        }+    }+    let mpn = up(slug);+    let (part, _) = load_part(slug, &mpn).ok()?;+    let dir = std::env::temp_dir().join(format!("apm-embed-{slug}"));+    std::fs::create_dir_all(&dir).ok()?;+    let (text, ext, tool) = match kind {+        "symbol" => (export_kicad_symbol(&part.symbol), "kicad_sym", "adom-symbol"),+        _ => (export_kicad_footprint(&part.footprint), "kicad_mod", "adom-footprint"),+    };+    let src = dir.join(format!("{mpn}.{ext}"));+    let out = dir.join(format!("{kind}.html"));+    std::fs::write(&src, text).ok()?;+    let ok = Command::new(tool)+        .args(["embed", "--file", src.to_str()?, "--out", out.to_str()?])+        .output()+        .ok()+        .map(|o| o.status.success())+        .unwrap_or(false);+    if !ok {+        return None;+    }+    let raw = std::fs::read_to_string(&out).ok()?;+    // Fit the embed into the PM's narrow 3-up cards: in the card, hide the info sidebar+    // (<aside>) AND the "adom-symbol/footprint" top bar so the viewer gets the whole card;+    // both reappear only when the card is fullscreened (iframe grows past the breakpoint).+    // Also shrink the top-left zoom controls (their default 30px is chunky in a small card).+    let fit = "<style id=\"apm-fit\">\+        @media(max-width:639px){aside{display:none!important}header{display:none!important}}\+        .tools,.av-tools{gap:2px!important;padding:2px!important;left:8px!important;top:8px!important}\+        .tools button,.av-tools button{width:22px!important;height:22px!important}\+        .tools button svg,.av-tools button svg{width:13px!important;height:13px!important}\+        </style>";+    let html = match raw.find("</head>") {+        Some(i) => format!("{}{}{}", &raw[..i], fit, &raw[i..]),+        None => format!("{fit}{raw}"),+    };+    EMBED_CACHE+        .lock()+        .unwrap()+        .get_or_insert_with(Default::default)+        .insert(key, html.clone());+    Some(html)+}+ /// cached), then render symbol/footprint SVG on the fly. The canonical adom-lbr /// JSON is the primary source; if the page has none, we import the KiCad files. fn get_part(slug: &str) -> Value {
src/manage.html+47−28
@@ -223,10 +223,17 @@   .chg.rm{color:#f87171}   .commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}   .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}-  .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}+  .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;position:relative}+  .card .cardfs{position:absolute;top:7px;right:9px;z-index:4}+  /* fullscreen a component card (its embed sidebar reveals itself past the width breakpoint) */+  .card.maxed{position:fixed;inset:0;z-index:300;margin:0;border-radius:0}+  .card.maxed .body{aspect-ratio:auto;flex:1;min-height:0}   .card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}   .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}   .card .body svg{max-width:100%;max-height:100%;will-change:transform}+  /* Interactive adom-symbol / adom-footprint embed (self-contained HTML in an iframe) */+  .card .body.embed{padding:0;cursor:default;background:#0d0f14}+  .embed-frame{width:100%;height:100%;border:0;display:block;background:#0d0f14}   .card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}   .lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}   .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}@@ -387,13 +394,22 @@ function renderFilterBar(){   el.style.display=''; fillChips(el); } function boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); }-// Fusion/EAGLE (.brd/.sch) STEP exports are Z-up; glTF/model-viewer is Y-up, so those GLBs-// render on their edge ("sideways"). KiCad exports are already Y-up (correct). We fix it-// client-side (leaving the GLBs + the wiki untouched) by tipping only Fusion boards -90deg-// about X via model-viewer's `orientation` (roll pitch yaw). Tunable if the sign is off.-const FUSION_ORIENT='0deg -90deg 0deg'; function isFusionBoard(slug){ const e=boardEdaOf(bySlug[slug]||{slug}); return !!(e&&e.includes('fusion')&&!e.includes('kicad')); }-function mvOrient(slug){ return isFusionBoard(slug)?` orientation="${FUSION_ORIENT}"`:''; }+// 3D orientation is keyed on the GLB's own up-axis stamp (asset.extras.adomMolecule.upAxis),+// NOT the EDA. step2glb --molecule normalizes to Z-up and stamps "z"; glTF/model-viewer is+// Y-up, so a Z-up GLB needs a -90° X tip. Raw (un-pipelined) exports carry no stamp — they're+// already Y-up, so no rotation. This is per-board and correct whether a board went through the+// pipeline or not. The stamp is fetched once per board and cached.+const FUSION_ORIENT='0deg -90deg 0deg';+const upAxisCache={};+async function fetchUpAxis(slug,ver){+  const k=slug+'@'+(ver||'');+  if(k in upAxisCache) return upAxisCache[k];+  try{ const r=await fetch('api/board-upaxis/'+slug+(ver?('?v='+encodeURIComponent(ver)):'')); const d=await r.json(); upAxisCache[k]=d.up_axis||null; }+  catch(e){ upAxisCache[k]=null; }+  return upAxisCache[k];+}+function mvOrientForAxis(up){ return up==='z' ? ` orientation="${FUSION_ORIENT}"` : ''; } function matchesFilter(c){   if(!filterTags.size) return true;   const kindSel=['project','molecule','board'].filter(t=>filterTags.has(t));@@ -543,14 +559,14 @@ function renderPubResults(){     return;   }   // DEFAULT leaderboard: top 10 popular projects-  const topBoards=[...pub].sort((a,b)=>(b.score||0)-(a.score||0)).slice(0,10);+  const topBoards=[...pub].sort((a,b)=>(b.score||0)-(a.score||0)).slice(0,5);   const h1=document.createElement('div');h1.className='cathead';h1.textContent='Trending';pr.appendChild(h1);   if(!topBoards.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default';d.textContent='no public projects yet';pr.appendChild(d);}   topBoards.forEach(c=>{const row=boardRow(c);const m=row.querySelector('.mpn');if(m)m.insertAdjacentHTML('beforeend',` <span class="scorebadge" title="popularity score">▲ ${Math.round(c.score||0)}</span>`);pr.appendChild(row);});   // top 10 popular organizations (by aggregate score of their public projects)   const agg={};   pub.forEach(c=>{const o=c.org;if(o){const a=agg[o]=agg[o]||{score:0,count:0};a.score+=(c.score||0);a.count++;}});-  const topOrgs=Object.entries(agg).sort((a,b)=>b[1].score-a[1].score).slice(0,10);+  const topOrgs=Object.entries(agg).sort((a,b)=>b[1].score-a[1].score).slice(0,5);   if(topOrgs.length){     const h2=document.createElement('div');h2.className='cathead';h2.textContent='Popular Organizations';pr.appendChild(h2);     topOrgs.forEach(([org,a],i)=>{@@ -665,11 +681,20 @@ function verQ(){ const m=curBoardMedia||{}; return (boardVersion && m.current && // version pill changes — each asset fetch carries ?v=<version> for a past version. async function mountBoardAssets(slug){   const m=curBoardMedia||{}, q=verQ();+  // Fusion/EAGLE renders carry their own colors baked in: the PCB already has the dark+  // editor canvas + pre-dimmed copper pour (so skip dimFills, and match the pane bg to+  // the #282828 canvas the paper-rect strip removes), and the schematic is on white+  // (EAGLE) so the pane goes white — makes it read like it's inside Fusion.+  const fusion=isFusionBoard(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');   if(d3){     if(m.has_3d){+      const up=await fetchUpAxis(slug, boardVersion);   // GLB up-axis stamp → per-board orientation+      if(boardSlug!==slug)return;       d3.style.position='relative'; d3.style.background='#0d0f14';-      d3.innerHTML=`<model-viewer src="board-glb/${slug}${q}"${mvOrient(slug)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`++      d3.innerHTML=`<model-viewer src="board-glb/${slug}${q}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`+         `<div class="vload ov"><img src="loader.gif" alt=""></div>`;       const mv=d3.querySelector('model-viewer'), ov=d3.querySelector('.vload.ov'), pct=ov.querySelector('.vload-pct');       const done=()=>{ if(ov)ov.remove(); };@@ -684,14 +709,14 @@ async function mountBoardAssets(slug){       if(sel){         sel.innerHTML=sheets.map((n,i)=>`<option value="${i}">${n}</option>`).join('');         sel.style.display=sheets.length>1?'':'none';-        sel.onchange=async()=>{ if(boardSlug!==slug)return; const p=showViewerLoader(sch); mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${sel.value}${verQ()}`,p), {light:true}); };+        sel.onchange=async()=>{ if(boardSlug!==slug)return; const p=showViewerLoader(sch); mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${sel.value}${verQ()}`,p), schOpt); };       }       const p=showViewerLoader(sch);-      mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/0${q}`,p), {light:true});+      mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/0${q}`,p), schOpt);     } else { if(sel)sel.style.display='none'; sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }   }   if(boardSlug!==slug)return;-  if(pcb){ if(m.has_pcb){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), {dimFills:0.4}); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+  if(pcb){ if(m.has_pcb){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), pcbOpt); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; } } function selectVersion(v){   const m=curBoardMedia||{};@@ -762,7 +787,7 @@ function mountSvg(box,svgText,opt){   try{ svg=new DOMParser().parseFromString(svgText,'image/svg+xml').documentElement; }catch(e){}   if(!svg||String(svg.nodeName).toLowerCase()!=='svg'){box.innerHTML='<div class="bo-na">view not available</div>';return;}   box.innerHTML=''; box.appendChild(svg);-  box.style.background=opt.light?'#f4f3ee':'#0d0f14';+  box.style.background=opt.bg||(opt.light?'#f4f3ee':'#0d0f14');   svg.removeAttribute('width');svg.removeAttribute('height');   svg.style.width='100%';svg.style.height='100%';svg.style.display='block';   if(opt.invert)svg.style.filter='invert(1) hue-rotate(180deg)';@@ -903,7 +928,7 @@ async function loadProject3d(slug){   try{     const blob=await fetchBlobProgress('board-glb/'+slug,pct);     if(projectSlug!==slug)return;-    if(blob){ const url=URL.createObjectURL(blob); d3.style.background='#0d0f14'; d3.innerHTML=`<model-viewer src="${url}"${mvOrient(slug)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`; return; }+    if(blob){ const up=await fetchUpAxis(slug,null); if(projectSlug!==slug)return; const url=URL.createObjectURL(blob); d3.style.background='#0d0f14'; d3.innerHTML=`<model-viewer src="${url}"${mvOrientForAxis(up)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`; return; }   }catch(e){}   if(projectSlug!==slug)return;   // no GLB yet → fall back to the page's hero image (proxied server-side, any hero type)@@ -940,7 +965,7 @@ function closeComponent(){ // Maximize a schematic/PCB view to fill the webview page (not the OS screen); // click the button again to restore. function toggleFs(btn){-  const view=btn.closest('.bo-view');+  const view=btn.closest('.bo-view')||btn.closest('.card');   // board viewers OR component cards   if(view)view.classList.toggle('maxed'); } async function openPart(slug,el){@@ -961,23 +986,17 @@ async function openPart(slug,el){   v.innerHTML=`     <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small>${wikiUrl?`<a class="wikibtn" href="${wikiUrl}" target="_blank" rel="noopener">Open on wiki ↗</a>`:''}</h2>     <div class="cols">-      <div class="card"><h3>Schematic Symbol</h3><div class="body">${p.symbol_svg}<span class="hint2">drag · scroll to zoom</span></div></div>-      <div class="card"><h3>Footprint</h3>-        <div class="lfilter">${(p.layers||[]).map(l=>`<label class="chip"><input type="checkbox" checked data-key="${l.key}"><span class="sw" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}</label>`).join('')}</div>-        <div class="body" id="fpbody">${p.footprint_svg}<span class="hint2">drag · scroll to zoom</span></div></div>+      <div class="card"><h3>Schematic Symbol</h3><button class="bo-fs cardfs" title="fullscreen (shows the pin panel)" onclick="toggleFs(this)">⛶</button><div class="body embed"><iframe class="embed-frame" src="part-embed/${slug}/symbol" title="schematic symbol" loading="lazy"></iframe></div></div>+      <div class="card"><h3>Footprint</h3><button class="bo-fs cardfs" title="fullscreen (shows the layers panel)" onclick="toggleFs(this)">⛶</button>+        <div class="body embed" id="fpbody"><iframe class="embed-frame" src="part-embed/${slug}/footprint" title="footprint" loading="lazy"></iframe></div></div>       <div class="card"><h3>3D Model</h3>${glb}</div>     </div>     <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>     <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>     <tbody>${rows}</tbody></table>`;-  v.querySelectorAll('.card .body').forEach(makePannable);-  // footprint layer filter — toggle visibility of each layer's SVG elements-  v.querySelectorAll('.lfilter input').forEach(cb=>{-    cb.onchange=()=>{-      v.querySelectorAll('#fpbody svg .ly-'+cb.dataset.key).forEach(el=>{el.style.display=cb.checked?'':'none';});-    };-  });-  setupHighlight(v);+  // Only the 3D "no model" fallback body is pannable now; symbol/footprint are+  // self-contained embed iframes with their own pan/zoom + pin-info/layers panels.+  v.querySelectorAll('.card .body:not(.embed)').forEach(makePannable); }  // Hover a pin (symbol) or pad (footprint) → highlight it + show its number and