main
Colby Knox AI Flow 0.2.67: signal and placement rankings (#129 #130) a34ceb8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728
//! The search and the driver: A* per connection, a net as a spanning tree onto its own copper,
//! rip-up when a connection fails, plane stubs, Kelvin taps, passes, and the plan.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap, HashSet};

use aiflow_board::{geom, B_CU, F_CU};
use aiflow_grid::RES;
use serde_json::{json, Value};

use crate::{slop, turn_cost, End, NetState, Route, Router, Snapshot, Term, COST_VIA, DIRS, HIST_COST, INF, JUMPER_COST, JUMPER_LAYER_MULT, LAYER_MULT, PAIR_OFF, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};

pub type Win = (i64, i64, i64, i64);

pub struct Search<'a> {
    pub sources: Vec<(Term, i64)>,
    pub targets: Option<&'a HashSet<Term>>,
    pub heur: Vec<(i64, i64)>,
    pub win: Win,
    pub allow_via: bool,
    pub soft: bool,
    pub via_target: bool,
    pub max_nodes: Option<usize>,
    pub vip: Vec<Term>,
}

// Existing copper is in via_ok. Also check layer transitions in this unfinished path.
fn path_vias_clear(n: usize, parent: &[usize], layer_cells: usize, width: usize, gap_mm: f64) -> bool {
    let m = n % layer_cells;
    let mut cursor = n;
    while parent[cursor] != usize::MAX {
        let prev = parent[cursor];
        if cursor / layer_cells != prev / layer_cells {
            let via = cursor % layer_cells;
            let dx = (m % width) as f64 - (via % width) as f64;
            let dy = (m / width) as f64 - (via / width) as f64;
            if dx.hypot(dy) * RES < gap_mm { return false; }
        }
        cursor = prev;
    }
    true
}

