← Commit history

Merge PR #73: import-altium: select the named component from a multi-component library (#21)

Colby Knox ·6490a27fc2 ·1mo ago ·parent 8b7ecd0
2 files changed +95−12
crates/altium-codec/src/altium_decode.rs+57−12
@@ -25,7 +25,9 @@ const UNITS_PER_MIL: f64 = 25.6; /// `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 {-        Some(b) => decode_schlib_symbol(b)?,+        // `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 {             name: mpn.to_string(),             pins: vec![],@@ -38,8 +40,12 @@ pub fn decode_altium(schlib: Option<&[u8]>, pcblib: Option<&[u8]>, mpn: &str) ->             pin_names_hidden: None,         },     };+    // 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)?,+        Some(b) => decode_pcblib_footprint(b, want_fp.as_deref())?,         None => Footprint { name: mpn.to_string(), pads: vec![], graphics: vec![] },     };     build_part(symbol, footprint, mpn)@@ -152,18 +158,34 @@ fn field<'a>(txt: &'a str, key: &str) -> Option<&'a str> { // SchLib → Symbol // --------------------------------------------------------------------------- -fn decode_schlib_symbol(bytes: &[u8]) -> Result<Symbol> {+fn decode_schlib_symbol(bytes: &[u8], want: &str) -> Result<Symbol> {     let streams = read_streams(bytes)?;-    // first component storage's Data (skip housekeeping streams)-    let (name, data) = streams+    // 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         .iter()         .filter(|(p, _)| p.ends_with("/Data") && !p.contains("FileHeader") && !p.contains("Storage"))         .map(|(p, d)| {             let comp = p.trim_start_matches('/').split('/').next().unwrap_or("SYM").to_string();-            (comp, d.clone())+            (comp, d)         })-        .next()-        .context("SchLib has no component Data stream")?;+        .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")?;+        (n, d.clone())+    } else {+        match comps.iter().find(|(n, _)| n.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 mut pins = Vec::new();     let mut names_shown: Vec<bool> = Vec::new();@@ -374,9 +396,9 @@ fn decode_sym_graphic(txt: &str, rec: &str) -> Option<Graphic> { // PcbLib → Footprint (pads + silk/courtyard/fab graphics) // --------------------------------------------------------------------------- -fn decode_pcblib_footprint(bytes: &[u8]) -> Result<Footprint> {+fn decode_pcblib_footprint(bytes: &[u8], want: Option<&str>) -> Result<Footprint> {     let streams = read_streams(bytes)?;-    let (name, data) = streams+    let comps: Vec<(String, &Vec<u8>)> = streams         .iter()         .filter(|(p, _)| {             p.ends_with("/Data")@@ -386,9 +408,32 @@ fn decode_pcblib_footprint(bytes: &[u8]) -> Result<Footprint> {         })         .map(|(p, d)| {             let comp = p.trim_start_matches('/').split('/').next().unwrap_or("FP").to_string();-            (comp, d.clone())+            (comp, d)         })-        .next()+        .collect();+    // A footprint's FULL name lives in its data stream (a >31-char name is+    // truncated in the OLE storage name but intact here), so match the symbol's+    // linked footprint on the full name first, then the storage name. A symbol+    // may link a footprint the PcbLib lacks, so fall back to the first storage+    // rather than failing the whole part.+    let full_name = |data: &[u8]| -> String {+        let name_len = le_u32(data, 0) as usize;+        let blk = &data[4..(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()+    };+    let picked = match want.filter(|w| !w.is_empty()) {+        Some(w) => comps+            .iter()+            .find(|(comp, d)| full_name(d).eq_ignore_ascii_case(w) || comp.eq_ignore_ascii_case(w))+            .or_else(|| comps.first()),+        None => comps.first(),+    };+    let (name, data) = picked+        .map(|(n, d)| (n.clone(), (*d).clone()))         .context("PcbLib has no component Data stream")?;      // component name block: <u32 len><name>
crates/altium-codec/src/lib.rs+38
@@ -161,6 +161,44 @@ mod tests {         assert_eq!(pins[0]["y"], 10);     } +    /// Multi-component selection (adom-lbr#21): `mpn` picks the NAMED component+    /// out of a multi-part library, and the footprint follows that SYMBOL's+    /// model link, never just the first storage. An unknown name fails naming+    /// what IS in the library; an empty selector keeps the historical+    /// first-component behaviour.+    #[test]+    fn multi_component_decode_selects_by_name() {+        let mut a = two_pin_symbol();+        a.name = "PARTA".into();+        a.footprint = Some("FPA".into());+        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 });+        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![] };+        let fpb = Footprint { name: "FPB".into(), pads: vec![pad("1", 0.0), pad("2", 2.0)], graphics: vec![] };+        let pcb = encode_pcblib_many(&[fpa, fpb]).expect("encode pcblib");++        let pb = decode_altium(Some(&sch), Some(&pcb), "PARTB").expect("decode PARTB");+        assert_eq!(pb.symbol.name, "PARTB");+        assert_eq!(pb.symbol.pins.len(), 3);+        assert_eq!(pb.footprint.name, "FPB", "footprint follows the symbol's model link");+        assert_eq!(pb.footprint.pads.len(), 2);++        let pa = decode_altium(Some(&sch), Some(&pcb), "parta").expect("selection is case-insensitive");+        assert_eq!(pa.symbol.pins.len(), 2);+        assert_eq!(pa.footprint.name, "FPA");+        assert_eq!(pa.footprint.pads.len(), 1);++        let first = decode_altium(Some(&sch), Some(&pcb), "").expect("empty selector keeps first");+        assert_eq!(first.symbol.name, "PARTA");++        let err = decode_altium(Some(&sch), Some(&pcb), "NOPE").unwrap_err().to_string();+        assert!(err.contains("PARTA") && err.contains("PARTB"), "error lists available components: {err}");+    }+     /// Pins the house layer convention: Silk and Fab both encode onto Mech13     /// (69) and decode back as Fab (deliberately lossy — see     /// `pcblib::layer_enc`); Courtyard and Copper round-trip exactly.