main
Colby Knox adom-fields 0.10.7: the board's ground (issue 25); coincident lands as one terminal (issue 26); verify keeps via copper… 554eeec
//! Fix and verify (0.9.3): apply fix-plan steps to a copy of the board file, solve the copy, and report what changed.
//! Only copper edits the plan spells out are applied, and only where they fit:
//!   add-vias  a through via at each point of the action (else near `at`, `count` of them), placed only where it lands
//!             on the net's own copper on two or more layers and keeps 0.2 mm from every other net's copper;
//!   widen     the net's segments within 2.5 mm of `at` (on `layer` when given) to `toMm` (else twice as wide),
//!             each grown only as far as 0.2 mm clearance to other nets allows.
//! Everything else (add a part, reroute, move, review) is refused with the reason: those need a person or the AI's
//! layout pass. The copy is not refilled: zone fills stay as saved, so refill and run DRC in KiCad before adopting it.
use crate::board::Board;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::path::Path;

const CLR: f64 = 0.2;
/// KiCad's default copper-to-board-edge clearance; a new via's copper keeps at least this from the outline
const EDGE_CLR: f64 = 0.5;

fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
    let (dx, dy) = (b.0 - a.0, b.1 - a.1); let l2 = dx * dx + dy * dy;
    let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };
    (p.0 - a.0 - t * dx).hypot(p.1 - a.1 - t * dy)
}
fn seg_seg(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> f64 {
    let cross = |o: (f64, f64), p: (f64, f64), q: (f64, f64)| (p.0 - o.0) * (q.1 - o.1) - (p.1 - o.1) * (q.0 - o.0);
    let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));
    if d1 * d2 < 0.0 && d3 * d4 < 0.0 { return 0.0; }
    seg_dist(a, c, d).min(seg_dist(b, c, d)).min(seg_dist(c, a, b)).min(seg_dist(d, a, b))
}
fn inside(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
    let mut c = false; let n = poly.len();
    for i in 0..n { let (a, b) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } }
    c
}
/// distance from a segment (a, b) to a filled polygon: 0 inside
fn poly_dist(a: (f64, f64), b: (f64, f64), poly: &[(f64, f64)]) -> f64 {
    if inside(a, poly) || inside(b, poly) { return 0.0; }
    let n = poly.len(); (0..n).map(|i| seg_seg(a, b, poly[i], poly[(i + 1) % n])).fold(f64::MAX, f64::min)
}

/// The clearance from a stroke (a to b, width w) on `layer` (None: every layer, a via) to the nearest copper of another net.
fn room(b: &Board, net: &str, layer: Option<&str>, a: (f64, f64), bb: (f64, f64), w: f64, soft: bool) -> f64 {
    let on = |l: &str| layer.map_or(true, |x| x == l || l == "*.Cu");
    let mut m = f64::MAX;
    for t in &b.tracks { if t.net != net && on(&t.layer) { m = m.min(seg_seg(a, bb, t.a, t.b) - t.w / 2.0); } }
    for v in &b.vias { if v.net != net { m = m.min(seg_dist((v.x, v.y), a, bb) - v.size / 2.0); } }
    for v in &b.plated_holes { if v.net != net { m = m.min(seg_dist((v.x, v.y), a, bb) - v.size / 2.0); } }
    // a pour's fill retreats from the new copper when the zones are refilled (soft); a pad does not
    for p in b.polys.iter().filter(|_| !soft).chain(b.pad_polys.iter()) { if p.net != net && on(&p.layer) && p.pts.len() > 2 { m = m.min(poly_dist(a, bb, &p.pts)); } }
    // unconnected copper (net "") still conducts, so it counts; holes without copper are kept clear the same way
    m - w / 2.0
}

