main
John Lauer Publish 0.2.59 82daddc
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
//! Plans to copper on a board file, and KiCad's DRC on the result.
//!
//! `apply` writes a routing plan (segments and vias as `kicad_route_net` would land them) into a
//! copy of the board. With a native KiCad (kicad-cli 9+) the DRC refills every zone itself
//! (`--refill-zones`), so the pours are KiCad's own fill; only for the shared headless DRC
//! (`service-kicad pcb drc`, which does not refill zones) does `apply` add a raster fill, so plane
//! vias count as connected there.
//! `run_drc` and `summarize` give the gate its numbers: errors, new versus inherited against a
//! baseline, unconnected items.
use std::collections::{BTreeMap, HashMap};
use std::process::Command;

pub mod single_face;

use aiflow_board::{num, Board, Pt};
use aiflow_grid::{Grid, Mask};
use serde_json::{json, Value};

pub const CLEARANCE: f64 = 0.2;
pub const EDGE_CLEARANCE: f64 = 0.5;
pub const SLOP: f64 = 0.1;

#[derive(Clone, Debug)]
pub struct Seg {
    pub x0: f64,
    pub y0: f64,
    pub x1: f64,
    pub y1: f64,
    pub layer: String,
    pub width: f64,
}

#[derive(Clone, Debug)]
pub struct ViaSpec {
    pub x: f64,
    pub y: f64,
    pub size: f64,
    pub drill: f64,
}

fn uuid() -> String {
    // v4-shaped from a time and counter seed: unique enough for a gate copy
    use std::sync::atomic::{AtomicU64, Ordering};
    static N: AtomicU64 = AtomicU64::new(0);
    let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) as u64;
    let n = N.fetch_add(1, Ordering::Relaxed);
    let mut h = t ^ n.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;
    let mut parts = Vec::new();
    for _ in 0..4 {
        h ^= h >> 33;
        h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
        h ^= h >> 33;
        parts.push(h);
    }
    format!("{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", parts[0] as u32, (parts[1] & 0xffff) as u16, (parts[1] >> 20) as u16 & 0xfff, 0x8000 | ((parts[2] & 0x3fff) as u16), parts[3] & 0xffff_ffff_ffff)
}

fn point(spec: &Value, board: &Board) -> Result<(f64, f64, Option<usize>, Option<String>), String> {
    if let Some(key) = spec.as_str() {
        let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;
        return Ok((board.pads[i].x, board.pads[i].y, Some(i), None));
    }
    if let Some(o) = spec.as_object() {
        let layer = o.get("layer").and_then(|l| l.as_str()).map(str::to_string);
        if let Some(key) = o.get("pad").and_then(|p| p.as_str()) {
            let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;
            return Ok((board.pads[i].x, board.pads[i].y, Some(i), layer));
        }
        return Ok((o.get("x").and_then(|v| v.as_f64()).ok_or("waypoint needs x")?, o.get("y").and_then(|v| v.as_f64()).ok_or("waypoint needs y")?, None, layer));
    }
    if let Some(a) = spec.as_array() {
        if a.len() == 2 {
            return Ok((a[0].as_f64().ok_or("bad x")?, a[1].as_f64().ok_or("bad y")?, None, None));
        }
    }
    Err(format!("bad waypoint {spec}"))
}

/// The pad key of a waypoint (`"J1.MP"` or `{"pad": "J1.MP", ...}`), if it is one.
fn waypoint_key(wp: &Value) -> Option<&str> {
    wp.as_str().or_else(|| wp.get("pad").and_then(|p| p.as_str()))
}

/// Issue #93: a key that names two pads (a Hirose FH12's two `MP` pads) is ambiguous. The gate took one
/// pad and the bridge another, so a GND path gated clean landed 10 mm the other way. Every such key is
/// replaced by the coordinates of the pad it means: of the pads with that key, those on the entry's net
/// (all, when none is), the one nearest the path's neighbouring waypoint (the one before, else the
/// one after). The gate and `land route` then both see the same point. Returns one note per change.
pub fn disambiguate_pads(board: &Board, net: &str, pts: &mut [Value], where_: &str) -> Vec<String> {
    let mut notes = Vec::new();
    let fixed = |wp: &Value| -> Option<Pt> {
        if let Some(k) = waypoint_key(wp) { return if board.key_is_ambiguous(k) { None } else { board.pad_by_key(k).map(|p| (p.x, p.y)) }; }
        point(wp, board).ok().map(|(x, y, _, _)| (x, y))
    };
    for i in 0..pts.len() {
        let Some(key) = waypoint_key(&pts[i]).map(str::to_string) else { continue };
        if !board.key_is_ambiguous(&key) { continue; }
        let all = board.pads_with_key(&key);
        let on_net: Vec<_> = all.iter().filter(|p| p.net == net).copied().collect();
        let cands = if on_net.is_empty() { all } else { on_net };
        let near = (0..i).rev().find_map(|k| fixed(&pts[k])).or_else(|| (i + 1..pts.len()).find_map(|k| fixed(&pts[k])));
        let pad = match near {
            Some(q) => cands.iter().min_by(|a, b| (a.x - q.0).hypot(a.y - q.1).total_cmp(&(b.x - q.0).hypot(b.y - q.1))).copied(),
            None => cands.first().copied(),
        };
        let Some(pad) = pad else { continue };
        let (x, y) = (pad.x, pad.y);
        let new = match pts[i].as_object() {
            Some(o) => {
                let mut m = o.clone();
                m.remove("pad");
                m.insert("x".into(), json!(x));
                m.insert("y".into(), json!(y));
                Value::Object(m)
            }
            None => json!([x, y]),
        };
        notes.push(format!("{where_}: {key} names {} pads; the waypoint is the one at ({}, {}){}", board.pads_with_key(&key).len(), num(x), num(y), if near.is_some() { ", nearest the path's neighbouring point" } else { "" }));
        pts[i] = new;
    }
    notes
}

