main
Colby Knox AI Flow 0.2.62: fixes #190 #191 #188 #189 #186 #187 and the #177 remote-model remainder 01b74f9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
//! Pours from the spec, not from anyone's memory of one board: zones (with priorities and
//! connection styles), rule-area keepouts along Kelvin taps, thermal and stitching vias; and the
//! zones as `.kicad_pcb` blocks for the offline copy.
use aiflow_board::{geom, num, quote, Board, NetFormat, Pt};
use serde_json::{json, Value};

pub type Box4 = (f64, f64, f64, f64);

fn pad_box(board: &Board, keys: &[String]) -> Option<Box4> {
    let mut pts = Vec::new();
    for k in keys {
        if let Some((r, n)) = k.split_once('.') {
            for p in board.pads.iter().filter(|p| p.reference == r && p.name == n) {
                pts.extend(p.polys.iter().flatten().copied());
            }
        } else {
            for p in board.pads.iter().filter(|p| p.reference == *k) {
                pts.extend(p.polys.iter().flatten().copied());
            }
        }
    }
    if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }
}

fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Value {
    let r = |v: f64| (v * 100.0).round() / 100.0;
    json!([[r(x0), r(y0)], [r(x1), r(y0)], [r(x1), r(y1)], [r(x0), r(y1)]])
}

fn strs(v: Option<&Value>) -> Vec<String> {
    v.and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
}

/// Segments of every non-Kelvin path in a routing plan, with net and half width, for via avoidance.
fn plan_blockers(plan: &Value) -> Vec<(Pt, Pt, String, f64)> {
    let mut out = Vec::new();
    for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
        let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);
        for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
            let mut prev: Option<Pt> = None;
            for wp in path.as_array().cloned().unwrap_or_default() {
                let pt = if let Some(a) = wp.as_array() { if a.len() == 2 { Some((a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))) } else { None } } else if let Some(o) = wp.as_object() { match (o.get("x").and_then(|v| v.as_f64()), o.get("y").and_then(|v| v.as_f64())) { (Some(x), Some(y)) => Some((x, y)), _ => None } } else { None };
                match (wp.is_string(), pt) {
                    (true, _) => prev = None,
                    (false, Some(p)) => {
                        if let Some(q) = prev {
                            out.push((q, p, net.clone(), w / 2.0 + 0.2 + 0.45));
                        }
                        prev = Some(p);
                    }
                    _ => {}
                }
            }
        }
    }
    out
}

/// `p` moved along the segment toward `other` until it is `stop` away from `c` (or onto `other`).
fn pull_back(p: Pt, other: Pt, c: Pt, stop: f64) -> Pt {
    let (dx, dy) = (other.0 - p.0, other.1 - p.1);
    let n = dx.hypot(dy);
    if n < 1e-9 {
        return p;
    }
    let mut t = 0.0;
    while t < n {
        t = (t + 0.02).min(n);
        let q = (p.0 + dx * t / n, p.1 + dy * t / n);
        if (q.0 - c.0).hypot(q.1 - c.1) >= stop {
            return q;
        }
    }
    other
}

fn clear(x: f64, y: f64, net: &str, blockers: &[(Pt, Pt, String, f64)]) -> bool {
    !blockers.iter().any(|(a, b, n, need)| n != net && geom::seg_distance(x, y, a.0, a.1, b.0, b.1) < *need)
}

/// A hole on the board or already planned: (x, y, copper diameter, drill, net).
pub type Hole = (f64, f64, f64, f64, String);

/// The spec's hole-to-hole minimum (drill edge to drill edge), 0.2 mm when it names none.
pub fn hole_to_hole(spec: &Value) -> f64 {
    spec.get("holeToHoleClearance").or_else(|| spec.get("holeToHole")).and_then(|v| v.as_f64()).unwrap_or(0.2)
}

/// The copper clearance a planned via keeps from other copper and from other vias: the spec's
/// `viaClearance`, else its `clearance`, else 0.2 mm (`with_project_rules` fills `viaClearance` from
/// the project's .kicad_dru when the spec names neither).
pub fn via_clearance(spec: &Value) -> f64 {
    spec.get("viaClearance").or_else(|| spec.get("clearance")).and_then(|v| v.as_f64()).unwrap_or(0.2)
}

/// A `.kicad_dru` minimum (`0.1mm`, `4mil`, `0.1`) in mm.
fn dru_mm(v: &str) -> Option<f64> {
    let v = v.trim();
    if let Some(m) = v.strip_suffix("mil") { return m.trim().parse::<f64>().ok().map(|x| x * 0.0254); }
    if let Some(m) = v.strip_suffix("in") { return m.trim().parse::<f64>().ok().map(|x| x * 25.4); }
    v.trim_end_matches("mm").trim().parse::<f64>().ok()
}

/// The minimum a project's `.kicad_dru` sets for `constraint` between two vias: from the rules with no
/// condition or a condition that only names vias (`A.Type == 'Via'`), the LAST such rule in the file,
/// as KiCad lets a later rule override an earlier one. A rule with any other condition (pads, a net
/// class, an area) does not decide via-to-via spacing and is left out. None when no rule sets it.
pub fn dru_via_min(dru: &str, constraint: &str) -> Option<f64> {
    let text: String = dru.lines().filter(|l| !l.trim_start().starts_with('#')).collect::<Vec<_>>().join("\n");
    let root = aiflow_board::sx::parse(&format!("(dru {text})")).ok()?;
    let mut found = None;
    for rule in root.find_all("rule") {
        if let Some(c) = rule.find("condition").and_then(|c| c.value()) {
            let mut rest = c.to_ascii_lowercase().replace([' ', '"', '(', ')'], "");
            for t in ["a.type=='via'", "b.type=='via'", "&&", "||"] { rest = rest.replace(t, ""); }
            if !rest.is_empty() { continue; }
        }
        for k in rule.find_all("constraint").filter(|k| k.atom(1) == Some(constraint)) {
            if let Some(v) = k.find("min").and_then(|m| m.atom(1)).and_then(dru_mm) { found = Some(v); }
        }
    }
    found
}

/// The spec with the project's own via spacing filled in where it names none (issue #90): `viaClearance`
/// from the `.kicad_dru` beside `board_path` (its clearance rule) when the spec has neither
/// `viaClearance` nor `clearance`, and `holeToHoleClearance` from its hole_to_hole rule when the spec
/// has no hole-to-hole key. Without a rules file, or a rule, the defaults stay (0.2 mm each).
pub fn with_project_rules(spec: &Value, board_path: &str) -> Value {
    let mut s = spec.clone();
    if !s.is_object() { return s; }
    let Ok(dru) = std::fs::read_to_string(std::path::Path::new(board_path).with_extension("kicad_dru")) else { return s };
    if s.get("viaClearance").is_none() && s.get("clearance").is_none() {
        if let Some(v) = dru_via_min(&dru, "clearance") { s["viaClearance"] = json!(v); s["viaClearanceFrom"] = json!("kicad_dru"); }
    }
    if s.get("holeToHoleClearance").is_none() && s.get("holeToHole").is_none() {
        if let Some(v) = dru_via_min(&dru, "hole_to_hole") { s["holeToHoleClearance"] = json!(v); s["holeToHoleFrom"] = json!("kicad_dru"); }
    }
    s
}

