Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to Semantic Versioning (https://semver.org/spec/v2.0.0.

## [Unreleased]

### Changed

- KiCad symbol is now the source of truth for component metadata (footprint, datasheet, part); generated `.zen` files are minimal wrappers.
- `Component()` inherits `skip_bom` from the KiCad symbol `in_bom` flag (inverted) when not explicitly set.

### Added

- Warn when module `io()`s are declared but never connected to any realized ports.
Expand Down
122 changes: 39 additions & 83 deletions crates/pcb-component-gen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,49 +93,28 @@ pub fn sanitize_pin_name(name: &str) -> String {
}

pub struct GenerateComponentZenArgs<'a> {
pub mpn: &'a str,
pub component_name: &'a str,
pub symbol: &'a Symbol,
pub symbol_filename: &'a str,
pub footprint_filename: Option<&'a str>,
pub datasheet_filename: Option<&'a str>,
pub manufacturer: Option<&'a str>,
pub generated_by: &'a str,
pub include_skip_bom: bool,
pub include_skip_pos: bool,
pub skip_bom_default: bool,
pub skip_pos_default: bool,
pub pin_defs: Option<&'a BTreeMap<String, String>>,
}

pub fn generate_component_zen(args: GenerateComponentZenArgs<'_>) -> Result<String> {
let component_name = sanitize_mpn_for_path(args.component_name);

// Group pins by sanitized name; duplicate signal names (e.g. multiple "NC" pads)
// naturally merge into one io() declaration.
let mut pin_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut pin_defs_vec: Option<Vec<serde_json::Value>> = None;

if let Some(pin_defs) = args.pin_defs {
pin_defs_vec = Some(
pin_defs
.iter()
.map(|(name, pad)| serde_json::json!({"name": name, "pad": pad}))
.collect(),
);

for signal_name in pin_defs.keys() {
pin_groups
.entry(sanitize_pin_name(signal_name))
.or_default()
.insert(signal_name.to_string());
}
} else {
for pin in &args.symbol.pins {
let signal_name = pin.signal_name().to_string();
pin_groups
.entry(sanitize_pin_name(&signal_name))
.or_default()
.insert(signal_name);
}
for pin in &args.symbol.pins {
let signal_name = pin.signal_name().to_string();
pin_groups
.entry(sanitize_pin_name(&signal_name))
.or_default()
.insert(signal_name);
}

let pin_groups_vec: Vec<_> = pin_groups
Expand All @@ -162,14 +141,9 @@ pub fn generate_component_zen(args: GenerateComponentZenArgs<'_>) -> Result<Stri
.get_template("component.zen")?
.render(serde_json::json!({
"component_name": component_name,
"mpn": args.mpn,
"manufacturer": args.manufacturer,
"sym_path": args.symbol_filename,
"footprint_path": args.footprint_filename.unwrap_or(&format!("{}.kicad_mod", args.mpn)),
"pin_defs": pin_defs_vec,
"pin_groups": pin_groups_vec,
"pin_mappings": pin_mappings,
"datasheet_file": args.datasheet_filename,
"generated_by": args.generated_by,
"include_skip_bom": args.include_skip_bom,
"include_skip_pos": args.include_skip_pos,
Expand Down Expand Up @@ -219,36 +193,40 @@ mod tests {
};

let zen = generate_component_zen(GenerateComponentZenArgs {
mpn: "MPN1",
component_name: "MPN1",
symbol: &symbol,
symbol_filename: "MPN1.kicad_sym",
footprint_filename: Some("FP.kicad_mod"),
datasheet_filename: None,
manufacturer: Some("ACME"),
generated_by: "pcb import",
include_skip_bom: false,
include_skip_pos: false,
skip_bom_default: false,
skip_pos_default: false,
pin_defs: None,
})
.unwrap();

assert!(zen.contains("Auto-generated using `pcb import`."));
assert!(!zen.contains("skip_bom = config("));
assert!(!zen.contains("skip_pos = config("));
assert!(zen.contains("Pins = struct("));
assert!(zen.contains("N_INT"));
assert!(zen.contains("\"~{INT}\": Pins.N_INT"));
assert!(zen.contains("VCC"));
assert!(zen.contains("footprint = File(\"FP.kicad_mod\")"));
assert!(!zen.contains("pin_defs"));
}

#[test]
fn includes_skip_flags_when_enabled() {
fn omits_symbol_backed_fields_even_when_symbol_has_them() {
let symbol = pcb_eda::Symbol {
name: "X".to_string(),
properties: [
("Footprint".to_string(), "X".to_string()),
("Datasheet".to_string(), "X.pdf".to_string()),
(
"Manufacturer_Part_Number".to_string(),
"SYM-MPN".to_string(),
),
("Manufacturer_Name".to_string(), "SYM-MFR".to_string()),
]
.into_iter()
.collect(),
pins: vec![pcb_eda::Pin {
name: "VCC".to_string(),
number: "1".to_string(),
Expand All @@ -258,30 +236,25 @@ mod tests {
};

let zen = generate_component_zen(GenerateComponentZenArgs {
mpn: "MPN1",
component_name: "MPN1",
symbol: &symbol,
symbol_filename: "MPN1.kicad_sym",
footprint_filename: Some("FP.kicad_mod"),
datasheet_filename: None,
manufacturer: Some("ACME"),
generated_by: "pcb import",
include_skip_bom: true,
include_skip_pos: true,
include_skip_bom: false,
include_skip_pos: false,
skip_bom_default: false,
skip_pos_default: false,
pin_defs: None,
})
.unwrap();

assert!(zen.contains("skip_bom = config(\"skip_bom\", bool, default=False)"));
assert!(zen.contains("skip_pos = config(\"skip_pos\", bool, default=False)"));
assert!(zen.contains("skip_bom = skip_bom"));
assert!(zen.contains("skip_pos = skip_pos"));
assert!(!zen.contains("footprint = File("));
assert!(!zen.contains("datasheet = File("));
assert!(!zen.contains("part = Part("));
assert!(!zen.contains("config("));
}

#[test]
fn uses_true_defaults_when_requested() {
fn renders_skip_flags_when_requested() {
let symbol = pcb_eda::Symbol {
name: "X".to_string(),
pins: vec![pcb_eda::Pin {
Expand All @@ -293,24 +266,21 @@ mod tests {
};

let zen = generate_component_zen(GenerateComponentZenArgs {
mpn: "MPN1",
component_name: "MPN1",
symbol: &symbol,
symbol_filename: "MPN1.kicad_sym",
footprint_filename: Some("FP.kicad_mod"),
datasheet_filename: None,
manufacturer: Some("ACME"),
generated_by: "pcb import",
include_skip_bom: true,
include_skip_pos: true,
skip_bom_default: true,
skip_bom_default: false,
skip_pos_default: true,
pin_defs: None,
})
.unwrap();

assert!(zen.contains("skip_bom = config(\"skip_bom\", bool, default=True)"));
assert!(zen.contains("skip_pos = config(\"skip_pos\", bool, default=True)"));
assert!(zen.contains("skip_bom = config(\"skip_bom\", bool, default = False)"));
assert!(zen.contains("skip_pos = config(\"skip_pos\", bool, default = True)"));
assert!(zen.contains("skip_bom = skip_bom"));
assert!(zen.contains("skip_pos = skip_pos"));
}

#[test]
Expand All @@ -333,19 +303,14 @@ mod tests {
};

let zen = generate_component_zen(GenerateComponentZenArgs {
mpn: "C1",
component_name: "TP_0.75mm_SMD",
symbol: &symbol,
symbol_filename: "C1.kicad_sym",
footprint_filename: Some("FP.kicad_mod"),
datasheet_filename: None,
manufacturer: None,
generated_by: "pcb import",
include_skip_bom: false,
include_skip_pos: false,
skip_bom_default: false,
skip_pos_default: false,
pin_defs: None,
})
.unwrap();

Expand All @@ -356,7 +321,7 @@ mod tests {
}

#[test]
fn renders_pin_defs_when_provided() {
fn duplicate_pin_names_merge_into_single_io() {
let symbol = pcb_eda::Symbol {
name: "X".to_string(),
pins: vec![
Expand All @@ -374,31 +339,22 @@ mod tests {
..Default::default()
};

let mut pin_defs: BTreeMap<String, String> = BTreeMap::new();
pin_defs.insert("NC_6".to_string(), "6".to_string());
pin_defs.insert("NC_7".to_string(), "7".to_string());

let zen = generate_component_zen(GenerateComponentZenArgs {
mpn: "MPN1",
component_name: "MPN1",
symbol: &symbol,
symbol_filename: "MPN1.kicad_sym",
footprint_filename: Some("FP.kicad_mod"),
datasheet_filename: None,
manufacturer: None,
generated_by: "pcb import",
include_skip_bom: false,
include_skip_pos: false,
skip_bom_default: false,
skip_pos_default: false,
pin_defs: Some(&pin_defs),
})
.unwrap();

assert!(zen.contains("pin_defs = {"));
assert!(zen.contains("\"NC_6\": \"6\""));
assert!(zen.contains("\"NC_7\": \"7\""));
assert!(zen.contains("\"NC_6\": Pins.NC_6"));
assert!(zen.contains("\"NC_7\": Pins.NC_7"));
// Single io() for the shared signal name
assert!(zen.contains("NC = io(\"NC\", Net)"));
assert!(zen.contains("\"NC\": Pins.NC"));
// No pin_defs needed
assert!(!zen.contains("pin_defs"));
}
}
31 changes: 6 additions & 25 deletions crates/pcb-component-gen/templates/component.zen.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
Auto-generated using `{{ generated_by }}`.
"""

mpn = config("mpn", str, optional=True, default="{{ mpn }}")
manufacturer = config("manufacturer", str, optional=True{% if manufacturer %}, default="{{ manufacturer }}"{% endif %})
alternatives = config("alternatives", list, default=[])
{%- if include_skip_bom %}
skip_bom = config("skip_bom", bool, default={{ "True" if skip_bom_default else "False" }})
{%- endif %}
{%- if include_skip_pos %}
skip_pos = config("skip_pos", bool, default={{ "True" if skip_pos_default else "False" }})
{%- endif %}
{% if include_skip_bom %}
skip_bom = config("skip_bom", bool, default = {{ "True" if skip_bom_default else "False" }})
{% endif %}
{% if include_skip_pos %}
skip_pos = config("skip_pos", bool, default = {{ "True" if skip_pos_default else "False" }})
{% endif %}

Pins = struct(
{%- for pin in pin_groups %}
Expand All @@ -22,32 +19,16 @@ Pins = struct(

Component(
name = "{{ component_name }}",
mpn = mpn,
manufacturer = manufacturer,
{%- if include_skip_bom %}
skip_bom = skip_bom,
{%- endif %}
{%- if include_skip_pos %}
skip_pos = skip_pos,
{%- endif %}
{%- if datasheet_file %}
datasheet = File("{{ datasheet_file }}"),
{%- endif %}
{%- if pin_defs %}
pin_defs = {
{%- for pin in pin_defs %}
"{{ pin.name }}": "{{ pin.pad }}",
{%- endfor %}
},
{%- endif %}
symbol = Symbol(library = "{{ sym_path }}"),
footprint = File("{{ footprint_path }}"),
pins = {
{%- for pin in pin_mappings %}
"{{ pin.original_name }}": Pins.{{ pin.sanitized_name }}{{ "," if not loop.last }}
{%- endfor %}
},
properties = {
"alternatives": alternatives,
},
)
Loading
Loading