← Commit history

2.30.0: user layers; package variants in one deviceset

John Lauer ·ca1726fd1b ·1mo ago ·parent 3d3fc67
7 files changed +255−13
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.29.1"+version = "2.30.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.29.1"+version = "2.30.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." 
VERSION+1−1
@@ -1 +1 @@-2.29.1+2.30.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.29.1",+  "version": "2.30.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": {},
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.29.1",+  "version": "2.30.0",   "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": {},@@ -88,4 +88,4 @@       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
src/main.rs+249−7
@@ -68,6 +68,12 @@ enum Commands {         /// falling back to the symbol name when the footprint does not carry one.         #[arg(long)]         package_name: Option<String>,+        /// Extra PACKAGE variant of this same part: `SUFFIX=<file.kicad_mod>[=description]`,+        /// repeatable. Each becomes another <device> in the SAME deviceset, so Fusion's+        /// CHANGE PACKAGE swaps between them without re-wiring. Extra pads that sit inside a+        /// baseline pad (heatsink vias in a thermal pad) join that pad's connect.+        #[arg(long = "variant")]+        variants: Vec<String>,         /// Description for the symbol and deviceset, overriding the .kicad_sym's own         /// Description property and the page brief. Use it to say which LOOK a variant is         /// ("4-side square symbol, pins grouped by function"), because a set of variants@@ -1244,6 +1250,56 @@ fn flatten_arc(start: (f64, f64), mid: (f64, f64), end: (f64, f64), segs: usize)     }).collect() } +/// One extra PACKAGE of the same part: the same silicon in a different footprint, or the+/// same footprint with extra geometry (heatsink vias as through pads, solder-ball outlines).+/// EAGLE models this as another `<device>` inside the SAME deviceset, which is why Fusion's+/// CHANGE PACKAGE swaps between them without re-wiring the schematic.+#[derive(Clone)]+pub(crate) struct PackageVariant {+    /// Device-name suffix, e.g. "-HV". The baseline device keeps the empty name.+    pub suffix: String,+    pub km: String,+    pub description: Option<String>,+}++/// Emit one `<package>` from a `.kicad_mod`, returning its pad groups for the connects.+/// This mirrors the baseline package branch rather than refactoring it: the baseline path+/// is exercised by every part we ship and is not worth destabilising for this.+fn emit_variant_package(xml: &mut String, km: &str, name: &str, description: &str)+    -> std::collections::HashMap<String, Vec<String>> {+    let pads = parse_mod_pads(km);+    xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(name)));+    xml.push_str(&format!("  <description>{}</description>\n", esc_xml(description)));+    let pad_names: Vec<String> = pads.iter().map(|p| p.name.clone()).collect();+    let (out_names, groups) = dedup_pad_names(&pad_names);+    for (i, p) in 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(&out_names[i]), p.x, -p.y, p.dx, p.dy, smd_rotation(p.angle)));+        } else {+            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(&out_names[i]), p.x, -p.y, drill, diameter, p.shape, smd_rotation(p.angle)));+        }+    }+    xml.push_str(&package_graphics(km));+    xml.push_str("</package>\n");+    groups+}++/// Which BASELINE pad an extra variant pad belongs to, by geometry: a heatsink via sits+/// inside the thermal pad it vents, so the pad that CONTAINS its centre is the one whose+/// net it joins. Naming cannot decide this ("V1" says nothing about which pin it serves)+/// and a footprint on its own carries no nets, so position is the only honest signal.+fn owning_pad<'a>(extra: &ModPad, baseline: &'a [ModPad]) -> Option<&'a ModPad> {+    baseline.iter().find(|b| {+        (extra.x - b.x).abs() <= b.dx / 2.0 && (extra.y - b.y).abs() <= b.dy / 2.0+    })+}+ /// Map a KiCad graphics layer to its EAGLE package-layer number. Only silk/fab /// are drawn in the package outline; copper/mask/paste/courtyard are skipped. fn kicad_layer_to_eagle(klayer: &str) -> Option<u32> {@@ -1252,8 +1308,47 @@ fn kicad_layer_to_eagle(klayer: &str) -> Option<u32> {         "B.SilkS" => Some(22),         "F.Fab" => Some(51),         "B.Fab" => Some(52),-        _ => None,+        // USER LAYERS. KiCad's User.N and the named Adom.* layers carry real geometry that+        // was being dropped on the floor: the LAN7800 footprint lost 296 solder-ball circles+        // that way, silently, because an unmapped layer returned None. EAGLE reserves 1..99+        // and leaves 100..255 for user layers, so they are mapped there and DECLARED in the+        // <layers> block, since Fusion ignores geometry on a layer the file never declares.+        "Adom.SolderBalls" | "User.9" => Some(101),+        "Adom.HeatsinkVias" => Some(102),+        other => other.strip_prefix("User.")+            .and_then(|n| n.parse::<u32>().ok())+            .filter(|n| *n <= 145)+            .map(|n| 110 + n),+    }+}++/// Which user layers this footprint actually draws on.+fn used_user_layers(km: &str) -> std::collections::BTreeSet<u32> {+    let re = regex_lite::Regex::new(r#"\(layer "?([A-Za-z0-9_.]+)"?\)"#).unwrap();+    re.captures_iter(km)+        .filter_map(|c| kicad_layer_to_eagle(&c[1]))+        .filter(|n| *n >= 100)+        .collect()+}++/// The EAGLE `<layer>` declarations for whatever user layers a package actually draws on.+/// Only layers in use are declared: a file full of empty layer declarations is noise, and+/// Fusion lists every declared layer in its layer dialog whether or not anything is on it.+fn user_layer_decls(used: &std::collections::BTreeSet<u32>) -> String {+    let mut out = String::new();+    for n in used.iter().filter(|n| **n >= 100) {+        let name = match n {+            101 => "SolderBalls".to_string(),+            102 => "HeatsinkVias".to_string(),+            other => format!("User{}", other - 110),+        };+        // colour 13 is EAGLE's light-grey user default; fill 1 is solid, matching how the+        // silk layers are declared above.+        out.push_str(&format!(+            "<layer number=\"{}\" name=\"{}\" color=\"13\" fill=\"1\" visible=\"yes\" active=\"yes\"/>\n",+            n, name));     }+    out }  /// Transcribe the footprint's REAL silkscreen/fab graphics — `fp_circle`,@@ -1992,10 +2087,10 @@ fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&S }  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 {-    generate_lbr_gates(sym, fp_content, symbol_art, symbol_art_scale, pin_map, part_attrs, &[])+    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]) -> 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]) -> String {     let mut xml = String::with_capacity(8192);      xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");@@ -2004,8 +2099,20 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s     xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");     xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n"); -    // Layers-    xml.push_str(EAGLE_LAYERS);+    // Layers. User layers are declared only when a footprint draws on one: Fusion IGNORES+    // geometry on a layer the file never declares, and lists every declared layer in its+    // dialog whether or not anything sits on it.+    let mut used: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();+    if let Some(km) = fp_content { used.extend(used_user_layers(km)); }+    for v in variants { used.extend(used_user_layers(&v.km)); }+    if used.is_empty() {+        xml.push_str(EAGLE_LAYERS);+    } else {+        let decls = user_layer_decls(&used);+        xml.push_str(&EAGLE_LAYERS.replace("</layers>", &format!("{}</layers>", decls)));+        println!("HINT: declared {} user layer(s) ({}) so their geometry survives into Fusion.",+                 used.len(), used.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", "));+    }      xml.push_str("<library>\n");     xml.push_str(&format!("<description>{}</description>\n", esc_xml(&sym.description)));@@ -2115,6 +2222,20 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s     }      xml.push_str("</package>\n");+    // One package per VARIANT. Same part, different footprint, so they belong in the same+    // deviceset as sibling devices rather than as separate parts.+    let base_pkg = sym.package_name.as_deref().unwrap_or(&sym.name).to_string();+    let mut variant_groups: Vec<(String, std::collections::HashMap<String, Vec<String>>)> = Vec::new();+    for v in variants {+        let pkg = format!("{}{}", base_pkg, v.suffix);+        let desc = match &v.description {+            Some(d) => d.clone(),+            None => format!("{}{}", sym.package_description.as_deref().unwrap_or(&base_pkg),+                            if v.suffix.is_empty() { String::new() } else { format!(", variant {}", v.suffix.trim_start_matches('-')) }),+        };+        let g = emit_variant_package(&mut xml, &v.km, &pkg, &desc);+        variant_groups.push((pkg, g));+    }     xml.push_str("</packages>\n");      // Symbols section@@ -2155,6 +2276,7 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s     for (a, names) in alts.iter().zip(alt_names.iter()) {         gate_pins.push((a.gate.as_str(), &a.sym, names));     }+    let gate_pins2 = gate_pins.clone();     for (gate_name, gsym, gnames) in gate_pins {     for (i, pin) in gsym.pins.iter().enumerate() {         // The pad reference is the symbol pin's NUMBER, which is the KiCad@@ -2193,6 +2315,59 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s         xml.push_str("        </technology>\n      </technologies>\n");     }     xml.push_str("    </device>\n");++    // ONE DEVICE PER VARIANT, sharing the gates and the connects. An extra pad that sits+    // inside a baseline pad joins that pad's connect as a space-separated multi-pad ref,+    // which is EAGLE's own form for one pin on several pads: sixteen heatsink vias in a+    // thermal pad become <connect pin="EP" pad="EP V1 V2 ...">. Attributes are repeated+    // verbatim, because it is the same orderable part whichever package you fit.+    let base_pads = fp_content.map(parse_mod_pads).unwrap_or_default();+    for (v, (pkg, vgroups)) in variants.iter().zip(variant_groups.iter()) {+        xml.push_str(&format!("    <device name=\"{}\" package=\"{}\">\n", esc_xml(&v.suffix), esc_xml(pkg)));+        xml.push_str("      <connects>\n");+        let vpads = parse_mod_pads(&v.km);+        // extra pad name -> the baseline pad whose area contains it+        let mut extra_for: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();+        let mut orphans: Vec<String> = Vec::new();+        for ep in &vpads {+            if base_pads.iter().any(|b| b.name == ep.name) { continue; }+            match owning_pad(ep, &base_pads) {+                Some(owner) => extra_for.entry(owner.name.clone()).or_default().push(ep.name.clone()),+                None => orphans.push(ep.name.clone()),+            }+        }+        if !orphans.is_empty() {+            eprintln!("WARN: variant {} has pad(s) {} that sit inside no baseline pad, so nothing connects them. \+Place them within the pad they serve, or they will import as unconnected copper.",+                v.suffix, orphans.join(", "));+        }+        for (gate_name, gsym, gnames) in &gate_pins2 {+            for (i, pin) in gsym.pins.iter().enumerate() {+                let pad = pin_map.get(&pin.number).unwrap_or(&pin.number);+                let mut refs: Vec<String> = match vgroups.get(pad) {+                    Some(group) => group.clone(),+                    None => vec![pad.clone()],+                };+                if let Some(extra) = extra_for.get(pad) { refs.extend(extra.clone()); }+                xml.push_str(&format!(+                    "        <connect gate=\"{}\" pin=\"{}\" pad=\"{}\"/>\n",+                    gate_name, esc_xml(&gnames[i]), esc_xml(&refs.join(" "))));+            }+        }+        xml.push_str("      </connects>\n");+        if part_attrs.is_empty() {+            xml.push_str("      <technologies><technology name=\"\"/></technologies>\n");+        } else {+            xml.push_str("      <technologies>\n        <technology name=\"\">\n");+            for (k, val) in part_attrs {+                xml.push_str(&format!(+                    "          <attribute name=\"{}\" value=\"{}\" constant=\"no\"/>\n",+                    esc_xml(k), esc_xml(val)));+            }+            xml.push_str("        </technology>\n      </technologies>\n");+        }+        xml.push_str("    </device>\n");+    }     xml.push_str("  </devices>\n");     xml.push_str("</deviceset>\n");     xml.push_str("</devicesets>\n");@@ -2559,7 +2734,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name, package_name, description, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {+        Commands::Generate { sym, fp, output, name, package_name, variants, description, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {             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);@@ -2606,6 +2781,29 @@ fn main() {                 }             } +            let pkg_variants: Vec<PackageVariant> = variants.iter().filter_map(|spec| {+                let mut it = spec.splitn(3, '=');+                let suffix = it.next().unwrap_or_default().to_string();+                let path = it.next().unwrap_or_default();+                let desc = it.next().filter(|d| !d.is_empty()).map(|d| d.to_string());+                if suffix.is_empty() || path.is_empty() {+                    eprintln!("ERROR: --variant wants SUFFIX=<file.kicad_mod>[=description], got '{spec}'");+                    process::exit(1);+                }+                match fs::read_to_string(path) {+                    Ok(km) => Some(PackageVariant {+                        suffix: if suffix.starts_with('-') { suffix } else { format!("-{suffix}") },+                        km, description: desc,+                    }),+                    Err(e) => { eprintln!("ERROR: cannot read variant {path}: {e}"); process::exit(1); }+                }+            }).collect();+            if !pkg_variants.is_empty() {+                println!("HINT: {} package variant(s) in ONE deviceset: {}. Fusion's CHANGE PACKAGE swaps between them without re-wiring.",+                    pkg_variants.len(),+                    pkg_variants.iter().map(|v| v.suffix.trim_start_matches('-')).collect::<Vec<_>>().join(", "));+            }+             let out_default = sym.as_ref().or(fp.as_ref()).cloned().unwrap_or_else(|| PathBuf::from(&parsed.name));             let art = match symbol_art.as_ref().map(|p| resolve_symbol_art(p)) {                 Some(Ok(a)) => Some(a),@@ -2778,7 +2976,7 @@ 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);+            let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants);              // Lint before writing             let lint = lint_lbr(&lbr);@@ -3615,6 +3813,50 @@ mod lint_tests {         let b = by("Ground"); assert_eq!(b.angle, 90); assert!(!b.justify_right);     } +    #[test]+    fn user_layers_are_mapped_and_declared_rather_than_dropped() {+        use super::{kicad_layer_to_eagle, used_user_layers, user_layer_decls};+        // An unmapped layer returned None and the geometry vanished: the LAN7800 build lost+        // 296 solder-ball circles that way, with nothing said.+        assert_eq!(kicad_layer_to_eagle("Adom.SolderBalls"), Some(101));+        assert_eq!(kicad_layer_to_eagle("User.9"), Some(101));+        assert_eq!(kicad_layer_to_eagle("Adom.HeatsinkVias"), Some(102));+        assert_eq!(kicad_layer_to_eagle("User.4"), Some(114));+        assert_eq!(kicad_layer_to_eagle("F.SilkS"), Some(21));   // unchanged+        assert_eq!(kicad_layer_to_eagle("F.Mask"), None);        // still skipped+        let km = r#"(footprint "T" (fp_circle (center 0 0) (end 1 0) (layer "Adom.SolderBalls"))+                                  (fp_line (start 0 0) (end 1 1) (layer "User.4")))"#;+        let used = used_user_layers(km);+        assert!(used.contains(&101) && used.contains(&114) && used.len() == 2);+        // Fusion IGNORES geometry on a layer the file never declares, so the declaration is+        // not decoration.+        let decls = user_layer_decls(&used);+        assert!(decls.contains(r#"<layer number="101" name="SolderBalls""#), "{decls}");+        assert!(decls.contains(r#"<layer number="114" name="User4""#), "{decls}");+        // and layers nothing draws on are not declared+        assert!(!decls.contains("102"), "{decls}");+    }++    #[test]+    fn a_heatsink_via_joins_the_pad_it_sits_inside() {+        use super::{owning_pad, parse_mod_pads};+        let km = r#"(footprint "T"+            (pad "EP" smd rect (at 0 0) (size 2 2) (layers "F.Cu"))+            (pad "1" smd rect (at -5 0) (size 1 1) (layers "F.Cu"))+            (pad "V1" thru_hole circle (at 0.5 0.5) (size 0.4 0.4) (drill 0.2) (layers "*.Cu"))+            (pad "V9" thru_hole circle (at 9 9) (size 0.4 0.4) (drill 0.2) (layers "*.Cu")))"#;+        let pads = parse_mod_pads(km);+        let base: Vec<_> = parse_mod_pads(km).into_iter().filter(|p| p.name == "EP" || p.name == "1").collect();+        let v1 = pads.iter().find(|p| p.name == "V1").unwrap();+        let v9 = pads.iter().find(|p| p.name == "V9").unwrap();+        // A via inside the thermal pad joins it: naming cannot decide this ("V1" says+        // nothing about which pin it serves) and a bare footprint carries no nets.+        assert_eq!(owning_pad(v1, &base).map(|p| p.name.as_str()), Some("EP"));+        // ...and one outside every pad belongs to nothing, which is warned about rather+        // than silently attached to whatever happens to be nearest.+        assert!(owning_pad(v9, &base).is_none());+    }+     #[test]     fn a_symbol_that_draws_itself_is_not_relaid_out() {         use super::{relayout_two_columns, KicadPin, ParsedSymbol, SymGraphic};