/// The via size and drill a stitch via gets when its entry names no `size` and `drill`: the spec's
/// `viaSignal` (a size or [size, drill], with `drillSignal`), else the board's most common via, else
/// 0.6 / 0.3 mm. The third value says where it came from.
pub fn stitch_via_default(spec: &Value, board: &Board) -> (f64, f64, &'static str) {
    let vs = spec.get("viaSignal");
    let size = vs.and_then(|v| v.as_f64()).or_else(|| vs.and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_f64()));
    if let Some(size) = size {
        let drill = vs.and_then(|v| v.as_array()).and_then(|a| a.get(1)).and_then(|v| v.as_f64()).or_else(|| spec.get("drillSignal").and_then(|v| v.as_f64())).unwrap_or((size / 2.0).min(0.3));
        return (size, drill, "spec viaSignal");
    }
    let mut counts: Vec<((i64, i64), usize)> = Vec::new();
    for v in board.vias() {
        let k = ((v.size * 1000.0).round() as i64, (v.drill * 1000.0).round() as i64);
        match counts.iter_mut().find(|(c, _)| *c == k) { Some(e) => e.1 += 1, None => counts.push((k, 1)) }
    }
    if let Some(((s, d), _)) = counts.iter().max_by_key(|(_, n)| *n) {
        return (*s as f64 / 1000.0, *d as f64 / 1000.0, "the board's most common via");
    }
    (0.6, 0.3, "default")
}

/// Every hole already on the board: its vias, its through-hole pads (copper to the pad's long half),
/// and its drilled holes with no copper.
pub fn board_holes(board: &Board) -> Vec<Hole> {
    let mut out: Vec<Hole> = board.vias().into_iter().map(|v| (v.x, v.y, v.size, v.drill, v.net)).collect();
    out.extend(board.pads.iter().filter(|p| p.thru).filter_map(|p| Some((p.x, p.y, 2.0 * p.half_long, p.drill?, p.net.clone()))));
    // drilled holes with no copper (mask-only NPTH): the hole is its own extent, no net
    out.extend(board.holes.iter().map(|h| (h.x, h.y, h.diameter(), h.diameter(), String::new())));
    out
}

/// The vias of a via plan (`{nets: [{net, viaSize, viaDrill, paths: [[[x, y], ...]]}]}`, what
/// `thermal-vias plan` and this planner write) as holes.
pub fn plan_holes(plan: &Value) -> Vec<Hole> {
    let mut out = Vec::new();
    for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
        let (size, drill) = (e.get("viaSize").and_then(|v| v.as_f64()).unwrap_or(0.6), e.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3));
        for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
            for wp in path.as_array().cloned().unwrap_or_default() {
                // a layer-changing waypoint is where the via is
                if let (Some(x), Some(y), true) = (wp.get("x").and_then(|v| v.as_f64()), wp.get("y").and_then(|v| v.as_f64()), wp.get("layer").is_some()) { out.push((x, y, size, drill, net.clone())); }
            }
        }
    }
    out
}

/// A via at (x, y) clear of every hole: drill edge to drill edge at least `h2h`, and copper to copper
/// at least `clearance` from another net's.
pub fn hole_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hole], h2h: f64, clearance: f64) -> bool {
    holes.iter().all(|(hx, hy, hs, hd, hn)| {
        let d = (hx - x).hypot(hy - y);
        d + 1e-6 >= (drill + hd) / 2.0 + h2h && (hn == net || d + 1e-6 >= (size + hs) / 2.0 + clearance)
    })
}

/// `hole_clear`, and also copper to copper at least `clearance` from every via and hole whatever its
/// net: two vias of one net may not overlap or crowd each other either (a 0.8 mm via 0.6 mm from a
/// 0.5 mm in-pad via is a drill and copper clash KiCad's DRC reports; issue #83).
pub fn via_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hole], h2h: f64, clearance: f64) -> bool {
    hole_clear(x, y, size, drill, net, holes, h2h, clearance)
        && holes.iter().all(|(hx, hy, hs, _, _)| (hx - x).hypot(hy - y) + 1e-6 >= (size + hs) / 2.0 + clearance)
}

/// The vias already at a pad: every via of `net` (any size), and every through-hole pad of `net` in
/// the same footprint (a footprint's own in-pad via pads), whose centre lies within the pad's copper
/// box grown by `grow` mm; the pad itself left out.
pub fn vias_at_pad(board: &Board, pad: &aiflow_board::Pad, net: &str, grow: f64) -> usize {
    let pts: Vec<Pt> = pad.polys.iter().flatten().copied().collect();
    let (x0, y0, x1, y1) = if pts.is_empty() { (pad.x - pad.half_long, pad.y - pad.half_long, pad.x + pad.half_long, pad.y + pad.half_long) } else { geom::bbox(&pts) };
    let within = |x: f64, y: f64| x >= x0 - grow && x <= x1 + grow && y >= y0 - grow && y <= y1 + grow;
    let vias = board.vias().into_iter().filter(|v| v.net == net && within(v.x, v.y)).count();
    let thru = board.pads.iter().filter(|q| q.thru && q.drill.is_some() && q.net == net && q.reference == pad.reference && !std::ptr::eq(*q, pad) && within(q.x, q.y)).count();
    vias + thru
}

/// The pad connection and island settings a pour may carry, so a reviewed plan can describe a zone
/// exactly: `connection` thermal (default), solid (or full) or none; `thermalGap` and
/// `thermalBridge` in mm; `islandRemoval` always (default), never or area, with `islandAreaMin` in mm2.
pub fn check_settings(z: &Value) -> Result<(), String> {
    if !matches!(z.get("connection").map(|c| c.as_str()), None | Some(Some("thermal" | "solid" | "full" | "none"))) {
        return Err(format!("connection {} is not thermal, solid or none", z["connection"]));
    }
    if !matches!(z.get("islandRemoval").map(|c| c.as_str()), None | Some(Some("always" | "never" | "area"))) {
        return Err(format!("islandRemoval {} is not always, never or area", z["islandRemoval"]));
    }
    for key in ["thermalGap", "thermalBridge", "islandAreaMin", "clearance", "minThickness"] {
        if z.get(key).is_some_and(|v| !v.as_f64().is_some_and(|x| x.is_finite() && x >= 0.0)) {
            return Err(format!("{key} must be a number of mm (mm2 for islandAreaMin), zero or more"));
        }
    }
    if z.get("islandAreaMin").is_some() && z.get("islandRemoval").and_then(|v| v.as_str()) != Some("area") {
        return Err("islandAreaMin applies only with islandRemoval area".into());
    }
    Ok(())
}

/// (zones in the kicad_add_zone shape, via plan entries, keepout count) from the spec and the routing plan.
pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<Value>, Vec<Value>, usize), String> { build_with(board, spec, plan, &[]) }

