main
Colby Knox 0.2.54 source (not yet released, needs signing): altium_routing_state answers no_current_board when a schematic is in f… cf04598 1d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328
//! The live-board lane AI Flow drives: read the whole board as data (`routing_state`), with a
//! revision so every edit can refuse a board that changed behind the caller's back.
//!
//! The agent dumps RAW internal coordinates (10000 per mil) so nothing is rounded inside Altium;
//! this module converts to mm and hashes the dump. Layer ids are Altium's (1 = Top Layer,
//! 32 = Bottom Layer, 74 = Multi Layer).

use serde_json::{json, Value};
use std::time::Duration;

use crate::agent;

/// Internal units per mm: 10000 per mil, 1 mil = 0.0254 mm.
const PER_MM: f64 = 10000.0 / 0.0254;

pub const TOP: i64 = 1;
pub const BOTTOM: i64 = 32;

fn mm(v: &Value) -> Value {
    let raw = v.as_f64().unwrap_or(0.0);
    // 0.1 um resolution: exact enough for any board, and stable when hashed.
    json!(((raw / PER_MM) * 10000.0).round() / 10000.0)
}

fn deg(v: &Value) -> Value {
    json!(v.as_f64().unwrap_or(0.0) / 1000.0)
}

/// Altium's copper layer id as the KiCad name AI Flow speaks.
pub fn layer_name(id: i64) -> String {
    match id {
        TOP => "F.Cu".into(),
        BOTTOM => "B.Cu".into(),
        74 => "*.Cu".into(),
        n if (2..=31).contains(&n) => format!("In{}.Cu", n - 1),
        n => format!("L{n}"),
    }
}

/// FNV-1a 64: stable across builds (std's hasher is not), plenty for change detection.
fn fnv(s: &str) -> String {
    let mut h: u64 = 0xcbf29ce484222325;
    for b in s.as_bytes() {
        h ^= *b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    format!("{h:016x}")
}

/// The revision covers copper, parts and outline: anything an edit could have changed. Order
/// is the iterator's, which is stable for an unchanged board.
pub fn revision(d: &Value) -> String {
    let keys = ["tracks", "arcs", "vias", "comps", "polys", "outline"];
    let canon: Vec<String> = keys.iter().map(|k| d.get(*k).map(|v| v.to_string()).unwrap_or_default()).collect();
    fnv(&canon.join("|"))
}

fn arr<'a>(d: &'a Value, k: &str) -> &'a [Value] {
    d.get(k).and_then(|v| v.as_array()).map(|v| v.as_slice()).unwrap_or(&[])
}

fn s(v: &Value) -> String {
    v.as_str().unwrap_or("").to_string()
}

fn i(v: &Value) -> i64 {
    v.as_i64().unwrap_or(0)
}

/// Altium's pad shape id (TShape) as a name.
fn pad_shape(id: i64) -> &'static str {
    match id { 1 => "round", 2 => "rect", 3 => "octagonal", 4 => "circle", 7 => "roundrect", 8 => "rotated-rect", _ => "other" }
}

// -- connectivity from the copper itself -------------------------------------------------------
//
// Altium's own ratsnest leaves out parts outside the board shape (parked parts before placement read 4
// lines on a 38-part board, measured 2026-10-15), so the count AI Flow judges is computed here: per net,
// pads, tracks, vias and pours are joined where their copper touches on a shared layer, and every group
// beyond the first is one connection still to make.

fn dist_seg(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 < 1e-12 { 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).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()
}

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 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) { return 0.0; }
    dist_seg(a, c, d).min(dist_seg(b, c, d)).min(dist_seg(c, a, b)).min(dist_seg(d, a, b))
}

fn in_poly(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
    let mut inside = false;
    let n = poly.len();
    for k in 0..n {
        let (a, b) = (poly[k], poly[(k + 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 { inside = !inside; }
    }
    inside
}

#[derive(Clone)]
enum Cu { Pad { c: (f64, f64), half: f64, layers: Vec<String> }, Seg { a: (f64, f64), b: (f64, f64), half: f64, layer: String }, Via { c: (f64, f64), r: f64 }, Pour { poly: Vec<(f64, f64)>, layer: String } }

fn on(cu: &Cu, layer: &str) -> bool {
    match cu {
        Cu::Pad { layers, .. } => layers.iter().any(|l| l == layer || l == "*.Cu"),
        Cu::Seg { layer: l, .. } | Cu::Pour { layer: l, .. } => l == layer,
        Cu::Via { .. } => true,
    }
}

fn touch(x: &Cu, y: &Cu) -> bool {
    let shared = ["F.Cu", "B.Cu"].iter().any(|l| on(x, l) && on(y, l)) || matches!(x, Cu::Pad { layers, .. } if layers.iter().any(|l| l == "*.Cu")) && matches!(y, Cu::Pad { layers, .. } if layers.iter().any(|l| l == "*.Cu"))
        || (1..31).any(|k| { let l = format!("In{k}.Cu"); on(x, &l) && on(y, &l) });
    if !shared { return false; }
    let eps = 1e-4;
    match (x, y) {
        (Cu::Pad { c: a, half: ha, .. }, Cu::Pad { c: b, half: hb, .. }) => ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() <= ha + hb + eps,
        (Cu::Pad { c, half, .. }, Cu::Seg { a, b, half: w, .. }) | (Cu::Seg { a, b, half: w, .. }, Cu::Pad { c, half, .. }) => dist_seg(*c, *a, *b) <= half + w + eps,
        (Cu::Pad { c, half, .. }, Cu::Via { c: v, r }) | (Cu::Via { c: v, r }, Cu::Pad { c, half, .. }) => ((c.0 - v.0).powi(2) + (c.1 - v.1).powi(2)).sqrt() <= half + r + eps,
        (Cu::Seg { a, b, half: w1, .. }, Cu::Seg { a: c, b: d, half: w2, .. }) => seg_seg(*a, *b, *c, *d) <= w1 + w2 + eps,
        (Cu::Seg { a, b, half: w, .. }, Cu::Via { c, r }) | (Cu::Via { c, r }, Cu::Seg { a, b, half: w, .. }) => dist_seg(*c, *a, *b) <= w + r + eps,
        (Cu::Via { c: a, r: ra }, Cu::Via { c: b, r: rb }) => ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() <= ra + rb + eps,
        (Cu::Pour { poly, .. }, other) | (other, Cu::Pour { poly, .. }) => match other {
            Cu::Pad { c, .. } | Cu::Via { c, .. } => in_poly(*c, poly),
            Cu::Seg { a, b, .. } => in_poly(*a, poly) || in_poly(*b, poly),
            Cu::Pour { poly: q, .. } => q.iter().any(|p| in_poly(*p, poly)) || poly.iter().any(|p| in_poly(*p, q)),
        },
    }
}

/// Connections still to make, from the copper: per net, (groups of touching copper) - 1. Also which
/// nets are split, for the hint.
pub fn unrouted(st: &Value) -> (usize, Vec<Value>) {
    let mut by_net: std::collections::BTreeMap<String, Vec<Cu>> = std::collections::BTreeMap::new();
    let pt = |v: &Value| (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0));
    for p in st["pads"].as_array().cloned().unwrap_or_default() {
        let net = p["net"].as_str().unwrap_or("");
        if net.is_empty() { continue; }
        let layer = p["layer"].as_str().unwrap_or("F.Cu").to_string();
        let half = p["sizeX"].as_f64().unwrap_or(0.0).min(p["sizeY"].as_f64().unwrap_or(0.0)) / 2.0;
        by_net.entry(net.into()).or_default().push(Cu::Pad { c: (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)), half, layers: vec![layer] });
    }
    for t in st["tracks"].as_array().cloned().unwrap_or_default() {
        let net = t["net"].as_str().unwrap_or("");
        let layer = t["layer"].as_str().unwrap_or("");
        if net.is_empty() || !(layer == "F.Cu" || layer == "B.Cu" || layer.starts_with("In")) { continue; }
        by_net.entry(net.into()).or_default().push(Cu::Seg { a: pt(&t["start"]), b: pt(&t["end"]), half: t["width"].as_f64().unwrap_or(0.0) / 2.0, layer: layer.into() });
    }
    for v in st["vias"].as_array().cloned().unwrap_or_default() {
        let net = v["net"].as_str().unwrap_or("");
        if net.is_empty() { continue; }
        by_net.entry(net.into()).or_default().push(Cu::Via { c: pt(&v["position"]), r: v["size"].as_f64().unwrap_or(0.0) / 2.0 });
    }
    for g in st["polygons"].as_array().cloned().unwrap_or_default() {
        let net = g["net"].as_str().unwrap_or("");
        if net.is_empty() { continue; }
        let poly: Vec<(f64, f64)> = g["points"].as_array().cloned().unwrap_or_default().iter().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect();
        if poly.len() >= 3 { by_net.entry(net.into()).or_default().push(Cu::Pour { poly, layer: g["layer"].as_str().unwrap_or("").into() }); }
    }
    let mut total = 0;
    let mut split = Vec::new();
    for (net, items) in by_net {
        let n = items.len();
        let mut parent: Vec<usize> = (0..n).collect();
        fn find(p: &mut Vec<usize>, x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let nx = p[y]; p[y] = r; y = nx; } r }
        for i in 0..n { for j in (i + 1)..n { if touch(&items[i], &items[j]) { let (a, b) = (find(&mut parent, i), find(&mut parent, j)); if a != b { parent[a] = b; } } } }
        // a group that holds no pad (a stray track or a pour on its own) still counts: copper that
        // reaches nothing is a connection not made
        let roots: std::collections::BTreeSet<usize> = (0..n).map(|i| find(&mut parent, i)).collect();
        if roots.len() > 1 { total += roots.len() - 1; split.push(json!({"net": net, "groups": roots.len()})); }
    }
    (total, split)
}