/// Make a plan say only what it lands, once, so the offline gate and `land route` agree (issue #33).
/// A waypoint at the same point and on the same layer as the one before it is dropped (it would
/// be a zero-length segment the bridge refuses mid-landing); the same point on another layer is a
/// via and stays. A path left with fewer than two waypoints has no geometry and is dropped, and an
/// entry left with no path is dropped. Returns one note per change, naming the net and the path.
/// Waypoints that do not resolve (an unknown pad) are kept as they are for the gate to report.
pub fn normalise_plan(board: &Board, plan: &mut Value) -> Vec<String> {
    let mut notes = Vec::new();
    let entries = match plan.get_mut("nets").and_then(|n| n.as_array_mut()) {
        Some(a) => a,
        None => match plan.as_array_mut() { Some(a) => a, None => return notes },
    };
    let mut keep_entries = Vec::with_capacity(entries.len());
    for (ei, mut e) in std::mem::take(entries).into_iter().enumerate() {
        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("?").to_string();
        let entry_layer = e.get("layer").and_then(|l| l.as_str()).map(str::to_string);
        let key = if e.get("paths").map(|p| p.is_array()).unwrap_or(false) { "paths" } else if e.get("points").map(|p| p.is_array()).unwrap_or(false) { "points" } else { keep_entries.push(e); continue };
        let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };
        let mut kept_paths = Vec::with_capacity(paths.len());
        for (pi, path) in paths.into_iter().enumerate() {
            let Some(pts) = path.as_array() else { kept_paths.push(path); continue };
            let where_ = if key == "paths" { format!("{net} (entry {}, path {})", ei + 1, pi + 1) } else { format!("{net} (entry {})", ei + 1) };
            let mut pts = pts.clone();
            notes.extend(disambiguate_pads(board, &net, &mut pts, &where_));
            // issue #139: a path that names no layer and starts at (or runs first through) a bottom part's
            // pad is on B.Cu; say so on its first waypoint, so `land route` sends the layer the gate saw
            if entry_layer.is_none() && !pts.is_empty() {
                let res: Vec<Option<(Option<usize>, bool)>> = pts.iter().map(|w| point(w, board).ok().map(|(_, _, pad, ex)| (pad, ex.is_some()))).collect();
                if res.iter().all(|r| r.is_some()) && !res[0].unwrap().1 && Board::path_start_layer(res.iter().map(|r| { let (pad, ex) = r.unwrap(); (pad.map(|i| &board.pads[i]), ex) })) == "B.Cu" {
                    let first = pts[0].clone();
                    pts[0] = match &first {
                        Value::String(k) => json!({"pad": k, "layer": "B.Cu"}),
                        Value::Array(a) => json!({"x": a[0], "y": a[1], "layer": "B.Cu"}),
                        Value::Object(o) => { let mut m = o.clone(); m.insert("layer".into(), json!("B.Cu")); Value::Object(m) }
                        _ => first.clone(),
                    };
                    notes.push(format!("{where_}: starts on a bottom-side pad with no layer named; set B.Cu on its first waypoint (the pad has no copper on F.Cu)"));
                }
            }
            let pts = &pts;
            let mut layer: Option<String> = entry_layer.clone();
            let mut prev: Option<(f64, f64)> = None;
            let mut out: Vec<Value> = Vec::with_capacity(pts.len());
            let mut unresolved = false;
            for wp in pts {
                let Ok((x, y, _, explicit)) = point(wp, board) else { unresolved = true; out.push(wp.clone()); prev = None; continue };
                let cur = layer.clone().or_else(|| explicit.clone()).unwrap_or_else(|| "F.Cu".to_string());
                let nxt = explicit.unwrap_or_else(|| cur.clone());
                if let Some((px, py)) = prev {
                    if (px - x).abs() < 1e-6 && (py - y).abs() < 1e-6 && nxt == cur {
                        notes.push(format!("{where_}: dropped a repeated waypoint at ({}, {}) on {cur}", num(x), num(y)));
                        continue;
                    }
                }
                out.push(wp.clone());
                layer = Some(nxt);
                prev = Some((x, y));
            }
            if out.len() < 2 && !unresolved {
                notes.push(format!("{where_}: dropped the path, no length left ({} waypoint{})", out.len(), if out.len() == 1 { "" } else { "s" }));
                continue;
            }
            kept_paths.push(Value::Array(out));
        }
        if kept_paths.is_empty() {
            notes.push(format!("{net} (entry {}): dropped the entry, no geometry left", ei + 1));
            continue;
        }
        if key == "paths" { e["paths"] = Value::Array(kept_paths); } else { e["points"] = kept_paths.into_iter().next().unwrap(); }
        keep_entries.push(e);
    }
    *entries = keep_entries;
    // issue #183: a plan for a board with no vias changes layer only on a through-hole pad, one path per face
    if plan.get("vias").and_then(|v| v.as_bool()) == Some(false) {
        notes.extend(single_face::split_at_pads(board, plan));
    }
    notes
}

/// One plan entry to its segments and vias.
pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<ViaSpec>), String> {
    let net = entry.get("net").and_then(|n| n.as_str()).ok_or_else(|| format!("entry needs net: {PLAN_SHAPE}"))?;
    if !board.nets.contains_key(net) {
        return Err(format!("unknown net {net:?}"));
    }
    let width = entry.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);
    let size = entry.get("viaSize").and_then(|w| w.as_f64()).unwrap_or(0.6);
    let drill = entry.get("viaDrill").and_then(|w| w.as_f64()).unwrap_or(0.3);
    let paths: Vec<Value> = match entry.get("paths").and_then(|p| p.as_array()) {
        Some(p) => p.clone(),
        None => vec![entry.get("points").cloned().ok_or_else(|| format!("entry needs paths or points: {PLAN_SHAPE}"))?],
    };
    let mut segments = Vec::new();
    let mut vias = Vec::new();
    for path in paths {
        let pts = path.as_array().ok_or("path must be a list")?;
        if pts.len() < 2 || pts.len() > 512 {
            return Err(format!("path needs 2..512 waypoints ({net})"));
        }
        let resolved: Vec<(f64, f64, Option<usize>, Option<String>)> = pts.iter().map(|p| point(p, board)).collect::<Result<_, _>>()?;
        for (_, _, pad, _) in &resolved {
            if let Some(i) = pad {
                if board.pads[*i].net != net {
                    return Err(format!("pad {} is on {}, not {}", board.pads[*i].key, board.pads[*i].net, net));
                }
            }
        }
        // issue #139: with no layer named, a path from a bottom part's pad starts on B.Cu
        let mut layer = entry.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| resolved[0].3.clone())
            .unwrap_or_else(|| Board::path_start_layer(resolved.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());
        for (i, (x, y, pad, explicit)) in resolved.iter().enumerate() {
            let nxt = explicit.clone().unwrap_or_else(|| layer.clone());
            if i > 0 {
                let (px, py) = (resolved[i - 1].0, resolved[i - 1].1);
                if (px, py) != (*x, *y) {
                    segments.push(Seg { x0: px, y0: py, x1: *x, y1: *y, layer: layer.clone(), width });
                }
            }
            if nxt != layer {
                vias.push(ViaSpec { x: *x, y: *y, size, drill });
                layer = nxt;
            }
            if let Some(pi) = pad {
                let p = &board.pads[*pi];
                let on = if layer == "F.Cu" { p.on_f } else if layer == "B.Cu" { p.on_b } else { p.thru };
                if !on {
                    return Err(format!("pad {} has no copper on {}", p.key, layer));
                }
            }
        }
    }
    Ok((segments, vias))
}