/// `build`, with vias planned elsewhere and not on the board yet (the heatsink vias) as obstacles: every
/// via this plans keeps the hole-to-hole minimum and the clearance from those, from the board's own vias
/// and through-hole pads, and from each other.
pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[Hole]) -> Result<(Vec<Value>, Vec<Value>, usize), String> {
    let (x0, y0, x1, y1) = board.bbox;
    let edge = spec.get("edgeClearance").and_then(|v| v.as_f64()).unwrap_or(0.5);
    let clearance = spec.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);
    let mut zones: Vec<Value> = Vec::new();
    // issue #189: a net-less keepout in spec.pours ({"keepout": {"tracks": true, ...}, "polygon": [...], "layers": [...]})
    // goes into the plan as the rule area it is, so the plan owns the human's keepouts
    let mut spec_keepouts: Vec<Value> = Vec::new();
    for e in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
        if e.get("net").is_none() && e.get("keepout").is_some() {
            let poly = e.get("polygon").cloned().ok_or("a keepout in spec.pours needs a polygon ([[x, y], ...])")?;
            if poly.as_array().map(|a| a.len()).unwrap_or(0) < 3 { return Err("a keepout in spec.pours needs a polygon of three or more points".into()); }
            let layers = e.get("layers").cloned().unwrap_or(json!(["F.Cu", "B.Cu"]));
            let name = e.get("name").and_then(|n| n.as_str()).map(str::to_string).unwrap_or_else(|| format!("keepout {}", spec_keepouts.len() + 1));
            let k = e["keepout"].clone();
            let k = if k.is_object() { k } else { json!({"tracks": true, "vias": true, "copperPour": true}) };
            let k = { let mut k = k; if let Some(v) = k.get("pours").cloned() { k["copperPour"] = v; k.as_object_mut().unwrap().remove("pours"); } k };
            spec_keepouts.push(json!({"type": "keepout", "layers": layers, "polygon": poly, "keepout": k, "name": name}));
            continue;
        }
        let named = e.get("net").and_then(|n| n.as_str()).ok_or("pour needs net")?.to_string();
        // `GND` in the spec is the board's `/GND` when that is the only match (exact names win)
        let Some(net) = aiflow_board::netname::resolve(&named, board.nets.keys()) else {
            let near = aiflow_board::netname::close(&named, board.nets.keys());
            return Err(if near.is_empty() { format!("pour {named}: the board has no pads on that net") } else { format!("pour {named}: the board has no pads on that net; the board has {} (name that in the spec)", near.join(", ")) });
        };
        let mut z = serde_json::Map::new();
        for (k, v) in e.as_object().unwrap() {
            if !matches!(k.as_str(), "around" | "margin" | "exclude" | "polygon" | "outline" | "inset") {
                z.insert(k.clone(), v.clone());
            }
        }
        z.insert("net".into(), json!(net));
        z.entry("clearance").or_insert(json!(clearance));
        z.entry("minThickness").or_insert(json!(0.25));
        if e.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {
            z.insert("outline".into(), json!(true));
            z.insert("inset".into(), json!(e.get("inset").and_then(|v| v.as_f64()).unwrap_or(edge)));
        } else if let Some(p) = e.get("polygon") {
            z.insert("polygon".into(), p.clone());
        } else if e.get("around").is_some() {
            let keys = strs(e.get("around"));
            let bx = pad_box(board, &keys).ok_or_else(|| format!("pour {net}: none of {keys:?} found on the board"))?;
            let m = e.get("margin").and_then(|v| v.as_f64()).unwrap_or(0.8);
            let mut b = ((bx.0 - m).max(x0 + edge), (bx.1 - m).max(y0 + edge), (bx.2 + m).min(x1 - edge), (bx.3 + m).min(y1 - edge));
            for ex in strs(e.get("exclude")) {
                if let Some(xb) = pad_box(board, &[ex.clone()]) {
                    if !(xb.2 < b.0 || xb.0 > b.2 || xb.3 < b.1 || xb.1 > b.3) {
                        // clip on the side that loses the least
                        let cuts = [(0usize, xb.2 + 1.0, (xb.2 + 1.0) - b.0), (1, xb.3 + 1.0, (xb.3 + 1.0) - b.1), (2, xb.0 - 1.0, b.2 - (xb.0 - 1.0)), (3, xb.1 - 1.0, b.3 - (xb.1 - 1.0))];
                        let (side, val, _) = cuts.iter().min_by(|a, c| a.2.partial_cmp(&c.2).unwrap()).unwrap();
                        match side { 0 => b.0 = *val, 1 => b.1 = *val, 2 => b.2 = *val, _ => b.3 = *val }
                    }
                }
            }
            z.insert("polygon".into(), rect(b.0, b.1, b.2, b.3));
        } else {
            return Err(format!("pour {net} needs outline, polygon or around"));
        }
        z.entry("name").or_insert(json!(format!("{net} pour")));
        check_settings(&Value::Object(z.clone())).map_err(|e| format!("pour {net}: {e}"))?;
        zones.push(Value::Object(z));
    }
    for key in strs(spec.get("solidAt")) {
        if let Some(p) = board.pad_by_key(&key) {
            let layers: Vec<&str> = if p.thru { vec!["F.Cu", "B.Cu"] } else if p.on_f { vec!["F.Cu"] } else { vec!["B.Cu"] };
            let r = if p.thru { p.half_long.max(p.half_short) + 0.6 } else { 0.9 };
            zones.push(json!({"net": p.net, "layers": layers, "polygon": rect(p.x - r, p.y - r, p.x + r, p.y + r), "priority": 1, "connection": "solid", "clearance": clearance, "minThickness": 0.25, "name": format!("{} solid joint at {key}", p.net)}));
        }
    }
    let blockers = plan.map(plan_blockers).unwrap_or_default();
    let h2h = hole_to_hole(spec);
    // via to other copper and to other vias: the spec's viaClearance (or the project's .kicad_dru
    // through with_project_rules), else its clearance; zones keep `clearance`
    let vclr = via_clearance(spec);
    // a via ringed outside a small pin keeps clear of the silkscreen (issue #90): text and graphics
    let silk = aiflow_board::silk::silk_shapes(board);
    let silk_gap = spec.get("silkClearance").and_then(|v| v.as_f64()).unwrap_or(0.1);
    let mut holes = board_holes(board);
    holes.extend(planned.iter().cloned());
    let mut vias: Vec<Value> = Vec::new();
    let via_entry = |net: &str, x: f64, y: f64, size: f64, drill: f64| json!({"net": net, "width": 0.5, "viaSize": size, "viaDrill": drill, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]});
    // issue #183: a board with no vias (spec "vias": false, one routed face, or the project's .kicad_dru)
    // gets no thermal or stitching vias
    let no_vias = spec.get("vias").and_then(|v| v.as_bool()) == Some(false);
    let via_asks = spec.get("thermalVias").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0) + spec.get("stitchVias").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
    if no_vias && via_asks > 0 { eprintln!("pours: this board has no vias (single-face or vias off): the spec's {via_asks} thermalVias/stitchVias entr{} left out", if via_asks == 1 { "y is" } else { "ies are" }); }
    let empty: Vec<Value> = Vec::new();
    for tv in if no_vias { empty.clone() } else { spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() } {
        let Some(key) = tv.get("pad").and_then(|p| p.as_str()) else { continue };
        let Some(p) = board.pad_by_key(key) else { continue };
        // a footprint can repeat a pad name (an exposed pad and its own in-pad via pads all "29"):
        // the heat pad is the largest copper of that name
        let p = board.pads.iter().filter(|q| q.reference == p.reference && q.name == p.name).max_by(|a, b| (a.half_long * a.half_short).partial_cmp(&(b.half_long * b.half_short)).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or(p);
        let net = tv.get("net").and_then(|n| n.as_str()).unwrap_or(&p.net).to_string();
        let want = tv.get("count").and_then(|c| c.as_u64()).unwrap_or(2) as usize;
        let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));
        let fits_inside = p.half_long - 0.4 > 0.0 && p.half_short - 0.4 > 0.0 && size <= 2.0 * p.half_short;
        // the pad's vias already on the board (any size) count toward `count`, as `thermal-vias plan`
        // counts them: in the pad for a pad that takes vias, on the ring just outside it for a small pin
        let existing = vias_at_pad(board, p, &net, if fits_inside { 0.0 } else { 0.25 + 0.6 + 3.0 * 0.35 });
        let n = want.saturating_sub(existing);
        if existing > 0 { eprintln!("thermal vias at {key}: {existing} via(s) of {net} already at the pad count toward {want}; planning {n} more"); }
        let mut placed = 0;
        if fits_inside {
            for (dx, dy) in [(-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)] {
                if placed >= n {
                    break;
                }
                let inside = (dx as f64).abs() < p.half_long - 0.4 && (dy as f64).abs() < p.half_short - 0.4;
                if !inside && (dx, dy) != (0.0, 0.0) {
                    continue;
                }
                let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);
                if clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, vclr) {
                    vias.push(via_entry(&net, x, y, size, drill));
                    holes.push((x, y, size, drill, net.clone()));
                    placed += 1;
                }
            }
        } else {
            // a small pin (SOT-23, 0603...) has no room for a via: an open via in the pad wicks the
            // solder away. Put small vias in the pour just outside the pad instead, on a ring of
            // candidates nearest first, clear of other nets' pads and planned copper and of each other.
            let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.6).min(0.6), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.3).min(0.3));
            let vr = size / 2.0;
            let mut cands: Vec<Pt> = Vec::new();
            for ring in 0..4 {
                let off = 0.25 + vr + ring as f64 * 0.35;
                for k in 0..16 {
                    let t = k as f64 * std::f64::consts::PI / 8.0;
                    let (ex, ey) = (t.cos(), t.sin());
                    // edge of the pad's box along this direction, in board axes
                    let (hx, hy) = if p.long_axis.0.abs() >= p.long_axis.1.abs() { (p.half_long, p.half_short) } else { (p.half_short, p.half_long) };
                    let reach = if ex.abs() * hy > ey.abs() * hx { hx / ex.abs().max(1e-9) } else { hy / ey.abs().max(1e-9) };
                    cands.push((p.x + ex * (reach + off), p.y + ey * (reach + off)));
                }
            }
            let (bx0, by0, bx1, by1) = board.bbox;
            let mut under_silk = 0;
            // keep clear of the spec's stitch vias and any via already on the board, not only our own
            let mut mine: Vec<Pt> = spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter()
                .filter_map(|v| v.get("x").and_then(|x| x.as_f64()).zip(v.get("y").and_then(|y| y.as_f64()))).collect();
            mine.extend(board.root.find_all("via").filter_map(|v| { let at = v.find("at")?; Some((at.number(1)?, at.number(2)?)) }));
            for (cx, cy) in cands {
                if placed >= n {
                    break;
                }
                let (x, y) = ((cx * 100.0).round() / 100.0, (cy * 100.0).round() / 100.0);
                if x - vr < bx0 + edge || x + vr > bx1 - edge || y - vr < by0 + edge || y + vr > by1 - edge {
                    continue;
                }
                let near_other_pad = board.pads.iter().any(|q| q.net != net && q.polys.iter().flatten().count() > 0 && {
                    let (qx0, qy0, qx1, qy1) = geom::bbox(&q.polys.iter().flatten().copied().collect::<Vec<_>>());
                    let (nx, ny) = (x.clamp(qx0, qx1), y.clamp(qy0, qy1));
                    (x - nx).hypot(y - ny) < vr + clearance + 0.1
                });
                let near_own_pad = board.pads.iter().any(|q| q.polys.iter().flatten().count() > 0 && {
                    let (qx0, qy0, qx1, qy1) = geom::bbox(&q.polys.iter().flatten().copied().collect::<Vec<_>>());
                    let (nx, ny) = (x.clamp(qx0, qx1), y.clamp(qy0, qy1));
                    (x - nx).hypot(y - ny) < vr + 0.1
                });
                let near_mine = mine.iter().any(|m| (m.0 - x).hypot(m.1 - y) < size + 0.3);
                if aiflow_board::silk::near(&silk, (x, y), vr + silk_gap).is_some() { under_silk += 1; continue; }
                if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, vclr) {
                    vias.push(via_entry(&net, x, y, size, drill));
                    mine.push((x, y)); holes.push((x, y, size, drill, net.clone()));
                    placed += 1;
                }
            }
            if placed < n && under_silk > 0 { eprintln!("thermal vias at {key}: {under_silk} spot(s) left out under silkscreen text or graphics (spec silkClearance {silk_gap} mm); move the label or accept fewer"); }
        }
        if placed < n {
            eprintln!("thermal vias at {key}: {} of {want} ({existing} already there, {placed} planned); no more room clear of other nets and of the vias and holes already there (add stitchVias by hand with their own size and drill, or accept fewer)", existing + placed);
        }
    }
    // a stitch via with no size and drill takes the spec's signal via, else the board's own (issue #90)
    let (dsize, ddrill, dfrom) = stitch_via_default(spec, board);
    for sv in if no_vias { empty.clone() } else { spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() } {
        let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };
        let (size, drill) = (sv.get("size").and_then(|v| v.as_f64()).unwrap_or(dsize), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(ddrill));
        let sized = if sv.get("size").is_some() { String::new() } else { format!(" ({size}/{drill} mm from {dfrom}; give the entry \"size\" and \"drill\" in mm for another via)") };
        // a stitch via on top of a heatsink via (or any hole) is a drill hit: left out, said why
        if !via_clear(x, y, size, drill, net, &holes, h2h, vclr) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the {vclr} mm clearance of a via or hole already there or planned{sized}"); continue; }
        if clear(x, y, net, &blockers) {
            vias.push(via_entry(net, x, y, size, drill));
            holes.push((x, y, size, drill, net.to_string()));
        }
    }
    let mut keepouts = spec_keepouts.len();
    zones.extend(spec_keepouts);
    // the sense pad of every Kelvin pair (spec.kelvin: {"U6.3": "R32.2"}) gets its own keepout: a
    // pour on the same net must never touch it, or the tap measures the pour's drop instead of the
    // shunt's (and KiCad's DRC reports the starved thermal relief the keepout along the tap leaves)
    for (src, _dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {
        if let Some(p) = board.pad_by_key(&src) {
            let m = clearance + 0.15;
            let (hx, hy) = if p.long_axis.0.abs() >= p.long_axis.1.abs() { (p.half_long, p.half_short) } else { (p.half_short, p.half_long) };
            zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": rect(p.x - hx - m, p.y - hy - m, p.x + hx + m, p.y + hy + m), "keepout": {"copperPour": true}, "name": format!("Kelvin sense pad keepout {} ({src})", p.net)}));
            keepouts += 1;
        }
    }
    // the power pins the taps land on (spec.kelvin values), with the radius a keepout must stay out of
    let kelvin_power_pads: Vec<(f64, f64, f64)> = spec.get("kelvin").and_then(|k| k.as_object()).map(|m| m.values().filter_map(|v| v.as_str()).filter_map(|k| board.pad_by_key(k)).map(|p| (p.x, p.y, p.half_long.max(p.half_short))).collect()).unwrap_or_default();
    // every keepout gets a unique name: KiCad Bridge's zone plan refuses duplicates
    let mut tap_count: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    if let (Some(plan), true) = (plan, spec.get("kelvin").map(|k| !k.is_null()).unwrap_or(false)) {
        for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
            if !e.get("kelvin").and_then(|k| k.as_bool()).unwrap_or(false) {
                continue;
            }
            let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
            let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25) / 2.0 + 0.2 + 0.1;
            let via_r = e.get("viaSize").and_then(|v| v.as_f64()).unwrap_or(0.8) / 2.0 + 0.2 + 0.1;
            let r = |v: f64| (v * 1000.0).round() / 1000.0;
            for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
                // walk the path on its own layers: it starts on its first pad's layer, and a
                // {x, y, layer} waypoint is a via onto that layer. A keepout goes on the layer its
                // sense segment runs on (not both), so the other layer's plane stays whole.
                let wps = path.as_array().cloned().unwrap_or_default();
                let mut layer = wps.first().and_then(|w| w.as_str()).and_then(|k| board.pad_by_key(k)).map(|p| if p.on_f { "F.Cu" } else { "B.Cu" }).unwrap_or("F.Cu").to_string();
                let mut prev: Option<Pt> = None;
                for wp in &wps {
                    let (pt, via_to) = if let Some(a) = wp.as_array() {
                        (if a.len() == 2 { a[0].as_f64().zip(a[1].as_f64()) } else { None }, None)
                    } else if let Some(o) = wp.as_object() {
                        (o.get("x").and_then(|v| v.as_f64()).zip(o.get("y").and_then(|v| v.as_f64())), o.get("layer").and_then(|l| l.as_str()).map(str::to_string))
                    } else if let Some(k) = wp.as_str() {
                        (board.pad_by_key(k).map(|p| (p.x, p.y)), None)
                    } else {
                        (None, None)
                    };
                    let Some(b) = pt else { continue };
                    if let Some(a) = prev {
                        // stop short of the power pin the tap lands on: that pin must stay joined to
                        // its pour (it is usually the part's heat path), only the tap is fenced
                        let (mut a, mut bb) = (a, b);
                        for d in &kelvin_power_pads {
                            let stop = d.2 + clearance + 0.3;
                            if (a.0 - d.0).hypot(a.1 - d.1) < stop { a = pull_back(a, bb, (d.0, d.1), stop); }
                            if (bb.0 - d.0).hypot(bb.1 - d.1) < stop { bb = pull_back(bb, a, (d.0, d.1), stop); }
                        }
                        let ((ax, ay), (bx, by)) = (a, bb);
                        let (dx, dy) = (bx - ax, by - ay);
                        let n = dx.hypot(dy);
                        if n >= 0.05 {
                            let (ux, uy) = (dx / n, dy / n);
                            let poly = json!([[r(ax - uy * w), r(ay + ux * w)], [r(bx - uy * w), r(by + ux * w)], [r(bx + uy * w), r(by - ux * w)], [r(ax + uy * w), r(ay - ux * w)]]);
                            let k = tap_count.entry(net.clone()).or_insert(0);
                            *k += 1;
                            zones.push(json!({"type": "keepout", "layers": [layer.clone()], "polygon": poly, "keepout": {"copperPour": true}, "name": format!("Kelvin tap keepout {net} #{k}")}));
                            keepouts += 1;
                        }
                    }
                    if let Some(to) = via_to {
                        // a via is copper on both layers: fence it on both, or a same-net plane joins the tap there
                        let k = tap_count.entry(net.clone()).or_insert(0);
                        *k += 1;
                        zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": rect(b.0 - via_r, b.1 - via_r, b.0 + via_r, b.1 + via_r), "keepout": {"copperPour": true}, "name": format!("Kelvin tap keepout {net} #{k} (via)")}));
                        keepouts += 1;
                        layer = to;
                    }
                    prev = Some(b);
                }
            }
        }
    }
    Ok((zones, vias, keepouts))
}