/// Convert the agent's compact dump into the mm-based state AI Flow reads.
pub fn shape(d: &Value) -> Value {
    let tracks: Vec<Value> = arr(d, "tracks").iter().map(|t| json!({
        "start": {"x": mm(&t[0]), "y": mm(&t[1])}, "end": {"x": mm(&t[2]), "y": mm(&t[3])},
        "width": mm(&t[4]), "layer": layer_name(i(&t[5])), "layerId": t[5], "net": t[6]})).collect();
    let arcs: Vec<Value> = arr(d, "arcs").iter().map(|a| json!({
        "center": {"x": mm(&a[0]), "y": mm(&a[1])}, "radius": mm(&a[2]),
        "startAngle": deg(&a[3]), "endAngle": deg(&a[4]), "width": mm(&a[5]),
        "layer": layer_name(i(&a[6])), "layerId": a[6], "net": a[7]})).collect();
    let vias: Vec<Value> = arr(d, "vias").iter().map(|v| json!({
        "position": {"x": mm(&v[0]), "y": mm(&v[1])}, "size": mm(&v[2]), "drill": mm(&v[3]),
        "layers": [layer_name(i(&v[4])), layer_name(i(&v[5]))], "net": v[6]})).collect();
    let pads: Vec<Value> = arr(d, "pads").iter().map(|p| json!({
        "ref": p[0], "name": p[1], "x": mm(&p[2]), "y": mm(&p[3]),
        "sizeX": mm(&p[4]), "sizeY": mm(&p[5]), "rotation": deg(&p[6]),
        "layer": layer_name(i(&p[7])), "layerId": p[7], "drill": mm(&p[8]), "net": p[9], "shape": pad_shape(i(&p[10]))})).collect();
    let comps: Vec<Value> = arr(d, "comps").iter().map(|c| json!({
        "ref": c[0], "x": mm(&c[1]), "y": mm(&c[2]), "rotation": deg(&c[3]),
        "side": if i(&c[4]) == BOTTOM { "bottom" } else { "top" }, "pattern": c[5], "bodies": c.get(6).cloned().unwrap_or(json!(null)),
        "has3d": c.get(6).and_then(|b| b.as_i64()).map(|b| b > 0)})).collect();
    let polys: Vec<Value> = arr(d, "polys").iter().map(|g| json!({
        "net": g[0], "layer": layer_name(i(&g[1])), "layerId": g[1],
        "points": g[2].as_array().map(|ps| ps.iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect::<Vec<_>>()).unwrap_or_default()})).collect();
    let outline: Vec<Value> = arr(d, "outline").iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect();
    // the outline as edges: a vertex of kind 1 bends the edge from it to the next into an arc
    let raw_out = arr(d, "outline");
    let edges: Vec<Value> = (0..raw_out.len()).map(|k| {
        let (p, q) = (&raw_out[k], &raw_out[(k + 1) % raw_out.len()]);
        if i(&p.get(2).cloned().unwrap_or(json!(0))) == 1 {
            json!({"kind": "arc", "start": [mm(&p[0]), mm(&p[1])], "end": [mm(&q[0]), mm(&q[1])], "center": [mm(&p[3]), mm(&p[4])], "radius": mm(&p[5]), "angle1": deg(&p[6]), "angle2": deg(&p[7])})
        } else {
            json!({"kind": "line", "start": [mm(&p[0]), mm(&p[1])], "end": [mm(&q[0]), mm(&q[1])]})
        }
    }).collect();
    let mut nets: Vec<String> = pads.iter().map(|p| s(&p["net"])).filter(|n| !n.is_empty()).collect();
    nets.sort();
    nets.dedup();
    let mut out = json!({
        "units": "mm", "file": d.get("file").cloned().unwrap_or(Value::Null),
        "boardId": d.get("file").cloned().unwrap_or(Value::Null), "revision": revision(d),
        "counts": {"tracks": tracks.len(), "arcs": arcs.len(), "vias": vias.len(), "pads": pads.len(),
                   "components": comps.len(), "polygons": polys.len(), "nets": nets.len()},
        "unconnected": d.get("unrouted").cloned().unwrap_or(json!(0)),
        "nets": nets, "tracks": tracks, "arcs": arcs, "vias": vias, "pads": pads,
        "components": comps, "polygons": polys, "outline": outline, "outlineEdges": edges,
    });
    // the count AI Flow judges is the copper's own; Altium's ratsnest count rides along
    let (n, split) = unrouted(&out);
    out["altiumRatsnest"] = out["unconnected"].clone();
    out["unconnected"] = json!(n);
    out["splitNets"] = json!(split);
    out
}

/// altium_routing_state {expectPath?}
pub fn routing_state(args: &Value) -> Value {
    let mut r = agent::call("routing_state", &[], None, Duration::from_secs(180));
    let data = match r.get("data") {
        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(),
        Some(d) => {
            let err = d.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();
            if err.contains("no current PCB board") {
                // another document (the schematic) is in front: say so, not an empty error
                r["errorCode"] = json!("no_current_board");
                r["error"] = json!(err);
                r["_hint"] = json!("Open the board first (altium_show_2d_board {filePath}), then retry.");
            }
            return r;
        }
        None => return r,
    };
    let st = shape(&data);
    if let Some(want) = args.get("expectPath").and_then(|v| v.as_str()) {
        let have = st["file"].as_str().unwrap_or("");
        if !have.eq_ignore_ascii_case(want) {
            return json!({"success": false, "errorCode": "wrong_board",
                "error": format!("the current board is {have}, not {want}"),
                "_hint": "altium_show_2d_board {filePath} makes the board you want current, then retry."});
        }
    }
    let mut out = json!({"success": true});
    for (k, v) in st.as_object().unwrap() {
        out[k] = v.clone();
    }
    out["roundtripMs"] = r.get("roundtripMs").cloned().unwrap_or(Value::Null);
    out["_hint"] = json!("mm, KiCad layer names (F.Cu/B.Cu/In<n>.Cu, *.Cu = multi-layer pad). Pass revision as expectedRevision to the edit verbs; unconnected is Altium's ratsnest count.");
    out
}

fn raw(mm: f64) -> i64 {
    (mm * PER_MM).round() as i64
}

/// KiCad layer name (or an Altium layer id as a number) to Altium's layer id.
pub fn layer_id(v: &Value) -> Option<i64> {
    if let Some(n) = v.as_i64() {
        return Some(n);
    }
    let name = v.as_str()?;
    match name {
        "F.Cu" | "Top" | "TopLayer" => Some(TOP),
        "B.Cu" | "Bottom" | "BottomLayer" => Some(BOTTOM),
        _ => name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse::<i64>().ok()).map(|n| n + 1),
    }
}

fn err(code: &str, msg: String, hint: &str) -> Value {
    json!({"success": false, "errorCode": code, "error": msg, "_hint": hint})
}

/// Read the live board and check the caller's guard: the right board and the revision it last
/// read. A board changed behind the caller's back is refused, never edited blind.
fn guarded(args: &Value) -> Result<Value, Value> {
    let st = routing_state(&json!({"expectPath": args.get("expectPath").cloned().unwrap_or(Value::Null)}));
    if st.get("success").and_then(|v| v.as_bool()) != Some(true) {
        return Err(st);
    }
    if let Some(id) = args.get("boardId").and_then(|v| v.as_str()) {
        if !st["boardId"].as_str().unwrap_or("").eq_ignore_ascii_case(id) {
            return Err(err("board_mismatch", format!("the current board is {}, not {id}", st["boardId"]), "altium_show_2d_board {filePath} the board you mean, then read altium_routing_state again."));
        }
    }
    if let Some(rev) = args.get("expectedRevision").and_then(|v| v.as_str()) {
        if st["revision"].as_str() != Some(rev) {
            return Err(err("stale_board", format!("the board changed since revision {rev} (now {})", st["revision"]),
                "Read altium_routing_state again and plan against the board as it is now."));
        }
    }
    Ok(st)
}

/// Send one batch to the agent's edit_board and read the board back.
fn apply(ops: &[String], before: &Value, what: &str) -> Value {
    if ops.is_empty() {
        return err("nothing_to_do", format!("{what}: no edits in the request"), "Pass at least one item.");
    }
    let payload = ops.join("\n");
    let r = agent::call("edit_board", &[], Some(&payload), Duration::from_secs(300));
    let d = match r.get("data") {
        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(),
        _ => return r,
    };
    let after = routing_state(&json!({}));
    let failed = d.get("failed").and_then(|v| v.as_i64()).unwrap_or(0);
    let mut out = json!({
        "success": failed == 0, "applied": d["applied"], "failed": failed, "results": d["results"],
        "revisionBefore": before["revision"], "revision": after["revision"], "boardId": after["boardId"],
        "unconnected": after["unconnected"],
        "countsBefore": before["counts"], "counts": after["counts"],
    });
    if failed > 0 {
        out["errorCode"] = json!("partial_edit");
        out["_hint"] = json!("results[] answers each edit in order; the ones marked err: did not land. The rest did (one undo step in Altium). Re-plan from the new revision.");
    } else {
        out["_hint"] = json!("Landed as one undo step. Pass revision as expectedRevision to the next edit.");
    }
    out["state"] = after;
    out
}

fn pad_xy(state: &Value, key: &str) -> Option<(f64, f64, String)> {
    let (r, n) = key.split_once('.')?;
    state["pads"].as_array()?.iter().find(|p| p["ref"].as_str() == Some(r) && p["name"].as_str() == Some(n))
        .map(|p| (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0), p["layer"].as_str().unwrap_or("").to_string()))
}

/// A waypoint: [x,y], {x,y,layer?} or {pad:"U1.3"}. Returns (x, y, layer-or-None).
fn waypoint(state: &Value, w: &Value) -> Result<(f64, f64, Option<i64>), String> {
    if let Some(a) = w.as_array() {
        return Ok((a.first().and_then(|v| v.as_f64()).ok_or("bad point")?, a.get(1).and_then(|v| v.as_f64()).ok_or("bad point")?, None));
    }
    if let Some(p) = w.get("pad").and_then(|v| v.as_str()) {
        let (x, y, _) = pad_xy(state, p).ok_or(format!("no pad {p} on the board"))?;
        return Ok((x, y, w.get("layer").and_then(layer_id)));
    }
    let x = w.get("x").and_then(|v| v.as_f64()).ok_or("waypoint needs x")?;
    let y = w.get("y").and_then(|v| v.as_f64()).ok_or("waypoint needs y")?;
    Ok((x, y, w.get("layer").and_then(layer_id)))
}

/// a drilled, all-layer pad of `net` under (x, y)
fn tht_pad_at(state: &Value, x: f64, y: f64, net: &str) -> bool {
    state["pads"].as_array().map(|ps| ps.iter().any(|p| {
        let f = |k: &str| p.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
        f("drill") > 0.0 && p["net"].as_str() == Some(net)
            && (f("x") - x).abs() <= f("sizeX").max(f("sizeY")) / 2.0 && (f("y") - y).abs() <= f("sizeX").max(f("sizeY")) / 2.0
    })).unwrap_or(false)
}