pub fn segment_sexpr(board: &Board, s: &Seg, net: &str) -> Result<String, String> {
    Ok(format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n{}\t\t(uuid \"{}\")\n\t)", num(s.x0), num(s.y0), num(s.x1), num(s.y1), num(s.width), s.layer, board.net_ref_sexpr(net)?, uuid()))
}

pub fn via_sexpr(board: &Board, v: &ViaSpec, net: &str) -> Result<String, String> {
    Ok(format!("\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"F.Cu\" \"B.Cu\")\n{}\t\t(uuid \"{}\")\n\t)", num(v.x), num(v.y), num(v.size), num(v.drill), board.net_ref_sexpr(net)?, uuid()))
}

/// Insert blocks before the closing paren of the kicad_pcb list.
pub fn splice(text: &str, blocks: &[String]) -> String {
    let trimmed = text.trim_end();
    let idx = trimmed.rfind(')').unwrap_or(trimmed.len());
    let body = text[..idx].trim_end();
    let nl = if text.get(..2000).unwrap_or(text).contains("\r\n") { "\r\n" } else { "\n" };
    let mut out = String::with_capacity(text.len() + blocks.iter().map(|b| b.len() + 2).sum::<usize>());
    out.push_str(body);
    out.push_str(nl);
    out.push_str(&blocks.iter().map(|b| b.replace('\n', nl)).collect::<Vec<_>>().join(nl));
    out.push_str(nl);
    out.push(')');
    out.push_str(nl);
    out
}

// ----------------------------------------------------------------------------- plane raster fill

type Lat = (f64, f64);

fn trace_boundaries(free: &Mask) -> Vec<Vec<Lat>> {
    let (w, h) = (free.w as i64, free.h as i64);
    let get = |i: i64, j: i64| -> bool { i >= 0 && j >= 0 && i < w && j < h && free.bits[(j * w + i) as usize] };
    let mut out: HashMap<(i64, i64), Vec<(i64, i64)>> = HashMap::new();
    // lattice points scaled by 2 to stay integral: corner (i-0.5, j-0.5) -> (2i-1, 2j-1)
    for j in 0..h {
        for i in 0..w {
            if !get(i, j) {
                continue;
            }
            let tl = (2 * i - 1, 2 * j - 1);
            let tr = (2 * i + 1, 2 * j - 1);
            let br = (2 * i + 1, 2 * j + 1);
            let bl = (2 * i - 1, 2 * j + 1);
            if !get(i, j - 1) { out.entry(tl).or_default().push(tr); }
            if !get(i + 1, j) { out.entry(tr).or_default().push(br); }
            if !get(i, j + 1) { out.entry(br).or_default().push(bl); }
            if !get(i - 1, j) { out.entry(bl).or_default().push(tl); }
        }
    }
    let mut loops = Vec::new();
    let mut keys: Vec<(i64, i64)> = out.keys().copied().collect();
    keys.sort();
    let mut ki = 0;
    while !out.is_empty() {
        while ki < keys.len() && !out.contains_key(&keys[ki]) {
            ki += 1;
        }
        if ki >= keys.len() {
            break;
        }
        let a = keys[ki];
        let b = out.get_mut(&a).unwrap().pop().unwrap();
        if out[&a].is_empty() {
            out.remove(&a);
        }
        let mut lp = vec![a, b];
        let mut prev = (b.0 - a.0, b.1 - a.1);
        loop {
            let cur = *lp.last().unwrap();
            let Some(cands) = out.get_mut(&cur) else { break };
            let nxt = if cands.len() == 1 {
                cands.pop().unwrap()
            } else {
                let right = (-prev.1, prev.0);
                let left = (prev.1, -prev.0);
                let mut pick = None;
                for want in [right, prev, left] {
                    if let Some(pos) = cands.iter().position(|c| (c.0 - cur.0, c.1 - cur.1) == want) {
                        pick = Some(cands.remove(pos));
                        break;
                    }
                }
                pick.unwrap_or_else(|| cands.pop().unwrap())
            };
            if cands.is_empty() {
                out.remove(&cur);
            }
            prev = (nxt.0 - cur.0, nxt.1 - cur.1);
            if nxt == lp[0] {
                break;
            }
            lp.push(nxt);
        }
        // drop collinear vertices
        let n = lp.len();
        let mut simp = Vec::new();
        for k in 0..n {
            let p0 = lp[(k + n - 1) % n];
            let p1 = lp[k];
            let p2 = lp[(k + 1) % n];
            if (p1.0 - p0.0) * (p2.1 - p1.1) - (p1.1 - p0.1) * (p2.0 - p1.0) != 0 {
                simp.push((p1.0 as f64 / 2.0, p1.1 as f64 / 2.0));
            }
        }
        if simp.len() >= 4 {
            loops.push(simp);
        }
    }
    loops
}

fn signed_area(poly: &[Lat]) -> f64 {
    aiflow_board::geom::polygon_area(poly)
}

fn leftmost_edge(h: &[Lat]) -> Option<(f64, usize)> {
    let n = h.len();
    let mut best: Option<(f64, usize)> = None;
    for k in 0..n {
        let (a, b) = (h[k], h[(k + 1) % n]);
        if a.0 == b.0 && best.map(|x| a.0 < x.0).unwrap_or(true) {
            best = Some((a.0, k));
        }
    }
    best
}

