app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← 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
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);+}