/// Shrink an outline by d: every edge moved d inward along its normal, each vertex where its two moved
/// edges meet, so straight edges and the short steps of a rounded corner's arc all keep d from the board
/// edge (a pull toward the centroid kept only d times the cosine off a long edge). A spike the meeting
/// point would make at a sharp inner corner is held to the bisector at d.
pub fn inset_polygon(poly: &[Pt], d: f64) -> Vec<Pt> {
    let mut p: Vec<Pt> = Vec::new();
    for &q in poly { if p.last().map(|l: &Pt| (l.0 - q.0).hypot(l.1 - q.1) > 1e-9).unwrap_or(true) { p.push(q); } }
    while p.len() > 1 && (p[0].0 - p[p.len() - 1].0).hypot(p[0].1 - p[p.len() - 1].1) < 1e-9 { p.pop(); }
    let n = p.len();
    if d <= 0.0 || n < 3 {
        return poly.to_vec();
    }
    let s = if geom::polygon_area(&p) >= 0.0 { 1.0 } else { -1.0 };
    // the inward unit normal of edge i (p[i] -> p[i+1])
    let nrm = |i: usize| { let (a, b) = (p[i], p[(i + 1) % n]); let l = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); (-(b.1 - a.1) / l * s, (b.0 - a.0) / l * s) };
    (0..n).map(|i| {
        let (n0, n1) = (nrm((i + n - 1) % n), nrm(i));
        let (bx, by) = (n0.0 + n1.0, n0.1 + n1.1);
        let k = 1.0 + n0.0 * n1.0 + n0.1 * n1.1;   // 2 cos^2 of half the turn
        if k < 0.25 { let l = bx.hypot(by).max(1e-12); return (p[i].0 + bx / l * d, p[i].1 + by / l * d); }
        (p[i].0 + bx / k * d, p[i].1 + by / k * d)
    }).collect()
}

