main
John Lauer adom-fields 0.7.17: the open PRs merged onto 0.7.16. Plated pad barrels and drill voids with wall coupling (5, 9), expl… df40420
//! The Fusion reader: a Fusion Electronics board as EAGLE source (.brd XML, what Fusion's
//! Document.CopyToDesktop / the Fusion bridge's fusion_export_eagle_source write).
//!
//! The source carries the outline, the packages, the placed elements, and every signal's wires,
//! vias and pours. Fusion writes each pour as `<polygonpour>` with the outline the user drew and,
//! once it has been filled (RATSNEST), its computed fill in `<polygonfilldetails>`: every island's
//! outline and holes. The reader uses that fill as-is (`Board.fills`), like KiCad's filled polygons.
//! A pour never filled comes in as a `Pour` (outline, net, layer, isolate) and `Raster::draw_board`
//! fills it on the solve grid: the outline minus other nets' copper grown by the isolate, keeping
//! the islands that touch the pour's own net.
//!
//! Coordinates: EAGLE is millimetres, y up. KiCad (which the rest of adom-fields speaks) is y down,
//! so y is negated on the way in; reports and `--at` points use the negated y, as for a KiCad board.
use crate::board::{Board, FillShape, Pad, Poly, Pour, Track, Via, LAYERS};
use std::collections::HashMap;

fn layer_name(n: &str, inner: &[String]) -> Option<String> {
    match n {
        "1" => Some("F.Cu".into()),
        "16" => Some("B.Cu".into()),
        x => { let k: u32 = x.parse().ok()?; if (2..=15).contains(&k) { inner.iter().position(|l| l == x).and_then(|i| LAYERS.get(1 + i)).map(|s| s.to_string()) } else { None } }
    }
}

/// "R90", "MR180", "SR45" -> (degrees, mirrored)
fn parse_rot(r: Option<&str>) -> (f64, bool) {
    let r = r.unwrap_or("R0");
    let mirror = r.contains('M');
    let deg = r.trim_start_matches(|c: char| c.is_ascii_alphabetic()).parse::<f64>().unwrap_or(0.0);
    (deg, mirror)
}
fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) { let a = deg.to_radians(); (x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos()) }
fn f(n: &roxmltree::Node, k: &str) -> f64 { n.attribute(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) }

pub fn is_eagle(text: &str) -> bool { let head: String = text.chars().take(600).collect(); head.contains("<eagle") }