impl Router {
    /// A* from sources to targets over the window. Returns (cells, blockers).
    pub fn search(&mut self, net: usize, width: f64, mut s: Search) -> (Option<Vec<Term>>, HashSet<usize>) {
        // the window inside the grid, whoever built it (issue #140)
        let (wx, hy) = (self.grid.w as i64 - 1, self.grid.h as i64 - 1);
        let (a, b) = (s.win.0.clamp(0, wx), s.win.1.clamp(0, hy));
        s.win = (a, b, s.win.2.clamp(a, wx), s.win.3.clamp(b, hy));
        let (i0, j0, i1, j1) = s.win;
        let ww = (i1 - i0 + 1) as usize;
        let hw = (j1 - j0 + 1) as usize;
        let nl = ww * hw;
        let (obs_o, obs_d, softm, via_ok) = self.obstacle_maps(net, width, s.win, s.soft);
        let guide = self.pair_guide(net, s.win);
        let g = self.grid.clone();
        // zone, wall, history windows
        let mut zone = vec![false; nl];
        let mut wall = vec![0i64; nl];
        let mut hist = [vec![0i64; nl], vec![0i64; nl]];
        for jj in 0..hw {
            for ii in 0..ww {
                let c = (j0 as usize + jj) * g.w + i0 as usize + ii;
                zone[jj * ww + ii] = self.ic_zone.bits[c];
                wall[jj * ww + ii] = self.chan_count[c] as i64;
                hist[0][jj * ww + ii] = (self.history[0][c] as i64).clamp(0, 40);
                hist[1][jj * ww + ii] = (self.history[1][c] as i64).clamp(0, 40);
            }
        }
        let nid = self.nets[net].idx;
        for ((n, _, _), cells) in &self.chan_lines {
            if *n == nid {
                for &c in cells {
                    let (cj, ci) = ((c / g.w) as i64, (c % g.w) as i64);
                    if cj >= j0 && cj <= j1 && ci >= i0 && ci <= i1 {
                        wall[(cj - j0) as usize * ww + (ci - i0) as usize] -= 1;
                    }
                }
            }
        }
        for w in wall.iter_mut() {
            *w = (*w).clamp(0, 60);
        }
        let mut tgt = vec![false; 2 * nl];
        let vipset: HashSet<usize> = s.vip.iter().filter(|t| t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1).map(|t| (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize).collect();
        if s.via_target {
            // a via site on the sources' layer: F.Cu, or B.Cu when every source is a bottom pad's (issue #139)
            let vl = if !s.sources.is_empty() && s.sources.iter().all(|(t, _)| t.layer() == B_CU) { B_CU } else { F_CU };
            for m in 0..nl {
                if via_ok.bits[m] && !obs_o[vl].bits[m] {
                    tgt[vl * nl + m] = true;
                }
            }
        }
        if let Some(targets) = s.targets {
            for t in targets {
                if t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1 {
                    tgt[t.layer() * nl + (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize] = true;
                }
            }
        }
        if !tgt.iter().any(|b| *b) {
            return (None, HashSet::new());
        }
        let mut gcost = vec![INF; 2 * nl];
        let mut parent = vec![usize::MAX; 2 * nl];
        let mut pdir = vec![-1i8; 2 * nl];
        let mut closed = vec![false; 2 * nl];
        let mut heap: BinaryHeap<Reverse<(i64, usize)>> = BinaryHeap::new();
        let hp: Vec<(i64, i64)> = s.heur.iter().map(|(i, j)| (i - i0, j - j0)).collect();
        let h = |il: i64, jl: i64| -> i64 {
            if hp.is_empty() {
                return 0;
            }
            hp.iter().map(|(hi, hj)| { let dx = (hi - il).abs(); let dy = (hj - jl).abs(); 10 * dx.max(dy) + 4 * dx.min(dy) }).min().unwrap()
        };
        // issue #183: with both layers and no vias, the jumper layer (F.Cu) is a wire on the component
        // side: entering it costs JUMPER_COST, so a connection takes a jumper only when the milled face
        // has no way through
        let jumper = self.rules.jumper_layer();
        let layer_mult: [f64; 2] = if jumper.is_some() { JUMPER_LAYER_MULT } else { LAYER_MULT };
        for (t, c0) in &s.sources {
            let c0 = &(c0 + if jumper == Some(t.layer()) { JUMPER_COST } else { 0 });
            let (i, j) = (t.i as i64, t.j as i64);
            if !(i >= i0 && i <= i1 && j >= j0 && j <= j1) {
                continue;
            }
            let m = (j - j0) as usize * ww + (i - i0) as usize;
            let n = t.layer() * nl + m;
            if obs_o[t.layer()].bits[m] {
                continue;
            }
            if *c0 < gcost[n] {
                gcost[n] = *c0;
                heap.push(Reverse((c0 + h(i - i0, j - j0), n)));
            }
        }
        let flat_turns = self.flat_turns;
        let mut expanded = 0usize;
        let mut found: Option<usize> = None;
        while let Some(Reverse((_, n))) = heap.pop() {
            if closed[n] {
                continue;
            }
            closed[n] = true;
            if tgt[n] {
                found = Some(n);
                break;
            }
            expanded += 1;
            if let Some(cap) = s.max_nodes {
                if expanded > cap {
                    break;
                }
            }
            let l = n / nl;
            let m = n - l * nl;
            let jl = (m / ww) as i64;
            let il = (m % ww) as i64;
            let gn = gcost[n];
            let pd = pdir[n];
            let mult = layer_mult[l];
            for d in 0..8 {
                let ni = il + DIRS[d].0 as i64;
                let nj = jl + DIRS[d].1 as i64;
                if ni < 0 || nj < 0 || ni >= ww as i64 || nj >= hw as i64 {
                    continue;
                }
                let nm = nj as usize * ww + ni as usize;
                if obs_o[l].bits[nm] {
                    continue;
                }
                if d >= 4 && obs_d[l].bits[nm] && obs_d[l].bits[m] {
                    continue;
                }
                let nn = l * nl + nm;
                if closed[nn] {
                    continue;
                }
                let mut c = (STEP[d] as f64 * mult) as i64;
                if pd != -1 {
                    c += turn_cost(pd as usize, d, flat_turns);
                }
                if let Some(sm) = &softm[l] {
                    if sm.bits[nm] {
                        c += SOFT_EXTRA;
                    }
                }
                if l == F_CU && zone[nm] {
                    c += ZONE_COST;
                }
                if hist[l][nm] > 0 {
                    c += HIST_COST * hist[l][nm];
                }
                if let Some((gl, gm)) = &guide {
                    if *gl == l && !gm[nm] {
                        c += PAIR_OFF;
                    }
                }
                let ng = gn + c;
                if ng < gcost[nn] {
                    gcost[nn] = ng;
                    parent[nn] = n;
                    pdir[nn] = d as i8;
                    heap.push(Reverse((ng + h(ni, nj), nn)));
                }
            }
            if s.allow_via && (via_ok.bits[m] || vipset.contains(&m))
                && path_vias_clear(n, &parent, nl, ww, self.nets[net].drill + self.rules.hole_to_hole + slop()) {

                let l2 = 1 - l;
                if !obs_o[l2].bits[m] {
                    let nn = l2 * nl + m;
                    if !closed[nn] {
                        let ng = gn + COST_VIA + VIA_WALL * wall[m] + if jumper == Some(l2) { JUMPER_COST } else { 0 };
                        if ng < gcost[nn] {
                            gcost[nn] = ng;
                            parent[nn] = n;
                            pdir[nn] = -1;
                            heap.push(Reverse((ng + h(il, jl), nn)));
                        }
                    }
                }
            }
        }
        self.stat("expanded", expanded as i64);
        let Some(mut n) = found else { return (None, HashSet::new()) };
        let mut cells = Vec::new();
        loop {
            let l = n / nl;
            let m = n - l * nl;
            cells.push(Term::new(l, (m % ww) as i64 + i0, (m / ww) as i64 + j0));
            if parent[n] == usize::MAX {
                break;
            }
            n = parent[n];
        }
        cells.reverse();
        let mut blockers = HashSet::new();
        if s.soft {
            let r = ((self.rules.clearance + width / 2.0 + slop()) / RES).ceil() as i64 + 1;
            for t in &cells {
                if let Some(sm) = &softm[t.layer()] {
                    let m = (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize;
                    if sm.bits[m] {
                        for jj in (t.j as i64 - r).max(0)..=(t.j as i64 + r).min(g.h as i64 - 1) {
                            for ii in (t.i as i64 - r).max(0)..=(t.i as i64 + r).min(g.w as i64 - 1) {
                                let v = self.route_raw[t.layer()][jj as usize * g.w + ii as usize];
                                if v != -1 && v as usize != nid {
                                    blockers.insert(v as usize);
                                }
                            }
                        }
                    }
                }
            }
        }
        (Some(cells), blockers)
    }

    // -- per net -----------------------------------------------------------------------
    pub fn window_for(&self, pts: &[(i64, i64)], margin_mm: f64) -> Win {
        let m = (margin_mm / RES).round() as i64;
        let xs: Vec<i64> = pts.iter().map(|p| p.0).collect();
        let ys: Vec<i64> = pts.iter().map(|p| p.1).collect();
        // every bound inside the grid (issue #140: a point off the grid, a part outside the outline, made a
        // window past the grid's end and obstacle_maps indexed out of bounds); search skips any source or
        // target outside the window
        let (wx, hy) = (self.grid.w as i64 - 1, self.grid.h as i64 - 1);
        ((xs.iter().min().unwrap() - m).clamp(0, wx), (ys.iter().min().unwrap() - m).clamp(0, hy), (xs.iter().max().unwrap() + m).clamp(0, wx), (ys.iter().max().unwrap() + m).clamp(0, hy))
    }

    pub fn copper_targets(&self, net: usize) -> HashSet<Term> {
        let mut t = HashSet::new();
        for c in &self.nets[net].clusters {
            if c.connected && !self.kelvin_clusters.contains(&(net, c.idx)) {
                t.extend(c.terminals.iter().copied());   // a sense pad is never a landing target either
            }
        }
        for r in &self.nets[net].routes {
            if r.kelvin {
                continue;   // a Kelvin tap is not copper the rest of its net may land on
            }
            if !self.pair_route_joined(net, r) {
                continue;   // a decoupling pair not yet joined to the tree is an island, not tree copper
            }
            t.extend(r.cells.iter().copied());
        }
        // the board's own copper of every group already joined to the tree
        if let Some(cells) = self.fixed_targets.get(&net) {
            let joined: HashSet<usize> = (0..self.nets[net].clusters.len()).filter(|&c| self.nets[net].clusters[c].connected).map(|c| self.comp_of(net, c)).collect();
            t.extend(cells.iter().filter(|(g, _)| joined.contains(g)).map(|(_, c)| *c));
        }
        t
    }

    fn vip_cells(&mut self, net: usize, clusters: &[usize]) -> Vec<Term> {
        let mut out = Vec::new();
        for &c in clusters {
            let cl = self.nets[net].clusters[c].clone();
            if self.board.pads[cl.primary].thru || !cl.escapes.is_empty() {
                continue;
            }
            for t in cl.terminals {
                if t.layer() == F_CU {
                    let (x, y) = self.grid.to_xy(t.i as i64, t.j as i64);
                    if self.via_clear_exact(net, x, y) {
                        out.push(t);
                    }
                }
            }
        }
        out
    }

    pub fn connect(&mut self, net: usize, cluster: usize, anchor: &[(i64, i64)], soft: bool, vip: bool) -> (Option<Route>, HashSet<usize>) {
        let mut targets = self.copper_targets(net);
        let cl = self.nets[net].clusters[cluster].clone();
        let mut sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();
        // a cluster the board's own copper already reaches may leave from anywhere on that copper
        if let Some(cells) = self.fixed_targets.get(&net) {
            let group = self.comp_of(net, cluster);
            sources.extend(cells.iter().filter(|(g, t)| *g == group && !targets.contains(t)).map(|(_, t)| (*t, 0)));
        }
        let allow = cl.allow;
        let mut widths: Vec<f64> = self.nets[net].widths.iter().copied().filter(|w| *w <= allow).collect();
        if widths.is_empty() {
            widths = vec![self.nets[net].widths.iter().cloned().fold(f64::INFINITY, f64::min)];
        }
        let mut allpts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();
        allpts.extend_from_slice(anchor);
        let mut vip_src = Vec::new();
        let vip = vip && self.rules.vias_allowed();   // issue #183: via-in-pad is a via
        if vip {
            vip_src = self.vip_cells(net, &[cluster]);
            let connected: Vec<usize> = self.nets[net].clusters.iter().filter(|c| c.connected).map(|c| c.idx).collect();
            for t in self.vip_cells(net, &connected) {
                targets.insert(Term::new(B_CU, t.i as i64, t.j as i64));
            }
        }
        let stages: Vec<(f64, Option<usize>)> = if soft || vip { vec![(12.0, Some(200_000))] } else { vec![(5.0, None), (12.0, None), (80.0, Some(250_000))] };
        for width in widths {
            for (margin, cap) in &stages {
                let win = self.window_for(&allpts, *margin);
                let (cells, blockers) = self.search(net, width, Search { sources: sources.clone(), targets: Some(&targets), heur: anchor.to_vec(), win, allow_via: true, soft, via_target: false, max_nodes: *cap, vip: vip_src.clone() });
                if let Some(cells) = cells {
                    let start_term = cells[0];
                    let end_cell = *cells.last().unwrap();
                    let from_pad = cl.terminals.contains(&start_term);
                    let start = if cl.escapes.contains_key(&start_term) { End::Escape(cluster, start_term) } else if from_pad { End::Pad(cluster, start_term) } else { End::Copper };
                    let mut end = End::Copper;
                    for c in &self.nets[net].clusters {
                        if c.connected && (c.terminals.contains(&end_cell) || (end_cell.layer() == B_CU && c.terminals.contains(&Term::new(F_CU, end_cell.i as i64, end_cell.j as i64)) && targets.contains(&end_cell))) {
                            end = if c.escapes.contains_key(&end_cell) { End::Escape(c.idx, end_cell) } else { End::Pad(c.idx, end_cell) };
                        }
                    }
                    let mut route = Route::new(cells.clone(), width, start, end.clone());
                    if let End::Pad(ec, _) = end {
                        // issue #139: a via only where the end pad has no copper on the arriving layer (a
                        // top pad reached on B.Cu through via-in-pad); a bottom part's pad reached on B.Cu
                        // is already joined, and a via there put the path's end on F.Cu, where it has none
                        let ep = &self.board.pads[self.nets[net].clusters[ec].primary];
                        if !ep.thru && !ep.on_layer(end_cell.layer()) && self.rules.vias_allowed() {
                            route.via_end = true;
                            route.via_end_layer = if ep.on_f { F_CU } else { B_CU };
                            self.stat("viaInPad", 1);
                            let key = self.nets[net].clusters[ec].key(&self.board).to_string();
                            self.nets[net].via_in_pad.push(key);
                        }
                    }
                    if from_pad && cells.len() > 1 && (cells[0].i, cells[0].j) == (cells[1].i, cells[1].j) && cells[0].layer() != cells[1].layer() && !self.board.pads[cl.primary].thru {
                        self.stat("viaInPad", 1);
                        let key = cl.key(&self.board).to_string();
                        self.nets[net].via_in_pad.push(key);
                    }
                    return (Some(route), blockers);
                }
            }
        }
        (None, HashSet::new())
    }

    /// Prim's order over clusters; each cluster to the existing copper. Returns blocker net ids on failure.
    pub fn route_net(&mut self, net: usize) -> HashSet<usize> {
        let n = self.nets[net].clusters.len();
        for c in 0..n {
            self.nets[net].clusters[c].connected = false;
        }
        self.nets[net].routes.retain(|r| r.pair.is_some());   // decoupling pairs stay (decoupling.rs)
        self.nets[net].failed.clear();
        self.nets[net].last_soft.clear();
        let start = (0..n).max_by(|&a, &b| {
            let ca = &self.nets[net].clusters[a];
            let cb = &self.nets[net].clusters[b];
            let pa = &self.board.pads[ca.primary];
            let pb = &self.board.pads[cb.primary];
            (ca.allow, pa.half_long * pa.half_short).partial_cmp(&(cb.allow, pb.half_long * pb.half_short)).unwrap()
        }).unwrap();
        // the start and every cluster the board's own copper already joins to it
        let mut connected = self.join_with_pairs(net, start);
        let mut remaining: Vec<usize> = (0..n).filter(|c| !connected.contains(c)).collect();
        let mut blockers_all = HashSet::new();
        // Kelvin taps first: sense pad to shunt pad as a dedicated trace
        let kelvin = self.rules.kelvin.clone();
        for c in remaining.clone() {
            let key = self.nets[net].clusters[c].key(&self.board).to_string();
            if self.joined_by_board(net, c) {
                continue;   // a sense pad the board's copper already reaches keeps that copper
            }
            if let Some(dst_key) = kelvin.get(&key) {
                let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dst_key);
                let Some(dst) = dst else { continue };
                if self.route_kelvin(net, c, dst) {
                    // the sense pad is done; the shunt pad still joins the rest of the net through
                    // the wide path (the tap itself is never a landing target, see copper_targets)
                    remaining.retain(|&x| x != c);
                } else {
                    let dk = self.nets[net].clusters[dst].key(&self.board).to_string();
                    self.nets[net].failed.push((key, format!("kelvin tap to {dk} found no dedicated path")));
                    remaining.retain(|&x| x != c);
                }
            }
        }
        while !remaining.is_empty() {
            let mut best: Option<(f64, usize, usize)> = None;
            for &c in &remaining {
                for &k in &connected {
                    let a = self.nets[net].clusters[c].xy(&self.board);
                    let b = self.nets[net].clusters[k].xy(&self.board);
                    let d = (a.0 - b.0).hypot(a.1 - b.1);
                    if best.map(|x| d < x.0).unwrap_or(true) {
                        best = Some((d, c, k));
                    }
                }
            }
            let (_, c, k) = best.unwrap();
            remaining.retain(|&x| x != c);
            let anchor: Vec<(i64, i64)> = self.nets[net].clusters[k].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();
            let (mut route, _) = self.connect(net, c, &anchor, false, false);
            if route.is_none() && self.rules.vias_allowed() {
                route = self.connect(net, c, &anchor, false, true).0;
            }
            match route {
                None => {
                    let (soft_route, blockers) = self.connect(net, c, &anchor, true, false);
                    blockers_all.extend(blockers.iter().copied());
                    if let Some(sr) = soft_route {
                        self.nets[net].last_soft.extend(sr.cells.iter().copied());
                    }
                    let key = self.nets[net].clusters[c].key(&self.board).to_string();
                    let why = if let Some(w) = self.off_layer_reason(net, c).or_else(|| self.off_layer_reason(net, k)) { w } else if blockers.is_empty() { if self.rules.default_copper_mode() { "no path".to_string() } else { format!("no path {}", self.mode_words()) } } else {
                        let mut names: Vec<String> = blockers.iter().map(|&b| self.name_of(b)).collect();
                        names.sort();
                        format!("blocked by {names:?}")
                    };
                    self.nets[net].failed.push((key, why));
                    self.nets[net].clusters[c].connected = false;
                }
                Some(r) => {
                    self.commit(net, r);
                    for k in self.join_with_pairs(net, c) {
                        if !connected.contains(&k) {
                            connected.push(k);
                        }
                        remaining.retain(|&x| x != k);
                    }
                }
            }
        }
        blockers_all
    }

    /// Issue #183: why a cluster cannot be reached in single-face mode: its pad has copper only on a
    /// layer the router may not use. None when the pad is reachable (or in the default mode).
    pub fn off_layer_reason(&self, net: usize, c: usize) -> Option<String> {
        if self.rules.default_copper_mode() { return None; }
        let p = &self.board.pads[self.nets[net].clusters[c].primary];
        if p.thru || (0..2).any(|l| p.on_layer(l) && self.rules.layer_allowed(l)) { return None; }
        let on = if p.on_f { "F.Cu" } else { "B.Cu" };
        Some(format!("pad {} has copper only on {on}, a layer this board does not route (routeLayers {}); allow {on}, or use a through-hole part there", p.key, self.mode_layers().join(" + ")))
    }

    /// The layers the router may use, by name.
    pub fn mode_layers(&self) -> Vec<&'static str> {
        (0..2).filter(|&l| self.rules.layer_allowed(l)).map(|l| aiflow_board::LAYER_NAMES[l]).collect()
    }

    /// "on B.Cu alone, no vias" or "on B.Cu with F.Cu jumpers, no vias".
    pub fn mode_words(&self) -> String {
        match self.rules.jumper_layer() {
            Some(j) => format!("on {} with {} jumpers, no vias", aiflow_board::LAYER_NAMES[1 - j], aiflow_board::LAYER_NAMES[j]),
            None if !self.rules.vias_allowed() => format!("on {} alone, no vias", self.mode_layers().join(" + ")),
            None => String::new(),
        }
    }

    pub fn name_of(&self, nid: usize) -> String {
        if let Some(&owner) = self.kelvin_owner.get(&nid) {
            return self.nets[owner].name.clone();
        }
        self.nets.iter().find(|n| n.idx == nid).map(|n| n.name.clone()).unwrap_or_else(|| "?".into())
    }

    pub fn commit(&mut self, net: usize, route: Route) {
        let end = *route.cells.last().unwrap();
        for r in self.nets[net].routes.iter_mut() {
            if let Some(pos) = r.cells.iter().position(|c| *c == end) {
                r.breaks.insert(pos);
            }
        }
        for e in [&route.start, &route.end] {
            if let End::Escape(c, t) = e {
                self.nets[net].used_escapes.insert((*c, *t));
            }
        }
        self.stamp_route(net, &route, 1);
        let ends = [route.start.clone(), route.end.clone()];
        self.nets[net].routes.push(route);
        for e in ends {
            match e {
                End::Escape(c, t) => {
                    self.release_escapes(net, c);
                    self.stamp_channel_pub(net, c, t, -1);
                }
                End::Pad(c, _) => self.release_escapes(net, c),
                _ => {}
            }
        }
    }

    pub fn stamp_channel_pub(&mut self, net: usize, c: usize, t: Term, sign: i32) {
        // the channel bookkeeping lives in lib.rs; re-expose through stamp_stub's helper
        let key = (self.nets[net].idx, c, t);
        if sign < 0 {
            if let Some(cells) = self.chan_lines.remove(&key) {
                for cell in cells {
                    self.chan_count[cell] -= 1;
                }
            }
        }
    }

    pub fn release_escapes(&mut self, net: usize, cluster: usize) {
        let escapes: Vec<Term> = self.nets[net].clusters[cluster].escapes.keys().copied().collect();
        for t in escapes {
            let used = self.nets[net].used_escapes.contains(&(cluster, t));
            let released = self.nets[net].clusters[cluster].released.contains(&t);
            if !used && !released {
                self.stamp_stub(net, cluster, t, -1);
                self.nets[net].clusters[cluster].released.insert(t);
            }
        }
    }

    /// Re-reserve a cluster's escapes after a rip-up; an escape crossed by another net's copper stays dead.
    pub fn reserve_escapes(&mut self, net: usize, cluster: usize) {
        let g = self.grid.clone();
        let escapes: Vec<Term> = self.nets[net].clusters[cluster].escapes.keys().copied().collect();
        let nid = self.nets[net].idx as i16;
        for t in escapes {
            if self.nets[net].clusters[cluster].released.contains(&t) {
                let l = t.layer();
                let a = self.board.pads[self.nets[net].clusters[cluster].primary].polys_centre;
                let b = g.to_xy(t.i as i64, t.j as i64);
                let mut checks = vec![(a, b, self.stub_for(net) / 2.0 + self.rules.clearance)];
                if let Some(k) = self.nets[net].clusters[cluster].corridors.get(&t) {
                    checks.push((b, *k, self.rules.track / 2.0 + self.rules.clearance));
                }
                let mut blocked = false;
                for (s0, s1, hw) in checks {
                    for c in g.segment_cells(s0, s1, hw, Some(1e-6)) {
                        let raw = self.route_raw[l][c];
                        if raw != -1 && raw != nid {
                            blocked = true;
                            break;
                        }
                    }
                    if blocked {
                        break;
                    }
                }
                if blocked {
                    let cl = &mut self.nets[net].clusters[cluster];
                    if cl.terminals.len() > 1 {
                        cl.terminals.retain(|x| *x != t);
                    }
                    self.stat("deadEscapes", 1);
                    continue;
                }
                self.stamp_stub(net, cluster, t, 1);
                self.nets[net].clusters[cluster].released.remove(&t);
                if !self.nets[net].clusters[cluster].terminals.contains(&t) {
                    self.nets[net].clusters[cluster].terminals.push(t);
                }
            } else {
                self.stamp_stub_channel(net, cluster, t);
            }
        }
    }

    fn stamp_stub_channel(&mut self, net: usize, cluster: usize, t: Term) {
        // (re)count the channel only: stamp_stub(+1) would re-stamp copper that is still there
        let key = (self.nets[net].idx, cluster, t);
        if self.chan_lines.contains_key(&key) {
            return;
        }
        // reuse stamp_stub's channel computation by a +1 stub stamp on already-stamped cells (idempotent copper)
        self.stamp_stub(net, cluster, t, 1);
    }

    pub fn rip_cluster(&mut self, net: usize, cluster: usize) {
        let routes = std::mem::take(&mut self.nets[net].routes);
        let mut keep = Vec::new();
        for r in routes {
            if r.start.cluster() == Some(cluster) && r.pair.is_none() {
                self.stamp_route(net, &r, -1);
                for c in &r.cells {
                    self.bump_history(c);
                }
            } else {
                keep.push(r);
            }
        }
        self.nets[net].routes = keep;
        self.nets[net].used_escapes.retain(|(ci, _)| *ci != cluster);
        self.nets[net].clusters[cluster].connected = false;
        self.reserve_escapes(net, cluster);
        let key = self.nets[net].clusters[cluster].key(&self.board).to_string();
        if !self.nets[net].failed.iter().any(|(k, _)| *k == key) {
            self.nets[net].failed.push((key, "ripped for a signal".into()));
        }
        self.stat("rips", 1);
        self.rip_total += 1;
    }

    fn bump_history(&mut self, c: &Term) {
        let g = &self.grid;
        for jj in (c.j as i64 - 2).max(0)..=(c.j as i64 + 2).min(g.h as i64 - 1) {
            for ii in (c.i as i64 - 2).max(0)..=(c.i as i64 + 2).min(g.w as i64 - 1) {
                let idx = jj as usize * g.w + ii as usize;
                self.history[c.layer()][idx] = self.history[c.layer()][idx].saturating_add(1);
            }
        }
    }

    /// Rip the net's routes, keeping its decoupling pair routes (decoupling.rs).
    pub fn rip(&mut self, net: usize) {
        self.rip_keeping(net, true);
    }

    pub fn rip_keeping(&mut self, net: usize, keep_pairs: bool) {
        let routes = std::mem::take(&mut self.nets[net].routes);
        let mut kept = Vec::new();
        for r in routes {
            if keep_pairs && r.pair.is_some() {
                kept.push(r);
                continue;
            }
            self.stamp_route(net, &r, -1);
            for c in &r.cells {
                self.bump_history(c);
            }
        }
        self.nets[net].routes = kept;
        self.nets[net].used_escapes.clear();
        let n = self.nets[net].clusters.len();
        for c in 0..n {
            self.nets[net].clusters[c].connected = false;
            self.reserve_escapes(net, c);
        }
        self.keep_pair_escapes(net);
        self.nets[net].rips += 1;
        self.rip_total += 1;
        self.stat("rips", 1);
    }

    // -- Kelvin taps -----------------------------------------------------------------------
    pub fn route_kelvin(&mut self, net: usize, src: usize, dst: usize) -> bool {
        let g = self.grid.clone();
        let mut kid = 30000 + (src % 1000);
        while self.nets.iter().any(|n| n.idx == kid) || self.kelvin_owner.contains_key(&kid) {
            kid += 1;
        }
        self.kelvin_owner.insert(kid, net);
        let nid = self.nets[net].idx as i16;
        let mut saved: Vec<(usize, Vec<usize>)> = Vec::new();
        for &cl in &[src, dst] {
            let p = self.board.pads[self.nets[net].clusters[cl].primary].clone();
            let cells = g.disk_cells((p.x, p.y), p.half_long + self.rules.escape + self.rules.corridor + 0.3, Some(1e-6));
            for l in 0..2 {
                let mine: Vec<usize> = cells.iter().copied().filter(|&c| self.pad_raw[l][c] == nid).collect();
                for &c in &mine {
                    self.pad_raw[l][c] = kid as i16;
                }
                saved.push((l, mine));
            }
        }
        // a synthetic net state just for the search
        let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill, done_by_board: false, outside: Vec::new() };
        self.nets.push(fake);
        let fidx = self.nets.len() - 1;
        let sources: Vec<(Term, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (*t, 0)).collect();
        let targets: HashSet<Term> = self.nets[net].clusters[dst].terminals.iter().copied().collect();
        let mut pts: Vec<(i64, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();
        let dpts: Vec<(i64, i64)> = self.nets[net].clusters[dst].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();
        pts.extend_from_slice(&dpts);
        let allow_via = self.nets[net].plane.is_none();
        let width = self.rules.stub + 0.05;
        let mut cells = None;
        for margin in [4.0, 10.0] {
            let win = self.window_for(&pts, margin);
            let (c, _) = self.search(fidx, width, Search { sources: sources.clone(), targets: Some(&targets), heur: dpts.clone(), win, allow_via, soft: false, via_target: false, max_nodes: None, vip: Vec::new() });
            if c.is_some() {
                cells = c;
                break;
            }
        }
        self.nets.pop();
        for (l, mine) in saved {
            for c in mine {
                self.pad_raw[l][c] = nid;
            }
        }
        let Some(cells) = cells else { self.kelvin_owner.remove(&kid); return false };
        let start = if self.nets[net].clusters[src].escapes.contains_key(&cells[0]) { End::Escape(src, cells[0]) } else { End::Pad(src, cells[0]) };
        let last = *cells.last().unwrap();
        let end = if self.nets[net].clusters[dst].escapes.contains_key(&last) { End::Escape(dst, last) } else { End::Pad(dst, last) };
        let mut r = Route::new(cells.clone(), width, start, end);
        r.kelvin = true;
        self.commit(net, r);
        // the tap is foreign copper to the rest of its net, except inside the pads it joins: the
        // pickup is the pad itself, and the wide path must still be able to land on that pad
        for c in &cells {
            let xy = g.to_xy(c.i as i64, c.j as i64);
            for cell in g.disk_cells(xy, width / 2.0, Some(1e-6)) {
                if self.pad_raw[c.layer()][cell] != nid {
                    self.route_raw[c.layer()][cell] = kid as i16;
                }
            }
        }
        self.nets[net].clusters[src].connected = true;
        self.kelvin_clusters.insert((net, src));
        self.stat("kelvin", 1);
        let (sk, dk) = (self.nets[net].clusters[src].key(&self.board).to_string(), self.nets[net].clusters[dst].key(&self.board).to_string());
        self.log(format!("    kelvin {sk} -> {dk}: {} cells, dedicated", cells.len()));
        true
    }

    // -- planes ------------------------------------------------------------------------------
    pub fn route_plane_net(&mut self, net: usize, retry: bool) {
        let g = self.grid.clone();
        let failed: HashSet<String> = if retry { self.nets[net].failed.iter().map(|(k, _)| k.clone()).collect() } else { HashSet::new() };
        if retry {
            self.nets[net].failed.clear();
        }
        let n = self.nets[net].clusters.len();
        let kelvin = self.rules.kelvin.clone();
        for c in 0..n {
            let cl = self.nets[net].clusters[c].clone();
            let key = cl.key(&self.board).to_string();
            if self.on_plane(net, c) || self.joined_by_board(net, c) {
                self.nets[net].clusters[c].connected = true;
                continue;
            }
            if kelvin.contains_key(&key) {
                continue;
            }
            if retry && (cl.connected || !failed.contains(&key)) {
                continue;
            }
            let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();
            let pts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();
            let win = self.window_for(&pts, self.rules.via_search_mm);
            // the pad's own layer: a bottom part's pad (issue #139) reaches its via on B.Cu, and the via's
            // marker names the other layer so the plan carries a through via, not a point on the same layer
            let pl = { let p = &self.board.pads[cl.primary]; if p.thru || p.on_f { F_CU } else { B_CU } };
            let other = if pl == F_CU { B_CU } else { F_CU };
            let own: HashSet<Term> = self.copper_targets(net).into_iter().filter(|t| t.layer() == pl).collect();
            let track = self.rules.track;
            let (cells, _) = self.search(net, track, Search { sources, targets: Some(&own), heur: Vec::new(), win, allow_via: false, soft: false, via_target: true, max_nodes: None, vip: Vec::new() });
            match cells {
                None => {
                    let p = self.board.pads[cl.primary].clone();
                    let (i, j) = g.to_cell(p.x, p.y);
                    if self.via_clear_exact(net, p.x, p.y) {
                        let mut r = Route::new(vec![Term::new(pl, i, j)], track, End::Pad(c, Term::new(pl, i, j)), End::Via);
                        r.via_end = true;
                        r.via_end_layer = other;
                        self.commit(net, r);
                        self.nets[net].clusters[c].connected = true;
                        self.nets[net].via_in_pad.push(key.clone());
                        self.stat("viaInPad", 1);
                    } else {
                        self.nets[net].failed.push((key.clone(), format!("no via site within {:.0} mm", self.rules.via_search_mm)));
                    }
                }
                Some(cells) => {
                    let start = if cl.escapes.contains_key(&cells[0]) { End::Escape(c, cells[0]) } else { End::Pad(c, cells[0]) };
                    let joined = own.contains(cells.last().unwrap());
                    let mut r = Route::new(cells, track, start, if joined { End::Copper } else { End::Via });
                    r.via_end = !joined;
                    r.via_end_layer = other;
                    self.commit(net, r);
                    self.nets[net].clusters[c].connected = true;
                }
            }
        }
    }

    /// Exact geometry: may a via of this net sit at (x, y)?
    pub fn via_clear_exact(&mut self, net: usize, x: f64, y: f64) -> bool {
        let g = self.grid.clone();
        let (via, drill) = (self.nets[net].via, self.nets[net].drill);
        let rv = via / 2.0;
        let (i, j) = g.to_cell(x, y);
        if !g.in_bounds(i, j) || !self.inside_for(via).get(i as usize, j as usize) {
            return false;
        }
        let reach = rv + self.big_clr_val + 1.5;
        let name = self.nets[net].name.clone();
        for p in &self.board.pads {
            if (p.x - x).abs() > reach + p.half_long || (p.y - y).abs() > reach + p.half_long {
                continue;
            }
            if p.net == name && !p.thru {
                continue;
            }
            let mut need = if p.thru && p.net == name { drill / 2.0 + self.rules.hole_to_hole + p.drill.unwrap_or(0.0) / 2.0 + slop() } else { rv + self.rules.clearance.max(p.clearance) };
            if p.thru && p.net != name {
                need = need.max(drill / 2.0 + self.rules.hole_to_hole + p.drill.unwrap_or(0.0) / 2.0);
            }
            for poly in &p.polys {
                if geom::point_poly_distance(x, y, poly) < need {
                    return false;
                }
            }
        }
        // drilled holes with no copper: hole to hole for the drill, clearance for the via's copper
        for h in &self.board.holes {
            if (h.x - x).abs() > reach + h.diameter() || (h.y - y).abs() > reach + h.diameter() {
                continue;
            }
            let need = (drill / 2.0 + self.rules.hole_to_hole).max(rv + self.rules.clearance) + slop();
            if geom::point_poly_distance(x, y, &h.poly) < need {
                return false;
            }
        }
        if self.fixed_blocks(&name, None, x, y, rv + self.rules.clearance) {
            return false;
        }
        if g.in_bounds(i, j) && (0..2).any(|l| self.kept_from(&name, l, j as usize * g.w + i as usize)) {
            return false;
        }
        let nid = self.nets[net].idx;
        for (oi, other) in self.nets.iter().enumerate() {
            if other.idx == nid || oi == net {
                continue;
            }
            for r in &other.routes {
                let need = rv + self.rules.clearance + r.width / 2.0;
                for (ax, ay, bx, by) in route_segments(&g, &r.cells) {
                    if (ax - x).abs() > 3.0 && (bx - x).abs() > 3.0 {
                        continue;
                    }
                    if geom::seg_distance(x, y, ax, ay, bx, by) < need {
                        return false;
                    }
                }
            }
        }
        for &(vi, vj, vnid, dia, vdrill) in &self.vias {
            let (vx, vy) = g.to_xy(vi as i64, vj as i64);
            if (vx - x).hypot(vy - y) < self.via_gap(net, vnid, dia, vdrill) {
                return false;
            }
        }
        true
    }

    // -- driver --------------------------------------------------------------------------
    pub fn mst_length(&self, net: usize) -> f64 {
        let pts: Vec<(f64, f64)> = self.nets[net].clusters.iter().map(|c| c.xy(&self.board)).collect();
        if pts.len() < 2 {
            return 0.0;
        }
        let mut inn = vec![pts[0]];
        let mut rest: Vec<(f64, f64)> = pts[1..].to_vec();
        let mut total = 0.0;
        while !rest.is_empty() {
            let mut best = (f64::INFINITY, 0usize);
            for (ri, b) in rest.iter().enumerate() {
                for a in &inn {
                    let d = (a.0 - b.0).hypot(a.1 - b.1);
                    if d < best.0 {
                        best = (d, ri);
                    }
                }
            }
            total += best.0;
            inn.push(rest.remove(best.1));
        }
        total
    }

    pub fn run(&mut self, planes: bool, rip_per_net: usize, rip_global: usize, priority: &[String]) -> usize {
        let t0 = std::time::Instant::now();
        self.rip_total = 0;
        for n in self.nets.iter_mut() {
            n.rips = 0;
        }
        let plane_nets: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_some()).map(|n| n.idx).collect();
        let kelvin = self.rules.kelvin.clone();
        // assigned-pin decoupling pairs go down before any rail distribution (spec "decoupling")
        self.route_decoupling();
        if planes {
            for &net in &plane_nets {
                if self.nets[net].done_by_board {
                    continue;
                }
                let t1 = std::time::Instant::now();
                let n = self.nets[net].clusters.len();
                for c in 0..n {
                    let key = self.nets[net].clusters[c].key(&self.board).to_string();
                    if self.joined_by_board(net, c) {
                        continue;
                    }
                    if let Some(dk) = kelvin.get(&key) {
                        let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dk);
                        if dst.map(|d| !self.route_kelvin(net, c, d)).unwrap_or(true) {
                            self.nets[net].failed.push((key, "kelvin tap found no dedicated path".into()));
                        }
                    }
                }
                self.route_plane_net(net, false);
                let (name, plane, nr, nv, nf) = (self.nets[net].name.clone(), self.nets[net].plane.clone().unwrap(), self.nets[net].routes.len(), self.nets[net].via_in_pad.len(), self.nets[net].failed.len());
                self.log(format!("  {name:<28} plane {plane}: {} smd stubs, {nv} via-in-pad, {nf} failed, {:.1}s", nr - nv, t1.elapsed().as_secs_f64()));
            }
        }
        let mut signal: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_none() && n.clusters.len() >= 2 && !n.done_by_board).map(|n| n.idx).collect();
        for &n in &signal {
            let l = self.mst_length(n);
            self.nets[n].mst_len = l;
        }
        signal.sort_by(|a, b| self.nets[*a].mst_len.partial_cmp(&self.nets[*b].mst_len).unwrap());
        // `route --variants`: another order of the same nets (the default keeps shortest first untouched)
        crate::variants::reorder(&mut signal, &self.nets, self.order);
        // issue #129: the AI's ranking goes first, class by class; within a class (and for the nets no class
        // names, which come last) the order above stands (the sort is stable)
        let ranked = !self.rules.signal_priority.is_empty();
        crate::signal_priority::sort_by_rank(&mut signal, &self.rules.signal_priority, |&n| self.nets[n].name.clone());
        let mut first: Vec<usize> = signal.iter().copied().filter(|n| priority.contains(&self.nets[*n].name)).collect();
        let mut rest: Vec<usize> = signal.iter().copied().filter(|n| !priority.contains(&self.nets[*n].name)).collect();
        let mut queue: Vec<usize> = Vec::new();
        queue.append(&mut first);
        queue.append(&mut rest);
        self.log(format!("routing {} signal nets, {}{}{}", queue.len(), if ranked { "by the spec's signalPriority classes, then " } else { "" }, self.order.describe(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) }));
        let mut order_no = 0;
        let mut best_state: Option<(usize, Snapshot, Vec<usize>)> = None;
        while !queue.is_empty() {
            let net = queue.remove(0);
            if self.nets[net].routes.iter().any(|r| r.pair.is_none()) {
                self.rip(net);
            }
            let t1 = std::time::Instant::now();
            let blockers = self.route_net(net);
            order_no += 1;
            self.nets[net].routed_order = Some(order_no);
            {
                let n = &self.nets[net];
                let widths: Vec<String> = n.widths.iter().map(|w| format!("{w}")).collect();
                let line = format!("  {:<28} {:>2} clusters  {}  {} routes, {} failed, {:.1}s{}", n.name, n.clusters.len(), widths.join("/"), n.routes.len(), n.failed.len(), t1.elapsed().as_secs_f64(), if n.failed.is_empty() { String::new() } else { format!("  FAILED {:?}", n.failed) });
                self.log(line);
            }
            let score = self.nets.iter().filter(|n| !queue.contains(&n.idx)).map(|n| n.failed.len()).sum::<usize>() + queue.iter().map(|&q| self.nets[q].clusters.len().saturating_sub(1)).sum::<usize>();
            if best_state.as_ref().map(|b| score < b.0).unwrap_or(true) {
                // issue #167: the nets still queued here are not routed in this snapshot; keep them to finish after a restore
                best_state = Some((score, self.snapshot(), queue.clone()));
            }
            if !self.nets[net].failed.is_empty() && !blockers.is_empty() && self.nets[net].rips < rip_per_net && self.rip_total < rip_global {
                let mut cands: Vec<usize> = blockers.iter().copied().filter(|b| !self.kelvin_owner.contains_key(b)).filter_map(|b| self.nets.iter().position(|n| n.idx == b)).filter(|&n| self.nets[n].routes.iter().any(|r| r.pair.is_none()) && self.nets[n].plane.is_none()).collect();
                cands.sort_by_key(|&n| std::cmp::Reverse(self.nets[n].routed_order.unwrap_or(0)));
                cands.truncate(4);
                let mut stubs: Vec<(usize, usize)> = Vec::new();
                if cands.is_empty() {
                    let soft: HashSet<(i32, i32)> = self.nets[net].last_soft.iter().flat_map(|t| { let (i, j) = (t.i, t.j); (-6..=6).flat_map(move |di| (-6..=6).map(move |dj| (i + di, j + dj))) }).collect();
                    for &b in &blockers {
                        if let Some(pn) = self.nets.iter().position(|n| n.idx == b) {
                            if self.nets[pn].plane.is_none() {
                                continue;
                            }
                            for r in self.nets[pn].routes.clone() {
                                if r.cells.iter().any(|t| soft.contains(&(t.i, t.j))) {
                                    if let Some(cl) = r.start.cluster() {
                                        stubs.push((pn, cl));
                                    }
                                }
                            }
                        }
                    }
                }
                if !cands.is_empty() || !stubs.is_empty() {
                    let cn: Vec<String> = cands.iter().map(|&c| self.nets[c].name.clone()).collect();
                    let sn: Vec<String> = stubs.iter().map(|(pn, cl)| self.nets[*pn].clusters[*cl].key(&self.board).to_string()).collect();
                    let name = self.nets[net].name.clone();
                    self.log(format!("    rip-up {cn:?}{} and retry {name}", if sn.is_empty() { String::new() } else { format!(" + plane stubs {sn:?}") }));
                    for &c in &cands {
                        self.rip(c);
                        queue.retain(|&q| q != c);
                    }
                    for (pn, cl) in stubs {
                        self.rip_cluster(pn, cl);
                    }
                    self.nets[net].rips += 1;
                    queue.insert(0, net);
                    for (k, &c) in cands.iter().enumerate() {
                        queue.insert(1 + k, c);
                    }
                    continue;
                }
            }
            if planes {
                for &pn in &plane_nets {
                    if !self.nets[pn].failed.is_empty() {
                        self.route_plane_net(pn, true);
                    }
                }
            }
        }
        if planes {
            for &pn in &plane_nets {
                if self.nets[pn].failed.is_empty() {
                    continue;
                }
                let before = self.nets[pn].failed.len();
                self.route_plane_net(pn, true);
                let (name, after) = (self.nets[pn].name.clone(), self.nets[pn].failed.len());
                self.log(format!("  {name:<28} plane retry: {} of {before} stubs recovered", before - after));
            }
        }
        let mut final_failed: usize = self.nets.iter().map(|n| n.failed.len()).sum();
        if let Some((score, snap, pending)) = best_state {
            if score < final_failed {
                // issue #167: the best intermediate state still had nets in the queue (never routed, never
                // failed); route them now, without rip-up, and keep that state only when it really is better
                let end = self.snapshot();
                self.restore(&snap);
                for &net in &pending {
                    if self.nets[net].routes.iter().any(|r| r.pair.is_none()) {
                        self.rip(net);
                    }
                    self.route_net(net);
                    order_no += 1;
                    self.nets[net].routed_order = Some(order_no);
                }
                if planes {
                    for &pn in &plane_nets {
                        if !self.nets[pn].failed.is_empty() {
                            self.route_plane_net(pn, true);
                        }
                    }
                }
                let restored: usize = self.nets.iter().map(|n| n.failed.len()).sum();
                if restored < final_failed {
                    self.log(format!("  restoring the best intermediate state ({restored} failed connections instead of {final_failed}, {} queued net(s) routed after it)", pending.len()));
                    final_failed = restored;
                } else {
                    self.restore(&end);
                }
            }
        }
        final_failed += self.flag_never_routed();
        let (exp, rips) = (self.stats.get("expanded").copied().unwrap_or(0), self.stats.get("rips").copied().unwrap_or(0));
        self.log(format!("routing done in {:.1}s, {exp} node expansions, {rips} rip-ups, {final_failed} failed connections", t0.elapsed().as_secs_f64()));
        final_failed
    }

    /// Issue #167: a signal net that ends a run with open clusters, no routes, no failures and no
    /// route order was dropped, never tried. Each open cluster becomes a failed connection with
    /// its reason, so the report, the summary and the variant ranking count it. Returns how many.
    pub fn flag_never_routed(&mut self) -> usize {
        let mut added = 0;
        for n in 0..self.nets.len() {
            let net = &self.nets[n];
            if net.plane.is_some() || net.done_by_board || net.clusters.len() < 2 || !net.failed.is_empty() || !net.routes.is_empty() {
                continue;
            }
            let groups: HashSet<usize> = (0..net.clusters.len()).map(|c| self.comp_of(n, c)).collect();
            if groups.len() < 2 {
                continue;
            }
            let mut seen: HashSet<usize> = HashSet::from([self.comp_of(n, 0)]);
            let open: Vec<String> = (1..net.clusters.len()).filter(|&c| seen.insert(self.comp_of(n, c))).map(|c| net.clusters[c].key(&self.board).to_string()).collect();
            for key in open {
                self.nets[n].failed.push((key, "never routed: the router dropped this net (ripped up and not retried before the rip budget or the pass ended); route again with a higher --rip-global or more --passes".into()));
                added += 1;
            }
        }
        if added > 0 {
            self.log(format!("  {added} connection(s) of nets that were never routed, now reported as failed"));
        }
        added
    }

    pub fn reset(&mut self) {
        let n = self.nets.len();
        for net in 0..n {
            if !self.nets[net].routes.is_empty() {
                self.rip_keeping(net, false);
            }
            self.nets[net].failed.clear();
            self.nets[net].via_in_pad.clear();
            self.nets[net].routed_order = None;
            self.nets[net].used_escapes.clear();
            let done = self.nets[net].done_by_board;
            for c in self.nets[net].clusters.iter_mut() {
                c.connected = done;
            }
        }
        self.rip_total = 0;
    }

    pub fn snapshot(&self) -> Snapshot {
        Snapshot { nets: self.nets.iter().map(|n| (n.routes.clone(), n.used_escapes.clone(), n.failed.clone(), n.via_in_pad.clone(), n.routed_order)).collect() }
    }

    pub fn restore(&mut self, snap: &Snapshot) {
        self.reset();
        for (net, (routes, used, failed, vip, order)) in snap.nets.iter().enumerate() {
            self.nets[net].used_escapes = used.clone();
            self.nets[net].failed = failed.clone();
            self.nets[net].via_in_pad = vip.clone();
            self.nets[net].routed_order = *order;
            for r in routes {
                self.stamp_route(net, r, 1);
                if r.kelvin {
                    // the tap's own-net exclusion is rebuilt by the next kelvin routing; here it is copper of its net
                }
                self.nets[net].routes.push(r.clone());
            }
            let n = self.nets[net].clusters.len();
            for c in 0..n {
                let plane_thru = self.nets[net].plane.is_some() && self.on_plane(net, c);
                let touched = self.nets[net].routes.iter().any(|r| r.start.cluster() == Some(c) || (matches!(r.end, End::Pad(..) | End::Escape(..)) && r.end.cluster() == Some(c)));
                let done = self.nets[net].done_by_board || self.joined_by_board(net, c);
                self.nets[net].clusters[c].connected = touched || plane_thru || done;
            }
        }
    }

    // -- plan emission -----------------------------------------------------------------------
    /// A cluster's pad as a plan waypoint: its key, or its coordinates when the key names more than one
    /// pad on the board (issue #93: a connector's two `MP` pads; the bridge would take the first).
    fn pad_waypoint(&self, net: usize, cl: usize, layer: Option<&str>) -> Value {
        let p = &self.board.pads[self.nets[net].clusters[cl].primary];
        match (self.board.key_is_ambiguous(&p.key), layer) {
            (false, None) => json!(p.key),
            (false, Some(l)) => json!({"pad": p.key, "layer": l}),
            (true, None) => json!([p.x, p.y]),
            (true, Some(l)) => json!({"x": p.x, "y": p.y, "layer": l}),
        }
    }

    fn waypoints(&self, net: usize, route: &Route) -> Vec<Value> {
        let g = &self.grid;
        let cells = &route.cells;
        let xy = |c: &Term| { let p = g.to_xy(c.i as i64, c.j as i64); json!([p.0, p.1]) };
        let mut pts: Vec<Value> = Vec::new();
        match &route.start {
            End::Pad(cl, term) => pts.push(self.pad_waypoint(net, *cl, (term.layer() == B_CU).then_some("B.Cu"))),
            _ => {
                let first = &cells[0];
                let p = g.to_xy(first.i as i64, first.j as i64);
                pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[first.layer()]}));
            }
        }
        let n = cells.len();
        let mut k = 1;
        while k < n {
            let (l, i, j) = (cells[k].layer(), cells[k].i, cells[k].j);
            let (pl, pi, pj) = (cells[k - 1].layer(), cells[k - 1].i, cells[k - 1].j);
            if l != pl {
                let p = g.to_xy(i as i64, j as i64);
                pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[l]}));
                k += 1;
                continue;
            }
            let (di, dj) = (i - pi, j - pj);
            let mut m = k;
            while m + 1 < n && cells[m + 1].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) && !route.breaks.contains(&m) {
                m += 1;
            }
            if m == n - 1 {
                break;
            }
            pts.push(xy(&cells[m]));
            k = m + 1;
        }
        let last = cells[n - 1];
        if route.via_end {
            let mut p = g.to_xy(last.i as i64, last.j as i64);
            if n == 1 {
                if let End::Pad(cl, _) = &route.start {
                    let pad = &self.board.pads[self.nets[net].clusters[*cl].primary];
                    p = (pad.x, pad.y);
                }
            }
            pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[route.via_end_layer]}));
            if let End::Pad(cl, _) = &route.end {
                pts.push(self.pad_waypoint(net, *cl, None));
            }
        } else {
            match &route.end {
                End::Pad(cl, _) => pts.push(self.pad_waypoint(net, *cl, None)),
                _ => pts.push(xy(&last)),
            }
        }
        pts
    }

    /// Issue #183: a route that changes layer at through-hole pads of its net, cut into one route per
    /// face. Each cut ends and starts on the pad (the lead joins the faces), so no via is planned.
    pub fn split_at_pads(&self, net: usize, route: &Route) -> Vec<Route> {
        let cells = &route.cells;
        let cuts: Vec<usize> = (1..cells.len()).filter(|&k| cells[k].layer() != cells[k - 1].layer()).collect();
        if cuts.is_empty() {
            return vec![route.clone()];
        }
        let mut out = Vec::new();
        let mut start = route.start.clone();
        let mut from = 0usize;
        for &k in cuts.iter().chain(std::iter::once(&cells.len())) {
            let piece: Vec<Term> = cells[from..k].to_vec();
            let end = if k == cells.len() { route.end.clone() } else {
                let t = cells[k - 1];
                match self.pth_cluster_at(net, t.i, t.j) { Some(cl) => End::Pad(cl, t), None => End::Copper }
            };
            let next_start = if k < cells.len() {
                let t = cells[k];
                match self.pth_cluster_at(net, t.i, t.j) { Some(cl) => End::Pad(cl, t), None => End::Copper }
            } else { End::Copper };
            if piece.len() >= 2 {
                let mut r = Route::new(piece, route.width, start.clone(), end);
                r.kelvin = route.kelvin;
                r.pair = route.pair;
                r.breaks = route.breaks.iter().filter(|&&b| b >= from && b < k).map(|b| b - from).collect();
                if k == cells.len() { r.via_end = route.via_end; r.via_end_layer = route.via_end_layer; }
                out.push(r);
            }
            start = next_start;
            from = k;
        }
        out
    }

    pub fn plan(&self) -> Vec<Value> {
        let mut order: Vec<usize> = (0..self.nets.len()).collect();
        order.sort_by_key(|&n| (self.nets[n].routed_order.unwrap_or(1_000_000), self.nets[n].name.clone()));
        let mut entries = Vec::new();
        for n in order {
            let net = &self.nets[n];
            if net.routes.is_empty() {
                continue;
            }
            let mut by_width: Vec<(f64, Vec<Value>)> = Vec::new();
            for r in &net.routes {
                // issue #183: with vias off a route that changes layer at a through-hole pad is one path per face
                let split = if self.rules.vias_allowed() { Vec::new() } else { self.split_at_pads(n, r) };
                if split.len() > 1 {
                    let paths: Vec<Value> = split.iter().map(|sr| self.waypoints(n, sr)).filter(|p| p.len() >= 2).map(Value::Array).collect();
                    if r.kelvin || r.pair.is_some() {
                        let mut e = json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": paths});
                        if r.kelvin { e["kelvin"] = json!(true); } else { e["decoupling"] = json!(r.pair.and_then(|k| self.pairs.get(k)).map(|p| p.r.label.clone())); }
                        entries.push(e);
                    } else if let Some(e) = by_width.iter_mut().find(|(w, _)| (*w - r.width).abs() < 1e-9) {
                        e.1.extend(paths);
                    } else {
                        by_width.push((r.width, paths));
                    }
                    continue;
                }
                let pts = self.waypoints(n, r);
                if pts.len() < 2 {
                    continue;
                }
                if r.kelvin {
                    entries.push(json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": true}));
                    continue;
                }
                if let Some(k) = r.pair {
                    entries.push(json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "decoupling": self.pairs.get(k).map(|p| p.r.label.clone())}));
                    continue;
                }
                if let Some(e) = by_width.iter_mut().find(|(w, _)| (*w - r.width).abs() < 1e-9) {
                    e.1.push(Value::Array(pts));
                } else {
                    by_width.push((r.width, vec![Value::Array(pts)]));
                }
            }
            let mut stubs: Vec<Value> = Vec::new();
            let mut used: Vec<(usize, Term)> = net.used_escapes.iter().copied().collect();
            used.sort();
            for (cidx, term) in used {
                let key = net.clusters[cidx].key(&self.board);
                let p = self.grid.to_xy(term.i as i64, term.j as i64);
                // the stub leaves along the pad's long axis: a pad centre is off the grid, and a stub
                // that slopes toward the escape cell from the first micrometre grazes the neighbouring
                // pin (0.1989 mm against a 0.2 rule on a 0.5 mm pitch); the jog to the cell happens at
                // the cell end, inside the stub's own width
                let pad = &self.board.pads[net.clusters[cidx].primary];
                let (ax, ay) = pad.long_axis;
                let norm = (ax * ax + ay * ay).sqrt();
                // issue #139: a bottom pad's stub is on B.Cu; a stub with no layer lands on F.Cu
                let mut pts = vec![if term.layer() == B_CU { self.pad_waypoint(n, cidx, Some("B.Cu")) } else { json!(key) }];
                if norm > 1e-9 {
                    let (ux, uy) = (ax / norm, ay / norm);
                    let t = (p.0 - pad.x) * ux + (p.1 - pad.y) * uy;
                    let mid = (((pad.x + ux * t) * 1000.0).round() / 1000.0, ((pad.y + uy * t) * 1000.0).round() / 1000.0);
                    let jog = (p.0 - mid.0).hypot(p.1 - mid.1);
                    if jog > 1e-6 && jog < self.stub_for(n) && t.abs() > 1e-6 {
                        pts.push(json!([mid.0, mid.1]));
                    }
                }
                pts.push(json!([p.0, p.1]));
                stubs.push(Value::Array(pts));
            }
            if !stubs.is_empty() {
                entries.push(json!({"net": net.name, "width": self.stub_for(n), "viaSize": net.via, "viaDrill": net.drill, "paths": stubs}));
            }
            by_width.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
            for (w, paths) in by_width {
                entries.push(json!({"net": net.name, "width": w, "viaSize": net.via, "viaDrill": net.drill, "paths": paths}));
            }
        }
        entries
    }

    pub fn report(&self) -> Value {
        let mut nets = serde_json::Map::new();
        let mut unrouted = Vec::new();
        for net in &self.nets {
            if net.clusters.len() < 2 && net.plane.is_none() && net.outside.is_empty() {
                continue;
            }
            let mut widths: Vec<f64> = net.routes.iter().map(|r| r.width).collect();
            widths.sort_by(|a, b| b.partial_cmp(a).unwrap());
            widths.dedup();
            nets.insert(net.name.clone(), json!({
                "clusters": net.clusters.len(), "pads": net.pads.len(), "routes": net.routes.len(), "widths": widths, "plane": net.plane,
                "failed": net.failed.iter().chain(&net.outside).collect::<Vec<_>>(), "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order, "mstMm": (net.mst_len * 100.0).round() / 100.0,
                "doneByBoard": net.done_by_board,
            }));
            for (k, why) in net.failed.iter().chain(&net.outside) {
                unrouted.push(json!([net.name, [k, why]]));
            }
        }
        let mut rep = json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted, "boardCopper": self.board_copper_summary(), "decoupling": self.decoupling_report(),
            "planeUnder": self.keep.iter().map(|k| json!({"label": k.label, "net": k.net, "layer": aiflow_board::LAYER_NAMES[k.layer], "polygon": k.poly})).collect::<Vec<_>>()});
        if !self.rules.default_copper_mode() {
            rep["singleFace"] = self.single_face_summary();
        }
        rep
    }

    /// Issue #183: what single-face routing made of the board: connections closed on the primary face
    /// alone, connections that needed the jumper layer, layer changes made on through-hole pads, and
    /// vias (always 0: the mode plans none).
    pub fn single_face_summary(&self) -> Value {
        let primary = self.rules.primary_layer();
        let (mut closed, mut single, mut jumpered, mut changes) = (0usize, 0usize, 0usize, 0usize);
        for net in &self.nets {
            for r in &net.routes {
                closed += 1;
                if r.cells.iter().all(|t| t.layer() == primary) { single += 1; } else { jumpered += 1; }
                changes += r.cells.windows(2).filter(|w| w[0].layer() != w[1].layer()).count();
            }
        }
        json!({"layers": self.mode_layers(), "vias": false, "primary": aiflow_board::LAYER_NAMES[primary],
               "jumperLayer": self.rules.jumper_layer().map(|l| aiflow_board::LAYER_NAMES[l]),
               "connections": closed, "singleLayer": single, "withJumper": jumpered, "layerChangesAtPads": changes, "viasPlanned": self.vias.len()})
    }

    /// What the board's own copper already did: its size, the nets it completes, the nets it
    /// only partly joins (the plan finishes those), and how many connections the router still had to plan.
    pub fn board_copper_summary(&self) -> Value {
        let mut complete = Vec::new();
        let mut partial = Vec::new();
        let mut open = 0usize;
        for (n, net) in self.nets.iter().enumerate() {
            if net.clusters.len() < 2 && net.plane.is_none() {
                continue;
            }
            if net.done_by_board {
                complete.push(net.name.clone());
                continue;
            }
            let groups: HashSet<usize> = (0..net.clusters.len()).map(|c| self.comp_of(n, c)).collect();
            let joined = (0..net.clusters.len()).any(|c| self.joined_by_board(n, c));
            if joined {
                partial.push(net.name.clone());
            }
            open += if net.plane.is_some() { (0..net.clusters.len()).filter(|&c| !self.joined_by_board(n, c) && !self.on_plane(n, c)).count() } else { groups.len() - 1 };
        }
        // a zone of a net the plan still tracks, with no fill in the board file: once refilled it
        // may join those pads itself, so say so instead of tracking across it in silence (issue #84)
        let zones = self.board.zones();
        let islands = self.board.copper_fills().len();
        let mut warnings: Vec<String> = Vec::new();
        for net in &self.nets {
            if net.done_by_board || net.routes.is_empty() { continue; }
            let unfilled: Vec<&aiflow_board::Zone> = zones.iter().filter(|z| z.net == net.name && z.fills.is_empty()).collect();
            if unfilled.is_empty() { continue; }
            let mut layers: Vec<&str> = unfilled.iter().flat_map(|z| z.layers.iter().map(|&l| aiflow_board::LAYER_NAMES[l])).collect();
            layers.sort();
            layers.dedup();
            warnings.push(format!("{} has a zone on {} with no fill in the board file, and the plan tracks {} connection(s) of it: refill the zones (KiCad: Edit > Fill All Zones, save) and route again instead of tracking across the pour", net.name, layers.join(" and "), net.routes.len()));
        }
        json!({"trackPieces": self.fixed_tracks.len(), "vias": self.fixed_vias.len(), "zoneIslands": islands, "netsComplete": complete, "netsPartial": partial, "openConnections": open, "warnings": warnings})
    }
}

