main
Colby Knox AI Flow 0.2.76: offline pours count in analyze (#202) 5026389
//! Issue #107: a pour passes the whole-net current screen only where it carries the current.
//!
//! The whole-net screen judges the narrowest track on a loaded net at the full load. A pour on the
//! net used to pass that screen by existing anywhere on the net, even as a small patch beside none of
//! the narrow copper. Now the pour counts only when (a) every narrow piece the screen judged (each
//! track too narrow for the full load) lies inside a same-net fill on its own layer, or (b) one pour
//! (fill islands joined by the net's own vias and through pads) reaches every terminal of the net, so
//! the narrow tracks run beside a pour that joins them. The terminals are every pad of the net, less
//! the pads the load declares at zero current (`loads.<net>.terminals`, as the branch model takes
//! them) and the pads that cannot pass load current by the spec's own declaration: a part whose other
//! pads are all on this net, on no net, or on nets the spec gives no load and that are not ground (a
//! test point, a feedback divider, a bootstrap or feed-forward capacitor). Anything else is not "the
//! pour carries the load"; a same-input Fields solve can still judge the net by its solved neck rise
//! (solved_ampacity). Issue #176: a load that declares terminals with non-zero amps names its terminals
//! (the heuristic is for the rest), a Kelvin sense pad (spec.kelvin) is never a terminal, and (c) a narrow
//! dead-end stub, copper that meets the load path at one place and otherwise reaches only pads that
//! draw nothing (a Kelvin tap, an LDO input declared at zero amps), is not judged at the net's load.
use aiflow_board::{geom, sx::Node, Board, Pt};

/// A point within this distance of one of the net's own pads on that layer counts as covered: the
/// thermal-relief gap a pour leaves around a pad it connects to.
pub const RELIEF_GAP_MM: f64 = 0.5;
const STEP_MM: f64 = 0.1;
const TOUCH_MM: f64 = 0.01;

pub struct Piece { pub layer: String, pub a: Pt, pub b: Pt, pub width: f64 }

struct Fill { layer: String, poly: Vec<Pt>, bbox: (f64, f64, f64, f64), outline: bool }

fn pts_of(n: &Node) -> Vec<Pt> {
    n.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default()
}

/// Every copper segment of `net` on any copper layer, arcs as short chords.
pub fn net_pieces(board: &Board, net: &str) -> Vec<Piece> {
    let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };
    let mut out = Vec::new();
    for node in board.root.nodes().filter(|n| n.tag == "segment" || n.tag == "arc") {
        if board.item_net(node) != net { continue; }
        let Some(layer) = node.find("layer").and_then(|l| l.value()).filter(|l| l.ends_with(".Cu")) else { continue };
        let width = node.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);
        let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) else { continue };
        let pts = match (node.tag.as_str(), p2(node.find("mid"))) { ("arc", Some(m)) => geom::arc_points(s, m, e, 0.1), _ => vec![s, e] };
        for w in pts.windows(2) { out.push(Piece { layer: layer.to_string(), a: w[0], b: w[1], width }); }
    }
    out
}

fn net_fills(board: &Board, net: &str) -> Vec<Fill> {
    let copper: Vec<String> = board.root.find("layers").map(|l| l.nodes().filter_map(|n| n.atom(1)).filter(|l| l.ends_with(".Cu")).map(str::to_string).collect()).unwrap_or_default();
    let mut out = Vec::new();
    for z in board.root.find_all("zone") {
        if z.find("keepout").is_some() || board.item_net(z) != net { continue; }
        let mut any = false;
        for f in z.find_all("filled_polygon") {
            let Some(layer) = f.find("layer").and_then(|l| l.value()) else { continue };
            let poly = pts_of(f);
            if poly.len() >= 3 { out.push(Fill { layer: layer.to_string(), bbox: geom::bbox(&poly), poly, outline: false }); any = true; }
        }
        if any { continue; }
        // a zone the saved file holds unfilled: its outline is the most it could cover
        let outline = z.find("polygon").map(pts_of).unwrap_or_default();
        if outline.len() < 3 { continue; }
        let mut names: Vec<String> = z.find("layer").and_then(|l| l.value()).map(|l| vec![l.to_string()]).unwrap_or_default();
        if let Some(ls) = z.find("layers") { names.extend((1..).map_while(|i| ls.atom(i)).map(str::to_string)); }
        let mut layers: Vec<String> = Vec::new();
        for n in names {
            let expand: Vec<String> = match n.as_str() { "*.Cu" => copper.clone(), "F&B.Cu" => vec!["F.Cu".into(), "B.Cu".into()], _ => vec![n] };
            for l in expand { if !layers.contains(&l) { layers.push(l); } }
        }
        for layer in layers { out.push(Fill { layer, bbox: geom::bbox(&outline), poly: outline.clone(), outline: true }); }
    }
    out
}