/// Resolve `outline: true` zones to explicit polygons (the live verb takes points).
pub fn resolve_outline(board: &Board, zones: &[Value]) -> Vec<Value> {
    let mut outline = board.outline.clone();
    if geom::polygon_area(&outline) < 0.0 {
        outline.reverse();
    }
    zones.iter().map(|z| {
        if z.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {
            let inset = z.get("inset").and_then(|v| v.as_f64()).unwrap_or(0.5);
            let mut o = z.as_object().unwrap().clone();
            o.remove("outline");
            o.remove("inset");
            let r = |v: f64| (v * 1000.0).round() / 1000.0;
            o.insert("polygon".into(), json!(inset_polygon(&outline, inset).iter().map(|(x, y)| json!([r(*x), r(*y)])).collect::<Vec<_>>()));
            Value::Object(o)
        } else {
            z.clone()
        }
    }).collect()
}

fn poly_of(z: &Value) -> Vec<Pt> {
    z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|pt| { let q = pt.as_array()?; Some((q.first()?.as_f64()?, q.get(1)?.as_f64()?)) }).collect()).unwrap_or_default()
}

/// The zone (copper pour or keepout) as a `.kicad_pcb` block, the way the bridge writes it.
pub fn zone_sexpr(board: &Board, z: &Value) -> Result<String, String> {
    let layers: Vec<String> = strs(z.get("layers"));
    let layer_line = if layers.len() == 1 { format!("\t\t(layer \"{}\")\n", layers[0]) } else { format!("\t\t(layers {})\n", layers.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" ")) };
    let pts: Vec<String> = poly_of(z).iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();
    if pts.len() < 3 {
        return Err("zone needs a polygon with three or more points".into());
    }
    let name = z.get("name").and_then(|n| n.as_str()).unwrap_or("zone");
    let uuid = format!("bf{:016x}-{:04x}-4000-8000-{:012x}", pts.len() as u64 * 7919 + name.len() as u64, layers.len(), std::process::id());
    let keepout = z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some();
    if keepout {
        let k = z.get("keepout").cloned().unwrap_or(json!({"copperPour": true}));
        let f = |n: &str| if k.get(n).and_then(|b| b.as_bool()).unwrap_or(false) { "not_allowed" } else { "allowed" };
        return Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n\t\t(keepout\n\t\t\t(tracks {})\n\t\t\t(vias {})\n\t\t\t(pads {})\n\t\t\t(copperpour {})\n\t\t\t(footprints {})\n\t\t)\n\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",
            if board.net_format == NetFormat::Number { "\t\t(net 0)\n\t\t(net_name \"\")\n" } else { "" }, layer_line, uuid, quote(name), f("tracks"), f("vias"), f("pads"), f("copperPour"), f("footprints"), pts.join(" ")));
    }
    let net = z.get("net").and_then(|n| n.as_str()).ok_or("copper zone needs net")?;
    let net_lines = match board.net_format {
        NetFormat::Number => format!("{}\t\t(net_name {})\n", board.net_ref_sexpr(net)?, quote(net)),
        NetFormat::Name => board.net_ref_sexpr(net)?,
    };
    let connect = match z.get("connection").and_then(|c| c.as_str()) {
        None | Some("thermal") => "",
        Some("solid") | Some("full") => " yes",
        Some("none") => " no",
        Some(other) => return Err(format!("zone connection {other:?}: use thermal, solid or none")),
    };
    // KiCad's island removal: 0 always (the default, not written), 1 never, 2 below an area
    let island = match z.get("islandRemoval").and_then(|c| c.as_str()) {
        None | Some("always") => String::new(),
        Some("never") => "\t\t\t(island_removal_mode 1)\n".to_string(),
        Some("area") => format!("\t\t\t(island_removal_mode 2)\n\t\t\t(island_area_min {})\n", num(z.get("islandAreaMin").and_then(|v| v.as_f64()).unwrap_or(10.0))),
        Some(other) => return Err(format!("zone islandRemoval {other:?}: use always, never or area")),
    };
    let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);
    let clearance = z.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);
    let min_t = z.get("minThickness").and_then(|v| v.as_f64()).unwrap_or(0.25);
    let gap = z.get("thermalGap").and_then(|v| v.as_f64()).unwrap_or(0.5);
    let bridge = z.get("thermalBridge").and_then(|v| v.as_f64()).unwrap_or(0.5);
    Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n{}{}\t\t(min_thickness {})\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap {})\n\t\t\t(thermal_bridge_width {})\n{}\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",
        net_lines, layer_line, uuid, quote(name), if prio > 0 { format!("\t\t(priority {prio})\n") } else { String::new() },
        format!("\t\t(connect_pads{connect}\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)),
        num(min_t), num(gap), num(bridge), island, pts.join(" ")))
}

