← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.33.0"+version = "2.34.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.33.0"+version = "2.34.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.33.0+2.34.0
bin/adom-lbr
⋯ 1 unchanged line ⋯
crates/altium-codec/src/altium_decode.rs+45−15
@@ -166,6 +166,8 @@ fn decode_schlib_symbol(bytes: &[u8]) -> Result<Symbol> {         .context("SchLib has no component Data stream")?;      let mut pins = Vec::new();+    let mut names_shown: Vec<bool> = Vec::new();+    let mut nums_shown: Vec<bool> = Vec::new();     let mut graphics = Vec::new();     let mut parameters = Vec::new();     let mut designator_prefix = Some("U".to_string());@@ -180,8 +182,10 @@ fn decode_schlib_symbol(bytes: &[u8]) -> Result<Symbol> {         cur += 4 + len;         if flags == 0x0100 {             // binary pin record-            if let Some(p) = decode_pin(payload) {+            if let Some((p, shown_name, shown_num)) = decode_pin(payload) {                 pins.push(p);+                names_shown.push(shown_name);+                nums_shown.push(shown_num);             }             continue;         }@@ -236,20 +240,42 @@ fn decode_schlib_symbol(bytes: &[u8]) -> Result<Symbol> {         designator_prefix,         footprint,         parameters,-        pin_numbers_hidden: None,-        pin_names_hidden: None,+        pin_numbers_hidden: consensus_hidden(&nums_shown),+        pin_names_hidden: consensus_hidden(&names_shown),     }) } -/// Decode a binary pin record → neutral [`Pin`] (connection-point mils).-fn decode_pin(p: &[u8]) -> Option<Pin> {+/// Per-pin visibility folded to the model's symbol-level flag: Some(true) when+/// every pin hides the label, Some(false) when every pin shows it, None on a+/// mixed or empty set (the exporter then applies its passive default).+fn consensus_hidden(shown: &[bool]) -> Option<bool> {+    if shown.is_empty() {+        None+    } else if shown.iter().all(|s| !*s) {+        Some(true)+    } else if shown.iter().all(|s| *s) {+        Some(false)+    } else {+        None+    }+}++/// Decode a binary pin record → neutral [`Pin`] plus its label-visibility+/// flags (connection-point mils).+fn decode_pin(p: &[u8]) -> Option<(Pin, bool, bool)> {     if p.len() < 26 {         return None;     }-    // orientation packed in low 2 bits of the length field [15:17]+    // 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+    // 0xFFFC instead of 0xFFC0 used to fold the flag bits into the length,+    // which read a 300 mil pin as 302.     let field15 = le_u16(p, 15);     let aorient = (field15 & 0x3) as u32;-    let length_units = (field15 & 0xFFFC) as f64;+    let show_name = field15 & 0x08 != 0;+    let show_designator = field15 & 0x10 != 0;+    let length_units = (field15 & 0xFFC0) as f64;     let len_mil = (length_units / UNITS_PER_MIL).round() as i32;     // body-end location: X = i24 @[17:20] (÷25.6/mil), Y = i16 @[20:22] (×10 mil)     let body_x = (le_i24(p, 17) as f64 / UNITS_PER_MIL).round() as i32;@@ -269,14 +295,18 @@ fn decode_pin(p: &[u8]) -> Option<Pin> {     let mut j = 26usize;     let name = read_pstr(p, &mut j);     let number = read_pstr(p, &mut j);-    Some(Pin {-        number: if number.is_empty() { "?".into() } else { number },-        name,-        x,-        y,-        length: Some(len_mil),-        rotation: (q * 90) as u16,-    })+    Some((+        Pin {+            number: if number.is_empty() { "?".into() } else { number },+            name,+            x,+            y,+            length: Some(len_mil),+            rotation: (q * 90) as u16,+        },+        show_name,+        show_designator,+    )) }  fn read_pstr(p: &[u8], j: &mut usize) -> String {
crates/altium-codec/src/lib.rs+97−1
@@ -21,7 +21,7 @@ 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}; pub use model::{AdomLbrPart, BodyRect, ConnectEntry, Footprint, FpGraphic, FpLayer, Graphic, Pad, Parameter, Pin, Symbol};-pub use pcblib::{decode_pcblib, encode_pcblib};+pub use pcblib::{decode_pcblib, encode_pcblib, encode_pcblib_many}; pub use schlib::{add_symbols_to_schlib, decode_schlib_first, encode_schlib};  #[cfg(test)]@@ -47,6 +47,102 @@ mod tests {         }     } +    #[test]+    fn long_footprint_names_truncate_the_storage_but_keep_the_pattern_name() {+        // OLE2 caps directory names at 31 UTF-16 units and the cfb crate+        // writes longer ones into entries Altium and olefile silently skip:+        // a 37-char KiCad name made its footprint vanish while the TOC still+        // listed it. The storage truncates; the pattern name inside Data+        // stays full, which is what readers resolve.+        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![] };+        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())+            .map(|e| e.name().to_string()).collect();+        for t in &tops {+            assert!(t.encode_utf16().count() <= 31, "storage name over the OLE2 cap: {t:?}");+        }+        // the two truncations must not collide+        let fps: Vec<String> = tops.iter().filter(|t| t.starts_with("JST")).cloned().collect();+        assert_eq!(fps.len(), 2, "both long-named footprints must survive: {tops:?}");+        // and each Data block still carries its FULL pattern name+        use std::io::Read;+        let mut full_names = vec![];+        for t in &fps {+            let mut d = Vec::new();+            comp.open_stream(format!("/{t}/Data")).unwrap().read_to_end(&mut d).unwrap();+            let n = d[4] as usize;+            full_names.push(String::from_utf8_lossy(&d[5..5 + n]).to_string());+        }+        full_names.sort();+        assert_eq!(full_names, vec![long.to_string(), format!("{long}2")]);+    }++    #[test]+    fn pin_label_visibility_rides_the_length_field_flags() {+        // Ground truth: a pin AD26 itself authored (adom-probe, 300 mil,+        // labels shown) carries field low byte 0x3a = 0x20 (every genuine+        // pin) | 0x10 ShowDesignator | 0x08 ShowName | orientation. The old+        // 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 });+        let bytes = encode_schlib(&[three.clone()]).expect("encode");+        let lows: Vec<u8> = pin_field_low_bytes(&bytes);+        assert_eq!(lows.len(), 3);+        for b in &lows {+            assert_eq!(b & 0x20, 0x20, "genuine-pin flag must be set: {b:#04x}");+            assert_eq!(b & 0x18, 0x18, "a 3-pin symbol defaults to visible labels: {b:#04x}");+        }+        // a 2-pin passive defaults to hidden labels (KiCad R/C/L convention)+        let bytes2 = encode_schlib(&[two_pin_symbol()]).expect("encode");+        for b in pin_field_low_bytes(&bytes2) {+            assert_eq!(b & 0x18, 0x00, "a 2-pin passive defaults to hidden labels: {b:#04x}");+            assert_eq!(b & 0x20, 0x20);+        }+        // explicit visibility overrides the passive default, and survives a+        // decode: the flags round-trip to the symbol-level model fields+        let mut shown2 = two_pin_symbol();+        shown2.pin_names_hidden = Some(false);+        shown2.pin_numbers_hidden = Some(false);+        let bytes3 = encode_schlib(&[shown2]).expect("encode");+        let part = decode_altium(Some(&bytes3), None, "REF2").expect("decode");+        assert_eq!(part.symbol.pin_names_hidden, Some(false));+        assert_eq!(part.symbol.pin_numbers_hidden, Some(false));+        let part2 = decode_altium(Some(&bytes2), None, "REF2").expect("decode");+        assert_eq!(part2.symbol.pin_names_hidden, Some(true));+        assert_eq!(part2.symbol.pin_numbers_hidden, Some(true));+    }++    /// Low byte of the length field of every framed binary pin record.+    fn pin_field_low_bytes(schlib: &[u8]) -> Vec<u8> {+        use std::io::Read;+        let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(schlib.to_vec())).unwrap();+        let paths: Vec<String> = comp.walk().filter(|e| e.is_stream())+            .map(|e| e.path().to_string_lossy().to_string())+            .filter(|p| p.ends_with("/Data") && !p.contains("FileHeader") && !p.contains("Storage"))+            .collect();+        let mut out = vec![];+        for path in paths {+            let mut d = Vec::new();+            comp.open_stream(&path).unwrap().read_to_end(&mut d).unwrap();+            let mut cur = 0usize;+            while cur + 4 <= d.len() {+                let len = u16::from_le_bytes([d[cur], d[cur + 1]]) as usize;+                let flags = u16::from_le_bytes([d[cur + 2], d[cur + 3]]);+                if flags == 0x0100 && len > 16 {+                    out.push(d[cur + 4 + 15]);+                }+                cur += 4 + len;+            }+        }+        out+    }+     #[test]     fn schlib_roundtrip_pins() {         let sym = two_pin_symbol();
crates/altium-codec/src/pcblib.rs+36−3
@@ -191,9 +191,18 @@ pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> {         write_stream(&mut comp, path, data, &mut made_storages)?;     } -    // 2) Write each component's storages.+    // 2) Write each component's storages. OLE2 directory names cap at 31+    //    UTF-16 units, and the cfb crate writes a longer name WITHOUT error+    //    into an entry Altium and olefile both silently skip: a 37-char+    //    KiCad footprint name made the whole footprint vanish from the+    //    library while the TOC still listed it. Genuine vendor libraries+    //    truncate the STORAGE name and keep the real pattern name in the+    //    footprint's own Data block, which is what readers resolve; do the+    //    same, keeping truncated names unique.+    let mut taken: std::collections::BTreeSet<String> = Default::default();     for fp in fps {-        write_component(&mut comp, fp, &pad_data_tpl, &mut made_storages)?;+        let storage = storage_name_31(&fp.name, &mut taken);+        write_component(&mut comp, fp, &storage, &pad_data_tpl, &mut made_storages)?;     }      comp.flush()?;@@ -204,11 +213,15 @@ pub fn encode_pcblib_many(fps: &[Footprint]) -> Result<Vec<u8>> { fn write_component<F: Read + Write + std::io::Seek>(     comp: &mut cfb::CompoundFile<F>,     fp: &Footprint,+    storage: &str,     pad_data_tpl: &[u8],     made: &mut std::collections::BTreeSet<String>, ) -> Result<()> {     let n = fp.pads.len();-    let base = format!("/{}", &fp.name);+    // The storage name may be a 31-char truncation; every name INSIDE the+    // streams (Data name block, Parameters, TOC, Library list) stays the full+    // pattern name, which is what Altium actually displays and links against.+    let base = format!("/{storage}");      // <comp>/Data — name block + pad records.     let mut data = Vec::new();@@ -500,6 +513,26 @@ fn harvest_pad_data_block(tpl: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>> { }  /// Create any missing parent storages, then write `path` as a stream.+/// A CFBF-legal storage name: illegal chars replaced, truncated to 31 UTF-16+/// units, made unique among `taken` by rewriting the tail.+fn storage_name_31(name: &str, taken: &mut std::collections::BTreeSet<String>) -> String {+    let cleaned: String = name+        .chars()+        .map(|c| if matches!(c, '/' | '\\' | ':' | '!') || (c as u32) < 0x20 { '_' } else { c })+        .take(31)+        .collect();+    let base = if cleaned.is_empty() { "Footprint".to_string() } else { cleaned };+    let mut cand = base.clone();+    let mut i = 2;+    while !taken.insert(cand.clone()) {+        let suffix = format!("@{i}");+        let keep: String = base.chars().take(31 - suffix.chars().count()).collect();+        cand = format!("{keep}{suffix}");+        i += 1;+    }+    cand+}+ fn write_stream<F: Read + Write + std::io::Seek>(     comp: &mut cfb::CompoundFile<F>,     path: &str,
crates/altium-codec/src/schlib.rs+26−9
@@ -354,9 +354,14 @@ fn build_component_data(sym: &Symbol, lib_ref: &str) -> Result<Vec<u8>> {         }     } -    // Pin records (binary).+    // Pin records (binary). Label visibility is symbol-level in the model;+    // unknown defaults to hidden for 2-pin passives (KiCad's own R/C/L+    // convention, mirrored from the KiCad exporter) and shown otherwise.+    let passive = sym.pins.len() <= 2;+    let show_name = !sym.pin_names_hidden.unwrap_or(passive);+    let show_designator = !sym.pin_numbers_hidden.unwrap_or(passive);     for (i, pin) in sym.pins.iter().enumerate() {-        out.extend(frame_pin(encode_pin_payload(pin, i)?));+        out.extend(frame_pin(encode_pin_payload(pin, i, show_name, show_designator)?));     }      // RECORD=34 — Designator label.@@ -498,7 +503,7 @@ fn emit_graphic(g: &Graphic, name: &str, gi: usize) -> Result<Vec<u8>> { }  /// Build the 36+ byte binary pin payload (before framing).-fn encode_pin_payload(pin: &Pin, _idx: usize) -> Result<Vec<u8>> {+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):@@ -514,13 +519,25 @@ fn encode_pin_payload(pin: &Pin, _idx: usize) -> Result<Vec<u8>> {         .length         .map(|l| (l as f64 * UNITS_PER_MIL).round() as i32)         .unwrap_or(DEFAULT_PIN_LEN as i32);-    // The field is u16 with orientation packed in bits 0..1 — a longer pin-    // would silently wrap BOTH the length and the orientation. validate_symbols-    // rejects these up front; this is the backstop.-    if !(0..=0xFFFC).contains(&length) {-        bail!("pin {:?} length {} units exceeds the u16 field (max 2559 mil)", pin.number, length);+    // Altium pin lengths sit on a 64-unit (2.5 mil) grid: the field's low bits+    // are FLAGS, not length (below), so snap to the grid first.+    let length = ((length + 32) / 64) * 64;+    if !(0..=0xFFC0).contains(&length) {+        bail!("pin {:?} length {} units exceeds the length field (max 2557 mil)", pin.number, length);     }-    let field = ((length as u16) & 0xFFFC) | aorient; // length in bits 2..15, orient in 0..1+    // The length field [15:17] is length AND flags, calibrated against a pin+    // AD26 itself authored (adom-probe, 300 mil, labels shown: 0x1e3a): bits+    // 6..15 length in 64-unit steps, bit 5 (0x20) set on every genuine pin,+    // bit 4 (0x10) ShowDesignator, bit 3 (0x08) ShowName, bits 0..1+    // orientation. The previous model put length in bits 2..15, which reads+    // the same length numerically (grid lengths keep bits 2..5 zero) while+    // wiping every flag — so Altium hid the name and designator on every+    // exported pin, and they rendered as bare lines.+    let field = ((length as u16) & 0xFFC0)+        | 0x20+        | if show_name { 0x08 } else { 0 }+        | if show_designator { 0x10 } else { 0 }+        | aorient;     p[15..17].copy_from_slice(&field.to_le_bytes());     // Altium's pin Location is the BODY end (where the pin meets the symbol),     // not the connection point. The neutral model stores the connection point
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.33.0",+  "version": "2.34.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.33.0",+  "version": "2.34.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"     }   ]-}\ No newline at end of file+}
src/main.rs+107
@@ -281,6 +281,21 @@ enum Commands {         #[arg(long, default_value = ".")]         out_dir: PathBuf,     },+    /// Assemble MANY adom-lbr JSONs into ONE native Altium library pair:+    /// <out>.SchLib holding every symbol (components named by MPN) and+    /// <out>.PcbLib holding every footprint, content-deduped so N parts+    /// sharing a land pattern share one footprint. Prints a JSON report+    /// (schlib, pcblib, components, footprints, deduped[], renamed[],+    /// skipped[]) for machine callers; skips are always NAMED, never silent,+    /// and a partial library still ships. Exit 2 only when nothing assembled.+    AssembleAltium {+        /// Output base path; writes <base>.SchLib and <base>.PcbLib+        #[arg(short, long)]+        out: PathBuf,+        /// Paths to adom-lbr JSONs (one per part)+        #[arg(required = true)]+        parts: Vec<PathBuf>,+    },     /// Convert an adom-lbr JSON into one self-contained Altium .IntLib.     ExportIntlib {         /// Path to the adom-lbr JSON@@ -3451,6 +3466,98 @@ The geometry is fine but a BOM cannot order this part - pass --part-metadata or                 sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len());         } +        Commands::AssembleAltium { out, parts } => {+            use serde_json::{json, Value};+            use std::collections::{BTreeMap, BTreeSet};+            let mut symbols: Vec<altium_codec::Symbol> = Vec::new();+            let mut fps: Vec<altium_codec::Footprint> = Vec::new();+            // Content key (footprint minus its name) -> the shipped footprint+            // name, so identical land patterns collapse to ONE footprint (and+            // ONE 3D binding downstream), mirroring the EAGLE assemble and the+            // 2.21.0 package-sharing rule.+            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();+            let mut comp_names: BTreeSet<String> = BTreeSet::new();+            let mut renamed: Vec<Value> = Vec::new();+            let mut skipped: Vec<Value> = Vec::new();+            let unique = |taken: &BTreeSet<String>, want: &str| -> String {+                if !taken.contains(want) { return want.to_string(); }+                (2..).map(|i| format!("{want}@{i}")).find(|c| !taken.contains(c)).unwrap()+            };+            for path in &parts {+                let raw = match fs::read_to_string(path) {+                    Ok(r) => r,+                    Err(e) => { skipped.push(json!({"part": path.display().to_string(), "reason": format!("read: {e}")})); continue; }+                };+                let p: altium_codec::AdomLbrPart = match serde_json::from_str(&raw) {+                    Ok(p) => p,+                    Err(e) => { skipped.push(json!({"part": path.display().to_string(), "reason": format!("parse: {e}")})); continue; }+                };+                let mut sym = p.symbol.clone();+                seed_parameters(&mut sym, &p);+                // 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.+                let want = if p.mpn.is_empty() { sym.name.clone() } else { p.mpn.clone() };+                let comp_name = unique(&comp_names, &want);+                if comp_name != want {+                    renamed.push(json!({"kind": "component", "from": want, "to": comp_name, "part": p.mpn}));+                }+                comp_names.insert(comp_name.clone());+                sym.name = comp_name;+                // Footprint content-dedup: same geometry = one footprint.+                let mut content_probe = p.footprint.clone();+                content_probe.name = String::new();+                let key = serde_json::to_string(&content_probe).unwrap_or_default();+                let fp_name = if let Some(existing) = fp_by_content.get(&key) {+                    fp_shared.entry(existing.clone()).or_default().push(p.mpn.clone());+                    existing.clone()+                } else {+                    let want_fp = if p.footprint.name.is_empty() { format!("{}-fp", p.mpn) } else { p.footprint.name.clone() };+                    let n = unique(&fp_names, &want_fp);+                    if n != want_fp {+                        renamed.push(json!({"kind": "footprint", "from": want_fp, "to": n, "part": p.mpn}));+                    }+                    fp_names.insert(n.clone());+                    fp_by_content.insert(key, n.clone());+                    fp_shared.insert(n.clone(), vec![p.mpn.clone()]);+                    let mut f = p.footprint.clone();+                    f.name = n.clone();+                    fps.push(f);+                    n+                };+                sym.footprint = Some(fp_name);+                symbols.push(sym);+            }+            if symbols.is_empty() {+                println!("{}", json!({"status": "error", "error": "no parts assembled", "skipped": skipped}));+                process::exit(2);+            }+            let sch_bytes = altium_codec::encode_schlib(&symbols)+                .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode SchLib: {e:#}"), "skipped": skipped})); process::exit(2); });+            let pcb_bytes = altium_codec::encode_pcblib_many(&fps)+                .unwrap_or_else(|e| { println!("{}", json!({"status":"error","error": format!("encode PcbLib: {e:#}"), "skipped": skipped})); process::exit(2); });+            let base = out.with_extension("");+            let sp = base.with_extension("SchLib");+            let pp = base.with_extension("PcbLib");+            if let Err(e) = fs::write(&sp, &sch_bytes) { println!("{}", json!({"status":"error","error": format!("write {}: {e}", sp.display())})); process::exit(2); }+            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();+            println!("{}", serde_json::to_string_pretty(&json!({+                "status": "ok",+                "schlib": sp.display().to_string(),+                "pcblib": pp.display().to_string(),+                "components": symbols.len(),+                "footprints": fps.len(),+                "deduped": deduped,+                "renamed": renamed,+                "skipped": skipped,+                "_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);             let mut symbol = p.symbol.clone();