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
John Lauer
Publish 0.2.50
01ab14c
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
//! The board model every stage reads: a KiCad 10 `.kicad_pcb` as footprints, copper pads with
//! their real outlines in board coordinates, nets, the Edge.Cuts outline and the copper layers.
//! Nothing here writes a file; the writers splice into `text` using the spans the reader keeps.
pub mod altium;
pub mod eagle;
pub mod geom;
pub mod netname;
pub mod silk;
pub mod sx;
use std::collections::{BTreeMap, HashMap};
pub use geom::Pt;
pub use sx::{num, quote, Node};
pub const F_CU: usize = 0;
pub const B_CU: usize = 1;
pub const LAYER_NAMES: [&str; 2] = ["F.Cu", "B.Cu"];
/// A copper layer by name for connectivity reads that see every layer: F.Cu 0, B.Cu 1, InN.Cu 1 + N.
/// Outer layers keep F_CU and B_CU, so outer-layer items read the same as before.
pub fn copper_layer(name: &str) -> Option<usize> {
match name {
"F.Cu" => Some(F_CU),
"B.Cu" => Some(B_CU),
_ => name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse::<usize>().ok()).filter(|&n| n >= 1).map(|n| 1 + n),
}
}
/// How deep a `copper_layer` index sits in the stack, from 0 at F.Cu down to COPPER_BOTTOM at B.Cu.
pub fn copper_depth(layer: usize) -> usize {
match layer { F_CU => 0, B_CU => COPPER_BOTTOM, k => k - 1 }
}
/// The depth of B.Cu in `copper_depth`.
pub const COPPER_BOTTOM: usize = 1000;
#[derive(Debug, Clone)]
pub struct Pad {
pub reference: String,
pub name: String,
pub key: String,
pub net: String,
pub x: f64,
pub y: f64,
pub angle: f64,
pub shape: String,
pub size: (f64, f64),
pub drill: Option<f64>,
pub thru: bool,
pub on_f: bool,
pub on_b: bool,
pub polys: Vec<Vec<Pt>>,
pub fp_x: f64,
pub fp_y: f64,
pub long_axis: Pt,
pub half_long: f64,
pub half_short: f64,
pub uuid: String,
pub polys_centre: Pt,
pub clearance: f64,
}
impl Pad {
pub fn min_dim(&self) -> f64 {
2.0 * self.half_short
}
pub fn on_layer(&self, layer: usize) -> bool {
if layer == F_CU { self.on_f } else { self.on_b }
}
/// Whether the pad has copper on a `copper_layer` index: outer layers as `on_layer`, inner
/// layers only for a plated through-hole pad.
pub fn on_copper(&self, layer: usize) -> bool {
if layer <= B_CU { self.on_layer(layer) } else { self.thru }
}
}
#[derive(Debug, Clone)]
pub struct Footprint {
pub reference: String,
pub x: f64,
pub y: f64,
pub rot: f64,
pub layer: String,
pub lib: String,
pub start: usize,
pub end: usize,
}
/// One straight piece of an existing track (a `segment`, or a piece of an `arc`).
#[derive(Debug, Clone)]
pub struct Track {
pub net: String,
pub layer: usize,
pub a: Pt,
pub b: Pt,
pub width: f64,
}
#[derive(Debug, Clone)]
pub struct Via {
pub net: String,
pub x: f64,
pub y: f64,
pub size: f64,
pub drill: f64,
/// The copper depths it spans (`copper_depth`): 0..=COPPER_BOTTOM for a through via, less for
/// a blind or buried one.
pub span: (usize, usize),
}
impl Via {
/// Whether the via has copper on a `copper_layer` index.
pub fn on_copper(&self, layer: usize) -> bool {
let d = copper_depth(layer);
self.span.0 <= d && d <= self.span.1
}
}
/// A copper zone: its outline and its filled islands per layer (empty when never filled).
#[derive(Debug, Clone)]
pub struct Zone {
pub net: String,
pub layers: Vec<usize>,
pub outline: Vec<Pt>,
pub fills: Vec<(usize, Vec<Pt>)>,
}
/// A drilled hole with no copper layer at all (an NPTH whose pad lists only `*.Mask`, such as a
/// USB-C shell peg). It is not a pad: it has no net and no copper, so pad, net, BOM and netlist
/// consumers never see it. Copper consumers (router, pours, via placement) keep clearance from it.
#[derive(Debug, Clone)]
pub struct DrillHole {
pub reference: String,
pub name: String,
pub x: f64,
pub y: f64,
/// the hole's extent (width, height) before rotation; equal for a round hole
pub size: (f64, f64),
pub angle: f64,
pub plated: bool,
/// the hole's outline in board coordinates
pub poly: Vec<Pt>,
pub uuid: String,
}
impl DrillHole {
/// The largest dimension: a round hole's diameter, an oval's length (conservative for clearance).
pub fn diameter(&self) -> f64 {
self.size.0.max(self.size.1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetFormat {
Number,
Name,
}
pub struct Board {
pub path: String,
pub text: String,
pub root: Node,
pub net_table: BTreeMap<i64, String>,
pub net_format: NetFormat,
pub pads: Vec<Pad>,
pub footprints: BTreeMap<String, Footprint>,
/// The board outline: the outermost closed Edge.Cuts loop.
pub outline: Vec<Pt>,
/// Closed Edge.Cuts loops inside the outline (a bore, a slot, a cutout): holes in the board.
pub outline_cutouts: Vec<Vec<Pt>>,
pub bbox: (f64, f64, f64, f64),
pub nets: BTreeMap<String, Vec<usize>>,
pub copper_layers: Vec<String>,
/// drilled holes with no copper (see `DrillHole`); never in `pads` or `nets`
pub holes: Vec<DrillHole>,
pad_index: HashMap<String, usize>,
}
fn pad_local_polys(pn: &Node, shape: &str, sx: f64, sy: f64) -> Vec<Vec<Pt>> {
let rectp = |sx: f64, sy: f64| vec![(-sx / 2.0, -sy / 2.0), (sx / 2.0, -sy / 2.0), (sx / 2.0, sy / 2.0), (-sx / 2.0, sy / 2.0)];
let mut polys = Vec::new();
match shape {
"rect" | "roundrect" | "trapezoid" | "chamfer" => polys.push(rectp(sx, sy)),
"oval" => polys.push(geom::stadium_poly(sx, sy, 10)),
"circle" => polys.push(geom::circle_poly(0.0, 0.0, sx / 2.0, 32)),
"custom" => {
let anchor = pn.find("options").and_then(|o| o.find("anchor")).and_then(|a| a.value()).unwrap_or("rect");
if anchor == "circle" {
polys.push(geom::circle_poly(0.0, 0.0, sx / 2.0, 32));
} else {
polys.push(rectp(sx, sy));
}
if let Some(prims) = pn.find("primitives") {
for prim in prims.nodes() {
match prim.tag.as_str() {
"gr_poly" => {
if let Some(pts) = prim.find("pts") {
let mut poly: Vec<Pt> = pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect();
let w = prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);
if w > 0.0 {
poly = geom::grow_poly(&poly, w / 2.0);
}
if poly.len() >= 3 {
polys.push(poly);
}
}
}
"gr_rect" => {
if let (Some(s), Some(e)) = (prim.find("start"), prim.find("end")) {
if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (s.number(1), s.number(2), e.number(1), e.number(2)) {
polys.push(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]);
}
}
}
"gr_circle" => {
if let (Some(c), Some(e)) = (prim.find("center"), prim.find("end")) {
if let (Some(cx), Some(cy), Some(ex), Some(ey)) = (c.number(1), c.number(2), e.number(1), e.number(2)) {
let r = (ex - cx).hypot(ey - cy) + prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.0) / 2.0;
polys.push(geom::circle_poly(cx, cy, r, 32));
}
}
}
"gr_line" => {
if let (Some(s), Some(e)) = (prim.find("start"), prim.find("end")) {
let hw = prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.1) / 2.0;
if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (s.number(1), s.number(2), e.number(1), e.number(2)) {
polys.push(geom::thick_line_poly((x0, y0), (x1, y1), hw));
}
}
}
_ => {}
}
}
}
}
_ => polys.push(rectp(sx, sy)),
}
polys
}
/// The hole of a pad with no copper layer, when it has a drill (`(drill d)` or `(drill oval w h)`,
/// optionally with an `(offset x y)`), in board coordinates.
fn drill_hole(pn: &Node, reference: &str, name: &str, ptype: &str, fx: f64, fy: f64, rot: f64) -> Option<DrillHole> {
if ptype != "np_thru_hole" && ptype != "thru_hole" {
return None;
}
let d = pn.find("drill")?;
let (w, h) = if d.atom(1) == Some("oval") {
let w = d.number(2)?;
(w, d.number(3).unwrap_or(w))
} else {
let w = d.number(1)?;
(w, d.number(2).unwrap_or(w))
};
if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {
return None;
}
let (ox, oy) = d.find("offset").map(|o| (o.number(1).unwrap_or(0.0), o.number(2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));
let pat = pn.find("at");
let px = pat.and_then(|a| a.number(1)).unwrap_or(0.0);
let py = pat.and_then(|a| a.number(2)).unwrap_or(0.0);
// a pad's angle in a .kicad_pcb is absolute (it already holds the footprint's rotation); KiCad
// leaves it out when that absolute angle is 0, even in a rotated footprint (issue #103)
let pang = pat.and_then(|a| a.number(3)).unwrap_or(0.0);
let (rx, ry) = geom::rot_kicad(px, py, rot);
let (x, y) = (fx + rx, fy + ry);
let local = if (w - h).abs() < 1e-9 { geom::circle_poly(ox, oy, w / 2.0, 32) } else { geom::stadium_poly(w, h, 10).into_iter().map(|(a, b)| (a + ox, b + oy)).collect() };
let poly: Vec<Pt> = local.iter().map(|&(a, b)| { let (qx, qy) = geom::rot_kicad(a, b, pang); (x + qx, y + qy) }).collect();
let (ux, uy) = geom::rot_kicad(ox, oy, pang);
Some(DrillHole {
reference: reference.to_string(), name: name.to_string(), x: x + ux, y: y + uy, size: (w, h), angle: pang,
plated: ptype == "thru_hole", poly, uuid: pn.find("uuid").and_then(|u| u.value()).unwrap_or("").to_string(),
})
}
pub fn reference_of(fp: &Node) -> Option<String> {
for p in fp.find_all("property") {
if p.atom(1) == Some("Reference") {
return p.atom(2).map(str::to_string);
}
}
fp.find("fp_text").filter(|t| t.atom(1) == Some("reference")).and_then(|t| t.atom(2)).map(str::to_string)
}
impl Board {
pub fn load(path: &str) -> Result<Board, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
// a Fusion board (EAGLE source): plan on its .kicad_pcb rendering (see eagle.rs)
if eagle::is_eagle(&text) { return Board::from_text(path, eagle::to_kicad(&text)?); }
// an Altium board read (the Altium bridge's JSON): plan on its .kicad_pcb rendering (see altium.rs)
if altium::is_altium(&text) { return Board::from_text(path, altium::render_plan_board(&text)?); }
Board::from_text(path, text)
}
pub fn from_text(path: &str, text: String) -> Result<Board, String> {
let root = sx::parse(&text)?;
if root.tag != "kicad_pcb" {
return Err(format!("{path} is not a kicad_pcb"));
}
let mut net_table = BTreeMap::new();
for n in root.find_all("net") {
if let (Some(k), Some(name)) = (n.atom(1).and_then(|a| a.parse::<i64>().ok()), n.atom(2)) {
net_table.insert(k, name.to_string());
}
}
let net_format = if net_table.is_empty() { NetFormat::Name } else { NetFormat::Number };
let mut b = Board { path: path.to_string(), text: String::new(), root, net_table, net_format, pads: Vec::new(), footprints: BTreeMap::new(), outline: Vec::new(), outline_cutouts: Vec::new(), bbox: (0.0, 0.0, 0.0, 0.0), nets: BTreeMap::new(), copper_layers: Vec::new(), holes: Vec::new(), pad_index: HashMap::new() };
b.read_footprints();
(b.outline, b.outline_cutouts) = b.read_outline()?;
b.bbox = geom::bbox(&b.outline);
for (i, p) in b.pads.iter().enumerate() {
if !p.net.is_empty() {
b.nets.entry(p.net.clone()).or_default().push(i);
}
b.pad_index.insert(p.key.clone(), i);
}
if let Some(layers) = b.root.find("layers") {
for l in layers.nodes() {
let kind = l.atom(2).unwrap_or("");
if matches!(kind, "signal" | "power" | "mixed") {
if let Some(name) = l.atom(1) {
b.copper_layers.push(name.to_string());
}
}
}
}
b.text = text;
Ok(b)
}
fn read_footprints(&mut self) {
let root = self.root.clone();
for fp in root.find_all("footprint") {
let Some(reference) = reference_of(fp) else { continue };
let at = fp.find("at");
let fx = at.and_then(|a| a.number(1)).unwrap_or(0.0);
let fy = at.and_then(|a| a.number(2)).unwrap_or(0.0);
let rot = at.and_then(|a| a.number(3)).unwrap_or(0.0);
let layer = fp.find("layer").and_then(|l| l.value()).unwrap_or("F.Cu").to_string();
let fp_clearance = fp.find("clearance").and_then(|c| c.number(1)).unwrap_or(0.0);
self.footprints.insert(reference.clone(), Footprint { reference: reference.clone(), x: fx, y: fy, rot, layer, lib: fp.value().unwrap_or("").to_string(), start: fp.start, end: fp.end });
for pn in fp.find_all("pad") {
let name = pn.value().unwrap_or("").to_string();
let ptype = pn.atom(2).unwrap_or("");
let shape = pn.atom(3).unwrap_or("rect").to_string();
let mut on_f = false;
let mut on_b = false;
let mut copper = false;
if let Some(ln) = pn.find("layers") {
for l in ln.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { Some(text.as_str()) } else { None }) {
if l == "*.Cu" {
on_f = true; on_b = true; copper = true;
} else if l.ends_with(".Cu") {
copper = true;
if l == "F.Cu" { on_f = true; }
if l == "B.Cu" { on_b = true; }
}
}
}
if !copper {
// a drilled hole with no copper (an NPTH with only mask layers): an obstacle, not a pad
if let Some(h) = drill_hole(pn, &reference, &name, ptype, fx, fy, rot) {
self.holes.push(h);
}
continue;
}
let pat = pn.find("at");
let px = pat.and_then(|a| a.number(1)).unwrap_or(0.0);
let py = pat.and_then(|a| a.number(2)).unwrap_or(0.0);
// absolute, and 0 when left out (see drill_hole; issue #103)
let pang = pat.and_then(|a| a.number(3)).unwrap_or(0.0);
let size = pn.find("size");
let sx_ = size.and_then(|s| s.number(1)).unwrap_or(0.0);
let sy_ = size.and_then(|s| s.number(2)).unwrap_or(0.0);
let drill = pn.find("drill").and_then(|d| d.number(1));
let mut net = pn.find("net").and_then(|n| n.value()).unwrap_or("").to_string();
if self.net_format == NetFormat::Number {
if let Some(k) = pn.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {
if let Some(nm) = self.net_table.get(&k) {
net = nm.clone();
}
}
}
let (ox, oy) = geom::rot_kicad(px, py, rot);
let (x, y) = (fx + ox, fy + oy);
let thru = ptype == "thru_hole" || ptype == "np_thru_hole";
let pclr = pn.find("clearance").and_then(|c| c.number(1)).unwrap_or(0.0);
let local = pad_local_polys(pn, &shape, sx_, sy_);
let (x0, y0, x1, y1) = geom::bbox(&local.iter().flatten().copied().collect::<Vec<_>>());
let (lx, ly) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);
let (lcx, lcy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (half_long, half_short, axis) = if lx >= ly { (lx, ly, geom::rot_kicad(1.0, 0.0, pang)) } else { (ly, lx, geom::rot_kicad(0.0, 1.0, pang)) };
let polys: Vec<Vec<Pt>> = local.iter().map(|poly| poly.iter().map(|&(a, b)| { let (rx, ry) = geom::rot_kicad(a, b, pang); (x + rx, y + ry) }).collect()).collect();
let (ccx, ccy) = geom::rot_kicad(lcx, lcy, pang);
// Unnumbered copper-layer pads, including NPTH mounting holes,
// are physical obstacles rather than electrical terminals.
let key = if name.is_empty() {
format!("{reference}.__physical_{}", self.pads.len())
} else {
format!("{reference}.{name}")
};
self.pads.push(Pad {
key, reference: reference.clone(), name, net, x, y, angle: pang, shape, size: (sx_, sy_), drill, thru,
on_f: on_f && (thru || true) && on_f, on_b: on_b || thru, polys, fp_x: fx, fp_y: fy, long_axis: axis, half_long, half_short,
uuid: pn.find("uuid").and_then(|u| u.value()).unwrap_or("").to_string(), polys_centre: (x + ccx, y + ccy), clearance: fp_clearance.max(pclr),
});
}
}
}
/// The outermost closed Edge.Cuts loop and the closed loops inside it (issue #77: a bore drawn as a
/// gr_circle used to be taken as the whole outline).
fn read_outline(&self) -> Result<(Vec<Pt>, Vec<Vec<Pt>>), String> {
let mut pieces: Vec<Vec<Pt>> = Vec::new();
let mut loops: Vec<Vec<Pt>> = Vec::new();
for node in self.root.nodes() {
if !node.tag.starts_with("gr_") {
continue;
}
if node.find("layer").and_then(|l| l.value()) != Some("Edge.Cuts") {
continue;
}
let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };
match node.tag.as_str() {
"gr_line" => {
if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {
pieces.push(vec![s, e]);
}
}
"gr_arc" => {
if let (Some(s), Some(m), Some(e)) = (p2(node.find("start")), p2(node.find("mid")), p2(node.find("end"))) {
pieces.push(geom::arc_points(s, m, e, 0.35));
}
}
"gr_rect" => {
if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {
loops.push(vec![s, (e.0, s.1), e, (s.0, e.1)]);
}
}
"gr_circle" => {
if let (Some(c), Some(e)) = (p2(node.find("center")), p2(node.find("end"))) {
loops.push(geom::circle_poly(c.0, c.1, (e.0 - c.0).hypot(e.1 - c.1), 64));
}
}
"gr_poly" => {
if let Some(pts) = node.find("pts") {
let poly: Vec<Pt> = pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect();
if poly.len() >= 3 { loops.push(poly); }
}
}
_ => {}
}
}
loops.extend(geom::chain_loops(pieces, 0.01)?);
if loops.is_empty() {
return Err("no Edge.Cuts outline".into());
}
// the outline is the loop with the largest area; every other closed loop is a cutout
let outer = (0..loops.len()).max_by(|&a, &b| geom::polygon_area(&loops[a]).abs().total_cmp(&geom::polygon_area(&loops[b]).abs())).unwrap();
let outline = loops.remove(outer);
Ok((outline, loops))
}
/// The `(net ...)` line for copper on this board (matches the bridge's net_sexpr).
pub fn net_ref_sexpr(&self, net: &str) -> Result<String, String> {
match self.net_format {
NetFormat::Number => {
let n = self.net_table.iter().find(|(_, v)| v.as_str() == net).map(|(k, _)| *k).ok_or_else(|| format!("net {net:?} is not in the net table"))?;
Ok(format!("\t\t(net {n})\n"))
}
NetFormat::Name => Ok(format!("\t\t(net {})\n", quote(net))),
}
}
pub fn pad_by_key(&self, key: &str) -> Option<&Pad> {
self.pad_index.get(key).map(|&i| &self.pads[i])
}
pub fn pad_idx(&self, key: &str) -> Option<usize> {
self.pad_index.get(key).copied()
}
/// Every pad a key names. A footprint may number two pads alike (a connector's two `MP` mounting
/// pads): such a key is ambiguous, and `pad_by_key` gives only one of them (issue #93).
pub fn pads_with_key(&self, key: &str) -> Vec<&Pad> {
self.pads.iter().filter(|p| p.key == key).collect()
}
/// Whether more than one pad answers to `key`.
pub fn key_is_ambiguous(&self, key: &str) -> bool {
self.pads.iter().filter(|p| p.key == key).nth(1).is_some()
}
pub fn pads_of(&self, reference: &str) -> Vec<&Pad> {
self.pads.iter().filter(|p| p.reference == reference).collect()
}
/// The net name of a board item's `(net ...)`: a number through the net table, or the name itself.
pub fn item_net(&self, n: &Node) -> String {
let Some(x) = n.find("net") else { return String::new() };
if self.net_format == NetFormat::Number {
if let Some(k) = x.atom(1).and_then(|a| a.parse::<i64>().ok()) {
return self.net_table.get(&k).cloned().unwrap_or_default();
}
}
x.atom(1).unwrap_or("").to_string()
}
/// The copper tracks already on the board: every `segment`, and every `arc` as short straight
/// pieces, on F.Cu or B.Cu.
pub fn tracks(&self) -> Vec<Track> {
self.tracks_on(false)
}
/// Every copper track on every copper layer, for connectivity: `layer` is a `copper_layer`
/// index (F.Cu 0, B.Cu 1, InN.Cu 1 + N).
pub fn copper_tracks(&self) -> Vec<Track> {
self.tracks_on(true)
}
fn tracks_on(&self, inner: bool) -> Vec<Track> {
let mut out = Vec::new();
let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };
for node in self.root.nodes() {
if node.tag != "segment" && node.tag != "arc" {
continue;
}
let Some(layer) = node.find("layer").and_then(|l| l.value()).and_then(copper_layer).filter(|&l| inner || l <= B_CU) else { continue };
let width = node.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);
let net = self.item_net(node);
let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) else { continue };
let pts = match (node.tag.as_str(), p2(node.find("mid"))) { ("arc", Some(m)) => geom::arc_points(s, m, e, 0.1), _ => vec![s, e] };
for w in pts.windows(2) {
out.push(Track { net: net.clone(), layer, a: w[0], b: w[1], width });
}
}
out
}
/// The vias already on the board (through vias: both outer layers).
pub fn vias(&self) -> Vec<Via> {
let mut out = Vec::new();
for v in self.root.find_all("via") {
let at = v.find("at");
let (Some(x), Some(y)) = (at.and_then(|a| a.number(1)), at.and_then(|a| a.number(2))) else { continue };
let size = v.find("size").and_then(|s| s.number(1)).unwrap_or(0.6);
let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);
// a blind or buried via names its two end layers; a through via spans the whole stack
let ends: Vec<usize> = v.find("layers").map(|ls| ls.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { copper_layer(text) } else { None }).map(copper_depth).collect()).unwrap_or_default();
let span = match (ends.iter().min(), ends.iter().max()) { (Some(&a), Some(&b)) if a < b => (a, b), _ => (0, COPPER_BOTTOM) };
out.push(Via { net: self.item_net(v), x, y, size, drill, span });
}
out
}
/// Copper zones with their net, layers, outline and filled islands (one polygon per island).
pub fn zones(&self) -> Vec<Zone> {
self.zones_on(false)
}
/// Every filled zone island on every copper layer, for connectivity: (net, `copper_layer`
/// index, island polygon).
pub fn copper_fills(&self) -> Vec<(String, usize, Vec<Pt>)> {
self.zones_on(true).into_iter().flat_map(|z| { let net = z.net; z.fills.into_iter().map(move |(l, p)| (net.clone(), l, p)) }).collect()
}
fn zones_on(&self, inner: bool) -> Vec<Zone> {
let mut out = Vec::new();
let pts_of = |n: &Node| -> Vec<Pt> { n.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default() };
for z in self.root.find_all("zone") {
if z.find("keepout").is_some() {
continue;
}
let mut layers = Vec::new();
let mut names: Vec<String> = Vec::new();
if let Some(l) = z.find("layer").and_then(|l| l.value()) { names.push(l.to_string()); }
if let Some(ls) = z.find("layers") {
names.extend(ls.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { Some(text.clone()) } else { None }).filter(|t| t != "layers"));
}
for n in &names {
for (k, l) in LAYER_NAMES.iter().enumerate() {
if (n == l || n == "*.Cu" || n == "F&B.Cu") && !layers.contains(&k) { layers.push(k); }
}
}
let outline = z.find("polygon").map(pts_of).unwrap_or_default();
let mut fills = Vec::new();
for f in z.find_all("filled_polygon") {
let Some(layer) = f.find("layer").and_then(|l| l.value()).and_then(copper_layer).filter(|&l| inner || l <= B_CU) else { continue };
let poly = pts_of(f);
if poly.len() >= 3 { fills.push((layer, poly)); }
}
out.push(Zone { net: self.item_net(z), layers, outline, fills });
}
out
}
/// Pad-box (copper extent) of every pad of a footprint: (x0, y0, x1, y1).
pub fn pad_box(&self, reference: &str) -> Option<(f64, f64, f64, f64)> {
let pts: Vec<Pt> = self.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).collect();
if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }
}
/// Issue #139: the layer a plan path with no layer of its own starts on. A path's first layer is the
/// entry's `layer` or its first waypoint's; when it names neither, the pads before its first layer
/// marker say it: the first single-sided pad among them decides (a bottom part's pad is on B.Cu only),
/// else F.Cu. `wps` is each waypoint's pad (None for a point) and whether it carries a layer.
pub fn path_start_layer<'a>(wps: impl IntoIterator<Item = (Option<&'a Pad>, bool)>) -> &'static str {
for (pad, explicit) in wps {
if explicit { break; }
if let Some(p) = pad {
if p.thru || (p.on_f && p.on_b) { continue; }
return if p.on_f { "F.Cu" } else if p.on_b { "B.Cu" } else { continue };
}
}
"F.Cu"
}
/// A point outside the board: outside the Edge.Cuts outline (when there is one) or inside a cutout.
pub fn point_outside(&self, x: f64, y: f64) -> bool {
(self.outline.len() >= 3 && !geom::point_in_poly(x, y, &self.outline)) || self.outline_cutouts.iter().any(|c| geom::point_in_poly(x, y, c))
}
/// Issue #140: the footprints with copper pads that are wholly off the board, every pad centre outside
/// the outline (a part `place pack` found no spot for and left where it was parked). A part that only
/// overhangs the edge (an edge connector) keeps a pad inside and is not listed.
pub fn parts_outside(&self) -> Vec<String> {
if self.outline.len() < 3 { return Vec::new(); }
self.footprints.keys().filter(|r| {
let mut pads = self.pads.iter().filter(|p| &p.reference == *r).peekable();
pads.peek().is_some() && pads.all(|p| self.point_outside(p.x, p.y))
}).cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pad_angle_is_absolute_and_zero_when_left_out() {
// KiCad writes a pad's absolute angle and omits it at 0, even in a footprint turned 90 degrees
// (issue #103): pad 1 lies along x on the board, pad 2 (written at 90) along y
let t = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(footprint "X" (layer "F.Cu") (at 10 10 90) (property "Reference" "X1")
(pad "1" smd rect (at 0 0) (size 2 0.4) (layers "F.Cu") (net "A"))
(pad "2" smd rect (at 0 3 90) (size 2 0.4) (layers "F.Cu") (net "A")))
(gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts")))"#;
let b = Board::from_text("t.kicad_pcb", t.to_string()).unwrap();
let ext = |k: &str| { let bb = geom::bbox(&b.pad_by_key(k).unwrap().polys[0]); ((bb.2 - bb.0).abs(), (bb.3 - bb.1).abs()) };
let (w1, h1) = ext("X1.1");
assert!((w1 - 2.0).abs() < 1e-6 && (h1 - 0.4).abs() < 1e-6, "{w1} x {h1}");
let (w2, h2) = ext("X1.2");
assert!((w2 - 0.4).abs() < 1e-6 && (h2 - 2.0).abs() < 1e-6, "{w2} x {h2}");
}
#[test]
fn copper_layers_read_inner_tracks_fills_and_via_spans() {
assert_eq!((copper_layer("F.Cu"), copper_layer("B.Cu"), copper_layer("In1.Cu"), copper_layer("In30.Cu"), copper_layer("F.SilkS")), (Some(0), Some(1), Some(2), Some(31), None));
let t = r#"(kicad_pcb (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(segment (start 0 0) (end 1 0) (width 0.2) (layer "In2.Cu") (net "A"))
(segment (start 0 0) (end 1 0) (width 0.2) (layer "F.Cu") (net "A"))
(via (at 1 1) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "A"))
(via blind (at 2 2) (size 0.6) (drill 0.3) (layers "F.Cu" "In1.Cu") (net "A"))
(gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts")))"#;
let b = Board::from_text("t.kicad_pcb", t.to_string()).unwrap();
assert_eq!(b.tracks().len(), 1, "the router's two layers only");
assert_eq!(b.copper_tracks().iter().map(|t| t.layer).collect::<Vec<_>>(), vec![3, 0]);
let v = b.vias();
assert!(v[0].on_copper(3) && v[0].on_copper(B_CU));
assert!(v[1].on_copper(F_CU) && v[1].on_copper(2) && !v[1].on_copper(3) && !v[1].on_copper(B_CU));
}
}