app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footp
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929
//! `adom-lbr manage` — the adom-lbr Manager. A small web app (tiny_http →
//! Hydrogen webview) that lists wiki components, and on click opens a viewer:
//! symbol + footprint + 3D, plus the footprint LAYER STACKUP showing what layer
//! each graphic becomes in KiCad and Altium.
//!
//! 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};
const WIKI: &str = "https://wiki.adom.inc";
// TODO: detect the signed-in wiki user rather than hard-coding this owner.
const MY_OWNER: &str = "barrett-land";
/// 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
}
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-lbr 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.");
for mut req in server.incoming_requests() {
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, "/") => html(INDEX_HTML),
(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("/api/board/") => {
let slug = &p["/api/board/".len()..];
json_resp(&board_deps(slug).to_string())
}
(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)
// ---------------------------------------------------------------------------
fn curl(url: &str) -> Option<Vec<u8>> {
let out = Command::new("curl")
.args(["-s", "-L", "--max-time", "12", url])
.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}"))
}
/// 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();
if let Some(body) = curl(&format!("{WIKI}/api/v1/pages?type=component&limit=300")) {
if let Ok(v) = serde_json::from_slice::<Value>(&body) {
if let Some(pages) = v.get("pages").and_then(|p| p.as_array()) {
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();
items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner}));
}
}
}
}
if items.is_empty() {
for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {
items.push(json!({"slug": s, "title": up(s), "brief": ""}));
}
}
json!({ "status": "ok", "components": items, "me": MY_OWNER,
"hints": ["click a component to open its viewer"] })
}
/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is
/// 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.
fn board_deps(slug: &str) -> Value {
let bare = |k: &str| k.rsplit('/').next().unwrap_or(k).trim().to_string();
let mut deps: Vec<String> = vec![];
if let Some(txt) = wiki_text(slug, "package.json") {
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
deps = 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![],
};
}
}
let items: Vec<Value> = deps.into_iter().map(|s| json!({"slug": s})).collect();
json!({ "status": "ok", "board": slug, "deps": items,
"hints": ["deps come from the board's package.json dependencies (same as the wiki dependency graph)"] })
}
/// 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 KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";
const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";
const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects";
const SCRATCH: &str = "/tmp/adom-lbr-apply";
const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");
/// 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!("{KICAD_PROJ_DIR}/{board}") } 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 mut names = vec![];
for f in &files {
let name = f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).unwrap_or("");
if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {
if let Some(bytes) = wiki_bytes(board, name) {
let base = name.rsplit('/').next().unwrap_or(name).to_string();
if std::fs::write(format!("{work}/{base}"), bytes).is_ok() {
names.push(base);
}
}
}
}
if names.is_empty() {
return (dest, got);
}
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");
ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));
ad_send(&pretty, &pdir);
}
got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();
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);
(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)
}
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.
fn ad_shell_out(cmd: &str) -> Option<String> {
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())
}
/// 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})
}
/// 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());
}
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 = format!("{KICAD_SYM_DIR}/{symlib}.kicad_sym");
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;
}
}
let local = format!("{SCRATCH}/{symlib}.kicad_sym");
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], KICAD_SYM_DIR) {
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 = format!("{KICAD_FP_DIR}/{fplib}.pretty");
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,"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");