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(" "));
            }
        }
    }
}