--- a/src/main.rs +++ b/src/main.rs @@ -2338,11 +2338,21 @@ 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, 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, part_attrs: &[(String, String)]) -> Result { 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, 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, part_attrs: &[(String, String)], alts: &[AltGate], variants: &[PackageVariant]) -> Result { + // 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("\n"); @@ -2627,7 +2637,7 @@ xml.push_str("\n"); xml.push_str("\n\n"); - xml + Ok(xml) } // ── Linter ── @@ -3536,7 +3546,10 @@ println!("HINT: {} alternate symbol(s) as extra gates of {}: {}", alts.len(), parsed.name, alts.iter().map(|a| format!("{} ({})", a.gate, a.sym.name)).collect::>().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 @@ 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(" { 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, }; --- /dev/null +++ b/tests/fixtures/zero-pad/sensor.kicad_sym @@ -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)))))))) --- /dev/null +++ b/tests/fixtures/zero-pad/sensor.kicad_mod @@ -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))))