/// Issue #202 (Noah): offline runs (pours written with pcbnew in a container and adopted with `verify-pours
/// --from-board`, no live editor): the zone state `measure` reads from the editor, built from the saved board's
/// own filled polygons instead, so `analyze current|thermal` see the pours the board really carries.
/// Only zones with saved fills count (an unfilled zone gives nothing); keepouts are skipped.
pub fn zone_state_from_board(board: &Board) -> serde_json::Value {
    let mut zones = Vec::new();
    for z in board.root.find_all("zone") {
        if z.find("keepout").is_some() { continue; }
        let net = board.item_net(z);
        if net.is_empty() { continue; }
        let mut fills = Vec::new();
        let mut layers: Vec<String> = Vec::new();
        for f in z.find_all("filled_polygon") {
            let Some(layer) = f.find("layer").and_then(|l| l.value()) else { continue };
            let poly = pts_of(f);
            if poly.len() < 3 { continue; }
            let mut a = 0.0;
            for i in 0..poly.len() { let (p, q) = (poly[i], poly[(i + 1) % poly.len()]); a += p.0 * q.1 - q.0 * p.1; }
            fills.push(serde_json::json!({"layer": layer, "areaMm2": ((a / 2.0).abs() * 10.0).round() / 10.0}));
            if !layers.iter().any(|l| l == layer) { layers.push(layer.to_string()); }
        }
        if fills.is_empty() { continue; }
        let name = z.find("name").and_then(|n| n.value()).unwrap_or("").to_string();
        zones.push(serde_json::json!({"type": "copper", "net": net, "name": name, "layers": layers, "fills": fills}));
    }
    serde_json::json!({"source": "saved-board", "zones": zones})
}

fn in_box(p: Pt, b: (f64, f64, f64, f64), m: f64) -> bool { p.0 >= b.0 - m && p.0 <= b.2 + m && p.1 >= b.1 - m && p.1 <= b.3 + m }

fn pad_on(p: &aiflow_board::Pad, layer: &str) -> bool {
    p.thru || (layer == "F.Cu" && p.on_f) || (layer == "B.Cu" && p.on_b)
}

fn rd(v: f64) -> f64 { (v * 100.0).round() / 100.0 }

/// A ground or return net by name: a part bridging to it can carry the load even when the spec
/// declares no load on it.
pub fn ground_like(net: &str) -> bool {
    let n = net.trim_start_matches('/').to_ascii_uppercase();
    n.contains("GND") || n.starts_with("VSS") || n.starts_with("VEE") || n == "0" || n.contains("EARTH") || n.contains("CHASSIS") || n.contains("RTN") || n.contains("RETURN")
}

