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.47.0: import-altium footprint pairing, pin types, overbars, --all (#28 #29 #30 #31); GH 65d3f4c
13 files changed
+585−68
Cargo.lock+1−1Cargo.toml+1−1VERSION+1−1bin/adom-lbrcrates/altium-codec/src/altium_decode.rs+239−39crates/altium-codec/src/kicad.rs+2crates/altium-codec/src/kicad_export.rs+21−2crates/altium-codec/src/lib.rs+6−5crates/altium-codec/src/model.rs+112crates/altium-codec/src/schlib.rs+8−6package.json+1−1page.json+1−1src/main.rs+192−11Cargo.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