app
Board to Molecule
Public Made by Adomby adom
Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings plus MP1-MP4 machine pins, written back in the board's own format.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
//! 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));
}
}