/// the layers where the net's own copper is at p
fn own_layers(b: &Board, net: &str, p: (f64, f64)) -> Vec<String> {
    let mut v: Vec<String> = Vec::new();
    for t in &b.tracks { if t.net == net && seg_dist(p, t.a, t.b) <= t.w / 2.0 { v.push(t.layer.clone()); } }
    for q in b.polys.iter().chain(b.pad_polys.iter()) { if q.net == net && q.pts.len() > 2 && inside(p, &q.pts) { v.push(q.layer.clone()); } }
    v.sort(); v.dedup(); v
}

/// one top-level s-expression block per (start, end) byte span, for the heads asked
fn blocks(text: &str, head: &str) -> Vec<(usize, usize)> {
    let pat = format!("({head}"); let bytes = text.as_bytes(); let mut out = Vec::new(); let mut i = 0;
    while let Some(k) = text[i..].find(&pat) {
        let s = i + k; let after = bytes.get(s + pat.len()).copied().unwrap_or(b' ');
        if !(after.is_ascii_whitespace() || after == b')') { i = s + pat.len(); continue; }
        let (mut depth, mut j, mut q) = (0i32, s, false);
        while j < bytes.len() { let c = bytes[j]; if c == b'"' && bytes[j.saturating_sub(1)] != b'\\' { q = !q; } else if !q { if c == b'(' { depth += 1; } else if c == b')' { depth -= 1; if depth == 0 { break; } } } j += 1; }
        out.push((s, j + 1)); i = j + 1;
    }
    out
}

fn uuid(seed: &str) -> String { let h = format!("{:x}", Sha256::digest(seed.as_bytes())); format!("{}-{}-4{}-a{}-{}", &h[0..8], &h[8..12], &h[13..16], &h[17..20], &h[20..32]) }

pub struct Edit { pub step: u64, pub kind: String, pub net: String, pub kind_action: String, pub applied: Vec<Value>, pub refused: Option<String> }

