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.42.0: lost features are named in every lane; --strict refuses them (wiki #25)
9 files changed
+203−42
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-lbr"-version = "2.41.1"+version = "2.42.0" dependencies = [ "altium-codec", "anyhow",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.41.1"+version = "2.42.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."
README.md+4@@ -102,6 +102,10 @@ Load an `.lbr` and the viewer breaks it down at a glance (**symbols, packages, d This is the whole point. Fusion will silently show "No Components" or refuse to place a part when a library is subtly broken. adom-lbr catches it first. Here the deviceset has **no pin-to-pad connect mappings**, so the linter flags `empty-connects` with a plain-English reason: *"Fusion cannot use this component."* It also catches missing packages, devices with no package attribute, malformed XML, and invisible symbol bodies, each with a code and a fix. +## What a target cannot carry is named, never dropped++Every source feature a target lane cannot represent is reported instead of silently lost. Today that is the EAGLE/Fusion lane's lack of per-pad solder-mask/paste margins (Fusion applies its library DRC stop/cream rules instead) and the Altium lane's lack of a paste **ratio** (mm expansions ride the pad record since 2.41.0). The policy is **warn and ship**: the file is written, stderr carries `WARN: [lost-pad-margins] pads 1,2,3 carry ...`, and stdout carries one machine-readable line per loss, `LOST: {"code":"lost-pad-margins","part":...,"feature":...,"pads":[...],"effect":...}`, which the wiki's export workers surface on the library receipt. Pass `--strict` to refuse instead: a single-part export writes nothing and exits 2 with `ERROR: [LOST_FEATURES] ...`; `assemble-altium` skips the refused parts into `refused[]`, still assembles the rest, reports `status: "partial"` and exits 2 with valid files on disk. The KiCad lane loses nothing, so `--strict` never refuses it.+ ## Generate from the command line 
SKILL.md+4@@ -226,6 +226,10 @@ Checks: - `malformed-xml`: unbalanced angle brackets - `no-symbol-layer`: nothing on layer 94 (symbol body invisible) +## Lost features and `--strict` (2.42.0)++A source feature the target lane cannot carry is reported, never silently dropped, with one shape in every lane: `{code, part, feature, pads[], effect}` (today `code` is `lost-pad-margins`). Default policy is warn and ship: the file is written, stderr says `WARN: [lost-pad-margins] pads 1,2,3 carry <feature>; <effect>.`, and stdout carries one `LOST: {...json...}` line per loss for machine callers (`OK:` and `HINT:` lines are unchanged). Losses today: the EAGLE/Fusion lane (`generate`) cannot carry per-pad solder-mask/paste margins; the Altium lane (`export-altium`, `export-intlib`, `assemble-altium`) cannot carry a paste ratio; the KiCad lane loses nothing. The global `--strict` flag refuses instead: single-part verbs print the LOST line, then `ERROR: [LOST_FEATURES] nothing written for the <lane> lane: refused under --strict: ...` and exit 2 with nothing written; `assemble-altium` moves refused parts to `refused[]` (and `skipped[]` with the reason), assembles the rest, reports `status: "partial"` and exits 2 while the files written stay valid. `assemble` (EAGLE) takes finished `.lbr` files, so the loss was already reported at `generate` time.+ ## validate Same as lint but human-readable output. Exits 0 = valid, 2 = errors found.
VERSION+1−1@@ -1 +1 @@-2.41.1+2.42.0
bin/adom-lbr⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-lbr",- "version": "2.41.1",+ "version": "2.42.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.41.1",+ "version": "2.42.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+183−30@@ -19,6 +19,15 @@ const SKILL: &str = include_str!("../SKILL.md"); struct Cli { #[command(subcommand)] command: Commands,+ /// Refuse an export that would LOSE a source feature the target lane+ /// cannot carry (per-pad mask/paste margins on the EAGLE/Fusion lane, a+ /// paste RATIO on the Altium lane). The default is warn and ship: the+ /// loss is named on stderr as WARN: [lost-pad-margins] and on stdout as a+ /// LOST: json line, and the file is written. Under --strict a single-part+ /// export writes nothing and exits 2; assemble-altium skips the refused+ /// parts into refused[], ships the rest, reports status "partial", exit 2.+ #[arg(long, global = true)]+ strict: bool, } #[derive(Subcommand)]@@ -1170,7 +1179,6 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> { // 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") {@@ -1180,7 +1188,7 @@ fn parse_mod_pads(km: &str) -> Vec<ModPad> { continue; } if margin_re.is_match(block) {- margin_pads.push(name.clone());+ eagle_margin_collect(&name); } let smd = nc.get(3).map(|m| m.as_str()).unwrap_or("") == "smd"; let Some(at) = at_re.captures(block) else { continue };@@ -1208,17 +1216,6 @@ 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 } @@ -2955,6 +2952,84 @@ pub(crate) fn load_part(path: &std::path::Path) -> altium_codec::AdomLbrPart { /// 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).+/// A source feature a target lane cannot carry. One shape for every lane+/// (Colby's policy call on wiki #25): `code` is the machine key, `pads` the+/// pads it applies to in source order, `effect` what the target does instead.+#[derive(Debug, Clone, PartialEq)]+struct LostFeature {+ code: &'static str,+ part: String,+ feature: String,+ pads: Vec<String>,+ effect: String,+}++impl LostFeature {+ fn json(&self) -> serde_json::Value {+ serde_json::json!({"code": self.code, "part": self.part, "feature": self.feature,+ "pads": self.pads, "effect": self.effect})+ }+ fn pad_list(&self) -> String { self.pads.join(",") }+}++/// The three outputs every lane emits for a loss, in the default warn-and-ship+/// policy: the human WARN on stderr (the shape people already read), and the+/// structured LOST: json line on stdout for the wiki export workers.+fn report_lost(l: &LostFeature) {+ eprintln!("WARN: [{}] pads {} carry {}; {}.", l.code, l.pad_list(), l.feature, l.effect);+ println!("LOST: {}", l.json());+}++/// The --strict decision for a single-part lane: refuse only when something+/// is actually lost. Pure on `strict` (not the global) so tests cannot race.+fn strict_refusal(strict: bool, lane: &str, lost: &[LostFeature]) -> Option<String> {+ if !strict || lost.is_empty() { return None; }+ let what: Vec<String> = lost.iter().map(|l| format!("{} on pads {}", l.feature, l.pad_list())).collect();+ Some(format!("nothing written for the {lane} lane: refused under --strict: {}", what.join("; ")))+}++/// Pads whose KiCad per-pad solder-mask/paste margins the EAGLE lane drops.+/// parse_mod_pads runs several times per part (baseline, variants, connects),+/// so it only COLLECTS here, deduped in source order; the generate handler+/// drains the collector once, before it lints or writes, and reports with the+/// part name (wiki #25).+static EAGLE_MARGIN_PADS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());++fn eagle_margin_collect(pad: &str) {+ if let Ok(mut v) = EAGLE_MARGIN_PADS.lock() {+ if !v.iter().any(|p| p == pad) { v.push(pad.to_string()); }+ }+}++fn eagle_margin_drain() -> Vec<String> {+ EAGLE_MARGIN_PADS.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()+}++fn eagle_lost_feature(part: &str, pads: Vec<String>) -> Option<LostFeature> {+ if pads.is_empty() { return None; }+ Some(LostFeature {+ code: "lost-pad-margins",+ part: part.to_string(),+ feature: "per-pad solder-mask/paste margins".into(),+ pads,+ effect: "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".into(),+ })+}++/// The Altium lane's loss: signed mm expansions ride the pad record since+/// 2.41.0; a KiCad paste RATIO has no radial equivalent.+fn altium_lost_feature(part: &str, fp: &altium_codec::Footprint) -> Option<LostFeature> {+ let pads = lost_margin_pads(fp);+ if pads.is_empty() { return None; }+ Some(LostFeature {+ code: "lost-pad-margins",+ part: part.to_string(),+ feature: "per-pad solder-paste ratio".into(),+ pads,+ effect: "Altium carries the mm mask/paste expansions; a paste ratio has no radial equivalent and is dropped, review the paste apertures before fab".into(),+ })+}+ 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@@ -2965,15 +3040,6 @@ fn lost_margin_pads(fp: &altium_codec::Footprint) -> Vec<String> { .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 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-}- 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| {@@ -3247,6 +3313,15 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or } let lbr = generate_lbr_gates(&parsed, fp_content.as_deref(), art.as_ref(), symbol_art_scale, &pin_map, &part_attrs, &alts, &pkg_variants); + // Losses are decided here, once, before anything is linted or+ // written: the parser only collected the margin pads.+ let lost: Vec<LostFeature> = eagle_lost_feature(&parsed.name, eagle_margin_drain()).into_iter().collect();+ for l in &lost { report_lost(l); }+ if let Some(why) = strict_refusal(cli.strict, "EAGLE/Fusion", &lost) {+ eprintln!("ERROR: [LOST_FEATURES] {why}");+ process::exit(2);+ }+ // Lint before writing let lint = lint_lbr(&lbr); for err in &lint.errors {@@ -3532,7 +3607,12 @@ 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 lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();+ for l in &lost { report_lost(l); }+ if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {+ eprintln!("ERROR: [LOST_FEATURES] {why}");+ process::exit(2);+ } let mut symbol = p.symbol.clone(); seed_parameters(&mut symbol, &p); let sym_bytes = altium_codec::encode_schlib(&[symbol])@@ -3562,6 +3642,7 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or 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 refused: 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();@@ -3583,10 +3664,16 @@ 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-paste ratio", "pads": lost,- "effect": "Altium carries the mm expansions; the paste ratio has no radial equivalent and is dropped"}));+ if let Some(l) = altium_lost_feature(&p.mpn, &p.footprint) {+ report_lost(&l);+ if let Some(why) = strict_refusal(cli.strict, "Altium", std::slice::from_ref(&l)) {+ // Under --strict the part is refused, the rest still+ // assemble; the loss stays a NAMED entry, never silent.+ skipped.push(json!({"part": path.display().to_string(), "reason": why}));+ refused.push(l.json());+ continue;+ }+ lost_features.push(l.json()); } // The component a user picks in Altium is the ORDERABLE part, // so it takes the MPN (the deviceset=MPN doctrine); the source@@ -3682,8 +3769,9 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or if let Err(e) = fs::write(&pp, &pcb_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", pp.display())})); process::exit(2); } let deduped: Vec<Value> = fp_shared.iter().filter(|(_, v)| v.len() > 1) .map(|(k, v)| json!({"footprint": k, "sharedBy": v})).collect();+ let partial = !refused.is_empty(); println!("{}", serde_json::to_string_pretty(&json!({- "status": "ok",+ "status": if partial { "partial" } else { "ok" }, "schlib": sp.display().to_string(), "pcblib": pp.display().to_string(), "components": symbols.len(),@@ -3696,13 +3784,22 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or "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."+ "refused": refused,+ "_hint": "components are named by MPN; identical land patterns share one footprint. skipped[] is the complete drop list, never silent. lostFeatures[] names what the Altium lane could not carry (warn and ship); under --strict those parts move to refused[] and skipped[], the rest still assemble, status is partial and the exit code 2 while the files written stay valid." })).unwrap());+ if partial {+ process::exit(2);+ } } Commands::ExportIntlib { part, out } => { let p = load_part(&part);- warn_lost_margins(&p.footprint, "Altium");+ let lost: Vec<LostFeature> = altium_lost_feature(&p.mpn, &p.footprint).into_iter().collect();+ for l in &lost { report_lost(l); }+ if let Some(why) = strict_refusal(cli.strict, "Altium", &lost) {+ eprintln!("ERROR: [LOST_FEATURES] {why}");+ process::exit(2);+ } 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) };@@ -4109,6 +4206,62 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> Ok(()) } +#[cfg(test)]+mod lost_feature_tests {+ use super::*;++ #[test]+ fn eagle_collector_dedupes_across_parses_and_drains() {+ // the parser runs several times per part; the collector keeps one+ // entry per pad in source order and empties on drain+ let km = r#"(footprint "M"+ (pad "1" smd rect (at 0 0) (size 1 1) (solder_mask_margin -0.05))+ (pad "2" smd rect (at 1 0) (size 1 1) (solder_paste_margin -0.05))+ (pad "3" smd rect (at 2 0) (size 1 1)))"#;+ let _ = eagle_margin_drain();+ let _ = parse_mod_pads(km);+ let _ = parse_mod_pads(km);+ let _ = parse_mod_pads(km);+ let pads = eagle_margin_drain();+ assert_eq!(pads, vec!["1".to_string(), "2".to_string()], "one entry per pad, source order");+ assert!(eagle_margin_drain().is_empty(), "drain empties the collector");+ let lost = eagle_lost_feature("M", pads).expect("a loss");+ assert_eq!(lost.code, "lost-pad-margins");+ assert_eq!(lost.json()["pads"], serde_json::json!(["1", "2"]));+ assert!(eagle_lost_feature("M", vec![]).is_none(), "no margin pads, no loss");+ }++ #[test]+ fn strict_refuses_only_when_something_is_lost() {+ let l = LostFeature { code: "lost-pad-margins", part: "P".into(),+ feature: "per-pad solder-mask/paste margins".into(), pads: vec!["1".into(), "2".into()],+ effect: "x".into() };+ assert!(strict_refusal(false, "EAGLE/Fusion", std::slice::from_ref(&l)).is_none(), "default ships");+ assert!(strict_refusal(true, "EAGLE/Fusion", &[]).is_none(), "nothing lost, nothing refused");+ let why = strict_refusal(true, "EAGLE/Fusion", std::slice::from_ref(&l)).expect("refused");+ assert_eq!(why, "nothing written for the EAGLE/Fusion lane: refused under --strict: per-pad solder-mask/paste margins on pads 1,2");+ }++ #[test]+ fn altium_lane_loses_only_the_paste_ratio() {+ let sym = r#"(kicad_symbol_lib (version 20231120) (generator "t")+ (symbol "P" (property "Reference" "U" (at 0 0 0))+ (symbol "P_1_1"+ (pin passive line (at -5.08 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+ (pin passive line (at -5.08 -2.54 0) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))"#;+ let km = r#"(footprint "P" (layer "F.Cu")+ (pad "1" smd rect (at 0 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 2 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask") (solder_paste_margin_ratio -0.1)))"#;+ let part = altium_codec::import_kicad(sym, km, "P").expect("import");+ let lost = altium_lost_feature(&part.mpn, &part.footprint).expect("the ratio is a loss");+ assert_eq!(lost.pads, vec!["2".to_string()], "mm margins on pad 1 are carried; only the ratio pad is lost");+ assert_eq!(lost.feature, "per-pad solder-paste ratio");+ let mut carried = part.footprint.clone();+ carried.pads.retain(|p| p.number == "1");+ assert!(altium_lost_feature("P", &carried).is_none(), "mm margins alone are not a loss on the Altium lane");+ }+}+ #[cfg(test)] mod lint_tests { #[test]