/// Splice every hole into the outer contour through a horizontal zero-width slit to the left.
fn fracture(outer: &[Lat], holes: &[Vec<Lat>]) -> Vec<Lat> {
    let mut poly: Vec<Lat> = outer.to_vec();
    let mut order: Vec<usize> = (0..holes.len()).collect();
    order.sort_by(|&a, &b| leftmost_edge(&holes[a]).map(|x| x.0).unwrap_or(0.0).partial_cmp(&leftmost_edge(&holes[b]).map(|x| x.0).unwrap_or(0.0)).unwrap());
    for hk in order {
        let h = &holes[hk];
        let Some((x_h, k)) = leftmost_edge(h) else { continue };
        let (a, b) = (h[k], h[(k + 1) % h.len()]);
        let mut ym = (a.1 + b.1) / 2.0;
        if (b.1 - a.1).abs() >= 2.0 {
            ym = a.1 + if b.1 > a.1 { 0.5 } else { -0.5 };
        }
        let start = (x_h, ym);
        let mut hole = vec![start];
        hole.extend_from_slice(&h[k + 1..]);
        hole.extend_from_slice(&h[..k + 1]);
        hole.push(start);
        let mut hit: Option<(f64, usize)> = None;
        for e in 0..poly.len() {
            let (p0, p1) = (poly[e], poly[(e + 1) % poly.len()]);
            if p0.0 == p1.0 && p0.0 < x_h && p0.1.min(p1.1) < ym && ym < p0.1.max(p1.1) && hit.map(|x| p0.0 > x.0).unwrap_or(true) {
                hit = Some((p0.0, e));
            }
        }
        let Some((xe, e)) = hit else { continue };
        let joint = (xe, ym);
        let mut np = poly[..e + 1].to_vec();
        np.push(joint);
        np.extend(hole);
        np.push(joint);
        np.extend_from_slice(&poly[e + 1..]);
        poly = np;
    }
    poly
}

/// One fractured polygon per connected region of the plane, in board mm.
pub fn zone_fill_polygons(board: &Board, grid: &Grid, zone_net: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> Vec<Vec<Pt>> {
    let mut free = grid.outline_inside(&board.outline, EDGE_CLEARANCE + SLOP);
    let mut block = grid.mask();
    let mut by_clearance: BTreeMap<i64, Mask> = BTreeMap::new();
    for p in &board.pads {
        if !p.thru || p.net == zone_net {
            continue;
        }
        let target = if p.clearance <= CLEARANCE { &mut block } else { by_clearance.entry((p.clearance * 1000.0) as i64).or_insert_with(|| grid.mask()) };
        for poly in &p.polys {
            grid.poly_mask(poly, target);
        }
        if let Some(d) = p.drill {
            for c in grid.disk_cells((p.x, p.y), d / 2.0, None) {
                target.bits[c] = true;
            }
        }
    }
    // drilled holes with no copper (mask-only NPTH): the pour keeps its clearance from the hole
    for h in &board.holes {
        grid.poly_mask(&h.poly, &mut block);
        for c in grid.disk_cells((h.x, h.y), h.size.0.min(h.size.1) / 2.0, None) {
            block.bits[c] = true;
        }
    }
    for (net, vias) in vias_by_net {
        if net == zone_net {
            continue;
        }
        for v in vias {
            for c in grid.disk_cells((v.x, v.y), v.size / 2.0, None) {
                block.bits[c] = true;
            }
        }
    }
    let mut blocked = grid.dilate(&block, (CLEARANCE + SLOP) / grid.res);
    for (clr, m) in &by_clearance {
        blocked.or_with(&grid.dilate(m, (*clr as f64 / 1000.0 + SLOP) / grid.res));
    }
    free.and_not(&blocked);
    let loops = trace_boundaries(&free);
    let outers: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) > 0.0).collect();
    let holes: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) < 0.0).collect();
    let mut polys = Vec::new();
    for outer in outers {
        let ob = aiflow_board::geom::bbox(outer);
        let mut mine: Vec<Vec<Lat>> = Vec::new();
        for h in &holes {
            let hb = aiflow_board::geom::bbox(h);
            if hb.0 < ob.0 || hb.1 < ob.1 || hb.2 > ob.2 || hb.3 > ob.3 {
                continue;
            }
            if aiflow_board::geom::point_in_poly(h[0].0 + 1e-6, h[0].1 + 1e-6, outer) {
                mine.push((*h).clone());
            }
        }
        let poly = fracture(outer, &mine);
        polys.push(poly.into_iter().map(|(x, y)| (grid.x0 + x * grid.res, grid.y0 + y * grid.res)).collect());
    }
    polys
}

/// Every top-level zone: (node start, node end, net, layer, connect_pads span).
fn zones_of(board: &Board) -> Vec<(usize, usize, String, Option<String>, Option<(usize, usize, bool)>)> {
    let mut out = Vec::new();
    for z in board.root.find_all("zone") {
        let mut net = z.find("net").and_then(|n| n.value()).unwrap_or("").to_string();
        if board.net_format == aiflow_board::NetFormat::Number {
            if let Some(k) = z.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {
                if let Some(nm) = board.net_table.get(&k) {
                    net = nm.clone();
                }
            }
        }
        if let Some(nn) = z.find("net_name").and_then(|n| n.value()) {
            net = nn.to_string();
        }
        let layer = z.find("layer").and_then(|l| l.value()).map(str::to_string);
        let cp = z.find("connect_pads").map(|c| (c.start, c.end, matches!(c.atom(1), Some("yes") | Some("no") | Some("thru_hole_only"))));
        out.push((z.start, z.end, net, layer, cp));
    }
    out
}

