app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
//! 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);
}
}