pub fn load(text: &str) -> Result<Board, String> {
    let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| format!("not a readable EAGLE board: {e}"))?;
    let board = doc.descendants().find(|n| n.has_tag_name("board")).ok_or("no <board> in the file: export the BOARD (.brd) from Fusion, not the schematic")?;
    let mut b = Board::default();
    // the declared stack (Fusion's layerSetup, "(1*2*15*16)") is the physical order, hidden or empty layers included (PR 5);
    // without it, the used inner copper layers in stack order
    let declared: Option<Vec<u32>> = board.descendants().find(|n| n.has_tag_name("param") && n.attribute("name") == Some("layerSetup")).and_then(|n| n.attribute("value")).map(|v| { let mut x: Vec<u32> = v.split(|c: char| !c.is_ascii_digit()).filter_map(|t| t.parse().ok()).filter(|k| (1..=16).contains(k)).collect(); x.sort_unstable(); x.dedup(); x });
    let mut inner: Vec<String> = Vec::new();
    for l in doc.descendants().filter(|n| n.has_tag_name("layer")) {
        let num = l.attribute("number").unwrap_or("");
        if let Ok(k) = num.parse::<u32>() { if (2..=15).contains(&k) && l.attribute("active") != Some("no") && l.attribute("visible") != Some("no") { inner.push(num.to_string()); } }
    }
    // only inner layers that actually carry copper count
    let used: std::collections::BTreeSet<String> = board.descendants().filter_map(|n| n.attribute("layer").map(str::to_string)).collect();
    inner.retain(|l| used.contains(l));
    if let Some(d) = &declared { if d.first() == Some(&1) && d.last() == Some(&16) { inner = d.iter().filter(|k| **k != 1 && **k != 16).map(|k| k.to_string()).collect(); } }
    if inner.len() > 4 { return Err(format!("this Fusion board has {} inner copper layers: boards up to six copper layers are supported", inner.len())); }
    b.layer_names = std::iter::once("F.Cu".to_string()).chain((0..inner.len()).map(|i| LAYERS[1 + i].to_string())).chain(std::iter::once("B.Cu".to_string())).collect();
    let y = |v: f64| -v;

    // outline: the Dimension layer (20) of the plain section
    let (mut xs, mut ys) = (Vec::new(), Vec::new());
    if let Some(plain) = board.children().find(|n| n.has_tag_name("plain")) {
        for w in plain.children().filter(|n| n.attribute("layer") == Some("20")) {
            match w.tag_name().name() {
                "wire" => { xs.extend([f(&w, "x1"), f(&w, "x2")]); ys.extend([y(f(&w, "y1")), y(f(&w, "y2"))]); }
                "rectangle" => { xs.extend([f(&w, "x1"), f(&w, "x2")]); ys.extend([y(f(&w, "y1")), y(f(&w, "y2"))]); }
                "polygon" => for v in w.children().filter(|n| n.has_tag_name("vertex")) { xs.push(f(&v, "x")); ys.push(y(f(&v, "y"))); },
                "circle" => { let (cx, cy, r) = (f(&w, "x"), y(f(&w, "y")), f(&w, "radius")); xs.extend([cx - r, cx + r]); ys.extend([cy - r, cy + r]); }
                _ => {}
            }
        }
    }
    if xs.is_empty() { return Err("no board outline: the Dimension layer (20) is empty".into()); }
    b.x0 = xs.iter().cloned().fold(f64::MAX, f64::min); b.x1 = xs.iter().cloned().fold(f64::MIN, f64::max);
    b.y0 = ys.iter().cloned().fold(f64::MAX, f64::min); b.y1 = ys.iter().cloned().fold(f64::MIN, f64::max);

    // the board's restring rules size every pad and via, as Fusion does: the ring is the drill
    // times the ratio, held between the minimum and the maximum; a larger library diameter wins
    let param = |name: &str| board.descendants().find(|x| x.has_tag_name("param") && x.attribute("name") == Some(name)).and_then(|x| x.attribute("value")).map(str::to_string);
    let len = |v: Option<String>, d: f64| v.and_then(|t| { let t = t.trim(); if let Some(m) = t.strip_suffix("mil") { m.parse::<f64>().ok().map(|x| x * 0.0254) } else { t.trim_end_matches("mm").parse::<f64>().ok() } }).unwrap_or(d);
    let ratio = |v: Option<String>, d: f64| v.and_then(|t| t.trim().parse::<f64>().ok()).unwrap_or(d);
    let (pad_rv, pad_min, pad_max) = (ratio(param("rvPadTop"), 0.25), len(param("rlMinPadTop"), 0.254), len(param("rlMaxPadTop"), 0.508));
    let (via_rv, via_min, via_max) = (ratio(param("rvViaOuter"), 0.25), len(param("rlMinViaOuter"), 0.2032), len(param("rlMaxViaOuter"), 0.508));
    let ring = |drill: f64, rv: f64, lo: f64, hi: f64| (drill * rv).max(lo).min(hi);

    // packages by (library, package)
    let mut packages: HashMap<(String, String), roxmltree::Node> = HashMap::new();
    for lib in board.descendants().filter(|n| n.has_tag_name("library")) {
        let ln = lib.attribute("name").unwrap_or("").to_string();
        for p in lib.descendants().filter(|n| n.has_tag_name("package")) { packages.insert((ln.clone(), p.attribute("name").unwrap_or("").to_string()), p); }
    }
    // pad -> net from the signals' contactrefs
    let mut pad_net: HashMap<(String, String), String> = HashMap::new();
    let signals: Vec<roxmltree::Node> = board.descendants().filter(|n| n.has_tag_name("signal")).collect();
    for s in &signals {
        let net = s.attribute("name").unwrap_or("").to_string();
        for c in s.children().filter(|n| n.has_tag_name("contactref")) {
            pad_net.insert((c.attribute("element").unwrap_or("").to_string(), c.attribute("pad").unwrap_or("").to_string()), net.clone());
        }
    }
    for e in board.descendants().filter(|n| n.has_tag_name("element")) {
        let reference = e.attribute("name").unwrap_or("?").to_string();
        if let Some(v) = e.attribute("value").filter(|v| !v.is_empty()) { b.values.insert(reference.clone(), v.to_string()); }
        if let Some(m) = e.descendants().find(|n| n.has_tag_name("attribute") && matches!(n.attribute("name").map(|x| x.to_ascii_uppercase()).as_deref(), Some("MPN") | Some("MFR_PN") | Some("MANUFACTURER_PART_NUMBER"))).and_then(|n| n.attribute("value")).filter(|v| !v.is_empty()) { b.mpns.insert(reference.clone(), m.to_string()); }
        if let Some(pk) = e.attribute("package") { b.footprints.insert(reference.clone(), pk.to_string()); }
        let key = (e.attribute("library").unwrap_or("").to_string(), e.attribute("package").unwrap_or("").to_string());
        let Some(pkg) = packages.get(&key) else { continue };
        let (ex, ey) = (f(&e, "x"), f(&e, "y"));
        let (ea, mirror) = parse_rot(e.attribute("rot"));
        let place = |px: f64, py: f64| -> (f64, f64) { let px = if mirror { -px } else { px }; let (rx, ry) = rot(px, py, ea); (ex + rx, y(ey + ry)) };
        for p in pkg.children().filter(|n| n.has_tag_name("smd") || n.has_tag_name("pad")) {
            let name = p.attribute("name").unwrap_or("").to_string();
            let Some(net) = pad_net.get(&(reference.clone(), name.clone())).cloned() else { continue };
            let (px, py) = (f(&p, "x"), f(&p, "y"));
            let (pa, _) = parse_rot(p.attribute("rot"));
            let (cx, cy) = place(px, py);
            let total = if mirror { -(ea + pa) } else { ea + pa };
            let corners = |dx: f64, dy: f64| -> Vec<(f64, f64)> {
                [(-dx / 2.0, -dy / 2.0), (dx / 2.0, -dy / 2.0), (dx / 2.0, dy / 2.0), (-dx / 2.0, dy / 2.0)].iter().map(|(qx, qy)| {
                    let (rx, ry) = rot(*qx, *qy, total); (cx + rx, cy - ry)
                }).collect()
            };
            if p.has_tag_name("smd") {
                let (dx, dy) = (f(&p, "dx"), f(&p, "dy"));
                let on_top = p.attribute("layer").unwrap_or("1") == "1";
                let layer = if on_top != mirror { "F.Cu" } else { "B.Cu" };
                b.pad_polys.push(Poly { net: net.clone(), layer: layer.into(), pts: corners(dx, dy) });
                b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: vec![layer.into()], area: dx * dy, func: String::new() });
            } else {
                let drill = f(&p, "drill");
                let dia = f(&p, "diameter").max(drill + 2.0 * ring(drill, pad_rv, pad_min, pad_max));
                if drill > 0.0 { b.plated_holes.push(Via { net: net.clone(), x: cx, y: cy, size: dia, drill, drill_long: None, drill_angle: 0.0 }); }
                let ring = |l: &str| Poly { net: net.clone(), layer: l.into(), pts: (0..24).map(|k| { let a = k as f64 / 24.0 * std::f64::consts::TAU; (cx + dia / 2.0 * a.cos(), cy + dia / 2.0 * a.sin()) }).collect() };
                let on: Vec<String> = b.layer_names.clone();
                for l in &on { b.pad_polys.push(ring(l)); }
                b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: on, area: std::f64::consts::PI * dia * dia / 4.0, func: String::new() });
            }
        }
    }
    for s in &signals {
        let net = s.attribute("name").unwrap_or("").to_string();
        for w in s.children().filter(|n| n.has_tag_name("wire")) {
            let Some(layer) = layer_name(w.attribute("layer").unwrap_or(""), &inner) else { continue };
            b.tracks.push(Track { net: net.clone(), layer, a: (f(&w, "x1"), y(f(&w, "y1"))), b: (f(&w, "x2"), y(f(&w, "y2"))), w: f(&w, "width").max(0.05) });
        }
        for v in s.children().filter(|n| n.has_tag_name("via")) {
            let drill = f(&v, "drill");
            let size = f(&v, "diameter").max(drill + 2.0 * ring(drill, via_rv, via_min, via_max));
            if v.attribute("extent").is_some_and(|e| e != "1-16") { return Err("only through vias (extent 1-16) are supported".into()); }
            b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill, drill_long: None, drill_angle: 0.0 });
        }
        // Fusion's own pours: <polygonpour> with the outline the user drew and, once Fusion has
        // filled it (RATSNEST), <polygonfilldetails>: each island's outline and its holes. Use the
        // real fill when it is there; compute it only when the pour was never filled.
        for p in s.children().filter(|n| n.has_tag_name("polygonpour")) {
            let Some(layer) = layer_name(p.attribute("layer").unwrap_or(""), &inner) else { continue };
            let verts = |n: roxmltree::Node| -> Vec<(f64, f64)> { n.children().filter(|v| v.has_tag_name("vertex")).map(|v| (f(&v, "x"), y(f(&v, "y")))).collect() };
            let mut filled = 0;
            if let Some(fd) = p.children().find(|n| n.has_tag_name("polygonfilldetails")) {
                for sh in fd.children().filter(|n| n.has_tag_name("polygonshape")) {
                    let sl = sh.attribute("layer").and_then(|l| layer_name(l, &inner)).unwrap_or_else(|| layer.clone());
                    let outer = sh.children().find(|n| n.has_tag_name("polygonoutlinesegments")).map(verts).unwrap_or_default();
                    if outer.len() < 3 { continue; }
                    let holes: Vec<Vec<(f64, f64)>> = sh.descendants().filter(|n| n.has_tag_name("polygonholesegments")).map(verts).filter(|h| h.len() > 2).collect();
                    b.fills.push(FillShape { net: net.clone(), layer: sl, outer, holes }); filled += 1;
                }
            }
            if filled == 0 {
                let pts = p.children().find(|n| n.has_tag_name("polygonoutlineobjects")).map(verts).unwrap_or_default();
                if pts.len() > 2 { b.pours.push(Pour { net: net.clone(), layer, pts, isolate: p.attribute("isolate").and_then(|v| v.parse().ok()).unwrap_or(0.3), rank: p.attribute("rank").and_then(|v| v.parse().ok()).unwrap_or(1), name: p.attribute("name").map(str::to_string) }); }
            }
        }
        for p in s.children().filter(|n| n.has_tag_name("polygon")) {
            let Some(layer) = layer_name(p.attribute("layer").unwrap_or(""), &inner) else { continue };
            let pts: Vec<(f64, f64)> = p.children().filter(|n| n.has_tag_name("vertex")).map(|v| (f(&v, "x"), y(f(&v, "y")))).collect();
            if pts.len() < 3 { continue; }
            b.pours.push(Pour { net: net.clone(), layer, pts, isolate: p.attribute("isolate").and_then(|v| v.parse().ok()).unwrap_or(0.3), rank: p.attribute("rank").and_then(|v| v.parse().ok()).unwrap_or(1), name: p.attribute("name").map(str::to_string) });
        }
    }
    Ok(b)
}