/// The board text with a filled_polygon list inside every zone (raster fill), and the counts.
pub fn fill_zones(board: &Board, text: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> (String, BTreeMap<String, usize>) {
    let grid = Grid::new(board.bbox);
    let mut edits: Vec<(usize, usize, String)> = Vec::new();
    let mut stats = BTreeMap::new();
    for (start, end, net, layer, cp) in zones_of(board) {
        let (Some(layer), false) = (layer, net.is_empty()) else { continue };
        let _ = start;
        let polys = zone_fill_polygons(board, &grid, &net, vias_by_net);
        let mut blocks = String::new();
        for poly in &polys {
            let pts: Vec<String> = poly.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();
            blocks.push_str(&format!("\t\t(filled_polygon\n\t\t\t(layer \"{}\")\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n", layer, pts.join(" ")));
        }
        edits.push((end - 1, end - 1, blocks + "\t"));
        if let Some((cs, _, explicit)) = cp {
            if !explicit {
                edits.push((cs, cs + "(connect_pads".len(), "(connect_pads yes".to_string()));
            }
        }
        stats.insert(net, polys.len());
    }
    edits.sort_by_key(|e| e.0);
    let mut out = String::with_capacity(text.len() + 4096);
    let mut pos = 0;
    for (at, end, ins) in edits {
        out.push_str(&text[pos..at]);
        out.push_str(&ins);
        pos = end;
    }
    out.push_str(&text[pos..]);
    (out, stats)
}

/// The board text with the plan's copper (and raster fills unless `fill` is false), and stats.
/// What a route plan must look like, for errors that refuse one. docs/plan-schema.md has the rest.
pub const PLAN_SHAPE: &str = "a route plan is {\"nets\": [entry, ...]} or a bare [entry, ...]; each entry is {\"net\": \"VIN\", \"width\": 0.25, \"viaSize\": 0.6, \"viaDrill\": 0.3, \"paths\": [[point, point, ...], ...]} (or one path as \"points\"), and a point is a pad key \"U1.3\", [x, y] in mm, {\"x\": x, \"y\": y, \"layer\": \"B.Cu\"} or {\"pad\": \"U1.3\", \"layer\": \"B.Cu\"}; a layer that differs from the current one drops a through via there (docs/plan-schema.md)";

/// The entries of a route plan: `{"nets": [...]}` or a bare array.
pub fn plan_entries(plan: &Value) -> Result<Vec<Value>, String> {
    plan.get("nets").and_then(|n| n.as_array()).cloned().or_else(|| plan.as_array().cloned())
        .ok_or_else(|| format!("plan needs nets: {PLAN_SHAPE}"))
}

/// A route plan in its object form: a bare array of entries becomes `{"nets": [...]}`, so every
/// reader of `plan["nets"]` (gate, land route, the via prechecks) sees the same entries.
pub fn normalize_plan(plan: Value) -> Value {
    match plan {
        Value::Array(a) => json!({"nets": a}),
        v => v,
    }
}

pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value), String> {
    let entries: Vec<Value> = plan_entries(plan)?;
    let mut blocks = Vec::new();
    let mut vias_by_net: HashMap<String, Vec<ViaSpec>> = HashMap::new();
    let (mut n_seg, mut n_via) = (0usize, 0usize);
    let mut length = 0.0;
    let mut widths: BTreeMap<String, usize> = BTreeMap::new();
    let mut nets = std::collections::HashSet::new();
    for e in &entries {
        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
        nets.insert(net.clone());
        let (segs, vias) = plan_to_copper(board, e)?;
        for s in &segs {
            blocks.push(segment_sexpr(board, s, &net)?);
            length += (s.x1 - s.x0).hypot(s.y1 - s.y0);
            *widths.entry(num(s.width)).or_insert(0) += 1;
        }
        for v in &vias {
            // issue #167: two paths of one net that change layer at one point are one via, not two
            // co-located holes
            if vias_by_net.get(&net).is_some_and(|vs| vs.iter().any(|w| (w.x - v.x).abs() < 1e-3 && (w.y - v.y).abs() < 1e-3)) {
                continue;
            }
            blocks.push(via_sexpr(board, v, &net)?);
            vias_by_net.entry(net.clone()).or_default().push(v.clone());
            n_via += 1;
        }
        n_seg += segs.len();
    }
    let mut text = splice(&board.text, &blocks);
    let mut stats = json!({"entries": entries.len(), "nets": nets.len(), "segments": n_seg, "vias": n_via, "copperLengthMm": (length * 100.0).round() / 100.0, "segmentsByWidth": widths});
    if fill {
        let (t, fills) = fill_zones(board, &text, &vias_by_net);
        text = t;
        stats["zoneFillPolygons"] = json!(fills);
    }
    Ok((text, stats))
}

// ----------------------------------------------------------------------------- DRC

/// Keep native project constraints beside each board snapshot, under its new basename.
pub fn copy_project_rules(source: &str, destination: &str) -> Result<(), String> {
    for ext in ["kicad_pro", "kicad_dru"] {
        let src=std::path::Path::new(source).with_extension(ext);
        let dst=std::path::Path::new(destination).with_extension(ext);
        if src.is_file() && src != dst {
            std::fs::copy(&src,&dst).map_err(|e| format!("copy project rules {}: {e}",src.display()))?;
        }
    }
    Ok(())
}

/// The native kicad-cli to use for DRC, when one is usable: `ADOM_AIFLOW_KICAD_CLI` or `kicad-cli`,
/// and only if its `pcb drc` supports `--refill-zones` (KiCad 9+). A stock KiCad 7 `kicad-cli` answers
/// `--version` but has no `pcb drc` and cannot read a KiCad 10 board, so it is not native here.
/// Cached: the probe spawns processes (and may cross Adom Bridge to a desktop).
pub fn native_kicad_cli() -> Option<String> {
    static NATIVE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
    NATIVE.get_or_init(|| {
        let probe = |cli: &str| -> bool {
            Command::new(cli).args(["pcb", "drc", "--help"]).output().ok()
                .map(|h| format!("{}{}", String::from_utf8_lossy(&h.stdout), String::from_utf8_lossy(&h.stderr)).contains("--refill-zones")).unwrap_or(false)
        };
        if let Ok(cli) = std::env::var("ADOM_AIFLOW_KICAD_CLI") { return probe(&cli).then_some(cli); }
        if probe("kicad-cli") { return Some("kicad-cli".into()); }
        // no KiCad here (a container): the run's own desktop runs it through Adom Bridge
        (std::env::var("KICAD_REMOTE_TARGET").is_ok() && probe("adom-aiflow-kicad-cli-remote")).then(|| "adom-aiflow-kicad-cli-remote".to_string())
    }).clone()
}

/// True when DRC runs on a native KiCad that refills zones itself (so no raster fill is needed).
pub fn native_drc_available() -> bool {
    native_kicad_cli().is_some()
}

pub fn run_drc(path: &str) -> Result<Value, String> {
    run_drc_with(path, None)
}

/// The argument and environment that ask native KiCad for schematic parity, or None when it cannot run.
/// A local kicad-cli reads `<board>.kicad_sch` beside the board; the remote wrapper sends that file, or
/// the run's schematic (KICAD_REMOTE_SCHEMATIC) under the board's name, with its sub-sheets (#96).
/// No schematic, or the headless service: no parity, and the report's empty `schematic_parity` means not run.
pub fn parity_request(cli: &str, board: &str, schematic: Option<&str>) -> Option<Option<String>> {
    let sch = schematic.filter(|s| std::path::Path::new(s).is_file())?;
    let remote = std::path::Path::new(cli).file_name().map(|n| n.to_string_lossy().ends_with("kicad-cli-remote")).unwrap_or(false);
    if remote { return Some(Some(sch.to_string())); }
    std::path::Path::new(board).with_extension("kicad_sch").is_file().then_some(None)
}

