← Commit history

2.47.5: generate refuses a supplied zero-pad footprint instead of inventing copper (#32, John's patch); GH 5230534

Colby Knox ·26c7ed58ce ·8d ago ·parent 43f792a
11 files changed +82−10
CHANGELOG.md+3
@@ -1,5 +1,8 @@ # Changelog +## 2.47.5 (2026-10-12)+- `generate` refuses a supplied footprint that has no pads (`ERROR: [ZERO_PAD_FOOTPRINT]`, exit 2, nothing written) instead of inventing copper. A mechanical-only footprint for an off-board part (a flying-lead sensor whose leads land on a separate connector) used to come out with two touching placeholder SMD pads and only a lint warning. A zero-pad package variant is refused the same way, and the Manager returns a structured error. Generating with no `--fp` still makes the documented placeholder. (John)+ ## 2.47.4 (2026-10-08) - Lint (`pad-clearance`, so `validate` too): overlapping pads wired to the same pin are one net, not a short. KiCad's USB-C receptacles stack paired pads on purpose (A1 over B12, both GND; A4 over B9, both VBUS), and EAGLE names the paired pins `GND@1`, `GND@2`; those footprints failed validation as four shorts. Overlapping pads on different pins still fail, and a pair counts as one net only if every device that uses the package wires it that way. (Ray) 
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.47.4"+version = "2.47.5" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.47.4"+version = "2.47.5" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.47.4+2.47.5
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.47.4",+  "version": "2.47.5",   "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": {},
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.47.4",+  "version": "2.47.5",   "type": "app",   "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},
src/main.rs+51−4
@@ -2338,11 +2338,21 @@ fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&S     deduped_names } -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 {+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)]) -> Result<String, String> {     generate_lbr_gates(sym, fp_content, symbol_art, symbol_art_scale, pin_map, part_attrs, &[], &[]) } -pub(crate) fn generate_lbr_gates(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)], alts: &[AltGate], variants: &[PackageVariant]) -> String {+pub(crate) fn generate_lbr_gates(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)], alts: &[AltGate], variants: &[PackageVariant]) -> Result<String, String> {+    // An explicitly supplied zero-pad footprint is not an absent footprint.+    // Reject this unsupported off-board export before inventing copper or writing output.+    if fp_content.is_some_and(|km| parse_mod_pads(km).is_empty()) {+        return Err("[ZERO_PAD_FOOTPRINT] Supplied footprint has no parsed electrical pads; off-board/mechanical export is unsupported. No placeholder copper was generated. Keep the schematic-only component separate from its actual PCB connector.".into());+    }+    for variant in variants {+        if parse_mod_pads(&variant.km).is_empty() {+            return Err(format!("[ZERO_PAD_FOOTPRINT] Package variant {} has no parsed electrical pads; export refused without inventing copper.", variant.suffix));+        }+    }     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -2627,7 +2637,7 @@ Place them within the pad they serve, or they will import as unconnected copper.     xml.push_str("</devicesets>\n");      xml.push_str("</library>\n</drawing>\n</eagle>");-    xml+    Ok(xml) }  // ── Linter ──@@ -3536,7 +3546,10 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or                 println!("HINT: {} alternate symbol(s) as extra gates of {}: {}", alts.len(), parsed.name,                     alts.iter().map(|a| format!("{} ({})", a.gate, a.sym.name)).collect::<Vec<_>>().join(", "));             }-            let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants);+            let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants).unwrap_or_else(|e| {+                eprintln!("ERROR: {e}");+                process::exit(2);+            });              // Losses are decided here, once, before anything is linted or             // written: the parser only collected the margin pads.@@ -5142,3 +5155,37 @@ mod lint_tests {         assert!(r.errors.iter().any(|e| e.code == "empty-packages"), "{:?}", r.errors);     } }++#[cfg(test)]+mod zero_pad_export_tests {+    use super::*;+    const SENSOR: &str = include_str!("../tests/fixtures/zero-pad/sensor.kicad_sym");+    const MECHANICAL: &str = include_str!("../tests/fixtures/zero-pad/sensor.kicad_mod");+    const ELECTRICAL: &str = r#"(footprint "two-pin"+      (pad "1" smd rect (at -2 0) (size 1 1) (layers "F.Cu"))+      (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu")))"#;+    #[test]+    fn explicit_zero_pad_footprint_is_refused_without_copper() {+        let sym = parse_kicad_sym(SENSOR, None).unwrap();+        let err = generate_lbr(&sym, Some(MECHANICAL), None, 0.38, &Default::default(), &[]).unwrap_err();+        assert!(err.contains("ZERO_PAD_FOOTPRINT"));+    }+    #[test]+    fn zero_pad_variant_is_refused_as_well() {+        let sym = parse_kicad_sym(SENSOR, None).unwrap();+        let variants = [PackageVariant { suffix: "-offboard".into(), km: MECHANICAL.into(), description: None }];+        let err = generate_lbr_gates(&sym, Some(ELECTRICAL), None, 0.38, &Default::default(), &[], &[], &variants).unwrap_err();+        assert!(err.contains("ZERO_PAD_FOOTPRINT"));+        assert!(err.contains("-offboard"));+    }+    #[test]+    fn real_pads_remain_exact_and_absent_footprint_stays_distinct() {+        let sym = parse_kicad_sym(SENSOR, None).unwrap();+        let xml = generate_lbr(&sym, Some(ELECTRICAL), None, 0.38, &Default::default(), &[]).unwrap();+        assert_eq!(xml.matches("<smd ").count(), 2);+        assert!(xml.contains("x=\"-2.0000\""));+        assert!(xml.contains("x=\"2.0000\""));+        let placeholder = generate_lbr(&sym, None, None, 0.38, &Default::default(), &[]).unwrap();+        assert!(placeholder.contains("placeholder footprint"));+    }+}
src/manage.rs+4−1
@@ -1302,7 +1302,10 @@ fn export_part(slug: &str) -> Value {     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, &[]))+            match crate::generate_lbr(&parsed, Some(&fp_txt), None, 0.38, &pin_map, &[]) {+                Ok(lbr) => Some(lbr),+                Err(error) => return json!({"status":"error","error":error}),+            }         }         Err(_) => None,     };
tests/fixtures/zero-pad/sensor.kicad_modadded+7
@@ -0,0 +1,7 @@+(footprint "NTCLE428E3103H301A_MECHANICAL_ONLY" (version 20240108) (generator "adom") (layer "F.Cu")+(attr board_only exclude_from_pos_files exclude_from_bom)+(descr "Off-board300mm straight sensor/cable envelope. Zero copper pads; terminate the flying leads using a separately selected connector.")+(fp_rect (start 0 -1.5) (end 10 1.5) (stroke (width 0.1) (type default)) (fill none) (layer "F.Fab"))+(fp_line (start 10 -.425) (end 300 -.425) (stroke (width .1) (type default)) (layer "F.Fab"))+(fp_line (start 10 .425) (end 300 .425) (stroke (width .1) (type default)) (layer "F.Fab"))+(model "${KIPRJMOD}/ntcle428e3103h301a.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0))))
tests/fixtures/zero-pad/sensor.kicad_symadded+12
@@ -0,0 +1,12 @@+(kicad_symbol_lib (version 20231120) (generator "adom")+(symbol "NTCLE428E3103H301A" (in_bom yes) (on_board no)+(property "Reference" "TH" (at 0 5.08 0) (effects (font (size 1.27 1.27))))+(property "Value" "NTCLE428E3103H301A" (at 0 -5.08 0) (effects (font (size 1.27 1.27))))+(property "MPN" "NTCLE428E3103H301A" (at 0 0 0) (hide yes) (effects (font (size 1.27 1.27))))+(property "Datasheet" "https://www.vishay.com/docs/29186/ntcle428.pdf" (at 0 0 0) (hide yes) (effects (font (size 1.27 1.27))))+(symbol "NTCLE428E3103H301A_0_1"+(rectangle (start -2.54 1.27) (end 2.54 -1.27) (stroke (width 0) (type default)) (fill (type background)))+(polyline (pts (xy -3.175 -2.54) (xy 3.175 2.54) (xy 4.445 2.54)) (stroke (width 0) (type default)) (fill (type none))))+(symbol "NTCLE428E3103H301A_1_1"+(pin passive line (at -5.08 0 0) (length 2.54) (name "Lead1" (effects (font (size 1 1)))) (number "1" (effects (font (size 1 1)))))+(pin passive line (at 5.08 0 180) (length 2.54) (name "Lead2" (effects (font (size 1 1)))) (number "2" (effects (font (size 1 1))))))))