main
Colby Knox board-to-molecule 1.0.0: Rust source, six before/after examples (KiCad, Fusion, Altium), Adom 3D viewer readme eb9ea91 4d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
//! PcbDoc reader: Board6 outline, copper, vias, pads, silkscreen, components,
//! and embedded 3D models into the shared preview model.

use super::rec::*;
use crate::model::{arc_mid_from_center, rot, Body3d, Board, Circle, Component, Pad, PadShape, Seg, Shape, Side, Via};
use anyhow::{Context, Result};
use std::collections::{BTreeMap, HashMap};
use std::io::{Cursor, Read};
use std::path::Path;

/// Altium V6 layer ids (the byte stored in binary records).
pub const L_TOP: u8 = 1;
pub const L_BOTTOM: u8 = 32;
pub const L_TOP_OVERLAY: u8 = 33;
pub const L_BOTTOM_OVERLAY: u8 = 34;
pub const L_MULTI: u8 = 74;
pub const NONE16: u16 = 0xFFFF;

pub type Cfb = cfb::CompoundFile<Cursor<Vec<u8>>>;

pub fn open_bytes(bytes: Vec<u8>) -> Result<Cfb> {
    cfb::CompoundFile::open(Cursor::new(bytes)).context("not an OLE2 compound file (PcbDoc)")
}

pub fn open(path: &Path) -> Result<Cfb> {
    let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
    open_bytes(bytes)
}

pub fn stream(cf: &mut Cfb, path: &str) -> Option<Vec<u8>> {
    let mut s = cf.open_stream(path).ok()?;
    let mut v = Vec::with_capacity(s.len() as usize);
    s.read_to_end(&mut v).ok()?;
    Some(v)
}

pub fn header_count(cf: &mut Cfb, storage: &str) -> Option<u32> {
    let h = stream(cf, &format!("/{storage}/Header"))?;
    if h.len() == 4 { rd_u32(&h, 0) } else { None }
}

/// One Board6 outline vertex.
#[derive(Debug, Clone, Copy)]
pub struct Vertex {
    pub arc: bool,
    pub x: f64,
    pub y: f64,
    pub cx: f64,
    pub cy: f64,
    pub r: f64,
    pub sa: f64,
    pub ea: f64,
}

pub fn board_vertices(p: &Props) -> Vec<Vertex> {
    let mut out = vec![];
    for i in 0.. {
        let (Some(x), Some(y)) = (p.mm(&format!("VX{i}")), p.mm(&format!("VY{i}"))) else { break };
        let num = |k: &str| p.f64(&format!("{k}{i}")).unwrap_or(0.0);
        let mm = |k: &str| p.mm(&format!("{k}{i}")).unwrap_or(0.0);
        out.push(Vertex {
            arc: p.get(&format!("KIND{i}")).and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0) != 0,
            x,
            y,
            cx: mm("CX"),
            cy: mm("CY"),
            r: mm("R"),
            sa: num("SA"),
            ea: num("EA"),
        });
    }
    out
}

