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
//! A preview-fidelity board model shared by all three EDA readers.
//!
//! It holds what is needed to draw a board and to rebuild it as a KiCad board for 3D
//! export: outline, copper pads, tracks, vias, silkscreen strokes, and component
//! placements. It is not a netlist-accurate translation (no zones, rules, or nets)
//! and never pretends to be one.
//!
//! Frame: millimeters, Y up, same as plan.rs. Angles in degrees, counterclockwise.

use crate::plan::BBox;
use std::collections::BTreeMap;

/// A straight segment, or an arc through `mid` when it is set.
#[derive(Debug, Clone)]
pub struct Seg {
    pub x1: f64,
    pub y1: f64,
    pub x2: f64,
    pub y2: f64,
    pub width: f64,
    pub mid: Option<(f64, f64)>,
}

#[derive(Debug, Clone)]
pub struct Circle {
    pub cx: f64,
    pub cy: f64,
    pub r: f64,
    pub width: f64,
    pub fill: bool,
}

#[derive(Debug, Clone)]
pub enum Shape {
    Seg(Seg),
    Circle(Circle),
    /// Closed filled polygon (silk or copper fills).
    Poly(Vec<(f64, f64)>),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
    Top,
    Bottom,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PadShape {
    Rect,
    Circle,
    Oval,
    RoundRect,
}

#[derive(Debug, Clone)]
pub struct Pad {
    pub x: f64,
    pub y: f64,
    pub w: f64,
    pub h: f64,
    pub shape: PadShape,
    /// Absolute rotation in degrees CCW.
    pub rot: f64,
    /// > 0 means through-hole (copper on both sides).
    pub drill: f64,
    pub side: Side,
    pub name: String,
}

#[derive(Debug, Clone)]
pub struct Via {
    pub x: f64,
    pub y: f64,
    pub dia: f64,
    pub drill: f64,
}

/// An embedded 3D body (Altium carries STEP models inside the board).
#[derive(Debug, Clone)]
pub struct Body3d {
    /// Key into `Board::models`.
    pub model: String,
    /// Rotations about X, Y, Z in degrees, applied before the placement.
    pub rot: (f64, f64, f64),
    /// Offset from the component origin in mm (z = standoff).
    pub offset: (f64, f64, f64),
}

#[derive(Debug, Clone)]
pub struct Component {
    pub r#ref: String,
    pub x: f64,
    pub y: f64,
    pub rot: f64,
    pub side: Side,
    pub value: String,
    pub package: String,
    /// Absolute board coordinates.
    pub pads: Vec<Pad>,
    pub bodies: Vec<Body3d>,
}

#[derive(Debug, Clone)]
pub struct Board {
    pub eda: String,
    /// Shapes on the edge/outline layer.
    pub outline: Vec<Shape>,
    pub tracks_top: Vec<Seg>,
    pub tracks_bottom: Vec<Seg>,
    pub vias: Vec<Via>,
    /// Pads not owned by a component.
    pub free_pads: Vec<Pad>,
    pub silk_top: Vec<Shape>,
    pub silk_bottom: Vec<Shape>,
    /// Filled copper pours (closed polygons).
    pub pours_top: Vec<Vec<(f64, f64)>>,
    pub pours_bottom: Vec<Vec<(f64, f64)>>,
    pub components: Vec<Component>,
    /// Embedded STEP models by name.
    pub models: BTreeMap<String, Vec<u8>>,
    /// Free-form counts and notes from the reader.
    pub meta: BTreeMap<String, String>,
}

impl Board {
    pub fn new(eda: &str) -> Board {
        Board {
            eda: eda.into(),
            outline: vec![],
            tracks_top: vec![],
            tracks_bottom: vec![],
            vias: vec![],
            free_pads: vec![],
            silk_top: vec![],
            silk_bottom: vec![],
            pours_top: vec![],
            pours_bottom: vec![],
            components: vec![],
            models: BTreeMap::new(),
            meta: BTreeMap::new(),
        }
    }

    pub fn outline_bbox(&self) -> anyhow::Result<BBox> {
        let b = bbox_of_shapes(&self.outline);
        if b.is_empty() {
            anyhow::bail!("no board outline found");
        }
        Ok(b)
    }

    pub fn all_pads(&self) -> impl Iterator<Item = &Pad> {
        self.components.iter().flat_map(|c| c.pads.iter()).chain(self.free_pads.iter())
    }

