app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
//! Fusion 360 Electronics / EAGLE `.brd` board parser.
//!
//! Fusion Electronics is EAGLE underneath: `fusion_export_eagle_source` writes a
//! plain-XML `.brd`, so one parser covers both EDAs. We build the SAME neutral
//! `Board` model the KiCad parser produces, which means net highlighting, pad
//! hover, the overlay and the embed all work unchanged.
//!
//! Coordinates: EAGLE XML is already in **mm**, but its Y axis points UP while
//! ours (like KiCad's) points DOWN — so every Y is negated on the way in. Angles
//! are counter-clockwise in EAGLE, clockwise for us, hence the rotation negation.
//!
//! Unlike KiCad there is no native SVG export to sit under the overlay (Fusion
//! has no headless renderer), so these boards use our own self-render — the same
//! one that already backstops KiCad when service-kicad is down.
use crate::pcb_render::{Arc, Board, Comp, Gfx, Pad, Track, Via, ZonePoly};
use roxmltree::{Document, Node};
use std::collections::HashMap;
use std::fmt::Write as _;
pub fn looks_like_eagle(text: &str) -> bool {
let head = &text[..text.len().min(4096)];
head.contains("<eagle") && text.contains("<board")
}
fn at(n: Node, k: &str) -> String { n.attribute(k).unwrap_or("").to_string() }
fn num(n: Node, k: &str) -> f64 { n.attribute(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) }
/// EAGLE layer number -> our layer bucket.
fn layer_of(n: i32) -> &'static str {
match n {
1 => "F.Cu",
16 => "B.Cu",
2..=15 => "In.Cu",
17 | 18 => "F.Cu", // Pads / Vias layers
20 => "Edge", // Dimension
21 | 25 => "F.SilkS", // tPlace / tNames
22 | 26 => "B.SilkS", // bPlace / bNames
27 => "F.SilkS", // tValues
28 => "B.SilkS",
39 | 41 => "F.CrtYd", // tKeepout / tRestrict
40 | 42 => "B.CrtYd",
51 => "F.Fab", // tDocu
52 => "B.Fab",
_ => "",
}
}
/// EAGLE rotation strings: "R90", "MR90" (mirrored), "SR90" (spin).
fn rot_of(s: &str) -> (f64, bool) {
let mirror = s.contains('M');
let deg = s.trim_start_matches(['M', 'S', 'R'])
.parse::<f64>()
.unwrap_or_else(|_| s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect::<String>().parse().unwrap_or(0.0));
(deg, mirror)
}
/// Place a package-local point into board coordinates.
/// Mirroring flips X in the package frame (EAGLE mirrors about the Y axis).
fn place(px: f64, py: f64, ex: f64, ey: f64, deg: f64, mirror: bool) -> (f64, f64) {
let x = if mirror { -px } else { px };
let r = deg.to_radians();
let (s, c) = (r.sin(), r.cos());
let (rx, ry) = (x * c - py * s, x * s + py * c);
// negate Y last: EAGLE is Y-up, we are Y-down
(ex + rx, -(ey + ry))
}
/// An arc from (x1,y1) to (x2,y2) with EAGLE's `curve` (included angle, degrees).
fn curve_path(x1: f64, y1: f64, x2: f64, y2: f64, curve: f64) -> String {
let (dx, dy) = (x2 - x1, y2 - y1);
let chord = (dx * dx + dy * dy).sqrt();
if chord < 1e-9 || curve.abs() < 1e-9 {
return format!("M {:.4} {:.4} L {:.4} {:.4}", x1, y1, x2, y2);
}
let half = (curve.abs().to_radians()) / 2.0;
let r = (chord / 2.0) / half.sin().max(1e-9);
let large = if curve.abs() > 180.0 { 1 } else { 0 };
// EAGLE curve is CCW-positive; our Y is already flipped, which reverses the
// apparent sweep, so a positive curve draws with sweep=1 here.
let sweep = if curve > 0.0 { 1 } else { 0 };
format!("M {:.4} {:.4} A {:.4} {:.4} 0 {} {} {:.4} {:.4}", x1, y1, r.abs(), r.abs(), large, sweep, x2, y2)
}
struct Pkg<'a, 'i> { nodes: Vec<Node<'a, 'i>> }
pub fn parse_brd(xml: &str) -> Result<Board, String> {
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let doc = Document::parse_with_options(xml, opts).map_err(|e| format!("eagle .brd: {e}"))?;
let root = doc.root_element();
let board = root.descendants().find(|n| n.has_tag_name("board")).ok_or("eagle: no <board>")?;
let mut b = Board::default();
// ── packages, keyed "library:package" ────────────────────────────────────
let mut pkgs: HashMap<String, Pkg> = HashMap::new();
for lib in board.descendants().filter(|n| n.has_tag_name("library")) {
let lname = at(lib, "name");
for p in lib.descendants().filter(|n| n.has_tag_name("package")) {
pkgs.insert(format!("{}:{}", lname, at(p, "name")), Pkg { nodes: p.children().filter(|c| c.is_element()).collect() });
}
}
// ── nets: one id per <signal>, plus (element,pad) -> net ─────────────────
let mut pad_net: HashMap<(String, String), u32> = HashMap::new();
let signals: Vec<Node> = board.descendants().filter(|n| n.has_tag_name("signal")).collect();
for (i, sig) in signals.iter().enumerate() {
let id = (i + 1) as u32;
b.nets.insert(id, at(*sig, "name"));
for cr in sig.children().filter(|c| c.has_tag_name("contactref")) {
pad_net.insert((at(cr, "element"), at(cr, "pad")), id);
}
}
// ── routing inside signals: wires (tracks/arcs), vias, copper polygons ───
for (i, sig) in signals.iter().enumerate() {
let net = (i + 1) as u32;
for w in sig.children().filter(|c| c.has_tag_name("wire")) {
let layer = layer_of(num(w, "layer") as i32);
if !layer.ends_with(".Cu") { continue; }
let (x1, y1) = (num(w, "x1"), -num(w, "y1"));
let (x2, y2) = (num(w, "x2"), -num(w, "y2"));
let width = num(w, "width");
let curve = num(w, "curve");
if curve.abs() > 1e-9 {
b.arcs.push(Arc { d: curve_path(x1, y1, x2, y2, -curve), layer: layer.into(), width, net });
} else {
b.tracks.push(Track { x1, y1, x2, y2, width, layer: layer.into(), net });
}
}
for v in sig.children().filter(|c| c.has_tag_name("via")) {
let drill = num(v, "drill");
let size = if num(v, "diameter") > 0.0 { num(v, "diameter") } else { drill + 0.5 };
b.vias.push(Via { x: num(v, "x"), y: -num(v, "y"), size, drill, net });
}
for poly in sig.children().filter(|c| c.has_tag_name("polygon")) {
let layer = layer_of(num(poly, "layer") as i32);
if !layer.ends_with(".Cu") { continue; }
let pts: Vec<(f64, f64)> = poly.children().filter(|c| c.has_tag_name("vertex"))
.map(|v| (num(v, "x"), -num(v, "y"))).collect();
if pts.len() < 3 { continue; }
let mut d = String::new();
for (i, (x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{} {:.4} {:.4} ", if i == 0 { "M" } else { "L" }, x, y);
}
d.push('Z');
b.zones.push(ZonePoly { d, layer: layer.into(), net });
}
}
// ── board outline + free graphics from <plain> ───────────────────────────
for n in board.children().filter(|c| c.has_tag_name("plain")).flat_map(|p| p.children()) {
if !n.is_element() { continue; }
push_plain(&mut b, n);
}
// ── elements: place package geometry, resolve pads ───────────────────────
for el in board.descendants().filter(|n| n.has_tag_name("element")) {
let name = at(el, "name");
let (ex, ey) = (num(el, "x"), num(el, "y"));
let (deg, mirror) = rot_of(&at(el, "rot"));
let key = format!("{}:{}", at(el, "library"), at(el, "package"));
let side = if mirror { 'B' } else { 'F' };
let mut pad_count = 0usize;
// bbox is accumulated from PLACED points as we go — never by re-parsing
// numbers back out of a path `d` (arc radii would be read as coordinates)
let mut ext: Vec<(f64, f64)> = Vec::new();
if let Some(pkg) = pkgs.get(&key) {
for n in &pkg.nodes {
let n = *n;
match n.tag_name().name() {
// THT pad
"pad" => {
let (x, y) = place(num(n, "x"), num(n, "y"), ex, ey, deg, mirror);
let dia = if num(n, "diameter") > 0.0 { num(n, "diameter") } else { num(n, "drill") * 1.8 };
let shape = match at(n, "shape").as_str() {
"square" => "rect", "octagon" => "roundrect", "long" => "oval", _ => "circle",
};
let (w, h) = if shape == "oval" { (dia * 2.0, dia) } else { (dia, dia) };
let numname = at(n, "name");
let net = *pad_net.get(&(name.clone(), numname.clone())).unwrap_or(&0);
ext.push((x - w / 2.0, y - h / 2.0)); ext.push((x + w / 2.0, y + h / 2.0));
pad_count += 1;
b.pads.push(Pad {
reference: name.clone(), num: numname, net, x, y,
rot: -(deg + num(n, "rot")), w, h,
shape: shape.into(), drill: num(n, "drill"), mount: "thru_hole".into(), side: 'F',
});
}
// SMD pad
"smd" => {
let (x, y) = place(num(n, "x"), num(n, "y"), ex, ey, deg, mirror);
let numname = at(n, "name");
let net = *pad_net.get(&(name.clone(), numname.clone())).unwrap_or(&0);
let lay = num(n, "layer") as i32;
let pside = if (lay == 16) != mirror { 'B' } else { 'F' };
let shape = if num(n, "roundness") > 0.0 { "roundrect" } else { "rect" };
let (pw, ph) = (num(n, "dx"), num(n, "dy"));
ext.push((x - pw / 2.0, y - ph / 2.0)); ext.push((x + pw / 2.0, y + ph / 2.0));
pad_count += 1;
b.pads.push(Pad {
reference: name.clone(), num: numname, net, x, y,
rot: -(deg + num(n, "rot")), w: pw, h: ph,
shape: shape.into(), drill: 0.0, mount: "smd".into(), side: pside,
});
}
_ => {
if let Some((g, layer, pts)) = gfx_of(n, ex, ey, deg, mirror) {
ext.extend(pts);
push_layer(&mut b, layer, g, side);
}
}
}
}
}
let bbox = if ext.is_empty() {
[ex - 0.5, -ey - 0.5, ex + 0.5, -ey + 0.5]
} else {
let (mut mnx, mut mny, mut mxx, mut mxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (x, y) in &ext { mnx = mnx.min(*x); mxx = mxx.max(*x); mny = mny.min(*y); mxy = mxy.max(*y); }
[mnx, mny, mxx, mxy]
};
b.comps.push(Comp {
reference: name,
value: at(el, "value"),
footprint: at(el, "package"),
side: if side == 'B' { "bottom".into() } else { "top".into() },
mpn: String::new(),
lcsc: String::new(),
x: ex, y: -ey,
bbox,
pad_count,
});
}
Ok(b)
}
/// Free graphics on the board itself (outline on layer 20, silk, etc).
fn push_plain(b: &mut Board, n: Node) {
if let Some((g, layer, _)) = gfx_of(n, 0.0, 0.0, 0.0, false) {
push_layer(b, layer, g, 'F');
}
}
fn push_layer(b: &mut Board, layer: &str, g: Gfx, side: char) {
match layer {
"Edge" => b.edges.push(g),
"F.SilkS" => { if side == 'B' { b.silk_b.push(g) } else { b.silk_f.push(g) } }
"B.SilkS" => { if side == 'B' { b.silk_f.push(g) } else { b.silk_b.push(g) } }
"F.Fab" => { if side == 'B' { b.fab_b.push(g) } else { b.fab_f.push(g) } }
"B.Fab" => { if side == 'B' { b.fab_f.push(g) } else { b.fab_b.push(g) } }
"F.CrtYd" => { if side == 'B' { b.crtyd_b.push(g) } else { b.crtyd_f.push(g) } }
"B.CrtYd" => { if side == 'B' { b.crtyd_f.push(g) } else { b.crtyd_b.push(g) } }
_ => {}
}
}
/// Turn one EAGLE graphic element into an SVG path in board coordinates.
fn gfx_of(n: Node, ex: f64, ey: f64, deg: f64, mirror: bool) -> Option<(Gfx, &'static str, Vec<(f64, f64)>)> {
let layer = layer_of(num(n, "layer") as i32);
if layer.is_empty() || layer.ends_with(".Cu") { return None; }
let p = |x: f64, y: f64| place(x, y, ex, ey, deg, mirror);
match n.tag_name().name() {
"wire" => {
let (x1, y1) = p(num(n, "x1"), num(n, "y1"));
let (x2, y2) = p(num(n, "x2"), num(n, "y2"));
let curve = num(n, "curve");
let d = if curve.abs() > 1e-9 { curve_path(x1, y1, x2, y2, -curve) }
else { format!("M {:.4} {:.4} L {:.4} {:.4}", x1, y1, x2, y2) };
Some((Gfx { d, width: num(n, "width").max(0.05), closed_fill: false }, layer, vec![(x1, y1), (x2, y2)]))
}
"circle" => {
let (cx, cy) = p(num(n, "x"), num(n, "y"));
let r = num(n, "radius");
let d = format!("M {:.4} {:.4} A {r:.4} {r:.4} 0 1 0 {:.4} {:.4} A {r:.4} {r:.4} 0 1 0 {:.4} {:.4} Z",
cx - r, cy, cx + r, cy, cx - r, cy);
Some((Gfx { d, width: num(n, "width").max(0.05), closed_fill: false }, layer, vec![(cx - r, cy - r), (cx + r, cy + r)]))
}
"rectangle" => {
let (x1, y1) = (num(n, "x1"), num(n, "y1"));
let (x2, y2) = (num(n, "x2"), num(n, "y2"));
let corners = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
let mut d = String::new();
let mut pts = Vec::new();
for (i, (x, y)) in corners.iter().enumerate() {
let (px, py) = p(*x, *y);
pts.push((px, py));
let _ = write!(d, "{} {:.4} {:.4} ", if i == 0 { "M" } else { "L" }, px, py);
}
d.push('Z');
Some((Gfx { d, width: 0.0, closed_fill: true }, layer, pts))
}
"polygon" => {
let pts: Vec<(f64, f64)> = n.children().filter(|c| c.has_tag_name("vertex"))
.map(|v| p(num(v, "x"), num(v, "y"))).collect();
if pts.len() < 3 { return None; }
let mut d = String::new();
for (i, (x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{} {:.4} {:.4} ", if i == 0 { "M" } else { "L" }, x, y);
}
d.push('Z');
Some((Gfx { d, width: num(n, "width"), closed_fill: true }, layer, pts))
}
_ => None, // <text>, <hole>, <dimension>, <attribute> — not stroke geometry
}
}