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.32.0: THT pad shape mapping + pad-clearance lint (#19)
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" } ]-}+}
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;