← Commit history
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"     }   ]-}\ No newline at end of file+}
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