/// Straight runs of a cell path as exact segments, vias skipped.
pub fn route_segments(g: &aiflow_grid::Grid, cells: &[Term]) -> Vec<(f64, f64, f64, f64)> {
    let mut out = Vec::new();
    let n = cells.len();
    let mut k = 0;
    while k + 1 < n {
        let l = cells[k].layer();
        let mut m = k + 1;
        if cells[m].layer() != l {
            k = m;
            continue;
        }
        let (di, dj) = (cells[m].i - cells[k].i, cells[m].j - cells[k].j);
        while m + 1 < n && cells[m + 1].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) {
            m += 1;
        }
        let a = g.to_xy(cells[k].i as i64, cells[k].j as i64);
        let b = g.to_xy(cells[m].i as i64, cells[m].j as i64);
        out.push((a.0, a.1, b.0, b.1));
        k = m;
    }
    out
}

/// Route a board: passes with the failed nets first; keeps the best pass. Returns (plan entries, report, failed).
pub fn route_board(router: &mut Router, passes: usize, rip_per_net: usize, rip_global: usize) -> (Vec<Value>, Value, usize) {
    let mut best: Option<(usize, Snapshot, usize)> = None;
    let mut priority: Vec<String> = Vec::new();
    for k in 1..=passes {
        if k > 1 {
            router.reset();
        }
        // rounded corners first; a board still open after half its passes needs the room more than the corners
        router.flat_turns = k > passes.div_ceil(2);
        router.log(format!("=== pass {k} of {passes}{} ===", if router.flat_turns { ", turns at one cost" } else { "" }));
        let failed = router.run(true, rip_per_net, rip_global, &priority);
        let mut names: Vec<String> = router.nets.iter().filter(|n| !n.failed.is_empty()).map(|n| n.name.clone()).collect();
        names.sort();
        names.dedup();
        router.log(format!("=== pass {k}: {failed} failed connections in {names:?}"));
        if best.as_ref().map(|b| failed < b.0).unwrap_or(true) {
            best = Some((failed, router.snapshot(), k));
        }
        if failed == 0 {
            break;
        }
        priority = names;
    }
    let (failed, snap, k) = best.unwrap();
    router.restore(&snap);
    router.log(format!("keeping pass {k} ({failed} failed connections)"));
    // issue #140: pads off the board were never routed; each is an unrouted connection with its reason
    let outside: usize = router.nets.iter().map(|n| n.outside.len()).sum();
    if outside > 0 {
        let why: Vec<String> = router.nets.iter().flat_map(|n| n.outside.iter().map(|(_, w)| w.clone())).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
        router.log(format!("{outside} more connection(s) not routed, off the board: {}", why.join("; ")));
    }
    let failed = failed + outside;
    let plan = router.plan();
    let report = router.report();
    (plan, report, failed)
}