/// Apply the selected steps of a fix plan to the board text; returns the new text and what happened per step.
pub fn apply(text: &str, b0: &Board, plan: &[Value], soft: bool) -> (String, Vec<Edit>) {
    let mut text = text.to_string();
    // each edit sees the ones before it: the vias added and the segments widened go into this copy of the board
    let mut b = b0.clone();
    // the board's own via size (the most common), and how it names nets: `(net "GND")` (KiCad 9 and later) or `(net 3)`
    let mut sizes: std::collections::BTreeMap<(i64, i64), usize> = Default::default();
    for v in &b.vias { *sizes.entry(((v.size * 1000.0).round() as i64, (v.drill * 1000.0).round() as i64)).or_default() += 1; }
    let (vs, vd) = sizes.iter().max_by_key(|e| e.1).map(|(k, _)| (k.0 as f64 / 1000.0, k.1 as f64 / 1000.0)).unwrap_or((0.6, 0.3));
    let numbered: std::collections::BTreeMap<String, String> = {
        let root = crate::sexp::parse(&text); root.kids("net").iter().filter_map(|n| Some((n.atom(2)?.to_string(), match n.list().get(1)? { crate::sexp::Sx::Num(x) => format!("{}", *x as i64), crate::sexp::Sx::Atom(a) => a.clone(), _ => return None }))).collect()
    };
    let net_tok = |n: &str| match numbered.get(n) { Some(id) => format!("(net {id})"), None => format!("(net \"{}\")", n.replace('"', "\\\"")) };
    let (top, bot) = (b.copper.first().cloned().unwrap_or("F.Cu".into()), b.copper.last().cloned().unwrap_or("B.Cu".into()));
    let mut placed: Vec<(f64, f64)> = Vec::new();
    let mut new_vias = String::new();
    let mut widths: Vec<(usize, usize, String)> = Vec::new();
    let mut edits = Vec::new();
    // a grouped finding carries each item's own action: carry them out one by one under the group's step number
    let plan: Vec<Value> = plan.iter().flat_map(|st| if st["action"]["type"] == "group" { st["action"]["items"].as_array().cloned().unwrap_or_default().into_iter().filter(|it| it["action"].is_object()).map(|it| json!({"step": st["step"], "kind": st["kind"], "action": it["action"], "at": it["at"]})).collect::<Vec<_>>() } else { vec![st.clone()] }).collect();
    for st in &plan {
        let a = &st["action"]; let ty = a["type"].as_str().unwrap_or("").to_string();
        let mut e = Edit { step: st["step"].as_u64().unwrap_or(0), kind: st["kind"].as_str().unwrap_or("").into(), net: a["net"].as_str().unwrap_or("").into(), kind_action: ty.clone(), applied: Vec::new(), refused: None };
        let net = a["net"].as_str().unwrap_or("").to_string();
        let at = a["at"].as_array().or_else(|| st["at"].as_array()).and_then(|v| Some((v.first()?.as_f64()?, v.get(1)?.as_f64()?)));
        match ty.as_str() {
            "add-vias" if !net.is_empty() => {
                // the via's copper, not its centre, keeps KiCad's default 0.5 mm board edge clearance
                let edge = EDGE_CLR + vs / 2.0;
                let fits = |p: (f64, f64), placed: &[(f64, f64)]| p.0 > b.x0 + edge && p.0 < b.x1 - edge && p.1 > b.y0 + edge && p.1 < b.y1 - edge
                    && own_layers(&b, &net, p).len() >= 2 && room(&b, &net, None, p, p, vs, soft) >= CLR
                    && b.vias.iter().filter(|v| v.net == net).chain(std::iter::empty()).all(|v| (v.x - p.0).hypot(v.y - p.1) >= vs + CLR)
                    && placed.iter().all(|q| (q.0 - p.0).hypot(q.1 - p.1) >= vs + CLR);
                // the nearest spot that fits, within r of p, on a 0.1 mm spiral of rings
                let near = |p: (f64, f64), r: f64, placed: &[(f64, f64)]| -> Option<(f64, f64)> {
                    if fits(p, placed) { return Some(p); }
                    let mut rr = 0.1; while rr <= r + 1e-9 { let n = ((2.0 * std::f64::consts::PI * rr / 0.1).ceil() as usize).max(8); for k in 0..n { let t = k as f64 / n as f64 * std::f64::consts::TAU; let q = (p.0 + rr * t.cos(), p.1 + rr * t.sin()); if fits(q, placed) { return Some(q); } } rr += 0.1; } None
                };
                let pts: Vec<(f64, f64)> = a["points"].as_array().map(|v| v.iter().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect()).unwrap_or_default();
                let mut miss = 0;
                if !pts.is_empty() { for p in pts { match near(p, 1.0, &placed) { Some(q) => placed.push(q), None => { miss += 1; continue; } } let q = *placed.last().unwrap(); e.applied.push(json!({"via": [r2(q.0), r2(q.1)], "movedMm": r2((q.0 - p.0).hypot(q.1 - p.1))})); } }
                else if let Some(p) = at {
                    let want = a["count"].as_u64().unwrap_or(match e.kind.as_str() { "thermal-vias" => 4, "ground-offset" => 3, _ => 1 }) as usize;
                    for _ in 0..want { match near(p, 2.5, &placed) { Some(q) => { placed.push(q); e.applied.push(json!({"via": [r2(q.0), r2(q.1)], "movedMm": r2((q.0 - p.0).hypot(q.1 - p.1))})); } None => { miss += 1; } } }
                } else { e.refused = Some("the action has no points and no `at`".into()); }
                for v in &e.applied { let q = &v["via"]; let (x, y) = (q[0].as_f64().unwrap(), q[1].as_f64().unwrap());
                    b.vias.push(crate::board::Via { net: net.clone(), x, y, size: vs, drill: vd, drill_long: None, drill_angle: 0.0 });
                    new_vias.push_str(&format!("\t(via\n\t\t(at {x} {y})\n\t\t(size {vs})\n\t\t(drill {vd})\n\t\t(layers \"{top}\" \"{bot}\")\n\t\t{}\n\t\t(uuid \"{}\")\n\t)\n", net_tok(&net), uuid(&format!("adom-fields verify {net} {x} {y}")))); }
                if e.applied.is_empty() && e.refused.is_none() { e.refused = Some(format!("no spot near the point(s) lands on {net}'s copper on two layers with {CLR} mm clearance to other nets")); }
                else if miss > 0 { e.applied.push(json!({"skipped": miss, "why": format!("no spot within reach lands on {net}'s copper on two layers with {CLR} mm to other nets' copper")})); }
            }
            "widen" if !net.is_empty() => {
                let Some(p) = at else { e.refused = Some(if a["target"].is_number() { "an impedance width change: use the review's `impedance` rows for the width (not applied automatically yet)".into() } else { "the action has no `at`".into() }); edits.push(e); continue };
                let layer = a["layer"].as_str();
                let mut hit = 0;
                for (s, t) in blocks(&text, "segment") {
                    let sg = crate::sexp::parse(&text[s..t]);
                    let n = sg.kid("net").and_then(|n| match n.list().get(1)? { crate::sexp::Sx::Num(x) => Some(format!("{}", *x as i64)), crate::sexp::Sx::Atom(a) => Some(a.clone()), _ => None }).map(|x| numbered.iter().find(|(_, id)| **id == x).map(|(k, _)| k.clone()).unwrap_or(x)).unwrap_or_default();
                    let ly = sg.kid("layer").and_then(|l| l.atom(1)).unwrap_or("");
                    if n != net || layer.is_some_and(|l| l != ly) { continue; }
                    let (Some(st0), Some(en)) = (sg.kid("start"), sg.kid("end")) else { continue };
                    let (pa, pb) = ((st0.num(1).unwrap_or(0.0), st0.num(2).unwrap_or(0.0)), (en.num(1).unwrap_or(0.0), en.num(2).unwrap_or(0.0)));
                    if seg_dist(p, pa, pb) > 2.5 { continue; }
                    let w0 = sg.kid("width").and_then(|w| w.num(1)).unwrap_or(0.2);
                    let goal = a["toMm"].as_f64().unwrap_or(w0 * 2.0).max(w0);
                    // the widest that keeps the clearance, in 0.05 mm steps down from the goal
                    let mut w = goal; while w > w0 + 1e-9 && room(&b, &net, Some(ly), pa, pb, w, soft) < CLR { w -= 0.05; }
                    if w <= w0 + 0.01 { continue; }
                    let w = r2(w);
                    if let Some(k) = text[s..t].find("(width ") { let ws = s + k; let we = ws + text[ws..t].find(')').unwrap() + 1; widths.push((ws, we, format!("(width {w})"))); hit += 1;
                        for tr in b.tracks.iter_mut().filter(|tr| tr.net == net && tr.layer == ly && tr.a == pa && tr.b == pb) { tr.w = w; }
                        e.applied.push(json!({"segment": [[r2(pa.0), r2(pa.1)], [r2(pb.0), r2(pb.1)]], "layer": ly, "fromMm": w0, "toMm": w, "short": w + 1e-9 < goal})); }
                }
                if hit == 0 { e.refused = Some(format!("no {net} segment within 2.5 mm of ({:.1}, {:.1}){} could grow and keep {CLR} mm to other nets{}", p.0, p.1, layer.map(|l| format!(" on {l}")).unwrap_or_default(), if soft { " (a neck in a pour needs the pour reshaped, which is a layout change)" } else { " (other nets' pours count as fixed: --refill lets them retreat, as KiCad's refill would)" })); }
            }
            "add-vias" | "widen" => e.refused = Some("the action names no net".into()),
            "" => e.refused = Some("no action".into()),
            t => e.refused = Some(format!("`{t}` is a layout or part change, not a copper edit: the AI or a person makes it, then `verify --board` the edited file")),
        }
        edits.push(e);
    }
    widths.sort_by_key(|w| std::cmp::Reverse(w.0));
    for (s, t, r) in widths { text.replace_range(s..t, &r); }
    if !new_vias.is_empty() { if let Some(end) = text.rfind(')') { text.insert_str(end, &new_vias); } }
    (text, edits)
}