/// `run_drc`, with schematic parity when the run records the schematic the board was made from.
pub fn run_drc_with(path: &str, schematic: Option<&str>) -> Result<Value, String> {
    let dir = std::env::temp_dir().join(format!("aiflow-drc-{}", std::process::id()));
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    let report = dir.join(format!("drc-{}.json", uuid()));
    let native = native_kicad_cli();
    let has_rules = ["kicad_pro", "kicad_dru"].iter().any(|ext|std::path::Path::new(path).with_extension(ext).is_file());
    if has_rules && native.is_none() {
        return Err("project rules are present but no native kicad-cli with `pcb drc --refill-zones` was found (a stock KiCad 7 kicad-cli does not count), and service-kicad accepts only the PCB and would lose the rules. Set ADOM_AIFLOW_KICAD_CLI to a KiCad 9+ kicad-cli (in a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote runs a desktop's KiCad through Adom Bridge; KICAD_REMOTE_TARGET picks the desktop) and retry; do not strip the project files to obtain a pass".into());
    }
    // Native KiCad refills every zone itself, so the copper in the pours is KiCad's own fill,
    // current for whatever the board now holds. The raster fill stays only for the headless service.
    let out = if let Some(cli) = &native {
        let mut c = Command::new(cli);
        c.args(["pcb","drc","--refill-zones"]);
        if let Some(env) = parity_request(cli, path, schematic) {
            c.arg("--schematic-parity");
            if let Some(s) = env { c.env("KICAD_REMOTE_SCHEMATIC", s); }
        }
        c.args(["--format","json","--output",report.to_str().unwrap(),path]).output()
    } else {
        Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output()
    }.map_err(|e|format!("DRC backend not runnable: {e}"))?;
    if !out.status.success() || !report.is_file() {
        return Err(format!("KiCad DRC failed: {} {}", clip_text(&String::from_utf8_lossy(&out.stdout), 600), clip_text(&String::from_utf8_lossy(&out.stderr), 600)));
    }
    let data: Value = serde_json::from_str(&std::fs::read_to_string(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
    let _ = std::fs::remove_file(&report);
    Ok(data)
}

fn fingerprint(v: &Value) -> String {
    let mut pos: Vec<String> = v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").map(|p| p.to_string()).unwrap_or_default()).collect()).unwrap_or_default();
    pos.sort();
    json!([v.get("type"), v.get("severity"), v.get("description"), pos]).to_string()
}

fn count_types(list: &[&Value]) -> BTreeMap<String, usize> {
    let mut m = BTreeMap::new();
    for v in list {
        *m.entry(v.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()).or_insert(0) += 1;
    }
    m
}

/// Silkscreen findings: ink over a mask opening (the fab clips it off), ink on ink, ink past the edge, text under
/// the fab's minimum. Advisories by default (issue #92, John's decision of 2026-10-21): every fab trims ink at the
/// mask openings, and the Altium lane grades the same rules as warnings. They are counted and listed, never a
/// refusal, unless the spec or the fab rules profile opts in to blocking (`fab.silkBlocking: true`), which makes
/// them blocking again whatever severity the project gives them.
pub const SILK_TYPES: &[&str] = &["silk_over_copper", "silk_overlap", "silk_edge_clearance", "text_height", "text_thickness"];
/// The former name of `SILK_TYPES` (before 0.2.19 these always blocked).
pub const SILK_BLOCKING: &[&str] = SILK_TYPES;

/// A silkscreen finding (`SILK_TYPES`).
pub fn is_silk(v: &Value) -> bool { v["type"].as_str().map(|t| SILK_TYPES.contains(&t)).unwrap_or(false) }

/// Blocking with the default silkscreen policy: silkscreen findings are advisories.
pub fn blocking_violation(v: &Value) -> bool { blocking_violation_with(v, false) }

/// Manufacturing constraints remain blocking even when a project downgrades them to warnings. Silkscreen
/// findings block only when `silk_blocking` (the opt-in), at either severity.
pub fn blocking_violation_with(v: &Value, silk_blocking: bool) -> bool {
    let graded = v["severity"] == "error" || v["severity"] == "warning";
    if is_silk(v) { return silk_blocking && graded; }
    v["severity"] == "error" || (v["severity"] == "warning" && matches!(v["type"].as_str(),
        Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap")))
}

/// One finding as a short line: its type and the items it names.
pub fn finding_line(v: &Value) -> String {
    let items = v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" / ")).unwrap_or_default();
    if items.is_empty() { v["type"].as_str().unwrap_or("?").to_string() } else { format!("{} ({items})", v["type"].as_str().unwrap_or("?")) }
}

/// The gate's summary with the default silkscreen policy (advisories).
pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value { summarize_with(data, baseline, false) }

/// The gate counts errors and manufacturing warnings, matching inherited findings by identity. Silkscreen
/// findings are counted and listed as advisories (`silkAdvisories`) unless `silk_blocking`.
pub fn summarize_with(data: &Value, baseline: Option<&Value>, silk_blocking: bool) -> Value {
    let blocking = |v: &Value| blocking_violation_with(v, silk_blocking);
    let viol: Vec<&Value> = data.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();
    let errors: Vec<&Value> = viol.iter().copied().filter(|v| blocking(v)).collect();
    let silk: Vec<&Value> = if silk_blocking { Vec::new() } else { viol.iter().copied().filter(|v| is_silk(v)).collect() };
    let warnings_all: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("warning")).collect();
    let mut new: Vec<&Value> = errors.clone();
    let mut inherited: Vec<&Value> = Vec::new();
    if let Some(base) = baseline {
        let mut existing: HashMap<String, usize> = HashMap::new();
        for v in base.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect::<Vec<_>>()).unwrap_or_default() {
            if blocking(v) {
                *existing.entry(fingerprint(v)).or_insert(0) += 1;
            }
        }
        new.clear();
        for v in &errors {
            let k = fingerprint(v);
            if existing.get(&k).copied().unwrap_or(0) > 0 {
                *existing.get_mut(&k).unwrap() -= 1;
                inherited.push(v);
            } else {
                new.push(v);
            }
        }
    }
    let unconnected: Vec<&Value> = data.get("unconnected_items").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();
    let fill_artifacts = warnings_all.iter().filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("isolated_copper")).count();
    let warnings: Vec<&Value> = warnings_all.iter().copied().filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("isolated_copper")).collect();
    let desc = |v: &Value| -> Vec<Value> { v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("description").cloned().unwrap_or(Value::Null)).collect()).unwrap_or_default() };
    json!({
        "blockingIncludesManufacturingWarnings": true, "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(),
        "rasterFillIslandWarnings": fill_artifacts, "errorTypes": count_types(&errors), "newErrorTypes": count_types(&new), "warningTypes": count_types(&warnings),
        "silkBlocking": silk_blocking, "silkAdvisories": silk.len(), "silkAdvisoryTypes": count_types(&silk),
        "silkAdvisoryList": silk.iter().take(50).map(|v| finding_line(v)).collect::<Vec<_>>(),
        "newErrorList": new.iter().map(|v| json!({"type": v.get("type"), "description": v.get("description"), "items": desc(v), "pos": v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())})).collect::<Vec<_>>(),
        "unconnectedList": unconnected.iter().map(|u| desc(u)).collect::<Vec<_>>(),
        "kicadVersion": data.get("kicad_version"),
    })
}

