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.34.0: Altium pin labels render, and assemble-altium builds one library from N parts
11 files changed
+323−40
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" } ]-}+}
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();