/// Summary line per zone for the human.
pub fn describe(z: &Value) -> String {
    let layers = strs(z.get("layers")).join("+");
    if z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some() {
        return format!("keepout    {layers:<12} {}", z.get("name").and_then(|n| n.as_str()).unwrap_or(""));
    }
    let poly = poly_of(z);
    format!("{:<10} {layers:<12} prio {} {} {}", z.get("net").and_then(|n| n.as_str()).unwrap_or("?"), z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0), z.get("connection").and_then(|c| c.as_str()).unwrap_or("thermal"), if z.get("outline").is_some() { "outline".to_string() } else { format!("{:.0} mm2", geom::polygon_area(&poly).abs()) })
}

/// Declared planes must be real copper zones before routing treats their pads as connected by stubs.
pub fn missing_planes(board: &Board, spec: &Value) -> Vec<String> {
    let mut missing=Vec::new();
    for (net, layer) in spec["planes"].as_object().into_iter().flatten() {
        let Some(layer)=layer.as_str() else { missing.push(format!("{net}: plane layer must be a string")); continue };
        let found=board.root.find_all("zone").any(|z| {
            if z.find("keepout").is_some(){return false}
            let zn=z.find("net_name").and_then(|n|n.value()).map(str::to_string).or_else(||z.find("net").and_then(|n|n.value()).map(|n|n.parse::<i64>().ok().and_then(|i|board.net_table.get(&i).cloned()).unwrap_or_else(||n.into())));
            let layers=z.find("layers").or_else(||z.find("layer"));
            let on_layer=layers.map(|n|(1..=n.items.len()).any(|i|n.atom(i)==Some(layer))).unwrap_or(false);
            zn.as_deref()==Some(net.as_str()) && on_layer && z.find("polygon").is_some()
        });
        if !found{missing.push(format!("{net} on {layer}"))}
    }
    missing
}

