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.21.0: symbol conversion fidelity (#17) + --name no longer renames the package
7 files changed
+136−18
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-lbr"-version = "2.20.0"+version = "2.21.0" dependencies = [ "altium-codec", "anyhow",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.20.0"+version = "2.21.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.20.0+2.21.0
bin/adom-lbr⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-lbr",- "version": "2.20.0",+ "version": "2.21.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.20.0",+ "version": "2.21.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+130−12@@ -60,9 +60,14 @@ enum Commands { /// Output .lbr file path (default: <name>.lbr in the same directory) #[arg(short, long)] output: Option<PathBuf>,- /// Symbol name override+ /// Symbol name override. Names the SYMBOL and the DEVICESET only; the package+ /// keeps the footprint's own name (see --package-name). #[arg(long)] name: Option<String>,+ /// Package name override. Defaults to the .kicad_mod's own `(footprint "NAME")`,+ /// falling back to the symbol name when the footprint does not carry one.+ #[arg(long)]+ package_name: Option<String>, /// Chip outline drawn on the schematic symbol as <wire>s, OVERRIDING the /// kicad_sym's own drawing. PREFERRED: adom-symbol's OUTLINE CONTRACT /// `<mpn>-symbol-outline.mm.svg` (+ its `.json` sidecar) — placed 1:1 in@@ -358,6 +363,13 @@ struct ParsedSymbol { body: Option<KicadBody>, group_labels: Vec<GroupLabel>, graphics: Vec<SymGraphic>,+ /// The EAGLE `<package>` name. None means "use `name`". Kept SEPARATE from `name`+ /// because `--name` renames the symbol and the deviceset, and it used to rename the+ /// package with them: generating eight symbol variants of one chip produced eight+ /// differently-named copies of the SAME footprint, so `assemble` could not share one+ /// and a Fusion library gained a duplicate footprint (and a duplicate 3D model) per+ /// variant. A package IS the footprint, so it takes the footprint's name.+ package_name: Option<String>, } pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {@@ -403,8 +415,31 @@ pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Res }) }; - // The symbol's own drawing: (polyline (pts (xy …))) and (arc (start)(mid)(end)).+ // The symbol's own drawing: (polyline (pts (xy …))), (arc (start)(mid)(end)),+ // (rectangle …) beyond the body one, and (circle (center)(radius)). Rectangles and+ // circles used to be dropped outright, so any symbol drawn with them reached Fusion+ // as pins with no body at all (wiki issue #17, ask 2). let mut graphics: Vec<SymGraphic> = Vec::new();+ let rect_all_re = regex_lite::Regex::new(+ r#"\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)"#+ ).unwrap();+ // skip(1): the FIRST rectangle is the body box, already drawn as four wires by the+ // emitter. Drawing it twice would double every body edge.+ for c in rect_all_re.captures_iter(content).skip(1) {+ graphics.push(SymGraphic::Rect {+ x1: c[1].parse().unwrap_or(0.0), y1: c[2].parse().unwrap_or(0.0),+ x2: c[3].parse().unwrap_or(0.0), y2: c[4].parse().unwrap_or(0.0),+ });+ }+ let circ_re = regex_lite::Regex::new(+ r#"\(circle\s+\(center\s+([-\d.]+)\s+([-\d.]+)\)\s+\(radius\s+([\d.]+)\)"#+ ).unwrap();+ for c in circ_re.captures_iter(content) {+ graphics.push(SymGraphic::Circle {+ cx: c[1].parse().unwrap_or(0.0), cy: c[2].parse().unwrap_or(0.0),+ r: c[3].parse().unwrap_or(0.0),+ });+ } let xy_re = regex_lite::Regex::new(r#"\(xy\s+(-?[\d.]+)\s+(-?[\d.]+)\)"#).unwrap(); for b in sexpr_blocks(content, "polyline") { let pts: Vec<(f64, f64)> = xy_re.captures_iter(b)@@ -446,6 +481,7 @@ pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Res }; Ok(ParsedSymbol {+ package_name: None, name, reference: prop("Reference"), value: prop("Value"),@@ -574,6 +610,7 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> { } Ok(ParsedSymbol {+ package_name: None, value: name.clone(), name, reference,@@ -671,11 +708,29 @@ fn pin_rotation(angle: u32) -> &'static str { } } +/// EAGLE has four fixed pin lengths; KiCad's is a free number. Map to the NEAREST,+/// never to the next one UP. The old ceiling rule turned a 2.794 mm KiCad pin (the+/// standard 0.11 in on the wiki's passives) into EAGLE `middle`, which is 5.08 mm, so+/// each pin reached 1.27 mm PAST the origin and a two-pin capacitor drew as one line+/// with its pins overlapping by 2.54 mm (adom/0603n101j500ct, wiki issue #17). Nearest+/// puts 2.794 on `short` (2.54), a 0.254 mm error instead of a 2.286 mm one.+/// The first `(footprint "NAME" …)` name in a `.kicad_mod`, if any. This is the+/// package's real identity: two symbol variants of one chip share ONE footprint, and+/// naming the package after the symbol hid that from `assemble`.+fn kicad_mod_footprint_name(content: &str) -> Option<String> {+ let re = regex_lite::Regex::new(r#"\(footprint\s+"([^"]+)""#).ok()?;+ let n = re.captures(content)?.get(1)?.as_str().trim().to_string();+ if n.is_empty() { None } else { Some(eagle_safe_name(&n)) }+}+ fn pin_length_keyword(len: f64) -> &'static str {- if len <= 0.0 { "point" }- else if len <= 2.54 { "short" }- else if len <= 5.08 { "middle" }- else { "long" }+ if len <= 0.0 { return "point"; }+ const STOPS: [(f64, &str); 4] = [(0.0, "point"), (2.54, "short"), (5.08, "middle"), (7.62, "long")];+ let mut best = STOPS[1];+ for stop in STOPS {+ if (len - stop.0).abs() < (len - best.0).abs() { best = stop; }+ }+ best.1 } /// EAGLE names cannot carry spaces or most punctuation; KiCad pin names can@@ -702,8 +757,18 @@ fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> { // <connect> emission, so the fallback fixes both at once. let eff: Vec<String> = pins.iter().enumerate().map(|(i, p)| { let n = p.name.trim();- if !n.is_empty() {- eagle_safe_name(n)+ // "~" is KiCad's UNNAMED marker, not a name. It used to reach eagle_safe_name,+ // which strips it as illegal and substitutes the literal "P", so an unnamed+ // two-pin part shipped pins called "P" and "P@2" and Fusion displayed exactly+ // that (wiki issue #17, ask 3). Treat it like an empty name: the pad NUMBER is+ // the meaningful identifier, and it is what a person reads on the schematic.+ // eagle_safe_name returns the literal "P" when NOTHING survives sanitising, so+ // that is the reliable "this name was not a name" signal. Guard the one case it+ // would misread: a pin genuinely called "P" keeps its name.+ let safe = eagle_safe_name(n);+ let unnamed = n.is_empty() || n == "~" || (safe == "P" && n != "P");+ if !unnamed {+ safe } else { let num = p.number.trim(); if !num.is_empty() { num.to_string() } else { format!("P{}", i + 1) }@@ -900,6 +965,7 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol { } }).collect(); ParsedSymbol {+ package_name: None, name: name.to_string(), reference: "U".into(), value: name.to_string(),@@ -918,6 +984,9 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol { enum SymGraphic { Poly(Vec<(f64, f64)>), Arc { start: (f64, f64), mid: (f64, f64), end: (f64, f64) },+ /// A `(rectangle …)` that is NOT the body box (the first one is the body).+ Rect { x1: f64, y1: f64, x2: f64, y2: f64 },+ Circle { cx: f64, cy: f64, r: f64 }, } /// One SMD/thru-hole pad parsed from a `.kicad_mod`, in KiCad coordinates@@ -1298,6 +1367,24 @@ fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) /// object carrying `pn` / `part_number`, because both shapes are already in use. /// An absent or unreadable file is not an error - a part with no metadata still /// generates, it just generates without attributes, exactly as before.+/// The wiki page's one-line `brief`, used when the `.kicad_sym` carries no Description.+/// Several published sources have none at all (adom/s10b-ph-sm4-tb, ray/stm32l431rct6tr,+/// ray/xl7026e1), so their devicesets reached Fusion with an empty Description column and+/// nothing to search on. The page brief is the right text and it is already sitting beside+/// the symbol, so the library carries it rather than the importer having to guess.+fn page_brief(path: &std::path::Path) -> Option<String> {+ let raw = fs::read_to_string(path).ok()?;+ let doc = serde_json::from_str::<serde_json::Value>(&raw).ok()?;+ for probe in [doc.get("brief"), doc.get("component").and_then(|c| c.get("brief")),+ doc.get("description"), doc.get("component").and_then(|c| c.get("description"))] {+ if let Some(t) = probe.and_then(|v| v.as_str()) {+ let t = t.trim();+ if !t.is_empty() { return Some(t.to_string()); }+ }+ }+ None+}+ fn part_attributes(path: &std::path::Path) -> Vec<(String, String)> { let Ok(raw) = fs::read_to_string(path) else { return Vec::new() }; let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else { return Vec::new() };@@ -1361,6 +1448,12 @@ fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&S xml.push_str(&format!(" <wire x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", w[0].0, w[0].1, w[1].0, w[1].1)); } }+ SymGraphic::Rect { x1, y1, x2, y2 } => {+ xml.push_str(&format!(" <rectangle x1=\"{:.4}\" y1=\"{:.4}\" x2=\"{:.4}\" y2=\"{:.4}\" layer=\"94\"/>\n", x1, y1, x2, y2));+ }+ SymGraphic::Circle { cx, cy, r } => {+ xml.push_str(&format!(" <circle x=\"{:.4}\" y=\"{:.4}\" radius=\"{:.4}\" width=\"0.1524\" layer=\"94\"/>\n", cx, cy, r));+ } } } }@@ -1421,7 +1514,7 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s // Packages section — generate a placeholder SMD package from pin positions xml.push_str("<packages>\n");- xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(&sym.name)));+ xml.push_str(&format!("<package name=\"{}\">\n", esc_xml(sym.package_name.as_deref().unwrap_or(&sym.name)))); // Real footprint from the .kicad_mod when given; else a placeholder. let mod_pads = fp_content.map(parse_mod_pads).unwrap_or_default();@@ -1429,7 +1522,12 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s let pad_groups: std::collections::HashMap<String, Vec<String>>; if !mod_pads.is_empty() { let km = fp_content.unwrap();- xml.push_str(&format!(" <description>{}</description>\n", esc_xml(&sym.name)));+ // The PACKAGE's description names the package, not the symbol. It used to carry+ // sym.name, so two symbol variants of one chip produced packages that were+ // byte-identical except for this line, and assemble's content dedup could not see+ // that they were the same footprint: the merged library gained a second package+ // (and the bridge a second 3D model) per variant.+ xml.push_str(&format!(" <description>{}</description>\n", esc_xml(sym.package_name.as_deref().unwrap_or(&sym.name)))); let pad_names: Vec<String> = mod_pads.iter().map(|p| p.name.clone()).collect(); let (out_names, groups) = dedup_pad_names(&pad_names); pad_groups = groups;@@ -1550,7 +1648,7 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s } xml.push_str(" </gates>\n"); xml.push_str(" <devices>\n");- xml.push_str(&format!(" <device name=\"\" package=\"{}\">\n", esc_xml(&sym.name)));+ xml.push_str(&format!(" <device name=\"\" package=\"{}\">\n", esc_xml(sym.package_name.as_deref().unwrap_or(&sym.name)))); xml.push_str(" <connects>\n"); // Generate connect mappings: gate pin name → pad number, for the primary gate and@@ -1939,7 +2037,7 @@ fn main() { let cli = Cli::parse(); match cli.command {- Commands::Generate { sym, fp, output, name, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {+ Commands::Generate { sym, fp, output, name, package_name, 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);@@ -1973,6 +2071,19 @@ fn main() { } }; + // The PACKAGE takes the footprint's own name, not the symbol's. --name renames+ // the symbol and deviceset only; it used to rename the package too, so eight+ // symbol variants of one chip produced eight differently-named copies of the+ // same footprint and `assemble` could not share a single package between them.+ parsed.package_name = package_name.clone().or_else(|| {+ fp_content.as_deref().and_then(kicad_mod_footprint_name)+ });+ if let Some(pkg) = &parsed.package_name {+ if pkg != &parsed.name {+ println!("HINT: package named \"{}\" (from the footprint); the symbol and deviceset are \"{}\". --package-name overrides.", pkg, parsed.name);+ }+ }+ 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),@@ -2011,6 +2122,12 @@ fn main() { sym.as_ref().and_then(|p| p.parent()).map(|d| d.join("page.json")).filter(|p| p.exists()) }); let part_attrs = pm_path.as_ref().map(|p| part_attributes(p)).unwrap_or_default();+ if parsed.description.trim().is_empty() {+ if let Some(brief) = pm_path.as_ref().and_then(|p| page_brief(p)) {+ println!("HINT: symbol had no Description; using the page brief so the deviceset is not blank in Fusion.");+ parsed.description = brief;+ }+ } if part_attrs.is_empty() { println!("HINT: no part attributes (MPN/MANUFACTURER/MOUSER_PN/DIGIKEY_PN/LCSC_PN). \ The geometry is fine but a BOM cannot order this part - pass --part-metadata or put a page.json beside the symbol.");@@ -2793,6 +2910,7 @@ mod lint_tests { x: 0.0, y: 0.0, angle, length: 2.54, }; let mut sym = ParsedSymbol {+ package_name: None, name: "U1".into(), reference: "U".into(), value: String::new(), footprint: String::new(), description: String::new(), pins: vec![