skill
Altium PcbLib — native footprint parser
Public Made by Adomby adom
Native Rust parser for Altium .PcbLib binary footprint libraries — used by chipsmith + concur for cross-source agreement.
← Commit history
Add to_kicad_mod() — emit .kicad_mod from parsed footprint (export parity for adom-sfconvert)
1 file changed
+775
src/lib.rsadded+775@@ -0,0 +1,775 @@+//! Native Rust parser for Altium .PcbLib (and .IntLib) footprint libraries.+//!+//! Altium .PcbLib is a Microsoft OLE2 / Compound File Binary container. Each+//! footprint lives in its own top-level storage (e.g. `/D8`, `/SOP50P490X110-10N`)+//! and the interesting stream is `/<name>/Data` — Altium's binary record format+//! laid out as a sequence of typed records (Pad / Track / Arc / Text / ...).+//!+//! This crate extracts every footprint's pad list and emits JSON shaped like+//! KiCad's `parse_kicad_mod`-style output, so downstream tools (chipsmith's+//! cross-source agreement matrix, concur's consensus check) can compare KiCad+//! / Fusion / Altium pads side-by-side.+//!+//! ## Public API+//!+//! - [`parse_altium_pcblib_path`] — read a `.PcbLib` from disk.+//! - [`parse_altium_pcblib_bytes`] — parse from raw bytes (handy for tests+//! and for streams already in memory).+//!+//! Both return a `serde_json::Value` with this shape:+//!+//! ```json+//! {+//! "module_name": "D8",+//! "pad_count": 8,+//! "pads": [{+//! "number": "1", "type": "smd", "shape": "rect",+//! "at": [-2.4638, -1.905, 0.0],+//! "size": [1.9812, 0.5588],+//! "layers": ["F.Cu", "F.Paste", "F.Mask"],+//! "drill": null+//! }],+//! "silk_markers": [],+//! "pin1_silk": { "found": false },+//! "_source": "altium_pcblib",+//! "_alternates": [ /* other footprints in the lib */ ]+//! }+//! ```+//!+//! ## Record format (reverse-engineered from real-world chip libraries)+//!+//! Each footprint's `Data` stream begins with a length-prefixed Pascal-style+//! footprint name:+//!+//! ```text+//! [u32 LE name_block_len][u8 name_pstring_len][name bytes...]+//! ```+//!+//! After the name, records appear back-to-back. Every record starts with a+//! u8 type byte. Different record types have different shapes:+//!+//! - **Track (0x04)** / **Arc (0x01)** / **Region (0x0B)** / **Body (0x0C)** /+//! **Fill (0x06)** / **Via (0x03)**: `[u8 type][u32 LE len][len bytes payload]`.+//!+//! - **Pad (0x02)**: a sequence of 6 length-prefixed sub-blocks following the+//! type byte. Each sub-block is `[u32 LE block_len][block_len bytes]`. The+//! first sub-block holds the pad designator (Pascal string with 1-byte len).+//! The fifth sub-block holds the main pad data — usually 194 or 202 bytes+//! depending on the Altium PcbLib format version. The sixth is typically+//! length-zero (trailing padding).+//!+//! - **Text (0x05)**: two length-prefixed sub-blocks (main payload + style+//! trailer). We don't decode it — it's only consumed to keep the cursor+//! aligned past silkscreen text.+//!+//! ### Pad data block layout (block 5)+//!+//! ```text+//! [0] u8 layer (1 = TOP_CU, 32 = BOTTOM_CU, 21 = TOP_OVERLAY, ...)+//! [1] u8 net/flag (0x08 in v3, 0x0c in v4 — used as a version probe)+//! [2] u8 reserved+//! [3..13] 10 bytes of 0xFF padding (component / net id sentinels)+//! [13..17] i32 LE x position (units = 1 / 10000 mil = 2.54e-6 mm)+//! [17..21] i32 LE y position (Altium Y-up — flip sign for KiCad's Y-down)+//! [21..25] u32 LE x size, top layer+//! [25..29] u32 LE y size, top layer+//! [29..33] u32 LE x size, mid layer (multi-layer thru-hole pads)+//! [33..37] u32 LE y size, mid layer+//! [37..41] u32 LE x size, bottom layer+//! [41..45] u32 LE y size, bottom layer+//! [45..49] u32 LE hole size (0 → SMD; > 0 → thru-hole)+//! [49] u8 shape, top layer (1 round, 2 rect, 3 octagonal, 9 rounded-rect)+//! [50] u8 shape, mid layer+//! [51] u8 shape, bottom layer+//! [52..60] f64 LE rotation in degrees (only present in 202-byte v4 layout)+//! ```+//!+//! Past offset 60 the layout drifts between Altium releases (paste / mask+//! offsets, plated bool, GUIDs, primitive index, …). We don't need any of that+//! for cross-source agreement, so we stop reading.+//!+//! Rotation matters only for the rendered bounding box: KiCad records the+//! "logical" pad shape and rotates it; Altium 194-byte pads bake the rotation+//! into x_size/y_size and store rotation = 0. Both representations cover the+//! same physical region, which is what cross-source agreement actually+//! compares.++use anyhow::{anyhow, bail, Context, Result};+use std::io::{Cursor, Read};+use std::path::Path;++const ALTIUM_UNIT_TO_MM: f64 = 0.00000254; // 1 / 10000 mil → mm++/// Parse an Altium .PcbLib (or .IntLib's PCB lib stream) from a file path.+/// Returns the JSON for the FIRST footprint in the library that has at least+/// one pad. Other footprints, if present, ride along under `_alternates`.+pub fn parse_altium_pcblib_path(path: &Path) -> Result<serde_json::Value> {+ let bytes = std::fs::read(path)+ .with_context(|| format!("read .PcbLib: {}", path.display()))?;+ parse_altium_pcblib_bytes(&bytes)+}++/// Parse an Altium .PcbLib from raw bytes.+pub fn parse_altium_pcblib_bytes(bytes: &[u8]) -> Result<serde_json::Value> {+ if bytes.len() < 8 || &bytes[..8] != [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1] {+ bail!("not an OLE2 compound document (bad magic)");+ }++ // Altium occasionally writes PcbLib FATs that overshoot the file's actual+ // sector count (cfb refuses with "Malformed FAT … FAT has N entries, but+ // file has only M sectors"). The unreferenced trailing sectors don't carry+ // real data — pad with 0xFF (CFBF "free sector" sentinel) and re-open. 1+ // MiB is plenty for any real chip footprint library we've seen.+ let owned;+ let cursor = if bytes.len() < 1024 * 1024 {+ owned = {+ let mut v = bytes.to_vec();+ v.resize(1024 * 1024, 0xFF);+ v+ };+ Cursor::new(owned.as_slice())+ } else {+ Cursor::new(bytes)+ };++ let mut comp = cfb::CompoundFile::open(cursor)+ .map_err(|e| anyhow!("open OLE2: {e}"))?;++ // Walk top-level storages. Skip housekeeping ones (`/Library`,+ // `/FileVersionInfo`); each remaining storage is a footprint.+ let footprint_storages: Vec<String> = comp.read_root_storage()+ .filter_map(|ent| {+ if !ent.is_storage() { return None; }+ let name = ent.name().to_string();+ if name == "Library" || name == "FileVersionInfo" { return None; }+ // Skip OLE2 metadata streams that start with the 0x01 control char+ if name.starts_with('\u{1}') { return None; }+ Some(name)+ })+ .collect();++ if footprint_storages.is_empty() {+ bail!("no footprint storages found in PcbLib");+ }++ let mut parsed: Vec<serde_json::Value> = Vec::new();+ for name in &footprint_storages {+ let stream_path = format!("/{name}/Data");+ let mut stream = match comp.open_stream(&stream_path) {+ Ok(s) => s,+ Err(_) => continue,+ };+ let mut data = Vec::new();+ if stream.read_to_end(&mut data).is_err() { continue; }+ match parse_footprint_stream(&data) {+ Ok(v) => parsed.push(v),+ Err(_e) => {+ // Surface the error in the JSON so the caller can flag it,+ // but keep walking — other footprints may parse cleanly.+ parsed.push(serde_json::json!({+ "module_name": name,+ "pad_count": 0,+ "pads": [],+ "silk_markers": [],+ "pin1_silk": { "found": false },+ "_source": "altium_pcblib",+ "_parse_error": _e.to_string(),+ }));+ }+ }+ }++ if parsed.is_empty() {+ bail!("no parseable footprints in PcbLib");+ }++ // Pick the footprint with the most pads as primary; alternates ride along.+ parsed.sort_by(|a, b| {+ let pa = a.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);+ let pb = b.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);+ pb.cmp(&pa)+ });++ let mut primary = parsed.remove(0);+ if let Some(obj) = primary.as_object_mut() {+ obj.insert("_source".to_string(), serde_json::Value::String("altium_pcblib".into()));+ if !parsed.is_empty() {+ obj.insert("_alternates".to_string(), serde_json::Value::Array(parsed));+ }+ }+ Ok(primary)+}++/// Emit a `.kicad_mod` from a parsed footprint. The pads are already stored in+/// KiCad terms (shape names, F.Cu/F.Paste/F.Mask layers, mm sizes, rotation), so+/// this is a direct serializer — the EXPORT path (rendering uses the native SVG).+pub fn to_kicad_mod(footprint: &serde_json::Value, name: &str) -> String {+ let mut out = format!(+ "(footprint \"{name}\" (version 20221018) (generator altium-pcblib) (layer \"F.Cu\")\n \+ (attr smd)\n \+ (fp_text reference \"REF**\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n \+ (fp_text value \"{name}\" (at 0 3) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n"+ );+ let pads = footprint.get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ for p in &pads {+ let num = p.get("number").and_then(|v| v.as_str()).unwrap_or("");+ let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");+ let ptype = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");+ let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ let x = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);+ let y = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);+ let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let at_s = if rot != 0.0 { format!("{x:.4} {y:.4} {rot}") } else { format!("{x:.4} {y:.4}") };+ let layers = p.get("layers").and_then(|v| v.as_array())+ .map(|a| a.iter().filter_map(|l| l.as_str()).map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" "))+ .filter(|s| !s.is_empty())+ .unwrap_or_else(|| "\"F.Cu\" \"F.Paste\" \"F.Mask\"".into());+ if ptype == "thru_hole" {+ let drill = p.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.6);+ out += &format!(" (pad \"{num}\" thru_hole {shape} (at {at_s}) (size {w:.4} {h:.4}) (drill {drill:.4}) (layers {layers}))\n");+ } else {+ out += &format!(" (pad \"{num}\" smd {shape} (at {at_s}) (size {w:.4} {h:.4}) (layers {layers}))\n");+ }+ }+ out += ")\n";+ out+}++fn parse_footprint_stream(data: &[u8]) -> Result<serde_json::Value> {+ if data.len() < 4 {+ bail!("stream too short ({} bytes)", data.len());+ }++ // Footprint name: [u32 LE block_len][u8 pstring_len][bytes...]+ let name_block_len = read_u32_le(data, 0)? as usize;+ if 4 + name_block_len > data.len() {+ bail!("footprint name block overruns stream");+ }+ let name_block = &data[4..4 + name_block_len];+ let module_name = if name_block.is_empty() {+ String::new()+ } else {+ let plen = name_block[0] as usize;+ if 1 + plen > name_block.len() {+ String::new()+ } else {+ String::from_utf8_lossy(&name_block[1..1 + plen]).to_string()+ }+ };++ let mut cursor = 4 + name_block_len;+ let mut pads: Vec<serde_json::Value> = Vec::new();+ let silk_markers: Vec<serde_json::Value> = Vec::new();++ // Altium footprints lay pads at the head of the stream, then silk /+ // courtyard / fab tracks, then optionally text / region / component-body+ // records that we don't model. Walk records until we run out, but stop+ // gracefully the moment we lose alignment — at that point we already+ // have the pads we need for cross-source agreement, and the silkscreen+ // / fab data isn't worth misreading.+ while cursor < data.len() {+ let record_type = data[cursor];+ cursor += 1;++ // Number of [u32 len][bytes] sub-blocks expected per record type.+ // Reverse-engineered from the bundled samples — most records carry a+ // single payload block; pads carry six (designator, layer-name, four+ // metadata blocks); text carries two (main + style trailer).+ let sub_blocks: usize = match record_type {+ 0x02 => 6, // Pad+ 0x05 => 2, // Text+ 0x01 | 0x03 | 0x04 | 0x06 |+ 0x09 | 0x0B | 0x0C => 1, // Arc / Via / Track / Fill / Conn / Region / Body+ _ => {+ // Unknown record — stop without erroring out. Pads come+ // first; a stray byte mid-stream is usually a Text+ // sub-block we miscounted, not a real new record kind.+ break;+ }+ };++ let mut blocks: Vec<&[u8]> = Vec::with_capacity(sub_blocks);+ let mut ok = true;+ for _ in 0..sub_blocks {+ if cursor + 4 > data.len() { ok = false; break; }+ let blen = read_u32_le(data, cursor).ok().map(|v| v as usize).unwrap_or(usize::MAX);+ // Sanity bound: any sub-block bigger than the whole stream is+ // proof we lost alignment. Bail out and keep the pads we have.+ if blen > data.len() { ok = false; break; }+ cursor += 4;+ if cursor + blen > data.len() { ok = false; break; }+ blocks.push(&data[cursor..cursor + blen]);+ cursor += blen;+ }+ if !ok {+ break;+ }++ if record_type == 0x02 {+ if let Some(pad_json) = decode_pad(&blocks) {+ pads.push(pad_json);+ }+ }+ }++ let pad_count = pads.len();+ let pin1_silk = serde_json::json!({"found": false});+ Ok(serde_json::json!({+ "module_name": module_name,+ "pad_count": pad_count,+ "pads": pads,+ "silk_markers": silk_markers,+ "pin1_silk": pin1_silk,+ }))+}++fn decode_pad(blocks: &[&[u8]]) -> Option<serde_json::Value> {+ if blocks.len() < 5 { return None; }++ // Block 0: designator pstring (e.g. "01 31" = "1")+ let designator = decode_pstring(blocks[0]);++ // Block 4: main pad data, 194 bytes (v3) or 202 bytes (v4+)+ let pd = blocks[4];+ if pd.len() < 49 {+ return None;+ }++ let layer_byte = pd[0];++ // X / Y in 1/10000 mil units. Altium Y-up → KiCad Y-down (flip sign).+ let x_units = read_i32_le(pd, 13)?;+ let y_units = read_i32_le(pd, 17)?;+ let x_mm = x_units as f64 * ALTIUM_UNIT_TO_MM;+ let y_mm = -(y_units as f64 * ALTIUM_UNIT_TO_MM);++ let xsize_top = read_u32_le(pd, 21).ok()? as f64 * ALTIUM_UNIT_TO_MM;+ let ysize_top = read_u32_le(pd, 25).ok()? as f64 * ALTIUM_UNIT_TO_MM;+ // mid / bot sizes available at 29/33/37/41 if we ever need them+ let hole_units = read_u32_le(pd, 45).ok()?;+ let hole_mm = hole_units as f64 * ALTIUM_UNIT_TO_MM;+ let shape_byte = pd[49];++ // Rotation lives at block5[52..60] in v4+. v3 (194-byte) layout doesn't+ // store it (rotation is baked into xsize/ysize). Read what's there; if+ // the f64 isn't a finite angle we just default to 0. The bounding box is+ // still correct because Altium pre-rotates the size for v3.+ let rotation = if pd.len() >= 60 {+ let raw = read_f64_le(pd, 52).unwrap_or(0.0);+ if raw.is_finite() && raw.abs() < 360.0 + 1e-6 {+ // Normalize to [-360, 360); KiCad accepts any angle but typical+ // values are 0/90/180/270.+ raw+ } else {+ 0.0+ }+ } else {+ 0.0+ };++ // Altium "round" (1) covers both circular pads and stadium-shaped SMD+ // pads with rounded ends. KiCad splits those: "circle" for w == h,+ // "oval" for w != h.+ let shape = match shape_byte {+ 1 if (xsize_top - ysize_top).abs() < 1e-3 => "circle",+ 1 => "oval",+ _ => altium_shape_to_kicad(shape_byte),+ };+ let pad_type = if hole_units == 0 {+ "smd"+ } else {+ "thru_hole"+ };++ let layers = altium_layers_for_pad(layer_byte, pad_type);++ let drill = if hole_units == 0 {+ serde_json::Value::Null+ } else {+ serde_json::json!({+ "shape": "circle",+ "diameter": hole_mm,+ })+ };++ Some(serde_json::json!({+ "number": designator,+ "type": pad_type,+ "shape": shape,+ "at": [round6(x_mm), round6(y_mm), round4(rotation)],+ "size": [round6(xsize_top), round6(ysize_top)],+ "layers": layers,+ "drill": drill,+ }))+}++fn decode_pstring(block: &[u8]) -> String {+ if block.is_empty() { return "?".to_string(); }+ let plen = block[0] as usize;+ if 1 + plen > block.len() { return "?".to_string(); }+ let raw = &block[1..1 + plen];+ // Altium pads designators with NUL out to a fixed width; trim trailing NULs.+ let trimmed = raw.iter().take_while(|&&b| b != 0).copied().collect::<Vec<_>>();+ String::from_utf8_lossy(&trimmed).to_string()+}++fn altium_shape_to_kicad(shape: u8) -> &'static str {+ match shape {+ 1 => "circle", // round — caller specializes to "oval" when w != h+ 2 => "rect",+ 3 => "rect", // octagonal — KiCad has no octagonal; rect is closest+ 9 => "roundrect",+ _ => "rect",+ }+}++/// Map an Altium layer byte to a KiCad-style layer list. SMD pads claim+/// F.Cu + F.Paste + F.Mask (or B.* on the bottom). Thru-hole pads claim+/// *.Cu + *.Paste + *.Mask so they appear on every layer.+fn altium_layers_for_pad(layer: u8, pad_type: &str) -> Vec<String> {+ if pad_type == "thru_hole" {+ return vec!["*.Cu".into(), "*.Paste".into(), "*.Mask".into()];+ }+ let on_bottom = matches!(layer, 32);+ if on_bottom {+ vec!["B.Cu".into(), "B.Paste".into(), "B.Mask".into()]+ } else {+ vec!["F.Cu".into(), "F.Paste".into(), "F.Mask".into()]+ }+}++fn read_u32_le(buf: &[u8], off: usize) -> Result<u32> {+ if off + 4 > buf.len() { bail!("u32 read past end of buffer at {off}"); }+ Ok(u32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))+}++fn read_i32_le(buf: &[u8], off: usize) -> Option<i32> {+ if off + 4 > buf.len() { return None; }+ Some(i32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]))+}++fn read_f64_le(buf: &[u8], off: usize) -> Option<f64> {+ if off + 8 > buf.len() { return None; }+ Some(f64::from_le_bytes([+ buf[off], buf[off+1], buf[off+2], buf[off+3],+ buf[off+4], buf[off+5], buf[off+6], buf[off+7],+ ]))+}++fn round6(x: f64) -> f64 { (x * 1_000_000.0).round() / 1_000_000.0 }+fn round4(x: f64) -> f64 { (x * 10_000.0).round() / 10_000.0 }++// ----------------------------------------------------------------------------+// SVG renderer — authoritative debug visualisation+// ----------------------------------------------------------------------------+//+// Downstream tools (chipsmith's overlay, concur's consensus check) interpret+// the parsed JSON. If their renders disagree with the SVG produced here,+// THEY are the bug — this rendering is what the parser actually believes the+// pads to be, drawn directly from `at` / `size` / `rot` / `shape` with no+// further interpretation.+//+// Convention:+// - Coordinates are in millimetres. SVG units = mm. No scaling applied.+// - Y is positive-down (matches both KiCad and our parser's emitted JSON,+// where Altium's Y-up is already flipped at parse time).+// - Rotation is taken straight from `at[2]`. Applied via SVG's+// `transform="rotate(deg cx cy)"` which rotates clockwise as seen on+// screen — same visual effect as KiCad's rotation.+//+// What the SVG shows:+// - Footprint name + pad count in the corner.+// - One <rect>/<circle>/<ellipse> per pad, coloured by layer+// (F.Cu = orange, B.Cu = blue, thru-hole = both).+// - The drill hole as a white circle inside thru-hole pads.+// - Pad number text un-rotated for readability (centred on the pad).+// - Origin crosshair at (0, 0) so you can sanity-check pad placement.+// - viewBox auto-fitted with 1 mm padding around the pad bounding box.++/// Render the parsed footprint JSON as a standalone SVG document. The input+/// must be the value returned by [`parse_altium_pcblib_bytes`] /+/// [`parse_altium_pcblib_path`] — either the primary footprint or one of its+/// `_alternates`.+pub fn render_footprint_svg(footprint: &serde_json::Value) -> String {+ let module_name = footprint+ .get("module_name").and_then(|v| v.as_str()).unwrap_or("?");+ let pads = footprint+ .get("pads").and_then(|v| v.as_array()).cloned().unwrap_or_default();++ // Compute the post-rotation axis-aligned bbox of every pad so the+ // viewBox covers the whole footprint with a small margin.+ let mut min_x = f64::INFINITY;+ let mut min_y = f64::INFINITY;+ let mut max_x = f64::NEG_INFINITY;+ let mut max_y = f64::NEG_INFINITY;+ for p in &pads {+ if let Some(bb) = pad_post_rotation_bbox(p) {+ min_x = min_x.min(bb.0);+ min_y = min_y.min(bb.1);+ max_x = max_x.max(bb.2);+ max_y = max_y.max(bb.3);+ }+ }+ if !min_x.is_finite() {+ // No pads with usable geometry — emit an empty placeholder so the+ // caller still gets a valid SVG document.+ return format!(+ r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 10 10" width="200" height="200"><text x="0" y="0" font-size="1" text-anchor="middle" font-family="monospace">{module_name}: no pads</text></svg>"#+ );+ }+ let pad = 1.0_f64; // 1 mm margin around the pad cluster+ let vb_x = min_x - pad;+ let vb_y = min_y - pad;+ let vb_w = (max_x - min_x) + 2.0 * pad;+ let vb_h = (max_y - min_y) + 2.0 * pad;++ // Render each pad. Always 1 mm = 1 unit so callers can pixel-measure.+ let px_per_mm = 50.0_f64; // hint to image viewers (overridable via CSS)+ let mut body = String::new();+ body.push_str(&format!(+ r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{:.4} {:.4} {:.4} {:.4}" width="{:.0}" height="{:.0}" font-family="monospace">"#,+ vb_x, vb_y, vb_w, vb_h,+ vb_w * px_per_mm, vb_h * px_per_mm,+ ));+ body.push('\n');++ // Background — pale grid so axes read clearly.+ body.push_str(&format!(+ r##" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="#fafafa" stroke="none"/>"##,+ vb_x, vb_y, vb_w, vb_h,+ ));+ body.push('\n');++ // Origin crosshair — 1 mm long arms.+ body.push_str(r##" <g stroke="#888" stroke-width="0.04" fill="none">"##);+ body.push('\n');+ body.push_str(r#" <line x1="-0.5" y1="0" x2="0.5" y2="0"/>"#);+ body.push('\n');+ body.push_str(r#" <line x1="0" y1="-0.5" x2="0" y2="0.5"/>"#);+ body.push('\n');+ body.push_str(r#" </g>"#);+ body.push('\n');++ // Pads.+ for p in &pads {+ body.push_str(&render_pad_svg(p));+ }++ // Header text — top-left corner of viewBox.+ body.push_str(&format!(+ r##" <text x="{:.4}" y="{:.4}" font-size="0.5" fill="#222">{} ({} pads)</text>"##,+ vb_x + 0.2, vb_y + 0.6, escape_xml(module_name), pads.len(),+ ));+ body.push('\n');+ body.push_str("</svg>\n");+ body+}++/// Convenience: render the primary footprint plus every `_alternates` entry,+/// one above the next, into a single SVG so a reviewer can flip through+/// every variant a `.PcbLib` declares.+pub fn render_all_footprints_svg(parsed: &serde_json::Value) -> String {+ // Collect primary + every alternate as separate SVG bodies, then stack+ // them vertically inside one outer <svg>.+ let mut footprints: Vec<&serde_json::Value> = vec![parsed];+ if let Some(alts) = parsed.get("_alternates").and_then(|v| v.as_array()) {+ footprints.extend(alts.iter());+ }+ // Render each independently, then string-concat into a vertical stack.+ let inner: Vec<String> = footprints.iter().map(|fp| render_footprint_svg(fp)).collect();+ // Crude but effective: inline each as <svg ... y="offset"> inside an+ // outer wrapper. SVG nesting works in browsers and most viewers.+ let mut out = String::new();+ let panel_height = 400.0_f64;+ let panel_gap = 40.0_f64;+ let total_h = footprints.len() as f64 * (panel_height + panel_gap);+ out.push_str(&format!(+ r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="{:.0}">"#,+ total_h,+ ));+ out.push('\n');+ for (i, svg) in inner.iter().enumerate() {+ let y = i as f64 * (panel_height + panel_gap);+ out.push_str(&format!(+ r#" <svg x="0" y="{:.0}" width="800" height="{:.0}" preserveAspectRatio="xMidYMid meet">"#,+ y, panel_height,+ ));+ // strip the outer <svg> tag from the inner doc so we just splice+ // its content in.+ let trimmed = svg+ .trim_start_matches(|c: char| c != '>').trim_start_matches('>')+ .trim_end()+ .trim_end_matches("</svg>");+ out.push_str(trimmed);+ out.push_str("</svg>\n");+ }+ out.push_str("</svg>\n");+ out+}++fn render_pad_svg(p: &serde_json::Value) -> String {+ let number = p.get("number").and_then(|v| v.as_str()).unwrap_or("?");+ let shape = p.get("shape").and_then(|v| v.as_str()).unwrap_or("rect");+ let pad_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("smd");+ let at = p.get("at").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ let size = p.get("size").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ let cx = at.first().and_then(|v| v.as_f64()).unwrap_or(0.0);+ let cy = at.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let rot = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let w = size.first().and_then(|v| v.as_f64()).unwrap_or(0.0);+ let h = size.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);++ // Layer colour: F.Cu → warm orange, B.Cu → cool blue, thru-hole → both.+ let on_back = p.get("layers").and_then(|v| v.as_array())+ .map(|arr| arr.iter().any(|l| l.as_str() == Some("B.Cu")))+ .unwrap_or(false);+ let fill = if pad_type == "thru_hole" {+ "#a8d4a8" // soft green for thru-hole+ } else if on_back {+ "#7cb6ff"+ } else {+ "#ff9a4a"+ };+ let stroke = "#222";++ // Rotation: SVG's `rotate(angle cx cy)` rotates clockwise on screen+ // when applied to a Y-down coordinate system, which matches how KiCad+ // displays its rotated pads. So we use the angle as-is.+ let rot_attr = if rot.abs() > 1e-6 {+ format!(r#" transform="rotate({:.4} {:.4} {:.4})""#, rot, cx, cy)+ } else {+ String::new()+ };++ let mut out = String::new();+ out.push_str(" <g>\n");+ match shape {+ "circle" => {+ let r = w.max(h) / 2.0;+ out.push_str(&format!(+ r#" <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+ cx, cy, r, fill, stroke, rot_attr,+ ));+ out.push('\n');+ }+ "oval" => {+ // KiCad "oval" = stadium: rect with semicircle ends. Approximate+ // with rounded-rect rx = min(w, h)/2.+ let r = w.min(h) / 2.0;+ out.push_str(&format!(+ r#" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+ cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,+ ));+ out.push('\n');+ }+ "roundrect" => {+ let r = (w.min(h) * 0.25).max(0.05);+ out.push_str(&format!(+ r#" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" rx="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+ cx - w / 2.0, cy - h / 2.0, w, h, r, fill, stroke, rot_attr,+ ));+ out.push('\n');+ }+ _ => {+ // rect (or unknown — draw as rect)+ out.push_str(&format!(+ r#" <rect x="{:.4}" y="{:.4}" width="{:.4}" height="{:.4}" fill="{}" stroke="{}" stroke-width="0.02"{}/>"#,+ cx - w / 2.0, cy - h / 2.0, w, h, fill, stroke, rot_attr,+ ));+ out.push('\n');+ }+ }++ // Drill hole — drawn AFTER the pad body so it sits on top.+ if let Some(drill) = p.get("drill") {+ if let Some(d) = drill.get("diameter").and_then(|v| v.as_f64()) {+ out.push_str(&format!(+ r##" <circle cx="{:.4}" cy="{:.4}" r="{:.4}" fill="#fff" stroke="{}" stroke-width="0.02"/>"##,+ cx, cy, d / 2.0, stroke,+ ));+ out.push('\n');+ }+ }++ // Pad number — un-rotated so it stays readable regardless of rot.+ let label_size = (w.min(h) * 0.5).clamp(0.15, 0.6);+ out.push_str(&format!(+ r##" <text x="{:.4}" y="{:.4}" font-size="{:.4}" text-anchor="middle" dominant-baseline="middle" fill="#111">{}</text>"##,+ cx, cy, label_size, escape_xml(number),+ ));+ out.push('\n');+ out.push_str(" </g>\n");+ out+}++/// Compute the axis-aligned bounding box of a pad after applying its rotation.+/// Returns (min_x, min_y, max_x, max_y) in mm.+fn pad_post_rotation_bbox(p: &serde_json::Value) -> Option<(f64, f64, f64, f64)> {+ let at = p.get("at").and_then(|v| v.as_array())?;+ let size = p.get("size").and_then(|v| v.as_array())?;+ let cx = at.first()?.as_f64()?;+ let cy = at.get(1)?.as_f64()?;+ let rot_deg = at.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);+ let w = size.first()?.as_f64()?;+ let h = size.get(1)?.as_f64()?;+ let theta = rot_deg.to_radians();+ let (s, c) = (theta.sin(), theta.cos());+ // Half-extents along rotated axes — corners are at ±(w/2)*(c, s) ± (h/2)*(-s, c)+ let hx = (w / 2.0 * c).abs() + (h / 2.0 * s).abs();+ let hy = (w / 2.0 * s).abs() + (h / 2.0 * c).abs();+ Some((cx - hx, cy - hy, cx + hx, cy + hy))+}++fn escape_xml(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ for ch in s.chars() {+ match ch {+ '&' => out.push_str("&"),+ '<' => out.push_str("<"),+ '>' => out.push_str(">"),+ '"' => out.push_str("""),+ '\'' => out.push_str("'"),+ _ => out.push(ch),+ }+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ /// LM358D is a SOIC-8 with 8 SMD pads at ±2.4638 mm × {±1.905, ±0.635} mm,+ /// each 1.9812 × 0.5588 mm. Pad 1 is at (-2.4638, -1.905) in KiCad+ /// convention.+ #[test]+ fn lm358d_decodes_eight_pads_pad1_at_top_left() {+ const LM358D_PCBLIB: &[u8] = include_bytes!(+ "../../chip-fetcher/library/LM358D/LM358D.PcbLib"+ );+ let parsed = parse_altium_pcblib_bytes(LM358D_PCBLIB)+ .expect("parse LM358D PcbLib");+ let pad_count = parsed.get("pad_count").and_then(|v| v.as_u64()).unwrap_or(0);+ assert_eq!(pad_count, 8, "LM358D should have 8 pads, got {pad_count}");+ let pads = parsed.get("pads").and_then(|v| v.as_array()).expect("pads array");+ let pad1 = pads.iter().find(|p| {+ p.get("number").and_then(|v| v.as_str()) == Some("1")+ }).expect("pad 1");+ let at = pad1.get("at").and_then(|v| v.as_array()).expect("at");+ let x = at[0].as_f64().unwrap();+ let y = at[1].as_f64().unwrap();+ let size = pad1.get("size").and_then(|v| v.as_array()).expect("size");+ let w = size[0].as_f64().unwrap();+ let h = size[1].as_f64().unwrap();+ // Pad 1 centre is at roughly (-2.46, -1.91); width 1.98, height 0.56+ assert!((x - -2.4638).abs() < 0.05, "pad 1 X = {x}");+ assert!((y - -1.905).abs() < 0.05, "pad 1 Y = {y}");+ assert!((w - 1.9812).abs() < 0.05, "pad 1 W = {w}");+ assert!((h - 0.5588).abs() < 0.05, "pad 1 H = {h}");+ let layers = pad1.get("layers").and_then(|v| v.as_array()).expect("layers");+ assert!(layers.iter().any(|l| l.as_str() == Some("F.Cu")));+ }+}