#[cfg(test)]
mod hole_tests {
    use super::*;
    #[test]
    fn inset_keeps_d_from_every_edge_of_a_rounded_outline() {
        // a rounded 30 x 20 outline (1 mm arcs) as the board reads it: every inset vertex d from the outline
        let b = Board::from_text("fixture", "(kicad_pcb (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_line (start 1 0) (end 29 0) (layer \"Edge.Cuts\")) (gr_arc (start 29 0) (mid 29.7071 0.2929) (end 30 1) (layer \"Edge.Cuts\")) (gr_line (start 30 1) (end 30 19) (layer \"Edge.Cuts\")) (gr_arc (start 30 19) (mid 29.7071 19.7071) (end 29 20) (layer \"Edge.Cuts\")) (gr_line (start 29 20) (end 1 20) (layer \"Edge.Cuts\")) (gr_arc (start 1 20) (mid 0.2929 19.7071) (end 0 19) (layer \"Edge.Cuts\")) (gr_line (start 0 19) (end 0 1) (layer \"Edge.Cuts\")) (gr_arc (start 0 1) (mid 0.2929 0.2929) (end 1 0) (layer \"Edge.Cuts\")))".into()).unwrap();
        for o in [b.outline.clone(), b.outline.iter().rev().cloned().collect::<Vec<_>>()] {
            let ins = inset_polygon(&o, 0.3);
            let n = o.len();
            for q in &ins {
                assert!(geom::point_in_poly(q.0, q.1, &o), "{q:?} outside");
                let dmin = (0..n).map(|k| geom::seg_distance(q.0, q.1, o[k].0, o[k].1, o[(k + 1) % n].0, o[(k + 1) % n].1)).fold(f64::MAX, f64::min);
                assert!(dmin > 0.3 - 0.01 && dmin < 0.3 + 0.02, "{q:?} is {dmin} from the edge");
            }
        }
    }
    #[test]
    fn stitch_vias_keep_clear_of_board_and_planned_holes() {
        let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (via (at 5 5) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)))".into()).unwrap();
        // the heatsink plan's via at (10, 10); stitch vias on top of each, a near miss, and one well clear
        let tv = json!({"nets": [{"net": "GND", "viaSize": 0.6, "viaDrill": 0.3, "paths": [[[10.0, 10.0], {"layer": "B.Cu", "x": 10.0, "y": 10.0}]]}]});
        let planned = plan_holes(&tv);
        assert_eq!(planned.len(), 1);
        let spec = json!({"stitchVias": [{"net": "GND", "x": 5.0, "y": 5.0}, {"net": "GND", "x": 10.05, "y": 10.0}, {"net": "GND", "x": 10.5, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]});
        let (_, vias, _) = build_with(&b, &spec, None, &planned).unwrap();
        let at: Vec<(f64, f64)> = vias.iter().map(|v| (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap())).collect();
        // no size given: the board's own 0.6/0.3 via (issue #90); copper to copper needs 0.6 + 0.2, so 10.5 (0.5 away) is out too
        assert_eq!(at, vec![(15.0, 15.0)]);
        assert!(hole_clear(10.6, 10.0, 0.8, 0.4, "GND", &planned, 0.2, 0.2));
        assert!(!hole_clear(10.6, 10.0, 0.8, 0.4, "VIN", &planned, 0.2, 0.2), "another net also needs copper clearance");
    }
    #[test]
    fn pour_settings_reach_the_zone_block() {
        let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"R\" (layer \"F.Cu\") (at 5 5) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\"))))".into()).unwrap();
        let spec = json!({"pours": [{"net": "GND", "layers": ["F.Cu"], "polygon": [[0, 0], [10, 0], [10, 10]], "connection": "none", "thermalGap": 0.3, "thermalBridge": 0.4, "islandRemoval": "area", "islandAreaMin": 2.5}]});
        let (zones, _, _) = build(&b, &spec, None).unwrap();
        assert_eq!(zones[0]["islandRemoval"], "area");
        let text = zone_sexpr(&b, &zones[0]).unwrap();
        for want in ["(connect_pads no", "(thermal_gap 0.3)", "(thermal_bridge_width 0.4)", "(island_removal_mode 2)", "(island_area_min 2.5)"] {
            assert!(text.contains(want), "{want} in {text}");
        }
        let mut z = zones[0].clone();
        z["islandRemoval"] = json!("never");
        z.as_object_mut().unwrap().remove("islandAreaMin");
        assert!(zone_sexpr(&b, &z).unwrap().contains("(island_removal_mode 1)"));
        z["connection"] = json!("solid");
        assert!(zone_sexpr(&b, &z).unwrap().contains("(connect_pads yes"));
        for bad in [json!({"connection": "spokes"}), json!({"islandRemoval": "sometimes"}), json!({"islandAreaMin": 3}), json!({"thermalGap": -1})] {
            let mut spec = spec.clone();
            spec["pours"][0]["islandRemoval"] = json!("always");
            spec["pours"][0].as_object_mut().unwrap().remove("islandAreaMin");
            for (k, v) in bad.as_object().unwrap() { spec["pours"][0][k] = v.clone(); }
            assert!(build(&b, &spec, None).is_err(), "{bad}");
        }
    }
    /// A QFN with a 3.4 mm exposed pad U1.29 at (113, 82) on GND carrying 10 in-pad 0.5/0.25 vias
    /// (2 x 5, x 112.4 and 113.6), as board vias or as the footprint's own through-hole "29" pads.
    fn qfn(as_pads: bool) -> Board {
        let ys = [80.8, 81.4, 82.0, 82.6, 83.2];
        let mut fp_vias = String::new();
        let mut board_vias = String::new();
        for x in [112.4, 113.6] {
            for y in ys {
                if as_pads { fp_vias.push_str(&format!(" (pad \"29\" thru_hole circle (at {} {}) (size 0.5 0.5) (drill 0.25) (layers \"*.Cu\") (net 1 \"GND\"))", ((x - 113.0) * 10.0f64).round() / 10.0, ((y - 82.0) * 10.0f64).round() / 10.0)); }
                else { board_vias.push_str(&format!(" (via (at {x} {y}) (size 0.5) (drill 0.25) (layers \"F.Cu\" \"B.Cu\") (net 1))")); }
            }
        }
        Board::from_text("fixture", format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 100 70)(end 126 94)(layer \"Edge.Cuts\")) (footprint \"QFN\" (layer \"F.Cu\") (at 113 82) (property \"Reference\" \"U1\") (pad \"29\" smd rect (at 0 0) (size 3.4 3.4) (layers \"F.Cu\") (net 1 \"GND\")){fp_vias}){board_vias})")).unwrap()
    }

    #[test]
    fn thermal_vias_count_and_clear_a_pads_existing_in_pad_vias() {
        // issue #83: count 12 planned three 0.8/0.4 vias 0.6 mm from the existing ones, count 10 the same
        for as_pads in [false, true] {
            let b = qfn(as_pads);
            let p = b.pads.iter().find(|p| p.key == "U1.29" && !p.thru).unwrap();
            assert_eq!(vias_at_pad(&b, p, "GND", 0.0), 10, "as pads: {as_pads}");
            let holes = board_holes(&b);
            for count in [10, 12] {
                let spec = json!({"thermalVias": [{"pad": "U1.29", "net": "GND", "count": count}]});
                let (_, vias, _) = build(&b, &spec, None).unwrap();
                if count == 10 { assert!(vias.is_empty(), "the 10 there already meet count 10: {vias:?}"); }
                for v in &vias {
                    let (x, y) = (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap());
                    for (hx, hy, hs, _, _) in &holes {
                        assert!((hx - x).hypot(hy - y) >= (0.8 + hs) / 2.0 + 0.2 - 1e-6, "planned via at ({x}, {y}) crowds the existing one at ({hx}, {hy})");
                    }
                }
            }
        }
        // the same pad bare: the planner still places its vias
        let bare = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 100 70)(end 126 94)(layer \"Edge.Cuts\")) (footprint \"QFN\" (layer \"F.Cu\") (at 113 82) (property \"Reference\" \"U1\") (pad \"29\" smd rect (at 0 0) (size 3.4 3.4) (layers \"F.Cu\") (net 1 \"GND\"))))".into()).unwrap();
        let (_, vias, _) = build(&bare, &json!({"thermalVias": [{"pad": "U1.29", "net": "GND", "count": 3}]}), None).unwrap();
        assert_eq!(vias.len(), 3);
        assert!(!via_clear(111.8, 82.0, 0.8, 0.4, "GND", &[(112.4, 82.0, 0.5, 0.25, "GND".into())], 0.2, 0.2), "same net still keeps via-to-via spacing");
    }

    #[test]
    fn mask_only_npth_is_a_board_hole_for_stitch_vias() {
        let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"T:USBC\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"J9\") (pad \"\" np_thru_hole circle (at 0 0) (size 0.65 0.65) (drill 0.65) (layers \"*.Mask\"))))".into()).unwrap();
        assert!(b.pads.is_empty() && b.holes.len() == 1);
        let holes = board_holes(&b);
        assert_eq!(holes.len(), 1);
        assert!(!hole_clear(10.4, 10.0, 0.6, 0.3, "GND", &holes, 0.25, 0.2), "a via next to the peg keeps hole clearance");
        let spec = json!({"stitchVias": [{"net": "GND", "x": 10.0, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]});
        let (_, vias, _) = build_with(&b, &spec, None, &[]).unwrap();
        assert_eq!(vias.len(), 1, "the via on the peg is left out");
    }
}

#[cfg(test)]
mod plane_tests {
 use super::*;
 #[test]
 fn declaration_without_copper_is_not_a_plane(){
  let make=|extra:&str|Board::from_text("fixture",format!("(kicad_pcb (layers (0 \"F.Cu\" signal)(4 \"In1.Cu\" power)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 10 10)(layer \"Edge.Cuts\")) {extra})")).unwrap();
  let spec=json!({"planes":{"GND":"In1.Cu"}});
  assert_eq!(missing_planes(&make(""),&spec),vec!["GND on In1.Cu"]);
  let zone="(zone (net \"GND\") (layer \"In1.Cu\") (polygon (pts (xy 0 0)(xy 10 0)(xy 10 10))))";
  assert!(missing_planes(&make(zone),&spec).is_empty());
  let keepout=zone.replacen("(zone", "(zone (keepout (copperpour not_allowed))",1);
  assert_eq!(missing_planes(&make(&keepout),&spec).len(),1);
 }
}

