app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← 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)
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