/// Issue #176: the indices (into `all`) of the narrow pieces on dead-end stubs. The narrow pieces not
/// wholly inside a pour (`open[i]`) join where their ends meet on one layer, or at one via or through
/// pad. A connected run of them is a stub when it meets the load path at one place only (within
/// `PLACE_MM`) and reaches a pad that draws nothing. It meets the load path where an end is on a load
/// terminal pad, on a piece wide enough for the load, or on a via, and where it runs into a pour
/// (`in_fill`: the first point of each run inside a fill). Pads that are not
/// terminals (zero amps, side parts) and `sense` pads (a Kelvin tap) draw nothing.
#[allow(clippy::too_many_arguments)]
fn dead_end_stubs(all: &[Piece], open: &[bool], wide_enough: &[bool], in_fill: impl Fn(Pt, &str) -> bool, terminals: &[&&aiflow_board::Pad], vias: &[aiflow_board::Via], pads: &[&aiflow_board::Pad], sense: impl Fn(&aiflow_board::Pad) -> bool) -> Vec<usize> {
    const PLACE_MM: f64 = 1.5;
    let n = all.len();
    let mut comp: Vec<usize> = (0..n).collect();
    fn find(g: &mut [usize], mut i: usize) -> usize { while g[i] != i { g[i] = g[g[i]]; i = g[i]; } i }
    let near = |a: Pt, b: Pt| (a.0 - b.0).hypot(a.1 - b.1) <= TOUCH_MM;
    let in_pad = |p: Pt, q: &aiflow_board::Pad| q.polys.iter().any(|poly| geom::point_poly_distance(p.0, p.1, poly) <= TOUCH_MM);
    let joint = |a: Pt, b: Pt| vias.iter().any(|v| (a.0 - v.x).hypot(a.1 - v.y) <= v.size / 2. && (b.0 - v.x).hypot(b.1 - v.y) <= v.size / 2.)
        || pads.iter().any(|q| q.thru && in_pad(a, q) && in_pad(b, q));
    for i in (0..n).filter(|&i| open[i]) {
        for j in (i + 1..n).filter(|&j| open[j]) {
            let (p, q) = (&all[i], &all[j]);
            let meet = [p.a, p.b].iter().any(|&x| [q.a, q.b].iter().any(|&y| (p.layer == q.layer && near(x, y)) || joint(x, y)));
            if meet { let (a, b) = (find(&mut comp, i), find(&mut comp, j)); comp[a.max(b)] = a.min(b); }
        }
    }
    let mut places: std::collections::BTreeMap<usize, Vec<Pt>> = std::collections::BTreeMap::new();
    let mut draws_nothing: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
    for i in (0..n).filter(|&i| open[i]) {
        let c = find(&mut comp, i);
        let piece = &all[i];
        let len = (piece.b.0 - piece.a.0).hypot(piece.b.1 - piece.a.1);
        let steps = (len / STEP_MM).ceil().max(1.0) as usize;
        let at = places.entry(c).or_default();
        let mut inside = false;
        for s in 0..=steps {
            let t = s as f64 / steps as f64;
            let p = (piece.a.0 + t * (piece.b.0 - piece.a.0), piece.a.1 + t * (piece.b.1 - piece.a.1));
            let now = in_fill(p, &piece.layer);
            if now && !inside { at.push(p); }
            inside = now;
        }
        for p in [piece.a, piece.b] {
            let on_load = terminals.iter().any(|q| pad_on(q, &piece.layer) && in_pad(p, q))
                || all.iter().enumerate().any(|(w, o)| wide_enough[w] && o.layer == piece.layer && geom::seg_distance(p.0, p.1, o.a.0, o.a.1, o.b.0, o.b.1) <= o.width / 2. + TOUCH_MM)
                || vias.iter().any(|v| (p.0 - v.x).hypot(p.1 - v.y) <= v.size / 2.);
            if on_load { at.push(p); }
            let idle = |q: &&aiflow_board::Pad| pad_on(q, &piece.layer) && in_pad(p, q) && (sense(q) || !terminals.iter().any(|t| std::ptr::eq(**t, *q)));
            if pads.iter().any(idle) { draws_nothing.insert(c); }
        }
    }
    let one_place = |pts: &Vec<Pt>| pts.iter().all(|p| (p.0 - pts[0].0).hypot(p.1 - pts[0].1) <= PLACE_MM);
    (0..n).filter(|&i| open[i] && { let c = find(&mut comp, i); places.get(&c).is_some_and(one_place) && draws_nothing.contains(&c) }).collect()
}