#[cfg(test)]
mod net_name_tests {
    use super::*;
    fn board() -> Board {
        Board::from_text("fixture", r#"(kicad_pcb (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (net 0 "") (net 1 "/GND")
 (gr_rect (start 0 0)(end 20 20)(layer "Edge.Cuts"))
 (footprint "R" (layer "F.Cu") (at 10 10) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "/GND"))))"#.to_string()).unwrap()
    }
    #[test]
    fn a_net_less_keepout_in_the_spec_goes_into_the_plan() {
        // issue #189: the human's keepout over a tether contact, declared in spec.pours
        let spec = json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]},
            {"name": "ESC tether", "keepout": {"tracks": true, "vias": true, "pours": true}, "polygon": [[2, 2], [4, 2], [4, 4], [2, 4]], "layers": ["F.Cu", "B.Cu"]}]});
        let (zones, _, keepouts) = build(&board(), &spec, None).unwrap();
        assert_eq!(keepouts, 1);
        let k = zones.iter().find(|z| z["type"] == "keepout").unwrap();
        assert_eq!(k["name"], "ESC tether");
        assert_eq!(k["keepout"], json!({"tracks": true, "vias": true, "copperPour": true}));
        assert!(build(&board(), &json!({"pours": [{"keepout": true, "polygon": [[0, 0], [1, 1]]}]}), None).is_err());
    }
    #[test]
    fn spec_gnd_pours_the_board_hierarchical_gnd() {
        let (zones, _, _) = build(&board(), &json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]}]}), None).unwrap();
        assert_eq!(zones[0]["net"], "/GND");
    }
    #[test]
    fn refusal_names_the_close_board_net() {
        let e = build(&board(), &json!({"pours": [{"net": "gnd", "outline": true}]}), None).unwrap_err();
        assert!(e.contains("no pads on that net") && e.contains("/GND"), "{e}");
        let e = build(&board(), &json!({"pours": [{"net": "VOUT", "outline": true}]}), None).unwrap_err();
        assert_eq!(e, "pour VOUT: the board has no pads on that net");
    }
}
#[cfg(test)]
mod issue90_tests {
    use super::*;

    #[test]
    fn project_rules_fill_via_spacing_the_spec_leaves_out() {
        let dru = "(version 1)\n# a comment (with parens)\n(rule \"pads apart\"\n  (condition \"A.Type == 'Pad' && B.Type == 'Pad'\")\n  (constraint hole_to_hole (min 0.45mm)))\n(rule \"base\"\n  (constraint clearance (min 0.15mm))\n  (constraint hole_to_hole (min 0.25mm)))\n(rule \"vias\"\n  (condition \"A.Type == 'Via' && B.Type == 'Via'\")\n  (constraint clearance (min 4mil)))\n(rule \"hv\"\n  (condition \"A.NetClass == 'HV'\")\n  (constraint clearance (min 1mm)))\n";
        // the last rule that decides via to via wins; the pad rule and the net-class rule do not
        assert!((dru_via_min(dru, "clearance").unwrap() - 0.1016).abs() < 1e-9);
        assert_eq!(dru_via_min(dru, "hole_to_hole"), Some(0.25));
        assert_eq!(dru_via_min(dru, "edge_clearance"), None);
        let d = std::env::temp_dir().join(format!("aiflow-dru-{}", std::process::id()));
        std::fs::create_dir_all(&d).unwrap();
        let board = d.join("b.kicad_pcb");
        let path = board.display().to_string();
        // no rules file: the spec as it is, and the defaults
        let s = with_project_rules(&json!({}), &path);
        assert_eq!((via_clearance(&s), hole_to_hole(&s)), (0.2, 0.2));
        std::fs::write(d.join("b.kicad_dru"), dru).unwrap();
        let s = with_project_rules(&json!({}), &path);
        assert!((via_clearance(&s) - 0.1016).abs() < 1e-9 && hole_to_hole(&s) == 0.25, "{s}");
        assert_eq!(s["viaClearanceFrom"], "kicad_dru");
        // the spec's own numbers win
        let s = with_project_rules(&json!({"clearance": 0.3, "holeToHole": 0.5}), &path);
        assert_eq!((via_clearance(&s), hole_to_hole(&s)), (0.3, 0.5));
        assert!(s.get("viaClearance").is_none());
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn stitch_vias_default_to_the_spec_or_the_boards_via_and_honour_size() {
        let text = |vias: &str| format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) {vias})");
        let bare = Board::from_text("f", text("")).unwrap();
        assert_eq!(stitch_via_default(&json!({}), &bare), (0.6, 0.3, "default"));
        let board = Board::from_text("f", text("(via (at 2 2) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net 1)) (via (at 4 2) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net 1)) (via (at 6 2) (size 0.8) (drill 0.4) (layers \"F.Cu\" \"B.Cu\") (net 1))")).unwrap();
        assert_eq!(stitch_via_default(&json!({}), &board).0, 0.4);
        assert_eq!(stitch_via_default(&json!({"viaSignal": [0.45, 0.2]}), &board), (0.45, 0.2, "spec viaSignal"));
        // a stitch via with no size takes the board's 0.4 mm via; one with size and drill keeps them
        let spec = json!({"stitchVias": [{"net": "GND", "x": 10.0, "y": 10.0}, {"net": "GND", "x": 14.0, "y": 14.0, "size": 0.5, "drill": 0.25}]});
        let (_, vias, _) = build(&board, &spec, None).unwrap();
        assert_eq!((vias[0]["viaSize"].as_f64(), vias[0]["viaDrill"].as_f64()), (Some(0.4), Some(0.2)));
        assert_eq!((vias[1]["viaSize"].as_f64(), vias[1]["viaDrill"].as_f64()), (Some(0.5), Some(0.25)));
        // the case in the issue: 0.7 mm from a 0.4 mm via clears with the board's 0.4 mm via, not with 0.8 mm
        let near = json!({"stitchVias": [{"net": "GND", "x": 2.0, "y": 2.7}]});
        assert_eq!(build(&board, &near, None).unwrap().1.len(), 1);
        let mut big = near.clone();
        big["stitchVias"][0]["size"] = json!(0.8);
        big["stitchVias"][0]["drill"] = json!(0.4);
        assert!(build(&board, &big, None).unwrap().1.is_empty());
    }

    #[test]
    fn ring_thermal_vias_keep_clear_of_the_silkscreen() {
        // a SOT-23-ish pin on GND with the part's reference printed right beside it
        let fp = |label: &str| Board::from_text("f", format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"SOT\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"U3\" (at 0 0) (layer \"F.SilkS\") (hide yes)) {label} (pad \"2\" smd rect (at 0 0) (size 0.6 0.7) (layers \"F.Cu\") (net 1 \"GND\"))))")).unwrap();
        let spec = json!({"thermalVias": [{"pad": "U3.2", "net": "GND", "count": 4}]});
        let free = fp("");
        let (_, vias, _) = build(&free, &spec, None).unwrap();
        assert_eq!(vias.len(), 4);
        let labelled = fp("(fp_text user \"U3\" (at 1.2 0 0) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))");
        let silk = aiflow_board::silk::silk_shapes(&labelled);
        assert_eq!(silk.len(), 1);
        let (_, vias, _) = build(&labelled, &spec, None).unwrap();
        assert!(!vias.is_empty());
        for v in &vias {
            let (x, y) = (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap());
            assert!(silk[0].distance((x, y)) >= 0.3 + 0.1 - 1e-6, "via at ({x}, {y}) under the label");
        }
    }
}