← Commit history

2.21.0: symbol conversion fidelity (#17) + --name no longer renames the package

John Lauer ·2ae0c2ef35 ·1mo ago ·parent 6fac6bf
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"     }   ]-}\ No newline at end of file+}
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![