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.30.0: user layers; package variants in one deviceset
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" } ]-}+}
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};