app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← Commit history
adom-project-manager: board browser + viewer (3D/schematic/PCB), Add-to-Library, self-contained Add-Project, service-kicad rendering
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('&', "&").replace('\'', "'").replace('"', """).replace('<', "<")+}++fn empty_svg(msg: &str) -> String {+ format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>")+}++// ---------------------------------------------------------------------------+// Add to Library — convert selected components → the user's KiCad libraries,+// delivered to their machine via the Adom Desktop bridge.+// ---------------------------------------------------------------------------++// TODO: detect from the bridge rather than hard-coding this user's home.+const KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";+const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";+const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects";+const SCRATCH: &str = "/tmp/adom-lbr-apply";++const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");++/// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,+/// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the+/// project self-contained: extract the embedded symbols/footprints into a project+/// library, re-point every `lib_id`/footprint reference to it, and write the+/// project-local sym/fp-lib-tables. Then deliver everything to `dest`.+fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {+ let dest = if dest.trim().is_empty() { format!("{KICAD_PROJ_DIR}/{board}") } else { dest.trim().to_string() };+ let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));+ let _ = std::fs::remove_dir_all(&work);+ let _ = std::fs::create_dir_all(&work);+ let mut got = vec![];+ let listing = match curl(&format!("{WIKI}/api/v1/pages/{board}/files")) {+ Some(b) => b,+ None => return (dest, got),+ };+ let v: Value = match serde_json::from_slice(&listing) {+ Ok(v) => v,+ Err(_) => return (dest, got),+ };+ let files = v.get("files").and_then(|f| f.as_array()).cloned().unwrap_or_default();+ let mut names = vec![];+ for f in &files {+ let name = f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).unwrap_or("");+ if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {+ if let Some(bytes) = wiki_bytes(board, name) {+ let base = name.rsplit('/').next().unwrap_or(name).to_string();+ if std::fs::write(format!("{work}/{base}"), bytes).is_ok() {+ names.push(base);+ }+ }+ }+ }+ if names.is_empty() {+ return (dest, got);+ }+ ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));++ if project_only {+ // self-contain: run the Python helper, which rewrites files in `work`,+ // creates <board>.kicad_sym / .pretty / lib-tables, and reports the split.+ let libnick = board.replace(['/', ' '], "_");+ let script = format!("{work}/_selfcontain.py");+ let _ = std::fs::write(&script, PYTHON_SELFCONTAIN);+ let out = Command::new("python3")+ .arg(&script)+ .arg(&work)+ .arg(&libnick)+ .args(&names)+ .output();+ if let Ok(o) = out {+ if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {+ let root: Vec<String> = v.get("root").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ let pretty: Vec<String> = v.get("pretty").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ if !root.is_empty() {+ ad_send(&root, &dest);+ }+ if !pretty.is_empty() {+ let pdir = format!("{dest}/{libnick}.pretty");+ ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));+ ad_send(&pretty, &pdir);+ }+ got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();+ return (dest, got);+ }+ }+ }++ // personal mode (or self-contain failed): deliver the files as fetched.+ let locals: Vec<String> = names.iter().map(|n| format!("{work}/{n}")).collect();+ ad_send(&locals, &dest);+ (dest, names)+}++/// Single-quote a string for safe interpolation into an `sh -c` command run on+/// the user's machine. Everything is inert inside single quotes except `'`,+/// which becomes the standard `'\''` splice.+fn sh_quote(s: &str) -> String {+ format!("'{}'", s.replace('\'', r"'\''"))+}++/// Library nicknames become file/dir names on the user's machine and are+/// interpolated into shell commands — restrict to a boring identifier charset.+fn valid_lib(s: &str) -> bool {+ !s.is_empty()+ && s.len() <= 64+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))+}++/// Wiki slugs (`owner/name`): same conservative charset plus `/`.+fn valid_slug(s: &str) -> bool {+ !s.is_empty()+ && s.len() <= 128+ && !s.contains("..")+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))+}++/// Pull a file off the user's machine to the container; None if it doesn't+/// exist OR the pull failed — callers that overwrite must disambiguate via+/// `ad_file_exists` before treating None as "absent".+fn ad_pull(remote: &str) -> Option<Vec<u8>> {+ let _ = std::fs::create_dir_all(SCRATCH);+ let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();+ Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;+ let base = remote.rsplit('/').next()?;+ std::fs::read(format!("{SCRATCH}/{base}")).ok()+}++/// Send container files to a directory on the user's machine.+fn ad_send(files: &[String], dest: &str) -> bool {+ let args = json!({"filePaths": files, "dest": dest}).to_string();+ Command::new("adom-desktop")+ .args(["send_files", &args])+ .output()+ .map(|o| o.status.success())+ .unwrap_or(false)+}++fn ad_shell(cmd: &str) {+ let args = json!({"command": cmd}).to_string();+ let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();+}++/// Run a shell command on the user's machine and return its captured reply+/// (whole adom-desktop stdout); None if the bridge call itself failed.+fn ad_shell_out(cmd: &str) -> Option<String> {+ let args = json!({"command": cmd}).to_string();+ let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;+ if !out.status.success() {+ return None;+ }+ Some(String::from_utf8_lossy(&out.stdout).to_string())+}++/// 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=>({'&':'&','<':'<','>':'>'}[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)}))