← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.46.3"+version = "2.47.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.46.3"+version = "2.47.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.46.3+2.47.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
crates/altium-codec/src/altium_decode.rs+239−39
@@ -21,14 +21,39 @@ const UNITS_PER_MIL: f64 = 25.6; // Public entry points // --------------------------------------------------------------------------- +/// Which footprint an import paired with the symbol, and by which rule, so a+/// caller can report it (wiki #28: the pairing used to be silent).+#[derive(Debug, Clone, PartialEq)]+pub struct FootprintChoice {+    pub name: String,+    /// current | nominal | first-listed | only-footprint | same-name | first-in-library | override | none+    pub rule: &'static str,+    /// The PCB models the symbol itself lists, in record order.+    pub listed: Vec<String>,+}+ /// Decode a `.SchLib` + `.PcbLib` pair into the canonical part. Either may be /// `None` (e.g. footprint-only), but at least one is required. pub fn decode_altium(schlib: Option<&[u8]>, pcblib: Option<&[u8]>, mpn: &str) -> Result<AdomLbrPart> {-    let symbol = match schlib {+    decode_altium_with(schlib, pcblib, mpn, None).map(|(part, _)| part)+}++/// As [`decode_altium`], with an optional explicit footprint and the pairing+/// decision returned. The footprint follows the SYMBOL's own PCB model list:+/// the model marked current, else the IPC nominal `_N` variant, else the first+/// listed. When the symbol lists models and none is in the PcbLib, this fails+/// naming what it looked for; it never falls back to an unrelated footprint.+pub fn decode_altium_with(+    schlib: Option<&[u8]>,+    pcblib: Option<&[u8]>,+    mpn: &str,+    footprint_override: Option<&str>,+) -> Result<(AdomLbrPart, FootprintChoice)> {+    let (mut symbol, models) = match schlib {         // `mpn` selects the component in a MULTI-component library; empty keeps         // the first (the historical single-part behaviour).         Some(b) => decode_schlib_symbol(b, mpn)?,-        None => Symbol {+        None => (Symbol {             name: mpn.to_string(),             pins: vec![],             body: None,@@ -39,17 +64,144 @@ pub fn decode_altium(schlib: Option<&[u8]>, pcblib: Option<&[u8]>, mpn: &str) ->             parameters: vec![],             pin_numbers_hidden: None,             pin_names_hidden: None,-        },+        }, Vec::new()),     };-    // The chosen symbol names the footprint it links to (RECORD=45 ModelName);-    // pick THAT footprint out of the PcbLib, not just the first storage, so a-    // multi-footprint library returns the pad set that belongs to this part.-    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![], mask_margin_mm: None, paste_margin_mm: None },+    let listed: Vec<String> = models.iter().map(|(n, _)| n.clone()).collect();+    let (footprint, choice) = match pcblib {+        Some(b) => {+            let names = pcblib_footprint_names(b)?;+            let has = |want: &str| names.iter().any(|(st, full)| full.eq_ignore_ascii_case(want) || st.eq_ignore_ascii_case(want));+            let (pick, rule): (Option<String>, &'static str) = if let Some(o) = footprint_override {+                if !has(o) {+                    anyhow::bail!("footprint {o:?} is not in the PcbLib; it holds {} footprints: {}",+                        names.len(), preview(names.iter().map(|(_, f)| f.as_str())));+                }+                (Some(o.to_string()), "override")+            } else if !models.is_empty() {+                let present: Vec<&(String, bool)> = models.iter().filter(|(n, _)| has(n)).collect();+                let chosen = present.iter().find(|(_, cur)| *cur).map(|m| (m.0.clone(), "current"))+                    .or_else(|| present.iter().find(|(n, _)| n.to_ascii_uppercase().ends_with("_N")).map(|m| (m.0.clone(), "nominal")))+                    .or_else(|| present.first().map(|m| (m.0.clone(), "first-listed")));+                match chosen {+                    Some((n, r)) => (Some(n), r),+                    None => anyhow::bail!(+                        "component {:?} links footprint(s) {} and none of them is in the PcbLib ({} footprints: {}); refusing to pair it with an unrelated footprint. Pass --footprint <name> to choose one.",+                        symbol.name, listed.join(", "), names.len(), preview(names.iter().map(|(_, f)| f.as_str()))),+                }+            } else if schlib.is_none() {+                // A footprint-only import: the footprint --mpn names, else the first.+                match names.iter().find(|(st, f)| !mpn.is_empty() && (f.eq_ignore_ascii_case(mpn) || st.eq_ignore_ascii_case(mpn))) {+                    Some((_, f)) => (Some(f.clone()), "same-name"),+                    None => (names.first().map(|(_, f)| f.clone()), if names.len() == 1 { "only-footprint" } else { "first-in-library" }),+                }+            } else if names.len() == 1 {+                (names.first().map(|(_, f)| f.clone()), "only-footprint")+            } else if let Some((_, f)) = names.iter().find(|(st, f)| f.eq_ignore_ascii_case(&symbol.name) || st.eq_ignore_ascii_case(&symbol.name)) {+                (Some(f.clone()), "same-name")+            } else {+                anyhow::bail!(+                    "component {:?} names no PCB model and the PcbLib holds {} footprints ({}); refusing to guess. Pass --footprint <name> to choose one.",+                    symbol.name, names.len(), preview(names.iter().map(|(_, f)| f.as_str())));+            };+            let fp = decode_pcblib_footprint(b, pick.as_deref())?;+            let ch = FootprintChoice { name: fp.name.clone(), rule, listed };+            (fp, ch)+        }+        None => (+            Footprint { name: mpn.to_string(), pads: vec![], graphics: vec![], polys: vec![], mask_margin_mm: None, paste_margin_mm: None },+            FootprintChoice { name: String::new(), rule: "none", listed },+        ),     };-    build_part(symbol, footprint, mpn)+    symbol.footprint = if choice.name.is_empty() { symbol.footprint } else { Some(choice.name.clone()) };+    Ok((build_part(symbol, footprint, mpn)?, choice))+}++/// First few names for an error message, so a 300-entry library stays readable.+fn preview<'a>(names: impl Iterator<Item = &'a str>) -> String {+    let all: Vec<&str> = names.collect();+    if all.len() <= 8 {+        all.join(", ")+    } else {+        format!("{}, ... ({} more)", all[..8].join(", "), all.len() - 8)+    }+}++/// Every component in a `.SchLib`: (OLE storage name, full LibReference). The+/// storage name is truncated at 31 characters; the LibReference is the real+/// name and what a report should show.+pub fn list_schlib_components(bytes: &[u8]) -> Result<Vec<(String, String)>> {+    let streams = read_streams(bytes)?;+    let mut out = Vec::new();+    for (p, d) in streams.iter().filter(|(p, _)| is_component_data(p)) {+        let storage = p.trim_start_matches('/').split('/').next().unwrap_or("SYM").to_string();+        let libref = component_libref(d).unwrap_or_else(|| storage.clone());+        out.push((storage, libref));+    }+    Ok(out)+}++/// The two halves of an `.IntLib`, for callers that walk every component.+pub fn extract_intlib(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {+    crate::intlib::extract_intlib_libs(bytes)+}++fn is_component_data(path: &str) -> bool {+    path.ends_with("/Data") && !path.contains("FileHeader") && !path.contains("Storage")+}++/// LibReference from a component's RECORD=1 text record.+fn component_libref(data: &[u8]) -> Option<String> {+    let mut cur = 0usize;+    while cur + 4 <= data.len() {+        let len = le_u16(data, cur) as usize;+        let flags = le_u16(data, cur + 2);+        let payload = &data[cur + 4..(cur + 4 + len).min(data.len())];+        cur += 4 + len;+        if flags == 0 && payload.first() == Some(&b'|') {+            let txt = String::from_utf8_lossy(payload);+            let txt = txt.split('\0').next().unwrap_or("");+            if field(txt, "RECORD") == Some("1") {+                return field(txt, "LibReference").map(str::to_string);+            }+        }+    }+    None+}++/// Every footprint in a `.PcbLib`: (OLE storage name, full pattern name).+fn pcblib_footprint_names(bytes: &[u8]) -> Result<Vec<(String, String)>> {+    let streams = read_streams(bytes)?;+    Ok(streams+        .iter()+        .filter(|(p, _)| is_footprint_data(p))+        .map(|(p, d)| {+            let storage = p.trim_start_matches('/').split('/').next().unwrap_or("FP").to_string();+            let full = footprint_full_name(d);+            (storage.clone(), if full.is_empty() { storage } else { full })+        })+        .collect())+}++fn is_footprint_data(path: &str) -> bool {+    // only a top-level <storage>/Data; PrimitiveGuids/Data and+    // UniqueIDPrimitiveInformation/Data sit one level deeper in the same storage+    path.ends_with("/Data")+        && path.trim_start_matches('/').matches('/').count() == 1+        && !path.contains("/Library")+        && !path.contains("FileVersionInfo")+        && !path.contains("FileHeader")+}++/// A footprint's FULL name lives in its data stream (a >31-char name is+/// truncated in the OLE storage name but intact here).+fn footprint_full_name(data: &[u8]) -> String {+    let name_len = le_u32(data, 0) as usize;+    let blk = &data[4.min(data.len())..(4 + name_len).min(data.len())];+    if blk.is_empty() {+        return String::new();+    }+    let n = blk[0] as usize;+    String::from_utf8_lossy(&blk[1..(1 + n).min(blk.len())]).to_string() }  /// Decode a `.IntLib` (bundles both) into the canonical part.@@ -146,10 +298,14 @@ fn le_f64(b: &[u8], o: usize) -> f64 {     f64::from_le_bytes(a) } -/// Read a `|KEY=VAL|...` field out of a text record.+/// Read a `|KEY=VAL|...` field out of a text record. Keys are matched without+/// regard to case: Altium writes `ModelName` in some libraries and `MODELNAME`+/// in others (its own vendor IntLibs), and a case-sensitive lookup read every+/// model link in those as absent (wiki #28). fn field<'a>(txt: &'a str, key: &str) -> Option<&'a str> {-    let pat = format!("|{key}=");-    let i = txt.find(&pat)? + pat.len();+    let pat = format!("|{}=", key.to_ascii_lowercase());+    // ASCII lowercasing keeps byte offsets, so the index is valid in `txt`.+    let i = txt.to_ascii_lowercase().find(&pat)? + pat.len();     let rest = &txt[i..];     let end = rest.find('|').unwrap_or(rest.len());     Some(&rest[..end])@@ -159,34 +315,44 @@ fn field<'a>(txt: &'a str, key: &str) -> Option<&'a str> { // SchLib → Symbol // --------------------------------------------------------------------------- -fn decode_schlib_symbol(bytes: &[u8], want: &str) -> Result<Symbol> {+fn decode_schlib_symbol(bytes: &[u8], want: &str) -> Result<(Symbol, Vec<(String, bool)>)> {     let streams = read_streams(bytes)?;-    // Every component storage's Data (skip housekeeping streams). The storage-    // name equals the component's LibReference (the encoder keeps them in sync),-    // so it is what `want` (an MPN / component name) is matched against.-    let comps: Vec<(String, &Vec<u8>)> = streams+    // Every component storage's Data (skip housekeeping streams). `want` (an+    // MPN / component name) matches the storage name OR the full LibReference:+    // the storage is truncated at 31 characters.+    let comps: Vec<(String, String, &Vec<u8>)> = streams         .iter()-        .filter(|(p, _)| p.ends_with("/Data") && !p.contains("FileHeader") && !p.contains("Storage"))+        .filter(|(p, _)| is_component_data(p))         .map(|(p, d)| {             let comp = p.trim_start_matches('/').split('/').next().unwrap_or("SYM").to_string();-            (comp, d)+            let libref = component_libref(d).unwrap_or_else(|| comp.clone());+            (comp, libref, d)         })         .collect();     // Empty selector keeps the first component; a named selector picks that     // component (case-insensitive) or fails naming what IS in the library, so a     // multi-component library never silently returns the wrong part.     let (name, data) = if want.is_empty() {-        let (n, d) = comps.into_iter().next().context("SchLib has no component Data stream")?;+        let (n, _, d) = comps.into_iter().next().context("SchLib has no component Data stream")?;         (n, d.clone())     } else {-        match comps.iter().find(|(n, _)| n.eq_ignore_ascii_case(want)) {-            Some((n, d)) => (n.clone(), (*d).clone()),+        match comps.iter().find(|(n, lr, _)| n.eq_ignore_ascii_case(want) || lr.eq_ignore_ascii_case(want)) {+            Some((n, _, d)) => (n.clone(), (*d).clone()),             None => {-                let avail: Vec<&str> = comps.iter().map(|(n, _)| n.as_str()).collect();-                anyhow::bail!("SchLib has no component named {want:?}; available: {}", avail.join(", "));+                let w = want.to_ascii_lowercase();+                let near: Vec<&str> = comps.iter().map(|(_, lr, _)| lr.as_str())+                    .filter(|lr| { let l = lr.to_ascii_lowercase(); l.contains(&w) || w.contains(&l) })+                    .take(5).collect();+                let avail = preview(comps.iter().map(|(_, lr, _)| lr.as_str()));+                if near.is_empty() {+                    anyhow::bail!("SchLib has no component named {want:?}; available: {avail}");+                }+                anyhow::bail!("SchLib has no component named {want:?}; did you mean {}? (available: {avail})",+                    near.iter().map(|n| format!("{n:?}")).collect::<Vec<_>>().join(" or "));             }         }     };+    let mut models: Vec<(String, bool)> = Vec::new();      let mut pins = Vec::new();     let mut names_shown: Vec<bool> = Vec::new();@@ -247,15 +413,25 @@ fn decode_schlib_symbol(bytes: &[u8], want: &str) -> Result<Symbol> {                 }             }             "45" => {-                if let Some(m) = field(txt, "ModelName") {-                    footprint = Some(m.to_string());+                // Every implementation record, not just the last: a component+                // can list several PCB models (IPC density variants), and only+                // PCBLIB models are footprints (SIM / SI / PCB3DLIB are not).+                let is_pcb = field(txt, "ModelType").map(|t| t.eq_ignore_ascii_case("PCBLIB")).unwrap_or(true);+                if let (true, Some(m)) = (is_pcb, field(txt, "ModelName")) {+                    let current = field(txt, "IsCurrent").map(|v| v.eq_ignore_ascii_case("T")).unwrap_or(false);+                    if !models.iter().any(|(n, _)| n == m) {+                        models.push((m.to_string(), current));+                    }+                    if footprint.is_none() || current {+                        footprint = Some(m.to_string());+                    }                 }             }             _ => {}         }     } -    Ok(Symbol {+    Ok((Symbol {         name: lib_name,         pins,         body: None,@@ -266,7 +442,7 @@ fn decode_schlib_symbol(bytes: &[u8], want: &str) -> Result<Symbol> {         parameters,         pin_numbers_hidden: consensus_hidden(&nums_shown),         pin_names_hidden: consensus_hidden(&names_shown),-    })+    }, models)) }  /// Per-pin visibility folded to the model's symbol-level flag: Some(true) when@@ -290,6 +466,19 @@ fn decode_pin(p: &[u8]) -> Option<(Pin, bool, bool)> {     if p.len() < 26 {         return None;     }+    // Byte 12 is the pin DESCRIPTION's pascal-string length; every later field+    // sits `dl` bytes further on. Offsets below assume an empty description,+    // then take the shift. Byte 14 (+dl) is the electrical type (Altium: 0+    // input, 1 I/O, 2 output, 3 open collector, 4 passive, 5 HiZ, 6 open+    // emitter, 7 power), as written on all 16,195 pins of Altium's own megaAVR+    // library and as KiCad's importer reads it.+    let dl = p[12] as usize;+    if p.len() < 26 + dl {+        return None;+    }+    let p_all = p;+    let p = &shifted(p, dl);+    let electrical = crate::model::altium_electrical_to_kicad(p_all[14 + dl]).to_string();     // The length field [15:17]: bits 6..15 length (64-unit steps), bit 4     // ShowDesignator, bit 3 ShowName, bits 0..1 orientation (same model as the     // encoder, calibrated against an AD26-authored pin). Masking length with@@ -317,7 +506,7 @@ fn decode_pin(p: &[u8]) -> Option<(Pin, bool, bool)> {     let y = body_y - len_mil * dy;     // strings: name pstr then designator pstr @ 26     let mut j = 26usize;-    let name = read_pstr(p, &mut j);+    let name = crate::model::altium_overbar_to_kicad(&read_pstr(p, &mut j));     let number = read_pstr(p, &mut j);     Some((         Pin {@@ -327,12 +516,25 @@ fn decode_pin(p: &[u8]) -> Option<(Pin, bool, bool)> {             y,             length: Some(len_mil),             rotation: (q * 90) as u16,+            electrical_type: Some(electrical),         },         show_name,         show_designator,     )) } +/// The pin record with a non-empty description cut out, so the fixed offsets+/// (calibrated on description-less pins) apply to every pin.+fn shifted(p: &[u8], dl: usize) -> Vec<u8> {+    if dl == 0 {+        return p.to_vec();+    }+    let mut v = p[..13].to_vec();+    v[12] = 0;+    v.extend_from_slice(&p[13 + dl..]);+    v+}+ fn read_pstr(p: &[u8], j: &mut usize) -> String {     if *j >= p.len() {         return String::new();@@ -403,12 +605,7 @@ fn decode_pcblib_footprint(bytes: &[u8], want: Option<&str>) -> Result<Footprint     let streams = read_streams(bytes)?;     let comps: Vec<(String, &Vec<u8>)> = streams         .iter()-        .filter(|(p, _)| {-            p.ends_with("/Data")-                && !p.contains("/Library")-                && !p.contains("FileVersionInfo")-                && !p.contains("FileHeader")-        })+        .filter(|(p, _)| is_footprint_data(p))         .map(|(p, d)| {             let comp = p.trim_start_matches('/').split('/').next().unwrap_or("FP").to_string();             (comp, d)@@ -428,11 +625,14 @@ fn decode_pcblib_footprint(bytes: &[u8], want: Option<&str>) -> Result<Footprint         let n = blk[0] as usize;         String::from_utf8_lossy(&blk[1..(1 + n).min(blk.len())]).to_string()     };+    // A NAMED footprint must exist: falling back to the first storage is how a+    // 32-pin symbol shipped on a 100-pad footprint (wiki #28). Only an unnamed+    // request (a footprint-only import) takes the first.     let picked = match want.filter(|w| !w.is_empty()) {-        Some(w) => comps+        Some(w) => Some(comps             .iter()             .find(|(comp, d)| full_name(d).eq_ignore_ascii_case(w) || comp.eq_ignore_ascii_case(w))-            .or_else(|| comps.first()),+            .with_context(|| format!("PcbLib has no footprint named {w:?}"))?),         None => comps.first(),     };     let (name, data) = picked
crates/altium-codec/src/kicad.rs+2
@@ -161,6 +161,8 @@ fn import_symbol(text: &str, wanted: &str) -> Result<Symbol> {             y: (ymm / MM_PER_MIL).round() as i32,             length: len_mil,             rotation: ((rot as i32 % 360 + 360) % 360) as u16,+            // `(pin <electrical> <graphic> ...)`: the first atom is the type.+            electrical_type: p.atom_at(1).map(str::to_string),         });     } 
crates/altium-codec/src/kicad_export.rs+21−2
@@ -194,10 +194,10 @@ pub fn export_kicad_symbol_for(sym: &Symbol, ver: KicadVersion) -> String {         // literal tilde, so write the real empty string.         let pname = if pin.name == "~" { "" } else { pin.name.as_str() };         s.push_str(&format!(-            "\t\t\t(pin unspecified line\n\t\t\t\t(at {} {} {})\n\t\t\t\t(length {})\n\+            "\t\t\t(pin {} line\n\t\t\t\t(at {} {} {})\n\t\t\t\t(length {})\n\              \t\t\t\t(name \"{}\" (effects (font (size 1.27 1.27))))\n\              \t\t\t\t(number \"{}\" (effects (font (size 1.27 1.27))))\n\t\t\t)\n",-            m(pin.x as f64), m(pin.y as f64), pin.rotation, m(len),+            kicad_electrical(pin.electrical_type.as_deref()), m(pin.x as f64), m(pin.y as f64), pin.rotation, m(len),             esc(pname), esc(&pin.number)         ));     }@@ -349,6 +349,25 @@ pub fn export_kicad_footprint_for(fp: &Footprint, ver: KicadVersion) -> String {     s } +/// A pin's KiCad electrical keyword; anything unknown or absent is+/// `unspecified`, which is what every exported pin used to say.+fn kicad_electrical(t: Option<&str>) -> &'static str {+    match t.unwrap_or("") {+        "input" => "input",+        "output" => "output",+        "bidirectional" => "bidirectional",+        "tri_state" => "tri_state",+        "passive" => "passive",+        "free" => "free",+        "power_in" => "power_in",+        "power_out" => "power_out",+        "open_collector" => "open_collector",+        "open_emitter" => "open_emitter",+        "no_connect" => "no_connect",+        _ => "unspecified",+    }+}+ fn kicad_fp_layer(l: &FpLayer) -> &'static str {     match l {         FpLayer::Silk => "F.SilkS",
crates/altium-codec/src/lib.rs+6−5
@@ -16,7 +16,7 @@ pub mod pcblib; pub mod schlib; pub mod sexpr; -pub use altium_decode::{decode_altium, decode_intlib_part};+pub use altium_decode::{decode_altium, decode_altium_with, decode_intlib_part, extract_intlib, list_schlib_components, FootprintChoice}; pub use intlib::{decode_intlib, encode_intlib}; pub use kicad::{import_kicad, import_kicad_with_pin_map}; pub use kicad_export::{export_kicad_footprint, export_kicad_symbol};@@ -31,6 +31,7 @@ mod tests {     fn two_pin_symbol() -> Symbol {         Symbol {             name: "REF2".into(),+            labels: Vec::new(),             designator_prefix: Some("U".into()),             footprint: None,             body: None,@@ -41,8 +42,8 @@ mod tests {             // pins in mils (connection point); length 100, angle 0 → Altium             // stores the body end = -100 + 100 = 0.             pins: vec![-                Pin { number: "1".into(), name: "A".into(), x: -100, y: 100, length: Some(100), rotation: 0 },-                Pin { number: "2".into(), name: "B".into(), x: -100, y: -100, length: Some(100), rotation: 0 },+                Pin { number: "1".into(), name: "A".into(), x: -100, y: 100, length: Some(100), rotation: 0, electrical_type: None },+                Pin { number: "2".into(), name: "B".into(), x: -100, y: -100, length: Some(100), rotation: 0, electrical_type: None },             ],         }     }@@ -306,7 +307,7 @@ mod tests {         // model wiped the flags and Altium drew bare pin lines.         let mut three = two_pin_symbol();         three.name = "REF3".into();-        three.pins.push(Pin { number: "3".into(), name: "C".into(), x: 300, y: 0, length: Some(100), rotation: 180 });+        three.pins.push(Pin { number: "3".into(), name: "C".into(), x: 300, y: 0, length: Some(100), rotation: 180, electrical_type: None });         let bytes = encode_schlib(&[three.clone()]).expect("encode");         let lows: Vec<u8> = pin_field_low_bytes(&bytes);         assert_eq!(lows.len(), 3);@@ -390,7 +391,7 @@ mod tests {         let mut b = two_pin_symbol();         b.name = "PARTB".into();         b.footprint = Some("FPB".into());-        b.pins.push(Pin { number: "3".into(), name: "C".into(), x: -100, y: -300, length: Some(100), rotation: 0 });+        b.pins.push(Pin { number: "3".into(), name: "C".into(), x: -100, y: -300, length: Some(100), rotation: 0, electrical_type: None });         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, 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 };
crates/altium-codec/src/model.rs+112
@@ -97,6 +97,91 @@ pub struct Pin {     /// Orientation in degrees: 0 / 90 / 180 / 270.     #[serde(default)]     pub rotation: u16,+    /// Electrical type in KiCad's vocabulary: input, output, bidirectional,+    /// tri_state, passive, free, unspecified, power_in, power_out,+    /// open_collector, open_emitter, no_connect. None when the source did not+    /// say. ERC on a converted part depends on it (wiki #29).+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub electrical_type: Option<String>,+}++/// Altium binary pin electrical byte to the model's KiCad vocabulary. Altium+/// has no power_out / free / no_connect; 7 is its single power type.+pub fn altium_electrical_to_kicad(b: u8) -> &'static str {+    match b {+        0 => "input",+        1 => "bidirectional",+        2 => "output",+        3 => "open_collector",+        4 => "passive",+        5 => "tri_state",+        6 => "open_emitter",+        7 => "power_in",+        _ => "unspecified",+    }+}++/// The inverse for the Altium encoder. Types Altium cannot express become+/// passive (4), which is also what an untyped pin has always been written as.+pub fn kicad_electrical_to_altium(t: Option<&str>) -> u8 {+    match t.unwrap_or("") {+        "input" => 0,+        "bidirectional" => 1,+        "output" => 2,+        "open_collector" => 3,+        "tri_state" => 5,+        "open_emitter" => 6,+        "power_in" | "power_out" => 7,+        _ => 4,+    }+}++/// Altium's overbar notation (a backslash after each overbarred character:+/// `S\S\` is S-bar S-bar) to KiCad's `~{SS}` (wiki #30). A run of marked+/// characters becomes one group; a backslash with nothing before it is kept.+pub fn altium_overbar_to_kicad(name: &str) -> String {+    let chars: Vec<char> = name.chars().collect();+    let mut out = String::with_capacity(name.len() + 4);+    let mut i = 0;+    while i < chars.len() {+        if i + 1 < chars.len() && chars[i] != '\\' && chars[i + 1] == '\\' {+            out.push_str("~{");+            while i + 1 < chars.len() && chars[i] != '\\' && chars[i + 1] == '\\' {+                out.push(chars[i]);+                i += 2;+            }+            out.push('}');+        } else {+            out.push(chars[i]);+            i += 1;+        }+    }+    out+}++/// KiCad's `~{..}` overbar groups back to Altium's per-character backslashes.+pub fn kicad_overbar_to_altium(name: &str) -> String {+    let mut out = String::with_capacity(name.len() + 4);+    let mut rest = name;+    while let Some(start) = rest.find("~{") {+        out.push_str(&rest[..start]);+        let after = &rest[start + 2..];+        match after.find('}') {+            Some(end) => {+                for c in after[..end].chars() {+                    out.push(c);+                    out.push('\\');+                }+                rest = &after[end + 1..];+            }+            None => {+                out.push_str(&rest[start..]);+                rest = "";+            }+        }+    }+    out.push_str(rest);+    out }  /// A generic symbol graphic primitive (the symbol body / decorations). Units are@@ -356,3 +441,30 @@ pub struct ConnectEntry {     pub pin: String,     pub pad: String, }++#[cfg(test)]+mod pin_mapping_tests {+    use super::*;++    #[test]+    fn altium_electrical_types_round_trip_through_kicad_names() {+        for b in [0u8, 1, 2, 3, 4, 5, 6, 7] {+            let k = altium_electrical_to_kicad(b);+            assert_eq!(kicad_electrical_to_altium(Some(k)), b, "{b} -> {k}");+        }+        assert_eq!(kicad_electrical_to_altium(Some("power_out")), 7);+        assert_eq!(kicad_electrical_to_altium(None), 4, "untyped stays passive");+        assert_eq!(kicad_electrical_to_altium(Some("no_connect")), 4);+    }++    #[test]+    fn altium_overbars_round_trip_through_kicad_groups() {+        assert_eq!(altium_overbar_to_kicad(r"S\S\"), "~{SS}");+        assert_eq!(altium_overbar_to_kicad(r"PB0 (S\S\/PCINT0)"), "PB0 (~{SS}/PCINT0)");+        assert_eq!(altium_overbar_to_kicad(r"R\E\S\E\T\"), "~{RESET}");+        assert_eq!(altium_overbar_to_kicad("VCC"), "VCC");+        for n in ["~{SS}", "PB0 (~{SS}/PCINT0)", "~{RESET}", "VCC"] {+            assert_eq!(altium_overbar_to_kicad(&kicad_overbar_to_altium(n)), n);+        }+    }+}
crates/altium-codec/src/schlib.rs+8−6
@@ -607,11 +607,12 @@ fn line_width_of(width_mil: f64) -> u8 { /// Build the 36+ byte binary pin payload (before framing). fn encode_pin_payload(pin: &Pin, _idx: usize, show_name: bool, show_designator: bool) -> Result<Vec<u8>> {     let mut p = PIN_PREFIX.to_vec();-    // conglom byte[14] = 0x04 → a plain pin (no edge-symbol triangle), rotation-    // bits (0..1) = 0. Matches Altium's own pins (build_symbol ground truth):-    // orientation lives ONLY in the length field, so conglom's rotation bits must-    // be 0 — leaving them nonzero (was 0x07 = rot 3) misplaced the pin number.-    p[14] = 0x04;+    // Byte 14 is the pin's ELECTRICAL type (0 input, 1 I/O, 2 output, 3 open+    // collector, 4 passive, 5 HiZ, 6 open emitter, 7 power), as Altium writes+    // it on every pin of its own vendor libraries. Orientation lives in the+    // length field below, never here. An untyped pin stays passive (4), which+    // is what every pin was written as before types were carried.+    p[14] = crate::model::kicad_electrical_to_altium(pin.electrical_type.as_deref());     // PIN ORIENTATION lives in the LOW 2 BITS of the length field [15:17] — NOT     // the conglom byte. Calibrated against Altium AD26 + LM555: byte-orient 0 =     // points left, 2 = points right. KiCad angle and Altium orientation are 180°@@ -665,7 +666,8 @@ fn encode_pin_payload(pin: &Pin, _idx: usize, show_name: bool, show_designator:     let uy = (body_y as f64 / 10.0).round() as i32 as i16;     p[20..22].copy_from_slice(&uy.to_le_bytes());     // strings: Name, Designator, "", "|&|"-    push_pstr(&mut p, &pin.name)?;+    // KiCad's ~{..} overbar groups become Altium's per-character backslashes.+    push_pstr(&mut p, &crate::model::kicad_overbar_to_altium(&pin.name))?;     push_pstr(&mut p, &pin.number)?;     push_pstr(&mut p, "")?;     push_pstr(&mut p, "|&|")?;
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.46.3",+  "version": "2.47.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+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.46.3",+  "version": "2.47.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": {},
src/main.rs+192−11
@@ -353,6 +353,18 @@ enum Commands {         /// Output adom-lbr JSON path (default: <mpn>.adom-lbr.json)         #[arg(long)]         out: Option<PathBuf>,+        /// Import EVERY component in the library: one adom-lbr JSON per+        /// component into --out-dir, each paired with its own footprint, and a+        /// JSON report of written, skipped (with reason) and mismatched parts.+        #[arg(long)]+        all: bool,+        /// Output directory for --all (default: .)+        #[arg(long, default_value = ".")]+        out_dir: PathBuf,+        /// Pair the part with this footprint instead of following the symbol's+        /// own PCB model list (single-part imports only)+        #[arg(long)]+        footprint: Option<String>,     },     /// Install skill + bash completions     Install,@@ -839,6 +851,15 @@ fn pin_length_keyword(len: f64) -> &'static str { /// such name, so every emitted pin name passes through this. Illegal runs /// collapse to one underscore, trimmed at the ends. fn eagle_safe_name(name: &str) -> String {+    // KiCad's ~{..} overbar groups become EAGLE's !..! before the charset pass+    // (which keeps '!'); otherwise "PB0 (~{SS}/PCINT0)" lost its bar to '_'.+    let converted;+    let name = if name.contains("~{") {+        converted = kicad_overbar_to_eagle(name);+        converted.as_str()+    } else {+        name+    };     let mut out = String::with_capacity(name.len());     let mut last_us = false;     for ch in name.chars() {@@ -850,6 +871,23 @@ fn eagle_safe_name(name: &str) -> String {     if trimmed.is_empty() { "P".to_string() } else { trimmed } } +/// `~{SS}` to `!SS!`; an unclosed group bars the rest of the name, which is+/// what EAGLE's unclosed `!` does too.+fn kicad_overbar_to_eagle(name: &str) -> String {+    let mut out = String::with_capacity(name.len() + 2);+    let mut rest = name;+    while let Some(start) = rest.find("~{") {+        out.push_str(&rest[..start]);+        let after = &rest[start + 2..];+        match after.find('}') {+            Some(end) => { out.push('!'); out.push_str(&after[..end]); out.push('!'); rest = &after[end + 1..]; }+            None => { out.push('!'); out.push_str(after); rest = ""; }+        }+    }+    out.push_str(rest);+    out+}+ fn dedup_pin_names(pins: &[KicadPin]) -> Vec<String> {     // Effective EAGLE pin name: when the KiCad pin name is empty (common on     // mechanical / 1-pin parts: `(number "1")(name "")`), fall back to the pad@@ -3140,6 +3178,40 @@ fn altium_lost_feature(part: &str, fp: &altium_codec::Footprint) -> Option<LostF     }) } +/// Pin/pad agreement for an imported part. A symbol whose pins do not all land+/// on pads, or a footprint with far more pads than the symbol has pins, is the+/// shape of a wrong pairing (a 32-pin symbol on a 100-pad footprint, wiki #28).+/// A thermal pad or a few mounting pads stay under the threshold.+fn pin_pad_mismatch(part: &altium_codec::AdomLbrPart) -> Option<serde_json::Value> {+    let pads: std::collections::HashSet<&str> = part.footprint.pads.iter().map(|p| p.number.as_str()).collect();+    let pins: std::collections::HashSet<&str> = part.symbol.pins.iter().map(|p| p.number.as_str()).collect();+    let missing: Vec<&str> = {+        let mut v: Vec<&str> = pins.iter().copied().filter(|n| !pads.contains(n)).collect();+        v.sort();+        v+    };+    let unconnected = pads.iter().filter(|n| !pins.contains(*n)).count();+    let many_spare = unconnected > 4 && unconnected * 2 > pins.len();+    if part.footprint.pads.is_empty() || (missing.is_empty() && !many_spare) {+        return None;+    }+    Some(serde_json::json!({+        "part": part.mpn, "footprint": part.footprint.name,+        "pins": part.symbol.pins.len(), "pads": part.footprint.pads.len(),+        "pinsWithoutPad": missing, "padsWithoutPin": unconnected,+    }))+}++/// A component name as a file name: path separators and shell-hostile+/// characters become `_`.+fn part_file_stem(name: &str) -> String {+    let s: String = name.chars()+        .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+') { c } else { '_' })+        .collect();+    let t = s.trim_matches('_').to_string();+    if t.is_empty() { "part".into() } else { t }+}+ 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@@ -3961,32 +4033,109 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or             written_hint(&outp, merged.len(), &sha);         } -        Commands::ImportAltium { file, pcblib, schlib, mpn, out } => {+        Commands::ImportAltium { file, pcblib, schlib, mpn, out, all, out_dir, footprint } => {+            use serde_json::json;             let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();             let read = |p: &std::path::Path| -> Vec<u8> {                 fs::read(p).unwrap_or_else(|e| { eprintln!("ERROR: read {}: {e}", p.display()); process::exit(1); })             };-            let part = if ext == "intlib" {-                altium_codec::decode_intlib_part(&read(&file), &mpn)+            // Resolve the two halves once, whatever the input shape.+            let (sch, pcb): (Option<Vec<u8>>, Option<Vec<u8>>) = if ext == "intlib" {+                match altium_codec::extract_intlib(&read(&file)) {+                    Ok((s, p)) => (Some(s), Some(p)),+                    Err(e) => { eprintln!("ERROR: {e:#}"); process::exit(1); }+                }             } else if ext == "pcblib" {-                let sch = schlib.as_ref().map(|p| read(p));-                altium_codec::decode_altium(sch.as_deref(), Some(&read(&file)), &mpn)+                (schlib.as_ref().map(|p| read(p)), Some(read(&file)))             } else {                 // treat as .SchLib; auto-find the .PcbLib next to it if not given                 let pcb_path = pcblib.or_else(|| {-                    let cand = file.with_extension("PcbLib");-                    cand.exists().then_some(cand)+                    ["PcbLib", "pcblib", "PCBLIB", "PcbLIB"].iter()+                        .map(|e| file.with_extension(e))+                        .find(|c| c.exists())                 });-                let pcb = pcb_path.as_ref().map(|p| read(p));-                altium_codec::decode_altium(Some(&read(&file)), pcb.as_deref(), &mpn)+                (Some(read(&file)), pcb_path.as_ref().map(|p| read(p)))+            };++            if all {+                let Some(sch) = sch.as_deref() else {+                    eprintln!("ERROR: --all needs symbols: give a .SchLib, an .IntLib, or --schlib beside the .PcbLib");+                    process::exit(2);+                };+                let comps = altium_codec::list_schlib_components(sch)+                    .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+                let mut written = Vec::new();+                let mut skipped = Vec::new();+                let mut mismatched = Vec::new();+                let mut used_stems: std::collections::HashSet<String> = Default::default();+                for (storage, libref) in &comps {+                    let (mut part, choice) = match altium_codec::decode_altium_with(Some(sch), pcb.as_deref(), storage, None) {+                        Ok(v) => v,+                        Err(e) => { skipped.push(json!({"part": libref, "reason": format!("{e:#}")})); continue; }+                    };+                    part.mpn = libref.clone();+                    if let Some(m) = pin_pad_mismatch(&part) {+                        if cli.strict {+                            skipped.push(json!({"part": libref,+                                "reason": format!("refused under --strict: {} pins on {} pads of {}", part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.name)}));+                            mismatched.push(m);+                            continue;+                        }+                        mismatched.push(m);+                    }+                    let mut stem = part_file_stem(libref);+                    let base = stem.clone();+                    let mut k = 2;+                    while !used_stems.insert(stem.to_ascii_lowercase()) { stem = format!("{base}-{k}"); k += 1; }+                    let path = out_dir.join(format!("{stem}.adom-lbr.json"));+                    let body = serde_json::to_string_pretty(&part).unwrap();+                    let sha = write_verified(&path, body.as_bytes());+                    written.push(json!({+                        "part": libref, "file": path.display().to_string(), "bytes": body.len(), "sha256": sha,+                        "pins": part.symbol.pins.len(), "pads": part.footprint.pads.len(),+                        "footprint": choice.name, "footprintRule": choice.rule, "footprintsListed": choice.listed,+                    }));+                }+                let partial = !skipped.is_empty();+                eprintln!("{} of {} components written, {} skipped, {} with a pin/pad mismatch",+                    written.len(), comps.len(), skipped.len(), mismatched.len());+                println!("{}", serde_json::to_string_pretty(&json!({+                    "status": if written.is_empty() { "error" } else if partial { "partial" } else { "ok" },+                    "library": file.display().to_string(),+                    "components": comps.len(),+                    "written": written,+                    "skipped": skipped,+                    "mismatched": mismatched,+                    "_hint": "one adom-lbr JSON per component, each paired with the footprint its own symbol lists (current, else the IPC nominal _N, else the first listed); skipped[] names every part not written and why, never silent. mismatched[] flags pin/pad disagreement (pins with no pad, or many more pads than pins): those parts are written and should be reviewed; under --strict they are skipped instead."+                })).unwrap());+                if written.is_empty() || (cli.strict && partial) {+                    process::exit(2);+                }+                return;+            }++            let (part, choice) = altium_codec::decode_altium_with(sch.as_deref(), pcb.as_deref(), &mpn, footprint.as_deref())+                .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });+            if let Some(m) = pin_pad_mismatch(&part) {+                let missing = m["pinsWithoutPad"].as_array().map(|a| a.len()).unwrap_or(0);+                eprintln!("WARN: [pin-pad-mismatch] {} pins on {} pads of {} ({} pins with no pad, {} pads with no pin); check the pairing, or choose one with --footprint <name>.",+                    part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.name, missing,+                    m["padsWithoutPin"].as_u64().unwrap_or(0));+                if cli.strict {+                    eprintln!("ERROR: [PIN_PAD_MISMATCH] nothing written: refused under --strict");+                    process::exit(2);+                }             }-            .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); });             let name = if part.mpn.is_empty() { "part".to_string() } else { part.mpn.clone() };-            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{name}.adom-lbr.json")));+            let outp = out.unwrap_or_else(|| PathBuf::from(format!("{}.adom-lbr.json", part_file_stem(&name))));             let json_out = serde_json::to_string_pretty(&part).unwrap();             let sha = write_verified(&outp, json_out.as_bytes());             println!("OK: wrote {} — {} pins, {} pads, {} footprint graphics",                 outp.display(), part.symbol.pins.len(), part.footprint.pads.len(), part.footprint.graphics.len());+            if !choice.name.is_empty() {+                let listed = if choice.listed.is_empty() { String::new() } else { format!(" (symbol lists {})", choice.listed.join(", ")) };+                println!("Hint: footprint {} chosen by rule {}{listed}", choice.name, choice.rule);+            }             written_hint(&outp, json_out.len(), &sha);         } @@ -4322,6 +4471,38 @@ fn cmd_embed(dir: &std::path::Path, out: Option<PathBuf>) -> Result<(), String> mod lost_feature_tests {     use super::*; +    #[test]+    fn kicad_overbars_become_eagle_bangs_and_survive_the_charset() {+        assert_eq!(kicad_overbar_to_eagle("PB0 (~{SS}/PCINT0)"), "PB0 (!SS!/PCINT0)");+        assert_eq!(kicad_overbar_to_eagle("~{RESET}"), "!RESET!");+        assert_eq!(kicad_overbar_to_eagle("~{CS"), "!CS");+        assert_eq!(eagle_safe_name("~{RESET}"), "!RESET!");+        assert_eq!(eagle_safe_name("PB0 (~{SS}/PCINT0)"), "PB0_!SS!/PCINT0");+        assert_eq!(eagle_safe_name("VCC"), "VCC", "names without overbars are untouched");+    }++    #[test]+    fn pin_pad_mismatch_flags_a_wrong_pairing_but_not_a_thermal_pad() {+        let sym = |n: usize| -> String {+            let pins: String = (1..=n).map(|i| format!(+                "(pin bidirectional line (at -5.08 {} 0) (length 2.54) (name \"P{i}\" (effects (font (size 1.27 1.27)))) (number \"{i}\" (effects (font (size 1.27 1.27)))))",+                -(i as f64) * 2.54)).collect();+            format!("(kicad_symbol_lib (version 20231120) (generator \"t\") (symbol \"S\" (property \"Reference\" \"U\" (at 0 0 0)) (symbol \"S_1_1\" {pins})))")+        };+        let fp = |n: usize| -> String {+            let pads: String = (1..=n).map(|i| format!("(pad \"{i}\" smd rect (at {} 0) (size 0.3 1) (layers \"F.Cu\"))", i as f64 * 0.5)).collect();+            format!("(footprint \"F\" (layer \"F.Cu\") {pads})")+        };+        let wrong = altium_codec::import_kicad(&sym(32), &fp(100), "W").unwrap();+        let m = pin_pad_mismatch(&wrong).expect("32 pins on 100 pads is flagged");+        assert_eq!(m["padsWithoutPin"], 68);+        let thermal = altium_codec::import_kicad(&sym(32), &fp(33), "T").unwrap();+        assert!(pin_pad_mismatch(&thermal).is_none(), "one exposed pad is not a mismatch");+        let short = altium_codec::import_kicad(&sym(8), &fp(6), "S").unwrap();+        let m = pin_pad_mismatch(&short).expect("pins with no pad are flagged");+        assert_eq!(m["pinsWithoutPad"], serde_json::json!(["7", "8"]));+    }+     #[test]     fn eagle_collector_dedupes_across_parses_and_drains() {         // the parser runs several times per part; the collector keeps one