← Commit history

2.10.0: same-net pad arrays get the EAGLE @-suffix with a multi-pad connect (adom-lbr#10); EE two-column symbol re-layout, --symbol-layout auto|keep|two-column (adom-lbr#5)

John Lauer ·3e4e7ed4d7 ·1mo ago ·parent a7b6700
6 files changed +388−17
Cargo.lock+74−2
@@ -2,12 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"+ [[package]] name = "adom-lbr"-version = "1.8.3"+version = "2.10.0" dependencies = [+ "altium-codec",  "anyhow",- "cfb",  "clap",  "clap_complete",  "quick-xml",@@ -17,6 +23,37 @@ dependencies = [  "tiny_http", ] +[[package]]+name = "altium-codec"+version = "0.1.0"+dependencies = [+ "altium-pcblib",+ "altium-schlib",+ "anyhow",+ "cfb",+ "flate2",+ "serde",+ "serde_json",+]++[[package]]+name = "altium-pcblib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]++[[package]]+name = "altium-schlib"+version = "0.1.0"+dependencies = [+ "anyhow",+ "cfb",+ "serde_json",+]+ [[package]] name = "anstream" version = "1.0.0"@@ -169,6 +206,25 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]+ [[package]] name = "fnv" version = "1.0.7"@@ -246,6 +302,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]+ [[package]] name = "once_cell" version = "1.21.4"@@ -347,6 +413,12 @@ dependencies = [  "zmij", ] +[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"+ [[package]] name = "slab" version = "0.4.12"
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.9.0"+version = "2.10.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." 
SKILL.md+14−1
@@ -115,6 +115,18 @@ Parses a KiCad .kicad_sym file and produces a valid EAGLE .lbr with:  Auto-lints the output before writing. Exits 2 if lint fails. +`--symbol-layout auto` (the default) re-lays a symbol that mirrors the physical+package (pins on all four sides, rotated colliding names) into EE-convention+two columns: inputs left, outputs and bidirectional right, power grouped+bottom-left, buses contiguous, every name horizontal. `keep` preserves the+.kicad_sym placement verbatim; `two-column` forces the re-layout.++Duplicate PAD names (KiCad same-net arrays: thermal vias, ground paddles,+shield tabs) are resolved to the EAGLE `@`-suffix convention — `17`, `17@2`,+`17@3`, ... — with one space-separated multi-pad `<connect>` binding the whole+group to its pin, so the electrical meaning is preserved. Fusion refuses the+entire library over a raw duplicate.+ ## import  Parses an EAGLE .lbr and converts to KiCad .kicad_sym format. Handles:@@ -134,7 +146,8 @@ Checks: - `empty-connects`: device has no connect mappings (fatal) - `device-no-package`: device element missing package attribute (fatal) - `duplicate-pin-name`: EAGLE requires unique pin names; use @suffix for power/NC duplicates-- `connect-pad-missing`: connect references a pad that doesn't exist in the package+- `duplicate-pad-name`: a pad name repeats within a package (fatal: Fusion refuses the whole library; regenerate to get the @-suffix convention)+- `connect-pad-unresolved`: a connect references a pad that doesn't exist in the package (each name in a space-separated multi-pad group is checked) - `malformed-xml`: unbalanced angle brackets - `no-symbol-layer`: nothing on layer 94 (symbol body invisible) 
VERSION+1−1
@@ -1 +1 @@-2.9.0+2.10.0
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.9.0",+  "version": "2.10.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": {},
src/main.rs+297−11
@@ -55,6 +55,15 @@ enum Commands {         /// `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 {@@ -627,6 +636,144 @@ fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> { }  /// 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.+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())@@ -1054,21 +1201,26 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option      // 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)));-        for p in &mod_pads {+        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(&p.name), p.x, -p.y, p.dx, p.dy, smd_rotation(p.angle)));+                    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(&p.name), p.x, -p.y, drill, diameter, p.shape, smd_rotation(p.angle)));+                    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.@@ -1101,7 +1253,10 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option     let pin_count = sym.pins.len();     let half = (pin_count + 1) / 2; -    for (i, pin) in sym.pins.iter().enumerate() {+    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)@@ -1111,7 +1266,7 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option         };         xml.push_str(&format!(             "  <smd name=\"{}\" x=\"{:.3}\" y=\"{:.3}\" dx=\"{:.2}\" dy=\"{:.2}\" layer=\"1\"/>\n",-            esc_xml(&pin.number), px, py, pad_size, pad_size+            esc_xml(&placeholder_out[i]), px, py, pad_size, pad_size         ));     } @@ -1229,9 +1384,17 @@ fn generate_lbr(sym: &ParsedSymbol, fp_content: Option<&str>, symbol_art: Option         // 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)+            esc_xml(&deduped_names[i]), esc_xml(&pad_ref)         ));     } @@ -1383,6 +1546,29 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {     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);     } @@ -1397,8 +1583,14 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {         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, pad) = (c[1].to_string(), c[2].to_string());-            if pad_set.contains(&pad) { used.insert(pad); } else { dangling.push((pin, pad)); }+            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@@ -1545,7 +1737,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map } => {+        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);@@ -1553,7 +1745,7 @@ fn main() {                 })             }); -            let parsed = match (&sym, &fp_content) {+            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);@@ -1611,6 +1803,25 @@ fn main() {                     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@@ -2079,7 +2290,52 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String>  #[cfg(test)] mod lint_tests {-    use super::lint_lbr;+    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.@@ -2130,6 +2386,36 @@ mod lint_tests {         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