← Commit history

2.32.0: THT pad shape mapping + pad-clearance lint (#19)

John Lauer ·93d322233c ·1mo ago ·parent a5355bf
7 files changed +202−12
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.31.0"+version = "2.32.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.31.0"+version = "2.32.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.31.0+2.32.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.31.0",+  "version": "2.32.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.31.0",+  "version": "2.32.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+196−6
@@ -1087,6 +1087,9 @@ struct ModPad {     /// EAGLE pad shape mapped from the KiCad shape token: "round" | "square" |     /// "long" | "octagon".     shape: String,+    /// The KiCad shape word as written ("circle", "oval", "roundrect", "rect", "custom"),+    /// kept because the THT rule needs the SOURCE shape, not the already-mapped EAGLE one.+    kicad_shape: String,     /// EAGLE `roundness`, 0..100 as a percent of the pad's SMALLER side, from KiCad's     /// `roundrect_rratio`. None for a pad that is not a roundrect. Altium rounds pads by     /// default to reduce copper lift-off; dropping this made every such pad import square.@@ -1170,14 +1173,15 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> {         } else { None };         let drill = drill_re.captures(block).and_then(|c| c[1].parse().ok());         // KiCad pad shape → EAGLE pad shape.-        let shape = match nc.get(4).map(|m| m.as_str()).unwrap_or("") {+        let kicad_shape = nc.get(4).map(|m| m.as_str()).unwrap_or("").to_string();+        let shape = match kicad_shape.as_str() {             "circle" => "round",             "rect" => "square",             "oval" => "long",             "roundrect" | "custom" => "round",             _ => "round",         }.to_string();-        pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape, roundness });+        pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape, kicad_shape, roundness });     }     pads }@@ -1188,6 +1192,53 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> { /// oriented along the lead. Rect pads are 180°-symmetric so the Y-flip sense /// is moot; the direct mapping is what Fusion writes (verified by the Fusion /// bridge against a round-tripped `.flbr`).+/// EAGLE geometry for a THROUGH-HOLE pad. EAGLE has no roundness on a thru pad and draws+/// `long` as a fixed 2:1 stadium whose SHORT axis is `diameter`, elongated along the pad's+/// rotation. adom-lbr was passing `dx.max(dy)` as the diameter, so a tall 1.2 x 1.75 JST PH+/// pad came out 3.5 x 1.75 elongated the WRONG WAY: on a 2.0 mm pitch its copper reached+/// 0.35 mm INSIDE its neighbour, a short between pin 1 and pin 2 (wiki #19, Colby, found in+/// the real adom/b2b-ph-k-s export).+///+/// Returns (eagle_shape, diameter, extra_rotation_degrees, warning).+fn tht_pad_geometry(kicad_shape: &str, dx: f64, dy: f64) -> (String, f64, f64, Option<String>) {+    let short = dx.min(dy);+    let long = dx.max(dy);+    let aspect = if short > 0.0 { long / short } else { 1.0 };+    // A tall pad elongates along Y, which EAGLE expresses as a 90 degree rotation.+    let along_y = dy > dx;+    let rot = if along_y { 90.0 } else { 0.0 };+    match kicad_shape {+        "circle" => ("round".into(), dx, 0.0, None),+        "rect" => ("square".into(), long, 0.0, None),+        "oval" => {+            // EAGLE's 2:1 is fixed, so the drawn length is 2 * short whatever the source+            // said. Worth stating when that overshoots by a visible margin rather than+            // letting the pad silently grow.+            let drawn = 2.0 * short;+            let warn = if (drawn - long).abs() > 0.3 {+                Some(format!("EAGLE draws a long pad at a fixed 2:1, so this {short:.2} x {long:.2} mm pad is drawn {short:.2} x {drawn:.2} mm"))+            } else { None };+            ("long".into(), short, rot, warn)+        }+        "roundrect" | "custom" => {+            // No roundness exists on a thru pad. An elongated one is closer to `long`; a+            // squarish one becomes round on its SHORT side, and says that it lost the rest,+            // because rounding on the LONG side is what grew a pad into its neighbour.+            if aspect > 1.2 {+                let drawn = 2.0 * short;+                let warn = if (drawn - long).abs() > 0.3 {+                    Some(format!("roundrect thru pad {short:.2} x {long:.2} mm has no EAGLE equivalent; drawn as a long pad {short:.2} x {drawn:.2} mm"))+                } else { None };+                ("long".into(), short, rot, warn)+            } else {+                ("round".into(), short, 0.0,+                 Some(format!("roundrect thru pad {short:.2} x {long:.2} mm drawn round at {short:.2} mm; the long side has no EAGLE equivalent on a thru pad")))+            }+        }+        _ => ("round".into(), short, 0.0, None),+    }+}+ fn smd_rotation(angle: f64) -> &'static str {     let a = ((angle.round() as i64) % 360 + 360) % 360;     match a {@@ -1293,10 +1344,11 @@ fn emit_variant_package(xml: &mut String, km: &str, name: &str, description: &st                     p.roundness.map(|r| format!(" roundness=\"{r}\"")).unwrap_or_default()));         } 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) };+            let (tht_shape, diameter, extra_rot, warn) = tht_pad_geometry(&p.kicad_shape, p.dx, p.dy);+            if let Some(w) = warn { eprintln!("WARN: pad {}: {}", p.name, w); }             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)));+                esc_xml(&out_names[i]), p.x, -p.y, drill, diameter, tht_shape, smd_rotation(p.angle + extra_rot)));         }     }     xml.push_str(&package_graphics(km));@@ -2159,10 +2211,11 @@ pub(crate) fn generate_lbr_gates(sym: &ParsedSymbol, fp_content: Option<&str>, s             } else {                 // Round thru-hole pads: real drill + circular diameter (not oblong).                 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) };+                let (tht_shape, diameter, extra_rot, warn) = tht_pad_geometry(&p.kicad_shape, p.dx, p.dy);+                if let Some(w) = warn { eprintln!("WARN: pad {}: {}", p.name, w); }                 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)));+                    esc_xml(&out_names[i]), p.x, -p.y, drill, diameter, tht_shape, smd_rotation(p.angle + extra_rot)));             }         }         // Real silk/fab graphics (circles/lines/arcs); bbox rectangle only as fallback.@@ -2420,6 +2473,88 @@ pub(crate) fn lint_lbr(content: &str) -> LintResult {         errors.push(LintError { code: "missing-devicesets".into(), message: "No <devicesets> section found".into() });     } +    // PAD CLEARANCE. A mapping that grows a pad into its neighbour is a SHORT, and the file+    // is still perfectly valid EAGLE, so nothing else here would catch it: adom/b2b-ph-k-s+    // shipped with pin 2's copper 0.35 mm inside pin 1 (wiki #19). Copper-to-copper under+    // 0.2 mm is an error rather than a warning, because a converter must refuse before a+    // board house does.+    {+        #[derive(Clone)]+        struct P { name: String, x: f64, y: f64, hx: f64, hy: f64, round: bool }+        let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);+        let rot90 = |r: Option<&str>| matches!(r, Some("R90") | Some("R270"));+        let mut bad: Vec<String> = Vec::new();+        let pkg_re = regex_lite::Regex::new(r#"(?s)<package name="([^"]*)">(.*?)</package>"#).unwrap();+        // Match the whole element, then read each attribute from it. An optional (?:rot=...)?+        // group followed by a greedy [^>]* silently matches EMPTY and lets the tail eat the+        // rotation, so every pad reads as unrotated: that made this lint fail the file it+        // had just been used to fix.+        let smd_re = regex_lite::Regex::new(r#"<smd [^>]*/>"#).unwrap();+        let pad_re = regex_lite::Regex::new(r#"<pad [^>]*/>"#).unwrap();+        let attr = |el: &str, k: &str| -> Option<String> {+            regex_lite::Regex::new(&format!(r#"{}="([^"]*)""#, k)).ok()?+                .captures(el).map(|c| c[1].to_string())+        };+        for pk in pkg_re.captures_iter(content) {+            let (pkg, body) = (pk[1].to_string(), pk[2].to_string());+            let mut pads: Vec<P> = Vec::new();+            for m in smd_re.find_iter(&body) {+                let el = m.as_str();+                let g = |k: &str| attr(el, k).unwrap_or_default();+                let (mut hx, mut hy) = (num(&g("dx")) / 2.0, num(&g("dy")) / 2.0);+                if rot90(attr(el, "rot").as_deref()) { std::mem::swap(&mut hx, &mut hy); }+                pads.push(P { name: g("name"), x: num(&g("x")), y: num(&g("y")), hx, hy, round: false });+            }+            for m in pad_re.find_iter(&body) {+                let el = m.as_str();+                let g = |k: &str| attr(el, k).unwrap_or_default();+                let shape = g("shape");+                let d = num(&g("diameter"));+                let (mut hx, mut hy) = match shape.as_str() {+                    // EAGLE's `long` is a 2:1 stadium elongated along the pad's ROTATION+                    // axis, which is X when unrotated: 2d wide by d tall, so the half+                    // extents are (d, d/2). Writing them the other way round is what made+                    // this lint pass the very file it was written for.+                    "long" => (d, d / 2.0),+                    _ => (d / 2.0, d / 2.0),+                };+                if rot90(attr(el, "rot").as_deref()) { std::mem::swap(&mut hx, &mut hy); }+                pads.push(P { name: g("name"), x: num(&g("x")), y: num(&g("y")), hx, hy, round: shape == "round" });+            }+            for i in 0..pads.len() {+                for j in i + 1..pads.len() {+                    let (a, b) = (&pads[i], &pads[j]);+                    // Pads of the SAME net legitimately touch or nest: heatsink vias sit+                    // inside the thermal pad they vent, and a same-net array is emitted as+                    // NAME, NAME@2, NAME@3. Comparing those would fail every such footprint.+                    let base = |n: &str| n.split('@').next().unwrap_or(n).to_string();+                    if base(&a.name) == base(&b.name) { continue; }+                    let gx = (a.x - b.x).abs() - (a.hx + b.hx);+                    let gy = (a.y - b.y).abs() - (a.hy + b.hy);+                    let gap = if a.round && b.round {+                        ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() - (a.hx + b.hx)+                    } else {+                        gx.max(gy)+                    };+                    if gap < 0.2 {+                        bad.push(if gap < 0.0 {+                            format!("{} pads {} and {} OVERLAP by {:.3} mm", pkg, a.name, b.name, -gap)+                        } else {+                            format!("{} pads {} and {} are {:.3} mm apart", pkg, a.name, b.name, gap)+                        });+                    }+                }+            }+        }+        if !bad.is_empty() {+            errors.push(LintError {+                code: "pad-clearance".into(),+                message: format!("copper closer than 0.2 mm: {}. A pad mapping that grows into its neighbour is a short.",+                                 bad.join("; ")),+            });+        }+    }+     // EAGLE's align vocabulary is exactly nine values. Anything else makes Fusion log a     // warning per element on open and fall back to bottom-left, so text anchored to an     // edge runs OUTWARD across the body line instead of inward. adom-lbr shipped@@ -3861,6 +3996,61 @@ mod lint_tests {         assert!(!decls.contains("102"), "{decls}");     } +    #[test]+    fn a_tall_thru_pad_elongates_along_its_own_long_axis() {+        use super::tht_pad_geometry;+        // The JST PH pad from wiki #19: 1.2 wide, 1.75 tall, on a 2.0 mm pitch. Passing+        // dx.max(dy) as the diameter drew it 3.5 x 1.75 elongated along X, so its copper+        // reached 0.35 mm inside its neighbour: a short between pin 1 and pin 2.+        let (shape, dia, rot, warn) = tht_pad_geometry("oval", 1.2, 1.75);+        assert_eq!(shape, "long");+        assert_eq!(dia, 1.2, "diameter is the SHORT side");+        assert_eq!(rot, 90.0, "a tall pad elongates along Y, which EAGLE writes as R90");+        assert!(warn.is_some(), "the fixed 2:1 draws 2.4 where the source said 1.75");+        // a WIDE oval keeps the same rule with no rotation+        let (_, dia, rot, _) = tht_pad_geometry("oval", 1.75, 1.2);+        assert_eq!((dia, rot), (1.2, 0.0));+        // roundrect has no thru-hole equivalent: elongated becomes long...+        let (shape, dia, _, warn) = tht_pad_geometry("roundrect", 1.2, 1.75);+        assert_eq!((shape.as_str(), dia), ("long", 1.2));+        assert!(warn.is_some());+        // ...and a squarish one becomes round on the SHORT side, never the long one, and+        // says that it dropped something.+        let (shape, dia, _, warn) = tht_pad_geometry("roundrect", 1.2, 1.3);+        assert_eq!((shape.as_str(), dia), ("round", 1.2));+        assert!(warn.is_some(), "silently rounding away the long side is what this fixes");+        // an ordinary circle is untouched+        assert_eq!(tht_pad_geometry("circle", 1.2, 1.2).0, "round");+    }++    #[test]+    fn pads_that_overlap_are_a_lint_error_but_same_net_vias_are_not() {+        use super::lint_lbr;+        let lib = |pads: &str| format!(r#"<eagle><drawing><layers></layers><library>+            <packages><package name="P">{pads}</package></packages>+            <symbols><symbol name="S"><pin name="A" x="0" y="0" length="short"/></symbol></symbols>+            <devicesets><deviceset name="D"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates>+            <devices><device name="" package="P"><connects><connect gate="G$1" pin="A" pad="1"/></connects>+            <technologies><technology name=""/></technologies></device></devices></deviceset></devicesets>+            </library></drawing></eagle>"#);+        // the shipped defect: a long pad drawn 2:1 along X reaches into its neighbour+        let bad = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.2" shape="round"/>+                        <pad name="2" x="2.0" y="0" drill="0.75" diameter="1.75" shape="long"/>"#);+        let r = lint_lbr(&bad);+        assert!(!r.passed, "overlapping copper must fail");+        assert!(r.errors.iter().any(|e| e.code == "pad-clearance"), "{:?}", r.errors);+        // the same pads mapped correctly: short side as diameter, rotated to the long axis+        let good = lib(r#"<pad name="1" x="0" y="0" drill="0.75" diameter="1.2" shape="long" rot="R90"/>+                         <pad name="2" x="2.0" y="0" drill="0.75" diameter="1.2" shape="long" rot="R90"/>"#);+        assert!(!lint_lbr(&good).errors.iter().any(|e| e.code == "pad-clearance"));+        // heatsink vias sit INSIDE the pad they vent and share its net, so they are not a+        // clearance problem: comparing them would fail every thermal-via footprint.+        let vias = lib(r#"<smd name="EP" x="0" y="0" dx="2" dy="2"/>+                         <smd name="EP@2" x="0.5" y="0.5" dx="0.4" dy="0.4"/>+                         <smd name="EP@3" x="-0.5" y="0.5" dx="0.4" dy="0.4"/>"#);+        assert!(!lint_lbr(&vias).errors.iter().any(|e| e.code == "pad-clearance"));+    }+     #[test]     fn roundrect_pads_carry_their_corner_radius_into_eagle() {         use super::parse_mod_pads;