app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← Commit history
v0.4.2: Fusion PCB+schematic render (EAGLE colors), upAxis-stamp 3D orientation, embedded adom-symbol/footprint viewers (card/fullscreen)
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