/// altium_route_net {net, width, layer, viaSize, viaDrill, paths:[[waypoint...]], expectedRevision?}
/// One track per segment; a waypoint that names a different layer drops a via at that point.
pub fn route_net(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let net = args.get("net").and_then(|v| v.as_str()).unwrap_or("");
    if net.is_empty() || !st["nets"].as_array().map(|n| n.iter().any(|x| x.as_str() == Some(net))).unwrap_or(false) {
        return err("unknown_net", format!("net {net:?} is not on the board"), "Use a net name from altium_routing_state nets[].");
    }
    let width = args.get("width").and_then(|v| v.as_f64()).unwrap_or(0.25);
    let mut layer = args.get("layer").and_then(layer_id).unwrap_or(TOP);
    let via_size = args.get("viaSize").or(args.get("viaDiameter")).and_then(|v| v.as_f64()).unwrap_or(0.6);
    let via_drill = args.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3);
    let paths: Vec<Value> = match args.get("paths").and_then(|v| v.as_array()) {
        Some(p) => p.clone(),
        None => match args.get("points") { Some(p) => vec![p.clone()], None => vec![] },
    };
    let mut ops = Vec::new();
    for path in &paths {
        let pts = match path.as_array() { Some(p) => p, None => return err("bad_args", "each path is an array of waypoints".into(), "") };
        let mut prev: Option<(f64, f64)> = None;
        for w in pts {
            let (x, y, lay) = match waypoint(&st, w) { Ok(v) => v, Err(e) => return err("bad_args", e, "Waypoints are [x,y], {x,y,layer} or {pad:\"U1.3\"}.") };
            // Fusion's convention, which AI Flow plans for: the segment ARRIVING at a waypoint is on
            // the current layer; a waypoint naming another layer drops a via there and the path
            // LEAVES on that layer.
            if let Some((px, py)) = prev {
                if (px - x).abs() > 1e-6 || (py - y).abs() > 1e-6 {
                    ops.push(format!("T|{}|{}|{}|{}|{}|{}|{}", raw(px), raw(py), raw(x), raw(y), raw(width), layer, net));
                }
            }
            if let Some(l) = lay {
                if l != layer {
                    // a layer change inside a through-hole pad of this net needs no via: the pad's barrel
                    // joins the layers, and a via there is a hole in a hole (measured 2026-10-16: vias on
                    // every machine pin and contact, Altium's DRC 'Pad/Via Touching Holes')
                    if !tht_pad_at(&st, x, y, net) {
                        let v = format!("V|{}|{}|{}|{}|{}|{}|{}", raw(x), raw(y), raw(via_size), raw(via_drill), TOP, BOTTOM, net);
                        if !ops.contains(&v) {
                            ops.push(v); // several branches may name the same transition: one physical via
                        }
                    }
                    layer = l;
                }
            }
            prev = Some((x, y));
        }
    }
    apply(&ops, &st, "route_net")
}

/// altium_move_parts {parts:[{ref,x,y,rotation}], expectedRevision?}
pub fn move_parts(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let mut ops = Vec::new();
    for p in args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        let r = p.get("ref").and_then(|v| v.as_str()).unwrap_or("");
        let (Some(x), Some(y)) = (p.get("x").and_then(|v| v.as_f64()), p.get("y").and_then(|v| v.as_f64())) else {
            return err("bad_args", format!("part {r}: x and y are required (mm)"), "");
        };
        let rot = p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0);
        ops.push(format!("M|{r}|{}|{}|{}", raw(x), raw(y), (rot * 1000.0).round() as i64));
    }
    let mut out = apply(&ops, &st, "move_parts");
    // Readback: every part must sit where it was asked to, to 0.001 mm and the exact rotation.
    if let Some(after) = out.get("state").cloned() {
        let mut off = Vec::new();
        for p in args["parts"].as_array().cloned().unwrap_or_default() {
            let r = p["ref"].as_str().unwrap_or("");
            let got = after["components"].as_array().and_then(|c| c.iter().find(|c| c["ref"].as_str() == Some(r)).cloned());
            match got {
                Some(c) if (c["x"].as_f64().unwrap_or(0.0) - p["x"].as_f64().unwrap_or(0.0)).abs() <= 0.001
                    && (c["y"].as_f64().unwrap_or(0.0) - p["y"].as_f64().unwrap_or(0.0)).abs() <= 0.001
                    && ((c["rotation"].as_f64().unwrap_or(0.0) - p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0)).rem_euclid(360.0)).min(
                        (p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0) - c["rotation"].as_f64().unwrap_or(0.0)).rem_euclid(360.0)) < 0.01 => {}
                other => off.push(json!({"ref": r, "asked": p, "got": other})),
            }
        }
        if !off.is_empty() {
            out["success"] = json!(false);
            out["errorCode"] = json!("readback_mismatch");
            out["mismatches"] = json!(off);
        }
    }
    out
}

/// altium_place_vias {vias:[{net,x,y,size,drill}], expectedRevision?}
pub fn place_vias(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let mut ops = Vec::new();
    for v in args.get("vias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        let (Some(x), Some(y)) = (v.get("x").and_then(|v| v.as_f64()), v.get("y").and_then(|v| v.as_f64())) else {
            return err("bad_args", "each via needs x and y (mm)".into(), "");
        };
        let size = v.get("size").or(v.get("diameter")).and_then(|v| v.as_f64()).unwrap_or(0.6);
        let drill = v.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.3);
        let net = v.get("net").and_then(|v| v.as_str()).unwrap_or("");
        ops.push(format!("V|{}|{}|{}|{}|{}|{}|{}", raw(x), raw(y), raw(size), raw(drill), TOP, BOTTOM, net));
    }
    apply(&ops, &st, "place_vias")
}

/// altium_remove_items {tracks:[{start{x,y},end{x,y},layer}], vias:[{x,y}|{position{x,y}}], expectedRevision?}
/// Accepts the exact objects altium_routing_state returned.
pub fn remove_items(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let mut ops = Vec::new();
    for t in args.get("tracks").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        let f = |k: &str, a: &str| t[k][a].as_f64().map(raw);
        let (Some(x1), Some(y1), Some(x2), Some(y2)) = (f("start", "x"), f("start", "y"), f("end", "x"), f("end", "y")) else {
            return err("bad_args", "a track needs start{x,y} and end{x,y}".into(), "Pass tracks as altium_routing_state returned them.");
        };
        ops.push(format!("D|T|{x1}|{y1}|{x2}|{y2}|{}", t.get("layerId").and_then(|v| v.as_i64()).or(t.get("layer").and_then(layer_id)).unwrap_or(TOP)));
    }
    for v in args.get("vias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        let p = if v.get("position").is_some() { &v["position"] } else { &v };
        let (Some(x), Some(y)) = (p["x"].as_f64(), p["y"].as_f64()) else { return err("bad_args", "a via needs x,y".into(), "") };
        ops.push(format!("D|V|{}|{}", raw(x), raw(y)));
    }
    apply(&ops, &st, "remove_items")
}

/// altium_ripup_nets {nets:[...], expectedRevision?}: every free track, arc and via on each net.
pub fn ripup_nets(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let ops: Vec<String> = args.get("nets").and_then(|v| v.as_array()).cloned().unwrap_or_default()
        .iter().filter_map(|n| n.as_str()).map(|n| format!("N|{n}")).collect();
    apply(&ops, &st, "ripup_nets")
}

fn shoelace_mm2(pts: &[Value]) -> f64 {
    let n = pts.len();
    if n < 3 {
        return 0.0;
    }
    let mut a = 0.0;
    for k in 0..n {
        let (x1, y1) = (pts[k][0].as_f64().unwrap_or(0.0), pts[k][1].as_f64().unwrap_or(0.0));
        let (x2, y2) = (pts[(k + 1) % n][0].as_f64().unwrap_or(0.0), pts[(k + 1) % n][1].as_f64().unwrap_or(0.0));
        a += x1 * y2 - x2 * y1;
    }
    (a / 2.0).abs() / (PER_MM * PER_MM)
}

/// The agent's pour dump as zones with their real poured copper area.
pub fn shape_pours(d: &Value) -> Value {
    let mut by_layer = serde_json::Map::new();
    let mut zones = Vec::new();
    let mut unfilled = Vec::new();
    for g in arr(d, "polys") {
        let layer = layer_name(i(&g[1]));
        let mut area = 0.0;
        let mut islands = 0;
        let pts_mm = |c: &Value| -> Value { json!(c.as_array().cloned().unwrap_or_default().iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect::<Vec<_>>()) };
        let fills: Vec<Value> = g[4].as_array().cloned().unwrap_or_default().iter().map(|f| json!({
            "outline": pts_mm(&f[0]), "holes": f[1].as_array().cloned().unwrap_or_default().iter().map(|h| pts_mm(h)).collect::<Vec<_>>()})).collect();
        for f in g[4].as_array().cloned().unwrap_or_default() {
            let main = shoelace_mm2(f[0].as_array().map(|v| v.as_slice()).unwrap_or(&[]));
            let holes: f64 = f[1].as_array().cloned().unwrap_or_default().iter()
                .map(|h| shoelace_mm2(h.as_array().map(|v| v.as_slice()).unwrap_or(&[]))).sum();
            area += main - holes;
            islands += 1;
        }
        let area = (area * 1000.0).round() / 1000.0;
        *by_layer.entry(layer.clone()).or_insert(json!(0.0)) = json!(by_layer.get(&layer).and_then(|v| v.as_f64()).unwrap_or(0.0) + area);
        let outline: Vec<Value> = g[3].as_array().cloned().unwrap_or_default().iter().map(|p| json!([mm(&p[0]), mm(&p[1])])).collect();
        let z = json!({"net": g[0], "name": g[5], "layer": layer, "layerId": g[1], "pourIndex": g[2], "outline": outline,
                       "filledMm2": area, "islands": islands, "filled": area > 0.0, "fills": fills});
        if area <= 0.0 {
            unfilled.push(z.clone());
        }
        zones.push(z);
    }
    json!({"zones": zones, "filledAreaByLayerMm2": by_layer, "unfilled": unfilled})
}

/// altium_pour_state {}: every pour with the copper it actually poured.
pub fn pour_state(_args: &Value) -> Value {
    let r = agent::call("pour_state", &[], None, Duration::from_secs(180));
    match r.get("data") {
        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => {
            let mut out = shape_pours(d);
            out["success"] = json!(true);
            out["file"] = d["file"].clone();
            out["_hint"] = json!("filledMm2 is the copper Altium poured (regions minus holes), not the outline area. A zone with filled:false poured nothing: an island removed as dead copper, or no connection to its net.");
            out
        }
        _ => r,
    }
}

/// a name that survives the agent's '|'/';' framing and an Altium query string
fn safe_name(s: &str) -> String {
    s.chars().filter(|c| !matches!(c, '|' | ';' | '\'' | '\n' | '\r')).collect::<String>().trim().to_string()
}

/// a connect style as the agent's one letter: direct (solid), relief (thermal), none
fn connect_code(s: &str) -> Option<&'static str> {
    match s.to_ascii_lowercase().as_str() {
        "direct" | "solid" | "full" => Some("D"),
        "relief" | "thermal" | "thermal_reliefs" | "spokes" => Some("R"),
        "none" | "no" | "no_connect" => Some("N"),
        _ => None,
    }
}

