app
adom-project-manager
Public Made by Adomby adom
DEPRECATED — renamed to Adom Nucleus (adom/adom-nucleus). Kept installable so existing installs and references keep working; depends on adom-nucleus. Install adom/adom-nucleus going forward.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939
//! Adom Project Manager — a standalone web app (tiny_http → Hydrogen webview),
//! built on the `adom-lbr` codec. Lists your wiki boards/projects (Personal /
//! Organization / Public), and on click opens a viewer (symbol + footprint + 3D,
//! plus the footprint LAYER STACKUP showing what layer each graphic becomes in
//! KiCad and Altium), adds components to your KiCad/Altium libraries, and
//! delivers self-contained KiCad projects.
//!
//! Routes:
//! GET / the UI (list + viewer)
//! GET /api/components list of wiki components
//! GET /api/part/<slug> {part, symbol_svg, footprint_svg, layers, glb}
//! GET /glb/<slug> proxied 3D model (GLB)
use altium_codec::{export_kicad_footprint, export_kicad_symbol, AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol};
use anyhow::Result;
use serde_json::{json, Value};
use std::io::{Cursor, Read};
use std::process::Command;
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
fn main() {
// `--render-board <slug>`: regenerate + upload the page's render/ assets, then
// exit (used by the publish flow). Otherwise serve on `--port <n>` (default 8785).
let mut port = 8785u16;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--port" || a == "-p" {
if let Some(v) = args.next() {
port = v.parse().unwrap_or(port);
}
} else if let Some(v) = a.strip_prefix("--port=") {
port = v.parse().unwrap_or(port);
} else if a == "--render-board" {
let slug = args.next().unwrap_or_default();
std::process::exit(if !slug.is_empty() && render_and_upload(&slug) { 0 } else { 1 });
}
}
if let Err(e) = run(port) {
eprintln!("ERROR: {e}");
std::process::exit(1);
}
}
/// Render a board's GLB + PCB SVG + schematic SVGs live and publish them to the
/// page's `render/` folder (with a manifest), so the Manager can serve them
/// instantly. Run on every board publish/update to keep the assets fresh.
fn render_and_upload(slug: &str) -> bool {
eprintln!("rendering {slug} …");
let (sheets, pcb_svg, glb) = render_board_media(slug);
// The board GLB from service-kicad is a raw kicad-cli export: glTF-standard
// Y-up and carrying no up-axis stamp (issue #218). Optimize it (quantize +
// the +90°X Z-up rotation) and stamp `adomMolecule.upAxis = "z"`, so BOTH the
// wiki 3D viewer (assumes Z-up) and APM (keys on the stamp) render it flat —
// no hand-patching, no consumer inferring orientation from geometry.
let glb = glb.map(|g| finalize_board_glb(&g));
if glb.is_none() && pcb_svg.is_none() && sheets.is_empty() {
eprintln!("nothing rendered (not a KiCad board?)");
return false;
}
let work = format!("{SCRATCH}/render-{}", slug.replace(['/', ' '], "_"));
let rdir = format!("{work}/render");
let _ = std::fs::remove_dir_all(&work);
if std::fs::create_dir_all(&rdir).is_err() {
return false;
}
if let Some(g) = &glb {
let _ = std::fs::write(format!("{rdir}/board.glb"), g);
}
if let Some(s) = &pcb_svg {
let _ = std::fs::write(format!("{rdir}/pcb.svg"), s);
}
for (i, (_, svg)) in sheets.iter().enumerate() {
let _ = std::fs::write(format!("{rdir}/sheet-{i:03}.svg"), svg);
}
let sheet_names: Vec<String> = sheets.iter().map(|(n, _)| n.clone()).collect();
// ---- version archive -------------------------------------------------
// Content-hash the just-rendered assets. A new version is archived ONLY when
// the render actually differs from the last archived one (a deps/metadata-only
// republish reuses the prior version → no duplicate visual version). Older
// render/<version>/ folders on the page are preserved (we push targeted paths).
let new_hash = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
if let Some(g) = &glb { g.hash(&mut h); }
if let Some(s) = &pcb_svg { s.as_bytes().hash(&mut h); }
for (_, svg) in &sheets { svg.as_bytes().hash(&mut h); }
sheet_names.hash(&mut h);
format!("{:016x}", h.finish())
};
let version = wiki_text(slug, "package.json")
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| "0.0.0".to_string());
// Read the existing manifest as our MERGE BASE (issue #218 problem 2). We only
// overwrite the keys the pipeline owns and preserve everything else — top-level
// keys a downstream tool added (`viewers`, `viewers_path`, …) and unknown keys
// on each versions[] entry. A wholesale rewrite silently destroyed those; a
// 404/error body that isn't really a manifest falls back to an empty base.
let existing: serde_json::Map<String, Value> = wiki_bytes(slug, "render/manifest.json")
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| m.as_object().cloned())
.filter(|m| m.contains_key("v") || m.contains_key("glb") || m.contains_key("versions"))
.unwrap_or_default();
// Did the render change vs the last archived version? (drives version archival)
let latest_hash = existing.get("versions").and_then(|a| a.as_array())
.and_then(|a| a.first()).and_then(|v| v.get("hash")).and_then(|h| h.as_str())
.unwrap_or("");
let changed = latest_hash != new_hash;
if changed {
// stage this version's assets under render/<version>/
let vdir = format!("{rdir}/{version}");
let _ = std::fs::create_dir_all(&vdir);
if let Some(g) = &glb { let _ = std::fs::write(format!("{vdir}/board.glb"), g); }
if let Some(s) = &pcb_svg { let _ = std::fs::write(format!("{vdir}/pcb.svg"), s); }
for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{vdir}/sheet-{i:03}.svg"), svg); }
}
let manifest = merge_render_manifest(&existing, &version, &new_hash, changed,
glb.is_some(), pcb_svg.is_some(), &sheet_names);
let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());
// Push TARGETED paths (not the whole render/ dir) so previously-archived
// render/<version>/ folders on the page are not clobbered.
let bare = slug.rsplit('/').next().unwrap_or(slug);
let mut files: Vec<String> = vec!["render/manifest.json".into()];
if glb.is_some() { files.push("render/board.glb".into()); }
if pcb_svg.is_some() { files.push("render/pcb.svg".into()); }
for i in 0..sheets.len() { files.push(format!("render/sheet-{i:03}.svg")); }
if changed { files.push(format!("render/{version}")); }
let mut args: Vec<String> = vec!["repo".into(), "push".into(), bare.into(), "--files".into()];
args.extend(files);
args.push("-m".into());
args.push(format!("render assets{}", if changed { format!(" + archive v{version}") } else { String::new() }));
let ok = Command::new("adom-wiki").args(&args).current_dir(&work)
.status().map(|s| s.success()).unwrap_or(false);
let msg = if !ok { "upload FAILED".to_string() }
else if changed { format!("uploaded render/ + archived v{version}") }
else { "uploaded render/ (no content change → no new version)".to_string() };
eprintln!("{msg}");
ok
}
const WIKI: &str = "https://wiki.adom.inc";
// Hosted headless KiCad 10 (service-kicad) — renders board SVG/GLB server-side,
// so board views need no local kicad-cli and no desktop bridge.
const KICAD_SVC: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
/// The wiki component list is slow (~8s) but stable — fetch it once, serve
/// instantly after. First click blocks; the rest are snappy.
static LIST_CACHE: Mutex<Option<String>> = Mutex::new(None);
fn cached_list() -> String {
let mut c = LIST_CACHE.lock().unwrap();
if let Some(s) = c.as_ref() {
return s.clone();
}
let s = list_components().to_string();
*c = Some(s.clone());
s
}
/// Follow a dotted path (`a.b.c`) into a JSON value.
fn dig<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = v;
for k in path.split('.') {
cur = cur.get(k)?;
}
Some(cur)
}
/// The signed-in wiki identity (username + org names) from `adom-wiki whoami`,
/// cached. Tolerant of JSON-shape differences across CLI versions; if it can't
/// be read it leaves the identity EMPTY rather than guessing, so the Personal and
/// Organization tabs simply show nothing instead of mislabeling every user.
static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None);
fn whoami() -> (String, Vec<String>) {
if let Some(v) = WHOAMI.lock().unwrap().clone() {
return v;
}
let mut me = String::new();
let mut orgs: Vec<String> = vec![];
if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {
if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {
me = ["data.user.username", "data.user.name", "data.user.handle", "user.username", "data.username", "username"]
.iter()
.find_map(|p| dig(&v, p).and_then(|x| x.as_str()).filter(|s| !s.is_empty()))
.unwrap_or_default()
.to_string();
if let Some(a) = dig(&v, "data.orgs").or_else(|| v.get("orgs")).and_then(|o| o.as_array()) {
orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).or_else(|| o.as_str()).filter(|s| !s.is_empty()).map(String::from)).collect();
}
}
}
let res = (me, orgs);
*WHOAMI.lock().unwrap() = Some(res.clone());
res
}
// Per-user "starred" comes straight from the wiki page data (pending the wiki API
// adding a per-user `starred` field — tracked in adom/wiki issues). Starred boards
// pin to the top of each list; there is no star toggle in this app (you star on
// the wiki). Until the field lands, `starred` is simply absent → false for all.
pub fn run(port: u16) -> Result<()> {
// Loopback by default: /api/apply writes files and runs commands on the
// user's machine via the bridge, so it must not be LAN-reachable.
// ADOM_LBR_BIND=0.0.0.0 opts back in (e.g. Hydrogen Desktop port-forward).
let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());
let server = Server::http(format!("{bind}:{port}"))
.map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?;
println!("OK: Adom Project Manager on http://localhost:{port}");
println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");
println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");
// Handle each request on its own thread. Many endpoints block on the desktop
// bridge (folder listing, library scans, renders); a single-threaded loop would
// serialize them, so e.g. the folder browser could wait tens of seconds behind
// the Add-Project library scan. All shared state is Mutex-guarded, so concurrent
// handlers are safe.
for mut req in server.incoming_requests() {
std::thread::spawn(move || {
let method = req.method().clone();
let url = req.url().to_string();
let mut body = String::new();
if matches!(method, Method::Post) {
// /api/apply bodies are small JSON — cap far above any real payload.
let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);
}
let resp = route(&method, &url, &body);
let _ = req.respond(resp);
});
}
Ok(())
}
fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {
let path = url.split('?').next().unwrap_or(url);
match (method, path) {
(Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),
(Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),
(Method::Get, "/api/env") => json_resp(&api_env().to_string()),
(Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()),
(Method::Get, "/api/pick-folder") => json_resp(&pick_folder().to_string()),
(Method::Get, p) if p.starts_with("/api/pick-folder-result") => {
let tok = url.split_once("?f=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
json_resp(&pick_folder_result(&tok).to_string())
}
(Method::Get, p) if p.starts_with("/api/project-diff") => {
let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default();
json_resp(&project_diff(¶m("board"), ¶m("dest")).to_string())
}
(Method::Get, p) if p.starts_with("/api/in-library") => {
let slugs: Vec<String> = url.split_once("?slugs=")
.map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q)))
.unwrap_or_default()
.split(',').filter(|s| !s.is_empty()).map(String::from).collect();
let matched: std::collections::HashMap<String, Value> = components_in_libs(&slugs)
.into_iter().map(|(s, by, lib, fp)| (s, json!({"by": by, "lib": lib, "fpLib": fp}))).collect();
json_resp(&json!(matched).to_string())
}
(Method::Get, p) if p.starts_with("/api/suggest-path") => {
let prefix = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
json_resp(&suggest_path(&prefix).to_string())
}
(Method::Get, p) if p.starts_with("/api/list-dirs") => {
let pp = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
json_resp(&list_dirs(&pp).to_string())
}
(Method::Get, "/") => html(INDEX_HTML),
(Method::Get, "/loader.gif") => Response::from_data(LOADER_GIF.to_vec())
.with_header(hdr("image/gif"))
.with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..]).unwrap()),
(Method::Get, "/api/components") => {
// ?refresh=1 busts the server-side cache so newly-pushed components appear
if url.contains("refresh=1") {
*LIST_CACHE.lock().unwrap() = None;
}
json_resp(&cached_list())
}
(Method::Get, p) if p.starts_with("/api/part/") => {
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())
}
(Method::Get, p) if p.starts_with("/api/project/") => {
let slug = &p["/api/project/".len()..];
json_resp(&project_boards(slug).to_string())
}
(Method::Get, p) if p.starts_with("/api/board-activity/") => {
let slug = &p["/api/board-activity/".len()..];
json_resp(&board_activity(slug).to_string())
}
(Method::Get, p) if p.starts_with("/api/board-eda/") => {
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(url);
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())
}
(Method::Get, p) if p.starts_with("/board-hero/") => {
// Proxy a page's hero image (used as the project 3D-view fallback). Resolve
// the hero descriptor → its image URL → serve the bytes. Works for billboard
// SVG heroes and plain-image heroes alike, and avoids a cross-origin fetch.
let slug = &p["/board-hero/".len()..];
let hero = curl(&format!("{WIKI}/api/v1/pages/{slug}/hero"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok());
let ct = hero.as_ref().and_then(|h| h.get("content_type").and_then(|c| c.as_str()))
.unwrap_or("image/png").to_string();
let img = hero.as_ref()
.and_then(|h| h.get("url").and_then(|u| u.as_str()))
.and_then(|u| { let full = if u.starts_with("http") { u.to_string() } else { format!("{WIKI}{u}") }; curl(&full) });
match img {
Some(bytes) => Response::from_data(bytes).with_header(hdr(&ct)),
None => Response::from_string("no hero").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/org-avatar/") => {
// Proxy an organization's real avatar. Org avatars live in Carbon/Hydrogen; the
// public Carbon API (carbon.adom.inc/orgs/<org>) returns an avatar_url on the
// public cdn.adom.inc, which we fetch and serve (avoids a cross-origin fetch).
let org = &p["/org-avatar/".len()..];
let org = org.split(['/', '?']).next().unwrap_or(org);
let img = curl(&format!("https://carbon.adom.inc/orgs/{org}"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| m.get("avatar_url").and_then(|u| u.as_str()).map(String::from))
.and_then(|u| curl(&u));
match img {
Some(bytes) => Response::from_data(bytes).with_header(hdr("image/png")),
None => Response::from_string("no avatar").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/board-svg/") => {
// forms: /board-svg/<slug>/pcb or /board-svg/<slug>/sch/<idx> [?v=<version>]
//
// The slug itself CONTAINS a '/' when it is owner-qualified (`adom/usb3-…`,
// which is every org board), so parse the view selector off the END. Splitting
// from the front took `adom` as the whole slug and 404'd both panes.
let (path, ver) = split_ver(url);
let rest = &path["/board-svg/".len()..];
let (slug, kind, sch_idx): (&str, &str, usize) = if let Some(s) = rest.strip_suffix("/pcb") {
(s, "pcb", 0)
} else if let Some((head, i)) = rest.rsplit_once('/') {
match (head.strip_suffix("/sch"), i.parse::<usize>().ok()) {
(Some(s), Some(n)) => (s, "sch", n),
_ => ("", "", 0),
}
} else {
("", "", 0)
};
let sub = |dir: &str| match kind {
"pcb" => Some(format!("{dir}pcb.svg")),
"sch" => Some(format!("{dir}sheet-{sch_idx:03}.svg")),
_ => None,
};
// a selected PAST version → serve from its render/<version>/ archive;
// else the live in-memory render; else the current stored top-level render/.
let svg = ver.as_ref()
.and_then(|v| sub(&format!("render/{v}/")))
.and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())
.or_else(|| cached_board(slug).and_then(|(sheets, pcb, _)| match kind {
"pcb" => pcb,
"sch" => sheets.get(sch_idx).map(|(_, s)| s.clone()),
_ => None,
}))
.or_else(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok()));
match svg {
Some(s) => Response::from_string(s).with_header(hdr("image/svg+xml")),
None => Response::from_string("not rendered").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/board-viewer/") => {
// forms: /board-viewer/<slug>/<name> [?v=<version>]
// Serves the self-contained INTERACTIVE viewer HTML (adom-layout-viewer /
// adom-schematic-viewer `embed` output): real net highlighting, layer
// toggles incl. inner copper, component hover. These are archived per board
// version under viewers/<version>/, mirroring render/<version>/.
//
// With ?v= we deliberately do NOT fall back to the current viewer: a board
// version that predates the viewer archive must show its own static SVG
// rather than silently render a DIFFERENT revision of the board.
let (path, ver) = split_ver(url);
let rest = &path["/board-viewer/".len()..];
let (slug, name) = match rest.rsplit_once('/') { Some(x) => x, None => ("", "") };
let safe = !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
let html = if !safe || slug.is_empty() {
None
} else if let Some(v) = ver.as_ref() {
wiki_bytes(slug, &format!("viewers/{v}/{name}.html"))
} else {
wiki_bytes(slug, &format!("viewers/{name}.html"))
};
match html {
Some(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")),
None => Response::from_string("no viewer").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/board-glb/") => {
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
// molecule-publish emits `<slug>.glb` and points model_3d_path at it), so the
// Manager's 3D matches the wiki page. Fall back to live cache, then render/board.glb.
let glb = ver.as_ref()
.and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))
.or_else(|| page_model_glb(slug))
.or_else(|| cached_board(slug).and_then(|(_, _, g)| g))
.or_else(|| wiki_bytes(slug, "render/board.glb"));
match glb {
Some(g) => Response::from_data(g).with_header(hdr("model/gltf-binary")),
None => Response::from_string("not rendered").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/glb/") => {
let slug = &p["/glb/".len()..];
match list_page_files(slug).iter().find(|n| n.ends_with(".glb")).and_then(|n| wiki_bytes(slug, n)) {
Some(b) => Response::from_data(b).with_header(hdr("model/gltf-binary")),
None => Response::from_string("no glb").with_status_code(404),
}
}
_ => Response::from_string("not found").with_status_code(404),
}
}
// ---------------------------------------------------------------------------
// Wiki access (via curl — no HTTP-client dep)
// ---------------------------------------------------------------------------
/// The wiki API token (same source the adom-wiki CLI uses), cached. Without it,
/// unauthenticated requests return only PUBLIC pages — so private/org component
/// pages would never be listed. Read once; None if unavailable.
static WIKI_TOKEN: Mutex<Option<Option<String>>> = Mutex::new(None);
fn wiki_token() -> Option<String> {
let mut c = WIKI_TOKEN.lock().unwrap();
if let Some(v) = c.as_ref() {
return v.clone();
}
let t = std::fs::read_to_string("/var/run/adom/api-key").ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("ADOM_API_KEY").ok().filter(|s| !s.is_empty()));
*c = Some(t.clone());
t
}
fn curl(url: &str) -> Option<Vec<u8>> {
// `--fail`: on an HTTP error (e.g. a missing file's 404) the wiki returns a JSON
// error body; without --fail curl hands us those bytes and callers mistake the
// error object for real content (a 404 manifest read as a valid render/ folder).
// With --fail curl exits non-zero and emits nothing, so we correctly return None.
let mut args: Vec<String> = vec!["-s".into(), "-f".into(), "-L".into(), "--max-time".into(), "12".into()];
// authenticate wiki requests so private/org pages are included (issue #120)
if url.contains("wiki.adom.inc") {
if let Some(tok) = wiki_token() {
args.push("-H".into());
args.push(format!("Authorization: Bearer {tok}"));
}
}
args.push(url.to_string());
let out = Command::new("curl").args(&args).output().ok()?;
if out.status.success() && !out.stdout.is_empty() {
Some(out.stdout)
} else {
None
}
}
fn wiki_text(slug: &str, file: &str) -> Option<String> {
let url = format!("{WIKI}/api/v1/pages/{slug}/files/{file}");
curl(&url).map(|b| String::from_utf8_lossy(&b).to_string())
}
fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> {
curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}"))
}
/// The page's model_3d GLB — the exact 3D file the wiki page displays. Drew's
/// molecule-publish emits `<slug>.glb` and sets `model_3d_path` to it, so serving
/// this keeps the Manager's 3D view consistent with the wiki page. Prefers the
/// explicit `model_3d_path` from package.json, else the `<slug>.glb` convention.
fn page_model_glb(slug: &str) -> Option<Vec<u8>> {
if let Some(txt) = wiki_text(slug, "package.json") {
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
if let Some(p) = v.get("model_3d_path").and_then(|x| x.as_str()).filter(|s| !s.is_empty()) {
let p = p.trim_start_matches("./").trim_start_matches('/');
if let Some(b) = wiki_bytes(slug, p) {
return Some(b);
}
}
}
}
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())
}
/// Build the render manifest MERGE-SAFELY (issue #218 problem 2). Starts from the
/// existing manifest so keys the pipeline does not own survive — unknown top-level
/// keys (`viewers`, `viewers_path`, …) a downstream tool added, and unknown keys on
/// each versions[] entry — overwriting only `v`/`current`/`glb`/`pcb`/`sheets`/
/// `versions[].{version,hash,glb,pcb,sheets}`. When `changed`, the current version's
/// entry is MERGED onto any prior entry for that version (so its extra keys survive)
/// and re-prepended, marking it the newest.
fn merge_render_manifest(
existing: &serde_json::Map<String, Value>,
version: &str, new_hash: &str, changed: bool,
has_glb: bool, has_pcb: bool, sheet_names: &[String],
) -> Value {
let mut versions: Vec<Value> = existing.get("versions")
.and_then(|a| a.as_array()).cloned().unwrap_or_default();
if changed {
let mut entry = versions.iter()
.find(|v| v.get("version").and_then(|x| x.as_str()) == Some(version))
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
entry.insert("version".into(), json!(version));
entry.insert("hash".into(), json!(new_hash));
entry.insert("glb".into(), json!(has_glb));
entry.insert("pcb".into(), json!(has_pcb));
entry.insert("sheets".into(), json!(sheet_names));
versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version));
versions.insert(0, Value::Object(entry));
}
let current = versions.first()
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| version.to_string());
let mut manifest = existing.clone();
manifest.insert("v".into(), json!(2));
manifest.insert("current".into(), json!(current));
manifest.insert("glb".into(), json!(has_glb));
manifest.insert("pcb".into(), json!(has_pcb));
manifest.insert("sheets".into(), json!(sheet_names));
manifest.insert("versions".into(), json!(versions));
Value::Object(manifest)
}
/// Make a raw kicad-cli board GLB safe for every consumer (issue #218 problem 1):
/// optimize it if the gltf-transform helper is installed (quantize + bake-friendly
/// merges, and its own +90°X Z-up rotation), then guarantee it is Z-up AND carries
/// `asset.extras.adomMolecule.upAxis = "z"`. Never fails — falls back to the input
/// bytes so a missing `node`/optimizer only costs size, never correctness.
fn finalize_board_glb(glb: &[u8]) -> Vec<u8> {
let optimized = optimize_glb(glb).unwrap_or_else(|| glb.to_vec());
ensure_zup_stamp(&optimized)
}
/// Locate the installed `glb_optimize.mjs` (shipped beside the binary by install.sh,
/// or in ~/.local/bin) and run it: `node glb_optimize.mjs <in> <out>`. Returns the
/// optimized bytes, or None if node/script/deps are unavailable or it fails.
fn optimize_glb(glb: &[u8]) -> Option<Vec<u8>> {
let script = std::env::current_exe().ok()
.and_then(|p| p.parent().map(|d| d.join("glb_optimize.mjs")))
.filter(|p| p.exists())
.or_else(|| {
let home = std::env::var("HOME").ok()?;
let p = std::path::PathBuf::from(home).join(".local/bin/glb_optimize.mjs");
p.exists().then_some(p)
})?;
let _ = std::fs::create_dir_all(SCRATCH);
let id = unique_id();
let inp = format!("{SCRATCH}/opt-{id}.in.glb");
let outp = format!("{SCRATCH}/opt-{id}.out.glb");
std::fs::write(&inp, glb).ok()?;
let ok = Command::new("node").arg(&script).arg(&inp).arg(&outp)
.status().map(|s| s.success()).unwrap_or(false);
let out = if ok { std::fs::read(&outp).ok() } else { None };
let _ = std::fs::remove_file(&inp);
let _ = std::fs::remove_file(&outp);
out.filter(|b| b.len() > 20 && &b[0..4] == b"glTF")
}
/// Guarantee a binary GLB is Z-up and stamped `adomMolecule.upAxis = "z"`.
/// Idempotent: if the stamp is already present the bytes are returned unchanged. If
/// the content is rotated (an `adom_zup` node from the optimizer) but unstamped, only
/// the stamp is added. Otherwise every scene root is reparented under a new node
/// rotated +90° about X (maps +Y → +Z) and the stamp is written. On any parse
/// failure the input is returned untouched. Only the JSON chunk is rewritten; the
/// BIN chunk (and any trailing chunks) are preserved verbatim.
fn ensure_zup_stamp(glb: &[u8]) -> Vec<u8> {
let orig = glb.to_vec();
if glb.len() < 20 || &glb[0..4] != b"glTF" {
return orig;
}
let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
let json_start = 20usize;
let json_end = match json_start.checked_add(json_len) {
Some(e) if e <= glb.len() => e,
_ => return orig,
};
// chunk 0 type must be JSON (0x4E4F534A)
if u32::from_le_bytes([glb[16], glb[17], glb[18], glb[19]]) != 0x4E4F_534A {
return orig;
}
let mut j: Value = match serde_json::from_slice(&glb[json_start..json_end]) {
Ok(v) => v,
Err(_) => return orig,
};
let already_stamped = j.get("asset").and_then(|a| a.get("extras"))
.and_then(|e| e.get("adomMolecule")).and_then(|m| m.get("upAxis"))
.and_then(|u| u.as_str()).map(|s| s.eq_ignore_ascii_case("z")).unwrap_or(false);
if already_stamped {
return orig;
}
let obj = match j.as_object_mut() {
Some(o) => o,
None => return orig,
};
// Rotate to Z-up unless already rotated (optimizer left an `adom_zup` node).
let has_zup = obj.get("nodes").and_then(|n| n.as_array())
.map(|a| a.iter().any(|n| n.get("name").and_then(|s| s.as_str()) == Some("adom_zup")))
.unwrap_or(false);
if !has_zup {
// Plan the reparenting from an immutable read (one +90°X wrapper per scene),
// then apply it — never holding two mutable sub-borrows of `obj` at once.
let scenes_roots = j_scenes_roots(obj);
let mut nodes_len = obj.get("nodes").and_then(|n| n.as_array()).map(|a| a.len()).unwrap_or(0);
let mut new_nodes: Vec<Value> = Vec::new();
let mut scene_updates: Vec<(usize, usize)> = Vec::new();
for (scene_idx, roots) in scenes_roots {
if roots.is_empty() { continue; }
let new_idx = nodes_len;
new_nodes.push(json!({
"name": "adom_zup",
"rotation": [0.707_106_78_f64, 0.0, 0.0, 0.707_106_78_f64],
"children": roots,
}));
scene_updates.push((scene_idx, new_idx));
nodes_len += 1;
}
if !new_nodes.is_empty() {
if let Some(nodes) = obj.entry("nodes").or_insert_with(|| json!([])).as_array_mut() {
nodes.extend(new_nodes);
}
if let Some(scenes) = obj.get_mut("scenes").and_then(|s| s.as_array_mut()) {
for (si, ni) in scene_updates {
if let Some(sc) = scenes.get_mut(si).and_then(|s| s.as_object_mut()) {
sc.insert("nodes".into(), json!([ni]));
}
}
}
}
}
// stamp asset.extras.adomMolecule.upAxis = "z"
let asset = obj.entry("asset").or_insert_with(|| json!({}));
let extras = asset.as_object_mut().unwrap().entry("extras").or_insert_with(|| json!({}));
let am = extras.as_object_mut().unwrap().entry("adomMolecule").or_insert_with(|| json!({}));
if let Some(m) = am.as_object_mut() { m.insert("upAxis".into(), json!("z")); }
rebuild_glb(glb, &j).unwrap_or(orig)
}
/// (scene_index, root node-index list) for every scene, read before mutation.
fn j_scenes_roots(obj: &serde_json::Map<String, Value>) -> Vec<(usize, Vec<Value>)> {
obj.get("scenes").and_then(|s| s.as_array()).map(|scenes| {
scenes.iter().enumerate().map(|(i, sc)| {
let roots = sc.get("nodes").and_then(|n| n.as_array()).cloned().unwrap_or_default();
(i, roots)
}).collect()
}).unwrap_or_default()
}
/// Re-emit a binary GLB with a replaced JSON chunk, preserving all following chunks
/// (BIN etc.) verbatim. JSON is padded with spaces, binary chunks with zeros, each to
/// a 4-byte boundary — per the glTF binary spec.
fn rebuild_glb(orig: &[u8], json: &Value) -> Option<Vec<u8>> {
let old_json_len = u32::from_le_bytes([orig[12], orig[13], orig[14], orig[15]]) as usize;
let after_json = 20usize.checked_add(old_json_len)?;
let trailing = orig.get(after_json..).unwrap_or(&[]);
let mut js = serde_json::to_vec(json).ok()?;
while js.len() % 4 != 0 { js.push(b' '); }
let total = 12 + 8 + js.len() + trailing.len();
let mut out = Vec::with_capacity(total);
out.extend_from_slice(b"glTF");
out.extend_from_slice(&2u32.to_le_bytes());
out.extend_from_slice(&(total as u32).to_le_bytes());
out.extend_from_slice(&(js.len() as u32).to_le_bytes());
out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes()); // "JSON"
out.extend_from_slice(&js);
out.extend_from_slice(trailing);
Some(out)
}
/// 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>) {
match p.split_once('?') {
Some((path, q)) => {
let v = q.split('&').find_map(|kv| kv.strip_prefix("v=")).map(|s| s.to_string());
(path, v.filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/')))
}
None => (p, None),
}
}
/// Uppercase-MPN filename stem for a slug (files are named `<MPN>.ext`).
fn up(slug: &str) -> String {
slug.to_uppercase()
}
/// List component pages from the wiki API (fast + reliable; `adompkg` is dead).
fn list_components() -> Value {
let mut items = Vec::new();
// The wiki caps each list response at ~200 rows regardless of `limit`, and there
// are now hundreds of components — a single fetch drops most of them (boards
// included). Page through with `offset` until we've collected every component.
let mut offset = 0usize;
loop {
let body = match curl(&format!("{WIKI}/api/v1/pages?type=component&limit=200&offset={offset}")) {
Some(b) => b,
None => break,
};
let v = match serde_json::from_slice::<Value>(&body) {
Ok(v) => v,
Err(_) => break,
};
let pages = match v.get("pages").and_then(|p| p.as_array()) {
Some(p) if !p.is_empty() => p.clone(),
_ => break,
};
let n = pages.len();
for pg in &pages {
let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_string();
if slug.is_empty() {
continue;
}
let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(&slug).to_string();
let brief = pg.get("brief").and_then(|s| s.as_str()).unwrap_or("").to_string();
// tags may arrive as a JSON-string or a real array
let tags = pg.get("tags").map(|t| match t.as_str() {
Some(s) => serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!([])),
None => t.clone(),
}).unwrap_or_else(|| json!([]));
let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string();
let org = pg.get("org_name").and_then(|s| s.as_str()).unwrap_or("").to_string();
let is_starred = pg.get("starred").and_then(|s| s.as_bool()).unwrap_or(false);
let visibility = pg.get("visibility").and_then(|s| s.as_str()).unwrap_or("").to_string();
let version = pg.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string();
let score = pg.get("composite_score").and_then(|s| s.as_f64()).unwrap_or(0.0);
items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version, "score": score}));
}
offset += n;
let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;
// stop when we've read everything, the page came back short, or as a hard cap
if offset >= total || n < 200 || offset >= 5000 {
break;
}
}
if items.is_empty() {
for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {
items.push(json!({"slug": s, "title": up(s), "brief": ""}));
}
}
// Fold the EDA type into every BOARD item up front, computed concurrently.
// The server is single-threaded, so lazy per-row `/api/board-eda` fetches would
// otherwise serialize N wiki file-listings (KiCad tags trickling in one by one).
let board_slugs: Vec<String> = items.iter().filter_map(|it| {
let is_board = it.get("tags").and_then(|t| t.as_array()).is_some_and(|a| {
a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule"))
});
if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }
}).collect();
let eda_map: std::collections::HashMap<String, Vec<&'static str>> = std::thread::scope(|sc| {
let handles: Vec<_> = board_slugs.iter().map(|s| {
let s = s.clone();
sc.spawn(move || { let e = board_eda_cached(&s); (s, e) })
}).collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
});
for it in items.iter_mut() {
if let Some(e) = it.get("slug").and_then(|s| s.as_str()).and_then(|s| eda_map.get(s)) {
it["eda"] = json!(e);
}
}
let (me, orgs) = whoami();
json!({ "status": "ok", "components": items, "me": me, "orgs": orgs,
"hints": ["click a component to open its viewer"] })
}
/// 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 {
let mpn = up(slug);
let (part, src): (AdomLbrPart, &str) = match load_part(slug, &mpn) {
Ok(v) => v,
Err(e) => {
return json!({"status":"error","error":e,
"hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})
}
};
let has_glb = list_page_files(slug).iter().any(|n| n.ends_with(".glb"));
json!({
"status": "ok",
"slug": slug,
"mpn": part.mpn,
"source": src, // "adom-lbr json" or "kicad (generated)"
"symbol_svg": symbol_svg(&part.symbol), // rendered on the fly, every click
"footprint_svg": footprint_svg(&part.footprint, &pin_names(&part.symbol)),
"layers": layer_stackup(&part.footprint),
"glb": has_glb,
"pins": part.symbol.pins.len(),
"pads": part.footprint.pads.len(),
})
}
/// A board/molecule's component dependencies — the other wiki components it's
/// built from. Read from the board's **`package.json` `dependencies`** (the same
/// npm-style manifest the wiki dependency graph is built from, so the two agree).
/// Values may be `{slug: ver}`, `[slug,…]`, or `[{slug|ref|name}, …]`; owner
/// prefixes (`adom/foo`) are stripped to the slug.
/// A page's package.json dependencies as bare slugs (owner prefix stripped).
fn deps_of(slug: &str) -> Vec<String> {
let bare = |k: &str| k.rsplit('/').next().unwrap_or(k).trim().to_string();
if let Some(txt) = wiki_text(slug, "package.json") {
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
return match v.get("dependencies") {
Some(Value::Object(m)) => m.keys().map(|k| bare(k)).collect(),
Some(Value::Array(a)) => a
.iter()
.filter_map(|d| {
d.as_str().map(|s| s.to_string()).or_else(|| {
d.get("slug").or_else(|| d.get("ref")).or_else(|| d.get("name")).and_then(|x| x.as_str()).map(|s| s.to_string())
})
})
.map(|s| bare(&s))
.collect(),
_ => vec![],
};
}
}
vec![]
}
/// Components on a board. Architecture: a board depends on a component LIBRARY,
/// and the library depends on the components. Resolve one hop — any dependency
/// that itself has dependencies is a library and is expanded to its components;
/// a dependency with none is treated as a direct component (backward-compatible
/// with boards that still list components directly).
fn board_deps(slug: &str) -> Value {
let mut components: Vec<String> = vec![];
let mut seen = std::collections::HashSet::new();
for d in deps_of(slug) {
let sub = deps_of(&d);
if sub.is_empty() {
if seen.insert(d.clone()) {
components.push(d);
}
} else {
for c in sub {
if seen.insert(c.clone()) {
components.push(c);
}
}
}
}
let items: Vec<Value> = components.into_iter().map(|s| json!({"slug": s})).collect();
// the page's git commit history (the wiki's Activity tab)
let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("log").cloned())
.unwrap_or_else(|| json!([]));
json!({ "status": "ok", "board": slug, "deps": items, "log": log,
"hints": ["board → component library → components (the library's dependencies)"] })
}
/// A PROJECT's direct board dependencies — a page tagged `project` depends on
/// boards (not components), so unlike `board_deps` these are NOT expanded. The
/// Manager lists them, and clicking one opens it as a normal board.
fn project_boards(slug: &str) -> Value {
let boards: Vec<Value> = deps_of(slug).into_iter().map(|s| json!({"slug": s})).collect();
let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("log").cloned())
.unwrap_or_else(|| json!([]));
json!({ "status": "ok", "project": slug, "boards": boards, "log": log })
}
/// The board's EDA toolchain, inferred from the files on its page:
/// "kicad" | "altium" | "fusion" | "".
/// Every EDA toolchain whose files are present on the board — a board can carry
/// more than one (e.g. a KiCad PCB alongside a Fusion/Eagle `.brd` + `.lbr`), so
/// this returns ALL matches, in a stable order, not just the first.
fn board_eda(slug: &str) -> Vec<&'static str> {
let files = list_page_files(slug);
let has = |exts: &[&str]| files.iter().any(|n| { let l = n.to_lowercase(); exts.iter().any(|e| l.ends_with(e)) });
let mut out = Vec::new();
if has(&[".kicad_pcb", ".kicad_sch", ".kicad_pro"]) {
out.push("kicad");
}
if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {
out.push("altium");
}
if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion", ".lbr"]) {
out.push("fusion");
}
out
}
/// `board_eda` cached across requests. The EDA set never changes for a given
/// board within a session, and the raw call costs a wiki file-listing round-trip;
/// caching turns the left-list's per-row EDA into a one-time cost.
static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, Vec<&'static str>>>> = Mutex::new(None);
fn board_eda_cached(slug: &str) -> Vec<&'static str> {
{
let mut g = EDA_CACHE.lock().unwrap();
if let Some(e) = g.get_or_insert_with(Default::default).get(slug) {
return e.clone();
}
}
let e = board_eda(slug);
EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e.clone());
e
}
/// The board's commit history enriched with what each commit changed: components
/// added/removed (footprint reference designators on the PCB) and whether the PCB
/// or schematic changed. Fetches each commit's .kicad_pcb + .kicad_sch via ?ref=.
fn board_activity(slug: &str) -> Value {
let log: Vec<Value> = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("log").and_then(|l| l.as_array().cloned()))
.unwrap_or_default();
if log.is_empty() {
return json!({ "activity": [] });
}
let files = list_page_files(slug);
let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();
let sch = files.iter().find(|n| n.ends_with(".kicad_sch")).cloned();
let at = |file: &Option<String>, hash: &str| -> Option<String> {
file.as_ref().and_then(|f| curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{f}?ref={hash}")).and_then(|b| String::from_utf8(b).ok()))
};
// component reference designators placed on the PCB
let refs = |pcb: &str| -> std::collections::HashSet<String> {
pcb.split("(property \"Reference\" \"").skip(1)
.filter_map(|c| c.split('"').next())
.filter(|r| !r.is_empty() && *r != "REF**")
.map(String::from)
.collect()
};
let hashes: Vec<String> = log.iter().filter_map(|c| c.get("hash").and_then(|h| h.as_str()).map(String::from)).collect();
let pcbs: Vec<Option<String>> = hashes.iter().map(|h| at(&pcb, h)).collect();
let schs: Vec<Option<String>> = hashes.iter().map(|h| at(&sch, h)).collect();
let mut out = vec![];
for i in 0..log.len() {
let mut e = log[i].clone();
// compare against the previous (older) commit — the next entry in the log
if i + 1 < log.len() {
e["pcb_changed"] = json!(pcbs[i] != pcbs[i + 1]);
e["sch_changed"] = json!(schs[i] != schs[i + 1]);
if let (Some(n), Some(o)) = (&pcbs[i], &pcbs[i + 1]) {
let (rn, ro) = (refs(n), refs(o));
e["comp_added"] = json!(rn.difference(&ro).count());
e["comp_removed"] = json!(ro.difference(&rn).count());
}
}
out.push(e);
}
json!({ "activity": out })
}
/// List the file names on a page (robust to any MPN→filename convention — the
/// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.).
fn list_page_files(slug: &str) -> Vec<String> {
let body = match curl(&format!("{WIKI}/api/v1/pages/{slug}/files")) {
Some(b) => b,
None => return vec![],
};
let v: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => return vec![],
};
v.get("files")
.and_then(|f| f.as_array())
.map(|a| {
a.iter()
.filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
}
/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate
/// it from the KiCad files. Discovers the ACTUAL filenames from the page listing
/// (never guesses from the slug). Returns the part + where it came from.
fn load_part(slug: &str, _mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {
let files = list_page_files(slug);
let find = |suf: &str| files.iter().find(|n| n.ends_with(suf)).cloned();
// 1) canonical adom-lbr JSON
if let Some(name) = find(".adom-lbr.json").or_else(|| find(".adomlbr.json")) {
if let Some(txt) = wiki_text(slug, &name) {
match serde_json::from_str::<AdomLbrPart>(&txt) {
Ok(p) => return Ok((p, "adom-lbr json")),
Err(e) => return Err(format!("{name} is not valid adom-lbr JSON: {e}")),
}
}
}
// 2) fall back to the KiCad source files
match (
find(".kicad_sym").and_then(|n| wiki_text(slug, &n)),
find(".kicad_mod").and_then(|n| wiki_text(slug, &n)),
) {
(Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, &up(slug))
.map(|p| (p, "kicad (generated)"))
.map_err(|e| e.to_string()),
_ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")),
}
}
// ---------------------------------------------------------------------------
// Layer stackup — the cross-EDA mapping (KiCad + Altium)
// ---------------------------------------------------------------------------
fn role_name(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "Silkscreen",
FpLayer::Courtyard => "Courtyard",
FpLayer::Fab => "Fabrication / Assembly",
FpLayer::Copper => "Copper",
FpLayer::Other { .. } => "Other",
}
}
fn kicad_layer(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "F.SilkS",
FpLayer::Courtyard => "F.CrtYd",
FpLayer::Fab => "F.Fab",
FpLayer::Copper => "F.Cu",
FpLayer::Other { .. } => "F.Fab",
}
}
/// Barrett's house convention (from the C0402 reference): outline+silk → Mech13
/// "Assembly Top", courtyard → Mech15 "Courtyard Top".
fn altium_layer(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "Mechanical 13 (Assembly Top)",
FpLayer::Fab => "Mechanical 13 (Assembly Top)",
FpLayer::Courtyard => "Mechanical 15 (Courtyard Top)",
FpLayer::Copper => "Top Layer",
FpLayer::Other { .. } => "Mechanical 1",
}
}
/// CSS-safe token per layer role — used to class SVG elements so the footprint
/// view can toggle layers on/off.
fn layer_key(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "silk",
FpLayer::Courtyard => "courtyard",
FpLayer::Fab => "fab",
FpLayer::Copper => "copper",
FpLayer::Other { .. } => "other",
}
}
fn layer_stackup(fp: &Footprint) -> Value {
use std::collections::BTreeMap;
// count graphics per role (+ the pad copper layer)
let mut counts: BTreeMap<&str, (FpLayer, usize)> = BTreeMap::new();
if !fp.pads.is_empty() {
counts.insert("Copper", (FpLayer::Copper, fp.pads.len()));
}
for g in &fp.graphics {
let l = match g {
FpGraphic::Line { layer, .. }
| FpGraphic::Rect { layer, .. }
| FpGraphic::Circle { layer, .. }
| FpGraphic::Arc { layer, .. } => *layer,
};
let e = counts.entry(role_name(&l)).or_insert((l, 0));
e.1 += 1;
}
let rows: Vec<Value> = counts
.values()
.map(|(l, n)| {
json!({
"role": role_name(l),
"key": layer_key(l),
"count": n,
"kicad": kicad_layer(l),
"altium": altium_layer(l),
})
})
.collect();
Value::Array(rows)
}
// ---------------------------------------------------------------------------
// SVG renderers (symbol in mils→viewport, footprint in mm)
// ---------------------------------------------------------------------------
/// Layer → SVG colour (KiCad-ish palette for the footprint view).
fn layer_color(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "#d8b4fe",
FpLayer::Courtyard => "#4ade80",
FpLayer::Fab => "#f472b6",
FpLayer::Copper => "#f59e0b",
FpLayer::Other { .. } => "#94a3b8",
}
}
fn symbol_svg(sym: &Symbol) -> String {
// bounds in mils over pins + graphics
let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut upd = |x: f64, y: f64| {
a = a.min(x);
b = b.min(y);
c = c.max(x);
d = d.max(y);
};
for p in &sym.pins {
upd(p.x as f64, p.y as f64);
}
for g in &sym.graphics {
match g {
Graphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }
Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }
Graphic::Polyline { points, .. } => { for pt in points { upd(pt[0], pt[1]); } }
}
}
if a > c { return empty_svg("no symbol"); }
let pad = 50.0;
let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);
// SVG y-down; symbol y-up → flip y
let fy = |y: f64| -y;
let mut body = String::new();
for g in &sym.graphics {
match g {
Graphic::Rect { x1, y1, x2, y2, filled, .. } => {
let fill = if *filled { "#e35d5d" } else { "none" };
body += &format!("<rect x='{:.1}' y='{:.1}' width='{:.1}' height='{:.1}' fill='{}' stroke='#e35d5d' stroke-width='4'/>",
x1.min(*x2), fy(y1.max(*y2)), (x2 - x1).abs(), (y2 - y1).abs(), fill);
}
Graphic::Circle { cx, cy, r, .. } => {
body += &format!("<circle cx='{:.1}' cy='{:.1}' r='{:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", cx, fy(*cy), r);
}
Graphic::Arc { cx, cy, r, start, end, .. } => {
let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), fy(cy + r * t.sin())) };
let (sx, sy) = p(*start); let (ex, ey) = p(*end);
let large = if ((end - start + 360.0) % 360.0) > 180.0 { 1 } else { 0 };
body += &format!("<path d='M{:.1} {:.1} A{:.1} {:.1} 0 {} 0 {:.1} {:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", sx, sy, r, r, large, ex, ey);
}
Graphic::Polyline { points, .. } => {
let pts: String = points.iter().map(|pt| format!("{:.1},{:.1} ", pt[0], fy(pt[1]))).collect();
body += &format!("<polyline points='{}' fill='none' stroke='#e35d5d' stroke-width='4'/>", pts.trim());
}
}
}
// Show pin numbers/names on ICs; hide on passives (respect the source flag,
// else default by pin count — same rule the KiCad exporter uses).
let passive = sym.pins.len() <= 2;
let show_num = !sym.pin_numbers_hidden.unwrap_or(passive);
let show_name = !sym.pin_names_hidden.unwrap_or(passive);
for p in &sym.pins {
let len = p.length.unwrap_or(200) as f64;
let (dx, dy) = match (p.rotation / 90) % 4 { 0 => (len, 0.0), 1 => (0.0, len), 2 => (-len, 0.0), _ => (0.0, -len) };
let (x1, y1) = (p.x as f64, fy(p.y as f64)); // free (connection) end
let (x2, y2) = (p.x as f64 + dx, fy(p.y as f64 + dy)); // body end, SVG space
body += &format!(
"<g class='pingroup' data-num='{}' data-name='{}'>\
<line class='pinvis' x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='#dfe4ec' stroke-width='4'/>\
<line x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='transparent' stroke-width='26' pointer-events='stroke'/></g>",
esc_attr(&p.number), esc_attr(&p.name), x1, y1, x2, y2, x1, y1, x2, y2
);
let ln = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt().max(1.0);
let (ux, uy) = ((x2 - x1) / ln, (y2 - y1) / ln); // free→body unit
let (px, py) = (-uy, ux);
// KiCad's default pin text is 1.27mm = 50 mil; match that (nudge with length).
let fs = (len * 0.5).clamp(45.0, 60.0);
if show_num {
body += &format!(
"<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#9fb0c8' text-anchor='middle' dominant-baseline='central'>{}</text>",
x1 + ux * ln * 0.5 + px * fs * 0.75, y1 + uy * ln * 0.5 + py * fs * 0.75, fs, esc_attr(&p.number)
);
}
if show_name && p.name != "~" && !p.name.is_empty() {
let anchor = if ux > 0.3 { "start" } else if ux < -0.3 { "end" } else { "middle" };
body += &format!(
"<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#e6e9ef' text-anchor='{}' dominant-baseline='central'>{}</text>",
x2 + ux * fs * 0.5, y2 + uy * fs * 0.5, fs, anchor, esc_attr(&p.name)
);
}
}
format!(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.0} {:.0} {:.0} {:.0}' style='width:100%;height:100%'>{}</svg>",
a, -d, c - a, d - b, body
)
}
/// number → pin name, so a pad can show the connected pin's name on hover.
fn pin_names(sym: &Symbol) -> std::collections::HashMap<String, String> {
sym.pins
.iter()
.filter(|p| !p.name.is_empty() && p.name != "~")
.map(|p| (p.number.clone(), p.name.clone()))
.collect()
}
fn footprint_svg(fp: &Footprint, names: &std::collections::HashMap<String, String>) -> String {
let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut upd = |x: f64, y: f64| { a = a.min(x); b = b.min(y); c = c.max(x); d = d.max(y); };
for p in &fp.pads {
upd(p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0);
upd(p.x_mm + p.w_mm / 2.0, p.y_mm + p.h_mm / 2.0);
}
for g in &fp.graphics {
match g {
FpGraphic::Line { x1, y1, x2, y2, .. } | FpGraphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }
FpGraphic::Circle { cx, cy, r, .. } | FpGraphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }
}
}
if a > c { return empty_svg("no footprint"); }
let pad = 0.4;
let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);
let mut body = String::new();
// graphics first (under pads)
for g in &fp.graphics {
let (layer, s) = match g {
FpGraphic::Line { layer, x1, y1, x2, y2, width } =>
(layer, format!("<line class='ly-{}' x1='{:.3}' y1='{:.3}' x2='{:.3}' y2='{:.3}' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1, y1, x2, y2, width.max(0.05))),
FpGraphic::Rect { layer, x1, y1, x2, y2, width } =>
(layer, format!("<rect class='ly-{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1.min(*x2), y1.min(*y2), (x2-x1).abs(), (y2-y1).abs(), width.max(0.05))),
FpGraphic::Circle { layer, cx, cy, r, width } =>
(layer, format!("<circle class='ly-{}' cx='{:.3}' cy='{:.3}' r='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), cx, cy, r, width.max(0.05))),
FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {
let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), cy + r * t.sin()) };
let (sx, sy) = p(*start); let (ex, ey) = p(*end);
(layer, format!("<path class='ly-{}' d='M{:.3} {:.3} A{:.3} {:.3} 0 0 1 {:.3} {:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), sx, sy, r, r, ex, ey, width.max(0.05)))
}
};
body += &s.replace("{}", layer_color(layer));
}
for p in &fp.pads {
let col = layer_color(&FpLayer::Copper);
let rx = if p.shape == "circle" || p.shape == "roundrect" { p.w_mm.min(p.h_mm) * 0.25 } else { 0.0 };
// the connected pin name (if the symbol has one) becomes the pad's label
let name = names.get(&p.number).cloned().unwrap_or_default();
body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' opacity='0.9'/>",
esc_attr(&p.number), esc_attr(&name), p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0, p.w_mm, p.h_mm, rx, col);
}
format!(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",
a, b, c - a, d - b, body
)
}
fn esc_attr(s: &str) -> String {
s.replace('&', "&").replace('\'', "'").replace('"', """).replace('<', "<")
}
fn empty_svg(msg: &str) -> String {
format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>")
}
// ---------------------------------------------------------------------------
// Add to Library — convert selected components → the user's KiCad libraries,
// delivered to their machine via the Adom Desktop bridge.
// ---------------------------------------------------------------------------
// TODO: detect from the bridge rather than hard-coding this user's home.
const SCRATCH: &str = "/tmp/adom-lbr-apply";
/// Run a command on the user's machine via the bridge, returning the command's
/// own stdout (not the adom-desktop JSON envelope). None if empty / bridge down.
fn ad_run(cmd: &str) -> Option<String> {
let raw = ad_shell_out(cmd)?;
let v: Value = serde_json::from_str(&raw).ok()?;
v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
/// The user's machine `(os, home)`, detected via the bridge and cached. Files are
/// delivered to the user's REAL machine (Windows/macOS/Linux), not this Linux
/// container, so delivery paths must be built for it.
static MACHINE: Mutex<Option<(String, String)>> = Mutex::new(None);
fn machine() -> (String, String) {
if let Some(v) = MACHINE.lock().unwrap().clone() {
return v;
}
// a Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.
let res = match ad_run("echo $HOME").filter(|h| h.starts_with('/')) {
Some(home) => {
let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };
(os.to_string(), home)
}
None => {
let home = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')).unwrap_or_else(|| "C:\\Users\\Default".into());
("windows".to_string(), home)
}
};
*MACHINE.lock().unwrap() = Some(res.clone());
res
}
/// The path separator for the user's OS.
fn sep() -> &'static str {
if machine().0 == "windows" { "\\" } else { "/" }
}
/// Join parts onto the user's home directory with the OS-appropriate separator.
fn home_path(parts: &[&str]) -> String {
let mut p = machine().1.trim_end_matches(['/', '\\']).to_string();
for x in parts {
p.push_str(sep());
p.push_str(x);
}
p
}
/// A KiCad library/project subdir on the user's machine (`sub` = Symbols /
/// Footprints / Projects).
fn kicad_dir(sub: &str) -> String {
home_path(&["Documents", "KiCad", sub])
}
/// Everything before the last path separator (the containing directory).
fn parent_dir(path: &str) -> String {
match path.rfind(['/', '\\']) {
Some(i) => path[..i].to_string(),
None => ".".to_string(),
}
}
/// GET /api/env — the user's machine OS + home + OS-appropriate default delivery
/// folders, so the UI's "Deliver project to" picker isn't hard-coded to Linux.
fn api_env() -> Value {
let (os, home) = machine();
json!({
"os": os,
"home": home,
"sep": sep(),
"dest_folders": [
kicad_dir("Projects"),
home_path(&["Documents"]),
home_path(&["Downloads"]),
home_path(&["Desktop"]),
],
})
}
/// The KiCad config dir on the user's machine (holds the global lib tables).
fn kicad_config_base() -> String {
let (os, home) = machine();
match os.as_str() {
"macos" => format!("{home}/Library/Preferences/kicad"),
"windows" => format!("{home}\\AppData\\Roaming\\kicad"),
_ => format!("{home}/.config/kicad"),
}
}
/// True for a KiCad-bundled standard library (as opposed to the user's own) —
/// identified by a `${KICAD*_…_DIR}` variable or an install-dir path.
fn is_stock_kicad_lib(uri: &str) -> bool {
let u = uri.to_lowercase();
uri.starts_with("${KICAD")
|| u.contains("/usr/share/kicad")
|| u.contains("/usr/lib/kicad")
|| u.contains("program files\\kicad")
|| u.contains("program files/kicad")
|| u.contains("/applications/kicad")
}
/// Parse `(lib (name "X") … (uri "Y"))` entries out of a KiCad lib-table.
fn parse_lib_table(text: &str) -> Vec<Value> {
text.split("(lib ")
.skip(1)
.filter_map(|chunk| {
let name = chunk.find("(name \"").and_then(|i| chunk[i + 7..].split('"').next())?;
let uri = chunk.find("(uri \"").and_then(|i| chunk[i + 6..].split('"').next())?;
if name.is_empty() {
return None;
}
Some(json!({ "name": name, "uri": uri }))
})
.collect()
}
/// GET /api/libraries — the user's REAL KiCad symbol + footprint libraries (name
/// + on-disk path) read from their global sym-lib-table / fp-lib-table via the
/// bridge, so placement suggests the user's own libraries — never hard-coded ones.
fn user_libraries() -> Value {
let (os, _) = machine();
let base = kicad_config_base();
let read = |table: &str| -> Vec<Value> {
let cmd = if os == "windows" {
format!("for /d %d in (\"{base}\\*\") do @type \"%d\\{table}\" 2>nul")
} else {
format!("cat {base}/*/{table} 2>/dev/null")
};
// merge across KiCad versions, first occurrence of each name wins
let mut seen = std::collections::HashSet::new();
ad_run(&cmd)
.map(|t| parse_lib_table(&t))
.unwrap_or_default()
.into_iter()
.filter(|l| {
let n = l.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();
let uri = l.get("uri").and_then(|x| x.as_str()).unwrap_or("");
// keep only the user's PERSONAL libraries: drop KiCad's bundled
// standard libs and the built-in default-table pointer.
n != "KiCad" && !is_stock_kicad_lib(uri) && seen.insert(n)
})
.collect()
};
json!({ "symLibs": read("sym-lib-table"), "fpLibs": read("fp-lib-table") })
}
/// Minimal percent-decoding for query values (`%20`, `+`, `%2F`, …).
fn urldecode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 3 <= b.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(if b[i] == b'+' { b' ' } else { b[i] });
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// GET /api/pick-folder — pop a NATIVE folder-picker dialog on the user's machine
/// (via the bridge) and return the chosen directory. Blocks until the user picks
/// or cancels (empty path = cancelled / no dialog available).
fn pick_folder() -> Value {
let (os, _) = machine();
let id = unique_id();
// A native folder dialog is MODAL and blocks until the user picks/cancels. If we
// ran it synchronously through the bridge, the bridge's shell_execute would wait
// for it and WEDGE the whole AD command channel. So launch it DETACHED, have it
// write the chosen path (or __CANCELLED__) to a temp file, and let the frontend
// poll /api/pick-folder-result. shell_execute returns instantly; AD stays free.
// The token is just the id; each side rebuilds the temp path itself (on Windows
// inside PowerShell via GetTempPath, since cmd mangles %TEMP%/forward-slash paths).
let launch = match os.as_str() {
"windows" => {
// inner: the TopMost dialog (owner form pulls it to the foreground), writing the result
let inner = format!(
"$ProgressPreference='SilentlyContinue'\n\
Add-Type -AssemblyName System.Windows.Forms\n\
$o=New-Object System.Windows.Forms.Form\n\
$o.TopMost=$true;$o.ShowInTaskbar=$false;$o.Opacity=0\n\
$o.Show();$o.Activate()\n\
$d=New-Object System.Windows.Forms.FolderBrowserDialog\n\
$d.Description='Select the project folder'\n\
$r=$d.ShowDialog($o)\n\
$o.Close()\n\
$out=if($r -eq [System.Windows.Forms.DialogResult]::OK){{$d.SelectedPath}}else{{'__CANCELLED__'}}\n\
$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'\n\
Set-Content -LiteralPath $p -Value $out -NoNewline -Encoding UTF8");
let inner_b64 = b64(&utf16le(&inner));
// outer: fire the inner off detached, then exit immediately so shell_execute
// returns. The inner carries -STA (needed for the dialog); the OUTER must NOT
// (an -STA outer adds ~7s of startup here). Base64-encoded so cmd/PS don't echo it.
let outer = format!("Start-Process -WindowStyle Hidden powershell -ArgumentList @('-NoProfile','-STA','-EncodedCommand','{inner_b64}')");
format!("powershell -NoProfile -EncodedCommand {}", b64(&utf16le(&outer)))
}
"macos" => format!("(osascript -e 'POSIX path of (choose folder)' > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),
_ => format!("(zenity --file-selection --directory > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),
};
let _ = ad_shell_out(&launch);
json!({ "token": id })
}
/// GET /api/pick-folder-result?f=<id> — poll the detached folder dialog: no file
/// yet = still open (`pending`); `__CANCELLED__`/empty = cancelled; else the path.
/// Reads (and deletes) the temp file so a stale result can't linger.
fn pick_folder_result(token: &str) -> Value {
// the token is our own generated id — keep only its safe characters
let id: String = token.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if id.is_empty() {
return json!({ "cancelled": true });
}
let (os, _) = machine();
let content = if os == "windows" {
// read+delete entirely inside PowerShell (cmd can't read the forward-slash %TEMP% path)
ad_run(&ps_command(&format!(
"$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'; \
if(Test-Path -LiteralPath $p){{[Console]::Out.Write((Get-Content -LiteralPath $p -Raw)); Remove-Item -LiteralPath $p -Force}}else{{[Console]::Out.Write('__PENDING__')}}")))
} else {
let tmp = format!("/tmp/apm_pick_{id}.txt");
let c = ad_run(&format!("cat '{tmp}' 2>/dev/null"));
if c.is_some() {
let _ = ad_shell_out(&format!("rm -f '{tmp}'"));
}
c.or_else(|| Some("__PENDING__".into()))
};
match content.as_deref().map(str::trim) {
None | Some("__PENDING__") => json!({ "pending": true }),
Some("") | Some("__CANCELLED__") => json!({ "cancelled": true }),
Some(path) => json!({ "path": path }),
}
}
/// GET /api/list-dirs?p=<abspath> — the sub-folders of a directory on the user's
/// machine (each with its full path) plus the parent, powering the in-app folder
/// browser. Empty p starts at the home directory. This replaces the native OS
/// folder dialog (which can't render from a detached bridge process and can't be
/// run modally without wedging the bridge), and — unlike a browser file input — it
/// yields the real absolute path, which delivery needs.
fn list_dirs(path: &str) -> Value {
let (os, home) = machine();
let base = if path.trim().is_empty() { home } else { path.to_string() };
if os == "windows" {
let esc = base.replace('\'', "''");
let script = format!(
"$ErrorActionPreference='SilentlyContinue'; \
$i=Get-Item -LiteralPath '{esc}'; \
if(-not $i){{[Console]::Out.Write('@@ERR@@'); return}}; \
$full=$i.FullName; $par=''; if($i.Parent){{$par=$i.Parent.FullName}}; \
$names=Get-ChildItem -LiteralPath $full -Directory -Force | ForEach-Object {{ $_.Name + [char]9 + $_.FullName }} | Sort-Object; \
[Console]::Out.Write($full + [char]10 + $par + [char]10 + '@@' + [char]10 + ($names -join [char]10))");
let out = ad_run(&ps_command(&script)).unwrap_or_default().replace('\r', "");
if out.is_empty() || out.contains("@@ERR@@") {
return json!({ "path": base, "parent": "", "dirs": [] });
}
let (head, rest) = out.split_once("\n@@\n").unwrap_or((out.as_str(), ""));
let mut hl = head.splitn(2, '\n');
let full = hl.next().unwrap_or(&base).to_string();
let par = hl.next().unwrap_or("").to_string();
let dirs: Vec<Value> = rest.split('\n').filter(|l| !l.trim().is_empty())
.filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))
.collect();
json!({ "path": full, "parent": par, "dirs": dirs })
} else {
let q = sh_quote(&base);
let full = ad_run(&format!("cd {q} 2>/dev/null && pwd")).unwrap_or_else(|| base.clone());
let fq = sh_quote(&full);
let par = ad_run(&format!("dirname {fq}")).filter(|p| *p != full).unwrap_or_default();
let out = ad_run(&format!("for d in {fq}/*/ ; do [ -d \"$d\" ] && printf '%s\\t%s\\n' \"$(basename \"$d\")\" \"${{d%/}}\" ; done 2>/dev/null")).unwrap_or_default();
let dirs: Vec<Value> = out.lines()
.filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))
.collect();
json!({ "path": full, "parent": par, "dirs": dirs })
}
}
/// GET /api/suggest-path?p=<prefix> — existing directories on the user's machine
/// whose path starts with the typed prefix (for the delivery autosuggest).
fn suggest_path(prefix: &str) -> Value {
let (os, _) = machine();
let clean: String = prefix.chars().filter(|c| !"\"'`;|&\n\r$".contains(*c)).collect();
let cmd = if os == "windows" {
format!("dir /b /ad \"{clean}*\" 2>nul")
} else {
format!("for d in {clean}*/ ; do [ -d \"$d\" ] && echo \"$d\" ; done 2>/dev/null")
};
let dirs: Vec<String> = ad_run(&cmd)
.map(|t| t.lines().map(|l| l.trim_end_matches(['/', '\\']).to_string()).filter(|l| !l.is_empty()).take(14).collect())
.unwrap_or_default();
json!({ "dirs": dirs })
}
const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");
/// The `.kicad_mod` filenames present in a directory on the user's machine.
fn list_kicad_mods(dir: &str) -> Vec<String> {
let (os, _) = machine();
let out = if os == "windows" {
ad_run(&format!("dir /b \"{}\\*.kicad_mod\" 2>nul", dir.replace('"', "")))
} else {
ad_run(&format!("ls {}/*.kicad_mod 2>/dev/null", sh_quote(dir)))
};
out.map(|o| {
o.lines()
.filter_map(|l| l.rsplit(['/', '\\']).next().map(|s| s.trim().to_string()))
.filter(|s| s.ends_with(".kicad_mod"))
.collect()
})
.unwrap_or_default()
}
/// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,
/// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the
/// project self-contained: extract the embedded symbols/footprints into a project
/// library, re-point every `lib_id`/footprint reference to it, and write the
/// project-local sym/fp-lib-tables. Then deliver everything to `dest`.
fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {
let dest = if dest.trim().is_empty() { format!("{}{}{board}", kicad_dir("Projects"), sep()) } else { dest.trim().to_string() };
let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::create_dir_all(&work);
let mut got = vec![];
let listing = match curl(&format!("{WIKI}/api/v1/pages/{board}/files")) {
Some(b) => b,
None => return (dest, got),
};
let v: Value = match serde_json::from_slice(&listing) {
Ok(v) => v,
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![];
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();
// 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);
}
}
}
}
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 {
// self-contain: run the Python helper, which rewrites files in `work`,
// creates <board>.kicad_sym / .pretty / lib-tables, and reports the split.
let libnick = board.replace(['/', ' '], "_");
let script = format!("{work}/_selfcontain.py");
let _ = std::fs::write(&script, PYTHON_SELFCONTAIN);
let out = Command::new("python3")
.arg(&script)
.arg(&work)
.arg(&libnick)
.args(&names)
.output();
if let Ok(o) = out {
if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {
let root: Vec<String> = v.get("root").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
let pretty: Vec<String> = v.get("pretty").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
if !root.is_empty() {
ad_send(&root, &dest);
}
if !pretty.is_empty() {
let pdir = format!("{dest}/{libnick}.pretty");
// if the project library already exists, update it as a DIFF: send
// the current footprints (add/replace), then delete any .kicad_mod
// that's no longer part of the board.
let keep: std::collections::HashSet<String> = pretty.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(String::from)).collect();
let stale: Vec<String> = list_kicad_mods(&pdir).into_iter().filter(|f| !keep.contains(f)).collect();
ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));
ad_send(&pretty, &pdir);
for f in stale {
ad_shell(&format!("rm -f {}", sh_quote(&format!("{pdir}/{f}"))));
}
}
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);
}
}
}
// 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)
}
/// Single-quote a string for safe interpolation into an `sh -c` command run on
/// the user's machine. Everything is inert inside single quotes except `'`,
/// which becomes the standard `'\''` splice.
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// Library nicknames become file/dir names on the user's machine and are
/// interpolated into shell commands — restrict to a boring identifier charset.
fn valid_lib(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
}
/// Wiki slugs (`owner/name`): same conservative charset plus `/`.
fn valid_slug(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& !s.contains("..")
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
}
/// Pull a file off the user's machine to the container; None if it doesn't
/// exist OR the pull failed — callers that overwrite must disambiguate via
/// `ad_file_exists` before treating None as "absent".
fn ad_pull(remote: &str) -> Option<Vec<u8>> {
let _ = std::fs::create_dir_all(SCRATCH);
let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();
Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;
let base = remote.rsplit('/').next()?;
std::fs::read(format!("{SCRATCH}/{base}")).ok()
}
/// Send container files to a directory on the user's machine.
fn ad_send(files: &[String], dest: &str) -> bool {
let args = json!({"filePaths": files, "dest": dest}).to_string();
Command::new("adom-desktop")
.args(["send_files", &args])
.output()
.map(|o| o.status.success())
.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();
}
/// Run a shell command on the user's machine and return its captured reply
/// (whole adom-desktop stdout); None if the bridge call itself failed.
// The desktop bridge (AD) processes shell commands one at a time. Firing several
// at once (now that the HTTP server is multi-threaded) makes concurrent filesystem
// searches thrash the disk and each balloon 5-10x. Serialize them here so every
// bridge call stays fast; the server still handles local/cached requests in parallel.
static BRIDGE_LOCK: Mutex<()> = Mutex::new(());
fn ad_shell_out(cmd: &str) -> Option<String> {
let _guard = BRIDGE_LOCK.lock().unwrap();
let args = json!({"command": cmd}).to_string();
let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).to_string())
}
/// Base64 (standard alphabet, padded).
fn b64(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let (b0, b1, b2) = (c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0));
out.push(T[(b0 >> 2) as usize] as char);
out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(if c.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });
out.push(if c.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });
}
out
}
fn utf16le(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
}
/// Wrap a PowerShell script as a `-EncodedCommand` invocation. The AD Windows
/// shell is cmd; passing `powershell -Command "…"` lets the double quotes survive
/// to PowerShell, which then treats the script as a string literal and ECHOES it
/// instead of running it. Base64 UTF-16LE has no quotes/spaces to mangle, so the
/// script runs verbatim regardless of the intervening cmd/JSON quoting.
fn ps_command(script: &str) -> String {
format!("powershell -NoProfile -STA -EncodedCommand {}", b64(&utf16le(script)))
}
/// A process-unique id (millis + counter) for temp filenames.
fn unique_id() -> String {
static C: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = C.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
format!("{t}_{n}")
}
/// Board media rendered by the hosted service-kicad container (KiCad 10) and
/// cached per board. Tuple: (schematic sheets `[(name, svg)]`, PCB svg, board GLB).
type BoardMedia = (Vec<(String, String)>, Option<String>, Option<Vec<u8>>);
static BOARD_MEDIA: Mutex<Option<std::collections::HashMap<String, BoardMedia>>> = Mutex::new(None);
static BOARD_RENDERING: Mutex<Option<std::collections::HashSet<String>>> = Mutex::new(None);
fn cached_board(slug: &str) -> Option<BoardMedia> {
BOARD_MEDIA.lock().unwrap().as_ref().and_then(|m| m.get(slug).cloned())
}
/// POST a KiCad file to service-kicad and return the rendered asset bytes.
/// `kind` = "pcb" | "sch", `fmt` = "svg" | "glb". None on any failure.
fn kicad_export(slug: &str, kind: &str, fmt: &str, query: &str, input: &[u8]) -> Option<Vec<u8>> {
let _ = std::fs::create_dir_all(SCRATCH);
let tmp = format!("{SCRATCH}/kx-{}-{kind}-{fmt}.in", slug.replace('/', "_"));
std::fs::write(&tmp, input).ok()?;
let url = format!("{KICAD_SVC}/kicad/{kind}/export/{fmt}{query}");
let out = Command::new("curl")
.args(["-s", "--max-time", "150", "-X", "POST", "--data-binary", &format!("@{tmp}"), &url])
.output()
.ok()?;
let _ = std::fs::remove_file(&tmp);
// the service returns the asset directly on success; proxy errors are short text
if out.status.success() && out.stdout.len() > 64 && !out.stdout.starts_with(b"error code") {
Some(out.stdout)
} else {
None
}
}
/// Render a board's full hierarchical schematic via service-kicad's project
/// endpoint: fetch every .kicad_sch (+ .kicad_pro) from the wiki with real
/// filenames, tar them, POST, untar one SVG per sheet (root sheet first).
fn render_sch_project(slug: &str) -> Vec<(String, String)> {
let files = list_page_files(slug);
let schs: Vec<&String> = files.iter().filter(|n| n.ends_with(".kicad_sch")).collect();
if schs.is_empty() {
return vec![];
}
let pro = files.iter().find(|n| n.ends_with(".kicad_pro"));
let key = slug.replace('/', "_");
let dir = format!("{SCRATCH}/sch-{key}");
let _ = std::fs::remove_dir_all(&dir);
if std::fs::create_dir_all(&dir).is_err() {
return vec![];
}
for n in schs.iter().copied().chain(pro) {
if let Some(b) = wiki_bytes(slug, n) {
let base = n.rsplit('/').next().unwrap_or(n);
let _ = std::fs::write(format!("{dir}/{base}"), b);
}
}
let tar_path = format!("{SCRATCH}/sch-{key}.tar");
if !Command::new("tar").args(["-cf", &tar_path, "-C", &dir, "."]).status().map(|s| s.success()).unwrap_or(false) {
return vec![];
}
let tar_bytes = std::fs::read(&tar_path).unwrap_or_default();
let out_tar = match kicad_export(slug, "sch", "svg-project", "", &tar_bytes) {
Some(b) => b,
None => return vec![],
};
let outdir = format!("{SCRATCH}/schout-{key}");
let _ = std::fs::remove_dir_all(&outdir);
let _ = std::fs::create_dir_all(&outdir);
let outtar = format!("{SCRATCH}/schout-{key}.tar");
if std::fs::write(&outtar, &out_tar).is_err() {
return vec![];
}
let _ = Command::new("tar").args(["-xf", &outtar, "-C", &outdir]).status();
let pro_stem = pro.map(|n| n.rsplit('/').next().unwrap_or(n).trim_end_matches(".kicad_pro").to_string());
let mut sheets: Vec<(String, String)> = vec![];
if let Ok(rd) = std::fs::read_dir(&outdir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("svg") {
if let Ok(s) = std::fs::read_to_string(&p) {
// kicad names them "<Project>" (root) and "<Project>-<Sheet>"; clean up
let raw = p.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet").to_string();
let name = if pro_stem.as_deref() == Some(raw.as_str()) {
"Root".to_string()
} else if let Some(st) = &pro_stem {
raw.strip_prefix(&format!("{st}-")).unwrap_or(&raw).to_string()
} else {
raw
};
sheets.push((name, s));
}
}
}
}
// Root sheet first, then the rest alphabetically
sheets.sort_by(|(a, _), (b, _)| (b == "Root").cmp(&(a == "Root")).then_with(|| a.cmp(b)));
sheets
}
/// Render the board GLB with the REAL component 3D models. The .kicad_pcb refers
/// to standard KiCad models by `${KICAD9_3DMODEL_DIR}/…` (the service has those)
/// and to custom models by absolute user paths (the service can't). The custom
/// 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).
/// 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;
while let Some(pos) = rest.find("(model \"") {
out.push_str(&rest[..pos + 8]);
let after = &rest[pos + 8..];
match after.find('"') {
Some(end) => {
let path = &after[..end];
if path.starts_with("${") {
if let Some(rel) = path.replace('\\', "/").strip_prefix("${KIPRJMOD}/") {
proj_models.insert(rel.to_string());
}
out.push_str(path);
} else {
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
models.insert(base.to_string());
out.push_str(&format!("${{KIPRJMOD}}/3d/{base}"));
}
rest = &after[end..];
}
None => break,
}
}
out.push_str(rest);
(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!("{dir}/3d/{base}"), mb);
break;
}
}
}
for rel in proj_models {
if let Some(mb) = wiki_bytes(slug, 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;
}
let bytes = std::fs::read(&targz).ok()?;
kicad_export(slug, "pcb", "glb-package", "?F=--include-silkscreen&F=--include-pads", &bytes)
}
/// Fetch a board's .kicad_pcb + full hierarchical schematic and render the
/// schematic sheets, the PCB SVG, and the board GLB via service-kicad.
fn render_board_media(slug: &str) -> BoardMedia {
let files = list_page_files(slug);
let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();
// schematic: render the whole hierarchical project so the root's sheet
// symbols resolve (a single-file render can't) — one SVG per sheet, root first
let sheets = render_sch_project(slug);
let pcb_bytes = pcb.as_ref().and_then(|n| wiki_bytes(slug, n));
// draw order = z-order (last on top): bottom/inner copper first (dimmed in
// the UI), then F.Cu on top (kept opaque = the solid top layer), then the
// silk/edge/fab docs. No F.Mask (its magenta would paint the pads purple).
let pcb_svg = pcb_bytes.as_ref().and_then(|b| kicad_export(slug, "pcb", "svg", "?layers=B.Cu,In2.Cu,In1.Cu,F.Cu,F.Silkscreen,Edge.Cuts", b)).and_then(|b| String::from_utf8(b).ok());
// GLB with the real component 3D models (bundle the custom .3dshapes models)
let glb = render_board_glb(slug);
(sheets, pcb_svg, glb)
}
/// The pre-rendered assets published to a board's `render/` folder, if any
/// (manifest = which assets exist + the ordered sheet names). When present the
/// Manager serves them directly — no service-kicad, no wait — instead of
/// rendering live. Regenerated on every board publish (`--render-board`).
fn stored_render(slug: &str) -> Option<Value> {
wiki_bytes(slug, "render/manifest.json")
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
// must actually look like a manifest, not e.g. a 404 error object
.filter(|m| m.get("v").is_some() || m.get("glb").is_some() || m.get("sheets").is_some())
}
/// {rendering, has_3d, has_sch, has_pcb}. With pre-rendered assets it returns
/// immediately; otherwise rendering runs in a background thread (a board GLB can
/// take ~a minute) so the single-threaded server stays responsive and the UI
/// polls until `rendering` is false.
fn board_media(slug: &str) -> Value {
// fast path: assets pre-rendered to the page's render/ folder at publish time
if let Some(m) = stored_render(slug) {
let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default();
return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "has_assets": true, "sheets": sheets,
"has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),
"has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false),
// version archive (empty on legacy flat manifests → the UI shows single-current)
"versions": m.get("versions").cloned().unwrap_or_else(|| json!([])),
// interactive viewer exports available for the CURRENT version (e.g.
// ["layout","sch-usb",…]). Per-version availability rides on versions[].viewers.
"viewers": m.get("viewers").cloned().unwrap_or_else(|| json!([])),
"current": m.get("current").cloned().unwrap_or(Value::Null)});
}
// no stored render/ folder from here on — the board live-renders instead.
if let Some((sheets, pcb, glb)) = cached_board(slug) {
let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();
return json!({"rendering": false, "is_kicad": true, "has_render": false, "has_assets": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});
}
// only KiCad boards (with a .kicad_pcb / .kicad_sch) can be rendered
let files = list_page_files(slug);
let has = |ext: &str| files.iter().any(|n| n.ends_with(ext));
let is_kicad = has(".kicad_pcb") || has(".kicad_sch");
// the assets the viewer needs to display the board even WITHOUT a stored render/
// folder: the KiCad source to render the PCB + hierarchical schematic (3D comes
// from a page .glb / model_3d_path, else is live-rendered from the .kicad_pcb).
// Only when these are absent is the board truly un-renderable → show the red bar.
let has_assets = has(".kicad_pcb") && has(".kicad_sch");
if !is_kicad {
// Not a KiCad board (EAGLE/Fusion .brd/.sch, or STEP/GLB-only). We can't
// render the 2D PCB/schematic (service-kicad is KiCad-only), but if the page
// ships a model GLB we can still show the 3D — served by the board-glb route's
// <slug>.glb fallback. So report has_3d from the GLB rather than giving up.
let has_3d = has(".glb");
return json!({"rendering": false, "is_kicad": false, "has_render": false,
"has_assets": has_3d, "sheets": [], "has_3d": has_3d, "has_pcb": false});
}
let start = {
let mut g = BOARD_RENDERING.lock().unwrap();
let set = g.get_or_insert_with(std::collections::HashSet::new);
set.insert(slug.to_string())
};
if start {
let s = slug.to_string();
std::thread::spawn(move || {
let v = render_board_media(&s);
BOARD_MEDIA.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(s.clone(), v);
if let Some(set) = BOARD_RENDERING.lock().unwrap().as_mut() {
set.remove(&s);
}
});
}
json!({"rendering": true, "is_kicad": true, "has_render": false, "has_assets": has_assets, "sheets": [], "has_3d": false, "has_pcb": false})
}
/// Does a file exist on the user's machine? None = couldn't determine
/// (bridge/desktop error) — callers must treat that as "assume it exists".
fn ad_file_exists(path: &str) -> Option<bool> {
let out = ad_shell_out(&format!("[ -f {} ] && echo __EXISTS__ || echo __ABSENT__", sh_quote(path)))?;
if out.contains("__EXISTS__") {
Some(true)
} else if out.contains("__ABSENT__") {
Some(false)
} else {
None
}
}
/// Extract the top-level `(symbol "NAME" …)` block (balanced parens) from a
/// single-symbol .kicad_sym → (name, block).
fn extract_symbol_block(t: &str) -> Option<(String, String)> {
let i = t.find("(symbol \"")?;
let name = t[i + 9..].split('"').next()?.to_string();
let b = t.as_bytes();
let mut depth = 0i32;
for j in i..b.len() {
match b[j] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some((name, t[i..=j].to_string()));
}
}
_ => {}
}
}
None
}
/// Merge symbol blocks into an existing (or new) `.kicad_sym`, skipping names
/// already present.
fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> String {
let base = existing.filter(|s| s.contains("kicad_symbol_lib")).unwrap_or_else(|| {
// Default tier (V8) — this lib lands on the user's desktop, whose
// KiCad (9.0.9) rejects files newer than itself.
let kv = altium_codec::kicad_export::KicadVersion::default();
format!(
"(kicad_symbol_lib\n\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{}\")\n)\n",
kv.sym_version(),
kv.generator_version().unwrap_or("8.0")
)
});
let mut names = std::collections::HashSet::new();
let mut idx = 0;
while let Some(p) = base[idx..].find("(symbol \"") {
let s = idx + p + 9;
match base[s..].find('"') {
Some(e) => {
names.insert(base[s..s + e].to_string());
idx = s + e;
}
None => break,
}
}
let mut add = String::new();
for (name, block) in blocks {
if !names.contains(name) {
add.push('\t');
add.push_str(block);
add.push('\n');
}
}
let cut = base.rfind(')').unwrap_or(base.len());
format!("{}{}{}", &base[..cut], add, &base[cut..])
}
/// POST /api/apply — body {items:[{slug,symLib,fpLib}], target, project?, board?}.
/// Converts each component, merges symbols into the mapped KiCad symbol library,
/// and drops footprints into the footprint `.pretty`, all on the user's machine.
// Live progress for the background apply, polled by the UI via /api/apply-progress.
static PROGRESS: Mutex<Option<Value>> = Mutex::new(None);
fn set_progress(v: Value) {
*PROGRESS.lock().unwrap() = Some(v);
}
fn get_progress() -> Value {
PROGRESS.lock().unwrap().clone().unwrap_or_else(|| json!({"stage":"idle","done":true}))
}
/// POST /api/apply — validate, then run the work on a background thread so the UI
/// can poll live stage updates. Returns immediately with `{status:"started"}`.
fn start_apply(body: &str) -> Value {
let req: Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return json!({"status":"error","error":format!("bad body: {e}")}),
};
if req.get("target").and_then(|t| t.as_str()).unwrap_or("kicad") != "kicad" {
return json!({"status":"error","error":"only KiCad add-to-library is implemented right now"});
}
// One apply at a time — concurrent runs share SCRATCH and the PROGRESS cell.
{
let p = PROGRESS.lock().unwrap();
if let Some(v) = p.as_ref() {
if v.get("done").and_then(|d| d.as_bool()) == Some(false) {
return json!({"status":"error","error":"an apply is already running",
"hint":"poll /api/apply-progress until done:true, then retry"});
}
}
}
let board = req.get("board").and_then(|b| b.as_str()).map(String::from);
if let Some(b) = &board {
if !valid_slug(b) {
return json!({"status":"error","error":format!("invalid board slug: {b:?}")});
}
}
let mut bad: Vec<String> = vec![];
let items: Vec<(String, String, String)> = req
.get("items")
.and_then(|i| i.as_array())
.map(|a| {
a.iter()
.filter_map(|it| {
let slug = it.get("slug").and_then(|s| s.as_str())?.to_string();
if slug.is_empty() {
return None;
}
let sym = it.get("symLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();
let fp = it.get("fpLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();
// These become paths + shell args on the user's machine.
if !valid_slug(&slug) || !valid_lib(&sym) || !valid_lib(&fp) {
bad.push(slug);
return None;
}
Some((slug, sym, fp))
})
.collect()
})
.unwrap_or_default();
if !bad.is_empty() {
return json!({"status":"error",
"error":format!("invalid slug or library name on: {}", bad.join(", ")),
"hint":"library names may only contain [A-Za-z0-9_.-]"});
}
if items.is_empty() && board.is_none() {
return json!({"status":"error","error":"no components selected"});
}
let dest = req.get("dest").and_then(|d| d.as_str()).unwrap_or("").to_string();
if dest.contains(['\n', '\r', '\0']) {
return json!({"status":"error","error":"invalid dest path"});
}
let project_only = req.get("project").and_then(|p| p.as_bool()).unwrap_or(false);
let total = items.len();
set_progress(json!({"stage":"Starting…","current":0,"total":total,"done":false}));
std::thread::spawn(move || run_apply(items, board, dest, project_only));
json!({"status":"started","total":total})
}
/// Every symbol name already present anywhere in the user's symbol libraries,
/// mapped to the library that has it — so an existing symbol is never re-added.
/// Best-effort (Unix grep / Windows Select-String); empty on failure.
fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String, String> {
let mut idx = std::collections::HashMap::new();
let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();
if files.is_empty() {
return idx;
}
let (os, _) = machine();
let out = if os == "windows" {
let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");
ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '\\(symbol \"([^\"]+)\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}")))
} else {
let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");
ad_run(&format!("grep -HoE '\\(symbol \"[^\"]+\"' {list} 2>/dev/null"))
};
if let Some(out) = out {
for line in out.lines() {
let (file, name) = if os == "windows" {
match line.rsplit_once('\t') { Some((f, n)) => (f, n.trim().to_string()), None => continue }
} else {
match line.split_once(":(symbol \"") { Some((f, r)) => (f, r.trim_end_matches('"').to_string()), None => continue }
};
if !name.is_empty() {
let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();
idx.entry(name).or_insert(lib);
}
}
}
idx
}
/// Which of the given MPNs appear (case-insensitively) in the user's symbol
/// libraries → a map from the found MPN (lowercased) to the library it's in.
/// Catches parts whose library symbol embeds the manufacturer part number.
fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections::HashMap<String, String> {
let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();
let pats: Vec<String> = mpns.iter()
.map(|m| m.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect::<String>())
.filter(|s| s.len() >= 4)
.collect();
if files.is_empty() || pats.is_empty() {
return Default::default();
}
let (os, _) = machine();
let pat = pats.join("|");
let out = if os == "windows" {
let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");
ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}")))
} else {
let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");
ad_run(&format!("grep -HioE '({pat})' {list} 2>/dev/null"))
};
// parse "<file>:<match>" (unix, split on the LAST ':') / "<path>\t<match>" (windows)
let mut map = std::collections::HashMap::new();
if let Some(out) = out {
for line in out.lines() {
let split = if os == "windows" { line.rsplit_once('\t') } else { line.rsplit_once(':') };
if let Some((file, m)) = split {
let m = m.trim().to_lowercase();
if m.is_empty() {
continue;
}
let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();
map.entry(m).or_insert(lib);
}
}
}
map
}
/// Normalize a value/name for fuzzy compare: lowercase, µ→u, alphanumerics only.
fn norm_val(s: &str) -> String {
s.to_lowercase().replace(['µ', 'μ'], "u").chars().filter(|c| c.is_ascii_alphanumeric()).collect()
}
/// The imperial size code in a package string (0402/0603/0805/1206/…), or "".
fn pkg_size(package: &str) -> String {
let b = package.as_bytes();
let mut i = 0;
while i + 4 <= b.len() {
if b[i..i + 4].iter().all(|c| c.is_ascii_digit())
&& (i == 0 || !b[i - 1].is_ascii_digit())
&& (i + 4 == b.len() || !b[i + 4].is_ascii_digit())
{
return package[i..i + 4].to_string();
}
i += 1;
}
String::new()
}
/// Derive a (normalized-value, package) signature from a component's symbol name
/// and footprint name — the wiki symbols encode it (`C_10uF_0402`, footprint
/// `C_0402_1005Metric`) even when the part JSON's value/package fields are blank.
/// None when there's no package code (e.g. an IC) — those match by MPN only.
fn value_package_sig(sym_name: &str, fp_name: &str) -> Option<(String, String)> {
let pkg = {
let p = pkg_size(fp_name);
if p.is_empty() { pkg_size(sym_name) } else { p }
};
if pkg.is_empty() {
return None;
}
// the value token: the first name segment with a digit that isn't the package
let val = sym_name
.split(|c: char| !c.is_ascii_alphanumeric() && c != '.')
.find(|t| t.chars().any(|c| c.is_ascii_digit()) && pkg_size(t).is_empty())?;
let v = norm_val(val);
if v.len() >= 2 { Some((v, pkg)) } else { None }
}
/// Footprint name (normalized) → the user's `.pretty` library that contains it.
fn footprint_index(dirs: &[String]) -> std::collections::HashMap<String, String> {
let mut idx = std::collections::HashMap::new();
let dirs: Vec<&String> = dirs.iter().filter(|d| !d.is_empty()).collect();
if dirs.is_empty() {
return idx;
}
let (os, _) = machine();
let out = if os == "windows" {
let cmds: Vec<String> = dirs.iter().map(|d| format!("dir /b /s \"{}\\*.kicad_mod\" 2>nul", d.replace('"', ""))).collect();
ad_run(&cmds.join(" & "))
} else {
let quoted = dirs.iter().map(|d| sh_quote(d)).collect::<Vec<_>>().join(" ");
ad_run(&format!("for d in {quoted}; do ls \"$d\"/*.kicad_mod 2>/dev/null; done"))
};
if let Some(out) = out {
for line in out.lines() {
let p = line.trim();
if p.is_empty() {
continue;
}
let fpname = p.rsplit(['/', '\\']).next().unwrap_or(p).trim_end_matches(".kicad_mod");
let lib = parent_dir(p).rsplit(['/', '\\']).next().unwrap_or("").trim_end_matches(".pretty").to_string();
if !fpname.is_empty() {
idx.entry(norm_val(fpname)).or_insert_with(|| lib.clone());
// also index the base part name (before the package suffix) — e.g.
// "BMA580" from "BMA580_WLCSP-6" — so stub parts match by base-part.
if let Some(base) = fpname.split('_').next() {
if base != fpname && base.len() >= 3 {
idx.entry(norm_val(base)).or_insert_with(|| lib.clone());
}
}
}
}
}
idx
}
/// Which of the given component slugs are ALREADY in the user's libraries, with
/// the reason and the SYMBOL + FOOTPRINT library each is found in — so neither a
/// duplicate symbol nor a duplicate footprint gets added. Symbol match: MPN (grep
/// or a lib symbol name that PREFIXES the MPN, covering stub parts), exact symbol
/// name, else value + package. Footprint match: the `.kicad_mod` is present in a
/// `.pretty`. Returns (slug, reason, symLib, fpLib) — either lib may be empty.
fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String, String)> {
let libs = user_libraries();
let uris = |key: &str| -> Vec<String> {
libs.get(key).and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|l| l.get("uri").and_then(|u| u.as_str()).map(String::from)).collect())
.unwrap_or_default()
};
let sym_files = uris("symLibs");
let fp_dirs = uris("fpLibs");
if (sym_files.is_empty() && fp_dirs.is_empty()) || slugs.is_empty() {
return vec![];
}
let mpn_map = mpns_present_in_libs(&sym_files, &slugs.iter().map(|s| up(s)).collect::<Vec<_>>());
let norm_index: std::collections::HashMap<String, String> =
existing_symbol_index(&sym_files).into_iter().map(|(n, l)| (norm_val(&n), l)).collect();
let fp_index = footprint_index(&fp_dirs);
let mut out = vec![];
for slug in slugs {
let nmpn = norm_val(&up(slug));
let part = load_part(slug, &up(slug)).ok().map(|(p, _)| p);
// symbol library this part already lives in (if any) + why
let (reason, sym_lib): (&str, String) = if let Some(lib) = mpn_map.get(&up(slug).to_lowercase()) {
("mpn", lib.clone())
} else if let Some((_, lib)) = norm_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())) {
("mpn", lib.clone())
} else if let Some(p) = &part {
let sn = norm_val(&p.symbol.name);
if sn.len() >= 3 && norm_index.contains_key(&sn) {
("symbol", norm_index[&sn].clone())
} else if let Some((v, pk)) = value_package_sig(&p.symbol.name, &p.footprint.name) {
match norm_index.iter().find(|(n, _)| n.contains(&v) && n.contains(&pk)) {
Some((_, lib)) => ("value+package", lib.clone()),
None => ("", String::new()),
}
} else {
("", String::new())
}
} else {
("", String::new())
};
// footprint library this part's .kicad_mod already lives in (if any) —
// by exact footprint name, else a footprint base name that PREFIXES the MPN
let fp_lib = part.as_ref()
.and_then(|p| fp_index.get(&norm_val(&p.footprint.name)).cloned())
.or_else(|| fp_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())).map(|(_, l)| l.clone()))
.unwrap_or_default();
if !sym_lib.is_empty() || !fp_lib.is_empty() {
let reason = if sym_lib.is_empty() { "footprint" } else { reason };
out.push((slug.clone(), reason.to_string(), sym_lib, fp_lib));
}
}
out
}
/// Diff a board's components against its self-contained project library at `dest`
/// on the user's machine. Returns which components are being ADDED (not yet in the
/// project lib), KEPT (already there), and which library symbols would be REMOVED
/// (in the lib, no longer on the board). `exists` = the project lib was found.
fn project_diff(board: &str, dest: &str) -> Value {
let libnick = board.replace(['/', ' '], "_");
let s = sep();
let mut lib_path = format!("{}{s}{libnick}.kicad_sym", dest.trim_end_matches(['/', '\\']));
// if it's not at the given destination, search the user's Documents for it
// (the default path guess can be wrong — e.g. "KiCad" vs "KiCAD")
if ad_file_exists(&lib_path) != Some(true) {
let (os, home) = machine();
let search = if os == "windows" {
format!("dir /b /s \"{home}\\Documents\\{libnick}.kicad_sym\" 2>nul")
} else {
format!("find {home}/Documents -maxdepth 6 -name '{libnick}.kicad_sym' 2>/dev/null | head -1")
};
if let Some(found) = ad_run(&search).filter(|f| !f.trim().is_empty()) {
lib_path = found.lines().next().unwrap_or("").trim().to_string();
}
}
// top-level symbol names already in the project lib (skip _N_N unit sub-symbols)
let existing: Vec<String> = ad_pull(&lib_path)
.and_then(|b| String::from_utf8(b).ok())
.map(|t| {
let mut names = vec![];
let mut idx = 0;
while let Some(p) = t[idx..].find("(symbol \"") {
let start = idx + p + 9;
match t[start..].find('"') {
Some(e) => {
let raw = &t[start..start + e];
let unit = { let tail: Vec<&str> = raw.rsplitn(3, '_').collect(); tail.len() == 3 && tail[0].chars().all(|c| c.is_ascii_digit()) && tail[1].chars().all(|c| c.is_ascii_digit()) && !tail[0].is_empty() && !tail[1].is_empty() };
if !unit {
names.push(raw.to_string());
}
idx = start + e;
}
None => break,
}
}
names
})
.unwrap_or_default();
let exists = !existing.is_empty() || ad_file_exists(&lib_path) == Some(true);
// The project library names its symbols the way the board's schematic does
// (generic "C"/"R"/"BMA580"), and neither carries MPNs — so compare the library
// against the board's actual SCHEMATIC symbol set, not the components' names.
let sch_syms = board_schematic_symbols(board);
let slugs: Vec<String> = board_deps(board).get("deps").and_then(|d| d.as_array())
.map(|a| a.iter().filter_map(|x| x.get("slug").and_then(|s| s.as_str()).map(String::from)).collect())
.unwrap_or_default();
// library symbols no longer used anywhere on the board → flagged for deletion
let remove: Vec<String> = existing.iter().filter(|n| !sch_syms.contains(&norm_val(n))).cloned().collect();
// the project library is built from THIS board: if it already exists every
// component is already in it (kept); otherwise every component is created (added).
let (add, keep) = if exists { (Vec::<String>::new(), slugs) } else { (slugs, Vec::<String>::new()) };
json!({ "exists": exists, "add": add, "keep": keep, "remove": remove })
}
/// The set of symbol names (normalized) the board's schematic actually uses — the
/// name after the `lib_id "Lib:Name"` colon, across every `.kicad_sch` on the page.
fn board_schematic_symbols(board: &str) -> std::collections::HashSet<String> {
let mut syms = std::collections::HashSet::new();
for name in list_page_files(board).iter().filter(|n| n.ends_with(".kicad_sch")) {
if let Some(txt) = wiki_text(board, name) {
for chunk in txt.split("(lib_id \"").skip(1) {
if let Some(id) = chunk.split('"').next() {
let n = id.rsplit(':').next().unwrap_or(id);
if !n.is_empty() {
syms.insert(norm_val(n));
}
}
}
}
}
syms
}
/// The actual work — converts + merges + delivers, updating PROGRESS at each step.
fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {
let _ = std::fs::create_dir_all(SCRATCH);
use std::collections::BTreeMap;
let mut by_symlib: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut by_fplib: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (slug, sym, fp) in &items {
by_symlib.entry(sym.clone()).or_default().push(slug.clone());
by_fplib.entry(fp.clone()).or_default().push(slug.clone());
}
// resolve each library NAME to its real on-disk path from the user's lib tables;
// a name not in their tables (a brand-new library) falls back to the KiCad dir.
let libs = user_libraries();
let path_map = |key: &str| -> std::collections::HashMap<String, String> {
libs.get(key).and_then(|a| a.as_array()).map(|a| {
a.iter().filter_map(|l| Some((l.get("name")?.as_str()?.to_string(), l.get("uri")?.as_str()?.to_string()))).collect()
}).unwrap_or_default()
};
let sym_paths = path_map("symLibs");
let fp_paths = path_map("fpLibs");
// for "integrate into my libraries": index every symbol/footprint already in
// the user's libraries so an existing one is skipped instead of duplicated.
// (Project-only mode builds a fresh isolated library — nothing to dedup.)
// which components are ALREADY in the user's libraries: match by MPN (which
// some library symbols embed), else by value + package. Skip those entirely
// (both symbol and footprint) so nothing gets duplicated.
// No blanket "already in a library" skip: the target library is pre-filled in
// the UI with wherever the part already lives, so merge_symbols (per-target
// dedup) keeps it out when unchanged and adds it when the user picks a
// different library. Changing the dropdown → a new symbol/footprint there.
let skipped: Vec<Value> = vec![];
let total = items.len();
let mut done_n = 0usize;
let mut added: Vec<Value> = vec![];
let mut errors: Vec<String> = vec![];
let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));
for (symlib, slugs) in &by_symlib {
let mut blocks: Vec<(String, String)> = vec![];
for slug in slugs {
done_n += 1;
prog(format!("Converting {slug} → {symlib}"), done_n);
match load_part(slug, &up(slug)) {
Ok((part, _)) => {
if let Some(b) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {
blocks.push(b);
}
}
Err(e) => errors.push(format!("{slug}: {e}")),
}
}
prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);
let remote = sym_paths.get(symlib).cloned().unwrap_or_else(|| format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep()));
let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());
// NEVER overwrite a library we failed to read. ad_pull returns None for
// both "absent" and "pull failed" — only proceed with a fresh library
// when the desktop confirms the file genuinely doesn't exist.
if existing.is_none() {
match ad_file_exists(&remote) {
Some(false) => {} // genuinely absent — start a fresh library
Some(true) => {
errors.push(format!(
"{symlib}.kicad_sym exists on the desktop but could not be pulled — skipped to avoid overwriting it"
));
continue;
}
None => {
errors.push(format!(
"could not verify {symlib}.kicad_sym on the desktop (bridge error) — skipped to avoid overwriting it"
));
continue;
}
}
}
let merged = merge_symbols(existing.clone(), &blocks);
// Never-shrink guard: the merge only appends, so the result must hold at
// least as many symbols as the library already had.
let n_syms = |s: &str| s.matches("(symbol \"").count();
if let Some(ex) = &existing {
if n_syms(&merged) < n_syms(ex) {
errors.push(format!("{symlib}.kicad_sym merge would shrink the library — aborted"));
continue;
}
}
// write back to the library's real file (send its basename to its dir)
let base = remote.rsplit(['/', '\\']).next().unwrap_or("lib.kicad_sym").to_string();
let local = format!("{SCRATCH}/{base}");
if let Err(e) = std::fs::write(&local, &merged) {
errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));
continue;
}
if ad_send(&[local], &parent_dir(&remote)) {
for (name, _) in &blocks {
added.push(json!({"symbol": name, "symLib": symlib}));
}
} else {
errors.push(format!("failed to write {symlib}.kicad_sym"));
}
}
for (fplib, slugs) in &by_fplib {
prog(format!("Delivering footprints → {fplib}.pretty"), total);
let dest = fp_paths.get(fplib).cloned().unwrap_or_else(|| format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep()));
ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));
let mut files = vec![];
for slug in slugs {
if let Ok((part, _)) = load_part(slug, &up(slug)) {
let name = if part.footprint.name.is_empty() { up(slug) } else { part.footprint.name.clone() };
let local = format!("{SCRATCH}/{}.kicad_mod", name.replace(['/', ' '], "_"));
match std::fs::write(&local, export_kicad_footprint(&part.footprint)) {
Ok(()) => files.push(local),
Err(e) => errors.push(format!("{slug}: failed to stage footprint locally: {e}")),
}
}
}
if !files.is_empty() && !ad_send(&files, &dest) {
errors.push(format!("failed to deliver footprints to {fplib}.pretty"));
}
}
let project = match &board {
Some(b) => {
prog(if project_only { "Building self-contained project (symbols, footprints, lib-tables)…".into() } else { "Fetching KiCad project files (schematic, PCB, sheets)…".to_string() }, total);
let (folder, files) = fetch_project_files(b, &dest, project_only);
json!({"folder": folder, "files": files})
}
None => json!(null),
};
let result = json!({"status":"ok","count":added.len(),"added":added,"skipped":skipped,"errors":errors,"project":project});
set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result}));
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
fn hdr(ct: &str) -> Header {
Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))
}
fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("application/json"))
}
const INDEX_HTML: &str = include_str!("manage.html");
const LOADER_GIF: &[u8] = include_bytes!("loader.gif");
#[cfg(test)]
mod glb_tests {
use super::*;
/// Assemble a minimal binary GLB from a glTF JSON value + BIN payload.
fn build_glb(json: &Value, bin: &[u8]) -> Vec<u8> {
let mut js = serde_json::to_vec(json).unwrap();
while js.len() % 4 != 0 { js.push(b' '); }
let mut bn = bin.to_vec();
while bn.len() % 4 != 0 { bn.push(0); }
let total = 12 + 8 + js.len() + 8 + bn.len();
let mut out = Vec::new();
out.extend_from_slice(b"glTF");
out.extend_from_slice(&2u32.to_le_bytes());
out.extend_from_slice(&(total as u32).to_le_bytes());
out.extend_from_slice(&(js.len() as u32).to_le_bytes());
out.extend_from_slice(&0x4E4F_534Au32.to_le_bytes());
out.extend_from_slice(&js);
out.extend_from_slice(&(bn.len() as u32).to_le_bytes());
out.extend_from_slice(&0x004E_4942u32.to_le_bytes());
out.extend_from_slice(&bn);
out
}
fn json_of(glb: &[u8]) -> Value {
let jlen = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
serde_json::from_slice(&glb[20..20 + jlen]).unwrap()
}
// A Y-up scene: one root node (index 0) bearing a mesh.
fn yup_glb(bin: &[u8]) -> Vec<u8> {
let j = json!({
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"scene": 0,
"nodes": [{"mesh": 0, "name": "board"}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
});
build_glb(&j, bin)
}
#[test]
fn stamps_and_rotates_raw_yup() {
let bin = b"BINARYDATA-1234567890";
let out = ensure_zup_stamp(&yup_glb(bin));
// stamp present + readable by the same accessor APM uses
assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));
let j = json_of(&out);
let nodes = j["nodes"].as_array().unwrap();
// exactly one adom_zup wrapper, rotating +90° about X, wrapping the old root
let zup: Vec<_> = nodes.iter().filter(|n| n["name"] == "adom_zup").collect();
assert_eq!(zup.len(), 1, "one wrapper node");
let rot = zup[0]["rotation"].as_array().unwrap();
assert!((rot[0].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);
assert!((rot[3].as_f64().unwrap() - 0.707_106_78).abs() < 1e-6);
assert_eq!(zup[0]["children"], json!([0]));
// the scene now points at the wrapper, not the bare root
let widx = nodes.iter().position(|n| n["name"] == "adom_zup").unwrap();
assert_eq!(j["scenes"][0]["nodes"], json!([widx]));
// BIN chunk survives verbatim
assert!(out.windows(bin.len()).any(|w| w == bin), "bin preserved");
}
#[test]
fn is_idempotent() {
let once = ensure_zup_stamp(&yup_glb(b"payload-xyz"));
let twice = ensure_zup_stamp(&once);
assert_eq!(once, twice, "already-stamped input returned byte-identical");
}
#[test]
fn rotated_but_unstamped_only_gets_stamp() {
// Mimics the optimizer having rotated (adom_zup) but not stamped.
let j = json!({
"asset": {"version": "2.0"},
"scenes": [{"nodes": [1]}],
"scene": 0,
"nodes": [
{"mesh": 0, "name": "board"},
{"name": "adom_zup", "rotation": [0.70710678, 0.0, 0.0, 0.70710678], "children": [0]}
],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
});
let out = ensure_zup_stamp(&build_glb(&j, b"bin"));
assert_eq!(glb_up_axis(&out).as_deref(), Some("z"));
let j2 = json_of(&out);
let zup = j2["nodes"].as_array().unwrap().iter().filter(|n| n["name"] == "adom_zup").count();
assert_eq!(zup, 1, "no second rotation node added");
}
#[test]
fn non_glb_passes_through() {
let junk = b"not a glb at all".to_vec();
assert_eq!(ensure_zup_stamp(&junk), junk);
}
fn obj(v: Value) -> serde_json::Map<String, Value> { v.as_object().unwrap().clone() }
#[test]
fn manifest_preserves_unknown_toplevel_keys() {
// The exact regression from #218: a re-render must not drop `viewers`.
let existing = obj(json!({
"v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],
"viewers": ["layout", "sch-usb"],
"viewers_path": "viewers/{version}/",
"versions": [{"version": "1.3.0", "hash": "aaa", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout"]}],
}));
// an unchanged re-render (same hash) still must not clobber unknown keys
let m = merge_render_manifest(&existing, "1.3.0", "aaa", false, true, true, &["Root".into()]);
assert_eq!(m["viewers"], json!(["layout", "sch-usb"]));
assert_eq!(m["viewers_path"], json!("viewers/{version}/"));
assert_eq!(m["versions"][0]["viewers"], json!(["layout"]), "per-version key survives");
}
#[test]
fn manifest_new_version_merges_not_replaces() {
// A changed render for a version string that already carries extra keys must
// update owned keys yet keep the unknown ones on that entry.
let existing = obj(json!({
"v": 2, "current": "1.3.0", "glb": true, "pcb": true, "sheets": ["Root"],
"viewers": ["layout"],
"versions": [{"version": "1.4.0", "hash": "old", "glb": true, "pcb": true, "sheets": ["Root"], "viewers": ["layout", "sch"]}],
}));
let m = merge_render_manifest(&existing, "1.4.0", "new", true, true, false, &["Root".into(), "Pwr".into()]);
let v0 = &m["versions"][0];
assert_eq!(v0["version"], json!("1.4.0"));
assert_eq!(v0["hash"], json!("new"), "owned key updated");
assert_eq!(v0["pcb"], json!(false), "owned key updated");
assert_eq!(v0["sheets"], json!(["Root", "Pwr"]));
assert_eq!(v0["viewers"], json!(["layout", "sch"]), "unknown per-version key preserved");
assert_eq!(m["current"], json!("1.4.0"));
assert_eq!(m["viewers"], json!(["layout"]), "top-level unknown key preserved");
// exactly one entry for 1.4.0 (merged, not duplicated)
let count = m["versions"].as_array().unwrap().iter()
.filter(|v| v["version"] == json!("1.4.0")).count();
assert_eq!(count, 1);
}
#[test]
fn manifest_first_archive_from_empty() {
let m = merge_render_manifest(&serde_json::Map::new(), "1.0.0", "h0", true, true, true, &["Root".into()]);
assert_eq!(m["v"], json!(2));
assert_eq!(m["current"], json!("1.0.0"));
assert_eq!(m["versions"][0]["hash"], json!("h0"));
}
}