← All Pull Requests

generate: emit fp_poly as <polygon> (pin-1 marker) and part attributes on <technology> #3

Merged opened by Ray 2026-08-28

Two gaps the fusion-bridge 1.9.232 end-to-end run surfaced, both in the emitter rather than in the component data.

1. fp_poly was never parsed — no pin-1 marker reaches Fusion (#933)

package_graphics() handled fp_circle, fp_rect, fp_line and fp_arc; its own docstring listed exactly those. KiCad draws the pin-1 marker as a small filled polygon on F.SilkS, and the gullwing generator draws the whole F.Fab body outline as one, so both were dropped silently while the geometry looked perfect.

Scope: across 2,540 published component pages, 2,473 (97%) mark pin 1 with an F.SilkS polygon, 24 use only an F.Fab chamfer, 43 (DIP/TO-92/TO-220) draw none and rely on pad-1 position. So one missing shape accounted for essentially the whole library.

Now emitted as EAGLE <polygon> with Y negated, consistent with how pads are already handled:

<polygon width="0.1200" layer="21">
  <vertex x="-2.6000" y="2.4700"/><vertex x="-2.8400" y="2.8000"/><vertex x="-2.3600" y="2.8000"/>
</polygon>

against the KiCad source (-2.6,-2.47) (-2.84,-2.8) (-2.36,-2.8) on SOIC-8_3.9x4.9mm_P1.27mm.

2. Part attributes were never emitted — geometry with nothing orderable

<technologies><technology name=""/></technologies> was hardcoded, so every generated .lbr shipped zero attributes. Confirmed on ray/adr4525 and ray/a4950: no MPN, no MANUFACTURER, no distributor part numbers.

generate now reads a wiki component page.json beside the symbol — the same auto-discovery pinmap.json already uses — or an explicit --part-metadata, and emits:

<technologies>
  <technology name="">
    <attribute name="MPN" value="ADR4525" constant="no"/>
    <attribute name="MANUFACTURER" value="Analog Devices" constant="no"/>
    <attribute name="MOUSER_PN" value="584-ADR4525BRZ-R7" constant="no"/>
    <attribute name="DIGIKEY_PN" value="505-ADR4525BRZ-ND" constant="no"/>
    <attribute name="LCSC_PN" value="C123456" constant="no"/>
  </technology>
</technologies>

Distributor numbers are read from distributor_links as either a bare string or an object with pn / part_number, since both shapes are already in use, and from flat mouser_pn / digikey_pn / lcsc_pn keys. A part with no metadata still generates and emits the previous empty technology, with a hint naming what is missing rather than an error.

Deliberately not changed

eagle_safe_name() already exists and dedup_pin_names() already calls it, so illegal pin names are handled in main — I wrote a duplicate before finding it and removed mine. Worth knowing that the published 2.16.0 binary predates it: EXPOSED PAD survives into the .lbr on 16 published pages built with it, and the newer validate correctly reports illegal-pin-name. Those pages need regenerating, not a code change.

The <technology name="..."> variant structure (baseline vs automotive sharing geometry, each with its own DESCRIPTION) is not implemented — that spec lives in the fusion-part-metadata skill, which I could not read from here, and guessing at the shape seemed worse than leaving it to a follow-up.

Verification

  • ./build.sh 2.18.0 — passes, tests included. Note the repo had pre-existing version drift (VERSION 2.17.1 vs package.json 2.16.0) which build.sh rejects; all three are now 2.18.0.
  • Regression across adr4525, al8843sp-13, ld1117s33ctr, at7456e, mc7805cdtrkg: identical pin counts before/after, all validate and lint clean, polygons and attributes present, zero illegal pin names.
  • The no-metadata path still emits the original empty technology.

What still needs doing by someone with GitHub access

build.sh says to do both halves or the page stops matching the tarball. I could only do the wiki half — git push origin main and adom-wiki pkg publish --org adom still need running. This PR is source only - PRs carry text, so the rebuilt bin/adom-lbr (2.50 MB, verified reporting 2.18.0) is not in it and ships with pkg publish.

Diff Skip to comments (2)

--- a/src/main.rs+++ b/src/main.rs@@ -1,2740 +1,2830 @@⋯ 80 unchanged lines ⋯         /// `pinmap.json` beside the symbol when that file exists.         #[arg(long)]         pin_map: Option<PathBuf>,-        /// Schematic-symbol pin layout policy. `auto` (default): a symbol that-        /// mirrors the physical PACKAGE (pins on all four sides, rotated names)-        /// is re-laid into EE-convention two columns — inputs left, outputs and-        /// bidirectional right, power grouped bottom-left, every name-        /// horizontal, buses contiguous. `two-column` forces the re-layout even-        /// for a left/right source; `keep` preserves the .kicad_sym placement-        /// verbatim.-        #[arg(long, default_value = "auto")]-        symbol_layout: String,-    },-    /// Lint an existing .lbr file for Fusion 360 compatibility-    Lint {-        /// Path to the .lbr file-        path: PathBuf,-    },-    /// Assemble many single-part .lbr files into ONE multi-deviceset library.-    /// Packages and symbols dedupe by content, name collisions rename with a-    /// numeric suffix, lint-failing inputs are refused, and the assembled-    /// document must itself pass lint. Prints a JSON summary.-    Assemble {-        /// Output .lbr path-        #[arg(long, short = 'o')]-        out: PathBuf,-        /// Library description (default: Adom Cloud Library)-        #[arg(long)]-        name: Option<String>,-        /// Input part .lbr files-        inputs: Vec<PathBuf>,-    },-    /// Validate a .lbr file has complete deviceset (symbol + package + connects)-    Validate {-        /// Path to the .lbr file-        path: PathBuf,-    },-    /// Import an EAGLE .lbr and convert to KiCad .kicad_sym-    Import {-        /// Path to the .lbr file-        path: PathBuf,-        /// Output .kicad_sym file path (default: <name>.kicad_sym)-        #[arg(short, long)]-        output: Option<PathBuf>,-    },-    /// Verify a .kicad_sym via service-kicad (headless KiCad validation)-    Check {-        /// Path to the .kicad_sym file-        path: PathBuf,-    },-    /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint-    /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet-    /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki-    /// component page). Assembles from a chip directory's renders + extracted data.-    Embed {-        /// Chip directory (a chip-fetcher library entry) to assemble the viewer from-        #[arg(long)]-        dir: PathBuf,-        /// Output .html path (default: <mpn>-lbr-embed.html in the dir)-        #[arg(long)]-        out: Option<PathBuf>,-    },-    /// Export per-slot interactive viewers (viewers/symbol.html + footprint.html)-    /// with the cross-highlight bus + tooltips + layer toggles, for a wiki-    /// component page's native viewer slots. CAD is rendered from the wiki slug;-    /// pin descriptions come from <MPN>-symbol.extracted.json in --dir.-    ExportViewers {-        /// Wiki page slug (renders symbol/footprint from its CAD)-        #[arg(long)]-        slug: String,-        /// Local chip dir holding <MPN>-symbol.extracted.json (pin type + descriptions)-        #[arg(long)]-        dir: PathBuf,-        /// Output directory for the viewer HTML files (default: ./viewers)-        #[arg(long)]-        out: Option<PathBuf>,-        /// Omit the symbol slot's built-in pin table (use when the page carries its-        /// own pin table, e.g. a rich readme datasheet table, to avoid duplication)-        #[arg(long)]-        no_pin_table: bool,-    },-    /// Export ONE configurable, cross-linked viewer combining symbol + footprint +-    /// 3D + pin-map in a declared layout (columns | quad | single). Which panes and-    /// per-pane options (e.g. the pin-map description column) are configurable via-    /// flags or a page.json `lbrView` block — same spirit as the wiki `sections`-    /// allowlist. Renders CAD from the hub/slug; pin descriptions come from-    /// <MPN>-symbol.extracted.json in --dir.-    View {-        /// Wiki page slug (or hub <MPN>.adom-lbr.json in --dir)-        #[arg(long)]-        slug: String,-        /// Local chip dir holding the hub JSON + <MPN>-symbol.extracted.json + .glb-        #[arg(long)]-        dir: PathBuf,-        /// Output .html path (default: <MPN>-lbr-<layout>.html in --dir)-        #[arg(long)]-        out: Option<PathBuf>,-        /// Layout mode: columns | quad | single-        #[arg(long, default_value = "quad")]-        layout: String,-        /// Comma-separated panes in order (default: all available). e.g. symbol,footprint,3d,pins-        #[arg(long)]-        panes: Option<String>,-        /// Show the pin-map description column (default: on for quad/single, off for columns)-        #[arg(long)]-        description: Option<bool>,-        /// Show the pin-map type roll-up footer (default: on for quad)-        #[arg(long)]-        rollup: Option<bool>,-        /// Read layout/panes/paneOpts from a page.json `lbrView` block (overrides the flags)-        #[arg(long)]-        config: Option<PathBuf>,-    },-    /// Run the live app server (AI-drivable: /state, /load, /eval, /console)-    Serve {-        /// Port to listen on-        #[arg(long, default_value_t = 8784)]-        port: u16,-        /// Optional .lbr to load into the view on startup-        #[arg(long)]-        file: Option<PathBuf>,-    },-    /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the-    /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.-    /// Foreground alias of `dashboard serve --foreground`.-    Manage {-        /// Port to listen on-        #[arg(long, default_value_t = 8785)]-        port: u16,-    },-    /// The adom-lbr Dashboard (the Manager plus live status, settings and the-    /// Hydrogen Dock Bar contract). `dashboard serve --print-url` is what the-    /// dock runs: idempotent, version-aware, self-backgrounding.-    Dashboard {-        #[command(subcommand)]-        cmd: DashboardCmd,-    },-    /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).-    ImportKicad {-        /// Path to the .kicad_sym file-        #[arg(long)]-        sym: PathBuf,-        /// Path to the .kicad_mod footprint file-        #[arg(long)]-        fp: PathBuf,-        /// Manufacturer part number / component id-        #[arg(long, default_value = "PART")]-        mpn: String,-        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)-        #[arg(long)]-        out: Option<PathBuf>,-        /// JSON map from SYMBOL PIN NUMBER to FOOTPRINT PAD NAME, for parts-        /// where the two disagree (a USB-C shield pin "S1" against a pad "SH").-        /// Defaults to `pinmap.json` beside the symbol when that file exists.-        #[arg(long)]-        pin_map: Option<PathBuf>,-    },-    /// Convert an adom-lbr JSON into native Altium .SchLib + .PcbLib (no Altium needed).-    ExportAltium {-        /// Path to the adom-lbr JSON-        part: PathBuf,-        /// Output directory (files named <mpn>.SchLib / <mpn>.PcbLib)-        #[arg(long, default_value = ".")]-        out_dir: PathBuf,-    },-    /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.-    ExportIntlib {-        /// Path to the adom-lbr JSON-        part: PathBuf,-        /// Output .IntLib path (default: <mpn>.IntLib)-        #[arg(long)]-        out: Option<PathBuf>,-    },-    /// Convert an adom-lbr JSON into KiCad .kicad_sym + .kicad_mod.-    ExportKicad {-        /// Path to the adom-lbr JSON-        part: PathBuf,-        /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)-        #[arg(long, default_value = ".")]-        out_dir: PathBuf,-    },-    /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).-    AddToSchlib {-        /// Existing .SchLib to merge into-        existing: PathBuf,-        /// Path to the adom-lbr JSON (or a bare Symbol / array of Symbols)-        part: PathBuf,-        /// Output .SchLib path (default: merged.SchLib)-        #[arg(long)]-        out: Option<PathBuf>,-    },-    /// Convert an Altium .SchLib / .PcbLib / .IntLib into the canonical adom-lbr JSON.-    /// Pass a .SchLib or .PcbLib and the other half via --pcblib/--schlib, or one .IntLib.-    ImportAltium {-        /// Path to a .SchLib, .PcbLib, or .IntLib-        file: PathBuf,-        /// The .PcbLib half (when `file` is a .SchLib)-        #[arg(long)]-        pcblib: Option<PathBuf>,-        /// The .SchLib half (when `file` is a .PcbLib)-        #[arg(long)]-        schlib: Option<PathBuf>,-        /// MPN / component id for the output part (default: the symbol's name)-        #[arg(long, default_value = "")]-        mpn: String,-        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)-        #[arg(long)]-        out: Option<PathBuf>,-    },-    /// Install skill + bash completions-    Install,-    /// Check health-    Health,-    /// Generate shell completions-    Completions {-        #[arg(value_enum)]-        shell: clap_complete::Shell,-    },-}--// ── KiCad .kicad_sym parser ──--#[derive(Debug, Serialize, Deserialize)]-struct KicadPin {-    name: String,-    number: String,-    pin_type: String,-    x: f64,-    y: f64,-    angle: u32,-    length: f64,-}--#[derive(Debug, Serialize, Deserialize)]-struct KicadBody {-    left: f64,-    top: f64,-    right: f64,-    bottom: f64,-}--#[derive(Debug, Serialize, Deserialize)]-struct GroupLabel {-    text: String,-    x: f64,-    y: f64,-}--#[derive(Debug, Serialize, Deserialize)]-struct ParsedSymbol {-    name: String,-    reference: String,-    value: String,-    footprint: String,-    description: String,-    pins: Vec<KicadPin>,-    body: Option<KicadBody>,-    group_labels: Vec<GroupLabel>,-    graphics: Vec<SymGraphic>,-}--pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {-    let name = if let Some(n) = name_override {-        n.to_string()-    } else {-        let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();-        re.captures(content)-            .and_then(|c| c.get(1))-            .map(|m| m.as_str().to_string())-            .ok_or("Cannot find symbol name in .kicad_sym")?-    };--    let mut pins = Vec::new();-    // The pin angle may be an int (`180`) or a float (`180.0`) depending on the-    // KiCad version that wrote the symbol; accept both. The shape/electrical-    // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).-    let pin_re = regex_lite::Regex::new(-        r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#-    ).unwrap();--    for cap in pin_re.captures_iter(content) {-        pins.push(KicadPin {-            pin_type: cap[1].to_string(),-            x: cap[2].parse().unwrap_or(0.0),-            y: cap[3].parse().unwrap_or(0.0),-            angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,-            length: cap[5].parse().unwrap_or(2.54),-            name: cap[6].to_string(),-            number: cap[7].to_string(),-        });-    }--    let body = {-        let rect_re = regex_lite::Regex::new(-            r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#-        ).unwrap();-        rect_re.captures(content).map(|c| KicadBody {-            left: c[1].parse().unwrap_or(0.0),-            top: c[2].parse().unwrap_or(0.0),-            right: c[3].parse().unwrap_or(0.0),-            bottom: c[4].parse().unwrap_or(0.0),-        })-    };--    // 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.]+)"#-    ).unwrap();-    for cap in text_re.captures_iter(content) {-        group_labels.push(GroupLabel {-            text: cap[1].to_string(),-            x: cap[2].parse().unwrap_or(0.0),-            y: cap[3].parse().unwrap_or(0.0),-        });-    }--    let prop = |key: &str| -> String {-        let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();-        re.captures(content)-            .and_then(|c| c.get(1))-            .map(|m| m.as_str().to_string())-            .unwrap_or_default()-    };--    Ok(ParsedSymbol {-        name,-        reference: prop("Reference"),-        value: prop("Value"),-        footprint: prop("Footprint"),-        description: prop("Description"),-        pins,-        body,-        group_labels,-        graphics,-    })-}--// ── EAGLE .lbr parser (import) ──--fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {-    let pin_re = regex_lite::Regex::new(-        r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#-    ).unwrap();--    let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();-    let name = sym_name_re.captures(content)-        .and_then(|c| c.get(1))-        .map(|m| m.as_str().to_string())-        .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;--    let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();-    let description = desc_re.captures(content)-        .and_then(|c| c.get(1))-        .map(|m| m.as_str().to_string())-        .unwrap_or_default()-        .replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")-        .replace("&quot;", "\"").replace("&apos;", "'");--    let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();-    let reference = prefix_re.captures(content)-        .and_then(|c| c.get(1))-        .map(|m| m.as_str().to_string())-        .unwrap_or_else(|| "U".to_string());--    let rect_re = regex_lite::Regex::new(-        r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#-    ).unwrap();--    let mut min_x = f64::MAX;-    let mut min_y = f64::MAX;-    let mut max_x = f64::MIN;-    let mut max_y = f64::MIN;-    let mut has_body = false;-    for cap in rect_re.captures_iter(content) {-        has_body = true;-        let x1: f64 = cap[1].parse().unwrap_or(0.0);-        let y1: f64 = cap[2].parse().unwrap_or(0.0);-        let x2: f64 = cap[3].parse().unwrap_or(0.0);-        let y2: f64 = cap[4].parse().unwrap_or(0.0);-        min_x = min_x.min(x1).min(x2);-        min_y = min_y.min(y1).min(y2);-        max_x = max_x.max(x1).max(x2);-        max_y = max_y.max(y1).max(y2);-    }--    let body = if has_body {-        Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })-    } else {-        None-    };--    let connect_re = regex_lite::Regex::new(-        r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#-    ).unwrap();-    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();-    for cap in connect_re.captures_iter(content) {-        pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());-    }--    let length_val = |kw: &str| -> f64 {-        match kw {-            "point" => 0.0,-            "short" => 2.54,-            "middle" => 5.08,-            "long" => 7.62,-            _ => 2.54,-        }-    };--    let eagle_to_kicad_dir = |d: &str| -> &'static str {-        match d {-            "pwr" => "power_in",-            "in" => "input",-            "out" => "output",-            "io" => "bidirectional",-            "pas" => "passive",-            "nc" => "unconnected",-            "hiz" => "tri_state",-            "oc" => "open_collector",-            _ => "bidirectional",-        }-    };--    let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();--    let mut pins = Vec::new();-    for cap in pin_re.captures_iter(content) {-        let pin_name_raw = cap[1].to_string();-        let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")-            .replace("@4", "").replace("@5", "").replace("@6", "")-            .replace("@7", "").replace("@8", "").replace("@9", "");--        let pad_number = pin_to_pad.get(&pin_name_raw)-            .cloned()-            .unwrap_or_else(|| "0".to_string());--        let angle = rot_re.captures(&cap[6])-            .and_then(|c| c.get(1))-            .and_then(|m| m.as_str().parse::<u32>().ok())-            .unwrap_or(0);--        pins.push(KicadPin {-            name: pin_name,-            number: pad_number,-            pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),-            x: cap[2].parse().unwrap_or(0.0),-            y: cap[3].parse().unwrap_or(0.0),-            angle,-            length: length_val(&cap[4]),-        });-    }--    Ok(ParsedSymbol {-        value: name.clone(),-        name,-        reference,-        footprint: String::new(),-        description,-        pins,-        body,-        group_labels: Vec::new(),-        graphics: Vec::new(),-    })-}--// ── KiCad .kicad_sym generator ──--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");-    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");-    s.push_str("    (in_bom yes)\n    (on_board yes)\n");--    s.push_str(&format!("    (property \"Reference\" \"{}\"\n      (at 0 2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.reference));-    s.push_str(&format!("    (property \"Value\" \"{}\"\n      (at 0 -2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.name));-    s.push_str(&format!("    (property \"Footprint\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",-        sym.footprint.replace('"', "\\\"")-    ));-    s.push_str("    (property \"Datasheet\" \"\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n");-    if !sym.description.is_empty() {-        s.push_str(&format!("    (property \"Description\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",-            sym.description.replace('"', "\\\"")-        ));-    }--    s.push_str(&format!("    (symbol \"{}_0_{}\"\n", sym.name, 1));--    if let Some(body) = &sym.body {-        s.push_str(&format!("      (rectangle (start {} {}) (end {} {})\n        (stroke (width 0.254) (type default))\n        (fill (type background))\n      )\n",-            body.left, body.top, body.right, body.bottom-        ));-    }--    for pin in &sym.pins {-        let kicad_angle = match pin.angle {-            0 => 0,-            90 => 90,-            180 => 180,-            270 => 270,-            _ => 0,-        };-        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('"', "\\\""),-            pin.number.replace('"', "\\\"")-        ));-    }--    s.push_str("    )\n");-    s.push_str("  )\n)\n");-    s-}--// ── EAGLE .lbr generator ──--pub(crate) fn esc_xml(s: &str) -> String {-    s.replace('&', "&amp;")-        .replace('<', "&lt;")-        .replace('>', "&gt;")-        .replace('"', "&quot;")-        .replace('\'', "&apos;")-}--fn pin_direction(kicad_type: &str) -> &'static str {-    match kicad_type {-        "power_in" => "pwr",-        "power_out" => "out",-        "input" => "in",-        "output" => "out",-        "bidirectional" => "io",-        "passive" => "pas",-        "unconnected" => "nc",-        "tri_state" => "hiz",-        "open_collector" | "open_emitter" => "oc",-        _ => "io",-    }-}--fn pin_rotation(angle: u32) -> &'static str {-    match angle {-        180 => " rot=\"R180\"",-        90 => " rot=\"R90\"",-        270 => " rot=\"R270\"",-        _ => "",-    }-}--fn pin_length_keyword(len: f64) -> &'static str {-    if len <= 0.0 { "point" }-    else if len <= 2.54 { "short" }-    else if len <= 5.08 { "middle" }-    else { "long" }-}--/// EAGLE names cannot carry spaces or most punctuation; KiCad pin names can-/// ("GND (TAB)" on a real TI part). Fusion refuses the ENTIRE library over one-/// such name, so every emitted pin name passes through this. Illegal runs-/// collapse to one underscore, trimmed at the ends.-fn eagle_safe_name(name: &str) -> String {-    let mut out = String::with_capacity(name.len());-    let mut last_us = false;-    for ch in name.chars() {-        let ok = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#');-        if ok { out.push(ch); last_us = false; }-        else if !last_us { out.push('_'); last_us = true; }-    }-    let trimmed = out.trim_matches('_').to_string();-    if trimmed.is_empty() { "P".to_string() } else { trimmed }-}--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() {-            eagle_safe_name(n)-        } 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<String, u32> = std::collections::HashMap::new();-    eff.iter().map(|name| {-        if counts[name] == 1 {-            name.clone()-        } else {-            let c = seen.entry(name.clone()).or_insert(0);-            *c += 1;-            if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }-        }-    }).collect()-}--/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.-/// EAGLE requires unique pad names within a package, and Fusion's importer-/// refuses the ENTIRE library over a repeat. KiCad legitimately reuses one pad-/// name across a same-net array (thermal vias, ground paddles, shield tabs), so-/// those come in as correct source data. EAGLE's own convention for one logical-/// pin on several physical pads is NAME, NAME@2, NAME@3, ... with the device's-/// connect naming every pad in the group — emit exactly that, keeping the-/// electrical meaning instead of losing it. Returns the per-pad output names-/// (document order) plus original-name → group map for building the connects.-fn dedup_pad_names(names: &[String]) -> (Vec<String>, std::collections::HashMap<String, Vec<String>>) {-    let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();-    let mut out = Vec::with_capacity(names.len());-    let mut groups: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();-    for n in names {-        let c = counts.entry(n.as_str()).or_insert(0);-        *c += 1;-        let out_name = if *c == 1 { n.clone() } else { format!("{}@{}", n, c) };-        groups.entry(n.clone()).or_default().push(out_name.clone());-        out.push(out_name);-    }-    (out, groups)-}--/// EE-convention symbol re-layout (adom/adom-lbr#5). A .kicad_sym that mirrors-/// the physical package puts pins on all four sides, which forces the top and-/// bottom names to render rotated and scatters logically-related pins to-/// physical corners — a schematic symbol is not a package drawing. Re-lay into-/// two columns: inputs and passives on the LEFT, outputs and bidirectional on-/// the RIGHT, power grouped at the bottom-left, NC at the bottom-right, buses-/// (GPIO0..47, DB0..15) contiguous in numeric order, a blank row between-/// groups, and a body wide enough that opposing names never meet. Every pin-/// name renders horizontal. Returns whether the re-layout was applied.-pub(crate) fn relayout_two_columns(sym: &mut ParsedSymbol, force: bool) -> bool {-    // KiCad pin angles: 0 = points right (pin on the LEFT edge), 180 = left-    // (RIGHT edge), 90 = up (BOTTOM edge), 270 = down (TOP edge). Top/bottom-    // pins are the package-mirror tell.-    let four_sided = sym.pins.iter().any(|p| p.angle == 90 || p.angle == 270);-    if sym.pins.is_empty() || !(force || four_sided) {-        return false;-    }--    // side: 0 = left, 1 = right; rank orders the sections top→bottom in a column-    fn placement(p: &KicadPin) -> (usize, u8) {-        let n = p.name.to_uppercase();-        let power = p.pin_type.starts_with("power")-            || ["VDD", "VCC", "VSS", "GND", "VBAT", "VBUS", "VSYS", "VREG"].iter().any(|k| n.contains(k));-        if power {-            return (0, 9);-        }-        match p.pin_type.as_str() {-            "input" => (0, 0),-            "output" => (1, 0),-            "bidirectional" | "tri_state" | "open_collector" | "open_emitter" => (1, 1),-            "no_connect" | "free" => (1, 9),-            _ => (0, 1), // passive / unspecified / analog-        }-    }--    // Natural sort key so DB2 sorts before DB10 and a bus stays contiguous.-    fn natural(name: &str) -> (String, u64, String) {-        let base = name.trim_end_matches(|c: char| c.is_ascii_digit());-        let num: u64 = name[base.len()..].parse().unwrap_or(0);-        (base.to_uppercase(), num, name.to_string())-    }--    let mut order: Vec<usize> = (0..sym.pins.len()).collect();-    order.sort_by(|&a, &b| {-        let (pa, pb) = (&sym.pins[a], &sym.pins[b]);-        (placement(pa), natural(&pa.name)).cmp(&(placement(pb), natural(&pb.name)))-    });--    // Bus-prefix population, to know which prefix runs are real buses.-    let mut prefix_count: std::collections::HashMap<(usize, u8, String), u32> = std::collections::HashMap::new();-    for p in &sym.pins {-        let (side, rank) = placement(p);-        *prefix_count.entry((side, rank, natural(&p.name).0)).or_insert(0) += 1;-    }--    // Build the two columns; None = blank spacer row between logical groups.-    let mut cols: [Vec<Option<usize>>; 2] = [Vec::new(), Vec::new()];-    let mut last: [Option<(u8, String)>; 2] = [None, None];-    for &i in &order {-        let p = &sym.pins[i];-        let (side, rank) = placement(p);-        let base = natural(&p.name).0;-        if let Some((prev_rank, prev_base)) = &last[side] {-            let bus_boundary = *prev_base != base-                && (prefix_count[&(side, rank, base.clone())] >= 4-                    || prefix_count[&(side, *prev_rank, prev_base.clone())] >= 4);-            if *prev_rank != rank || bus_boundary {-                cols[side].push(None);-            }-        }-        last[side] = Some((rank, base));-        cols[side].push(Some(i));-    }--    // Body wide enough that the longest left + right names never approach each-    // other (~1.6 mm per char at EAGLE's default pin-name size, plus a gap).-    let widest = |col: &Vec<Option<usize>>| {-        col.iter().flatten().map(|&i| sym.pins[i].name.chars().count()).max().unwrap_or(0)-    };-    if std::env::var("ADOM_LBR_DEBUG_LAYOUT").is_ok() {-        eprintln!("DEBUG layout: widest L={} R={}", widest(&cols[0]), widest(&cols[1]));-    }-    let mut w = ((widest(&cols[0]) + widest(&cols[1])) as f64 * 1.6 + 5.08).max(15.24);-    w = (w / 2.54).ceil() * 2.54;--    let rows = cols[0].len().max(cols[1].len());-    let y_top = (((rows.saturating_sub(1)) as f64) * 2.54 / 2.0 / 2.54).ceil() * 2.54;-    for (side, col) in cols.iter().enumerate() {-        for (row, slot) in col.iter().enumerate() {-            if let Some(i) = slot {-                let p = &mut sym.pins[*i];-                p.y = y_top - row as f64 * 2.54;-                p.length = 5.08; // EAGLE "middle": numbers outside, names inside-                if side == 0 {-                    p.x = -(w / 2.0) - 5.08;-                    p.angle = 0;-                } else {-                    p.x = w / 2.0 + 5.08;-                    p.angle = 180;-                }-            }-        }-    }--    sym.body = Some(KicadBody {-        left: -(w / 2.0),-        right: w / 2.0,-        top: y_top + 2.54,-        bottom: y_top - ((rows.saturating_sub(1)) as f64) * 2.54 - 2.54,-    });-    // Source graphics and group labels were positioned for the old layout.-    sym.graphics.clear();-    sym.group_labels.clear();-    true-}--fn first_footprint_name(km: &str) -> Option<String> {-    let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();-    re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())-}--/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no-/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so-/// the resulting EAGLE deviceset has the correct pin/pad names and connects.-fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {-    // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).-    // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").-    let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();-    let mut nums: Vec<String> = Vec::new();-    for c in pad_re.captures_iter(km) {-        let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();-        if !n.is_empty() && !nums.contains(&n) {-            nums.push(n);-        }-    }-    let count = nums.len().max(1);-    let half = (count + 1) / 2;-    let body_h = (half as f64) * 2.54 + 2.54;-    let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {-        let (x, y, angle) = if i < half {-            (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)-        } else {-            (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)-        };-        KicadPin {-            name: num.clone(),-            number: num.clone(),-            pin_type: "passive".into(),-            x, y, angle, length: 2.54,-        }-    }).collect();-    ParsedSymbol {-        name: name.to_string(),-        reference: "U".into(),-        value: name.to_string(),-        footprint: name.to_string(),-        description: format!("{name} (symbol synthesized from footprint pads)"),-        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),-    }-}--pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>) -> String {-    let mut xml = String::with_capacity(8192);--    xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");-    xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");-    xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");-    xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");-    xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");--    // Layers-    xml.push_str(EAGLE_LAYERS);--    xml.push_str("<library>\n");-    xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));--    // 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();-    // Original pad/pin-number name → the @-suffixed pad group it became, for the connects.-    let pad_groups: std::collections::HashMap<String, Vec<String>>;-    if !mod_pads.is_empty() {-        let km = fp_content.unwrap();-        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.name)));-        let pad_names: Vec<String> = mod_pads.iter().map(|p| p.name.clone()).collect();-        let (out_names, groups) = dedup_pad_names(&pad_names);-        pad_groups = groups;-        for (i, p) in mod_pads.iter().enumerate() {-            if p.smd {-                xml.push_str(&format!(-                    "  <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",-                    esc_xml(&out_names[i]), 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(&out_names[i]), 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-    let pad_size = 0.5;-    let pitch = 0.5;-    let pin_count = sym.pins.len();-    let half = (pin_count + 1) / 2;--    let placeholder_names: Vec<String> = sym.pins.iter().map(|p| p.number.clone()).collect();-    let (placeholder_out, groups) = dedup_pad_names(&placeholder_names);-    pad_groups = groups;-    for i in 0..pin_count {-        let (px, py) = if i < half {-            // Left column-            (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)-        } else {-            // Right column-            (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)-        };-        xml.push_str(&format!(-            "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",-            esc_xml(&placeholder_out[i]), px, py, pad_size, pad_size-        ));-    }--    // Package outline-    let body_w = pin_count as f64 * pitch / 2.0 + 1.0;-    let body_h = half as f64 * pitch + 1.0;-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0-    ));-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0-    ));-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0-    ));-    xml.push_str(&format!(-        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",-        -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0-    ));--    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");--    // Symbols section-    xml.push_str("<symbols>\n");-    xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));--    if let Some(body) = &sym.body {-        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));-        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));-        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));-        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));-    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">&gt;VALUE</text>\n", value_y));--    let deduped_names = dedup_pin_names(&sym.pins);-    for (i, pin) in sym.pins.iter().enumerate() {-        xml.push_str(&format!(-            "  <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",-            esc_xml(&deduped_names[i]), pin.x, pin.y,-            pin_length_keyword(pin.length),-            pin_direction(&pin.pin_type),-            pin_rotation(pin.angle)-        ));-    }--    let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);-    let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);-    let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);-    for label in &sym.group_labels {-        let (lx, align) = if label.x > body_center_x {-            (body_right - 1.0, " align=\"right\"")-        } else {-            (body_left + 1.0, "")-        };-        xml.push_str(&format!(-            "  <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",-            lx, label.y, align, esc_xml(&label.text)-        ));-    }--    xml.push_str("</symbol>\n");-    xml.push_str("</symbols>\n");--    // Deviceset with COMPLETE device (package + connects)-    let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };-    xml.push_str("<devicesets>\n");-    xml.push_str(&format!("<deviceset name=\"{}\" prefix=\"{}\">\n", esc_xml(&sym.name), esc_xml(ref_prefix)));-    xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.description)));-    xml.push_str("  <gates>\n");-    xml.push_str(&format!("    <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"/>\n", esc_xml(&sym.name)));-    xml.push_str("  </gates>\n");-    xml.push_str("  <devices>\n");-    xml.push_str(&format!("    <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));-    xml.push_str("      <connects>\n");--    // Generate connect mappings: gate pin name → pad number-    for (i, pin) in sym.pins.iter().enumerate() {-        // The pad reference is the symbol pin's NUMBER, which is the KiCad-        // convention and right for almost every part. A pin map is the escape-        // hatch for the ones where the footprint names that pad something else.-        let pad = pin_map.get(&pin.number).unwrap_or(&pin.number);-        // A same-net pad array (KiCad repeats the name; we emitted NAME, NAME@2,-        // ...) connects this one pin to EVERY pad in the group, space-separated —-        // standard EAGLE multi-pad connect form.-        let pad_ref = match pad_groups.get(pad) {-            Some(group) if group.len() > 1 => group.join(" "),-            Some(group) => group[0].clone(),-            None => pad.clone(),-        };-        xml.push_str(&format!(-            "        <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",-            esc_xml(&deduped_names[i]), esc_xml(&pad_ref)-        ));-    }--    xml.push_str("      </connects>\n");-    xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");-    xml.push_str("    </device>\n");-    xml.push_str("  </devices>\n");-    xml.push_str("</deviceset>\n");-    xml.push_str("</devicesets>\n");--    xml.push_str("</library>\n</drawing>\n</eagle>");-    xml-}--// ── Linter ──--#[derive(Debug, Serialize)]-pub(crate) struct LintResult {-    pub passed: bool,-    pub errors: Vec<LintError>,-    pub warnings: Vec<LintError>,-}--#[derive(Debug, Serialize)]-pub(crate) struct LintError {-    pub code: String,-    pub message: String,-}--pub(crate) fn lint_lbr(content: &str) -> LintResult {-    let mut errors = Vec::new();-    let mut warnings = Vec::new();--    if !content.contains("<packages>") || !content.contains("</packages>") {-        errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });-    }-    if !content.contains("<symbols>") || !content.contains("</symbols>") {-        errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });-    }-    if !content.contains("<devicesets>") || !content.contains("</devicesets>") {-        errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });-    }--    // Check for empty packages. Matching the literal "<packages></packages>" missed-    // a library whose section held only whitespace or a comment, which shipped with-    // zero packages while its .kicad_mod had two perfectly good SMD pads. Ask the-    // real question instead: is there a package in there at all?-    if content.contains("<packages>") && !content.contains("<package name=") {-        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });-    }-    if content.contains("<packages/>") {-        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });-    }--    // Check for empty connects-    if content.contains("<connects/>") || content.contains("<connects></connects>") {-        errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });-    }--    // Check device has a package attribute-    if content.contains("<device name=\"\">") && !content.contains("package=") {-        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;-            }-            if pname.chars().any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#'))) {-                errors.push(LintError {-                    code: "illegal-pin-name".into(),-                    message: format!("Symbol '{}' pin '{}' contains characters an EAGLE name cannot carry (space or punctuation outside _+-./!@$#) — Fusion refuses the whole library over it", sname, pname),-                });-            }-            *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);-    }--    // 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),-                        });-                    }-                }-            }-        }-    }--    // Connect PAD integrity (adom/adom-lbr#9). This was a warning that only looked-    // at <smd>, so it stayed silent on through-hole parts and never failed the run:-    // a USB-C receptacle shipped with <connect pin="SHIELD" pad="S1"/> against a-    // package whose shield pad is "SH", and validate called the library complete.-    // The shield connected to nothing and every visual check still passed, which is-    // exactly the kind of defect that only shows up in hardware.-    //-    // So: an ERROR, counting BOTH <smd> and <pad> (through-hole), resolved against-    // the package the device actually names rather than globally, and the message-    // names the pads nothing connects to, since that is the half that tells you the-    // fix ("SH" is obviously the intended target).-    let package_block_re = regex_lite::Regex::new(r#"(?s)<package name="([^"]*)">(.*?)</package>"#).unwrap();-    let pad_name_re = regex_lite::Regex::new(r#"<(?:smd|pad) name="([^"]+)""#).unwrap();-    let mut pkg_pads: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();-    for pc in package_block_re.captures_iter(content) {-        let pads: Vec<String> = pad_name_re.captures_iter(&pc[2]).map(|c| c[1].to_string()).collect();--        // Duplicate pad names within a package (adom/adom-lbr#10). KiCad allows a-        // same-net pad array to share one name (thermal vias, ground paddles,-        // shield tabs); EAGLE does not, and Fusion's importer refuses the WHOLE-        // library over it. generate resolves these to NAME, NAME@2, ... — so a-        // file that still carries repeats either predates that or came from-        // somewhere else, and it will not import.-        let mut pad_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();-        for p in &pads { *pad_counts.entry(p.as_str()).or_insert(0) += 1; }-        let mut dup_pads: Vec<(&str, u32)> = pad_counts.iter().filter(|(_, c)| **c > 1).map(|(n, c)| (*n, *c)).collect();-        dup_pads.sort();-        for (name, count) in dup_pads {-            errors.push(LintError {-                code: "duplicate-pad-name".into(),-                message: format!(-                    "Pad name '{}' appears {} times in package '{}' — Fusion's importer refuses the \-entire library over duplicate pad names. Use the EAGLE @-suffix convention ({name}, {name}@2, ...) \-with a multi-pad <connect> (adom-lbr generate now emits this).",-                    name, count, &pc[1]-                ),-            });-        }--        pkg_pads.insert(pc[1].to_string(), pads);-    }--    let device_re = regex_lite::Regex::new(r#"(?s)<device name="[^"]*" package="([^"]*)">(.*?)</device>"#).unwrap();-    let connect_pad_re = regex_lite::Regex::new(r#"<connect[^>]*pin="([^"]*)"[^>]*pad="([^"]+)""#).unwrap();-    for dev in device_re.captures_iter(content) {-        let pkg = dev[1].to_string();-        let body = &dev[2];-        let Some(pads) = pkg_pads.get(&pkg) else { continue };   // package missing is its own error-        if pads.is_empty() { continue; }-        let pad_set: std::collections::HashSet<&String> = pads.iter().collect();-        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();-        let mut dangling: Vec<(String, String)> = Vec::new();-        for c in connect_pad_re.captures_iter(body) {-            let pin = c[1].to_string();-            // pad= may be a space-separated group ("17 17@2 17@3"): the standard-            // EAGLE multi-pad connect for one pin on a same-net pad array. Each-            // named pad must resolve.-            for pad in c[2].split_whitespace() {-                let pad = pad.to_string();-                if pad_set.contains(&pad) { used.insert(pad); } else { dangling.push((pin.clone(), pad)); }-            }-        }-        if !dangling.is_empty() {-            // One name, once: a USB-C shield is four tabs all named SH, and-            // "SH, SH, SH, SH" reads like four different problems.-            let mut seen_unref: std::collections::HashSet<&str> = std::collections::HashSet::new();-            let unref: Vec<&str> = pads.iter()-                .filter(|p| !used.contains(*p))-                .map(|p| p.as_str())-                .filter(|p| seen_unref.insert(p))-                .collect();-            for (pin, pad) in &dangling {-                errors.push(LintError {-                    code: "connect-pad-unresolved".into(),-                    message: format!(-                        "pin \"{}\" -> pad \"{}\" (no such pad in package '{}'{}). \-Symbol pin numbers and footprint pad names disagree for this part: supply a pin map \-(generate --pin-map) or rename the pad in the footprint.",-                        pin, pad, pkg,-                        if unref.is_empty() { String::new() }-                        else { format!("; unreferenced pads in package: {}", unref.join(", ")) }-                    ),-                });-            }-        }-    }--    // A published .lbr must not carry <package3d>/urn: that is an account-scoped-    // pointer into the minting user's Autodesk cloud, not embedded geometry, so it-    // resolves to nothing for anyone else. Ship the .step beside the library and let-    // each viewer's Fusion mint its own 3D.-    if content.contains("<package3d") || content.contains("wip_urn") || content.contains("urn:adsk") {-        errors.push(LintError {-            code: "package3d-urn".into(),-            message: "Library carries a <package3d>/Autodesk urn — that is a pointer into the \-minting account's cloud, not geometry, and resolves to nothing for anyone else. Remove it and \-ship the .step alongside the .lbr.".into(),-        });-    }--    // Check XML is well-formed-    if content.matches('<').count() != content.matches('>').count() {-        errors.push(LintError { code: "malformed-xml".into(), message: "Unbalanced XML angle brackets".into() });-    }--    // Fusion-specific checks-    if !content.contains("layer=\"94\"") {-        warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });-    }--    LintResult {-        passed: errors.is_empty(),-        errors,-        warnings,-    }-}--// ── Layers constant ──--const EAGLE_LAYERS: &str = r#"<layers>-<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>-<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>-<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>-<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>-<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>-<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>-<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>-<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>-<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>-<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>-<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>-<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>-<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>-<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>-<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>-<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>-<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>-<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>-<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>-<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>-<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>-<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>-<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>-<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>-<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>-<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>-<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>-<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>-<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>-<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>-<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>-<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>-<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>-<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>-<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>-<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>-<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>-<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>-<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>-<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>-<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>-<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>-<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>-</layers>-"#;--// ── Main ──--/// Read a text file or exit with a clear error.-fn read_or_exit(path: &std::path::Path) -> String {-    fs::read_to_string(path).unwrap_or_else(|e| {-        eprintln!("ERROR: read {}: {e}", path.display());-        process::exit(1);-    })-}--/// Load an adom-lbr JSON into the canonical part.-pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {-    let json = read_or_exit(path);-    serde_json::from_str(&json).unwrap_or_else(|e| {-        eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());-        process::exit(1);-    })-}--/// Seed the symbol's Altium property fields from the part identity (Value drawn;-/// Manufacturer/MPN/Package hidden), skipping empties + names already present.-fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {-    use altium_codec::Parameter;-    let mut push = |name: &str, value: &str, visible: bool| {-        if value.is_empty()-            || symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name))-        {-            return;-        }-        symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });-    };-    push("Value", &part.value, true);-    push("Manufacturer", &part.manufacturer, false);-    push("Manufacturer Part Number", &part.mpn, false);-    push("Package", &part.package, false);-}--fn main() {-    let cli = Cli::parse();--    match cli.command {-        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map, symbol_layout } => {-            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);-                    process::exit(1);-                })-            });--            let mut parsed = match (&sym, &fp_content) {-                (Some(sym_path), _) => {-                    let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {-                        eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);-                        eprintln!("Hint: Check the .kicad_sym path exists.");-                        process::exit(1);-                    });-                    parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {-                        eprintln!("ERROR: {}", e);-                        process::exit(1);-                    })-                }-                (None, Some(fp)) => {-                    // Footprint-only: synthesize a placeable symbol from the pads.-                    let syn_name = name.clone()-                        .or_else(|| first_footprint_name(fp))-                        .unwrap_or_else(|| "FOOTPRINT".to_string());-                    synth_symbol_from_mod(fp, &syn_name)-                }-                (None, None) => {-                    eprintln!("ERROR: Generate needs at least --sym or --fp.");-                    eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");-                    process::exit(1);-                }-            };--            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,-            };-            // Pin map: an explicit --pin-map wins, else pinmap.json beside the-            // symbol, so a page that publishes the sidecar just works.-            let pin_map_path = pin_map.clone().or_else(|| {-                sym.as_ref()-                    .and_then(|p| p.parent())-                    .map(|d| d.join("pinmap.json"))-                    .filter(|p| p.exists())-            });-            let pin_map: std::collections::HashMap<String, String> = match &pin_map_path {-                Some(path) => {-                    let raw = fs::read_to_string(path).unwrap_or_else(|e| {-                        eprintln!("ERROR: Cannot read pin map {}: {}", path.display(), e);-                        process::exit(1);-                    });-                    serde_json::from_str(&raw).unwrap_or_else(|e| {-                        eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);-                        process::exit(1);-                    })-                }-                None => std::collections::HashMap::new(),-            };-            if !pin_map.is_empty() {-                println!("HINT: pin map applied ({} entries) from {}", pin_map.len(),-                    pin_map_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default());-            }--            match symbol_layout.as_str() {-                "keep" => {}-                "auto" | "two-column" => {-                    if art.is_some() {-                        // Symbol art is placed in the source symbol's own-                        // coordinate space; re-laying the pins would orphan it.-                        if symbol_layout == "two-column" {-                            eprintln!("WARN: --symbol-layout two-column ignored: --symbol-art is placed in the source symbol's coordinates");-                        }-                    } else if relayout_two_columns(&mut parsed, symbol_layout == "two-column") {-                        println!("HINT: symbol re-laid into two columns (source mirrored the package: pins on top/bottom). --symbol-layout keep preserves the original.");-                    }-                }-                other => {-                    eprintln!("ERROR: --symbol-layout must be auto, two-column, or keep (got '{}')", other);-                    process::exit(1);-                }-            }--            let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map);--            // Lint before writing-            let lint = lint_lbr(&lbr);-            for err in &lint.errors {-                eprintln!("LINT ERROR: [{}] {}", err.code, err.message);-            }-            for warn in &lint.warnings {-                eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);-            }--            let out_path = output.unwrap_or_else(|| {-                out_default.with_extension("lbr")-            });--            fs::write(&out_path, &lbr).unwrap_or_else(|e| {-                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);-                process::exit(1);-            });--            println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());-            println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());--            if !lint.passed {-                process::exit(2);-            }-        }--        Commands::Lint { path } => {-            let content = fs::read_to_string(&path).unwrap_or_else(|e| {-                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);-                eprintln!("Hint: Check the path exists. Use `ls` to verify.");-                process::exit(1);-            });--            let result = lint_lbr(&content);-            if result.passed {-                println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());-            } else {-                eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());-                for err in &result.errors {-                    eprintln!("  [{}] {}", err.code, err.message);-                }-                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());-            }-            for warn in &result.warnings {-                eprintln!("WARN: [{}] {}", warn.code, warn.message);-            }-            // Also output JSON for programmatic consumers-            let json = serde_json::to_string_pretty(&result).unwrap();-            println!("{}", json);--            if !result.passed {-                process::exit(2);-            }-        }--        Commands::Assemble { out, name, inputs } => {-            assemble::run(&out, name.as_deref(), &inputs);-        }-        Commands::Validate { path } => {-            let content = fs::read_to_string(&path).unwrap_or_else(|e| {-                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);-                process::exit(1);-            });--            let result = lint_lbr(&content);-            if result.passed {-                println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());-            } else {-                eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());-                for err in &result.errors {-                    eprintln!("  [{}] {}", err.code, err.message);-                }-                process::exit(2);-            }-        }--        Commands::Import { path, output } => {-            let content = fs::read_to_string(&path).unwrap_or_else(|e| {-                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);-                process::exit(1);-            });--            let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {-                eprintln!("ERROR: {}", e);-                process::exit(1);-            });--            let kicad_sym = generate_kicad_sym(&parsed);--            let out_path = output.unwrap_or_else(|| {-                path.with_extension("kicad_sym")-            });--            fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {-                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);-                process::exit(1);-            });--            println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());-            println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());-        }--        Commands::Check { path } => {-            let content = fs::read_to_string(&path).unwrap_or_else(|e| {-                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);-                process::exit(1);-            });--            let mut errors: Vec<String> = Vec::new();--            if !content.contains("(kicad_symbol_lib") {-                errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());-            }-            if !content.contains("(symbol \"") {-                errors.push("No (symbol) definitions found".into());-            }--            let pin_count = content.matches("(pin ").count();-            if pin_count == 0 {-                errors.push("No pins found — symbol has no electrical connections".into());-            }--            if !content.contains("(property \"Reference\"") {-                errors.push("Missing Reference property".into());-            }-            if !content.contains("(property \"Value\"") {-                errors.push("Missing Value property".into());-            }--            // Check for duplicate pin names within each symbol-            let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();-            let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();-            for cap in sym_re.captures_iter(&content) {-                *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;-            }-            let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();-            if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {-                let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();-                if !dup_list.is_empty() {-                    // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).-                    // Warn but don't error — EAGLE import will need @N suffixes.-                    eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));-                }-            }--            if errors.is_empty() {-                println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());-            } else {-                eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());-                for e in &errors {-                    eprintln!("  {}", e);-                }-                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());-                process::exit(2);-            }-        }--        Commands::ExportViewers { slug, dir, out, no_pin_table } => {-            let out_dir = out.unwrap_or_else(|| PathBuf::from("viewers"));-            if let Err(e) = manage::export_viewers(&slug, &dir, &out_dir, no_pin_table) {-                eprintln!("ERROR: {e}"); std::process::exit(1);-            }-        }-        Commands::Embed { dir, out } => {-            if let Err(e) = cmd_embed(&dir, out) {-                eprintln!("ERROR: {e:#}");-                process::exit(1);-            }-        }-        Commands::View { slug, dir, out, layout, panes, description, rollup, config } => {-            let (layout, panes_vec, description, rollup) =-                resolve_view_cfg(layout, panes, description, rollup, config.as_deref());-            if let Err(e) = manage::export_composed(&slug, &dir, out, &layout, &panes_vec, description, rollup) {-                eprintln!("ERROR: {e}");-                process::exit(1);-            }-        }-        Commands::Manage { port } => {-            if let Err(e) = manage::run(port) {-                eprintln!("ERROR: {e}");-                process::exit(1);-            }-        }-        Commands::Dashboard { cmd } => match cmd {-            DashboardCmd::Serve { port, print_url, foreground } => {-                // A foreign service on the default port moves us over rather-                // than killing the launch (John's box, 2026-08-25).-                let port = match dock::resolve_port(port) {-                    Ok(p) => p,-                    Err(e) => { eprintln!("ERROR: {e}"); process::exit(2); }-                };-                match dock::serve_entry(port, print_url, foreground) {-                    Ok(true) => {}-                    Ok(false) => {-                        if let Err(e) = manage::run(port) {-                            // Lost a bind race? If it is us after all, this launch still succeeded.-                            if dock::running_version(port).is_some() {-                                if print_url { println!("{}", dock::direct_url(port)); }-                                return;-                            }-                            eprintln!("ERROR: adom-lbr dashboard cannot serve on port {port}: {e}. Something else owns it; pick another with --port.");-                            process::exit(2);-                        }-                    }-                    Err(e) => {-                        eprintln!("ERROR: {e}");-                        process::exit(2);-                    }-                }-            }-            DashboardCmd::Status { port } => {-                let out = process::Command::new("curl").args(["-s", "--max-time", "3", &format!("http://127.0.0.1:{port}/api/status")]).output();-                match out {-                    Ok(o) if o.status.success() && !o.stdout.is_empty() => println!("{}", String::from_utf8_lossy(&o.stdout)),-                    _ => { eprintln!("ERROR: no adom-lbr dashboard answering on port {port}"); eprintln!("Hint: adom-lbr dashboard serve --print-url"); process::exit(2); }-                }-            }-            DashboardCmd::Say => {-                for p in dock::say_phrases() { println!("{p}"); }-            }-        },-        Commands::Serve { port, file } => {-            if let Some(f) = file {-                match fs::read_to_string(&f) {-                    Ok(content) => server::set_state(server::analyze_lbr(&content)),-                    Err(e) => {-                        eprintln!("ERROR: Cannot read {}: {}", f.display(), e);-                        process::exit(1);-                    }-                }-            }-            if let Err(e) = server::run(port) {-                eprintln!("ERROR: {e:#}");-                process::exit(1);-            }-        }--        Commands::ImportKicad { sym, fp, mpn, out, pin_map } => {-            let sym_txt = read_or_exit(&sym);-            let fp_txt = read_or_exit(&fp);-            // Same pin-map convention as generate: explicit --pin-map wins, else-            // a pinmap.json sidecar beside the symbol just works.-            let pin_map_path = pin_map.or_else(|| {-                sym.parent().map(|d| d.join("pinmap.json")).filter(|p| p.exists())-            });-            let pin_map: std::collections::HashMap<String, String> = match &pin_map_path {-                Some(path) => serde_json::from_str(&read_or_exit(path)).unwrap_or_else(|e| {-                    eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);-                    process::exit(1);-                }),-                None => Default::default(),-            };-            if !pin_map.is_empty() {-                println!("HINT: pin map applied ({} entries) from {}", pin_map.len(),-                    pin_map_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default());-            }-            let (part, dropped) = altium_codec::import_kicad_with_pin_map(&sym_txt, &fp_txt, &mpn, &pin_map)-                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });-            if !dropped.is_empty() {-                // A pin that matches no pad is a broken netlist in EVERY export-                // target — say so loudly instead of letting it vanish (adom-lbr#9).-                let referenced: std::collections::HashSet<&str> =-                    part.connect.iter().map(|c| c.pad.as_str()).collect();-                let mut unref: Vec<&str> = part.footprint.pads.iter()-                    .map(|p| p.number.as_str())-                    .filter(|n| !n.is_empty() && !referenced.contains(n))-                    .collect();-                unref.sort();-                unref.dedup();-                eprintln!("WARN: {} symbol pin(s) match no footprint pad and were left out of connect[]: {}",-                    dropped.len(), dropped.join(", "));-                if !unref.is_empty() {-                    eprintln!("      unreferenced pads in footprint: {} — if one of these is the intended target, supply --pin-map (or a pinmap.json sidecar)", unref.join(", "));-                }-            }-            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));-            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());-        }--        Commands::ExportAltium { part, out_dir } => {-            let p = load_part(&part);-            let mut symbol = p.symbol.clone();-            seed_parameters(&mut symbol, &p);-            let sym_bytes = altium_codec::encode_schlib(&[symbol])-                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });-            let fp_bytes = altium_codec::encode_pcblib(&p.footprint)-                .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();-            println!("OK: wrote {} ({} pins) + {} ({} pads)",-                sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());-        }--        Commands::ExportIntlib { part, out } => {-            let p = load_part(&part);-            let mut symbol = p.symbol.clone();-            seed_parameters(&mut symbol, &p);-            let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };-            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();-            println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());-        }--        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::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));-            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());-        }--        Commands::AddToSchlib { existing, part, out } => {-            let existing_bytes = fs::read(&existing)-                .unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", existing.display()); process::exit(1); });-            let json = read_or_exit(&part);-            let syms: Vec<altium_codec::Symbol> =-                if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json) {-                    vec![p.symbol]-                } else if let Ok(v) = serde_json::from_str::<Vec<altium_codec::Symbol>>(&json) {-                    v-                } else {-                    vec![serde_json::from_str::<altium_codec::Symbol>(&json)-                        .unwrap_or_else(|e| { eprintln!("ERROR: parse Symbol/AdomLbrPart: {e}"); process::exit(1); })]-                };-            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();-            println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());-        }--        Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {-            let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();-            let read = |p: &std::path::Path| -> Vec<u8> {-                fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })-            };-            let part = if ext == "intlib" {-                altium_codec::decode_intlib_part(&read(&file), &mpn)-            } else if ext == "pcblib" {-                let sch = schlib.as_ref().map(|p| read(p));-                altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)-            } else {-                // treat as .SchLib; auto-find the .PcbLib next to it if not given-                let pcb_path = pcblib.or_else(|| {-                    let cand = file.with_extension("PcbLib");-                    cand.exists().then_some(cand)-                });-                let pcb = pcb_path.as_ref().map(|p| read(p));-                altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)-            }-            .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();-            println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",-                outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());-        }--        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());-        }--        Commands::Health => {-            println!("OK: adom-lbr v{}", VERSION.trim());-        }--        Commands::Completions { shell } => {-            use clap::CommandFactory;-            clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());-        }-    }-}--// ── `embed`: Fusion-style 3-column interactive HTML package ──--pub(crate) fn b64(data: &[u8]) -> String {-    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";-    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);-    for ch in data.chunks(3) {-        let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];-        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;-        out.push(T[(n >> 18 & 63) as usize] as char);-        out.push(T[(n >> 12 & 63) as usize] as char);-        out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });-        out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });-    }-    out-}--fn esc_html(s: &str) -> String {-    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")-}--/// (human label, hex color) for a ds2sf/kicad pin type token.-pub(crate) fn pin_type_style(t: &str) -> (&'static str, &'static str) {-    match t.to_lowercase().replace('-', "_").as_str() {-        "power_in" | "power_out" | "power" => ("Power", "#ef5350"),-        "input" | "in" => ("Input", "#26c6da"),-        "output" | "out" => ("Output", "#66bb6a"),-        "bidirectional" | "bidi" | "io" => ("Bidir", "#ab47bc"),-        "open_collector" | "open_emitter" => ("Open", "#ffa726"),-        "tri_state" => ("Tri-state", "#ffa726"),-        "passive" => ("Passive", "#90a4ae"),-        "unspecified" | "" => ("—", "#90a4ae"),-        "no_connect" | "nc" | "unconnected" => ("NC", "#5b6673"),-        _ => ("Signal", "#80cbc4"),-    }-}--/// Resolve the `View` layout config. A `--config page.json` with an `lbrView`-/// block fully determines layout/panes/paneOpts (overriding the flags); otherwise-/// the flags are used. Returns (layout, panes, pins.description, pins.rollup) where-/// the two Options are None when the caller should derive the layout default.-fn resolve_view_cfg(-    layout: String,-    panes: Option<String>,-    description: Option<bool>,-    rollup: Option<bool>,-    config: Option<&std::path::Path>,-) -> (String, Vec<String>, Option<bool>, Option<bool>) {-    if let Some(cfgpath) = config {-        if let Ok(txt) = fs::read_to_string(cfgpath) {-            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {-                if let Some(lv) = v.get("lbrView") {-                    let layout = lv.get("layout").and_then(|x| x.as_str()).unwrap_or(&layout).to_string();-                    let panes: Vec<String> = lv.get("panes").and_then(|x| x.as_array())-                        .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())-                        .unwrap_or_default();-                    let pins = lv.get("paneOpts").and_then(|x| x.get("pins"));-                    let desc = pins.and_then(|p| p.get("description")).and_then(|x| x.as_bool());-                    let roll = pins.and_then(|p| p.get("rollup")).and_then(|x| x.as_bool());-                    return (layout, panes, desc, roll);-                }-            }-        }-        eprintln!("WARN: --config {} has no usable `lbrView` block; using flags", cfgpath.display());-    }-    let panes_vec = panes-        .map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())-        .unwrap_or_default();-    (layout, panes_vec, description, rollup)-}--fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {-    for n in names {-        if let Ok(s) = fs::read_to_string(dir.join(n)) {-            return Some(s);-        }-    }-    None-}--/// Find the directory's model, transcode Draco away when present, and emit a-/// self-contained interactive 3D pane (inlined model-viewer + data-URI GLB +-/// the static iso render as the poster). Err(reason) falls back to the PNG.-fn build_3d_viewer(dir: &std::path::Path, mpn: &str) -> Result<String, String> {-    // prefer <MPN>.glb (any case), else the first .glb in the directory-    let mut glb_path: Option<PathBuf> = None;-    if let Ok(rd) = fs::read_dir(dir) {-        let mut all: Vec<PathBuf> = rd.flatten().map(|e| e.path())-            .filter(|p| p.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("glb")).unwrap_or(false))-            .collect();-        all.sort();-        glb_path = all.iter().find(|p| {-            p.file_stem().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case(mpn)).unwrap_or(false)-        }).cloned().or_else(|| all.first().cloned());-    }-    let glb_path = glb_path.ok_or("no .glb in this directory")?;-    let mut bytes = fs::read(&glb_path).map_err(|e| format!("read {}: {e}", glb_path.display()))?;-    let orig_len = bytes.len();--    if glb_is_draco(&bytes) {-        let tool = ensure_gltf_transform()?;-        let out_tmp = std::env::temp_dir().join(format!("adom-lbr-{mpn}-quantized.glb"));-        let st = process::Command::new(&tool)-            .args(["quantize", glb_path.to_str().unwrap_or_default(), out_tmp.to_str().unwrap_or_default()])-            .output()-            .map_err(|e| format!("gltf-transform did not run: {e}"))?;-        if !st.status.success() {-            return Err(format!("gltf-transform quantize failed: {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));-        }-        bytes = fs::read(&out_tmp).map_err(|e| format!("read transcode output: {e}"))?;-        if glb_is_draco(&bytes) {-            return Err("transcode output still requires the Draco decoder".into());-        }-        println!("HINT: 3D model transcoded Draco -> quantized for the sandbox: {} -> {} bytes", orig_len, bytes.len());-    }--    let viewer_js = ensure_model_viewer()?;-    // STEP-sourced GLBs are usually Z-up while glTF is Y-up, so a chip stands-    // on its edge (barrett's PR #46 catch). Chips are flat: whichever bbox-    // axis is thinnest is the height. Thinnest on Z => Z-up model => pitch it-    // -90deg; thinnest on Y => already Y-up, leave it alone.-    let orientation = match glb_flat_axis(&bytes) {-        Some('z') => " orientation=\"0deg -90deg 0deg\"",-        _ => "",-    };-    let poster = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]-        .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())-        .and_then(|p| fs::read(&p).ok())-        .map(|b| format!(" poster=\"data:image/png;base64,{}\"", b64(&b)))-        .unwrap_or_default();-    Ok(format!(-        "<script type=\"module\">{viewer_js}</script>\n\-         <model-viewer src=\"data:model/gltf-binary;base64,{model}\"{poster}{orientation} camera-controls auto-rotate shadow-intensity=\"1\" style=\"position:absolute;inset:0;width:100%;height:100%;background:#161c27\"></model-viewer>",-        model = b64(&bytes)-    ))-}--/// Which axis is the model THINNEST along, from the union of the JSON chunk's-/// VEC3 float accessor min/max bounds. For chip-shaped parts the thinnest axis-/// is the height, which tells us whether the model is Y-up or Z-up.-fn glb_flat_axis(bytes: &[u8]) -> Option<char> {-    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {-        return None;-    }-    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;-    let end = (20 + json_len).min(bytes.len());-    let v: serde_json::Value = serde_json::from_slice(&bytes[20..end]).ok()?;-    let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);-    let mut seen = false;-    for acc in v.get("accessors")?.as_array()? {-        // quantized meshes store positions as ints (KHR_mesh_quantization), so-        // accept ANY VEC3 accessor that carries min/max bounds-        if acc.get("type").and_then(|t| t.as_str()) != Some("VEC3") {-            continue;-        }-        let (Some(mn), Some(mx)) = (acc.get("min").and_then(|m| m.as_array()), acc.get("max").and_then(|m| m.as_array())) else { continue };-        if mn.len() != 3 || mx.len() != 3 { continue; }-        for i in 0..3 {-            lo[i] = lo[i].min(mn[i].as_f64().unwrap_or(0.0));-            hi[i] = hi[i].max(mx[i].as_f64().unwrap_or(0.0));-        }-        seen = true;-    }-    if !seen { return None; }-    let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];-    let thin = if ext[0] <= ext[1] && ext[0] <= ext[2] { 'x' } else if ext[1] <= ext[2] { 'y' } else { 'z' };-    // only meaningful when the part is clearly flat (height under half the next axis)-    let mut sorted = ext; sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());-    if sorted[0] > sorted[1] * 0.6 { return None; }-    Some(thin)-}--/// GLB magic + JSON chunk scan for the Draco extension requirement.-fn glb_is_draco(bytes: &[u8]) -> bool {-    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {-        return false;-    }-    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;-    let end = (20 + json_len).min(bytes.len());-    let json = String::from_utf8_lossy(&bytes[20..end]);-    json.contains("KHR_draco_mesh_compression")-}--/// The gltf-transform CLI, installed once into the adom-lbr cache.-fn ensure_gltf_transform() -> Result<PathBuf, String> {-    let home = std::env::var("HOME").map_err(|_| "no HOME")?;-    let prefix = std::path::Path::new(&home).join(".cache/adom-lbr/gltf");-    let bin = prefix.join("node_modules/.bin/gltf-transform");-    if bin.exists() {-        return Ok(bin);-    }-    process::Command::new("node").arg("-v").output().map_err(|_| "node is not installed (needed once to transcode a Draco .glb)")?;-    println!("HINT: fetching @gltf-transform/cli into {} (once)…", prefix.display());-    let _ = fs::create_dir_all(&prefix);-    let st = process::Command::new("npm")-        .args(["install", "--prefix", prefix.to_str().unwrap_or_default(), "--no-fund", "--no-audit", "@gltf-transform/cli@3", "draco3dgltf"])-        .output()-        .map_err(|e| format!("npm did not run: {e}"))?;-    if !st.status.success() || !bin.exists() {-        return Err(format!("npm install @gltf-transform/cli@3 failed (v3 pinned: v4 needs Node 20+): {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));-    }-    Ok(bin)-}--/// The model-viewer bundle, fetched once into the cache and inlined thereafter-/// (the readme sandbox may fetch nothing at view time, so it must ride along).-fn ensure_model_viewer() -> Result<String, String> {-    let home = std::env::var("HOME").map_err(|_| "no HOME")?;-    let path = std::path::Path::new(&home).join(".cache/adom-lbr/model-viewer-3.5.0.min.js");-    if !path.exists() {-        let _ = fs::create_dir_all(path.parent().unwrap());-        let st = process::Command::new("curl")-            .args(["-sL", "--max-time", "60", "-o", path.to_str().unwrap_or_default(),-                   "https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"])-            .status()-            .map_err(|e| format!("curl did not run: {e}"))?;-        if !st.success() {-            return Err("could not fetch the model-viewer bundle (offline?); the static render stands in".into());-        }-    }-    let js = fs::read_to_string(&path).map_err(|e| format!("read model-viewer bundle: {e}"))?;-    if js.len() < 100_000 {-        let _ = fs::remove_file(&path);-        return Err("model-viewer bundle looks truncated; refetch next run".into());-    }-    Ok(js)-}--fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {-    let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();-    // renders-    let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg")])-        .map(|s| s)-        .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());-    let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])-        .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());-    // The 3D column: a real interactive viewer when the directory has a .glb-    // (adom-lbr#4, barrett's design carried forward). The model rides INLINE as-    // a data URI and the model-viewer bundle is inlined too, because the wiki-    // serves readmes with connect-src 'none': nothing may be fetched at view-    // time. Draco models are transcoded to KHR_mesh_quantization at build time-    // (three.js decodes that natively; the Draco decoder is a CDN fetch the-    // sandbox blocks - and quantized is measured ~40% smaller anyway).-    let static_3d = || {-        let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]-            .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists());-        match cands.and_then(|p| fs::read(&p).ok()) {-            Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),-            None => "<div class=\"ph\">no 3D render in this directory</div>".into(),-        }-    };-    let threed = match build_3d_viewer(dir, &mpn) {-        Ok(html) => html,-        Err(reason) => {-            eprintln!("HINT: 3D column falls back to the static render: {reason}");-            static_3d()-        }-    };-    // optional pin→pad connects from a .lbr-    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();-    if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {-        if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) {-            for cap in re.captures_iter(&lbr) {-                pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());-            }-        }-    }-    // pins + descriptions from the extracted symbol JSON-    let mut pins_json = String::from("[]");-    let mut pad_count = fp_svg.matches("adom-pad").count().max(0);-    if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) {-        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) {-            if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {-                let rows: Vec<serde_json::Value> = arr.iter().map(|p| {-                    let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();-                    let raw_t = p.get("type").and_then(|x| x.as_str()).unwrap_or("");-                    let (label, color) = pin_type_style(raw_t);-                    let pad = pin_to_pad.get(p.get("name").and_then(|x| x.as_str()).unwrap_or(""))-                        .cloned().unwrap_or_else(|| num.clone());-                    serde_json::json!({-                        "number": num,-                        "name": p.get("name").and_then(|x| x.as_str()).unwrap_or(""),-                        "pad": pad,-                        "type": label,-                        "color": color,-                        "desc": p.get("description").and_then(|x| x.as_str()).unwrap_or(""),-                    })-                }).collect();-                pad_count = pad_count.max(rows.len());-                pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());-            }-        }-    }-    let pin_count = serde_json::from_str::<serde_json::Value>(&pins_json).ok()-        .and_then(|v| v.as_array().map(|a| a.len())).unwrap_or(0);--    let html = include_str!("embed.html")-        .replace("__NAME__", &esc_html(&mpn))-        .replace("__SYM__", &sym_svg)-        .replace("__FP__", &fp_svg)-        .replace("__THREED__", &threed)-        .replace("__PINS__", &pins_json)-        .replace("__PADN__", &pad_count.to_string())-        .replace("__PINN__", &pin_count.to_string());-    let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));-    fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;-    println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());-    Ok(())-}--#[cfg(test)]-mod lint_tests {-    use super::{dedup_pad_names, lint_lbr};--    #[test]-    fn four_sided_symbol_relays_into_two_columns() {-        use super::{relayout_two_columns, KicadPin, ParsedSymbol};-        let pin = |name: &str, number: &str, pin_type: &str, angle: u32| KicadPin {-            name: name.into(), number: number.into(), pin_type: pin_type.into(),-            x: 0.0, y: 0.0, angle, length: 2.54,-        };-        let mut sym = ParsedSymbol {-            name: "U1".into(), reference: "U".into(), value: String::new(),-            footprint: String::new(), description: String::new(),-            pins: vec![-                pin("OUT", "1", "output", 180),-                pin("GND", "2", "power_in", 90),   // bottom pin: the package-mirror tell-                pin("IN", "3", "input", 0),-                pin("VDD", "4", "power_in", 270),  // top pin-            ],-            body: None, group_labels: vec![], graphics: vec![],-        };-        assert!(relayout_two_columns(&mut sym, false));-        for p in &sym.pins {-            assert!(p.angle == 0 || p.angle == 180, "no rotated (top/bottom) pins survive: {} at {}", p.name, p.angle);-        }-        let body = sym.body.as_ref().unwrap();-        let by = |n: &str| sym.pins.iter().find(|p| p.name == n).unwrap();-        assert!(by("IN").x < body.left && by("IN").angle == 0, "inputs on the LEFT");-        assert!(by("OUT").x > body.right && by("OUT").angle == 180, "outputs on the RIGHT");-        assert!(by("GND").x < body.left && by("VDD").x < body.left, "power grouped LEFT");-        assert!(by("GND").y < by("IN").y, "power below the signal pins");-        // a two-column source stays untouched under auto-        let mut two_col = ParsedSymbol {-            pins: vec![pin("IN", "1", "input", 0), pin("OUT", "2", "output", 180)],-            ..sym-        };-        assert!(!relayout_two_columns(&mut two_col, false), "auto must not touch a left/right symbol");-    }--    #[test]-    fn pad_dedup_uses_the_eagle_at_suffix_convention() {-        let names: Vec<String> = ["17", "18", "17", "SH", "17"].iter().map(|s| s.to_string()).collect();-        let (out, groups) = dedup_pad_names(&names);-        assert_eq!(out, vec!["17", "18", "17@2", "SH", "17@3"]);-        assert_eq!(groups["17"], vec!["17", "17@2", "17@3"]);-        assert_eq!(groups["18"], vec!["18"]);-    }--    /// A library shaped like the one that shipped broken: a USB-C receptacle whose-    /// symbol calls the shield pin S1 while the footprint names that pad SH.-    fn lbr(connect_pad: &str, extra_pkg: &str) -> String {-        format!(-            r#"<eagle><drawing><library>-<packages><package name="TYPEC"><smd name="A4" x="0" y="0"/><smd name="SH" x="3" y="0"/>{extra}</package></packages>-<symbols><symbol name="TYPEC"><pin name="VBUS" x="0" y="0"/><pin name="SHIELD" x="0" y="5"/></symbol></symbols>-<devicesets><deviceset name="TYPEC"><gates><gate name="G$1" symbol="TYPEC" x="0" y="0"/></gates>-<devices><device name="" package="TYPEC"><connects>-<connect gate="G$1" pin="VBUS" pad="A4"/>-<connect gate="G$1" pin="SHIELD" pad="{pad}"/>-</connects><technologies><technology name=""/></technologies></device></devices>-</deviceset></devicesets>-</library></drawing></eagle>"#,-            pad = connect_pad, extra = extra_pkg-        )-    }--    #[test]-    fn dangling_connect_pad_fails_and_names_the_unreferenced_pad() {-        let r = lint_lbr(&lbr("S1", ""));-        assert!(!r.passed, "a connect pointing at a pad that does not exist must FAIL, not warn");-        let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");-        assert!(e.message.contains("\"SHIELD\"") && e.message.contains("\"S1\""), "names the pin and the pad: {}", e.message);-        assert!(e.message.contains("unreferenced pads in package: SH"),-            "names the pad nothing connects to, which is the half that shows the fix: {}", e.message);-    }--    #[test]-    fn resolved_connect_passes() {-        let r = lint_lbr(&lbr("SH", ""));-        assert!(r.errors.iter().all(|e| e.code != "connect-pad-unresolved"), "SH resolves: {:?}", r.errors);-    }--    #[test]-    fn through_hole_pads_count_too() {-        // the old check read <smd> only, so every through-hole part passed blind-        let tht = lbr("SH", "").replace(r#"<smd name="SH" x="3" y="0"/>"#, r#"<pad name="SH" x="3" y="0"/>"#);-        assert!(lint_lbr(&tht).errors.iter().all(|e| e.code != "connect-pad-unresolved"),-            "a <pad> (through-hole) resolves a connect just like an <smd>");-    }--    #[test]-    fn published_library_must_not_carry_an_autodesk_urn() {-        let r = lint_lbr(&lbr("SH", r#"<package3d name="X" urn="urn:adsk.wipprod:fs.file:co.abc"/>"#));-        assert!(!r.passed);-        assert!(r.errors.iter().any(|e| e.code == "package3d-urn"), "{:?}", r.errors);-    }--    #[test]-    fn duplicate_pad_names_fail_the_lint() {-        // KiCad same-net arrays (thermal vias, shield tabs) share one pad name;-        // Fusion refuses the entire library over the repeat (adom/adom-lbr#10)-        let r = lint_lbr(&lbr("SH", r#"<smd name="SH" x="4" y="0"/><smd name="SH" x="5" y="0"/>"#));-        assert!(!r.passed);-        let e = r.errors.iter().find(|e| e.code == "duplicate-pad-name").expect("duplicate-pad-name");-        assert!(e.message.contains("'SH'") && e.message.contains("3 times"), "{}", e.message);-        assert_eq!(r.errors.iter().filter(|e| e.code == "duplicate-pad-name").count(), 1,-            "one repeated name reports once, not once per extra pad");-    }--    #[test]-    fn suffixed_pad_group_with_multi_pad_connect_passes() {-        // the shape generate now emits for a same-net array: NAME, NAME@2, ...-        // and one connect naming the whole group, space-separated-        let r = lint_lbr(&lbr("SH SH@2 SH@3",-            r#"<smd name="SH@2" x="4" y="0"/><smd name="SH@3" x="5" y="0"/>"#));-        assert!(r.errors.iter().all(|e| e.code != "duplicate-pad-name" && e.code != "connect-pad-unresolved"),-            "@-suffixed group + multi-pad connect is the sanctioned form: {:?}", r.errors);-    }--    #[test]-    fn multi_pad_connect_with_a_missing_pad_still_fails() {-        // each name in a space-separated pad group must resolve individually-        let r = lint_lbr(&lbr("SH SH@2", ""));-        let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");-        assert!(e.message.contains("\"SH@2\""), "names the missing group member: {}", e.message);-    }--    #[test]-    fn empty_packages_is_caught_through_whitespace() {-        // the literal "<packages></packages>" match missed a section holding only-        // whitespace, and such a library shipped with zero packages-        let empty = lbr("SH", "").replace(-            r#"<package name="TYPEC"><smd name="A4" x="0" y="0"/><smd name="SH" x="3" y="0"/></package>"#,-            "\n\n  ");-        let r = lint_lbr(&empty);-        assert!(r.errors.iter().any(|e| e.code == "empty-packages"), "{:?}", r.errors);-    }-}+        /// Part metadata for the EAGLE/Fusion attributes that make a part+        /// ORDERABLE — MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN — read+        /// from a wiki component `page.json` (or any JSON with the same+        /// `component` block). Defaults to `page.json` beside the symbol, so a+        /// component repo just works. Geometry and 3D bindings alone do not tell+        /// a BOM what to buy, and the bridge downstream cannot invent part+        /// numbers, so they have to be carried here.+        #[arg(long)]+        part_metadata: Option<PathBuf>,+        /// Schematic-symbol pin layout policy. `auto` (default): a symbol that+        /// mirrors the physical PACKAGE (pins on all four sides, rotated names)+        /// is re-laid into EE-convention two columns — inputs left, outputs and+        /// bidirectional right, power grouped bottom-left, every name+        /// horizontal, buses contiguous. `two-column` forces the re-layout even+        /// for a left/right source; `keep` preserves the .kicad_sym placement+        /// verbatim.+        #[arg(long, default_value = "auto")]+        symbol_layout: String,+    },+    /// Lint an existing .lbr file for Fusion 360 compatibility+    Lint {+        /// Path to the .lbr file+        path: PathBuf,+    },+    /// Assemble many single-part .lbr files into ONE multi-deviceset library.+    /// Packages and symbols dedupe by content, name collisions rename with a+    /// numeric suffix, lint-failing inputs are refused, and the assembled+    /// document must itself pass lint. Prints a JSON summary.+    Assemble {+        /// Output .lbr path+        #[arg(long, short = 'o')]+        out: PathBuf,+        /// Library description (default: Adom Cloud Library)+        #[arg(long)]+        name: Option<String>,+        /// Input part .lbr files+        inputs: Vec<PathBuf>,+    },+    /// Validate a .lbr file has complete deviceset (symbol + package + connects)+    Validate {+        /// Path to the .lbr file+        path: PathBuf,+    },+    /// Import an EAGLE .lbr and convert to KiCad .kicad_sym+    Import {+        /// Path to the .lbr file+        path: PathBuf,+        /// Output .kicad_sym file path (default: <name>.kicad_sym)+        #[arg(short, long)]+        output: Option<PathBuf>,+    },+    /// Verify a .kicad_sym via service-kicad (headless KiCad validation)+    Check {+        /// Path to the .kicad_sym file+        path: PathBuf,+    },+    /// Export a Fusion-style 3-column INTERACTIVE HTML package — symbol | footprint+    /// | 3D, each pan/zoomable, with a pin→pad mapping table carrying the datasheet+    /// descriptions below. Self-contained for embedding in an iframe (e.g. a wiki+    /// component page). Assembles from a chip directory's renders + extracted data.+    Embed {+        /// Chip directory (a chip-fetcher library entry) to assemble the viewer from+        #[arg(long)]+        dir: PathBuf,+        /// Output .html path (default: <mpn>-lbr-embed.html in the dir)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Export per-slot interactive viewers (viewers/symbol.html + footprint.html)+    /// with the cross-highlight bus + tooltips + layer toggles, for a wiki+    /// component page's native viewer slots. CAD is rendered from the wiki slug;+    /// pin descriptions come from <MPN>-symbol.extracted.json in --dir.+    ExportViewers {+        /// Wiki page slug (renders symbol/footprint from its CAD)+        #[arg(long)]+        slug: String,+        /// Local chip dir holding <MPN>-symbol.extracted.json (pin type + descriptions)+        #[arg(long)]+        dir: PathBuf,+        /// Output directory for the viewer HTML files (default: ./viewers)+        #[arg(long)]+        out: Option<PathBuf>,+        /// Omit the symbol slot's built-in pin table (use when the page carries its+        /// own pin table, e.g. a rich readme datasheet table, to avoid duplication)+        #[arg(long)]+        no_pin_table: bool,+    },+    /// Export ONE configurable, cross-linked viewer combining symbol + footprint ++    /// 3D + pin-map in a declared layout (columns | quad | single). Which panes and+    /// per-pane options (e.g. the pin-map description column) are configurable via+    /// flags or a page.json `lbrView` block — same spirit as the wiki `sections`+    /// allowlist. Renders CAD from the hub/slug; pin descriptions come from+    /// <MPN>-symbol.extracted.json in --dir.+    View {+        /// Wiki page slug (or hub <MPN>.adom-lbr.json in --dir)+        #[arg(long)]+        slug: String,+        /// Local chip dir holding the hub JSON + <MPN>-symbol.extracted.json + .glb+        #[arg(long)]+        dir: PathBuf,+        /// Output .html path (default: <MPN>-lbr-<layout>.html in --dir)+        #[arg(long)]+        out: Option<PathBuf>,+        /// Layout mode: columns | quad | single+        #[arg(long, default_value = "quad")]+        layout: String,+        /// Comma-separated panes in order (default: all available). e.g. symbol,footprint,3d,pins+        #[arg(long)]+        panes: Option<String>,+        /// Show the pin-map description column (default: on for quad/single, off for columns)+        #[arg(long)]+        description: Option<bool>,+        /// Show the pin-map type roll-up footer (default: on for quad)+        #[arg(long)]+        rollup: Option<bool>,+        /// Read layout/panes/paneOpts from a page.json `lbrView` block (overrides the flags)+        #[arg(long)]+        config: Option<PathBuf>,+    },+    /// Run the live app server (AI-drivable: /state, /load, /eval, /console)+    Serve {+        /// Port to listen on+        #[arg(long, default_value_t = 8784)]+        port: u16,+        /// Optional .lbr to load into the view on startup+        #[arg(long)]+        file: Option<PathBuf>,+    },+    /// adom-lbr Manager: browse wiki components, view symbol/footprint/3D + the+    /// footprint layer stackup (KiCad + Altium). Renders SVG on the fly per click.+    /// Foreground alias of `dashboard serve --foreground`.+    Manage {+        /// Port to listen on+        #[arg(long, default_value_t = 8785)]+        port: u16,+    },+    /// The adom-lbr Dashboard (the Manager plus live status, settings and the+    /// Hydrogen Dock Bar contract). `dashboard serve --print-url` is what the+    /// dock runs: idempotent, version-aware, self-backgrounding.+    Dashboard {+        #[command(subcommand)]+        cmd: DashboardCmd,+    },+    /// Import a KiCad symbol + footprint into the canonical adom-lbr JSON (the hub).+    ImportKicad {+        /// Path to the .kicad_sym file+        #[arg(long)]+        sym: PathBuf,+        /// Path to the .kicad_mod footprint file+        #[arg(long)]+        fp: PathBuf,+        /// Manufacturer part number / component id+        #[arg(long, default_value = "PART")]+        mpn: String,+        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)+        #[arg(long)]+        out: Option<PathBuf>,+        /// JSON map from SYMBOL PIN NUMBER to FOOTPRINT PAD NAME, for parts+        /// where the two disagree (a USB-C shield pin "S1" against a pad "SH").+        /// Defaults to `pinmap.json` beside the symbol when that file exists.+        #[arg(long)]+        pin_map: Option<PathBuf>,+    },+    /// Convert an adom-lbr JSON into native Altium .SchLib + .PcbLib (no Altium needed).+    ExportAltium {+        /// Path to the adom-lbr JSON+        part: PathBuf,+        /// Output directory (files named <mpn>.SchLib / <mpn>.PcbLib)+        #[arg(long, default_value = ".")]+        out_dir: PathBuf,+    },+    /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.+    ExportIntlib {+        /// Path to the adom-lbr JSON+        part: PathBuf,+        /// Output .IntLib path (default: <mpn>.IntLib)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Convert an adom-lbr JSON into KiCad .kicad_sym + .kicad_mod.+    ExportKicad {+        /// Path to the adom-lbr JSON+        part: PathBuf,+        /// Output directory (files named <mpn>.kicad_sym / <mpn>.kicad_mod)+        #[arg(long, default_value = ".")]+        out_dir: PathBuf,+    },+    /// Add an adom-lbr JSON's symbol into an EXISTING Altium .SchLib (non-destructive merge).+    AddToSchlib {+        /// Existing .SchLib to merge into+        existing: PathBuf,+        /// Path to the adom-lbr JSON (or a bare Symbol / array of Symbols)+        part: PathBuf,+        /// Output .SchLib path (default: merged.SchLib)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Convert an Altium .SchLib / .PcbLib / .IntLib into the canonical adom-lbr JSON.+    /// Pass a .SchLib or .PcbLib and the other half via --pcblib/--schlib, or one .IntLib.+    ImportAltium {+        /// Path to a .SchLib, .PcbLib, or .IntLib+        file: PathBuf,+        /// The .PcbLib half (when `file` is a .SchLib)+        #[arg(long)]+        pcblib: Option<PathBuf>,+        /// The .SchLib half (when `file` is a .PcbLib)+        #[arg(long)]+        schlib: Option<PathBuf>,+        /// MPN / component id for the output part (default: the symbol's name)+        #[arg(long, default_value = "")]+        mpn: String,+        /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)+        #[arg(long)]+        out: Option<PathBuf>,+    },+    /// Install skill + bash completions+    Install,+    /// Check health+    Health,+    /// Generate shell completions+    Completions {+        #[arg(value_enum)]+        shell: clap_complete::Shell,+    },+}++// ── KiCad .kicad_sym parser ──++#[derive(Debug, Serialize, Deserialize)]+struct KicadPin {+    name: String,+    number: String,+    pin_type: String,+    x: f64,+    y: f64,+    angle: u32,+    length: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct KicadBody {+    left: f64,+    top: f64,+    right: f64,+    bottom: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct GroupLabel {+    text: String,+    x: f64,+    y: f64,+}++#[derive(Debug, Serialize, Deserialize)]+struct ParsedSymbol {+    name: String,+    reference: String,+    value: String,+    footprint: String,+    description: String,+    pins: Vec<KicadPin>,+    body: Option<KicadBody>,+    group_labels: Vec<GroupLabel>,+    graphics: Vec<SymGraphic>,+}++pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {+    let name = if let Some(n) = name_override {+        n.to_string()+    } else {+        let re = regex_lite::Regex::new(r#"\(symbol "([^"]+)""#).unwrap();+        re.captures(content)+            .and_then(|c| c.get(1))+            .map(|m| m.as_str().to_string())+            .ok_or("Cannot find symbol name in .kicad_sym")?+    };++    let mut pins = Vec::new();+    // The pin angle may be an int (`180`) or a float (`180.0`) depending on the+    // KiCad version that wrote the symbol; accept both. The shape/electrical+    // tokens may also be hyphenated (e.g. `power_in`, `no_connect`).+    let pin_re = regex_lite::Regex::new(+        r#"(?s)\(pin\s+([\w-]+)\s+[\w-]+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)\s+\(length\s+([-\d.]+)\).*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)""#+    ).unwrap();++    for cap in pin_re.captures_iter(content) {+        pins.push(KicadPin {+            pin_type: cap[1].to_string(),+            x: cap[2].parse().unwrap_or(0.0),+            y: cap[3].parse().unwrap_or(0.0),+            angle: cap[4].parse::<f64>().unwrap_or(0.0).round() as u32,+            length: cap[5].parse().unwrap_or(2.54),+            name: cap[6].to_string(),+            number: cap[7].to_string(),+        });+    }++    let body = {+        let rect_re = regex_lite::Regex::new(+            r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#+        ).unwrap();+        rect_re.captures(content).map(|c| KicadBody {+            left: c[1].parse().unwrap_or(0.0),+            top: c[2].parse().unwrap_or(0.0),+            right: c[3].parse().unwrap_or(0.0),+            bottom: c[4].parse().unwrap_or(0.0),+        })+    };++    // 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.]+)"#+    ).unwrap();+    for cap in text_re.captures_iter(content) {+        group_labels.push(GroupLabel {+            text: cap[1].to_string(),+            x: cap[2].parse().unwrap_or(0.0),+            y: cap[3].parse().unwrap_or(0.0),+        });+    }++    let prop = |key: &str| -> String {+        let re = regex_lite::Regex::new(&format!(r#"\(property\s+"{}"\s+"([^"]*)""#, key)).unwrap();+        re.captures(content)+            .and_then(|c| c.get(1))+            .map(|m| m.as_str().to_string())+            .unwrap_or_default()+    };++    Ok(ParsedSymbol {+        name,+        reference: prop("Reference"),+        value: prop("Value"),+        footprint: prop("Footprint"),+        description: prop("Description"),+        pins,+        body,+        group_labels,+        graphics,+    })+}++// ── EAGLE .lbr parser (import) ──++fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {+    let pin_re = regex_lite::Regex::new(+        r#"<pin name="([^"]+)" x="([^"]+)" y="([^"]+)" length="([^"]+)" direction="([^"]+)"([^/]*)/>"#+    ).unwrap();++    let sym_name_re = regex_lite::Regex::new(r#"<symbol name="([^"]+)">"#).unwrap();+    let name = sym_name_re.captures(content)+        .and_then(|c| c.get(1))+        .map(|m| m.as_str().to_string())+        .ok_or("Cannot find <symbol name=\"...\"> in .lbr")?;++    let desc_re = regex_lite::Regex::new(r#"<description>([^<]*)</description>"#).unwrap();+    let description = desc_re.captures(content)+        .and_then(|c| c.get(1))+        .map(|m| m.as_str().to_string())+        .unwrap_or_default()+        .replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")+        .replace("&quot;", "\"").replace("&apos;", "'");++    let prefix_re = regex_lite::Regex::new(r#"<deviceset[^>]*prefix="([^"]+)""#).unwrap();+    let reference = prefix_re.captures(content)+        .and_then(|c| c.get(1))+        .map(|m| m.as_str().to_string())+        .unwrap_or_else(|| "U".to_string());++    let rect_re = regex_lite::Regex::new(+        r#"<wire x1="([^"]+)" y1="([^"]+)" x2="([^"]+)" y2="([^"]+)" width="[^"]*" layer="94"/>"#+    ).unwrap();++    let mut min_x = f64::MAX;+    let mut min_y = f64::MAX;+    let mut max_x = f64::MIN;+    let mut max_y = f64::MIN;+    let mut has_body = false;+    for cap in rect_re.captures_iter(content) {+        has_body = true;+        let x1: f64 = cap[1].parse().unwrap_or(0.0);+        let y1: f64 = cap[2].parse().unwrap_or(0.0);+        let x2: f64 = cap[3].parse().unwrap_or(0.0);+        let y2: f64 = cap[4].parse().unwrap_or(0.0);+        min_x = min_x.min(x1).min(x2);+        min_y = min_y.min(y1).min(y2);+        max_x = max_x.max(x1).max(x2);+        max_y = max_y.max(y1).max(y2);+    }++    let body = if has_body {+        Some(KicadBody { left: min_x, top: max_y, right: max_x, bottom: min_y })+    } else {+        None+    };++    let connect_re = regex_lite::Regex::new(+        r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#+    ).unwrap();+    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();+    for cap in connect_re.captures_iter(content) {+        pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+    }++    let length_val = |kw: &str| -> f64 {+        match kw {+            "point" => 0.0,+            "short" => 2.54,+            "middle" => 5.08,+            "long" => 7.62,+            _ => 2.54,+        }+    };++    let eagle_to_kicad_dir = |d: &str| -> &'static str {+        match d {+            "pwr" => "power_in",+            "in" => "input",+            "out" => "output",+            "io" => "bidirectional",+            "pas" => "passive",+            "nc" => "unconnected",+            "hiz" => "tri_state",+            "oc" => "open_collector",+            _ => "bidirectional",+        }+    };++    let rot_re = regex_lite::Regex::new(r#"rot="R(\d+)""#).unwrap();++    let mut pins = Vec::new();+    for cap in pin_re.captures_iter(content) {+        let pin_name_raw = cap[1].to_string();+        let pin_name = pin_name_raw.replace("@2", "").replace("@3", "")+            .replace("@4", "").replace("@5", "").replace("@6", "")+            .replace("@7", "").replace("@8", "").replace("@9", "");++        let pad_number = pin_to_pad.get(&pin_name_raw)+            .cloned()+            .unwrap_or_else(|| "0".to_string());++        let angle = rot_re.captures(&cap[6])+            .and_then(|c| c.get(1))+            .and_then(|m| m.as_str().parse::<u32>().ok())+            .unwrap_or(0);++        pins.push(KicadPin {+            name: pin_name,+            number: pad_number,+            pin_type: eagle_to_kicad_dir(&cap[5]).to_string(),+            x: cap[2].parse().unwrap_or(0.0),+            y: cap[3].parse().unwrap_or(0.0),+            angle,+            length: length_val(&cap[4]),+        });+    }++    Ok(ParsedSymbol {+        value: name.clone(),+        name,+        reference,+        footprint: String::new(),+        description,+        pins,+        body,+        group_labels: Vec::new(),+        graphics: Vec::new(),+    })+}++// ── KiCad .kicad_sym generator ──++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");+    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");+    s.push_str("    (in_bom yes)\n    (on_board yes)\n");++    s.push_str(&format!("    (property \"Reference\" \"{}\"\n      (at 0 2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.reference));+    s.push_str(&format!("    (property \"Value\" \"{}\"\n      (at 0 -2.54 0)\n      (effects (font (size 1.27 1.27)))\n    )\n", sym.name));+    s.push_str(&format!("    (property \"Footprint\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",+        sym.footprint.replace('"', "\\\"")+    ));+    s.push_str("    (property \"Datasheet\" \"\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n");+    if !sym.description.is_empty() {+        s.push_str(&format!("    (property \"Description\" \"{}\"\n      (at 0 0 0)\n      (effects (font (size 1.27 1.27)) (hide yes))\n    )\n",+            sym.description.replace('"', "\\\"")+        ));+    }++    s.push_str(&format!("    (symbol \"{}_0_{}\"\n", sym.name, 1));++    if let Some(body) = &sym.body {+        s.push_str(&format!("      (rectangle (start {} {}) (end {} {})\n        (stroke (width 0.254) (type default))\n        (fill (type background))\n      )\n",+            body.left, body.top, body.right, body.bottom+        ));+    }++    for pin in &sym.pins {+        let kicad_angle = match pin.angle {+            0 => 0,+            90 => 90,+            180 => 180,+            270 => 270,+            _ => 0,+        };+        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('"', "\\\""),+            pin.number.replace('"', "\\\"")+        ));+    }++    s.push_str("    )\n");+    s.push_str("  )\n)\n");+    s+}++// ── EAGLE .lbr generator ──++pub(crate) fn esc_xml(s: &str) -> String {+    s.replace('&', "&amp;")+        .replace('<', "&lt;")+        .replace('>', "&gt;")+        .replace('"', "&quot;")+        .replace('\'', "&apos;")+}++fn pin_direction(kicad_type: &str) -> &'static str {+    match kicad_type {+        "power_in" => "pwr",+        "power_out" => "out",+        "input" => "in",+        "output" => "out",+        "bidirectional" => "io",+        "passive" => "pas",+        "unconnected" => "nc",+        "tri_state" => "hiz",+        "open_collector" | "open_emitter" => "oc",+        _ => "io",+    }+}++fn pin_rotation(angle: u32) -> &'static str {+    match angle {+        180 => " rot=\"R180\"",+        90 => " rot=\"R90\"",+        270 => " rot=\"R270\"",+        _ => "",+    }+}++fn pin_length_keyword(len: f64) -> &'static str {+    if len <= 0.0 { "point" }+    else if len <= 2.54 { "short" }+    else if len <= 5.08 { "middle" }+    else { "long" }+}++/// EAGLE names cannot carry spaces or most punctuation; KiCad pin names can+/// ("GND (TAB)" on a real TI part). Fusion refuses the ENTIRE library over one+/// such name, so every emitted pin name passes through this. Illegal runs+/// collapse to one underscore, trimmed at the ends.+fn eagle_safe_name(name: &str) -> String {+    let mut out = String::with_capacity(name.len());+    let mut last_us = false;+    for ch in name.chars() {+        let ok = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#');+        if ok { out.push(ch); last_us = false; }+        else if !last_us { out.push('_'); last_us = true; }+    }+    let trimmed = out.trim_matches('_').to_string();+    if trimmed.is_empty() { "P".to_string() } else { trimmed }+}++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() {+            eagle_safe_name(n)+        } 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<String, u32> = std::collections::HashMap::new();+    eff.iter().map(|name| {+        if counts[name] == 1 {+            name.clone()+        } else {+            let c = seen.entry(name.clone()).or_insert(0);+            *c += 1;+            if *c == 1 { name.clone() } else { format!("{}@{}", name, c) }+        }+    }).collect()+}++/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any.+/// EAGLE requires unique pad names within a package, and Fusion's importer+/// refuses the ENTIRE library over a repeat. KiCad legitimately reuses one pad+/// name across a same-net array (thermal vias, ground paddles, shield tabs), so+/// those come in as correct source data. EAGLE's own convention for one logical+/// pin on several physical pads is NAME, NAME@2, NAME@3, ... with the device's+/// connect naming every pad in the group — emit exactly that, keeping the+/// electrical meaning instead of losing it. Returns the per-pad output names+/// (document order) plus original-name → group map for building the connects.+fn dedup_pad_names(names: &[String]) -> (Vec<String>, std::collections::HashMap<String, Vec<String>>) {+    let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+    let mut out = Vec::with_capacity(names.len());+    let mut groups: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();+    for n in names {+        let c = counts.entry(n.as_str()).or_insert(0);+        *c += 1;+        let out_name = if *c == 1 { n.clone() } else { format!("{}@{}", n, c) };+        groups.entry(n.clone()).or_default().push(out_name.clone());+        out.push(out_name);+    }+    (out, groups)+}++/// EE-convention symbol re-layout (adom/adom-lbr#5). A .kicad_sym that mirrors+/// the physical package puts pins on all four sides, which forces the top and+/// bottom names to render rotated and scatters logically-related pins to+/// physical corners — a schematic symbol is not a package drawing. Re-lay into+/// two columns: inputs and passives on the LEFT, outputs and bidirectional on+/// the RIGHT, power grouped at the bottom-left, NC at the bottom-right, buses+/// (GPIO0..47, DB0..15) contiguous in numeric order, a blank row between+/// groups, and a body wide enough that opposing names never meet. Every pin+/// name renders horizontal. Returns whether the re-layout was applied.+pub(crate) fn relayout_two_columns(sym: &mut ParsedSymbol, force: bool) -> bool {+    // KiCad pin angles: 0 = points right (pin on the LEFT edge), 180 = left+    // (RIGHT edge), 90 = up (BOTTOM edge), 270 = down (TOP edge). Top/bottom+    // pins are the package-mirror tell.+    let four_sided = sym.pins.iter().any(|p| p.angle == 90 || p.angle == 270);+    if sym.pins.is_empty() || !(force || four_sided) {+        return false;+    }++    // side: 0 = left, 1 = right; rank orders the sections top→bottom in a column+    fn placement(p: &KicadPin) -> (usize, u8) {+        let n = p.name.to_uppercase();+        let power = p.pin_type.starts_with("power")+            || ["VDD", "VCC", "VSS", "GND", "VBAT", "VBUS", "VSYS", "VREG"].iter().any(|k| n.contains(k));+        if power {+            return (0, 9);+        }+        match p.pin_type.as_str() {+            "input" => (0, 0),+            "output" => (1, 0),+            "bidirectional" | "tri_state" | "open_collector" | "open_emitter" => (1, 1),+            "no_connect" | "free" => (1, 9),+            _ => (0, 1), // passive / unspecified / analog+        }+    }++    // Natural sort key so DB2 sorts before DB10 and a bus stays contiguous.+    fn natural(name: &str) -> (String, u64, String) {+        let base = name.trim_end_matches(|c: char| c.is_ascii_digit());+        let num: u64 = name[base.len()..].parse().unwrap_or(0);+        (base.to_uppercase(), num, name.to_string())+    }++    let mut order: Vec<usize> = (0..sym.pins.len()).collect();+    order.sort_by(|&a, &b| {+        let (pa, pb) = (&sym.pins[a], &sym.pins[b]);+        (placement(pa), natural(&pa.name)).cmp(&(placement(pb), natural(&pb.name)))+    });++    // Bus-prefix population, to know which prefix runs are real buses.+    let mut prefix_count: std::collections::HashMap<(usize, u8, String), u32> = std::collections::HashMap::new();+    for p in &sym.pins {+        let (side, rank) = placement(p);+        *prefix_count.entry((side, rank, natural(&p.name).0)).or_insert(0) += 1;+    }++    // Build the two columns; None = blank spacer row between logical groups.+    let mut cols: [Vec<Option<usize>>; 2] = [Vec::new(), Vec::new()];+    let mut last: [Option<(u8, String)>; 2] = [None, None];+    for &i in &order {+        let p = &sym.pins[i];+        let (side, rank) = placement(p);+        let base = natural(&p.name).0;+        if let Some((prev_rank, prev_base)) = &last[side] {+            let bus_boundary = *prev_base != base+                && (prefix_count[&(side, rank, base.clone())] >= 4+                    || prefix_count[&(side, *prev_rank, prev_base.clone())] >= 4);+            if *prev_rank != rank || bus_boundary {+                cols[side].push(None);+            }+        }+        last[side] = Some((rank, base));+        cols[side].push(Some(i));+    }++    // Body wide enough that the longest left + right names never approach each+    // other (~1.6 mm per char at EAGLE's default pin-name size, plus a gap).+    let widest = |col: &Vec<Option<usize>>| {+        col.iter().flatten().map(|&i| sym.pins[i].name.chars().count()).max().unwrap_or(0)+    };+    if std::env::var("ADOM_LBR_DEBUG_LAYOUT").is_ok() {+        eprintln!("DEBUG layout: widest L={} R={}", widest(&cols[0]), widest(&cols[1]));+    }+    let mut w = ((widest(&cols[0]) + widest(&cols[1])) as f64 * 1.6 + 5.08).max(15.24);+    w = (w / 2.54).ceil() * 2.54;++    let rows = cols[0].len().max(cols[1].len());+    let y_top = (((rows.saturating_sub(1)) as f64) * 2.54 / 2.0 / 2.54).ceil() * 2.54;+    for (side, col) in cols.iter().enumerate() {+        for (row, slot) in col.iter().enumerate() {+            if let Some(i) = slot {+                let p = &mut sym.pins[*i];+                p.y = y_top - row as f64 * 2.54;+                p.length = 5.08; // EAGLE "middle": numbers outside, names inside+                if side == 0 {+                    p.x = -(w / 2.0) - 5.08;+                    p.angle = 0;+                } else {+                    p.x = w / 2.0 + 5.08;+                    p.angle = 180;+                }+            }+        }+    }++    sym.body = Some(KicadBody {+        left: -(w / 2.0),+        right: w / 2.0,+        top: y_top + 2.54,+        bottom: y_top - ((rows.saturating_sub(1)) as f64) * 2.54 - 2.54,+    });+    // Source graphics and group labels were positioned for the old layout.+    sym.graphics.clear();+    sym.group_labels.clear();+    true+}++fn first_footprint_name(km: &str) -> Option<String> {+    let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).unwrap();+    re.captures(km).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())+}++/// Synthesize a placeable symbol from a `.kicad_mod`'s pads when there is no+/// `.kicad_sym`. One symbol pin per unique pad number, balanced left/right, so+/// the resulting EAGLE deviceset has the correct pin/pad names and connects.+fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {+    // Pad numbers in document order, de-duplicated (thru-hole pairs share a no.).+    // Pad ids may be quoted ("A1") or bare (1); skip mechanical pads ("").+    let pad_re = regex_lite::Regex::new(r#"\(pad\s+(?:"([^"]*)"|(\S+))"#).unwrap();+    let mut nums: Vec<String> = Vec::new();+    for c in pad_re.captures_iter(km) {+        let n = c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()).unwrap_or_default();+        if !n.is_empty() && !nums.contains(&n) {+            nums.push(n);+        }+    }+    let count = nums.len().max(1);+    let half = (count + 1) / 2;+    let body_h = (half as f64) * 2.54 + 2.54;+    let pins: Vec<KicadPin> = nums.iter().enumerate().map(|(i, num)| {+        let (x, y, angle) = if i < half {+            (-12.7, (half as f64 / 2.0 - i as f64) * 2.54, 0)+        } else {+            (12.7, (half as f64 / 2.0 - (i - half) as f64) * 2.54, 180)+        };+        KicadPin {+            name: num.clone(),+            number: num.clone(),+            pin_type: "passive".into(),+            x, y, angle, length: 2.54,+        }+    }).collect();+    ParsedSymbol {+        name: name.to_string(),+        reference: "U".into(),+        value: name.to_string(),+        footprint: name.to_string(),+        description: format!("{name} (symbol synthesized from footprint pads)"),+        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`, `fp_poly` — into EAGLE `<circle>`/`<wire>`/+/// `<polygon>` 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 xy_re = regex_lite::Regex::new(r#"\(xy\s+(-?[\d.]+)\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));+    }+    // fp_poly was silently skipped, and it is not a rare shape: KiCad draws the+    // pin-1 marker as a small FILLED polygon on F.SilkS, and the gullwing generator+    // draws the whole F.Fab body outline as one. Across 2,540 published parts, 97%+    // mark pin 1 that way — so every one of them reached Fusion with no pin-1+    // indicator at all while its geometry looked perfect (adom/adom-lbr#933).+    for b in sexpr_blocks(km, "fp_poly") {+        let Some(el) = el_of(b) else { continue };+        let verts: Vec<(f64, f64)> = xy_re+            .captures_iter(b)+            .map(|c| (c[1].parse::<f64>().unwrap_or(0.0), c[2].parse::<f64>().unwrap_or(0.0)))+            .collect();+        if verts.len() < 3 { continue }+        out.push_str(&format!("  <polygon width=\"{:.4}\" layer=\"{}\">\n", w_of(b), el));+        for (x, y) in verts {+            out.push_str(&format!("    <vertex x=\"{:.4}\" y=\"{:.4}\"/>\n", x, -y));+        }+        out.push_str("  </polygon>\n");+    }+    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),+    }+}++/// The attributes a BOM and the fusion-bridge linter read off a part.+///+/// Accepts a wiki component `page.json` (fields under `component`) or a bare JSON+/// object with the same keys. Distributor part numbers may be written either flat+/// (`"mouser_pn": "584-..."`) or under `distributor_links` as a string or as an+/// object carrying `pn` / `part_number`, because both shapes are already in use.+/// An absent or unreadable file is not an error - a part with no metadata still+/// generates, it just generates without attributes, exactly as before.+fn part_attributes(path: &std::path::Path) -> Vec<(String, String)> {+    let Ok(raw) = fs::read_to_string(path) else { return Vec::new() };+    let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else { return Vec::new() };+    let c = doc.get("component").unwrap_or(&doc);+    let clean = |v: Option<&serde_json::Value>| -> Option<String> {+        v.and_then(|x| x.as_str()).map(|x| x.trim().to_string()).filter(|x| !x.is_empty())+    };+    let mut out = Vec::new();+    if let Some(v) = clean(c.get("mpn")) { out.push(("MPN".to_string(), v)); }+    if let Some(v) = clean(c.get("manufacturer")) { out.push(("MANUFACTURER".to_string(), v)); }+    let links = c.get("distributor_links");+    for (key, attr) in [("mouser", "MOUSER_PN"), ("digikey", "DIGIKEY_PN"), ("lcsc", "LCSC_PN")] {+        let flat = clean(c.get(&format!("{key}_pn")));+        let nested = links.and_then(|d| d.get(key)).and_then(|e| match e {+            serde_json::Value::String(t) => Some(serde_json::Value::String(t.clone())),+            other => other.get("pn").or_else(|| other.get("part_number")).cloned(),+        });+        if let Some(v) = flat.or_else(|| clean(nested.as_ref())) {+            out.push((attr.to_string(), v));+        }+    }+    out+}++pub(crate) fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64, pin_map: &std::collections::HashMap<String, String>, part_attrs: &[(String, String)]) -> String {+    let mut xml = String::with_capacity(8192);++    xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");+    xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");+    xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");+    xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");+    xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");++    // Layers+    xml.push_str(EAGLE_LAYERS);++    xml.push_str("<library>\n");+    xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));++    // 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();+    // Original pad/pin-number name → the @-suffixed pad group it became, for the connects.+    let pad_groups: std::collections::HashMap<String, Vec<String>>;+    if !mod_pads.is_empty() {+        let km = fp_content.unwrap();+        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.name)));+        let pad_names: Vec<String> = mod_pads.iter().map(|p| p.name.clone()).collect();+        let (out_names, groups) = dedup_pad_names(&pad_names);+        pad_groups = groups;+        for (i, p) in mod_pads.iter().enumerate() {+            if p.smd {+                xml.push_str(&format!(+                    "  <smd name=\"{}\" x=\"{:.4}\" y=\"{:.4}\" dx=\"{:.4}\" dy=\"{:.4}\" layer=\"1\"{}/>\n",+                    esc_xml(&out_names[i]), 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(&out_names[i]), 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+    let pad_size = 0.5;+    let pitch = 0.5;+    let pin_count = sym.pins.len();+    let half = (pin_count + 1) / 2;++    let placeholder_names: Vec<String> = sym.pins.iter().map(|p| p.number.clone()).collect();+    let (placeholder_out, groups) = dedup_pad_names(&placeholder_names);+    pad_groups = groups;+    for i in 0..pin_count {+        let (px, py) = if i < half {+            // Left column+            (-(pin_count as f64 * pitch / 4.0), (half as f64 / 2.0 - i as f64) * pitch)+        } else {+            // Right column+            (pin_count as f64 * pitch / 4.0, (half as f64 / 2.0 - (i - half) as f64) * pitch)+        };+        xml.push_str(&format!(+            "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",+            esc_xml(&placeholder_out[i]), px, py, pad_size, pad_size+        ));+    }++    // Package outline+    let body_w = pin_count as f64 * pitch / 2.0 + 1.0;+    let body_h = half as f64 * pitch + 1.0;+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        -body_w / 2.0, body_h / 2.0, body_w / 2.0, body_h / 2.0+    ));+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        body_w / 2.0, body_h / 2.0, body_w / 2.0, -body_h / 2.0+    ));+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        body_w / 2.0, -body_h / 2.0, -body_w / 2.0, -body_h / 2.0+    ));+    xml.push_str(&format!(+        "  <wire x1=\"{:.3}\" y1=\"{:.3}\" x2=\"{:.3}\" y2=\"{:.3}\" width=\"0.127\" layer=\"21\"/>\n",+        -body_w / 2.0, -body_h / 2.0, -body_w / 2.0, body_h / 2.0+    ));++    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");++    // Symbols section+    xml.push_str("<symbols>\n");+    xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));++    if let Some(body) = &sym.body {+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.top, body.right, body.bottom));+        xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.right, body.bottom, body.left, body.bottom));+        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));+    xml.push_str(&format!("  <text x=\"0\" y=\"{}\" size=\"1.27\" layer=\"96\" align=\"top-center\">&gt;VALUE</text>\n", value_y));++    let deduped_names = dedup_pin_names(&sym.pins);+    for (i, pin) in sym.pins.iter().enumerate() {+        xml.push_str(&format!(+            "  <pin name=\"{}\" x=\"{}\" y=\"{}\" length=\"{}\" direction=\"{}\"{}/>\n",+            esc_xml(&deduped_names[i]), pin.x, pin.y,+            pin_length_keyword(pin.length),+            pin_direction(&pin.pin_type),+            pin_rotation(pin.angle)+        ));+    }++    let body_center_x = sym.body.as_ref().map(|b| (b.left + b.right) / 2.0).unwrap_or(0.0);+    let body_left = sym.body.as_ref().map(|b| b.left).unwrap_or(-10.0);+    let body_right = sym.body.as_ref().map(|b| b.right).unwrap_or(10.0);+    for label in &sym.group_labels {+        let (lx, align) = if label.x > body_center_x {+            (body_right - 1.0, " align=\"right\"")+        } else {+            (body_left + 1.0, "")+        };+        xml.push_str(&format!(+            "  <text x=\"{}\" y=\"{}\" size=\"0.762\" layer=\"94\" font=\"vector\" ratio=\"15\"{}>{}</text>\n",+            lx, label.y, align, esc_xml(&label.text)+        ));+    }++    xml.push_str("</symbol>\n");+    xml.push_str("</symbols>\n");++    // Deviceset with COMPLETE device (package + connects)+    let ref_prefix = if sym.reference.is_empty() { "U" } else { &sym.reference };+    xml.push_str("<devicesets>\n");+    xml.push_str(&format!("<deviceset name=\"{}\" prefix=\"{}\">\n", esc_xml(&sym.name), esc_xml(ref_prefix)));+    xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.description)));+    xml.push_str("  <gates>\n");+    xml.push_str(&format!("    <gate name=\"G$1\" symbol=\"{}\" x=\"0\" y=\"0\"/>\n", esc_xml(&sym.name)));+    xml.push_str("  </gates>\n");+    xml.push_str("  <devices>\n");+    xml.push_str(&format!("    <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));+    xml.push_str("      <connects>\n");++    // Generate connect mappings: gate pin name → pad number+    for (i, pin) in sym.pins.iter().enumerate() {+        // The pad reference is the symbol pin's NUMBER, which is the KiCad+        // convention and right for almost every part. A pin map is the escape+        // hatch for the ones where the footprint names that pad something else.+        let pad = pin_map.get(&pin.number).unwrap_or(&pin.number);+        // A same-net pad array (KiCad repeats the name; we emitted NAME, NAME@2,+        // ...) connects this one pin to EVERY pad in the group, space-separated —+        // standard EAGLE multi-pad connect form.+        let pad_ref = match pad_groups.get(pad) {+            Some(group) if group.len() > 1 => group.join(" "),+            Some(group) => group[0].clone(),+            None => pad.clone(),+        };+        xml.push_str(&format!(+            "        <connect gate=\"G$1\" pin=\"{}\" pad=\"{}\"/>\n",+            esc_xml(&deduped_names[i]), esc_xml(&pad_ref)+        ));+    }++    xml.push_str("      </connects>\n");+    // Attributes ride on <technology>, which is where Fusion's BOM export and the+    // fusion-bridge partMetadata linter look for them. An empty technology - what+    // this always emitted - produces a part with perfect geometry that nothing can+    // order.+    if part_attrs.is_empty() {+        xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");+    } else {+        xml.push_str("      <technologies>\n        <technology name=\"\">\n");+        for (k, v) in part_attrs {+            xml.push_str(&format!(+                "          <attribute name=\"{}\" value=\"{}\" constant=\"no\"/>\n",+                esc_xml(k), esc_xml(v)));+        }+        xml.push_str("        </technology>\n      </technologies>\n");+    }+    xml.push_str("    </device>\n");+    xml.push_str("  </devices>\n");+    xml.push_str("</deviceset>\n");+    xml.push_str("</devicesets>\n");++    xml.push_str("</library>\n</drawing>\n</eagle>");+    xml+}++// ── Linter ──++#[derive(Debug, Serialize)]+pub(crate) struct LintResult {+    pub passed: bool,+    pub errors: Vec<LintError>,+    pub warnings: Vec<LintError>,+}++#[derive(Debug, Serialize)]+pub(crate) struct LintError {+    pub code: String,+    pub message: String,+}++pub(crate) fn lint_lbr(content: &str) -> LintResult {+    let mut errors = Vec::new();+    let mut warnings = Vec::new();++    if !content.contains("<packages>") || !content.contains("</packages>") {+        errors.push(LintError { code: "missing-packages".into(), message: "No <packages> section found".into() });+    }+    if !content.contains("<symbols>") || !content.contains("</symbols>") {+        errors.push(LintError { code: "missing-symbols".into(), message: "No <symbols> section found".into() });+    }+    if !content.contains("<devicesets>") || !content.contains("</devicesets>") {+        errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });+    }++    // Check for empty packages. Matching the literal "<packages></packages>" missed+    // a library whose section held only whitespace or a comment, which shipped with+    // zero packages while its .kicad_mod had two perfectly good SMD pads. Ask the+    // real question instead: is there a package in there at all?+    if content.contains("<packages>") && !content.contains("<package name=") {+        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });+    }+    if content.contains("<packages/>") {+        errors.push(LintError { code: "empty-packages".into(), message: "Packages section is empty — Fusion will show 'No Components'".into() });+    }++    // Check for empty connects+    if content.contains("<connects/>") || content.contains("<connects></connects>") {+        errors.push(LintError { code: "empty-connects".into(), message: "Device has no pin-to-pad connect mappings — Fusion cannot use this component".into() });+    }++    // Check device has a package attribute+    if content.contains("<device name=\"\">") && !content.contains("package=") {+        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;+            }+            if pname.chars().any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.' | '/' | '!' | '@' | '$' | '#'))) {+                errors.push(LintError {+                    code: "illegal-pin-name".into(),+                    message: format!("Symbol '{}' pin '{}' contains characters an EAGLE name cannot carry (space or punctuation outside _+-./!@$#) — Fusion refuses the whole library over it", sname, pname),+                });+            }+            *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);+    }++    // 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),+                        });+                    }+                }+            }+        }+    }++    // Connect PAD integrity (adom/adom-lbr#9). This was a warning that only looked+    // at <smd>, so it stayed silent on through-hole parts and never failed the run:+    // a USB-C receptacle shipped with <connect pin="SHIELD" pad="S1"/> against a+    // package whose shield pad is "SH", and validate called the library complete.+    // The shield connected to nothing and every visual check still passed, which is+    // exactly the kind of defect that only shows up in hardware.+    //+    // So: an ERROR, counting BOTH <smd> and <pad> (through-hole), resolved against+    // the package the device actually names rather than globally, and the message+    // names the pads nothing connects to, since that is the half that tells you the+    // fix ("SH" is obviously the intended target).+    let package_block_re = regex_lite::Regex::new(r#"(?s)<package name="([^"]*)">(.*?)</package>"#).unwrap();+    let pad_name_re = regex_lite::Regex::new(r#"<(?:smd|pad) name="([^"]+)""#).unwrap();+    let mut pkg_pads: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();+    for pc in package_block_re.captures_iter(content) {+        let pads: Vec<String> = pad_name_re.captures_iter(&pc[2]).map(|c| c[1].to_string()).collect();++        // Duplicate pad names within a package (adom/adom-lbr#10). KiCad allows a+        // same-net pad array to share one name (thermal vias, ground paddles,+        // shield tabs); EAGLE does not, and Fusion's importer refuses the WHOLE+        // library over it. generate resolves these to NAME, NAME@2, ... — so a+        // file that still carries repeats either predates that or came from+        // somewhere else, and it will not import.+        let mut pad_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();+        for p in &pads { *pad_counts.entry(p.as_str()).or_insert(0) += 1; }+        let mut dup_pads: Vec<(&str, u32)> = pad_counts.iter().filter(|(_, c)| **c > 1).map(|(n, c)| (*n, *c)).collect();+        dup_pads.sort();+        for (name, count) in dup_pads {+            errors.push(LintError {+                code: "duplicate-pad-name".into(),+                message: format!(+                    "Pad name '{}' appears {} times in package '{}' — Fusion's importer refuses the \+entire library over duplicate pad names. Use the EAGLE @-suffix convention ({name}, {name}@2, ...) \+with a multi-pad <connect> (adom-lbr generate now emits this).",+                    name, count, &pc[1]+                ),+            });+        }++        pkg_pads.insert(pc[1].to_string(), pads);+    }++    let device_re = regex_lite::Regex::new(r#"(?s)<device name="[^"]*" package="([^"]*)">(.*?)</device>"#).unwrap();+    let connect_pad_re = regex_lite::Regex::new(r#"<connect[^>]*pin="([^"]*)"[^>]*pad="([^"]+)""#).unwrap();+    for dev in device_re.captures_iter(content) {+        let pkg = dev[1].to_string();+        let body = &dev[2];+        let Some(pads) = pkg_pads.get(&pkg) else { continue };   // package missing is its own error+        if pads.is_empty() { continue; }+        let pad_set: std::collections::HashSet<&String> = pads.iter().collect();+        let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();+        let mut dangling: Vec<(String, String)> = Vec::new();+        for c in connect_pad_re.captures_iter(body) {+            let pin = c[1].to_string();+            // pad= may be a space-separated group ("17 17@2 17@3"): the standard+            // EAGLE multi-pad connect for one pin on a same-net pad array. Each+            // named pad must resolve.+            for pad in c[2].split_whitespace() {+                let pad = pad.to_string();+                if pad_set.contains(&pad) { used.insert(pad); } else { dangling.push((pin.clone(), pad)); }+            }+        }+        if !dangling.is_empty() {+            // One name, once: a USB-C shield is four tabs all named SH, and+            // "SH, SH, SH, SH" reads like four different problems.+            let mut seen_unref: std::collections::HashSet<&str> = std::collections::HashSet::new();+            let unref: Vec<&str> = pads.iter()+                .filter(|p| !used.contains(*p))+                .map(|p| p.as_str())+                .filter(|p| seen_unref.insert(p))+                .collect();+            for (pin, pad) in &dangling {+                errors.push(LintError {+                    code: "connect-pad-unresolved".into(),+                    message: format!(+                        "pin \"{}\" -> pad \"{}\" (no such pad in package '{}'{}). \+Symbol pin numbers and footprint pad names disagree for this part: supply a pin map \+(generate --pin-map) or rename the pad in the footprint.",+                        pin, pad, pkg,+                        if unref.is_empty() { String::new() }+                        else { format!("; unreferenced pads in package: {}", unref.join(", ")) }+                    ),+                });+            }+        }+    }++    // A published .lbr must not carry <package3d>/urn: that is an account-scoped+    // pointer into the minting user's Autodesk cloud, not embedded geometry, so it+    // resolves to nothing for anyone else. Ship the .step beside the library and let+    // each viewer's Fusion mint its own 3D.+    if content.contains("<package3d") || content.contains("wip_urn") || content.contains("urn:adsk") {+        errors.push(LintError {+            code: "package3d-urn".into(),+            message: "Library carries a <package3d>/Autodesk urn — that is a pointer into the \+minting account's cloud, not geometry, and resolves to nothing for anyone else. Remove it and \+ship the .step alongside the .lbr.".into(),+        });+    }++    // Check XML is well-formed+    if content.matches('<').count() != content.matches('>').count() {+        errors.push(LintError { code: "malformed-xml".into(), message: "Unbalanced XML angle brackets".into() });+    }++    // Fusion-specific checks+    if !content.contains("layer=\"94\"") {+        warnings.push(LintError { code: "no-symbol-layer".into(), message: "No elements on layer 94 (Symbols) — the symbol body may be invisible".into() });+    }++    LintResult {+        passed: errors.is_empty(),+        errors,+        warnings,+    }+}++// ── Layers constant ──++const EAGLE_LAYERS: &str = r#"<layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>+<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>+<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>+<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>+<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>+<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>+<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>+<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>+<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>+<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>+<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>+<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>+<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>+<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>+<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>+<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>+<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>+<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>+<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>+<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>+<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>+<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>+<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>+<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>+<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>+<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>+<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>+<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>+<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>+<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>+</layers>+"#;++// ── Main ──++/// Read a text file or exit with a clear error.+fn read_or_exit(path: &std::path::Path) -> String {+    fs::read_to_string(path).unwrap_or_else(|e| {+        eprintln!("ERROR: read {}: {e}", path.display());+        process::exit(1);+    })+}++/// Load an adom-lbr JSON into the canonical part.+pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart {+    let json = read_or_exit(path);+    serde_json::from_str(&json).unwrap_or_else(|e| {+        eprintln!("ERROR: parse adom-lbr JSON {}: {e}", path.display());+        process::exit(1);+    })+}++/// Seed the symbol's Altium property fields from the part identity (Value drawn;+/// Manufacturer/MPN/Package hidden), skipping empties + names already present.+fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) {+    use altium_codec::Parameter;+    let mut push = |name: &str, value: &str, visible: bool| {+        if value.is_empty()+            || symbol.parameters.iter().any(|p| p.name.eq_ignore_ascii_case(name))+        {+            return;+        }+        symbol.parameters.push(Parameter { name: name.into(), value: value.into(), visible });+    };+    push("Value", &part.value, true);+    push("Manufacturer", &part.manufacturer, false);+    push("Manufacturer Part Number", &part.mpn, false);+    push("Package", &part.package, false);+}++fn main() {+    let cli = Cli::parse();++    match cli.command {+        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout } => {+            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);+                    process::exit(1);+                })+            });++            let mut parsed = match (&sym, &fp_content) {+                (Some(sym_path), _) => {+                    let sym_content = fs::read_to_string(sym_path).unwrap_or_else(|e| {+                        eprintln!("ERROR: Cannot read {}: {}", sym_path.display(), e);+                        eprintln!("Hint: Check the .kicad_sym path exists.");+                        process::exit(1);+                    });+                    parse_kicad_sym(&sym_content, name.as_deref()).unwrap_or_else(|e| {+                        eprintln!("ERROR: {}", e);+                        process::exit(1);+                    })+                }+                (None, Some(fp)) => {+                    // Footprint-only: synthesize a placeable symbol from the pads.+                    let syn_name = name.clone()+                        .or_else(|| first_footprint_name(fp))+                        .unwrap_or_else(|| "FOOTPRINT".to_string());+                    synth_symbol_from_mod(fp, &syn_name)+                }+                (None, None) => {+                    eprintln!("ERROR: Generate needs at least --sym or --fp.");+                    eprintln!("Hint: `adom-lbr generate --fp part.kicad_mod` synthesizes a symbol from the pads.");+                    process::exit(1);+                }+            };++            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,+            };+            // Pin map: an explicit --pin-map wins, else pinmap.json beside the+            // symbol, so a page that publishes the sidecar just works.+            let pin_map_path = pin_map.clone().or_else(|| {+                sym.as_ref()+                    .and_then(|p| p.parent())+                    .map(|d| d.join("pinmap.json"))+                    .filter(|p| p.exists())+            });+            let pin_map: std::collections::HashMap<String, String> = match &pin_map_path {+                Some(path) => {+                    let raw = fs::read_to_string(path).unwrap_or_else(|e| {+                        eprintln!("ERROR: Cannot read pin map {}: {}", path.display(), e);+                        process::exit(1);+                    });+                    serde_json::from_str(&raw).unwrap_or_else(|e| {+                        eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);+                        process::exit(1);+                    })+                }+                None => std::collections::HashMap::new(),+            };+            if !pin_map.is_empty() {+                println!("HINT: pin map applied ({} entries) from {}", pin_map.len(),+                    pin_map_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default());+            }++            // Part metadata: an explicit --part-metadata wins, else page.json beside+            // the symbol, mirroring how pinmap.json is picked up.+            let pm_path = part_metadata.clone().or_else(|| {+                sym.as_ref().and_then(|p| p.parent()).map(|d| d.join("page.json")).filter(|p| p.exists())+            });+            let part_attrs = pm_path.as_ref().map(|p| part_attributes(p)).unwrap_or_default();+            if part_attrs.is_empty() {+                println!("HINT: no part attributes (MPN/MANUFACTURER/MOUSER_PN/DIGIKEY_PN/LCSC_PN). \+The geometry is fine but a BOM cannot order this part - pass --part-metadata or put a page.json beside the symbol.");+            } else {+                println!("HINT: part attributes: {}", part_attrs.iter()+                    .map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(", "));+            }++            match symbol_layout.as_str() {+                "keep" => {}+                "auto" | "two-column" => {+                    if art.is_some() {+                        // Symbol art is placed in the source symbol's own+                        // coordinate space; re-laying the pins would orphan it.+                        if symbol_layout == "two-column" {+                            eprintln!("WARN: --symbol-layout two-column ignored: --symbol-art is placed in the source symbol's coordinates");+                        }+                    } else if relayout_two_columns(&mut parsed, symbol_layout == "two-column") {+                        println!("HINT: symbol re-laid into two columns (source mirrored the package: pins on top/bottom). --symbol-layout keep preserves the original.");+                    }+                }+                other => {+                    eprintln!("ERROR: --symbol-layout must be auto, two-column, or keep (got '{}')", other);+                    process::exit(1);+                }+            }++            let lbr = generate_lbr(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs);++            // Lint before writing+            let lint = lint_lbr(&lbr);+            for err in &lint.errors {+                eprintln!("LINT ERROR: [{}] {}", err.code, err.message);+            }+            for warn in &lint.warnings {+                eprintln!("LINT WARN: [{}] {}", warn.code, warn.message);+            }++            let out_path = output.unwrap_or_else(|| {+                out_default.with_extension("lbr")+            });++            fs::write(&out_path, &lbr).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+                process::exit(1);+            });++            println!("OK: Generated {} ({} pins, {} bytes)", out_path.display(), parsed.pins.len(), lbr.len());+            println!("HINT: Lint {} with {} error(s), {} warning(s)", if lint.passed { "PASSED" } else { "FAILED" }, lint.errors.len(), lint.warnings.len());++            if !lint.passed {+                process::exit(2);+            }+        }++        Commands::Lint { path } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                eprintln!("Hint: Check the path exists. Use `ls` to verify.");+                process::exit(1);+            });++            let result = lint_lbr(&content);+            if result.passed {+                println!("OK: Lint passed with 0 error(s), {} warning(s)", result.warnings.len());+            } else {+                eprintln!("ERROR: Lint failed with {} error(s)", result.errors.len());+                for err in &result.errors {+                    eprintln!("  [{}] {}", err.code, err.message);+                }+                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr lint {}`", path.display());+            }+            for warn in &result.warnings {+                eprintln!("WARN: [{}] {}", warn.code, warn.message);+            }+            // Also output JSON for programmatic consumers+            let json = serde_json::to_string_pretty(&result).unwrap();+            println!("{}", json);++            if !result.passed {+                process::exit(2);+            }+        }++        Commands::Assemble { out, name, inputs } => {+            assemble::run(&out, name.as_deref(), &inputs);+        }+        Commands::Validate { path } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                process::exit(1);+            });++            let result = lint_lbr(&content);+            if result.passed {+                println!("OK: {} is a valid EAGLE .lbr with complete deviceset", path.display());+            } else {+                eprintln!("FAIL: {} has {} error(s)", path.display(), result.errors.len());+                for err in &result.errors {+                    eprintln!("  [{}] {}", err.code, err.message);+                }+                process::exit(2);+            }+        }++        Commands::Import { path, output } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                process::exit(1);+            });++            let parsed = parse_eagle_lbr(&content).unwrap_or_else(|e| {+                eprintln!("ERROR: {}", e);+                process::exit(1);+            });++            let kicad_sym = generate_kicad_sym(&parsed);++            let out_path = output.unwrap_or_else(|| {+                path.with_extension("kicad_sym")+            });++            fs::write(&out_path, &kicad_sym).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot write {}: {}", out_path.display(), e);+                process::exit(1);+            });++            println!("OK: Imported {} → {} ({} pins)", path.display(), out_path.display(), parsed.pins.len());+            println!("HINT: Run 'adom-lbr check {}' to validate via service-kicad", out_path.display());+        }++        Commands::Check { path } => {+            let content = fs::read_to_string(&path).unwrap_or_else(|e| {+                eprintln!("ERROR: Cannot read {}: {}", path.display(), e);+                process::exit(1);+            });++            let mut errors: Vec<String> = Vec::new();++            if !content.contains("(kicad_symbol_lib") {+                errors.push("Missing (kicad_symbol_lib) header — not a valid .kicad_sym file".into());+            }+            if !content.contains("(symbol \"") {+                errors.push("No (symbol) definitions found".into());+            }++            let pin_count = content.matches("(pin ").count();+            if pin_count == 0 {+                errors.push("No pins found — symbol has no electrical connections".into());+            }++            if !content.contains("(property \"Reference\"") {+                errors.push("Missing Reference property".into());+            }+            if !content.contains("(property \"Value\"") {+                errors.push("Missing Value property".into());+            }++            // Check for duplicate pin names within each symbol+            let sym_re = regex_lite::Regex::new(r#"\(pin \w+ \w+ \(at [^)]+\) \(length [^)]+\) \(name "([^"]+)""#).unwrap();+            let mut pin_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();+            for cap in sym_re.captures_iter(&content) {+                *pin_names.entry(cap[1].to_string()).or_insert(0) += 1;+            }+            let dups: Vec<_> = pin_names.iter().filter(|(_, &c)| c > 1).collect();+            if !dups.is_empty() && !dups.iter().all(|(n, _)| n.contains('@') || n.as_str() == "~") {+                let dup_list: Vec<_> = dups.iter().filter(|(n, _)| !n.contains('@') && n.as_str() != "~").map(|(n, c)| format!("{} (x{})", n, c)).collect();+                if !dup_list.is_empty() {+                    // Duplicate pin names are valid in KiCad for power pins (DVDD, IOVDD, GND).+                    // Warn but don't error — EAGLE import will need @N suffixes.+                    eprintln!("WARN: Duplicate pin names (will need @N suffix for EAGLE): {}", dup_list.join(", "));+                }+            }++            if errors.is_empty() {+                println!("OK: {} validated — {} pins, {} unique pin names", path.display(), pin_count, pin_names.len());+            } else {+                eprintln!("ERROR: {} failed validation with {} error(s)", path.display(), errors.len());+                for e in &errors {+                    eprintln!("  {}", e);+                }+                eprintln!("Hint: Fix the errors above, then re-run `adom-lbr check {}`", path.display());+                process::exit(2);+            }+        }++        Commands::ExportViewers { slug, dir, out, no_pin_table } => {+            let out_dir = out.unwrap_or_else(|| PathBuf::from("viewers"));+            if let Err(e) = manage::export_viewers(&slug, &dir, &out_dir, no_pin_table) {+                eprintln!("ERROR: {e}"); std::process::exit(1);+            }+        }+        Commands::Embed { dir, out } => {+            if let Err(e) = cmd_embed(&dir, out) {+                eprintln!("ERROR: {e:#}");+                process::exit(1);+            }+        }+        Commands::View { slug, dir, out, layout, panes, description, rollup, config } => {+            let (layout, panes_vec, description, rollup) =+                resolve_view_cfg(layout, panes, description, rollup, config.as_deref());+            if let Err(e) = manage::export_composed(&slug, &dir, out, &layout, &panes_vec, description, rollup) {+                eprintln!("ERROR: {e}");+                process::exit(1);+            }+        }+        Commands::Manage { port } => {+            if let Err(e) = manage::run(port) {+                eprintln!("ERROR: {e}");+                process::exit(1);+            }+        }+        Commands::Dashboard { cmd } => match cmd {+            DashboardCmd::Serve { port, print_url, foreground } => {+                // A foreign service on the default port moves us over rather+                // than killing the launch (John's box, 2026-08-25).+                let port = match dock::resolve_port(port) {+                    Ok(p) => p,+                    Err(e) => { eprintln!("ERROR: {e}"); process::exit(2); }+                };+                match dock::serve_entry(port, print_url, foreground) {+                    Ok(true) => {}+                    Ok(false) => {+                        if let Err(e) = manage::run(port) {+                            // Lost a bind race? If it is us after all, this launch still succeeded.+                            if dock::running_version(port).is_some() {+                                if print_url { println!("{}", dock::direct_url(port)); }+                                return;+                            }+                            eprintln!("ERROR: adom-lbr dashboard cannot serve on port {port}: {e}. Something else owns it; pick another with --port.");+                            process::exit(2);+                        }+                    }+                    Err(e) => {+                        eprintln!("ERROR: {e}");+                        process::exit(2);+                    }+                }+            }+            DashboardCmd::Status { port } => {+                let out = process::Command::new("curl").args(["-s", "--max-time", "3", &format!("http://127.0.0.1:{port}/api/status")]).output();+                match out {+                    Ok(o) if o.status.success() && !o.stdout.is_empty() => println!("{}", String::from_utf8_lossy(&o.stdout)),+                    _ => { eprintln!("ERROR: no adom-lbr dashboard answering on port {port}"); eprintln!("Hint: adom-lbr dashboard serve --print-url"); process::exit(2); }+                }+            }+            DashboardCmd::Say => {+                for p in dock::say_phrases() { println!("{p}"); }+            }+        },+        Commands::Serve { port, file } => {+            if let Some(f) = file {+                match fs::read_to_string(&f) {+                    Ok(content) => server::set_state(server::analyze_lbr(&content)),+                    Err(e) => {+                        eprintln!("ERROR: Cannot read {}: {}", f.display(), e);+                        process::exit(1);+                    }+                }+            }+            if let Err(e) = server::run(port) {+                eprintln!("ERROR: {e:#}");+                process::exit(1);+            }+        }++        Commands::ImportKicad { sym, fp, mpn, out, pin_map } => {+            let sym_txt = read_or_exit(&sym);+            let fp_txt = read_or_exit(&fp);+            // Same pin-map convention as generate: explicit --pin-map wins, else+            // a pinmap.json sidecar beside the symbol just works.+            let pin_map_path = pin_map.or_else(|| {+                sym.parent().map(|d| d.join("pinmap.json")).filter(|p| p.exists())+            });+            let pin_map: std::collections::HashMap<String, String> = match &pin_map_path {+                Some(path) => serde_json::from_str(&read_or_exit(path)).unwrap_or_else(|e| {+                    eprintln!("ERROR: {} is not a JSON object of \"pin number\": \"pad name\": {}", path.display(), e);+                    process::exit(1);+                }),+                None => Default::default(),+            };+            if !pin_map.is_empty() {+                println!("HINT: pin map applied ({} entries) from {}", pin_map.len(),+                    pin_map_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default());+            }+            let (part, dropped) = altium_codec::import_kicad_with_pin_map(&sym_txt, &fp_txt, &mpn, &pin_map)+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            if !dropped.is_empty() {+                // A pin that matches no pad is a broken netlist in EVERY export+                // target — say so loudly instead of letting it vanish (adom-lbr#9).+                let referenced: std::collections::HashSet<&str> =+                    part.connect.iter().map(|c| c.pad.as_str()).collect();+                let mut unref: Vec<&str> = part.footprint.pads.iter()+                    .map(|p| p.number.as_str())+                    .filter(|n| !n.is_empty() && !referenced.contains(n))+                    .collect();+                unref.sort();+                unref.dedup();+                eprintln!("WARN: {} symbol pin(s) match no footprint pad and were left out of connect[]: {}",+                    dropped.len(), dropped.join(", "));+                if !unref.is_empty() {+                    eprintln!("      unreferenced pads in footprint: {} — if one of these is the intended target, supply --pin-map (or a pinmap.json sidecar)", unref.join(", "));+                }+            }+            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{mpn}.adom-lbr.json")));+            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());+        }++        Commands::ExportAltium { part, out_dir } => {+            let p = load_part(&part);+            let mut symbol = p.symbol.clone();+            seed_parameters(&mut symbol, &p);+            let sym_bytes = altium_codec::encode_schlib(&[symbol])+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            let fp_bytes = altium_codec::encode_pcblib(&p.footprint)+                .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();+            println!("OK: wrote {} ({} pins) + {} ({} pads)",+                sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());+        }++        Commands::ExportIntlib { part, out } => {+            let p = load_part(&part);+            let mut symbol = p.symbol.clone();+            seed_parameters(&mut symbol, &p);+            let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };+            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();+            println!("OK: wrote {} ({} bytes) — symbol + footprint bundled", outp.display(), bytes.len());+        }++        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::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));+            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());+        }++        Commands::AddToSchlib { existing, part, out } => {+            let existing_bytes = fs::read(&existing)+                .unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", existing.display()); process::exit(1); });+            let json = read_or_exit(&part);+            let syms: Vec<altium_codec::Symbol> =+                if let Ok(p) = serde_json::from_str::<altium_codec::AdomLbrPart>(&json) {+                    vec![p.symbol]+                } else if let Ok(v) = serde_json::from_str::<Vec<altium_codec::Symbol>>(&json) {+                    v+                } else {+                    vec![serde_json::from_str::<altium_codec::Symbol>(&json)+                        .unwrap_or_else(|e| { eprintln!("ERROR: parse Symbol/AdomLbrPart: {e}"); process::exit(1); })]+                };+            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();+            println!("OK: wrote {} — added {} symbol(s) into the library", outp.display(), syms.len());+        }++        Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {+            let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();+            let read = |p: &std::path::Path| -> Vec<u8> {+                fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })+            };+            let part = if ext == "intlib" {+                altium_codec::decode_intlib_part(&read(&file), &mpn)+            } else if ext == "pcblib" {+                let sch = schlib.as_ref().map(|p| read(p));+                altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)+            } else {+                // treat as .SchLib; auto-find the .PcbLib next to it if not given+                let pcb_path = pcblib.or_else(|| {+                    let cand = file.with_extension("PcbLib");+                    cand.exists().then_some(cand)+                });+                let pcb = pcb_path.as_ref().map(|p| read(p));+                altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)+            }+            .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();+            println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",+                outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());+        }++        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());+        }++        Commands::Health => {+            println!("OK: adom-lbr v{}", VERSION.trim());+        }++        Commands::Completions { shell } => {+            use clap::CommandFactory;+            clap_complete::generate(shell, &mut Cli::command(), "adom-lbr", &mut std::io::stdout());+        }+    }+}++// ── `embed`: Fusion-style 3-column interactive HTML package ──++pub(crate) fn b64(data: &[u8]) -> String {+    const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+    for ch in data.chunks(3) {+        let b = [ch[0], *ch.get(1).unwrap_or(&0), *ch.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18 & 63) as usize] as char);+        out.push(T[(n >> 12 & 63) as usize] as char);+        out.push(if ch.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });+        out.push(if ch.len() > 2 { T[(n & 63) as usize] as char } else { '=' });+    }+    out+}++fn esc_html(s: &str) -> String {+    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")+}++/// (human label, hex color) for a ds2sf/kicad pin type token.+pub(crate) fn pin_type_style(t: &str) -> (&'static str, &'static str) {+    match t.to_lowercase().replace('-', "_").as_str() {+        "power_in" | "power_out" | "power" => ("Power", "#ef5350"),+        "input" | "in" => ("Input", "#26c6da"),+        "output" | "out" => ("Output", "#66bb6a"),+        "bidirectional" | "bidi" | "io" => ("Bidir", "#ab47bc"),+        "open_collector" | "open_emitter" => ("Open", "#ffa726"),+        "tri_state" => ("Tri-state", "#ffa726"),+        "passive" => ("Passive", "#90a4ae"),+        "unspecified" | "" => ("—", "#90a4ae"),+        "no_connect" | "nc" | "unconnected" => ("NC", "#5b6673"),+        _ => ("Signal", "#80cbc4"),+    }+}++/// Resolve the `View` layout config. A `--config page.json` with an `lbrView`+/// block fully determines layout/panes/paneOpts (overriding the flags); otherwise+/// the flags are used. Returns (layout, panes, pins.description, pins.rollup) where+/// the two Options are None when the caller should derive the layout default.+fn resolve_view_cfg(+    layout: String,+    panes: Option<String>,+    description: Option<bool>,+    rollup: Option<bool>,+    config: Option<&std::path::Path>,+) -> (String, Vec<String>, Option<bool>, Option<bool>) {+    if let Some(cfgpath) = config {+        if let Ok(txt) = fs::read_to_string(cfgpath) {+            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {+                if let Some(lv) = v.get("lbrView") {+                    let layout = lv.get("layout").and_then(|x| x.as_str()).unwrap_or(&layout).to_string();+                    let panes: Vec<String> = lv.get("panes").and_then(|x| x.as_array())+                        .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())+                        .unwrap_or_default();+                    let pins = lv.get("paneOpts").and_then(|x| x.get("pins"));+                    let desc = pins.and_then(|p| p.get("description")).and_then(|x| x.as_bool());+                    let roll = pins.and_then(|p| p.get("rollup")).and_then(|x| x.as_bool());+                    return (layout, panes, desc, roll);+                }+            }+        }+        eprintln!("WARN: --config {} has no usable `lbrView` block; using flags", cfgpath.display());+    }+    let panes_vec = panes+        .map(|s| s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())+        .unwrap_or_default();+    (layout, panes_vec, description, rollup)+}++fn read_first(dir: &std::path::Path, names: &[String]) -> Option<String> {+    for n in names {+        if let Ok(s) = fs::read_to_string(dir.join(n)) {+            return Some(s);+        }+    }+    None+}++/// Find the directory's model, transcode Draco away when present, and emit a+/// self-contained interactive 3D pane (inlined model-viewer + data-URI GLB ++/// the static iso render as the poster). Err(reason) falls back to the PNG.+fn build_3d_viewer(dir: &std::path::Path, mpn: &str) -> Result<String, String> {+    // prefer <MPN>.glb (any case), else the first .glb in the directory+    let mut glb_path: Option<PathBuf> = None;+    if let Ok(rd) = fs::read_dir(dir) {+        let mut all: Vec<PathBuf> = rd.flatten().map(|e| e.path())+            .filter(|p| p.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("glb")).unwrap_or(false))+            .collect();+        all.sort();+        glb_path = all.iter().find(|p| {+            p.file_stem().and_then(|s| s.to_str()).map(|s| s.eq_ignore_ascii_case(mpn)).unwrap_or(false)+        }).cloned().or_else(|| all.first().cloned());+    }+    let glb_path = glb_path.ok_or("no .glb in this directory")?;+    let mut bytes = fs::read(&glb_path).map_err(|e| format!("read {}: {e}", glb_path.display()))?;+    let orig_len = bytes.len();++    if glb_is_draco(&bytes) {+        let tool = ensure_gltf_transform()?;+        let out_tmp = std::env::temp_dir().join(format!("adom-lbr-{mpn}-quantized.glb"));+        let st = process::Command::new(&tool)+            .args(["quantize", glb_path.to_str().unwrap_or_default(), out_tmp.to_str().unwrap_or_default()])+            .output()+            .map_err(|e| format!("gltf-transform did not run: {e}"))?;+        if !st.status.success() {+            return Err(format!("gltf-transform quantize failed: {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));+        }+        bytes = fs::read(&out_tmp).map_err(|e| format!("read transcode output: {e}"))?;+        if glb_is_draco(&bytes) {+            return Err("transcode output still requires the Draco decoder".into());+        }+        println!("HINT: 3D model transcoded Draco -> quantized for the sandbox: {} -> {} bytes", orig_len, bytes.len());+    }++    let viewer_js = ensure_model_viewer()?;+    // STEP-sourced GLBs are usually Z-up while glTF is Y-up, so a chip stands+    // on its edge (barrett's PR #46 catch). Chips are flat: whichever bbox+    // axis is thinnest is the height. Thinnest on Z => Z-up model => pitch it+    // -90deg; thinnest on Y => already Y-up, leave it alone.+    let orientation = match glb_flat_axis(&bytes) {+        Some('z') => " orientation=\"0deg -90deg 0deg\"",+        _ => "",+    };+    let poster = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]+        .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists())+        .and_then(|p| fs::read(&p).ok())+        .map(|b| format!(" poster=\"data:image/png;base64,{}\"", b64(&b)))+        .unwrap_or_default();+    Ok(format!(+        "<script type=\"module\">{viewer_js}</script>\n\+         <model-viewer src=\"data:model/gltf-binary;base64,{model}\"{poster}{orientation} camera-controls auto-rotate shadow-intensity=\"1\" style=\"position:absolute;inset:0;width:100%;height:100%;background:#161c27\"></model-viewer>",+        model = b64(&bytes)+    ))+}++/// Which axis is the model THINNEST along, from the union of the JSON chunk's+/// VEC3 float accessor min/max bounds. For chip-shaped parts the thinnest axis+/// is the height, which tells us whether the model is Y-up or Z-up.+fn glb_flat_axis(bytes: &[u8]) -> Option<char> {+    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+        return None;+    }+    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;+    let end = (20 + json_len).min(bytes.len());+    let v: serde_json::Value = serde_json::from_slice(&bytes[20..end]).ok()?;+    let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]);+    let mut seen = false;+    for acc in v.get("accessors")?.as_array()? {+        // quantized meshes store positions as ints (KHR_mesh_quantization), so+        // accept ANY VEC3 accessor that carries min/max bounds+        if acc.get("type").and_then(|t| t.as_str()) != Some("VEC3") {+            continue;+        }+        let (Some(mn), Some(mx)) = (acc.get("min").and_then(|m| m.as_array()), acc.get("max").and_then(|m| m.as_array())) else { continue };+        if mn.len() != 3 || mx.len() != 3 { continue; }+        for i in 0..3 {+            lo[i] = lo[i].min(mn[i].as_f64().unwrap_or(0.0));+            hi[i] = hi[i].max(mx[i].as_f64().unwrap_or(0.0));+        }+        seen = true;+    }+    if !seen { return None; }+    let ext = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];+    let thin = if ext[0] <= ext[1] && ext[0] <= ext[2] { 'x' } else if ext[1] <= ext[2] { 'y' } else { 'z' };+    // only meaningful when the part is clearly flat (height under half the next axis)+    let mut sorted = ext; sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());+    if sorted[0] > sorted[1] * 0.6 { return None; }+    Some(thin)+}++/// GLB magic + JSON chunk scan for the Draco extension requirement.+fn glb_is_draco(bytes: &[u8]) -> bool {+    if bytes.len() < 20 || &bytes[0..4] != b"glTF" {+        return false;+    }+    let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;+    let end = (20 + json_len).min(bytes.len());+    let json = String::from_utf8_lossy(&bytes[20..end]);+    json.contains("KHR_draco_mesh_compression")+}++/// The gltf-transform CLI, installed once into the adom-lbr cache.+fn ensure_gltf_transform() -> Result<PathBuf, String> {+    let home = std::env::var("HOME").map_err(|_| "no HOME")?;+    let prefix = std::path::Path::new(&home).join(".cache/adom-lbr/gltf");+    let bin = prefix.join("node_modules/.bin/gltf-transform");+    if bin.exists() {+        return Ok(bin);+    }+    process::Command::new("node").arg("-v").output().map_err(|_| "node is not installed (needed once to transcode a Draco .glb)")?;+    println!("HINT: fetching @gltf-transform/cli into {} (once)…", prefix.display());+    let _ = fs::create_dir_all(&prefix);+    let st = process::Command::new("npm")+        .args(["install", "--prefix", prefix.to_str().unwrap_or_default(), "--no-fund", "--no-audit", "@gltf-transform/cli@3", "draco3dgltf"])+        .output()+        .map_err(|e| format!("npm did not run: {e}"))?;+    if !st.status.success() || !bin.exists() {+        return Err(format!("npm install @gltf-transform/cli@3 failed (v3 pinned: v4 needs Node 20+): {}", String::from_utf8_lossy(&st.stderr).lines().last().unwrap_or("")));+    }+    Ok(bin)+}++/// The model-viewer bundle, fetched once into the cache and inlined thereafter+/// (the readme sandbox may fetch nothing at view time, so it must ride along).+fn ensure_model_viewer() -> Result<String, String> {+    let home = std::env::var("HOME").map_err(|_| "no HOME")?;+    let path = std::path::Path::new(&home).join(".cache/adom-lbr/model-viewer-3.5.0.min.js");+    if !path.exists() {+        let _ = fs::create_dir_all(path.parent().unwrap());+        let st = process::Command::new("curl")+            .args(["-sL", "--max-time", "60", "-o", path.to_str().unwrap_or_default(),+                   "https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"])+            .status()+            .map_err(|e| format!("curl did not run: {e}"))?;+        if !st.success() {+            return Err("could not fetch the model-viewer bundle (offline?); the static render stands in".into());+        }+    }+    let js = fs::read_to_string(&path).map_err(|e| format!("read model-viewer bundle: {e}"))?;+    if js.len() < 100_000 {+        let _ = fs::remove_file(&path);+        return Err("model-viewer bundle looks truncated; refetch next run".into());+    }+    Ok(js)+}++fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> {+    let mpn = dir.file_name().and_then(|s| s.to_str()).ok_or("bad --dir")?.to_string();+    // renders+    let sym_svg = read_first(dir, &[format!("{mpn}-symbol.svg")])+        .map(|s| s)+        .unwrap_or_else(|| "<div class=\"ph\">no symbol render in this directory</div>".into());+    let fp_svg = read_first(dir, &[format!("{mpn}-footprint.authoritative.svg"), format!("{mpn}-footprint.svg")])+        .unwrap_or_else(|| "<div class=\"ph\">no footprint render in this directory</div>".into());+    // The 3D column: a real interactive viewer when the directory has a .glb+    // (adom-lbr#4, barrett's design carried forward). The model rides INLINE as+    // a data URI and the model-viewer bundle is inlined too, because the wiki+    // serves readmes with connect-src 'none': nothing may be fetched at view+    // time. Draco models are transcoded to KHR_mesh_quantization at build time+    // (three.js decodes that natively; the Draco decoder is a CDN fetch the+    // sandbox blocks - and quantized is measured ~40% smaller anyway).+    let static_3d = || {+        let cands = ["-3d-iso-named.png", "-3d-iso.png", "-3d-iso-lg.png"]+            .iter().map(|s| dir.join(format!("{mpn}{s}"))).find(|p| p.exists());+        match cands.and_then(|p| fs::read(&p).ok()) {+            Some(bytes) => format!("<img src=\"data:image/png;base64,{}\">", b64(&bytes)),+            None => "<div class=\"ph\">no 3D render in this directory</div>".into(),+        }+    };+    let threed = match build_3d_viewer(dir, &mpn) {+        Ok(html) => html,+        Err(reason) => {+            eprintln!("HINT: 3D column falls back to the static render: {reason}");+            static_3d()+        }+    };+    // optional pin→pad connects from a .lbr+    let mut pin_to_pad: std::collections::HashMap<String, String> = std::collections::HashMap::new();+    if let Some(lbr) = read_first(dir, &[format!("{mpn}.lbr")]) {+        if let Ok(re) = regex_lite::Regex::new(r#"<connect gate="[^"]*" pin="([^"]+)" pad="([^"]+)"/>"#) {+            for cap in re.captures_iter(&lbr) {+                pin_to_pad.insert(cap[1].to_string(), cap[2].to_string());+            }+        }+    }+    // pins + descriptions from the extracted symbol JSON+    let mut pins_json = String::from("[]");+    let mut pad_count = fp_svg.matches("adom-pad").count().max(0);+    if let Some(ext) = read_first(dir, &[format!("{mpn}-symbol.extracted.json")]) {+        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&ext) {+            if let Some(arr) = v.get("pins").and_then(|p| p.as_array()) {+                let rows: Vec<serde_json::Value> = arr.iter().map(|p| {+                    let num = p.get("number").and_then(|x| x.as_str()).unwrap_or("").to_string();+                    let raw_t = p.get("type").and_then(|x| x.as_str()).unwrap_or("");+                    let (label, color) = pin_type_style(raw_t);+                    let pad = pin_to_pad.get(p.get("name").and_then(|x| x.as_str()).unwrap_or(""))+                        .cloned().unwrap_or_else(|| num.clone());+                    serde_json::json!({+                        "number": num,+                        "name": p.get("name").and_then(|x| x.as_str()).unwrap_or(""),+                        "pad": pad,+                        "type": label,+                        "color": color,+                        "desc": p.get("description").and_then(|x| x.as_str()).unwrap_or(""),+                    })+                }).collect();+                pad_count = pad_count.max(rows.len());+                pins_json = serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into());+            }+        }+    }+    let pin_count = serde_json::from_str::<serde_json::Value>(&pins_json).ok()+        .and_then(|v| v.as_array().map(|a| a.len())).unwrap_or(0);++    let html = include_str!("embed.html")+        .replace("__NAME__", &esc_html(&mpn))+        .replace("__SYM__", &sym_svg)+        .replace("__FP__", &fp_svg)+        .replace("__THREED__", &threed)+        .replace("__PINS__", &pins_json)+        .replace("__PADN__", &pad_count.to_string())+        .replace("__PINN__", &pin_count.to_string());+    let out = out.unwrap_or_else(|| dir.join(format!("{mpn}-lbr-embed.html")));+    fs::write(&out, &html).map_err(|e| format!("writing {}: {e}", out.display()))?;+    println!("OK: 3-column interactive embed ({pin_count} pins) → {}", out.display());+    Ok(())+}++#[cfg(test)]+mod lint_tests {+    use super::{dedup_pad_names, lint_lbr};++    #[test]+    fn four_sided_symbol_relays_into_two_columns() {+        use super::{relayout_two_columns, KicadPin, ParsedSymbol};+        let pin = |name: &str, number: &str, pin_type: &str, angle: u32| KicadPin {+            name: name.into(), number: number.into(), pin_type: pin_type.into(),+            x: 0.0, y: 0.0, angle, length: 2.54,+        };+        let mut sym = ParsedSymbol {+            name: "U1".into(), reference: "U".into(), value: String::new(),+            footprint: String::new(), description: String::new(),+            pins: vec![+                pin("OUT", "1", "output", 180),+                pin("GND", "2", "power_in", 90),   // bottom pin: the package-mirror tell+                pin("IN", "3", "input", 0),+                pin("VDD", "4", "power_in", 270),  // top pin+            ],+            body: None, group_labels: vec![], graphics: vec![],+        };+        assert!(relayout_two_columns(&mut sym, false));+        for p in &sym.pins {+            assert!(p.angle == 0 || p.angle == 180, "no rotated (top/bottom) pins survive: {} at {}", p.name, p.angle);+        }+        let body = sym.body.as_ref().unwrap();+        let by = |n: &str| sym.pins.iter().find(|p| p.name == n).unwrap();+        assert!(by("IN").x < body.left && by("IN").angle == 0, "inputs on the LEFT");+        assert!(by("OUT").x > body.right && by("OUT").angle == 180, "outputs on the RIGHT");+        assert!(by("GND").x < body.left && by("VDD").x < body.left, "power grouped LEFT");+        assert!(by("GND").y < by("IN").y, "power below the signal pins");+        // a two-column source stays untouched under auto+        let mut two_col = ParsedSymbol {+            pins: vec![pin("IN", "1", "input", 0), pin("OUT", "2", "output", 180)],+            ..sym+        };+        assert!(!relayout_two_columns(&mut two_col, false), "auto must not touch a left/right symbol");+    }++    #[test]+    fn pad_dedup_uses_the_eagle_at_suffix_convention() {+        let names: Vec<String> = ["17", "18", "17", "SH", "17"].iter().map(|s| s.to_string()).collect();+        let (out, groups) = dedup_pad_names(&names);+        assert_eq!(out, vec!["17", "18", "17@2", "SH", "17@3"]);+        assert_eq!(groups["17"], vec!["17", "17@2", "17@3"]);+        assert_eq!(groups["18"], vec!["18"]);+    }++    /// A library shaped like the one that shipped broken: a USB-C receptacle whose+    /// symbol calls the shield pin S1 while the footprint names that pad SH.+    fn lbr(connect_pad: &str, extra_pkg: &str) -> String {+        format!(+            r#"<eagle><drawing><library>+<packages><package name="TYPEC"><smd name="A4" x="0" y="0"/><smd name="SH" x="3" y="0"/>{extra}</package></packages>+<symbols><symbol name="TYPEC"><pin name="VBUS" x="0" y="0"/><pin name="SHIELD" x="0" y="5"/></symbol></symbols>+<devicesets><deviceset name="TYPEC"><gates><gate name="G$1" symbol="TYPEC" x="0" y="0"/></gates>+<devices><device name="" package="TYPEC"><connects>+<connect gate="G$1" pin="VBUS" pad="A4"/>+<connect gate="G$1" pin="SHIELD" pad="{pad}"/>+</connects><technologies><technology name=""/></technologies></device></devices>+</deviceset></devicesets>+</library></drawing></eagle>"#,+            pad = connect_pad, extra = extra_pkg+        )+    }++    #[test]+    fn dangling_connect_pad_fails_and_names_the_unreferenced_pad() {+        let r = lint_lbr(&lbr("S1", ""));+        assert!(!r.passed, "a connect pointing at a pad that does not exist must FAIL, not warn");+        let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");+        assert!(e.message.contains("\"SHIELD\"") && e.message.contains("\"S1\""), "names the pin and the pad: {}", e.message);+        assert!(e.message.contains("unreferenced pads in package: SH"),+            "names the pad nothing connects to, which is the half that shows the fix: {}", e.message);+    }++    #[test]+    fn resolved_connect_passes() {+        let r = lint_lbr(&lbr("SH", ""));+        assert!(r.errors.iter().all(|e| e.code != "connect-pad-unresolved"), "SH resolves: {:?}", r.errors);+    }++    #[test]+    fn through_hole_pads_count_too() {+        // the old check read <smd> only, so every through-hole part passed blind+        let tht = lbr("SH", "").replace(r#"<smd name="SH" x="3" y="0"/>"#, r#"<pad name="SH" x="3" y="0"/>"#);+        assert!(lint_lbr(&tht).errors.iter().all(|e| e.code != "connect-pad-unresolved"),+            "a <pad> (through-hole) resolves a connect just like an <smd>");+    }++    #[test]+    fn published_library_must_not_carry_an_autodesk_urn() {+        let r = lint_lbr(&lbr("SH", r#"<package3d name="X" urn="urn:adsk.wipprod:fs.file:co.abc"/>"#));+        assert!(!r.passed);+        assert!(r.errors.iter().any(|e| e.code == "package3d-urn"), "{:?}", r.errors);+    }++    #[test]+    fn duplicate_pad_names_fail_the_lint() {+        // KiCad same-net arrays (thermal vias, shield tabs) share one pad name;+        // Fusion refuses the entire library over the repeat (adom/adom-lbr#10)+        let r = lint_lbr(&lbr("SH", r#"<smd name="SH" x="4" y="0"/><smd name="SH" x="5" y="0"/>"#));+        assert!(!r.passed);+        let e = r.errors.iter().find(|e| e.code == "duplicate-pad-name").expect("duplicate-pad-name");+        assert!(e.message.contains("'SH'") && e.message.contains("3 times"), "{}", e.message);+        assert_eq!(r.errors.iter().filter(|e| e.code == "duplicate-pad-name").count(), 1,+            "one repeated name reports once, not once per extra pad");+    }++    #[test]+    fn suffixed_pad_group_with_multi_pad_connect_passes() {+        // the shape generate now emits for a same-net array: NAME, NAME@2, ...+        // and one connect naming the whole group, space-separated+        let r = lint_lbr(&lbr("SH SH@2 SH@3",+            r#"<smd name="SH@2" x="4" y="0"/><smd name="SH@3" x="5" y="0"/>"#));+        assert!(r.errors.iter().all(|e| e.code != "duplicate-pad-name" && e.code != "connect-pad-unresolved"),+            "@-suffixed group + multi-pad connect is the sanctioned form: {:?}", r.errors);+    }++    #[test]+    fn multi_pad_connect_with_a_missing_pad_still_fails() {+        // each name in a space-separated pad group must resolve individually+        let r = lint_lbr(&lbr("SH SH@2", ""));+        let e = r.errors.iter().find(|e| e.code == "connect-pad-unresolved").expect("connect-pad-unresolved");+        assert!(e.message.contains("\"SH@2\""), "names the missing group member: {}", e.message);+    }++    #[test]+    fn empty_packages_is_caught_through_whitespace() {+        // the literal "<packages></packages>" match missed a section holding only+        // whitespace, and such a library shipped with zero packages+        let empty = lbr("SH", "").replace(+            r#"<package name="TYPEC"><smd name="A4" x="0" y="0"/><smd name="SH" x="3" y="0"/></package>"#,+            "\n\n  ");+        let r = lint_lbr(&empty);+        assert!(r.errors.iter().any(|e| e.code == "empty-packages"), "{:?}", r.errors);+    }+}+--- a/src/manage.rs+++ b/src/manage.rs@@ -1,1596 +1,1597 @@⋯ 1243 unchanged lines ⋯     let lbr = match crate::parse_kicad_sym(&sym_txt, Some(&mpn)) {         Ok(mut parsed) => {             crate::relayout_two_columns(&mut parsed, false);-            Some(crate::generate_lbr(&parsed, Some(&fp_txt), None, 0.38, &pin_map))+            Some(crate::generate_lbr(&parsed, Some(&fp_txt), None, 0.38, &pin_map, &[]))         }         Err(_) => None,     };⋯ 343 unchanged lines ⋯         panes.join("+"), rows.len(), has3d, out.display());     Ok(()) }+--- a/VERSION+++ b/VERSION@@ -1,1 +1,2 @@-2.17.1+2.18.0+--- a/Cargo.toml+++ b/Cargo.toml@@ -1,28 +1,29 @@ [package] name = "adom-lbr"-version = "2.16.0"+version = "2.18.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." ⋯ 19 unchanged lines ⋯ opt-level = "z" lto = true strip = true+--- a/package.json+++ b/package.json@@ -1,85 +1,86 @@ {   "slug": "adom-lbr",-  "version": "2.16.0",+  "version": "2.18.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad 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": {},⋯ 76 unchanged lines ⋯     }   ] }+

Comments

John Lauer · 1mo ago

Verified against the fusion-bridge linter: this emits exactly the right shape. One gap worth a follow-up, not a blocker.

I fed your sample XML through the bridge's own _lint_part_metadata (the check that surfaced the gap you fixed): metadataOk: true, zero warnings, all five attribute names read back — MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN. Those are the exact names the bridge's BOM export reads, so a library from this generator now produces a populated BOM instead of a blank one. Thank you for taking the finding to the source rather than papering over it downstream.

The pin-1 polygon is the bigger fix by scope (97% of pages), and the "geometry looked perfect while a shape was silently dropped" description is the same failure class the bridge has been chasing all week: a result that agrees with itself and disagrees with reality.

The gap: one page.json → one <technology name="">, so variants cannot be expressed

The emitter hardcodes a single technology. That is correct for a part with one orderable form, but John's requirement for the bridge (and the reason the linter exists) is the variant case:

"one chip could be the same symbol, footprint, and 3d chip, but there might be an automotive high temp version vs a cheaper baseline variant"

EAGLE spells that as several <technology name="..."> entries under one <device>, each with its own attributes and a DESCRIPTION. Same symbol, footprint and 3D body; different MPN, different distributor numbers, different grade:

<technologies>
  <technology name="">
    <attribute name="MPN" value="TJA1050T/CM,118"/>
    <attribute name="MANUFACTURER" value="NXP"/>
    <attribute name="DIGIKEY_PN" value="568-4885-1-ND"/>
    <attribute name="DESCRIPTION" value="Baseline, -40 to +125C"/>
  </technology>
  <technology name="-AUTO">
    <attribute name="MPN" value="TJA1050T/VM,118"/>
    <attribute name="MANUFACTURER" value="NXP"/>
    <attribute name="MOUSER_PN" value="771-TJA1050TVM118"/>
    <attribute name="DESCRIPTION" value="AEC-Q100 automotive, -40 to +150C"/>
  </technology>
</technologies>

Full name becomes deviceset + device + technology (TJA1050T, TJA1050T-AUTO). The bridge's linter already understands this and will flag variants_indistinguishable if two technologies share geometry but lack distinct MPNs, so the downstream check is in place; it is the emission that cannot produce it yet.

Suggested shape, if the component metadata can carry it: a variants list on the page (or in --part-metadata), each entry with a technology suffix, its own part numbers, and a description. Absent that list, keep exactly what you have — a single unnamed technology is the right default and should not change.

Not asking you to build it in this PR. Filing it as the follow-up so the variant contract has an owner on the emitter side; the bridge side is done and documented in fusion-part-metadata.

One nit, informational: your sample shows constant="no" on each attribute but the diff does not emit it. EAGLE defaults it, so nothing is wrong; just noting the sample and the code differ so nobody expects the attribute.

Ray · 1mo ago

Reference correction: the body above cites #933, which is the global discussion id, not a followable ref — adom-wiki issue view adom/adom-lbr#933 returns 404, because issue numbers on a page are per-page ordinals.

The issue this fixes is adom/adom-lbr#16 — https://wiki.adom.inc/adom/adom-lbr/issues/16 (global id 933). I have commented there with the shipped result (2.18.0, merged as 2d093ce).

Log in to comment.