/// altium_apply_pours {pours:[{net, layer, points:[[x,y]...], order?, name?, connect?, reliefWidth?, reliefGap?, reliefEntries?,
///   removeDead?, removeSmallIslands?, minNeck?, solidPads?:[[x1,y1,x2,y2]...]}],
///   cutouts?:[{layer, points, name?}], expectedRevision?}
pub fn apply_pours(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let mut ops = Vec::new();
    let mut pads_state: Option<Value> = None;
    let mut joint_notes: Vec<Value> = Vec::new();
    // cutouts land first so every pour in this call is poured clear of them
    for (i, c) in args.get("cutouts").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {
        let layer = c.get("layer").and_then(layer_id).unwrap_or(TOP);
        let pts = c.get("points").and_then(|v| v.as_array()).cloned().unwrap_or_default();
        if pts.len() < 3 {
            return err("bad_args", format!("cutout {i}: points needs 3 or more [x,y]"), "");
        }
        let p: Vec<String> = pts.iter().map(|q| format!("{},{}", raw(q[0].as_f64().unwrap_or(0.0)), raw(q[1].as_f64().unwrap_or(0.0)))).collect();
        let name = safe_name(c.get("name").and_then(|v| v.as_str()).unwrap_or(""));
        let name = if name.is_empty() { format!("Adom keepout {i}") } else if name.starts_with("Adom") { name } else { format!("Adom {name}") };
        ops.push(format!("C|{layer}|{}|{name}", p.join(";")));
    }
    for g in args.get("pours").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        let net = g.get("net").and_then(|v| v.as_str()).unwrap_or("");
        let layer = g.get("layer").and_then(layer_id).unwrap_or(TOP);
        let pts = g.get("points").and_then(|v| v.as_array()).cloned().unwrap_or_default();
        if pts.len() < 3 {
            return err("bad_args", format!("pour on {net}: points needs 3 or more [x,y]"), "");
        }
        let order = g.get("order").or(g.get("pourIndex")).and_then(|v| v.as_i64()).unwrap_or(0);
        let p: Vec<String> = pts.iter().map(|q| format!("{},{}", raw(q[0].as_f64().unwrap_or(0.0)), raw(q[1].as_f64().unwrap_or(0.0)))).collect();
        let dead = if g.get("removeDead").and_then(|v| v.as_bool()) == Some(false) { "0" } else { "1" };
        let name = safe_name(g.get("name").and_then(|v| v.as_str()).unwrap_or(""));
        let connect = match g.get("connect").and_then(|v| v.as_str()) {
            None => "",
            Some(c) => match connect_code(c) {
                Some(code) => code,
                None => return err("bad_args", format!("pour on {net}: connect \"{c}\" is not direct, relief or none"), "connect: \"direct\" (solid joints), \"relief\" (thermal spokes), \"none\"."),
            },
        };
        let mm = |k: &str| g.get(k).and_then(|v| v.as_f64()).map(raw).unwrap_or(0);
        let entries = g.get("reliefEntries").and_then(|v| v.as_i64()).unwrap_or(0);
        let small = if g.get("removeSmallIslands").and_then(|v| v.as_bool()) == Some(true) { "1" } else { "0" };
        let mut joints = Vec::new();
        for r in g.get("solidPads").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
            let n: Vec<f64> = r.as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).collect()).unwrap_or_default();
            if n.len() != 4 { return err("bad_args", format!("pour on {net}: solidPads entries are [x1, y1, x2, y2] mm"), ""); }
            // Altium's region test wants an object COMPLETELY inside, and a joint box is often smaller
            // than its pad: one region per pad of this net the box touches, the pad's own outline
            let bx = [n[0].min(n[2]), n[1].min(n[3]), n[0].max(n[2]), n[1].max(n[3])];
            let st = pads_state.get_or_insert_with(|| routing_state(&json!({})));
            let mut hit = false;
            for p in st["pads"].as_array().cloned().unwrap_or_default() {
                if p["net"].as_str() != Some(net) { continue; }
                let on = p["layerId"].as_i64() == Some(layer) || p["layer"].as_str() == Some("*.Cu");
                if !on { continue; }
                let f = |k: &str| p.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
                let rot = f("rotation").rem_euclid(180.0);
                let (hx, hy) = if (rot - 90.0).abs() < 0.01 { (f("sizeY") / 2.0, f("sizeX") / 2.0) }
                    else if rot < 0.01 || (rot - 180.0).abs() < 0.01 { (f("sizeX") / 2.0, f("sizeY") / 2.0) }
                    else { let m = f("sizeX").hypot(f("sizeY")) / 2.0; (m, m) };
                let pb = [f("x") - hx, f("y") - hy, f("x") + hx, f("y") + hy];
                if pb[0] <= bx[2] && pb[2] >= bx[0] && pb[1] <= bx[3] && pb[3] >= bx[1] {
                    hit = true;
                    joints.push(format!("{},{},{},{}", raw(pb[0]), raw(pb[1]), raw(pb[2]), raw(pb[3])));
                }
            }
            if !hit { joint_notes.push(json!(format!("pour on {net}: no {net} pad touches solidPads box {bx:?}; nothing to join there"))); }
        }
        ops.push(format!("P|{net}|{layer}|{order}|{}|{dead}|{name}|{connect}|{}|{}|{entries}|{small}|{}|{}", p.join(";"), mm("reliefWidth"), mm("reliefGap"), mm("minNeck"), joints.join(";")));
    }
    if ops.is_empty() {
        return err("nothing_to_do", "apply_pours: no pours or cutouts in the request".into(), "Pass pours:[{net, layer, points}] and/or cutouts:[{layer, points}].");
    }
    let gap_ignored = args.get("pours").and_then(|v| v.as_array()).map(|a| a.iter().any(|g| g.get("reliefGap").is_some())).unwrap_or(false);
    let r = agent::call("apply_pours", &[], Some(&ops.join("\n")), Duration::from_secs(600));
    let d = match r.get("data") {
        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(),
        _ => return r,
    };
    // a pour poured inside the agent ignored its own scoped connect rule: pour again outside
    let scoped = ops.iter().any(|o| o.starts_with('P') && (o.split('|').nth(7).map(|c| !c.is_empty()).unwrap_or(false) || o.split('|').nth(13).map(|c| !c.is_empty()).unwrap_or(false)));
    let outside = if scoped { repour_outside_script() } else { json!(null) };
    let after = routing_state(&json!({}));
    let pours = pour_state(&json!({}));
    let failed = d.get("failed").and_then(|v| v.as_i64()).unwrap_or(0);
    let mut notes = joint_notes;
    if gap_ignored { notes.push(json!("reliefGap is not set by script on this Altium build (the gap is per pad type there); the relief keeps Altium's 10 mil (0.254 mm) air gap")); }
    json!({"success": failed == 0, "applied": d["applied"], "failed": failed, "results": d["results"], "repouredOutsideScript": outside,
           "revisionBefore": st["revision"], "revision": after["revision"], "unconnected": after["unconnected"],
           "zones": pours["zones"], "filledAreaByLayerMm2": pours["filledAreaByLayerMm2"], "unfilled": pours["unfilled"],
           "notes": notes,
           "_hint": "Each pour was added and poured (solid, dead copper removed); results[i] is ok:<polygon name>. A pour with connect gets its own PolygonConnect rule (AdomPC <name>, scoped IsNamedPolygon) that outranks the board's; without it the board's Polygon Connect Style applies. Cutouts keep every pour out of their area on their layer. Clearance comes from the board's Clearance rule."})
}

/// altium_remove_pours {pours?:"all" | [{net, layer?} | {name}], cutouts?:"all" | [{layer}], expectedRevision?}
pub fn remove_pours(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let mut lines: Vec<String> = match args.get("pours") {
        Some(Value::String(s)) if s == "all" => vec!["*".into()],
        Some(Value::Array(a)) => a.iter().map(|g| match g.get("name").and_then(|v| v.as_str()) {
            Some(n) => format!("={}|0", safe_name(n)),
            None => format!("{}|{}", g.get("net").and_then(|v| v.as_str()).unwrap_or(""), g.get("layer").and_then(layer_id).unwrap_or(0)),
        }).collect(),
        None => vec![],
        _ => return err("bad_args", "pours is \"all\" or [{net, layer?} | {name}]".into(), ""),
    };
    match args.get("cutouts") {
        Some(Value::String(s)) if s == "all" => lines.push("C|0".into()),
        Some(Value::Array(a)) => lines.extend(a.iter().map(|c| format!("C|{}", c.get("layer").and_then(layer_id).unwrap_or(0)))),
        None => {}
        _ => return err("bad_args", "cutouts is \"all\" or [{layer}]".into(), ""),
    }
    if lines.is_empty() {
        return err("bad_args", "nothing to remove: pass pours and/or cutouts".into(), "pours:\"all\" | [{net, layer?}], cutouts:\"all\" | [{layer}]");
    }
    let r = agent::call("remove_pours", &[], Some(&lines.join("\n")), Duration::from_secs(300));
    let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };
    let after = routing_state(&json!({}));
    json!({"success": true, "removed": d["removed"], "cutoutsRemoved": d["cutoutsRemoved"], "rulesRemoved": d["rulesRemoved"], "revisionBefore": st["revision"], "revision": after["revision"], "counts": after["counts"],
           "_hint": "Tracks and vias are untouched; altium_ripup_nets clears those."})
}

