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.40.0: per-pad solder-mask and paste margins survive the hub (wiki #25)
12 files changed
+158−26
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-lbr"-version = "2.39.0"+version = "2.40.0" dependencies = [ "altium-codec", "anyhow",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.39.0"+version = "2.40.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.39.0+2.40.0
bin/adom-lbr⋯ 1 unchanged line ⋯
crates/altium-codec/src/altium_decode.rs+7−2@@ -46,7 +46,7 @@ pub fn decode_altium(schlib: Option<&[u8]>, pcblib: Option<&[u8]>, mpn: &str) -> let want_fp = symbol.footprint.clone(); let footprint = match pcblib { Some(b) => decode_pcblib_footprint(b, want_fp.as_deref())?,- None => Footprint { name: mpn.to_string(), pads: vec![], graphics: vec![], polys: vec![] },+ None => Footprint { name: mpn.to_string(), pads: vec![], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None }, }; build_part(symbol, footprint, mpn) }@@ -502,7 +502,7 @@ fn decode_pcblib_footprint(bytes: &[u8], want: Option<&str>) -> Result<Footprint } } - Ok(Footprint { name: fp_name, pads, graphics, polys: vec![] })+ Ok(Footprint { name: fp_name, pads, graphics, polys: vec![], mask_margin_mm: None, paste_margin_mm: None }) } /// Altium layer byte → neutral role. Counterpart of `pcblib::layer_enc`.@@ -551,6 +551,11 @@ 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,+ paste_margin_ratio: None, }) }
crates/altium-codec/src/kicad.rs+11−1@@ -382,10 +382,20 @@ pub(crate) fn import_footprint(text: &str) -> Result<Footprint> { rotation: rot, shape: kshape.to_string(), plated: if is_th { Some(ptype != "np_thru_hole") } else { None },+ mask_margin_mm: p.child("solder_mask_margin").and_then(|m| m.nums().first().copied()),+ paste_margin_mm: p.child("solder_paste_margin").and_then(|m| m.nums().first().copied()),+ paste_margin_ratio: p.child("solder_paste_margin_ratio").and_then(|m| m.nums().first().copied()), }); } - Ok(Footprint { name, pads, graphics: import_footprint_graphics(&root), polys: import_footprint_polys(&root) })+ Ok(Footprint {+ name,+ pads,+ graphics: import_footprint_graphics(&root),+ polys: import_footprint_polys(&root),+ mask_margin_mm: root.child("solder_mask_margin").and_then(|m| m.nums().first().copied()),+ paste_margin_mm: root.child("solder_paste_margin").and_then(|m| m.nums().first().copied()),+ }) } /// Map a KiCad footprint layer name to a neutral [`FpLayer`] role. Returns
crates/altium-codec/src/kicad_export.rs+17@@ -277,6 +277,13 @@ pub fn export_kicad_footprint_for(fp: &Footprint, ver: KicadVersion) -> String { s.push_str(if smd { "\t(attr smd)\n" } else { "\t(attr through_hole)\n" }); // silk / courtyard / fab graphics+ // footprint-level aperture defaults, inherited by pads without their own+ if let Some(m) = fp.mask_margin_mm {+ s.push_str(&format!("\t(solder_mask_margin {})\n", n(m)));+ }+ if let Some(m) = fp.paste_margin_mm {+ s.push_str(&format!("\t(solder_paste_margin {})\n", n(m)));+ } for g in &fp.graphics { s.push_str(&fp_graphic(g)); }@@ -326,6 +333,16 @@ pub fn export_kicad_footprint_for(fp: &Footprint, ver: KicadVersion) -> String { if shape == "roundrect" { s.push_str(" (roundrect_rratio 0.25)"); }+ // Signed aperture margins ride back out exactly as imported.+ if let Some(m) = pad.mask_margin_mm {+ s.push_str(&format!(" (solder_mask_margin {})", n(m)));+ }+ if let Some(m) = pad.paste_margin_mm {+ s.push_str(&format!(" (solder_paste_margin {})", n(m)));+ }+ if let Some(r) = pad.paste_margin_ratio {+ s.push_str(&format!(" (solder_paste_margin_ratio {})", n(r)));+ } s.push_str(")\n"); } s.push_str(")\n");
crates/altium-codec/src/lib.rs+43−11@@ -47,6 +47,34 @@ mod tests { } } + #[test]+ fn signed_pad_margins_round_trip_through_canonical_json() {+ // wiki #25: solder_mask_margin / solder_paste_margin are aperture+ // geometry (negative = solder-mask-defined pad); they were dropped+ // silently between import and export.+ let km = r#"(footprint "APT" (layer "F.Cu") (solder_mask_margin 0.1) (solder_paste_margin -0.02)+ (pad "1" smd rect (at -1 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_mask_margin -0.05) (solder_paste_margin -0.05))+ (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_mask_margin 0.05) (solder_paste_margin_ratio -0.1))+ (pad "3" smd rect (at 3 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask")))"#;+ let fp = crate::kicad::import_footprint(km).expect("import");+ assert_eq!(fp.mask_margin_mm, Some(0.1));+ assert_eq!(fp.paste_margin_mm, Some(-0.02));+ assert_eq!(fp.pads[0].mask_margin_mm, Some(-0.05));+ assert_eq!(fp.pads[0].paste_margin_mm, Some(-0.05));+ assert_eq!(fp.pads[1].mask_margin_mm, Some(0.05));+ assert_eq!(fp.pads[1].paste_margin_ratio, Some(-0.1));+ assert!(fp.pads[2].mask_margin_mm.is_none() && fp.pads[2].paste_margin_mm.is_none());+ let back: Footprint = serde_json::from_str(&serde_json::to_string(&fp).unwrap()).unwrap();+ let out = export_kicad_footprint(&back);+ assert!(out.contains("(solder_mask_margin -0.05)") && out.contains("(solder_paste_margin -0.05)"), "{out}");+ assert!(out.contains("(solder_mask_margin 0.05)") && out.contains("(solder_paste_margin_ratio -0.1)"), "{out}");+ assert!(out.contains("\t(solder_mask_margin 0.1)\n") && out.contains("\t(solder_paste_margin -0.02)\n"), "footprint defaults: {out}");+ // a pad without margins emits none, and legacy JSON without the fields loads+ assert_eq!(out.matches("solder_mask_margin").count(), 3);+ let legacy: Footprint = serde_json::from_str(r#"{"name":"L","pads":[{"number":"1","x_mm":0,"y_mm":0,"w_mm":1,"h_mm":1}]}"#).unwrap();+ assert!(legacy.pads[0].mask_margin_mm.is_none() && legacy.mask_margin_mm.is_none());+ }+ #[test] fn background_and_outline_fill_remain_distinct_through_canonical_json() { // wiki #24: (fill (type background)) came back as (fill (type outline)),@@ -168,9 +196,9 @@ mod tests { use crate::model::{Footprint, Pad}; use std::io::Read; let pad = Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0,- drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None };- let fp1 = Footprint { name: "WITHMODEL".into(), pads: vec![pad.clone()], graphics: vec![], polys: vec![] };- let fp2 = Footprint { name: "BARE".into(), pads: vec![pad], graphics: vec![], polys: vec![] };+ drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None, mask_margin_mm: None, paste_margin_mm: None, paste_margin_ratio: None };+ let fp1 = Footprint { name: "WITHMODEL".into(), pads: vec![pad.clone()], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None };+ let fp2 = Footprint { name: "BARE".into(), pads: vec![pad], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None }; let step = b"ISO-10303-21;\r\nHEADER;\r\nENDSEC;\r\nEND-ISO-10303-21;\r\n".to_vec(); let m = EmbeddedModel { name: "WITHMODEL.step".into(), step: step.clone(), rot_x: 0.0, rot_y: 0.0, rot_z: 0.0, dz_mil: 0.0, overall_height_mil: 0.0 };@@ -215,8 +243,8 @@ mod tests { use crate::model::{Pad, Footprint}; let long = "JST_PH_B2B-PH-K_1x02_P2.00mm_Vertical"; let pad = Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0,- drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None };- let mk = |name: &str| Footprint { name: name.into(), pads: vec![pad.clone()], graphics: vec![], polys: vec![] };+ drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None, mask_margin_mm: None, paste_margin_mm: None, paste_margin_ratio: None };+ let mk = |name: &str| Footprint { name: name.into(), pads: vec![pad.clone()], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None }; let bytes = encode_pcblib_many(&[mk(long), mk(&format!("{long}2"))]).expect("encode"); let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).unwrap(); let tops: Vec<String> = comp.read_root_storage().filter(|e| e.is_storage())@@ -334,9 +362,9 @@ mod tests { b.footprint = Some("FPB".into()); b.pins.push(Pin { number: "3".into(), name: "C".into(), x: -100, y: -300, length: Some(100), rotation: 0 }); let sch = encode_schlib(&[a, b]).expect("encode schlib");- let pad = |n: &str, x: 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 };- let fpa = Footprint { name: "FPA".into(), pads: vec![pad("1", 0.0)], graphics: vec![], polys: vec![] };- let fpb = Footprint { name: "FPB".into(), pads: vec![pad("1", 0.0), pad("2", 2.0)], graphics: vec![], polys: vec![] };+ let pad = |n: &str, x: 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: None, paste_margin_mm: None, paste_margin_ratio: None };+ let fpa = Footprint { name: "FPA".into(), pads: vec![pad("1", 0.0)], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None };+ let fpb = Footprint { name: "FPB".into(), pads: vec![pad("1", 0.0), pad("2", 2.0)], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None }; let pcb = encode_pcblib_many(&[fpa, fpb]).expect("encode pcblib"); let pb = decode_altium(Some(&sch), Some(&pcb), "PARTB").expect("decode PARTB");@@ -366,8 +394,10 @@ mod tests { let line = |layer: FpLayer, y: f64| FpGraphic::Line { layer, x1: -1.0, y1: y, x2: 1.0, y2: y, width: 0.1 }; let fp = Footprint { name: "LAYERFP".into(),- pads: vec![Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None }],+ pads: vec![Pad { number: "1".into(), x_mm: 0.0, 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: None, paste_margin_mm: None, paste_margin_ratio: None }], polys: vec![],+ mask_margin_mm: None,+ paste_margin_mm: None, graphics: vec![ line(FpLayer::Silk, 1.0), line(FpLayer::Fab, 2.0),@@ -454,11 +484,13 @@ mod tests { let fp = Footprint { name: "REFFP".into(), pads: vec![- Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },- Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },+ Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None, mask_margin_mm: None, paste_margin_mm: None, paste_margin_ratio: None },+ Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None, mask_margin_mm: None, paste_margin_mm: None, paste_margin_ratio: None }, ], graphics: vec![], polys: vec![],+ mask_margin_mm: None,+ paste_margin_mm: None, }; let bytes = encode_pcblib(&fp).expect("encode"); let decoded = decode_pcblib(&bytes).expect("decode our own output");
crates/altium-codec/src/model.rs+19@@ -195,6 +195,13 @@ pub struct Footprint { /// unknown FIELD is skipped. Old binaries simply do not see polygons. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub polys: Vec<FpPoly>,+ /// Footprint-level solder-mask margin default in mm (KiCad footprint+ /// `solder_mask_margin`); pads without their own value inherit it.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub mask_margin_mm: Option<f64>,+ /// Footprint-level solder-paste margin default in mm.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub paste_margin_mm: Option<f64>, } /// A filled polygon on a footprint layer, vertices in mm, Y-down neutral.@@ -273,6 +280,18 @@ pub struct Pad { /// distinction doesn't. KiCad export round-trips it faithfully. #[serde(default, skip_serializing_if = "Option::is_none")] pub plated: Option<bool>,+ /// Per-pad solder-mask margin in mm (KiCad `solder_mask_margin`), signed:+ /// negative = solder-mask-defined pad. Aperture geometry, not decoration;+ /// dropping it changes what gets soldered (wiki #25). None = inherit.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub mask_margin_mm: Option<f64>,+ /// Per-pad solder-paste margin in mm (KiCad `solder_paste_margin`), signed.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub paste_margin_mm: Option<f64>,+ /// Per-pad paste margin as a ratio of the pad size (KiCad+ /// `solder_paste_margin_ratio`, -1.0 to 1.0).+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub paste_margin_ratio: Option<f64>, } fn default_shape() -> String {
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-lbr",- "version": "2.39.0",+ "version": "2.40.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.39.0",+ "version": "2.40.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+49@@ -1165,6 +1165,13 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> { // percent of the same side, and a ratio of 0.5 is a fully rounded end. So N = r * 200. let rr_re = regex_lite::Regex::new(r#"\(roundrect_rratio\s+(-?[\d.]+)"#).unwrap(); + // Per-pad aperture margins (solder_mask_margin / solder_paste_margin) are+ // real geometry EAGLE cannot express per pad: it has only the library-wide+ // DRC stop/cream rules. Name the pads instead of dropping them silently+ // (wiki #25); the lint mirrors this as lost-pad-margins.+ let margin_re = regex_lite::Regex::new(r#"\(solder_(?:mask|paste)_margin(?:_ratio)?\s+-?[\d.]+"#).unwrap();+ let mut margin_pads: Vec<String> = Vec::new();+ let mut pads = Vec::new(); for block in sexpr_blocks(km, "pad") { let Some(nc) = name_re.captures(block) else { continue };@@ -1172,6 +1179,9 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> { if name.is_empty() { continue; }+ if margin_re.is_match(block) {+ margin_pads.push(name.clone());+ } let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd"; let Some(at) = at_re.captures(block) else { continue }; let Some(size) = size_re.captures(block) else { continue };@@ -1198,6 +1208,17 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> { }.to_string(); pads.push(ModPad { name, smd, x, y, dx, dy, angle, drill, shape, kicad_shape, roundness }); }+ if !margin_pads.is_empty() {+ // generate parses the same footprint more than once (baseline, variants,+ // connects), so the warning is keyed on its pad list and printed once.+ static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::OnceLock::new();+ let key = margin_pads.join(",");+ let fresh = WARNED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))+ .lock().map(|mut set| set.insert(key.clone())).unwrap_or(true);+ if fresh {+ eprintln!("WARN: [lost-pad-margins] pads {key} carry per-pad solder-mask/paste margins; EAGLE has no per-pad aperture margin, so Fusion applies the library DRC stop/cream rules instead. The copper is exact; review the mask/paste apertures in Fusion before fab.");+ }+ } pads } @@ -2896,6 +2917,25 @@ pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart { /// Seed the symbol's Altium property fields from the part identity (Value drawn; /// Manufacturer/MPN/Package hidden), skipping empties + names already present.+/// Pads whose per-pad solder-mask/paste margins the Altium encoder cannot+/// 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> {+ fp.pads.iter()+ .filter(|p| p.mask_margin_mm.is_some() || p.paste_margin_mm.is_some() || p.paste_margin_ratio.is_some())+ .map(|p| p.number.clone())+ .collect()+}++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.",+ lost.join(","));+ }+ lost+}+ fn seed_parameters(symbol: &mut altium_codec::Symbol, part: &altium_codec::AdomLbrPart) { use altium_codec::Parameter; let mut push = |name: &str, value: &str, visible: bool| {@@ -3452,6 +3492,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or Commands::ExportAltium { part, out_dir } => { let p = load_part(&part);+ warn_lost_margins(&p.footprint, "Altium"); let mut symbol = p.symbol.clone(); seed_parameters(&mut symbol, &p); let sym_bytes = altium_codec::encode_schlib(&[symbol])@@ -3478,6 +3519,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or let mut models: Vec<Option<altium_codec::EmbeddedModel>> = Vec::new(); let mut fp_index: BTreeMap<String, usize> = BTreeMap::new(); let mut model_missing: Vec<Value> = Vec::new();+ let mut lost_features: Vec<Value> = Vec::new(); let mut fp_by_content: BTreeMap<String, String> = BTreeMap::new(); let mut fp_shared: BTreeMap<String, Vec<String>> = BTreeMap::new(); let mut fp_names: BTreeSet<String> = BTreeSet::new();@@ -3499,6 +3541,11 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or }; let mut sym = p.symbol.clone(); 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"}));+ } // The component a user picks in Altium is the ORDERABLE part, // so it takes the MPN (the deviceset=MPN doctrine); the source // symbol name is often a shared class name like Conn_01x02.@@ -3606,12 +3653,14 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or "embedded": models.iter().filter(|m| m.is_some()).count(), "missing": model_missing, },+ "lostFeatures": lost_features, "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent." })).unwrap()); } Commands::ExportIntlib { part, out } => { let p = load_part(&part);+ warn_lost_margins(&p.footprint, "Altium"); let mut symbol = p.symbol.clone(); seed_parameters(&mut symbol, &p); let descr = if p.value.is_empty() { p.mpn.clone() } else { format!("{} {}", p.value, p.package) };