app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
John Lauer
0.10.3: KiCad 8 numbered nets (issue 21); same-net copper pieces kept apart (issue 22)
5ed6b7b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
//! The board as the solver needs it: outline, filled copper per net and layer, tracks, pads, vias.
use crate::sexp::{parse, Sx};
/// the copper slots, top to bottom: boards up to six layers (a two- or four-layer board leaves the inner slots it lacks empty)
pub const LAYERS: [&str; 6] = ["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "B.Cu"];
#[derive(Debug, Clone)]
pub struct Poly { pub net: String, pub layer: String, pub pts: Vec<(f64, f64)> }
#[derive(Debug, Clone)]
pub struct Track { pub net: String, pub layer: String, pub a: (f64, f64), pub b: (f64, f64), pub w: f64 }
#[derive(Debug, Clone)]
pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64, /** a slotted hole's long dimension */ pub drill_long: Option<f64>, /** the slot's angle, degrees */ pub drill_angle: f64 }
impl Via {
/// The plated barrel's copper cross-section: a circular or slotted hole, 25 µm plating (PR 5).
pub fn barrel_area_mm2(&self) -> f64 { let t = 0.025; let per = std::f64::consts::PI * self.drill + 2.0 * (self.drill_long.unwrap_or(self.drill) - self.drill); per * t + std::f64::consts::PI * t * t }
}
#[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, /** the symbol pin's name when the file has it (KiCad pinfunction: VIN, VOUT_3, GND) */ pub func: String }
/// 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,
pub polys: Vec<Poly>, pub tracks: Vec<Track>, pub vias: Vec<Via>, pub pads: Vec<Pad>,
/// pad copper shapes as polygons (already in board coordinates)
pub pad_polys: Vec<Poly>,
pub arc_nets: Vec<String>,
pub layer_names: Vec<String>,
/// plated through-hole pads' barrels, joining the copper layers like vias (PR 5)
pub plated_holes: Vec<Via>,
/// 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,
/// each part's value as the board carries it ("15uH", "22uF 10V"): the energy cycle reads L and C from it
pub values: std::collections::BTreeMap<String, String>,
/// each part's manufacturer part number and footprint name, when the file carries them (the capacitor model
/// reads the technology from them: an electrolytic's Value is just "47uF 35V")
pub mpns: std::collections::BTreeMap<String, String>,
pub footprints: std::collections::BTreeMap<String, String>,
/// the copper layers the board declares (a two-layer board has no In1/In2: pads never spread onto them)
pub copper: Vec<String>,
/// the dielectric between each pair of consecutive real copper layers, top down, from the board's own stackup
/// (else its overall thickness), when the file carries one
pub stack_mm: Option<Vec<f64>>,
/// εr of each of those gaps (thickness-weighted over its dielectric sublayers), when the stackup says
pub stack_er: Option<Vec<f64>>,
}
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()) }
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(); b.copper = LAYERS.iter().filter(|l| b.layer_names.iter().any(|n| n == *l)).map(|l| l.to_string()).collect(); return Ok(b); }
let root = parse(&text);
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();
b.copper = LAYERS.iter().filter(|l| b.layer_names.iter().any(|n| n == *l)).map(|l| l.to_string()).collect();
// the saved stackup: the dielectric thickness between each pair of consecutive copper layers
if let Some(st) = root.kid("setup").and_then(|s| s.kid("stackup")) {
let mut gaps: Vec<f64> = Vec::new(); let mut ers: Vec<f64> = Vec::new(); let (mut acc, mut acc_er) = (0.0, 0.0); let mut seen_cu = false;
for l in st.kids("layer") {
let name = l.atom(1).unwrap_or("");
let t = l.kid("thickness").and_then(|x| x.num(1)).unwrap_or(0.0);
if name.ends_with(".Cu") { if seen_cu { gaps.push(acc); ers.push(if acc > 0.0 && acc_er > 0.0 { acc_er / acc } else { 0.0 }); } acc = 0.0; acc_er = 0.0; seen_cu = true; }
else if seen_cu && name.starts_with("dielectric") { acc += t; if let Some(e) = l.kid("epsilon_r").and_then(|x| x.num(1)) { acc_er += e * t; } }
}
if ers.len() == gaps.len() && !ers.is_empty() && ers.iter().all(|e| *e >= 1.0) { b.stack_er = Some(ers.clone()); }
if !gaps.is_empty() && gaps.iter().all(|g| *g > 0.0) { b.stack_mm = Some(gaps); }
}
// no stackup saved: the board's overall thickness, less its copper, split the usual way
if b.stack_mm.is_none() {
if let Some(t) = root.kid("general").and_then(|g| g.kid("thickness")).and_then(|t| t.num(1)) {
let ncu = if b.copper.is_empty() { 4 } else { b.copper.len() }; let d = t - 0.035 * ncu as f64;
if d > 0.2 { b.stack_mm = Some(match ncu { 2 => vec![d], 4 => vec![0.2, (d - 0.4).max(0.1), 0.2], _ => { let c = ((d - 0.4) / 3.0).max(0.05); vec![0.2, c, c, c, 0.2] } }); }
}
}
// the net table: `(net 41 "GND")` (KiCad 8 and earlier number every net; KiCad 10 names them on each item)
let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.tok(1)?,n.atom(2)?.to_string()))).collect();
// an item's net: its name when it carries one (`(net 41 "GND")`, `(net "GND")`), else its number through the table
// (a KiCad 8 track or via is `(net 41)`, issue 21)
let net_name = |n: &Sx| -> String { if let Some(name) = n.atom(2) { return name.to_string(); } let id = n.tok(1).unwrap_or_default(); net_names.get(&id).cloned().unwrap_or(id) };
// outline: the Edge.Cuts graphics' bounding box
let (mut xs, mut ys) = (Vec::new(), Vec::new());
for key in ["gr_line", "gr_rect", "gr_arc", "gr_poly", "gr_circle"] {
for g in root.kids(key) {
if g.kid("layer").and_then(|l| l.atom(1)) != Some("Edge.Cuts") { continue; }
for k in ["start", "end", "mid", "center"] { if let Some(p) = g.kid(k) { if let (Some(x), Some(y)) = (p.num(1), p.num(2)) { xs.push(x); ys.push(y); } } }
if let Some(pts) = g.kid("pts") { for xy in pts.kids("xy") { if let (Some(x), Some(y)) = (xy.num(1), xy.num(2)) { xs.push(x); ys.push(y); } } }
}
}
if xs.is_empty() { return Err("no Edge.Cuts outline on the board".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);
for z in root.kids("zone") {
let net = z.kid("net_name").and_then(|n| n.atom(1)).map(str::to_string).or_else(|| z.kid("net").map(&net_name)).unwrap_or_default();
for fp in z.kids("filled_polygon") {
let layer = fp.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string();
let pts: Vec<(f64, f64)> = fp.kid("pts").map(|p| p.kids("xy").iter().filter_map(|xy| Some((xy.num(1)?, xy.num(2)?))).collect()).unwrap_or_default();
if pts.len() > 2 { b.polys.push(Poly { net: net.clone(), layer, pts }); }
}
}
b.arc_nets = root.kids("arc").iter().filter_map(|a|a.kid("net").map(&net_name)).collect();
for s in root.kids("segment") {
let (Some(st), Some(en)) = (s.kid("start"), s.kid("end")) else { continue };
b.tracks.push(Track { net: s.kid("net").map(&net_name).unwrap_or_default(), layer: s.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string(), a: (st.num(1).unwrap_or(0.0), st.num(2).unwrap_or(0.0)), b: (en.num(1).unwrap_or(0.0), en.num(2).unwrap_or(0.0)), w: s.kid("width").and_then(|w| w.num(1)).unwrap_or(0.2) });
}
for v in root.kids("via") {
// the field model joins every copper layer through a via: a blind, buried or micro via is refused (PR 5)
if v.atom(1).is_some_and(|k| k == "blind" || k == "micro") { return Err("blind, buried or micro vias are not supported: the field model joins every copper layer through a via".into()); }
if let Some(span) = v.kid("layers") { if span.atom(1).is_some_and(|a| a != "F.Cu") || span.atom(2).is_some_and(|z| z != "B.Cu") { return Err("only through vias (F.Cu to B.Cu) are supported".into()); } }
let Some(at) = v.kid("at") else { continue };
b.vias.push(Via { net: v.kid("net").map(&net_name).unwrap_or_default(), x: at.num(1).unwrap_or(0.0), y: at.num(2).unwrap_or(0.0), size: v.kid("size").and_then(|s| s.num(1)).unwrap_or(0.6), drill: v.kid("drill").and_then(|d| d.num(1)).unwrap_or(0.3), drill_long: None, drill_angle: 0.0 });
}
for fp in root.kids("footprint") {
let reference = fp.kids("property").iter().find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).unwrap_or("?").to_string();
if let Some(v) = fp.kids("property").iter().find(|p| p.atom(1) == Some("Value")).and_then(|p| p.atom(2)) { b.values.insert(reference.clone(), v.to_string()); }
if let Some(v) = fp.kids("property").iter().find(|p| matches!(p.atom(1), Some("MPN") | Some("Manufacturer Part Number") | Some("MFR_PN") | Some("Mfr PN") | Some("PN"))).and_then(|p| p.atom(2)).filter(|v| !v.is_empty()) { b.mpns.insert(reference.clone(), v.to_string()); }
if let Some(v) = fp.atom(1) { b.footprints.insert(reference.clone(), v.to_string()); }
let at = fp.kid("at");
let (fx, fy, fa) = (at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0), at.and_then(|a| a.num(3)).unwrap_or(0.0));
for p in fp.kids("pad") {
let Some(net) = p.kid("net").map(&net_name) else { continue };
let pat = p.kid("at");
let (dx, dy) = (pat.and_then(|a| a.num(1)).unwrap_or(0.0), pat.and_then(|a| a.num(2)).unwrap_or(0.0));
let pa = pat.and_then(|a| a.num(3)).unwrap_or(fa);
// KiCad rotates counter-clockwise in a y-down frame
let (rx, ry) = rot(dx, -dy, fa);
let (x, y) = (fx + rx, fy - ry);
let (sw, sh) = (p.kid("size").and_then(|s| s.num(1)).unwrap_or(1.0), p.kid("size").and_then(|s| s.num(2)).unwrap_or(1.0));
let kind = p.atom(2).unwrap_or("smd");
let shape = p.atom(3).unwrap_or("rect");
if kind == "thru_hole" {
if let Some(dn) = p.kid("drill") {
let (dr, dl) = if dn.atom(1) == Some("oval") { let a = dn.num(2).unwrap_or(0.0); let z = dn.num(3).unwrap_or(a); (a.min(z), Some(a.max(z))) } else { (dn.num(1).unwrap_or(0.0), None) };
// a slot along its own axis (PR 9); an offset drill moves the hole with the pad's rotation
let ang = pa + if dn.atom(1) == Some("oval") && dn.num(3) > dn.num(2) { 90.0 } else { 0.0 };
let (ox, oy) = dn.kid("offset").map(|o| (o.num(1).unwrap_or(0.0), o.num(2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));
let (qx, qy) = rot(ox, -oy, pa);
if dr > 0.0 { b.plated_holes.push(Via { net: net.clone(), x: x + qx, y: y - qy, size: sw.min(sh), drill: dr, drill_long: dl, drill_angle: ang }); }
}
}
let lay: Vec<String> = p.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| if let Sx::Atom(a) = x { Some(a.clone()) } else { None }).collect()).unwrap_or_default();
// only onto copper the board has: a plated pad on a two-layer board is on F.Cu and B.Cu, never In1/In2
let real = |l: &&&str| b.copper.is_empty() || b.copper.iter().any(|c| c == **l);
let on: Vec<String> = if kind == "thru_hole" { LAYERS.iter().filter(real).map(|s| s.to_string()).collect() } else { LAYERS.iter().filter(real).filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).map(|s| s.to_string()).collect() };
let mut area = sw * sh;
for l in &on {
let mut shapes: Vec<Vec<(f64, f64)>> = Vec::new();
if shape == "custom" {
if let Some(prims) = p.kid("primitives") {
for gp in prims.kids("gr_poly") {
let pts: Vec<(f64, f64)> = gp.kid("pts").map(|q| q.kids("xy").iter().filter_map(|xy| { let (qx, qy) = rot(xy.num(1)?, -xy.num(2)?, fa); Some((x + qx, y - qy)) }).collect()).unwrap_or_default();
if pts.len() > 2 { area = area.max(poly_area(&pts)); shapes.push(pts); }
}
}
shapes.push(circle_poly(x, y, sw.min(sh) / 2.0));
} else if shape == "circle" {
shapes.push(circle_poly(x, y, sw / 2.0));
} else if shape == "oval" {
shapes.push(stadium_poly(x, y, sw, sh, pa));
} else {
let corners = [(-sw / 2.0, -sh / 2.0), (sw / 2.0, -sh / 2.0), (sw / 2.0, sh / 2.0), (-sw / 2.0, sh / 2.0)];
shapes.push(corners.iter().map(|(cx, cy)| { let (qx, qy) = rot(*cx, -*cy, pa); (x + qx, y - qy) }).collect());
}
for pts in shapes { b.pad_polys.push(Poly { net: net.clone(), layer: l.clone(), pts }); }
}
b.pads.push(Pad { reference: reference.clone(), pad: p.atom(1).map(str::to_string).or_else(|| p.num(1).map(|n| format!("{}", n as i64))).unwrap_or_default(), net, x, y, layers: on, area, func: p.kid("pinfunction").and_then(|k| k.atom(1)).map(str::to_string).unwrap_or_default() });
}
}
// a numbered net with no table entry would silently drop the item from every solve: refuse instead (issue 21)
let unresolved = b.tracks.iter().map(|t| &t.net).chain(b.vias.iter().map(|v| &v.net)).filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())).count();
if unresolved > 0 { return Err(format!("{path}: {unresolved} track(s) or via(s) name their net by a number the board's net table does not list; open and save the board in KiCad, then run again")); }
Ok(b)
}
pub fn width(&self) -> f64 { self.x1 - self.x0 }
pub fn height(&self) -> f64 { self.y1 - self.y0 }
}
fn poly_area(p: &[(f64, f64)]) -> f64 { let mut a = 0.0; for i in 0..p.len() { let j = (i + 1) % p.len(); a += p[i].0 * p[j].1 - p[j].0 * p[i].1; } (a / 2.0).abs() }
fn circle_poly(x: f64, y: f64, r: f64) -> Vec<(f64, f64)> { (0..24).map(|k| { let a = k as f64 / 24.0 * std::f64::consts::TAU; (x + r * a.cos(), y + r * a.sin()) }).collect() }
fn stadium_poly(x: f64, y: f64, w: f64, h: f64, deg: f64) -> Vec<(f64, f64)> {
let (long, short) = (w.max(h), w.min(h)); let r = short / 2.0; let d = (long - short) / 2.0;
let mut pts = Vec::new();
for k in 0..=12 { let a = -std::f64::consts::FRAC_PI_2 + k as f64 / 12.0 * std::f64::consts::PI; pts.push((d + r * a.cos(), r * a.sin())); }
for k in 0..=12 { let a = std::f64::consts::FRAC_PI_2 + k as f64 / 12.0 * std::f64::consts::PI; pts.push((-d + r * a.cos(), r * a.sin())); }
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()
}
#[cfg(test)] mod layer_tests {
use super::*;
// issue 10 (senthilkumaranms): a plated pad on a two-layer board is on F.Cu and B.Cu only
fn fixture(layers: &str, stackup: &str) -> Board {
let txt = format!(r#"(kicad_pcb (version 20260206) (generator pcbnew)
(general (thickness 1.6)) (layers {layers}) {stackup}
(gr_rect (start 0 0) (end 10 10) (stroke (width 0.05) (type default)) (fill none) (layer "Edge.Cuts"))
(footprint "Connector" (layer "F.Cu") (at 2 5)
(property "Reference" "J1" (at 0 0) (layer "F.SilkS"))
(pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 0.8) (layers "*.Cu" "*.Mask") (net "3V3")))
(segment (start 2 5) (end 8 5) (width 1) (layer "F.Cu") (net "3V3")))"#);
let p = std::env::temp_dir().join(format!("adom-fields-layers-{}.kicad_pcb", layers.len() + stackup.len()));
std::fs::write(&p, txt).unwrap(); let b = Board::load(p.to_str().unwrap()).unwrap(); let _ = std::fs::remove_file(&p); b
}
#[test] fn two_layer_pads_stay_on_real_copper() {
let b = fixture(r#"(0 "F.Cu" signal) (2 "B.Cu" signal)"#, "");
assert_eq!(b.pads[0].layers, vec!["F.Cu".to_string(), "B.Cu".to_string()]);
let s = b.stack_mm.unwrap(); assert_eq!(s.len(), 1); assert!((s[0] - 1.53).abs() < 1e-6);
}
#[test] fn four_layer_pads_and_saved_stackup() {
let st = r#"(setup (stackup (layer "F.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 1" (type "prepreg") (thickness 0.21)) (layer "In1.Cu" (type "copper") (thickness 0.0152)) (layer "dielectric 2" (type "core") (thickness 1.065)) (layer "In2.Cu" (type "copper") (thickness 0.0152)) (layer "dielectric 3" (type "prepreg") (thickness 0.21)) (layer "B.Cu" (type "copper") (thickness 0.035))))"#;
let b = fixture(r#"(0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)"#, st);
assert_eq!(b.pads[0].layers.len(), 4);
assert_eq!(b.stack_mm, Some(vec![0.21, 1.065, 0.21]));
}
}
/// The copper layers in physical order (F.Cu, In1.Cu .. In<n>.Cu, B.Cu, contiguous) or an error naming what is wrong.
pub fn check_copper(copper: &[String], declared: &[String]) -> Result<(), String> {
let extra: Vec<&String> = declared.iter().filter(|n| n.ends_with(".Cu") && !LAYERS.contains(&n.as_str())).collect();
if !extra.is_empty() { return Err(format!("this board has copper layers past six ({}): boards up to six copper layers are supported", extra.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }
if copper.is_empty() { return Ok(()); }
let inner: Vec<usize> = copper.iter().filter_map(|c| c.strip_prefix("In").and_then(|x| x.strip_suffix(".Cu")).and_then(|x| x.parse().ok())).collect();
if !copper.iter().any(|c| c == "F.Cu") || !copper.iter().any(|c| c == "B.Cu") || inner.iter().enumerate().any(|(i, n)| *n != i + 1) || copper.len() % 2 != 0 { return Err(format!("the copper layers {} are not a stack this solve supports: F.Cu, In1.Cu .. In<n>.Cu in order, B.Cu, an even count", copper.join(", "))); }
Ok(())
}
#[cfg(test)] mod stack_tests {
use super::*;
fn v(x: &[&str]) -> Vec<String> { x.iter().map(|s| s.to_string()).collect() }
// issue 11: six layers in order pass, a gap or an odd count fails, more than six is named
#[test] fn copper_stacks() {
assert!(check_copper(&v(&["F.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu", "B.Cu"]), &v(&["F.Cu", "B.Cu", "In1.Cu", "In2.Cu", "In3.Cu", "In4.Cu"])).is_ok());
assert!(check_copper(&v(&["F.Cu", "In2.Cu", "B.Cu"]), &[]).is_err());
assert!(check_copper(&v(&["F.Cu", "In1.Cu", "B.Cu"]), &[]).is_err());
assert!(check_copper(&v(&["F.Cu", "B.Cu"]), &v(&["F.Cu", "In5.Cu", "B.Cu"])).is_err());
}
#[test] fn four_layer_gaps_spread_over_slots() {
let s = crate::fields::spread(&[0.2, 1.065, 0.21], &v(&["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]));
assert_eq!(s.len(), 5); assert!((s[0] - 0.2).abs() < 1e-12 && (s[1] - 1.065).abs() < 1e-12 && (s[2] - 0.07).abs() < 1e-12 && (s[4] - 0.07).abs() < 1e-12);
}
}
#[cfg(test)] mod barrel_tests {
use super::*;
// PR 5: a two-layer board's plated pad and via stay on F.Cu and B.Cu, and the pad's barrel is kept
#[test] fn two_layer_plated_pad_and_via() {
let p = std::env::temp_dir().join(format!("fields-barrel-{}.kicad_pcb", std::process::id()));
std::fs::write(&p, r#"(kicad_pcb (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))
(net 1 "GND") (gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts"))
(via (at 5 5) (size 1) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1))
(footprint "test" (property "Reference" "J1") (at 3 3)
(pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 1) (layers "*.Cu" "*.Mask") (net 1 "GND"))))"#).unwrap();
let b = Board::load(p.to_str().unwrap()).unwrap(); let _ = std::fs::remove_file(&p);
assert_eq!(b.pads[0].layers, vec!["F.Cu".to_string(), "B.Cu".to_string()]);
assert_eq!(b.plated_holes.len(), 1); assert_eq!(b.plated_holes[0].drill, 1.0);
let mut ras = crate::raster::Raster::new(&b, 0.1); ras.draw_board(&b);
assert!(ras.masks.keys().all(|(_, l)| l == "F.Cu" || l == "B.Cu"));
}
#[test] fn circular_and_slotted_barrels() {
let round = Via { net: "GND".into(), x: 0., y: 0., size: 2., drill: 1., drill_long: None, drill_angle: 0.0 };
assert!((round.barrel_area_mm2() - 0.08050331175).abs() < 1e-9);
let slot = Via { drill: 0.6, drill_long: Some(1.7), ..round };
assert!((slot.barrel_area_mm2() - 0.10408738521).abs() < 1e-9);
}
#[test] fn blind_via_refused() {
let p = std::env::temp_dir().join(format!("fields-blind-{}.kicad_pcb", std::process::id()));
std::fs::write(&p, r#"(kicad_pcb (layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "In2.Cu" signal) (31 "B.Cu" signal))
(gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts")) (via blind (at 5 5) (size 0.6) (drill 0.3) (layers "F.Cu" "In1.Cu") (net 1)))"#).unwrap();
let r = Board::load(p.to_str().unwrap()); let _ = std::fs::remove_file(&p); assert!(r.is_err());
}
}