#[allow(dead_code)]
fn _unused(_: HashMap<u8, u8>) {}

#[cfg(test)]
mod path_via_tests {
    use super::*;

    #[test]
    fn existing_copper_start_preserves_its_layer_in_export() {
        let b = crate::board_copper::tests::board(&[("J1", "A", 2.0, 5.0, true)], "");
        let r = Router::new(b, crate::Rules::default(), false);
        let net = r.net_ids["A"];
        let (i, j) = r.grid.to_cell(10.0, 5.0);
        for layer in [F_CU, B_CU] {
            let cells = vec![Term::new(layer, i, j), Term::new(layer, i + 1, j)];
            let route = Route::new(cells, 0.25, End::Copper, End::Copper);
            let points = r.waypoints(net, &route);
            assert_eq!(points[0]["layer"], aiflow_board::LAYER_NAMES[layer]);
            let expected = r.grid.to_xy(i, j);
            assert_eq!(points[0]["x"], expected.0);
            assert_eq!(points[0]["y"], expected.1);
        }
    }
    const BOTTOM: &str = include_str!("../../../tests/fixtures/bottom-side.kicad_pcb");

    /// Every pad a plan path reaches, with the layer the path is on there (as plan_to_copper reads it):
    /// the pads with no copper on that layer.
    fn pads_off_their_layer(b: &aiflow_board::Board, plan: &[Value]) -> Vec<String> {
        let mut bad = Vec::new();
        for e in plan {
            for path in e["paths"].as_array().unwrap() {
                let pts = path.as_array().unwrap();
                let wp = |v: &Value| -> (Option<&aiflow_board::Pad>, Option<String>) {
                    let key = v.as_str().or_else(|| v.get("pad").and_then(|p| p.as_str()));
                    (key.and_then(|k| b.pad_by_key(k)), v.get("layer").and_then(|l| l.as_str()).map(str::to_string))
                };
                let mut layer = e["layer"].as_str().map(str::to_string).or_else(|| wp(&pts[0]).1)
                    .unwrap_or_else(|| aiflow_board::Board::path_start_layer(pts.iter().map(|v| { let (p, l) = wp(v); (p, l.is_some()) })).to_string());
                for v in pts {
                    let (pad, l) = wp(v);
                    if let Some(l) = l { layer = l; }
                    if let Some(p) = pad {
                        let on = p.thru || (layer == "F.Cu" && p.on_f) || (layer == "B.Cu" && p.on_b);
                        if !on { bad.push(format!("{} on {layer} ({} {})", p.key, e["net"], path)); }
                    }
                }
            }
        }
        bad
    }

