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.41.0: Altium pads carry manual mask and paste expansion (wiki #25)
10 files changed
+79−22
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-lbr"-version = "2.40.0"+version = "2.41.0" dependencies = [ "altium-codec", "anyhow",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.40.0"+version = "2.41.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.40.0+2.41.0
bin/adom-lbr⋯ 1 unchanged line ⋯
crates/altium-codec/src/altium_decode.rs+13−4@@ -551,14 +551,23 @@ fn decode_pad(blocks: &[&[u8]]) -> Option<Pad> { // The plated flag byte in the Altium pad record isn't calibrated yet — // unknown, so leave None (treated as plated downstream). plated: None,- // Altium's per-pad mask/paste expansion bytes are not calibrated- // either; None means "inherit", the honest reading.- mask_margin_mm: None,- paste_margin_mm: None,+ // Manual mask/paste expansion (mode byte 2) reads back as the signed+ // margin; rule mode (1) means inherit, so None.+ mask_margin_mm: manual_expansion(pd, 90, 102),+ paste_margin_mm: manual_expansion(pd, 86, 101), paste_margin_ratio: None, }) } +/// Signed expansion at `offset` when the mode byte at `mode` says manual (2).+fn manual_expansion(pd: &[u8], offset: usize, mode: usize) -> Option<f64> {+ if pd.len() > mode && pd[mode] == 2 {+ Some(le_i32(pd, offset) as f64 * ALTIUM_UNIT_MM)+ } else {+ None+ }+}+ /// Track (0x04): [0]layer, [13:17]x1, [17:21]y1, [21:25]x2, [25:29]y2, [29:33]width. fn decode_track(p: &[u8]) -> Option<FpGraphic> { if p.len() < 33 {
crates/altium-codec/src/lib.rs+30@@ -47,6 +47,36 @@ mod tests { } } + #[test]+ fn altium_pad_expansions_encode_decode_and_match_the_vendor_fixture() {+ // wiki #25, Altium half: manual mask/paste expansion at sub-block-5+ // offsets 90/86 with mode bytes 102/101 (2 = manual, 1 = rule).+ use crate::model::{Footprint, Pad};+ let pad = |n: &str, x: f64, mask: Option<f64>, paste: Option<f64>| Pad {+ number: n.into(), x_mm: x, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0, drill_mm: 0.0, rotation: 0.0,+ shape: "rect".into(), plated: None, mask_margin_mm: mask, paste_margin_mm: paste, paste_margin_ratio: None };+ let fp = Footprint { name: "EXP".into(), graphics: vec![], polys: vec![],+ mask_margin_mm: Some(0.1), paste_margin_mm: None,+ pads: vec![pad("1", 0.0, Some(-0.05), Some(-0.05)), pad("2", 2.0, None, None)] };+ let bytes = encode_pcblib(&fp).expect("encode");+ let back = decode_altium(None, Some(&bytes), "EXP").expect("decode").footprint;+ let p1 = &back.pads[0];+ assert!((p1.mask_margin_mm.unwrap() - -0.05).abs() < 1e-4, "{:?}", p1.mask_margin_mm);+ assert!((p1.paste_margin_mm.unwrap() - -0.05).abs() < 1e-4);+ // pad 2 inherits the footprint mask default (manual 0.1) and no paste (rule mode)+ let p2 = &back.pads[1];+ assert!((p2.mask_margin_mm.unwrap() - 0.1).abs() < 1e-4, "{:?}", p2.mask_margin_mm);+ assert!(p2.paste_margin_mm.is_none(), "rule mode must read back as inherit");+ // the vendor fixture's TI pads are manual -155000 / -55118 units+ let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/../altium-pcblib/tests/fixtures/ADS1220IRVAT.PcbLib");+ if let Ok(v) = std::fs::read(fixture) {+ let vf = decode_altium(None, Some(&v), "ADS1220").expect("decode fixture").footprint;+ let p = vf.pads.iter().find(|p| p.number == "1").expect("pad 1");+ assert!((p.mask_margin_mm.unwrap() - (-155000.0 * 0.00000254)).abs() < 1e-6, "{:?}", p.mask_margin_mm);+ assert!((p.paste_margin_mm.unwrap() - (-55118.0 * 0.00000254)).abs() < 1e-6, "{:?}", p.paste_margin_mm);+ }+ }+ #[test] fn signed_pad_margins_round_trip_through_canonical_json() { // wiki #25: solder_mask_margin / solder_paste_margin are aperture
crates/altium-codec/src/pcblib.rs+17−2@@ -315,7 +315,7 @@ fn write_component<F: Read + Write + std::io::Seek>( data.extend_from_slice(&(name_block.len() as u32).to_le_bytes()); data.extend_from_slice(&name_block); for pad in &fp.pads {- data.extend(encode_pad_record(pad, pad_data_tpl));+ data.extend(encode_pad_record(pad, fp, pad_data_tpl)); } // Silk / courtyard / fab graphics as Track/Arc records after the pads. let graphics = expand_fp_graphics(fp);@@ -486,7 +486,7 @@ fn parses_as_name_list(b: &[u8]) -> bool { } /// Build one pad record (type 0x02 + 6 sub-blocks), patching the data block.-fn encode_pad_record(pad: &Pad, pad_tpl: &[u8]) -> Vec<u8> {+fn encode_pad_record(pad: &Pad, fp: &Footprint, pad_tpl: &[u8]) -> Vec<u8> { let mut designator = vec![pad.number.len() as u8]; designator.extend_from_slice(pad.number.as_bytes()); @@ -513,6 +513,21 @@ fn encode_pad_record(pad: &Pad, pad_tpl: &[u8]) -> Vec<u8> { if pd.len() >= 60 { pd[52..60].copy_from_slice(&pad.rotation.to_le_bytes()); }+ // Per-pad mask/paste expansion (wiki #25): signed i32 units at 86 (paste)+ // and 90 (mask), mode bytes at 101 (paste) and 102 (mask) with 1 = rule,+ // 2 = manual. Calibrated against the ADS1220IRVAT vendor fixture, whose TI+ // pads carry manual -55118 / -155000 with modes 2/2, and matching KiCad's+ // Altium importer. A pad without a value (own or footprint default) is+ // written rule-mode with 0, never a stale template value.+ if pd.len() >= 103 {+ let mask = pad.mask_margin_mm.or(fp.mask_margin_mm);+ let paste = pad.paste_margin_mm.or(fp.paste_margin_mm);+ for (offset, mode, value) in [(86usize, 101usize, paste), (90, 102, mask)] {+ let units = (value.unwrap_or(0.0) * MM_TO_UNIT).round() as i32;+ pd[offset..offset + 4].copy_from_slice(&units.to_le_bytes());+ pd[mode] = if value.is_some() { 2 } else { 1 };+ }+ } let blocks: [&[u8]; 6] = [ &designator,
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-lbr",- "version": "2.40.0",+ "version": "2.41.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+8−8@@ -1,8 +1,8 @@ { "slug": "adom-lbr",- "version": "2.40.0",+ "version": "2.41.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.",+ "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": {}, "tags": [ "eda",@@ -61,15 +61,15 @@ "screenshots/**", "dockbar.json" ],- "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",+ "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools \u2014 KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.", "sample_prompts": [ { "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",- "label": "KiCad → Altium"+ "label": "KiCad \u2192 Altium" }, { "prompt": "Import this Altium footprint into the adom-lbr JSON",- "label": "Altium → JSON"+ "label": "Altium \u2192 JSON" }, { "prompt": "Bundle this part into a single Altium IntLib",@@ -77,15 +77,15 @@ }, { "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",- "label": "Altium → KiCad"+ "label": "Altium \u2192 KiCad" }, { "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",- "label": "JSON → KiCad"+ "label": "JSON \u2192 KiCad" }, { "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup", "label": "Browse parts" } ]-}+}
src/main.rs+7−4@@ -2921,8 +2921,11 @@ pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart { /// carry yet (its pad-record expansion bytes are uncalibrated): named so the /// loss is explicit, never a silent success (wiki #25). fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> {+ // Signed mm expansions now ride the Altium pad record (manual mode); a+ // KiCad paste RATIO has no radial equivalent, so it is the one that is+ // still named as lost. fp.pads.iter()- .filter(|p| p.mask_margin_mm.is_some() || p.paste_margin_mm.is_some() || p.paste_margin_ratio.is_some())+ .filter(|p| p.paste_margin_ratio.is_some()) .map(|p| p.number.clone()) .collect() }@@ -2930,7 +2933,7 @@ fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> { fn warn_lost_margins(fp: &altium_codec::Footprint, lane: &str) -> Vec<String> { let lost = lost_margin_pads(fp); if !lost.is_empty() {- eprintln!("WARN: [lost-pad-margins] pads {} carry per-pad solder-mask/paste margins that the {lane} writer does not carry into the pad record yet; Altium's rule-based expansion applies. The copper is exact; review the mask/paste apertures before fab.",+ eprintln!("WARN: [lost-pad-margins] pads {} carry a per-pad solder-paste RATIO, which the {lane} pad record cannot express (its expansion is radial, in mm); the mm expansions are carried, the ratio is not. Review the paste apertures before fab.", lost.join(",")); } lost@@ -3543,8 +3546,8 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or seed_parameters(&mut sym, &p); let lost = warn_lost_margins(&p.footprint, "Altium"); if !lost.is_empty() {- lost_features.push(json!({"part": p.mpn, "feature": "per-pad solder-mask/paste margins", "pads": lost,- "effect": "Altium applies its rule-based mask/paste expansion to these pads"}));+ lost_features.push(json!({"part": p.mpn, "feature": "per-pad solder-paste ratio", "pads": lost,+ "effect": "Altium carries the mm expansions; the paste ratio has no radial equivalent and is dropped"})); } // The component a user picks in Altium is the ORDERABLE part, // so it takes the MPN (the deviceset=MPN doctrine); the source