← Commit history

2.22.0: descriptions on symbol, deviceset and package rows

John Lauer ·70676010a2 ·1mo ago ·parent 1cd9219
8 files changed +136−9
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.21.0"+version = "2.22.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.21.0"+version = "2.22.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." 
SKILL.md+28
@@ -157,6 +157,34 @@ adom-lbr generate --sym CH340G.kicad_sym --fp CH340G.kicad_mod --part-metadata p   --alt-sym CH340G-2CG.kicad_sym --alt-sym CH340G-SQG.kicad_sym -o CH340G.lbr ``` +### Naming and descriptions (2.22.0)++`--name` names the SYMBOL and the DEVICESET only. The PACKAGE takes the footprint's own+`(footprint "NAME")`, so N symbol variants of one chip share ONE package and `assemble`+dedupes it to a single footprint (and the bridge to a single 3D model). `--package-name`+overrides that when you want the MPN, or any other name, in Fusion's package column.++Descriptions matter in Fusion: the tree shows a Description column for every row and the+ADD dialog searches the deviceset's text. adom-lbr fills all three:++- SYMBOL and DEVICESET take, in order: `--description`, else the `.kicad_sym`'s own+  Description property, else the wiki page's brief from `--part-metadata`.+- PACKAGE takes the `.kicad_mod`'s `(descr …)`, else a line derived from the footprint+  name: `SOIC-8_3.9x4.9mm_P1.27mm` reads as `SOIC-8, 3.9 x 4.9 mm body, 1.27 mm pitch`.+  It no longer repeats the package name, which told a reader nothing.+- Catalogue boilerplate ("Datasheet-verified pinout", "KiCad symbol/footprint/3D") is+  stripped from a page brief before it becomes a description. It is page prose.++For a VARIANT SET, give each `generate` its own `--description` saying which look it is.+Ten devicesets carrying one identical sentence tell a reader nothing about which to pick:++```+adom-lbr generate --sym CH340G-SQ.kicad_sym --fp CH340G.kicad_mod --part-metadata page.json \+  --name CH340G-SQ \+  --description "CH340G: USB to serial converter, UART. Square 4-side symbol, pins grouped by function" \+  -o CH340G-SQ.lbr+```+ Duplicate PAD names (KiCad same-net arrays: thermal vias, ground paddles, shield tabs) are resolved to the EAGLE `@`-suffix convention (`17`, `17@2`, `17@3`, ...) with one space-separated multi-pad `<connect>` binding the whole
VERSION+1−1
@@ -1 +1 @@-2.21.0+2.22.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.21.0",+  "version": "2.22.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.21.0",+  "version": "2.22.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+102−3
@@ -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>,+        /// 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+        /// that all carry the same sentence tells a reader nothing about which to pick.+        #[arg(long)]+        description: 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@@ -370,6 +376,8 @@ struct ParsedSymbol {     /// 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>,+    /// A human line for the package, e.g. "SOIC-16, 3.9 x 9.9 mm body, 1.27 mm pitch".+    package_description: Option<String>, }  pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Result<ParsedSymbol, String> {@@ -482,6 +490,7 @@ pub(crate) fn parse_kicad_sym(content: &str, name_override: Option<&str>) -> Res      Ok(ParsedSymbol {         package_name: None,+        package_description: None,         name,         reference: prop("Reference"),         value: prop("Value"),@@ -611,6 +620,7 @@ fn parse_eagle_lbr(content: &str) -> Result<ParsedSymbol, String> {      Ok(ParsedSymbol {         package_name: None,+        package_description: None,         value: name.clone(),         name,         reference,@@ -966,6 +976,7 @@ fn synth_symbol_from_mod(km: &str, name: &str) -> ParsedSymbol {     }).collect();     ParsedSymbol {         package_name: None,+        package_description: None,         name: name.to_string(),         reference: "U".into(),         value: name.to_string(),@@ -1372,6 +1383,74 @@ fn symbol_art_block(art: &SymbolArt, body: Option<&KicadBody>, scale_frac: f64) /// 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.+/// Catalogue boilerplate that belongs on a wiki PAGE, not in a part's description. A page+/// brief is written for a reader browsing the library; the same sentence repeated in every+/// Fusion Description cell is noise that pushes the real text out of a narrow column.+fn strip_catalog_boilerplate(text: &str) -> String {+    const NOISE: [&str; 5] = [+        "Datasheet-verified pinout",+        "KiCad symbol/footprint/3D",+        "Symbol, footprint and 3D model",+        "Generated by adom",+        "Verified pinout",+    ];+    // Remove the boilerplate PHRASES in place. An earlier version split the text on every+    // '.' and rejoined it, which turned "2.5V precision reference" into "2. 5V precision+    // reference": a part number or a voltage is full of periods that are not sentence ends.+    let mut out = text.to_string();+    for n in NOISE {+        while let Some(i) = out.find(n) {+            // take the phrase plus any trailing separator it brought with it+            let mut j = i + n.len();+            let bytes = out.as_bytes();+            while j < bytes.len() && matches!(bytes[j], b'.' | b',' | b';' | b' ' | b'\n') { j += 1; }+            out.replace_range(i..j, "");+        }+    }+    let out = out.trim().trim_end_matches(&['.', ',', ';', ' '][..]).trim().to_string();+    if out.is_empty() { text.trim().to_string() } else { out }+}++/// A human line for the PACKAGE. The `.kicad_mod`'s own `(descr …)` when it has one,+/// otherwise the geometry encoded in the footprint NAME turned into words:+/// `SOIC-16_3.9x9.9mm_P1.27mm` reads as "SOIC-16, 3.9 x 9.9 mm body, 1.27 mm pitch".+/// It used to repeat the package name, which tells a reader nothing they cannot already+/// see in the Name column beside it.+fn package_description(fp_content: Option<&str>, package_name: &str) -> String {+    if let Some(c) = fp_content {+        if let Some(re) = regex_lite::Regex::new(r#"\(descr\s+"([^"]*)""#).ok() {+            if let Some(m) = re.captures(c).and_then(|c| c.get(1)) {+                // drop a trailing URL: useful in a file, useless in a narrow Fusion column+                let t = m.as_str();+                let mut t = t.split("http").next().unwrap_or(t).trim().to_string();+                // Cutting the URL can leave the bracket that introduced it, e.g.+                // "SOIC, 8 Pin (JEDEC MS-012AA, " from "(JEDEC MS-012AA, https://...)".+                // Drop from an unmatched '(' rather than shipping half a parenthetical.+                if t.matches('(').count() > t.matches(')').count() {+                    if let Some(i) = t.rfind('(') { t.truncate(i); }+                }+                let t = t.trim().trim_end_matches(&[',', ';', '·', '-'][..]).trim().to_string();+                if !t.is_empty() { return t; }+            }+        }+    }+    let mut family = String::new();+    let mut body = String::new();+    let mut pitch = String::new();+    let dims = regex_lite::Regex::new(r"^([\d.]+)x([\d.]+)mm$").unwrap();+    let pit = regex_lite::Regex::new(r"^P([\d.]+)mm$").unwrap();+    for (i, tok) in package_name.split('_').enumerate() {+        if i == 0 { family = tok.to_string(); continue; }+        if let Some(c) = dims.captures(tok) { body = format!("{} x {} mm body", &c[1], &c[2]); continue; }+        if let Some(c) = pit.captures(tok) { pitch = format!("{} mm pitch", &c[1]); continue; }+    }+    let mut parts: Vec<String> = vec![family];+    if !body.is_empty() { parts.push(body); }+    if !pitch.is_empty() { parts.push(pitch); }+    parts.retain(|p| !p.is_empty());+    parts.join(", ")+}+ 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()?;@@ -1420,6 +1499,12 @@ pub(crate) struct AltGate { /// Returns the pin names as emitted (deduplicated); the connects must reuse them. fn emit_symbol_block(xml: &mut String, sym: &ParsedSymbol, symbol_art: Option<&SymbolArt>, symbol_art_scale: f64) -> Vec<String> {     xml.push_str(&format!("<symbol name=\"{}\">\n", esc_xml(&sym.name)));+    // Fusion shows a Description column for every row in the tree, and the SYMBOL rows were+    // blank for every part we ship. The deviceset text is the right text for the symbol too:+    // it is what a person is looking for when they are reading the list.+    if !sym.description.trim().is_empty() {+        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(&sym.description)));+    }      if let Some(body) = &sym.body {         xml.push_str(&format!("  <wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.254\" layer=\"94\"/>\n", body.left, body.top, body.right, body.top));@@ -1527,7 +1612,7 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s         // 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))));+        xml.push_str(&format!("  <description>{}</description>\n", esc_xml(sym.package_description.as_deref().unwrap_or(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;@@ -2037,7 +2122,7 @@ fn main() {     let cli = Cli::parse();      match cli.command {-        Commands::Generate { sym, fp, output, name, package_name, symbol_art, symbol_art_scale, pin_map, part_metadata, symbol_layout, alt_sym } => {+        Commands::Generate { sym, fp, output, name, package_name, 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);@@ -2122,12 +2207,25 @@ 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() {+            // Description precedence: an explicit --description, then the .kicad_sym's own+            // Description property, then the wiki page's brief. Whatever wins has catalogue+            // boilerplate stripped: a page brief is written for someone browsing the wiki,+            // and repeating "Datasheet-verified pinout, KiCad symbol/footprint/3D" in every+            // Fusion Description cell crowds out the words that identify the part.+            if let Some(d) = description.clone() {+                parsed.description = d;+            } else 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;                 }             }+            parsed.description = strip_catalog_boilerplate(&parsed.description);+            if parsed.description.trim().is_empty() {+                println!("HINT: this part has NO description: the deviceset, the symbol and the ADD dialog will all read blank in Fusion. Pass --description, or give the source a Description property.");+            }+            parsed.package_description = Some(package_description(fp_content.as_deref(),+                parsed.package_name.as_deref().unwrap_or(&parsed.name)));             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.");@@ -2911,6 +3009,7 @@ mod lint_tests {         };         let mut sym = ParsedSymbol {             package_name: None,+        package_description: None,             name: "U1".into(), reference: "U".into(), value: String::new(),             footprint: String::new(), description: String::new(),             pins: vec![