/// Error text within `n` characters that keeps its beginning (where the cause is) and its end:
/// about 70% from the front, " ... ", the rest from the back (issue #76). Never cuts the front off.
fn clip_text(s: &str, n: usize) -> String {
    let s = s.trim();
    let count = s.chars().count();
    if count <= n { return s.to_string(); }
    let head = n * 7 / 10;
    let tail = n.saturating_sub(head + 5);
    let front: String = s.chars().take(head).collect();
    let back: String = s.chars().skip(count - tail).collect();
    format!("{front} ... {back}")
}

#[cfg(test)]
mod gate_tests {
    use super::*;
    #[test]
    fn manufacturing_warnings_block_but_library_warnings_do_not() {
        let hole = json!({"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":1,"y":2}}]});
        let library = json!({"type":"lib_footprint_mismatch","severity":"warning","description":"local library differs"});
        let candidate=json!({"violations":[hole.clone(),library]});
        assert_eq!(summarize(&candidate,None)["newErrors"],1);
        assert_eq!(summarize(&candidate,Some(&json!({"violations":[hole]})))["newErrors"],0);
        let moved=json!({"violations":[{"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":2,"y":2}}]}]});
        assert_eq!(summarize(&candidate,Some(&moved))["newErrors"],1);
    }
    #[test]
    fn silkscreen_findings_are_advisories_unless_opted_in() {
        // issue #92: a test pad under a part's value text. KiCad grades it a warning; the fab clips the ink, so it
        // is reported (counted and listed), not a refusal, as on the Altium lane
        let over = json!({"type":"silk_over_copper","severity":"warning","description":"Silkscreen clipped by solder mask","items":[{"description":"PCB text '150k'"},{"description":"Pad 1 [VIN] of TP6"}]});
        let overlap = json!({"type":"silk_overlap","severity":"error","description":"Silkscreen clearance"});
        let library = json!({"type":"lib_footprint_issues","severity":"warning","description":"library not enabled"});
        let board = json!({"violations":[over.clone(), overlap.clone(), library.clone()]});
        let s = summarize(&board, None);
        assert_eq!(s["newErrors"], 0, "{s}");
        assert_eq!(s["errors"], 0);
        assert_eq!(s["silkAdvisories"], 2);
        assert_eq!(s["silkAdvisoryTypes"]["silk_over_copper"], 1);
        assert!(s["silkAdvisoryList"][0].as_str().unwrap().contains("Pad 1 [VIN] of TP6"), "{s}");
        // the opt-in (fab.silkBlocking) restores the old behaviour, at either severity
        let s = summarize_with(&board, None, true);
        assert_eq!(s["newErrors"], 2, "{s}");
        assert_eq!(s["silkAdvisories"], 0);
        assert!(blocking_violation_with(&over, true) && !blocking_violation(&over));
        // every other manufacturing check still blocks
        let hole = json!({"type":"hole_to_hole","severity":"warning","description":"too close"});
        let s = summarize(&json!({"violations":[over, hole, library]}), None);
        assert_eq!(s["newErrors"], 1, "{s}");
        assert_eq!(s["newErrorTypes"]["hole_to_hole"], 1);
        assert_eq!(s["silkAdvisories"], 1);
    }
    #[test]
    fn parity_is_requested_only_when_a_schematic_can_reach_kicad() {
        let dir = std::env::temp_dir().join(format!("aiflow-parity-{}", uuid()));
        std::fs::create_dir_all(&dir).unwrap();
        let sch = dir.join("design.kicad_sch"); std::fs::write(&sch, "(kicad_sch)").unwrap();
        let board = dir.join("board-1.kicad_pcb"); let b = board.to_str().unwrap(); let s = sch.to_str().unwrap();
        // no recorded schematic, or a missing one: no parity for any backend
        assert_eq!(parity_request("adom-aiflow-kicad-cli-remote", b, None), None);
        assert_eq!(parity_request("adom-aiflow-kicad-cli-remote", b, Some("/no/such.kicad_sch")), None);
        // the remote wrapper sends the recorded schematic under the board's name
        assert_eq!(parity_request("/opt/x/adom-aiflow-kicad-cli-remote", b, Some(s)), Some(Some(s.to_string())));
        // a local kicad-cli reads only <board>.kicad_sch beside the board
        assert_eq!(parity_request("kicad-cli", b, Some(s)), None);
        std::fs::write(board.with_extension("kicad_sch"), "(kicad_sch)").unwrap();
        assert_eq!(parity_request("kicad-cli", b, Some(s)), Some(None));
        std::fs::remove_dir_all(&dir).ok();
    }
}