/// Repour All with NO script running. Altium evaluates a rule's custom query through its scripting
/// system, so while the agent loop is resident every scoped rule (InNamedPolygon, InNet, a net-class
/// clearance...) matches nothing and a repour falls back to the board's catch-all rules: the pours
/// came out with the default relief spokes whatever their own connect rule said (measured AD 26.11.1,
/// 2026-10-15: Test Queries read 0 objects with the agent up, 106 with it stopped). So: stop the
/// agent, run the repour from a helper (-R), start the agent again.
fn repour_outside_script() -> Value {
    let Some(exe) = crate::lifecycle::x2_exe_pub() else { return json!({"success": false, "error": "X2.exe not found"}) };
    let stop = crate::lifecycle::stop();
    if stop.get("success").and_then(|v| v.as_bool()) != Some(true) { return json!({"success": false, "stage": "agent_stop", "stop": stop}); }
    let t0 = std::time::Instant::now();
    let ran = match crate::lifecycle::spawn_helper_pub(&exe, "-RPCB:ChangeObject(ContextObject=Polygon|Action=RepourAllPolygons)") {
        Ok(mut c) => crate::lifecycle::wait_helper_pub(&mut c, Duration::from_secs(120)),
        Err(_) => false,
    };
    // the agent's start is queued behind the repour on Altium's main thread, so it answers after it
    let start = crate::lifecycle::start(Duration::from_secs(90));
    json!({"success": ran && start.get("success").and_then(|v| v.as_bool()) == Some(true), "helperExited": ran,
           "seconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "agentStart": start.get("success").cloned().unwrap_or(json!(false))})
}

/// altium_repour {inScript?}: repour every polygon on the board (Altium's Repour All). By default
/// outside the script engine so scoped rules apply; inScript:true keeps the old in-agent repour.
pub fn repour(args: &Value) -> Value {
    let r = if args.get("inScript").and_then(|v| v.as_bool()) == Some(true) {
        agent::call("run_process", &[("process", "PCB:ChangeObject".into()), ("params", "ContextObject=Polygon|Action=RepourAllPolygons".into())], None, Duration::from_secs(600))
    } else {
        json!({"data": repour_outside_script()})
    };
    let pours = pour_state(&json!({}));
    let mut out = json!({"success": pours.get("success").cloned().unwrap_or(json!(false)), "run": r.get("data").cloned().unwrap_or(r.clone())});
    for k in ["zones", "filledAreaByLayerMm2", "unfilled"] {
        out[k] = pours[k].clone();
    }
    out["_hint"] = json!("Repoured every polygon outside the script engine (the agent stops for it and starts again), so scoped rules (each pour's connect style, net-class clearances) apply. zones[] is the copper after the repour.");
    out
}

/// altium_routing_drc {full?}: violations with locations + the unrouted count. full:true runs
/// Altium's batch DRC on the CURRENT board first (otherwise online DRC's live set is read).
pub fn routing_drc(args: &Value) -> Value {
    let mut batch = Value::Null;
    if args.get("full").and_then(|v| v.as_bool()) == Some(true) {
        let st = routing_state(&json!({}));
        let path = st["file"].as_str().unwrap_or("").to_string();
        if path.is_empty() {
            return st;
        }
        batch = crate::outputs::run_drc(&json!({"filePath": path}));
    }
    let r = agent::call("routing_drc", &[], None, Duration::from_secs(180));
    let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };
    let viol: Vec<Value> = arr(&d, "violations").iter().map(|v| json!({
        "rule": v[0], "description": v[1],
        "at": {"x": json!(((v[2].as_f64().unwrap_or(0.0) + v[4].as_f64().unwrap_or(0.0)) / 2.0 / PER_MM * 1000.0).round() / 1000.0),
               "y": json!(((v[3].as_f64().unwrap_or(0.0) + v[5].as_f64().unwrap_or(0.0)) / 2.0 / PER_MM * 1000.0).round() / 1000.0)}})).collect();
    let errors = d["violationCount"].as_i64().unwrap_or(0);
    let unconnected = d["unrouted"].as_i64().unwrap_or(0);
    let mut out = json!({"success": true, "file": d["file"], "unconnected": unconnected, "errors": errors,
        "clean": errors == 0 && unconnected == 0, "violations": viol,
        "_hint": if args.get("full").and_then(|v| v.as_bool()) == Some(true) { "Batch DRC ran on this board first, so violations[] is the full set." } else { "Read from online DRC's live set; pass full:true to run the batch DRC first (it covers rules online DRC skips)." }});
    if !batch.is_null() {
        out["batch"] = json!({"success": batch["success"], "violationCount": batch["violationCount"], "errorCode": batch["errorCode"]});
    }
    out
}

/// Outline vertices in the agent's form (`x,y,kind,cx,cy,r,a1,a2`, RAW units) for a rectangle, with
/// rounded corners when `r > 0`: each corner is an arc from its vertex to the next, counterclockwise.
pub fn rect_outline(x0: f64, y0: f64, w: f64, h: f64, r: f64) -> Vec<String> {
    let (x1, y1) = (x0 + w, y0 + h);
    if r <= 0.0 {
        return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().map(|(x, y)| format!("{},{},0", raw(*x), raw(*y))).collect();
    }
    let arc = |x: f64, y: f64, cx: f64, cy: f64, a1: f64, a2: f64| format!("{},{},1,{},{},{},{},{}", raw(x), raw(y), raw(cx), raw(cy), raw(r), (a1 * 1000.0) as i64, (a2 * 1000.0) as i64);
    let line = |x: f64, y: f64| format!("{},{},0", raw(x), raw(y));
    vec![
        line(x0 + r, y0), arc(x1 - r, y0, x1 - r, y0 + r, 270.0, 360.0),
        line(x1, y0 + r), arc(x1, y1 - r, x1 - r, y1 - r, 0.0, 90.0),
        line(x1 - r, y1), arc(x0 + r, y1, x0 + r, y1 - r, 90.0, 180.0),
        line(x0, y1 - r), arc(x0, y0 + r, x0 + r, y0 + r, 180.0, 270.0),
    ]
}

/// The outline argument: {rect:[x0,y0,w,h], cornerRadius} or {points:[[x,y]...]} (mm, Altium frame).
fn outline_spec(o: &Value) -> Result<String, Value> {
    if let Some(r) = o.get("rect").and_then(|v| v.as_array()) {
        let n: Vec<f64> = r.iter().filter_map(|v| v.as_f64()).collect();
        if n.len() != 4 || n[2] <= 0.0 || n[3] <= 0.0 { return Err(err("bad_args", "outline.rect is [x0, y0, w, h] in mm with w, h > 0".into(), "")); }
        let cr = o.get("cornerRadius").and_then(|v| v.as_f64()).unwrap_or(0.0);
        if cr * 2.0 >= n[2].min(n[3]) { return Err(err("bad_args", "cornerRadius must be under half the shorter side".into(), "")); }
        return Ok(rect_outline(n[0], n[1], n[2], n[3], cr).join(";"));
    }
    if let Some(ps) = o.get("points").and_then(|v| v.as_array()) {
        if ps.len() < 3 { return Err(err("bad_args", "outline.points needs 3 or more [x,y]".into(), "")); }
        return Ok(ps.iter().map(|p| format!("{},{},0", raw(p[0].as_f64().unwrap_or(0.0)), raw(p[1].as_f64().unwrap_or(0.0)))).collect::<Vec<_>>().join(";"));
    }
    Err(err("bad_args", "outline is {rect:[x0,y0,w,h], cornerRadius?} or {points:[[x,y]...]}".into(), ""))
}

/// altium_build_board {pcbPath, libraryPath, outline, parts:[{ref, footprint, x, y, rotation, comment}], nets:{net:["R1.1",...]}}
/// The documents a .PrjPcb lists (`DocumentPath=` lines), resolved against the project's folder.
pub fn listed_documents(prj_text: &str, prj_dir: &std::path::Path) -> Vec<std::path::PathBuf> {
    prj_text.lines().filter_map(|l| l.trim().strip_prefix("DocumentPath=")).map(|d| {
        let d = d.trim();
        let pth = std::path::Path::new(d);
        if pth.is_absolute() || d.contains(':') { pth.to_path_buf() } else { prj_dir.join(d) }
    }).collect()
}

fn same_file_name(a: &std::path::Path, b: &std::path::Path) -> bool {
    match (a.file_name(), b.file_name()) {
        (Some(x), Some(y)) => x.to_string_lossy().eq_ignore_ascii_case(&y.to_string_lossy()),
        _ => false,
    }
}

/// Altium's own view of a project: the documents it holds OPEN under that project, and those of them
/// the project FILE does not list (they joined in memory only). Closing the project over an unlisted,
/// modified member raises "Save changes?" on the user's screen.
fn project_members(prj: &std::path::Path) -> (Vec<String>, Vec<String>) {
    let dir = prj.parent().unwrap_or(std::path::Path::new("."));
    let listed = std::fs::read(prj).map(|b| listed_documents(&String::from_utf8_lossy(&b), dir)).unwrap_or_default();
    let name = prj.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    let st = agent::call("state", &[], None, Duration::from_secs(30));
    let open: Vec<String> = st.get("data").and_then(|d| d.get("documents")).and_then(|d| d.as_array()).cloned().unwrap_or_default()
        .iter().filter(|d| d["project"].as_str().map(|p| p.eq_ignore_ascii_case(&name)).unwrap_or(false) && d["open"].as_bool() == Some(true))
        .filter_map(|d| d["path"].as_str().map(str::to_string)).collect();
    let unlisted = open.iter().filter(|o| !listed.iter().any(|l| same_file_name(l, std::path::Path::new(o.as_str())))).cloned().collect();
    (open, unlisted)
}

/// Save every open member of a project, then close them and the project: no prompt, nothing lost.
fn close_project_saved(prj: &str, open: &[String]) {
    for d in open {
        let _ = crate::surfaces::save(&json!({"filePath": d}));
    }
    for d in open.iter().map(|s| s.as_str()).chain(std::iter::once(prj)) {
        let _ = agent::call("close_document", &[("path", d.to_string())], None, Duration::from_secs(30));
    }
}

/// A board built into a folder whose project already lists it (altium_build_schematic writes the
/// project first) is created as a new document that joins the project and is then renamed onto the
/// listed path: Altium holds the file twice, one entry greyed, and a second hidden view survives a
/// project close (#23). Save every open member of that project, close them and the project, reopen it
/// and show the board: one entry, one view.
/// The members come from Altium's own view (the agent's state), not the file: a document that joined
/// the project in memory only is not in the file, and closing the project over it raised "Save
/// changes? battery.PcbDoc" mid-battery. When the project holds such a member the reopen is skipped
/// (the caller's documents are not this verb's to close). None when no project lists the board.
fn reopen_listing_project(pcb: &str) -> Option<Value> {
    let pcb_p = std::path::Path::new(pcb);
    let dir = pcb_p.parent()?;
    let (prj, listed) = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path())
        .filter(|p| p.extension().map(|x| x.eq_ignore_ascii_case("prjpcb")).unwrap_or(false))
        .find_map(|prj| {
            let text = String::from_utf8_lossy(&std::fs::read(&prj).ok()?).to_string();
            let docs = listed_documents(&text, dir);
            docs.iter().any(|d| same_file_name(d, pcb_p)).then_some((prj, docs))
        })?;
    let prj_s = prj.display().to_string();
    let _ = listed;
    let (open, unlisted) = project_members(&prj);
    if !unlisted.is_empty() {
        return Some(json!({"project": prj_s, "reopened": false,
            "skipped": format!("the open project also holds documents its file does not list ({}); they are not this verb's to close", unlisted.join(", "))}));
    }
    close_project_saved(&prj_s, &open);
    let _ = crate::support::agent_open(&prj_s, 90);
    let _ = crate::support::agent_open(pcb, 90);
    Some(json!({"project": prj_s, "reopened": true, "closed": open}))
}

pub fn build_board(args: &Value) -> Value {
    let pcb = args.get("pcbPath").and_then(|v| v.as_str()).unwrap_or("");
    let lib = args.get("libraryPath").and_then(|v| v.as_str()).unwrap_or("");
    if pcb.is_empty() || lib.is_empty() { return err("bad_args", "pcbPath (the .PcbDoc to write) and libraryPath (the .PcbLib with every footprint) are required".into(), ""); }
    let outline = match args.get("outline") { Some(o) => match outline_spec(o) { Ok(s) => s, Err(e) => return e }, None => String::new() };
    let mut ops = Vec::new();
    if !outline.is_empty() { ops.push(format!("O|{outline}")); }
    let parts = args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    if parts.is_empty() { return err("bad_args", "parts is empty".into(), "Pass parts:[{ref, footprint, x, y, rotation}] (mm, Altium frame)."); }
    for p in &parts {
        let r = p.get("ref").and_then(|v| v.as_str()).unwrap_or("");
        let fp = p.get("footprint").and_then(|v| v.as_str()).unwrap_or("");
        if r.is_empty() || fp.is_empty() { return err("bad_args", "every part needs ref and footprint".into(), ""); }
        ops.push(format!("C|{r}|{fp}|{}|{}|{}|{}", raw(p.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0)), raw(p.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0)),
            (p.get("rotation").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0).round() as i64, p.get("comment").and_then(|v| v.as_str()).unwrap_or("").replace('|', "/")));
    }
    let mut want: Vec<(String, String, String)> = Vec::new();
    for (net, pins) in args.get("nets").and_then(|v| v.as_object()).cloned().unwrap_or_default() {
        for pin in pins.as_array().cloned().unwrap_or_default() {
            if let Some((r, pad)) = pin.as_str().and_then(|s| s.split_once('.')) {
                ops.push(format!("N|{r}|{pad}|{net}"));
                want.push((r.to_string(), pad.to_string(), net.clone()));
            }
        }
    }
    // the library must be the current document for the agent to read its footprints
    let open = crate::support::agent_open(lib, 90);
    if open.get("success").and_then(|v| v.as_bool()) != Some(true) { return open; }
    let r = agent::call("build_board", &[("pcb", pcb.to_string())], Some(&ops.join("\n")), Duration::from_secs(900));
    let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };
    // read back: every part on the board, every listed pad on its net
    let st = routing_state(&json!({}));
    let mut wrong = Vec::new();
    for (rf, pad, net) in &want {
        let got = st["pads"].as_array().and_then(|ps| ps.iter().find(|p| p["ref"].as_str() == Some(rf) && p["name"].as_str() == Some(pad)).cloned());
        match got {
            Some(p) if p["net"].as_str() == Some(net) => {}
            other => wrong.push(json!({"pin": format!("{rf}.{pad}"), "want": net, "got": other.map(|p| p["net"].clone()).unwrap_or(json!("no such pad"))})),
        }
    }
    let placed_refs: Vec<String> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();
    let missing: Vec<&str> = parts.iter().filter_map(|p| p["ref"].as_str()).filter(|r| !placed_refs.iter().any(|x| x == r)).collect();
    let clean = wrong.is_empty() && missing.is_empty();
    let reopened = reopen_listing_project(pcb);
    json!({"success": clean, "errorCode": if clean { Value::Null } else { json!("readback_mismatch") }, "pcb": pcb, "projectReopened": reopened, "placed": d["placed"], "padsWired": d["padsWired"],
           "outlineVertices": d["outlineVertices"], "missingParts": missing, "wrongPins": wrong, "revision": st["revision"], "unconnected": st["unconnected"],
           "counts": st["counts"],
           "_hint": if clean { "Saved and read back: every part is on the board and every listed pin on its net. altium_routing_state reads it; unconnected is the ratsnest to route." } else { "Some pins did not land on their net (wrongPins) or parts are missing: a footprint's pad names must match the netlist's pin numbers." }})
}