/// Outline shapes from the vertex list, following KiCad's reading: a line vertex
/// is a polygon corner; an arc vertex contributes the arc from its center, oriented
/// so it starts at the end nearer the vertex point; consecutive pieces are joined
/// by straight edges and the chain is closed.
pub fn outline_shapes(verts: &[Vertex], width: f64) -> Vec<Shape> {
    // Each piece: (start, end, optional mid).
    let mut pieces: Vec<((f64, f64), (f64, f64), Option<(f64, f64)>)> = vec![];
    for v in verts {
        if v.arc && v.r > 0.0 {
            let sweep = (v.ea - v.sa).rem_euclid(360.0);
            let (sa, ea) = (v.sa.to_radians(), v.ea.to_radians());
            let a = (v.cx + v.r * sa.cos(), v.cy + v.r * sa.sin());
            let b = (v.cx + v.r * ea.cos(), v.cy + v.r * ea.sin());
            let short = (a.0 - b.0).hypot(a.1 - b.1) < 0.001 || sweep < 0.2;
            let mid = if short { None } else { Some(arc_mid_from_center(v.cx, v.cy, a.0, a.1, sweep)) };
            let d = |p: (f64, f64)| (p.0 - v.x).hypot(p.1 - v.y);
            if d(a) < d(b) { pieces.push((a, b, mid)) } else { pieces.push((b, a, mid)) }
        } else {
            pieces.push(((v.x, v.y), (v.x, v.y), None));
        }
    }
    let mut out = vec![];
    let n = pieces.len();
    for i in 0..n {
        let (s, e, mid) = pieces[i];
        if let Some(m) = mid {
            out.push(Shape::Seg(Seg { x1: s.0, y1: s.1, x2: e.0, y2: e.1, width, mid: Some(m) }));
        } else if (s.0 - e.0).hypot(s.1 - e.1) > 1e-9 {
            out.push(Shape::Seg(Seg { x1: s.0, y1: s.1, x2: e.0, y2: e.1, width, mid: None }));
        }
        let next = pieces[(i + 1) % n].0;
        if n > 1 && (e.0 - next.0).hypot(e.1 - next.1) > 1e-9 {
            out.push(Shape::Seg(Seg { x1: e.0, y1: e.1, x2: next.0, y2: next.1, width, mid: None }));
        }
    }
    out
}

pub fn board6_props(cf: &mut Cfb) -> Result<Props> {
    let data = stream(cf, "/Board6/Data").context("PcbDoc has no Board6/Data stream")?;
    let recs = split_text(&data).context("Board6/Data")?;
    let first = recs.first().context("Board6/Data is empty")?;
    Ok(Props::parse(first))
}

/// Decoded Pads6 record (only the fields the preview and checks use).
#[derive(Debug, Clone)]
pub struct PadRec {
    pub name: String,
    pub layer: u8,
    pub component: u16,
    pub x: f64,
    pub y: f64,
    pub w: f64,
    pub h: f64,
    pub hole: f64,
    pub shape: u8,
    pub rot: f64,
    pub plated: bool,
    pub roundrect: bool,
    pub slot: bool,
}

pub fn decode_pad(r: &BinRec) -> Option<PadRec> {
    if r.kind != 2 || r.subs.len() != 6 {
        return None;
    }
    let s5 = &r.subs[4];
    if s5.len() < 110 {
        return None;
    }
    let s6 = &r.subs[5];
    let (roundrect, slot) = if s6.len() >= 596 { (s6[532] == 9, s6[262] == 2) } else { (false, false) };
    Some(PadRec {
        name: pascal(&r.subs[0]),
        layer: s5[0],
        component: rd_u16(s5, 7)?,
        x: u2mm(rd_i32(s5, 13)?),
        y: u2mm(rd_i32(s5, 17)?),
        w: u2mm(rd_i32(s5, 21)?),
        h: u2mm(rd_i32(s5, 25)?),
        hole: u2mm(rd_i32(s5, 45)?),
        shape: s5[49],
        rot: rd_f64(s5, 52)?,
        plated: s5[60] != 0,
        roundrect,
        slot,
    })
}

#[derive(Debug, Clone)]
pub struct TextRec {
    pub component: u16,
    pub is_comment: bool,
    pub is_designator: bool,
    pub widx: Option<u32>,
    pub legacy: String,
}

pub fn decode_text(r: &BinRec) -> Option<TextRec> {
    if r.kind != 5 || r.subs.len() != 2 {
        return None;
    }
    let a = &r.subs[0];
    let long = a.len() >= 123;
    Some(TextRec {
        component: rd_u16(a, 7)?,
        is_comment: long && a[40] != 0,
        is_designator: long && a[41] != 0,
        widx: if long { rd_u32(a, 115) } else { None },
        legacy: pascal(&r.subs[1]),
    })
}

