← All Pull Requests

Fusion reader: analyze Fusion Electronics boards with Fusion's own fill and pad rules #3

Merged Released in 0.6.0 on 5 Oct 2026 opened by John Lauer

Replaces #2 with the same reader plus one fix found building the buck molecule in Fusion.

New since #2: pads and vias are sized by the board's rules. A through-hole pad or via whose library diameter is left to the rules is as big as Fusion makes it: the drill plus a ring of the drill times rvPadTop (rvViaOuter for vias), held between rlMinPadTop and rlMaxPadTop; a larger library diameter wins. The reader used to assume drill + 0.5 mm (pads) and drill + 0.4 mm (vias), which made a 1.2 mm drill's pad 1.7 mm on a board whose rules make it 1.6 mm. On the buck molecule this reader's solve is what found the SW neck (133 A/mm2) and the VIN via ring (62 A/mm2) that the AI then refined to a clean 51 A/mm2.

Adds a Fusion Electronics reader: --board now also takes a Fusion board saved as EAGLE source (.brd, what the Fusion bridge's fusion_export_eagle_source writes). This was on the roadmap in the README ("an Altium and Fusion reader"); it is needed now for a full Fusion demo (Hydrogen builds the 12 V to 5 V buck in Fusion end to end, then analyses it with adom-fields).

What changed

  • src/eagle.rs (new): parses the board outline (Dimension layer 20), packages, placed elements (rotation and mirroring; a mirrored element's SMD pads go to B.Cu), every signal's wires, vias, Fusion <polygonpour>s and classic <polygon>s. y is negated so coordinates read like a KiCad board.
  • Fusion stores each pour's computed fill in the file (polygonfilldetails: every island's outline and its holes), so the reader uses Fusion's own copper (Board.fills). A pour that was never filled is filled on the solve grid instead (Raster::fill_pours): the outline minus other nets' copper grown by the isolate, keeping the islands that touch the pour's own net, by rank.
  • src/parts.rs: for a Fusion board the viewer's 3D models are found by name (<MPN>.step, else deviceset or package) in --models-dir, placed with the STEP origin on the footprint origin, the way the Fusion bridge binds them.
  • Version 0.6.0; README and SKILL.md document it.

Verified

  • cargo test: 11 pass, two new (reads_a_fusion_board: rotation, mirroring, fill with a hole, MPN parts; unfilled_pour_falls_back_to_a_computed_fill).
  • On a real routed Fusion export (BQ25792 charger, 2 layers, 6 pours with fill, 90 vias): solved in 19 s (3 loaded nets, 1 hot part). Every one of the 135 dangling trace ends in the file lands on a pad of its own net, and of the 99 pads the solver placed, 70 have a trace ending on them and the other 29 all belong to poured nets, so placement is right. The copper mask matches the pours and clearances in the file.

KiCad boards are untouched (the reader is chosen by the file's content, <eagle).

🤖 Generated with Claude Code

Diff Skip to comments

--- a/Cargo.lock+++ b/Cargo.lock@@ -1,457 +1,465 @@⋯ 9 unchanged lines ⋯  [[package]] name = "adom-fields"-version = "0.5.0"+version = "0.6.0" dependencies = [  "clap",  "png",+ "roxmltree",  "serde",  "serde_json",  "sha2",⋯ 297 unchanged lines ⋯ ]  [[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"++[[package]] name = "serde" version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index"⋯ 132 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/Cargo.toml+++ b/Cargo.toml@@ -1,21 +1,23 @@ [package] name = "adom-fields"-version = "0.5.0"+version = "0.6.0" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."⋯ 6 unchanged lines ⋯ tiny_http = "0.12" png = "0.17" sha2 = "0.10"+roxmltree = "0.20"  [profile.release] opt-level = 3 lto = true codegen-units = 1 strip = true+--- a/README.md+++ b/README.md@@ -1,73 +1,101 @@⋯ 45 unchanged lines ⋯  ## Honest limits, 0.1 -DC only; one copper thickness for all layers; a flat convection coefficient, no heatsink, no enclosure, no radiation; the tab watts come from the spec. A screen that finds the necks and the starved tabs and shows them, not a datasheet number. Next: heatsinks as conducting blocks, pour capacitance, differential pair and impedance checks (the signals tab), and an Altium and Fusion reader.+DC only; one copper thickness for all layers; a flat convection coefficient, no heatsink, no enclosure, no radiation; the tab watts come from the spec. A screen that finds the necks and the starved tabs and shows them, not a datasheet number. Next: heatsinks as conducting blocks, pour capacitance, differential pair and impedance checks (the signals tab), and an Altium reader. (The Fusion reader landed in 0.6.0, below.)  ## Give it back ⋯ 18 unchanged lines ⋯ A hot zone alone does not say what is heating it. Fields now stands each part's own 3D model on the board, where KiCad places it, see-through by default so the field glows through the body: the regulator sits in its hot spot, the inductor in its own. The models come from the board's footprints, converted once on the Adom STEP service and cached; a footprint with no model gets a box from its courtyard. The header's cube button (or `ui view --parts ghost|solid|off`) switches them. Every camera move and the idle sway now run at half speed, so a clip the flow plays at ten times speed stays calm.  ![The first buck board's heat flow with the parts see-through: U1 and L1 standing in their hot zones](docs/screenshots/parts-heat-iso.png)++## Fusion boards in 0.6.0++`--board` now also takes a Fusion Electronics board saved as EAGLE source (`.brd`: the Fusion+bridge's `fusion_export_eagle_source`, or Fusion's own export). The same solve, the same app, the+same tour.++```+adom-fields analyze --board buck.brd --spec spec.json --out fields --models-dir 3d+```++- **The copper is Fusion's own.** Fusion stores each pour's computed fill in the board file (every+  island's outline and its holes, `polygonfilldetails`), so the reader uses that, the way it uses+  KiCad's filled polygons. A pour that was never filled (no RATSNEST since it was drawn) is filled on+  the solve grid instead: the outline, minus every other net's copper grown by the pour's isolate+  distance, keeping only the islands that touch the pour's own net, higher-priority pours first.+- **Placement.** Elements are placed with their rotation and mirroring (a mirrored element is on the+  bottom, its SMD pads on B.Cu). Checked on a routed Fusion board: every trace end lands on a pad of+  its own net, and every pad without a trace belongs to a poured net.+- **Coordinates.** EAGLE is y up; the reader negates y so reports, `--at` points and the app read the+  way they do for a KiCad board.+- **Parts.** A Fusion board binds its 3D packages by cloud urn, so the viewer's models are found by+  name: the element's MPN attribute as `<MPN>.step`, else its deviceset or package name, in the+  `--models-dir` folders. Each STEP is placed with its own origin on the footprint origin, the way the+  Fusion bridge binds it.+- Two copper layers plus up to two inner layers, as for KiCad boards.++--- a/SKILL.md+++ b/SKILL.md@@ -1,82 +1,91 @@⋯ 10 unchanged lines ⋯ ## Solve  ```-adom-fields analyze --board <board.kicad_pcb> --spec <spec.json> --out fields+adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields ```  The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.⋯ 62 unchanged lines ⋯ Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.  For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.++## Fusion boards (0.6.0+)++A Fusion Electronics board works as `--board <board>.brd` (EAGLE source: `fusion_export_eagle_source`+on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the reader then uses+Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the+chips sit over the fields.++--- a/package.json+++ b/package.json@@ -1,69 +1,70 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.5.0",+  "version": "0.6.0",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",⋯ 59 unchanged lines ⋯   },   "dependencies": {} }+--- a/page.json+++ b/page.json@@ -1,76 +1,76 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.5.0",+  "version": "0.6.0",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",⋯ 69 unchanged lines ⋯--- a/src/board.rs+++ b/src/board.rs@@ -1,120 +1,137 @@⋯ 11 unchanged lines ⋯ #[derive(Debug, Clone)] pub struct Pad { pub reference: String, pub pad: String, pub net: String, pub x: f64, pub y: f64, pub layers: Vec<String>, pub area: f64 } +/// A copper pour whose fill the file does not carry (a Fusion/EAGLE polygon): the outline, its net and+/// layer, and the isolate distance; the raster fills it (see `Raster::draw_board`).+#[derive(Debug, Clone)]+pub struct Pour { pub net: String, pub layer: String, pub pts: Vec<(f64, f64)>, pub isolate: f64, pub rank: u32, pub name: Option<String> }++/// One island of a pour's computed fill as Fusion stores it: the outline and its holes.+#[derive(Debug, Clone)]+pub struct FillShape { pub net: String, pub layer: String, pub outer: Vec<(f64, f64)>, pub holes: Vec<Vec<(f64, f64)>> }+ #[derive(Debug, Default, Clone)] pub struct Board {     pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64,⋯ 2 unchanged lines ⋯     pub pad_polys: Vec<Poly>,     pub arc_nets: Vec<String>,     pub layer_names: Vec<String>,+    /// pours to fill on the solve grid (Fusion/EAGLE boards; empty for KiCad, whose fill is in the file)+    pub pours: Vec<Pour>,+    /// pours whose fill the file DOES carry as outline-with-holes islands (Fusion's polygonfilldetails)+    pub fills: Vec<FillShape>,+    /// "kicad" or "fusion"+    pub source: String, }  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()) }⋯ 1 unchanged line ⋯ impl Board {     pub fn load(path: &str) -> Result<Board, String> {         let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+        if crate::eagle::is_eagle(&text) { let mut b = crate::eagle::load(&text)?; b.source = "fusion".into(); return Ok(b); }         let root = parse(&text);-        let mut b = Board::default();+        let mut b = Board::default(); b.source = "kicad".into();         b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).map(str::to_string)).collect()).unwrap_or_default();         let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.atom(1)?.to_string(),n.atom(2)?.to_string()))).collect();         let net_name = |n: &Sx| { let value=n.last_atom().unwrap_or(""); net_names.get(value).cloned().unwrap_or_else(||value.to_string()) };⋯ 83 unchanged lines ⋯     let ang = if w >= h { deg } else { deg + 90.0 };     pts.into_iter().map(|(px, py)| { let (qx, qy) = rot(px, -py, ang); (x + qx, y - qy) }).collect() }+--- a/src/eagle.rs+++ b/src/eagle.rs@@ -0,0 +1,226 @@+//! 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();+    // used inner copper layers, in stack order, become In1.Cu / In2.Cu+    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)); inner.truncate(2);+    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();+        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 });+            } else {+                let drill = f(&p, "drill");+                let dia = f(&p, "diameter").max(drill + 2.0 * ring(drill, pad_rv, pad_min, pad_max));+                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 });+            }+        }+    }+    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));+            b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill });+        }+        // 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);+    }+}+--- a/src/main.rs+++ b/src/main.rs@@ -1,169 +1,170 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod eagle; mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf; ⋯ 160 unchanged lines ⋯         }     } }+--- a/src/parts.rs+++ b/src/parts.rs@@ -1,169 +1,183 @@⋯ 19 unchanged lines ⋯  fn read_parts(board: &str) -> Result<Vec<Part>, String> {     let text = std::fs::read_to_string(board).map_err(|e| format!("{board}: {e}"))?;+    if crate::eagle::is_eagle(&text) { return eagle_parts(&text); }     let root = parse(&text);     let mut out = Vec::new();     for fp in root.kids("footprint").into_iter().chain(root.kids("module")) {⋯ 30 unchanged lines ⋯     Ok(out) } +/// A Fusion board binds its 3D packages by cloud urn, so its models are found by name instead: the+/// element's MPN, then its deviceset, then its package, as `<name>.step` in the --models-dir folders+/// (and ./3d, ./3dmodels beside the board). The model is placed as the Fusion bridge binds it: the+/// STEP's own origin on the footprint origin, no extra offset or rotation.+fn eagle_parts(text: &str) -> Result<Vec<Part>, String> {+    Ok(crate::eagle::elements(text)?.into_iter().map(|e| Part {+        reference: e.reference, value: e.value, x: e.x, y: e.y, rot: e.rot, bottom: e.bottom,+        models: e.names.first().map(|n| Model { path: format!("{n}.step"), offset: [0.0; 3], scale: [1.0; 3], rotate: [0.0; 3] }).into_iter().collect(),+        bbox: None, skip: false,+    }).collect())+}+ /// ${VAR} and $(VAR) from the environment, KIPRJMOD = the board's folder fn expand(p: &str, board_dir: &Path) -> (String, Option<String>) {     let mut s = p.replace('\\', "/"); let mut lib_var = None;⋯ 105 unchanged lines ⋯         assert_eq!(p[0].bbox, Some([-1.0, -2.0, 1.0, 2.0])); assert!(p[1].bottom && p[1].skip);     } }+--- a/src/raster.rs+++ b/src/raster.rs@@ -1,89 +1,162 @@⋯ 60 unchanged lines ⋯         for p in &polys { self.fill_poly(&p.net, &p.layer, &p.pts); }         for t in &b.tracks { let (net, layer, a, bb, w) = (t.net.clone(), t.layer.clone(), t.a, t.b, t.w); self.thick_line(&net, &layer, a, bb, w); }         for v in &b.vias { for l in LAYERS { let (net, x, y, s) = (v.net.clone(), v.x, v.y, v.size); self.disc(&net, l, x, y, s / 2.0); } }+        self.draw_fills(b);+        self.fill_pours(b);     }+    /// Pour islands the file carries already filled (Fusion): each outline minus its holes.+    fn draw_fills(&mut self, b: &Board) {+        let (w, h) = (self.w, self.h);+        for f in &b.fills {+            let (so, sh) = ("\u{0}fill", "\u{0}hole");+            self.masks.remove(&(so.to_string(), f.layer.clone())); self.masks.remove(&(sh.to_string(), f.layer.clone()));+            self.fill_poly(so, &f.layer, &f.outer);+            for hole in &f.holes { self.fill_poly(sh, &f.layer, hole); }+            let outer = self.masks.remove(&(so.to_string(), f.layer.clone())).unwrap_or_else(|| vec![0u8; w * h]);+            let holes = self.masks.remove(&(sh.to_string(), f.layer.clone())).unwrap_or_else(|| vec![0u8; w * h]);+            let m = self.mask(&f.net, &f.layer);+            for i in 0..w * h { if outer[i] != 0 && holes[i] == 0 { m[i] = 1; } }+        }+    }+    /// Fill the pours whose fill the file does not carry (Fusion/EAGLE polygons), the way Fusion+    /// computes it: the outline, inside the board, minus every other net's copper on that layer grown+    /// by the pour's isolate distance, keeping only the islands that touch the pour's own copper+    /// (Fusion's default: orphans off). Higher-priority pours (lower rank) are filled first and count+    /// as other-net copper for the ones after them.+    fn fill_pours(&mut self, b: &Board) {+        let (w, h) = (self.w, self.h);+        let mut pours: Vec<&crate::board::Pour> = b.pours.iter().collect();+        pours.sort_by_key(|p| p.rank);+        for p in pours {+            // the outline alone, in a scratch mask+            let scratch = "\u{0}pour";+            self.masks.remove(&(scratch.to_string(), p.layer.clone()));+            self.fill_poly(scratch, &p.layer, &p.pts);+            let Some(mut fill) = self.masks.remove(&(scratch.to_string(), p.layer.clone())) else { continue };+            // other nets' copper on this layer, grown by the isolate distance+            let grow = (p.isolate / self.res).ceil() as i64;+            let mut blocked = vec![0u8; w * h];+            for ((net, layer), m) in &self.masks {+                if layer != &p.layer || net == &p.net { continue; }+                for i in 0..w * h { if m[i] != 0 { blocked[i] = 1; } }+            }+            let blocked = dilate(&blocked, w, h, grow);+            for i in 0..w * h { if blocked[i] != 0 { fill[i] = 0; } }+            // keep the islands that touch the net's own copper+            let own = self.masks.get(&(p.net.clone(), p.layer.clone())).cloned().unwrap_or_else(|| vec![0u8; w * h]);+            let mut keep = vec![0u8; w * h];+            let mut stack: Vec<usize> = (0..w * h).filter(|i| fill[*i] != 0 && own[*i] != 0).collect();+            for i in &stack { keep[*i] = 1; }+            while let Some(i) = stack.pop() {+                let (r, c) = (i / w, i % w);+                let mut nb = Vec::with_capacity(4);+                if r > 0 { nb.push(i - w); } if r + 1 < h { nb.push(i + w); } if c > 0 { nb.push(i - 1); } if c + 1 < w { nb.push(i + 1); }+                for j in nb { if fill[j] != 0 && keep[j] == 0 { keep[j] = 1; stack.push(j); } }+            }+            let m = self.mask(&p.net, &p.layer);+            for i in 0..w * h { if keep[i] != 0 { m[i] = 1; } }+        }+    }     /// The copper fraction of one net on one layer per solve cell (pool x pool fine pixels each).     pub fn fraction(&self, net: &str, layer: &str, pool: usize) -> Grid {         let (gw, gh) = (self.w / pool, self.h / pool);⋯ 19 unchanged lines ⋯     pub fn sum(&self) -> f64 { self.v.iter().map(|x| *x as f64).sum() }     pub fn max(&self) -> f32 { self.v.iter().cloned().fold(0.0, f32::max) } }++/// Grow a mask by r pixels (a disc), separably approximated by a square of the same radius then+/// trimmed to the disc.+fn dilate(m: &[u8], w: usize, h: usize, r: i64) -> Vec<u8> {+    if r <= 0 { return m.to_vec(); }+    let mut out = vec![0u8; w * h];+    let offs: Vec<(i64, i64)> = (-r..=r).flat_map(|dy| (-r..=r).map(move |dx| (dx, dy))).filter(|(dx, dy)| dx * dx + dy * dy <= r * r).collect();+    for i in 0..w * h {+        if m[i] == 0 { continue; }+        let (r0, c0) = ((i / w) as i64, (i % w) as i64);+        for (dx, dy) in &offs {+            let (rr, cc) = (r0 + dy, c0 + dx);+            if rr >= 0 && cc >= 0 && (rr as usize) < h && (cc as usize) < w { out[rr as usize * w + cc as usize] = 1; }+        }+    }+    out+}+

Comments

No comments yet.

Log in to comment.