/// altium_set_outline {outline: {rect, cornerRadius} | {points}, expectedRevision?}
pub fn set_outline(args: &Value) -> Value {
    let st = match guarded(args) { Ok(s) => s, Err(e) => return e };
    let spec = match args.get("outline") { Some(o) => match outline_spec(o) { Ok(s) => s, Err(e) => return e }, None => return err("bad_args", "outline is required".into(), "") };
    let r = agent::call("set_outline", &[], Some(&format!("O|{spec}")), Duration::from_secs(120));
    let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r };
    let after = routing_state(&json!({}));
    json!({"success": true, "vertices": d["vertices"], "revisionBefore": st["revision"], "revision": after["revision"], "outlineEdges": after["outlineEdges"],
           "_hint": "Copper keeps its edge clearance from the new outline: repour (altium_repour) when a pour followed the old one."})
}

/// altium_set_design_rules {clearance, trackWidth:{min,preferred,max}, via:{diameter:{min,preferred,max}, drill:{min,preferred,max}},
/// hole:{min,max}, annularRing, holeToHole, edgeClearance} (mm). Each rule kind is written by its own agent call,
/// so a property one Altium build lacks fails alone; the reply says which kinds landed.
pub fn set_design_rules(args: &Value) -> Value {
    let mm_of = |v: Option<&Value>| v.and_then(|x| x.as_f64());
    let g = |o: &str, k: &str| mm_of(args.get(o).and_then(|x| x.get(k)));
    let mut plan: Vec<(&str, Vec<(&str, String)>)> = Vec::new();
    if let Some(c) = mm_of(args.get("clearance")) { plan.push(("clearance", vec![("a", raw(c).to_string())])); }
    if let Some(min) = g("trackWidth", "min") {
        let pref = g("trackWidth", "preferred").unwrap_or(min.max(0.25));
        let max = g("trackWidth", "max").unwrap_or(5.0);
        plan.push(("width", vec![("a", raw(min).to_string()), ("b", raw(pref).to_string()), ("c", raw(max).to_string())]));
    }
    if let Some(v) = args.get("via") {
        let d = |k: &str, j: &str, def: f64| v.get(k).and_then(|x| x.get(j)).and_then(|x| x.as_f64()).unwrap_or(def);
        let (dmin, dmax) = (d("diameter", "min", 0.45), d("diameter", "max", 1.0));
        let (hmin, hmax) = (d("drill", "min", 0.2), d("drill", "max", 0.6));
        plan.push(("via", vec![("a", raw(dmin).to_string()), ("b", raw(d("diameter", "preferred", dmin.max(0.6))).to_string()), ("c", raw(dmax).to_string()),
                                ("d", raw(hmin).to_string()), ("e", raw(d("drill", "preferred", hmin.max(0.3))).to_string()), ("f", raw(hmax).to_string())]));
    }
    if let Some(min) = g("hole", "min") { plan.push(("hole", vec![("a", raw(min).to_string()), ("c", raw(g("hole", "max").unwrap_or(6.3)).to_string())])); }
    if let Some(r) = mm_of(args.get("annularRing")) { plan.push(("ring", vec![("a", raw(r).to_string())])); }
    if let Some(h) = mm_of(args.get("holeToHole")) { plan.push(("holetohole", vec![("a", raw(h).to_string())])); }
    if let Some(e) = mm_of(args.get("edgeClearance")) { plan.push(("edge", vec![("a", raw(e).to_string())])); }
    if let Some(pc) = args.get("polygonConnect") {
        let style = pc.get("style").and_then(|v| v.as_str()).or(pc.as_str()).unwrap_or("");
        let a = match connect_code(style) { Some("D") => "1", Some("N") => "2", Some(_) => "0",
            None => return err("bad_args", format!("polygonConnect style \"{style}\" is not direct, relief or none"), "polygonConnect: \"direct\" or {style:\"relief\", reliefWidth: mm, reliefEntries: 2|4}") };
        let w = pc.get("reliefWidth").and_then(|v| v.as_f64()).map(raw).unwrap_or(0);
        let e = pc.get("reliefEntries").and_then(|v| v.as_i64()).unwrap_or(0);
        plan.push(("polyconnect", vec![("a", a.to_string()), ("b", w.to_string()), ("c", e.to_string())]));
    }
    if plan.is_empty() { return err("bad_args", "no rule values given".into(), "Pass any of clearance, trackWidth{min,preferred,max}, via{diameter{...}, drill{...}}, hole{min,max}, annularRing, holeToHole, edgeClearance (mm), polygonConnect (direct|relief|none or {style, reliefWidth, reliefEntries})."); }
    let mut landed = Vec::new();
    let mut failed = Vec::new();
    let mut first_shot: Option<Value> = None;
    for (kind, vals) in plan {
        let mut params: Vec<(&str, String)> = vec![("kind", kind.to_string())];
        params.extend(vals);
        let r = agent::call("set_rule", &params, None, Duration::from_secs(60));
        match r.get("data") {
            Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => landed.push(json!({"kind": kind, "rulesWritten": d["rulesWritten"]})),
            _ => {
                if let Some(shot) = r.get("errorShot") { first_shot.get_or_insert(shot.clone()); }
                failed.push(json!({"kind": kind, "errorCode": r.get("errorCode").cloned().unwrap_or(Value::Null), "error": r.get("error").cloned().or_else(|| r.get("data").and_then(|d| d.get("error")).cloned()).unwrap_or(Value::Null)}));
                // a script error recovers the agent and can drop the current board: stop, do not cascade
                if r.get("errorCode").and_then(|c| c.as_str()) == Some("agent_script_error") { break; }
            }
        }
    }
    let rules = agent::call("read_rules", &[], None, Duration::from_secs(60));
    json!({"success": failed.is_empty(), "errorCode": if failed.is_empty() { Value::Null } else { json!("partial_rules") }, "landed": landed, "failed": failed,
           "rules": rules.get("data").and_then(|d| d.get("rules")).cloned().unwrap_or(Value::Null),
           "errorShot": first_shot.unwrap_or(Value::Null),
           "_hint": "Every rule of each kind now carries the fab's values (scopes kept). failed[] names any kind this Altium build refused; the rest landed."})
}

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

    #[test]
    fn converts_raw_units_and_hashes() {
        let d = json!({"ok": true, "file": "C:/x.PcbDoc",
            "tracks": [[0, 0, 393700, 0, 78740, 1, "GND"]],
            "arcs": [], "vias": [[100000, 200000, 236220, 118110, 1, 32, "GND"]],
            "pads": [["U1", "1", 0, 0, 39370, 39370, 90000, 1, 0, "GND"]],
            "comps": [["U1", 0, 0, 90000, 32, "SOT-23"]], "polys": [], "outline": [[0, 0], [393700, 0]], "unrouted": 3});
        let st = shape(&d);
        assert_eq!(st["tracks"][0]["end"]["x"], json!(1.0));
        assert_eq!(st["tracks"][0]["width"], json!(0.2));
        assert_eq!(st["vias"][0]["layers"], json!(["F.Cu", "B.Cu"]));
        assert_eq!(st["components"][0]["side"], json!("bottom"));
        assert_eq!(st["components"][0]["rotation"], json!(90.0));
        assert_eq!(st["altiumRatsnest"], json!(3));
        let r1 = revision(&d);
        let mut d2 = d.clone();
        d2["tracks"][0][2] = json!(393701);
        assert_ne!(r1, revision(&d2));
        assert_eq!(r1, revision(&d));
    }

    #[test]
    fn unrouted_counts_groups_of_touching_copper() {
        let st = json!({"pads": [
            {"net": "A", "x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "layer": "F.Cu"},
            {"net": "A", "x": 10.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "layer": "F.Cu"},
            {"net": "A", "x": 20.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "layer": "F.Cu"}],
            "tracks": [{"net": "A", "start": {"x": 0.0, "y": 0.0}, "end": {"x": 10.0, "y": 0.0}, "width": 0.3, "layer": "F.Cu"}],
            "vias": [], "polygons": []});
        assert_eq!(unrouted(&st).0, 1);
        let mut st2 = st.clone();
        st2["tracks"].as_array_mut().unwrap().push(json!({"net": "A", "start": {"x": 10.0, "y": 0.0}, "end": {"x": 20.0, "y": 0.0}, "width": 0.3, "layer": "B.Cu"}));
        assert_eq!(unrouted(&st2).0, 2, "a track on the wrong layer reaches neither F.Cu pad: it is its own island");
        st2["tracks"][1]["layer"] = json!("F.Cu");
        assert_eq!(unrouted(&st2).0, 0);
    }

    #[test]
    fn a_rounded_rectangle_is_eight_vertices_with_four_arcs() {
        let v = rect_outline(0.0, 0.0, 28.0, 20.0, 1.0);
        assert_eq!(v.len(), 8);
        assert_eq!(v.iter().filter(|s| s.split(',').nth(2) == Some("1")).count(), 4);
        assert_eq!(rect_outline(0.0, 0.0, 28.0, 20.0, 0.0).len(), 4);
    }

    #[test]
    fn layer_names_round_trip() {
        assert_eq!(layer_id(&json!("F.Cu")), Some(1));
        assert_eq!(layer_id(&json!("B.Cu")), Some(32));
        assert_eq!(layer_id(&json!("In1.Cu")), Some(2));
        assert_eq!(layer_name(layer_id(&json!("In3.Cu")).unwrap()), "In3.Cu");
        assert_eq!(raw(1.0), 393701);
    }
}

