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.
Preserve component pin and pad semantics and report export failures #6
Manufacturer TMF8829 imports lost electrical pin roles and converted extended rounded-rectangle pads into circles. The library viewer then ignored pad rotation, and KiCad/Altium export could report success after a failed file write. This change preserves pin types and pad corner ratio, decodes the real variable-length binary pin fields, requires the selected symbol's linked footprint, and makes file failures explicit.
The viewer uses the canonical electrical roles, pad rotation/corner geometry and readable vertical pin labels. A component-fidelity skill documents exact-part selection, manufacturer STEP extraction/placement, output read-back and native validation.
Validation: 26 codec tests pass, including independent manufacturer pin bytes, variable TEXT offsets, extended pad shape decoding, role/radius round trips and missing-footprint rejection. Full workspace tests pass (50 tests total). A real CLI export to a missing directory creates and writes both files; a destination that is a regular file produces a nonzero exit and explicit error. Original modified files were compared against upstream e8fb0f13601303cafe2cc590dd4e2e6391a306b0 before this proposal. Live webview review exposed the renderer issues and is continuing.
Draft limitations: native KiCad/Fusion/Altium parity is pending. Group text and field placement remain unsupported in the canonical record, and original STEP placement is not yet round-trip verified. Altium has fewer electrical roles than KiCad; the existing mapping collapses unspecified/free/no-connect to passive and power_out to power, requiring explicit fidelity reporting before release. Fractional pin coordinates are rejected instead of silently rounded until PinFrac is implemented. No installed shared binary is replaced.
Diff Skip to comments (4)
@@ -1,593 +1,634 @@⋯ 13 unchanged lines ⋯ /// Altium base unit = 1/10000 mil = 0.00000254 mm (PCB coordinates). const ALTIUM_UNIT_MM: f64 = 0.00000254;-/// Schematic binary coordinate units per mil (pin X).-const UNITS_PER_MIL: f64 = 25.6; // --------------------------------------------------------------------------- // Public entry points⋯ 109 unchanged lines ⋯ fn le_i16(b: &[u8], o: usize) -> i16 { le_u16(b, o) as i16 }-fn le_i24(b: &[u8], o: usize) -> i32 {- let v = (*b.get(o).unwrap_or(&0) as i32)- | ((*b.get(o + 1).unwrap_or(&0) as i32) << 8)- | ((*b.get(o + 2).unwrap_or(&0) as i32) << 16);- (v << 8) >> 8 // sign-extend 24-bit-} fn le_f64(b: &[u8], o: usize) -> f64 { let mut a = [0u8; 8]; for i in 0..8 {⋯ 145 unchanged lines ⋯ if p.len() < 26 { return None; }- // 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 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;- let body_y = (le_i16(p, 20) as i32) * 10;- // kicad angle q from aorient = (q + 2) % 4- let q = ((aorient + 2) % 4) as i32;- let (dx, dy) = match q % 4 {- 0 => (1, 0),- 1 => (0, 1),- 2 => (-1, 0),- _ => (0, -1),- };- // connection = body_end - length * dir+ // See KiCad's handleBinaryPinLambda: the text Pascal string at byte 12+ // makes subsequent offsets variable. Electrical and conglomerate are+ // separate bytes; length and XY are signed 16-bit values in 10 mil units.+ let mut j = 12usize;+ let text_len = *p.get(j)? as usize;+ j += 1 + text_len;+ if j + 13 > p.len() { return None; }+ j += 1; // unknown byte+ let electrical_type = Some(match p[j] {+ 0 => "input", 1 => "bidirectional", 2 => "output", 3 => "open_collector",+ 4 => "passive", 5 => "tri_state", 6 => "open_emitter", 7 => "power_in",+ _ => "unspecified",+ }.to_string());+ j += 1;+ let flags = p[j]; j += 1;+ let show_name = flags & 0x08 != 0;+ let show_designator = flags & 0x10 != 0;+ let len_mil = le_i16(p, j) as i32 * 10; j += 2;+ let body_x = le_i16(p, j) as i32 * 10; j += 2;+ let body_y = le_i16(p, j) as i32 * 10; j += 2;+ j += 4; // color+ let q = ((flags & 3) as i32 + 2) % 4;+ let (dx, dy) = match q { 0 => (1, 0), 1 => (0, 1), 2 => (-1, 0), _ => (0, -1) }; let x = body_x - len_mil * dx; 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 number = read_pstr(p, &mut j); Some((⋯ 2 unchanged lines ⋯ name, x, y,+ electrical_type, length: Some(len_mil), rotation: (q * 90) as u16, },⋯ 73 unchanged lines ⋯ .iter() .filter(|(p, _)| { p.ends_with("/Data")+ && p.trim_start_matches('/').split('/').count() == 2 && !p.contains("/Library") && !p.contains("FileVersionInfo") && !p.contains("FileHeader")⋯ 6 unchanged lines ⋯ // 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.+ // may link a footprint the PcbLib lacks; fail instead of assigning another part. let full_name = |data: &[u8]| -> String {+ if data.len() < 5 { return String::new(); } let name_len = le_u32(data, 0) as usize; let blk = &data[4..(4 + name_len).min(data.len())]; if blk.is_empty() {⋯ 5 unchanged lines ⋯ 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()),+ .find(|(comp, d)| full_name(d).eq_ignore_ascii_case(w) || comp.eq_ignore_ascii_case(w)), None => comps.first(), }; let (name, data) = picked .map(|(n, d)| (n.clone(), (*d).clone()))- .context("PcbLib has no component Data stream")?;+ .with_context(|| format!("PcbLib has no matching component Data stream for {:?}", want))?; // component name block: <u32 len><name> let name_len = le_u32(&data, 0) as usize;⋯ 91 unchanged lines ⋯ let w = le_u32(pd, 21) as f64 * ALTIUM_UNIT_MM; let h = le_u32(pd, 25) as f64 * ALTIUM_UNIT_MM; let hole = le_u32(pd, 45) as f64 * ALTIUM_UNIT_MM;- let shape = match pd[49] {- 1 => "circle",- 9 => "roundrect",+ // Extended pad-stack: 29 inner XY sizes/shapes, slot/hole fields,+ // 32 hole offsets, then alternate shapes at 532 and corner percentages at 564.+ // Altium encodes roundrect as base CIRCLE + alternate shape 9.+ let extra = blocks.get(5).copied().unwrap_or(&[]);+ let is_roundrect = pd[49] == 9 || (pd[49] == 1 && extra.len() >= 596 && extra[532] == 9);+ let roundrect_rratio = if is_roundrect && extra.len() >= 596 {+ Some(extra[564] as f64 / 200.0)+ } else { None };+ let shape = if is_roundrect { "roundrect" } else { match pd[49] {+ 1 if le_u32(pd, 21) == le_u32(pd, 25) => "circle",+ 1 => "oval", _ => "rect",- };+ }}; let rotation = if pd.len() >= 60 { le_f64(pd, 52) } else { 0.0 }; Some(Pad { number,⋯ 4 unchanged lines ⋯ drill_mm: hole, rotation, shape: shape.to_string(),+ roundrect_rratio, // The plated flag byte in the Altium pad record isn't calibrated yet — // unknown, so leave None (treated as plated downstream). plated: None,⋯ 38 unchanged lines ⋯ Some(FpGraphic::Arc { layer, cx, cy, r, start: -end, end: -start, width }) } }++#[cfg(test)]+mod fidelity_tests {+ use super::*;++ #[test]+ fn manufacturer_pin_bytes_preserve_type_and_signed_coordinates() {+ // TMF8829 manufacturer's VDDC record prefix, not produced by this codec.+ let mut p = vec![0x02,0,0,0,0,1,0,0,0,0,0,0,0,1,7,0x38,0x14,0,0x5a,0,0xf1,0xff,0,0,0,0];+ p.extend_from_slice(b"\x04VDDC\x011");+ let (pin, name, number) = decode_pin(&p).unwrap();+ assert_eq!(pin.electrical_type.as_deref(), Some("power_in"));+ assert_eq!((pin.x, pin.y, pin.length, pin.rotation), (1100,-150,Some(200),180));+ assert!(name && number);+ // Non-empty TEXT shifts all subsequent fields; reader must follow pstring.+ p.splice(12..13, [3,b'a',b'b',b'c']);+ let (shifted, _, _) = decode_pin(&p).unwrap();+ assert_eq!((shifted.x, shifted.y, shifted.number.as_str()), (1100,-150,"1"));+ }++ #[test]+ fn altium_round_pad_requires_extended_shape_before_circle_or_oval() {+ let mut pd = vec![0u8; 60];+ pd[21..25].copy_from_slice(&100000u32.to_le_bytes());+ pd[25..29].copy_from_slice(&200000u32.to_le_bytes());+ pd[49] = 1;+ let empty = &[][..];+ let mut extra = vec![0u8;596];+ extra[532] = 9; extra[564] = 20;+ let blocks = [&b"\x011"[..], empty, empty, empty, &pd, &extra];+ let pad = decode_pad(&blocks).unwrap();+ assert_eq!(pad.shape, "roundrect");+ assert_eq!(pad.roundrect_rratio, Some(0.1));+ let blocks = [&b"\x011"[..], empty, empty, empty, &pd, empty];+ assert_eq!(decode_pad(&blocks).unwrap().shape, "oval");+ pd[25..29].copy_from_slice(&100000u32.to_le_bytes());+ let blocks = [&b"\x011"[..], empty, empty, empty, &pd, empty];+ assert_eq!(decode_pad(&blocks).unwrap().shape, "circle");+ }+}+@@ -1,634 +1,637 @@⋯ 156 unchanged lines ⋯ pins.push(Pin { number, name: pname,+ electrical_type: p.atom_at(1).map(str::to_owned), x: (xmm / MM_PER_MIL).round() as i32, y: (ymm / MM_PER_MIL).round() as i32, length: len_mil,⋯ 206 unchanged lines ⋯ drill_mm: if is_th { drill } else { 0.0 }, rotation: rot, shape: kshape.to_string(),+ roundrect_rratio: p.child("roundrect_rratio").and_then(|r| r.nums().first().copied()), plated: if is_th { Some(ptype != "np_thru_hole") } else { None }, }); }⋯ 257 unchanged lines ⋯ assert!(e.contains("none is named"), "reports the miss: {e}"); } }+@@ -1,426 +1,433 @@⋯ 183 unchanged lines ⋯ s.push_str(&format!("\t\t(symbol \"{name}_1_1\"\n")); for pin in &sym.pins { let len = pin.length.unwrap_or(200) as f64;+ let electrical = match pin.electrical_type.as_deref().unwrap_or("unspecified") {+ t @ ("input" | "output" | "bidirectional" | "tri_state" | "passive" |+ "free" | "unspecified" | "power_in" | "power_out" | "open_collector" |+ "open_emitter" | "no_connect") => t,+ _ => "unspecified",+ }; // legacy sources used "~" for an unnamed pin; in KiCad 10 that's a // 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 {electrical} 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),⋯ 117 unchanged lines ⋯ } s.push_str(&format!(" (layers {layers})")); if shape == "roundrect" {- s.push_str(" (roundrect_rratio 0.25)");+ s.push_str(&format!(" (roundrect_rratio {})", n(pad.roundrect_rratio.unwrap_or(0.25)))); } s.push_str(")\n"); }⋯ 105 unchanged lines ⋯ ((minx + maxx) / 2.0) * MM_PER_MIL, ) }+@@ -1,421 +1,460 @@⋯ 40 unchanged lines ⋯ // 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 { electrical_type: None, number: "1".into(), name: "A".into(), x: -100, y: 100, length: Some(100), rotation: 0 },+ Pin { electrical_type: None, number: "2".into(), name: "B".into(), x: -100, y: -100, length: Some(100), rotation: 0 }, ], } }⋯ 64 unchanged lines ⋯ // the footprint stream referencing the MODELID. use crate::model::{Footprint, Pad}; use std::io::Read;- let pad = Pad { number: "1".into(), x_mm: 0.0, y_mm: 0.0, w_mm: 1.0, h_mm: 1.0,+ let pad = Pad { roundrect_rratio: None, 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 fp1 = Footprint { name: "WITHMODEL".into(), pads: vec![pad.clone()], graphics: vec![], polys: vec![] }; let fp2 = Footprint { name: "BARE".into(), pads: vec![pad], graphics: vec![], polys: vec![] };⋯ 40 unchanged lines ⋯ // 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,+ let pad = Pad { roundrect_rratio: None, 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![], polys: vec![] }; let bytes = encode_pcblib_many(&[mk(long), mk(&format!("{long}2"))]).expect("encode");⋯ 27 unchanged lines ⋯ // 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 { electrical_type: None, 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);⋯ 77 unchanged lines ⋯ 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 { electrical_type: None, 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 pad = |n: &str, x: f64| Pad { roundrect_rratio: None, 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![], polys: vec![] }; let fpb = Footprint { name: "FPB".into(), pads: vec![pad("1", 0.0), pad("2", 2.0)], graphics: vec![], polys: vec![] }; let pcb = encode_pcblib_many(&[fpa, fpb]).expect("encode pcblib");⋯ 25 unchanged lines ⋯ let line = |layer: FpLayer, y: f64| FpGraphic::Line { layer, x1: -1.0, y1: y, x2: 1.0, y2: y, width: 0.1 }; let fp = Footprint { name: "LAYERFP".into(),- pads: vec![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 }],+ pads: vec![Pad { roundrect_rratio: None, 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 }], polys: vec![], graphics: vec![ line(FpLayer::Silk, 1.0),⋯ 81 unchanged lines ⋯ let fp = Footprint { name: "REFFP".into(), pads: vec![- Pad { number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },- Pad { number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },+ Pad { roundrect_rratio: None, number: "1".into(), x_mm: -0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None },+ Pad { roundrect_rratio: None, number: "2".into(), x_mm: 0.762, y_mm: 0.0, w_mm: 1.016, h_mm: 1.27, drill_mm: 0.0, rotation: 0.0, shape: "rect".into(), plated: None }, ], graphics: vec![], polys: vec![],⋯ 10 unchanged lines ⋯ let w = pads[0]["size"][0].as_f64().unwrap(); assert!((w - 1.016).abs() < 1e-3, "w was {w}"); }+ #[test]+ fn pin_electrical_roles_survive_kicad_and_altium() {+ for role in ["input","output","bidirectional","tri_state","passive","power_in","open_collector","open_emitter"] {+ let mut sym = two_pin_symbol();+ for pin in &mut sym.pins { pin.electrical_type = Some(role.into()); }+ let ks = export_kicad_symbol(&sym);+ let restored = import_kicad(&ks, r#"(footprint "REF2")"#, "REF2").unwrap().symbol;+ assert!(restored.pins.iter().all(|p| p.electrical_type.as_deref() == Some(role)));+ let data = encode_schlib(&[sym]).unwrap();+ let part = decode_altium(Some(&data), None, "REF2").unwrap();+ assert!(part.symbol.pins.iter().all(|p| p.electrical_type.as_deref() == Some(role)));+ }+ }++ #[test]+ fn roundrect_radius_survives_kicad_and_altium() {+ let km = r#"(footprint "ROUND" (layer "F.Cu")+ (pad "1" smd roundrect (at -1 2 90) (size 0.35 0.53) (layers "F.Cu") (roundrect_rratio 0.1)))"#;+ let fp = crate::kicad::import_footprint(km).unwrap();+ assert_eq!(fp.pads[0].roundrect_rratio,Some(0.1));+ assert!(export_kicad_footprint(&fp).contains("roundrect_rratio 0.1"));+ let data = encode_pcblib(&fp).unwrap();+ let part = decode_altium(None, Some(&data), "ROUND").unwrap();+ let pad = &part.footprint.pads[0];+ assert_eq!(pad.shape,"roundrect");+ assert_eq!(pad.roundrect_rratio,Some(0.1));+ }++ #[test]+ fn linked_footprint_absence_must_not_select_another_component() {+ let mut sym = two_pin_symbol();+ sym.footprint = Some("MISSING".into());+ let sl = encode_schlib(&[sym]).unwrap();+ let fp = crate::kicad::import_footprint(r#"(footprint "UNRELATED" (layer "F.Cu"))"#).unwrap();+ let pl = encode_pcblib(&fp).unwrap();+ assert!(decode_altium(Some(&sl),Some(&pl),"REF2").is_err());+ }+ }+@@ -1,306 +1,313 @@⋯ 62 unchanged lines ⋯ pub number: String, /// Pin name / label (e.g. "VDD", "GND"). pub name: String,+ /// Electrical role, using KiCad pin type names. Missing legacy values stay unspecified.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub electrical_type: Option<String>, /// Location.X in mils. pub x: i32, /// Location.Y in mils.⋯ 191 unchanged lines ⋯ /// "rect" | "circle" | "oval" | "roundrect". #[serde(default = "default_shape")] pub shape: String,+ /// Roundrect corner radius divided by the smaller pad dimension (0..=0.5).+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub roundrect_rratio: Option<f64>, /// Through-hole plating. `None`/`Some(true)` → plated (normal TH pad); /// `Some(false)` → NPTH (KiCad `np_thru_hole`, e.g. a mounting hole). /// Only meaningful when `drill_mm > 0`. NOTE: the Altium binary encoder⋯ 38 unchanged lines ⋯ pub pin: String, pub pad: String, }+@@ -1,794 +1,821 @@⋯ 197 unchanged lines ⋯ if fps.len() != models.len() { bail!("models slice ({}) must run parallel to footprints ({})", models.len(), fps.len()); }+ for fp in fps {+ for pad in &fp.pads {+ if let Some(ratio) = pad.roundrect_rratio {+ if !ratio.is_finite() || !(0.0..=0.5).contains(&ratio) {+ bail!("pad {:?} has invalid roundrect radius ratio {}", pad.number, ratio);+ }+ if pad.shape == "roundrect" && (ratio * 200.0 - (ratio * 200.0).round()).abs() > 1e-8 {+ bail!("pad {:?} radius ratio {} is not representable in Altium's whole-percent corner field", pad.number, ratio);+ }+ }+ }+ } // Model ids and checksums are computed once and used in BOTH the model // store record and the footprint's body record; the id is deterministic // (hash of name + payload) so identical inputs encode identically.⋯ 310 unchanged lines ⋯ pd[52..60].copy_from_slice(&pad.rotation.to_le_bytes()); } + let mut extended = Vec::new();+ if pad.shape == "roundrect" {+ // See KiCad APAD6_SIZE_AND_SHAPE: alternate shape 9 requires a+ // complete 596-byte pad-stack record; base shape remains CIRCLE.+ extended.resize(596, 0);+ for i in 0..29 {+ extended[i*4..i*4+4].copy_from_slice(&xs.to_le_bytes());+ extended[116+i*4..120+i*4].copy_from_slice(&ys.to_le_bytes());+ extended[232+i] = 1;+ }+ let percent = (pad.roundrect_rratio.unwrap_or(0.25) * 200.0).round() as u8;+ extended[532..564].fill(9);+ extended[564..596].fill(percent);+ } let blocks: [&[u8]; 6] = [ &designator, &[0x00], &[0x04, 0x7c, 0x26, 0x7c, 0x30], &[0x00], &pd,- &[],+ &extended, ]; let mut rec = vec![0x02u8]; for b in blocks {⋯ 6 unchanged lines ⋯ fn shape_byte(shape: &str) -> u8 { match shape { "circle" | "oval" => 1,- "roundrect" => 9,+ "roundrect" => 1, _ => 2, } }⋯ 252 unchanged lines ⋯ pub fn decode_pcblib(bytes: &[u8]) -> Result<serde_json::Value> { altium_pcblib::parse_altium_pcblib_bytes(bytes).context("altium-pcblib decode") }+@@ -1,635 +1,589 @@⋯ 21 unchanged lines ⋯ use std::collections::BTreeSet; use std::io::{Cursor, Read, Seek, Write}; -/// The 26-byte fixed prefix of a binary pin record (tag .. just before the-/// Name pstring), captured from an Altium-generated reference pin. The encoder-/// patches conglomerate@14, [email protected], [email protected], [email protected] and leaves the-/// rest (which Altium fills with defaults) intact for guaranteed acceptance.+/// Binary-pin fixed prefix for an empty TEXT string. Offsets follow KiCad's+/// Altium library reader: electrical@14, conglomerate@15, i16 length/XY@16/18/20. const PIN_PREFIX: [u8; 26] = [ 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,- 0x04, // [14] conglomerate (orientation bits 0..1) — patched- 0x18, 0x1e, // [15..17] length i16 — patched- 0x00, 0xf6, // [17..19] x i16 — patched- 0xff, 0x00, // [19..21] y i16 — patched+ 0x04, // [14] electrical type — patched+ 0x18, 0x1e, // [15] flags, [16] low byte of length — patched+ 0x00, 0xf6, // [17] length high, [18] X low — patched+ 0xff, 0x00, // [19] X high, [20] Y low — patched 0x00, 0x00, 0x00, 0x00, 0x00, // [21..26] ]; -/// Default Altium pin length (internal units) when the model leaves it unset.-const DEFAULT_PIN_LEN: i16 = 7704;--/// Altium schematic binary coordinate units per mil. Calibrated by differential-/// analysis against Altium AD26 (1000 mil → 25600 units, exact). The model-/// carries pin X/Y in mils; the encoder scales to these units.-const UNITS_PER_MIL: f64 = 25.6;- // --------------------------------------------------------------------------- // Encode // ---------------------------------------------------------------------------⋯ 456 unchanged lines ⋯ /// 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;- // 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°- // apart, so Altium_orient = (kicad_angle/90 + 2) % 4.- let aorient = ((pin.rotation as u32 / 90 + 2) % 4) as u16;- let length = pin- .length- .map(|l| (l as f64 * UNITS_PER_MIL).round() as i32)- .unwrap_or(DEFAULT_PIN_LEN as i32);- // 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);- }- // 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- // (KiCad convention); convert by moving length-mils toward the body, i.e.- // along the KiCad angle direction.- let len_mil = pin- .length- .unwrap_or((DEFAULT_PIN_LEN as f64 / UNITS_PER_MIL).round() as i32);- let (dx, dy) = match (pin.rotation / 90) % 4 {- 0 => (1, 0), // angle 0: body is to the +x- 1 => (0, 1), // angle 90: body to +y- 2 => (-1, 0), // angle 180: body to -x- _ => (0, -1), // angle 270: body to -y+ // Empty TEXT pstring at 12, unknown at 13, ELECTRICAL at 14,+ // conglomerate at 15, i16 length/XY at 16/18/20 (10 mil units).+ p[14] = match pin.electrical_type.as_deref().unwrap_or("unspecified") {+ "input" => 0, "bidirectional" => 1, "output" => 2, "open_collector" => 3,+ "passive" | "unspecified" | "free" | "no_connect" => 4,+ "tri_state" => 5, "open_emitter" => 6, "power_in" | "power_out" => 7,+ other => bail!("unsupported pin electrical type {:?}", other), };- let body_x = pin.x + len_mil * dx;- let body_y = pin.y + len_mil * dy;- // X = round(mil*25.6) as i24 at [17:20]; Y = round(mil/10) as i16 at [20:22].- let ux = (body_x as f64 * UNITS_PER_MIL).round() as i32;- let xb = ux.to_le_bytes();- p[17] = xb[0];- p[18] = xb[1];- p[19] = xb[2];- let uy = (body_y as f64 / 10.0).round() as i32 as i16;- p[20..22].copy_from_slice(&uy.to_le_bytes());+ let q = (pin.rotation / 90) % 4;+ p[15] = 0x20 | ((q + 2) % 4) as u8+ | if show_name { 0x08 } else { 0 } | if show_designator { 0x10 } else { 0 };+ let len_mil = pin.length.unwrap_or(300);+ let (dx, dy) = match q { 0 => (1, 0), 1 => (0, 1), 2 => (-1, 0), _ => (0, -1) };+ for (offset, value) in [(16, len_mil), (18, pin.x + len_mil * dx), (20, pin.y + len_mil * dy)] {+ if value % 10 != 0 {+ bail!("pin {:?} value {} mil needs a fractional PinFrac record; refusing rounding", pin.number, value);+ }+ let units = i16::try_from(value / 10).context("pin coordinate exceeds signed 16-bit Altium range")?;+ p[offset..offset + 2].copy_from_slice(&units.to_le_bytes());+ } // strings: Name, Designator, "", "|&|" push_pstr(&mut p, &pin.name)?; push_pstr(&mut p, &pin.number)?;⋯ 65 unchanged lines ⋯ pub fn decode_schlib_first(bytes: &[u8]) -> Result<serde_json::Value> { altium_schlib::parse_altium_schlib_bytes(bytes).context("altium-schlib decode") }+@@ -1,4471 +1,4488 @@⋯ 3459 unchanged lines ⋯ .unwrap_or_else(|e| { eprintln!("ERROR: {e:#}"); process::exit(1); }); let sp = out_dir.join(format!("{}.SchLib", p.mpn)); let pp = out_dir.join(format!("{}.PcbLib", p.mpn));- fs::write(&sp, &sym_bytes).ok();- fs::write(&pp, &fp_bytes).ok();+ fs::create_dir_all(&out_dir).unwrap_or_else(|e| {+ eprintln!("ERROR: create output directory {}: {}", out_dir.display(), e);+ process::exit(2);+ });+ for (path, content) in [(&sp, &sym_bytes), (&pp, &fp_bytes)] {+ fs::write(path, content).unwrap_or_else(|e| {+ eprintln!("ERROR: write {}: {}", path.display(), e);+ process::exit(2);+ });+ } println!("OK: wrote {} ({} pins) + {} ({} pads)", sp.display(), p.symbol.pins.len(), pp.display(), p.footprint.pads.len()); }⋯ 162 unchanged lines ⋯ let fp_txt = altium_codec::export_kicad_footprint(&p.footprint); let sp = out_dir.join(format!("{}.kicad_sym", p.mpn)); let fp = out_dir.join(format!("{}.kicad_mod", p.mpn));- fs::write(&sp, sym_txt).ok();- fs::write(&fp, fp_txt).ok();+ fs::create_dir_all(&out_dir).unwrap_or_else(|e| {+ eprintln!("ERROR: create output directory {}: {}", out_dir.display(), e);+ process::exit(2);+ });+ for (path, content) in [(&sp, sym_txt.as_bytes()), (&fp, fp_txt.as_bytes())] {+ fs::write(path, content).unwrap_or_else(|e| {+ eprintln!("ERROR: write {}: {}", path.display(), e);+ process::exit(2);+ });+ } println!("OK: wrote {} ({} pins) + {} ({} pads, {} graphics)", sp.display(), p.symbol.pins.len(), fp.display(), p.footprint.pads.len(), p.footprint.graphics.len());⋯ 831 unchanged lines ⋯ assert!(r.errors.iter().any(|e| e.code == "empty-packages"), "{:?}", r.errors); } }+@@ -1,1654 +1,1669 @@⋯ 665 unchanged lines ⋯ ); } if show_name && p.name != "~" && !p.name.is_empty() {- let anchor = if ux > 0.3 { "start" } else if ux < -0.3 { "end" } else { "middle" };+ let tx = x2 + ux * fs * 0.5;+ let ty = y2 + uy * fs * 0.5;+ let vertical = ux.abs() < 0.3;+ let anchor = if vertical { if uy > 0.0 { "end" } else { "start" } }+ else if ux > 0.0 { "start" } else { "end" };+ let transform = if vertical { format!(" transform='rotate(-90 {tx:.1} {ty:.1})'") } else { String::new() }; body += &format!(- "<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#e6e9ef' text-anchor='{}' dominant-baseline='central'>{}</text>",- x2 + ux * fs * 0.5, y2 + uy * fs * 0.5, fs, anchor, esc_attr(&p.name)+ "<text x='{tx:.1}' y='{ty:.1}' font-size='{fs:.0}' fill='#e6e9ef' text-anchor='{anchor}' dominant-baseline='central'{transform}>{}</text>",+ esc_attr(&p.name) ); } }⋯ 98 unchanged lines ⋯ } for p in &fp.pads { let col = layer_color(&FpLayer::Copper);- let rx = if p.shape == "circle" || p.shape == "roundrect" { p.w_mm.min(p.h_mm) * 0.25 } else { 0.0 };+ let rx = match p.shape.as_str() {+ "circle" | "oval" => p.w_mm.min(p.h_mm) * 0.5,+ "roundrect" => p.w_mm.min(p.h_mm) * p.roundrect_rratio.unwrap_or(0.25),+ _ => 0.0,+ };+ body += &format!("<g transform='rotate({:.3} {:.3} {:.3})'>", p.rotation, p.x_mm, p.y_mm); // the connected pin name (if the symbol has one) becomes the pad's label let name = names.get(&p.number).cloned().unwrap_or_default(); body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' stroke='#00000033' stroke-width='0.03' opacity='0.94'/>",⋯ 10 unchanged lines ⋯ }; body += &format!("<text class='padnum' x='{:.3}' y='{:.3}' font-size='{:.3}'{} fill='#1c1206' text-anchor='middle' dominant-baseline='central' pointer-events='none' font-family='ui-monospace,monospace' font-weight='700'>{}</text>", p.x_mm, p.y_mm, fs, rot, esc_attr(&p.number));+ body += "</g>"; } format!( "<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",⋯ 783 unchanged lines ⋯ } let rows: Vec<Value> = part.symbol.pins.iter().map(|p| { let (ty, color, d) = meta.get(&p.number).cloned()- .unwrap_or_else(|| ("—".to_string(), "#90a4ae".to_string(), String::new()));+ .unwrap_or_else(|| {+ let (label, color) = crate::pin_type_style(p.electrical_type.as_deref().unwrap_or("unspecified"));+ (label.to_string(), color.to_string(), String::new())+ }); let short = d.split_once(". ").map(|(a, _)| format!("{a}.")).unwrap_or(d); json!({ "number": p.number, "name": p.name, "type": ty, "color": color, "desc": short }) }).collect();⋯ 65 unchanged lines ⋯ panes.join("+"), rows.len(), has3d, out.display()); Ok(()) }+@@ -0,0 +1,26 @@+---+name: adom-lbr-component-fidelity+description: Use when importing a manufacturer component into adom-lbr or proving library parity across KiCad, Fusion and Altium. Check exact library identity, pin semantics, pad geometry, model placement and actual output read-back before release.+---+# Manufacturer component fidelity++Parent skill: adom-lbr.++A successful import/export or a plausible thumbnail is insufficient evidence of parity. Select the exact symbol in a multi-component library and require its linked footprint. Do not fall back to a different footprint when that link is missing. Record the exact manufacturer ordering variant separately from its family library name.++For the TMF8829 worked example, the manufacturer Altium archive starts with another sensor. Explicit `--mpn TMF8829` selects the vendor family record; the purchased project variant is TMF8829-1A. Preserve the raw source and prove 18 pin numbers, 18 pad numbers and the complete mapping against the datasheet. Manufacturer CAD pin electrical classifications may be wrong: compare each role with the datasheet before authoring the project symbol.++Keep electrical types, pin directions, labels, functional group text, graphic primitives and field placement in the canonical record and every export. Check connection points against the schematic grid. Group spacing chosen for a viewer can leave pins off-grid. Confirm the real symbol, rather than accepting a viewer that suppresses unsupported text or rotates every name horizontally.++Altium binary pin TEXT is a Pascal string and can shift subsequent fields. ELECTRICAL and PINCONGLOMERATE are separate bytes; length and X/Y are signed fields. Use a primary parser or native differential evidence and an independent fixture before changing offsets. Encode/decode agreement alone can reproduce the same mistake twice.++Altium pad base shape CIRCLE can mean a circle, oval, or a rounded rectangle selected by the extended pad-stack record. Retain corner radius and rotation. In the TMF8829 source, alternate shape 9 and corner percentage 20 mean a radius/minimum-dimension ratio of 0.1. This is part-specific evidence, not a default for other parts. Validate the webview renderer too: correct canonical data does not help if it ignores rotation or substitutes a fixed corner radius.++Manufacturer PcbLib models may be zlib-compressed STEP streams. Match the body model ID to the model registry before extraction. Record archive URL/hash, member, stream, model ID, original filename and decompressed-byte hash. Preserve original geometry. Separate physical models from optical field-of-view helpers and board-context geometry.++A model shown by itself is not a validated footprint placement. Preserve native translation, rotation and stand-off; verify pad/body/pin-1 alignment before attaching it to a board. Mark display annotations as derivatives and keep the original manufacturer STEP.++After every file export, inspect actual files and read them back. Missing output directories or permissions must produce errors, not `OK` after a discarded write result. Test a failed write path when correcting this behavior. Keep fixes in an isolated app branch; installed binary, source branch and validated native runtime are separate states.++For this project's component wiki requirement, follow adom-project-flow and adom-webview-wiki-review. Use the existing component page where identity matches; keep each selected MPN in the project register. Show the authenticated page and the live library during component work, and record real edits with interruption/replay labels when applicable.+
Comments
Log in to comment.
Follow-up implementation checkpoint on the isolated
feature/tmf8829-library-fidelitycheckout: commit320373epreserves free group labels in canonical JSON, KiCad import/export and the composed webview, and explicitly refuses Altium export containing unsupported free labels. The whole workspace now passes 51 tests. The refreshed TMF8829 quad was visually checked: group labels, electrical roles and rotated pads are present; small power labels still need layout work.This file-set PR still contains its original snapshot; the follow-up commit is not silently part of this PR. Native Altium text/font/field placement, central artwork and physical model attachment remain open before the full component-parity milestone. The installed shared binary remains unchanged. The owning-app skill was extended with text fidelity and explicit unsupported-export guidance.
Follow-up candidate through 15f644b (the PR file-set snapshot itself has not been replaced). Attached patch is against 98e8e72 and includes the later fill/metadata fixes, canonical STEP placement/staging, Altium free labels and fractional model offset, separate Top Overlay silk, and Fusion label-size preservation. 54 workspace tests pass. Actual TMF8829 Altium output retains 8 labels/18 pads and embeds the exact 8,623,696 manufacturer STEP bytes; body XY/Z/rotation readback matches the source. Altium font conversion is explicit: 40 mil maps to 6 native pt, 43.2 mil stroke-equivalent on readback. Native Altium validation is pending because crr lacks the host app. Fusion native symbol and footprint renders on crr are attached; model binding remains pending. KiCad native manufacturer body now renders after explicit model installation and a fresh pcbnew process. This is a review candidate, not a released parity claim.
lbr-parity-followup.patch
The isolated candidate now preserves the supervisor source mask/paste offsets through KiCad instead of returning an apparently complete footprint with changed openings. Commit
19e46a6extends the canonical model with signed per-pad/footprint margins, additive paste ratios and explicit layer participation. KiCad import/export retains them, including zero versus inheritance. Altium encodes/decodes signed manual expansion fields and resolves footprint defaults, with bounds on unsupported semantics. The CLI and Manager refuse Fusion aperture conversions that would silently discard these fields; the Manager returns a per-target reason.Validation: 58 workspace tests pass (24 main, 33 codec, 1 auxiliary). Native KiCad 10.0.2 loaded an isolated test board made from the actual supervisor canonical export and produced separate copper/mask/paste SVGs. All 18 shapes' outer dimensions match the source intent. Source/native hashes and measurements are attached. The live inspection is at https://wiki.adom.inc/adom/tps389001dser/render/aperture-candidate/aperture-inspection.html .
Remaining work is explicit: this supervisor's Altium export refuses the non-whole-percent corner ratio; Fusion needs explicit aperture geometry; native desktop seating/parity remain unproven. The native KiCad check also shows that aperture corner radii scale with the adjusted smaller dimension: pad 1 is approximately R0.0714, not TI's example stencil R0.05. Thus passing widths/heights does not release the source land pattern for manufacture. Original failing diagnostic artifacts are retained; the newer candidate lives separately.
Altium field offsets/modes were cross-checked against KiCad 10.0.0 source, not inferred from a matching encode/decode pair alone: https://github.com/KiCad/kicad-source-mirror/blob/10.0.0/pcbnew/pcb_io/altium/altium_parser_pcb.cpp (APAD6 subrecord 5, signed offsets 86/90 and mode bytes 101/102). This supports the codec implementation; live Altium validation is still required. KiCad pad.cpp at the same tag documents optional overrides and paste-margin arithmetic.
The attached patch is incremental from
15f644bto19e46a6. This comment does not replace the older PR6 file-set snapshot or merge/install a shared release. Older consumers can ignore newly added JSON fields, so use a feature-capable binary and require post-export aperture inspection. Owning issue: adom/adom-lbr#25.adom-lbr-apertures-19e46a6.patch
native-aperture-report.json
Heads up: the pin electrical-type part of this landed on main in 2.47.0 (for issue #29). It uses this PR's field name,
electrical_typeonPinwith KiCad vocabulary, and the same Altium byte mapping, so there is no schema fork. Main also decodes the pin record's variable-length description (the electrical byte sits at 14 + description length).The rest of the PR (pad semantics and export-failure reporting) is untouched. Its model.rs and altium_decode.rs hunks will conflict with 2.47.0 on
Pinanddecode_pin, so taking main's versions of those two pieces should make the rebase straightforward. Thanks for the groundwork.