← Commit history

2.22.1: geometry-first package description

John Lauer ·8e8c8295cc ·1mo ago ·parent 6bd9c5e
7 files changed +93−33
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.22.0"+version = "2.22.1" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.22.0"+version = "2.22.1" 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.22.0+2.22.1
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.22.0",+  "version": "2.22.1",   "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.22.0",+  "version": "2.22.1",   "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+87−27
@@ -1411,29 +1411,31 @@ fn strip_catalog_boilerplate(text: &str) -> 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.+/// A human line for the PACKAGE, GEOMETRY FIRST.+///+/// Fusion's Content Manager shows about 20 characters of this column before it truncates+/// with an ellipsis (the full text is on hover), measured by the fusion-bridge thread. So+/// the first words have to be the ones that identify the package. A manufacturer-first+/// `(descr …)` truncates to "Walsin Technology C…", which identifies nothing, while the+/// geometry derived from the footprint NAME truncates to "SOIC-16, 3.9 x 9.9 m…", which+/// still tells you the family, the pin count and the size.+///+/// The `.kicad_mod`'s own `(descr …)` is appended after it when it adds something. It is+/// skipped when it merely restates the geometry: "SOIC, 8 Pin" beside+/// "SOIC-8, 3.9 x 4.9 mm body, 1.27 mm pitch" is noise, not detail. 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 descr = fp_content.and_then(|c| {+        let re = regex_lite::Regex::new(r#"\(descr\s+"([^"]*)""#).ok()?;+        let raw = re.captures(c)?.get(1)?.as_str();+        let mut t = raw.split("http").next().unwrap_or(raw).trim().to_string();+        // Cutting a URL can leave the bracket that introduced it, e.g. "(JEDEC MS-012AA, ".+        if t.matches('(').count() > t.matches(')').count() {+            if let Some(i) = t.rfind('(') { t.truncate(i); }         }-    }+        let t = t.trim().trim_end_matches(&[',', ';', '\u{b7}', '-'][..]).trim().to_string();+        if t.is_empty() { None } else { Some(t) }+    });+     let mut family = String::new();     let mut body = String::new();     let mut pitch = String::new();@@ -1441,14 +1443,72 @@ fn package_description(fp_content: Option<&str>, package_name: &str) -> String {     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) = dims.captures(tok) { body = format!("{} x {} mm", &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(", ")+    // Geometry only counts when the NAME actually encodes it. "0603N101J500CT" is a part+    // number, not a package description. Rather than fall back to the manufacturer-first+    // descr (which truncates to "Walsin Technology Co...", identifying nothing) or to the+    // package name (which just repeats the Name column beside it, the thing John objected+    // to), MEASURE the footprint: the pad count and the outline are always available and+    // they are what actually distinguishes one package from another.+    let geometry = if body.is_empty() && pitch.is_empty() {+        match fp_content {+            Some(km) => {+                let pads = parse_mod_pads(km);+                if pads.is_empty() {+                    String::new()+                } else {+                    // Measure the COPPER, not the silkscreen. package_outline() prefers the+                    // F.SilkS box, which on a two-terminal chip part is a pair of short+                    // ticks beside the pads: it measured an 0603 as "0.6 x 0.0 mm", a+                    // number that is confidently wrong. The pad extent is always defined.+                    let (mut x1, mut y1, mut x2, mut y2) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+                    for pd in &pads {+                        x1 = x1.min(pd.x - pd.dx / 2.0); y1 = y1.min(pd.y - pd.dy / 2.0);+                        x2 = x2.max(pd.x + pd.dx / 2.0); y2 = y2.max(pd.y + pd.dy / 2.0);+                    }+                    let (w, h) = ((x2 - x1).abs(), (y2 - y1).abs());+                    // Refuse to state a measurement that cannot be right rather than print+                    // a tidy-looking zero.+                    if w < 0.2 || h < 0.2 {+                        format!("{} pads", pads.len())+                    } else {+                        format!("{} pads, {:.1} x {:.1} mm", pads.len(), w, h)+                    }+                }+            }+            None => String::new(),+        }+    } else {+        let mut parts = vec![family];+        if !body.is_empty() { parts.push(body); }+        if !pitch.is_empty() { parts.push(pitch); }+        parts.join(", ")+    };++    match (geometry.is_empty(), descr) {+        (true, Some(d)) => d,+        (true, None) => package_name.to_string(),+        (false, None) => geometry,+        (false, Some(d)) => {+            // Skip a descr that just restates the geometry: compare on alphanumeric tokens+            // rather than on the exact string, so "SOIC, 8 Pin" is recognised as a restating+            // of "SOIC-8, ...".+            let norm = |t: &str| -> Vec<String> {+                t.to_lowercase()+                    .split(|c: char| !c.is_ascii_alphanumeric() && c != '.')+                    .filter(|w| !w.is_empty() && *w != "pin" && *w != "pins" && *w != "mm")+                    .map(|w| w.to_string())+                    .collect()+            };+            let g = norm(&geometry);+            let dt = norm(&d);+            let covered = dt.iter().filter(|w| g.iter().any(|x| x == *w)).count();+            let redundant = !dt.is_empty() && covered * 100 / dt.len() >= 60;+            if redundant { geometry } else { format!("{}. {}", geometry, d) }+        }+    } }  fn page_brief(path: &std::path::Path) -> Option<String> {