/// The parts of an EAGLE board for the 3D viewer: reference, value, placement, side, and the model
/// file names worth looking for (MPN, then deviceset, then package), since a Fusion board binds its
/// 3D packages by cloud urn, not by a local file path.
pub struct EagleElement { pub reference: String, pub value: String, pub x: f64, pub y: f64, pub rot: f64, pub bottom: bool, pub names: Vec<String> }
pub fn elements(text: &str) -> Result<Vec<EagleElement>, String> {
    let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for e in doc.descendants().filter(|n| n.has_tag_name("element")) {
        let (deg, mirror) = parse_rot(e.attribute("rot"));
        let attr = |k: &str| e.children().find(|a| a.has_tag_name("attribute") && a.attribute("name").map(|n| n.eq_ignore_ascii_case(k)).unwrap_or(false)).and_then(|a| a.attribute("value")).map(str::to_string);
        let mut names: Vec<String> = Vec::new();
        for k in ["MPN", "MPN_MODEL"] { if let Some(v) = attr(k) { names.push(v); } }
        if let Some(v) = e.attribute("deviceset") { names.push(v.to_string()); }
        if let Some(v) = e.attribute("package") { names.push(v.to_string()); }
        names.retain(|n| !n.is_empty()); names.dedup();
        out.push(EagleElement { reference: e.attribute("name").unwrap_or("?").to_string(), value: e.attribute("value").unwrap_or("").to_string(), x: f(&e, "x"), y: -f(&e, "y"), rot: deg, bottom: mirror, names });
    }
    Ok(out)
}

