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