/// Board thickness KiCad would use is derived from the full stackup; the preview
/// only needs it to place bottom-projected 3D bodies, so a standard 1.6 mm board
/// is assumed.
const BOARD_THICKNESS_MM: f64 = 1.6;

pub fn read(path: &Path) -> Result<Board> {
    let mut cf = open(path)?;
    let mut b = Board::new("altium");
    let mut skipped: BTreeMap<&str, usize> = BTreeMap::new();

    let bp = board6_props(&mut cf)?;
    let verts = board_vertices(&bp);
    b.outline = outline_shapes(&verts, 0.1);
    b.meta.insert("outline_vertices".into(), verts.len().to_string());
    b.meta.insert("outline_arcs".into(), verts.iter().filter(|v| v.arc).count().to_string());

    // Components.
    let mut comps: Vec<Component> = vec![];
    if let Some(data) = stream(&mut cf, "/Components6/Data") {
        match split_text(&data) {
            Ok(recs) => {
                for r in recs {
                    let p = Props::parse(r);
                    let side = if p.str("LAYER").eq_ignore_ascii_case("BOTTOM") { Side::Bottom } else { Side::Top };
                    comps.push(Component {
                        r#ref: p.str("SOURCEDESIGNATOR"),
                        x: p.mm("X").unwrap_or(0.0),
                        y: p.mm("Y").unwrap_or(0.0),
                        rot: p.f64("ROTATION").unwrap_or(0.0),
                        side,
                        value: String::new(),
                        package: p.str("PATTERN"),
                        pads: vec![],
                        bodies: vec![],
                    });
                }
            }
            Err(e) => {
                b.meta.insert("components_error".into(), e.to_string());
            }
        }
    }

    // Designator and comment strings from Texts6 (+ WideStrings6).
    let wide: HashMap<u32, String> =
        stream(&mut cf, "/WideStrings6/Data").map(|d| parse_widestrings(&d).into_iter().collect()).unwrap_or_default();
    let mut text_count = 0;
    if let Some(data) = stream(&mut cf, "/Texts6/Data") {
        if let Ok(recs) = split_bin(&data, 2) {
            for r in &recs {
                let Some(t) = decode_text(r) else {
                    *skipped.entry("texts").or_default() += 1;
                    continue;
                };
                text_count += 1;
                let Some(c) = comps.get_mut(t.component as usize) else { continue };
                let s = t.widx.and_then(|i| wide.get(&i).cloned()).unwrap_or(t.legacy);
                if t.is_designator && !s.is_empty() && !s.starts_with('.') {
                    c.r#ref = s;
                } else if t.is_comment && !s.starts_with('.') {
                    c.value = s;
                }
            }
        }
    }

    // Pads.
    let mut pad_total = 0;
    if let Some(data) = stream(&mut cf, "/Pads6/Data") {
        match split_bin(&data, 6) {
            Ok(recs) => {
                for r in &recs {
                    let Some(p) = decode_pad(r) else {
                        *skipped.entry("pads").or_default() += 1;
                        continue;
                    };
                    pad_total += 1;
                    let shape = match p.shape {
                        1 if (p.w - p.h).abs() < 1e-9 => PadShape::Circle,
                        1 => PadShape::Oval,
                        2 if p.roundrect => PadShape::RoundRect,
                        _ => PadShape::Rect,
                    };
                    let th = p.layer == L_MULTI && p.hole > 0.0;
                    let pad = Pad {
                        x: p.x,
                        y: p.y,
                        w: p.w,
                        h: p.h,
                        shape,
                        rot: p.rot,
                        drill: if th { p.hole } else { 0.0 },
                        side: if p.layer == L_BOTTOM { Side::Bottom } else { Side::Top },
                        name: p.name,
                    };
                    if p.layer != L_TOP && p.layer != L_BOTTOM && p.layer != L_MULTI {
                        *skipped.entry("pads_other_layer").or_default() += 1;
                        continue;
                    }
                    match comps.get_mut(p.component as usize) {
                        Some(c) if p.component != NONE16 => c.pads.push(pad),
                        _ => b.free_pads.push(pad),
                    }
                }
            }
            Err(e) => {
                b.meta.insert("pads_error".into(), e.to_string());
            }
        }
    }

    // Tracks.
    let mut n_tracks = 0;
    if let Some(data) = stream(&mut cf, "/Tracks6/Data") {
        if let Ok(recs) = split_bin(&data, 1) {
            for r in &recs {
                let s = &r.subs[0];
                let (Some(x1), Some(y1), Some(x2), Some(y2), Some(w)) =
                    (rd_i32(s, 13), rd_i32(s, 17), rd_i32(s, 21), rd_i32(s, 25), rd_i32(s, 29))
                else {
                    *skipped.entry("tracks").or_default() += 1;
                    continue;
                };
                n_tracks += 1;
                if s.get(2) == Some(&2) {
                    continue; // keepout
                }
                let seg = Seg { x1: u2mm(x1), y1: u2mm(y1), x2: u2mm(x2), y2: u2mm(y2), width: u2mm(w), mid: None };
                match s[0] {
                    L_TOP => b.tracks_top.push(seg),
                    L_BOTTOM => b.tracks_bottom.push(seg),
                    L_TOP_OVERLAY => b.silk_top.push(Shape::Seg(seg)),
                    L_BOTTOM_OVERLAY => b.silk_bottom.push(Shape::Seg(seg)),
                    _ => {}
                }
            }
        }
    }

    // Arcs.
    let mut n_arcs = 0;
    if let Some(data) = stream(&mut cf, "/Arcs6/Data") {
        if let Ok(recs) = split_bin(&data, 1) {
            for r in &recs {
                let s = &r.subs[0];
                let (Some(cx), Some(cy), Some(rad), Some(sa), Some(ea), Some(w)) =
                    (rd_i32(s, 13), rd_i32(s, 17), rd_i32(s, 21), rd_f64(s, 25), rd_f64(s, 33), rd_i32(s, 41))
                else {
                    *skipped.entry("arcs").or_default() += 1;
                    continue;
                };
                n_arcs += 1;
                if s.get(2) == Some(&2) {
                    continue;
                }
                let (cx, cy, rr, w) = (u2mm(cx), u2mm(cy), u2mm(rad), u2mm(w));
                let sweep = (ea - sa).rem_euclid(360.0);
                let full = sweep < 1e-9 && (ea - sa).abs() > 1e-9;
                let shape = if full || (ea - sa).abs() >= 360.0 - 1e-9 {
                    Shape::Circle(Circle { cx, cy, r: rr, width: w, fill: false })
                } else {
                    let (x1, y1) = (cx + rr * sa.to_radians().cos(), cy + rr * sa.to_radians().sin());
                    let (x2, y2) = (cx + rr * ea.to_radians().cos(), cy + rr * ea.to_radians().sin());
                    Shape::Seg(Seg { x1, y1, x2, y2, width: w, mid: Some(arc_mid_from_center(cx, cy, x1, y1, sweep)) })
                };
                match (s[0], shape) {
                    (L_TOP, Shape::Seg(sg)) => b.tracks_top.push(sg),
                    (L_BOTTOM, Shape::Seg(sg)) => b.tracks_bottom.push(sg),
                    (L_TOP_OVERLAY, sh) => b.silk_top.push(sh),
                    (L_BOTTOM_OVERLAY, sh) => b.silk_bottom.push(sh),
                    _ => {}
                }
            }
        }
    }

    // Vias.
    if let Some(data) = stream(&mut cf, "/Vias6/Data") {
        if let Ok(recs) = split_bin(&data, 1) {
            for r in &recs {
                let s = &r.subs[0];
                match (rd_i32(s, 13), rd_i32(s, 17), rd_i32(s, 21), rd_i32(s, 25)) {
                    (Some(x), Some(y), Some(d), Some(h)) if r.kind == 3 => {
                        b.vias.push(Via { x: u2mm(x), y: u2mm(y), dia: u2mm(d), drill: u2mm(h) })
                    }
                    _ => *skipped.entry("vias").or_default() += 1,
                }
            }
        }
    }

    // Embedded 3D models.
    let (models, by_id) = read_models(&mut cf, &mut skipped);
    b.models = models;
    let mut n_bodies = 0;
    if let Some(data) = stream(&mut cf, "/ComponentBodies6/Data") {
        if let Ok(recs) = split_bin(&data, 1) {
            for r in &recs {
                let Some((ci, p)) = decode_body(r) else {
                    *skipped.entry("bodies").or_default() += 1;
                    continue;
                };
                if p.bool("MODEL.EMBED") != Some(true) {
                    continue;
                }
                let Some(name) = by_id.get(&p.str("MODELID")) else { continue };
                let Some(c) = comps.get_mut(ci as usize) else { continue };
                c.bodies.push(body_transform(c, &p, name));
                n_bodies += 1;
            }
        }
    }

    let n_comp = comps.len();
    b.components = comps;
    let m = &mut b.meta;
    m.insert("components".into(), n_comp.to_string());
    m.insert("pads".into(), pad_total.to_string());
    m.insert("tracks_records".into(), n_tracks.to_string());
    m.insert("arcs_records".into(), n_arcs.to_string());
    m.insert("texts_records".into(), text_count.to_string());
    m.insert("vias".into(), b.vias.len().to_string());
    m.insert("models".into(), b.models.len().to_string());
    m.insert("bodies_embedded".into(), n_bodies.to_string());
    m.insert("board_thickness_mm_assumed".into(), BOARD_THICKNESS_MM.to_string());
    if let (Some(ox), Some(oy)) = (bp.mm("ORIGINX"), bp.mm("ORIGINY")) {
        m.insert("origin_mm".into(), format!("{ox:.6},{oy:.6}"));
    }
    for (k, v) in skipped {
        m.insert(format!("skipped_{k}"), v.to_string());
    }
    Ok(b)
}