    /// Issue #139: parts on the bottom (B.Cu-only SMD pads, one with narrow pads and escapes) route,
    /// and every path reaches each bottom pad on B.Cu (a via where it comes from F.Cu).
    #[test]
    fn bottom_side_pads_are_routed_and_reached_on_b_cu() {
        let b = aiflow_board::Board::from_text("bottom.kicad_pcb", BOTTOM.to_string()).unwrap();
        let u1 = b.pad_by_key("U1.1").unwrap();
        assert!(u1.on_b && !u1.on_f && !u1.thru);
        let mut r = Router::new(aiflow_board::Board::from_text("bottom.kicad_pcb", BOTTOM.to_string()).unwrap(), crate::Rules::default(), false);
        let (plan, report, failed) = route_board(&mut r, 3, 4, 40);
        assert_eq!(failed, 0, "{}", report["unrouted"]);
        assert!(!plan.is_empty());
        let bad = pads_off_their_layer(&b, &plan);
        assert!(bad.is_empty(), "paths reach pads on a layer they have no copper on: {bad:?}");
        // B (U1.2 to R2.1) is bottom to bottom: no via needed, so B.Cu all the way
        let text = serde_json::to_string(&plan).unwrap();
        assert!(text.contains("B.Cu"), "{text}");
    }