/// Whether the net's pour carries its load. Ok(how) when it does; Err(why not and what to do).
pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_rise: f64, t_um: f64) -> Result<String, String> {
    let load = &spec["loads"][net];
    let fills = net_fills(board, net);
    let vias: Vec<_> = board.vias().into_iter().filter(|v| v.net == net).collect();
    if fills.is_empty() {
        return Err(format!("the saved board has no {net} zone to check the pour against; save the board after `measure` and analyze again"));
    }
    let outline_note = if fills.iter().any(|f| f.outline) { " (an unfilled zone counted by its outline)" } else { "" };
    let pads: Vec<_> = board.pads.iter().filter(|p| p.net == net).collect();
    let all = net_pieces(board, net);
    let wide = all.iter().any(|p| p.width > 0.2);
    let judged: Vec<&Piece> = all.iter().filter(|p| (!wide || p.width > 0.2) && crate::rise_for(p.width, t_um, amps, true) > max_rise).collect();
    let covered = |p: Pt, layer: &str| -> bool {
        fills.iter().any(|f| f.layer == layer && in_box(p, f.bbox, TOUCH_MM) && geom::point_poly_distance(p.0, p.1, &f.poly) <= TOUCH_MM)
            || pads.iter().any(|q| pad_on(q, layer) && q.polys.iter().any(|poly| geom::point_poly_distance(p.0, p.1, poly) <= RELIEF_GAP_MM))
            || vias.iter().any(|v| (p.0 - v.x).hypot(p.1 - v.y) <= v.size / 2. + RELIEF_GAP_MM)
    };
    let first_gap = judged.iter().find_map(|piece| {
        let len = (piece.b.0 - piece.a.0).hypot(piece.b.1 - piece.a.1);
        let n = (len / STEP_MM).ceil().max(1.0) as usize;
        (0..=n).map(|i| { let t = i as f64 / n as f64; (piece.a.0 + t * (piece.b.0 - piece.a.0), piece.a.1 + t * (piece.b.1 - piece.a.1)) })
            .find(|p| !covered(*p, &piece.layer)).map(|p| (piece, p))
    });
    if first_gap.is_none() && !all.is_empty() {
        return Ok(if judged.is_empty() {
            format!("no {net} track on the saved board is too narrow for {amps:.1} A; the pour joins the rest{outline_note}")
        } else {
            format!("the {net} pour covers every piece too narrow for {amps:.1} A on its own layer ({} pieces){outline_note}", judged.len())
        });
    }
    // (b) one pour (islands joined by the net's vias and through pads) that reaches every terminal
    let touches = |f: &Fill, q: &aiflow_board::Pad| pad_on(q, &f.layer) && q.polys.iter().any(|poly| {
        (0..poly.len()).any(|k| geom::seg_poly_distance(poly[k], poly[(k + 1) % poly.len()], &f.poly) <= TOUCH_MM)
    });
    let mut group: Vec<usize> = (0..fills.len()).collect();
    fn find(g: &mut [usize], mut i: usize) -> usize { while g[i] != i { g[i] = g[g[i]]; i = g[i]; } i }
    let joins: Vec<(Pt, f64)> = vias.iter().map(|v| ((v.x, v.y), v.size / 2.))
        .chain(pads.iter().filter(|q| q.thru).map(|q| ((q.x, q.y), q.half_short))).collect();
    for (at, r) in &joins {
        let hit: Vec<usize> = fills.iter().enumerate().filter(|(_, f)| in_box(*at, f.bbox, r + TOUCH_MM) && geom::point_poly_distance(at.0, at.1, &f.poly) <= r + TOUCH_MM).map(|(i, _)| i).collect();
        for w in hit.windows(2) { let (a, b) = (find(&mut group, w[0]), find(&mut group, w[1])); group[a.max(b)] = a.min(b); }
    }
    let zero = |q: &aiflow_board::Pad| load["terminals"].as_array().into_iter().flatten().any(|t| {
        t["reference"].as_str() == Some(q.reference.as_str()) && t["pad"].as_str() == Some(q.name.as_str()) && t["amps"].as_f64() == Some(0.0)
            && (t["at"].is_null() || (t["at"][0].as_f64() == Some(q.x) && t["at"][1].as_f64() == Some(q.y)))
    });
    let loaded = |n: &str| spec["loads"][n]["amps"].as_f64().is_some_and(|a| a > 0.0);
    // issue #190: a part with no pads on other nets (a machine contact, a machine pin) is never a side part;
    // `.all()` of nothing is true, which dropped every single-pad contact from the terminals
    let side_part = |q: &aiflow_board::Pad| {
        let mut others = board.pads.iter().filter(|o| o.reference == q.reference && o.net != net).peekable();
        others.peek().is_some() && others.all(|o| o.net.is_empty() || (!loaded(&o.net) && !ground_like(&o.net)))
    };
    // issue #176: a load that declares its terminals with current names them; the heuristic is for the rest
    // a terminal that names only the reference (a single-pad contact) matches that part's pads
    let declared = |q: &aiflow_board::Pad| load["terminals"].as_array().into_iter().flatten().any(|t| {
        t["reference"].as_str() == Some(q.reference.as_str()) && (t["pad"].is_null() || t["pad"].as_str() == Some(q.name.as_str())) && t["amps"].as_f64().is_some_and(|a| a != 0.0)
            && (t["at"].is_null() || (t["at"][0].as_f64() == Some(q.x) && t["at"][1].as_f64() == Some(q.y)))
    });
    let explicit = pads.iter().any(|q| declared(q));
    // a Kelvin sense pad (spec.kelvin "REF.PAD": shunt pad) carries no load by definition
    let kelvin: Vec<&str> = spec["kelvin"].as_object().map(|o| o.keys().map(String::as_str).collect()).unwrap_or_default();
    let sense = |q: &aiflow_board::Pad| kelvin.contains(&format!("{}.{}", q.reference, q.name).as_str());
    let terminals: Vec<_> = pads.iter().filter(|q| !sense(q) && if explicit { declared(q) } else { !zero(q) && !side_part(q) }).collect();
    let roots: std::collections::BTreeSet<usize> = (0..fills.len()).map(|i| find(&mut group, i)).collect();
    let mut best: Option<(String, Vec<String>)> = None;
    for root in roots {
        let members: Vec<&Fill> = (0..fills.len()).filter(|i| find(&mut group, *i) == root).map(|i| &fills[i]).collect();
        let missed: Vec<String> = terminals.iter().filter(|q| !members.iter().any(|f| touches(f, q))).map(|q| format!("{}.{}", q.reference, q.name)).collect();
        let mut layers: Vec<&str> = members.iter().map(|f| f.layer.as_str()).collect();
        layers.sort();
        layers.dedup();
        if terminals.len() >= 2 && missed.is_empty() {
            return Ok(format!("one {} pour reaches all {} {net} terminals, so the narrow tracks run beside it{outline_note}", layers.join("+"), terminals.len()));
        }
        if best.as_ref().is_none_or(|b| missed.len() < b.1.len()) { best = Some((layers.join("+"), missed)); }
    }
    // issue #176: (c) a narrow piece on a dead-end stub, copper that meets the load path at one place only and
    // otherwise reaches only pads that carry no load (declared zero amps, a Kelvin sense pad, a side part),
    // carries no current: the pour carries the load when it covers every other narrow piece
    let wide_enough: Vec<bool> = all.iter().map(|p| crate::rise_for(p.width, t_um, amps, true) <= max_rise).collect();
    let fully = |p: &Piece| { let len = (p.b.0 - p.a.0).hypot(p.b.1 - p.a.1); let n = (len / STEP_MM).ceil().max(1.0) as usize; (0..=n).all(|i| { let t = i as f64 / n as f64; covered((p.a.0 + t * (p.b.0 - p.a.0), p.a.1 + t * (p.b.1 - p.a.1)), &p.layer) }) };
    let open: Vec<bool> = all.iter().enumerate().map(|(i, p)| !wide_enough[i] && !fully(p)).collect();
    let in_fill = |p: Pt, layer: &str| fills.iter().any(|f| f.layer == layer && in_box(p, f.bbox, TOUCH_MM) && geom::point_poly_distance(p.0, p.1, &f.poly) <= TOUCH_MM);
    let stubs = dead_end_stubs(&all, &open, &wide_enough, in_fill, &terminals, &vias, &pads, sense);
    // with fewer than two load terminals there is no telling where the load runs: no stub is exempt
    if terminals.len() >= 2 && !stubs.is_empty() {
        let stub_set: std::collections::HashSet<*const Piece> = stubs.iter().map(|&k| &all[k] as *const Piece).collect();
        let rest_gap = judged.iter().filter(|p| !stub_set.contains(&(**p as *const Piece))).find_map(|piece| {
            let len = (piece.b.0 - piece.a.0).hypot(piece.b.1 - piece.a.1);
            let n = (len / STEP_MM).ceil().max(1.0) as usize;
            (0..=n).map(|i| { let t = i as f64 / n as f64; (piece.a.0 + t * (piece.b.0 - piece.a.0), piece.a.1 + t * (piece.b.1 - piece.a.1)) }).find(|p| !covered(*p, &piece.layer))
        });
        if rest_gap.is_none() {
            let n_judged = judged.iter().filter(|p| stub_set.contains(&(**p as *const Piece))).count();
            return Ok(format!("the {net} pour covers every piece too narrow for {amps:.1} A that carries load; {n_judged} narrow piece(s) on dead-end stubs to pads that carry no load (zero-amp terminals, Kelvin sense pads) are not judged at the net's load{outline_note}"));
        }
    }
    let unreached = if terminals.len() < 2 {
        format!("fewer than two load terminals on {net} ({}): declare where the load enters and leaves (`loads.{net}.terminals` with non-zero amps)", if terminals.is_empty() { "none".to_string() } else { terminals.iter().map(|q| format!("{}.{}", q.reference, q.name)).collect::<Vec<_>>().join(", ") })
    } else {
        best.map(|(l, m)| format!("no one pour reaches every terminal (the best, on {l}, misses {}{})", m.iter().take(4).cloned().collect::<Vec<_>>().join(", "), if m.len() > 4 { ", ..." } else { "" })).unwrap_or_default()
    };
    let gap = match first_gap {
        Some((piece, gap)) => format!("it leaves the {:.2} mm piece on {} from ({:.2}, {:.2}) to ({:.2}, {:.2}) uncovered at ({:.2}, {:.2}) ({:.0} C at {amps:.1} A, limit {max_rise:.0} C)",
            piece.width, piece.layer, rd(piece.a.0), rd(piece.a.1), rd(piece.b.0), rd(piece.b.1), rd(gap.0), rd(gap.1), crate::rise_for(piece.width, t_um, amps, true)),
        // reached only with no tracks at all (issue #149): the pour is the whole conductor
        None => format!("{net} is carried only by its pour (no tracks on the saved board)"),
    };
    Err(format!("the {net} pour does not carry the load: {gap}, and {unreached}. Extend the pour over the narrow copper, widen it, declare the pads that carry no load (`loads.{net}.terminals`, zero amps), or solve it with Adom Fields (analyze current then judges the net by the solved rise at its narrowest neck)"))
}

