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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
//! Fusion / EAGLE .brd lane (EAGLE 6+ XML; Fusion Electronics exports the same format).
//!
//! The writer patches the XML text by byte span, like the KiCad lane: the outer
//! Dimension-layer outline goes, a new rectangle goes into <plain>, the Adom master
//! pin packages go into <libraries>, and MP1-MP4 go into <elements>. The packages
//! are copied verbatim from Adom_Library_Master, so Fusion resolves the real 3D pin
//! and step2glb reads the pin size from the package name.
use crate::model::{self, Board, Circle, Component, Pad, PadShape, Seg, Shape, Side, Via};
use crate::plan::{BBox, Plan};
use anyhow::{bail, Context, Result};
use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
pub const MASTER_PINS: &str = include_str!("../../assets/pins/adom_master_pins.eagle.xml");
pub const MASTER_LIB: &str = "Adom_Library_Master Library";
pub const MASTER_URN: &str = "urn:adsk.wipprod:fs.file:vf.en6fDvCzT4yZQjaNTF2DDA";
const DIMENSION: &str = "20";
/// A minimal DOM with byte spans.
#[derive(Debug, Clone)]
pub struct El {
pub tag: String,
pub attrs: Vec<(String, String)>,
pub children: Vec<El>,
pub start: usize,
pub end: usize,
/// Byte offset of the closing tag (== end for self-closing elements).
pub close: usize,
}
impl El {
pub fn a(&self, k: &str) -> Option<&str> {
self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
}
pub fn f(&self, k: &str) -> f64 {
self.a(k).and_then(|v| v.parse().ok()).unwrap_or(0.0)
}
pub fn child(&self, tag: &str) -> Option<&El> {
self.children.iter().find(|c| c.tag == tag)
}
pub fn kids<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a El> + 'a {
self.children.iter().filter(move |c| c.tag == tag)
}
/// Descend a path like "drawing/board/plain".
pub fn path(&self, p: &str) -> Option<&El> {
p.split('/').try_fold(self, |e, t| e.child(t))
}
}
fn el_of(e: &BytesStart, start: usize) -> El {
let attrs = e
.attributes()
.flatten()
.map(|a| (String::from_utf8_lossy(a.key.as_ref()).into_owned(), a.unescape_value().map(|v| v.into_owned()).unwrap_or_default()))
.collect();
El { tag: String::from_utf8_lossy(e.name().as_ref()).into_owned(), attrs, children: vec![], start, end: 0, close: 0 }
}
pub fn parse(text: &str) -> Result<El> {
let mut r = Reader::from_str(text);
r.config_mut().trim_text(false);
let mut stack: Vec<El> = vec![];
loop {
let pos = r.buffer_position() as usize;
match r.read_event().context("XML parse")? {
Event::Start(e) => stack.push(el_of(&e, pos)),
Event::Empty(e) => {
let mut el = el_of(&e, pos);
el.end = r.buffer_position() as usize;
el.close = el.end;
match stack.last_mut() {
Some(p) => p.children.push(el),
None => return Ok(el),
}
}
Event::End(_) => {
let mut el = stack.pop().context("unbalanced XML")?;
el.close = pos;
el.end = r.buffer_position() as usize;
match stack.last_mut() {
Some(p) => p.children.push(el),
None => return Ok(el),
}
}
Event::Eof => bail!("unexpected end of XML"),
_ => {}
}
}
}
fn load(path: &Path) -> Result<(String, El)> {
let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
if !bytes.starts_with(b"<?xml") && !bytes.starts_with(b"<eagle") && !bytes.starts_with(b"\xEF\xBB\xBF") {
bail!("{} is not an XML board (EAGLE 5 and older used a binary format; re-save it in EAGLE 6+ or Fusion)", path.display());
}
let text = String::from_utf8(bytes).context("board is not UTF-8")?;
let root = parse(&text)?;
anyhow::ensure!(root.tag == "eagle", "root element is <{}>, expected <eagle>", root.tag);
Ok((text, root))
}
fn board_el(root: &El) -> Result<&El> {
root.path("drawing/board").context("no <drawing><board> in this file (is it a schematic?)")
}
/// Mid point of an EAGLE wire with a `curve` (degrees, CCW positive from 1 to 2).
fn curve_mid(x1: f64, y1: f64, x2: f64, y2: f64, curve: f64) -> Option<(f64, f64)> {
if curve.abs() < 1e-9 {
return None;
}
let (dx, dy) = (x2 - x1, y2 - y1);
let c = dx.hypot(dy);
if c < 1e-12 {
return None;
}
let half = curve.to_radians() / 2.0;
let r = c / (2.0 * half.sin());
let sag = r * (1.0 - half.cos());
// Positive curves bulge to the right of the direction 1 -> 2.
Some(((x1 + x2) / 2.0 + dy / c * sag, (y1 + y2) / 2.0 - dx / c * sag))
}
fn wire_shape(w: &El) -> Shape {
let (x1, y1, x2, y2) = (w.f("x1"), w.f("y1"), w.f("x2"), w.f("y2"));
Shape::Seg(Seg { x1, y1, x2, y2, width: w.f("width"), mid: curve_mid(x1, y1, x2, y2, w.f("curve")) })
}
fn shapes_of(e: &El) -> Vec<Shape> {
match e.tag.as_str() {
"wire" => vec![wire_shape(e)],
"circle" => {
let w = e.f("width");
vec![Shape::Circle(Circle { cx: e.f("x"), cy: e.f("y"), r: e.f("radius"), width: w, fill: w == 0.0 })]
}
"rectangle" => {
let (x1, y1, x2, y2) = (e.f("x1"), e.f("y1"), e.f("x2"), e.f("y2"));
let (cx, cy) = ((x1 + x2) / 2.0, (y1 + y2) / 2.0);
let r = rot_of(e.a("rot")).1;
vec![Shape::Poly([(x1, y1), (x2, y1), (x2, y2), (x1, y2)].iter().map(|&(x, y)| {
let (a, b) = model::rot(x - cx, y - cy, r);
(a + cx, b + cy)
}).collect())]
}
"polygon" => vec![Shape::Poly(poly_pts(e))],
_ => vec![],
}
}
fn poly_pts(e: &El) -> Vec<(f64, f64)> {
let v: Vec<&El> = e.kids("vertex").collect();
let mut pts = vec![];
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
pts.push((a.f("x"), a.f("y")));
if let Some(m) = curve_mid(a.f("x"), a.f("y"), b.f("x"), b.f("y"), a.f("curve")) {
pts.push(m);
}
}
pts
}
/// "MR90" -> (mirrored, 90.0). Spin (S) does not change geometry.
fn rot_of(s: Option<&str>) -> (bool, f64) {
let s = s.unwrap_or("R0");
let mirror = s.contains('M');
let deg = s.trim_start_matches(|c: char| c.is_ascii_alphabetic()).parse().unwrap_or(0.0);
(mirror, deg)
}
/// Outline bbox in mm (EAGLE is Y up already).
pub fn outline_bbox(path: &Path) -> Result<BBox> {
let (_, root) = load(path)?;
let b = model::bbox_of_shapes(&dimension_items(board_el(&root)?).iter().flat_map(|e| shapes_of(e)).collect::<Vec<_>>());
anyhow::ensure!(!b.is_empty(), "no Dimension-layer (20) outline in {}", path.display());
Ok(b)
}
fn dimension_items(board: &El) -> Vec<&El> {
board.child("plain").map(|p| p.children.iter().filter(|e| e.a("layer") == Some(DIMENSION)).collect()).unwrap_or_default()
}
/// Spans of the outer outline (same rule as the KiCad lane).
fn outer_outline_spans(board: &El) -> Vec<(usize, usize)> {
let items = dimension_items(board);
let all: Vec<Shape> = items.iter().flat_map(|e| shapes_of(e)).collect();
let bb = model::bbox_of_shapes(&all);
let eps = 0.5;
let full = |b: &BBox| (b.xmin - bb.xmin).abs() < eps && (b.xmax - bb.xmax).abs() < eps && (b.ymin - bb.ymin).abs() < eps && (b.ymax - bb.ymax).abs() < eps;
let mut spans = vec![];
let wires: Vec<&&El> = items.iter().filter(|e| e.tag == "wire").collect();
for e in items.iter().filter(|e| e.tag != "wire") {
if full(&model::bbox_of_shapes(&shapes_of(e))) {
spans.push((e.start, e.end));
}
}
let mut parent: Vec<usize> = (0..wires.len()).collect();
fn find(p: &mut Vec<usize>, mut i: usize) -> usize {
while p[i] != i {
p[i] = p[p[i]];
i = p[i];
}
i
}
let key = |x: f64, y: f64| ((x / 0.01).round() as i64, (y / 0.01).round() as i64);
let mut seen = HashMap::new();
for (k, w) in wires.iter().enumerate() {
for p in [key(w.f("x1"), w.f("y1")), key(w.f("x2"), w.f("y2"))] {
if let Some(&o) = seen.get(&p) {
let (a, b) = (find(&mut parent, k), find(&mut parent, o));
parent[a] = b;
} else {
seen.insert(p, k);
}
}
}
let mut groups: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for k in 0..wires.len() {
let r = find(&mut parent, k);
groups.entry(r).or_default().push(k);
}
for g in groups.values() {
let shapes: Vec<Shape> = g.iter().map(|&k| wire_shape(wires[k])).collect();
if full(&model::bbox_of_shapes(&shapes)) {
spans.extend(g.iter().map(|&k| (wires[k].start, wires[k].end)));
}
}
spans.sort();
spans
}
fn x(v: f64) -> String {
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
/// The master library block, with the package3d list trimmed to what is used.
fn master_library() -> &'static str {
MASTER_PINS.trim()
}
/// Write the molecule.
pub fn write_molecule(src: &Path, plan: &Plan, out: &Path) -> Result<Value> {
let (text, root) = load(src)?;
let board = board_el(&root)?;
let plain = board.child("plain").context("board has no <plain> section")?;
let libs = board.child("libraries").context("board has no <libraries> section")?;
let elements = board.child("elements").context("board has no <elements> section")?;
let s = plan.spec();
let taken: Vec<&str> = elements.kids("element").filter_map(|e| e.a("name")).collect();
let clash: Vec<&str> = plan.pins.iter().map(|p| p.r#ref.as_str()).filter(|r| taken.contains(r)).collect();
if !clash.is_empty() {
bail!("the board already has parts named {}; the molecule pins must be MP1-MP4. Rename those parts first", clash.join(", "));
}
let spans = outer_outline_spans(board);
// Library: reuse the master library when it already carries the package.
let master = libs.kids("library").find(|l| l.a("name") == Some(MASTER_LIB));
let has_pkg = master.is_some_and(|l| l.child("packages").is_some_and(|p| p.kids("package").any(|k| k.a("name") == Some(s.eagle_package))));
let lib_name = if master.is_none() || has_pkg { MASTER_LIB.to_string() } else { format!("{MASTER_LIB} (board-to-molecule)") };
let pkg3d_urn = pkg3d_urn(s.eagle_package);
let mut ins: Vec<(usize, usize, String)> = spans.iter().map(|&(a, b)| {
let a2 = text[..a].trim_end_matches([' ', '\t']).len();
let a2 = if text[..a2].ends_with('\n') { a2 - 1 } else { a2 };
(a2, b, String::new())
}).collect();
let n = &plan.new;
let corners = [(n.xmin, n.ymin), (n.xmax, n.ymin), (n.xmax, n.ymax), (n.xmin, n.ymax)];
let mut outline = String::new();
for i in 0..4 {
let (a, b) = (corners[i], corners[(i + 1) % 4]);
outline.push_str(&format!("<wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0\" layer=\"20\"/>\n", x(a.0), x(a.1), x(b.0), x(b.1)));
}
if plan.vcut {
for l in plan.vcut_lines() {
outline.push_str(&format!("<wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.2\" layer=\"48\"/>\n", x(l[0]), x(l[1]), x(l[2]), x(l[3])));
}
}
ins.push((plain.close, plain.close, outline));
if master.is_none() || !has_pkg {
let lib = master_library().replacen(&format!("name=\"{MASTER_LIB}\""), &format!("name=\"{lib_name}\""), 1);
ins.push((libs.close, libs.close, format!("{lib}\n")));
}
let mut els = String::new();
for p in &plan.pins {
els.push_str(&format!(
"<element name=\"{}\" library=\"{lib_name}\" library_urn=\"{MASTER_URN}\" package=\"{}\" package3d_urn=\"{pkg3d_urn}\" value=\"\" x=\"{}\" y=\"{}\"/>\n",
p.r#ref, s.eagle_package, x(p.x), x(p.y)
));
}
ins.push((elements.close, elements.close, els));
ins.sort_by_key(|c| (c.0, c.1));
let mut o = String::with_capacity(text.len() + 8192);
let mut last = 0;
for (a, b, s) in &ins {
o.push_str(&text[last..*a]);
o.push_str(s);
last = *b;
}
o.push_str(&text[last..]);
parse(&o).context("internal error: the written board does not parse as XML")?;
std::fs::write(out, &o).with_context(|| format!("write {}", out.display()))?;
Ok(json!({
"eagle_version": root.a("version"),
"outline_items_removed": spans.len(),
"pins_added": plan.pins.len(),
"package": s.eagle_package,
"library": lib_name,
"library_added": master.is_none() || !has_pkg,
"bytes_in": text.len(),
"bytes_out": o.len(),
}))
}
/// package3d URN of a master pin package.
fn pkg3d_urn(pkg: &str) -> &'static str {
if pkg.contains("LRG") { "urn:adsk.eagle:package:47088803/8" } else { "urn:adsk.eagle:package:47088807/7" }
}
struct Pkg<'a> {
el: &'a El,
}
/// Preview model.
pub fn read(path: &Path) -> Result<Board> {
let (_, root) = load(path)?;
let board = board_el(&root)?;
let mut b = Board::new("eagle");
b.meta.insert("eagle_version".into(), root.a("version").unwrap_or("?").into());
if let Some(plain) = board.child("plain") {
for e in &plain.children {
let sh = shapes_of(e);
match e.a("layer") {
Some("20") => b.outline.extend(sh),
Some("46") => b.outline.extend(sh.into_iter().filter(|s| matches!(s, Shape::Circle(_)))),
Some("21") => b.silk_top.extend(sh),
Some("22") => b.silk_bottom.extend(sh),
Some("1") if e.tag == "polygon" => b.pours_top.push(poly_pts(e)),
Some("16") if e.tag == "polygon" => b.pours_bottom.push(poly_pts(e)),
Some("1") => b.tracks_top.extend(sh.into_iter().filter_map(|s| if let Shape::Seg(g) = s { Some(g) } else { None })),
Some("16") => b.tracks_bottom.extend(sh.into_iter().filter_map(|s| if let Shape::Seg(g) = s { Some(g) } else { None })),
_ => {}
}
if e.tag == "hole" {
b.outline.push(Shape::Circle(Circle { cx: e.f("x"), cy: e.f("y"), r: e.f("drill") / 2.0, width: 0.0, fill: false }));
}
}
}
let mut pkgs: HashMap<(String, String), Pkg> = HashMap::new();
if let Some(libs) = board.child("libraries") {
for l in libs.kids("library") {
let ln = l.a("name").unwrap_or("").to_string();
if let Some(ps) = l.child("packages") {
for p in ps.kids("package") {
pkgs.insert((ln.clone(), p.a("name").unwrap_or("").to_string()), Pkg { el: p });
}
}
}
}
if let Some(els) = board.child("elements") {
for e in els.kids("element") {
let (mirror, deg) = rot_of(e.a("rot"));
let (ex, ey) = (e.f("x"), e.f("y"));
let side = if mirror { Side::Bottom } else { Side::Top };
// Mirrored parts: flip X in package space, then rotate the other way.
let tf = |px: f64, py: f64| {
let (mx, a) = if mirror { (-px, -deg) } else { (px, deg) };
let (rx, ry) = model::rot(mx, py, a);
(rx + ex, ry + ey)
};
let mut c = Component {
r#ref: e.a("name").unwrap_or("?").into(),
x: ex,
y: ey,
rot: deg,
side,
value: e.a("value").unwrap_or("").into(),
package: e.a("package").unwrap_or("").into(),
pads: vec![],
bodies: vec![],
};
let key = (e.a("library").unwrap_or("").to_string(), e.a("package").unwrap_or("").to_string());
if let Some(pk) = pkgs.get(&key) {
for g in &pk.el.children {
match g.tag.as_str() {
"pad" => {
let (px, py) = tf(g.f("x"), g.f("y"));
let drill = g.f("drill");
let dia = if g.f("diameter") > 0.0 { g.f("diameter") } else { drill + 2.0 * (drill * 0.25).max(0.254) };
let (shape, w, h) = match g.a("shape").unwrap_or("round") {
"square" => (PadShape::Rect, dia, dia),
"long" => (PadShape::Oval, dia * 2.0, dia),
"offset" => (PadShape::Oval, dia * 2.0, dia),
"octagon" => (PadShape::Circle, dia, dia),
_ => (PadShape::Circle, dia, dia),
};
let r = rot_of(g.a("rot")).1;
c.pads.push(Pad { x: px, y: py, w, h, shape, rot: if mirror { -(deg + r) } else { deg + r }, drill, side: Side::Top, name: g.a("name").unwrap_or("").into() });
}
"smd" => {
let (px, py) = tf(g.f("x"), g.f("y"));
let (dx, dy) = (g.f("dx"), g.f("dy"));
let rnd = g.f("roundness");
let shape = if rnd >= 100.0 { if (dx - dy).abs() < 1e-9 { PadShape::Circle } else { PadShape::Oval } } else if rnd > 0.0 { PadShape::RoundRect } else { PadShape::Rect };
let on_bottom = (g.a("layer") == Some("16")) != mirror;
let r = rot_of(g.a("rot")).1;
c.pads.push(Pad { x: px, y: py, w: dx, h: dy, shape, rot: if mirror { -(deg + r) } else { deg + r }, drill: 0.0, side: if on_bottom { Side::Bottom } else { Side::Top }, name: g.a("name").unwrap_or("").into() });
}
"wire" | "circle" | "rectangle" | "polygon" => {
let layer = g.a("layer").unwrap_or("");
let top = match layer { "21" => !mirror, "22" => mirror, _ => continue };
let shapes = shapes_of(g).into_iter().map(|s| place(&s, &tf)).collect::<Vec<_>>();
if top { b.silk_top.extend(shapes) } else { b.silk_bottom.extend(shapes) }
}
"hole" => {
let (hx, hy) = tf(g.f("x"), g.f("y"));
b.outline.push(Shape::Circle(Circle { cx: hx, cy: hy, r: g.f("drill") / 2.0, width: 0.0, fill: false }));
}
_ => {}
}
}
}
b.components.push(c);
}
}
if let Some(sigs) = board.child("signals") {
for s in sigs.kids("signal") {
for g in &s.children {
match (g.tag.as_str(), g.a("layer")) {
("wire", Some("1")) => if let Shape::Seg(w) = wire_shape(g) { b.tracks_top.push(w) },
("wire", Some("16")) => if let Shape::Seg(w) = wire_shape(g) { b.tracks_bottom.push(w) },
("polygon", Some("1")) => b.pours_top.push(poly_pts(g)),
("polygon", Some("16")) => b.pours_bottom.push(poly_pts(g)),
("via", _) => {
let drill = g.f("drill");
let dia = if g.f("diameter") > 0.0 { g.f("diameter") } else { drill + 2.0 * (drill * 0.25).max(0.15) };
b.vias.push(Via { x: g.f("x"), y: g.f("y"), dia, drill });
}
_ => {}
}
}
}
}
b.meta.insert("libraries".into(), pkgs.len().to_string());
Ok(b)
}
fn place(s: &Shape, tf: &dyn Fn(f64, f64) -> (f64, f64)) -> Shape {
match s {
Shape::Seg(g) => {
let (x1, y1) = tf(g.x1, g.y1);
let (x2, y2) = tf(g.x2, g.y2);
Shape::Seg(Seg { x1, y1, x2, y2, width: g.width, mid: g.mid.map(|m| tf(m.0, m.1)) })
}
Shape::Circle(c) => {
let (cx, cy) = tf(c.cx, c.cy);
Shape::Circle(Circle { cx, cy, ..c.clone() })
}
Shape::Poly(p) => Shape::Poly(p.iter().map(|&(a, b)| tf(a, b)).collect()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curve_mid_half_circle() {
let m = curve_mid(1.0, 0.0, -1.0, 0.0, 180.0).unwrap();
assert!((m.0).abs() < 1e-9 && (m.1 - 1.0).abs() < 1e-9);
let m = curve_mid(1.0, 0.0, -1.0, 0.0, -180.0).unwrap();
assert!((m.1 + 1.0).abs() < 1e-9);
}
#[test]
fn rot_strings() {
assert_eq!(rot_of(Some("MR90")), (true, 90.0));
assert_eq!(rot_of(Some("SR270")), (false, 270.0));
assert_eq!(rot_of(Some("R12.5")), (false, 12.5));
}
#[test]
fn master_library_parses() {
let l = parse(MASTER_PINS.trim()).unwrap();
assert_eq!(l.a("name"), Some(MASTER_LIB));
assert_eq!(l.child("packages").unwrap().kids("package").count(), 2);
}
}