    /// Issue #167: a net left with open clusters, no routes and no failure (dropped from the queue by a
    /// best-state restore) is reported unrouted with its reason and counted, never left silent.
    #[test]
    fn a_dropped_net_is_reported_unrouted_and_counted() {
        let mut r = Router::new(aiflow_board::Board::from_text("bottom.kicad_pcb", BOTTOM.to_string()).unwrap(), crate::Rules::default(), false);
        let (_, _, failed) = route_board(&mut r, 2, 4, 40);
        assert_eq!(failed, 0);
        assert_eq!(r.flag_never_routed(), 0, "a fully routed board flags nothing");
        let net = r.nets.iter().position(|n| n.plane.is_none() && n.clusters.len() >= 2 && !n.done_by_board && !n.routes.is_empty()).unwrap();
        r.rip_keeping(net, false);
        r.nets[net].failed.clear();
        r.nets[net].routed_order = None;
        let report = r.report();
        assert_eq!(report["nets"][&r.nets[net].name]["routes"], 0);
        assert!(report["unrouted"].as_array().unwrap().is_empty(), "the silent state the issue saw");
        let added = r.flag_never_routed();
        assert_eq!(added, r.nets[net].clusters.len() - 1);
        let un = r.report()["unrouted"].as_array().unwrap().clone();
        assert_eq!(un.len(), added);
        assert!(un[0][1][1].as_str().unwrap().starts_with("never routed"), "{un:?}");
    }

