← Commit history

2.6.1: RESTORE Fusion-critical main.rs fixes that 2.6.0 reverted (round pad, fp_circle, empty-pin->number, lint ERRORs, --symbol-art, real-footprint) onto barrett's board-centric Manager. Fusion-reject regression fixed; his Manager kept intact. Manager MERGE (his board nav + my tabs/completeness/scale) to follow.

John Lauer ·5b527fcbc1 ·3mo ago ·parent 8bae059
3 files changed +579−127
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "0.3.0"+version = "2.6.1" 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 @@-0.3.0+2.6.1
src/main.rs+577−125
@@ -37,6 +37,16 @@ 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 {@@ -127,11 +137,6 @@ 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 {@@ -210,41 +215,10 @@ 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 {@@ -287,6 +261,28 @@ 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.]+)"#@@ -316,20 +312,12 @@ 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="([^"]+)"([^/]*)/>"#@@ -452,6 +440,7 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {         pins,         body,         group_labels: Vec::new(),+        graphics: Vec::new(),     }) } @@ -459,13 +448,7 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {  fn generate_kicad_sym(sym: &ParsedSymbol) -> String {     let mut s = String::with_capacity(4096);-    // 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("(kicad_symbol_lib\n  (version 20231120)\n  (generator \"adom-lbr\")\n");     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");@@ -499,13 +482,10 @@ 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,-            pname.replace('"', "\\\""),+            pin.name.replace('"', "\\\""),             pin.number.replace('"', "\\\"")         ));     }@@ -516,10 +496,6 @@ 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;")@@ -561,23 +537,32 @@ fn pin_length_keyword(len: f64) -> &'static str { }  fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {-    let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();-    for pin in pins {-        *counts.entry(&pin.name).or_insert(0) += 1;+    // 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 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()+    let mut seen: std::collections::HashMap<String, u32> = std::collections::HashMap::new();+    eff.iter().map(|name| {+        if counts[name] == 1 {+            name.clone()         } else {-            let n = seen.entry(&pin.name).or_insert(0);-            *n += 1;-            if *n == 1 {-                pin.name.clone()-            } else {-                format!("{}@{}", pin.name, n)-            }+            let c = seen.entry(name.clone()).or_insert(0);+            *c += 1;+            if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }         }     }).collect() }@@ -627,10 +612,369 @@ 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) } -fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {+/// 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 {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -648,6 +992,48 @@ fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {     // 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@@ -692,6 +1078,7 @@ fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {      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");@@ -707,6 +1094,30 @@ fn generate_lbr(sym: &ParsedSymbol, _fp_content: Option<&str>) -> String {         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));@@ -816,20 +1227,74 @@ 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() });     } -    // 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;+    // 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);     }-    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),-            });++    // 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),+                        });+                    }+                }+            }         }     } @@ -923,16 +1388,6 @@ 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);@@ -964,7 +1419,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name } => {+        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale } => {             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);@@ -999,7 +1454,12 @@ fn main() {             };              let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));-            let lbr = generate_lbr(&parsed, fp_content.as_deref());+            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);              // Lint before writing             let lint = lint_lbr(&lbr);@@ -1189,7 +1649,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")));-            write_or_exit(&outp, serde_json::to_string_pretty(&part).unwrap());+            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();             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());         }@@ -1204,8 +1664,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));-            write_or_exit(&sp, &sym_bytes);-            write_or_exit(&pp, &fp_bytes);+            fs::write(&sp, &sym_bytes).ok();+            fs::write(&pp, &fp_bytes).ok();             println!("OK: wrote {} ({} pins) + {} ({} pads)",                 sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());         }@@ -1218,25 +1678,20 @@ 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)));-            write_or_exit(&outp, &bytes);+            fs::write(&outp, &bytes).ok();             println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());         } -        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);-            };+        Commands::ExportKicad { part, out_dir } => {             let p = load_part(&part);             let mut symbol = p.symbol.clone();             seed_parameters(&mut symbol, &p);-            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 sym_txt = altium_codec::export_kicad_symbol(&symbol);+            let fp_txt = altium_codec::export_kicad_footprint(&p.footprint);             let sp = out_dir.join(format!("{}.kicad_sym", p.mpn));             let fp = out_dir.join(format!("{}.kicad_mod", p.mpn));-            write_or_exit(&sp, sym_txt);-            write_or_exit(&fp, fp_txt);+            fs::write(&sp, sym_txt).ok();+            fs::write(&fp, fp_txt).ok();             println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)",                 sp.display(), p.symbol.pins.len(), fp.display(),                 p.footprint.pads.len(), p.footprint.graphics.len());@@ -1258,7 +1713,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"));-            write_or_exit(&outp, &merged);+            fs::write(&outp, &merged).ok();             println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());         } @@ -1284,7 +1739,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")));-            write_or_exit(&outp, serde_json::to_string_pretty(&part).unwrap());+            fs::write(&outp, serde_json::to_string_pretty(&part).unwrap()).ok();             println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",                 outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());         }@@ -1292,13 +1747,10 @@ 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).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());+            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());         }          Commands::Health => {