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
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);
}
}
}