/// ComponentBodies6 record: component index and the property block.
pub fn decode_body(r: &BinRec) -> Option<(u16, Props)> {
    if r.kind != 12 {
        return None;
    }
    let s = r.subs.first()?;
    let comp = rd_u16(s, 7)?;
    let n = rd_u32(s, 18)? as usize;
    let props = s.get(22..22 + n)?;
    Some((comp, Props::parse(props)))
}

/// Models/Data index plus the zlib-compressed Models/N STEP streams. Names are
/// made unique the way KiCad does it (`name_1.ext`, ...).
fn read_models(cf: &mut Cfb, skipped: &mut BTreeMap<&str, usize>) -> (BTreeMap<String, Vec<u8>>, HashMap<String, String>) {
    let mut models = BTreeMap::new();
    let mut by_id = HashMap::new();
    let Some(data) = stream(cf, "/Models/Data") else { return (models, by_id) };
    let Ok(recs) = split_text(&data) else { return (models, by_id) };
    for (i, r) in recs.iter().enumerate() {
        let p = Props::parse(r);
        let Some(raw) = stream(cf, &format!("/Models/{i}")) else {
            *skipped.entry("models_missing").or_default() += 1;
            continue;
        };
        let mut step = vec![];
        if flate2::read::ZlibDecoder::new(&raw[..]).read_to_end(&mut step).is_err() {
            *skipped.entry("models_bad_zlib").or_default() += 1;
            continue;
        }
        let base = p.str("NAME");
        let base = if base.is_empty() || !base.is_ascii() || base.contains(['/', '\\', ':']) {
            format!("model_{i}.step")
        } else {
            base
        };
        let mut name = base.clone();
        let mut k = 1;
        while models.contains_key(&name) {
            name = match base.rsplit_once('.') {
                Some((stem, ext)) => format!("{stem}_{k}.{ext}"),
                None => format!("{base}_{k}"),
            };
            k += 1;
        }
        by_id.insert(p.str("ID"), name.clone());
        models.insert(name, step);
    }
    (models, by_id)
}