fn n(v: &Value) -> Option<f64> { v.as_f64() }
fn delta(name: &str, a: Option<f64>, b: Option<f64>, unit: &str, better_low: bool) -> Option<Value> {
    let (a, b) = (a?, b?); if r2(a) == r2(b) { return None; }
    Some(json!({"metric": name, "before": r2(a), "after": r2(b), "unit": unit, "better": if better_low { b < a } else { b > a }}))
}

/// The before/after comparison of two solves.
pub fn compare(before: &Value, after: &Value, edits: &[Edit]) -> Value {
    let key = |i: &Value| format!("{}|{}|{}", i["kind"].as_str().unwrap_or(""), i["net"].as_str().unwrap_or(""), i["reference"].as_str().unwrap_or(""));
    let bi = before["issues"].as_array().cloned().unwrap_or_default(); let ai = after["issues"].as_array().cloned().unwrap_or_default();
    let bk: Vec<String> = bi.iter().map(key).collect(); let ak: Vec<String> = ai.iter().map(key).collect();
    let cleared: Vec<Value> = bi.iter().filter(|i| !ak.contains(&key(i))).map(|i| json!({"severity": i["severity"], "kind": i["kind"], "what": i["what"]})).collect();
    let new: Vec<Value> = ai.iter().filter(|i| !bk.contains(&key(i))).map(|i| json!({"severity": i["severity"], "kind": i["kind"], "what": i["what"]})).collect();
    let mut m: Vec<Value> = Vec::new();
    let bn = before["nets"].as_array().cloned().unwrap_or_default();
    for x in after["nets"].as_array().into_iter().flatten() {
        let nm = x["net"].as_str().unwrap_or(""); let Some(y) = bn.iter().find(|y| y["net"] == nm) else { continue };
        if let Some(d) = delta(&format!("{nm} peak current density"), n(&y["peakJ"]), n(&x["peakJ"]), "A/mm²", true) { m.push(d); }
        if let Some(d) = delta(&format!("{nm} Joule loss"), n(&y["jouleW"]), n(&x["jouleW"]), "W", true) { m.push(d); }
    }
    let r = |v: &Value, p: &[&str]| { let mut c = v; for k in p { c = &c[*k]; } c.as_f64() };
    for (nm, p, u, lo) in [("peak rise, still air", &["heat", "peakRiseC"][..], "°C", true), ("peak rise, moving air", &["heat", "peakRiseMovingC"], "°C", true), ("ground offset", &["review", "ground", "offsetMv"], "mV", true), ("hot-loop emissions margin", &["review", "converter", "emissions", "worstMarginDb"], "dB", false), ("conducted emissions margin", &["review", "conductedEmissions", "worstMarginDb"], "dB", false), ("ground edge stitched", &["review", "ground", "stitching", "edgeCoveredPct"], "%", false)] {
        if let Some(d) = delta(nm, r(before, p), r(after, p), u, lo) { m.push(d); }
    }
    for c in after["review"]["cableEmissions"]["connectors"].as_array().into_iter().flatten() {
        let cj = c["connector"].as_str().unwrap_or(""); let b0 = before["review"]["cableEmissions"]["connectors"].as_array().and_then(|a| a.iter().find(|x| x["connector"] == cj).cloned());
        if let Some(d) = delta(&format!("{cj} cable emissions margin"), b0.and_then(|x| x["worstMarginDb"].as_f64()), c["worstMarginDb"].as_f64(), "dB", false) { m.push(d); }
    }
    for x in after["chips"].as_array().into_iter().flatten() {
        let rf = x["reference"].as_str().unwrap_or(""); let y = before["chips"].as_array().and_then(|a| a.iter().find(|y| y["reference"] == rf).cloned());
        if let Some(d) = delta(&format!("{rf} rise"), y.and_then(|y| y["riseC"].as_f64()), x["riseC"].as_f64(), "°C", true) { m.push(d); }
    }
    let steps: Vec<Value> = edits.iter().map(|e| {
        let still = ai.iter().any(|i| i["kind"].as_str() == Some(e.kind.as_str()) && (e.net.is_empty() || i["net"].as_str() == Some(e.net.as_str())));
        json!({"step": e.step, "kind": e.kind, "action": e.kind_action, "applied": e.applied, "refused": e.refused, "issueCleared": if e.refused.is_some() || e.applied.is_empty() { Value::Null } else { json!(!still) }})
    }).collect();
    let (up, down) = (m.iter().filter(|x| x["better"] == true).count(), m.iter().filter(|x| x["better"] == false).count());
    json!({"steps": steps, "cleared": cleared, "new": new, "issuesBefore": bi.len(), "issuesAfter": ai.len(), "metrics": m, "better": up, "worse": down,
        "readNote": "a net's peak current density is one cell: a widened trace can move it to a new corner where wide meets narrow; Joule loss, temperatures and drops are the robust measures"})
}