/// altium_zoom_to {rect?:[x1,y1,x2,y2] mm | ref?: "U1" | refs?: [..] | net?: "SW", margin?: mm}: frame the view.
pub fn zoom_to(args: &Value) -> Value {
    let margin = args.get("margin").and_then(|v| v.as_f64()).unwrap_or(1.0);
    let mut b: Option<[f64; 4]> = None;
    let mut grow = |x: f64, y: f64, r: f64| {
        let e = b.get_or_insert([x - r, y - r, x + r, y + r]);
        e[0] = e[0].min(x - r); e[1] = e[1].min(y - r); e[2] = e[2].max(x + r); e[3] = e[3].max(y + r);
    };
    if let Some(r) = args.get("rect").and_then(|v| v.as_array()) {
        let n: Vec<f64> = r.iter().filter_map(|v| v.as_f64()).collect();
        if n.len() != 4 { return err("bad_args", "rect is [x1, y1, x2, y2] in mm".into(), ""); }
        grow(n[0], n[1], 0.0); grow(n[2], n[3], 0.0);
    } else {
        let refs: Vec<String> = match (args.get("ref").and_then(|v| v.as_str()), args.get("refs").and_then(|v| v.as_array())) {
            (Some(r), _) => vec![r.to_string()],
            (None, Some(a)) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
            _ => vec![],
        };
        let net = args.get("net").and_then(|v| v.as_str());
        if refs.is_empty() && net.is_none() {
            return err("bad_args", "pass rect, ref, refs or net".into(), "e.g. {\"ref\":\"U1\"} or {\"net\":\"SW\"} or {\"rect\":[10,5,20,15]}");
        }
        let st = routing_state(&json!({}));
        if st.get("success").and_then(|v| v.as_bool()) != Some(true) { return st; }
        let f = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);
        for p in st["pads"].as_array().cloned().unwrap_or_default() {
            let by_ref = p["ref"].as_str().map(|r| refs.iter().any(|x| x == r)).unwrap_or(false);
            let by_net = net.is_some() && p["net"].as_str() == net;
            if by_ref || by_net { grow(f(&p, "x"), f(&p, "y"), f(&p, "sizeX").max(f(&p, "sizeY")) / 2.0); }
        }
        if net.is_some() {
            for t in st["tracks"].as_array().cloned().unwrap_or_default() {
                if t["net"].as_str() != net { continue; }
                let w = f(&t, "width") / 2.0;
                grow(f(&t["start"], "x"), f(&t["start"], "y"), w); grow(f(&t["end"], "x"), f(&t["end"], "y"), w);
            }
            for v in st["vias"].as_array().cloned().unwrap_or_default() {
                if v["net"].as_str() == net { grow(f(&v["position"], "x"), f(&v["position"], "y"), f(&v, "size") / 2.0); }
            }
        }
    }
    let Some(r) = b else { return err("not_found", "nothing on the board matched".into(), "Check the ref or net name against altium_routing_state."); };
    let rect = [r[0] - margin, r[1] - margin, r[2] + margin, r[3] + margin];
    let c = agent::call("zoom_rect", &[("l", raw(rect[0]).to_string()), ("b", raw(rect[1]).to_string()), ("r", raw(rect[2]).to_string()), ("t", raw(rect[3]).to_string())], None, Duration::from_secs(30));
    match c.get("data") {
        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "rect": rect, "_hint": "The view is framed on rect (mm, Altium's y-up frame). desktop_screenshot_window shows it."}),
        _ => c,
    }
}

/// altium_add_rule {kind: clearance|width, name, scope1, scope2?, gap? | min?/preferred?/max?, comment?}:
/// one scoped rule, mm. scope is Altium's query language: InNet('SW'), InNetClass('Power'), All.
pub fn add_rule(args: &Value) -> Value {
    let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
    let kind = st("kind");
    let name = safe_name(&st("name"));
    let scope1 = st("scope1");
    if args.get("remove").and_then(|v| v.as_bool()) == Some(true) {
        if name.is_empty() || !matches!(kind.as_str(), "clearance" | "width") { return err("bad_args", "remove needs kind and name".into(), ""); }
        let r = agent::call("add_rule", &[("kind", kind.clone()), ("name", name.clone()), ("remove", "1".into())], None, Duration::from_secs(60));
        return match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "name": d["name"], "removed": d["removed"]}), _ => r };
    }
    if name.is_empty() || scope1.is_empty() { return err("bad_args", "name and scope1 are required".into(), "e.g. {kind:\"clearance\", name:\"SW 0.3\", scope1:\"InNet('SW')\", scope2:\"All\", gap:0.3}"); }
    if scope1.contains('\n') || st("scope2").contains('\n') { return err("bad_args", "a scope is one line".into(), ""); }
    let scope2 = if st("scope2").is_empty() { "All".to_string() } else { st("scope2") };
    let mm = |k: &str| args.get(k).and_then(|v| v.as_f64());
    let mut p: Vec<(&str, String)> = vec![("kind", kind.clone()), ("name", name.clone()), ("scope1", scope1), ("scope2", scope2), ("comment", st("comment"))];
    match kind.as_str() {
        "clearance" => { let Some(g) = mm("gap") else { return err("bad_args", "clearance needs gap (mm)".into(), "") }; p.push(("a", raw(g).to_string())); }
        "width" => {
            let Some(min) = mm("min") else { return err("bad_args", "width needs min (mm)".into(), "") };
            p.push(("a", raw(min).to_string())); p.push(("b", raw(mm("preferred").unwrap_or(min)).to_string())); p.push(("c", raw(mm("max").unwrap_or(min.max(5.0))).to_string()));
        }
        _ => return err("bad_args", format!("kind \"{kind}\" is not clearance or width"), ""),
    }
    let r = agent::call("add_rule", &p, None, Duration::from_secs(60));
    match r.get("data") {
        Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => json!({"success": true, "name": d["name"], "priority": d["priority"],
            "_hint": "The rule is first among its kind. Check its query in Altium's rules editor (Test Queries) when unsure: a bad scope matches nothing and DRC passes silently."}),
        _ => r,
    }
}

fn agent_ok(r: &Value) -> Option<Value> {
    match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => Some(d.clone()), _ => None }
}

/// a document's path as the project file lists it: relative when it sits under the project's folder
fn rel_to(dir: &str, path: &str) -> String {
    let d = dir.trim_end_matches(['\\', '/']).replace('/', "\\");
    let p = path.replace('/', "\\");
    if p.to_lowercase().starts_with(&(d.to_lowercase() + "\\")) { p[d.len() + 1..].to_string() } else { p }
}

/// altium_build_schematic {schPath, libraryPath (.SchLib), parts:[{ref, symbol, block?, comment?}], nets:{net:[ref.pin]},
///   projectPath?, pcbPath?, pcbLibPath?, title?, number?, revision?, drawnBy?}
pub fn build_schematic(args: &Value) -> Value {
    let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
    let (sch, lib) = (st("schPath"), st("libraryPath"));
    if sch.is_empty() || lib.is_empty() { return err("bad_args", "schPath and libraryPath (.SchLib) are required".into(), ""); }
    let parts = args.get("parts").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    if parts.is_empty() { return err("bad_args", "parts is empty".into(), "parts:[{ref, symbol, block?, comment?}]"); }
    let nets = args.get("nets").and_then(|v| v.as_object()).cloned().unwrap_or_default();
    let clean = |s: &str| s.chars().filter(|c| !matches!(c, '|' | '\n' | '\r')).collect::<String>();
    let mut ops = Vec::new();
    let mut refs = std::collections::BTreeSet::new();
    for p in &parts {
        let r = clean(p.get("ref").and_then(|v| v.as_str()).unwrap_or(""));
        let sym = clean(p.get("symbol").and_then(|v| v.as_str()).unwrap_or(""));
        if r.is_empty() || sym.is_empty() { return err("bad_args", "every part needs ref and symbol (its LibReference in the SchLib)".into(), ""); }
        ops.push(format!("C|{r}|{sym}|{}|{}|{}", clean(p.get("block").and_then(|v| v.as_str()).unwrap_or("")), clean(p.get("comment").and_then(|v| v.as_str()).unwrap_or("")),
            clean(p.get("footprint").and_then(|v| v.as_str()).unwrap_or(""))));
        refs.insert(r);
    }
    // the nets as the sheet should compile: only pins of parts on it
    let mut want: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> = Default::default();
    for (n, pins) in &nets {
        for pin in pins.as_array().cloned().unwrap_or_default() {
            let Some(pin) = pin.as_str() else { continue };
            let Some((r, num)) = pin.split_once('.') else { continue };
            if !refs.contains(r) { continue; }
            ops.push(format!("P|{r}|{num}|{}", clean(n)));
            want.entry(n.clone()).or_default().insert(pin.to_string());
        }
    }
    // a sheet drawn again replaces the old one: close it first (an open document cannot be overwritten)
    agent::call("close_document", &[("path", sch.clone())], None, Duration::from_secs(60));
    // the sheet starts as one of Altium's own landscape templates (title block included), sized for the parts
    let (tpl, tw, th) = match parts.len() { 0..=12 => ("ANSI B Landscape", 17000, 11000), 13..=40 => ("ANSI C Landscape", 22000, 17000), _ => ("ANSI D Landscape", 34000, 22000) };
    let tpl_path = match crate::support::altium_public_file(&format!(r"Templates\{tpl}.SchDot")) {
        Ok(p) => p,
        Err(e) => return err("template_missing", e, "Altium's sheet templates live under Public Documents\\Altium\\<install>\\Templates (AD26, ADDevelop, ...)."),
    };
    if let Err(e) = std::fs::copy(&tpl_path, &sch) { return err("template_missing", format!("could not start the sheet from {tpl_path}: {e}"), ""); }
    // the project: the sheet, the board, the libraries and the manufacturing OutJob, as Altium lists them
    let dir = sch.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
    let stem = sch.rsplit_once(['\\', '/']).map(|(_, f)| f).unwrap_or(&sch).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
    let prj = if st("projectPath").is_empty() { format!("{dir}\\{stem}.PrjPcb") } else { st("projectPath") };
    let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
    // the manufacturing OutJob belongs to the project from the start (an OutJob outside it writes nothing)
    let outjob = if st("pcbPath").is_empty() { String::new() } else {
        crate::manufacturing::write_outjob(&prj, &st("pcbPath"), None).unwrap_or_default()
    };
    let docs: Vec<String> = [sch.clone(), st("pcbPath"), lib.clone(), st("pcbLibPath"), outjob].into_iter().filter(|d| !d.is_empty()).collect();
    let mut text = format!("[Design]\r\nVersion=1.0\r\nHierarchyMode=0\r\nOutputPath=Project Outputs for {stem}\r\nLogFolderPath=Project Logs for {stem}\r\n");
    for (i, d) in docs.iter().enumerate() { text.push_str(&format!("\r\n[Document{}]\r\nDocumentPath={}\r\n", i + 1, rel_to(&pdir, d))); }
    // written only when it changes: rewriting an OPEN project's file makes Altium ask to reload it
    // (a modal that blocked the next verb, measured 2026-10-15)
    if std::fs::read_to_string(&prj).ok().as_deref() != Some(text.as_str()) {
        // an open project closes first, so the new file opens clean instead of asking to reload; its
        // open members are saved and closed with it (closing it over a modified one raised "Save
        // changes?", then the rewrite raised "Reload?", mid-battery 2026-10-16). Documents that joined
        // it in memory only are the caller's: refuse rather than close them.
        if std::path::Path::new(&prj).exists() {
            let (open, unlisted) = project_members(std::path::Path::new(&prj));
            if !unlisted.is_empty() {
                return err("project_has_unlisted_documents", format!("{prj} is open in Altium with documents its file does not list: {}", unlisted.join(", ")),
                    "Save or close those documents (or the project) first: altium_save / altium_close_document, then call again. The bridge will not close documents it did not make.");
            }
            close_project_saved(&prj, &open);
        }
        if let Err(e) = std::fs::write(&prj, &text) { return err("write_failed", format!("could not write {prj}: {e}"), ""); }
    }
    // the project open first, so the sheet the agent opens is the project's
    agent::call("open", &[("path", prj.clone())], None, Duration::from_secs(120));
    let o = agent::call("open", &[("path", lib.clone())], None, Duration::from_secs(120));
    if agent_ok(&o).is_none() { return o; }
    let mut p: Vec<(&str, String)> = vec![("sch", sch.clone()), ("pcblib", st("pcbLibPath")), ("tplW", tw.to_string()), ("tplH", th.to_string())];
    for k in ["title", "number", "revision", "drawnBy"] { if !st(k).is_empty() { p.push((k, st(k))); } }
    let b = agent::call("build_schematic", &p, Some(&ops.join("\n")), Duration::from_secs(900));
    let Some(bd) = agent_ok(&b) else { return b };
    let c = agent::call("compile", &[("objectKind", "Project".into())], None, Duration::from_secs(300));
    let n = agent::call("project_nets", &[], None, Duration::from_secs(120));
    let Some(nd) = agent_ok(&n) else { return json!({"success": false, "errorCode": "netlist_unread", "sch": sch, "project": prj, "build": bd, "compile": c.get("data").cloned().unwrap_or(c.clone()), "netlist": n}) };
    let mut got: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> = Default::default();
    for (net, pins) in nd["nets"].as_object().cloned().unwrap_or_default() {
        got.insert(net, pins.as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(String::from)).collect());
    }
    let mut mismatches = Vec::new();
    for (net, w) in &want {
        let g = got.get(net).cloned().unwrap_or_default();
        if &g != w { mismatches.push(json!({"net": net, "missing": w.difference(&g).collect::<Vec<_>>(), "extra": g.difference(w).collect::<Vec<_>>()})); }
    }
    for (net, g) in &got {
        if !want.contains_key(net) && g.len() > 1 { mismatches.push(json!({"net": net, "unexpected": g})); }
    }
    let compile_errors: Vec<Value> = c.get("data").and_then(|d| d.get("messages")).and_then(|m| m.as_array()).cloned().unwrap_or_default()
        .into_iter().filter(|m| m["class"].as_str().map(|k| k.to_lowercase().contains("error")).unwrap_or(false)).collect();
    json!({"success": mismatches.is_empty(), "sch": sch, "project": prj, "placed": bd["placed"], "wires": bd["wires"], "labels": bd["labels"],
           "pinsWithoutNet": bd["pinsWithoutNet"], "sheetMil": bd["sheetMil"], "nets": want.len(), "compiledNets": got.len(),
           "mismatches": mismatches, "compileErrors": compile_errors,
           "_hint": "The sheet is drawn and saved inside its project, compiled, and its netlist compared pin by pin with nets (pins of parts on the sheet only). mismatches[] names any net that differs. Parts without a symbol (board-only pads) stay off the sheet."})
}