fn norm180(a: f64) -> f64 {
    let r = (a + 180.0).rem_euclid(360.0) - 180.0;
    if r == -180.0 { 180.0 } else { r }
}

/// 3D placement exactly as KiCad 10's Altium importer computes a footprint model:
/// offset in footprint-local mm (x right, y up, z standoff) and rotation in
/// degrees, ready for a KiCad `(model (offset) (rotate))` block.
fn body_transform(c: &Component, p: &Props, name: &str) -> Body3d {
    let mx = p.mm("MODEL.2D.X").unwrap_or(c.x);
    let my = p.mm("MODEL.2D.Y").unwrap_or(c.y);
    let mut z = p.mm("MODEL.3D.DZ").unwrap_or(0.0);
    let mut rx = p.f64("MODEL.3D.ROTX").unwrap_or(0.0);
    let ry = p.f64("MODEL.3D.ROTY").unwrap_or(0.0);
    let mut rz = p.f64("MODEL.3D.ROTZ").unwrap_or(0.0);
    let rot2d = p.f64("MODEL.2D.ROTATION").unwrap_or(0.0);
    let projection = p.get("BODYPROJECTION").and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0);
    let flipped = c.side == Side::Bottom;
    let (mut ox, mut oy) = (mx - c.x, my - c.y);
    let mut orient = c.rot;
    if flipped {
        oy = -oy;
        orient = -orient;
    }
    if (projection == 1) != flipped {
        rx += 180.0;
        rz = -rz;
        z = -BOARD_THICKNESS_MM - z;
    }
    let (lx, ly) = rot(ox, oy, -orient);
    ox = lx;
    oy = ly;
    Body3d {
        model: name.to_string(),
        rot: (norm180(-rx), norm180(-ry), norm180(-rz + rot2d + orient)),
        offset: (ox, oy, z),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::bbox_of_shapes;

    fn line(x: f64, y: f64) -> Vertex {
        Vertex { arc: false, x, y, cx: 0.0, cy: 0.0, r: 0.0, sa: 0.0, ea: 0.0 }
    }

    #[test]
    fn rounded_corner_outline() {
        // 10 x 10 square whose top-right corner is a radius-2 arc (center 8,8).
        let arc = Vertex { arc: true, x: 10.0, y: 8.0, cx: 8.0, cy: 8.0, r: 2.0, sa: 0.0, ea: 90.0 };
        let v = [line(0.0, 0.0), line(10.0, 0.0), arc, line(8.0, 10.0), line(0.0, 10.0), line(0.0, 0.0)];
        let shapes = outline_shapes(&v, 0.1);
        let b = bbox_of_shapes(&shapes);
        assert!((b.xmax - 10.0).abs() < 1e-9 && (b.ymax - 10.0).abs() < 1e-9 && b.xmin == 0.0 && b.ymin == 0.0);
        let arcs: Vec<_> = shapes.iter().filter_map(|s| if let Shape::Seg(g) = s { g.mid } else { None }).collect();
        assert_eq!(arcs.len(), 1);
        let h = 2.0 * std::f64::consts::FRAC_1_SQRT_2;
        assert!((arcs[0].0 - (8.0 + h)).abs() < 1e-9 && (arcs[0].1 - (8.0 + h)).abs() < 1e-9);
        // Closed loop: every segment end meets the next segment start.
        let segs: Vec<_> = shapes.iter().filter_map(|s| if let Shape::Seg(g) = s { Some(g) } else { None }).collect();
        for w in segs.windows(2) {
            assert!((w[0].x2 - w[1].x1).abs() < 1e-9 && (w[0].y2 - w[1].y1).abs() < 1e-9);
        }
    }
}