#[cfg(test)]
mod tests {
    use super::*;
    // a buck-style net: two pads joined by a 0.3 mm track, 1.5 A, poured on F.Cu; J1 and U1 also
    // have a ground pin, so either can be where the load enters or leaves
    fn board(zone: &str) -> Board {
        let text = format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "V") (net 2 "GND")
  (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))
  (footprint "A" (layer "F.Cu") (at 5 10) (property "Reference" "J1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))
  (footprint "A" (layer "F.Cu") (at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))
  (segment (start 5 10) (end 25 10) (width 0.3) (layer "F.Cu") (net 1))
  {zone})"#);
        Board::from_text("t.kicad_pcb", text).unwrap()
    }
    fn zone(layer: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> String {
        format!(r#"(zone (net 1) (net_name "V") (layer "{layer}") (polygon (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1}))) (filled_polygon (layer "{layer}") (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1}))))"#)
    }
    #[test]
    fn a_pour_over_the_narrow_track_carries_it() {
        let b = board(&zone("F.Cu", 6.0, 8.0, 24.0, 12.0));
        let how = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();
        assert!(how.contains("covers every piece"), "{how}");
    }
    #[test]
    fn a_token_patch_does_not_carry_the_load() {
        let b = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0));
        let e = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap_err();
        assert!(e.contains("does not carry the load") && e.contains("0.30 mm piece"), "{e}");
        // the same patch on the other layer covers nothing on F.Cu either
        let b = board(&zone("B.Cu", 4.0, 8.0, 26.0, 12.0));
        assert!(check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).is_err());
    }
    #[test]
    fn a_pour_joining_every_pad_carries_the_load_beside_a_narrow_track() {
        // the track runs outside the pour, but the pour reaches both pads
        let text = board("").text.replace("(segment (start 5 10) (end 25 10)", "(segment (start 5 10) (end 5 15) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 5 15) (end 25 15) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 25 15) (end 25 10)");
        let mut t = text; t.pop(); t.push_str(&zone("F.Cu", 4.6, 9.0, 25.4, 11.0)); t.push(')');
        let b = Board::from_text("t.kicad_pcb", t).unwrap();
        let how = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();
        assert!(how.contains("reaches all 2"), "{how}");
    }
    #[test]
    fn wide_enough_tracks_need_no_cover() {
        let b = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0));
        assert!(check(&b, "V", &serde_json::Value::Null, 0.3, 10.0, 35.0).unwrap().contains("too narrow"));
    }
    #[test]
    fn a_sense_tap_declared_at_zero_current_does_not_need_the_pour() {
        // pour joins J1 and U1; a 0.3 mm tap runs off to R1 (a feedback divider) outside the pour
        let mut t = board(&zone("F.Cu", 4.6, 9.0, 25.4, 11.0)).text;
        // R1 bridges V to GND (it could be the load), so it is a terminal until the spec says otherwise
        t = t.replacen("(segment", r#"(footprint "A" (layer "F.Cu") (at 15 18) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 0.6 0.6) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND"))) (segment (start 15 10) (end 15 18) (width 0.3) (layer "F.Cu") (net 1)) (segment"#, 1)
            .replacen(r#"(net 2 "GND")"#, r#"(net 2 "GND") (net 3 "FB")"#, 1);
        let b = Board::from_text("t.kicad_pcb", t.clone()).unwrap();
        let e = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap_err();
        assert!(e.contains("misses R1.1") && e.contains("terminals"), "{e}");
        let spec = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}, {"reference": "U1", "pad": "1", "amps": -1.5}, {"reference": "R1", "pad": "1", "amps": 0}]}}});
        assert!(check(&b, "V", &spec, 1.5, 10.0, 35.0).unwrap().contains("reaches all 2"));
        // a divider to FB, a net the spec gives no load, cannot pass the load: no declaration needed
        let b = Board::from_text("t.kicad_pcb", t.replace(r#"(at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND")"#, r#"(at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 3 "FB")"#)).unwrap();
        assert!(check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}}}), 1.5, 10.0, 35.0).unwrap().contains("reaches all 2"));
        // unless the spec loads FB
        assert!(check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}, "FB": {"amps": 1.0}}}), 1.5, 10.0, 35.0).is_err());
    }
    #[test]
    fn a_single_pad_contact_is_a_terminal_not_a_side_part() {
        // issue #190: U1 becomes a one-pad machine contact; the pour joins it to J1 beside the narrow track
        let mut t = board("").text.replace(r#"(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))
  (segment"#, r#"(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))
  (segment"#);
        t = t.replace("(segment (start 5 10) (end 25 10)", "(segment (start 5 10) (end 5 15) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 5 15) (end 25 15) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 25 15) (end 25 10)");
        t.pop(); t.push_str(&zone("F.Cu", 4.6, 9.0, 25.4, 11.0)); t.push(')');
        let b = Board::from_text("t.kicad_pcb", t).unwrap();
        assert_eq!(b.pads.iter().filter(|p| p.reference == "U1").count(), 1);
        let how = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();
        assert!(how.contains("reaches all 2"), "{how}");
        // declared by reference only (no pad name): still the contact's terminal
        let spec = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}, {"reference": "U1", "amps": -1.5}]}}});
        assert!(check(&b, "V", &spec, 1.5, 10.0, 35.0).unwrap().contains("reaches all 2"));
    }
    /// Issue #176: J1 to U1 on a 2 mm track (wide enough), a token V patch, and a 0.25 mm sense trace from
    /// U1.1 to S1.1 (S1's other pad on GND, so the heuristic takes S1.1 for a load terminal).
    fn sense_board() -> Board {
        let mut t = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0)).text
            .replace("(segment (start 5 10) (end 25 10) (width 0.3)", "(segment (start 5 10) (end 25 10) (width 2.0)");
        t = t.replacen("(segment", r#"(footprint "A" (layer "F.Cu") (at 25 18) (property "Reference" "S1") (pad "1" smd rect (at 0 0) (size 0.6 0.6) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND"))) (segment (start 25 10) (end 25 18) (width 0.25) (layer "F.Cu") (net 1)) (segment"#, 1);
        Board::from_text("t.kicad_pcb", t).unwrap()
    }
    #[test]
    fn a_dead_end_sense_trace_to_zero_amp_pads_is_not_judged_at_the_load() {
        let b = sense_board();
        // undeclared, S1.1 may carry the load: the narrow trace is judged and fails
        let e = check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}}}), 1.5, 10.0, 35.0).unwrap_err();
        assert!(e.contains("0.25 mm piece"), "{e}");
        // declared at zero amps (explicit terminals): a dead end to a pad that draws nothing
        let spec = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}, {"reference": "U1", "pad": "1", "amps": -1.5}, {"reference": "S1", "pad": "1", "amps": 0}]}}});
        let how = check(&b, "V", &spec, 1.5, 10.0, 35.0).unwrap();
        assert!(how.contains("dead-end stubs") && how.contains("1 narrow piece"), "{how}");
        // a Kelvin sense pad (spec.kelvin) draws nothing either, with no zero-amp declaration
        let kelvin = serde_json::json!({"kelvin": {"S1.1": "U1.1"}, "loads": {"V": {"amps": 1.5}}});
        assert!(check(&b, "V", &kelvin, 1.5, 10.0, 35.0).unwrap().contains("dead-end stubs"));
        // a narrow trace that joins two load terminals is not a stub: it is judged
        let t = b.text.replace("(segment (start 5 10) (end 25 10) (width 2.0)", "(segment (start 5 10) (end 25 10) (width 0.25)");
        let e = check(&Board::from_text("t.kicad_pcb", t).unwrap(), "V", &spec, 1.5, 10.0, 35.0).unwrap_err();
        assert!(e.contains("does not carry the load"), "{e}");
    }
    #[test]
    fn explicit_terminals_name_the_load_and_too_few_are_said_plainly() {
        let one = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}]}}});
        let b = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0));
        let e = check(&b, "V", &one, 1.5, 10.0, 35.0).unwrap_err();
        assert!(e.contains("fewer than two load terminals on V (J1.1)") && !e.contains("misses )"), "{e}");
    }
    #[test]
    fn islands_on_both_layers_joined_by_a_via_are_one_pour() {
        // J1 poured on F.Cu, U1 on B.Cu (a through pad), the islands joined by a via; the track runs outside both
        let text = board("").text
            .replace(r#"(at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")"#, r#"(at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "B.Cu")"#)
            .replace("(segment (start 5 10) (end 25 10) (width 0.3) (layer \"F.Cu\") (net 1))", "(segment (start 5 10) (end 5 16) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 5 16) (end 25 16) (width 0.3) (layer \"F.Cu\") (net 1)) (via (at 25 16) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)) (segment (start 25 16) (end 25 10) (width 0.3) (layer \"B.Cu\") (net 1))");
        let mut t = text; t.pop();
        t.push_str(&zone("F.Cu", 4.0, 9.0, 16.0, 11.0));
        t.push_str(&zone("B.Cu", 15.0, 9.0, 26.0, 11.0));
        let lone = format!("{t})");
        assert!(check(&Board::from_text("t.kicad_pcb", lone).unwrap(), "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).is_err());
        t.push_str("(via (at 15.5 10) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)))");
        let how = check(&Board::from_text("t.kicad_pcb", t).unwrap(), "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();
        assert!(how.contains("B.Cu+F.Cu pour reaches all 2"), "{how}");
    }
    /// Issue #202: an offline run's pours come from the saved board's fills; an unfilled zone and a keepout give nothing.
    #[test]
    fn the_zone_state_of_an_offline_run_comes_from_the_saved_fills() {
        let b = Board::from_text("t.kicad_pcb", r#"(kicad_pcb (version 20240108) (generator "t")
  (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))
  (net 0 "") (net 1 "+5V") (net 2 "GND")
  (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (stroke (width 0.1) (type default)))
  (zone (net 1) (net_name "+5V") (layer "F.Cu") (name "P5") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))
    (filled_polygon (layer "F.Cu") (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10))))
  (zone (net 2) (net_name "GND") (layer "B.Cu") (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20))))
  (zone (net 2) (net_name "GND") (layer "F.Cu") (keepout (tracks not_allowed)) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1)))
    (filled_polygon (layer "F.Cu") (pts (xy 0 0) (xy 1 0) (xy 1 1))))
)"#.to_string()).unwrap();
        let z = zone_state_from_board(&b);
        let zones = z["zones"].as_array().unwrap();
        assert_eq!(zones.len(), 1, "the unfilled GND zone and the keepout give nothing: {z}");
        assert_eq!(zones[0]["net"], "+5V");
        assert_eq!(zones[0]["fills"][0]["areaMm2"], 100.0);
        assert_eq!(zones[0]["layers"], serde_json::json!(["F.Cu"]));
    }

}