/// altium_select_net {net?, zoom?}: select one net as a whole (and nothing else); no net clears.
pub fn select_net(args: &Value) -> Value {
    let net = args.get("net").and_then(|v| v.as_str()).unwrap_or("").to_string();
    let r = agent::call("select_net", &[("net", net.clone())], None, Duration::from_secs(120));
    let Some(d) = agent_ok(&r) else { return r };
    let mut out = json!({"success": true, "net": net, "selected": d["selected"], "byKind": d["byKind"]});
    if !net.is_empty() && args.get("zoom").and_then(|v| v.as_bool()) == Some(true) {
        out["zoom"] = zoom_to(&json!({"net": net, "margin": args.get("margin").cloned().unwrap_or(json!(1.0))}))["rect"].clone();
    }
    if !net.is_empty() && d["selected"].as_u64() == Some(0) { out["_hint"] = json!("Nothing on the board is on that net: check the name against routing_state nets[]."); }
    out
}

fn mmv(v: &Value) -> f64 { (v.as_f64().unwrap_or(0.0) / PER_MM * 10000.0).round() / 10000.0 }
fn boxv(v: &Value) -> Value { json!(v.as_array().map(|a| a.iter().map(mmv).collect::<Vec<_>>()).unwrap_or_default()) }
fn textv(t: &Value) -> Value {
    json!({"text": t[0], "x": mmv(&t[1]), "y": mmv(&t[2]), "size": mmv(&t[3]), "rotation": t[4], "shown": t[5], "box": boxv(&t[6])})
}

/// altium_silk_state {}: every part's body, designator and comment text, and the free overlay texts and lines, mm.
pub fn silk_state(_args: &Value) -> Value {
    let r = agent::call("silk_state", &[], None, Duration::from_secs(180));
    let Some(d) = agent_ok(&r) else { return r };
    let (top, bottom) = (d["top"].as_i64().unwrap_or(-1), d["bottom"].as_i64().unwrap_or(-2));
    let side = |l: &Value| if l.as_i64() == Some(bottom) { "bottom" } else if l.as_i64() == Some(top) { "top" } else { "other" };
    let comps: Vec<Value> = d["comps"].as_array().cloned().unwrap_or_default().iter().map(|c| json!({
        "ref": c[0], "pattern": c[1], "x": mmv(&c[2]), "y": mmv(&c[3]), "rotation": c[4], "side": if c[5].as_bool() == Some(true) { "bottom" } else { "top" },
        "body": boxv(&c[6]), "name": textv(&c[7]), "comment": textv(&c[8])})).collect();
    let texts: Vec<Value> = d["texts"].as_array().cloned().unwrap_or_default().iter().map(|t| json!({
        "side": side(&t[0]), "text": t[1], "x": mmv(&t[2]), "y": mmv(&t[3]), "size": mmv(&t[4]), "rotation": t[5], "mirror": t[6], "box": boxv(&t[7]),
        "raw": {"layer": t[0], "x": t[2], "y": t[3]}})).collect();
    let lines: Vec<Value> = d["lines"].as_array().cloned().unwrap_or_default().iter().map(|l| json!({
        "side": side(&l[0]), "x1": mmv(&l[1]), "y1": mmv(&l[2]), "x2": mmv(&l[3]), "y2": mmv(&l[4]), "width": mmv(&l[5]),
        "raw": {"layer": l[0], "x1": l[1], "y1": l[2], "x2": l[3], "y2": l[4]}})).collect();
    json!({"success": true, "components": comps, "texts": texts, "lines": lines,
           "_hint": "mm, Altium's frame (y up). A text's x,y is its own location (lower left of the text before rotation); box is what Altium measures. texts/lines are the free overlay items, not a part's own."})
}

/// altium_apply_silkscreen {names?:[{ref,x,y,size,rotation?,show?}], comments?:[...], texts?:[{text,x,y,size,rotation?,side?}],
///   lines?:[{x1,y1,x2,y2,width?,side?}], remove?:{texts:[raw from silk_state], lines:[raw]}}
pub fn apply_silkscreen(args: &Value) -> Value {
    let f = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);
    let clean = |s: &str| s.chars().filter(|c| !matches!(c, '|' | '\n' | '\r')).collect::<String>();
    let side = |v: &Value| if v.get("side").and_then(|x| x.as_str()) == Some("bottom") { "B" } else { "T" };
    let mut ops = Vec::new();
    let rm = args.get("remove").cloned().unwrap_or(json!({}));
    for t in rm["texts"].as_array().cloned().unwrap_or_default() {
        ops.push(format!("DT|{}|{}|{}|{}", t["layer"], clean(t["text"].as_str().unwrap_or("")), t["x"], t["y"]));
    }
    for l in rm["lines"].as_array().cloned().unwrap_or_default() {
        ops.push(format!("DL|{}|{}|{}|{}|{}", l["layer"], l["x1"], l["y1"], l["x2"], l["y2"]));
    }
    for (key, k) in [("names", "N"), ("comments", "V")] {
        for n in args.get(key).and_then(|v| v.as_array()).cloned().unwrap_or_default() {
            let show = if n.get("show").and_then(|v| v.as_bool()) == Some(false) { "0" } else { "1" };
            ops.push(format!("{k}|{}|{}|{}|{}|{}|{show}", clean(n["ref"].as_str().unwrap_or("")), raw(f(&n, "x")), raw(f(&n, "y")), raw(f(&n, "size").max(0.3)), f(&n, "rotation").round() as i64));
        }
    }
    for t in args.get("texts").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        ops.push(format!("T|{}|{}|{}|{}|{}|{}", side(&t), clean(t["text"].as_str().unwrap_or("")), raw(f(&t, "x")), raw(f(&t, "y")), raw(f(&t, "size").max(0.3)), f(&t, "rotation").round() as i64));
    }
    for l in args.get("lines").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
        let w = if f(&l, "width") > 0.0 { f(&l, "width") } else { 0.15 };
        ops.push(format!("L|{}|{}|{}|{}|{}|{}", side(&l), raw(f(&l, "x1")), raw(f(&l, "y1")), raw(f(&l, "x2")), raw(f(&l, "y2")), raw(w)));
    }
    if ops.is_empty() { return err("nothing_to_do", "no names, comments, texts, lines or removals".into(), ""); }
    let r = agent::call("apply_silk", &[], Some(&ops.join("\n")), Duration::from_secs(600));
    let Some(d) = agent_ok(&r) else { return r };
    let after = silk_state(&json!({}));
    json!({"success": d["failed"].as_i64() == Some(0), "applied": d["applied"], "failed": d["failed"], "results": d["results"],
           "after": after, "_hint": "after is silk_state once it landed: check each label's box (Altium's own measure) against pads, vias, bodies and the other labels."})
}

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

    #[test]
    fn listed_documents_resolve_against_the_project_folder() {
        let text = "[Design]\r\n[Document1]\r\nDocumentPath=Buck.SchDoc\r\n[Document2]\r\nDocumentPath=Buck.PcbDoc\r\n[Document3]\r\nDocumentPath=library\\Buck.SchLib\r\n[Document4]\r\nDocumentPath=C:\\other\\Shared.PcbLib\r\n";
        let docs = listed_documents(text, std::path::Path::new("/w"));
        assert_eq!(docs.len(), 4);
        assert!(same_file_name(&docs[1], std::path::Path::new("/elsewhere/BUCK.pcbdoc")));
        assert_eq!(docs[0], std::path::Path::new("/w").join("Buck.SchDoc"));
        assert_eq!(docs[3], std::path::PathBuf::from("C:\\other\\Shared.PcbLib"));
    }
}