    /// Issue #140: a part parked outside the outline (pads off the routing grid) no longer panics in
    /// obstacle_maps; its connections are reported unrouted with the part named, the rest route.
    #[test]
    fn a_part_off_the_board_is_reported_not_a_panic() {
        let text = BOTTOM.replace("(footprint \"T:R2\"\n\t\t(layer \"B.Cu\")\n\t\t(at 21 6)", "(footprint \"T:R2\"\n\t\t(layer \"B.Cu\")\n\t\t(at 61 46)");
        assert_ne!(text, BOTTOM);
        let b = aiflow_board::Board::from_text("off.kicad_pcb", text).unwrap();
        assert_eq!(b.parts_outside(), ["R2"]);
        let mut r = Router::new(b, crate::Rules::default(), false);
        let (_, report, failed) = route_board(&mut r, 2, 4, 40);
        let un = report["unrouted"].as_array().unwrap();
        let named: Vec<&Value> = un.iter().filter(|u| u[1][1].as_str().unwrap_or("").starts_with("R2 is outside the board outline")).collect();
        assert_eq!(named.len(), 2, "{un:?}");   // R2.1 on B, R2.2 on D
        assert!(failed >= 2);
        assert!(report["nets"]["D"]["failed"].as_array().unwrap().iter().any(|f| f[0] == "R2.2"), "{}", report["nets"]["D"]);
        // a window built from a point off the grid stays inside the grid, and maps from it are window-sized
        let w = r.window_for(&[(r.grid.w as i64 + 300, r.grid.h as i64 + 900)], 5.0);
        assert!(w.0 <= w.2 && w.1 <= w.3 && w.2 < r.grid.w as i64 && w.3 < r.grid.h as i64, "{w:?}");
        let (o, _, _, via) = r.obstacle_maps(0, 0.25, (r.grid.w as i64 + 10, -40, r.grid.w as i64 + 400, 3), false);
        assert_eq!(o[0].bits.len(), via.bits.len());
    }

