← All Pull Requests

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

Merged opened by Colby Knox 2026-09-02

Fixes #21. Base: 2.34.0 (wiki 02a48d93 / GH 0d45d9e). Surgical, decode-path only: no generate/assemble output changes, so no git-wiki re-vendor is needed.

What

import-altium --mpn now SELECTS the named component from a multi-component .SchLib/.PcbLib instead of always returning the first storage:

  • decode_schlib_symbol takes the selector: case-insensitive match on the component storage name (which the encoder keeps equal to LibReference). An empty selector keeps the historical first-component behaviour; an unknown name fails listing every component the library holds, never silently wrong.
  • The footprint now follows the CHOSEN symbol's model link (RECORD=45 ModelName) rather than taking the first footprint storage. Matching is on the footprint's FULL name from its data stream first (a >31-char name is truncated in the OLE storage name but intact in the stream), then the storage name; a link the PcbLib lacks falls back to the first footprint rather than failing the part.

Proof

New unit test multi_component_decode_selects_by_name pins: named selection, model-link following, case-insensitivity, empty-selector compatibility, and the available-components error. All 17 altium-codec tests pass.

Against the real 7-part Cloud Library bundle (the #21 repro):

TPS73733DCQR: pins=6  footprint=SOT-223-6 pads=6
QCPL-7847-500E: pins=8 footprint=DIP-8_W7.62mm pads=8
AT7456E: pins=28 footprint=TSSOP-28-1EP_4.4x9.7mm_P0.65mm_EP2.85x6.7mm pads=35
TX6143: pins=5  footprint=SOT-89-5 pads=5
--mpn NOPE -> ERROR: SchLib has no component named "NOPE"; available: AT7456E, CN3125, QCPL-7847-500E, TP4056, TPS73733DCQR, TX4140, TX6143

Before the fix every one of those returned the identical wrong part (28 pins, DIP-8).

Diff Skip to comments

--- a/crates/altium-codec/src/altium_decode.rs+++ b/crates/altium-codec/src/altium_decode.rs@@ -1,548 +1,594 @@⋯ 24 unchanged lines ⋯ /// `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![],⋯ 6 unchanged lines ⋯             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)⋯ 106 unchanged lines ⋯ // 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();⋯ 204 unchanged lines ⋯ // 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")⋯ 3 unchanged lines ⋯         })         .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>⋯ 151 unchanged lines ⋯         Some(FpGraphic::Arc { layer, cx, cy, r, start: -end, end: -start, width })     } }+--- a/crates/altium-codec/src/lib.rs+++ b/crates/altium-codec/src/lib.rs@@ -1,277 +1,316 @@⋯ 160 unchanged lines ⋯         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.⋯ 108 unchanged lines ⋯         assert!((w - 1.016).abs() < 1e-3, "w was {w}");     } }+

Comments

No comments yet.

Log in to comment.