/// KiCad's own zone refill and DRC (errors only), saving the refilled board in place; the shim runs it on service-kicad or
/// the user's desktop. Returns the violations.
fn refill_drc(board: &Path, report: &Path) -> Result<Vec<Value>, String> {
    let o = std::process::Command::new("kicad-cli").args(["pcb", "drc", "--refill-zones", "--save-board", "--format", "json", "--severity-error", "-o"]).arg(report).arg(board).output().map_err(|e| format!("kicad-cli: {e} (--refill needs kicad-cli: service-kicad, or KiCad through Adom Bridge)"))?;
    let v: Value = std::fs::read_to_string(report).ok().and_then(|t| serde_json::from_str(&t).ok()).ok_or_else(|| format!("kicad-cli pcb drc gave no report: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("")))?;
    Ok(v["violations"].as_array().cloned().unwrap_or_default())
}
fn drc_key(v: &Value) -> String { format!("{}|{}", v["type"].as_str().unwrap_or(""), v["items"].as_array().into_iter().flatten().map(|i| format!("{:.1},{:.1}", i["pos"]["x"].as_f64().unwrap_or(0.0), i["pos"]["y"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(";")) }

/// The verify command: solve (or reuse) the original, apply the steps to a copy, solve the copy, compare. With refill,
/// both the original and the copy are refilled and DRC'd by KiCad first, so the two solves differ only by the fix.
pub fn run(board: &str, spec: &str, out: &Path, steps: &[u64], pitch: f64, reuse: Option<&Path>, refill: bool) -> Result<Value, String> {
    if !board.ends_with(".kicad_pcb") { return Err("verify edits a KiCad .kicad_pcb (it writes the fixed copy beside the solve)".into()); }
    std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
    let text = std::fs::read_to_string(board).map_err(|e| format!("{board}: {e}"))?;
    let sha = format!("{:x}", Sha256::digest(text.as_bytes()));
    let given = match reuse.map(|d| d.join("fields.json")).and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
        Some(v) if v["inputIdentity"]["board"].as_str() == Some(sha.as_str()) => Some(v),
        Some(_) => return Err("--fields is a solve of a different board file (its inputIdentity does not match); drop --fields to solve it here".into()),
        None => None,
    };
    let name = Path::new(board).file_name().unwrap().to_string_lossy().to_string();
    let orig_path = out.join(format!("original-{name}"));
    let mut before = match &given { Some(v) if !refill => v.clone(), _ => Value::Null };
    if before.is_null() && !refill { before = crate::run_analyze(board, spec, &out.join("before"), pitch, 60.0, None, true, &["all".to_string()])?; }
    let plan_src = given.clone().unwrap_or_else(|| before.clone());
    let b = Board::load(board)?;
    // with refill and no plan yet, the plan comes from the refilled original's solve (below); edits need it first, so
    // solve the board as saved for the plan when nothing else gives one
    let plan_src = if plan_src.is_null() { crate::run_analyze(board, spec, &out.join("plan"), pitch, 60.0, None, true, &["all".to_string()])? } else { plan_src };
    let plan: Vec<Value> = plan_src["fixPlan"].as_array().cloned().unwrap_or_default().into_iter().filter(|s| steps.is_empty() || steps.contains(&s["step"].as_u64().unwrap_or(0))).collect();
    if plan.is_empty() { return Err(if steps.is_empty() { "the fix plan is empty: nothing to verify".into() } else { format!("no fix-plan step {:?} (the plan has {})", steps, plan_src["fixPlan"].as_array().map_or(0, |a| a.len())) }); }
    let (fixed, edits) = apply(&text, &b, &plan, refill);
    let fixed_path = out.join(&name);
    if edits.iter().all(|e| e.applied.is_empty()) {
        return Ok(json!({"applied": 0, "board": Value::Null, "report": compare(&plan_src, &plan_src, &edits)}));
    }
    std::fs::write(&fixed_path, &fixed).map_err(|e| e.to_string())?;
    let mut drc = Value::Null;
    if refill {
        std::fs::write(&orig_path, &text).map_err(|e| e.to_string())?;
        // the two KiCad runs at once (each is a round trip to the desktop or the service)
        let (o2, f2) = (orig_path.clone(), fixed_path.clone()); let (r1p, r2p) = (out.join("drc-before.json"), out.join("drc-after.json"));
        let h = std::thread::spawn(move || refill_drc(&o2, &r1p));
        let va = refill_drc(&f2, &r2p)?; let vb = h.join().map_err(|_| "kicad-cli thread")??;
        let kb: Vec<String> = vb.iter().map(drc_key).collect();
        let new: Vec<Value> = va.iter().filter(|v| !kb.contains(&drc_key(v))).map(|v| json!({"type": v["type"], "description": v["description"], "items": v["items"].as_array().into_iter().flatten().map(|i| json!({"what": i["description"], "at": [i["pos"]["x"], i["pos"]["y"]]})).collect::<Vec<_>>()})).collect();
        drc = json!({"errorsBefore": vb.len(), "errorsAfter": va.len(), "new": new});
        before = crate::run_analyze(orig_path.to_str().unwrap(), spec, &out.join("before"), pitch, 60.0, None, true, &["all".to_string()])?;
    }
    let after = crate::run_analyze(fixed_path.to_str().unwrap(), spec, &out.join("after"), pitch, 60.0, None, true, &["all".to_string()])?;
    let report = compare(&before, &after, &edits);
    let v = json!({"applied": edits.iter().filter(|e| !e.applied.is_empty()).count(), "board": fixed_path.display().to_string(), "refilled": refill, "drc": drc, "before": out.join("before").display().to_string(), "after": out.join("after").display().to_string(),
        "note": if refill { "both boards refilled by KiCad before solving; `drc.new` lists errors the fix introduced (none: it is clean to adopt)" } else { "zone fills are as saved: refill the zones and run DRC in KiCad before adopting the fixed board (or run verify with --refill)" }, "report": report});
    std::fs::write(out.join("verify.json"), serde_json::to_string_pretty(&v).unwrap()).map_err(|e| e.to_string())?;
    Ok(v)
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn geometry() {
        assert!((seg_dist((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)) - 1.0).abs() < 1e-12);
        assert_eq!(seg_seg((0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)), 0.0);
        let sq = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];
        assert!(inside((1.0, 1.0), &sq) && !inside((3.0, 1.0), &sq));
        assert!((poly_dist((3.0, 1.0), (4.0, 1.0), &sq) - 1.0).abs() < 1e-12);
    }
    #[test] fn block_spans() {
        let t = "(kicad_pcb (segment (start 0 0) (end 1 0) (width 0.2) (net \"A\")) (segments x) (segment (start 1 0) (end 2 0) (width 0.3)))";
        let b = blocks(t, "segment"); assert_eq!(b.len(), 2); assert!(t[b[1].0..b[1].1].contains("0.3"));
        let sg = crate::sexp::parse(&t[b[0].0..b[0].1]); assert_eq!(sg.head(), "segment"); assert!(sg.kid("start").is_some());
    }
}