    #[test]
    fn nearby_transitions_in_one_path_are_rejected() {
        let mut parent=vec![usize::MAX;200];
        parent[122]=22; parent[123]=122; parent[128]=123;
        assert!(!path_vias_clear(123,&parent,100,10,2.0*RES));
        assert!(path_vias_clear(128,&parent,100,10,2.0*RES));
        assert!(path_vias_clear(22,&parent,100,10,2.0*RES));
    }
}

/// Issue #183: single-face / no-via routing on an all through-hole strip where nets A and B must cross
/// (tests/fixtures/single-layer.kicad_pcb: J1 and J2 against the board's edges, A on J1.1, R1, J2.2 and
/// B on J1.2, J2.1, so one face alone cannot close both).
#[cfg(test)]
mod single_face_tests {
    use super::*;
    use serde_json::json;

    const STRIP: &str = include_str!("../../../tests/fixtures/single-layer.kicad_pcb");
    const BOTTOM: &str = include_str!("../../../tests/fixtures/bottom-side.kicad_pcb");

    fn strip() -> aiflow_board::Board {
        aiflow_board::Board::from_text("strip.kicad_pcb", STRIP.to_string()).unwrap()
    }

    /// Every waypoint after a path's first that names a layer: a layer change inside the path (a via).
    fn in_path_layer_changes(plan: &[Value]) -> Vec<String> {
        let mut out = Vec::new();
        for e in plan {
            for p in e["paths"].as_array().unwrap() {
                for w in p.as_array().unwrap().iter().skip(1) {
                    if w.get("layer").is_some() { out.push(format!("{} {}", e["net"], p)); }
                }
            }
        }
        out
    }

    /// The layer each path is on (paths are single-layer in this mode): its first waypoint's layer, else F.Cu.
    fn path_layers(plan: &[Value]) -> Vec<String> {
        plan.iter().flat_map(|e| e["paths"].as_array().unwrap().iter().map(|p| p[0].get("layer").and_then(|l| l.as_str()).unwrap_or("F.Cu").to_string())).collect()
    }

    #[test]
    fn single_layer_only_uses_one_face_and_reports_the_crossing() {
        let rules = crate::Rules::from_spec(&json!({"routeLayers": ["B.Cu"]}));
        assert!(!rules.default_copper_mode() && !rules.vias_allowed() && rules.jumper_layer().is_none());
        let mut r = Router::new(strip(), rules, false);
        let (plan, report, failed) = route_board(&mut r, 3, 4, 40);
        assert!(failed >= 1, "A and B must cross: one face cannot close both: {}", report["unrouted"]);
        assert!(r.vias.is_empty());
        assert!(r.nets.iter().all(|n| n.routes.iter().all(|rt| rt.cells.iter().all(|t| t.layer() == B_CU))), "copper only on B.Cu");
        assert!(path_layers(&plan).iter().all(|l| l == "B.Cu"), "{plan:?}");
        assert!(in_path_layer_changes(&plan).is_empty());
        let sf = &report["singleFace"];
        assert_eq!(sf["layers"], json!(["B.Cu"]));
        assert_eq!(sf["viasPlanned"], 0);
        assert_eq!(sf["withJumper"], 0);
        assert!(sf["singleLayer"].as_u64().unwrap() >= 1, "{sf}");
        // the open connection is A or B, with its reason (the other net in the way)
        assert!(report["unrouted"].as_array().unwrap().iter().all(|u| (u[0] == "A" || u[0] == "B") && !u[1][1].as_str().unwrap().is_empty()), "{}", report["unrouted"]);
    }

    #[test]
    fn jumpers_close_the_crossing_with_layer_changes_only_on_pth_pads() {
        let rules = crate::Rules::from_spec(&json!({"vias": false}));
        assert_eq!(rules.jumper_layer(), Some(F_CU));
        let mut r = Router::new(strip(), rules, false);
        let (plan, report, failed) = route_board(&mut r, 3, 4, 40);
        assert_eq!(failed, 0, "{}", report["unrouted"]);
        assert!(r.vias.is_empty(), "no via is ever stamped: {:?}", r.vias);
        // every layer change inside a route sits on a through-hole pad of its own net
        for (n, net) in r.nets.iter().enumerate() {
            for rt in &net.routes {
                for w in rt.cells.windows(2).filter(|w| w[0].layer() != w[1].layer()) {
                    let c = w[1].j as usize * r.grid.w + w[1].i as usize;
                    assert!(r.own_pth_cell(net.idx as i16, c) && r.pth_cluster_at(n, w[1].i, w[1].j).is_some(), "layer change off a pad in {}", net.name);
                }
            }
        }
        // the plan has no layer change inside a path: one path per face
        assert!(in_path_layer_changes(&plan).is_empty(), "{:?}", in_path_layer_changes(&plan));
        let layers = path_layers(&plan);
        assert!(layers.iter().any(|l| l == "B.Cu") && layers.iter().any(|l| l == "F.Cu"), "B.Cu milled, F.Cu jumper: {layers:?}");
        let sf = &report["singleFace"];
        assert_eq!(sf["jumperLayer"], "F.Cu");
        assert!(sf["withJumper"].as_u64().unwrap() >= 1 && sf["singleLayer"].as_u64().unwrap() >= 1, "{sf}");
        // the jumper is the exception: most connections stay on the milled face
        assert!(sf["withJumper"].as_u64().unwrap() <= sf["singleLayer"].as_u64().unwrap(), "{sf}");
    }

    #[test]
    fn with_vias_off_a_via_is_never_possible_except_on_an_own_pth_pad() {
        let mut r = Router::new(strip(), crate::Rules::from_spec(&json!({"vias": false})), false);
        let a = r.net_ids["A"];
        let win = (0, 0, r.grid.w as i64 - 1, r.grid.h as i64 - 1);
        let (_, _, _, via_ok) = r.obstacle_maps(a, 0.25, win, false);
        let nid = r.nets[a].idx as i16;
        let mut on_pads = 0;
        for (c, ok) in via_ok.bits.iter().enumerate() {
            if *ok { assert!(r.own_pth_cell(nid, c), "via allowed off A's own pads at cell {c}"); on_pads += 1; }
        }
        assert!(on_pads > 0, "A's own through-hole pads are where it may change face");
        // and B.Cu only: nowhere, and F.Cu is a wall
        let mut r = Router::new(strip(), crate::Rules::from_spec(&json!({"routeLayers": ["B.Cu"]})), false);
        let (o, _, _, via_ok) = r.obstacle_maps(a, 0.25, win, false);
        assert!(o[F_CU].bits.iter().all(|b| *b));
        let nid = r.nets[a].idx as i16;
        assert!(via_ok.bits.iter().enumerate().all(|(c, ok)| !*ok || r.own_pth_cell(nid, c)));
    }

    #[test]
    fn a_default_spec_routes_exactly_as_before() {
        let d = crate::Rules::default();
        assert!(d.default_copper_mode() && d.vias_allowed() && d.jumper_layer().is_none());
        let both = crate::Rules::from_spec(&json!({"routeLayers": ["F.Cu", "B.Cu"], "vias": true}));
        assert!(both.default_copper_mode());
        // the rules record is unchanged for a default spec (no new keys in plan-route-report.json)
        let v = serde_json::to_value(crate::Rules::from_spec(&json!({}))).unwrap();
        assert!(v.get("route_layers").is_none() && v.get("vias").is_none(), "{v}");
        assert!(serde_json::to_value(crate::Rules::from_spec(&json!({"vias": false}))).unwrap()["vias"] == json!(false));
        for text in [BOTTOM, STRIP] {
            let mut a = Router::new(aiflow_board::Board::from_text("a.kicad_pcb", text.to_string()).unwrap(), crate::Rules::default(), false);
            let mut b = Router::new(aiflow_board::Board::from_text("a.kicad_pcb", text.to_string()).unwrap(), both.clone(), false);
            let (pa, ra, fa) = route_board(&mut a, 3, 4, 40);
            let (pb, rb, fb) = route_board(&mut b, 3, 4, 40);
            assert_eq!(serde_json::to_string(&pa).unwrap(), serde_json::to_string(&pb).unwrap());
            assert_eq!(fa, fb);
            assert!(ra.get("singleFace").is_none() && rb.get("singleFace").is_none());
        }
    }
}