#[cfg(test)] mod tests {
    // a two-layer board: a rotated, mirrored resistor, a trace, a via, and a pour Fusion has filled
    const BRD: &str = r#"<?xml version="1.0"?><eagle><drawing><layers><layer number="1" name="Top"/><layer number="16" name="Bottom"/></layers><board>
<plain><wire x1="0" y1="0" x2="20" y2="0" width="0" layer="20"/><wire x1="20" y1="0" x2="20" y2="10" width="0" layer="20"/></plain>
<libraries><library name="L"><packages><package name="R0402"><smd name="1" x="-0.5" y="0" dx="0.5" dy="0.6" layer="1"/><smd name="2" x="0.5" y="0" dx="0.5" dy="0.6" layer="1"/></package></packages></library></libraries>
<elements><element name="R1" library="L" package="R0402" value="10k" x="5" y="5" rot="R90"><attribute name="MPN" value="RC0402"/></element>
<element name="R2" library="L" package="R0402" value="1k" x="10" y="5" rot="MR0"/></elements>
<signals>
<signal name="A"><contactref element="R1" pad="1"/><contactref element="R2" pad="1"/><wire x1="5" y1="4.5" x2="10.5" y2="5" width="0.25" layer="1"/><via x="8" y="5" extent="1-16" drill="0.3"/></signal>
<signal name="GND"><contactref element="R1" pad="2"/><polygonpour layer="16" isolate="0.2"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygonoutlineobjects>
<polygonfilldetails><polygonshape layer="16"><polygonoutlinesegments><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygonoutlinesegments>
<polygonholelist><polygonholesegments><vertex x="7" y="4"/><vertex x="9" y="4"/><vertex x="9" y="6"/></polygonholesegments></polygonholelist></polygonshape></polygonfilldetails></polygonpour></signal>
</signals></board></drawing></eagle>"#;
    #[test] fn reads_a_fusion_board() {
        let b = super::load(BRD).unwrap();
        assert_eq!((b.x0, b.x1, b.y0, b.y1), (0.0, 20.0, -10.0, 0.0), "outline from the Dimension layer, y negated");
        // R1 rotated 90: pad 1 (-0.5, 0) lands at (5, 4.5) in EAGLE, (5, -4.5) here
        let p1 = b.pads.iter().find(|p| p.reference == "R1" && p.pad == "1").unwrap();
        assert!((p1.x - 5.0).abs() < 1e-9 && (p1.y + 4.5).abs() < 1e-9, "{p1:?}");
        assert_eq!(p1.net, "A");
        // R2 mirrored: on the bottom, pad 1 (-0.5, 0) lands at (10.5, 5) -> (10.5, -5)
        let p2 = b.pads.iter().find(|p| p.reference == "R2" && p.pad == "1").unwrap();
        assert_eq!(p2.layers, vec!["B.Cu".to_string()]); assert!((p2.x - 10.5).abs() < 1e-9 && (p2.y + 5.0).abs() < 1e-9);
        assert_eq!(b.tracks.len(), 1); assert_eq!(b.vias.len(), 1);
        assert_eq!(b.fills.len(), 1, "Fusion's own fill is used"); assert_eq!(b.fills[0].holes.len(), 1); assert!(b.pours.is_empty());
        let parts = super::elements(BRD).unwrap();
        assert_eq!(parts[0].names[0], "RC0402"); assert!(parts[1].bottom);
    }
    #[test] fn unfilled_pour_falls_back_to_a_computed_fill() {
        let t = BRD.replace(|_: char| false, "");
        let i = t.find("<polygonfilldetails>").unwrap(); let j = t.find("</polygonfilldetails>").unwrap() + "</polygonfilldetails>".len();
        let b = super::load(&format!("{}{}", &t[..i], &t[j..])).unwrap();
        assert!(b.fills.is_empty()); assert_eq!(b.pours.len(), 1); assert_eq!(b.pours[0].isolate, 0.2);
    }
}