#[cfg(test)]
mod normalise_tests {
    use super::*;
    fn board() -> Board {
        Board::from_text("test.kicad_pcb", r#"(kicad_pcb
  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))
  (net 1 "VCC")
  (gr_rect (start 0 0) (end 200 200) (layer "Edge.Cuts"))
  (footprint "t" (layer "F.Cu") (at 80 100) (property "Reference" "U1")
    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "VCC"))))"#.into()).unwrap()
    }
    #[test]
    fn zero_length_path_is_dropped_before_gate_and_landing() {
        // issue #33: this passed the gate and stopped `land route` with zero_length
        let b = board();
        let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [[[88.2, 106.8], [88.2, 106.8]]]},
            {"net": "VCC", "width": 0.25, "paths": [[[80.0, 100.0], [80.0, 100.0], [85.0, 100.0]], [[85.0, 100.0], [85.0, 100.0]]]}]});
        let notes = normalise_plan(&b, &mut plan);
        assert_eq!(plan["nets"].as_array().unwrap().len(), 1, "{plan}");
        assert_eq!(plan["nets"][0]["paths"], json!([[[80.0, 100.0], [85.0, 100.0]]]));
        assert!(notes.iter().any(|n| n.contains("VCC (entry 1): dropped the entry")), "{notes:?}");
        assert!(notes.iter().any(|n| n.contains("entry 2, path 1): dropped a repeated waypoint at (80, 100) on F.Cu")), "{notes:?}");
        assert!(notes.iter().any(|n| n.contains("entry 2, path 2): dropped the path")), "{notes:?}");
        // what is left applies, and normalising again changes nothing
        assert!(apply(&b, &plan, false).is_ok());
        assert!(normalise_plan(&b, &mut plan).is_empty());
    }
    #[test]
    fn a_path_starting_on_back_copper_lands_on_back_copper() {
        // issue #37: the router exports a start on existing B.Cu copper as {x, y, layer: "B.Cu"};
        // the gate copy puts the first segment on B.Cu with no via at the start
        let b = board();
        let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [[{"x": 84.0, "y": 104.0, "layer": "B.Cu"}, [86.0, 104.0], [86.0, 106.0]]]}]});
        assert!(normalise_plan(&b, &mut plan).is_empty());
        let (segs, vias) = plan_to_copper(&b, &plan["nets"][0]).unwrap();
        assert_eq!(segs.len(), 2);
        assert!(segs.iter().all(|s| s.layer == "B.Cu"), "{segs:?}");
        assert!(vias.is_empty());
    }
    #[test]
    fn coincident_points_on_different_layers_stay_a_via() {
        let b = board();
        let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [["U1.1", [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 104.0]]]}]});
        let notes = normalise_plan(&b, &mut plan);
        assert_eq!(notes.len(), 1, "{notes:?}");
        assert!(notes[0].contains("(84, 100) on B.Cu"), "{notes:?}");
        assert_eq!(plan["nets"][0]["paths"][0].as_array().unwrap().len(), 4);
        let (segs, vias) = plan_to_copper(&b, &plan["nets"][0]).unwrap();
        assert_eq!((segs.len(), vias.len()), (2, 1));
        // a via-only path (same point, two layers) keeps its geometry
        let mut via_only = json!({"nets": [{"net": "VCC", "points": [{"x": 90.0, "y": 90.0, "layer": "F.Cu"}, {"x": 90.0, "y": 90.0, "layer": "B.Cu"}]}]});
        assert!(normalise_plan(&b, &mut via_only).is_empty());
        assert_eq!(via_only["nets"][0]["points"].as_array().unwrap().len(), 2);
    }
    #[test]
    fn two_paths_of_one_net_changing_layer_at_one_point_are_one_via() {
        // issue #167: the gate copy had two same-net vias at one point (holes_co_located)
        let b = board();
        let plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [
            ["U1.1", [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 104.0]],
            [[88.0, 100.0], [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 96.0]]]}]});
        let (text, stats) = apply(&b, &plan, false).unwrap();
        assert_eq!(stats["vias"], 1, "{stats}");
        assert_eq!(text.matches("(via").count(), 1);
    }
}

#[cfg(test)]
mod duplicate_pad_tests {
    use super::*;
    /// A Hirose FH12-style connector: two mounting pads both numbered MP, 13.3 mm apart, on GND.
    fn board() -> Board {
        Board::from_text("test.kicad_pcb", r#"(kicad_pcb
  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))
  (net 1 "GND") (net 2 "OUTA1")
  (gr_rect (start 80 170) (end 120 200) (layer "Edge.Cuts"))
  (footprint "FH12" (layer "F.Cu") (at 100 189.3) (property "Reference" "J1")
    (pad "MP" smd rect (at -6.65 0) (size 1.8 2.2) (layers "F.Cu") (net 1 "GND"))
    (pad "MP" smd rect (at 6.65 0) (size 1.8 2.2) (layers "F.Cu") (net 1 "GND"))
    (pad "1" smd rect (at 0 -1.5) (size 0.3 1) (layers "F.Cu") (net 2 "OUTA1")))
  (footprint "R" (layer "F.Cu") (at 90 180) (property "Reference" "R1")
    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))))"#.into()).unwrap()
    }
    #[test]
    fn a_key_naming_two_pads_becomes_the_nearest_pads_point_for_gate_and_landing() {
        let b = board();
        assert!(b.key_is_ambiguous("J1.MP") && !b.key_is_ambiguous("J1.1"));
        assert_eq!(b.pads_with_key("J1.MP").len(), 2);
        // issue #93: the path comes from R1 on the left, so J1.MP is the left pad (93.35, 189.3)
        let mut plan = json!({"nets": [{"net": "GND", "width": 0.25, "paths": [["R1.1", [93.35, 185.0], "J1.MP"]]},
            {"net": "GND", "width": 0.25, "paths": [[{"pad": "J1.MP", "layer": "F.Cu"}, [110.0, 189.3]]]}]});
        let notes = normalise_plan(&b, &mut plan);
        assert_eq!(notes.len(), 2, "{notes:?}");
        assert!(notes[0].contains("J1.MP names 2 pads; the waypoint is the one at (93.35, 189.3)"), "{notes:?}");
        assert_eq!(plan["nets"][0]["paths"][0][2], json!([93.35, 189.3]));
        // a starting {pad, layer} keeps its layer; the next point is right of the board centre, so the right pad
        let start = &plan["nets"][1]["paths"][0][0];
        assert!(start.get("pad").is_none() && start["layer"] == "F.Cu", "{start}");
        assert!((start["x"].as_f64().unwrap() - 106.65).abs() < 1e-9);
        // the gate copy and the landing read the same points, and again normalising changes nothing
        let (segs, _) = plan_to_copper(&b, &plan["nets"][0]).unwrap();
        assert!((segs.last().unwrap().x1 - 93.35).abs() < 1e-9);
        // `land route` sends these points to kicad_route_net: no key is left for the bridge to pick a pad by
        assert!(!plan.to_string().contains("J1.MP"), "{plan}");
        assert!(normalise_plan(&b, &mut plan).is_empty());
        // an unambiguous key stays a key
        let mut plain = json!({"nets": [{"net": "GND", "paths": [["R1.1", [85.0, 180.0]]]}]});
        assert!(normalise_plan(&b, &mut plain).is_empty());
        assert_eq!(plain["nets"][0]["paths"][0][0], "R1.1");
    }
}