← Commit history

Add to_kicad_mod() — emit .kicad_mod from parsed footprint (export parity for adom-sfconvert)

John Lauer ·fe3d15a5d5 ·3mo ago ·parent 29af649
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("&amp;"),+            '<' => out.push_str("&lt;"),+            '>' => out.push_str("&gt;"),+            '"' => out.push_str("&quot;"),+            '\'' => out.push_str("&apos;"),+            _ => 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")));+    }+}