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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
//! Copper already on the board (an adopted, hand-reworked or partly routed board): its tracks and
//! vias are fixed obstacles for every other net, and its connectivity (tracks, vias and filled zone
//! islands joining pads) decides which connections are already made, so the router plans only the
//! ones still open. A board with no copper takes none of these paths.
use std::collections::HashMap;
use aiflow_board::{geom, Track, Via, B_CU, F_CU};
use crate::{Router, Term};
struct Dsu(Vec<usize>);
impl Dsu {
fn find(&mut self, mut a: usize) -> usize {
while self.0[a] != a {
self.0[a] = self.0[self.0[a]];
a = self.0[a];
}
a
}
fn join(&mut self, a: usize, b: usize) {
let (ra, rb) = (self.find(a), self.find(b));
if ra != rb {
self.0[ra] = rb;
}
}
}
/// What a connected group of pads carries: any fixed copper at all, and a via or zone island
/// (what joins a pad to a plane).
#[derive(Clone, Copy, Debug, Default)]
pub struct CompCopper {
pub copper: bool,
pub via_or_zone: bool,
}
const TOUCH: f64 = 1e-3;
/// Bucket size of the spatial index over pads, tracks and vias, in mm.
const CELL: f64 = 1.0;
/// Join the board's copper the way KiCad's connectivity does: two items of one net connect when
/// their copper overlaps on a layer they share. Tracks overlap when the distance between their
/// centre lines is at most the sum of their half widths (a T-junction ending inside the other
/// track's width, collinear overlaps, an end off a via's centre but on its annulus); a track, via or
/// pad touches a pad when it reaches the pad's polygon; a via joins every layer it spans; a filled
/// zone island joins what reaches inside it, and overlapping islands of one net join each other. Arcs arrive as short straight pieces. Nodes: pads, then
/// tracks, then vias, then islands. A uniform grid keeps it near linear in the item count.
fn copper_groups(pads: &[aiflow_board::Pad], tracks: &[Track], vias: &[Via], islands: &[(String, usize, Vec<aiflow_board::Pt>)]) -> Dsu {
let (np, nt, nv) = (pads.len(), tracks.len(), vias.len());
let mut dsu = Dsu((0..np + nt + nv + islands.len()).collect());
// nets as small integers: no string compare in the pair loop
let mut net_ix: HashMap<String, u32> = HashMap::new();
let mut nid = |n: &str| -> u32 {
if n.is_empty() { return u32::MAX; }
if let Some(&k) = net_ix.get(n) { return k; }
let k = net_ix.len() as u32;
net_ix.insert(n.to_string(), k);
k
};
let mut net: Vec<u32> = Vec::with_capacity(np + nt + nv);
let mut bb: Vec<(f64, f64, f64, f64)> = Vec::with_capacity(np + nt + nv);
for p in pads {
net.push(nid(&p.net));
bb.push(geom::bbox(&p.polys.iter().flatten().copied().collect::<Vec<_>>()));
}
for t in tracks {
net.push(nid(&t.net));
let r = t.width / 2.0;
bb.push((t.a.0.min(t.b.0) - r, t.a.1.min(t.b.1) - r, t.a.0.max(t.b.0) + r, t.a.1.max(t.b.1) + r));
}
for v in vias {
net.push(nid(&v.net));
let r = v.size / 2.0;
bb.push((v.x - r, v.y - r, v.x + r, v.y + r));
}
let n = net.len();
let finite: Vec<bool> = bb.iter().map(|b| b.0.is_finite() && b.1.is_finite() && b.2.is_finite() && b.3.is_finite()).collect();
let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
for (b, _) in bb.iter().zip(&finite).filter(|(_, f)| **f) {
x0 = x0.min(b.0); y0 = y0.min(b.1); x1 = x1.max(b.2); y1 = y1.max(b.3);
}
if !x0.is_finite() { return dsu; }
let (gw, gh) = (((x1 - x0) / CELL) as usize + 1, ((y1 - y0) / CELL) as usize + 1);
let span = |b: &(f64, f64, f64, f64)| -> (usize, usize, usize, usize) {
let c = |v: f64, o: f64, m: usize| (((v - o - TOUCH) / CELL).floor().max(0.0) as usize).min(m - 1);
let d = |v: f64, o: f64, m: usize| (((v - o + TOUCH) / CELL).floor().max(0.0) as usize).min(m - 1);
(c(b.0, x0, gw), c(b.1, y0, gh), d(b.2, x0, gw), d(b.3, y0, gh))
};
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); gw * gh];
for k in 0..n {
if net[k] == u32::MAX || !finite[k] { continue; }
let (i0, j0, i1, j1) = span(&bb[k]);
for j in j0..=j1 { for i in i0..=i1 { cells[j * gw + i].push(k as u32); } }
}
let pad_pad = |p: &aiflow_board::Pad, q: &aiflow_board::Pad| -> bool {
let shared = (p.on_f && q.on_f) || (p.on_b && q.on_b) || (p.thru && q.thru);
shared && p.polys.iter().any(|pp| q.polys.iter().any(|qq| {
(0..pp.len()).any(|k| geom::seg_poly_distance(pp[k], pp[(k + 1) % pp.len()], qq) <= TOUCH)
|| pp.first().map(|&(x, y)| geom::point_in_poly(x, y, qq)).unwrap_or(false)
|| qq.first().map(|&(x, y)| geom::point_in_poly(x, y, pp)).unwrap_or(false)
}))
};
let touch = |a: usize, b: usize| -> bool {
let (a, b) = if a < b { (a, b) } else { (b, a) };
if a < np {
let p = &pads[a];
if b < np { return pad_pad(p, &pads[b]); }
if b < np + nt {
let t = &tracks[b - np];
return p.on_copper(t.layer) && p.polys.iter().any(|poly| geom::seg_poly_distance(t.a, t.b, poly) <= t.width / 2.0 + TOUCH);
}
let v = &vias[b - np - nt];
let shared = (p.on_f && v.on_copper(F_CU)) || (p.on_b && v.on_copper(B_CU)) || p.thru;
return shared && p.polys.iter().any(|poly| geom::point_poly_distance(v.x, v.y, poly) <= v.size / 2.0 + TOUCH);
}
if a < np + nt {
let t = &tracks[a - np];
if b < np + nt {
let u = &tracks[b - np];
// segment to segment: a 2-point polygon is the segment itself
return t.layer == u.layer && geom::seg_poly_distance(t.a, t.b, &[u.a, u.b]) <= (t.width + u.width) / 2.0 + TOUCH;
}
let v = &vias[b - np - nt];
return v.on_copper(t.layer) && geom::seg_distance(v.x, v.y, t.a.0, t.a.1, t.b.0, t.b.1) <= (v.size + t.width) / 2.0 + TOUCH;
}
let (v, w) = (&vias[a - np - nt], &vias[b - np - nt]);
v.span.0 <= w.span.1 && w.span.0 <= v.span.1 && (v.x - w.x).hypot(v.y - w.y) <= (v.size + w.size) / 2.0 + TOUCH
};
// each pair once: a candidate is checked from the lower index, and `seen` drops repeats across cells
let mut seen: Vec<u32> = vec![u32::MAX; n];
for k in 0..n {
if net[k] == u32::MAX || !finite[k] { continue; }
let (i0, j0, i1, j1) = span(&bb[k]);
for j in j0..=j1 {
for i in i0..=i1 {
for &m in &cells[j * gw + i] {
let m = m as usize;
if m <= k || seen[m] == k as u32 || net[m] != net[k] { continue; }
seen[m] = k as u32;
let (a, b) = (&bb[k], &bb[m]);
if a.0 > b.2 + TOUCH || b.0 > a.2 + TOUCH || a.1 > b.3 + TOUCH || b.1 > a.3 + TOUCH { continue; }
if dsu.find(k) == dsu.find(m) { continue; }
if touch(k, m) { dsu.join(k, m); }
}
}
}
}
// filled zone islands: per island, only items of its net on its layer, inside its box
let mut by_net: HashMap<u32, Vec<usize>> = HashMap::new();
for k in 0..n {
if net[k] != u32::MAX && finite[k] { by_net.entry(net[k]).or_default().push(k); }
}
for (z, (zn, l, poly)) in islands.iter().enumerate() {
let Some(&zid) = net_ix.get(zn) else { continue };
let node = np + nt + nv + z;
let zb = geom::bbox(poly);
let inside = |x: f64, y: f64| geom::point_in_poly(x, y, poly);
for &k in by_net.get(&zid).map(|v| v.as_slice()).unwrap_or(&[]) {
let b = &bb[k];
if b.0 > zb.2 || zb.0 > b.2 || b.1 > zb.3 || zb.1 > b.3 { continue; }
let hit = if k < np {
let p = &pads[k];
// a thermal relief leaves the pad centre bare: its spokes reach into the pad
p.on_copper(*l) && p.polys.iter().any(|pp| pp.iter().any(|&(x, y)| inside(x, y)) || poly.iter().any(|&(x, y)| geom::point_in_poly(x, y, pp)))
} else if k < np + nt {
let t = &tracks[k - np];
t.layer == *l && (inside(t.a.0, t.a.1) || inside(t.b.0, t.b.1) || geom::seg_poly_distance(t.a, t.b, poly) <= t.width / 2.0)
} else {
let v = &vias[k - np - nt];
v.on_copper(*l) && geom::point_poly_distance(v.x, v.y, poly) <= v.size / 2.0
};
if hit { dsu.join(k, node); }
}
}
// two islands of one net on one layer that overlap are one piece of copper (two zones of a net
// drawn over each other, a teardrop zone on a pour): KiCad joins them
let zb: Vec<(f64, f64, f64, f64)> = islands.iter().map(|z| geom::bbox(&z.2)).collect();
for a in 0..islands.len() {
for b in a + 1..islands.len() {
let (p, q) = (&islands[a], &islands[b]);
if p.0.is_empty() || p.0 != q.0 || p.1 != q.1 { continue; }
let (u, v) = (&zb[a], &zb[b]);
if u.0 > v.2 || v.0 > u.2 || u.1 > v.3 || v.1 > u.3 { continue; }
let (na, nb) = (np + nt + nv + a, np + nt + nv + b);
if dsu.find(na) == dsu.find(nb) { continue; }
let ov = (u.0.max(v.0) - TOUCH, u.1.max(v.1) - TOUCH, u.2.min(v.2) + TOUCH, u.3.min(v.3) + TOUCH);
if polys_overlap(&p.2, &q.2, ov) { dsu.join(na, nb); }
}
}
dsu
}
/// Whether two filled polygons share copper, looking only inside `ov` (where their boxes meet):
/// a vertex of one inside the other, or two edges within TOUCH.
fn polys_overlap(p: &[aiflow_board::Pt], q: &[aiflow_board::Pt], ov: (f64, f64, f64, f64)) -> bool {
let inb = |&(x, y): &aiflow_board::Pt| x >= ov.0 && x <= ov.2 && y >= ov.1 && y <= ov.3;
if p.iter().filter(|v| inb(v)).any(|&(x, y)| geom::point_in_poly(x, y, q)) { return true; }
if q.iter().filter(|v| inb(v)).any(|&(x, y)| geom::point_in_poly(x, y, p)) { return true; }
let edges = |poly: &[aiflow_board::Pt]| -> Vec<(aiflow_board::Pt, aiflow_board::Pt)> {
(0..poly.len()).map(|k| (poly[k], poly[(k + 1) % poly.len()]))
.filter(|(a, b)| a.0.max(b.0) >= ov.0 && a.0.min(b.0) <= ov.2 && a.1.max(b.1) >= ov.1 && a.1.min(b.1) <= ov.3).collect()
};
let (pe, qe) = (edges(p), edges(q));
pe.iter().any(|&(a, b)| qe.iter().any(|&(c, d)| geom::seg_poly_distance(a, b, &[c, d]) <= TOUCH))
}
impl Router {
/// Stamp the board's own tracks and vias as fixed copper and work out which pads they already join.
pub(crate) fn stamp_board_copper(&mut self) {
let np = self.board.pads.len();
self.pad_comp = (0..np).collect();
// connectivity reads every copper layer (inner-layer tracks and plane fills join pads through
// vias, issue #103); only F.Cu and B.Cu are stamped, since the router plans on those two
let tracks: Vec<Track> = self.board.copper_tracks().into_iter().filter(|t| t.width > 0.0).collect();
let vias: Vec<Via> = self.board.vias();
let islands = self.board.copper_fills();
// a board whose only copper is filled zones (a chassis pour joining through-hole pins, no
// tracks or vias) still has connections made: read its islands too (issue #84)
if tracks.is_empty() && vias.is_empty() && islands.is_empty() {
return;
}
let g = self.grid.clone();
let nid_of = |r: &Router, net: &str| -> i16 { r.net_ids.get(net).map(|&k| k as i16).unwrap_or(-2) };
for t in tracks.iter().filter(|t| t.layer <= B_CU) {
let nid = nid_of(self, &t.net);
for c in g.segment_cells(t.a, t.b, t.width / 2.0, Some(1e-6)) {
self.fixed_raw[t.layer][c] = nid;
}
}
for v in &vias {
let nid = nid_of(self, &v.net);
for c in g.disk_cells((v.x, v.y), v.size / 2.0, Some(1e-6)) {
if v.on_copper(F_CU) { self.fixed_raw[F_CU][c] = nid; }
if v.on_copper(B_CU) { self.fixed_raw[B_CU][c] = nid; }
}
let (i, j) = g.to_cell(v.x, v.y);
if nid >= 0 && g.in_bounds(i, j) {
self.vias.push((i as i32, j as i32, nid as usize, v.size, v.drill));
}
}
let (nt, nv) = (tracks.len(), vias.len());
let pads = self.board.pads.clone();
let tc = std::time::Instant::now();
let mut dsu = copper_groups(&pads, &tracks, &vias, &islands);
let conn_s = tc.elapsed().as_secs_f64();
let mut kind = vec![CompCopper::default(); np + nt + nv + islands.len()];
for k in 0..nt { kind[np + k] = CompCopper { copper: true, via_or_zone: false }; }
for k in 0..nv + islands.len() { kind[np + nt + k] = CompCopper { copper: true, via_or_zone: true }; }
let mut comp_copper: HashMap<usize, CompCopper> = HashMap::new();
for (n, kd) in kind.iter().enumerate() {
let root = dsu.find(n);
let e = comp_copper.entry(root).or_default();
e.copper |= kd.copper;
e.via_or_zone |= kd.via_or_zone;
}
for pi in 0..np {
self.pad_comp[pi] = dsu.find(pi);
}
// landing cells on the fixed copper, per net and group, for groups that reach a pad
let with_pad: std::collections::HashSet<usize> = self.pad_comp.iter().copied().collect();
for (k, t) in tracks.iter().enumerate() {
if t.layer > B_CU { continue; }
let root = dsu.find(np + k);
let Some(&net) = self.net_ids.get(&t.net) else { continue };
if !with_pad.contains(&root) { continue; }
let cells = g.segment_cells(t.a, t.b, (t.width / 2.0 - 0.03).max(0.0), Some(0.0));
let e = self.fixed_targets.entry(net).or_default();
for c in cells {
e.push((root, Term::new(t.layer, (c % g.w) as i64, (c / g.w) as i64)));
}
}
for (m, v) in vias.iter().enumerate() {
let root = dsu.find(np + nt + m);
let Some(&net) = self.net_ids.get(&v.net) else { continue };
if !with_pad.contains(&root) { continue; }
let (i, j) = g.to_cell(v.x, v.y);
if !g.in_bounds(i, j) { continue; } // a via off the board is no landing cell (issue #140)
let e = self.fixed_targets.entry(net).or_default();
for l in [F_CU, B_CU] {
if v.on_copper(l) { e.push((root, Term::new(l, i, j))); }
}
}
self.comp_copper = comp_copper;
let (it, iz) = (tracks.iter().filter(|t| t.layer > B_CU).count(), islands.iter().filter(|z| z.1 > B_CU).count());
let inner_note = if it + iz > 0 { format!(" ({it} track pieces and {iz} islands on inner layers, read for connectivity only)") } else { String::new() };
self.log(format!("board copper: {} track pieces and {} vias kept as fixed copper, {} filled zone islands read for connectivity{inner_note}, joined in {conn_s:.2}s", nt, nv, islands.len()));
// inner-layer tracks stay in the list: a via of another net must keep clear of them, while a
// track query on F.Cu or B.Cu never matches their layer index
self.fixed_tracks = tracks;
self.fixed_vias = vias;
}
/// The fixed-copper group a cluster's pad belongs to (its own pad index when nothing joins it).
pub fn comp_of(&self, net: usize, c: usize) -> usize {
let p = self.nets[net].clusters[c].primary;
self.pad_comp.get(p).copied().unwrap_or(p)
}
/// Does the board's own copper already reach this cluster (for a plane net: through a via or a zone)?
pub fn joined_by_board(&self, net: usize, c: usize) -> bool {
let cc = self.comp_copper.get(&self.comp_of(net, c)).copied().unwrap_or_default();
if self.nets[net].plane.is_some() { cc.via_or_zone } else { cc.copper }
}
/// A plane net's cluster that its plane reaches with no stub and no via: a through-hole pad
/// (on every layer), or a pad on the plane's own outer layer (an F.Cu pad on an F.Cu plane sits in
/// that pour; issue #84). An inner-layer plane reaches only through-hole pads.
pub fn on_plane(&self, net: usize, c: usize) -> bool {
let p = &self.board.pads[self.nets[net].clusters[c].primary];
if p.thru { return true; }
match self.nets[net].plane.as_deref() {
Some("F.Cu") => p.on_f,
Some("B.Cu") => p.on_b,
_ => false,
}
}
/// Mark every cluster of the net in the same fixed-copper group as `c` connected; returns them.
pub fn connect_group(&mut self, net: usize, c: usize) -> Vec<usize> {
let comp = self.comp_of(net, c);
let n = self.nets[net].clusters.len();
let same: Vec<usize> = (0..n).filter(|&k| self.comp_of(net, k) == comp).collect();
for &k in &same {
self.nets[net].clusters[k].connected = true;
}
same
}
/// Other-net fixed copper within `need` of a point on a layer (None: either layer, as for a via).
pub fn fixed_blocks(&self, net_name: &str, layer: Option<usize>, x: f64, y: f64, need: f64) -> bool {
for t in &self.fixed_tracks {
if t.net == net_name || layer.map(|l| l != t.layer).unwrap_or(false) { continue; }
// a box test: a long track passes near x even when both its ends are far from it
let r = need + t.width;
if x < t.a.0.min(t.b.0) - r || x > t.a.0.max(t.b.0) + r || y < t.a.1.min(t.b.1) - r || y > t.a.1.max(t.b.1) + r { continue; }
if geom::seg_distance(x, y, t.a.0, t.a.1, t.b.0, t.b.1) < need + t.width / 2.0 { return true; }
}
self.fixed_vias.iter().any(|v| v.net != net_name && (v.x - x).hypot(v.y - y) < need + v.size / 2.0)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::Rules;
use aiflow_board::Board;
/// A 20 x 10 mm two-layer board: `pads` as (reference, net, x, y, thru), plus raw `extra` items.
pub(crate) fn board(pads: &[(&str, &str, f64, f64, bool)], extra: &str) -> Board {
let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");
for (r, net, x, y, thru) in pads {
let pad = if *thru { format!("(pad \"1\" thru_hole circle\n\t\t\t(at 0 0)\n\t\t\t(size 1.2 1.2)\n\t\t\t(drill 0.6)\n\t\t\t(layers \"*.Cu\")\n\t\t\t(net \"{net}\")\n\t\t)") } else { format!("(pad \"1\" smd rect\n\t\t\t(at 0 0)\n\t\t\t(size 1 1)\n\t\t\t(layers \"F.Cu\")\n\t\t\t(net \"{net}\")\n\t\t)") };
t.push_str(&format!("\t(footprint \"T:{r}\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n\t\t{pad}\n\t)\n"));
}
t.push_str(extra);
t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 20 10)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");
Board::from_text("test.kicad_pcb", t).unwrap()
}
pub(crate) fn seg(net: &str, layer: &str, a: (f64, f64), b: (f64, f64)) -> String {
format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width 0.25)\n\t\t(layer \"{layer}\")\n\t\t(net \"{net}\")\n\t)\n", a.0, a.1, b.0, b.1)
}
#[test]
fn unnamed_mounting_holes_block_vias_without_becoming_terminals() {
let extra = r#"(footprint "T:Mount"
(layer "F.Cu") (at 10 5)
(property "Reference" "MH")
(pad "" np_thru_hole circle (at 0 -2) (size 1 1)
(drill 1) (layers "*.Cu" "*.Mask"))
(pad "" np_thru_hole circle (at 0 2) (size 1 1)
(drill 1) (layers "*.Cu" "*.Mask")))"#;
let b = board(&[("J1", "A", 2.0, 5.0, true)], extra);
let holes = b.pads_of("MH");
assert_eq!(holes.len(), 2);
assert_ne!(holes[0].key, holes[1].key);
assert!(holes.iter().all(|p| p.name.is_empty() && p.net.is_empty() && p.thru));
assert_eq!(b.nets.len(), 1);
assert_eq!(b.nets["A"].len(), 1);
let mut r = Router::new(b, Rules::default(), false);
let net = r.net_ids["A"];
for y in [3.0, 7.0] {
let (i, j) = r.grid.to_cell(10.0, y);
assert!(r.thru_cells.get(i as usize, j as usize));
assert!(!r.via_clear_exact(net, 10.0, y));
assert!(!r.via_clear_exact(net, 10.0, y - 0.31));
}
assert!(r.via_clear_exact(net, 13.0, 5.0));
}
#[test]
fn mask_only_npth_blocks_vias_and_tracks_without_becoming_a_pad() {
// a USB-C receptacle: one signal pad and two shell pegs drilled with no copper layer at all
let extra = r#"(footprint "T:USBC"
(layer "F.Cu") (at 10 5)
(property "Reference" "J9")
(pad "A5" smd rect (at -3 -3) (size 0.3 1) (layers "F.Cu" "F.Mask") (net "A"))
(pad "" np_thru_hole circle (at 0 0) (size 0.65 0.65)
(drill 0.65) (layers "*.Mask"))
(pad "" np_thru_hole oval (at 3 0 90) (size 0.6 1.2)
(drill oval 0.6 1.2) (layers "F.Mask" "B.Mask")))"#;
let b = board(&[("J1", "A", 2.0, 5.0, true)], extra);
assert_eq!(b.holes.len(), 2, "both mask-only drills are holes");
assert_eq!(b.pads_of("J9").len(), 1, "a hole is not a pad");
assert_eq!(b.nets["A"].len(), 2);
assert!(b.nets.keys().all(|n| !n.is_empty()));
assert!((b.holes[0].x - 10.0).abs() < 1e-9 && (b.holes[0].y - 5.0).abs() < 1e-9);
assert!((b.holes[1].diameter() - 1.2).abs() < 1e-9);
let mut r = Router::new(b, Rules::default(), false);
let net = r.net_ids["A"];
let (i, j) = r.grid.to_cell(10.0, 5.0);
assert!(r.thru_cells.get(i as usize, j as usize));
assert_eq!(r.pad_raw[F_CU][j as usize * r.grid.w + i as usize], -2, "physical, no net");
assert_eq!(r.pad_raw[B_CU][j as usize * r.grid.w + i as usize], -2);
// a via on the peg, or within hole clearance of it, is refused; one well clear is not
assert!(!r.via_clear_exact(net, 10.0, 5.0));
assert!(!r.via_clear_exact(net, 10.0, 5.0 + 0.325 + 0.35));
assert!(!r.via_clear_exact(net, 13.0, 5.0 + 0.7), "the oval peg's long axis");
assert!(r.via_clear_exact(net, 10.0, 8.0));
}
const PADS: [(&str, &str, f64, f64, bool); 4] = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J3", "B", 10.0, 2.0, true), ("J4", "B", 10.0, 8.0, true)];
fn route(b: Board) -> (Router, Vec<serde_json::Value>, serde_json::Value, usize) {
let mut r = Router::new(b, Rules::default(), false);
let (plan, report, failed) = crate::drive::route_board(&mut r, 2, 4, 50);
(r, plan, report, failed)
}
fn nets_in(plan: &[serde_json::Value]) -> Vec<String> {
let mut n: Vec<String> = plan.iter().filter_map(|e| e["net"].as_str().map(str::to_string)).collect();
n.dedup();
n
}
#[test]
fn a_board_without_copper_routes_every_net() {
let (_, plan, report, failed) = route(board(&PADS, ""));
assert_eq!(failed, 0);
let nets = nets_in(&plan);
assert!(nets.contains(&"A".to_string()) && nets.contains(&"B".to_string()), "{nets:?}");
assert_eq!(report["boardCopper"]["trackPieces"], 0);
}
#[test]
fn a_fully_routed_board_gets_an_empty_plan() {
let extra = seg("A", "F.Cu", (2.0, 5.0), (18.0, 5.0)) + &seg("B", "B.Cu", (10.0, 2.0), (10.0, 8.0));
let (r, plan, report, failed) = route(board(&PADS, &extra));
assert_eq!(failed, 0);
assert!(plan.is_empty(), "{plan:?}");
assert!(r.nets.iter().all(|n| n.done_by_board));
assert_eq!(report["boardCopper"]["openConnections"], 0);
assert_eq!(report["boardCopper"]["netsComplete"], serde_json::json!(["A", "B"]));
}
#[test]
fn existing_copper_is_kept_and_only_open_nets_are_planned() {
// A is done on F.Cu straight through the middle; B must cross it, so not on F.Cu
let (r, plan, _, failed) = route(board(&PADS, &seg("A", "F.Cu", (2.0, 5.0), (18.0, 5.0))));
assert_eq!(failed, 0);
assert_eq!(nets_in(&plan), vec!["B".to_string()]);
let b = r.net_ids["B"];
let g = &r.grid;
for route in &r.nets[b].routes {
for c in &route.cells {
let (x, y) = g.to_xy(c.i as i64, c.j as i64);
if c.layer() == F_CU {
assert!((y - 5.0).abs() > 0.125 + 0.2 + 0.125 - 1e-6 || x < 1.0, "B crosses A's fixed track at ({x}, {y})");
}
}
}
}
#[test]
fn a_partly_routed_net_is_finished_onto_its_own_copper() {
let (r, plan, report, failed) = route(board(&PADS, &seg("A", "F.Cu", (2.0, 5.0), (8.0, 5.0))));
assert_eq!(failed, 0);
assert!(nets_in(&plan).contains(&"A".to_string()));
assert_eq!(report["boardCopper"]["netsPartial"], serde_json::json!(["A"]));
let a = r.net_ids["A"];
// one new connection for A, and it is shorter than the whole 16 mm span: it lands on the stub
assert_eq!(r.nets[a].routes.len(), 1);
assert!(r.nets[a].routes[0].cells.len() < 150, "{}", r.nets[a].routes[0].cells.len());
}
#[test]
fn a_filled_zone_island_joins_its_pads() {
let zone = "\t(zone\n\t\t(net \"A\")\n\t\t(layer \"F.Cu\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 1 4) (xy 19 4) (xy 19 6) (xy 1 6)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon\n\t\t\t(layer \"F.Cu\")\n\t\t\t(pts\n\t\t\t\t(xy 1 4) (xy 19 4) (xy 19 6) (xy 1 6)\n\t\t\t)\n\t\t)\n\t)\n";
// a via somewhere else on the board so the fixed-copper pass runs
let via = "\t(via\n\t\t(at 5 8.5)\n\t\t(size 0.6)\n\t\t(drill 0.3)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n\t\t(net \"B\")\n\t)\n";
let (r, plan, _, failed) = route(board(&PADS, &format!("{zone}{via}")));
assert_eq!(failed, 0);
assert!(r.nets[r.net_ids["A"]].done_by_board);
assert_eq!(nets_in(&plan), vec!["B".to_string()]);
}
/// A zone of `net` over the whole board on `layer`, filled or not.
fn full_zone(net: &str, layer: &str, filled: bool) -> String {
let fill = if filled { format!("\t\t(filled_polygon\n\t\t\t(layer \"{layer}\")\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 19.5 0.5) (xy 19.5 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n") } else { String::new() };
format!("\t(zone\n\t\t(net \"{net}\")\n\t\t(layer \"{layer}\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 19.5 0.5) (xy 19.5 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n{fill}\t)\n")
}
// issue #84: a mechanical carrier, four through-hole GND pins and GND filled on both layers, no
// tracks or vias. The pour joins them: route plans nothing (it planned 393 mm of track).
const PINS: [(&str, &str, f64, f64, bool); 4] = [("H1", "GND", 2.0, 2.0, true), ("H2", "GND", 18.0, 2.0, true), ("H3", "GND", 2.0, 8.0, true), ("H4", "GND", 18.0, 8.0, true)];
#[test]
fn filled_zones_alone_join_a_net_with_no_tracks_or_vias() {
let zones = full_zone("GND", "F.Cu", true) + &full_zone("GND", "B.Cu", true);
let (r, plan, report, failed) = route(board(&PINS, &zones));
assert_eq!(failed, 0);
assert!(plan.is_empty(), "{plan:?}");
assert!(r.nets[r.net_ids["GND"]].done_by_board);
assert_eq!(report["boardCopper"]["netsComplete"], serde_json::json!(["GND"]));
assert_eq!(report["boardCopper"]["zoneIslands"], 2);
assert!(report["boardCopper"]["warnings"].as_array().unwrap().is_empty());
}
#[test]
fn a_fill_on_one_side_joins_only_the_pads_it_reaches() {
// the F.Cu fill reaches the left pair only; the right pair is planned, and nothing more
let left = "\t(zone\n\t\t(net \"GND\")\n\t\t(layer \"F.Cu\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 6 0.5) (xy 6 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon\n\t\t\t(layer \"F.Cu\")\n\t\t\t(pts\n\t\t\t\t(xy 0.5 0.5) (xy 6 0.5) (xy 6 9.5) (xy 0.5 9.5)\n\t\t\t)\n\t\t)\n\t)\n";
let (r, _, report, failed) = route(board(&PINS, left));
assert_eq!(failed, 0);
let g = r.net_ids["GND"];
assert!(!r.nets[g].done_by_board);
assert_eq!(r.nets[g].routes.len(), 2, "three groups (the joined left pair, H2, H4): two connections");
assert_eq!(report["boardCopper"]["netsPartial"], serde_json::json!(["GND"]));
}
#[test]
fn an_unfilled_zone_of_a_tracked_net_is_warned() {
let zones = full_zone("GND", "F.Cu", false) + &full_zone("GND", "B.Cu", false);
let (_, plan, report, failed) = route(board(&PINS, &zones));
assert_eq!(failed, 0);
assert!(!plan.is_empty(), "no fill: nothing joins the pins yet");
let w = report["boardCopper"]["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{w:?}");
let w = w[0].as_str().unwrap();
assert!(w.starts_with("GND has a zone on B.Cu and F.Cu with no fill") && w.contains("refill"), "{w}");
}
#[test]
fn a_pad_on_its_outer_layer_plane_gets_no_via() {
// issue #84: planes {"GND": "F.Cu"} on a 2-layer board; TP1 is an F.Cu pad on the F.Cu plane
let pads = [("H1", "GND", 2.0, 2.0, true), ("TP1", "GND", 10.0, 5.0, false), ("H2", "GND", 18.0, 8.0, true)];
for (plane, vias) in [("F.Cu", 0usize), ("B.Cu", 1)] {
let zone = full_zone("GND", plane, false);
let mut rules = Rules::default();
rules.planes.insert("GND".into(), plane.into());
let mut r = Router::new(board(&pads, &zone), rules, false);
let (plan, _, failed) = crate::drive::route_board(&mut r, 2, 4, 50);
assert_eq!(failed, 0, "{plane}");
let g = r.net_ids["GND"];
let planned_vias = plan.iter().map(|e| e["paths"].to_string().matches("\"layer\"").count()).sum::<usize>();
if vias == 0 {
assert!(plan.is_empty() && r.nets[g].routes.is_empty(), "{plane}: {plan:?}");
} else {
assert!(planned_vias >= 1 || !r.nets[g].via_in_pad.is_empty(), "{plane}: an F.Cu pad reaches a B.Cu plane through a via: {plan:?}");
}
}
}
// issue #103: existing copper joins the way KiCad's connectivity does (overlapping copper)
fn seg_w(net: &str, layer: &str, a: (f64, f64), b: (f64, f64), w: f64) -> String {
format!("(segment (start {} {}) (end {} {}) (width {w}) (layer \"{layer}\") (net \"{net}\"))\n", a.0, a.1, b.0, b.1)
}
fn via_at(net: &str, at: (f64, f64), size: f64) -> String {
format!("(via (at {} {}) (size {size}) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"{net}\"))\n", at.0, at.1)
}
const AB: [(&str, &str, f64, f64, bool); 3] = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J5", "A", 10.0, 1.0, true)];
fn done(extra: &str, pads: &[(&str, &str, f64, f64, bool)]) -> bool {
let r = Router::new(board(pads, extra), Rules::default(), false);
r.nets[r.net_ids["A"]].done_by_board
}
#[test]
fn a_t_junction_ending_inside_the_other_tracks_width_joins() {
// the branch ends 0.2 mm off the trunk's centre line: past the trunk's own half width (0.125)
// but its round end still overlaps the trunk's copper (0.2 <= 0.125 + 0.125)
let trunk = seg_w("A", "F.Cu", (2.0, 5.0), (18.0, 5.0), 0.25);
assert!(done(&(trunk.clone() + &seg_w("A", "F.Cu", (10.0, 1.0), (10.0, 4.8), 0.25)), &AB));
// the same branch stopped 0.1 mm short of touching: still split
assert!(!done(&(trunk + &seg_w("A", "F.Cu", (10.0, 1.0), (10.0, 4.65), 0.25)), &AB));
}
#[test]
fn a_track_end_on_a_via_annulus_off_centre_joins() {
let pads = &AB[..2];
// F.Cu ends 0.35 mm left of the via centre, B.Cu starts 0.4 mm right of it (annulus 0.3 + half width 0.125)
let x = seg_w("A", "F.Cu", (2.0, 5.0), (9.65, 5.0), 0.25) + &via_at("A", (10.0, 5.0), 0.6) + &seg_w("A", "B.Cu", (10.4, 5.0), (18.0, 5.0), 0.25);
assert!(done(&x, pads));
// moved 0.5 mm off the via: no copper overlap
let y = seg_w("A", "F.Cu", (2.0, 5.0), (9.65, 5.0), 0.25) + &via_at("A", (10.0, 5.0), 0.6) + &seg_w("A", "B.Cu", (10.9, 5.0), (18.0, 5.0), 0.25);
assert!(!done(&y, pads));
}
#[test]
fn collinear_overlapping_segments_join_and_a_gap_stays_split() {
let pads = &AB[..2];
assert!(done(&(seg_w("A", "F.Cu", (2.0, 5.0), (11.0, 5.0), 0.25) + &seg_w("A", "F.Cu", (9.0, 5.0), (18.0, 5.0), 0.25)), pads));
// ends 0.4 mm apart, copper gap 0.15 mm: close but not touching
assert!(!done(&(seg_w("A", "F.Cu", (2.0, 5.0), (9.6, 5.0), 0.25) + &seg_w("A", "F.Cu", (10.0, 5.0), (18.0, 5.0), 0.25)), pads));
// touching copper of another net never joins
let other = seg_w("A", "F.Cu", (2.0, 5.0), (9.5, 5.0), 0.25) + &seg_w("B", "F.Cu", (9.5, 5.0), (10.5, 5.0), 0.25) + &seg_w("A", "F.Cu", (10.5, 5.0), (18.0, 5.0), 0.25);
assert!(!done(&other, pads));
}
#[test]
fn inner_layer_tracks_join_through_hole_pads_and_vias() {
// a 4-layer board routed on In1.Cu: through-hole pads and through vias reach it
let thru = &AB[..2];
assert!(done(&seg_w("A", "In1.Cu", (2.0, 5.0), (18.0, 5.0), 0.25), thru));
let smd = [("J1", "A", 2.0, 5.0, false), ("J2", "A", 18.0, 5.0, false)];
assert!(!done(&seg_w("A", "In1.Cu", (2.0, 5.0), (18.0, 5.0), 0.25), &smd), "an SMD pad has no inner-layer copper");
let x = via_at("A", (2.0, 5.0), 0.6) + &via_at("A", (18.0, 5.0), 0.6) + &seg_w("A", "In2.Cu", (2.0, 5.0), (18.0, 5.0), 0.25);
assert!(done(&x, &smd), "vias in the pads carry the inner track up to them");
// inner copper is never stamped on the router's two layers
let r = Router::new(board(&smd, &x), Rules::default(), false);
let (i, j) = r.grid.to_cell(10.0, 5.0);
assert_eq!(r.fixed_raw[F_CU][j as usize * r.grid.w + i as usize], -1);
assert_eq!(r.fixed_raw[B_CU][j as usize * r.grid.w + i as usize], -1);
}
fn zone_box(net: &str, layer: &str, x0: f64, x1: f64) -> String {
let pts = format!("(xy {x0} 3) (xy {x1} 3) (xy {x1} 7) (xy {x0} 7)");
format!("(zone (net \"{net}\") (layer \"{layer}\") (polygon (pts {pts})) (filled_polygon (layer \"{layer}\") (pts {pts})))\n")
}
#[test]
fn overlapping_fills_of_one_net_join_and_an_inner_plane_reaches_through_vias() {
let pads = &AB[..2];
// two zones of A on F.Cu, one under each pad, overlapping from x 9 to 11
assert!(done(&(zone_box("A", "F.Cu", 1.0, 11.0) + &zone_box("A", "F.Cu", 9.0, 19.0)), pads));
assert!(!done(&(zone_box("A", "F.Cu", 1.0, 9.0) + &zone_box("A", "F.Cu", 11.0, 19.0)), pads), "a 2 mm gap between the fills");
// an In1.Cu fill joins the two through-hole pads
assert!(done(&zone_box("A", "In1.Cu", 1.0, 19.0), pads));
}
#[test]
fn a_via_keeps_clear_of_another_nets_inner_track() {
// B runs along In1.Cu right under the middle of the board, its ends far from x = 10
let pads = [("J1", "A", 2.0, 2.0, true), ("J2", "A", 18.0, 2.0, true), ("J3", "B", 1.0, 5.0, true), ("J4", "B", 19.0, 5.0, true)];
let r = Router::new(board(&pads, &seg_w("B", "In1.Cu", (1.0, 5.0), (19.0, 5.0), 0.25)), Rules::default(), false);
assert!(r.fixed_blocks("A", None, 10.0, 5.0, 0.5), "a via of A on B's inner track");
assert!(!r.fixed_blocks("A", Some(F_CU), 10.0, 5.0, 0.5), "an F.Cu track of A crosses it freely");
assert!(!r.fixed_blocks("A", None, 10.0, 8.0, 0.5));
}
#[test]
fn a_large_routed_board_joins_fast() {
// the size of the board in issue #103: about 13k track pieces, 1.4k vias, 960 pads; 480 two-pad
// nets, each a chain of 27 collinear pieces changing layer through 3 vias
let (mut pads, mut extra) = (Vec::new(), String::new());
let names: Vec<(String, String, String)> = (0..480).map(|k| (format!("N{k}"), format!("P{k}"), format!("Q{k}"))).collect();
for (k, (net, p, q)) in names.iter().enumerate() {
let (x0, y0) = (2.0 + (k % 20) as f64 * 7.5, 2.0 + (k / 20) as f64 * 6.0);
pads.push((p.clone(), net.clone(), x0, y0));
pads.push((q.clone(), net.clone(), x0 + 6.0, y0));
let step = 6.0 / 27.0;
for i in 0..27 {
let layer = if (i / 7) % 2 == 0 { "F.Cu" } else { "B.Cu" };
extra += &seg_w(net, layer, (x0 + i as f64 * step, y0), (x0 + (i + 1) as f64 * step, y0), 0.2);
if i % 7 == 6 && i < 21 { extra += &via_at(net, (x0 + (i + 1) as f64 * step, y0), 0.5); }
}
}
let refs: Vec<(&str, &str, f64, f64, bool)> = pads.iter().map(|(r, n, x, y)| (r.as_str(), n.as_str(), *x, *y, true)).collect();
// the test helper's outline is 20 x 10 mm: widen it to the 155 x 150 mm the nets sit on
let text = board(&refs, &extra).text.replace("(end 20 10)", "(end 155 150)");
let b = Board::from_text("big.kicad_pcb", text).unwrap();
let (tracks, vias) = (b.copper_tracks(), b.vias());
assert_eq!((tracks.len(), vias.len(), b.pads.len()), (12960, 1440, 960));
let t0 = std::time::Instant::now();
let mut dsu = copper_groups(&b.pads, &tracks, &vias, &[]);
let s = t0.elapsed().as_secs_f64();
for k in 0..480 { assert_eq!(dsu.find(2 * k), dsu.find(2 * k + 1), "net N{k}"); }
assert_ne!(dsu.find(0), dsu.find(2));
eprintln!("copper_groups on 12960 tracks, 1440 vias, 960 pads: {s:.3}s");
// generous for an unoptimised test build; the release build takes well under a second
assert!(s < if cfg!(debug_assertions) { 20.0 } else { 2.0 }, "{s}s");
if !cfg!(debug_assertions) {
// the whole router init on that board (grid, pads, copper, clusters): every net complete
let t1 = std::time::Instant::now();
let r = Router::new(b, Rules::default(), false);
let init = t1.elapsed().as_secs_f64();
eprintln!("Router::new on it: {init:.2}s ({} x {} cells)", r.grid.w, r.grid.h);
assert!(r.nets.iter().all(|n| n.done_by_board), "every net is complete on its own copper");
assert!(init < 10.0, "{init}s");
}
}
}
#[cfg(test)]
mod issue93_tests {
use crate::{Router, Rules};
use aiflow_board::Board;
fn route(text: String) -> (Router, Vec<serde_json::Value>, usize) {
let mut r = Router::new(Board::from_text("t.kicad_pcb", text).unwrap(), Rules::default(), false);
let (plan, _, failed) = crate::drive::route_board(&mut r, 2, 4, 50);
(r, plan, failed)
}
#[test]
fn a_pad_number_used_twice_is_written_as_coordinates() {
// a Hirose FH12-style connector: both mounting pads are "MP", on GND, with R1 on GND to their left
let text = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(footprint "FH12" (layer "F.Cu") (at 20 10) (property "Reference" "J1")
(pad "MP" smd rect (at -6.65 0) (size 1.8 2.2) (layers "F.Cu") (net "GND"))
(pad "MP" smd rect (at 6.65 0) (size 1.8 2.2) (layers "F.Cu") (net "GND")))
(footprint "R" (layer "F.Cu") (at 4 4) (property "Reference" "R1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "GND")))
(gr_rect (start 0 0) (end 32 16) (layer "Edge.Cuts")))"#;
let (_, plan, failed) = route(text.into());
assert_eq!(failed, 0);
let s = serde_json::to_string(&plan).unwrap();
assert!(!s.contains("J1.MP"), "an ambiguous key reached the plan: {s}");
assert!(s.contains("R1.1"), "an unambiguous key stays a key: {s}");
// every path end at J1 is one of the two pads' centres
let ends: Vec<(f64, f64)> = plan.iter().flat_map(|e| e["paths"].as_array().unwrap().iter().flat_map(|p| { let a = p.as_array().unwrap(); [a.first().unwrap().clone(), a.last().unwrap().clone()] }).collect::<Vec<_>>())
.filter_map(|w| w.as_array().map(|a| (a[0].as_f64().unwrap(), a[1].as_f64().unwrap())).or_else(|| Some((w.get("x")?.as_f64()?, w.get("y")?.as_f64()?)))).collect();
assert!(ends.iter().any(|e| (e.0 - 13.35).abs() < 1e-9 && (e.1 - 10.0).abs() < 1e-9), "{ends:?} {s}");
assert!(ends.iter().any(|e| (e.0 - 26.65).abs() < 1e-9 && (e.1 - 10.0).abs() < 1e-9), "{ends:?}");
}
/// A receiver ring: a custom B.Cu pad whose anchor is a tiny circle at its own origin and whose copper
/// is a gr_poly ring segment away from it, as the capencoder's RXA1 pads are drawn (issue #93).
fn ring_board(track: &str) -> String {
format!(r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(footprint "Ring" (layer "B.Cu") (at 20 20) (property "Reference" "RX1")
(pad "9" smd custom (at -4 -8) (size 0.04 0.04) (layers "B.Cu") (net "RXA1")
(options (clearance outline) (anchor circle))
(primitives (gr_poly (pts (xy 0 0) (xy 8 0) (xy 8 1) (xy 0 1)) (width 0) (fill yes)))))
(footprint "R" (layer "F.Cu") (at 6 34) (property "Reference" "R1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "RXA1")))
(footprint "R" (layer "F.Cu") (at 34 34) (property "Reference" "R2")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "RXA1")))
{track}
(gr_rect (start 0 0) (end 40 40) (layer "Edge.Cuts")))"#)
}
fn seg(a: (f64, f64), b: (f64, f64), layer: &str) -> String {
format!("(segment (start {} {}) (end {} {}) (width 0.15) (layer \"{layer}\") (net \"RXA1\"))", a.0, a.1, b.0, b.1)
}
fn via(at: (f64, f64)) -> String {
format!("(via (at {} {}) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"RXA1\"))", at.0, at.1)
}
#[test]
fn landed_copper_on_a_custom_pads_polygon_joins_its_cluster() {
// the ring's copper runs (16, 12) to (24, 13); its anchor is at (16, 12)
let b = Board::from_text("t", ring_board("")).unwrap();
let ring = b.pad_by_key("RX1.9").unwrap();
assert!(ring.on_b && !ring.on_f);
assert!(ring.polys.iter().any(|p| aiflow_board::geom::point_in_poly(23.0, 12.5, p)), "{:?}", ring.polys);
// both resistors reach the ring on B.Cu through a via, the feeders ending on the ring's polygon
// (one deep inside it, one on its edge), never on its anchor
let mut copper = String::new();
for (r, inside) in [((6.0, 34.0), (23.0, 12.5)), ((34.0, 34.0), (20.0, 13.0))] {
let v = (r.0, r.1 - 3.0);
copper += &seg(r, v, "F.Cu");
copper += &via(v);
copper += &seg(v, inside, "B.Cu");
}
let (r, plan, failed) = route(ring_board(&copper));
assert_eq!(failed, 0);
assert!(plan.is_empty(), "the landed copper already joins the net: {plan:?}");
assert!(r.nets[r.net_ids["RXA1"]].done_by_board);
}
#[test]
fn a_track_crossing_a_custom_pad_with_both_ends_outside_still_joins_it() {
// a feeder laid straight across the ring: neither end on the pad's copper
let mut copper = String::new();
copper += &seg((6.0, 34.0), (6.0, 31.0), "F.Cu");
copper += &via((6.0, 31.0));
copper += &seg((6.0, 31.0), (20.0, 10.0), "B.Cu");
copper += &seg((34.0, 34.0), (34.0, 31.0), "F.Cu");
copper += &via((34.0, 31.0));
copper += &seg((34.0, 31.0), (22.0, 15.0), "B.Cu");
copper += &seg((22.0, 15.0), (22.0, 10.0), "B.Cu");
let (r, plan, failed) = route(ring_board(&copper));
assert_eq!(failed, 0);
assert!(plan.is_empty(), "{plan:?}");
assert!(r.nets[r.net_ids["RXA1"]].done_by_board);
}
}
#[cfg(test)]
mod probe_real_board {
use crate::{Router, Rules};
/// `AIFLOW_PROBE_BOARD=path cargo test -p aiflow-router probe_routed_board -- --ignored --nocapture`:
/// how a real board's own copper reads (init time, nets left open, their clusters).
#[test]
#[ignore]
fn probe_routed_board() {
let Ok(path) = std::env::var("AIFLOW_PROBE_BOARD") else { return };
let b = aiflow_board::Board::load(&path).unwrap();
let t0 = std::time::Instant::now();
let mut r = Router::new(b, Rules::default(), false);
let init = t0.elapsed().as_secs_f64();
if std::env::var("AIFLOW_PROBE_ROUTE").is_ok() {
let (plan, report, failed) = crate::drive::route_board(&mut r, 2, 4, 50);
println!("route: {} plan entries, {failed} failed, open connections {}", plan.len(), report["boardCopper"]["openConnections"]);
}
for l in &r.log_lines { println!("{l}"); }
let open: Vec<String> = r.nets.iter().filter(|n| n.clusters.len() >= 2 && !n.done_by_board && n.plane.is_none())
.map(|n| format!("{} ({} clusters, {} groups)", n.name, n.clusters.len(), (0..n.clusters.len()).map(|c| r.comp_of(n.idx, c)).collect::<std::collections::HashSet<_>>().len())).collect();
println!("init {init:.2}s; {} nets open: {}", open.len(), open.join(", "));
if let Ok(name) = std::env::var("AIFLOW_PROBE_NET") {
let n = r.net_ids[&name];
for c in 0..r.nets[n].clusters.len() {
let keys: Vec<String> = r.nets[n].clusters[c].pads.iter().map(|&p| format!("{} @({:.2},{:.2})", r.board.pads[p].key, r.board.pads[p].x, r.board.pads[p].y)).collect();
println!(" group {} : {}", r.comp_of(n, c), keys.join(" "));
}
}
}
}