← Commit history

rust: phase 2 routing group on kicad-ipc-rs (routing_state, route_net, remove_route, routing_validate, board_pads, add_track, add_via, route, autoroute); 139 tests

John Lauer ·ac9a1e972e ·28d ago ·parent f870838
6 files changed +3206−9
rust/crates/kicad-bridge/src/catalog.rs+1−1
@@ -200,6 +200,6 @@ pub fn describe(version: &str) -> Value {         "runtime": "native",         "verbCount": verbs.len(),         "verbs": verbs,-        "_hint": "Phase 1 of the Rust port: headless verbs only. Window, show, routing and demo verbs are still served by the Python bridge until each phase passes the verb runner.",+        "_hint": "Native bridge: headless, schematic edit and live routing verbs are served here. Window, show and demo verbs arrive with phase 3; until then the Python bridge serves them.",     }) }
rust/crates/kicad-bridge/src/verbs_routing.rs+570−7
@@ -1,11 +1,574 @@-//! Verb group "routing". Placeholder until phase 2 lands.-use serde_json::Value;+//! Verb group "routing": the four live IPC verbs (routing_state, route_net, remove_route,+//! routing_validate) on `kicad_core::ipc`, the four file verbs (board_pads, add_track,+//! add_via, route) on `kicad_core::pcb`, and `kicad_autoroute`. Ported from+//! `handlers/live_routing.py` and `handlers/route.py`; the catalog text is the Python+//! `_VERB_CATALOG` plus the autoroute entry from docs/rust-port-plan.md. -use crate::catalog::Verb;-use crate::util::State;+use serde_json::{json, Value}; -pub static VERBS: &[Verb] = &[];+use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::pcb::{self, NetRef}; -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {-    None+pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_routing_state",+        summary: "Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\", \"ipcTimeoutMs\"?: 5000}",+        example: "kicad_routing_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Pass exact filePath and optional local ipc:// socketPath. Use revision as expectedRevision for edits.",+        related: &["kicad_route_net", "kicad_routing_validate", "kicad_ipc_api"],+        pitfalls: &["Requires KiCad 10.0.1+ with the IPC API server enabled and the board open in the PCB editor; no file fallback (kicad_board_pads reads the file).", "api_server_off means the switch in kicad_common.json is off: kicad_ipc_api {\"enable\":true}, then relaunch KiCad."],+    },+    Verb {+        name: "kicad_route_net",+        summary: "Commit a waypoint path or branching paths for ONE net live with native Undo and DRC preflight.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"net\", \"points\": [\"R1.2\", [120.5, 90], {\"x\":130,\"y\":88,\"layer\":\"B.Cu\"}] | \"paths\": [[...],[...]], \"width\"?: 0.25, \"layer\"?: \"F.Cu\", \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"validate\"?: true, \"dryRun\"?: false, \"save\"?: false}",+        example: "kicad_route_net {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<from routing_state>\",\"net\":\"GND\",\"points\":[\"J1.1\",[112,85],\"J2.1\"],\"width\":0.25}",+        hint: "Pass filePath, expectedRevision from routing_state, exact net name, points or paths, optional width/layer/viaSize/viaDrill. dryRun validates only; save:true explicitly saves.",+        related: &["kicad_routing_state", "kicad_remove_route", "kicad_routing_validate"],+        pitfalls: &["Caller chooses the route: this is not a shove router or an autonomous autorouter.", "DRC rejects new errors by default; validate:false explicitly skips it.", "Do not blindly retry a timed-out mutation (mutation_outcome_unknown, ipc_timeout); inspect live state first.", "A waypoint with a different layer drops a through via there, including at the final pad."],+    },+    Verb {+        name: "kicad_remove_route",+        summary: "Remove specific unlocked copper item IDs live as one Undo step.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"itemIds\": [\"<uuid>\", ...]}",+        example: "kicad_remove_route {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"itemIds\":[\"6f1c...\"]}",+        hint: "filePath, expectedRevision, itemIds from route_net. Refuses missing IDs, pads, footprints and locked copper.",+        related: &["kicad_route_net", "kicad_routing_state"],+        pitfalls: &["Does not save the file or delete a whole net implicitly."],+    },+    Verb {+        name: "kicad_routing_validate",+        summary: "Run KiCad DRC against the current unsaved live board snapshot.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_routing_validate {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "filePath and optional socketPath. Reports errors, warnings, unconnected, revision and stale.",+        related: &["kicad_route_net", "kicad_routing_state"],+        pitfalls: &["A stale report is superseded by newer edits; clean requires zero violations and zero unconnected items.", "Zones are refilled on the disposable snapshot only; the live board and the file are never touched."],+    },+    Verb {+        name: "kicad_board_pads",+        summary: "Every pad in BOARD coordinates with its net: the input a router needs.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"net\"?: \"GND\", \"reference\"?: \"U1\"}",+        example: "kicad_board_pads {\"filePath\":\"C:/d/x.kicad_pcb\"}",+        hint: "kicad_board_pads {\"filePath\":\"C:/d/x.kicad_pcb\"} (optional net/reference filter). Read-only. x/y are mm with footprint rotation applied, +y DOWN; hasCopper and netsWithoutTracks are file inventory only; use kicad_routing_state for connectivity.",+        related: &["kicad_route", "kicad_add_track", "kicad_extract_netlist"],+        pitfalls: &["pad coordinates are the pad CENTRE; a track to it still has to clear other nets", "KiCad 10 boards have no net numbers; address nets by name"],+    },+    Verb {+        name: "kicad_add_track",+        summary: "Write one copper segment to a CLOSED board file.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"start\": \"R1.2\" | [x,y], \"end\": ..., \"net\"?: \"GND\" | 0, \"layer\"?: \"F.Cu\", \"width\"?: 0.2, \"backup\"?: true}",+        example: "kicad_add_track {\"filePath\":\"C:/d/x.kicad_pcb\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}",+        hint: "kicad_add_track {\"filePath\":\"...\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}. Endpoints are pad names or [x,y] mm. Width defaults to the board's own.",+        related: &["kicad_route", "kicad_add_via", "kicad_board_pads", "kicad_run_drc"],+        pitfalls: &["there is no autorouter: YOU choose the path, and nothing checks clearance until kicad_run_drc", "refuses open boards; use kicad_route_net for live, undoable routing"],+    },+    Verb {+        name: "kicad_add_via",+        summary: "Drop one via so a route can change layer.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"at\": [x,y] | \"R1.2\", \"net\"?: \"GND\", \"layers\"?: [\"F.Cu\",\"B.Cu\"], \"size\"?: 0.6, \"drill\"?: 0.3, \"backup\"?: true}",+        example: "kicad_add_via {\"filePath\":\"C:/d/x.kicad_pcb\",\"at\":[120,90],\"net\":\"GND\",\"layers\":[\"F.Cu\",\"B.Cu\"]}",+        hint: "kicad_add_via {\"filePath\":\"...\",\"at\":[120,90],\"net\":\"GND\",\"layers\":[\"F.Cu\",\"B.Cu\"]}. Defaults 0.6 mm pad / 0.3 mm drill.",+        related: &["kicad_add_track", "kicad_route", "kicad_list_design_rules"],+        pitfalls: &["the via alone connects nothing; the tracks either side still have to be drawn", "check size/drill against the fab's rules"],+    },+    Verb {+        name: "kicad_route",+        summary: "Lay a whole path for one net in a single write, vias included.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"net\"?: \"GND\", \"points\": [\"R1.2\", [120,88], {\"x\":130,\"y\":88,\"layer\":\"B.Cu\"}, \"U1.7\"], \"width\"?: 0.25, \"layer\"?: \"F.Cu\", \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"backup\"?: true}",+        example: "kicad_route {\"filePath\":\"C:/d/x.kicad_pcb\",\"net\":\"GND\",\"points\":[\"R1.2\",[120,88],{\"x\":130,\"y\":88,\"layer\":\"B.Cu\"},\"U1.7\"]}",+        hint: "kicad_route {\"filePath\":\"...\",\"net\":\"GND\",\"points\":[\"R1.2\",[120,88],{\"x\":130,\"y\":88,\"layer\":\"B.Cu\"},\"U1.7\"]}. A waypoint with a different layer drops a via there.",+        related: &["kicad_add_track", "kicad_board_pads", "kicad_run_drc"],+        pitfalls: &["draws exactly the waypoints you give; it does not search for a path or avoid other nets", "for a visible demo, call kicad_route_net per segment"],+    },+    Verb {+        name: "kicad_autoroute",+        summary: "Route a board: engine \"ai\" (recommended) hands the AI a structured plan request for kicad_route_net; engine \"freerouting\" (phase 2b) will run Freerouting headless and land its copper as native undo steps.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"engine\": \"ai\" | \"freerouting\", \"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"GND\", ...], \"dryRun\"?: bool}",+        example: "kicad_autoroute {\"engine\":\"ai\",\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "ASK THE USER WHICH ENGINE THEY WANT BEFORE ROUTING, and say why ai is recommended: the routing verbs already give the AI pads, nets, zones, a live revision, one native undo step per trace and DRC per step; the AI sees the board, honours the schematic's intent (current, return paths, thermal) and can explain every trace, which an autorouter cannot. Offer freerouting for a deterministic pass. Report which engine produced the copper. engine ai returns the plan request (pads, nets, live state); you then route with kicad_route_net one net at a time.",+        related: &["kicad_routing_state", "kicad_route_net", "kicad_routing_validate", "kicad_board_pads"],+        pitfalls: &["Never pick the engine for the user; the hint makes you ask (same pattern as foregroundReason).", "engine ai mutates nothing by itself: the copper lands through kicad_route_net calls you make afterwards.", "engine freerouting returns not_implemented until phase 2b (Specctra DSN writer, Freerouting plus a portable Java fetched into the bridge cache, SES applied through kicad_add_track/kicad_add_via)."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_routing_state" => live(state, args, Live::State),+        "kicad_route_net" => live(state, args, Live::RouteNet),+        "kicad_remove_route" => live(state, args, Live::RemoveRoute),+        "kicad_routing_validate" => live(state, args, Live::Validate),+        "kicad_board_pads" => board_pads(args),+        "kicad_add_track" => add_track(args),+        "kicad_add_via" => add_via(args),+        "kicad_route" => route(args),+        "kicad_autoroute" => autoroute(state, args),+        _ => return None,+    })+}++// ---------------------------------------------------------------------------+// Live (IPC) verbs+// ---------------------------------------------------------------------------++#[derive(Clone, Copy)]+enum Live {+    State,+    RouteNet,+    RemoveRoute,+    Validate,+}++#[cfg(feature = "ipc")]+fn live(state: &mut State, args: &Value, which: Live) -> Value {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| std::path::PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let result = match which {+        Live::State => ipc::routing_state(&ctx, args),+        Live::RouteNet => ipc::route_net(&ctx, args),+        Live::RemoveRoute => ipc::remove_route(&ctx, args),+        Live::Validate => ipc::routing_validate(&ctx, args),+    };+    match result {+        Ok(v) => v,+        Err(e) => ipc::error_response(&e),+    }+}++#[cfg(not(feature = "ipc"))]+fn live(_state: &mut State, _args: &Value, _which: Live) -> Value {+    let mut v = fail("ipc_unavailable", "This build of the bridge has the ipc feature off; live routing needs the KiCad IPC client compiled in", "Use a build with the ipc feature (the default), or route the closed board file with kicad_board_pads, kicad_route, kicad_add_track and kicad_add_via.");+    v["ipcFeature"] = json!(false);+    v+}++fn ipc_available() -> bool {+    #[cfg(feature = "ipc")]+    {+        kicad_core::ipc::available()+    }+    #[cfg(not(feature = "ipc"))]+    {+        false+    }+}++// ---------------------------------------------------------------------------+// File verbs (handlers/route.py)+// ---------------------------------------------------------------------------++/// `_after_write`: file writes never reload or revert an editor.+fn after_write(mut result: Value) -> Value {+    result["source"] = json!("file");+    result["reloaded"] = json!(false);+    let hint = result["_hint"].as_str().unwrap_or("").to_string();+    result["_hint"] = json!(format!("{hint} Open the saved board to inspect it. For live editing use kicad_route_net."));+    result+}++fn backup_wanted(args: &Value) -> bool {+    args.get("backup") != Some(&json!(false))+}++fn board_pads(args: &Value) -> Value {+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board = &loaded.board;+    let all = pcb::pads(board);+    let mut pads = all.clone();+    if let Some(net_filter) = args.get("net").filter(|v| !v.is_null() && *v != &json!("")) {+        let net = match pcb::resolve_net(board, Some(net_filter)) {+            Ok(n) => n,+            Err(e) => return e,+        };+        pads.retain(|p| p.net_key == net.key);+    }+    if let Some(reference) = arg_str(args, "reference") {+        let want = reference.to_uppercase();+        pads.retain(|p| p.reference.to_uppercase() == want);+    }+    let mut by_net: Vec<(String, usize)> = Vec::new();+    for p in &all {+        let no_net = match &p.net_key {+            Value::Null => true,+            Value::Number(n) => n.as_f64() == Some(0.0),+            Value::String(s) => s.is_empty(),+            _ => false,+        };+        if no_net {+            continue;+        }+        let name = if p.net_name.is_empty() { format!("net{}", p.net.unwrap_or(0)) } else { p.net_name.clone() };+        match by_net.iter_mut().find(|(n, _)| *n == name) {+            Some(e) => e.1 += 1,+            None => by_net.push((name, 1)),+        }+    }+    let routed: Vec<Value> = board.segments.iter().map(|s| pcb::net_key(board, s.net, s.net_name.as_deref())).collect();+    let nets: Vec<Value> = board+        .nets+        .iter()+        .filter(|n| !n.name.is_empty())+        .map(|n| {+            json!({+                "number": n.number, "name": n.name,+                "pads": by_net.iter().find(|(k, _)| *k == n.name).map(|(_, c)| *c).unwrap_or(0),+                "hasCopper": routed.contains(&pcb::net_key(board, n.number, Some(&n.name))),+            })+        })+        .collect();+    let mut needs_copper: Vec<String> = nets.iter().filter(|n| n["pads"].as_u64().unwrap_or(0) > 1 && n["hasCopper"] == json!(false)).map(|n| n["name"].as_str().unwrap_or("").to_string()).collect();+    needs_copper.sort();+    let copper = pcb::copper_layers(board);+    json!({+        "success": true,+        "boardPath": loaded.path.to_string_lossy(),+        "pads": pads,+        "padCount": pads.len(),+        "nets": nets,+        "copperLayers": copper,+        "netFormat": board.net_format.as_str(),+        "defaultWidth": pcb::default_width(board, None),+        "existingTracks": board.segments.len(),+        "existingVias": board.vias.len(),+        "boardDimensions": board.dimensions,+        "netsWithoutTracks": needs_copper.iter().take(60).collect::<Vec<_>>(),+        "source": "file", "connectivityChecked": false,+        "output": format!("{} pads, {} nets, {} tracks on {} copper layers", pads.len(), nets.len(), board.segments.len(), copper.len()),+        "_hint": "Pad x/y are BOARD millimetres (+y is DOWN) with footprint rotation applied: feed them straight to kicad_route or kicad_add_track. Route a net by name and pick your own waypoints. hasCopper means a track exists, not that the net is connected. Use kicad_routing_state for live connectivity. kicad_run_drc is the authority on clearance.",+    })+}++fn add_track(args: &Value) -> Value {+    let loaded = match pcb::load(args, false) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board = &loaded.board;+    let pads = pcb::pads(board);+    let start_spec = args.get("start").or_else(|| args.get("from"));+    let end_spec = args.get("end").or_else(|| args.get("to"));+    let (Some(start_spec), Some(end_spec)) = (start_spec.filter(|v| !v.is_null()), end_spec.filter(|v| !v.is_null())) else {+        return fail("missing_arg", "add_track needs both start and end", "kicad_add_track {\"filePath\":\"...\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}. Points are pad names or [x,y] in mm.");+    };+    let (a, pad_a) = match pcb::resolve_point(start_spec, &pads) {+        Ok(v) => v,+        Err(e) => return e,+    };+    let (b, pad_b) = match pcb::resolve_point(end_spec, &pads) {+        Ok(v) => v,+        Err(e) => return e,+    };+    let net_spec = args.get("net").filter(|v| !v.is_null() && *v != &json!(""));+    let net: NetRef = match net_spec {+        None => match pcb::pad_net(board, pad_a).or_else(|| pcb::pad_net(board, pad_b)) {+            Some(n) => n,+            None => return fail("missing_net", "no net given and neither endpoint is a pad on a net", "Pass \"net\":\"GND\" (a name from kicad_board_pads), or \"net\":0 for deliberately unconnected copper. Copper on the wrong net is a DRC error."),+        },+        Some(spec) => match pcb::resolve_net(board, Some(spec)) {+            Ok(n) => n,+            Err(e) => return e,+        },+    };+    for pad in [pad_a, pad_b] {+        if let Some(pn) = pcb::pad_net(board, pad) {+            if pn.key != net.key {+                let p = pad.expect("pad_net implies a pad");+                return fail(+                    "net_mismatch",+                    format!("pad {} is on net '{}', not the net you asked to route ('{}')", p.name, p.net_name, net.name),+                    "Routing a track to a pad on a different net is a short. Check the pad's net with kicad_board_pads, or drop the net arg and let the endpoints decide it.",+                );+            }+        }+    }+    if pcb::dist(a, b) < 1e-6 {+        return fail("zero_length", "start and end are the same point", "A zero-length track is a DRC error in KiCad. Give the segment a real span, or use kicad_add_via for a layer change at one point.");+    }+    let layer = arg_str(args, "layer").map(str::to_string).or_else(|| pad_a.map(|p| p.side.clone()).filter(|s| !s.is_empty())).unwrap_or_else(|| pcb::DEFAULT_LAYER.to_string());+    let copper = pcb::copper_layers(board);+    if !copper.contains(&layer) {+        let mut v = fail("unknown_layer", format!("'{layer}' is not a copper layer on this board"), "Tracks only live on copper. This board's copper layers are listed above; a 2-layer board has only F.Cu and B.Cu.");+        v["copperLayers"] = json!(copper);+        return v;+    }+    let width = args.get("width").and_then(Value::as_f64).unwrap_or_else(|| pcb::default_width(board, Some(&net.key)));+    let block = pcb::segment_sexpr(a, b, width, &layer, &net);+    let bak = match pcb::write_board(&loaded.path, &pcb::splice(&loaded.text, &[block]), backup_wanted(args), Some(&loaded.text)) {+        Ok(b) => b,+        Err(e) => return e,+    };+    let warnings: Vec<String> = [pad_a, pad_b]+        .into_iter()+        .flatten()+        .filter(|p| !pcb::pad_reaches(Some(p), &layer))+        .map(|p| {+            let on = if p.layers.is_empty() { "no copper".to_string() } else { p.layers.join(", ") };+            format!("pad {} is not on {layer} (it is on {on}): this end of the track is dangling, not connected", p.name)+        })+        .collect();+    let net_label = if net.name.is_empty() { "no net".to_string() } else { net.name.clone() };+    after_write(json!({+        "success": true, "boardPath": loaded.path.to_string_lossy(), "backupPath": bak, "warnings": warnings,+        "start": [a.0, a.1], "end": [b.0, b.1], "layer": layer, "width": width,+        "net": net.number, "netName": net.name,+        "lengthMm": (pcb::dist(a, b) * 10000.0).round() / 10000.0,+        "tracksOnBoard": board.segments.len() + 1,+        "output": format!("Track on {net_label}: ({}, {}) -> ({}, {}) on {layer}, {} mm wide", pcb::num(a.0), pcb::num(a.1), pcb::num(b.0), pcb::num(b.1), pcb::num(width)),+        "_hint": "One segment written. Call again for the next one: that is the trace-by-trace loop. For a whole path in a single write use kicad_route. Nothing here checks clearance: run kicad_run_drc when the net is done.",+    }))+}++fn add_via(args: &Value) -> Value {+    let loaded = match pcb::load(args, false) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board = &loaded.board;+    let pads = pcb::pads(board);+    let Some(at_spec) = args.get("at").or_else(|| args.get("position")).filter(|v| !v.is_null()) else {+        return fail("missing_arg", "add_via needs `at`", "kicad_add_via {\"filePath\":\"...\",\"at\":[120,90],\"net\":\"GND\"}. Place it where the track changes layer.");+    };+    let (at, pad) = match pcb::resolve_point(at_spec, &pads) {+        Ok(v) => v,+        Err(e) => return e,+    };+    let net_spec = args.get("net").filter(|v| !v.is_null() && *v != &json!(""));+    let net = match net_spec {+        None => match pcb::pad_net(board, pad) {+            Some(n) => n,+            None => return fail("missing_net", "no net given and `at` is not a pad on a net", "Pass \"net\":\"GND\". A via must carry the net of the track it joins."),+        },+        Some(spec) => match pcb::resolve_net(board, Some(spec)) {+            Ok(n) => n,+            Err(e) => return e,+        },+    };+    let copper = pcb::copper_layers(board);+    let layers: Vec<String> = match args.get("layers").and_then(Value::as_array).filter(|a| !a.is_empty()) {+        Some(a) => a.iter().map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).collect(),+        None => vec![copper.first().cloned().unwrap_or_else(|| "F.Cu".into()), copper.last().cloned().unwrap_or_else(|| "B.Cu".into())],+    };+    if layers.len() < 2 || layers[..2].iter().any(|l| !copper.contains(l)) {+        let mut v = fail("unknown_layer", format!("via layers {} are not both copper on this board", json!(layers)), "A via spans two copper layers, e.g. \"layers\":[\"F.Cu\",\"B.Cu\"].");+        v["copperLayers"] = json!(copper);+        return v;+    }+    let size = args.get("size").and_then(Value::as_f64).unwrap_or(pcb::DEFAULT_VIA_SIZE);+    let drill = args.get("drill").and_then(Value::as_f64).unwrap_or(pcb::DEFAULT_VIA_DRILL);+    if drill >= size {+        return fail("bad_via_geometry", format!("drill {drill} is not smaller than pad size {size}"), "A via's drill must be smaller than its pad, or there is no annular ring.");+    }+    let block = pcb::via_sexpr(at, size, drill, (&layers[0], &layers[1]), &net);+    let bak = match pcb::write_board(&loaded.path, &pcb::splice(&loaded.text, &[block]), backup_wanted(args), Some(&loaded.text)) {+        Ok(b) => b,+        Err(e) => return e,+    };+    let net_label = if net.name.is_empty() { "no net".to_string() } else { net.name.clone() };+    after_write(json!({+        "success": true, "boardPath": loaded.path.to_string_lossy(), "backupPath": bak,+        "at": [at.0, at.1], "size": size, "drill": drill, "layers": [layers[0], layers[1]],+        "net": net.number, "netName": net.name,+        "viasOnBoard": board.vias.len() + 1,+        "output": format!("Via at ({}, {}) joining {}->{} on {net_label}", pcb::num(at.0), pcb::num(at.1), layers[0], layers[1]),+        "_hint": "The via is copper on its own: the tracks either side of it still have to be drawn (kicad_add_track), or let kicad_route place vias for you by giving a waypoint a different layer. Check the size against the fab's rules: kicad_list_design_rules.",+    }))+}++fn route(args: &Value) -> Value {+    let loaded = match pcb::load(args, false) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board = &loaded.board;+    let raw = args.get("points").or_else(|| args.get("path")).cloned().unwrap_or(json!([]));+    let Some(mut points) = raw.as_array().cloned() else {+        return fail("invalid_path", "points must be an array", "Use points:[REF.PAD,[x,y],...] in board millimetres.");+    };+    if let Some(from) = args.get("from").filter(|v| !v.is_null()) {+        points.insert(0, from.clone());+    }+    if let Some(to) = args.get("to").filter(|v| !v.is_null()) {+        points.push(to.clone());+    }+    let mut net_spec = args.get("net").cloned().filter(|v| !v.is_null());+    if net_spec.is_none() {+        let pads = pcb::pads(board);+        for spec in &points {+            if let Ok((_, Some(pad))) = pcb::resolve_point(spec, &pads) {+                if !pad.net_name.is_empty() {+                    net_spec = Some(json!(pad.net_name));+                    break;+                }+            }+        }+    }+    let net = match pcb::resolve_net(board, net_spec.as_ref()) {+        Ok(n) => n,+        Err(e) => return e,+    };+    let mut plan_args = args.clone();+    plan_args["net"] = json!(net.name);+    plan_args["points"] = json!(points);+    let plan = match pcb::plan(board, &plan_args) {+        Ok(p) => p,+        Err(e) => {+            let mut v = fail(&e.code, e.message, "Inspect kicad_board_pads and correct the route before retrying.");+            for (k, val) in e.detail {+                v[k] = val;+            }+            return v;+        }+    };+    let bak = match pcb::write_board(&loaded.path, &pcb::splice(&loaded.text, &plan.blocks), backup_wanted(args), Some(&loaded.text)) {+        Ok(b) => b,+        Err(e) => return e,+    };+    after_write(json!({+        "success": true, "boardPath": loaded.path.to_string_lossy(), "backupPath": bak,+        "netName": net.name, "segments": plan.segments, "vias": plan.vias,+        "segmentCount": plan.segments.len(), "viaCount": plan.vias.len(),+        "_hint": "Copper written to the closed board. Run kicad_run_drc for clearance and connectivity.",+    }))+}++// ---------------------------------------------------------------------------+// kicad_autoroute+// ---------------------------------------------------------------------------++const AI_WHY: &str = "The routing verbs already give the AI what it needs: pads, nets, zones, a live revision, one native undo step per trace and DRC per step. The AI sees the board, honours the schematic's intent (current, return paths, thermal) and can explain every trace; an autorouter cannot. GPT-6 Astra in Codex has routed a 94-footprint public board this way to zero unconnected items, Claude Fable 5.1 handles the same verbs well, and newer models are expected to be better still.";+const FREEROUTING_WHAT: &str = "A deterministic pass for the user who wants one: the bridge writes a Specctra DSN from the parsed board, fetches the Freerouting release and a portable Java runtime once into the bridge cache (verified, never a UAC prompt), runs it headless and applies the SES result through kicad_add_track and kicad_add_via, so even the autorouter's copper lands as native undo steps with a DRC check.";++fn engines() -> Value {+    json!([+        {"engine": "ai", "recommended": true, "available": true, "why": AI_WHY},+        {"engine": "freerouting", "recommended": false, "available": false, "phase": "2b", "what": FREEROUTING_WHAT},+    ])+}++fn autoroute(state: &mut State, args: &Value) -> Value {+    let engine = arg_str(args, "engine").map(|s| s.trim().to_ascii_lowercase());+    let dry_run = args.get("dryRun") == Some(&json!(true));+    match engine.as_deref() {+        None => {+            let mut v = fail("engine_required", "kicad_autoroute needs engine: \"ai\" or \"freerouting\"", "Ask the user which engine they want before routing. Recommend ai and say why (see engines[0].why); offer freerouting for a deterministic pass (phase 2b). Then call again with their choice.");+            v["engines"] = engines();+            v["mutated"] = json!(false);+            v+        }+        Some("ai") => autoroute_ai(state, args, dry_run),+        Some("freerouting") => {+            let mut v = fail("not_implemented", "engine freerouting lands with phase 2b of the Rust port", format!("{FREEROUTING_WHAT} Until phase 2b, route with engine ai: kicad_autoroute {{\"engine\":\"ai\"}} returns the plan request and kicad_route_net lays each net as one undo step."));+            v["engine"] = json!("freerouting");+            v["phase"] = json!("2b");+            v["engines"] = engines();+            v["mutated"] = json!(false);+            v+        }+        Some(other) => {+            let mut v = fail("unknown_engine", format!("unknown engine '{other}'"), "engine is \"ai\" (recommended) or \"freerouting\". Ask the user which one they want.");+            v["engines"] = engines();+            v+        }+    }+}++/// Engine `ai`: nothing is routed here. The response is the plan request: the board's+/// pads and nets from the file, the live routing state when the PCB editor has the board+/// open, and the instructions for routing with kicad_route_net.+fn autoroute_ai(state: &mut State, args: &Value, dry_run: bool) -> Value {+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board = &loaded.board;+    let wanted: Option<Vec<String>> = args.get("nets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());+    let all_pads = pcb::pads(board);+    let mut nets: Vec<Value> = board+        .nets+        .iter()+        .filter(|n| !n.name.is_empty())+        .filter(|n| wanted.as_ref().map(|w| w.contains(&n.name)).unwrap_or(true))+        .map(|n| {+            let key = pcb::net_key(board, n.number, Some(&n.name));+            let pads: Vec<&str> = all_pads.iter().filter(|p| p.net_key == key).map(|p| p.name.as_str()).collect();+            let has_copper = board.segments.iter().any(|s| pcb::net_key(board, s.net, s.net_name.as_deref()) == key);+            json!({"name": n.name, "number": n.number, "padCount": pads.len(), "pads": pads, "hasCopper": has_copper})+        })+        .collect();+    nets.sort_by(|a, b| b["padCount"].as_u64().cmp(&a["padCount"].as_u64()).then_with(|| a["name"].as_str().cmp(&b["name"].as_str())));+    let unknown: Vec<&String> = wanted.as_ref().map(|w| w.iter().filter(|n| !board.nets.iter().any(|b| &b.name == *n)).collect()).unwrap_or_default();+    let pads: Vec<&pcb::Pad> = match &wanted {+        Some(w) => all_pads.iter().filter(|p| w.contains(&p.net_name)).collect(),+        None => all_pads.iter().collect(),+    };+    // Live state when the board is open in the PCB editor; otherwise the file inventory.+    let live = if ipc_available() {+        let mut live_args = json!({"filePath": loaded.path.to_string_lossy()});+        if let Some(s) = args.get("socketPath") {+            live_args["socketPath"] = s.clone();+        }+        live(state, &live_args, Live::State)+    } else {+        fail("ipc_unavailable", "ipc feature off in this build", "Live state needs the ipc feature; the file inventory below still describes the board.")+    };+    let live_ok = live["success"] == json!(true);+    let existing = if live_ok {+        json!({+            "source": "live-editor", "revision": live["revision"], "segments": live["segments"], "vias": live["vias"],+            "nets": live["nets"], "netsRemaining": live["netsRemaining"],+        })+    } else {+        json!({+            "source": "file", "revision": Value::Null, "segments": board.segments, "vias": board.vias,+            "netsRemaining": nets.iter().filter(|n| n["padCount"].as_u64().unwrap_or(0) > 1 && n["hasCopper"] == json!(false)).map(|n| n["name"].clone()).collect::<Vec<_>>(),+            "live": {"errorCode": live["errorCode"], "error": live["error"], "_hint": live["_hint"]},+        })+    };+    let mut out = json!({+        "success": true, "engine": "ai", "dryRun": dry_run, "mutated": false,+        "boardPath": loaded.path.to_string_lossy(),+        "copperLayers": pcb::copper_layers(board),+        "netFormat": board.net_format.as_str(),+        "defaultWidth": pcb::default_width(board, None),+        "boardDimensions": board.dimensions,+        "pads": pads, "padCount": pads.len(),+        "nets": nets, "netCount": nets.len(),+        "unknownNets": unknown,+        "existing": existing,+        "engines": engines(),+        "planRequest": {+            "what": "Route every net in `nets` (netsRemaining first) with kicad_route_net, one net per call, using expectedRevision from the previous response.",+            "steps": [+                "Confirm with the user that they chose engine ai (say why it is recommended; offer freerouting as the deterministic alternative).",+                "If existing.source is file, open the board in the PCB editor with the IPC API on (kicad_ipc_api {\"enable\":true}, relaunch) and call kicad_routing_state for a revision.",+                "For each net: pick waypoints from `pads` (mm, +y down), keep clearance from other nets' pads and copper, change layer with {\"x\",\"y\",\"layer\":\"B.Cu\"} waypoints (a through via lands there), then kicad_route_net {net, points|paths, width, expectedRevision, dryRun:true} to preflight, then dryRun:false with the same revision.",+                "Read routing_state between nets (netsRemaining) and finish with kicad_routing_validate: clean requires zero errors and zero unconnected items.",+                "Report which engine produced the copper (ai) and that every net is one native Undo step."+            ],+            "why_ai": AI_WHY,+        },+        "_hint": "Nothing was routed. Ask the user which engine they want if you have not already; ai is recommended (see planRequest.why_ai). Then route with kicad_route_net per net, expectedRevision chained from each response, and validate with kicad_routing_validate. Report the engine used.",+    });+    if live_ok {+        out["revision"] = live["revision"].clone();+    }+    out }
rust/crates/kicad-core/src/ipc.rs+972−1
@@ -1 +1,972 @@-//! Placeholder: KiCad IPC client wrapper, filled in by phase 2.+//! Live, undoable copper edits through the official KiCad IPC API (KiCad 10.0.1+), on+//! `kicad-ipc-rs`. Port of `handlers/live_routing.py`: the caller plans the path, KiCad+//! checks candidate copper on a disposable snapshot before we commit it, and every+//! mutation is one native Undo step. No File > Revert, no clicks, no writes to the open+//! board file.+//!+//! Every IPC round trip carries the crate's send/receive deadline, so a KiCad whose UI+//! thread is blocked produces `ipc_timeout` instead of a hung HTTP server. The first+//! call after connecting is the responsiveness probe (`GetVersion`).+//!+//! How the Python's kipy concepts map onto the crate:+//! - `Board.get_as_string()` is `get_board_as_string()`; the revision is the SHA-256 of+//!   that text, exactly as the Python derives it (`_snapshot`).+//! - `begin_commit / push_commit / drop_commit` are `begin_commit()` and+//!   `end_commit(session, Commit | Drop, message)`; the message is the Undo entry.+//! - `create_items(Track | Via)` is `create_editable_items(vec![EditablePcbItem::...])`.+//! - `DeleteItems` is `delete_items(ids)`, whose per-row check matches the Python's+//!   `_delete_items` (KiCad 10.0.5 returns no rows; the board is re-read after push).++use std::collections::HashSet;+use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Map, Value};++use kicad_ipc_rs::{BoardLayerInfo, BoardNet, DocumentSpecifier, DocumentType, EditablePcbItem, KiCadClientBlocking, KiCadError, PcbItem, PcbObjectTypeCode, TrackItem, Vector2Nm, VersionInfo, ViaItem};++use crate::cli;+use crate::pcb::{self, Board, Plan, RoutingError};++/// The ipc cargo feature is compiled in. The verb group asks this before promising live+/// routing; a build with the feature off has no `ipc` module at all.+pub fn available() -> bool {+    true+}++pub const DEFAULT_TIMEOUT_MS: u64 = 5000;+pub const DRC_TIMEOUT: Duration = Duration::from_secs(90);+/// Connectivity walks stop here (`board_too_large`).+pub const MAX_WALK: usize = 100_000;++type RResult<T> = Result<T, RoutingError>;++/// What the verbs know that the operations need: where kicad-cli is (for the DRC+/// snapshot) and KiCad's config dir (to say `api_server_off` honestly).+#[derive(Clone, Debug, Default)]+pub struct Ctx {+    pub kicad_cli: Option<PathBuf>,+    pub config_dir: Option<PathBuf>,+}++// ---------------------------------------------------------------------------+// Errors and the response shape+// ---------------------------------------------------------------------------++const HINT: &str = "Read kicad_routing_state with the exact filePath and socketPath. Enable Preferences > Plugins > Enable IPC API server in KiCad 10.0.1+ (kicad_ipc_api {\"enable\":true} writes the switch; KiCad must be closed and relaunched). After a mutation timeout inspect the live board; never blindly replay it.";++/// The Python `_error`: `success:false`, `errorCode`, `error`, every detail key, `_hint`.+pub fn error_response(e: &RoutingError) -> Value {+    let mut out = Map::new();+    out.insert("success".into(), json!(false));+    out.insert("errorCode".into(), json!(e.code));+    out.insert("error".into(), json!(e.message));+    for (k, v) in &e.detail {+        out.insert(k.clone(), v.clone());+    }+    out.entry("_hint").or_insert(json!(HINT));+    Value::Object(out)+}++/// Map a crate error to the routing contract. Timeouts become `ipc_timeout` (nothing of+/// ours is still running; KiCad may answer nobody later), transport failures+/// `ipc_unavailable`, no open board `no_board_open`, an ambiguous board `board_mismatch`,+/// and any KiCad status or decode failure `ipc_error`.+fn map_err(e: KiCadError) -> RoutingError {+    match e {+        KiCadError::Timeout { timeout } => RoutingError::new("ipc_timeout", format!("KiCad did not answer within {} ms; its UI thread may be blocked by a dialog or a long operation", timeout.as_millis()))+            .with("ipcTimeoutMs", json!(timeout.as_millis() as u64))+            .with("stillRunning", json!(false))+            .with("mutated", json!(false)),+        KiCadError::SocketUnavailable { socket_uri } => RoutingError::new("ipc_unavailable", format!("KiCad IPC socket not available at {socket_uri}; open the board in the PCB editor with the API server enabled")).with("socket", json!(socket_uri)),+        KiCadError::Connection { socket_uri, reason } => RoutingError::new("ipc_unavailable", format!("connection failed for {socket_uri}: {reason}")).with("socket", json!(socket_uri)),+        KiCadError::TransportSend { .. } | KiCadError::TransportReceive { .. } | KiCadError::TransportClosed | KiCadError::BlockingRuntimeClosed | KiCadError::RuntimeJoin(_) => RoutingError::new("ipc_unavailable", e.to_string()),+        KiCadError::BoardNotOpen => RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])),+        KiCadError::AmbiguousBoardSelection { boards } => RoutingError::new("board_mismatch", "This IPC endpoint exposes more than one board").with("openBoards", json!(boards)),+        other => RoutingError::new("ipc_error", other.to_string()),+    }+}++/// A failure inside a commit: the message plus whether it was a deadline, so the+/// transaction can say `mutation_outcome_unknown` with `stillRunning`.+#[derive(Clone, Debug, PartialEq)]+pub struct IpcFailure {+    pub message: String,+    pub timed_out: bool,+}++impl From<KiCadError> for IpcFailure {+    fn from(e: KiCadError) -> Self {+        IpcFailure { timed_out: matches!(e, KiCadError::Timeout { .. }), message: e.to_string() }+    }+}++impl From<String> for IpcFailure {+    fn from(message: String) -> Self {+        IpcFailure { message, timed_out: false }+    }+}++// ---------------------------------------------------------------------------+// Pure helpers (tested without KiCad)+// ---------------------------------------------------------------------------++/// The board revision: SHA-256 of the editor's own serialisation of the board, the way+/// `live_routing._snapshot` derives it. Any edit (ours or the user's) changes it.+pub fn revision_of(text: &str) -> String {+    crate::pcm::sha256_hex(text.as_bytes())+}++/// `_revision`: `expectedRevision` must match the live board (`stale_board`).+pub fn check_revision(args: &Value, revision: &str) -> RResult<()> {+    if args.get("expectedRevision").and_then(Value::as_str) != Some(revision) {+        return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board").with("currentRevision", json!(revision)).with("mutated", json!(false)));+    }+    Ok(())+}++/// `_path`: an absolute `.kicad_pcb` path, normalised for comparison (forward slashes,+/// no `\\?\` prefix, case folded, resolved through the filesystem when it exists).+pub fn normalize_board_path(value: Option<&Value>) -> RResult<String> {+    let Some(s) = value.and_then(Value::as_str) else {+        return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));+    };+    let looks_absolute = Path::new(s).is_absolute() || s.as_bytes().get(1) == Some(&b':') && s.as_bytes().get(2).map(|c| *c == b'/' || *c == b'\\').unwrap_or(false);+    if !looks_absolute {+        return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));+    }+    if !s.to_ascii_lowercase().ends_with(".kicad_pcb") {+        return Err(RoutingError::new("invalid_board_path", "filePath must name a .kicad_pcb"));+    }+    Ok(normalize_existing(s))+}++fn normalize_existing(s: &str) -> String {+    let resolved = std::fs::canonicalize(s).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| s.to_string());+    let resolved = resolved.replace('\\', "/");+    let resolved = resolved.strip_prefix("//?/").unwrap_or(&resolved).to_string();+    resolved.to_lowercase()+}++/// The absolute path of an open document (`document_path` in the Python).+pub fn document_path(d: &DocumentSpecifier) -> String {+    let filename = d.board_filename.clone().unwrap_or_default();+    let p = Path::new(&filename);+    let absolute = p.is_absolute() || filename.as_bytes().get(1) == Some(&b':');+    if absolute {+        filename+    } else {+        match &d.project.path {+            Some(dir) => dir.join(&filename).to_string_lossy().to_string(),+            None => filename,+        }+    }+}++/// `_report_limits`: KiCad 10.0.3 DRC_ENGINE caps 199 markers per type, 499 for+/// clearance and unconnected items. At the boundary completeness is unknown.+pub fn report_limits(violations: &[Value], unconnected_len: usize) -> Value {+    let mut counts: Vec<(String, usize)> = Vec::new();+    for v in violations {+        let kind = match v.get("type") {+            Some(Value::String(s)) => s.clone(),+            Some(Value::Null) | None => "None".to_string(),+            Some(other) => other.to_string(),+        };+        match counts.iter_mut().find(|(k, _)| *k == kind) {+            Some(c) => c.1 += 1,+            None => counts.push((kind, 1)),+        }+    }+    let mut capped: Vec<String> = counts.into_iter().filter(|(k, n)| *n >= if k == "clearance" { 499 } else { 199 }).map(|(k, _)| k).collect();+    capped.sort();+    json!({+        "unconnectedReportMayBeTruncated": unconnected_len >= 499,+        "unconnectedCountIsLowerBound": unconnected_len >= 499,+        "violationReportMayBeTruncated": !capped.is_empty(),+        "possiblyTruncatedViolationTypes": capped,+    })+}++fn fingerprint(v: &Value) -> String {+    let mut positions: Vec<String> = v.get("items").and_then(Value::as_array).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null).to_string()).collect()).unwrap_or_default();+    positions.sort();+    json!([v.get("type"), v.get("severity"), v.get("description"), positions]).to_string()+}++/// `_new_errors`: the error-severity violations in `after` that `before` does not+/// account for, one to one (an existing error never masks a second identical one).+/// Refuses to certify a delta when either list may be capped (`drc_incomplete`).+pub fn new_errors(before: &Value, after: &Value) -> RResult<Vec<Value>> {+    let truncated = |r: &Value| r.get("violationReportMayBeTruncated").and_then(Value::as_bool).unwrap_or(false);+    if truncated(before) || truncated(after) {+        return Err(RoutingError::new("drc_incomplete", "DRC violation lists may be capped; cannot certify the error delta. No live copper was changed").with("mutated", json!(false)));+    }+    let errors = |r: &Value| -> Vec<Value> { r.get("violations").and_then(Value::as_array).map(|a| a.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).cloned().collect()).unwrap_or_default() };+    let mut existing: Vec<(String, usize)> = Vec::new();+    for v in errors(before) {+        let key = fingerprint(&v);+        match existing.iter_mut().find(|(k, _)| *k == key) {+            Some(c) => c.1 += 1,+            None => existing.push((key, 1)),+        }+    }+    let mut added = Vec::new();+    for v in errors(after) {+        let key = fingerprint(&v);+        match existing.iter_mut().find(|(k, n)| *k == key && *n > 0) {+            Some(c) => c.1 -= 1,+            None => added.push(v),+        }+    }+    Ok(added)+}++/// `_drc`: KiCad DRC on a disposable snapshot of the live board text, with the project's+/// own rules copied alongside and zones refilled so connectivity reflects the proposed+/// copper. The live document and the source file are never touched.+pub fn drc_snapshot(kicad_cli: Option<&Path>, source: &Path, text: &str) -> RResult<Value> {+    let Some(exe) = kicad_cli.filter(|p| p.is_file()) else {+        return Err(RoutingError::new("drc_unavailable", "kicad-cli is required for routing validation"));+    };+    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+    let tmp = std::env::temp_dir().join(format!("adom-routing-drc-{}-{n}", std::process::id()));+    let result = drc_in(exe, source, text, &tmp);+    let _ = std::fs::remove_dir_all(&tmp);+    result+}++fn drc_in(exe: &Path, source: &Path, text: &str, tmp: &Path) -> RResult<Value> {+    std::fs::create_dir_all(tmp).map_err(|e| RoutingError::new("drc_failed", format!("cannot create snapshot dir: {e}")))?;+    let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "board.kicad_pcb".into());+    let candidate = tmp.join(&name);+    std::fs::write(&candidate, text).map_err(|e| RoutingError::new("drc_failed", format!("cannot write snapshot: {e}")))?;+    // Preserve the actual project's rules and exclusions; never save/revert the live+    // document merely to get a headless DRC snapshot.+    let mut copied: Vec<String> = Vec::new();+    for ext in ["kicad_pro", "kicad_dru"] {+        let sibling = source.with_extension(ext);+        if sibling.exists() && std::fs::copy(&sibling, candidate.with_extension(ext)).is_ok() {+            copied.push(sibling.to_string_lossy().to_string());+        }+    }+    let report = tmp.join("drc.json");+    let report_s = report.to_string_lossy().to_string();+    let candidate_s = candidate.to_string_lossy().to_string();+    let r = cli::run(exe, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--refill-zones", "--output", &report_s, &candidate_s], DRC_TIMEOUT);+    if r.timed_out {+        return Err(RoutingError::new("drc_timeout", "kicad-cli DRC on the snapshot timed out").with("cliElapsedMs", json!(r.elapsed_ms)));+    }+    let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());+    let Some(result) = parsed.filter(|_| r.exit_code == 0) else {+        let msg = if !r.stderr.trim().is_empty() { r.stderr.trim().to_string() } else if !r.stdout.trim().is_empty() { r.stdout.trim().to_string() } else { "No DRC report".into() };+        return Err(RoutingError::new("drc_failed", msg).with("cliExitCode", json!(r.exit_code)));+    };+    Ok(drc_result(&result, copied))+}++/// Shape the raw kicad-cli report the way `_drc` returns it, plus `summary`: the same+/// `kicad_run_drc` summary (which counts unconnected items among its errors).+pub fn drc_result(report: &Value, project_rules_copied: Vec<String>) -> Value {+    let violations: Vec<Value> = report.get("violations").and_then(Value::as_array).cloned().unwrap_or_default();+    let unconnected: Vec<Value> = report.get("unconnected_items").and_then(Value::as_array).cloned().unwrap_or_default();+    let sev = |s: &str| violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some(s)).count();+    let mut out = json!({+        "errors": sev("error"),+        "warnings": sev("warning"),+        "unconnected": unconnected.len(),+        "violations": violations,+        "unconnectedItems": unconnected,+        "clean": violations.is_empty() && unconnected.is_empty(),+        "source": "live-editor-snapshot",+        "projectRulesCopied": project_rules_copied,+        "zonesRefilled": true,+        "summary": cli::summarize_report(report),+    });+    if let (Some(o), Some(limits)) = (out.as_object_mut(), report_limits(&violations, unconnected.len()).as_object()) {+        for (k, v) in limits {+            o.insert(k.clone(), v.clone());+        }+    }+    out+}++/// A KiCad commit session, abstracted so the transaction logic is testable without KiCad.+pub trait CommitTarget {+    fn begin(&self) -> Result<String, IpcFailure>;+    fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure>;+    fn drop_commit(&self, id: &str) -> Result<(), IpcFailure>;+}++/// `_transaction`: begin, run the operation, push. On any failure drop the commit and say+/// exactly what is known: `mutation_rolled_back` (nothing landed), or+/// `mutation_outcome_unknown` (`mutated: null`) when the push or the rollback itself+/// failed, with `stillRunning: true` when a deadline expired mid-commit.+pub fn transaction<T>(target: &dyn CommitTarget, message: &str, op: impl FnOnce() -> Result<T, IpcFailure>) -> RResult<T> {+    let id = target.begin().map_err(|e| RoutingError::new("mutation_rolled_back", e.message).with("mutated", json!(false)))?;+    let (failure, pushing) = match op() {+        Ok(result) => match target.push(&id, message) {+            Ok(()) => return Ok(result),+            Err(e) => (e, true),+        },+        Err(e) => (e, false),+    };+    let base = |code: &str| {+        let mut err = RoutingError::new(code, failure.message.clone());+        if failure.timed_out {+            err = err.with("ipcTimeout", json!(true));+        }+        err+    };+    match target.drop_commit(&id) {+        Err(rollback) => Err(base("mutation_outcome_unknown").with("rollbackError", json!(rollback.message)).with("mutated", Value::Null).with("stillRunning", json!(failure.timed_out || rollback.timed_out))),+        Ok(()) if pushing => Err(base("mutation_outcome_unknown").with("mutated", Value::Null).with("rollbackAttempted", json!(true)).with("stillRunning", json!(failure.timed_out))),+        Ok(()) => Err(base("mutation_rolled_back").with("mutated", json!(false))),+    }+}++// ---------------------------------------------------------------------------+// Connection+// ---------------------------------------------------------------------------++/// One connection to the PCB editor showing the requested board.+pub struct Session {+    client: KiCadClientBlocking,+    pub file_path: String,+    pub version: VersionInfo,+    pub socket: String,+    pub timeout: Duration,+}++impl CommitTarget for Session {+    fn begin(&self) -> Result<String, IpcFailure> {+        Ok(self.client.begin_commit()?.id)+    }+    fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure> {+        Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Commit, message)?)+    }+    fn drop_commit(&self, id: &str) -> Result<(), IpcFailure> {+        Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Drop, "")?)+    }+}++fn timeout_of(args: &Value) -> Duration {+    let ms = args.get("ipcTimeoutMs").and_then(Value::as_u64).unwrap_or(DEFAULT_TIMEOUT_MS).clamp(1000, 60_000);+    Duration::from_millis(ms)+}++/// `_connect`: validate the arguments, connect with a deadline, probe responsiveness+/// (`GetVersion`), require KiCad 10.0.1+, and require that the endpoint exposes exactly+/// the requested board.+pub fn connect(ctx: &Ctx, args: &Value) -> RResult<Session> {+    let wanted = normalize_board_path(args.get("filePath"))?;+    let file_path = args.get("filePath").and_then(Value::as_str).unwrap_or_default().to_string();+    let socket = match args.get("socketPath") {+        None | Some(Value::Null) => None,+        Some(Value::String(s)) if s.starts_with("ipc://") => Some(s.clone()),+        Some(_) => return Err(RoutingError::new("invalid_socket", "socketPath must be a local ipc:// socket")),+    };+    let timeout = timeout_of(args);+    let name = format!("adom-routing-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));+    let mut builder = KiCadClientBlocking::builder().client_name(name).timeout(timeout);+    if let Some(s) = &socket {+        builder = builder.socket_path(s.clone());+    }+    let client = match builder.connect() {+        Ok(c) => c,+        Err(e) => {+            let mut err = map_err(e);+            if err.code == "ipc_unavailable" {+                if crate::kicad_common::api_server_enabled(ctx.config_dir.as_deref()) == Some(false) {+                    err.code = "api_server_off".into();+                    err.message = format!("{}. KiCad's IPC API server switch is off in kicad_common.json", err.message);+                    err = err.with("_hint", json!("Save work and kicad_close, then kicad_ipc_api {\"enable\":true} (backup kept), then relaunch KiCad and open the board in the PCB editor. A running KiCad rewrites the file on exit, so close it first."));+                } else {+                    err = err.with("_hint", json!("Open the board in the PCB editor (KiCad 10.0.1+) with Preferences > Plugins > Enable IPC API server on. kicad_ipc_api {} reports the switch; the socket appears only while the PCB editor is running."));+                }+            }+            return Err(err);+        }+    };+    let socket_uri = client.socket_uri().to_string();+    // Responsiveness probe: the first round trip with the deadline.+    let version = client.get_version().map_err(map_err)?;+    if (version.major, version.minor, version.patch) < (10, 0, 1) {+        return Err(RoutingError::new("unsupported_kicad", "Live routing requires KiCad 10.0.1 or newer").with("kicadVersion", json!(version.full_version)));+    }+    let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;+    let open: Vec<String> = docs.iter().map(document_path).collect();+    if docs.is_empty() {+        return Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])).with("kicadVersion", json!(version.full_version)));+    }+    let matches = open.iter().filter(|p| normalize_existing(p) == wanted).count();+    if matches != 1 {+        return Err(RoutingError::new("board_mismatch", "This IPC endpoint does not expose exactly the requested board").with("openBoards", json!(open)).with("kicadVersion", json!(version.full_version)));+    }+    Ok(Session { client, file_path, version, socket: socket_uri, timeout })+}++impl Session {+    /// `_snapshot`: the editor's own serialisation, its parse, and the revision.+    pub fn snapshot(&self) -> RResult<(String, Board, String)> {+        let text = self.client.get_board_as_string().map_err(map_err)?;+        let board = pcb::parse_pcb_text(&text, &self.file_path).map_err(|e| RoutingError::new("board_unparsable", format!("could not parse the live board: {e}")))?;+        let revision = revision_of(&text);+        Ok((text, board, revision))+    }++    fn revision(&self) -> RResult<String> {+        Ok(revision_of(&self.client.get_board_as_string().map_err(map_err)?))+    }++    fn items_of(&self, codes: &[PcbObjectTypeCode]) -> RResult<Vec<PcbItem>> {+        self.client.get_items_by_type_codes(codes.iter().map(|c| c.code).collect()).map_err(map_err)+    }++    pub fn source_path(&self) -> PathBuf {+        PathBuf::from(&self.file_path)+    }+}++fn item_id(item: &PcbItem) -> Option<&str> {+    match item {+        PcbItem::Track(t) => t.id.as_deref(),+        PcbItem::Arc(a) => a.id.as_deref(),+        PcbItem::Via(v) => v.id.as_deref(),+        PcbItem::Footprint(f) => f.id.as_deref(),+        PcbItem::Pad(p) => p.id.as_deref(),+        PcbItem::BoardGraphicShape(s) => s.id.as_deref(),+        PcbItem::BoardText(t) => t.id.as_deref(),+        PcbItem::BoardTextBox(t) => t.id.as_deref(),+        PcbItem::Zone(z) => z.id.as_deref(),+        PcbItem::Dimension(d) => d.id.as_deref(),+        PcbItem::ReferenceImage(r) => r.id.as_deref(),+        PcbItem::Barcode(b) => b.id.as_deref(),+        PcbItem::Group(g) => g.id.as_deref(),+        PcbItem::Field(_) | PcbItem::Unknown(_) => None,+    }+}++fn item_locked(item: &PcbItem) -> bool {+    match item {+        PcbItem::Track(t) => t.locked.is_locked(),+        PcbItem::Arc(a) => a.locked.is_locked(),+        PcbItem::Via(v) => v.locked.is_locked(),+        _ => false,+    }+}++// ---------------------------------------------------------------------------+// Items+// ---------------------------------------------------------------------------++fn nm(mm: f64) -> i64 {+    (mm * 1_000_000.0).round() as i64+}++fn vector(p: [f64; 2]) -> Vector2Nm {+    Vector2Nm { x_nm: nm(p[0]), y_nm: nm(p[1]) }+}++/// `_items`: the plan as protobuf Track and Via items on the live net. Layer ids come+/// from the crate's canonical name table; via padstacks are what kipy builds (through,+/// normal padstack, one circular copper entry, F.Cu to B.Cu drill).+pub fn build_items(plan: &Plan, net: &BoardNet) -> RResult<Vec<EditablePcbItem>> {+    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));+    let f_cu = layer_id("F.Cu")?;+    let b_cu = layer_id("B.Cu")?;+    let mut items = Vec::with_capacity(plan.segments.len() + plan.vias.len());+    for seg in &plan.segments {+        let mut track = TrackItem::from_proto(Default::default());+        track.set_start_nm(vector(seg.start));+        track.set_end_nm(vector(seg.end));+        track.set_layer_id(layer_id(&seg.layer)?);+        let p = track.proto_mut();+        p.width = Some(Default::default());+        if let Some(w) = p.width.as_mut() {+            w.value_nm = nm(seg.width);+        }+        p.net = Some(Default::default());+        if let Some(n) = p.net.as_mut() {+            n.name = net.name.clone();+            n.code = Some(Default::default());+            if let Some(c) = n.code.as_mut() {+                c.value = net.code;+            }+        }+        items.push(EditablePcbItem::Track(track));+    }+    for v in &plan.vias {+        let mut via = ViaItem::from_proto(Default::default());+        via.set_position_nm(vector(v.at));+        let p = via.proto_mut();+        p.r#type = 1; // ViaType::VT_THROUGH+        p.net = Some(Default::default());+        if let Some(n) = p.net.as_mut() {+            n.name = net.name.clone();+            n.code = Some(Default::default());+            if let Some(c) = n.code.as_mut() {+                c.value = net.code;+            }+        }+        p.pad_stack = Some(Default::default());+        if let Some(ps) = p.pad_stack.as_mut() {+            ps.r#type = 1; // PadStackType::PST_NORMAL+            ps.copper_layers.push(Default::default());+            if let Some(cl) = ps.copper_layers.last_mut() {+                cl.layer = f_cu;+                cl.shape = 1; // PadStackShape::PSS_CIRCLE+                cl.size = Some(Default::default());+                if let Some(s) = cl.size.as_mut() {+                    s.x_nm = nm(v.size);+                    s.y_nm = nm(v.size);+                }+            }+            ps.drill = Some(Default::default());+            if let Some(d) = ps.drill.as_mut() {+                d.start_layer = f_cu;+                d.end_layer = b_cu;+                d.diameter = Some(Default::default());+                if let Some(s) = d.diameter.as_mut() {+                    s.x_nm = nm(v.drill);+                    s.y_nm = nm(v.drill);+                }+            }+        }+        items.push(EditablePcbItem::Via(via));+    }+    Ok(items)+}++// ---------------------------------------------------------------------------+// The four operations+// ---------------------------------------------------------------------------++/// `kicad_routing_state`: pads, copper, revision and KiCad-measured connectivity.+pub fn routing_state(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (_text, data, revision) = s.snapshot()?;+    let pads = pcb::pads(&data);+    let live_pads: Vec<(String, String)> = s+        .items_of(&[PcbObjectTypeCode::new_pad()])?+        .into_iter()+        .filter_map(|i| match i {+            PcbItem::Pad(p) => Some((p.id?, p.net.map(|n| n.name).unwrap_or_default())),+            _ => None,+        })+        .collect();+    let mut names: Vec<String> = data.nets.iter().map(|n| n.name.clone()).filter(|n| !n.is_empty()).collect();+    names.sort();+    names.dedup();+    let mut nets = Vec::new();+    for name in names {+        let net_pads: Vec<&str> = live_pads.iter().filter(|(_, n)| *n == name).map(|(id, _)| id.as_str()).collect();+        let mut remaining: Vec<&str> = net_pads.clone();+        let mut groups: Vec<Vec<&str>> = Vec::new();+        while let Some(seed) = remaining.first().copied() {+            let mut seen: HashSet<String> = HashSet::new();+            seen.insert(seed.to_string());+            let mut frontier: Vec<String> = vec![seed.to_string()];+            while !frontier.is_empty() {+                let neighbors = s.client.get_connected_items(frontier.clone(), Vec::new()).map_err(map_err)?;+                frontier = neighbors.iter().filter_map(item_id).filter(|id| !seen.contains(*id)).map(str::to_string).collect();+                seen.extend(frontier.iter().cloned());+                if seen.len() > MAX_WALK {+                    return Err(RoutingError::new("board_too_large", format!("Connectivity walk exceeds {MAX_WALK} items")));+                }+            }+            let group: Vec<&str> = remaining.iter().copied().filter(|id| seen.contains(*id)).collect();+            remaining.retain(|id| !seen.contains(*id));+            groups.push(group);+        }+        nets.push(json!({+            "name": name, "padCount": net_pads.len(), "padGroups": groups,+            "connected": groups.len() <= 1, "missingConnections": groups.len().saturating_sub(1),+        }));+    }+    let current = s.revision()?;+    if current != revision {+        return Err(RoutingError::new("stale_board", "Board changed during connectivity inspection; read again").with("currentRevision", json!(current)));+    }+    let remaining: Vec<Value> = nets.iter().filter(|n| n["connected"] == json!(false)).map(|n| n["name"].clone()).collect();+    Ok(json!({+        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",+        "kicadVersion": s.version.full_version, "socket": s.socket,+        "pads": pads, "nets": nets, "netsRemaining": remaining,+        "segments": data.segments, "vias": data.vias, "copperLayers": pcb::copper_layers(&data),+        "_hint": "Use this revision as expectedRevision in kicad_route_net. Coordinates are mm, +y down. Connectivity is measured by KiCad; kicad_routing_validate checks clearance and unconnected items.",+    }))+}++/// `kicad_route_net`: DRC preflight on a snapshot, then one commit of the planned copper.+pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = pcb::plan(&data, args)?;+    let mut check = Value::Null;+    if args.get("validate") != Some(&json!(false)) {+        let source = s.source_path();+        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;+        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&text, &plan.blocks))?;+        let added = new_errors(&before, &after)?;+        if !added.is_empty() {+            return Err(RoutingError::new("drc_rejected", "Candidate route adds DRC errors; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));+        }+        check = after;+    }+    let public = plan.public();+    if args.get("dryRun") == Some(&json!(true)) {+        let mut out = json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "_hint": "Plan validated without editing. Submit with dryRun:false and the same revision."});+        merge(&mut out, &public);+        return Ok(out);+    }+    // DRC runs outside KiCad. Re-read immediately before begin_commit so another+    // caller's or the user's edits during validation invalidate this plan.+    check_revision(args, &s.revision()?)?;+    let nets = s.client.get_nets().map_err(map_err)?;+    let Some(net) = nets.into_iter().find(|n| n.name == plan.net_name) else {+        return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", plan.net_name)).with("mutated", json!(false)));+    };+    let items = build_items(&plan, &net)?;+    let count = items.len();+    let created = transaction(&s, &format!("Adom route {}", plan.net_name), || {+        let created = s.client.create_editable_items(items, None)?;+        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {+            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));+        }+        Ok(created)+    })?;+    let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor",+        "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,+        "_hint": "Copper committed live as one KiCad Undo step. Inspect routing_state before the next route. The file is saved only with save:true; routing_validate checks the unsaved live board.",+    });+    merge(&mut result, &public);+    // Commit succeeded. A subsequent read/save failure must NEVER be reported as a failed+    // route inviting the caller to add it twice.+    match s.revision() {+        Ok(r) => {+            result["revision"] = json!(r);+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => result["saved"] = json!(true),+                    Err(e) => result["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// `kicad_remove_route`: delete specific unlocked copper items as one Undo step.+pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (_, _, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let ids: Vec<String> = match args.get("itemIds").and_then(Value::as_array) {+        Some(a) if !a.is_empty() && a.len() <= 1024 && a.iter().all(Value::is_string) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        _ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..1024 copper itemIds returned by route_net")),+    };+    let wanted: HashSet<String> = ids.iter().cloned().collect();+    let copper = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via()])?;+    let items: Vec<&PcbItem> = copper.iter().filter(|i| item_id(i).map(|id| wanted.contains(id)).unwrap_or(false)).collect();+    if items.len() != wanted.len() || items.iter().any(|i| item_locked(i)) {+        return Err(RoutingError::new("invalid_item_ids", "Some IDs are missing, locked, or are not copper; nothing removed"));+    }+    check_revision(args, &s.revision()?)?;+    let to_delete: Vec<String> = wanted.iter().cloned().collect();+    transaction(&s, "Adom remove route", || {+        let deleted: HashSet<String> = s.client.delete_items(to_delete.clone())?.into_iter().collect();+        if deleted != wanted {+            return Err(IpcFailure::from("KiCad did not accept every requested copper deletion".to_string()));+        }+        Ok(())+    })?;+    let mut result = json!({+        "success": true, "mutated": true, "removedIds": ids, "undoSteps": 1,+        "_hint": "Copper removed live. Use KiCad Undo to restore it, or inspect routing_state and route again.",+    });+    match s.snapshot() {+        Ok((_, after, rev)) => {+            result["revision"] = json!(rev);+            let present: HashSet<String> = after.segments.iter().filter_map(|x| x.uuid.clone()).chain(after.vias.iter().filter_map(|x| x.uuid.clone())).collect();+            let remaining: Vec<String> = wanted.iter().filter(|id| present.contains(*id)).cloned().collect();+            if !remaining.is_empty() {+                let mut removed: Vec<String> = wanted.iter().filter(|id| !present.contains(*id)).cloned().collect();+                removed.sort();+                result["removedIds"] = json!(removed);+                result["postCommitError"] = json!("Some requested copper remains after commit; inspect the live board");+            }+            result["verified"] = json!(remaining.is_empty());+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// `kicad_routing_validate`: KiCad DRC on the live, unsaved board snapshot.+pub fn routing_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, _, revision) = s.snapshot()?;+    let drc = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;+    let current = s.revision()?;+    let mut out = json!({+        "success": true, "revision": revision, "currentRevision": current, "stale": current != revision,+        "_hint": "This is KiCad DRC on the live editor snapshot with sibling project rules. A stale result is superseded by newer edits. clean requires no violations or unconnected items.",+    });+    merge(&mut out, &drc);+    Ok(out)+}++fn merge(into: &mut Value, from: &Value) {+    if let (Some(a), Some(b)) = (into.as_object_mut(), from.as_object()) {+        for (k, v) in b {+            a.insert(k.clone(), v.clone());+        }+    }+}++// ---------------------------------------------------------------------------+// Tests (no live KiCad)+// ---------------------------------------------------------------------------++#[cfg(test)]+mod tests {+    use super::*;+    use std::cell::RefCell;++    #[test]+    fn revision_is_sha256_of_the_board_text() {+        assert_eq!(revision_of("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");+        assert_ne!(revision_of("(kicad_pcb)"), revision_of("(kicad_pcb)\n"));+        assert!(check_revision(&json!({"expectedRevision": "x"}), "x").is_ok());+        for rev in [Value::Null, json!("old"), json!(1)] {+            let e = check_revision(&json!({"expectedRevision": rev}), "new").unwrap_err();+            assert_eq!(e.code, "stale_board");+            assert_eq!(e.detail["currentRevision"], "new");+            assert_eq!(e.detail["mutated"], false);+        }+        assert_eq!(check_revision(&json!({}), "new").unwrap_err().code, "stale_board");+    }++    #[test]+    fn board_path_validation_and_normalisation() {+        assert_eq!(normalize_board_path(None).unwrap_err().code, "invalid_board_path");+        assert_eq!(normalize_board_path(Some(&json!("relative/x.kicad_pcb"))).unwrap_err().code, "invalid_board_path");+        assert_eq!(normalize_board_path(Some(&json!("C:/x/y.kicad_sch"))).unwrap_err().code, "invalid_board_path");+        assert_eq!(normalize_board_path(Some(&json!("C:\\Users\\J\\Board.kicad_pcb"))).unwrap(), "c:/users/j/board.kicad_pcb");+        assert_eq!(normalize_board_path(Some(&json!("/tmp/nope/Board.kicad_pcb"))).unwrap(), "/tmp/nope/board.kicad_pcb");+        let d = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };+        assert_eq!(normalize_existing(&document_path(&d)), "/tmp/proj/live.kicad_pcb");+        let abs = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("C:/p/live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };+        assert_eq!(document_path(&abs), "C:/p/live.kicad_pcb");+    }++    #[test]+    fn capped_opens_are_lower_bound_without_blocking_error_comparison() {+        let limits = report_limits(&[], 499);+        assert_eq!(limits["unconnectedCountIsLowerBound"], true);+        assert_eq!(limits["violationReportMayBeTruncated"], false);+        let mut before = json!({"violations": []});+        merge(&mut before, &limits);+        assert_eq!(new_errors(&before, &before).unwrap(), Vec::<Value>::new());+        assert_eq!(report_limits(&[], 498)["unconnectedCountIsLowerBound"], false);+    }++    #[test]+    fn per_type_caps_block_error_delta_even_when_reports_match() {+        for (kind, cap) in [("clearance", 499usize), ("shorting_items", 199)] {+            let v = json!({"type": kind, "severity": "error", "items": []});+            let under: Vec<Value> = vec![v.clone(); cap - 1];+            assert_eq!(report_limits(&under, 0)["violationReportMayBeTruncated"], false);+            let at: Vec<Value> = vec![v.clone(); cap];+            let mut report = json!({"violations": at});+            let limits = report_limits(report["violations"].as_array().unwrap(), 0);+            assert_eq!(limits["possiblyTruncatedViolationTypes"], json!([kind]));+            merge(&mut report, &limits);+            let e = new_errors(&report, &report).unwrap_err();+            assert_eq!(e.code, "drc_incomplete");+            assert_eq!(e.detail["mutated"], false);+        }+    }++    #[test]+    fn existing_error_does_not_mask_additional_same_error() {+        let v = json!({"type": "clearance", "severity": "error", "description": "collision", "items": [{"pos": {"x": 1, "y": 2}}]});+        let w = json!({"type": "clearance", "severity": "warning", "description": "collision", "items": []});+        let added = new_errors(&json!({"violations": [v, w]}), &json!({"violations": [v, v, w, w]})).unwrap();+        assert_eq!(added, vec![v.clone()]);+        // Item order inside a violation does not change its identity.+        let a = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 1}}, {"pos": {"x": 2}}]});+        let b = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 2}}, {"pos": {"x": 1}}]});+        assert!(new_errors(&json!({"violations": [a]}), &json!({"violations": [b]})).unwrap().is_empty());+    }++    #[test]+    fn drc_result_has_the_python_shape() {+        let report = json!({+            "$schema": "x", "kicad_version": "10.0.5", "source": "board.kicad_pcb",+            "violations": [+                {"type": "clearance", "severity": "error", "description": "Clearance violation", "items": [{"description": "Track", "pos": {"x": 1.0, "y": 2.0}}]},+                {"type": "lib_footprint_issues", "severity": "warning", "description": "Footprint not found in libraries", "items": []},+            ],+            "unconnected_items": [{"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": []}],+        });+        let r = drc_result(&report, vec!["/p/board.kicad_pro".into()]);+        assert_eq!(r["errors"], 1);+        assert_eq!(r["warnings"], 1);+        assert_eq!(r["unconnected"], 1);+        assert_eq!(r["clean"], false);+        assert_eq!(r["zonesRefilled"], true);+        assert_eq!(r["source"], "live-editor-snapshot");+        assert_eq!(r["projectRulesCopied"], json!(["/p/board.kicad_pro"]));+        assert_eq!(r["violationReportMayBeTruncated"], false);+        assert_eq!(r["unconnectedItems"].as_array().unwrap().len(), 1);+        // `summary` is the bridge's kicad_run_drc summary, which counts unconnected items as errors too.+        assert_eq!(r["summary"]["errors"], 2);+        assert_eq!(r["summary"]["unconnected"], 1);+        assert_eq!(drc_result(&json!({"violations": [], "unconnected_items": []}), vec![])["clean"], true);+    }++    #[test]+    fn drc_snapshot_needs_kicad_cli() {+        let e = drc_snapshot(None, Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();+        assert_eq!(e.code, "drc_unavailable");+        let e = drc_snapshot(Some(Path::new("/definitely/not/kicad-cli")), Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();+        assert_eq!(e.code, "drc_unavailable");+    }++    /// A fake commit session recording begin/push/drop like tests/test_live_routing.py.+    struct Fake {+        ops: RefCell<Vec<&'static str>>,+        push_fails: Option<IpcFailure>,+        drop_fails: Option<IpcFailure>,+    }++    impl Fake {+        fn new() -> Self {+            Fake { ops: RefCell::new(Vec::new()), push_fails: None, drop_fails: None }+        }+    }++    impl CommitTarget for Fake {+        fn begin(&self) -> Result<String, IpcFailure> {+            self.ops.borrow_mut().push("begin");+            Ok("commit".into())+        }+        fn push(&self, _id: &str, _message: &str) -> Result<(), IpcFailure> {+            self.ops.borrow_mut().push("push");+            self.push_fails.clone().map(Err).unwrap_or(Ok(()))+        }+        fn drop_commit(&self, _id: &str) -> Result<(), IpcFailure> {+            self.ops.borrow_mut().push("drop");+            self.drop_fails.clone().map(Err).unwrap_or(Ok(()))+        }+    }++    #[test]+    fn success_is_one_undo_step() {+        let b = Fake::new();+        assert_eq!(transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap(), 42);+        assert_eq!(*b.ops.borrow(), vec!["begin", "push"]);+    }++    #[test]+    fn partial_create_failure_rolls_back() {+        let b = Fake::new();+        let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("partial create".to_string()))).unwrap_err();+        assert_eq!(e.code, "mutation_rolled_back");+        assert_eq!(e.detail["mutated"], false);+        assert_eq!(*b.ops.borrow(), vec!["begin", "drop"]);+    }++    #[test]+    fn push_timeout_is_unknown_even_if_drop_acknowledged() {+        let mut b = Fake::new();+        b.push_fails = Some(IpcFailure { message: "commit response lost".into(), timed_out: true });+        let e = transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap_err();+        assert_eq!(e.code, "mutation_outcome_unknown");+        assert_eq!(e.detail["mutated"], Value::Null);+        assert_eq!(e.detail["rollbackAttempted"], true);+        assert_eq!(e.detail["stillRunning"], true);+        assert_eq!(e.detail["ipcTimeout"], true);+        assert_eq!(*b.ops.borrow(), vec!["begin", "push", "drop"]);+    }++    #[test]+    fn failed_rollback_is_unknown_not_safe_to_retry() {+        let mut b = Fake::new();+        b.drop_fails = Some(IpcFailure::from("lost IPC".to_string()));+        let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("create failed".to_string()))).unwrap_err();+        assert_eq!(e.code, "mutation_outcome_unknown");+        assert_eq!(e.detail["mutated"], Value::Null);+        assert_eq!(e.detail["rollbackError"], "lost IPC");+        assert_eq!(e.detail["stillRunning"], false);+    }++    #[test]+    fn error_response_carries_detail_and_hint() {+        let e = RoutingError::new("stale_board", "old").with("currentRevision", json!("abc"));+        let v = error_response(&e);+        assert_eq!(v["success"], false);+        assert_eq!(v["errorCode"], "stale_board");+        assert_eq!(v["currentRevision"], "abc");+        assert!(v["_hint"].as_str().unwrap().contains("kicad_routing_state"));+        let custom = error_response(&RoutingError::new("api_server_off", "x").with("_hint", json!("enable it")));+        assert_eq!(custom["_hint"], "enable it");+    }++    #[test]+    fn timeout_maps_to_ipc_timeout_with_no_mutation() {+        let e = map_err(KiCadError::Timeout { timeout: Duration::from_millis(5000) });+        assert_eq!(e.code, "ipc_timeout");+        assert_eq!(e.detail["ipcTimeoutMs"], 5000);+        assert_eq!(e.detail["stillRunning"], false);+        assert_eq!(e.detail["mutated"], false);+        assert_eq!(map_err(KiCadError::BoardNotOpen).code, "no_board_open");+        assert_eq!(map_err(KiCadError::SocketUnavailable { socket_uri: "ipc:///x".into() }).code, "ipc_unavailable");+        assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_BAD_REQUEST".into(), message: "nope".into() }).code, "ipc_error");+        assert_eq!(timeout_of(&json!({})), Duration::from_millis(5000));+        assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 10})), Duration::from_millis(1000));+        assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));+    }++    #[test]+    fn items_carry_kipy_units_net_and_layers() {+        let text = crate::pcb::tests::fixture_text();+        let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();+        let plan = pcb::plan(&board, &json!({"net": "NET_1", "points": ["J1.1", {"x": 112, "y": 85, "layer": "B.Cu"}, "J2.1"]})).unwrap();+        let items = build_items(&plan, &BoardNet { code: 7, name: "NET_1".into() }).unwrap();+        assert_eq!(items.len(), 3);+        match &items[0] {+            EditablePcbItem::Track(t) => {+                assert_eq!(t.start_nm().unwrap(), Vector2Nm { x_nm: 105_000_000, y_nm: 85_000_000 });+                assert_eq!(t.proto().width.as_ref().unwrap().value_nm, 250_000);+                assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("F.Cu").unwrap());+                assert_eq!(t.proto().net.as_ref().unwrap().name, "NET_1");+            }+            other => panic!("expected a track, got {other:?}"),+        }+        match &items[1] {+            EditablePcbItem::Track(t) => assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("B.Cu").unwrap()),+            other => panic!("expected a track, got {other:?}"),+        }+        match &items[2] {+            EditablePcbItem::Via(v) => {+                assert_eq!(v.position_nm().unwrap(), Vector2Nm { x_nm: 112_000_000, y_nm: 85_000_000 });+                let ps = v.proto().pad_stack.as_ref().unwrap();+                assert_eq!(ps.copper_layers[0].size.as_ref().unwrap().x_nm, 600_000);+                assert_eq!(ps.drill.as_ref().unwrap().diameter.as_ref().unwrap().x_nm, 300_000);+                assert_eq!(v.proto().net.as_ref().unwrap().name, "NET_1");+            }+            other => panic!("expected a via, got {other:?}"),+        }+        // The Python contract: a KiCad 10 board with an unknown layer name is refused before any IPC.+        let bad = Plan { net_name: "NET_1".into(), segments: vec![pcb::PlanSegment { start: [0.0, 0.0], end: [1.0, 0.0], layer: "Nope.Cu".into(), width: 0.2 }], vias: vec![], blocks: vec![] };+        assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer");+    }+}
rust/crates/kicad-core/src/lib.rs+1
@@ -14,6 +14,7 @@ pub mod kicad_common; pub mod render; pub mod bridge_log; pub mod install;+pub mod pcb; #[cfg(feature = "ipc")] pub mod ipc; pub mod uninstall;
rust/crates/kicad-core/src/pcb.rsadded+1501
@@ -0,0 +1,1501 @@+//! `.kicad_pcb` reading and copper editing on disk. Port of `parsers/pcb.py` and the pure+//! parts of `handlers/route.py` (pads in board coordinates, net resolution, s-expression+//! emission, the atomic write with `.adom-bak`) plus the route planner that+//! `handlers/live_routing.py::_plan` and `handlers/route.py::handle_route` share.+//!+//! Coordinates are millimetres in KiCad's board frame: +x right, +y DOWN. Nothing here+//! talks to KiCad; the IPC group (`crate::ipc`) and the file verbs build on it.++use std::collections::HashMap;+use std::path::{Path, PathBuf};++use serde::Serialize;+use serde_json::{json, Map, Value};++use crate::model_check::{parse_sexpr, Sx};+use crate::schematic::new_uuid;++pub const DEFAULT_WIDTH: f64 = 0.2;+pub const DEFAULT_VIA_SIZE: f64 = 0.6;+pub const DEFAULT_VIA_DRILL: f64 = 0.3;+pub const DEFAULT_LAYER: &str = "F.Cu";+/// First KiCad file format that names nets instead of numbering them (KiCad 10).+pub const NAME_BASED_NET_VERSION: i64 = 20260206;++// ---------------------------------------------------------------------------+// Errors+// ---------------------------------------------------------------------------++/// The Python `RoutingError`: a code, a message and extra detail keys that land in the+/// response next to `errorCode` and `error`.+#[derive(Clone, Debug, PartialEq)]+pub struct RoutingError {+    pub code: String,+    pub message: String,+    pub detail: Map<String, Value>,+}++impl RoutingError {+    pub fn new(code: &str, message: impl Into<String>) -> Self {+        RoutingError { code: code.into(), message: message.into(), detail: Map::new() }+    }+    pub fn with(mut self, key: &str, value: Value) -> Self {+        self.detail.insert(key.into(), value);+        self+    }+}++impl std::fmt::Display for RoutingError {+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+        write!(f, "{}: {}", self.code, self.message)+    }+}++// ---------------------------------------------------------------------------+// Parsed board+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, Default, Serialize)]+pub struct Layer {+    pub number: String,+    pub name: String,+    #[serde(rename = "type")]+    pub kind: String,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub canonical_name: Option<String>,+}++#[derive(Clone, Debug, Default, Serialize, PartialEq)]+pub struct Net {+    pub number: Option<i64>,+    pub name: String,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Position {+    pub x: f64,+    pub y: f64,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub rotation: Option<f64>,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Point {+    pub x: f64,+    pub y: f64,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct FpPad {+    pub number: String,+    #[serde(rename = "type")]+    pub kind: String,+    pub shape: String,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub x: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub y: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub width: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub height: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub layers: Option<Vec<String>>,+    /// Present when the pad carries a (net ...) reference at all (either dialect).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net_number: Option<Option<i64>>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net_name: Option<String>,+}++impl FpPad {+    fn has_net(&self) -> bool {+        self.net_number.is_some() || self.net_name.is_some()+    }+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Footprint {+    pub footprint: String,+    pub layer: Option<String>,+    pub uuid: Option<String>,+    pub position: Option<Position>,+    pub locked: bool,+    pub properties: HashMap<String, String>,+    pub reference: String,+    pub value: String,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub path: Option<String>,+    pub sheetname: String,+    pub sheetfile: String,+    pub attr: String,+    pub pad_count: usize,+    pub pads: Vec<FpPad>,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Segment {+    pub uuid: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub start: Option<Point>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub end: Option<Point>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub width: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub layer: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net: Option<i64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net_name: Option<String>,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Via {+    pub uuid: Option<String>,+    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]+    pub kind: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub position: Option<Point>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub size: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub drill: Option<f64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub layers: Option<Vec<String>>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net: Option<i64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net_name: Option<String>,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Zone {+    pub uuid: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net: Option<i64>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub net_name: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub layer: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub layers: Option<Vec<String>>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub name: Option<String>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub priority: Option<i64>,+}++#[derive(Clone, Debug, Default, Serialize)]+pub struct Dimensions {+    pub min_x: f64,+    pub min_y: f64,+    pub max_x: f64,+    pub max_y: f64,+    pub width: f64,+    pub height: f64,+}++/// Which net dialect the board speaks: `Number` (KiCad <= 9, `(net 3)`) or `Name`+/// (KiCad 10, `(net "GND")`).+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]+#[serde(rename_all = "lowercase")]+pub enum NetFormat {+    Number,+    Name,+}++impl NetFormat {+    pub fn as_str(self) -> &'static str {+        match self {+            NetFormat::Number => "number",+            NetFormat::Name => "name",+        }+    }+}++#[derive(Clone, Debug, Serialize)]+pub struct Board {+    pub file: String,+    pub version: Option<String>,+    pub generator: Option<String>,+    pub general: Map<String, Value>,+    pub paper: Option<String>,+    pub title_block: Map<String, Value>,+    pub layers: Vec<Layer>,+    pub nets: Vec<Net>,+    pub footprints: Vec<Footprint>,+    pub segments: Vec<Segment>,+    pub vias: Vec<Via>,+    pub zones: Vec<Zone>,+    pub dimensions: Option<Dimensions>,+    pub net_format: NetFormat,+}++// ---------------------------------------------------------------------------+// Parsing+// ---------------------------------------------------------------------------++fn atom_f64(sx: &Sx, i: usize) -> Option<f64> {+    sx.atom_at(i)?.parse::<f64>().ok()+}++fn value_f64(sx: Option<&Sx>) -> Option<f64> {+    sx?.value()?.parse::<f64>().ok()+}++fn value_string(sx: Option<&Sx>) -> Option<String> {+    sx?.value().map(str::to_string)+}++fn string_atoms(sx: &Sx) -> Vec<String> {+    sx.as_list().map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default()+}++/// Read a `(net ...)` reference in either board format (`_net_ref`).+fn net_ref(node: Option<&Sx>, name_based: bool) -> (Option<i64>, Option<String>) {+    let Some(node) = node else { return (None, None) };+    let Some(first) = node.atom_at(1) else { return (None, None) };+    if name_based {+        return (None, Some(first.to_string()));+    }+    let digits = first.strip_prefix('-').unwrap_or(first);+    if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {+        let name = node.atom_at(2).map(str::to_string);+        return (first.parse::<i64>().ok(), name);+    }+    (None, Some(first.to_string()))+}++/// Parse board text already in memory (`parse_pcb_text`). The routing verbs splice new+/// copper into the TEXT they hold, so re-reading the file would race their own write.+pub fn parse_pcb_text(text: &str, filepath: &str) -> Result<Board, String> {+    let tree = parse_sexpr(text).ok_or_else(|| "Empty S-expression".to_string())?;+    if tree.as_list().is_none() {+        return Err("not an s-expression document".into());+    }+    Ok(resolve_net_format(extract_raw(&tree, filepath)))+}++pub fn parse_pcb(path: &Path) -> Result<Board, String> {+    let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;+    parse_pcb_text(&text, &path.to_string_lossy())+}++fn extract_raw(tree: &Sx, filepath: &str) -> Board {+    let version = value_string(tree.find("version"));+    let name_based = version.as_deref().and_then(|v| v.parse::<i64>().ok()).unwrap_or(0) >= NAME_BASED_NET_VERSION;+    Board {+        file: filepath.to_string(),+        version,+        generator: value_string(tree.find("generator")),+        general: extract_general(tree.find("general")),+        paper: value_string(tree.find("paper")),+        title_block: extract_title_block(tree.find("title_block")),+        layers: extract_layers(tree),+        nets: extract_nets(tree, name_based),+        footprints: extract_footprints(tree, name_based),+        segments: extract_segments(tree, name_based),+        vias: extract_vias(tree, name_based),+        zones: extract_zones(tree, name_based),+        dimensions: extract_dimensions(tree),+        net_format: NetFormat::Number,+    }+}++/// `_resolve_net_format`: a KiCad 10 board has no net table, so its net inventory is the+/// names its pads and copper mention.+fn resolve_net_format(mut data: Board) -> Board {+    let numbered = !data.nets.is_empty()+        || data.footprints.iter().flat_map(|f| f.pads.iter()).any(|p| matches!(p.net_number, Some(Some(_))));+    data.net_format = if numbered { NetFormat::Number } else { NetFormat::Name };+    if data.nets.is_empty() {+        let mut names: Vec<String> = Vec::new();+        let mut push = |n: Option<&String>| {+            if let Some(n) = n {+                if !n.is_empty() && !names.contains(n) {+                    names.push(n.clone());+                }+            }+        };+        for fp in &data.footprints {+            for p in &fp.pads {+                push(p.net_name.as_ref());+            }+        }+        for s in &data.segments {+            push(s.net_name.as_ref());+        }+        for v in &data.vias {+            push(v.net_name.as_ref());+        }+        for z in &data.zones {+            push(z.net_name.as_ref());+        }+        data.nets = names.into_iter().map(|name| Net { number: None, name }).collect();+    }+    data+}++fn extract_general(node: Option<&Sx>) -> Map<String, Value> {+    let mut out = Map::new();+    if let Some(node) = node {+        if let Some(t) = value_f64(node.find("thickness")) {+            out.insert("thickness".into(), json!(t));+        }+    }+    out+}++fn extract_title_block(node: Option<&Sx>) -> Map<String, Value> {+    let mut out = Map::new();+    let Some(node) = node else { return out };+    for field in ["title", "date", "rev", "company"] {+        if let Some(v) = value_string(node.find(field)).filter(|v| !v.is_empty()) {+            out.insert(field.into(), json!(v));+        }+    }+    for c in node.find_all("comment") {+        if let (Some(n), Some(text)) = (c.atom_at(1), c.atom_at(2)) {+            out.insert(format!("comment_{n}"), json!(text));+        }+    }+    out+}++fn extract_layers(tree: &Sx) -> Vec<Layer> {+    let Some(layers) = tree.find("layers").and_then(Sx::as_list) else { return Vec::new() };+    layers+        .iter()+        .skip(1)+        .filter_map(|child| {+            let l = child.as_list()?;+            if l.len() < 3 {+                return None;+            }+            Some(Layer {+                number: l[0].as_atom().unwrap_or("").to_string(),+                name: l[1].as_atom().unwrap_or("").to_string(),+                kind: l[2].as_atom().unwrap_or("").to_string(),+                canonical_name: l.get(3).and_then(Sx::as_atom).map(str::to_string),+            })+        })+        .collect()+}++fn extract_nets(tree: &Sx, name_based: bool) -> Vec<Net> {+    tree.find_all("net")+        .filter_map(|n| {+            let (num, name) = net_ref(Some(n), name_based);+            num.map(|number| Net { number: Some(number), name: name.unwrap_or_default() })+        })+        .collect()+}++fn extract_position(node: &Sx) -> Option<Position> {+    let at = node.find("at")?;+    let x = atom_f64(at, 1)?;+    let y = atom_f64(at, 2)?;+    Some(Position { x, y, rotation: atom_f64(at, 3) })+}++fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> {+    let mut out = Vec::new();+    for node in tree.find_all("footprint") {+        let Some(list) = node.as_list() else { continue };+        if list.len() < 2 {+            continue;+        }+        let mut props: HashMap<String, String> = HashMap::new();+        for p in node.find_all("property") {+            if let (Some(k), Some(v)) = (p.atom_at(1), p.atom_at(2)) {+                props.insert(k.to_string(), v.to_string());+            }+        }+        let pads: Vec<FpPad> = node+            .find_all("pad")+            .filter_map(|pad| {+                let l = pad.as_list()?;+                if l.len() < 4 {+                    return None;+                }+                let at = pad.find("at");+                let size = pad.find("size");+                let (num, name) = net_ref(pad.find("net"), name_based);+                let has_net = num.is_some() || name.is_some();+                Some(FpPad {+                    number: pad.atom_at(1).unwrap_or("").to_string(),+                    kind: pad.atom_at(2).unwrap_or("").to_string(),+                    shape: pad.atom_at(3).unwrap_or("").to_string(),+                    x: at.and_then(|a| atom_f64(a, 1)),+                    y: at.and_then(|a| atom_f64(a, 2)),+                    width: size.and_then(|s| atom_f64(s, 1)),+                    height: size.and_then(|s| atom_f64(s, 2)),+                    layers: pad.find("layers").map(string_atoms),+                    net_number: if has_net { Some(num) } else { None },+                    net_name: if has_net { Some(name.unwrap_or_default()) } else { None },+                })+            })+            .collect();+        out.push(Footprint {+            footprint: node.atom_at(1).unwrap_or("").to_string(),+            layer: value_string(node.find("layer")),+            uuid: value_string(node.find("uuid")),+            position: extract_position(node),+            locked: node.find("locked").is_some(),+            reference: props.get("Reference").cloned().unwrap_or_default(),+            value: props.get("Value").cloned().unwrap_or_default(),+            properties: props,+            path: value_string(node.find("path")),+            sheetname: value_string(node.find("sheetname")).unwrap_or_default(),+            sheetfile: value_string(node.find("sheetfile")).unwrap_or_default(),+            attr: value_string(node.find("attr")).unwrap_or_default(),+            pad_count: pads.len(),+            pads,+        });+    }+    out+}++fn point_of(node: Option<&Sx>) -> Option<Point> {+    let n = node?;+    Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })+}++fn extract_segments(tree: &Sx, name_based: bool) -> Vec<Segment> {+    tree.find_all("segment")+        .map(|node| {+            let (num, name) = net_ref(node.find("net"), name_based);+            Segment {+                uuid: value_string(node.find("uuid")),+                start: point_of(node.find("start")),+                end: point_of(node.find("end")),+                width: value_f64(node.find("width")),+                layer: value_string(node.find("layer")),+                net: num,+                net_name: name,+            }+        })+        .collect()+}++fn extract_vias(tree: &Sx, name_based: bool) -> Vec<Via> {+    tree.find_all("via")+        .map(|node| {+            let (num, name) = net_ref(node.find("net"), name_based);+            Via {+                uuid: value_string(node.find("uuid")),+                kind: node.atom_at(1).map(str::to_string),+                position: point_of(node.find("at")),+                size: value_f64(node.find("size")),+                drill: value_f64(node.find("drill")),+                layers: node.find("layers").map(string_atoms),+                net: num,+                net_name: name,+            }+        })+        .collect()+}++fn extract_zones(tree: &Sx, name_based: bool) -> Vec<Zone> {+    tree.find_all("zone")+        .map(|node| {+            let (num, mut name) = net_ref(node.find("net"), name_based);+            if let Some(n) = value_string(node.find("net_name")) {+                name = Some(n);+            }+            Zone {+                uuid: value_string(node.find("uuid")),+                net: num,+                net_name: name,+                layer: value_string(node.find("layer")),+                layers: node.find("layers").map(string_atoms),+                name: value_string(node.find("name")),+                priority: node.find("priority").and_then(Sx::value).and_then(|v| v.parse::<i64>().ok()),+            }+        })+        .collect()+}++fn extract_dimensions(tree: &Sx) -> Option<Dimensions> {+    let mut min_x = f64::INFINITY;+    let mut min_y = f64::INFINITY;+    let mut max_x = f64::NEG_INFINITY;+    let mut max_y = f64::NEG_INFINITY;+    let mut found = false;+    let mut update = |x: f64, y: f64| {+        min_x = min_x.min(x);+        min_y = min_y.min(y);+        max_x = max_x.max(x);+        max_y = max_y.max(y);+        found = true;+    };+    let on_edge = |node: &Sx| node.find("layer").and_then(Sx::value) == Some("Edge.Cuts");+    for tag in ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"] {+        for node in tree.find_all(tag) {+            if !on_edge(node) {+                continue;+            }+            for coord in ["start", "end", "center", "mid"] {+                if let Some(p) = point_of(node.find(coord)) {+                    update(p.x, p.y);+                }+            }+            if let Some(pts) = node.find("pts") {+                for xy in pts.find_all("xy") {+                    if let (Some(x), Some(y)) = (atom_f64(xy, 1), atom_f64(xy, 2)) {+                        update(x, y);+                    }+                }+            }+        }+    }+    for fp in tree.find_all("footprint") {+        let (fx, fy) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));+        for tag in ["fp_line", "fp_rect", "fp_arc"] {+            for node in fp.find_all(tag) {+                if !on_edge(node) {+                    continue;+                }+                for coord in ["start", "end", "center", "mid"] {+                    if let Some(p) = point_of(node.find(coord)) {+                        update(p.x + fx, p.y + fy);+                    }+                }+            }+        }+    }+    if !found {+        return None;+    }+    let r3 = |v: f64| (v * 1000.0).round() / 1000.0;+    Some(Dimensions { min_x: r3(min_x), min_y: r3(min_y), max_x: r3(max_x), max_y: r3(max_y), width: r3(max_x - min_x), height: r3(max_y - min_y) })+}++// ---------------------------------------------------------------------------+// Numbers and geometry+// ---------------------------------------------------------------------------++/// KiCad-style trimmed float (`1.5`, not `1.500000`).+pub fn num(v: f64) -> String {+    let s = format!("{v:.6}");+    let s = s.trim_end_matches('0').trim_end_matches('.');+    if s.is_empty() || s == "-" || s == "-0" {+        "0".into()+    } else {+        s.to_string()+    }+}++pub fn round6(v: f64) -> f64 {+    (v * 1_000_000.0).round() / 1_000_000.0+}++pub fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {+    ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()+}++/// Python `_number`: a finite, in-range number rounded to KiCad's nanometre precision.+/// Booleans are refused (Python's `isinstance(True, int)` trap).+pub fn number(value: Option<&Value>, label: &str, positive: bool) -> Result<f64, RoutingError> {+    let v = match value {+        Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),+        _ => return Err(RoutingError::new("invalid_geometry", format!("{label} must be a finite number"))),+    };+    if !v.is_finite() {+        return Err(RoutingError::new("invalid_geometry", format!("{label} must be a finite number")));+    }+    if v.abs() > 2000.0 || (positive && v <= 0.0) {+        return Err(RoutingError::new("invalid_geometry", format!("{label} is outside the supported board range")));+    }+    let rounded = round6(v);+    if positive && rounded <= 0.0 {+        return Err(RoutingError::new("invalid_geometry", format!("{label} is below KiCad nanometre precision")));+    }+    Ok(rounded)+}++// ---------------------------------------------------------------------------+// Nets+// ---------------------------------------------------------------------------++/// A net reference the writers can emit, in this board's dialect.+#[derive(Clone, Debug, PartialEq)]+pub struct NetRef {+    pub format: NetFormat,+    pub number: Option<i64>,+    pub name: String,+    /// The identity of the net on THIS board: its number pre-KiCad-10, else its name.+    pub key: Value,+}++pub fn net_key(board: &Board, number: Option<i64>, name: Option<&str>) -> Value {+    match board.net_format {+        NetFormat::Number => number.map(|n| json!(n)).unwrap_or(Value::Null),+        NetFormat::Name => json!(name.unwrap_or("")),+    }+}++fn is_no_net(key: &Value) -> bool {+    match key {+        Value::Null => true,+        Value::Number(n) => n.as_f64() == Some(0.0),+        Value::String(s) => s.is_empty(),+        _ => false,+    }+}++/// `_resolve_net`: a name or (pre-KiCad-10) number to a reference this board can carry.+/// Never invents one. Err carries the ready-to-return response.+pub fn resolve_net(board: &Board, spec: Option<&Value>) -> Result<NetRef, Value> {+    let fmt = board.net_format;+    let names: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).collect();+    let spec = spec.cloned().unwrap_or(Value::Null);+    let spec = if spec.is_null() { json!("") } else { spec };+    match &spec {+        Value::String(s) => {+            if let Some(n) = board.nets.iter().find(|n| &n.name == s) {+                return Ok(NetRef { format: fmt, number: n.number, name: s.clone(), key: net_key(board, n.number, Some(s)) });+            }+        }+        Value::Number(n) => {+            if let Some(f) = n.as_f64().filter(|f| f.is_finite() && *f == f.trunc()) {+                let i = f as i64;+                if fmt == NetFormat::Name && i == 0 {+                    return Ok(NetRef { format: fmt, number: None, name: String::new(), key: json!("") });+                }+                if fmt == NetFormat::Number {+                    if let Some(n) = board.nets.iter().find(|n| n.number == Some(i)) {+                        return Ok(NetRef { format: fmt, number: Some(i), name: n.name.clone(), key: json!(i) });+                    }+                }+            }+        }+        _ => {}+    }+    Err(json!({+        "success": false, "errorCode": "unknown_net", "error": format!("No exact net matching {}", py_repr(&spec)),+        "nets": names.iter().take(60).collect::<Vec<_>>(),+        "_hint": "Use an exact net name. Integer net codes are supported only for pre-KiCad-10 boards.",+    }))+}++/// Python `repr()` for the values that reach error messages.+fn py_repr(v: &Value) -> String {+    match v {+        Value::String(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),+        Value::Bool(true) => "True".into(),+        Value::Bool(false) => "False".into(),+        Value::Null => "None".into(),+        other => other.to_string(),+    }+}++/// The `(net ...)` line in this board's dialect, or nothing for deliberately unconnected+/// copper on a KiCad 10 board (which has no net 0 to name).+pub fn net_sexpr(net: &NetRef) -> String {+    match net.format {+        NetFormat::Number => format!("\t\t(net {})\n", net.number.unwrap_or(0)),+        NetFormat::Name => {+            if net.name.is_empty() {+                String::new()+            } else {+                format!("\t\t(net {})\n", serde_json::to_string(&net.name).unwrap_or_default())+            }+        }+    }+}++/// Match the board's own copper: this net's width, else the commonest width, else 0.2.+pub fn default_width(board: &Board, net_key: Option<&Value>) -> f64 {+    let commonest = |widths: Vec<f64>| -> Option<f64> {+        let mut counts: Vec<(f64, usize)> = Vec::new();+        for w in widths {+            match counts.iter_mut().find(|(k, _)| *k == w) {+                Some(c) => c.1 += 1,+                None => counts.push((w, 1)),+            }+        }+        counts.into_iter().max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))).map(|c| c.0)+    };+    let on_net: Vec<f64> = board+        .segments+        .iter()+        .filter(|s| s.width.map(|w| w != 0.0).unwrap_or(false))+        .filter(|s| net_key.map(|k| net_key_of_segment(board, s) == *k).unwrap_or(true))+        .filter_map(|s| s.width)+        .collect();+    if let Some(w) = commonest(on_net) {+        return w;+    }+    let any: Vec<f64> = board.segments.iter().filter_map(|s| s.width).filter(|w| *w != 0.0).collect();+    commonest(any).unwrap_or(DEFAULT_WIDTH)+}++fn net_key_of_segment(board: &Board, s: &Segment) -> Value {+    net_key(board, s.net, s.net_name.as_deref())+}++// ---------------------------------------------------------------------------+// Pads in board coordinates+// ---------------------------------------------------------------------------++/// One pad as `kicad_board_pads` reports it: board millimetres with footprint rotation+/// applied, +y DOWN, plus its net.+#[derive(Clone, Debug, Serialize, PartialEq)]+pub struct Pad {+    #[serde(rename = "ref")]+    pub reference: String,+    pub pad: String,+    pub name: String,+    pub x: f64,+    pub y: f64,+    pub net: Option<i64>,+    #[serde(rename = "netName")]+    pub net_name: String,+    #[serde(rename = "netKey")]+    pub net_key: Value,+    #[serde(rename = "type")]+    pub kind: String,+    pub layers: Vec<String>,+    pub side: String,+    pub footprint: String,+}++/// Every pad, transformed from footprint-local into BOARD coordinates. KiCad rotates a+/// footprint's pads by the footprint orientation in a y-DOWN frame (trigo.cpp+/// RotatePoint): x' = x*cos + y*sin, y' = -x*sin + y*cos.+pub fn pads(board: &Board) -> Vec<Pad> {+    let mut out = Vec::new();+    for fp in &board.footprints {+        let (fx, fy, rot) = fp.position.as_ref().map(|p| (p.x, p.y, p.rotation.unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));+        let ang = rot.to_radians();+        let (sin_a, cos_a) = ang.sin_cos();+        for pad in &fp.pads {+            let (Some(px), Some(py)) = (pad.x, pad.y) else { continue };+            let net_number = pad.net_number.flatten();+            let net_name = pad.net_name.clone().unwrap_or_default();+            out.push(Pad {+                reference: fp.reference.clone(),+                pad: pad.number.clone(),+                name: format!("{}.{}", fp.reference, pad.number),+                x: round6(fx + px * cos_a + py * sin_a),+                y: round6(fy - px * sin_a + py * cos_a),+                net: net_number,+                net_key: if pad.has_net() { net_key(board, net_number, Some(&net_name)) } else { net_key(board, None, None) },+                net_name,+                kind: pad.kind.clone(),+                layers: pad.layers.clone().unwrap_or_default(),+                side: fp.layer.clone().unwrap_or_default(),+                footprint: fp.footprint.clone(),+            });+        }+    }+    out+}++pub fn copper_layers(board: &Board) -> Vec<String> {+    board.layers.iter().filter(|l| l.name.ends_with(".Cu")).map(|l| l.name.clone()).collect()+}++/// Is this pad actually on that copper layer? An SMD pad lives on ONE side; a track that+/// ends on it from the other side is a dangling track to DRC. Through-hole pads carry+/// the `*.Cu` wildcard.+pub fn pad_reaches(pad: Option<&Pad>, layer: &str) -> bool {+    let Some(pad) = pad else { return true };+    if pad.layers.is_empty() {+        return true;+    }+    for l in &pad.layers {+        if l == layer || l == "*.Cu" || (l.ends_with('*') && layer.starts_with(&l[..l.len() - 1])) {+            return true;+        }+    }+    !pad.layers.iter().any(|l| l.ends_with(".Cu") || l == "*.Cu")+}++/// The net a pad sits on, or None for a pad with no net (a mounting hole, a fiducial).+pub fn pad_net(board: &Board, pad: Option<&Pad>) -> Option<NetRef> {+    let pad = pad?;+    if is_no_net(&pad.net_key) {+        return None;+    }+    Some(NetRef { format: board.net_format, number: pad.net, name: pad.net_name.clone(), key: pad.net_key.clone() })+}++/// `'R1.2' / 'R1-2' / 'R1 2'` -> that pad.+pub fn find_pad<'a>(pads: &'a [Pad], spec: &str) -> Result<&'a Pad, Value> {+    let key = spec.trim().replace('-', ".").replace(' ', ".").to_uppercase();+    if let Some(p) = pads.iter().find(|p| p.name.to_uppercase() == key) {+        return Ok(p);+    }+    let reference = key.split('.').next().unwrap_or("").to_string();+    let near: Vec<&str> = pads.iter().filter(|p| p.reference.to_uppercase() == reference).map(|p| p.name.as_str()).collect();+    let candidates: Vec<&str> = if near.is_empty() { pads.iter().map(|p| p.name.as_str()).collect() } else { near };+    Err(json!({+        "success": false, "error": format!("no pad '{spec}' on this board"), "errorCode": "unknown_pad",+        "candidates": candidates.iter().take(20).collect::<Vec<_>>(),+        "_hint": "Pads are addressed 'REF.PAD' (e.g. 'U1.7'). kicad_board_pads lists every pad with its board coordinates.",+    }))+}++/// `(x, y)` and the pad it came from. Accepts `[x,y]`, `{x,y}`, `"REF.PAD"` or `{"pad":"REF.PAD"}`.+pub fn resolve_point<'a>(spec: &Value, pads: &'a [Pad]) -> Result<((f64, f64), Option<&'a Pad>), Value> {+    let as_f64 = |v: &Value| -> Option<f64> {+        match v {+            Value::Number(n) => n.as_f64(),+            Value::String(s) => s.trim().parse::<f64>().ok(),+            _ => None,+        }+    };+    match spec {+        Value::String(s) => {+            let pad = find_pad(pads, s)?;+            return Ok(((pad.x, pad.y), Some(pad)));+        }+        Value::Object(o) => {+            if let Some(p) = o.get("pad").filter(|p| truthy(p)) {+                let name = match p {+                    Value::String(s) => s.clone(),+                    other => other.to_string(),+                };+                let pad = find_pad(pads, &name)?;+                return Ok(((pad.x, pad.y), Some(pad)));+            }+            if let (Some(x), Some(y)) = (o.get("x"), o.get("y")) {+                if let (Some(x), Some(y)) = (as_f64(x), as_f64(y)) {+                    return Ok(((x, y), None));+                }+            }+        }+        Value::Array(a) if a.len() >= 2 => {+            if let (Some(x), Some(y)) = (as_f64(&a[0]), as_f64(&a[1])) {+                return Ok(((x, y), None));+            }+        }+        _ => {}+    }+    Err(json!({+        "success": false, "error": format!("could not read a point from {}", py_repr(spec)), "errorCode": "bad_point",+        "_hint": "A point is [x,y] in mm, {\"x\":..,\"y\":..}, or a pad name like \"R1.2\". +y is DOWN in KiCad board coordinates.",+    }))+}++fn truthy(v: &Value) -> bool {+    match v {+        Value::Null => false,+        Value::Bool(b) => *b,+        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),+        Value::String(s) => !s.is_empty(),+        Value::Array(a) => !a.is_empty(),+        Value::Object(o) => !o.is_empty(),+    }+}++// ---------------------------------------------------------------------------+// s-expression emission+// ---------------------------------------------------------------------------++pub fn segment_sexpr(a: (f64, f64), b: (f64, f64), width: f64, layer: &str, net: &NetRef) -> String {+    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(a.0),+        num(a.1),+        num(b.0),+        num(b.1),+        num(width),+        layer,+        net_sexpr(net),+        new_uuid()+    )+}++pub fn via_sexpr(at: (f64, f64), size: f64, drill: f64, layers: (&str, &str), net: &NetRef) -> String {+    format!(+        "\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"{}\" \"{}\")\n{}\t\t(uuid \"{}\")\n\t)",+        num(at.0),+        num(at.1),+        num(size),+        num(drill),+        layers.0,+        layers.1,+        net_sexpr(net),+        new_uuid()+    )+}++/// Insert board items before the closing paren (where KiCad keeps copper).+pub fn splice(text: &str, blocks: &[String]) -> String {+    let trimmed = text.trim_end();+    let idx = trimmed.rfind(')').unwrap_or(trimmed.len());+    format!("{}\n{}\n)\n", text[..idx].trim_end(), blocks.join("\n"))+}++// ---------------------------------------------------------------------------+// Route planning (shared by kicad_route and kicad_route_net)+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, Serialize)]+pub struct PlanSegment {+    pub start: [f64; 2],+    pub end: [f64; 2],+    pub layer: String,+    pub width: f64,+}++#[derive(Clone, Debug, Serialize)]+pub struct PlanVia {+    pub at: [f64; 2],+    pub layers: [String; 2],+    pub size: f64,+    pub drill: f64,+}++/// The validated copper for one net: what the caller sees (`segments`, `vias`) and the+/// s-expression blocks the DRC snapshot and the file writer splice in.+#[derive(Clone, Debug)]+pub struct Plan {+    pub net_name: String,+    pub segments: Vec<PlanSegment>,+    pub vias: Vec<PlanVia>,+    pub blocks: Vec<String>,+}++impl Plan {+    /// `{"netName", "segments", "vias"}` (everything but `blocks`).+    pub fn public(&self) -> Value {+        json!({"netName": self.net_name, "segments": self.segments, "vias": self.vias})+    }+}++/// Pure planning and validation (`live_routing._plan`); every waypoint is checked before+/// any mutation. `args`: net, points or paths, width, layer, viaSize, viaDrill.+pub fn plan(board: &Board, args: &Value) -> Result<Plan, RoutingError> {+    let netname = match args.get("net") {+        Some(Value::String(s)) if !s.is_empty() => s.clone(),+        _ => return Err(RoutingError::new("missing_net", "net must be an exact, nonempty net name")),+    };+    let matches: Vec<&Net> = board.nets.iter().filter(|n| n.name == netname).collect();+    if matches.len() != 1 {+        return Err(RoutingError::new("unknown_net", format!("No unique net named '{netname}'")));+    }+    let net = NetRef { format: board.net_format, number: matches[0].number, name: netname.clone(), key: net_key(board, matches[0].number, Some(&netname)) };+    let pads = pads(board);+    let copper = copper_layers(board);+    let width = number(Some(args.get("width").unwrap_or(&json!(0.25))), "width", true)?;+    let size = number(Some(args.get("viaSize").unwrap_or(&json!(0.6))), "viaSize", true)?;+    let drill = number(Some(args.get("viaDrill").unwrap_or(&json!(0.3))), "viaDrill", true)?;+    if drill >= size {+        return Err(RoutingError::new("invalid_geometry", "viaDrill must be smaller than viaSize"));+    }+    let paths: Vec<Value> = match args.get("paths") {+        Some(Value::Array(a)) if !a.is_empty() && a.len() <= 128 => a.clone(),+        Some(_) => return Err(RoutingError::new("invalid_path", "Supply points or up to 128 paths for one net")),+        None => vec![args.get("points").cloned().unwrap_or(Value::Null)],+    };+    let mut segments = Vec::new();+    let mut vias = Vec::new();+    let mut blocks = Vec::new();+    for path in &paths {+        let Some(path) = path.as_array().filter(|p| (2..=512).contains(&p.len())) else {+            return Err(RoutingError::new("invalid_path", "Each path needs 2..512 waypoints"));+        };+        // (point, pad, explicit layer)+        let mut resolved: Vec<((f64, f64), Option<&Pad>, Option<String>)> = Vec::new();+        for spec in path {+            let coordinates: Option<[Option<&Value>; 2]> = match spec {+                Value::Array(a) => {+                    if a.len() != 2 {+                        return Err(RoutingError::new("invalid_geometry", "Coordinates require exactly [x, y]"));+                    }+                    Some([a.first(), a.get(1)])+                }+                Value::Object(o) if !o.get("pad").map(truthy).unwrap_or(false) => Some([o.get("x"), o.get("y")]),+                _ => None,+            };+            if let Some(c) = coordinates {+                for v in c {+                    number(v, "coordinate", false)?;+                }+            }+            let (point, pad) = resolve_point(spec, &pads).map_err(|e| {+                RoutingError::new(e.get("errorCode").and_then(Value::as_str).unwrap_or("bad_point"), e.get("error").and_then(Value::as_str).unwrap_or("bad point").to_string())+            })?;+            let point = (number(Some(&json!(point.0)), "coordinate", false)?, number(Some(&json!(point.1)), "coordinate", false)?);+            if let Some(p) = pad {+                if p.net_name != netname {+                    return Err(RoutingError::new("net_mismatch", format!("Pad {} is not on {netname}", p.name)));+                }+            }+            let layer = spec.get("layer").and_then(Value::as_str).map(str::to_string);+            if let Some(l) = &layer {+                if !copper.contains(l) {+                    return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {l}")));+                }+            }+            resolved.push((point, pad, layer));+        }+        let mut layer: String = match args.get("layer").and_then(Value::as_str) {+            Some(l) => l.to_string(),+            None => resolved[0]+                .2+                .clone()+                .or_else(|| resolved[0].1.map(|p| p.side.clone()).filter(|s| !s.is_empty()))+                .unwrap_or_else(|| DEFAULT_LAYER.to_string()),+        };+        if !copper.contains(&layer) {+            return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {layer}")));+        }+        for i in 0..resolved.len() {+            let (point, pad, explicit) = (&resolved[i].0, resolved[i].1, resolved[i].2.clone());+            let next_layer = explicit.unwrap_or_else(|| layer.clone());+            if i > 0 {+                let previous = resolved[i - 1].0;+                if previous != *point {+                    segments.push(PlanSegment { start: [previous.0, previous.1], end: [point.0, point.1], layer: layer.clone(), width });+                    blocks.push(segment_sexpr(previous, *point, width, &layer, &net));+                }+            }+            // A layer marker changes the outgoing layer AT this point, including the final+            // waypoint (where a via can reach a back-side pad).+            if next_layer != layer {+                vias.push(PlanVia { at: [point.0, point.1], layers: ["F.Cu".into(), "B.Cu".into()], size, drill });+                blocks.push(via_sexpr(*point, size, drill, ("F.Cu", "B.Cu"), &net));+                layer = next_layer;+            }+            if let Some(p) = pad {+                if !(p.layers.iter().any(|l| l == &layer) || p.layers.iter().any(|l| l == "*.Cu")) {+                    return Err(RoutingError::new("pad_layer_mismatch", format!("Pad {} has no copper on {layer}", p.name)));+                }+            }+        }+    }+    if segments.is_empty() && vias.is_empty() {+        return Err(RoutingError::new("zero_length", "No nonzero segments or layer transitions"));+    }+    if segments.len() + vias.len() > 1024 {+        return Err(RoutingError::new("route_too_large", "Limit each call to 1024 copper items"));+    }+    Ok(Plan { net_name: netname, segments, vias, blocks })+}++// ---------------------------------------------------------------------------+// The board on disk+// ---------------------------------------------------------------------------++/// KiCad's own open-file marker, `~<name>.kicad_pcb.lck` beside the board. The one signal+/// that works with no window list at all.+pub fn lock_file(path: &Path) -> Option<PathBuf> {+    let name = path.file_name()?.to_string_lossy().to_string();+    let lck = path.with_file_name(format!("~{name}.lck"));+    lck.exists().then_some(lck)+}++/// A loaded board: path, the exact text on disk and the parse of it.+#[derive(Debug)]+pub struct Loaded {+    pub path: PathBuf,+    pub text: String,+    pub board: Board,+}++/// `route._load`: read and parse a board file, or the ready-to-return error. Without+/// `read_only` an open board (lock file present) is refused before any write.+pub fn load(args: &Value, read_only: bool) -> Result<Loaded, Value> {+    let raw = ["filePath", "boardPath"].iter().find_map(|k| args.get(*k).and_then(Value::as_str)).filter(|s| !s.is_empty());+    let Some(raw) = raw else {+        return Err(json!({+            "success": false, "error": "No filePath specified", "errorCode": "missing_arg",+            "_hint": "Pass the absolute path to a .kicad_pcb, e.g. {\"filePath\":\"C:/designs/foo/foo.kicad_pcb\"}. Call kicad_board_pads on it first to see pads, nets and copper layers.",+        }));+    };+    let path = PathBuf::from(raw);+    if !read_only && lock_file(&path).is_some() {+        return Err(json!({+            "success": false, "errorCode": "board_open",+            "error": "This board is open in KiCad; file routing would conflict with live edits",+            "_hint": "Use kicad_routing_state and kicad_route_net for live edits, or close this board before offline routing.",+        }));+    }+    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    if !name.to_ascii_lowercase().ends_with(".kicad_pcb") {+        return Err(json!({+            "success": false, "error": format!("not a board file: {name}"), "errorCode": "not_a_board",+            "_hint": "These verbs edit a .kicad_pcb. For a schematic there is nothing to route.",+        }));+    }+    if !path.exists() {+        return Err(json!({+            "success": false, "error": format!("board not found: {}", path.display()), "errorCode": "board_not_found",+            "_hint": "Absolute path, no %VAR% expansion. kicad_place_footprint can make a scratch board to route on if you have no project yet.",+        }));+    }+    let text = match std::fs::read_to_string(&path) {+        Ok(t) => t,+        Err(e) => {+            return Err(json!({"success": false, "error": format!("could not read board: {e}"), "errorCode": "board_unreadable", "_hint": "Is the file locked by another process?"}))+        }+    };+    if !text.trim_start().starts_with("(kicad_pcb") {+        return Err(json!({+            "success": false, "error": "file does not start with '(kicad_pcb'", "errorCode": "not_a_board",+            "_hint": "A legacy .brd or a truncated file. kicad_format_upgrade converts old formats.",+        }));+    }+    let board = match parse_pcb_text(&text, &path.to_string_lossy()) {+        Ok(b) => b,+        Err(e) => {+            return Err(json!({+                "success": false, "error": format!("could not parse board: {e}"), "errorCode": "board_unparsable",+                "_hint": "Our reader is not KiCad's parser. kicad_lint_board gets KiCad's own verdict.",+            }))+        }+    };+    Ok(Loaded { path, text, board })+}++/// Validate the new copper, refuse live or changed boards, back up to `<path>.adom-bak`+/// and replace the file through a unique temp file (`route._write`). Returns the backup+/// path, or the ready-to-return `board_write_refused` response.+pub fn write_board(path: &Path, new_text: &str, backup: bool, expected: Option<&str>) -> Result<Option<String>, Value> {+    match write_board_inner(path, new_text, backup, expected) {+        Ok(bak) => Ok(bak),+        Err(e) => Err(json!({+            "success": false, "error": format!("Board write refused: {e}"), "errorCode": "board_write_refused",+            "_hint": "No board replacement was performed. Reinspect geometry and the live editor before retrying.",+        })),+    }+}++fn write_board_inner(path: &Path, new_text: &str, backup: bool, expected: Option<&str>) -> Result<Option<String>, String> {+    let file = path.to_string_lossy().to_string();+    let data = parse_pcb_text(new_text, &file)?;+    let old_ids: Vec<String> = match expected {+        Some(exp) => {+            let original = parse_pcb_text(exp, &file)?;+            original.segments.iter().filter_map(|s| s.uuid.clone()).chain(original.vias.iter().filter_map(|v| v.uuid.clone())).collect()+        }+        None => Vec::new(),+    };+    let is_old = |uuid: &Option<String>| uuid.as_ref().map(|u| old_ids.contains(u)).unwrap_or(false);+    for s in &data.segments {+        if is_old(&s.uuid) {+            continue;+        }+        let (Some(w), Some(a), Some(b)) = (s.width, &s.start, &s.end) else {+            return Err("Track coordinates must be finite and width must be positive".into());+        };+        if ![w, a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite()) || w <= 0.0 {+            return Err("Track coordinates must be finite and width must be positive".into());+        }+    }+    for v in &data.vias {+        if is_old(&v.uuid) {+            continue;+        }+        let (Some(size), Some(drill), Some(p)) = (v.size, v.drill, &v.position) else {+            return Err("Via geometry must be finite, with 0 < drill < size".into());+        };+        if ![size, drill, p.x, p.y].iter().all(|x| x.is_finite()) || !(0.0 < drill && drill < size) {+            return Err("Via geometry must be finite, with 0 < drill < size".into());+        }+        let layers = v.layers.clone().unwrap_or_default();+        let mut sorted = layers.clone();+        sorted.sort();+        sorted.dedup();+        if sorted != ["B.Cu".to_string(), "F.Cu".to_string()] {+            return Err("Only through vias spanning F.Cu to B.Cu are supported".into());+        }+    }+    if lock_file(path).is_some() {+        return Err("Board was opened while planning; use live routing".into());+    }+    if let Some(exp) = expected {+        let current = std::fs::read_to_string(path).map_err(|e| e.to_string())?;+        if current != exp {+            return Err("Board changed while planning; inspect it and retry".into());+        }+    }+    let mut bak = None;+    if backup {+        let b = format!("{}.adom-bak", path.to_string_lossy());+        std::fs::copy(path, &b).map_err(|e| e.to_string())?;+        bak = Some(b);+    }+    let dir = path.parent().unwrap_or(Path::new("."));+    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    let tmp = dir.join(format!("{name}.{}.{}.adom-tmp", std::process::id(), new_uuid().split('-').next().unwrap_or("x")));+    let result = (|| -> Result<(), String> {+        {+            use std::io::Write;+            let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;+            f.write_all(new_text.as_bytes()).map_err(|e| e.to_string())?;+            f.sync_all().map_err(|e| e.to_string())?;+        }+        std::fs::rename(&tmp, path).map_err(|e| e.to_string())+    })();+    let _ = std::fs::remove_file(&tmp);+    result?;+    Ok(bak)+}++// ---------------------------------------------------------------------------+// Tests+// ---------------------------------------------------------------------------++#[cfg(test)]+pub(crate) mod tests {+    use super::*;++    fn repo_root() -> PathBuf {+        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap()+    }++    fn rp2040() -> (String, Board) {+        let p = repo_root().join("tour-pack-rp2040/template/rp2040-breakout.kicad_pcb");+        let text = std::fs::read_to_string(&p).expect("template board present");+        let b = parse_pcb_text(&text, &p.to_string_lossy()).unwrap();+        (text, b)+    }++    /// The Python `demo/routing/make_fixture.py` board: three through-hole connectors on+    /// six named nets, KiCad 10 dialect.+    pub(crate) fn fixture_text() -> String {+        let base = std::fs::read_to_string(repo_root().join("templates/blank-board.kicad_pcb")).unwrap();+        let mut blocks = String::new();+        let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {+            blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));+            blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));+            blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");+            for (n, px, py, net) in pads {+                blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));+            }+            blocks.push_str(")\n");+        };+        let six = |_: ()| (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();+        footprint("J1", 105.0, 85.0, six(()));+        footprint("J2", 145.0, 85.0, six(()));+        footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);+        blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));+        let idx = base.rfind(')').unwrap();+        format!("{}{}\n)\n", &base[..idx], blocks)+    }++    fn test_board() -> Board {+        // The tests/test_live_routing.py `board()` fixture as s-expression text (KiCad 10 dialect).+        let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")+  (layers (0 "F.Cu" signal) (1 "In1.Cu" signal) (2 "B.Cu" signal) (5 "F.SilkS" user))+  (footprint "X:J1" (layer "F.Cu") (at 10 20 90) (property "Reference" "J1")+    (pad "1" thru_hole circle (at 2 0) (size 1 1) (layers "*.Cu") (net "GND"))+    (pad "2" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VCC")))+  (footprint "X:J2" (layer "B.Cu") (at 30 20) (property "Reference" "J2")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "B.Cu") (net "GND")))+  (footprint "X:J3" (layer "F.Cu") (at 50 20) (property "Reference" "J3")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "123")))+)"#;+        parse_pcb_text(text, "test.kicad_pcb").unwrap()+    }++    #[test]+    fn parses_the_rp2040_template_pads_layers_and_dimensions() {+        let (_, b) = rp2040();+        assert_eq!(b.version.as_deref(), Some("20240108"));+        assert_eq!(b.net_format, NetFormat::Number, "the template carries a (net 0 \"\") table");+        assert_eq!(b.nets, vec![Net { number: Some(0), name: String::new() }]);+        assert_eq!(copper_layers(&b), vec!["F.Cu", "B.Cu"]);+        let all = pads(&b);+        assert_eq!(all.len(), 117);+        let u1 = b.footprints.iter().find(|f| f.reference == "U1").unwrap();+        assert_eq!(u1.footprint, "AdomRP2040:QFN-56_AdomRP2040");+        assert_eq!((u1.position.as_ref().unwrap().x, u1.position.as_ref().unwrap().y), (130.0, 110.0));+        let u1_1 = all.iter().find(|p| p.name == "U1.1").unwrap();+        assert_eq!((u1_1.x, u1_1.y), (130.0 - 3.4375, 110.0 - 2.6));+        assert_eq!(u1_1.layers, vec!["F.Cu", "F.Mask", "F.Paste"]);+        assert_eq!(u1_1.side, "F.Cu");+        assert!(b.segments.is_empty() && b.vias.is_empty());+        assert!(b.dimensions.is_some() || b.footprints.len() > 1);+    }++    #[test]+    fn rp2040_add_track_and_via_round_trip_with_backup() {+        let (text, b) = rp2040();+        let dir = std::env::temp_dir().join(format!("adom-pcb-test-{}-{}", std::process::id(), new_uuid()));+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join("rp2040-breakout.kicad_pcb");+        std::fs::write(&path, &text).unwrap();+        let net = resolve_net(&b, Some(&json!(0))).unwrap();+        assert_eq!(net.format, NetFormat::Number);+        let all = pads(&b);+        let ((ax, ay), pad_a) = resolve_point(&json!("U1.1"), &all).unwrap();+        assert!(pad_a.is_some());+        let seg = segment_sexpr((ax, ay), (ax - 3.0, ay), 0.2, "F.Cu", &net);+        let via = via_sexpr((ax - 3.0, ay), 0.6, 0.3, ("F.Cu", "B.Cu"), &net);+        let new_text = splice(&text, &[seg, via]);+        let bak = write_board(&path, &new_text, true, Some(&text)).unwrap();+        assert_eq!(std::fs::read_to_string(bak.as_ref().unwrap()).unwrap(), text);+        let after = parse_pcb(&path).unwrap();+        assert_eq!(after.segments.len(), 1);+        assert_eq!(after.vias.len(), 1);+        let s = &after.segments[0];+        assert_eq!(s.net, Some(0));+        assert_eq!(s.layer.as_deref(), Some("F.Cu"));+        assert_eq!((s.start.as_ref().unwrap().x, s.end.as_ref().unwrap().x), (ax, ax - 3.0));+        let v = &after.vias[0];+        assert_eq!((v.size, v.drill), (Some(0.6), Some(0.3)));+        assert_eq!(v.layers.as_ref().unwrap(), &["F.Cu", "B.Cu"]);+        assert!(v.uuid.is_some() && s.uuid.is_some());+        // Every pad survives the splice untouched.+        assert_eq!(pads(&after), all);+        // A changed file is refused (the text on disk no longer matches `expected`).+        let err = write_board(&path, &new_text, false, Some(&text)).unwrap_err();+        assert_eq!(err["errorCode"], "board_write_refused");+        // A lock file marks an open board.+        std::fs::write(dir.join("~rp2040-breakout.kicad_pcb.lck"), "open").unwrap();+        assert!(lock_file(&path).is_some());+        let err = load(&json!({"filePath": path.to_string_lossy()}), false).unwrap_err();+        assert_eq!(err["errorCode"], "board_open");+        assert!(load(&json!({"filePath": path.to_string_lossy()}), true).is_ok());+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn numeric_net_name_in_kicad_10_file() {+        let data = parse_pcb_text("(kicad_pcb (version 20260206) (footprint \"X\" (layer \"F.Cu\") (pad \"1\" thru_hole circle (net \"123\"))))", "x").unwrap();+        assert_eq!(data.net_format, NetFormat::Name);+        assert_eq!(data.nets, vec![Net { number: None, name: "123".into() }]);+    }++    #[test]+    fn numbered_dialect_reads_both_shapes() {+        let data = parse_pcb_text(+            "(kicad_pcb (version 20240108) (net 0 \"\") (net 3 \"GND\") (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (net 3 \"GND\"))) (segment (start 0 0) (end 1 0) (width 0.2) (layer \"F.Cu\") (net 3)))",+            "x",+        )+        .unwrap();+        assert_eq!(data.net_format, NetFormat::Number);+        assert_eq!(data.nets.len(), 2);+        assert_eq!(data.segments[0].net, Some(3));+        assert_eq!(data.segments[0].net_name, None);+        let p = &pads(&data)[0];+        assert_eq!((p.net, p.net_name.as_str(), p.net_key.clone()), (Some(3), "GND", json!(3)));+        assert_eq!(resolve_net(&data, Some(&json!("123"))).unwrap_err()["errorCode"], "unknown_net");+        let b = parse_pcb_text("(kicad_pcb (net 1 \"123\"))", "x").unwrap();+        assert_eq!(resolve_net(&b, Some(&json!("123"))).unwrap().number, Some(1));+        for spec in [json!(1.5), json!(true), json!("123x")] {+            assert!(resolve_net(&b, Some(&spec)).is_err(), "{spec}");+        }+        assert_eq!(resolve_net(&b, Some(&json!(1))).unwrap().name, "123");+    }++    #[test]+    fn rotated_pad_coordinates_and_final_layer_transition() {+        let p = plan(&test_board(), &json!({"net": "GND", "points": ["J1.1", {"pad": "J2.1", "layer": "B.Cu"}]})).unwrap();+        assert_eq!(p.segments[0].start, [10.0, 18.0]);+        assert_eq!(p.vias[0].at, [30.0, 20.0]);+        assert_eq!(p.vias[0].layers, ["F.Cu", "B.Cu"]);+        assert_eq!(p.blocks.len(), 2);+    }++    #[test]+    fn branches_on_one_net() {+        let p = plan(&test_board(), &json!({"net": "GND", "paths": [["J1.1", [20, 18]], [[20, 18], [20, 25]]]})).unwrap();+        assert_eq!(p.segments.len(), 2);+    }++    #[test]+    fn reject_wrong_net_and_layer() {+        for (args, code) in [+            (json!({"net": "GND", "points": ["J1.1", "J1.2"]}), "net_mismatch"),+            (json!({"net": "GND", "points": ["J1.1", "J2.1"]}), "pad_layer_mismatch"),+            (json!({"net": "GND", "points": ["J1.1", {"x": 20, "y": 20, "layer": "F.SilkS"}]}), "unknown_layer"),+            (json!({"net": "GND", "points": ["J1.1", "J9.1"]}), "unknown_pad"),+            (json!({"net": "NOPE", "points": [[0, 0], [1, 1]]}), "unknown_net"),+            (json!({"points": [[0, 0], [1, 1]]}), "missing_net"),+        ] {+            assert_eq!(plan(&test_board(), &args).unwrap_err().code, code, "{args}");+        }+    }++    #[test]+    fn invalid_geometry_is_rejected_before_mutation() {+        for extra in [+            json!({"width": 0}),+            json!({"width": -1}),+            json!({"width": true}),+            json!({"points": [[true, 0], [1, 1]]}),+            json!({"points": [[0, 0, 9], [1, 1]]}),+            json!({"viaDrill": 0.8, "viaSize": 0.6}),+            json!({"points": [[0, 0], [5000, 0]]}),+            json!({"points": [[0, 0], [0, 0]]}),+            json!({"points": [[0, 0]]}),+            json!({"paths": []}),+        ] {+            let mut args = json!({"net": "GND", "points": [[0, 0], [1, 1]]});+            for (k, v) in extra.as_object().unwrap() {+                args[k] = v.clone();+            }+            assert!(plan(&test_board(), &args).is_err(), "{extra}");+        }+    }++    #[test]+    fn exact_numeric_net_name() {+        assert_eq!(plan(&test_board(), &json!({"net": "123", "points": [[0, 0], [1, 1]]})).unwrap().net_name, "123");+    }++    #[test]+    fn quoted_net_serialization() {+        let net = NetRef { format: NetFormat::Name, number: None, name: "a\"b\\c".into(), key: json!("a\"b\\c") };+        assert!(net_sexpr(&net).contains("\"a\\\"b\\\\c\""));+        let none = NetRef { format: NetFormat::Name, number: None, name: String::new(), key: json!("") };+        assert_eq!(net_sexpr(&none), "");+        let numbered = NetRef { format: NetFormat::Number, number: Some(4), name: "X".into(), key: json!(4) };+        assert_eq!(net_sexpr(&numbered), "\t\t(net 4)\n");+    }++    #[test]+    fn num_formats_like_kicad() {+        assert_eq!(num(1.5), "1.5");+        assert_eq!(num(1.0), "1");+        assert_eq!(num(0.0), "0");+        assert_eq!(num(-0.0000001), "0");+        assert_eq!(num(105.0), "105");+        assert_eq!(num(0.25), "0.25");+    }++    #[test]+    fn fixture_routes_whole_plan_through_the_file_writer() {+        let text = fixture_text();+        let b = parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();+        assert_eq!(b.net_format, NetFormat::Name);+        assert_eq!(b.nets.len(), 6);+        assert_eq!(pads(&b).len(), 13);+        let p = plan(&b, &json!({"net": "NET_3", "width": 0.25, "points": ["J1.3", {"x": 112, "y": 95, "layer": "B.Cu"}, {"x": 138, "y": 95, "layer": "F.Cu"}, "J2.3"]})).unwrap();+        assert_eq!(p.segments.len(), 3);+        assert_eq!(p.vias.len(), 2);+        assert_eq!(p.segments[1].layer, "B.Cu");+        let spliced = splice(&text, &p.blocks);+        let after = parse_pcb_text(&spliced, "x").unwrap();+        assert_eq!(after.segments.len(), 3);+        assert_eq!(after.vias.len(), 2);+        assert_eq!(after.segments[0].net_name.as_deref(), Some("NET_3"));+        assert_eq!(after.vias[0].net_name.as_deref(), Some("NET_3"));+        assert_eq!(default_width(&after, None), 0.25);+    }++    #[test]+    fn pad_reach_and_pad_net() {+        let b = test_board();+        let all = pads(&b);+        let j1_2 = all.iter().find(|p| p.name == "J1.2").unwrap();+        assert!(pad_reaches(Some(j1_2), "F.Cu"));+        assert!(!pad_reaches(Some(j1_2), "B.Cu"));+        let j1_1 = all.iter().find(|p| p.name == "J1.1").unwrap();+        assert!(pad_reaches(Some(j1_1), "B.Cu"));+        assert_eq!(pad_net(&b, Some(j1_1)).unwrap().name, "GND");+        assert!(find_pad(&all, "j1-2").is_ok());+        assert!(find_pad(&all, "J1 1").is_ok());+        let err = find_pad(&all, "J1.9").unwrap_err();+        assert_eq!(err["candidates"].as_array().unwrap().len(), 2);+    }+}
rust/crates/kicad-core/tests/routing_file.rsadded+161
@@ -0,0 +1,161 @@+//! File-based routing end to end: the six-net fixture from `demo/routing/make_fixture.py`+//! (rebuilt here from the blank template), every net routed through the same planner and+//! writer `kicad_route` uses, then (when `service-kicad` is on PATH) a real KiCad 10 DRC on+//! the edited board: zero unconnected items, zero errors, and the DRC delta helper the+//! live verbs use certifies that the copper added no error.++use std::path::{Path, PathBuf};+#[cfg(feature = "ipc")]+use std::process::Command;++use serde_json::{json, Value};++use kicad_core::pcb::{self, NetFormat};+use kicad_core::schematic::new_uuid;++fn repo_file(rel: &str) -> PathBuf {+    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+}++fn temp_dir(tag: &str) -> PathBuf {+    let d = std::env::temp_dir().join(format!("kicad-core-routing-{tag}-{}-{}", std::process::id(), new_uuid()));+    std::fs::create_dir_all(&d).unwrap();+    d+}++#[cfg(feature = "ipc")]+fn service_kicad() -> Option<PathBuf> {+    let path = std::env::var_os("PATH")?;+    std::env::split_paths(&path).map(|d| d.join("service-kicad")).find(|p| p.is_file())+}++/// `demo/routing/make_fixture.py`: J1 and J2 with six pads each on NET_1..NET_6, J3 with+/// one pad on NET_6, an Edge.Cuts rectangle, KiCad 10 dialect.+fn fixture_text() -> String {+    let base = std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).expect("blank-board template");+    let mut blocks = String::new();+    let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {+        blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));+        blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));+        blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");+        for (n, px, py, net) in pads {+            blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));+        }+        blocks.push_str(")\n");+    };+    let six = || (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();+    footprint("J1", 105.0, 85.0, six());+    footprint("J2", 145.0, 85.0, six());+    footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);+    blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));+    let idx = base.rfind(')').unwrap();+    format!("{}{}\n)\n", &base[..idx], blocks)+}++/// The fixture's `plan.json`: straight runs, NET_3 with a layer change, NET_6 branched to J3.+fn plan_rows() -> Vec<Value> {+    (1..=6)+        .map(|i| {+            let y = 85.0 + ((i - 1) * 5) as f64;+            let net = format!("NET_{i}");+            let points = if i == 3 {+                json!([format!("J1.{i}"), {"x": 112, "y": y, "layer": "B.Cu"}, {"x": 138, "y": y, "layer": "F.Cu"}, format!("J2.{i}")])+            } else {+                json!([format!("J1.{i}"), [112, y], [138, y], format!("J2.{i}")])+            };+            if i == 6 {+                json!({"net": net, "width": 0.25, "paths": [points, [[125, y], [125, 113], "J3.1"]]})+            } else {+                json!({"net": net, "width": 0.25, "points": points})+            }+        })+        .collect()+}++/// What `kicad_route` does per call: load, plan, splice, atomic write with backup.+fn route_on_disk(path: &Path, row: &Value) -> (usize, usize) {+    let loaded = pcb::load(&json!({"filePath": path.to_string_lossy()}), false).expect("board loads");+    let plan = pcb::plan(&loaded.board, row).expect("plan is valid");+    pcb::write_board(path, &pcb::splice(&loaded.text, &plan.blocks), true, Some(&loaded.text)).expect("write accepted");+    (plan.segments.len(), plan.vias.len())+}++#[test]+fn routes_the_six_net_fixture_on_disk() {+    let dir = temp_dir("fixture");+    let path = dir.join("live-routing.kicad_pcb");+    std::fs::write(&path, fixture_text()).unwrap();+    let before = pcb::parse_pcb(&path).unwrap();+    assert_eq!(before.net_format, NetFormat::Name);+    assert_eq!(pcb::pads(&before).len(), 13);+    assert!(before.segments.is_empty() && before.vias.is_empty());++    let mut segments = 0;+    let mut vias = 0;+    for row in plan_rows() {+        let (s, v) = route_on_disk(&path, &row);+        segments += s;+        vias += v;+    }+    // The Python demo's verified numbers: 20 segments and two through vias.+    assert_eq!((segments, vias), (20, 2));+    let after = pcb::parse_pcb(&path).unwrap();+    assert_eq!(after.segments.len(), 20);+    assert_eq!(after.vias.len(), 2);+    assert_eq!(pcb::pads(&after), pcb::pads(&before), "the splice never touches footprints");+    let bak = std::fs::read_to_string(format!("{}.adom-bak", path.display())).unwrap();+    assert_eq!(pcb::parse_pcb_text(&bak, "bak").unwrap().segments.len(), 20 - 5, "the backup is the state before the last (branched, five-segment) net");+    // A second route of an already routed pad pair still plans (the file verbs do not check+    // connectivity), but a stale `expected` text is refused.+    let stale = fixture_text();+    let plan = pcb::plan(&after, &json!({"net": "NET_1", "points": ["J1.1", "J2.1"]})).unwrap();+    let err = pcb::write_board(&path, &pcb::splice(&stale, &plan.blocks), false, Some(&stale)).unwrap_err();+    assert_eq!(err["errorCode"], "board_write_refused");+    let _ = std::fs::remove_dir_all(&dir);+}++/// Real KiCad 10 verdict through the shared service on the board the file verbs edited.+/// Needs the `ipc` feature for the DRC shaping helpers the live verbs share.+#[cfg(feature = "ipc")]+#[test]+fn service_kicad_drc_accepts_the_routed_fixture() {+    let Some(svc) = service_kicad() else {+        eprintln!("service-kicad not on PATH; skipping the live DRC test");+        return;+    };+    let dir = temp_dir("drc");+    let path = dir.join("live-routing.kicad_pcb");+    std::fs::write(&path, fixture_text()).unwrap();+    let drc = |tag: &str| -> Value {+        let report = dir.join(format!("drc-{tag}.json"));+        let out = Command::new(&svc).args(["pcb", "drc"]).arg(&path).args(["--format", "json", "--out"]).arg(&report).output().expect("service-kicad runs");+        assert!(report.is_file(), "service-kicad pcb drc wrote no report: {}\n{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));+        let rep: Value = serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).expect("DRC report is JSON");+        assert!(rep.get("kicad_version").is_some(), "not a DRC report: {rep}");+        rep+    };+    let unrouted = drc("before");+    let before = kicad_core::ipc::drc_result(&unrouted, vec![]);+    assert_eq!(before["unconnected"], 7, "six two-pad nets plus the NET_6 branch: {}", before["unconnected"]);+    assert_eq!(before["errors"], 0);++    for row in plan_rows() {+        route_on_disk(&path, &row);+    }+    let routed = drc("after");+    let after = kicad_core::ipc::drc_result(&routed, vec![]);+    eprintln!("service-kicad DRC on the routed fixture: {} error(s), {} warning(s), {} unconnected; kicad {}", after["errors"], after["warnings"], after["unconnected"], routed["kicad_version"]);+    for v in after["violations"].as_array().unwrap() {+        eprintln!("  {} {}: {}", v["severity"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["description"].as_str().unwrap_or(""));+    }+    assert_eq!(after["unconnected"], 0, "every net is connected by the written copper");+    assert_eq!(after["errors"], 0, "no DRC errors: {}", after["violations"]);+    // The demo's three warnings: the deliberately uninstalled RoutingFixture library.+    let warnings: Vec<&str> = after["violations"].as_array().unwrap().iter().filter_map(|v| v["type"].as_str()).collect();+    assert!(warnings.iter().all(|t| *t == "lib_footprint_issues"), "only library warnings expected: {warnings:?}");+    assert_eq!(after["clean"], false, "warnings keep clean false, as in the Python");+    assert_eq!(after["violationReportMayBeTruncated"], false);+    // The route_net preflight on these two reports certifies the copper added no error.+    assert!(kicad_core::ipc::new_errors(&before, &after).unwrap().is_empty());+    let _ = std::fs::remove_dir_all(&dir);+}