← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.5.0"+version = "0.3.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.5.0+0.3.0
crates/altium-codec/src/altium_decode.rs+8
@@ -429,6 +429,11 @@ fn decode_pcblib_footprint(bytes: &[u8]) -> Result<Footprint> {     Ok(Footprint { name: fp_name, pads, graphics }) } +/// Altium layer byte → neutral role. Counterpart of `pcblib::layer_enc`.+/// NOTE: the house convention encodes BOTH Silk and Fab onto Mech13 (69), so+/// 69 canonically decodes to Fab — silk art merged there on encode stays Fab+/// after a round trip (deliberate; see the doc on `layer_enc`). Vendor libs+/// (UL/SamacSys) put silk on TopOverlay (33), which decodes to Silk correctly. fn altium_layer(byte: u8) -> FpLayer {     match byte {         33 => FpLayer::Silk,@@ -467,6 +472,9 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<Pad> {         drill_mm: hole,         rotation,         shape: shape.to_string(),+        // The plated flag byte in the Altium pad record isn't calibrated yet —+        // unknown, so leave None (treated as plated downstream).+        plated: None,     }) } 
crates/altium-codec/src/intlib.rs+7−1
@@ -134,8 +134,14 @@ pub fn extract_intlib_libs(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {     let inflate = |comp: &mut cfb::CompoundFile<Cursor<Vec<u8>>>, path: &str| -> Result<Vec<u8>> {         let mut raw = Vec::new();         comp.open_stream(path)?.read_to_end(&mut raw)?;+        // Payload is a 0x02 marker byte + zlib stream — verify, don't assume.+        match raw.first() {+            None => anyhow::bail!("{path}: empty embedded stream"),+            Some(0x02) => {}+            Some(b) => anyhow::bail!("{path}: unexpected marker byte {b:#04x} (expected 0x02)"),+        }         let mut out = Vec::new();-        ZlibDecoder::new(&raw[1..]).read_to_end(&mut out)?; // skip 0x02 marker+        ZlibDecoder::new(&raw[1..]).read_to_end(&mut out).with_context(|| format!("{path}: inflate"))?;         Ok(out)     };     let sch = inflate(&mut comp, "/SchLib/0.schlib")?;
crates/altium-codec/src/kicad.rs+48−142
@@ -13,153 +13,51 @@ use anyhow::{Context, Result};  const MM_PER_MIL: f64 = 0.0254; -// ----------------------------------------------------------------------------// Minimal s-expression parser-// ---------------------------------------------------------------------------+// The s-expression parser lives in crate::sexpr (shared, KiCad-parser-inspired:+// line-tracked errors, strict balance checking, version gate). Re-exported here+// for backward compatibility with existing use sites.+pub use crate::sexpr::{parse_root, Sx}; -#[derive(Debug)]-enum Sx {-    Atom(String),-    List(Vec<Sx>),-}--impl Sx {-    fn head(&self) -> Option<&str> {-        match self {-            Sx::List(v) => match v.first() {-                Some(Sx::Atom(a)) => Some(a.as_str()),-                _ => None,-            },-            _ => None,-        }-    }-    fn items(&self) -> &[Sx] {-        match self {-            Sx::List(v) => v,-            _ => &[],-        }-    }-    /// First child list whose head == name.-    fn child(&self, name: &str) -> Option<&Sx> {-        self.items().iter().find(|s| s.head() == Some(name))-    }-    /// Atom string at position i (after the head), if it's an atom.-    fn atom_at(&self, i: usize) -> Option<&str> {-        match self.items().get(i) {-            Some(Sx::Atom(a)) => Some(a.as_str()),-            _ => None,-        }-    }-    /// Numeric atoms in this list (skipping the head atom).-    fn nums(&self) -> Vec<f64> {-        self.items()-            .iter()-            .skip(1)-            .filter_map(|s| match s {-                Sx::Atom(a) => a.parse::<f64>().ok(),-                _ => None,-            })-            .collect()-    }-    /// Walk the whole tree, collecting every list whose head == name.-    fn collect<'a>(&'a self, name: &str, out: &mut Vec<&'a Sx>) {-        if self.head() == Some(name) {-            out.push(self);-        }-        for s in self.items() {-            if let Sx::List(_) = s {-                s.collect(name, out);-            }-        }-    }-}--fn tokenize(s: &str) -> Vec<String> {-    let mut toks = Vec::new();-    let mut it = s.chars().peekable();-    while let Some(&c) = it.peek() {-        if c.is_whitespace() {-            it.next();-        } else if c == '(' || c == ')' {-            toks.push(c.to_string());-            it.next();-        } else if c == '"' {-            it.next();-            let mut buf = String::from("\u{0}"); // sentinel marks "was a string"-            while let Some(&c) = it.peek() {-                if c == '"' {-                    it.next();-                    break;-                } else if c == '\\' {-                    it.next();-                    if let Some(&e) = it.peek() {-                        buf.push(e);-                        it.next();-                    }-                } else {-                    buf.push(c);-                    it.next();-                }-            }-            toks.push(buf);-        } else {-            let mut buf = String::new();-            while let Some(&c) = it.peek() {-                if c.is_whitespace() || c == '(' || c == ')' {-                    break;-                }-                buf.push(c);-                it.next();-            }-            toks.push(buf);-        }-    }-    toks-}--fn parse(toks: &[String], pos: &mut usize) -> Sx {-    // expects toks[*pos] == "("-    *pos += 1; // consume "("-    let mut list = Vec::new();-    while *pos < toks.len() {-        let t = &toks[*pos];-        if t == "(" {-            list.push(parse(toks, pos));-        } else if t == ")" {-            *pos += 1;-            break;-        } else {-            // strip the string sentinel if present-            let a = t.strip_prefix('\u{0}').unwrap_or(t).to_string();-            list.push(Sx::Atom(a));-            *pos += 1;-        }-    }-    Sx::List(list)-}--fn parse_root(text: &str) -> Result<Sx> {-    let toks = tokenize(text);-    let start = toks.iter().position(|t| t == "(").context("no s-expr")?;-    let mut pos = start;-    Ok(parse(&toks, &mut pos))-}  // --------------------------------------------------------------------------- // Symbol import // --------------------------------------------------------------------------- -fn import_symbol(text: &str) -> Result<Symbol> {+fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {     let root = parse_root(text)?;-    let sym = root-        .child("symbol")-        .context("no (symbol ...) in kicad_sym")?;+    // The file's declared format version drives version-scoped semantics+    // (KiCad parser's m_requiredVersion gate).+    let doc_version = root.doc_version();+    // Top-level symbols only — a .kicad_sym is a LIBRARY and may hold many.+    // Everything below must be scoped to the selected one, or a multi-symbol+    // lib bleeds other symbols' properties/graphics into this part.+    let syms: Vec<&Sx> = root+        .items()+        .iter()+        .filter(|s| s.head() == Some("symbol"))+        .collect();+    let sym: &Sx = match syms.len() {+        0 => anyhow::bail!("no (symbol ...) in kicad_sym"),+        1 => syms[0],+        _ => syms+            .iter()+            .find(|s| s.atom_at(1).is_some_and(|n| n.eq_ignore_ascii_case(wanted)))+            .copied()+            .with_context(|| {+                let names: Vec<_> = syms.iter().filter_map(|s| s.atom_at(1)).collect();+                format!(+                    "library holds {} symbols and none is named {wanted:?} — available: {}",+                    syms.len(),+                    names.join(", ")+                )+            })?,+    };     let name = sym.atom_at(1).unwrap_or("SYM").to_string(); -    // designator prefix from the Reference property+    // designator prefix from the Reference property — of THIS symbol only     let mut prefix = "U".to_string();     let mut props = Vec::new();-    root.collect("property", &mut props);+    sym.collect("property", &mut props);     for p in &props {         if p.atom_at(1) == Some("Reference") {             if let Some(r) = p.atom_at(2) {@@ -184,11 +82,13 @@ fn import_symbol(text: &str) -> Result<Symbol> {             .and_then(|n| n.atom_at(1))             .unwrap_or("?")             .to_string();+        // "~" means "unnamed" only under the legacy (< 20250318) format —+        // gate on the file's declared version, exactly like KiCad's parser.         let pname = p             .child("name")             .and_then(|n| n.atom_at(1))-            .unwrap_or("~")-            .to_string();+            .map(|s| crate::sexpr::resolve_tilde(s, doc_version))+            .unwrap_or_default();         // KiCad pin length is in mm → convert to mils (the model's unit).         let len_mil = p             .child("length")@@ -221,7 +121,7 @@ fn import_symbol(text: &str) -> Result<Symbol> {         name,         pins,         body: None,-        graphics: import_symbol_graphics(&root),+        graphics: import_symbol_graphics(sym),         designator_prefix: Some(prefix),         footprint: None,         parameters: Vec::new(),@@ -385,9 +285,11 @@ fn import_footprint(text: &str) -> Result<Footprint> {         let at = p.child("at").map(|a| a.nums()).unwrap_or_default();         let size = p.child("size").map(|a| a.nums()).unwrap_or_default();         let rot = at.get(2).copied().unwrap_or(0.0);+        // (drill d) or (drill oval w h) — take the larger axis of a slot so+        // the hole isn't undersized (full slot support is a future field).         let drill = p             .child("drill")-            .and_then(|d| d.nums().first().copied())+            .map(|d| d.nums().into_iter().fold(0.0_f64, f64::max))             .unwrap_or(0.0);         let kshape = match shape.as_str() {             "circle" => "circle",@@ -395,15 +297,19 @@ fn import_footprint(text: &str) -> Result<Footprint> {             "roundrect" => "roundrect",             _ => "rect",         };+        // thru_hole AND np_thru_hole both have a real hole; dropping the NPTH+        // drill would silently turn a mounting hole into an SMD copper pad.+        let is_th = ptype.ends_with("thru_hole");         pads.push(Pad {             number,             x_mm: at.first().copied().unwrap_or(0.0),             y_mm: at.get(1).copied().unwrap_or(0.0),             w_mm: size.first().copied().unwrap_or(0.5),             h_mm: size.get(1).copied().unwrap_or(0.5),-            drill_mm: if ptype == "thru_hole" { drill } else { 0.0 },+            drill_mm: if is_th { drill } else { 0.0 },             rotation: rot,             shape: kshape.to_string(),+            plated: if is_th { Some(ptype != "np_thru_hole") } else { None },         });     } @@ -505,7 +411,7 @@ fn import_footprint_graphics(root: &Sx) -> Vec<FpGraphic> { /// link is identity-mapped (pin number == pad number) where they coincide, and /// the symbol is given the footprint name so the Altium writer links them. pub fn import_kicad(sym_text: &str, mod_text: &str, mpn: &str) -> Result<AdomLbrPart> {-    let mut symbol = import_symbol(sym_text)?;+    let mut symbol = import_symbol(sym_text, mpn)?;     let footprint = import_footprint(mod_text)?;     symbol.footprint = Some(footprint.name.clone());     // Center the symbol on the origin (KiCad sources are often drawn off-origin).
crates/altium-codec/src/kicad_export.rs+144−18
@@ -7,6 +7,92 @@ use crate::model::{Footprint, FpGraphic, FpLayer, Graphic, Symbol};  const MM_PER_MIL: f64 = 0.0254; +/// Selectable KiCad output format tier. Every KiCad reads all OLDER formats,+/// so only three tiers are meaningful (7 and 9 are redundant — their readers+/// accept the previous tier's files):+///   V6  — oldest stable s-expr grammar; for third-party tools stuck there+///         (Fusion 360's KiCad importer, kiutils-era parsers).+///   V8  — the "normalized bools" cleanup; the interop sweet spot, readable by+///         KiCad 8/9/10 and most modern third-party parsers. THE DEFAULT —+///         barrett's desktop runs KiCad 9.0.9, which REJECTS v10-format files+///         ("Unable to load library", verified 2026-07-06), and KiCad refuses+///         any file newer than itself.+///   V10 — current native format; opt in via --kicad-version 10.+/// Version values are what each KiCad release itself writes. Sources of truth+/// in the KiCad repo (branch `10.0`):+///   symbol lib → eeschema/sch_file_versions.h  SEXPR_SYMBOL_LIB_FILE_VERSION+///   footprint  → pcbnew/pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h  SEXPR_BOARD_FILE_VERSION+/// ≥20250318 semantics: a bare "~" is a literal tilde, never "empty" — always+/// write real empty strings (valid in every tier).+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]+pub enum KicadVersion {+    V6,+    #[default]+    V8,+    V10,+}++impl KicadVersion {+    /// Parse a user-facing selector ("6" | "8" | "10").+    pub fn parse(s: &str) -> Option<Self> {+        match s.trim() {+            "6" | "v6" => Some(Self::V6),+            "8" | "v8" => Some(Self::V8),+            "10" | "v10" => Some(Self::V10),+            _ => None,+        }+    }+    pub fn sym_version(self) -> u32 {+        match self {+            Self::V6 => 20211014,+            Self::V8 => 20231120,+            Self::V10 => 20251024,+        }+    }+    pub fn fp_version(self) -> u32 {+        match self {+            Self::V6 => 20211014,+            Self::V8 => 20240108,+            Self::V10 => 20260206,+        }+    }+    /// `generator_version` token value; V6 predates the token entirely.+    pub fn generator_version(self) -> Option<&'static str> {+        match self {+            Self::V6 => None,+            Self::V8 => Some("8.0"),+            Self::V10 => Some("10.0"),+        }+    }+    /// V6 wrote bare boolean flags (`hide`); V8+ normalized to `(hide yes)`.+    fn hide_flag(self) -> &'static str {+        match self {+            Self::V6 => " hide",+            _ => " (hide yes)",+        }+    }+    /// `exclude_from_sim` didn't exist in the V6 grammar — real KiCad 6+    /// errors on unknown tokens, so omit it there.+    fn writes_exclude_from_sim(self) -> bool {+        !matches!(self, Self::V6)+    }+    /// Library header line(s) shared by the symbol emitters.+    fn sym_header(self, generator: &str) -> String {+        match self.generator_version() {+            Some(gv) => format!(+                "(kicad_symbol_lib\n\t(version {})\n\t(generator \"{generator}\")\n\t(generator_version \"{gv}\")\n",+                self.sym_version()+            ),+            None => format!("(kicad_symbol_lib\n\t(version {})\n\t(generator \"{generator}\")\n", self.sym_version()),+        }+    }+}++/// Convenience constants for callers that just want "current native".+pub const KICAD_SYM_VERSION: u32 = 20251024;+pub const KICAD_FP_VERSION: u32 = 20260206;+pub const KICAD_GENERATOR_VERSION: &str = "10.0";+ /// Format a float compactly (KiCad tolerates plain decimals; trim trailing 0s). fn n(v: f64) -> String {     let s = format!("{v:.6}");@@ -18,8 +104,14 @@ fn n(v: f64) -> String { // Symbol → .kicad_sym // --------------------------------------------------------------------------- -/// Export a symbol as a complete `.kicad_sym` library (one symbol).+/// Export a symbol as a complete `.kicad_sym` library (one symbol), in the+/// default format tier (V8 — loads in KiCad 8/9/10; see [`KicadVersion`]). pub fn export_kicad_symbol(sym: &Symbol) -> String {+    export_kicad_symbol_for(sym, KicadVersion::default())+}++/// Export a symbol targeting a specific KiCad format tier.+pub fn export_kicad_symbol_for(sym: &Symbol, ver: KicadVersion) -> String {     let m = |mil: f64| n(mil * MM_PER_MIL);     let name = &sym.name;     let reference = sym.designator_prefix.clone().unwrap_or_else(|| "U".into());@@ -36,7 +128,7 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String {     let (top, bottom, cx) = symbol_bbox_mm(sym);      let mut s = String::new();-    s.push_str("(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"adom-lbr\")\n");+    s.push_str(&ver.sym_header("adom-lbr"));     s.push_str(&format!("\t(symbol \"{name}\"\n"));     // Preserve the source's pin-number/name visibility; when unknown (e.g. built     // from Altium or scratch), default passives (≤2 pins) to hidden like KiCad's@@ -44,20 +136,24 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String {     let passive = sym.pins.len() <= 2;     let hide_num = sym.pin_numbers_hidden.unwrap_or(passive);     let hide_name = sym.pin_names_hidden.unwrap_or(passive);-    s.push_str(if hide_num {-        "\t\t(pin_numbers (hide yes))\n"+    let hide = ver.hide_flag();+    s.push_str(&if hide_num {+        format!("\t\t(pin_numbers{hide})\n")     } else {-        "\t\t(pin_numbers)\n"+        "\t\t(pin_numbers)\n".to_string()     });-    s.push_str(if hide_name {-        "\t\t(pin_names (offset 0.254) (hide yes))\n"+    s.push_str(&if hide_name {+        format!("\t\t(pin_names (offset 0.254){hide})\n")     } else {-        "\t\t(pin_names (offset 0.254))\n"+        "\t\t(pin_names (offset 0.254))\n".to_string()     });-    s.push_str("\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n");+    if ver.writes_exclude_from_sim() {+        s.push_str("\t\t(exclude_from_sim no)\n");+    }+    s.push_str("\t\t(in_bom yes)\n\t\t(on_board yes)\n");     // properties (Reference above the symbol, Value below; metadata hidden at origin)     let prop = |k: &str, v: &str, x: f64, y: f64, hidden: bool| {-        let h = if hidden { " (hide yes)" } else { "" };+        let h = if hidden { hide } else { "" };         format!(             "\t\t(property \"{k}\" \"{}\"\n\t\t\t(at {} {} 0)\n\t\t\t(effects (font (size 1.27 1.27)){h})\n\t\t)\n",             esc(v), n(x), n(y)@@ -66,7 +162,8 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String {     s.push_str(&prop("Reference", &reference, cx, top + 1.27, false));     s.push_str(&prop("Value", &value, cx, bottom - 1.27, false));     s.push_str(&prop("Footprint", &footprint, 0.0, 0.0, true));-    s.push_str(&prop("Datasheet", "~", 0.0, 0.0, true));+    // "" not "~": since format 20250318 a tilde is a literal tilde.+    s.push_str(&prop("Datasheet", "", 0.0, 0.0, true));     // any extra parameters become hidden properties (at origin)     for p in &sym.parameters {         if !p.name.eq_ignore_ascii_case("Value") {@@ -87,12 +184,15 @@ pub fn export_kicad_symbol(sym: &Symbol) -> String {     s.push_str(&format!("\t\t(symbol \"{name}_1_1\"\n"));     for pin in &sym.pins {         let len = pin.length.unwrap_or(200) as f64;+        // legacy sources used "~" for an unnamed pin; in KiCad 10 that's a+        // literal tilde, so write the real empty string.+        let pname = if pin.name == "~" { "" } else { pin.name.as_str() };         s.push_str(&format!(             "\t\t\t(pin unspecified line\n\t\t\t\t(at {} {} {})\n\t\t\t\t(length {})\n\              \t\t\t\t(name \"{}\" (effects (font (size 1.27 1.27))))\n\              \t\t\t\t(number \"{}\" (effects (font (size 1.27 1.27))))\n\t\t\t)\n",             m(pin.x as f64), m(pin.y as f64), pin.rotation, m(len),-            esc(&pin.name), esc(&pin.number)+            esc(pname), esc(&pin.number)         ));     }     s.push_str("\t\t)\n\t)\n)\n");@@ -141,12 +241,27 @@ fn sym_graphic(g: &Graphic, m: &dyn Fn(f64) -> String) -> String { // Footprint → .kicad_mod // --------------------------------------------------------------------------- -/// Export a footprint as a `.kicad_mod`.+/// Export a footprint as a `.kicad_mod`, in the default format tier (V8 —+/// loads in KiCad 8/9/10; see [`KicadVersion`]). pub fn export_kicad_footprint(fp: &Footprint) -> String {+    export_kicad_footprint_for(fp, KicadVersion::default())+}++/// Export a footprint targeting a specific KiCad format tier.+pub fn export_kicad_footprint_for(fp: &Footprint, ver: KicadVersion) -> String {     let smd = fp.pads.iter().all(|p| p.drill_mm <= 0.0);     let mut s = String::new();     s.push_str(&format!("(footprint \"{}\"\n", esc(&fp.name)));-    s.push_str("\t(version 20240108)\n\t(generator \"adom-lbr\")\n\t(layer \"F.Cu\")\n");+    match ver.generator_version() {+        Some(gv) => s.push_str(&format!(+            "\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{gv}\")\n\t(layer \"F.Cu\")\n",+            ver.fp_version()+        )),+        None => s.push_str(&format!(+            "\t(version {})\n\t(generator \"adom-lbr\")\n\t(layer \"F.Cu\")\n",+            ver.fp_version()+        )),+    }     s.push_str(if smd { "\t(attr smd)\n" } else { "\t(attr through_hole)\n" });      // silk / courtyard / fab graphics@@ -157,7 +272,11 @@ pub fn export_kicad_footprint(fp: &Footprint) -> String {     // pads     for pad in &fp.pads {         let thru = pad.drill_mm > 0.0;-        let (ptype, layers) = if thru {+        let npth = thru && pad.plated == Some(false);+        let (ptype, layers) = if npth {+            // NPTH (mounting hole): no copper ring, mask relief only.+            ("np_thru_hole", "\"*.Mask\"")+        } else if thru {             ("thru_hole", "\"*.Cu\" \"*.Mask\"")         } else {             ("smd", "\"F.Cu\" \"F.Paste\" \"F.Mask\"")@@ -231,9 +350,16 @@ fn fp_graphic(g: &FpGraphic) -> String { /// center/radius/start-end angles (deg, CCW) → the (start, mid, end) points /// KiCad uses. Same units in and out. fn arc_points(cx: f64, cy: f64, r: f64, start: f64, end: f64) -> ((f64, f64), (f64, f64), (f64, f64)) {-    let mut sweep = end - start;-    while sweep <= 0.0 {-        sweep += 360.0;+    // Angles can come straight out of a decoded (untrusted) binary — normalize+    // arithmetically. A `while sweep <= 0 { += 360 }` loop hangs on huge+    // negatives and never terminates on NaN.+    let (start, end) = (+        if start.is_finite() { start } else { 0.0 },+        if end.is_finite() { end } else { 0.0 },+    );+    let mut sweep = (end - start).rem_euclid(360.0);+    if sweep == 0.0 {+        sweep = 360.0; // same start/end angle = full circle, as before     }     let mid_ang = start + sweep / 2.0;     let pt = |deg: f64| {
crates/altium-codec/src/lib.rs+96−2
@@ -14,6 +14,7 @@ pub mod kicad_export; pub mod model; pub mod pcblib; pub mod schlib;+pub mod sexpr;  pub use altium_decode::{decode_altium, decode_intlib_part}; pub use intlib::{decode_intlib, encode_intlib};@@ -35,6 +36,8 @@ mod tests {             body: None,             graphics: vec![],             parameters: vec![],+            pin_numbers_hidden: None,+            pin_names_hidden: None,             // pins in mils (connection point); length 100, angle 0 → Altium             // stores the body end = -100 + 100 = 0.             pins: vec![@@ -62,13 +65,104 @@ mod tests {         assert_eq!(pins[0]["y"], 10);     } +    /// Pins the house layer convention: Silk and Fab both encode onto Mech13+    /// (69) and decode back as Fab (deliberately lossy — see+    /// `pcblib::layer_enc`); Courtyard and Copper round-trip exactly.+    #[test]+    fn layer_roundtrip_house_convention() {+        use model::{FpGraphic, FpLayer};+        let line = |layer: FpLayer, y: f64| FpGraphic::Line { layer, x1: -1.0, y1: y, x2: 1.0, y2: y, width: 0.1 };+        let fp = Footprint {+            name: "LAYERFP".into(),+            pads: vec![Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None }],+            graphics: vec![+                line(FpLayer::Silk, 1.0),+                line(FpLayer::Fab, 2.0),+                line(FpLayer::Courtyard, 3.0),+                line(FpLayer::Copper, 4.0),+            ],+        };+        let bytes = encode_pcblib(&fp).expect("encode");+        let part = decode_altium(None, Some(&bytes), "LAYERFP").expect("decode");+        let layer_of = |y_want: f64| {+            part.footprint.graphics.iter().find_map(|g| match g {+                model::FpGraphic::Line { layer, y1, .. } if (y1 - y_want).abs() < 1e-3 => Some(layer.clone()),+                _ => None,+            })+        };+        assert!(matches!(layer_of(1.0), Some(FpLayer::Fab)), "silk merges onto Mech13 → decodes as Fab (house convention)");+        assert!(matches!(layer_of(2.0), Some(FpLayer::Fab)), "fab round-trips");+        assert!(matches!(layer_of(3.0), Some(FpLayer::Courtyard)), "courtyard round-trips");+        assert!(matches!(layer_of(4.0), Some(FpLayer::Copper)), "copper round-trips");+    }++    /// The `|KEY=VALUE` text records have no escape mechanism — encoding must+    /// REFUSE values that would desync the stream, not silently corrupt it.+    #[test]+    fn schlib_rejects_corrupting_inputs() {+        // '|' in a parameter value+        let mut s = two_pin_symbol();+        s.parameters.push(Parameter { name: "Value".into(), value: "10k|1%".into(), visible: true });+        assert!(encode_schlib(&[s]).is_err(), "pipe in parameter value must be rejected");++        // '|' in the symbol name+        let mut s = two_pin_symbol();+        s.name = "BAD|NAME".into();+        assert!(encode_schlib(&[s]).is_err(), "pipe in symbol name must be rejected");++        // pin longer than the u16 length field can hold (max 2559 mil)+        let mut s = two_pin_symbol();+        s.pins[0].length = Some(3000);+        assert!(encode_schlib(&[s]).is_err(), "oversize pin length must be rejected");+    }++    /// Storage name, FileHeader LibRef, and RECORD=1 LibReference must agree —+    /// and a name like "LM358.A" must keep its dot (CFBF allows it).+    #[test]+    fn schlib_dotted_name_stays_resolvable() {+        let mut s = two_pin_symbol();+        s.name = "LM358.A".into();+        let bytes = encode_schlib(&[s]).expect("encode");+        let decoded = decode_schlib_first(&bytes).expect("decode");+        assert_eq!(decoded["module_name"], "LM358.A");+        // merging the same symbol again is a no-op (dedup on the storage name)+        let mut again = two_pin_symbol();+        again.name = "LM358.A".into();+        let merged = add_symbols_to_schlib(&bytes, &[again]).expect("merge");+        assert_eq!(merged, bytes, "re-adding an existing symbol must not change the library");+    }++    /// NPTH mounting holes must survive KiCad import → export: keep the drill+    /// (not become an SMD copper pad) and stay `np_thru_hole`.+    #[test]+    fn npth_pad_roundtrips_through_kicad() {+        let sym = "(kicad_symbol_lib (symbol \"M3\" (pin passive line (at 0 0 0) (length 2.54) (name \"1\") (number \"1\"))))";+        let fpt = concat!(+            "(footprint \"MountM3\" (layer \"F.Cu\")\n",+            "  (pad \"\" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers \"*.Cu\" \"*.Mask\"))\n",+            "  (pad \"1\" thru_hole circle (at 5 0) (size 1.7 1.7) (drill oval 1.0 1.6) (layers \"*.Cu\" \"*.Mask\"))\n",+            ")"+        );+        let part = import_kicad(sym, fpt, "M3").expect("import");+        let npth = &part.footprint.pads[0];+        assert!(npth.drill_mm > 3.1, "NPTH drill preserved, got {}", npth.drill_mm);+        assert_eq!(npth.plated, Some(false));+        let th = &part.footprint.pads[1];+        assert_eq!(th.plated, Some(true));+        assert!((th.drill_mm - 1.6).abs() < 1e-9, "slot drill takes the larger axis, got {}", th.drill_mm);++        let out = export_kicad_footprint(&part.footprint);+        assert!(out.contains("np_thru_hole"), "export keeps np_thru_hole:\n{out}");+        assert!(out.contains("(drill 3.2)"), "export keeps the NPTH drill:\n{out}");+    }+     #[test]     fn pcblib_roundtrip_pads() {         let fp = Footprint {             name: "REFFP".into(),             pads: vec![-                Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into() },-                Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into() },+                Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },+                Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },             ],             graphics: vec![],         };
crates/altium-codec/src/main.rs+8−2
@@ -75,8 +75,11 @@ fn main() -> Result<()> {         }         // kicad sym+mod  →  universal part.json  (the import / hub step)         "import-kicad" => {+            let md_path = args+                .get(3)+                .ok_or_else(|| anyhow::anyhow!("usage: altium-codec import-kicad <in.kicad_sym> <in.kicad_mod> [mpn]"))?;             let sym = fs::read_to_string(&args[2]).context("read kicad_sym")?;-            let md = fs::read_to_string(&args[3]).context("read kicad_mod")?;+            let md = fs::read_to_string(md_path).context("read kicad_mod")?;             let mpn = args.get(4).cloned().unwrap_or_else(|| "PART".into());             let part = import_kicad(&sym, &md, &mpn)?;             let json = serde_json::to_string_pretty(&part)?;@@ -119,8 +122,11 @@ fn main() -> Result<()> {         // Add a part's symbol into an EXISTING .SchLib (non-destructive merge):         //   altium-codec add-to-schlib <existing.SchLib> <part.json> <out.SchLib>         "add-to-schlib" => {+            let json_path = args+                .get(3)+                .ok_or_else(|| anyhow::anyhow!("usage: altium-codec add-to-schlib <existing.SchLib> <part.json> [out.SchLib]"))?;             let existing = fs::read(&args[2]).context("read existing SchLib")?;-            let json = fs::read_to_string(&args[3]).context("read part.json")?;+            let json = fs::read_to_string(json_path).context("read part.json")?;             // Accept an AdomLbrPart, a bare Symbol, or an array of Symbols.             let syms: Vec<Symbol> = if let Ok(p) =                 serde_json::from_str::<altium_codec::AdomLbrPart>(&json)
crates/altium-codec/src/model.rs+8
@@ -238,6 +238,14 @@ pub struct Pad {     /// "rect" | "circle" | "oval" | "roundrect".     #[serde(default = "default_shape")]     pub shape: String,+    /// Through-hole plating. `None`/`Some(true)` → plated (normal TH pad);+    /// `Some(false)` → NPTH (KiCad `np_thru_hole`, e.g. a mounting hole).+    /// Only meaningful when `drill_mm > 0`. NOTE: the Altium binary encoder+    /// currently emits NPTH as a plated TH pad (the plated flag byte in the+    /// pad record is not yet calibrated) — the hole survives, the plating+    /// distinction doesn't. KiCad export round-trips it faithfully.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub plated: Option<bool>, }  fn default_shape() -> String {
crates/altium-codec/src/pcblib.rs+9−6
@@ -37,14 +37,17 @@ const TEMPLATE_COMPONENTS: [&str; 2] = ["REFFP", "PCBComponent_1"]; /// class [43])`. Altium honours the tail pair `[41]/[43]` (not just `[0]`), so /// both must be set or the primitive lands on whatever the template layer was. ///-/// Mapping (barrett's house convention): silk → Top Overlay; the component-/// outline (fab) → a Mechanical layer; courtyard → the Keep-Out layer.+/// Mapping (barrett's house convention, from his C0402): the part outline+/// lives on Mechanical 13 ("Assembly Top") and the courtyard on Mechanical 15+/// ("Courtyard Top") — nothing on Top Overlay.+///+/// ⚠ DELIBERATELY LOSSY: Silk and Fab both encode onto Mech13 (one layer byte+/// can't carry two roles), and the decoder ([`altium_decode::altium_layer`])+/// canonically maps 69 back to Fab. So a KiCad→Altium→KiCad round trip moves+/// F.SilkS art onto F.Fab. That is the house convention, not drift — the+/// `layer_roundtrip_house_convention` test pins it. Change both sites together. fn layer_enc(l: &FpLayer) -> (u8, u8, u8) {     match l {-        // Barrett's house convention (from his C0402): the part outline lives on-        // Mechanical 13 ("Assembly Top") and the courtyard on Mechanical 15-        // ("Courtyard Top") — nothing on Top Overlay. KiCad silk + fab both map-        // to the assembly outline layer.         FpLayer::Silk => (69, 13, 2),       // Mechanical 13 (Assembly Top)         FpLayer::Fab => (69, 13, 2),        // Mechanical 13 (Assembly Top)         FpLayer::Courtyard => (71, 15, 2),  // Mechanical 15 (Courtyard Top)
crates/altium-codec/src/schlib.rs+124−43
@@ -49,11 +49,21 @@ const UNITS_PER_MIL: f64 = 25.6;  /// Encode one or more symbols into a `.SchLib` byte image. pub fn encode_schlib(symbols: &[Symbol]) -> Result<Vec<u8>> {+    validate_symbols(symbols)?;     let cursor = Cursor::new(Vec::<u8>::new());     let mut comp = cfb::CompoundFile::create(cursor).context("create CFBF")?; +    // The CFBF storage name, the FileHeader LibRef entry, and the RECORD=1+    // LibReference must all agree, or Altium can't resolve the component —+    // compute each symbol's storage name once and use it in all three.+    let named: Vec<(String, &Symbol)> = symbols+        .iter()+        .enumerate()+        .map(|(i, s)| (sanitize_storage_name(&s.name, i), s))+        .collect();+     // FileHeader — library index listing every component.-    let fh = build_file_header(symbols);+    let fh = build_file_header(&named);     write_root_stream(&mut comp, "FileHeader", &fh)?;      // Storage — fixed "Icon storage" housekeeping stream.@@ -61,11 +71,10 @@ pub fn encode_schlib(symbols: &[Symbol]) -> Result<Vec<u8>> {     write_root_stream(&mut comp, "Storage", &storage)?;      // One storage per component, each holding a Data record stream.-    for (idx, sym) in symbols.iter().enumerate() {-        let store_name = sanitize_storage_name(&sym.name, idx);+    for (store_name, sym) in &named {         comp.create_storage(&format!("/{store_name}"))             .with_context(|| format!("create storage /{store_name}"))?;-        let data = build_component_data(sym);+        let data = build_component_data(sym, store_name)?;         let mut s = comp             .create_stream(&format!("/{store_name}/Data"))             .with_context(|| format!("create /{store_name}/Data"))?;@@ -111,10 +120,15 @@ pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec<     }     let comp_count_existing = existing_comps.len(); -    // Skip symbols whose name already exists.-    let to_add: Vec<&Symbol> = symbols+    validate_symbols(symbols)?;+    // Skip symbols already present. Compare SANITIZED names — the stored+    // storages are sanitized, so comparing the raw name would let e.g.+    // "LM358.A" slip past its own existing "LM358_A" storage and collide.+    let to_add: Vec<(String, &Symbol)> = symbols         .iter()-        .filter(|s| !existing_comps.contains(&s.name))+        .enumerate()+        .map(|(i, s)| (sanitize_storage_name(&s.name, comp_count_existing + i), s))+        .filter(|(store, _)| !existing_comps.contains(store))         .collect();     if to_add.is_empty() {         // Nothing new — return the original unchanged.@@ -138,9 +152,8 @@ pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec<         let data: &[u8] = if p == "/FileHeader" { &patched_fh } else { bytes };         write_stream_deep(&mut dst, p, data, &mut made)?;     }-    for (k, sym) in to_add.iter().enumerate() {-        let store = sanitize_storage_name(&sym.name, comp_count_existing + k);-        let data = build_component_data(sym);+    for (store, sym) in &to_add {+        let data = build_component_data(sym, store)?;         write_stream_deep(&mut dst, &format!("/{store}/Data"), &data, &mut made)?;     }     dst.flush()?;@@ -150,7 +163,7 @@ pub fn add_symbols_to_schlib(existing: &[u8], symbols: &[Symbol]) -> Result<Vec< /// Patch a SchLib FileHeader text record: bump `CompCount` by `add.len()` and /// append a `|LibRef{i}=..|PartCount{i}=1` entry per added symbol (indices /// continue after the existing `old_n` components).-fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[&Symbol]) -> Result<Vec<u8>> {+fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[(String, &Symbol)]) -> Result<Vec<u8>> {     if fh.len() < 5 {         bail!("FileHeader too short");     }@@ -174,10 +187,11 @@ fn patch_file_header_add(fh: &[u8], old_n: usize, add: &[&Symbol]) -> Result<Vec     new_text.push_str(&new_n.to_string());     new_text.push_str(&text[val_start + digits.len()..]); -    // Append the new component index entries.-    for (k, sym) in add.iter().enumerate() {+    // Append the new component index entries — LibRef = the SANITIZED storage+    // name, so Altium can resolve the component to its storage.+    for (k, (store, _)) in add.iter().enumerate() {         let i = old_n + k;-        new_text.push_str(&format!("|LibRef{i}={}|PartCount{i}=1", sym.name));+        new_text.push_str(&format!("|LibRef{i}={store}|PartCount{i}=1"));     }     Ok(u32_text_record(&new_text)) }@@ -220,6 +234,49 @@ fn write_root_stream<F: std::io::Read + std::io::Write + std::io::Seek>(     Ok(()) } +/// Validate symbols before encoding. Text records are `|KEY=VALUE` streams+/// with NO escape mechanism — a `|` inside a name/value desyncs the whole+/// record — and several binary fields are narrow. Refuse loudly rather than+/// emit a corrupt library.+fn validate_symbols(symbols: &[Symbol]) -> Result<()> {+    /// Max pin length: the length field is u16 with orientation in bits 0..1,+    /// so length_units ≤ 0xFFFC → 0xFFFC / 25.6 = 2559 mil.+    const MAX_PIN_LEN_MIL: i32 = 2559;+    for sym in symbols {+        let no_pipe = |what: &str, s: &str| -> Result<()> {+            if s.contains('|') {+                bail!("symbol {:?}: {what} {s:?} contains '|', which corrupts Altium text records — rename it", sym.name);+            }+            Ok(())+        };+        no_pipe("name", &sym.name)?;+        if let Some(p) = &sym.designator_prefix {+            no_pipe("designator prefix", p)?;+        }+        if let Some(f) = &sym.footprint {+            no_pipe("footprint name", f)?;+        }+        for param in &sym.parameters {+            no_pipe("parameter name", &param.name)?;+            no_pipe("parameter value", &param.value)?;+            if param.name.contains('=') {+                bail!("symbol {:?}: parameter name {:?} contains '=', which corrupts Altium text records", sym.name, param.name);+            }+        }+        for pin in &sym.pins {+            if pin.name.len() > 255 || pin.number.len() > 255 {+                bail!("symbol {:?}: pin name/number longer than 255 bytes (pin {:?})", sym.name, pin.number);+            }+            if let Some(l) = pin.length {+                if !(0..=MAX_PIN_LEN_MIL).contains(&l) {+                    bail!("symbol {:?}: pin {:?} length {l} mil out of range 0..={MAX_PIN_LEN_MIL} (Altium field limit)", sym.name, pin.number);+                }+            }+        }+    }+    Ok(())+}+ /// `<u32 len>` + text + `\0`. Used for FileHeader and Storage streams. fn u32_text_record(text: &str) -> Vec<u8> {     let mut payload = text.as_bytes().to_vec();@@ -230,16 +287,21 @@ fn u32_text_record(text: &str) -> Vec<u8> { }  /// `<u16 len><u16 flags>` + text + `\0`. The component-data text record framing.-fn u16_text_record(text: &str) -> Vec<u8> {+/// Errors instead of silently wrapping the u16 frame length (a >64 KiB record —+/// e.g. a polyline with thousands of points — would desync the whole stream).+fn u16_text_record(text: &str) -> Result<Vec<u8>> {     let mut payload = text.as_bytes().to_vec();     payload.push(0);+    if payload.len() > u16::MAX as usize {+        bail!("text record too large for u16 framing ({} bytes) — simplify the symbol graphics", payload.len());+    }     let mut out = (payload.len() as u16).to_le_bytes().to_vec();     out.extend_from_slice(&0u16.to_le_bytes()); // flags = 0     out.extend_from_slice(&payload);-    out+    Ok(out) } -fn build_file_header(symbols: &[Symbol]) -> Vec<u8> {+fn build_file_header(symbols: &[(String, &Symbol)]) -> Vec<u8> {     let mut s = String::from(         "|HEADER=Protel for Windows - Schematic Library Editor Binary File Version 5.0\ |Weight=0|MinorVersion=9|UniqueID=ADOMSFHX|FontIdCount=1|Size1=10\@@ -249,14 +311,17 @@ fn build_file_header(symbols: &[Symbol]) -> Vec<u8> { |ReferenceZonesOn=T|Display_Unit=0",     );     s.push_str(&format!("|CompCount={}", symbols.len()));-    for (i, sym) in symbols.iter().enumerate() {+    for (i, (store_name, _)) in symbols.iter().enumerate() {         let parts = 1; // single-part symbols for now-        s.push_str(&format!("|LibRef{i}={}|PartCount{i}={}", sym.name, parts));+        // LibRef must equal the storage name (and RECORD=1 LibReference).+        s.push_str(&format!("|LibRef{i}={store_name}|PartCount{i}={parts}"));     }     u32_text_record(&s) } -fn build_component_data(sym: &Symbol) -> Vec<u8> {+/// `lib_ref` is the storage name — LibReference, the FileHeader LibRef entry,+/// and the CFBF storage MUST all agree or Altium can't resolve the component.+fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {     let mut out = Vec::new();     let pin_count = sym.pins.len();     let body = sym.body.unwrap_or_default();@@ -265,12 +330,11 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> {      // RECORD=1 — component header.     out.extend(u16_text_record(&format!(-        "|RECORD=1|LibReference={name}|PartCount=1|DisplayModeCount=1|IndexInSheet=-1\+        "|RECORD=1|LibReference={lib_ref}|PartCount=1|DisplayModeCount=1|IndexInSheet=-1\ |OwnerPartId=-1|CurrentPartId=1|LibraryPath=*|SourceLibraryName=*|SheetPartFileName=*\ |TargetFileName=*|UniqueID={uid}|AreaColor=11599871|Color=128|PartIDLocked=T\ |AllPinCount={pin_count}",-        name = sym.name,-    )));+    ))?);      // Body: emit the symbol's own graphics if it has any; otherwise a default     // body rectangle. Either way these are the records between header and pins.@@ -283,16 +347,16 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> {             x2 = body.x2 / 10,             y2 = body.y2 / 10,             uid2 = pseudo_uid(&sym.name, 14),-        )));+        ))?);     } else {         for (gi, g) in sym.graphics.iter().enumerate() {-            out.extend(emit_graphic(g, &sym.name, gi));+            out.extend(emit_graphic(g, &sym.name, gi)?);         }     }      // Pin records (binary).     for (i, pin) in sym.pins.iter().enumerate() {-        out.extend(frame_pin(encode_pin_payload(pin, i)));+        out.extend(frame_pin(encode_pin_payload(pin, i)?));     }      // RECORD=34 — Designator label.@@ -300,14 +364,14 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> {         "|RECORD=34|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=5|Color=8388608\ |FontID=1|Text={prefix}?|Name=Designator|ReadOnlyState=1|UniqueID={uid3}",         uid3 = pseudo_uid(&sym.name, 34),-    )));+    ))?);      // RECORD=41 — Comment label.     out.extend(u16_text_record(&format!(         "|RECORD=41|IndexInSheet=-1|OwnerPartId=-1|Location.X=-5|Location.Y=-15|Color=8388608\ |FontID=1|Text=*|Name=Comment|UniqueID={uid4}",         uid4 = pseudo_uid(&sym.name, 41),-    )));+    ))?);      // RECORD=41 — component property fields (Value, Manufacturer, MPN, …). Each     // is another parameter record; visible ones are drawn (stacked below the@@ -328,14 +392,14 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> {                 t = param.value, n = param.name             )         };-        out.extend(u16_text_record(&rec));+        out.extend(u16_text_record(&rec)?);     }      // RECORD=44 — implementations list. Then, if the symbol names a footprint,     // the implementation that links it: RECORD=45 (the PCBLIB model reference)     // plus its RECORD=46/48 children. OwnerIndex values are record positions in     // this stream: R1=0, R14=1, pins=2..(pin_count+1), R34, R41, params.., R44…-    out.extend(u16_text_record("|RECORD=44"));+    out.extend(u16_text_record("|RECORD=44")?);     if let Some(fp) = sym.footprint.as_deref().filter(|s| !s.is_empty()) {         // record positions: R1=0, body records, pins, R34, R41-comment, params, R44.         let body_recs = if sym.graphics.is_empty() { 1 } else { sym.graphics.len() };@@ -347,14 +411,14 @@ fn build_component_data(sym: &Symbol) -> Vec<u8> {             "|RECORD=45|OwnerIndex={idx_44}|IndexInSheet=-1|ModelName={fp}|ModelType=PCBLIB\ |DatafileCount=1|ModelDatafileEntity0={fp}|ModelDatafileKind0=PCBLIB|IsCurrent=T|UniqueID={uid45}",             uid45 = pseudo_uid(&sym.name, 45),-        )));+        ))?);         // RECORD=46 / 48 — implementation children (empty → implicit identity         // pin↔pad map, valid whenever pin numbers equal pad numbers).-        out.extend(u16_text_record(&format!("|RECORD=46|OwnerIndex={idx_45}")));-        out.extend(u16_text_record(&format!("|RECORD=48|OwnerIndex={idx_45}")));+        out.extend(u16_text_record(&format!("|RECORD=46|OwnerIndex={idx_45}"))?);+        out.extend(u16_text_record(&format!("|RECORD=48|OwnerIndex={idx_45}"))?);     } -    out+    Ok(out) }  /// Split a mil value into Altium schematic form: a base in 10-mil units plus a@@ -387,7 +451,7 @@ fn coord_field(key: &str, mil: f64) -> String { /// Emit one symbol graphic as its Altium record, with sub-10-mil precision via /// `_Frac` fields. Polyline→RECORD=6, Rect→RECORD=14, Circle→RECORD=11, /// Arc→RECORD=12.-fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Vec<u8> {+fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Result<Vec<u8>> {     let uid = pseudo_uid(name, 1000 + gi as u32);     // Altium standard schematic-symbol line color for passives = pure blue,     // RGB(0,0,255) stored as R + G*256 + B*65536 = 16711680. (Was 128 = maroon.)@@ -434,7 +498,7 @@ fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Vec<u8> { }  /// Build the 36+ byte binary pin payload (before framing).-fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> {+fn encode_pin_payload(pin: &Pin, _idx: usize) -> Result<Vec<u8>> {     let mut p = PIN_PREFIX.to_vec();     // conglom byte[14] = 0x04 → a plain pin (no edge-symbol triangle), rotation     // bits (0..1) = 0. Matches Altium's own pins (build_symbol ground truth):@@ -450,6 +514,12 @@ fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> {         .length         .map(|l| (l as f64 * UNITS_PER_MIL).round() as i32)         .unwrap_or(DEFAULT_PIN_LEN as i32);+    // The field is u16 with orientation packed in bits 0..1 — a longer pin+    // would silently wrap BOTH the length and the orientation. validate_symbols+    // rejects these up front; this is the backstop.+    if !(0..=0xFFFC).contains(&length) {+        bail!("pin {:?} length {} units exceeds the u16 field (max 2559 mil)", pin.number, length);+    }     let field = ((length as u16) & 0xFFFC) | aorient; // length in bits 2..15, orient in 0..1     p[15..17].copy_from_slice(&field.to_le_bytes());     // Altium's pin Location is the BODY end (where the pin meets the symbol),@@ -476,12 +546,12 @@ fn encode_pin_payload(pin: &Pin, _idx: usize) -> Vec<u8> {     let uy = (body_y as f64 / 10.0).round() as i32 as i16;     p[20..22].copy_from_slice(&uy.to_le_bytes());     // strings: Name, Designator, "", "|&|"-    push_pstr(&mut p, &pin.name);-    push_pstr(&mut p, &pin.number);-    push_pstr(&mut p, "");-    push_pstr(&mut p, "|&|");+    push_pstr(&mut p, &pin.name)?;+    push_pstr(&mut p, &pin.number)?;+    push_pstr(&mut p, "")?;+    push_pstr(&mut p, "|&|")?;     p.push(0x00);-    p+    Ok(p) }  fn frame_pin(payload: Vec<u8>) -> Vec<u8> {@@ -491,10 +561,16 @@ fn frame_pin(payload: Vec<u8>) -> Vec<u8> {     out } -fn push_pstr(buf: &mut Vec<u8>, s: &str) {+/// Pascal string: `<u8 len>` + bytes. Errors instead of silently wrapping the+/// length prefix (a >255-byte string would desync the pin record framing).+fn push_pstr(buf: &mut Vec<u8>, s: &str) -> Result<()> {     let b = s.as_bytes();+    if b.len() > 255 {+        bail!("string too long for a pascal-string field ({} bytes, max 255): {:?}…", b.len(), &s[..32.min(s.len())]);+    }     buf.push(b.len() as u8);     buf.extend_from_slice(b);+    Ok(()) }  /// Altium UniqueIDs are 8 uppercase letters. Generate a stable pseudo-id from@@ -514,10 +590,15 @@ fn pseudo_uid(name: &str, salt: u32) -> String {     out } +/// CFBF storage name for a component. Only characters CFBF actually forbids+/// are replaced ('/', '\', ':', '!', controls), so names like "LM358.A" keep+/// their real spelling — important because this name doubles as the LibRef /+/// LibReference Altium resolves by. OLE2 names max out at 31 UTF-16 units. fn sanitize_storage_name(name: &str, idx: usize) -> String {     let cleaned: String = name         .chars()-        .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })+        .map(|c| if matches!(c, '/' | '\\' | ':' | '!') || (c as u32) < 0x20 { '_' } else { c })+        .take(31)         .collect();     if cleaned.is_empty() {         format!("Component_{idx}")
crates/altium-codec/src/sexpr.rsadded+252
@@ -0,0 +1,252 @@+//! KiCad-style s-expression reader — the shared parser for every `.kicad_sym`+//! / `.kicad_mod` consumer in this workspace (replaces the old regex parsing).+//!+//! Modeled on KiCad's own parser pair (DSNLEXER + SCH_IO_KICAD_SEXPR_PARSER in+//! the KiCad repo), from which it borrows three design points:+//!  1. a line-tracking lexer, so every parse error cites its line+//!     (KiCad: `THROW_PARSE_ERROR(..., CurLineNumber(), CurOffset())`);+//!  2. strict structure enforcement — unbalanced parens, unterminated strings+//!     and trailing garbage are ERRORS, never a silently-partial tree+//!     (KiCad: `Expecting(T_LEFT)` / `NeedRIGHT()` discipline);+//!  3. the format-version gate — semantics key off the file's declared+//!     `(version N)` (KiCad: `m_requiredVersion`), e.g. `~` means "empty+//!     string" only in files older than 20250318.++use anyhow::{bail, Result};++/// A parsed s-expression node. Atoms carry their text with string quoting+/// already resolved (`"a b"` and `a_b` both become plain atoms; a `\u{0}`+/// marker is NOT leaked — quoted-ness is dropped after parse).+#[derive(Debug)]+pub enum Sx {+    Atom(String),+    List(Vec<Sx>),+}++impl Sx {+    pub fn head(&self) -> Option<&str> {+        match self {+            Sx::List(v) => match v.first() {+                Some(Sx::Atom(a)) => Some(a.as_str()),+                _ => None,+            },+            _ => None,+        }+    }+    pub fn items(&self) -> &[Sx] {+        match self {+            Sx::List(v) => v,+            _ => &[],+        }+    }+    /// First child list whose head == name.+    pub fn child(&self, name: &str) -> Option<&Sx> {+        self.items().iter().find(|s| s.head() == Some(name))+    }+    /// Every direct child list whose head == name (non-recursive).+    pub fn children<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Sx> + 'a {+        self.items().iter().filter(move |s| s.head() == Some(name))+    }+    /// Atom string at position i (after the head), if it's an atom.+    pub fn atom_at(&self, i: usize) -> Option<&str> {+        match self.items().get(i) {+            Some(Sx::Atom(a)) => Some(a.as_str()),+            _ => None,+        }+    }+    /// Numeric atoms in this list (skipping the head atom).+    pub fn nums(&self) -> Vec<f64> {+        self.items()+            .iter()+            .skip(1)+            .filter_map(|s| match s {+                Sx::Atom(a) => a.parse::<f64>().ok(),+                _ => None,+            })+            .collect()+    }+    /// Walk the whole tree, collecting every list whose head == name.+    pub fn collect<'a>(&'a self, name: &str, out: &mut Vec<&'a Sx>) {+        if self.head() == Some(name) {+            out.push(self);+        }+        for s in self.items() {+            if let Sx::List(_) = s {+                s.collect(name, out);+            }+        }+    }+    /// The document's declared `(version N)`, if present — the equivalent of+    /// KiCad's `m_requiredVersion` gate. Callers use this for version-scoped+    /// semantics (e.g. `~` means empty only when `version < 20250318`).+    pub fn doc_version(&self) -> Option<u32> {+        self.child("version")+            .and_then(|v| v.atom_at(1))+            .and_then(|s| s.parse().ok())+    }+}++/// A property/field string under the document's version semantics: before+/// format 20250318 a bare `~` meant the empty string (KiCad parser:+/// `if( m_requiredVersion < 20250318 && CurStr() == "~" )`).+pub fn resolve_tilde(s: &str, doc_version: Option<u32>) -> String {+    if s == "~" && doc_version.unwrap_or(0) < 20250318 {+        String::new()+    } else {+        s.to_string()+    }+}++#[derive(Debug)]+enum Tok {+    Open,+    Close,+    Atom(String),+}++/// Tokenize with line tracking. Errors: unterminated string.+fn tokenize(text: &str) -> Result<Vec<(Tok, u32)>> {+    let mut toks = Vec::new();+    let mut it = text.chars().peekable();+    let mut line: u32 = 1;+    while let Some(&c) = it.peek() {+        if c == '\n' {+            line += 1;+            it.next();+        } else if c.is_whitespace() {+            it.next();+        } else if c == '(' {+            toks.push((Tok::Open, line));+            it.next();+        } else if c == ')' {+            toks.push((Tok::Close, line));+            it.next();+        } else if c == '"' {+            let start = line;+            it.next();+            let mut buf = String::new();+            let mut closed = false;+            while let Some(&c) = it.peek() {+                match c {+                    '"' => {+                        it.next();+                        closed = true;+                        break;+                    }+                    '\\' => {+                        it.next();+                        if let Some(&e) = it.peek() {+                            // resolve the standard escapes; pass others through+                            buf.push(match e {+                                'n' => '\n',+                                't' => '\t',+                                other => other,+                            });+                            if e == '\n' {+                                line += 1;+                            }+                            it.next();+                        }+                    }+                    _ => {+                        if c == '\n' {+                            line += 1;+                        }+                        buf.push(c);+                        it.next();+                    }+                }+            }+            if !closed {+                bail!("unterminated string starting at line {start}");+            }+            toks.push((Tok::Atom(buf), start));+        } else {+            let mut buf = String::new();+            while let Some(&c) = it.peek() {+                if c.is_whitespace() || c == '(' || c == ')' {+                    break;+                }+                buf.push(c);+                it.next();+            }+            toks.push((Tok::Atom(buf), line));+        }+    }+    Ok(toks)+}++/// Parse one list starting at toks[*pos] (which must be Open). Strict: a+/// missing close paren is an error, not an implicit EOF-close.+fn parse_list(toks: &[(Tok, u32)], pos: &mut usize) -> Result<Sx> {+    let open_line = toks[*pos].1;+    *pos += 1; // consume '('+    let mut list = Vec::new();+    loop {+        match toks.get(*pos) {+            Some((Tok::Open, _)) => list.push(parse_list(toks, pos)?),+            Some((Tok::Close, _)) => {+                *pos += 1;+                return Ok(Sx::List(list));+            }+            Some((Tok::Atom(a), _)) => {+                list.push(Sx::Atom(a.clone()));+                *pos += 1;+            }+            None => bail!("unbalanced s-expression: '(' at line {open_line} is never closed"),+        }+    }+}++/// Parse a document into its single root list. Strict about structure+/// (unbalanced/unterminated input errors with a line number); tolerant about+/// leading junk before the first '(' (some exporters emit a BOM/comment).+pub fn parse_root(text: &str) -> Result<Sx> {+    let toks = tokenize(text)?;+    let Some(start) = toks.iter().position(|(t, _)| matches!(t, Tok::Open)) else {+        bail!("no s-expression found");+    };+    let mut pos = start;+    let root = parse_list(&toks, &mut pos)?;+    // Anything after the root except stray whitespace is a malformed file —+    // report it instead of silently ignoring half the document.+    if let Some((tok, line)) = toks.get(pos) {+        let what = match tok {+            Tok::Open => "'('".to_string(),+            Tok::Close => "')'".to_string(),+            Tok::Atom(a) => format!("{a:?}"),+        };+        bail!("trailing content after the root s-expression: {what} at line {line}");+    }+    Ok(root)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn strict_errors_carry_lines() {+        let e = parse_root("(a\n(b c)\n").unwrap_err().to_string();+        assert!(e.contains("line 1"), "unclosed root cites its open line: {e}");+        let e = parse_root("(a \"unterminated\n").unwrap_err().to_string();+        assert!(e.contains("line 1"), "unterminated string cites its line: {e}");+        let e = parse_root("(a) (b)").unwrap_err().to_string();+        assert!(e.contains("trailing"), "second root is trailing content: {e}");+    }++    #[test]+    fn tilde_gate_matches_kicad() {+        assert_eq!(resolve_tilde("~", Some(20211014)), "");+        assert_eq!(resolve_tilde("~", Some(20231120)), "");+        assert_eq!(resolve_tilde("~", Some(20251024)), "~"); // literal in new files+        assert_eq!(resolve_tilde("~", None), ""); // no version → legacy file+        assert_eq!(resolve_tilde("A~B", Some(20211014)), "A~B");+    }++    #[test]+    fn doc_version_reads_header() {+        let r = parse_root("(kicad_symbol_lib (version 20231120) (generator \"x\"))").unwrap();+        assert_eq!(r.doc_version(), Some(20231120));+    }+}
crates/altium-pcblib/src/lib.rs+46−28
@@ -296,7 +296,8 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> {      // Block 4: main pad data, 194 bytes (v3) or 202 bytes (v4+)     let pd = blocks[4];-    if pd.len() < 49 {+    // The fixed fields we read run through pd[49] (shape byte) — require 50.+    if pd.len() < 50 {         return None;     } @@ -315,11 +316,11 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> {     let hole_mm = hole_units as f64 * ALTIUM_UNIT_TO_MM;     let shape_byte = pd[49]; -    // Rotation lives at block5[52..60] in v4+. v3 (194-byte) layout doesn't-    // store it (rotation is baked into xsize/ysize). Read what's there; if-    // the f64 isn't a finite angle we just default to 0. The bounding box is-    // still correct because Altium pre-rotates the size for v3.-    let rotation = if pd.len() >= 60 {+    // Rotation lives at block5[52..60] in v4+ (202-byte layout). v3 (194-byte)+    // doesn't store it (rotation is baked into xsize/ysize) — and a v3 block+    // still passes a `>= 60` check, so gate on the full v4 size or v3 bytes+    // 52..60 (some other field) get reinterpreted as an f64 angle.+    let rotation = if pd.len() >= 202 {         let raw = read_f64_le(pd, 52).unwrap_or(0.0);         if raw.is_finite() && raw.abs() < 360.0 + 1e-6 {             // Normalize to [-360, 360); KiCad accepts any angle but typical@@ -704,34 +705,51 @@ fn escape_xml(s: &str) -> String { mod tests {     use super::*; -    /// LM358D is a SOIC-8 with 8 SMD pads at ±2.4638 mm × {±1.905, ±0.635} mm,-    /// each 1.9812 × 0.5588 mm. Pad 1 is at (-2.4638, -1.905) in KiCad-    /// convention.+    /// ADS1220IRVAT is TI's RVA package — VQFN-16, 0.5 mm pitch: 16 perimeter+    /// pads 0.28 × 0.80 mm with pad rows at ±1.75 mm, a 2.14 mm square exposed+    /// pad ("17") at the origin, and 4 thermal vias (0.254 mm PTH drill).+    /// Geometry cross-checked against the TI datasheet land pattern.+    /// (Fixture vendored from the UltraLibrarian download so the standalone+    /// repo can always run its own tests.)     #[test]-    fn lm358d_decodes_eight_pads_pad1_at_top_left() {-        const LM358D_PCBLIB: &[u8] = include_bytes!(-            "../../chip-fetcher/library/LM358D/LM358D.PcbLib"-        );-        let parsed = parse_altium_pcblib_bytes(LM358D_PCBLIB)-            .expect("parse LM358D PcbLib");+    fn ads1220_vqfn16_decodes_pads_ep_and_thermal_vias() {+        const PCBLIB: &[u8] = include_bytes!("../tests/fixtures/ADS1220IRVAT.PcbLib");+        let parsed = parse_altium_pcblib_bytes(PCBLIB).expect("parse ADS1220IRVAT PcbLib");+        // 16 perimeter + 1 EP + 4 thermal vias         let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);-        assert_eq!(pad_count, 8, "LM358D should have 8 pads, got {pad_count}");+        assert_eq!(pad_count, 21, "expected 21 pads, got {pad_count}");         let pads = parsed.get("pads").and_then(|v| v.as_array()).expect("pads array");-        let pad1 = pads.iter().find(|p| {-            p.get("number").and_then(|v| v.as_str()) == Some("1")-        }).expect("pad 1");++        // Pad 1: left column, (-1.75, -0.75), 0.28 × 0.80, SMD on F.Cu.+        let pad1 = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("1")).expect("pad 1");         let at = pad1.get("at").and_then(|v| v.as_array()).expect("at");-        let x = at[0].as_f64().unwrap();-        let y = at[1].as_f64().unwrap();+        let (x, y) = (at[0].as_f64().unwrap(), at[1].as_f64().unwrap());         let size = pad1.get("size").and_then(|v| v.as_array()).expect("size");-        let w = size[0].as_f64().unwrap();-        let h = size[1].as_f64().unwrap();-        // Pad 1 centre is at roughly (-2.46, -1.91); width 1.98, height 0.56-        assert!((x - -2.4638).abs() < 0.05, "pad 1 X = {x}");-        assert!((y - -1.905).abs() < 0.05, "pad 1 Y = {y}");-        assert!((w - 1.9812).abs() < 0.05, "pad 1 W = {w}");-        assert!((h - 0.5588).abs() < 0.05, "pad 1 H = {h}");+        let (w, h) = (size[0].as_f64().unwrap(), size[1].as_f64().unwrap());+        assert!((x - -1.75).abs() < 0.01, "pad 1 X = {x}");+        assert!((y - -0.75).abs() < 0.01, "pad 1 Y = {y}");+        assert!((w - 0.28).abs() < 0.01, "pad 1 W = {w}");+        assert!((h - 0.80).abs() < 0.01, "pad 1 H = {h}");+        assert!(pad1.get("drill").map(|d| d.is_null()).unwrap_or(false), "pad 1 is SMD");         let layers = pad1.get("layers").and_then(|v| v.as_array()).expect("layers");         assert!(layers.iter().any(|l| l.as_str() == Some("F.Cu")));++        // Pads 2..5 confirm the 0.5 mm pitch up the left column.+        let pad2 = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("2")).expect("pad 2");+        let y2 = pad2["at"][1].as_f64().unwrap();+        assert!((y2 - y - 0.5).abs() < 0.01, "pitch was {}", y2 - y);++        // Exposed pad "17": 2.14 mm square at the origin.+        let ep = pads.iter().find(|p| p.get("number").and_then(|v| v.as_str()) == Some("17")).expect("EP");+        let ew = ep["size"][0].as_f64().unwrap();+        assert!((ew - 2.14).abs() < 0.01, "EP W = {ew}");+        assert!(ep["at"][0].as_f64().unwrap().abs() < 0.01);++        // Thermal vias: plated through-hole, 0.254 mm drill, on *.Cu.+        let vias: Vec<_> = pads.iter().filter(|p| p.get("number").and_then(|v| v.as_str()) == Some("V")).collect();+        assert_eq!(vias.len(), 4, "expected 4 thermal vias");+        let d = vias[0]["drill"]["diameter"].as_f64().unwrap();+        assert!((d - 0.254).abs() < 0.001, "via drill = {d}");+        assert!(vias[0]["layers"].as_array().unwrap().iter().any(|l| l.as_str() == Some("*.Cu")));     } }
crates/altium-pcblib/tests/fixtures/ADS1220IRVAT.PcbLibadded
⋯ 1 unchanged line ⋯
crates/altium-schlib/src/lib.rs+10−2
@@ -353,8 +353,15 @@ pub fn to_kicad_sym(sym: &serde_json::Value, lib_name: &str) -> String {     emit(&left, true, &mut body);     emit(&right, false, &mut body); +    // V8-tier symbol-lib format version — loads in KiCad 8/9/10. Deliberately+    // NOT the KiCad-10 value (20251024): KiCad rejects files newer than+    // itself, and the user's desktop runs 9.0.9. Keep in sync with+    // altium-codec's kicad_export::KicadVersion (this crate is standalone, so+    // the value is duplicated here on purpose).+    const KICAD_SYM_VERSION: u32 = 20231120;+    let name = kesc(lib_name);     format!(-        "(kicad_symbol_lib (version 20211014) (generator altium-schlib)\n  (symbol \"{lib_name}\" (in_bom yes) (on_board yes)\n    (property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n    (property \"Value\" \"{lib_name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n    (symbol \"{lib_name}_1_1\"\n      (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{body}    )\n  )\n)\n",+        "(kicad_symbol_lib (version {KICAD_SYM_VERSION}) (generator \"altium-schlib\") (generator_version \"8.0\")\n  (symbol \"{name}\" (in_bom yes) (on_board yes)\n    (property \"Reference\" \"U\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n    (property \"Value\" \"{name}\" (at 0 {:.2} 0) (effects (font (size 1.27 1.27))))\n    (symbol \"{name}_1_1\"\n      (rectangle (start {:.2} {:.2}) (end {:.2} {:.2}) (stroke (width 0.254) (type default)) (fill (type background)))\n{body}    )\n  )\n)\n",         half_h + 1.27, -half_h - 1.27, -half_w, half_h, half_w, -half_h,     ) }@@ -368,6 +375,7 @@ fn kicad_name(s: &str) -> String {         kesc(s)     } }+/// Escape for a KiCad double-quoted string (backslash first, then quotes). fn kesc(s: &str) -> String {-    s.replace('"', "'")+    s.replace('\\', "\\\\").replace('"', "\\\"") }
package.json+3−7
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.5.0",+  "version": "2.2.0",   "type": "app",-  "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Library Manager: Local / Mine / Org / Public tabs, infinite-scroll browse + server-side search, per-part symbol/footprint/3D completeness marks, and canonical adom-symbol/adom-footprint/babylon viewers + per-EDA layer stackup.",+  "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},   "tags": [     "eda",@@ -50,11 +50,7 @@     "convert between any eda tools",     "adom-lbr manager",     "view footprint layer stackup",-    "json to kicad",-    "lbr placeholder footprint",-    "eagle round thru-hole pad oblong",-    "lbr silk circle dropped",-    "lbr generate real footprint from kicad_mod"+    "json to kicad"   ],   "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [
src/main.rs+125−577
@@ -37,16 +37,6 @@ enum Commands {         /// Symbol name override         #[arg(long)]         name: Option<String>,-        /// Chip outline drawn on the schematic symbol as <wire>s, OVERRIDING the-        /// kicad_sym's own drawing. PREFERRED: adom-symbol's OUTLINE CONTRACT-        /// `<mpn>-symbol-outline.mm.svg` (+ its `.json` sidecar) — placed 1:1 in-        /// symbol mm-space. Else a legacy flattened outline SVG. Composes with --fp.-        #[arg(long)]-        symbol_art: Option<PathBuf>,-        /// Size of LEGACY symbol art as a fraction of the body's smaller side.-        /// Ignored for the `.mm.svg` contract (placement is exact).-        #[arg(long, default_value_t = 0.38)]-        symbol_art_scale: f64,     },     /// Lint an existing .lbr file for Fusion 360 compatibility     Lint {@@ -137,6 +127,11 @@ enum Commands {         /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)         #[arg(long, default_value = ".")]         out_dir: PathBuf,+        /// KiCad format tier: 8 (default — loads in KiCad 8/9/10),+        /// 10 (native KiCad 10; NOT readable by KiCad 9 or older),+        /// 6 (legacy tools, e.g. Fusion 360 import)+        #[arg(long, default_value = "8")]+        kicad_version: String,     },     /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).     AddToSchlib {@@ -215,10 +210,41 @@ struct ParsedSymbol {     pins: Vec<KicadPin>,     body: Option<KicadBody>,     group_labels: Vec<GroupLabel>,-    graphics: Vec<SymGraphic>, } +// TODO(no-regex-sexpr): this .kicad_sym parsing (and the pad/footprint-name+// extraction + `check` pin scan below) is regex-based. The designated+// replacement already exists and is tested: `altium_codec::sexpr` — a+// KiCad-parser-inspired s-expression reader (strict paren/string balance with+// line-numbered errors, `doc_version()` for KiCad's m_requiredVersion-style+// gates like "~ = empty only before 20250318", Sx tree helpers). Port these+// sites onto it and drop the regex-lite dependency. Not converted yet per+// barrett (2026-07-06) — the multi-symbol guard below stays until then. fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+    // This parser is regex-based and file-global: with more than one symbol in+    // the library it would silently merge every symbol's pins/body into one+    // garbage part. Detect that case and refuse. Sub-units (`NAME_0_1`) of a+    // single parent are fine — they belong to the same part.+    {+        let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();+        let unit_re = regex_lite::Regex::new(r"^(.*)_\d+_\d+$").unwrap();+        let mut parents: Vec<String> = Vec::new();+        for c in re.captures_iter(content) {+            let n = c.get(1).map(|m| m.as_str()).unwrap_or("");+            // unit names are `<parent>_<unit>_<style>` — fold onto the parent+            let base = unit_re.captures(n).and_then(|c| c.get(1)).map(|m| m.as_str()).unwrap_or(n);+            if !parents.iter().any(|p| p == base) {+                parents.push(base.to_string());+            }+        }+        if parents.len() > 1 {+            return Err(format!(+                "this .kicad_sym is a multi-symbol library ({}); extract the one symbol you want first — symbols: {}",+                parents.len(),+                parents.join(", ")+            ));+        }+    }     let name = if let Some(n) = name_override {         n.to_string()     } else {@@ -261,28 +287,6 @@ fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedS         })     }; -    // The symbol's own drawing: (polyline (pts (xy …))) and (arc (start)(mid)(end)).-    let mut graphics: Vec<SymGraphic> = Vec::new();-    let xy_re = regex_lite::Regex::new(r#"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();-    for b in sexpr_blocks(content, "polyline") {-        let pts: Vec<(f64, f64)> = xy_re.captures_iter(b)-            .map(|c| (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)))-            .collect();-        if pts.len() >= 2 { graphics.push(SymGraphic::Poly(pts)); }-    }-    let arc_re = regex_lite::Regex::new(-        r#"(?s)\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\).*?\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\).*?\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#-    ).unwrap();-    for b in sexpr_blocks(content, "arc") {-        if let Some(c) = arc_re.captures(b) {-            graphics.push(SymGraphic::Arc {-                start: (c[1].parse().unwrap_or(0.0), c[2].parse().unwrap_or(0.0)),-                mid: (c[3].parse().unwrap_or(0.0), c[4].parse().unwrap_or(0.0)),-                end: (c[5].parse().unwrap_or(0.0), c[6].parse().unwrap_or(0.0)),-            });-        }-    }-     let mut group_labels = Vec::new();     let text_re = regex_lite::Regex::new(         r#"\(text "([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)"#@@ -312,12 +316,20 @@ fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedS         pins,         body,         group_labels,-        graphics,     }) }  // ── EAGLE .lbr parser (import) ── +// TODO(no-regex-xml): this whole EAGLE-.lbr section parses XML with regexes,+// which silently breaks on reordered attributes, single quotes, entities,+// comments, CDATA, and multi-line tags. Replace BOTH parsing and writing with+// quick-xml (already a workspace dependency, `serialize` feature enabled):+// event-based reads for <symbol>/<pin>/<smd>/<connect>, and a serde or+// Writer-based emitter instead of the format!()+esc_xml string building below.+// Same direction as the KiCad side, which now has a real s-expression parser+// (altium_codec::sexpr) instead of regexes. Deliberately not converted yet —+// planned change, kept as comment per barrett (2026-07-06). fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {     let pin_re = regex_lite::Regex::new(         r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#@@ -440,7 +452,6 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {         pins,         body,         group_labels: Vec::new(),-        graphics: Vec::new(),     }) } @@ -448,7 +459,13 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {  fn generate_kicad_sym(sym: &ParsedSymbol) -> String {     let mut s = String::with_capacity(4096);-    s.push_str("(kicad_symbol_lib\n  (version 20231120)\n  (generator \"adom-lbr\")\n");+    // Default tier (V8) — see KicadVersion: loads in KiCad 8/9/10.+    let kv = altium_codec::kicad_export::KicadVersion::default();+    s.push_str(&format!(+        "(kicad_symbol_lib\n  (version {})\n  (generator \"adom-lbr\")\n  (generator_version \"{}\")\n",+        kv.sym_version(),+        kv.generator_version().unwrap_or("8.0")+    ));     s.push_str(&format!("  (symbol \"{}\"\n", sym.name));     s.push_str("    (pin_names (offset 1.016))\n");     s.push_str("    (exclude_from_sim no)\n");@@ -482,10 +499,13 @@ fn generate_kicad_sym(sym: &ParsedSymbol) -> String {             270 => 270,             _ => 0,         };+        // "~" meant "unnamed" in old KiCad; since format 20250318 it's a+        // literal tilde — write the real empty string instead.+        let pname = if pin.name == "~" { "" } else { pin.name.as_str() };         s.push_str(&format!(             "      (pin {} line (at {} {} {}) (length {}) (name \"{}\") (number \"{}\"))\n",             pin.pin_type, pin.x, pin.y, kicad_angle, pin.length,-            pin.name.replace('"', "\\\""),+            pname.replace('"', "\\\""),             pin.number.replace('"', "\\\"")         ));     }@@ -496,6 +516,10 @@ fn generate_kicad_sym(sym: &ParsedSymbol) -> String { }  // ── EAGLE .lbr generator ──+// TODO(no-regex-xml): the generator builds XML by string concatenation with+// esc_xml. Move to quick-xml's Writer (attribute escaping, well-formedness by+// construction) together with the parser above. See the comment on+// parse_eagle_lbr; not converted yet per barrett (2026-07-06).  fn esc_xml(s: &str) -> String {     s.replace('&', "&amp;")@@ -537,32 +561,23 @@ fn pin_length_keyword(len: f64) -> &'static str { }  fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {-    // Effective EAGLE pin name: when the KiCad pin name is empty (common on-    // mechanical / 1-pin parts: `(number "1")(name "")`), fall back to the pad-    // NUMBER. Fusion REJECTS `<pin name="">` / `<connect pin="">`, so a name is-    // mandatory. This is the single source of truth for both the <pin> and the-    // <connect> emission, so the fallback fixes both at once.-    let eff: Vec<String> = pins.iter().enumerate().map(|(i, p)| {-        let n = p.name.trim();-        if !n.is_empty() {-            n.to_string()-        } else {-            let num = p.number.trim();-            if !num.is_empty() { num.to_string() } else { format!("P{}", i + 1) }-        }-    }).collect();-    let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();-    for n in &eff {-        *counts.entry(n.clone()).or_insert(0) += 1;+    let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+    for pin in pins {+        *counts.entry(&pin.name).or_insert(0) += 1;     }-    let mut seen: std::collections::HashMap<String, u32> = std::collections::HashMap::new();-    eff.iter().map(|name| {-        if counts[name] == 1 {-            name.clone()+    let mut seen: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+    pins.iter().map(|pin| {+        let total = counts[pin.name.as_str()];+        if total == 1 {+            pin.name.clone()         } else {-            let c = seen.entry(name.clone()).or_insert(0);-            *c += 1;-            if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }+            let n = seen.entry(&pin.name).or_insert(0);+            *n += 1;+            if *n == 1 {+                pin.name.clone()+            } else {+                format!("{}@{}", pin.name, n)+            }         }     }).collect() }@@ -612,369 +627,10 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {         pins,         body: Some(KicadBody { left: -7.62, top: body_h / 2.0, right: 7.62, bottom: -body_h / 2.0 }),         group_labels: Vec::new(),-        graphics: Vec::new(),-    }-}--/// A drawing primitive from the `.kicad_sym` symbol body (KiCad symbol space,-/// Y-up — same as the pins, so it carries through to EAGLE without a Y-flip).-#[derive(Debug, Serialize, Deserialize)]-enum SymGraphic {-    Poly(Vec<(f64, f64)>),-    Arc { start: (f64, f64), mid: (f64, f64), end: (f64, f64) },-}--/// One SMD/thru-hole pad parsed from a `.kicad_mod`, in KiCad coordinates-/// (millimetres, Y-down). `drill` is `Some` only for thru-hole pads.-struct ModPad {-    name: String,-    smd: bool,-    x: f64,-    y: f64,-    dx: f64,-    dy: f64,-    angle: f64,-    drill: Option<f64>,-    /// EAGLE pad shape mapped from the KiCad shape token: "round" | "square" |-    /// "long" | "octagon".-    shape: String,-}--/// Split a `.kicad_mod` into the raw text of each `(<tag> …)` s-expression,-/// balancing parens so multi-line definitions are captured whole. `tag` is the-/// opening token without its paren, e.g. `"pad"` or `"fp_line"`.-fn sexpr_blocks<'a>(km: &'a str, tag: &str) -> Vec<&'a str> {-    let open = format!("({tag}");-    let mut blocks = Vec::new();-    let mut i = 0;-    while let Some(rel) = km[i..].find(&open) {-        let start = i + rel;-        // Require whitespace after the token so "(pads"/"(fp_lines" don't match.-        let after = &km[start + open.len()..];-        if !after.starts_with(|c: char| c.is_whitespace()) {-            i = start + open.len();-            continue;-        }-        let mut depth = 0i32;-        let mut end = start;-        for (k, c) in km[start..].char_indices() {-            match c {-                '(' => depth += 1,-                ')' => {-                    depth -= 1;-                    if depth == 0 {-                        end = start + k + 1;-                        break;-                    }-                }-                _ => {}-            }-        }-        if end > start {-            blocks.push(&km[start..end]);-            i = end;-        } else {-            break;-        }-    }-    blocks-}--/// Parse every real (named) pad out of a `.kicad_mod`, preserving the true-/// per-side positions, sizes, and rotations. Mechanical pads (empty name) and-/// pads missing position/size are skipped.-fn parse_mod_pads(km: &str) -> Vec<ModPad> {-    // Groups: 1/2 = pad name (quoted/bare), 3 = type (smd/thru_hole), 4 = SHAPE-    // (circle/oval/rect/roundrect/…) — the shape word we were previously dropping.-    let name_re = regex_lite::Regex::new(r#"^\(pad\s+(?:"([^"]*)"|(\S+))\s+(\S+)\s+(\S+)"#).unwrap();-    let at_re = regex_lite::Regex::new(r#"\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\s*\)"#).unwrap();-    let size_re = regex_lite::Regex::new(r#"\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\)"#).unwrap();-    let drill_re = regex_lite::Regex::new(r#"\(drill\s+(-?[\d.]+)"#).unwrap();--    let mut pads = Vec::new();-    for block in sexpr_blocks(km, "pad") {-        let Some(nc) = name_re.captures(block) else { continue };-        let name = nc.get(1).or_else(|| nc.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();-        if name.is_empty() {-            continue;-        }-        let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd";-        let Some(at) = at_re.captures(block) else { continue };-        let Some(size) = size_re.captures(block) else { continue };-        let x: f64 = at[1].parse().unwrap_or(0.0);-        let y: f64 = at[2].parse().unwrap_or(0.0);-        let angle: f64 = at.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0.0);-        let dx: f64 = size[1].parse().unwrap_or(0.0);-        let dy: f64 = size[2].parse().unwrap_or(0.0);-        let drill = drill_re.captures(block).and_then(|c| c[1].parse().ok());-        // KiCad pad shape → EAGLE pad shape.-        let shape = match nc.get(4).map(|m| m.as_str()).unwrap_or("") {-            "circle" => "round",-            "rect" => "square",-            "oval" => "long",-            "roundrect" | "custom" => "round",-            _ => "round",-        }.to_string();-        pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape });-    }-    pads-}--/// Map a KiCad pad rotation (degrees) to an EAGLE `rot=` attribute, matching-/// Fusion 360's own library export convention: the KiCad angle maps directly-/// (KiCad 90 → `R90`), so a non-square pad on a QFP/SOIC top/bottom side is-/// oriented along the lead. Rect pads are 180°-symmetric so the Y-flip sense-/// is moot; the direct mapping is what Fusion writes (verified by the Fusion-/// bridge against a round-tripped `.flbr`).-fn smd_rotation(angle: f64) -> &'static str {-    let a = ((angle.round() as i64) % 360 + 360) % 360;-    match a {-        90 => " rot=\"R90\"",-        180 => " rot=\"R180\"",-        270 => " rot=\"R270\"",-        _ => "",-    }-}--/// Bounding box (min_x, min_y, max_x, max_y) of `fp_line` endpoints on the-/// given layer, in KiCad coordinates. Each `fp_line` is extracted as a balanced-/// block and matched against its OWN `(layer …)`, so coordinates never leak-/// across layers. Returns `None` if the layer has no lines.-fn fp_line_bbox(km: &str, layer: &str) -> Option<(f64, f64, f64, f64)> {-    let pt_re = regex_lite::Regex::new(r#"\((?:start|end)\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();-    let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();-    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);-    let mut found = false;-    for block in sexpr_blocks(km, "fp_line") {-        if layer_re.captures(block).map(|c| c[1].to_string()).as_deref() != Some(layer) {-            continue;-        }-        for c in pt_re.captures_iter(block) {-            let x: f64 = c[1].parse().unwrap_or(0.0);-            let y: f64 = c[2].parse().unwrap_or(0.0);-            minx = minx.min(x); miny = miny.min(y);-            maxx = maxx.max(x); maxy = maxy.max(y);-            found = true;-        }-    }-    found.then_some((minx, miny, maxx, maxy))-}--/// Derive the package silkscreen outline bbox from the `.kicad_mod` body.-/// Prefers the silkscreen layer, falls back to fab, then to the pad extents.-fn package_outline(km: &str, pads: &[ModPad]) -> (f64, f64, f64, f64) {-    fp_line_bbox(km, "F.SilkS")-        .or_else(|| fp_line_bbox(km, "F.Fab"))-        .unwrap_or_else(|| {-            let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);-            for p in pads {-                minx = minx.min(p.x - p.dx); miny = miny.min(p.y - p.dy);-                maxx = maxx.max(p.x + p.dx); maxy = maxy.max(p.y + p.dy);-            }-            (minx, miny, maxx, maxy)-        })-}--/// Flatten a 3-point (start → mid → end) circular arc into a polyline of `segs`-/// segments. Falls back to the three literal points when they're collinear.-fn flatten_arc(start: (f64, f64), mid: (f64, f64), end: (f64, f64), segs: usize) -> Vec<(f64, f64)> {-    use std::f64::consts::PI;-    let ((ax, ay), (bx, by), (cx, cy)) = (start, mid, end);-    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));-    if d.abs() < 1e-9 {-        return vec![start, mid, end];-    }-    let ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;-    let uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;-    let r = ((ax - ux).powi(2) + (ay - uy).powi(2)).sqrt();-    let a0 = (ay - uy).atan2(ax - ux);-    let a1 = (cy - uy).atan2(cx - ux);-    let am = (by - uy).atan2(bx - ux);-    let norm = |mut t: f64| { while t < 0.0 { t += 2.0 * PI; } while t >= 2.0 * PI { t -= 2.0 * PI; } t };-    let ccw_end = norm(a1 - a0);           // CCW sweep from start→end-    let ccw_mid = norm(am - a0);           // CCW offset of the mid point-    // If mid lies within the CCW sweep, go CCW; else go CW the other way round.-    let (dir, sweep) = if ccw_mid <= ccw_end { (1.0, ccw_end) } else { (-1.0, 2.0 * PI - ccw_end) };-    (0..=segs).map(|i| {-        let a = a0 + dir * sweep * (i as f64 / segs as f64);-        (ux + r * a.cos(), uy + r * a.sin())-    }).collect()-}--/// Map a KiCad graphics layer to its EAGLE package-layer number. Only silk/fab-/// are drawn in the package outline; copper/mask/paste/courtyard are skipped.-fn kicad_layer_to_eagle(klayer: &str) -> Option<u32> {-    match klayer {-        "F.SilkS" => Some(21),-        "B.SilkS" => Some(22),-        "F.Fab" => Some(51),-        "B.Fab" => Some(52),-        _ => None,-    }-}--/// Transcribe the footprint's REAL silkscreen/fab graphics — `fp_circle`,-/// `fp_rect`, `fp_line`, `fp_arc` — into EAGLE `<circle>`/`<wire>` on the mapped-/// layer (21 tPlace / 51 tDocu / …), Y negated (KiCad Y-down → EAGLE Y-up).-/// Returns "" when the footprint carries no silk/fab graphics, so the caller can-/// fall back to a bbox rectangle.-fn package_graphics(km: &str) -> String {-    let layer_re = regex_lite::Regex::new(r#"\(layer\s+"([^"]+)"\)"#).unwrap();-    let center_re = regex_lite::Regex::new(r#"\(center\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();-    let start_re = regex_lite::Regex::new(r#"\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();-    let mid_re = regex_lite::Regex::new(r#"\(mid\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();-    let end_re = regex_lite::Regex::new(r#"\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap();-    let width_re = regex_lite::Regex::new(r#"\(width\s+(-?[\d.]+)\)"#).unwrap();-    let el_of = |b: &str| layer_re.captures(b).and_then(|c| kicad_layer_to_eagle(&c[1]));-    let w_of = |b: &str| width_re.captures(b).and_then(|c| c[1].parse::<f64>().ok()).unwrap_or(0.127);-    let f = |re: &regex_lite::Regex, b: &str| re.captures(b).map(|c| (c[1].parse::<f64>().unwrap_or(0.0), c[2].parse::<f64>().unwrap_or(0.0)));--    let mut out = String::new();-    for b in sexpr_blocks(km, "fp_circle") {-        let (Some(el), Some((cx, cy)), Some((ex, ey))) = (el_of(b), f(&center_re, b), f(&end_re, b)) else { continue };-        let radius = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt();-        out.push_str(&format!("  <circle x=\"{:.4}\" y=\"{:.4}\" radius=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", cx, -cy, radius, w_of(b), el));-    }-    for b in sexpr_blocks(km, "fp_rect") {-        let (Some(el), Some((x1, y1r)), Some((x2, y2r))) = (el_of(b), f(&start_re, b), f(&end_re, b)) else { continue };-        let (y1, y2, w) = (-y1r, -y2r, w_of(b));-        for (ax, ay, bx, by) in [(x1, y1, x2, y1), (x2, y1, x2, y2), (x2, y2, x1, y2), (x1, y2, x1, y1)] {-            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", ax, ay, bx, by, w, el));-        }-    }-    for b in sexpr_blocks(km, "fp_line") {-        let (Some(el), Some((sx, sy)), Some((ex, ey))) = (el_of(b), f(&start_re, b), f(&end_re, b)) else { continue };-        out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", sx, -sy, ex, -ey, w_of(b), el));     }-    for b in sexpr_blocks(km, "fp_arc") {-        let (Some(el), Some(s), Some(m), Some(e)) = (el_of(b), f(&start_re, b), f(&mid_re, b), f(&end_re, b)) else { continue };-        let w = w_of(b);-        for win in flatten_arc(s, m, e, 12).windows(2) {-            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"{:.4}\" layer=\"{}\"/>\n", win[0].0, -win[0].1, win[1].0, -win[1].1, w, el));-        }-    }-    out-}--/// Resolved `--symbol-art` input. PREFERRED: an adom-symbol OUTLINE CONTRACT-/// (`<mpn>-symbol-outline.mm.svg` + `<mpn>-symbol-outline.json` sidecar) whose-/// polylines are ALREADY placed in the symbol's mm space; the sidecar gives the-/// exact Y-up transform, so it's transcribed 1:1 with zero guessing. Anything-/// else is a `Legacy` raw outline we centre + scale by eye.-enum SymbolArt {-    Contract { mm_svg: String, dx: f64, svg_cy: f64, e_cy: f64 },-    Legacy(String),-}--/// Resolve a `--symbol-art` path. A `*.mm.svg` with a readable `*.json` sidecar-/// is a contract; otherwise legacy.-fn resolve_symbol_art(path: &std::path::Path) -> Result<SymbolArt, String> {-    let content = fs::read_to_string(path).map_err(|e| format!("Cannot read symbol art {}: {}", path.display(), e))?;-    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");-    if name.ends_with(".mm.svg") {-        let json_path = path.with_file_name(name.replace(".mm.svg", ".json"));-        if let Ok(js) = fs::read_to_string(&json_path) {-            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&js) {-                let g = |a: &str, b: &str| v.get(a).and_then(|o| o.get(b)).and_then(|n| n.as_f64());-                if let (Some(svg_cx), Some(svg_cy), Some(e_cx), Some(e_cy)) = (-                    g("placement_svg_mm", "center_x"), g("placement_svg_mm", "center_y"),-                    g("placement_eagle_mm", "center_x"), g("placement_eagle_mm", "center_y"),-                ) {-                    return Ok(SymbolArt::Contract { mm_svg: content, dx: svg_cx - e_cx, svg_cy, e_cy });-                }-            }-        }-        eprintln!("WARN: {name} looks like an outline contract but its .json sidecar is missing/unreadable — falling back to centre+scale guessing.");-    }-    Ok(SymbolArt::Legacy(content))-}--/// Transcribe a contract `.mm.svg` (symbol mm-space, Y-down) to EAGLE `<wire>`s-/// (Y-up), 1:1: `eagle_x = svg_x - dx`, `eagle_y = e_cy - (svg_y - svg_cy)`.-fn contract_wires(mm_svg: &str, dx: f64, svg_cy: f64, e_cy: f64) -> (String, usize) {-    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();-    let mut out = String::new();-    let mut segs = 0usize;-    for c in poly_re.captures_iter(mm_svg) {-        let closed = &c[1] == "polygon";-        let mut pts: Vec<(f64, f64)> = c[2].split_whitespace().filter_map(|p| {-            let mut it = p.split(',');-            let x: f64 = it.next()?.trim().parse().ok()?;-            let y: f64 = it.next()?.trim().parse().ok()?;-            Some((x - dx, e_cy - (y - svg_cy)))-        }).collect();-        if closed { if let Some(&first) = pts.first() { pts.push(first); } }-        for w in pts.windows(2) {-            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));-            segs += 1;-        }-    }-    (out, segs) } -/// Legacy raw outline SVG → wires: centre + scale to ~`scale_frac` of the body's-/// smaller side, decimate in output space so dense sampling collapses.-fn symbol_art_wires(svg: &str, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {-    let poly_re = regex_lite::Regex::new(r#"<(polyline|polygon)\b[^>]*\bpoints="([^"]+)""#).unwrap();-    let mut polys: Vec<Vec<(f64, f64)>> = Vec::new();-    for c in poly_re.captures_iter(svg) {-        let closed = &c[1] == "polygon";-        let mut pts: Vec<(f64, f64)> = c[2].split_whitespace().filter_map(|p| {-            let mut it = p.split(',');-            let x: f64 = it.next()?.trim().parse().ok()?;-            let y: f64 = it.next()?.trim().parse().ok()?;-            Some((x, y))-        }).collect();-        if closed { if let Some(&first) = pts.first() { pts.push(first); } }-        polys.push(pts);-    }-    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);-    for poly in &polys { for &(x, y) in poly {-        minx = minx.min(x); miny = miny.min(y); maxx = maxx.max(x); maxy = maxy.max(y);-    }}-    let (sw, sh) = (maxx - minx, maxy - miny);-    if !(sw > 0.0 && sh > 0.0) { return (String::new(), 0); }-    let (target, cx, cy) = match body {-        Some(b) => {-            let bw = (b.right - b.left).abs();-            let bh = (b.top - b.bottom).abs();-            (bw.min(bh) * scale_frac, (b.left + b.right) / 2.0, (b.top + b.bottom) / 2.0)-        }-        None => (26.0 * scale_frac, 0.0, 0.0),-    };-    let scale = target / sw.max(sh);-    let tol_mm = 0.12;-    let (smx, smy) = ((minx + maxx) / 2.0, (miny + maxy) / 2.0);-    let map = |x: f64, y: f64| (cx + (x - smx) * scale, cy - (y - smy) * scale);-    let mut out = String::new();-    let mut segs = 0usize;-    for poly in &polys {-        let mut kept: Vec<(f64, f64)> = Vec::new();-        let n = poly.len();-        for (i, &(px, py)) in poly.iter().enumerate() {-            let pp = map(px, py);-            if i == 0 || i == n - 1 { kept.push(pp); }-            else if let Some(&last) = kept.last() {-                if ((pp.0 - last.0).powi(2) + (pp.1 - last.1).powi(2)).sqrt() >= tol_mm { kept.push(pp); }-            }-        }-        for w in kept.windows(2) {-            out.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1016\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));-            segs += 1;-        }-    }-    (out, segs)-}--/// Wire block for a resolved symbol-art input.-fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) -> (String, usize) {-    match art {-        SymbolArt::Contract { mm_svg, dx, svg_cy, e_cy } => contract_wires(mm_svg, *dx, *svg_cy, *e_cy),-        SymbolArt::Legacy(svg) => symbol_art_wires(svg, body, scale_frac),-    }-}--fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> String {+fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -992,48 +648,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option     // Packages section — generate a placeholder SMD package from pin positions     xml.push_str("<packages>\n");     xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));--    // Real footprint from the .kicad_mod when given; else a placeholder.-    let mod_pads = fp_content.map(parse_mod_pads).unwrap_or_default();-    if !mod_pads.is_empty() {-        let km = fp_content.unwrap();-        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.name)));-        for p in &mod_pads {-            if p.smd {-                xml.push_str(&format!(-                    "  <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",-                    esc_xml(&p.name), p.x, -p.y, p.dx, p.dy, smd_rotation(p.angle)));-            } else {-                // Round thru-hole pads: real drill + circular diameter (not oblong).-                let drill = p.drill.unwrap_or_else(|| p.dx.min(p.dy) * 0.6);-                let diameter = if p.shape == "round" { p.dx } else { p.dx.max(p.dy) };-                xml.push_str(&format!(-                    "  <pad name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" drill=\"{:.4}\" diameter=\"{:.4}\" shape=\"{}\"{}/>\n",-                    esc_xml(&p.name), p.x, -p.y, drill, diameter, p.shape, smd_rotation(p.angle)));-            }-        }-        // Real silk/fab graphics (circles/lines/arcs); bbox rectangle only as fallback.-        let (minx, miny, maxx, maxy) = package_outline(km, &mod_pads);-        let (sx1, sy1, sx2, sy2) = (minx, -maxy, maxx, -miny);-        let silk = package_graphics(km);-        if silk.is_empty() {-            for (x1, y1, x2, y2) in [(sx1, sy1, sx2, sy1), (sx2, sy1, sx2, sy2), (sx2, sy2, sx1, sy2), (sx1, sy2, sx1, sy1)] {-                xml.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.127\" layer=\"21\"/>\n", x1, y1, x2, y2));-            }-        } else {-            xml.push_str(&silk);-        }-        let mut top = sy2;-        let mut bot = sy1;-        for p in &mod_pads {-            let py = -p.y;-            let r = p.dx.max(p.dy) / 2.0;-            top = top.max(py + r);-            bot = bot.min(py - r);-        }-        xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", top + 0.6));-        xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", bot - 0.6));-    } else {     xml.push_str(&format!("  <description>{} placeholder footprint</description>\n", esc_xml(&sym.name)));      // Generate SMD pads from pin numbers@@ -1078,7 +692,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option      xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"25\" align=\"bottom-center\">&gt;NAME</text>\n", body_h / 2.0 + 0.5));     xml.push_str(&format!("  <text x=\"0\" y=\"{:.3}\" size=\"1.27\" layer=\"27\" align=\"top-center\">&gt;VALUE</text>\n", -body_h / 2.0 - 0.5));-    }      xml.push_str("</package>\n");     xml.push_str("</packages>\n");@@ -1094,30 +707,6 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option         xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.bottom, body.left, body.top));     } -    // The chip drawing. A `--symbol-art` SVG is an explicit OVERRIDE; otherwise-    // transcribe the `.kicad_sym`'s OWN (polyline)/(arc) graphics as raw layer-94-    // wires (KiCad symbol space is Y-up, same as the pins → carried through 1:1).-    if let Some(art) = symbol_art {-        let (wires, _) = symbol_art_block(art, sym.body.as_ref(), symbol_art_scale);-        xml.push_str(&wires);-    } else {-        for g in &sym.graphics {-            match g {-                SymGraphic::Poly(pts) => {-                    for w in pts.windows(2) {-                        xml.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));-                    }-                }-                SymGraphic::Arc { start, mid, end } => {-                    let pts = flatten_arc(*start, *mid, *end, 12);-                    for w in pts.windows(2) {-                        xml.push_str(&format!("  <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1));-                    }-                }-            }-        }-    }-     let name_y = sym.body.as_ref().map(|b| b.top + 2.54).unwrap_or(10.0);     let value_y = sym.body.as_ref().map(|b| b.bottom - 2.54).unwrap_or(-10.0);     xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"95\" align=\"bottom-center\">&gt;NAME</text>\n", name_y));@@ -1227,74 +816,20 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {         errors.push(LintError { code: "device-no-package".into(), message: "Device has no package attribute — Fusion needs a package to show the component".into() });     } -    // Per-symbol pin analysis. Pin names must be NON-EMPTY (Fusion rejects-    // <pin name="">) and UNIQUE WITHIN a symbol. Across symbols, a repeat is-    // LEGAL — e.g. a merged multi-part lib where four 1-pin symbols each have a-    // pin "1" — so the uniqueness check is scoped to each <symbol> block, not-    // the whole file.-    let sym_block_re = regex_lite::Regex::new(r#"(?s)<symbol name="([^"]*)">(.*?)</symbol>"#).unwrap();-    let pin_name_re = regex_lite::Regex::new(r#"<pin name="([^"]*)""#).unwrap();-    let mut sym_pins: std::collections::HashMap<String, std::collections::HashSet<String>> =-        std::collections::HashMap::new();-    for sc in sym_block_re.captures_iter(content) {-        let sname = sc[1].to_string();-        let body = &sc[2];-        let mut within: std::collections::HashMap<String, u32> = std::collections::HashMap::new();-        let mut set = std::collections::HashSet::new();-        for pc in pin_name_re.captures_iter(body) {-            let pname = pc[1].to_string();-            if pname.trim().is_empty() {-                errors.push(LintError {-                    code: "empty-pin-name".into(),-                    message: format!("Symbol '{}' has a <pin name=\"\"> — Fusion rejects empty pin names (fall back to the pad number)", sname),-                });-                continue;-            }-            *within.entry(pname.clone()).or_insert(0) += 1;-            set.insert(pname);-        }-        for (name, count) in &within {-            if *count > 1 {-                errors.push(LintError {-                    code: "duplicate-pin-name".into(),-                    message: format!("Pin name '{}' appears {} times in symbol '{}' — EAGLE requires unique pin names within a symbol (use @suffix)", name, count, sname),-                });-            }-        }-        sym_pins.insert(sname, set);+    // Check for duplicate pin names (EAGLE requires unique)+    // TODO(no-regex-xml): these lint scans regex over raw XML too — port to+    // quick-xml along with parse_eagle_lbr (see its comment).+    let pin_re = regex_lite::Regex::new(r#"<pin name="([^"]+)""#).unwrap();+    let mut pin_names = std::collections::HashMap::new();+    for cap in pin_re.captures_iter(content) {+        *pin_names.entry(cap[1].to_string()).or_insert(0u32) += 1;     }--    // Connect integrity: no empty pin=, and every connect's pin must resolve to a-    // named <pin> in its gate's symbol. Resolve per-deviceset (gate names like-    // G$1 repeat across devicesets, so a global map would cross-wire them).-    let deviceset_re = regex_lite::Regex::new(r#"(?s)<deviceset\b.*?</deviceset>"#).unwrap();-    let gate_re = regex_lite::Regex::new(r#"<gate name="([^"]*)" symbol="([^"]*)""#).unwrap();-    let connect_full_re = regex_lite::Regex::new(r#"<connect gate="([^"]*)" pin="([^"]*)" pad="([^"]*)"/>"#).unwrap();-    for ds in deviceset_re.captures_iter(content) {-        let block = &ds[0];-        let mut gate_sym: std::collections::HashMap<String, String> = std::collections::HashMap::new();-        for g in gate_re.captures_iter(block) {-            gate_sym.insert(g[1].to_string(), g[2].to_string());-        }-        for c in connect_full_re.captures_iter(block) {-            let (gate, pin) = (c[1].to_string(), c[2].to_string());-            if pin.trim().is_empty() {-                errors.push(LintError {-                    code: "empty-connect-pin".into(),-                    message: "A <connect> has pin=\"\" — Fusion rejects empty connect pins (map to the gate's pin name)".into(),-                });-                continue;-            }-            if let Some(sym) = gate_sym.get(&gate) {-                if let Some(names) = sym_pins.get(sym) {-                    if !names.contains(&pin) {-                        errors.push(LintError {-                            code: "connect-pin-unresolved".into(),-                            message: format!("Connect pin '{}' (gate '{}') matches no <pin name> in symbol '{}' — Fusion cannot bind it", pin, gate, sym),-                        });-                    }-                }-            }+    for (name, count) in &pin_names {+        if *count > 1 {+            errors.push(LintError {+                code: "duplicate-pin-name".into(),+                message: format!("Pin name '{}' appears {} times — EAGLE requires unique pin names (use @suffix for duplicates)", name, count),+            });         }     } @@ -1388,6 +923,16 @@ fn read_or_exit(path: &std::path::Path) -> String {     }) } +/// Write a file or exit with a clear error — never print `OK: wrote …` for a+/// write that didn't happen.+fn write_or_exit(path: &std::path::Path, bytes: impl AsRef<[u8]>) {+    fs::write(path, bytes).unwrap_or_else(|e| {+        eprintln!("ERROR: write {}: {e}", path.display());+        eprintln!("Hint: check the output directory exists and is writable (mkdir -p first).");+        process::exit(1);+    })+}+ /// Load an adom-lbr JSON into the canonical part. fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {     let json = read_or_exit(path);@@ -1419,7 +964,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale } => {+        Commands::Generate { sym, fp, output, name } => {             let fp_content = fp.as_ref().map(|p| {                 fs::read_to_string(p).unwrap_or_else(|e| {                     eprintln!("ERROR: Cannot read footprint {}: {}", p.display(), e);@@ -1454,12 +999,7 @@ fn main() {             };              let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));-            let art = match symbol_art.as_ref().map(|p| resolve_symbol_art(p)) {-                Some(Ok(a)) => Some(a),-                Some(Err(e)) => { eprintln!("ERROR: {e}"); process::exit(1); }-                None => None,-            };-            let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale);+            let lbr = generate_lbr(&parsed, fp_content.as_deref());              // Lint before writing             let lint = lint_lbr(&lbr);@@ -1649,7 +1189,7 @@ fn main() {             let part = altium_codec::import_kicad(&sym_txt, &fp_txt, &mpn)                 .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });             let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));-            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+            write_or_exit(&outp, serde_json::to_string_pretty(&part).unwrap());             println!("OK: wrote {} (adom-lbr JSON — the canonical hub)", outp.display());             println!("Hint: convert it with `adom-lbr export-altium {}` or `export-intlib`.", outp.display());         }@@ -1664,8 +1204,8 @@ fn main() {                 .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });             let sp = out_dir.join(format!("{}.SchLib", p.mpn));             let pp = out_dir.join(format!("{}.PcbLib", p.mpn));-            fs::write(&sp, &sym_bytes).ok();-            fs::write(&pp, &fp_bytes).ok();+            write_or_exit(&sp, &sym_bytes);+            write_or_exit(&pp, &fp_bytes);             println!("OK: wrote {} ({} pins) + {} ({} pads)",                 sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());         }@@ -1678,20 +1218,25 @@ fn main() {             let bytes = altium_codec::encode_intlib(&symbol, &p.footprint, &descr)                 .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });             let outp = out.unwrap_or_else(|| PathBuf::from(format!("{}.IntLib", p.mpn)));-            fs::write(&outp, &bytes).ok();+            write_or_exit(&outp, &bytes);             println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());         } -        Commands::ExportKicad { part, out_dir } => {+        Commands::ExportKicad { part, out_dir, kicad_version } => {+            let Some(ver) = altium_codec::kicad_export::KicadVersion::parse(&kicad_version) else {+                eprintln!("ERROR: unknown --kicad-version {kicad_version:?}");+                eprintln!("Hint: valid tiers are 8 (default; loads in KiCad 8/9/10), 10 (native KiCad 10 only), 6 (legacy tools).");+                process::exit(1);+            };             let p = load_part(&part);             let mut symbol = p.symbol.clone();             seed_parameters(&mut symbol, &p);-            let sym_txt = altium_codec::export_kicad_symbol(&symbol);-            let fp_txt = altium_codec::export_kicad_footprint(&p.footprint);+            let sym_txt = altium_codec::kicad_export::export_kicad_symbol_for(&symbol, ver);+            let fp_txt = altium_codec::kicad_export::export_kicad_footprint_for(&p.footprint, ver);             let sp = out_dir.join(format!("{}.kicad_sym", p.mpn));             let fp = out_dir.join(format!("{}.kicad_mod", p.mpn));-            fs::write(&sp, sym_txt).ok();-            fs::write(&fp, fp_txt).ok();+            write_or_exit(&sp, sym_txt);+            write_or_exit(&fp, fp_txt);             println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",                 sp.display(), p.symbol.pins.len(), fp.display(),                 p.footprint.pads.len(), p.footprint.graphics.len());@@ -1713,7 +1258,7 @@ fn main() {             let merged = altium_codec::add_symbols_to_schlib(&existing_bytes, &syms)                 .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });             let outp = out.unwrap_or_else(|| PathBuf::from("merged.SchLib"));-            fs::write(&outp, &merged).ok();+            write_or_exit(&outp, &merged);             println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());         } @@ -1739,7 +1284,7 @@ fn main() {             .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });             let name = if part.mpn.is_empty() { "part".to_string() } else { part.mpn.clone() };             let outp = out.unwrap_or_else(|| PathBuf::from(format!("{name}.adom-lbr.json")));-            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();+            write_or_exit(&outp, serde_json::to_string_pretty(&part).unwrap());             println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",                 outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());         }@@ -1747,10 +1292,13 @@ fn main() {         Commands::Install => {             let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());             let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-lbr");-            fs::create_dir_all(&skill_dir).ok();-            fs::write(skill_dir.join("SKILL.md"), SKILL).ok();-            println!("Installed skill: adom-lbr -> {}", skill_dir.display());-            println!("Installed adom-lbr v{}", VERSION.trim());+            fs::create_dir_all(&skill_dir).unwrap_or_else(|e| {+                eprintln!("ERROR: create {}: {e}", skill_dir.display());+                process::exit(1);+            });+            write_or_exit(&skill_dir.join("SKILL.md"), SKILL);+            println!("OK: installed skill adom-lbr -> {}", skill_dir.display());+            println!("OK: installed adom-lbr v{}", VERSION.trim());         }          Commands::Health => {
src/manage.html+350−129
@@ -4,49 +4,80 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>adom-lbr Manager</title>+<script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script> <style>-  :root{--bg:#0d1117;--panel:#171a21;--edge:#222b38;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b1;--dot:#00e6dc}+  :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;flex-direction:column}-  /* Canonical Adom app-shell header (matches adom-symbol / adom-footprint). */-  #appbar{display:flex;align-items:center;gap:10px;padding:12px 18px;border-bottom:1px solid var(--edge);background:var(--panel);flex:0 0 auto}-  #appbar .dot{width:10px;height:10px;border-radius:50%;background:var(--dot);box-shadow:0 0 12px var(--dot)}-  #appbar .brand{font-weight:700}-  #appbar .brand span{color:var(--mut);font-weight:400}-  #appbar .legend{margin-left:auto;display:inline-flex;align-items:center;gap:10px;font-size:11px;color:var(--mut)}-  #appbar .legend .pip{width:9px;height:9px}-  #shell{flex:1;display:flex;min-height:0}-  /* Sidebar: tabs (source) → search + complete toggle → scrolling list. */-  #list{width:340px;min-width:340px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column;min-height:0}-  .tabs{display:flex;gap:2px;padding:8px 8px 0;flex-wrap:wrap}-  .tab{font-size:12px;padding:6px 10px;border-radius:7px 7px 0 0;color:var(--mut);cursor:pointer;border:1px solid transparent;border-bottom:none;white-space:nowrap;max-width:130px;overflow:hidden;text-overflow:ellipsis}-  .tab:hover{color:var(--ink)}-  .tab.sel{color:var(--ink);background:var(--bg);border-color:var(--edge)}-  .srcline{font-size:11px;color:var(--mut);padding:4px 14px;border-bottom:1px solid var(--edge)}-  .ctl{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--edge)}-  #q{flex:1;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}-  .only{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);cursor:pointer;user-select:none;white-space:nowrap}-  .only input{margin:0}+  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}+  .pubsearch{margin-top:6px;border-top:1px solid var(--edge)}+  .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{display:flex;align-items:center;gap:8px;padding:9px 14px;border-bottom:1px solid var(--edge);cursor:pointer}+  .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}   .row:hover{background:#1e222b}-  .row.sel{background:#123331;border-left:3px solid var(--acc);padding-left:11px}-  .row .meta{flex:1;min-width:0}-  .row .mpn{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}-  .row .sub{color:var(--mut);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}-  .pips{display:flex;gap:3px;flex:0 0 auto;cursor:help}-  .pip{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--mut);opacity:.4}-  .pip.on{background:var(--acc);border-color:var(--acc);opacity:1}-  .endmsg{color:var(--mut);text-align:center;font-size:12px;padding:12px}-  /* Viewer */+  .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:auto;padding:18px}   #hint{color:var(--mut);text-align:center;margin-top:20vh}   .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 h3 small{text-transform:none;letter-spacing:0;color:#5b6675;font-weight:400}-  .card iframe.embed{width:100%;height:460px;border:0;background:#161b22;display:block}-  .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;background:#161b22;color:#8b93a3}+  .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}@@ -57,121 +88,311 @@   .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>-  <header id="appbar">-    <span class="dot"></span>-    <span class="brand">adom-lbr <span>· Library Manager</span></span>-    <span class="legend"><span class="pips"><span class="pip on"></span><span class="pip on"></span><span class="pip on"></span></span>= symbol · footprint · 3D present</span>-  </header>-  <div id="shell">-    <aside id="list">-      <div class="tabs" id="tabs"></div>-      <div class="srcline" id="srcline"></div>-      <div class="ctl">-        <input id="q" placeholder="filter / search…">-        <label class="only" title="Only show parts that have symbol + footprint + 3D"><input type="checkbox" id="only">complete</label>+  <aside id="list">+    <h1>adom-lbr <small>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 id="rows"></div>-    </aside>-    <main id="view"><div id="hint">← pick a component to open its viewer</div></main>-  </div>+    </div>+    <div id="rows"></div>+  </aside>+  <main id="view"><div id="hint">← pick a board, then a component, to open its viewer</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'};-const LIMIT=50;-let tabs=[], curTab=null, offset=0, loading=false, done=false, seen=new Set();-const rowsEl=document.getElementById('rows');--function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}-function cssq(s){return String(s).replace(/["\\]/g,'\\$&');}--async function loadTabs(){-  let r; try{ r=await fetch('api/tabs').then(x=>x.json()); }catch(e){ document.getElementById('tabs').innerHTML='<div class="endmsg">failed to load</div>'; return; }-  tabs=r.tabs||[];-  const tb=document.getElementById('tabs'); tb.innerHTML='';-  tabs.forEach(t=>{-    const d=document.createElement('div'); d.className='tab'; d.textContent=t.label; d.title=t.label;-    d.onclick=()=>selectTab(t.key); tb.appendChild(d); t._el=d;-  });-  selectTab((tabs[0]&&tabs[0].key)||'public');+// 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='';+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; }-function srcText(t){-  if(t.source==='local') return 'local container · ~/project/library';-  if(t.key==='public') return 'wiki.adom.inc · public · scroll to browse, type to search';-  if(t.key==='mine') return 'wiki.adom.inc · authored by you';-  return 'wiki.adom.inc · org: '+t.label;+// 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)); }-function selectTab(key){-  curTab=key; offset=0; done=false; loading=false; seen=new Set();-  tabs.forEach(t=>t._el&&t._el.classList.toggle('sel',t.key===key));-  const t=tabs.find(x=>x.key===key);-  document.getElementById('srcline').textContent=t?srcText(t):'';-  rowsEl.innerHTML=''; loadMore();+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||''; 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'); } }--async function loadMore(){-  if(loading||done||!curTab) return; loading=true;-  const q=document.getElementById('q').value.trim();-  const only=document.getElementById('only').checked?1:0;-  const url=`api/list?tab=${encodeURIComponent(curTab)}&offset=${offset}&limit=${LIMIT}&q=${encodeURIComponent(q)}&complete=${only}`;-  let r; try{ r=await fetch(url).then(x=>x.json()); }catch(e){ loading=false; return; }-  const items=r.items||[];-  const fresh=items.filter(it=>!seen.has(it.token)); fresh.forEach(it=>seen.add(it.token));-  fresh.forEach(addRow);-  offset = (typeof r.next==='number') ? r.next : offset+items.length;-  done = !r.more;-  loading=false;-  fillPips(fresh);-  if(done){-    if(!rowsEl.querySelector('.row')) rowsEl.insertAdjacentHTML('beforeend','<div class="endmsg">no components</div>');-    else rowsEl.insertAdjacentHTML('beforeend','<div class="endmsg">— end —</div>');-  } else if(rowsEl.scrollHeight<=rowsEl.clientHeight+40){ loadMore(); } // fill the viewport+function render(){ boardSlug?renderDeps():renderBoards(); }+function renderBoards(){+  document.getElementById('toolbar').style.display='none';selected.clear();+  document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';+  document.getElementById('q').placeholder='filter projects…';+  const q=qval(),el=rowsEl();el.innerHTML='';+  const boards=all.filter(isBoard);+  const mineAll=boards.filter(c=>c.owner&&c.owner===me);+  const pubAll=boards.filter(c=>!(c.owner&&c.owner===me));+  // My projects — a normal list (filtered by the top box)+  const mine=mineAll.filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));+  const h=document.createElement('div');h.className='cathead';h.textContent=`My projects · ${mine.length}`;el.appendChild(h);+  if(!mine.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML='none yet — push a board tagged <b>board</b>/<b>molecule</b> to your wiki, then press ↻';el.appendChild(d);}+  mine.forEach(c=>el.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,openBoard,'board')));+  // Public projects — type a query, press Enter or the button to load matches+  const wrap=document.createElement('div');wrap.className='pubsearch';+  wrap.innerHTML=`<div class="cathead">Public projects</div><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=pubAll.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;}+    m.forEach(c=>pr.appendChild(makeRow(c.slug,c.title||c.slug,c.slug,openBoard,'board')));+  };+  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>`;+  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); }+  catch(e){ curDeps=[]; }+  renderDeps(); }-function addRow(it){-  const d=document.createElement('div'); d.className='row'; d.dataset.token=it.token;-  d.innerHTML=`<div class="meta"><div class="mpn">${esc(it.title)}</div><div class="sub">${esc(it.id)}</div></div>`-    +`<div class="pips" data-token="${esc(it.token)}" title="symbol · footprint · 3D"><div class="pip" data-k="s"></div><div class="pip" data-k="f"></div><div class="pip" data-k="d"></div></div>`;-  d.onclick=()=>openPart(it,d);-  rowsEl.appendChild(d);+// 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'; }-async function fillPips(items){-  if(!items.length) return;-  const ids=items.map(i=>i.token).join(',');-  let r; try{ r=await fetch('api/complete?ids='+encodeURIComponent(ids)).then(x=>x.json()); }catch(e){ return; }-  const c=r.complete||{};-  items.forEach(it=>{-    const box=rowsEl.querySelector(`.pips[data-token="${cssq(it.token)}"]`); if(!box) return;-    const f=c[it.token]||{};-    ['s','f','d'].forEach(k=>{const p=box.querySelector(`.pip[data-k="${k}"]`); if(p)p.classList.toggle('on',!!f[k]);});+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(); }-rowsEl.addEventListener('scroll',()=>{-  if(rowsEl.scrollTop+rowsEl.clientHeight>=rowsEl.scrollHeight-120) loadMore();-});-let qto=null;-document.getElementById('q').oninput=()=>{ clearTimeout(qto); qto=setTimeout(()=>selectTab(curTab),250); };-document.getElementById('only').onchange=()=>selectTab(curTab);--async function openPart(it,el){+function backToBoards(){boardSlug=null;curDeps=[];document.getElementById('q').value='';document.querySelector('#view #hint')||(document.getElementById('view').innerHTML='<div id="hint">← pick a board, then a component, to open its viewer</div>');render();}+async function openPart(slug,el){   document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel'));   if(el)el.classList.add('sel');-  const v=document.getElementById('view'); const tok=it.token;-  v.innerHTML='<div id="hint">loading '+esc(it.id)+'…</div>';-  let p; try{ p=await fetch('api/part/'+tok).then(x=>x.json()); }catch(e){ v.innerHTML='<div class="err">failed to load</div>'; return; }-  const glb=(p&&p.glb)?`<iframe class="embed" src="viewer3d/${tok}" loading="lazy" title="3D"></iframe>`:'<div class="body">no 3D model</div>';-  const layers=(p&&p.layers)||[];-  const rows=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('');+  const v=document.getElementById('view');+  v.innerHTML='<div id="hint">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">${esc((p&&p.mpn)||it.title)} <small>· ${(p&&p.pins)||0} pins · ${(p&&p.pads)||0} pads</small></h2>+    <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small></h2>     <div class="cols">-      <div class="card"><h3>Schematic Symbol <small>· via adom-symbol</small></h3><iframe class="embed" src="embed/symbol/${tok}" loading="lazy" title="symbol"></iframe></div>-      <div class="card"><h3>Footprint <small>· via adom-footprint</small></h3><iframe class="embed" src="embed/footprint/${tok}" loading="lazy" title="footprint"></iframe></div>+      <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>-    ${rows?`<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>`:''}`;+    <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>`; }-loadTabs();+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/manage.rs+565−371
@@ -9,15 +9,17 @@ //!   GET  /api/part/<slug>       {part, symbol_svg, footprint_svg, layers, glb} //!   GET  /glb/<slug>            proxied 3D model (GLB) -use altium_codec::{AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol};+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;+use std::io::{Cursor, Read}; use std::process::Command; use std::sync::Mutex; use tiny_http::{Header, Method, Response, Server};  const WIKI: &str = "https://wiki.adom.inc";+// TODO: detect the signed-in wiki user rather than hard-coding this owner.+const MY_OWNER: &str = "barrett-land";  /// The wiki component list is slow (~8s) but stable — fetch it once, serve /// instantly after. First click blocks; the rest are snappy.@@ -34,345 +36,61 @@ fn cached_list() -> String { }  pub fn run(port: u16) -> Result<()> {-    let server = Server::http(format!("0.0.0.0:{port}"))-        .map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+    // Loopback by default: /api/apply writes files and runs commands on the+    // user's machine via the bridge, so it must not be LAN-reachable.+    // ADOM_LBR_BIND=0.0.0.0 opts back in (e.g. Hydrogen Desktop port-forward).+    let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());+    let server = Server::http(format!("{bind}:{port}"))+        .map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?;     println!("OK: adom-lbr Manager on http://localhost:{port}");     println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");     println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");     for mut req in server.incoming_requests() {         let method = req.method().clone();         let url = req.url().to_string();-        let resp = route(&method, &url);+        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) -> Response<Cursor<Vec<u8>>> {-    let (path, query) = url.split_once('?').unwrap_or((url, ""));-    let q = |key: &str| -> Option<String> {-        query.split('&').find_map(|kv| {-            let (k, v) = kv.split_once('=')?;-            if k == key { Some(urldecode(v)) } else { None }-        })-    };+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/tabs") => json_resp(&tabs_config().to_string()),-        (Method::Get, "/api/list") => {-            let tab = q("tab").unwrap_or_else(|| "public".into());-            let offset = q("offset").and_then(|s| s.parse().ok()).unwrap_or(0usize);-            let limit = q("limit").and_then(|s| s.parse().ok()).unwrap_or(50usize).min(100);-            let search = q("q").unwrap_or_default();-            let complete = q("complete").as_deref() == Some("1");-            json_resp(&list_for_tab(&tab, offset, limit, &search, complete).to_string())+        (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, "/api/complete") => json_resp(&complete_batch(&q("ids").unwrap_or_default()).to_string()),         (Method::Get, p) if p.starts_with("/api/part/") => {-            let (source, id) = parse_token(&p["/api/part/".len()..]);-            json_resp(&get_part(&source, &id).to_string())+            let slug = &p["/api/part/".len()..];+            json_resp(&get_part(slug).to_string())+        }+        (Method::Get, p) if p.starts_with("/api/board/") => {+            let slug = &p["/api/board/".len()..];+            json_resp(&board_deps(slug).to_string())         }         (Method::Get, p) if p.starts_with("/glb/") => {-            let (source, id) = parse_token(&p["/glb/".len()..]);-            match fetch_component_bytes(&source, &id, "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),             }         }-        (Method::Get, p) if p.starts_with("/embed/symbol/") => {-            let (source, id) = parse_token(&p["/embed/symbol/".len()..]);-            embed_view(&source, &id, "sym", "adom-symbol")-        }-        (Method::Get, p) if p.starts_with("/embed/footprint/") => {-            let (source, id) = parse_token(&p["/embed/footprint/".len()..]);-            embed_view(&source, &id, "fp", "adom-footprint")-        }-        (Method::Get, "/js/adom-3d-viewer.min.js") =>-            Response::from_data(VIEWER_JS.to_vec()).with_header(hdr("application/javascript")),-        (Method::Get, "/js/environmentSpecular.env") =>-            Response::from_data(VIEWER_ENV.to_vec()).with_header(hdr("application/octet-stream")),-        (Method::Get, p) if p.starts_with("/viewer3d/") => viewer3d_page(&p["/viewer3d/".len()..]),         _ => Response::from_string("not found").with_status_code(404),     } } -fn urldecode(s: &str) -> String {-    let s = s.replace('+', " ");-    let b = s.as_bytes();-    let mut out = String::new();-    let mut i = 0;-    while i < b.len() {-        if b[i] == b'%' && i + 2 < b.len() {-            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(byte as char); i += 3; continue; }-        }-        out.push(b[i] as char); i += 1;-    }-    out-}-fn urlenc(s: &str) -> String {-    s.bytes().map(|b| match b {-        b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),-        _ => format!("%{:02X}", b),-    }).collect()-}--/// A component is `(source, id)`. Encoded in URLs as `source:id` (one path-/// segment; slugs/MPNs contain no ':' or '/'). A bare token defaults to wiki.-fn parse_token(tok: &str) -> (String, String) {-    let t = tok.trim_end_matches('/');-    match t.split_once(':') {-        Some((sc, id)) => (sc.to_string(), id.to_string()),-        None => ("wiki".to_string(), t.to_string()),-    }-}--// ── Identity (for the Mine / Org tabs) ──-static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None);-fn whoami() -> (String, Vec<String>) {-    if let Some(v) = WHOAMI.lock().unwrap().as_ref() { return v.clone(); }-    let mut res = (String::new(), Vec::new());-    if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {-        if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {-            let name = v.pointer("/data/user/displayName").and_then(|x| x.as_str()).unwrap_or("").to_string();-            let orgs = v.pointer("/data/orgs").and_then(|x| x.as_array())-                .map(|a| a.iter().filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from)).collect())-                .unwrap_or_default();-            res = (name, orgs);-        }-    }-    *WHOAMI.lock().unwrap() = Some(res.clone());-    res-}--// ── Local library: the standard $HOME/project/library (fallback chip-fetcher) ──-fn library_root() -> Option<std::path::PathBuf> {-    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());-    for c in [std::env::var("ADOM_LIBRARY").ok(),-              Some(format!("{home}/project/library")),-              Some(format!("{home}/project/chip-fetcher/library"))].into_iter().flatten() {-        let pb = std::path::PathBuf::from(&c);-        if pb.is_dir() && !local_scan(&pb).is_empty() { return Some(pb); }-    }-    None-}-fn local_scan(root: &std::path::Path) -> Vec<String> {-    let mut out = Vec::new();-    if let Ok(rd) = std::fs::read_dir(root) {-        for e in rd.flatten() {-            let pb = e.path();-            if pb.is_dir() {-                let mpn = e.file_name().to_string_lossy().to_string();-                if pb.join(format!("{mpn}.kicad_sym")).exists() || pb.join(format!("{mpn}.kicad_mod")).exists() {-                    out.push(mpn);-                }-            }-        }-    }-    out.sort();-    out-}-fn local_components() -> Vec<String> { library_root().map(|r| local_scan(&r)).unwrap_or_default() }--// ── Per-component files = completeness signal AND the exact filename to fetch ──-#[derive(Clone, Default)]-struct CompFiles { sym: Option<String>, fp: Option<String>, glb: Option<String> }-static FILES_CACHE: Mutex<Option<std::collections::HashMap<String, CompFiles>>> = Mutex::new(None);--fn component_files(source: &str, id: &str) -> CompFiles {-    let key = format!("{source}:{id}");-    if let Some(m) = FILES_CACHE.lock().unwrap().as_ref() { if let Some(c) = m.get(&key) { return c.clone(); } }-    let cf = if source == "local" {-        let root = library_root();-        let pick = |ext: &str| root.as_ref().and_then(|r| {-            let name = format!("{id}.{ext}");-            if r.join(id).join(&name).exists() { Some(name) } else { None }-        });-        CompFiles { sym: pick("kicad_sym"), fp: pick("kicad_mod"), glb: pick("glb") }-    } else {-        let mut cf = CompFiles::default();-        if let Some(body) = curl(&format!("{WIKI}/api/v1/pages/{id}/files")) {-            if let Ok(v) = serde_json::from_slice::<Value>(&body) {-                if let Some(files) = v.get("files").and_then(|f| f.as_array()) {-                    for f in files {-                        let n = f.get("name").and_then(|n| n.as_str()).unwrap_or("");-                        let nl = n.to_lowercase();-                        if nl.contains(".altium.") || nl.contains(".fusion.") { continue; }-                        if nl.ends_with(".kicad_sym") && cf.sym.is_none() { cf.sym = Some(n.to_string()); }-                        else if nl.ends_with(".kicad_mod") && cf.fp.is_none() { cf.fp = Some(n.to_string()); }-                        else if nl.ends_with(".glb") && cf.glb.is_none() { cf.glb = Some(n.to_string()); }-                    }-                }-            }-        }-        cf-    };-    FILES_CACHE.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(key, cf.clone());-    cf-}-fn fetch_component_text(source: &str, id: &str, which: &str) -> Option<String> {-    let cf = component_files(source, id);-    let name = match which { "sym" => cf.sym, "fp" => cf.fp, _ => cf.glb }?;-    if source == "local" { library_root().and_then(|r| std::fs::read_to_string(r.join(id).join(&name)).ok()) }-    else { wiki_text(id, &name) }-}-fn fetch_component_bytes(source: &str, id: &str, which: &str) -> Option<Vec<u8>> {-    let cf = component_files(source, id);-    let name = match which { "sym" => cf.sym, "fp" => cf.fp, _ => cf.glb }?;-    if source == "local" { library_root().and_then(|r| std::fs::read(r.join(id).join(&name)).ok()) }-    else { wiki_bytes(id, &name) }-}-fn complete_batch(ids: &str) -> Value {-    let mut out = serde_json::Map::new();-    for tok in ids.split(',').filter(|s| !s.is_empty()) {-        let (source, id) = parse_token(tok);-        let cf = component_files(&source, &id);-        out.insert(tok.to_string(), json!({"s": cf.sym.is_some(), "f": cf.fp.is_some(), "d": cf.glb.is_some()}));-    }-    json!({ "status": "ok", "complete": out })-}--// ── Tabs + listing ──-fn tabs_config() -> Value {-    let (name, orgs) = whoami();-    let mut tabs = vec![json!({"key":"local","label":"Local","source":"local"})];-    if !name.is_empty() { tabs.push(json!({"key":"mine","label":name,"source":"wiki"})); }-    for org in &orgs { tabs.push(json!({"key":format!("org:{org}"),"label":org,"source":"wiki"})); }-    tabs.push(json!({"key":"public","label":"Public","source":"wiki"}));-    json!({ "status": "ok", "tabs": tabs })-}--static PAGES_CACHE: Mutex<Option<Vec<Value>>> = Mutex::new(None);-fn wiki_pages_all() -> Vec<Value> {-    if let Some(v) = PAGES_CACHE.lock().unwrap().as_ref() { return v.clone(); }-    let mut all: Vec<Value> = Vec::new();-    let mut offset = 0usize;-    loop {-        let body = match curl(&format!("{WIKI}/api/v1/pages?type=component&limit=200&offset={offset}")) { Some(b) => b, None => break };-        let v: Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(_) => break };-        let pages = v.get("pages").and_then(|p| p.as_array()).cloned().unwrap_or_default();-        let n = pages.len();-        all.extend(pages);-        offset += n;-        let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;-        if n == 0 || all.len() >= total || all.len() >= 5000 { break; }-    }-    *PAGES_CACHE.lock().unwrap() = Some(all.clone());-    all-}-fn page_item(pg: &Value) -> Value {-    let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("");-    let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(slug);-    json!({"source":"wiki","id":slug,"token":format!("wiki:{slug}"),"title":title})-}-fn matches(pg: &Value, q: &str) -> bool {-    if q.is_empty() { return true; }-    let s = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_lowercase();-    let t = pg.get("title").and_then(|s| s.as_str()).unwrap_or("").to_lowercase();-    s.contains(q) || t.contains(q)-}--/// List a tab. Local/Mine/Org are bounded (filter the cached set, paginate here).-/// Public browse is server-paged (`/pages` offset); Public search is server-/// relevance (`/search`, top-N). `complete` drops parts missing sym/fp/3d.-/// Total count of a bounded tab (local/mine/org) BEFORE pagination — so the-/// scroll `more` flag is right even when the complete-filter shrinks a page.-fn next_total(tab: &str, ql: &str, name: &str) -> usize {-    if tab == "local" {-        local_components().into_iter().filter(|m| ql.is_empty() || m.to_lowercase().contains(ql)).count()-    } else if tab == "mine" {-        wiki_pages_all().iter().filter(|pg| pg.get("author_name").and_then(|a| a.as_str()) == Some(name) && matches(pg, ql)).count()-    } else if let Some(org) = tab.strip_prefix("org:") {-        wiki_pages_all().iter().filter(|pg| pg.get("org_name").and_then(|a| a.as_str()) == Some(org) && matches(pg, ql)).count()-    } else { 0 }-}--fn list_for_tab(tab: &str, offset: usize, limit: usize, search: &str, complete: bool) -> Value {-    let (name, _) = whoami();-    let ql = search.to_lowercase();-    let (mut items, mut more): (Vec<Value>, bool) = if tab == "local" {-        let all: Vec<Value> = local_components().into_iter()-            .filter(|m| ql.is_empty() || m.to_lowercase().contains(&ql))-            .map(|m| json!({"source":"local","id":m,"token":format!("local:{m}"),"title":m}))-            .collect();-        let total = all.len();-        (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)-    } else if tab == "mine" {-        let all: Vec<Value> = wiki_pages_all().iter()-            .filter(|pg| pg.get("author_name").and_then(|a| a.as_str()) == Some(name.as_str()) && matches(pg, &ql))-            .map(page_item).collect();-        let total = all.len();-        (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)-    } else if let Some(org) = tab.strip_prefix("org:") {-        let all: Vec<Value> = wiki_pages_all().iter()-            .filter(|pg| pg.get("org_name").and_then(|a| a.as_str()) == Some(org) && matches(pg, &ql))-            .map(page_item).collect();-        let total = all.len();-        (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)-    } else if !search.is_empty() {-        // Public SEARCH — server relevance, top-N (no offset paging).-        let mut v = Vec::new();-        if let Some(b) = curl(&format!("{WIKI}/api/v1/search?q={}&type=component&limit={}", urlenc(search), limit.max(50))) {-            if let Ok(j) = serde_json::from_slice::<Value>(&b) {-                if let Some(rs) = j.get("results").and_then(|r| r.as_array()) { for r in rs { v.push(page_item(r)); } }-            }-        }-        (v, false)-    } else {-        // Public BROWSE — server-paged.-        let mut v = Vec::new();-        if let Some(b) = curl(&format!("{WIKI}/api/v1/pages?type=component&limit={limit}&offset={offset}")) {-            if let Ok(j) = serde_json::from_slice::<Value>(&b) {-                if let Some(pgs) = j.get("pages").and_then(|p| p.as_array()) { for pg in pgs { v.push(page_item(pg)); } }-            }-        }-        let got = v.len();-        (v, got == limit)-    };--    // Next offset to request = how many SLOTS this page consumed (before the-    // complete-filter drops any), so scroll pagination stays correct.-    let raw = items.len();-    let next = if tab == "public" && search.is_empty() { offset + raw }-               else if tab == "public" { offset }-               else { offset + limit };-    if complete {-        items.retain(|it| {-            let sc = it.get("source").and_then(|x| x.as_str()).unwrap_or("");-            let id = it.get("id").and_then(|x| x.as_str()).unwrap_or("");-            let cf = component_files(sc, id);-            cf.sym.is_some() && cf.fp.is_some() && cf.glb.is_some()-        });-    }-    let _ = more;-    let more = if tab == "public" && search.is_empty() { raw == limit }-               else if tab == "public" { false }-               else { offset + limit < next_total(tab, &ql, &name) };-    json!({ "status": "ok", "items": items, "more": more, "next": next })-}--/// The canonical Adom Babylon viewer bundle + its HDRI, vendored so the 3D-/// column is self-contained (no CDN, no external host — the strict-CSP-safe way).-const VIEWER_JS: &[u8] = include_bytes!("adom-3d-viewer.min.js");-const VIEWER_ENV: &[u8] = include_bytes!("environmentSpecular.env");--/// A minimal interactive 3D page for one component, iframed by the 3D column.-/// Uses RELATIVE asset paths (`../js/…`, `../glb/…`) so it resolves correctly-/// behind the code-server `/proxy/<port>/` prefix (absolute `/js` would not).-fn viewer3d_page(slug: &str) -> Response<Cursor<Vec<u8>>> {-    let slug = slug.trim_end_matches('/');-    let html = format!(-        "<!doctype html><html><head><meta charset=\"utf-8\">\-<style>html,body{{margin:0;height:100%;background:#161b22;overflow:hidden}}#w{{width:100vw;height:100vh}}</style></head>\-<body><div id=\"w\"></div>\-<script src=\"../js/adom-3d-viewer.min.js\"></script>\-<script>window.addEventListener('load',function(){{try{{window.Adom3DViewer.init(document.getElementById('w'),\-{{modelUrl:'../glb/{slug}',environmentUrl:'../js/environmentSpecular.env',zUp:true,showGround:true,showViewCube:true}});}}\-catch(e){{document.body.innerHTML='<pre style=\"color:#f87171;font:12px monospace;padding:16px\">'+e.message+'</pre>';}}}});</script>\-</body></html>"-    );-    Response::from_string(html).with_header(hdr("text/html; charset=utf-8"))-}- // --------------------------------------------------------------------------- // Wiki access (via curl — no HTTP-client dep) // ---------------------------------------------------------------------------@@ -415,7 +133,13 @@ fn list_components() -> Value {                     }                     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();-                    items.push(json!({"slug": slug, "title": title, "brief": brief}));+                    // tags may arrive as a JSON-string or a real array+                    let tags = pg.get("tags").map(|t| match t.as_str() {+                        Some(s) => serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!([])),+                        None => t.clone(),+                    }).unwrap_or_else(|| json!([]));+                    let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string();+                    items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner}));                 }             }         }@@ -425,71 +149,112 @@ fn list_components() -> Value {             items.push(json!({"slug": s, "title": up(s), "brief": ""}));         }     }-    json!({ "status": "ok", "components": items,+    json!({ "status": "ok", "components": items, "me": MY_OWNER,         "hints": ["click a component to open its viewer"] }) } -/// Render a component's canonical INTERACTIVE viewer by shelling out to the-/// authoritative tool's `embed` (`adom-symbol` / `adom-footprint`) on the wiki-/// source file. Self-contained HTML, iframed by the Manager — no external assets,-/// no home-grown SVG. Requires the tool to be installed (declared as a dep).-fn embed_view(source: &str, id: &str, which: &str, tool: &str) -> Response<Cursor<Vec<u8>>> {-    let page = |msg: &str, code: u16| {-        Response::from_string(format!(-            "<body style='margin:0;background:#0d1117;color:#8b949e;font:13px -apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh'><div>{msg}</div></body>"-        )).with_header(hdr("text/html; charset=utf-8")).with_status_code(code)-    };-    let text = match fetch_component_text(source, id, which) {-        Some(t) if !t.trim_start().starts_with('<') && !t.contains("\"error\"") => t,-        _ => return page(&format!("no {} for {id}", if which == "sym" { "symbol" } else { "footprint" }), 200),-    };-    let ext = if which == "sym" { "kicad_sym" } else { "kicad_mod" };-    let base = std::env::temp_dir().join(format!("adomlbr-mgr-{source}-{id}"));-    let infile = base.with_extension(ext);-    let outfile = base.with_extension("embed.html");-    if std::fs::write(&infile, text.as_bytes()).is_err() { return page("temp write failed", 500); }-    let ok = Command::new(tool)-        .args(["embed", "--file", infile.to_str().unwrap_or(""), "--out", outfile.to_str().unwrap_or("")])-        .output().map(|o| o.status.success()).unwrap_or(false);-    if !ok { return page(&format!("{tool} embed unavailable — is {tool} installed?"), 200); }-    match std::fs::read(&outfile) {-        Ok(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")),-        Err(_) => page("embed read failed", 500),-    }-}--/// Build a component's part (source-aware) for the layer-stackup + counts.-fn get_part(source: &str, id: &str) -> Value {-    let (part, src) = match load_part(source, id) {+/// 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}),+        Err(e) => {+            return json!({"status":"error","error":e,+                "hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})+        }     };-    let cf = component_files(source, id);+    let has_glb = list_page_files(slug).iter().any(|n| n.ends_with(".glb"));     json!({         "status": "ok",-        "id": id,+        "slug": slug,         "mpn": part.mpn,-        "source_kind": src,+        "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": cf.glb.is_some(),+        "glb": has_glb,         "pins": part.symbol.pins.len(),         "pads": part.footprint.pads.len(),     }) } -fn load_part(source: &str, id: &str) -> Result<(AdomLbrPart, &'static str), String> {-    let stem = if source == "local" { id.to_string() } else { up(id) };-    for name in [format!("{stem}.adom-lbr.json"), format!("{stem}.json"), "adom-lbr.json".to_string()] {-        let txt = if source == "local" {-            library_root().and_then(|r| std::fs::read_to_string(r.join(id).join(&name)).ok())-        } else { wiki_text(id, &name) };-        if let Some(txt) = txt {-            if let Ok(p) = serde_json::from_str::<AdomLbrPart>(&txt) { return Ok((p, "adom-lbr json")); }+/// 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![],+            };         }     }-    match (fetch_component_text(source, id, "sym"), fetch_component_text(source, id, "fp")) {-        (Some(sy), Some(m)) => altium_codec::import_kicad(&sy, &m, id).map(|p| (p, "kicad (generated)")).map_err(|e| e.to_string()),-        _ => Err(format!("no adom-lbr JSON or KiCad files for {id}")),+    let items: Vec<Value> = deps.into_iter().map(|s| json!({"slug": s})).collect();+    json!({ "status": "ok", "board": slug, "deps": items,+        "hints": ["deps come from the board's package.json dependencies (same as the wiki dependency graph)"] })+}++/// List the file names on a page (robust to any MPN→filename convention — the+/// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.).+fn list_page_files(slug: &str) -> Vec<String> {+    let body = match curl(&format!("{WIKI}/api/v1/pages/{slug}/files")) {+        Some(b) => b,+        None => return vec![],+    };+    let v: Value = match serde_json::from_slice(&body) {+        Ok(v) => v,+        Err(_) => return vec![],+    };+    v.get("files")+        .and_then(|f| f.as_array())+        .map(|a| {+            a.iter()+                .filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))+                .collect()+        })+        .unwrap_or_default()+}++/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate+/// it from the KiCad files. Discovers the ACTUAL filenames from the page listing+/// (never guesses from the slug). Returns the part + where it came from.+fn load_part(slug: &str, _mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {+    let files = list_page_files(slug);+    let find = |suf: &str| files.iter().find(|n| n.ends_with(suf)).cloned();+    // 1) canonical adom-lbr JSON+    if let Some(name) = find(".adom-lbr.json").or_else(|| find(".adomlbr.json")) {+        if let Some(txt) = wiki_text(slug, &name) {+            match serde_json::from_str::<AdomLbrPart>(&txt) {+                Ok(p) => return Ok((p, "adom-lbr json")),+                Err(e) => return Err(format!("{name} is not valid adom-lbr JSON: {e}")),+            }+        }+    }+    // 2) fall back to the KiCad source files+    match (+        find(".kicad_sym").and_then(|n| wiki_text(slug, &n)),+        find(".kicad_mod").and_then(|n| wiki_text(slug, &n)),+    ) {+        (Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, &up(slug))+            .map(|p| (p, "kicad (generated)"))+            .map_err(|e| e.to_string()),+        _ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")),     } } @@ -633,19 +398,40 @@ fn symbol_svg(sym: &Symbol) -> String {             }         }     }+    // 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 (x2, y2) = (p.x as f64 + dx, p.y as f64 + dy);-        let (x1, y1) = (p.x as f64, fy(p.y as f64));-        // grouped so hover highlights the pin + reads its number/name; the fat-        // transparent line is the (easy-to-hit) hover target.+        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, fy(y2), x1, y1, x2, fy(y2)+            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>",@@ -718,6 +504,414 @@ fn empty_svg(msg: &str) -> String {     format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>") } +// ---------------------------------------------------------------------------+// Add to Library — convert selected components → the user's KiCad libraries,+// delivered to their machine via the Adom Desktop bridge.+// ---------------------------------------------------------------------------++// TODO: detect from the bridge rather than hard-coding this user's home.+const KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";+const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";+const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects";+const SCRATCH: &str = "/tmp/adom-lbr-apply";++const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");++/// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,+/// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the+/// project self-contained: extract the embedded symbols/footprints into a project+/// library, re-point every `lib_id`/footprint reference to it, and write the+/// project-local sym/fp-lib-tables. Then deliver everything to `dest`.+fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {+    let dest = if dest.trim().is_empty() { format!("{KICAD_PROJ_DIR}/{board}") } else { dest.trim().to_string() };+    let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));+    let _ = std::fs::remove_dir_all(&work);+    let _ = std::fs::create_dir_all(&work);+    let mut got = vec![];+    let listing = match curl(&format!("{WIKI}/api/v1/pages/{board}/files")) {+        Some(b) => b,+        None => return (dest, got),+    };+    let v: Value = match serde_json::from_slice(&listing) {+        Ok(v) => v,+        Err(_) => return (dest, got),+    };+    let files = v.get("files").and_then(|f| f.as_array()).cloned().unwrap_or_default();+    let mut names = vec![];+    for f in &files {+        let name = f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).unwrap_or("");+        if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {+            if let Some(bytes) = wiki_bytes(board, name) {+                let base = name.rsplit('/').next().unwrap_or(name).to_string();+                if std::fs::write(format!("{work}/{base}"), bytes).is_ok() {+                    names.push(base);+                }+            }+        }+    }+    if names.is_empty() {+        return (dest, got);+    }+    ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));++    if project_only {+        // self-contain: run the Python helper, which rewrites files in `work`,+        // creates <board>.kicad_sym / .pretty / lib-tables, and reports the split.+        let libnick = board.replace(['/', ' '], "_");+        let script = format!("{work}/_selfcontain.py");+        let _ = std::fs::write(&script, PYTHON_SELFCONTAIN);+        let out = Command::new("python3")+            .arg(&script)+            .arg(&work)+            .arg(&libnick)+            .args(&names)+            .output();+        if let Ok(o) = out {+            if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {+                let root: Vec<String> = v.get("root").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+                let pretty: Vec<String> = v.get("pretty").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+                if !root.is_empty() {+                    ad_send(&root, &dest);+                }+                if !pretty.is_empty() {+                    let pdir = format!("{dest}/{libnick}.pretty");+                    ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));+                    ad_send(&pretty, &pdir);+                }+                got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();+                return (dest, got);+            }+        }+    }++    // personal mode (or self-contain failed): deliver the files as fetched.+    let locals: Vec<String> = names.iter().map(|n| format!("{work}/{n}")).collect();+    ad_send(&locals, &dest);+    (dest, names)+}++/// Single-quote a string for safe interpolation into an `sh -c` command run on+/// the user's machine. Everything is inert inside single quotes except `'`,+/// which becomes the standard `'\''` splice.+fn sh_quote(s: &str) -> String {+    format!("'{}'", s.replace('\'', r"'\''"))+}++/// Library nicknames become file/dir names on the user's machine and are+/// interpolated into shell commands — restrict to a boring identifier charset.+fn valid_lib(s: &str) -> bool {+    !s.is_empty()+        && s.len() <= 64+        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))+}++/// Wiki slugs (`owner/name`): same conservative charset plus `/`.+fn valid_slug(s: &str) -> bool {+    !s.is_empty()+        && s.len() <= 128+        && !s.contains("..")+        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))+}++/// Pull a file off the user's machine to the container; None if it doesn't+/// exist OR the pull failed — callers that overwrite must disambiguate via+/// `ad_file_exists` before treating None as "absent".+fn ad_pull(remote: &str) -> Option<Vec<u8>> {+    let _ = std::fs::create_dir_all(SCRATCH);+    let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();+    Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;+    let base = remote.rsplit('/').next()?;+    std::fs::read(format!("{SCRATCH}/{base}")).ok()+}++/// Send container files to a directory on the user's machine.+fn ad_send(files: &[String], dest: &str) -> bool {+    let args = json!({"filePaths": files, "dest": dest}).to_string();+    Command::new("adom-desktop")+        .args(["send_files", &args])+        .output()+        .map(|o| o.status.success())+        .unwrap_or(false)+}++fn ad_shell(cmd: &str) {+    let args = json!({"command": cmd}).to_string();+    let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();+}++/// Run a shell command on the user's machine and return its captured reply+/// (whole adom-desktop stdout); None if the bridge call itself failed.+fn ad_shell_out(cmd: &str) -> Option<String> {+    let args = json!({"command": cmd}).to_string();+    let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;+    if !out.status.success() {+        return None;+    }+    Some(String::from_utf8_lossy(&out.stdout).to_string())+}++/// Does a file exist on the user's machine? None = couldn't determine+/// (bridge/desktop error) — callers must treat that as "assume it exists".+fn ad_file_exists(path: &str) -> Option<bool> {+    let out = ad_shell_out(&format!("[ -f {} ] && echo __EXISTS__ || echo __ABSENT__", sh_quote(path)))?;+    if out.contains("__EXISTS__") {+        Some(true)+    } else if out.contains("__ABSENT__") {+        Some(false)+    } else {+        None+    }+}++/// Extract the top-level `(symbol "NAME" …)` block (balanced parens) from a+/// single-symbol .kicad_sym → (name, block).+fn extract_symbol_block(t: &str) -> Option<(String, String)> {+    let i = t.find("(symbol \"")?;+    let name = t[i + 9..].split('"').next()?.to_string();+    let b = t.as_bytes();+    let mut depth = 0i32;+    for j in i..b.len() {+        match b[j] {+            b'(' => depth += 1,+            b')' => {+                depth -= 1;+                if depth == 0 {+                    return Some((name, t[i..=j].to_string()));+                }+            }+            _ => {}+        }+    }+    None+}++/// Merge symbol blocks into an existing (or new) `.kicad_sym`, skipping names+/// already present.+fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> String {+    let base = existing.filter(|s| s.contains("kicad_symbol_lib")).unwrap_or_else(|| {+        // Default tier (V8) — this lib lands on the user's desktop, whose+        // KiCad (9.0.9) rejects files newer than itself.+        let kv = altium_codec::kicad_export::KicadVersion::default();+        format!(+            "(kicad_symbol_lib\n\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{}\")\n)\n",+            kv.sym_version(),+            kv.generator_version().unwrap_or("8.0")+        )+    });+    let mut names = std::collections::HashSet::new();+    let mut idx = 0;+    while let Some(p) = base[idx..].find("(symbol \"") {+        let s = idx + p + 9;+        match base[s..].find('"') {+            Some(e) => {+                names.insert(base[s..s + e].to_string());+                idx = s + e;+            }+            None => break,+        }+    }+    let mut add = String::new();+    for (name, block) in blocks {+        if !names.contains(name) {+            add.push('\t');+            add.push_str(block);+            add.push('\n');+        }+    }+    let cut = base.rfind(')').unwrap_or(base.len());+    format!("{}{}{}", &base[..cut], add, &base[cut..])+}++/// POST /api/apply — body {items:[{slug,symLib,fpLib}], target, project?, board?}.+/// Converts each component, merges symbols into the mapped KiCad symbol library,+/// and drops footprints into the footprint `.pretty`, all on the user's machine.+// Live progress for the background apply, polled by the UI via /api/apply-progress.+static PROGRESS: Mutex<Option<Value>> = Mutex::new(None);+fn set_progress(v: Value) {+    *PROGRESS.lock().unwrap() = Some(v);+}+fn get_progress() -> Value {+    PROGRESS.lock().unwrap().clone().unwrap_or_else(|| json!({"stage":"idle","done":true}))+}++/// POST /api/apply — validate, then run the work on a background thread so the UI+/// can poll live stage updates. Returns immediately with `{status:"started"}`.+fn start_apply(body: &str) -> Value {+    let req: Value = match serde_json::from_str(body) {+        Ok(v) => v,+        Err(e) => return json!({"status":"error","error":format!("bad body: {e}")}),+    };+    if req.get("target").and_then(|t| t.as_str()).unwrap_or("kicad") != "kicad" {+        return json!({"status":"error","error":"only KiCad add-to-library is implemented right now"});+    }+    // One apply at a time — concurrent runs share SCRATCH and the PROGRESS cell.+    {+        let p = PROGRESS.lock().unwrap();+        if let Some(v) = p.as_ref() {+            if v.get("done").and_then(|d| d.as_bool()) == Some(false) {+                return json!({"status":"error","error":"an apply is already running",+                              "hint":"poll /api/apply-progress until done:true, then retry"});+            }+        }+    }+    let board = req.get("board").and_then(|b| b.as_str()).map(String::from);+    if let Some(b) = &board {+        if !valid_slug(b) {+            return json!({"status":"error","error":format!("invalid board slug: {b:?}")});+        }+    }+    let mut bad: Vec<String> = vec![];+    let items: Vec<(String, String, String)> = req+        .get("items")+        .and_then(|i| i.as_array())+        .map(|a| {+            a.iter()+                .filter_map(|it| {+                    let slug = it.get("slug").and_then(|s| s.as_str())?.to_string();+                    if slug.is_empty() {+                        return None;+                    }+                    let sym = it.get("symLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();+                    let fp = it.get("fpLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();+                    // These become paths + shell args on the user's machine.+                    if !valid_slug(&slug) || !valid_lib(&sym) || !valid_lib(&fp) {+                        bad.push(slug);+                        return None;+                    }+                    Some((slug, sym, fp))+                })+                .collect()+        })+        .unwrap_or_default();+    if !bad.is_empty() {+        return json!({"status":"error",+                      "error":format!("invalid slug or library name on: {}", bad.join(", ")),+                      "hint":"library names may only contain [A-Za-z0-9_.-]"});+    }+    if items.is_empty() && board.is_none() {+        return json!({"status":"error","error":"no components selected"});+    }+    let dest = req.get("dest").and_then(|d| d.as_str()).unwrap_or("").to_string();+    if dest.contains(['\n', '\r', '\0']) {+        return json!({"status":"error","error":"invalid dest path"});+    }+    let project_only = req.get("project").and_then(|p| p.as_bool()).unwrap_or(false);+    let total = items.len();+    set_progress(json!({"stage":"Starting…","current":0,"total":total,"done":false}));+    std::thread::spawn(move || run_apply(items, board, dest, project_only));+    json!({"status":"started","total":total})+}++/// The actual work — converts + merges + delivers, updating PROGRESS at each step.+fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {+    let _ = std::fs::create_dir_all(SCRATCH);+    use std::collections::BTreeMap;+    let mut by_symlib: BTreeMap<String, Vec<String>> = BTreeMap::new();+    let mut by_fplib: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for (slug, sym, fp) in &items {+        by_symlib.entry(sym.clone()).or_default().push(slug.clone());+        by_fplib.entry(fp.clone()).or_default().push(slug.clone());+    }+    let total = items.len();+    let mut done_n = 0usize;+    let mut added: Vec<Value> = vec![];+    let mut errors: Vec<String> = vec![];+    let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));++    for (symlib, slugs) in &by_symlib {+        let mut blocks: Vec<(String, String)> = vec![];+        for slug in slugs {+            done_n += 1;+            prog(format!("Converting {slug} → {symlib}"), done_n);+            match load_part(slug, &up(slug)) {+                Ok((part, _)) => {+                    if let Some(b) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {+                        blocks.push(b);+                    }+                }+                Err(e) => errors.push(format!("{slug}: {e}")),+            }+        }+        prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);+        let remote = format!("{KICAD_SYM_DIR}/{symlib}.kicad_sym");+        let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());+        // NEVER overwrite a library we failed to read. ad_pull returns None for+        // both "absent" and "pull failed" — only proceed with a fresh library+        // when the desktop confirms the file genuinely doesn't exist.+        if existing.is_none() {+            match ad_file_exists(&remote) {+                Some(false) => {} // genuinely absent — start a fresh library+                Some(true) => {+                    errors.push(format!(+                        "{symlib}.kicad_sym exists on the desktop but could not be pulled — skipped to avoid overwriting it"+                    ));+                    continue;+                }+                None => {+                    errors.push(format!(+                        "could not verify {symlib}.kicad_sym on the desktop (bridge error) — skipped to avoid overwriting it"+                    ));+                    continue;+                }+            }+        }+        let merged = merge_symbols(existing.clone(), &blocks);+        // Never-shrink guard: the merge only appends, so the result must hold at+        // least as many symbols as the library already had.+        let n_syms = |s: &str| s.matches("(symbol \"").count();+        if let Some(ex) = &existing {+            if n_syms(&merged) < n_syms(ex) {+                errors.push(format!("{symlib}.kicad_sym merge would shrink the library — aborted"));+                continue;+            }+        }+        let local = format!("{SCRATCH}/{symlib}.kicad_sym");+        if let Err(e) = std::fs::write(&local, &merged) {+            errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));+            continue;+        }+        if ad_send(&[local], KICAD_SYM_DIR) {+            for (name, _) in &blocks {+                added.push(json!({"symbol": name, "symLib": symlib}));+            }+        } else {+            errors.push(format!("failed to write {symlib}.kicad_sym"));+        }+    }++    for (fplib, slugs) in &by_fplib {+        prog(format!("Delivering footprints → {fplib}.pretty"), total);+        let dest = format!("{KICAD_FP_DIR}/{fplib}.pretty");+        ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));+        let mut files = vec![];+        for slug in slugs {+            if let Ok((part, _)) = load_part(slug, &up(slug)) {+                let name = if part.footprint.name.is_empty() { up(slug) } else { part.footprint.name.clone() };+                let local = format!("{SCRATCH}/{}.kicad_mod", name.replace(['/', ' '], "_"));+                match std::fs::write(&local, export_kicad_footprint(&part.footprint)) {+                    Ok(()) => files.push(local),+                    Err(e) => errors.push(format!("{slug}: failed to stage footprint locally: {e}")),+                }+            }+        }+        if !files.is_empty() && !ad_send(&files, &dest) {+            errors.push(format!("failed to deliver footprints to {fplib}.pretty"));+        }+    }++    let project = match &board {+        Some(b) => {+            prog(if project_only { "Building self-contained project (symbols, footprints, lib-tables)…".into() } else { "Fetching KiCad project files (schematic, PCB, sheets)…".to_string() }, total);+            let (folder, files) = fetch_project_files(b, &dest, project_only);+            json!({"folder": folder, "files": files})+        }+        None => json!(null),+    };++    let result = json!({"status":"ok","count":added.len(),"added":added,"errors":errors,"project":project});+    set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result}));+}+ // --------------------------------------------------------------------------- // HTTP helpers // ---------------------------------------------------------------------------
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)}))
src/server.rs+14−4
@@ -98,17 +98,25 @@ fn state_json() -> String { pub fn run(port: u16) -> Result<()> {     *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());     *CONSOLE.lock().unwrap() = Some(Vec::new());-    let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;+    // Loopback by default: /eval queues JS for the user's browser, /deliver+    // pushes files to their desktop, /shutdown kills the app — none of that+    // should be LAN-reachable. ADOM_LBR_BIND=0.0.0.0 opts back in.+    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}"))?;     eprintln!("[adom-lbr] serving on http://127.0.0.1:{port}");     // app-creator §6a — best-effort HD port registration (internal/proxy-only).     register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)"); +    // Cap POST bodies: the biggest legitimate payload is a large .lbr; anything+    // beyond this is a bug or abuse, not a library.+    const MAX_BODY: u64 = 32 * 1024 * 1024;     for mut req in server.incoming_requests() {         let url = req.url().split('?').next().unwrap_or("/").to_string();         let method = req.method().clone();         let mut body = String::new();         if matches!(method, Method::Post) {-            let _ = req.as_reader().read_to_string(&mut body);+            use std::io::Read;+            let _ = req.as_reader().take(MAX_BODY).read_to_string(&mut body);         }         let resp = route(&method, &url, &body);         let _ = req.respond(resp);@@ -222,8 +230,10 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec                 }             }             if let Ok(id) = rest.parse::<u64>() {-                let map = EVAL_RESULTS.lock().unwrap();-                if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {+                let mut map = EVAL_RESULTS.lock().unwrap();+                // remove, don't get: results are one-shot; leaving them in the+                // map leaks for the life of the server.+                if let Some(r) = map.as_mut().and_then(|m| m.remove(&id)) {                     return json_resp(&json!({ "done": true, "result": r }).to_string());                 }                 return json_resp("{\"done\":false}");