app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
Colby Knox
AI Flow 0.2.77: Fusion layer-304 bottom copper (#203)
d6d2719
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
//! The Fusion lane's plan board: a Fusion Electronics board, as the EAGLE source Fusion exports
//! (`fusion_export_eagle_source` on the Fusion bridge), written as the `.kicad_pcb` text every stage
//! here reads. Fusion stays the board of record: this file is only the plan copy the router, the
//! pour planner and the gate work on, re-made from a fresh export whenever the live board changes,
//! and everything they decide lands back in Fusion through the Fusion bridge.
//!
//! The mapping, both ways (see `to_fusion_*`):
//! - y is negated (EAGLE is y up, KiCad y down); millimetres throughout.
//! - an element at (x, y, Rθ) is a footprint at (x, -y, θ) with its pads at (px, -py) and pad angle
//! θ + the pad's own rotation; `rot_kicad` then puts every pad exactly where Fusion has it.
//! - a mirrored element (on the bottom) is written with its pads already in board position
//! (footprint angle 0, pad angles absolute) on B.Cu, so the plan sees the copper where it is.
//! - layer 1 is F.Cu, layer 16 is B.Cu; the Dimension layer (20) is Edge.Cuts. A curved wire there
//! (`curve`, what MITER leaves at a rounded corner) is a `gr_arc` through its true midpoint, so a
//! rounded outline stays one closed loop with the board's real extents.
//! - signal wires, vias and Fusion pours (`polygonpour`, with their filled islands) come along, so a
//! partly routed or poured board is planned from where it stands.
//! - a rotation string `MRθ` is EAGLE's: rotate by θ counterclockwise, then mirror x (x -> -x) about the
//! element's origin, so a bottom part turns clockwise as seen from the top. This is the order Fusion
//! uses (the Fusion bridge's routing preflight, KiCad's EAGLE importer, and the wires that end on the
//! pins of an MR270 part in a real Fusion schematic all agree); R0 and R180 read the same either way.
//! - silkscreen text (issue #74): what Fusion prints on the silkscreen is written as `gr_text`, so
//! `finish` counts the board's real labels. tPlace (21) and tNames (25) go to F.SilkS, bPlace (22) and
//! bNames (26) to B.SilkS: the layers Fusion's and EAGLE's silkscreen CAM print (tNames with tPlace).
//! tValues/bValues (27/28) are not printed (aiflow's library puts >VALUE on 51) and are left out.
//! Plain board texts, a smashed element's displayed attributes (NAME, VALUE, ...) where they stand,
//! and an unsmashed element's package texts (>NAME, >VALUE) through the element's transform come
//! along; a smashed part whose NAME was deleted prints no name. Texts keep EAGLE's alignment and its
//! readability rule (a text turned past 90 up to 270 degrees is drawn turned back 180 degrees with its
//! alignment reversed, unless it spins). The footprint's own Reference stays hidden, so no name counts twice.
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write;
fn f(n: &roxmltree::Node, k: &str) -> f64 { n.attribute(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) }
fn q(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) }
fn n(v: f64) -> String { let r = (v * 1e6).round() / 1e6; if r == 0.0 { "0".into() } else { format!("{r}") } }
/// A Fusion pour's id in the plan board and the zone state (issue fusion-bridge#138): Fusion stores no
/// id on a polygon, so it is made from what the pour is: net, layer number and outline, whichever vertex
/// the outline starts at (EAGLE frame). The same pour keeps it across saves; a new outline is a new pour.
/// UUID-shaped, because the plan board also goes to KiCad.
pub fn zone_uuid(net: &str, layer: i64, outline: &[(f64, f64)]) -> String {
let mut pts: Vec<(i64, i64)> = outline.iter().map(|p| ((p.0 * 1000.0).round() as i64, (p.1 * 1000.0).round() as i64)).collect();
pts.sort_unstable();
let text = format!("{net}|{layer}|{pts:?}");
let fnv = |seed: u64| text.bytes().fold(seed, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));
let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x6c62_272e_07bb_0142));
format!("{:08x}-{:04x}-5{:03x}-8{:03x}-{:012x}", a >> 32, (a >> 16) & 0xffff, a & 0xfff, (b >> 48) & 0xfff, b & 0xffff_ffff_ffff)
}
/// Each element's EAGLE rotation in a .brd: reference -> (degrees, mirrored).
pub fn element_rotations(brd: &str) -> Result<HashMap<String, (f64, bool)>, String> {
let doc = roxmltree::Document::parse(brd).map_err(|e| format!("not a Fusion board: {e}"))?;
Ok(doc.descendants().filter(|e| e.has_tag_name("element")).filter_map(|e| Some((e.attribute("name")?.to_string(), parse_rot(e.attribute("rot"))))).collect())
}
/// "R90" / "MR180" / "SR45" -> (degrees, mirrored)
pub fn parse_rot(r: Option<&str>) -> (f64, bool) {
let r = r.unwrap_or("R0");
(r.trim_start_matches(|c: char| c.is_ascii_alphabetic()).parse::<f64>().unwrap_or(0.0), r.contains('M'))
}
pub fn is_eagle(text: &str) -> bool { text.chars().take(600).collect::<String>().contains("<eagle") }
/// The midpoint of an EAGLE curved wire: `curve` degrees of arc from `a` to `b`, counterclockwise when
/// positive (EAGLE frame, y up). The chord's midpoint for a straight wire.
pub fn arc_mid(a: (f64, f64), b: (f64, f64), curve: f64) -> (f64, f64) {
let (mx, my) = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (c, t) = (dx.hypot(dy), curve.to_radians());
if c < 1e-12 || t.abs() < 1e-9 || (t.abs() - std::f64::consts::TAU).abs() < 1e-9 { return (mx, my); }
// the centre sits on the chord's left for a counterclockwise arc under 180 degrees (tan's sign does the rest)
let d = (c / 2.0) / (t / 2.0).tan();
let (cx, cy) = (mx - dy / c * d, my + dx / c * d);
let (ux, uy, h) = (a.0 - cx, a.1 - cy, t / 2.0);
(cx + ux * h.cos() - uy * h.sin(), cy + ux * h.sin() + uy * h.cos())
}
/// The board's inner copper layers in stack order, as EAGLE numbers (2..15): from the `layerSetup` rule
/// ("(1+2*15+16)" is Top, Route2, Route15, Bottom), else every inner layer the board's copper uses, in
/// number order. Issue #112: a 4-layer Fusion board's inner planes were dropped.
fn inner_layers(board: &roxmltree::Node) -> Vec<String> {
let setup = board.descendants().find(|x| x.has_tag_name("param") && x.attribute("name") == Some("layerSetup")).and_then(|x| x.attribute("value"));
let mut out: Vec<String> = Vec::new();
if let Some(v) = setup {
let mut cur = String::new();
for ch in v.chars().chain(std::iter::once(' ')) {
if ch.is_ascii_digit() { cur.push(ch); continue; }
if let Ok(k) = cur.parse::<u32>() { if (2..=15).contains(&k) && !out.contains(&cur) { out.push(cur.clone()); } }
cur.clear();
}
if !out.is_empty() { return out; }
}
let mut used: Vec<u32> = board.descendants().filter(|x| x.has_tag_name("wire") || x.has_tag_name("polygon") || x.has_tag_name("polygonpour"))
.filter(|x| x.ancestors().any(|a| a.has_tag_name("signal")))
.filter_map(|x| x.attribute("layer").and_then(|l| l.parse::<u32>().ok())).filter(|k| (2..=15).contains(k)).collect();
used.sort(); used.dedup();
used.into_iter().map(|k| k.to_string()).collect()
}
/// Issue #203 (Zoltán): the board's bottom copper layer number. Fusion's newer board format numbers it 304
/// (named "Bottom"), with layer 16 left as an empty "Route16"; older boards use 16. The layer the board names
/// "Bottom" decides; 16 when none does.
fn bottom_of(board: &roxmltree::Node) -> String {
board.document().descendants().filter(|x| x.has_tag_name("layer"))
.find(|x| x.attribute("name").is_some_and(|n| n.eq_ignore_ascii_case("bottom")) && x.attribute("number").is_some_and(|k| k != "1"))
.and_then(|x| x.attribute("number")).unwrap_or("16").to_string()
}
/// The bottom copper layer number of a Fusion/EAGLE board file (see `bottom_of`), for the bridge's layer arguments.
pub fn bottom_number(text: &str) -> Option<i64> {
let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?;
let board = doc.descendants().find(|x| x.has_tag_name("board"))?;
bottom_of(&board).parse().ok()
}
/// An EAGLE copper layer's KiCad name: 1 is F.Cu, the board's bottom (16, or 304 in Fusion's newer format) is
/// B.Cu, and the inner layers In1.Cu, In2.Cu ... in the board's stack order (`inner_layers`). None for a layer
/// that is not copper on this board.
fn layer(l: Option<&str>, inner: &[String], bottom: &str) -> Option<String> {
match l {
Some("1") => Some("F.Cu".into()),
Some(x) if x == bottom || x == "16" => Some("B.Cu".into()),
Some(x) => inner.iter().position(|i| i == x).map(|k| format!("In{}.Cu", k + 1)),
None => None,
}
}
/// The KiCad silkscreen layer an EAGLE layer prints on (see the module notes); None when it does not print.
pub fn silk_layer(l: &str) -> Option<&'static str> { match l { "21" | "25" => Some("F.SilkS"), "22" | "26" => Some("B.SilkS"), _ => None } }
/// An EAGLE top/bottom layer pair swapped, as a package's layers are on a mirrored (bottom) element.
fn flip_layer(l: &str) -> String {
match l { "21" => "22", "22" => "21", "25" => "26", "26" => "25", "27" => "28", "28" => "27", "51" => "52", "52" => "51", x => x }.to_string()
}
/// A point of an element's package in board coordinates (EAGLE frame): rotate by the element's angle,
/// then mirror x when it is mirrored, then move to the element's origin.
pub fn place(p: (f64, f64), at: (f64, f64), deg: f64, mirror: bool) -> (f64, f64) {
let a = deg.to_radians();
let (x, y) = (p.0 * a.cos() - p.1 * a.sin(), p.0 * a.sin() + p.1 * a.cos());
(at.0 + if mirror { -x } else { x }, at.1 + y)
}
/// One EAGLE text as a KiCad `gr_text` line: anchor (EAGLE frame), size (height), ratio (stroke, percent
/// of the size), the final rotation (degrees, mirrored, spin) and EAGLE's alignment.
fn silk_text(o: &mut String, text: &str, at: (f64, f64), size: f64, ratio: f64, rot: (f64, bool, bool), align: &str, layer: &str) {
if text.trim().is_empty() { return; }
let (deg, mirror, spin) = rot;
let mut a = ((deg % 360.0) + 360.0) % 360.0;
let (mut h, mut v) = match align {
"bottom-left" | "" => ("left", "bottom"), "bottom-center" => ("", "bottom"), "bottom-right" => ("right", "bottom"),
"center-left" => ("left", ""), "center" => ("", ""), "center-right" => ("right", ""),
"top-left" => ("left", "top"), "top-center" => ("", "top"), "top-right" => ("right", "top"), _ => ("left", "bottom"),
};
// EAGLE keeps an unspun text readable: past 90 up to 270 degrees it is drawn turned back 180 degrees
// about its anchor with the alignment reversed (the same box on the board)
if !spin && a > 90.0 + 1e-9 && a <= 270.0 + 1e-9 {
a -= 180.0;
h = match h { "left" => "right", "right" => "left", x => x };
v = match v { "bottom" => "top", "top" => "bottom", x => x };
}
// a mirrored EAGLE text (rotate, then mirror x) is KiCad's mirrored text at the opposite angle
let k = if mirror { (360.0 - a) % 360.0 } else { a };
let size = if size > 0.0 { size } else { 1.27 };
let ratio = if ratio > 0.0 { ratio } else { 8.0 };
let just: Vec<&str> = [h, v, if mirror { "mirror" } else { "" }].into_iter().filter(|s| !s.is_empty()).collect();
let just = if just.is_empty() { String::new() } else { format!(" (justify {})", just.join(" ")) };
let _ = writeln!(o, " (gr_text {} (at {} {} {}) (layer \"{layer}\") (effects (font (size {} {}) (thickness {})){just}))", q(text), n(at.0), n(-at.1), n(k), n(size), n(size), n((size * ratio / 100.0).max(0.01)));
}
/// The plan board for a Fusion board (EAGLE source text).
pub fn to_kicad(text: &str) -> Result<String, 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(|x| x.has_tag_name("board")).ok_or("no <board> in the file: export the BOARD (.brd) from Fusion, not the schematic")?;
let yy = |v: f64| -v;
// nets: every signal, numbered from 1 in file order
let signals: Vec<roxmltree::Node> = board.descendants().filter(|x| x.has_tag_name("signal")).collect();
let mut nets: BTreeMap<String, usize> = BTreeMap::new();
let mut order: Vec<String> = Vec::new();
for s in &signals { let name = s.attribute("name").unwrap_or("").to_string(); if !name.is_empty() && !nets.contains_key(&name) { order.push(name.clone()); nets.insert(name, order.len()); } }
let mut pad_net: BTreeMap<(String, String), String> = BTreeMap::new();
for s in &signals { for c in s.children().filter(|x| x.has_tag_name("contactref")) { pad_net.insert((c.attribute("element").unwrap_or("").into(), c.attribute("pad").unwrap_or("").into()), s.attribute("name").unwrap_or("").into()); } }
// KiCad's numbered net format: a pad names its net, a track or via gives the number only, and a zone
// gives the number and then (net_name ...). KiCad 10 refuses a track, via or zone written like a pad.
let net = |name: &str| -> String { match nets.get(name) { Some(k) => format!("(net {k} {})", q(name)), None => "(net 0 \"\")".into() } };
let net_id = |name: &str| -> String { format!("(net {})", nets.get(name).copied().unwrap_or(0)) };
let zone_net = |name: &str| -> String { format!("(net {}) (net_name {})", nets.get(name).copied().unwrap_or(0), q(name)) };
let inner = inner_layers(&board);
let bottom = bottom_of(&board);
// KiCad numbers inner copper In<n>.Cu as 2n+2, listed between F.Cu and B.Cu
let inner_decl: String = (1..=inner.len()).map(|k| format!("({} \"In{k}.Cu\" signal) ", 2 * k + 2)).collect();
let mut o = String::new();
o.push_str("(kicad_pcb (version 20241229) (generator \"adom-aiflow-fusion\") (generator_version \"1\")\n (general (thickness 1.6))\n (paper \"A4\")\n");
o.push_str(&format!(" (layers (0 \"F.Cu\" signal) {inner_decl}(2 \"B.Cu\" signal) (9 \"F.Adhes\" user) (11 \"B.Adhes\" user) (13 \"F.Paste\" user) (15 \"B.Paste\" user) (5 \"F.SilkS\" user) (7 \"B.SilkS\" user) (1 \"F.Mask\" user) (3 \"B.Mask\" user) (25 \"Edge.Cuts\" user) (31 \"F.CrtYd\" user) (29 \"B.CrtYd\" user) (35 \"F.Fab\" user) (33 \"B.Fab\" user))\n"));
o.push_str(" (setup (pad_to_mask_clearance 0))\n (net 0 \"\")\n");
for (k, name) in order.iter().enumerate() { let _ = writeln!(o, " (net {} {})", k + 1, q(name)); }
// packages by (library, package)
let mut packages: BTreeMap<(String, String), roxmltree::Node> = BTreeMap::new();
for lib in board.descendants().filter(|x| x.has_tag_name("library")) {
let ln = lib.attribute("name").unwrap_or("").to_string();
for p in lib.descendants().filter(|x| x.has_tag_name("package")) { packages.insert((ln.clone(), p.attribute("name").unwrap_or("").to_string()), p); }
}
// the board's restring rules size every pad and via, as EAGLE/Fusion do: 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);
// a polygon's thermal gap is the board's Supply rule "thermal isolation" (EAGLE's default 10 mil)
let thermal_gap = len(param("slThermalIsolate"), 0.254).max(0.05);
let mut silk = String::new();
for e in board.descendants().filter(|x| x.has_tag_name("element")) {
let reference = e.attribute("name").unwrap_or("?").to_string();
let Some(pkg) = packages.get(&(e.attribute("library").unwrap_or("").to_string(), e.attribute("package").unwrap_or("").to_string())) else { continue };
let (ex, ey) = (f(&e, "x"), f(&e, "y"));
let (ea, mirror) = parse_rot(e.attribute("rot"));
let (fx, fy, fa, side) = (ex, yy(ey), if mirror { 0.0 } else { ea }, if mirror { "B.Cu" } else { "F.Cu" });
let _ = writeln!(o, " (footprint {} (layer {side}) (at {} {} {})\n (property \"Reference\" {} (at 0 0 0) (layer \"F.SilkS\") (hide yes))\n (property \"Value\" {} (at 0 0 0) (layer \"F.Fab\") (hide yes))\n (attr smd)",
q(&format!("{}:{}", e.attribute("library").unwrap_or(""), e.attribute("package").unwrap_or(""))), n(fx), n(fy), n(fa), q(&reference), q(e.attribute("value").unwrap_or("")));
for p in pkg.children().filter(|x| x.has_tag_name("smd") || x.has_tag_name("pad")) {
let name = p.attribute("name").unwrap_or("").to_string();
let (px, py) = (f(&p, "x"), f(&p, "y"));
let (pa, _) = parse_rot(p.attribute("rot"));
// local position: plain y negation; a mirrored element's pads go straight to board space
let (lx, ly, ang) = if mirror {
let (wx, wy) = place((px, py), (ex, ey), ea, true);
(wx - ex, yy(wy) - fy, -(ea + pa))
} else { (px, -py, ea + pa) };
let pn = pad_net.get(&(reference.clone(), name.clone())).map(|s| net(s)).unwrap_or_default();
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 l = if on_top != mirror { "F" } else { "B" };
let round = p.attribute("roundness").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
let shape = if round >= 100.0 && (dx - dy).abs() < 1e-9 { "circle" } else if round > 0.0 { "roundrect" } else { "rect" };
let rr = if shape == "roundrect" { format!(" (roundrect_rratio {})", n((round / 200.0).min(0.5))) } else { String::new() };
let _ = writeln!(o, " (pad {} smd {shape} (at {} {} {}) (size {} {}) (layers \"{l}.Cu\" \"{l}.Paste\" \"{l}.Mask\"){rr} {pn})", q(&name), n(lx), n(ly), n(ang), n(dx), n(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 shape = match p.attribute("shape") { Some("square") => "rect", Some("long") | Some("offset") => "oval", _ => "circle" };
let (sx, sy) = if shape == "oval" { (dia * 2.0, dia) } else { (dia, dia) };
let _ = writeln!(o, " (pad {} thru_hole {shape} (at {} {} {}) (size {} {}) (drill {}) (layers \"*.Cu\" \"*.Mask\") {pn})", q(&name), n(lx), n(ly), n(ang), n(sx), n(sy), n(drill));
}
}
o.push_str(" )\n");
silk_of_element(&mut silk, &e, pkg, (ex, ey), ea, mirror);
}
o.push_str(&silk);
if let Some(plain) = board.children().find(|x| x.has_tag_name("plain")) {
for t in plain.children().filter(|x| x.has_tag_name("text")) {
let Some(l) = silk_layer(t.attribute("layer").unwrap_or("")) else { continue };
let content = t.text().unwrap_or("");
if content.starts_with('>') { continue; } // a drawing-frame placeholder, not printed text
let (d, m) = parse_rot(t.attribute("rot"));
silk_text(&mut o, content, (f(&t, "x"), f(&t, "y")), f(&t, "size"), f(&t, "ratio"), (d, m, t.attribute("rot").unwrap_or("").contains('S')), t.attribute("align").unwrap_or(""), l);
}
}
// outline: the Dimension layer
let mut edges = 0;
if let Some(plain) = board.children().find(|x| x.has_tag_name("plain")) {
for w in plain.children().filter(|x| x.attribute("layer") == Some("20")) {
match w.tag_name().name() {
"wire" => {
edges += 1;
let (a, b, curve) = ((f(&w, "x1"), f(&w, "y1")), (f(&w, "x2"), f(&w, "y2")), f(&w, "curve"));
if curve.abs() > 1e-6 {
let m = arc_mid(a, b, curve);
let _ = writeln!(o, " (gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(m.0), n(yy(m.1)), n(b.0), n(yy(b.1)));
} else { let _ = writeln!(o, " (gr_line (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(b.0), n(yy(b.1))); }
}
"rectangle" => { edges += 1; let _ = writeln!(o, " (gr_rect (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2")))); }
"polygon" => {
// a vertex's `curve` bends the edge to the next vertex: written as points along the arc
edges += 1; o.push_str(" (gr_poly (pts");
let vs: Vec<((f64, f64), f64)> = w.children().filter(|x| x.has_tag_name("vertex")).map(|v| ((f(&v, "x"), f(&v, "y")), f(&v, "curve"))).collect();
for (i, (p, curve)) in vs.iter().enumerate() {
let _ = write!(o, " (xy {} {})", n(p.0), n(yy(p.1)));
if curve.abs() > 1e-6 {
let q = vs[(i + 1) % vs.len()].0;
let pts = crate::geom::arc_points(*p, arc_mid(*p, q, *curve), q, 0.25);
for a in &pts[1..pts.len() - 1] { let _ = write!(o, " (xy {} {})", n(a.0), n(yy(a.1))); }
}
}
o.push_str(") (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))\n");
}
_ => {}
}
}
}
if edges == 0 { return Err("no board outline: the Dimension layer (20) is empty; set the board shape in Fusion first".into()); }
// copper already on the board
for s in &signals {
let name = s.attribute("name").unwrap_or("");
for w in s.children().filter(|x| x.has_tag_name("wire")) {
let Some(l) = layer(w.attribute("layer"), &inner, &bottom) else { continue };
let _ = writeln!(o, " (segment (start {} {}) (end {} {}) (width {}) (layer {l}) {})", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2"))), n(f(&w, "width").max(0.05)), net_id(name));
}
for v in s.children().filter(|x| x.has_tag_name("via")) {
let drill = f(&v, "drill");
let _ = writeln!(o, " (via (at {} {}) (size {}) (drill {}) (layers \"F.Cu\" \"B.Cu\") {})", n(f(&v, "x")), n(yy(f(&v, "y"))), n(f(&v, "diameter").max(drill + 2.0 * ring(drill, via_rv, via_min, via_max))), n(drill), net_id(name));
}
for p in s.children().filter(|x| x.has_tag_name("polygonpour") || x.has_tag_name("polygon")) {
let Some(l) = layer(p.attribute("layer"), &inner, &bottom) else { continue };
let verts = |nd: roxmltree::Node| -> String { nd.children().filter(|v| v.has_tag_name("vertex")).map(|v| format!("(xy {} {})", n(f(&v, "x")), n(yy(f(&v, "y"))))).collect::<Vec<_>>().join(" ") };
let outline = if p.has_tag_name("polygon") { verts(p) } else { p.children().find(|x| x.has_tag_name("polygonoutlineobjects")).map(verts).unwrap_or_default() };
if outline.is_empty() { continue; }
let pname = p.attribute("name").map(|v| format!(" (name {})", q(v))).unwrap_or_default();
// thermals="no" (CHANGE THERMALS OFF) joins pads solid; EAGLE's default is thermal relief, its
// gap the board's thermal isolation, its spokes Fusion's thermalwidth, else the polygon's width (issue #112)
let width = f(&p, "width").max(0.1);
let spoke = p.attribute("thermalwidth").and_then(|v| v.parse::<f64>().ok()).filter(|v| *v > 0.0).unwrap_or(width);
let connect = if p.attribute("thermals") == Some("no") { "connect_pads yes" } else { "connect_pads" };
let raw = |nd: roxmltree::Node| -> Vec<(f64, f64)> { nd.children().filter(|v| v.has_tag_name("vertex")).map(|v| (f(&v, "x"), f(&v, "y"))).collect() };
let raw_outline = if p.has_tag_name("polygon") { raw(p) } else { p.children().find(|x| x.has_tag_name("polygonoutlineobjects")).map(raw).unwrap_or_default() };
let uuid = zone_uuid(name, p.attribute("layer").and_then(|v| v.parse().ok()).unwrap_or(1), &raw_outline);
let _ = write!(o, " (zone {} (layer {l}) (uuid {}){pname} (hatch edge 0.5) (priority {}) ({connect} (clearance {})) (min_thickness {}) (fill yes (thermal_gap {}) (thermal_bridge_width {}))\n (polygon (pts {outline}))", zone_net(name), q(&uuid), p.attribute("rank").and_then(|r| r.parse::<i64>().ok()).map(|r| (7 - r).max(0)).unwrap_or(0), n(f(&p, "isolate").max(0.1)), n(width), n(thermal_gap), n(spoke));
if let Some(fd) = p.children().find(|x| x.has_tag_name("polygonfilldetails")) {
for sh in fd.children().filter(|x| x.has_tag_name("polygonshape")) {
if let Some(seg) = sh.children().find(|x| x.has_tag_name("polygonoutlinesegments")) { let _ = write!(o, "\n (filled_polygon (layer {l}) (pts {}))", verts(seg)); }
}
}
o.push_str(")\n");
}
}
o.push_str(")\n");
Ok(o)
}
/// The silkscreen text an element shows: a smashed element its displayed attributes where they stand
/// (their layer is already the board's), an unsmashed one its package texts through its transform.
fn silk_of_element(o: &mut String, e: &roxmltree::Node, pkg: &roxmltree::Node, at: (f64, f64), ea: f64, mirror: bool) {
let name = e.attribute("name").unwrap_or("");
let value = e.attribute("value").unwrap_or("");
let attr = |key: &str| -> Option<String> {
match key.to_ascii_uppercase().as_str() { "NAME" => Some(name.to_string()), "VALUE" => Some(value.to_string()), k => 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) }
};
if e.attribute("smashed") == Some("yes") {
for a in e.children().filter(|x| x.has_tag_name("attribute")) {
let Some(l) = a.attribute("layer").and_then(silk_layer) else { continue };
let key = a.attribute("name").unwrap_or("");
let val = attr(key).or_else(|| a.attribute("value").map(str::to_string)).unwrap_or_default();
let shown = match a.attribute("display").unwrap_or("value") { "off" => continue, "name" => key.to_string(), "both" => format!("{key}={val}"), _ => val };
let (d, m) = parse_rot(a.attribute("rot"));
silk_text(o, &shown, (f(&a, "x"), f(&a, "y")), f(&a, "size"), f(&a, "ratio"), (d, m, a.attribute("rot").unwrap_or("").contains('S')), a.attribute("align").unwrap_or(""), l);
}
return;
}
for t in pkg.children().filter(|x| x.has_tag_name("text")) {
let pl = t.attribute("layer").unwrap_or("");
let Some(l) = silk_layer(&if mirror { flip_layer(pl) } else { pl.to_string() }) else { continue };
let raw = t.text().unwrap_or("");
let shown = match raw.strip_prefix('>') { Some(k) => match attr(k) { Some(v) => v, None => continue }, None => raw.to_string() };
let (ta, tm) = parse_rot(t.attribute("rot"));
// the element's transform on the text's own: mirrored once (either) reads mirrored; a mirrored
// text's angle runs against the element's
let deg = if tm { ta - ea } else { ta + ea };
let p = place((f(&t, "x"), f(&t, "y")), at, ea, mirror);
silk_text(o, &shown, p, f(&t, "size"), f(&t, "ratio"), (deg, tm != mirror, t.attribute("rot").unwrap_or("").contains('S')), t.attribute("align").unwrap_or(""), l);
}
}
/// A footprint position in the plan (KiCad frame) as the Fusion MOVE/ROTATE it needs: (x, y, "R<deg>").
pub fn to_fusion_xy(x: f64, y: f64) -> (f64, f64) { (x, -y) }
pub fn to_fusion_rot(deg: f64) -> String { let d = ((deg % 360.0) + 360.0) % 360.0; format!("R{}", n(d)) }
#[cfg(test)]
mod tests {
const BRD: &str = r#"<?xml version="1.0"?><eagle><drawing><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"/><wire x1="20" y1="10" x2="0" y2="10" width="0" layer="20"/><wire x1="0" y1="10" x2="0" y2="0" 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"/><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" drill="0.3"/></signal>
<signal name="B"><contactref element="R1" pad="2"/></signal></signals></board></drawing></eagle>"#;
#[test]
fn the_plan_board_puts_every_pad_where_fusion_has_it() {
let text = super::to_kicad(BRD).unwrap();
let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();
// R1 at (5,5) rotated 90: pad 1 (-0.5,0) is at (5, 4.5) in Fusion, (5, -4.5) in the plan
let p = b.pad_by_key("R1.1").unwrap();
assert!((p.x - 5.0).abs() < 1e-6 && (p.y + 4.5).abs() < 1e-6, "{} {}", p.x, p.y);
assert_eq!(p.net, "A");
// R2 mirrored (bottom): pad 1 (-0.5,0) -> (10.5, 5) in Fusion, on B.Cu
let p = b.pad_by_key("R2.1").unwrap();
assert!((p.x - 10.5).abs() < 1e-6 && (p.y + 5.0).abs() < 1e-6 && p.on_b && !p.on_f, "{} {} {} {}", p.x, p.y, p.on_f, p.on_b);
assert_eq!(b.tracks().len(), 1); assert_eq!(b.vias().len(), 1);
assert_eq!((b.bbox.0, b.bbox.2), (0.0, 20.0));
assert_eq!(super::to_fusion_xy(5.0, -4.5), (5.0, 4.5)); assert_eq!(super::to_fusion_rot(-90.0), "R270");
}
/// fusion-bridge#138: a pour in the plan board has an id to own, the one the zone state gives it.
#[test]
fn a_saved_pour_has_a_stable_id_in_the_plan_board() {
let with = BRD.replace(r#"<signal name="B">"#, r#"<signal name="B"><polygon width="0.3" layer="1" isolate="0.25"><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygon>"#);
let text = super::to_kicad(&with).unwrap();
let id = super::zone_uuid("B", 1, &[(19.0, 9.0), (1.0, 1.0), (19.0, 1.0)]);
assert_eq!(id.len(), 36);
assert!(text.contains(&format!("(uuid \"{id}\")")), "{text}");
assert_eq!(crate::Board::from_text("plan.kicad_pcb", text).unwrap().footprints.len(), 2);
}
#[test]
fn element_rotations_read_the_side_and_the_angle() {
let r = super::element_rotations(BRD).unwrap();
assert_eq!(r["R1"], (90.0, false));
assert_eq!(r["R2"], (0.0, true));
}
/// Issue #112: a polygon with thermals="no" joins its pads solid; one without keeps thermal relief,
/// its gap the board's thermal isolation (slThermalIsolate) and its spokes Fusion's thermalwidth
/// (else the polygon's width).
#[test]
fn polygon_thermals_carry_over_from_fusion() {
let poly = |th: &str| format!(r#"<polygon width="0.3" layer="1" isolate="0.25"{th}><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/><vertex x="1" y="9"/></polygon>"#);
for (th, rules, want_cp, gap) in [(r#" thermals="no""#, "", "(connect_pads yes (clearance 0.25))", "0.254"),
("", "", "(connect_pads (clearance 0.25))", "0.254"),
(r#" thermals="yes""#, r#"<designrules name="x"><param name="slThermalIsolate" value="16mil"/></designrules>"#, "(connect_pads (clearance 0.25))", "0.4064")] {
let brd = BRD.replace(r#"<signal name="B">"#, &format!(r#"<signal name="B">{}"#, poly(th))).replace("<plain>", &format!("{rules}<plain>"));
let text = super::to_kicad(&brd).unwrap();
let z = text.lines().find(|l| l.contains("(zone ")).unwrap();
assert!(z.contains(want_cp), "{th}: {z}");
assert!(z.contains(&format!("(thermal_gap {gap}) (thermal_bridge_width 0.3)")), "{th}: {z}");
crate::Board::from_text("plan.kicad_pcb", text.clone()).unwrap();
}
// a Fusion polygonpour as fusion_board_source writes it: thermalwidth sets the spokes
let pour = r#"<polygonpour layer="1" isolate="0.2" thermalwidth="0.1524" width="0.2" thermals="yes"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygonoutlineobjects></polygonpour>"#;
let text = super::to_kicad(&BRD.replace(r#"<signal name="B">"#, &format!(r#"<signal name="B">{pour}"#))).unwrap();
let z = text.lines().find(|l| l.contains("(zone ")).unwrap();
assert!(z.contains("(connect_pads (clearance 0.2))") && z.contains("(thermal_gap 0.254) (thermal_bridge_width 0.1524)"), "{z}");
}
/// Issue #112 (Glenn's TI252772 module): a 4-layer Fusion board keeps its inner planes. The stack order
/// comes from layerSetup ("(1+2*15+16)": Route2 is In1.Cu, Route15 is In2.Cu); without it, the inner
/// layers the copper uses, in number order. A 2-layer board is unchanged.
#[test]
fn inner_copper_layers_carry_over_from_fusion() {
let pour = |layer: &str, net: &str| format!(r#"<polygonpour layer="{layer}" isolate="0.2" width="0.2" thermals="no"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygonoutlineobjects></polygonpour>"#).replace("NET", net);
let four = |rules: &str| BRD.replace("<plain>", &format!("{rules}<plain>"))
.replace(r#"<signal name="B">"#, &format!(r#"<signal name="B">{}<wire x1="1" y1="2" x2="5" y2="2" width="0.2" layer="15"/>"#, pour("2", "B")))
.replace(r#"<signal name="A">"#, &format!(r#"<signal name="A">{}"#, pour("15", "A")));
let rules = r#"<designrules name="x"><param name="layerSetup" value="(1+2*15+16)"/></designrules>"#;
for brd in [four(rules), four("")] {
let text = super::to_kicad(&brd).unwrap();
assert!(text.contains(r#"(layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)"#), "{}", &text[..400]);
let zones: Vec<&str> = text.lines().filter(|l| l.contains("(zone ")).collect();
assert!(zones.iter().any(|z| z.contains("(net_name \"B\") (layer In1.Cu)")), "{zones:?}");
assert!(zones.iter().any(|z| z.contains("(net_name \"A\") (layer In2.Cu)")), "{zones:?}");
assert!(text.lines().any(|l| l.contains("(segment") && l.contains("(layer In2.Cu)")));
let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();
assert_eq!(b.copper_layers, ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);
}
// a stack that lists only one inner layer maps it to In1.Cu, and copper on an unlisted layer is left out
let one = four(r#"<designrules name="x"><param name="layerSetup" value="(1*15+16)"/></designrules>"#);
let text = super::to_kicad(&one).unwrap();
assert!(text.contains(r#"(0 "F.Cu" signal) (4 "In1.Cu" signal) (2 "B.Cu" signal)"#));
assert!(text.lines().any(|l| l.contains("(net_name \"A\") (layer In1.Cu)")));
// two layers: as before
let text = super::to_kicad(BRD).unwrap();
assert!(text.contains(r#"(layers (0 "F.Cu" signal) (2 "B.Cu" signal)"#) && !text.contains("In1.Cu"));
}
/// EAGLE's MRθ: rotate θ counterclockwise, then mirror x. A pad off both axes tells the order apart
/// (mirror-then-rotate puts MR90's pad 1 at the opposite corner).
#[test]
fn a_bottom_part_turned_90_or_270_has_its_pads_where_fusion_does() {
let brd = BRD.replace(r#"<smd name="1" x="-0.5" y="0""#, r#"<smd name="1" x="-0.5" y="0.2""#);
for (rot, want) in [("R0", (9.5, 5.2)), ("R90", (9.8, 4.5)), ("MR0", (10.5, 5.2)), ("MR90", (10.2, 4.5)), ("MR180", (9.5, 4.8)), ("MR270", (9.8, 5.5))] {
let text = super::to_kicad(&brd.replace(r#"rot="MR0""#, &format!(r#"rot="{rot}""#))).unwrap();
let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();
let p = b.pad_by_key("R2.1").unwrap();
assert!((p.x - want.0).abs() < 1e-6 && (p.y + want.1).abs() < 1e-6, "{rot}: pad 1 at ({}, {}), want ({}, {})", p.x, -p.y, want.0, want.1);
assert_eq!(p.on_b, rot.starts_with('M'), "{rot}");
}
}
#[test]
fn silkscreen_text_comes_along_on_the_layers_fusion_prints() {
let brd = BRD.replace("</plain>", r#"<text x="2" y="3" size="1" layer="21">TOP</text><text x="4" y="5" size="1.2" layer="22" rot="MR0" align="center">BOTTOM</text><text x="1" y="1" size="1" layer="21" rot="R180">TURNED</text><text x="1" y="9" size="1" layer="51">DOC</text></plain>"#)
.replace(r#"<package name="R0402">"#, r#"<package name="R0402"><text x="0" y="1" size="0.8" layer="25">>NAME</text><text x="0" y="-1" size="0.8" layer="27">>VALUE</text>"#)
.replace(r#"x="5" y="5" rot="R90"/>"#, r#"x="5" y="5" rot="R90" smashed="yes"><attribute name="NAME" x="6" y="7" size="0.8" layer="25" rot="R90"/><attribute name="VALUE" x="6" y="8" size="0.8" layer="27"/></element><element name="R3" library="L" package="R0402" value="1k" x="15" y="5" smashed="yes"/>"#);
let text = super::to_kicad(&brd).unwrap();
let lines: Vec<&str> = text.lines().filter(|l| l.contains("(gr_text")).collect();
// TOP, BOTTOM, TURNED; R1's smashed NAME (its VALUE is on tValues); R2's package >NAME through its
// bottom-side transform (its >VALUE is tValues too); R3 is smashed with its NAME deleted: nothing
assert_eq!(lines.len(), 5, "{lines:#?}");
let has = |s: &str| lines.iter().any(|l| l.contains(s));
assert!(has(r#"(gr_text "TOP" (at 2 -3 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.08)) (justify left bottom)))"#), "{lines:#?}");
assert!(has(r#"(gr_text "BOTTOM" (at 4 -5 0) (layer "B.SilkS")"#) && has("(justify mirror)"), "{lines:#?}");
assert!(has(r#"(gr_text "TURNED" (at 1 -1 0) (layer "F.SilkS")"#) && has("(justify right top)"), "readable, not upside down: {lines:#?}");
assert!(has(r#"(gr_text "R1" (at 6 -7 90) (layer "F.SilkS")"#), "{lines:#?}");
assert!(has(r#"(gr_text "R2" (at 10 -6 0) (layer "B.SilkS")"#) && has("(justify left bottom mirror)"), "{lines:#?}");
assert!(!has("\"R3\"") && !has("10k") && !has("DOC"), "{lines:#?}");
// the board still reads, and the footprints' own references stay hidden (no name counted twice)
let b = crate::Board::from_text("plan.kicad_pcb", text.clone()).unwrap();
assert_eq!(b.footprints.len(), 3);
assert!(text.lines().filter(|l| l.contains("(property \"Reference\"")).all(|l| l.contains("(hide yes)")));
}
/// Opt-in on a real Fusion board: AIFLOW_FUSION_BRD=<board.brd> cargo test -p aiflow-board -- --ignored real_fusion_board_silk
#[test]
#[ignore]
fn real_fusion_board_silk() {
let path = std::env::var("AIFLOW_FUSION_BRD").expect("AIFLOW_FUSION_BRD");
let text = super::to_kicad(&std::fs::read_to_string(&path).unwrap()).unwrap();
let b = crate::Board::from_text("plan.kicad_pcb", text.clone()).unwrap();
let (f, bk) = (text.lines().filter(|l| l.contains("(gr_text") && l.contains("F.SilkS")).count(), text.lines().filter(|l| l.contains("(gr_text") && l.contains("B.SilkS")).count());
eprintln!("{path}: {} footprints, {f} top and {bk} bottom silkscreen texts", b.footprints.len());
assert!(f + bk > 0);
}
/// A 30 x 20 board with 1 mm corners as MITER leaves it (straight wires shortened, a curve="90" wire
/// at each corner), drawn either way round.
fn rounded(ccw: bool) -> String {
let mut w = vec![((1.0, 0.0), (29.0, 0.0), 0.0), ((29.0, 0.0), (30.0, 1.0), 90.0), ((30.0, 1.0), (30.0, 19.0), 0.0), ((30.0, 19.0), (29.0, 20.0), 90.0),
((29.0, 20.0), (1.0, 20.0), 0.0), ((1.0, 20.0), (0.0, 19.0), 90.0), ((0.0, 19.0), (0.0, 1.0), 0.0), ((0.0, 1.0), (1.0, 0.0), 90.0)];
if !ccw { w = w.into_iter().rev().map(|(a, b, c)| (b, a, -c)).collect(); }
let wires: String = w.iter().map(|(a, b, c)| format!(r#"<wire x1="{}" y1="{}" x2="{}" y2="{}" width="0" layer="20"{}/>"#, a.0, a.1, b.0, b.1, if *c != 0.0 { format!(r#" curve="{c}""#) } else { String::new() })).collect();
BRD.replace(&BRD[BRD.find("<plain>").unwrap()..BRD.find("</plain>").unwrap()], &format!("<plain>{wires}"))
}
#[test]
fn a_rounded_outline_converts_to_arcs_and_stays_closed() {
let m = super::arc_mid((29.0, 0.0), (30.0, 1.0), 90.0);
assert!((m.0 - (29.0 + 0.5f64.sqrt())).abs() < 1e-9 && (m.1 - (1.0 - 0.5f64.sqrt())).abs() < 1e-9, "{m:?}");
for ccw in [true, false] {
let text = super::to_kicad(&rounded(ccw)).unwrap();
assert_eq!(text.matches("(gr_arc").count(), 4); assert_eq!(text.matches("(gr_line").count(), 4);
let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();
// one closed loop (read_outline chains it or fails), the board's true extents, and the area of
// the rounded rectangle (30 x 20 less 4 - pi, the arcs as 0.35 mm steps), not the 598 of a chord per corner
let (x0, y0, x1, y1) = b.bbox;
assert!(x0.abs() < 1e-6 && (y0 + 20.0).abs() < 1e-6 && (x1 - 30.0).abs() < 1e-6 && y1.abs() < 1e-6, "{:?}", b.bbox);
let area = crate::geom::polygon_area(&b.outline).abs();
assert!((area - (600.0 - (4.0 - std::f64::consts::PI))).abs() < 0.1, "area {area}");
// every outline point is on the rounded rectangle: no point outside it, the corners on their circles
for p in &b.outline {
let (cx, cy) = (p.0.clamp(1.0, 29.0), p.1.clamp(-19.0, -1.0));
let d = (p.0 - cx).hypot(p.1 - cy);
assert!(d < 1.0 + 1e-6 && (p.0 >= 1.0 - 1e-9 && p.0 <= 29.0 + 1e-9 || p.1 >= -19.0 - 1e-9 && p.1 <= -1.0 + 1e-9 || (d - 1.0).abs() < 1e-6), "{p:?} off the outline");
}
}
}
/// Issue #203 (Zoltán): Fusion's newer board format numbers the bottom copper 304 ("Bottom"), with 16 an
/// empty "Route16"; its tracks and pours land on B.Cu, not dropped.
#[test]
fn the_newer_fusion_format_keeps_its_bottom_copper_on_304() {
let brd = r#"<?xml version="1.0"?><eagle><drawing><layers>
<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>
<layer number="16" name="Route16" color="1" fill="1" visible="no" active="no"/>
<layer number="304" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>
<layer number="20" name="Dimension" color="24" fill="1" visible="yes" active="yes"/></layers>
<board><plain><wire x1="0" y1="0" x2="20" y2="0" width="0" layer="20"/><wire x1="20" y1="0" x2="20" y2="20" width="0" layer="20"/><wire x1="20" y1="20" x2="0" y2="20" width="0" layer="20"/><wire x1="0" y1="20" x2="0" y2="0" width="0" layer="20"/></plain>
<designrules name="x"><param name="layerSetup" value="(1*64)"/></designrules>
<signals><signal name="GND"><wire x1="2" y1="2" x2="10" y2="2" width="0.3" layer="304"/><via x="10" y="2" extent="1-64" drill="0.3"/>
<polygonpour width="0.2" layer="304"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="19"/><vertex x="1" y="19"/></polygonoutlineobjects></polygonpour></signal></signals>
</board></drawing></eagle>"#;
let k = crate::eagle::to_kicad(brd).unwrap();
assert!(k.contains("(layer B.Cu)") || k.contains("(layer \"B.Cu\")"), "the 304 track is on B.Cu: {k}");
assert!(k.matches("B.Cu").count() >= 3, "the pour is kept on B.Cu too: {k}");
assert!(!k.contains("In1.Cu"), "no inner layer is invented");
assert_eq!(crate::eagle::bottom_number(brd), Some(304));
assert_eq!(crate::eagle::bottom_number(&brd.replace("number=\"304\" name=\"Bottom\"", "number=\"305\" name=\"Other\"").replace("number=\"16\" name=\"Route16\"", "number=\"16\" name=\"Bottom\"")), Some(16), "an older board's Bottom is 16");
}
}