← Commit history

adom-project-manager: board browser + viewer (3D/schematic/PCB), Add-to-Library, self-contained Add-Project, service-kicad rendering

barrett-land ·fefd1ec3d1 ·3mo ago ·parent 89b5f66
5 files changed +2355
Cargo.lockadded+373
@@ -0,0 +1,373 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-project-manager"+version = "0.1.0"+dependencies = [+ "altium-codec",+ "anyhow",+ "serde",+ "serde_json",+ "tiny_http",+]++[[package]]+name = "altium-codec"+version = "0.1.0"+dependencies = [+ "altium-pcblib",+ "altium-schlib",+ "anyhow",+ "cfb",+ "flate2",+ "serde",+ "serde_json",+]++[[package]]+name = "altium-pcblib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "altium-schlib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "anyhow"+version = "1.0.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "bumpalo"+version = "3.20.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"++[[package]]+name = "byteorder"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"++[[package]]+name = "cfb"+version = "0.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"+dependencies = [+ "byteorder",+ "fnv",+ "uuid",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "fnv"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"++[[package]]+name = "futures-core"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"++[[package]]+name = "futures-task"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"++[[package]]+name = "futures-util"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "js-sys"+version = "0.3.103"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"+dependencies = [+ "cfg-if",+ "futures-util",+ "wasm-bindgen",+]++[[package]]+name = "log"+version = "0.4.33"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.46"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "rustversion"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.150"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "syn"+version = "2.0.118"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "uuid"+version = "1.23.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"+dependencies = [+ "js-sys",+ "wasm-bindgen",+]++[[package]]+name = "wasm-bindgen"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"+dependencies = [+ "cfg-if",+ "once_cell",+ "rustversion",+ "wasm-bindgen-macro",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-macro"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"+dependencies = [+ "quote",+ "wasm-bindgen-macro-support",+]++[[package]]+name = "wasm-bindgen-macro-support"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"+dependencies = [+ "bumpalo",+ "proc-macro2",+ "quote",+ "syn",+ "wasm-bindgen-shared",+]++[[package]]+name = "wasm-bindgen-shared"+version = "0.2.126"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+26
@@ -0,0 +1,26 @@+[package]+name = "adom-project-manager"+version = "0.1.0"+edition = "2021"+description = "adom Project Manager — browse your wiki boards/projects, view symbol + footprint + 3D and the footprint layer stackup, add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr."++[[bin]]+name = "adom-project-manager"+path = "src/main.rs"++[dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"+anyhow = "1"+# The Manager is built on adom-lbr: it reuses the codec (KiCad import/export,+# the canonical adom-lbr part model) to render + convert on the fly.+altium-codec = { path = "../adom-lbr/crates/altium-codec" }++# Standalone workspace root (do not get absorbed into the adom-lbr workspace).+[workspace]++[profile.release]+opt-level = "z"+lto = true+strip = true
src/main.rsadded+1215
@@ -0,0 +1,1215 @@+//! 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() {+    // Minimal arg parse: `--port <n>` / `--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);+        }+    }+    if let Err(e) = run(port) {+        eprintln!("ERROR: {e}");+        std::process::exit(1);+    }+}++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";+// 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+}++/// The signed-in wiki identity (username + org names), from `adom-wiki whoami`,+/// cached. Falls back to MY_OWNER if the CLI isn't reachable.+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 = MY_OWNER.to_string();+    let mut orgs = vec![];+    if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {+        if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {+            let data = v.get("data");+            if let Some(u) = data.and_then(|d| d.get("user")).and_then(|u| u.get("username")).and_then(|s| s.as_str()) {+                me = u.to_string();+            }+            if let Some(a) = data.and_then(|d| d.get("orgs")).and_then(|o| o.as_array()) {+                orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).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.");+    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("/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-svg/") => {+            // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>+            let segs: Vec<&str> = p["/board-svg/".len()..].splitn(3, '/').collect();+            let svg = cached_board(segs.first().copied().unwrap_or("")).and_then(|(sheets, pcb, _)| {+                match segs.get(1).copied() {+                    Some("pcb") => pcb,+                    Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).and_then(|i| sheets.get(i).map(|(_, s)| s.clone())),+                    _ => None,+                }+            });+            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-glb/") => {+            match cached_board(&p["/board-glb/".len()..]).and_then(|(_, _, g)| g) {+                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)+// ---------------------------------------------------------------------------++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();+                    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);+                    items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred}));+                }+            }+        }+    }+    if items.is_empty() {+        for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {+            items.push(json!({"slug": s, "title": up(s), "brief": ""}));+        }+    }+    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+/// 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();+    // 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": ["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('&', "&amp;").replace('\'', "&#39;").replace('"', "&quot;").replace('<', "&lt;")+}++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())+}++/// 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).+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()?;+    // rewrite absolute (model "/…/<name>") refs to ${KIPRJMOD}/3d/<name>; collect names+    let mut models: std::collections::HashSet<String> = std::collections::HashSet::new();+    let mut out = String::with_capacity(pcb.len());+    let mut rest = pcb.as_str();+    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('/') {+                    let base = path.rsplit('/').next().unwrap_or(path);+                    models.insert(base.to_string());+                    out.push_str(&format!("${{KIPRJMOD}}/3d/{base}"));+                } else {+                    out.push_str(path);+                }+                rest = &after[end..];+            }+            None => break,+        }+    }+    out.push_str(rest);+    // 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()?;+    if let Some(sd) = &shapes_dir {+        for base in &models {+            if let Some(mb) = wiki_bytes(slug, &format!("{sd}/{base}")) {+                let _ = std::fs::write(format!("{bundle}/3d/{base}"), mb);+            }+        }+    }+    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,F.Fab", 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)+}++/// {rendering, has_3d, has_sch, has_pcb}. Rendering runs in a background thread+/// (a board GLB can take ~a minute) so the single-threaded server stays+/// responsive; the UI polls until `rendering` is false.+fn board_media(slug: &str) -> Value {+    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, "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 is_kicad = list_page_files(slug).iter().any(|n| n.ends_with(".kicad_pcb") || n.ends_with(".kicad_sch"));+    if !is_kicad {+        return json!({"rendering": false, "is_kicad": false, "sheets": [], "has_3d": false, "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, "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})+}++/// 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");
src/manage.htmladded+601
@@ -0,0 +1,601 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Adom Project Manager</title>+<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script>+<style>+  :root{--bg:#0f1115;--panel:#171a21;--edge:#252a34;--ink:#e6e9ef;--mut:#8b93a3;--acc:#6ea8fe}+  *{box-sizing:border-box}+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex}+  #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}+  #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}+  #list h1 small{color:var(--mut);font-weight:400}+  #refresh{float:right;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:26px;height:24px;font-size:14px;cursor:pointer;line-height:1}+  #refresh:hover{background:#243049;border-color:var(--acc)}+  #crumb{padding:7px 16px 0;font-size:12px;color:var(--mut)}+  #crumb a{color:var(--acc);text-decoration:none}+  #crumb a:hover{text-decoration:underline}+  #crumb .cactive{color:var(--ink);font-weight:600}+  .row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}+  .cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1}+  .tabs{display:flex;border-bottom:1px solid var(--edge)}+  .tabs .tab{flex:1;background:none;border:none;border-bottom:2px solid transparent;color:var(--mut);padding:10px 4px;font-size:12px;cursor:pointer;white-space:nowrap}+  .tabs .tab:hover{color:var(--ink)}+  .tabs .tab.active{color:var(--ink);border-bottom-color:var(--acc);font-weight:600}+  .row .star{margin-right:6px;color:#f5c518;font-size:13px;line-height:1}+  .pubsearch{margin-top:8px}+  .pubsearch .pubrow{display:flex;gap:8px;margin:8px 12px}+  .pubsearch input{flex:1;min-width:0;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 10px;font-size:13px}+  .pubsearch input:focus{outline:none;border-color:var(--acc)}+  .pubsearch button{background:var(--acc);color:#08111f;border:none;border-radius:8px;padding:0 13px;font-size:19px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}+  .pubsearch button:hover{filter:brightness(1.1)}+  .row .selbox{margin-right:9px;vertical-align:middle;cursor:pointer}+  #toolbar{border-bottom:1px solid var(--edge);padding:8px 12px;display:flex;flex-direction:column;gap:7px}+  #toolbar .selall{font-size:12px;color:var(--mut);cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}+  #toolbar .tbtns{display:flex;gap:7px}+  #toolbar button{flex:1;background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:7px;padding:7px 8px;font-size:12px;cursor:pointer}+  #toolbar button:hover{border-color:var(--acc);background:#22304a}+  #toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600}+  #toolbar button:disabled{opacity:.45;cursor:not-allowed}+  .modal-overlay{position:fixed;inset:0;background:#000a;display:flex;align-items:center;justify-content:center;z-index:1000}+  .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a}+  .modal h2{margin:0 0 4px;font-size:18px}+  .modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}+  .modal .opts{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}+  .modal .opt{border:1px solid var(--edge);border-radius:9px;padding:9px 13px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}+  .modal .opt.sel{border-color:var(--acc);background:#1a2740}+  .modal table{width:100%;margin:0 0 6px}+  .modal .sure{margin:18px 0 10px;font-size:15px;font-weight:600}+  .modal-actions{display:flex;gap:10px;justify-content:flex-end}+  .modal-actions button{background:#1a2130;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:9px 18px;font-size:14px;cursor:pointer}+  .modal-actions button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:700}+  .modal .lib{font:12px ui-monospace,monospace;color:#7ee0c0}+  .modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}+  #modal-dest{margin:0 0 16px}+  #modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}+  #modal-dest .destrow{display:flex;gap:8px}+  #modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}+  #modal-dest select{min-width:200px}+  #modal-dest input{flex:1;font:12px ui-monospace,monospace}+  #modal-dest input:focus,#modal-dest select:focus{outline:none;border-color:var(--acc)}+  #refresh.spin{animation:sp .6s linear infinite}+  @keyframes sp{to{transform:rotate(360deg)}}+  #q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}+  #rows{overflow:auto;flex:1}+  .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}+  .row:hover{background:#1e222b}+  .row.sel{background:#243049;border-left:3px solid var(--acc);padding-left:13px}+  .row .mpn{font-weight:600}+  .row .sub{color:var(--mut);font-size:12px}+  #view{flex:1;overflow:hidden;padding:0;display:flex;flex-direction:column;min-width:0;position:relative}+  #cv-close{display:none;position:absolute;right:14px;top:calc(50% + 10px);z-index:6;background:#1a2130;border:1px solid var(--edge);color:var(--ink);width:27px;height:27px;border-radius:6px;cursor:pointer;font-size:14px;line-height:1}+  #view.split #cv-close{display:block}+  #cv-close:hover{border-color:var(--acc);background:#22304a}+  #board-overview{flex:1;overflow:auto;min-height:0}+  #component-view{display:none;min-height:0;overflow:auto;padding:16px}+  #view.split #board-overview{flex:0 0 50%}+  #view.split #component-view{flex:0 0 50%;display:block;border-top:3px solid var(--acc)}+  #hint{color:var(--mut);text-align:center;margin-top:20vh}+  /* board overview (top of the right pane) */+  .bo-head{padding:16px 20px 12px;border-bottom:1px solid var(--edge)}+  .bo-head .title{margin:0;font-size:20px;font-weight:700}+  .bo-author{color:var(--mut);font-size:13px;margin-top:3px}+  .bo-3d-wrap{position:relative;margin:16px 20px;padding:0}+  .bo-3d-wrap .bo-3d{margin:0}+  .bo-fs-abs{position:absolute;top:0;right:0;z-index:3}+  .bo-3d-wrap .bo-fs-abs{top:10px;right:10px}+  .bo-view.maxed .bo-3d{flex:1;height:auto;aspect-ratio:auto}+  .bo-3d{aspect-ratio:1;margin:16px 20px;background:#161b22;border:1px solid var(--edge);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden}+  .bo-3d model-viewer{width:100%;height:100%;aspect-ratio:auto}+  .bo-views{display:flex;gap:14px;padding:0 20px 20px}+  .bo-view{flex:1;min-width:0;position:relative}+  .bo-view-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;min-height:24px;padding-right:34px}+  .bo-view h3{margin:0;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}+  .bo-sheet{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;max-width:62%}+  .bo-sheet:focus{outline:none;border-color:var(--acc)}+  .bo-hd-ctl{display:flex;align-items:center;gap:8px}+  .bo-fs{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;width:27px;height:24px;font-size:13px;cursor:pointer;line-height:1;padding:0}+  .bo-fs:hover{border-color:var(--acc)}+  .bo-view.maxed{position:fixed;inset:0;z-index:300;background:var(--bg);margin:0;padding:16px;display:flex;flex-direction:column}+  .bo-view.maxed .bo-viewbody{flex:1;height:auto}+  .bo-view.maxed .bo-view-hd{margin-bottom:12px}+  .bo-viewbody{height:260px;background:#161b22;border:1px solid var(--edge);border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;touch-action:none}+  .bo-viewbody svg{width:100%;height:100%;display:block}+  .bo-na{color:#8b93a3;font-size:13px;text-align:center;padding:20px;line-height:1.5}+  .bo-section{padding:0 20px 20px}+  .bo-section>h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--edge);padding-bottom:6px}+  .bo-log .commit{padding:8px 0;border-bottom:1px solid var(--edge)}+  .bo-log .commit:last-child{border-bottom:none}+  .commit-msg{font-size:13px;color:var(--ink)}+  .commit-meta{font-size:11.5px;color:var(--mut);margin-top:2px}+  .commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}+  .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}+  .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}+  .card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)}+  .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}+  .card .body svg{max-width:100%;max-height:100%;will-change:transform}+  .card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}+  .lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}+  .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}+  .chip input{margin:0;cursor:pointer}+  .chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}+  .chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}+  model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22}+  h2.title{margin:0 0 14px}+  h2.title small{color:var(--mut);font-weight:400;font-size:14px}+  table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}+  th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--edge);font-size:13px}+  th{color:var(--mut);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}+  tr:last-child td{border-bottom:none}+  .swatch{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:7px;vertical-align:-1px}+  .badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px}+  .stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}+  .err{color:#f87171;padding:16px}+  .card .body [data-num]{cursor:pointer}+  .pinhi .pinvis{stroke:#6ea8fe !important;vector-effect:non-scaling-stroke;stroke-width:3}+  rect.pad.pinhi{stroke:#6ea8fe;vector-effect:non-scaling-stroke;stroke-width:3}+  #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}+  #tip b{color:var(--acc)}+</style>+</head>+<body>+  <aside id="list">+    <h1>Adom <small>Project Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>+    <div id="crumb"></div>+    <input id="q" placeholder="filter boards…">+    <div id="toolbar" style="display:none">+      <label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label>+      <div class="tbtns">+        <button id="btn-addlib" disabled>Add to Library</button>+        <button id="btn-addproj">Add Project ↓</button>+      </div>+    </div>+    <div id="rows"></div>+  </aside>+  <main id="view">+    <button id="cv-close" onclick="closeComponent()" title="close">✕</button>+    <div id="board-overview"><div id="hint">← pick a board to see it here</div></div>+    <div id="component-view"></div>+  </main>+  <div id="modal" class="modal-overlay" style="display:none"><div class="modal">+    <h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>+    <div class="opts" id="modal-opts"></div>+    <div id="modal-dest"></div>+    <div id="modal-preview"></div>+    <div class="sure">Are you sure this is good?</div>+    <div class="modal-actions"><button id="modal-cancel">Cancel</button><button class="primary" id="modal-go">Yes, add it</button></div>+  </div></div>++<script>+const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};+// Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component+// dependencies ▸ the symbol/footprint/3D viewer.+let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine';+const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};+const qval=()=>document.getElementById('q').value.toLowerCase();+const rowsEl=()=>document.getElementById('rows');+function makeRow(slug,title,sub,onclick,tag,sel){+  const d=document.createElement('div');d.className='row';d.dataset.slug=slug;+  const cb=sel?`<input type="checkbox" class="selbox"${selected.has(slug)?' checked':''}>`:'';+  d.innerHTML=`<div class="mpn">${cb}${title}${tag?`<span class="tag">${tag}</span>`:''}</div><div class="sub">${sub}</div>`;+  d.onclick=(e)=>{ if(e.target.classList.contains('selbox')){toggleSel(slug,e.target.checked);e.stopPropagation();return;} onclick(slug,d); };+  return d;+}+// component-type → the user's existing KiCad symbol library (fallback JLC_Misc_BLT)+const TYPE_TO_SYMLIB={'Capacitors':'JLC_Capacitors_BLT','Resistors':'JLC_Resistors_BLT','MOSFETs':'JLC_FETs_BLT','Transistors':'JLC_FETs_BLT','Inductors':'JLC_Inductors_BLT','LEDs':'JLC_LEDs_BLT','Diodes':'JLC_Diodes_BLT','Connectors':'JLC_Connectors_BLT','Microcontrollers':'JLC_MCUs_BLT','Power ICs':'JLC_Power_BLT','Crystals & Oscillators':'JLC_Oscillators_BLT','ICs':'JLC_Misc_BLT','Other':'JLC_Misc_BLT'};+const selected=new Set();+function symLibFor(c){return TYPE_TO_SYMLIB[typeOf(c)]||'JLC_Misc_BLT';}+function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();}+function updateToolbar(){+  const n=selected.size;+  const cnt=document.getElementById('selcount'); if(cnt)cnt.textContent=n?`${n} selected · clear`:'select all';+  const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';}+  const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s));+}+async function loadList(fresh){+  const btn=document.getElementById('refresh'); if(btn&&fresh)btn.classList.add('spin');+  if(!fresh) rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>';+  try{+    const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());+    all=r.components||[]; me=r.me||''; orgs=r.orgs||[]; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);+    render();+  }catch(e){+    rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';+  }finally{ if(btn)btn.classList.remove('spin'); }+}+function render(){ boardSlug?renderDeps():renderBoards(); }+function renderBoards(){+  document.getElementById('toolbar').style.display='none';selected.clear();+  document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';+  const q=qval(),el=rowsEl();el.innerHTML='';+  const boards=all.filter(isBoard);+  const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org);+  const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org));+  // tabs: My projects (personal, no org) · Org (in my orgs) · Public (search)+  const tabbar=document.createElement('div');tabbar.className='tabs';+  const defs=[['mine',`Personal · ${mineAll.length}`],['org',`Organization · ${orgAll.length}`],['public','Public']];+  tabbar.innerHTML=defs.map(([k,label])=>`<button class="tab${boardTab===k?' active':''}" data-tab="${k}">${label}</button>`).join('');+  el.appendChild(tabbar);+  tabbar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{boardTab=t.dataset.tab;renderBoards();});+  const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…';+  if(boardTab==='public'){renderPublic(boards,el);return;}+  const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));+  if(!list.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML=boardTab==='mine'?'no personal projects yet — push a board tagged <b>board</b>/<b>molecule</b> (without --org), then press ↻':'no projects in your organization(s) yet';el.appendChild(d);return;}+  if(boardTab==='mine'){+    sortBoards(list).forEach(c=>el.appendChild(boardRow(c)));+  } else {+    // group by organization (so multiple orgs list under their own headers)+    orgs.forEach(o=>{+      const inOrg=sortBoards(list.filter(c=>c.org===o));+      if(!inOrg.length)return;+      const h=document.createElement('div');h.className='cathead';h.textContent=`${o} · ${inOrg.length}`;el.appendChild(h);+      inOrg.forEach(c=>el.appendChild(boardRow(c)));+    });+  }+}+// A board row. Boards you've starred on the wiki show a ★ and pin to the top of+// their list (starred status comes from the wiki API; there's no toggle here).+function boardRow(c){+  const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;+  const star=c.starred?`<span class="star" title="starred on the wiki">★</span>`:'';+  d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}<span class="tag">board</span></div><div class="sub">${c.slug}</div>`;+  d.onclick=()=>openBoard(c.slug);+  return d;+}+function sortBoards(list){+  return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase()));+}+function renderPublic(boards,el){+  const wrap=document.createElement('div');wrap.className='pubsearch';+  wrap.innerHTML=`<div class="pubrow"><input id="pubq" placeholder="search public projects"><button id="pubgo" title="search">⏎</button></div><div id="pubresults"></div>`;+  el.appendChild(wrap);+  const pubq=document.getElementById('pubq');pubq.value=pubQuery;+  const renderPub=()=>{+    const pr=document.getElementById('pubresults');pr.innerHTML='';const pq=pubQuery.trim().toLowerCase();+    if(!pq)return;+    const m=boards.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq));+    if(!m.length){pr.innerHTML=`<div class="row" style="color:#8b93a3;cursor:default">no public projects match “${pubQuery.trim()}”</div>`;return;}+    sortBoards(m).forEach(c=>pr.appendChild(boardRow(c)));+  };+  const doPub=()=>{pubQuery=pubq.value;renderPub();};+  pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};+  document.getElementById('pubgo').onclick=doPub;+  renderPub();+}+async function openBoard(slug){+  boardSlug=slug; curDeps=[]; selected.clear();+  const title=(bySlug[slug]||{}).title||slug;+  document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;+  renderBoardOverview(slug);+  rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';+  try{ const r=await fetch('api/board/'+slug).then(r=>r.json()); curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }+  catch(e){ curDeps=[]; }+  renderDeps();+}+// The board overview (top of the right pane): name + author, a 3D view of the+// board (from a wiki GLB if present), and small KiCad schematic + PCB views.+function renderBoardOverview(slug){+  const c=bySlug[slug]||{slug};+  const view=document.getElementById('view');+  view.classList.remove('split');+  document.getElementById('component-view').innerHTML='';+  document.getElementById('board-overview').innerHTML=`+    <div class="bo-head"><h2 class="title">${c.title||slug}</h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div></div>+    <div class="bo-view bo-3d-wrap"><div class="bo-3d" id="bo3d"><div class="bo-na">loading 3D…</div></div><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button></div>+    <div class="bo-views">+      <div class="bo-view"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>Schematic</h3><select id="bo-sheet" class="bo-sheet" style="display:none"></select></div><div class="bo-viewbody" id="bo-sch"><div class="bo-na">rendering…</div></div></div>+      <div class="bo-view"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody" id="bo-pcb"><div class="bo-na">rendering…</div></div></div>+    </div>+    <div class="bo-section"><h3>Activity</h3><div class="bo-log" id="bo-log"><div class="bo-na" style="text-align:left;padding:8px 0">loading…</div></div></div>`;+  loadBoardMedia(slug);+}+function esc(s){return (s||'').replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[m]));}+function timeago(ts){+  if(!ts)return'';const s=Math.floor(Date.now()/1000)-ts;+  if(s<60)return'just now';const m=Math.floor(s/60);if(m<60)return m+'m ago';+  const h=Math.floor(m/60);if(h<24)return h+'h ago';const d=Math.floor(h/24);if(d<30)return d+'d ago';+  const mo=Math.floor(d/30);if(mo<12)return mo+'mo ago';return Math.floor(mo/12)+'y ago';+}+function fillBoardInfo(slug,log){+  if(boardSlug!==slug)return;+  const lg=document.getElementById('bo-log');+  if(lg)lg.innerHTML=(log&&log.length)?log.map(c=>`<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div><div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+}+async function loadBoardMedia(slug){+  // service-kicad renders in the background (a board GLB can take ~a minute) —+  // poll until it's done, keeping the "rendering…" placeholders meanwhile.+  let m={rendering:true};+  for(let i=0;i<60 && m.rendering;i++){+    try{ m=await fetch('api/board-media/'+slug).then(r=>r.json()); }catch(e){ m={}; break; }+    if(boardSlug!==slug)return; // navigated away+    if(m.rendering)await new Promise(r=>setTimeout(r,2000));+  }+  if(boardSlug!==slug)return;+  const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');+  if(m.is_kicad===false){+    const na='<div class="bo-na">board type not supported</div>';+    if(d3)d3.innerHTML=na;if(sch)sch.innerHTML=na;if(pcb)pcb.innerHTML=na;return;+  }+  if(d3)d3.innerHTML=m.has_3d?`<model-viewer src="board-glb/${slug}" camera-controls auto-rotate shadow-intensity="1"></model-viewer>`:`<div class="bo-na">board 3D file not available</div>`;+  const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');+  if(sch){+    if(sheets.length){+      if(sel){+        sel.innerHTML=sheets.map((n,i)=>`<option value="${i}">${n}</option>`).join('');+        sel.style.display=sheets.length>1?'':'none';+        sel.onchange=async()=>{ if(boardSlug!==slug)return; sch.innerHTML='<div class="bo-na">loading…</div>'; mountSvg(sch, await fetch(`board-svg/${slug}/sch/${sel.value}`).then(r=>r.text()), {light:true}); };+      }+      mountSvg(sch, await fetch(`board-svg/${slug}/sch/0`).then(r=>r.text()), {light:true});+    } else { if(sel)sel.style.display='none'; sch.innerHTML='<div class="bo-na">schematic view not available</div>'; }+  }+  if(boardSlug!==slug)return;+  if(pcb){ if(m.has_pcb){ mountSvg(pcb, await fetch('board-svg/'+slug+'/pcb').then(r=>r.text()), {dimFills:0.4}); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+}+// Inject an SVG on a dark background: drop the full-page paper rect (so the dark+// box shows through AND the content crops tight via getBBox), optionally invert+// for the PCB, then enable pan/zoom.+function mountSvg(box,svgText,opt){+  opt=opt||{};+  // parse as real SVG (innerHTML with the XML prolog doesn't expose the <g> tree)+  let svg;+  try{ svg=new DOMParser().parseFromString(svgText,'image/svg+xml').documentElement; }catch(e){}+  if(!svg||String(svg.nodeName).toLowerCase()!=='svg'){box.innerHTML='<div class="bo-na">view not available</div>';return;}+  box.innerHTML=''; box.appendChild(svg);+  box.style.background=opt.light?'#f4f3ee':'#0d0f14';+  svg.removeAttribute('width');svg.removeAttribute('height');+  svg.style.width='100%';svg.style.height='100%';svg.style.display='block';+  if(opt.invert)svg.style.filter='invert(1) hue-rotate(180deg)';+  // dim the copper fills so lower layers show through (see all 4 copper layers);+  // keep silkscreen/edge fully opaque so they stay readable+  if(opt.dimFills){+    const keep=new Set(['#f2eda1','#d0d2cd','#c83434']); // silk, edge, F.Cu → stay opaque+    svg.querySelectorAll('[style*="fill"]').forEach(el=>{+      const m=(el.getAttribute('style')||'').match(/fill:\s*(#[0-9a-fA-F]{6})/);+      if(!m)return;+      const c=m[1].toLowerCase();+      if(c==='#ffffff'){+        // drill holes: diameter < 0.5mm = via → gold; larger (TH pads, mounting holes) → background.+        // ([r] not "circle": the parsed SVG is namespaced, so type selectors don't match)+        const circles=el.querySelectorAll('[r]');+        if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; });+        else el.style.fill='#0d0f14';+      } else if(!keep.has(c)){ el.style.fillOpacity=opt.dimFills; } // dim inner/bottom copper+    });+  }+  // remove the full-page paper rect (crop to the content on the box background)+  {+    const vb=svg.viewBox.baseVal;+    if(vb)[...svg.querySelectorAll('rect')].forEach(r=>{const w=+r.getAttribute('width')||0,h=+r.getAttribute('height')||0;if(w>=vb.width*0.92&&h>=vb.height*0.92)r.remove();});+  }+  try{const bb=svg.getBBox();if(bb.width&&bb.height){const pad=Math.max(bb.width,bb.height)*0.03;svg.setAttribute('viewBox',`${bb.x-pad} ${bb.y-pad} ${bb.width+2*pad} ${bb.height+2*pad}`);}}catch(e){}+  panZoomSvg(svg);+}+function panZoomSvg(svg){+  const vb=svg.viewBox.baseVal;svg.style.cursor='grab';let drag=null;+  svg.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY};svg.setPointerCapture(e.pointerId);svg.style.cursor='grabbing';});+  svg.addEventListener('pointermove',e=>{if(!drag)return;const r=svg.getBoundingClientRect();vb.x-=(e.clientX-drag.x)*vb.width/r.width;vb.y-=(e.clientY-drag.y)*vb.height/r.height;drag={x:e.clientX,y:e.clientY};});+  const end=()=>{drag=null;svg.style.cursor='grab';};+  svg.addEventListener('pointerup',end);svg.addEventListener('pointercancel',end);+  svg.addEventListener('wheel',e=>{e.preventDefault();const r=svg.getBoundingClientRect();const px=(e.clientX-r.left)/r.width,py=(e.clientY-r.top)/r.height;const mx=vb.x+px*vb.width,my=vb.y+py*vb.height;const f=e.deltaY<0?0.85:1.18;vb.width*=f;vb.height*=f;vb.x=mx-px*vb.width;vb.y=my-py*vb.height;},{passive:false});+}+// Infer a component's type from its tags + brief + title (most parts carry no+// explicit category, so keywords in the brief are the signal).+const TYPE_RULES=[+  [/microcontroller|\bmcu\b|\bmpu\b|\bsoc\b/,'Microcontrollers'],+  [/regulator|\bldo\b|dc-?dc|buck|boost|power management|\bpmic\b/,'Power ICs'],+  [/mosfet|\bfet\b/,'MOSFETs'],+  [/transistor|\bbjt\b|\bnpn\b|\bpnp\b/,'Transistors'],+  [/\bled\b/,'LEDs'],+  [/diode|schottky|zener|\btvs\b|rectifier/,'Diodes'],+  [/mlcc|capacitor|\bcap\b|tantalum/,'Capacitors'],+  [/resistor|thick film|thin film/,'Resistors'],+  [/inductor|chip coil|\bcoil\b|ferrite|choke|\bbead\b/,'Inductors'],+  [/crystal|oscillator|resonator/,'Crystals & Oscillators'],+  [/connector|header|receptacle|socket|\bjack\b|\busb\b/,'Connectors'],+  [/switch|button|tactile/,'Switches'],+  [/\bfuse\b/,'Fuses'],+  [/amplifier|op-?amp|\bdriver\b|sensor|\badc\b|\bdac\b|transceiver|controller|\bgate\b|logic|\bic\b/,'ICs'],+];+const TYPE_ORDER=['Microcontrollers','ICs','Power ICs','MOSFETs','Transistors','Diodes','LEDs','Resistors','Capacitors','Inductors','Crystals & Oscillators','Connectors','Switches','Fuses','Other'];+function typeOf(c){+  const hay=(((c.tags||[]).join(' '))+' '+(c.brief||'')+' '+(c.title||'')+' '+(c.slug||'')).toLowerCase();+  for(const [re,label] of TYPE_RULES) if(re.test(hay)) return label;+  return 'Other';+}+function renderDeps(){+  document.getElementById('q').placeholder='filter components…';+  document.getElementById('toolbar').style.display='flex';+  const q=qval(),el=rowsEl();el.innerHTML='';+  if(!curDeps.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this board lists no component dependencies</div>';updateToolbar();return;}+  const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))+    .filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));+  const groups={};+  items.forEach(x=>{const t=typeOf(x.c);(groups[t]=groups[t]||[]).push(x);});+  const cats=Object.keys(groups).sort((a,b)=>{const ia=TYPE_ORDER.indexOf(a),ib=TYPE_ORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib)||a.localeCompare(b);});+  cats.forEach(cat=>{+    const h=document.createElement('div');h.className='cathead';h.textContent=`${cat} · ${groups[cat].length}`;el.appendChild(h);+    groups[cat].forEach(x=>{const on=bySlug[x.slug];el.appendChild(makeRow(x.slug,x.c.title||x.slug,on?x.slug:x.slug+' · not on wiki',openPart,null,true));});+  });+  updateToolbar();+}+function backToBoards(){+  boardSlug=null;curDeps=[];document.getElementById('q').value='';+  const view=document.getElementById('view');view.classList.remove('split');+  document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';+  document.getElementById('component-view').innerHTML='';+  render();+}+// Close the bottom-half component view (the ✕), back to just the board overview.+function closeComponent(){+  document.getElementById('view').classList.remove('split');+  document.getElementById('component-view').innerHTML='';+  document.querySelectorAll('.row.sel').forEach(r=>r.classList.remove('sel'));+}+// Maximize a schematic/PCB view to fill the webview page (not the OS screen);+// click the button again to restore.+function toggleFs(btn){+  const view=btn.closest('.bo-view');+  if(view)view.classList.toggle('maxed');+}+async function openPart(slug,el){+  document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));+  if(el)el.classList.add('sel');+  const view=document.getElementById('view');view.classList.add('split');+  const v=document.getElementById('component-view');+  v.innerHTML='<div id="hint" style="margin-top:6vh">loading '+slug+'…</div>';+  const p=await fetch('api/part/'+slug).then(r=>r.json());+  if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}+  const glb=p.glb?`<model-viewer src="glb/${slug}" camera-controls auto-rotate ar shadow-intensity="1"></model-viewer>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';+  let rows=(p.layers||[]).map(l=>`<tr>+      <td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>+      <td><span class="badge">${l.kicad}</span></td>+      <td><span class="badge">${l.altium}</span></td></tr>`).join('');+  v.innerHTML=`+    <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small></h2>+    <div class="cols">+      <div class="card"><h3>Schematic Symbol</h3><div class="body">${p.symbol_svg}<span class="hint2">drag · scroll to zoom</span></div></div>+      <div class="card"><h3>Footprint</h3>+        <div class="lfilter">${(p.layers||[]).map(l=>`<label class="chip"><input type="checkbox" checked data-key="${l.key}"><span class="sw" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}</label>`).join('')}</div>+        <div class="body" id="fpbody">${p.footprint_svg}<span class="hint2">drag · scroll to zoom</span></div></div>+      <div class="card"><h3>3D Model</h3>${glb}</div>+    </div>+    <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>+    <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>+    <tbody>${rows}</tbody></table>`;+  v.querySelectorAll('.card .body').forEach(makePannable);+  // footprint layer filter — toggle visibility of each layer's SVG elements+  v.querySelectorAll('.lfilter input').forEach(cb=>{+    cb.onchange=()=>{+      v.querySelectorAll('#fpbody svg .ly-'+cb.dataset.key).forEach(el=>{el.style.display=cb.checked?'':'none';});+    };+  });+  setupHighlight(v);+}++// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and+// name/description in a tooltip. Pins/pads carry data-num + data-name.+function setupHighlight(v){+  let tip=document.getElementById('tip');+  if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}+  const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));+  v.querySelectorAll('.card .body').forEach(box=>{+    box.addEventListener('pointermove',e=>{+      const hit=e.target.closest('[data-num]');+      clear(box);+      if(hit){+        hit.classList.add('pinhi');+        const num=hit.dataset.num, nm=hit.dataset.name;+        const kind=hit.closest('#fpbody')?'Pad':'Pin';+        tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;+        tip.style.display='block';+        tip.style.left=(e.clientX+14)+'px';+        tip.style.top=(e.clientY+14)+'px';+      } else { tip.style.display='none'; }+    });+    box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});+  });+}++// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag+// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.+function makePannable(box){+  const svg=box.querySelector('svg');+  if(!svg) return;+  let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;+  const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};+  box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};+  box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};+  box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};+  box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};+  box.ondblclick=()=>{tx=ty=0;scale=1;apply();};+}+// ---- selection controls + add-to-library / add-project modal ----+document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();};+function previewTable(plan){+  return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${+    plan.map(p=>`<tr><td>${p.title}</td><td>${p.type}</td><td><span class="lib">${p.symLib}.kicad_sym</span></td><td><span class="lib">${p.fpLib}.pretty</span></td></tr>`).join('')}</tbody></table>`;+}+function buildPlan(slugs,projectLib,projName){+  return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);+    return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:'adom_lbr'};});+}+let modalGo=null;+const showModal=()=>document.getElementById('modal').style.display='flex';+const hideModal=()=>{document.getElementById('modal').style.display='none';modalGo=null;const b=document.getElementById('applybar');if(b)b.remove();};+document.getElementById('modal-cancel').onclick=hideModal;+document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();};+function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});}+// recommended destination parents for delivered projects (editable in the box)+const DEST_FOLDERS=['/home/barrett-land/Documents/KiCAD/Projects','/home/barrett-land/Documents/Adom/Molecules/Other Molecules','/home/barrett-land/Documents/Adom/Electrical','/home/barrett-land/Downloads'];+function openAddLib(){+  const slugs=[...selected]; if(!slugs.length)return;+  const plan=buildPlan(slugs,false);+  document.getElementById('modal-dest').innerHTML='';+  document.getElementById('modal-title').textContent='Add to Library';+  document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → your existing KiCad libraries`;+  document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;+  const go=document.getElementById('modal-go');+  selOpt('eda','eda',v=>{document.getElementById('modal-preview').innerHTML=v==='altium'?'<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>':previewTable(plan);go.disabled=v==='altium';});+  document.getElementById('modal-preview').innerHTML=previewTable(plan);+  go.disabled=false;go.textContent='Yes, add it';+  modalGo=()=>doApply(plan,'kicad',false);+  showModal();+}+function openAddProj(){+  const slugs=curDeps.slice();+  const projName=((bySlug[boardSlug]||{}).title||boardSlug).replace(/[^A-Za-z0-9_-]+/g,'_');+  let mode='personal';+  document.getElementById('modal-title').textContent='Add Project to my Computer';+  document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;+  document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;+  // editable destination + recommended-folder presets+  document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${DEST_FOLDERS.map(f=>`<option value="${f}">${f.replace('/home/barrett-land','~')}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;+  const destpath=document.getElementById('destpath'), destsel=document.getElementById('destsel');+  destpath.value=DEST_FOLDERS[0]+'/'+boardSlug;+  destsel.onchange=()=>{destpath.value=destsel.value+'/'+boardSlug;};+  const draw=()=>{+    const plan=buildPlan(slugs,mode==='project',projName);+    document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> symbol + footprint library is created for this project — your personal libraries stay untouched.':'Symbols merge into your existing per-type libraries; footprints go to <b>adom_lbr.pretty</b>. The schematic library references get registered so nothing shows as broken.'}</div>`+previewTable(plan);+    modalGo=()=>doApply(plan,'kicad',mode==='project',boardSlug,document.getElementById('destpath').value);+  };+  selOpt('mode','mode',v=>{mode=v;draw();});+  document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project';+  draw();showModal();+}+async function doApply(plan,eda,project,board,dest){+  const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';+  const opts=document.getElementById('modal-opts');+  const start=await fetch('api/apply',{method:'POST',headers:{'Content-Type':'application/json'},+    body:JSON.stringify({target:eda,project:!!project,board:board||null,dest:dest||'',items:plan.map(p=>({slug:p.slug,symLib:p.symLib,fpLib:p.fpLib}))})}).then(r=>r.json()).catch(()=>({status:'error',error:'request failed'}));+  if(start.status!=='started'){alert(start.error||'failed');go.textContent='Yes, add it';go.disabled=false;return;}+  // live status bar above the preview+  let bar=document.getElementById('applybar');+  if(!bar){bar=document.createElement('div');bar.id='applybar';bar.className='note';opts.parentNode.insertBefore(bar,opts.nextSibling);}+  const poll=setInterval(async()=>{+    let p; try{p=await fetch('api/apply-progress').then(r=>r.json());}catch(e){return;}+    const pct=p.total?` · ${p.current}/${p.total}`:'';+    go.textContent=(p.done?'✓ ':'')+(p.stage||'Working…').replace(/…$/,'').slice(0,22)+'…';+    bar.innerHTML=`<b>${p.stage||'Working…'}</b>${pct}`;+    if(p.done){+      clearInterval(poll);+      const r=p.result||{}, pf=(r.project&&r.project.files||[]);+      go.textContent=`✓ Added ${r.count||0}`;+      const folder=(r.project&&r.project.folder||'').replace('/home/barrett-land','~');+      bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`++        (pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')++        ((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');+      setTimeout(hideModal,pf.length||(r.errors&&r.errors.length)?3600:1600);+    }+  },350);+}+document.getElementById('btn-addlib').onclick=openAddLib;+document.getElementById('btn-addproj').onclick=openAddProj;++document.getElementById('q').oninput=render;+document.getElementById('refresh').onclick=()=>loadList(true);+loadList();+</script>+</body>+</html>
src/selfcontain.pyadded+140
@@ -0,0 +1,140 @@+#!/usr/bin/env python3+"""Make a delivered KiCad project self-contained.++Extracts the symbols embedded in the schematics' (lib_symbols ...) and the+footprints embedded in the .kicad_pcb into a project library named <libnick>,+re-points every lib_id / footprint reference to that library, and writes+project-local sym-lib-table / fp-lib-table (using ${KIPRJMOD}). Reads/writes the+files in <work>; prints JSON {root:[...], pretty:[...], symbols, footprints}.++usage: selfcontain.py <work_dir> <libnick> <file...>+"""+import sys, os, re, json++work, nick = sys.argv[1], sys.argv[2]+names = sys.argv[3:]+++def balanced(s, start):+    d = 0+    for i in range(start, len(s)):+        if s[i] == '(':+            d += 1+        elif s[i] == ')':+            d -= 1+            if d == 0:+                return i + 1+    return len(s)+++def top_symbols(section):+    """Top-level (symbol "NAME" ...) blocks inside a section (skips nested)."""+    out, i = [], 0+    while True:+        p = section.find('(symbol "', i)+        if p < 0:+            break+        ns = p + len('(symbol "')+        ne = section.find('"', ns)+        e = balanced(section, p)+        out.append((section[ns:ne], section[p:e]))+        i = e+    return out+++def repoint(content, needle, nick):+    """Rewrite the library part of `needle "LIB:NAME"` occurrences to `nick`."""+    out, i = [], 0+    n = len(needle)+    while True:+        p = content.find(needle, i)+        if p < 0:+            out.append(content[i:])+            break+        vs = p + n+        ve = content.find('"', vs)+        val = content[vs:ve]+        out.append(content[i:vs])+        out.append(nick + ':' + val.split(':', 1)[1] if ':' in val else val)+        i = ve+    return ''.join(out)+++schs = [n for n in names if n.endswith('.kicad_sch')]+pcbs = [n for n in names if n.endswith('.kicad_pcb')]++# ---- 1. collect symbols from every schematic's lib_symbols ----+symbols = {}+for f in schs:+    c = open(os.path.join(work, f), encoding='utf-8').read()+    ls = c.find('(lib_symbols')+    if ls < 0:+        continue+    section = c[ls:balanced(c, ls)]+    for name, block in top_symbols(section):+        bare = name.split(':', 1)[1] if ':' in name else name+        if bare not in symbols:+            symbols[bare] = block.replace('"' + name + '"', '"' + bare + '"', 1)++lib = '(kicad_symbol_lib\n\t(version 20231120)\n\t(generator "adom-lbr")\n'+for b in symbols.values():+    lib += '\t' + b + '\n'+lib += ')\n'+open(os.path.join(work, nick + '.kicad_sym'), 'w', encoding='utf-8').write(lib)++# ---- 2. re-point the schematics ----+for f in schs:+    path = os.path.join(work, f)+    c = open(path, encoding='utf-8').read()+    c = repoint(c, '(lib_id "', nick)+    c = repoint(c, '(symbol "', nick)                 # lib_symbols keys (the colon'd ones)+    c = repoint(c, '(property "Footprint" "', nick)+    open(path, 'w', encoding='utf-8').write(c)++# ---- 3. extract footprints from the PCB into <nick>.pretty ----+pretty = os.path.join(work, nick + '.pretty')+os.makedirs(pretty, exist_ok=True)+pfiles = []+UUID = re.compile(r'\s*\(uuid "[^"]*"\)')+NET = re.compile(r'\s*\(net \d+ "[^"]*"\)')+ATPLACE = re.compile(r'\(at [-\d.eE ]+\)')+HEADER = '\n\t(version 20240108)\n\t(generator "adom-lbr")\n\t(generator_version "9.0")'+for f in pcbs:+    path = os.path.join(work, f)+    c = open(path, encoding='utf-8').read()+    i, seen = 0, set()+    while True:+        p = c.find('(footprint "', i)+        if p < 0:+            break+        ns = p + len('(footprint "')+        ne = c.find('"', ns)+        name = c[ns:ne]+        bare = name.split(':', 1)[1] if ':' in name else name+        e = balanced(c, p)+        if bare not in seen:+            seen.add(bare)+            blk = c[p:e].replace('"' + name + '"', '"' + bare + '"', 1)+            blk = ATPLACE.sub('', blk, count=1)   # drop the footprint's board placement (first (at ...))+            blk = UUID.sub('', blk)               # drop uuids (instance-specific)+            blk = NET.sub('', blk)                # drop pad nets (instance-specific)+            # a *library* footprint needs a header right after the name+            blk = blk.replace('(footprint "' + bare + '"', '(footprint "' + bare + '"' + HEADER, 1)+            open(os.path.join(pretty, bare + '.kicad_mod'), 'w', encoding='utf-8').write(blk + '\n')+            pfiles.append(os.path.join(pretty, bare + '.kicad_mod'))+        i = e+    c = repoint(c, '(footprint "', nick)+    open(path, 'w', encoding='utf-8').write(c)++# ---- 4. project-local lib tables ----+open(os.path.join(work, 'sym-lib-table'), 'w', encoding='utf-8').write(+    '(sym_lib_table\n  (version 7)\n  (lib (name "%s")(type "KiCad")(uri "${KIPRJMOD}/%s.kicad_sym")(options "")(descr "adom-lbr self-contained project symbols"))\n)\n' % (nick, nick))+open(os.path.join(work, 'fp-lib-table'), 'w', encoding='utf-8').write(+    '(fp_lib_table\n  (version 7)\n  (lib (name "%s")(type "KiCad")(uri "${KIPRJMOD}/%s.pretty")(options "")(descr "adom-lbr self-contained project footprints"))\n)\n' % (nick, nick))++root = [os.path.join(work, f) for f in names] + [+    os.path.join(work, nick + '.kicad_sym'),+    os.path.join(work, 'sym-lib-table'),+    os.path.join(work, 'fp-lib-table'),+]+print(json.dumps({'root': root, 'pretty': pfiles, 'symbols': len(symbols), 'footprints': len(pfiles)}))