    /// Placement self-check: track ends that do not meet another track must land on
    /// a pad or via on the same side. Returns (landed, checked). A reader that gets
    /// footprint rotation or mirroring wrong scores far below 1.0.
    pub fn track_end_check(&self) -> (usize, usize) {
        use std::collections::HashMap;
        let key = |x: f64, y: f64| ((x * 50.0).round() as i64, (y * 50.0).round() as i64);
        let mut landed = 0;
        let mut checked = 0;
        for (side, tracks) in [(Side::Top, &self.tracks_top), (Side::Bottom, &self.tracks_bottom)] {
            let mut count: HashMap<(i64, i64), usize> = HashMap::new();
            for t in tracks.iter() {
                *count.entry(key(t.x1, t.y1)).or_default() += 1;
                *count.entry(key(t.x2, t.y2)).or_default() += 1;
            }
            let pads: Vec<&Pad> = self.all_pads().filter(|p| p.drill > 0.0 || p.side == side).collect();
            for t in tracks.iter() {
                for (x, y) in [(t.x1, t.y1), (t.x2, t.y2)] {
                    if count[&key(x, y)] > 1 {
                        continue;
                    }
                    checked += 1;
                    let on_pad = pads.iter().any(|p| {
                        let (lx, ly) = rot(x - p.x, y - p.y, -p.rot);
                        lx.abs() <= p.w / 2.0 + 0.02 && ly.abs() <= p.h / 2.0 + 0.02
                    });
                    let on_via = self.vias.iter().any(|v| (v.x - x).hypot(v.y - y) <= v.dia / 2.0 + 0.02);
                    if on_pad || on_via {
                        landed += 1;
                    }
                }
            }
        }
        (landed, checked)
    }

    pub fn pad_count(&self) -> usize {
        self.components.iter().map(|c| c.pads.len()).sum::<usize>() + self.free_pads.len()
    }
}

/// Center and radius of the circle through three points, or None if collinear.
pub fn circle_from_3pts(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<(f64, f64, f64)> {
    let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
    if d.abs() < 1e-12 {
        return None;
    }
    let (a2, b2, c2) = (a.0 * a.0 + a.1 * a.1, b.0 * b.0 + b.1 * b.1, c.0 * c.0 + c.1 * c.1);
    let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;
    let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;
    Some((ux, uy, (a.0 - ux).hypot(a.1 - uy)))
}

/// Endpoints plus every axis extreme the arc start-mid-end actually passes.
pub fn arc_extent_points(p1: (f64, f64), mid: (f64, f64), p2: (f64, f64)) -> Vec<(f64, f64)> {
    let mut pts = vec![p1, mid, p2];
    let Some((ux, uy, r)) = circle_from_3pts(p1, mid, p2) else { return pts };
    let tau = std::f64::consts::TAU;
    let ang = |p: (f64, f64)| (p.1 - uy).atan2(p.0 - ux);
    let ccw = |a: f64, b: f64| (b - a).rem_euclid(tau);
    let (a1, am, a2) = (ang(p1), ang(mid), ang(p2));
    let (start, sweep) = if ccw(a1, am) <= ccw(a1, a2) { (a1, ccw(a1, a2)) } else { (a2, ccw(a2, a1)) };
    for k in 0..4 {
        let a = k as f64 * tau / 4.0;
        if ccw(start, a) <= sweep {
            pts.push((ux + r * a.cos(), uy + r * a.sin()));
        }
    }
    pts
}

/// Mid point of an arc given its center, start point, and CCW sweep in degrees.
pub fn arc_mid_from_center(cx: f64, cy: f64, x1: f64, y1: f64, sweep_deg: f64) -> (f64, f64) {
    let a = (y1 - cy).atan2(x1 - cx) + sweep_deg.to_radians() / 2.0;
    let r = (x1 - cx).hypot(y1 - cy);
    (cx + r * a.cos(), cy + r * a.sin())
}

pub fn bbox_of_shapes(shapes: &[Shape]) -> BBox {
    let mut b = BBox::empty();
    for s in shapes {
        match s {
            Shape::Circle(c) => {
                b.add(c.cx - c.r, c.cy - c.r);
                b.add(c.cx + c.r, c.cy + c.r);
            }
            Shape::Seg(s) => match s.mid {
                Some(m) => arc_extent_points((s.x1, s.y1), m, (s.x2, s.y2)).into_iter().for_each(|(x, y)| b.add(x, y)),
                None => {
                    b.add(s.x1, s.y1);
                    b.add(s.x2, s.y2);
                }
            },
            Shape::Poly(p) => p.iter().for_each(|&(x, y)| b.add(x, y)),
        }
    }
    b
}

/// Rotate (x, y) CCW by `deg` about the origin.
pub fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) {
    if deg == 0.0 {
        return (x, y);
    }
    let (s, c) = deg.to_radians().sin_cos();
    (x * c - y * s, x * s + y * c)
}

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

    #[test]
    fn quarter_arc_extent() {
        // Quarter circle from (1,0) through 45 deg to (0,1): no extra extremes.
        let h = std::f64::consts::FRAC_1_SQRT_2;
        assert_eq!(arc_extent_points((1.0, 0.0), (h, h), (0.0, 1.0)).len(), 5); // two axis hits at the ends
        // Half circle over the top passes (0,1).
        let pts = arc_extent_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0));
        assert!(pts.iter().any(|p| (p.1 - 1.0).abs() < 1e-9));
    }
}