← Commit history

1.0.1: 1.0.1: Freerouting on the user's terms. kicad_freerouting {status|install|uninstall} installs Freerouting's own self-contained bundle (88 MB download, 147 MB on disk, its Java runtime inside, nothing installed on the PC, no UAC) only when the user asks, as a background job with progress in status, and removes it on its own. kicad_autoroute {engine: freerouting} writes the Specctra DSN from the board, routes headless with a deadline, and lands the SES copper as native undo steps through the IPC API (or into the closed file with a backup), then DRC. Every autoroute reply names both engines; the AI engine stays the recommendation. Verified on ConfRoomROG: install 6 s, six-net fixture in 3 s, DRC clean, live apply six undo steps, uninstall clean. kicad_uninstall removes the engine too. New kicad-autorouting skill.

John Lauer ·b691a936e2 ·26d ago ·parent 6fbb42e
7 files changed +2482−28
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.0"+version = "1.0.1" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.0"+version = "1.0.1" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -280,7 +280,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.0"+version = "1.0.1" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.0"+version = "1.0.1" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_routing.rs+429−24
@@ -1,14 +1,20 @@ //! 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+//! add_via, route) on `kicad_core::pcb`, `kicad_autoroute` (engine ai: the plan request;+//! engine freerouting: `kicad_core::dsn` + `kicad_core::freerouting`) and+//! `kicad_freerouting` (the engine's status/install/uninstall). 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 std::path::PathBuf;+use std::time::Duration;+ use serde_json::{json, Value};  use crate::catalog::{Mechanism, Verb}; use crate::util::*; use kicad_core::pcb::{self, NetRef};+use kicad_core::{dsn, freerouting, progress};  pub static VERBS: &[Verb] = &[     Verb {@@ -17,8 +23,8 @@ pub static VERBS: &[Verb] = &[         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"],+        hint: "Pass exact filePath and optional local ipc:// socketPath. Use revision as expectedRevision for edits. Choosing how to route the rest? kicad_autoroute names both engines: ai (recommended, you drive kicad_route_net) and freerouting (deterministic, installed on demand).",+        related: &["kicad_route_net", "kicad_routing_validate", "kicad_ipc_api", "kicad_autoroute"],         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 {@@ -93,13 +99,23 @@ pub static VERBS: &[Verb] = &[     },     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}",+        summary: "Route a board with the engine the USER picked: \"ai\" (recommended) returns the plan request you drive with kicad_route_net; \"freerouting\" runs Freerouting headless (installed on demand) and lands its copper live as one undo step per net, or into the closed file.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 900,+        input: "{\"engine\": \"ai\" | \"freerouting\", \"filePath\": \"C:/.../x.kicad_pcb\", \"nets\"?: [\"GND\", ...], \"dryRun\"?: bool, \"passes\"?: 20, \"threads\"?: n, \"timeoutSec\"?: 600, \"apply\"?: \"file\" | \"ipc\", \"backup\"?: true}",         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)."],+        hint: "ASK THE USER WHICH ENGINE THEY WANT BEFORE ROUTING and name both every time. ai (recommended): any capable model can drive kicad_route_net (GPT-6 Astra in Codex has routed a 94-footprint board to zero unconnected items this way, Claude Fable 5.1 can, newer models will); the routing verbs give it pads, nets, zones, a live revision, one native undo step per trace and DRC per step, so it honours the schematic's intent and can explain every trace. freerouting: deterministic, installed on demand (88 MB download, bundled Java, no elevation), kicad_freerouting {\"action\":\"status\"} says whether it is present; not installed answers freerouting_not_installed with the install call, and you ask the user before installing. Report which engine produced the copper.",+        related: &["kicad_freerouting", "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 never installs anything by itself: freerouting_not_installed means ask, then kicad_freerouting {\"action\":\"install\"}, then call again.", "engine freerouting applies live (apply ipc: the board open in the PCB editor with the IPC API on, one native undo step per net named Freerouting <net>) or to the closed file (apply file, .adom-bak beside it); auto picks ipc when the board is open, file otherwise.", "Oval, roundrect and custom pads reach the router as their bounding rect (conservative); dsn.approximatedPads lists them."],+    },+    Verb {+        name: "kicad_freerouting",+        summary: "The Freerouting engine's lifecycle: status (disk facts only), install (88 MB download with its own Java, no elevation, only when asked), uninstall (removes the bridge's Freerouting folder and nothing else).",+        mechanism: Mechanism::File, risk: "exec", timeout_sec: 900,+        input: "{\"action\": \"status\" | \"install\" | \"uninstall\", \"force\"?: bool}",+        example: "kicad_freerouting {\"action\":\"status\"}",+        hint: "status runs nothing and downloads nothing: call it freely to tell the user whether Freerouting is present. install is the USER's decision (88 MB download, 141 MB on disk, into the bridge's own folder): ask before calling it; it streams, size-checks, extracts and verifies --help (about a minute; timeout 900 s). uninstall deletes that folder only. Risk per action: status read, install exec, uninstall write. Engine ai needs none of this.",+        related: &["kicad_autoroute", "kicad_uninstall"],+        pitfalls: &["Never install because a route was requested: kicad_autoroute engine freerouting answers freerouting_not_installed with the exact install call, and you ask the user first.", "macOS has no bundle (Freerouting ships a dmg there): status says supported:false; set ADOM_FREEROUTING_EXE to a copy you installed, or use engine ai.", "Nothing here touches KiCad, a system Java, the registry or PATH; kicad_uninstall removes the folder too."],     }, ]; @@ -114,6 +130,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_add_via" => add_via(args),         "kicad_route" => route(args),         "kicad_autoroute" => autoroute(state, args),+        "kicad_freerouting" => freerouting_verb(args),         _ => return None,     }) }@@ -453,13 +470,15 @@ fn route(args: &Value) -> Value { // 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.";+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. Any capable model can drive kicad_route_net: GPT-6 Astra in Codex has routed a 94-footprint public board this way to zero unconnected items, Claude Fable 5.1 can, and newer models will.";+const FREEROUTING_WHAT: &str = "A deterministic pass for the user who wants one: the bridge writes a Specctra DSN from the board, runs Freerouting headless (installed on demand into the bridge's own folder, 88 MB download with its own Java runtime, never a UAC prompt) and applies the SES result live as one native undo step per net, or into the closed file with a backup, then runs DRC. kicad_freerouting {\"action\":\"status\"} says whether it is present.";+const ASK_BOTH: &str = "Ask the user which engine they want and name both: ai (recommended; see engines[0].why) and freerouting (deterministic, installed on demand; engines[1].installed says whether it is present).";  fn engines() -> Value {+    let s = freerouting::status();     json!([         {"engine": "ai", "recommended": true, "available": true, "why": AI_WHY},-        {"engine": "freerouting", "recommended": false, "available": false, "phase": "2b", "what": FREEROUTING_WHAT},+        {"engine": "freerouting", "recommended": false, "available": s.installed(), "installed": s.installed(), "supported": s.bundle.is_some(), "what": FREEROUTING_WHAT, "status": "kicad_freerouting {\"action\":\"status\"}", "install": "kicad_freerouting {\"action\":\"install\"}"},     ]) } @@ -468,22 +487,15 @@ fn autoroute(state: &mut State, args: &Value) -> Value {     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.");+            let mut v = fail("engine_required", "kicad_autoroute needs engine: \"ai\" or \"freerouting\"", format!("{ASK_BOTH} 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("freerouting") => autoroute_freerouting(state, args, dry_run),         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.");+            let mut v = fail("unknown_engine", format!("unknown engine '{other}'"), format!("engine is \"ai\" (recommended) or \"freerouting\". {ASK_BOTH}"));             v["engines"] = engines();             v         }@@ -557,7 +569,7 @@ fn autoroute_ai(state: &mut State, args: &Value, dry_run: bool) -> Value {         "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).",+                "Confirm with the user that they chose engine ai (say why it is recommended; offer freerouting as the deterministic alternative, installed on demand).",                 "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.",@@ -565,10 +577,403 @@ fn autoroute_ai(state: &mut State, args: &Value, dry_run: bool) -> Value {             ],             "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.",+        "_hint": "Nothing was routed. Ask the user which engine they want if you have not already: ai is recommended (see planRequest.why_ai); freerouting is the deterministic alternative (kicad_autoroute {\"engine\":\"freerouting\"}, installed on demand through kicad_freerouting). 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 }++// ---------------------------------------------------------------------------+// Engine freerouting+// ---------------------------------------------------------------------------++/// A live progress frame for the caller; ends when dropped (every early return included).+struct Phase {+    phase: &'static str,+    caller: String,+}++impl Phase {+    fn begin(phase: &'static str, label: &str) -> Phase {+        let caller = progress::caller_name();+        progress::begin_phase(phase, "warm", label, &caller);+        Phase { phase, caller }+    }+    fn step(&self, label: &str, percent: Option<i64>) {+        progress::step_for(&self.caller, self.phase, label, percent, None);+    }+}++impl Drop for Phase {+    fn drop(&mut self) {+        progress::end_phase(self.phase, &self.caller);+    }+}++fn freerouting_verb(args: &Value) -> Value {+    let action = arg_str(args, "action").map(|s| s.trim().to_ascii_lowercase()).unwrap_or_else(|| "status".into());+    let mut out = match action.as_str() {+        "status" => freerouting::status_json(),+        "install" => {+            let force = args.get("force") == Some(&json!(true));+            if args.get("wait") == Some(&json!(true)) {+                let phase = Phase::begin("freerouting_install", "starting");+                freerouting::install(force, |label, pct| phase.step(label, pct))+            } else {+                install_job_start(force)+            }+        }+        "uninstall" => freerouting::uninstall(),+        other => fail("unknown_action", format!("unknown action '{other}'"), "action is \"status\" (disk facts), \"install\" (ask the user first) or \"uninstall\"."),+    };+    if action == "status" {+        if let Some(job) = install_job_snapshot() {+            out["installing"] = job;+        }+    }+    out["action"] = json!(action);+    out+}++/// The install runs on its own thread: an 88 MB download plus an msiexec extract takes+/// longer than ab's request budget on a slow link, and a cut-off request must not leave+/// a half-installed folder behind unnoticed.+struct InstallJob {+    started: std::time::Instant,+    phase: String,+    percent: Option<i64>,+    result: Option<Value>,+}++static INSTALL_JOB: std::sync::Mutex<Option<InstallJob>> = std::sync::Mutex::new(None);++fn install_job_snapshot() -> Option<Value> {+    let g = INSTALL_JOB.lock().ok()?;+    let j = g.as_ref()?;+    Some(json!({+        "running": j.result.is_none(),+        "phase": j.phase,+        "percent": j.percent,+        "elapsedSec": j.started.elapsed().as_secs_f64().round(),+        "result": j.result,+    }))+}++fn install_job_start(force: bool) -> Value {+    {+        let mut g = INSTALL_JOB.lock().unwrap_or_else(|e| e.into_inner());+        if let Some(j) = g.as_ref() {+            if j.result.is_none() {+                return json!({"success": true, "started": false, "alreadyRunning": true, "statusVerb": "kicad_freerouting", "statusArgs": {"action": "status"},+                    "phase": j.phase, "percent": j.percent, "elapsedSec": j.started.elapsed().as_secs_f64().round(),+                    "_hint": "An install is already running; poll kicad_freerouting {\"action\":\"status\"} and read `installing`."});+            }+        }+        *g = Some(InstallJob { started: std::time::Instant::now(), phase: "starting".into(), percent: Some(0), result: None });+    }+    std::thread::spawn(move || {+        let phase = Phase::begin("freerouting_install", "starting");+        let out = freerouting::install(force, |label, pct| {+            phase.step(label, pct);+            if let Ok(mut g) = INSTALL_JOB.lock() {+                if let Some(j) = g.as_mut() {+                    j.phase = label.to_string();+                    j.percent = pct;+                }+            }+        });+        if let Ok(mut g) = INSTALL_JOB.lock() {+            if let Some(j) = g.as_mut() {+                j.phase = if out.get("success") == Some(&json!(true)) { "done".into() } else { "failed".into() };+                j.percent = Some(100);+                j.result = Some(out);+            }+        }+    });+    json!({"success": true, "started": true, "jobId": "freerouting-install", "statusVerb": "kicad_freerouting", "statusArgs": {"action": "status"},+        "estimatedSec": 90,+        "_hint": "Downloading Freerouting's own bundle (88 MB) and extracting it into the bridge cache; nothing is installed on the PC. Poll kicad_freerouting {\"action\":\"status\"} every few seconds: `installing.phase` and `installing.percent` move, and `installed:true` with the version means done. Pass wait:true to block instead."})+}++fn norm_path(p: &std::path::Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++/// Engine `freerouting`: board -> DSN -> Freerouting -> SES -> copper applied live (one+/// undo step per net) or into the closed file, then DRC. Not installed: the install call+/// and a hint that says ask the user; nothing is downloaded here.+fn autoroute_freerouting(state: &mut State, args: &Value, dry_run: bool) -> Value {+    let status = freerouting::status();+    let Some(exe) = status.exe.clone() else {+        let mut v = freerouting::not_installed(&status);+        v["engines"] = engines();+        return v;+    };+    let passes = args.get("passes").and_then(Value::as_u64).map(|p| p.clamp(1, 999) as u32).unwrap_or(freerouting::DEFAULT_PASSES);+    let threads = args.get("threads").and_then(Value::as_u64).map(|t| t.clamp(1, 64) as u32);+    let timeout_sec = args.get("timeoutSec").and_then(Value::as_u64).map(|t| t.clamp(10, 3600)).unwrap_or(freerouting::DEFAULT_TIMEOUT_SECS);+    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 apply_arg = arg_str(args, "apply").map(|s| s.trim().to_ascii_lowercase());+    if let Some(a) = &apply_arg {+        if a != "file" && a != "ipc" {+            return fail("bad_apply", format!("apply must be \"file\" or \"ipc\", not '{a}'"), "apply file writes the closed board with a backup; apply ipc commits live into the open PCB editor. Omit it and the bridge picks ipc when the board is open, file otherwise.");+        }+    }+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let board_open = pcb::lock_file(&loaded.path).is_some();+    let use_ipc = match apply_arg.as_deref() {+        Some("ipc") => true,+        Some("file") => false,+        _ => board_open,+    };+    if !use_ipc && board_open {+        return json!({+            "success": false, "errorCode": "board_open", "engine": "freerouting", "mutated": false,+            "error": "This board is open in KiCad; apply:\"file\" would conflict with the editor",+            "_hint": "Let the bridge apply live instead (omit apply, or apply:\"ipc\": the PCB editor with the IPC API on takes the copper as one undo step per net), or close the board for a file apply.",+        });+    }+    if use_ipc && !ipc_available() {+        return fail("ipc_unavailable", "This build has the ipc feature off; a live apply needs the KiCad IPC client compiled in", "Close the board and call again with apply:\"file\", or use a build with the ipc feature.");+    }+    let phase = Phase::begin("autoroute", "reading the board");+    // The board the router sees: the editor's own serialisation when applying live (unsaved+    // edits included), the file otherwise.+    let mut live_args = json!({"filePath": loaded.path.to_string_lossy()});+    for k in ["socketPath", "ipcTimeoutMs"] {+        if let Some(v) = args.get(k) {+            live_args[k] = v.clone();+        }+    }+    let (text, board, mut revision): (String, pcb::Board, Option<String>) = if use_ipc {+        match live_snapshot(state, &live_args) {+            Ok((t, b, r)) => (t, b, Some(r)),+            Err(e) => return e,+        }+    } else {+        (loaded.text.clone(), loaded.board.clone(), None)+    };+    let stem = loaded.path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+    let rules = dsn::rules_for(&loaded.path, &board);+    phase.step("writing DSN", Some(3));+    let out = match dsn::write_dsn(&board, &text, &stem, &rules, wanted.as_deref()) {+        Ok(d) => d,+        Err(e) => return fail("dsn_failed", format!("could not write the Specctra DSN: {e}"), "The board needs at least one copper layer and, ideally, a closed Edge.Cuts outline. kicad_board_pads shows what the parser sees."),+    };+    if out.stats.routed_nets.is_empty() {+        let mut v = fail("nothing_to_route", "no net with two or more pads to route", "Nets with one pad have nothing to connect. Pass nets:[...] with names from kicad_board_pads, or omit it to route every net.");+        v["engine"] = json!("freerouting");+        v["unknownNets"] = json!(out.stats.unknown_nets);+        v["mutated"] = json!(false);+        return v;+    }+    let work = std::env::temp_dir().join(format!("adom-freerouting-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+    if let Err(e) = std::fs::create_dir_all(&work) {+        return fail("io_error", format!("cannot create {}: {e}", work.display()), "Check the temp folder is writable.");+    }+    let dsn_path = work.join(format!("{stem}.dsn"));+    let ses_path = work.join(format!("{stem}.ses"));+    if let Err(e) = std::fs::write(&dsn_path, &out.text) {+        return fail("io_error", format!("cannot write {}: {e}", dsn_path.display()), "Check the temp folder is writable.");+    }+    // ---- run+    phase.step(&format!("Freerouting: {} net(s), up to {passes} passes", out.stats.routed_nets.len()), Some(5));+    let run = match freerouting::run(&exe, &dsn_path, &ses_path, &work, passes, threads, Duration::from_secs(timeout_sec), |line| {+        if let Some(p) = freerouting::parse_pass(line) {+            let pct = 5 + (85.0 * (p.min(passes) as f64) / (passes as f64)) as i64;+            phase.step(&format!("Freerouting pass {p} of up to {passes}"), Some(pct));+        }+    }) {+        Ok(r) => r,+        Err(e) => {+            let _ = std::fs::remove_dir_all(&work);+            return fail("freerouting_failed", e, "Freerouting did not start. kicad_freerouting {\"action\":\"status\"} shows the executable; reinstall with {\"action\":\"install\",\"force\":true} if it is broken.");+        }+    };+    let fr = json!({+        "version": run.version.clone().unwrap_or_else(|| freerouting::VERSION.into()), "exe": norm_path(&exe),+        "passes": run.passes_seen, "maxPasses": passes, "threads": threads, "seconds": run.seconds,+        "unrouted": run.unrouted, "violations": run.violations,+        "exitCode": run.exit_code, "timedOut": run.timed_out, "stdoutTail": run.stdout_tail, "args": run.args,+    });+    if run.timed_out {+        let mut v = fail("freerouting_timeout", format!("Freerouting did not finish within {timeout_sec} s and was stopped; nothing was applied"), "Raise timeoutSec, lower passes, or route fewer nets (nets:[...]). The DSN is kept in workDir for inspection.");+        v["engine"] = json!("freerouting");+        v["freerouting"] = fr;+        v["workDir"] = json!(norm_path(&work));+        v["mutated"] = json!(false);+        return v;+    }+    let ses_text = match std::fs::read_to_string(&ses_path) {+        Ok(t) => t,+        Err(_) => {+            let mut v = fail("freerouting_failed", format!("Freerouting exited with {} and wrote no session file", run.exit_code), "Read freerouting.stdoutTail: a DSN the router rejected names the offending section. The DSN is kept in workDir.");+            v["engine"] = json!("freerouting");+            v["freerouting"] = fr;+            v["workDir"] = json!(norm_path(&work));+            v["mutated"] = json!(false);+            return v;+        }+    };+    let session = match dsn::read_ses(&ses_text) {+        Ok(s) => s,+        Err(e) => {+            let mut v = fail("ses_unreadable", format!("could not read Freerouting's session file: {e}"), "The .ses is kept in workDir; this is a bridge parser gap, report it with the file.");+            v["engine"] = json!("freerouting");+            v["freerouting"] = fr;+            v["workDir"] = json!(norm_path(&work));+            v["mutated"] = json!(false);+            return v;+        }+    };+    let copper = dsn::copper_for(&session, &board, wanted.as_deref(), &rules);+    let routed_names: Vec<&str> = copper.iter().map(|c| c.net.as_str()).collect();+    let unrouted: Vec<&String> = out.stats.routed_nets.iter().filter(|n| !routed_names.contains(&n.as_str())).collect();+    let segments: usize = copper.iter().map(|c| c.segments.len()).sum();+    let vias: usize = copper.iter().map(|c| c.vias.len()).sum();+    let skipped: usize = copper.iter().map(|c| c.skipped_existing).sum();+    let routed = json!({+        "nets": routed_names, "netCount": copper.len(), "segments": segments, "vias": vias,+        "skippedExisting": skipped, "unroutedNets": unrouted,+        "perNet": copper.iter().map(|c| json!({"net": c.net, "segments": c.segments.len(), "vias": c.vias.len()})).collect::<Vec<_>>(),+    });+    let dsn_info = json!({"stats": out.stats, "rules": rules, "viaPadstack": out.via_padstack});+    let mut response = json!({+        "success": true, "engine": "freerouting", "dryRun": dry_run, "mutated": false,+        "boardPath": loaded.path.to_string_lossy(),+        "freerouting": fr, "dsn": dsn_info, "routed": routed,+        "applied": false, "backup": Value::Null, "drc": Value::Null,+    });+    if dry_run {+        response["workDir"] = json!(norm_path(&work));+        response["copper"] = json!(copper);+        response["_hint"] = json!("Dry run: Freerouting ran and its copper is listed under copper (mm, +y down) but nothing was applied; the DSN and SES stay in workDir. Call again without dryRun to apply (live when the board is open, else the file), then report the engine used.");+        return response;+    }+    if copper.is_empty() {+        let _ = std::fs::remove_dir_all(&work);+        response["_hint"] = json!("Freerouting produced no new copper (every requested net was already routed, or the router gave up: see freerouting.stdoutTail and routed.unroutedNets). Nothing was changed.");+        return response;+    }+    // ---- apply+    let apply_result = if use_ipc {+        phase.step(&format!("committing {} net(s) live", copper.len()), Some(90));+        commit_live(state, &live_args, &copper, revision.take().unwrap_or_default())+    } else {+        phase.step("writing the board file", Some(90));+        let backup = backup_wanted(args);+        match freerouting::apply_to_file(&loaded, &copper, backup) {+            Ok((bak, s, v)) => Ok(json!({"applied": "file", "backup": bak, "segments": s, "vias": v, "reloaded": false, "source": "file"})),+            Err(e) => Err(e),+        }+    };+    let _ = std::fs::remove_dir_all(&work);+    match apply_result {+        Ok(a) => {+            response["mutated"] = json!(true);+            response["applied"] = a["applied"].clone();+            response["backup"] = a["backup"].clone();+            for k in ["undoSteps", "netsCommitted", "itemIds", "revision", "reloaded", "source", "saved"] {+                if let Some(v) = a.get(k) {+                    response[k] = v.clone();+                }+            }+        }+        Err(e) => {+            // A partial live apply says exactly which nets landed (each is its own undo step).+            let mut v = e;+            v["engine"] = json!("freerouting");+            v["freerouting"] = response["freerouting"].clone();+            v["routed"] = response["routed"].clone();+            return v;+        }+    }+    // ---- DRC+    phase.step("DRC", Some(95));+    let kicad_cli = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file());+    response["drc"] = if use_ipc {+        let v = live(state, &live_args, Live::Validate);+        if v["success"] == json!(true) {+            json!({"available": true, "source": "live-editor-snapshot", "errors": v["errors"], "warnings": v["warnings"], "unconnected": v["unconnected"], "clean": v["clean"], "summary": v["summary"], "revision": v["revision"]})+        } else {+            json!({"available": false, "reason": v["error"], "errorCode": v["errorCode"], "_hint": "kicad_routing_validate needs kicad-cli beside KiCad; the copper is committed regardless."})+        }+    } else {+        freerouting::drc_file(kicad_cli.as_deref(), &loaded.path)+    };+    let drc_word = match (response["drc"]["available"].as_bool(), response["drc"]["errors"].as_u64(), response["drc"]["unconnected"].as_u64()) {+        (Some(true), Some(0), Some(0)) => "DRC reports zero errors and zero unconnected items".to_string(),+        (Some(true), Some(e), Some(u)) => format!("DRC reports {e} error(s) and {u} unconnected item(s): inspect drc.summary.first before trusting the result"),+        _ => "no DRC ran here (no kicad-cli): run kicad_run_drc or kicad_routing_validate".to_string(),+    };+    let how = if use_ipc { "committed live, one native undo step per net named \"Freerouting <net>\"" } else { "written to the closed board file (backup beside it)" };+    response["_hint"] = json!(format!("Freerouting routed {} net(s) ({segments} segments, {vias} vias) in {} s over {} pass(es); copper {how}; {drc_word}. Tell the user the copper came from Freerouting, not the AI, and name routed.unroutedNets if any.", copper.len(), run.seconds, run.passes_seen));+    response+}++#[cfg(feature = "ipc")]+fn live_snapshot(state: &mut State, live_args: &Value) -> Result<(String, pcb::Board, String), Value> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let session = ipc::connect(&ctx, live_args).map_err(|e| ipc::error_response(&e))?;+    session.snapshot().map_err(|e| ipc::error_response(&e))+}++#[cfg(not(feature = "ipc"))]+fn live_snapshot(_state: &mut State, _live_args: &Value) -> Result<(String, pcb::Board, String), Value> {+    Err(fail("ipc_unavailable", "ipc feature off in this build", "Close the board and apply to the file."))+}++/// One `commit_copper` per net, chunked at 1000 items, each a native undo step named+/// "Freerouting <net>", the revision chained from commit to commit.+#[cfg(feature = "ipc")]+fn commit_live(state: &mut State, live_args: &Value, copper: &[dsn::NetCopper], mut revision: String) -> Result<Value, Value> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let mut committed: Vec<String> = Vec::new();+    let mut undo_steps = 0u64;+    let mut item_ids = 0u64;+    for c in copper {+        let mut segs = c.segments.clone();+        let mut vias = c.vias.clone();+        while !segs.is_empty() || !vias.is_empty() {+            let take_s = segs.len().min(1000);+            let take_v = vias.len().min(1000 - take_s);+            let plan = pcb::Plan { net_name: c.net.clone(), segments: segs.drain(..take_s).collect(), vias: vias.drain(..take_v).collect(), blocks: Vec::new() };+            let mut call = live_args.clone();+            call["expectedRevision"] = json!(revision);+            let r = ipc::commit_copper(&ctx, &call, &plan, &format!("Freerouting {}", c.net)).map_err(|e| {+                let mut v = ipc::error_response(&e);+                v["netsCommitted"] = json!(committed);+                v["undoSteps"] = json!(undo_steps);+                v["_hint"] = json!(format!("Live apply stopped at net {}: {} net(s) already landed, each its own undo step. Inspect kicad_routing_state; do not replay blindly.", c.net, committed.len()));+                v+            })?;+            undo_steps += 1;+            item_ids += r["itemIds"].as_array().map(|a| a.len() as u64).unwrap_or(0);+            if let Some(rev) = r["revision"].as_str() {+                revision = rev.to_string();+            }+        }+        committed.push(c.net.clone());+    }+    Ok(json!({"applied": "ipc", "backup": Value::Null, "undoSteps": undo_steps, "netsCommitted": committed, "itemIds": item_ids, "revision": revision, "source": "live-editor", "saved": false}))+}++#[cfg(not(feature = "ipc"))]+fn commit_live(_state: &mut State, _live_args: &Value, _copper: &[dsn::NetCopper], _revision: String) -> Result<Value, Value> {+    Err(fail("ipc_unavailable", "ipc feature off in this build", "Close the board and apply to the file."))+}
rust/crates/kicad-core/src/dsn.rsadded+1386
@@ -0,0 +1,1386 @@+//! Specctra DSN writer and SES reader for the Freerouting engine of `kicad_autoroute`.+//!+//! The writer follows pcbnew's own exporter (KiCad `pcbnew/specctra_export.cpp`): units+//! are micrometres with `(resolution um 10)` declaring a tenth of a micrometre of precision+//! (so a clearance can be `200.1`), the y axis is flipped (`dsn_y = -board_y`), pins are `REF-PAD`, the+//! through via is `Via[0-1]_<size>:<drill>_um`, and existing copper goes into `(wiring)`+//! as protected wires so the router keeps it. Where this writer is simpler than pcbnew:+//!+//! - one `(image)` per footprint INSTANCE, placed `front 0`, with every pin already in the+//!   footprint's board-frame orientation. Rotation and side are baked into the pins and+//!   padstacks, so the DSN place semantics (counter-clockwise, mirrored back side) never+//!   come into play;+//! - circle and rect pads are exact (rect as a rotated polygon); oval, roundrect and every+//!   other shape are written as the rect of their bounding size and listed in+//!   `approximatedPads`, which is conservative (never smaller than the real pad);+//! - the boundary is the largest closed loop on Edge.Cuts (arcs and circles as segments),+//!   else the outline's bounding box, else the pad bounding box plus a margin (`notes`+//!   says which).+//!+//! The parser header says `host_cad "KiCad's Pcbnew"` on purpose: this IS pcbnew's+//! dialect, and Freerouting keys compatibility behaviour off that string (any other host+//! is treated as a pre-6 KiCad export with a warning).+//!+//! The reader takes Freerouting's `.ses` (`routes > network_out > net > wire | via`) back+//! to millimetre segments and through vias in KiCad's frame, honouring the session's own+//! `(resolution ...)` (Freerouting writes tenths of a micrometre there, ten times the DSN+//! numbers) and the `(string_quote ")` parser quirk.++use std::collections::BTreeMap;+use std::path::Path;++use serde::Serialize;+use serde_json::Value;++use crate::model_check::{parse_sexpr, Sx};+use crate::pcb::{self, Board, PlanSegment, PlanVia};++/// KiCad's routing defaults when the board has no project file beside it.+pub const DEFAULT_CLEARANCE_MM: f64 = 0.2;+pub const DEFAULT_TRACK_MM: f64 = 0.25;+pub const DEFAULT_VIA_SIZE_MM: f64 = 0.8;+pub const DEFAULT_VIA_DRILL_MM: f64 = 0.4;++/// DSN numbers are micrometres, written with at most one decimal (`(resolution um 10)`).+const UM_PER_MM: f64 = 1000.0;+/// Endpoints closer than this (mm) are the same point when chaining the outline.+const CHAIN_TOL: f64 = 0.01;++// ---------------------------------------------------------------------------+// Rules+// ---------------------------------------------------------------------------++/// One netclass as the DSN carries it: mm.+#[derive(Clone, Debug, PartialEq, Serialize)]+pub struct NetClass {+    pub name: String,+    pub clearance: f64,+    #[serde(rename = "trackWidth")]+    pub track_width: f64,+    #[serde(rename = "viaSize")]+    pub via_size: f64,+    #[serde(rename = "viaDrill")]+    pub via_drill: f64,+    /// Exact net names assigned to this class (resolved against the board's nets).+    pub nets: Vec<String>,+}++/// The Default class plus every other class that owns at least one net, and where the+/// numbers came from.+#[derive(Clone, Debug, PartialEq, Serialize)]+pub struct Rules {+    pub clearance: f64,+    #[serde(rename = "trackWidth")]+    pub track_width: f64,+    #[serde(rename = "viaSize")]+    pub via_size: f64,+    #[serde(rename = "viaDrill")]+    pub via_drill: f64,+    pub classes: Vec<NetClass>,+    pub source: String,+}++impl Default for Rules {+    fn default() -> Self {+        Rules { clearance: DEFAULT_CLEARANCE_MM, track_width: DEFAULT_TRACK_MM, via_size: DEFAULT_VIA_SIZE_MM, via_drill: DEFAULT_VIA_DRILL_MM, classes: Vec::new(), source: "kicad-defaults".into() }+    }+}++impl Rules {+    /// The class a net belongs to (Default when unassigned).+    pub fn class_for(&self, net: &str) -> (f64, f64, f64, f64) {+        for c in &self.classes {+            if c.nets.iter().any(|n| n == net) {+                return (c.clearance, c.track_width, c.via_size, c.via_drill);+            }+        }+        (self.clearance, self.track_width, self.via_size, self.via_drill)+    }+}++/// Rules for a board: the sibling `.kicad_pro`'s Default netclass (floored by the+/// board's minimum constraints), plus any other class that owns a net on this board.+/// KiCad's defaults when there is no project file or it does not say.+pub fn rules_for(board_path: &Path, board: &Board) -> Rules {+    let mut rules = Rules::default();+    let pro = board_path.with_extension("kicad_pro");+    let Some(doc) = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) else {+        return rules;+    };+    let f = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|x| x.is_finite() && *x > 0.0);+    let mins = &doc["board"]["design_settings"]["rules"];+    let min_clearance = f(mins.get("min_clearance")).unwrap_or(0.0);+    let min_track = f(mins.get("min_track_width")).unwrap_or(0.0);+    let min_via = f(mins.get("min_via_diameter")).unwrap_or(0.0);+    let min_hole = f(mins.get("min_through_hole_diameter")).unwrap_or(0.0);+    let classes = doc["net_settings"]["classes"].as_array().cloned().unwrap_or_default();+    let default = classes.iter().find(|c| c["name"].as_str() == Some("Default"));+    if let Some(d) = default {+        rules.clearance = f(d.get("clearance")).unwrap_or(rules.clearance);+        rules.track_width = f(d.get("track_width")).unwrap_or(rules.track_width);+        rules.via_size = f(d.get("via_diameter")).unwrap_or(rules.via_size);+        rules.via_drill = f(d.get("via_drill")).unwrap_or(rules.via_drill);+        rules.source = pro.to_string_lossy().replace('\\', "/");+    }+    rules.clearance = rules.clearance.max(min_clearance);+    rules.track_width = rules.track_width.max(min_track);+    rules.via_size = rules.via_size.max(min_via);+    rules.via_drill = rules.via_drill.max(min_hole);+    if rules.via_drill >= rules.via_size {+        rules.via_drill = (rules.via_size / 2.0 * 1000.0).round() / 1000.0;+    }+    // Net to class: KiCad 8+ exact assignments, KiCad 7 patterns (exact or a trailing `*`).+    let mut assigned: BTreeMap<String, String> = BTreeMap::new();+    if let Some(map) = doc["net_settings"]["netclass_assignments"].as_object() {+        for (net, class) in map {+            if let Some(c) = class.as_str() {+                assigned.insert(net.clone(), c.to_string());+            }+        }+    }+    if let Some(pats) = doc["net_settings"]["netclass_patterns"].as_array() {+        for p in pats {+            let (Some(pattern), Some(class)) = (p["pattern"].as_str(), p["netclass"].as_str()) else { continue };+            for n in &board.nets {+                if n.name.is_empty() || assigned.contains_key(&n.name) {+                    continue;+                }+                let hit = match pattern.strip_suffix('*') {+                    Some(prefix) => n.name.starts_with(prefix),+                    None => n.name == pattern,+                };+                if hit {+                    assigned.insert(n.name.clone(), class.to_string());+                }+            }+        }+    }+    for c in &classes {+        let Some(name) = c["name"].as_str().filter(|n| *n != "Default") else { continue };+        let nets: Vec<String> = assigned.iter().filter(|(n, cl)| cl.as_str() == name && board.nets.iter().any(|b| &b.name == *n)).map(|(n, _)| n.clone()).collect();+        if nets.is_empty() {+            continue;+        }+        let mut nc = NetClass {+            name: name.to_string(),+            clearance: f(c.get("clearance")).unwrap_or(rules.clearance).max(min_clearance),+            track_width: f(c.get("track_width")).unwrap_or(rules.track_width).max(min_track),+            via_size: f(c.get("via_diameter")).unwrap_or(rules.via_size).max(min_via),+            via_drill: f(c.get("via_drill")).unwrap_or(rules.via_drill).max(min_hole),+            nets,+        };+        if nc.via_drill >= nc.via_size {+            nc.via_drill = (nc.via_size / 2.0 * 1000.0).round() / 1000.0;+        }+        rules.classes.push(nc);+    }+    rules+}++// ---------------------------------------------------------------------------+// Units, quoting, geometry+// ---------------------------------------------------------------------------++fn u(mm: f64) -> String {+    let tenths = (mm * UM_PER_MM * 10.0).round();+    if tenths % 10.0 == 0.0 {+        format!("{}", (tenths / 10.0) as i64)+    } else {+        format!("{:.1}", tenths / 10.0)+    }+}++/// A board point (mm, +y down) as DSN units with the y axis flipped.+fn pt(x: f64, y: f64) -> String {+    format!("{} {}", u(x), u(-y))+}++/// Quote the way pcbnew does: bare when every character is safe, else in double quotes.+pub fn q(s: &str) -> String {+    let safe = |c: char| c.is_ascii_alphanumeric() || "_-./+:@#$&%!~^*?=<>|[]".contains(c);+    if !s.is_empty() && s.chars().all(safe) {+        s.to_string()+    } else {+        format!("\"{}\"", s.replace('"', "'"))+    }+}++/// KiCad's pad rotation in a y-down frame (trigo.cpp RotatePoint): the same formula+/// `pcb::pads` uses for footprint rotation.+fn rotate(x: f64, y: f64, deg: f64) -> (f64, f64) {+    let (s, c) = deg.to_radians().sin_cos();+    (x * c + y * s, -x * s + y * c)+}++fn atom_f64(sx: &Sx, i: usize) -> Option<f64> {+    sx.atom_at(i)?.parse::<f64>().ok()+}++fn xy(node: Option<&Sx>) -> Option<(f64, f64)> {+    let n = node?;+    Some((atom_f64(n, 1)?, atom_f64(n, 2)?))+}++fn via_name(size: f64, drill: f64) -> String {+    format!("Via[0-1]_{}:{}_um", (size * 1000.0).round() as i64, (drill * 1000.0).round() as i64)+}++/// `Via[0-1]_800:400_um` -> (0.8, 0.4).+pub fn via_dims(name: &str) -> Option<(f64, f64)> {+    let rest = name.strip_prefix("Via[")?;+    let (_, rest) = rest.split_once("]_")?;+    let rest = rest.strip_suffix("_um")?;+    let (a, b) = rest.split_once(':')?;+    Some((a.parse::<f64>().ok()? / 1000.0, b.parse::<f64>().ok()? / 1000.0))+}++// ---------------------------------------------------------------------------+// Board outline+// ---------------------------------------------------------------------------++/// Arc through three points as a polyline (start and end inclusive).+fn arc_points(a: (f64, f64), m: (f64, f64), b: (f64, f64)) -> Vec<(f64, f64)> {+    let (ax, ay) = a;+    let (mx, my) = m;+    let (bx, by) = b;+    let d = 2.0 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my));+    if d.abs() < 1e-9 {+        return vec![a, b];+    }+    let a2 = ax * ax + ay * ay;+    let m2 = mx * mx + my * my;+    let b2 = bx * bx + by * by;+    let cx = (a2 * (my - by) + m2 * (by - ay) + b2 * (ay - my)) / d;+    let cy = (a2 * (bx - mx) + m2 * (ax - bx) + b2 * (mx - ax)) / d;+    let r = ((ax - cx).powi(2) + (ay - cy).powi(2)).sqrt();+    let t0 = (ay - cy).atan2(ax - cx);+    let tm = (my - cy).atan2(mx - cx);+    let t1 = (by - cy).atan2(bx - cx);+    let two_pi = std::f64::consts::TAU;+    let ccw = |from: f64, to: f64| ((to - from) % two_pi + two_pi) % two_pi;+    // Sweep counter-clockwise from start unless the mid point is not on that way.+    let (sweep, sign) = if ccw(t0, tm) <= ccw(t0, t1) { (ccw(t0, t1), 1.0) } else { (ccw(t1, t0), -1.0) };+    let n = ((sweep.to_degrees() / 5.0).ceil() as usize).clamp(4, 180);+    let mut out = Vec::with_capacity(n + 1);+    for i in 0..=n {+        let t = t0 + sign * sweep * (i as f64) / (n as f64);+        out.push((cx + r * t.cos(), cy + r * t.sin()));+    }+    out[0] = a;+    out[n] = b;+    out+}++fn circle_points(c: (f64, f64), r: f64) -> Vec<(f64, f64)> {+    let n = 64;+    let mut out: Vec<(f64, f64)> = (0..n).map(|i| {+        let t = std::f64::consts::TAU * (i as f64) / (n as f64);+        (c.0 + r * t.cos(), c.1 + r * t.sin())+    }).collect();+    out.push(out[0]);+    out+}++/// Every Edge.Cuts primitive as a polyline in board mm (footprint ones transformed).+fn edge_polylines(tree: &Sx) -> Vec<Vec<(f64, f64)>> {+    let mut out = Vec::new();+    let on_edge = |node: &Sx| node.find("layer").and_then(Sx::value) == Some("Edge.Cuts");+    let mut collect = |node: &Sx, tag: &str, tf: &dyn Fn((f64, f64)) -> (f64, f64)| {+        if !on_edge(node) {+            return;+        }+        let poly: Option<Vec<(f64, f64)>> = (|| match tag {+            "gr_line" | "fp_line" => Some(vec![tf(xy(node.find("start"))?), tf(xy(node.find("end"))?)]),+            "gr_rect" | "fp_rect" => {+                let (x1, y1) = xy(node.find("start"))?;+                let (x2, y2) = xy(node.find("end"))?;+                Some(vec![tf((x1, y1)), tf((x2, y1)), tf((x2, y2)), tf((x1, y2)), tf((x1, y1))])+            }+            "gr_arc" | "fp_arc" => {+                let a = xy(node.find("start"))?;+                let b = xy(node.find("end"))?;+                match xy(node.find("mid")) {+                    Some(m) => Some(arc_points(a, m, b).into_iter().map(tf).collect()),+                    None => Some(vec![tf(a), tf(b)]),+                }+            }+            "gr_circle" | "fp_circle" => {+                let c = xy(node.find("center"))?;+                let e = xy(node.find("end"))?;+                Some(circle_points(c, pcb::dist(c, e)).into_iter().map(tf).collect())+            }+            "gr_poly" | "fp_poly" => {+                let pts = node.find("pts")?;+                let mut v: Vec<(f64, f64)> = pts.find_all("xy").filter_map(|p| Some(tf((atom_f64(p, 1)?, atom_f64(p, 2)?)))).collect();+                if v.len() >= 3 {+                    v.push(v[0]);+                }+                Some(v)+            }+            _ => None,+        })()+        .filter(|v| v.len() >= 2);+        if let Some(p) = poly {+            out.push(p);+        }+    };+    for tag in ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"] {+        for node in tree.find_all(tag) {+            collect(node, tag, &|p| p);+        }+    }+    for fp in tree.find_all("footprint") {+        let (fx, fy, rot) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0), atom_f64(a, 3).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));+        let tf = move |p: (f64, f64)| {+            let (x, y) = rotate(p.0, p.1, rot);+            (fx + x, fy + y)+        };+        for tag in ["fp_line", "fp_rect", "fp_arc", "fp_circle", "fp_poly"] {+            for node in fp.find_all(tag) {+                collect(node, tag, &tf);+            }+        }+    }+    out+}++fn same_point(a: (f64, f64), b: (f64, f64)) -> bool {+    pcb::dist(a, b) <= CHAIN_TOL+}++fn polygon_area(p: &[(f64, f64)]) -> f64 {+    let mut s = 0.0;+    for i in 0..p.len() {+        let (x1, y1) = p[i];+        let (x2, y2) = p[(i + 1) % p.len()];+        s += x1 * y2 - x2 * y1;+    }+    s.abs() / 2.0+}++/// Chain the outline primitives into closed loops and keep the largest one.+fn outline_loop(mut pieces: Vec<Vec<(f64, f64)>>) -> Option<Vec<(f64, f64)>> {+    let mut loops: Vec<Vec<(f64, f64)>> = Vec::new();+    while let Some(mut chain) = pieces.pop() {+        loop {+            let head = chain[0];+            let tail = chain[chain.len() - 1];+            if chain.len() >= 4 && same_point(head, tail) {+                chain.pop();+                loops.push(chain);+                break;+            }+            let mut joined = false;+            for i in 0..pieces.len() {+                let p = &pieces[i];+                let (ph, pt_) = (p[0], p[p.len() - 1]);+                if same_point(tail, ph) {+                    let p = pieces.remove(i);+                    chain.extend(p.into_iter().skip(1));+                    joined = true;+                } else if same_point(tail, pt_) {+                    let p = pieces.remove(i);+                    chain.extend(p.into_iter().rev().skip(1));+                    joined = true;+                } else if same_point(head, pt_) {+                    let mut p = pieces.remove(i);+                    p.pop();+                    p.extend(chain.iter().copied());+                    chain = p;+                    joined = true;+                } else if same_point(head, ph) {+                    let p = pieces.remove(i);+                    let mut p: Vec<(f64, f64)> = p.into_iter().rev().collect();+                    p.pop();+                    p.extend(chain.iter().copied());+                    chain = p;+                    joined = true;+                }+                if joined {+                    break;+                }+            }+            if !joined {+                break; // an open chain: not an outline+            }+        }+    }+    loops.into_iter().filter(|l| l.len() >= 3 && polygon_area(l) > 0.0).max_by(|a, b| polygon_area(a).partial_cmp(&polygon_area(b)).unwrap_or(std::cmp::Ordering::Equal))+}++// ---------------------------------------------------------------------------+// The writer+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, Default, Serialize)]+pub struct DsnStats {+    pub components: usize,+    pub pins: usize,+    pub nets: usize,+    /// Nets the router is asked to connect (all nets with 2+ pins, or the requested subset).+    #[serde(rename = "routedNets")]+    pub routed_nets: Vec<String>,+    /// Requested nets the board does not have.+    #[serde(rename = "unknownNets")]+    pub unknown_nets: Vec<String>,+    #[serde(rename = "existingWires")]+    pub existing_wires: usize,+    #[serde(rename = "existingVias")]+    pub existing_vias: usize,+    #[serde(rename = "boundaryPoints")]+    pub boundary_points: usize,+    #[serde(rename = "boundarySource")]+    pub boundary_source: String,+    pub layers: Vec<String>,+    /// `REF.PAD (shape)` for every pad written as its bounding rect.+    #[serde(rename = "approximatedPads")]+    pub approximated_pads: Vec<String>,+    pub planes: usize,+    pub keepouts: usize,+    pub notes: Vec<String>,+}++pub struct Dsn {+    pub text: String,+    pub stats: DsnStats,+    /// The padstack the Default class routes with.+    pub via_padstack: String,+}++struct PadDef {+    number: String,+    kind: String,+    shape: String,+    x: f64,+    y: f64,+    rot: f64,+    w: f64,+    h: f64,+    drill: Option<f64>,+    offset: (f64, f64),+    layers: Vec<String>,+    net: String,+}++fn net_name_of(node: Option<&Sx>, board: &Board) -> String {+    let Some(node) = node else { return String::new() };+    let Some(first) = node.atom_at(1) else { return String::new() };+    if let Some(name) = node.atom_at(2) {+        return name.to_string();+    }+    if board.net_format == pcb::NetFormat::Number {+        if let Ok(n) = first.parse::<i64>() {+            return board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()).unwrap_or_default();+        }+    }+    first.to_string()+}++fn pad_defs(fp: &Sx, board: &Board) -> Vec<PadDef> {+    fp.find_all("pad")+        .filter_map(|pad| {+            let at = pad.find("at")?;+            let size = pad.find("size");+            let drill = pad.find("drill");+            let (drill_d, offset) = match drill {+                Some(d) => {+                    let dia = if d.atom_at(1) == Some("oval") { match (atom_f64(d, 2), atom_f64(d, 3)) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b) } } else { atom_f64(d, 1) };+                    (dia, xy(d.find("offset")).unwrap_or((0.0, 0.0)))+                }+                None => (None, (0.0, 0.0)),+            };+            Some(PadDef {+                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: atom_f64(at, 1)?,+                y: atom_f64(at, 2)?,+                rot: atom_f64(at, 3).unwrap_or(0.0),+                w: size.and_then(|s| atom_f64(s, 1)).unwrap_or(0.0),+                h: size.and_then(|s| atom_f64(s, 2)).unwrap_or(0.0),+                drill: drill_d,+                offset,+                layers: pad.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default(),+                net: net_name_of(pad.find("net"), board),+            })+        })+        .collect()+}++/// The copper layers a pad's `(layers ...)` list reaches, in board order.+fn pad_copper(layers: &[String], copper: &[String]) -> Vec<String> {+    let mut out = Vec::new();+    for c in copper {+        let hit = layers.iter().any(|l| l == c || l == "*.Cu" || (l.ends_with("*.Cu") && c.starts_with(&l[..l.len() - 4])) || (l == "F&B.Cu" && (c == "F.Cu" || c == "B.Cu")));+        if hit {+            out.push(c.clone());+        }+    }+    out+}++fn layer_tag(on: &[String], copper: &[String]) -> String {+    if on.len() == copper.len() {+        "A".into()+    } else if on.len() == 1 && on[0] == "F.Cu" {+        "T".into()+    } else if on.len() == 1 && on[0] == "B.Cu" {+        "B".into()+    } else {+        format!("L{}", on.iter().map(|l| copper.iter().position(|c| c == l).unwrap_or(0).to_string()).collect::<Vec<_>>().join("-"))+    }+}++fn fmt_mm_um(v: f64) -> i64 {+    (v * 1000.0).round() as i64+}++/// Write the DSN for `board` (parsed from `text`). `nets`: route only these (None: every+/// net with two or more pins); the other nets' pads and copper stay as obstacles.+pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>) -> Result<Dsn, String> {+    let tree = parse_sexpr(text).ok_or("empty board text")?;+    let copper = pcb::copper_layers(board);+    if copper.is_empty() {+        return Err("the board has no copper layers".into());+    }+    let mut stats = DsnStats { layers: copper.clone(), ..Default::default() };++    // ---- which nets get routed+    let board_nets: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).filter(|n| !n.is_empty()).collect();+    let mut unknown: Vec<String> = Vec::new();+    let wanted: Option<Vec<String>> = nets.map(|list| {+        let mut ok = Vec::new();+        for n in list {+            if board_nets.contains(&n.as_str()) {+                if !ok.contains(n) {+                    ok.push(n.clone());+                }+            } else {+                unknown.push(n.clone());+            }+        }+        ok+    });+    stats.unknown_nets = unknown;+    let routed = |net: &str| -> bool {+        !net.is_empty() && wanted.as_ref().map(|w| w.iter().any(|x| x == net)).unwrap_or(true)+    };++    // ---- components, images, padstacks, pins per net+    let mut padstacks: BTreeMap<String, String> = BTreeMap::new();+    let mut images = String::new();+    let mut placement = String::new();+    let mut pins_by_net: BTreeMap<String, Vec<String>> = BTreeMap::new();+    let mut seen_refs: BTreeMap<String, usize> = BTreeMap::new();+    for (index, fp) in tree.find_all("footprint").enumerate() {+        let reference = fp.find_all("property").find(|p| p.atom_at(1) == Some("Reference")).and_then(|p| p.atom_at(2)).unwrap_or("").to_string();+        let mut image_name = if reference.is_empty() { format!("FP{index}") } else { reference.clone() };+        let n = seen_refs.entry(image_name.clone()).or_insert(0);+        *n += 1;+        if *n > 1 {+            image_name = format!("{image_name}#{}", *n);+            stats.notes.push(format!("duplicate reference {reference}: pins named {image_name}"));+        }+        let (fx, fy, rot) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0), atom_f64(a, 3).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));+        let pads = pad_defs(fp, board);+        let mut pin_lines = String::new();+        let mut pin_counts: BTreeMap<String, usize> = BTreeMap::new();+        for pad in &pads {+            let on = pad_copper(&pad.layers, &copper);+            let is_npth = pad.kind == "np_thru_hole";+            // A pad with no copper is a hole (obstacle) or a paste/mask-only pad (nothing).+            let (shape, w, h, on) = if on.is_empty() {+                match (is_npth, pad.drill) {+                    (true, Some(d)) if d > 0.0 => ("circle".to_string(), d, d, copper.clone()),+                    _ => continue,+                }+            } else {+                (pad.shape.clone(), pad.w, pad.h, on)+            };+            if w <= 0.0 || h <= 0.0 {+                continue;+            }+            let exact = shape == "circle" || shape == "rect";+            if !exact {+                stats.approximated_pads.push(format!("{}.{} ({shape})", reference, pad.number));+            }+            let tag = layer_tag(&on, &copper);+            let rot_key = if shape == "circle" { 0 } else { (pad.rot.rem_euclid(360.0) * 10.0).round() as i64 };+            let (ox, oy) = pad.offset;+            let offset_key = if ox == 0.0 && oy == 0.0 { String::new() } else { format!("_o{}x{}", fmt_mm_um(ox), fmt_mm_um(oy)) };+            let kind_name = match shape.as_str() {+                "circle" => "Round",+                "rect" => "Rect",+                "oval" => "Oval",+                "roundrect" => "RoundRect",+                "trapezoid" => "Trapz",+                "custom" => "Custom",+                other => other,+            };+            let ps_name = if shape == "circle" {+                format!("{kind_name}[{tag}]Pad_{}_um{offset_key}", fmt_mm_um(w))+            } else if rot_key == 0 {+                format!("{kind_name}[{tag}]Pad_{}x{}_um{offset_key}", fmt_mm_um(w), fmt_mm_um(h))+            } else {+                format!("{kind_name}[{tag}]Pad_{}x{}_r{}_um{offset_key}", fmt_mm_um(w), fmt_mm_um(h), rot_key as f64 / 10.0)+            };+            padstacks.entry(ps_name.clone()).or_insert_with(|| {+                let mut s = format!("    (padstack {}\n", q(&ps_name));+                for layer in &on {+                    if shape == "circle" {+                        s.push_str(&format!("      (shape (circle {} {} {} {}))\n", layer, u(w), u(ox), u(-oy)));+                    } else {+                        // Corners in the pad's own orientation (absolute in a board file),+                        // y flipped afterwards. Oval, roundrect and the rest use the rect.+                        let corners = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];+                        let pts: Vec<String> = corners.iter().map(|(cx, cy)| {+                            let (rx, ry) = rotate(*cx, *cy, pad.rot);+                            pt(rx + ox, ry + oy)+                        }).collect();+                        s.push_str(&format!("      (shape (polygon {} 0 {}))\n", layer, pts.join(" ")));+                    }+                }+                s.push_str("      (attach off)\n    )\n");+                s+            });+            // Pin id: REF-PAD, made unique when a footprint repeats a pad number.+            let c = pin_counts.entry(pad.number.clone()).or_insert(0);+            *c += 1;+            let base = if pad.number.is_empty() { "NP".to_string() } else { pad.number.clone() };+            let pin_id = if *c == 1 && !pad.number.is_empty() { base } else { format!("{base}{}{}", if pad.number.is_empty() { "" } else { "@" }, *c) };+            let (px, py) = rotate(pad.x, pad.y, rot);+            pin_lines.push_str(&format!("      (pin {} {} {})\n", q(&ps_name), q(&pin_id), pt(px, py)));+            stats.pins += 1;+            if routed(&pad.net) {+                pins_by_net.entry(pad.net.clone()).or_default().push(format!("{image_name}-{pin_id}"));+            }+        }+        images.push_str(&format!("    (image {}\n{}    )\n", q(&image_name), pin_lines));+        placement.push_str(&format!("    (component {}\n      (place {} {} front 0)\n    )\n", q(&image_name), q(&image_name), pt(fx, fy)));+        stats.components += 1;+    }++    // ---- via padstacks: the rules' vias plus every size already on the board+    let default_via = via_name(rules.via_size, rules.via_drill);+    let mut via_names: Vec<String> = vec![default_via.clone()];+    for c in &rules.classes {+        let n = via_name(c.via_size, c.via_drill);+        if !via_names.contains(&n) {+            via_names.push(n);+        }+    }+    let via_ps = |name: &str, size: f64| {+        let mut s = format!("    (padstack {}\n", q(name));+        for layer in &copper {+            s.push_str(&format!("      (shape (circle {} {}))\n", layer, u(size)));+        }+        s.push_str("      (attach off)\n    )\n");+        s+    };+    padstacks.insert(default_via.clone(), via_ps(&default_via, rules.via_size));+    for c in &rules.classes {+        let n = via_name(c.via_size, c.via_drill);+        padstacks.entry(n.clone()).or_insert_with(|| via_ps(&n, c.via_size));+    }++    // ---- existing copper as protected wiring+    let net_of = |num: Option<i64>, name: Option<&str>| -> String {+        match board.net_format {+            pcb::NetFormat::Name => name.unwrap_or("").to_string(),+            pcb::NetFormat::Number => name.map(str::to_string).or_else(|| num.and_then(|n| board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()))).unwrap_or_default(),+        }+    };+    let mut wiring = String::new();+    for s in &board.segments {+        let (Some(a), Some(b), Some(w), Some(layer)) = (&s.start, &s.end, s.width, &s.layer) else { continue };+        if !copper.contains(layer) {+            continue;+        }+        let net = net_of(s.net, s.net_name.as_deref());+        let net_part = if net.is_empty() { String::new() } else { format!(" (net {})", q(&net)) };+        wiring.push_str(&format!("    (wire (path {} {} {} {}){} (type protect))\n", layer, u(w), pt(a.x, a.y), pt(b.x, b.y), net_part));+        stats.existing_wires += 1;+    }+    for v in &board.vias {+        let (Some(p), Some(size)) = (&v.position, v.size) else { continue };+        let drill = v.drill.unwrap_or(size / 2.0);+        let name = via_name(size, drill);+        padstacks.entry(name.clone()).or_insert_with(|| via_ps(&name, size));+        let net = net_of(v.net, v.net_name.as_deref());+        let net_part = if net.is_empty() { String::new() } else { format!(" (net {})", q(&net)) };+        wiring.push_str(&format!("    (via {} {}{} (type protect))\n", q(&name), pt(p.x, p.y), net_part));+        stats.existing_vias += 1;+    }++    // ---- zones: copper zones as planes, rule areas as keepouts+    let mut planes = String::new();+    for z in tree.find_all("zone") {+        let mut layers: Vec<String> = z.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();+        if let Some(l) = z.find("layer").and_then(Sx::value) {+            layers.push(l.to_string());+        }+        let layers: Vec<String> = layers.iter().flat_map(|l| pad_copper(std::slice::from_ref(l), &copper)).collect();+        if layers.is_empty() {+            continue;+        }+        let Some(pts) = z.find("polygon").and_then(|p| p.find("pts")) else { continue };+        let points: Vec<String> = pts.find_all("xy").filter_map(|p| Some(pt(atom_f64(p, 1)?, atom_f64(p, 2)?))).collect();+        if points.len() < 3 {+            continue;+        }+        let keepout = z.find("keepout").map(|k| k.find("tracks").and_then(Sx::value) == Some("not_allowed") || k.find("vias").and_then(Sx::value) == Some("not_allowed")).unwrap_or(false);+        let net = z.find("net_name").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| net_name_of(z.find("net"), board));+        for layer in &layers {+            if keepout {+                planes.push_str(&format!("    (keepout \"\" (polygon {} 0 {}))\n", layer, points.join(" ")));+                stats.keepouts += 1;+            } else if !net.is_empty() {+                planes.push_str(&format!("    (plane {} (polygon {} 0 {}))\n", q(&net), layer, points.join(" ")));+                stats.planes += 1;+            }+        }+    }++    // ---- boundary+    let pieces = edge_polylines(&tree);+    let (boundary, source): (Vec<(f64, f64)>, &str) = match outline_loop(pieces.clone()) {+        Some(l) => (l, "Edge.Cuts"),+        None => {+            let flat: Vec<(f64, f64)> = pieces.iter().flatten().copied().collect();+            let (points, source) = if !flat.is_empty() {+                (flat, "Edge.Cuts bounding box (outline is not one closed loop)")+            } else {+                let pads = pcb::pads(board);+                (pads.iter().map(|p| (p.x, p.y)).collect(), "pad bounding box plus 5 mm (no Edge.Cuts outline)")+            };+            if points.is_empty() {+                return Err("no Edge.Cuts outline and no pads: nothing to route inside".into());+            }+            let margin = if source.starts_with("pad") { 5.0 } else { 0.0 };+            let (mut x1, mut y1, mut x2, mut y2) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+            for (x, y) in points {+                x1 = x1.min(x);+                y1 = y1.min(y);+                x2 = x2.max(x);+                y2 = y2.max(y);+            }+            (vec![(x1 - margin, y1 - margin), (x2 + margin, y1 - margin), (x2 + margin, y2 + margin), (x1 - margin, y2 + margin)], source)+        }+    };+    stats.boundary_points = boundary.len();+    stats.boundary_source = source.to_string();+    if source != "Edge.Cuts" {+        stats.notes.push(format!("boundary: {source}"));+    }+    let mut boundary_pts: Vec<String> = boundary.iter().map(|(x, y)| pt(*x, *y)).collect();+    boundary_pts.push(boundary_pts[0].clone());++    // ---- network+    let mut network = String::new();+    let mut class_default_nets: Vec<String> = Vec::new();+    let mut class_nets: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for (net, pins) in &pins_by_net {+        if pins.len() < 2 {+            continue;+        }+        network.push_str(&format!("    (net {}\n      (pins {})\n    )\n", q(net), pins.iter().map(|p| q(p)).collect::<Vec<_>>().join(" ")));+        stats.routed_nets.push(net.clone());+        match rules.classes.iter().find(|c| c.nets.iter().any(|n| n == net)) {+            Some(c) => class_nets.entry(c.name.clone()).or_default().push(net.clone()),+            None => class_default_nets.push(net.clone()),+        }+    }+    stats.nets = board_nets.len();+    // Clearance carries pcbnew's 0.1 um safety margin so KiCad's own DRC never sees an+    // exact-touch case as a violation.+    let clearance_u = |mm: f64| u(mm + 0.0001);+    network.push_str(&format!(+        "    (class kicad_default {}\n      (circuit (use_via {}))\n      (rule (width {}) (clearance {}))\n    )\n",+        class_default_nets.iter().map(|n| q(n)).collect::<Vec<_>>().join(" "),+        q(&default_via),+        u(rules.track_width),+        clearance_u(rules.clearance)+    ));+    for c in &rules.classes {+        let Some(list) = class_nets.get(&c.name) else { continue };+        network.push_str(&format!(+            "    (class {} {}\n      (circuit (use_via {}))\n      (rule (width {}) (clearance {}))\n    )\n",+            q(&c.name),+            list.iter().map(|n| q(n)).collect::<Vec<_>>().join(" "),+            q(&via_name(c.via_size, c.via_drill)),+            u(c.track_width),+            clearance_u(c.clearance)+        ));+    }++    // ---- assemble+    let mut out = String::new();+    out.push_str(&format!("(pcb {}\n", q(&format!("{name}.dsn"))));+    let host_version = tree.find("generator_version").and_then(Sx::value).filter(|v| v.split('.').next().and_then(|m| m.parse::<u32>().ok()).map(|m| m >= 6).unwrap_or(false)).unwrap_or("10.0");+    out.push_str(&format!("  (parser\n    (string_quote \")\n    (space_in_quoted_tokens on)\n    (host_cad \"KiCad's Pcbnew\")\n    (host_version \"{host_version}\")\n  )\n"));+    out.push_str("  (resolution um 10)\n  (unit um)\n");+    out.push_str("  (structure\n");+    for (i, layer) in copper.iter().enumerate() {+        out.push_str(&format!("    (layer {}\n      (type signal)\n      (property\n        (index {i})\n      )\n    )\n", layer));+    }+    out.push_str(&format!("    (boundary\n      (path pcb 0 {})\n    )\n", boundary_pts.join(" ")));+    out.push_str(&planes);+    for n in &via_names {+        out.push_str(&format!("    (via {})\n", q(n)));+    }+    out.push_str(&format!("    (rule\n      (width {})\n      (clearance {})\n      (clearance {} (type default_smd))\n      (clearance {} (type smd_smd))\n    )\n", u(rules.track_width), clearance_u(rules.clearance), clearance_u(rules.clearance), u(rules.clearance / 4.0)));+    out.push_str("  )\n");+    out.push_str(&format!("  (placement\n{}  )\n", placement));+    out.push_str("  (library\n");+    out.push_str(&images);+    for (_, def) in &padstacks {+        out.push_str(def);+    }+    out.push_str("  )\n");+    out.push_str(&format!("  (network\n{}  )\n", network));+    if !wiring.is_empty() {+        out.push_str(&format!("  (wiring\n{}  )\n", wiring));+    }+    out.push_str(")\n");+    Ok(Dsn { text: out, stats, via_padstack: default_via })+}++// ---------------------------------------------------------------------------+// SES reader+// ---------------------------------------------------------------------------++/// Specctra tokenizer: parentheses, quoted strings (no escapes), bare atoms, and the+/// `(string_quote ")` quirk where the quote character itself is the atom.+pub fn parse_specctra(text: &str) -> Result<Sx, String> {+    let chars: Vec<char> = text.chars().collect();+    let mut i = 0;+    let mut stack: Vec<Vec<Sx>> = vec![Vec::new()];+    let mut quote = '"';+    let mut last_atom_was_string_quote = false;+    while i < chars.len() {+        let c = chars[i];+        if c.is_whitespace() {+            i += 1;+            continue;+        }+        if c == '(' {+            stack.push(Vec::new());+            i += 1;+            last_atom_was_string_quote = false;+            continue;+        }+        if c == ')' {+            let list = stack.pop().ok_or("unbalanced ')'")?;+            let parent = stack.last_mut().ok_or("unbalanced ')'")?;+            parent.push(Sx::List(list));+            i += 1;+            last_atom_was_string_quote = false;+            continue;+        }+        if last_atom_was_string_quote {+            // `(string_quote ")`: the next non-blank character is the atom.+            quote = c;+            stack.last_mut().unwrap().push(Sx::Atom(c.to_string()));+            i += 1;+            last_atom_was_string_quote = false;+            continue;+        }+        if c == quote {+            let mut j = i + 1;+            let mut s = String::new();+            while j < chars.len() && chars[j] != quote {+                s.push(chars[j]);+                j += 1;+            }+            if j >= chars.len() {+                return Err("unterminated quoted string".into());+            }+            stack.last_mut().unwrap().push(Sx::Atom(s));+            i = j + 1;+            continue;+        }+        let mut j = i;+        let mut s = String::new();+        while j < chars.len() && !chars[j].is_whitespace() && chars[j] != '(' && chars[j] != ')' {+            s.push(chars[j]);+            j += 1;+        }+        last_atom_was_string_quote = s == "string_quote";+        stack.last_mut().unwrap().push(Sx::Atom(s));+        i = j;+    }+    if stack.len() != 1 {+        return Err("unbalanced '('".into());+    }+    let mut top = stack.pop().unwrap();+    match top.len() {+        1 => Ok(top.pop().unwrap()),+        0 => Err("empty session".into()),+        _ => Ok(Sx::List(top)),+    }+}++#[derive(Clone, Debug, PartialEq, Serialize)]+pub struct SesWire {+    pub net: String,+    pub layer: String,+    /// mm+    pub width: f64,+    /// mm, KiCad frame (+y down)+    pub points: Vec<[f64; 2]>,+}++#[derive(Clone, Debug, PartialEq, Serialize)]+pub struct SesVia {+    pub net: String,+    pub padstack: String,+    pub at: [f64; 2],+    /// mm, from the session's `library_out` or the padstack name.+    pub size: Option<f64>,+    pub drill: Option<f64>,+}++#[derive(Clone, Debug, Default, PartialEq, Serialize)]+pub struct Session {+    pub wires: Vec<SesWire>,+    pub vias: Vec<SesVia>,+    pub unit: String,+    pub resolution: f64,+    pub nets: Vec<String>,+}++fn unit_mm(unit: &str) -> Option<f64> {+    Some(match unit {+        "um" => 0.001,+        "mm" => 1.0,+        "cm" => 10.0,+        "mil" => 0.0254,+        "inch" | "in" => 25.4,+        _ => return None,+    })+}++/// Walk every list in the tree, depth first.+fn walk<'a>(node: &'a Sx, f: &mut dyn FnMut(&'a Sx)) {+    if let Some(list) = node.as_list() {+        f(node);+        for c in list {+            walk(c, f);+        }+    }+}++pub fn read_ses(text: &str) -> Result<Session, String> {+    let tree = parse_specctra(text)?;+    if tree.tag() != Some("session") {+        return Err(format!("not a Specctra session file (top-level tag {:?})", tree.tag()));+    }+    let routes = tree.find("routes").ok_or("session has no (routes) section")?;+    // The routes section's own resolution wins; fall back to placement or the top level.+    let res_node = routes.find("resolution").or_else(|| tree.find("placement").and_then(|p| p.find("resolution"))).or_else(|| tree.find("resolution"));+    let (unit, resolution) = match res_node {+        Some(r) => (r.atom_at(1).unwrap_or("um").to_string(), atom_f64(r, 2).unwrap_or(1.0)),+        None => ("um".to_string(), 10.0),+    };+    let per_unit = unit_mm(&unit).ok_or_else(|| format!("unknown session unit {unit}"))?;+    let to_mm = |v: f64| pcb::round6(v / resolution * per_unit) + 0.0;+    // Via sizes from library_out (circle diameter) and drills from the padstack names.+    let mut via_sizes: BTreeMap<String, f64> = BTreeMap::new();+    if let Some(lib) = routes.find("library_out") {+        for ps in lib.find_all("padstack") {+            let Some(name) = ps.atom_at(1) else { continue };+            let mut best: Option<f64> = None;+            for shape in ps.find_all("shape") {+                if let Some(circle) = shape.find("circle") {+                    if let Some(d) = atom_f64(circle, 2) {+                        best = Some(best.map_or(d, |b: f64| b.max(d)));+                    }+                }+            }+            if let Some(d) = best {+                via_sizes.insert(name.to_string(), to_mm(d));+            }+        }+    }+    let mut session = Session { unit: unit.clone(), resolution, ..Default::default() };+    let network = routes.find("network_out").ok_or("session has no (network_out) section")?;+    for net_node in network.find_all("net") {+        let net = net_node.atom_at(1).unwrap_or("").to_string();+        if !session.nets.contains(&net) {+            session.nets.push(net.clone());+        }+        let mut items: Vec<&Sx> = Vec::new();+        walk(net_node, &mut |n| {+            if matches!(n.tag(), Some("wire") | Some("via")) {+                items.push(n);+            }+        });+        for item in items {+            let item_net = item.find("net").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| net.clone());+            match item.tag() {+                Some("wire") => {+                    let Some(path) = item.find("path").or_else(|| item.find("polyline_path")) else { continue };+                    let Some(layer) = path.atom_at(1) else { continue };+                    let Some(width) = atom_f64(path, 2) else { continue };+                    let nums: Vec<f64> = path.as_list().map(|l| l.iter().skip(3).filter_map(Sx::as_atom).filter_map(|a| a.parse::<f64>().ok()).collect()).unwrap_or_default();+                    let mut points: Vec<[f64; 2]> = Vec::new();+                    for pair in nums.chunks(2) {+                        if pair.len() == 2 {+                            let p = [to_mm(pair[0]), to_mm(-pair[1])];+                            if points.last() != Some(&p) {+                                points.push(p);+                            }+                        }+                    }+                    if points.len() >= 2 {+                        session.wires.push(SesWire { net: item_net, layer: layer.to_string(), width: to_mm(width), points });+                    }+                }+                Some("via") => {+                    let Some(padstack) = item.atom_at(1) else { continue };+                    let (Some(x), Some(y)) = (atom_f64(item, 2), atom_f64(item, 3)) else { continue };+                    let dims = via_dims(padstack);+                    session.vias.push(SesVia {+                        net: item_net,+                        padstack: padstack.to_string(),+                        at: [to_mm(x), to_mm(-y)],+                        size: via_sizes.get(padstack).copied().or(dims.map(|d| d.0)),+                        drill: dims.map(|d| d.1),+                    });+                }+                _ => {}+            }+        }+    }+    Ok(session)+}++/// The copper of one net as the applier lays it: `PlanSegment`/`PlanVia` in mm.+#[derive(Clone, Debug, Default, Serialize)]+pub struct NetCopper {+    pub net: String,+    pub segments: Vec<PlanSegment>,+    pub vias: Vec<PlanVia>,+    /// Session items identical to copper already on the board (protected wiring echoed back).+    #[serde(rename = "skippedExisting")]+    pub skipped_existing: usize,+}++fn same_xy(a: [f64; 2], b: [f64; 2]) -> bool {+    (a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4+}++/// Session wires and vias as per-net copper, KiCad frame, deduplicated against the copper+/// the board already had, restricted to `nets` when given. Vias span the outer copper pair+/// (`F.Cu`, `B.Cu`) as `pcb::write_board` requires.+pub fn copper_for(session: &Session, board: &Board, nets: Option<&[String]>, rules: &Rules) -> Vec<NetCopper> {+    let copper = pcb::copper_layers(board);+    let mut out: Vec<NetCopper> = Vec::new();+    fn entry(out: &mut Vec<NetCopper>, net: &str) -> usize {+        match out.iter().position(|c| c.net == net) {+            Some(i) => i,+            None => {+                out.push(NetCopper { net: net.to_string(), ..Default::default() });+                out.len() - 1+            }+        }+    }+    let wanted = |net: &str| !net.is_empty() && board.nets.iter().any(|n| n.name == net) && nets.map(|w| w.iter().any(|x| x == net)).unwrap_or(true);+    let existing_seg = |a: [f64; 2], b: [f64; 2], layer: &str| -> bool {+        board.segments.iter().any(|s| {+            let (Some(sa), Some(sb)) = (&s.start, &s.end) else { return false };+            s.layer.as_deref() == Some(layer) && ((same_xy([sa.x, sa.y], a) && same_xy([sb.x, sb.y], b)) || (same_xy([sa.x, sa.y], b) && same_xy([sb.x, sb.y], a)))+        })+    };+    let existing_via = |at: [f64; 2]| -> bool { board.vias.iter().any(|v| v.position.as_ref().map(|p| same_xy([p.x, p.y], at)).unwrap_or(false)) };+    for w in &session.wires {+        if !wanted(&w.net) || !copper.contains(&w.layer) {+            continue;+        }+        let i = entry(&mut out, &w.net);+        for pair in w.points.windows(2) {+            let (a, b) = (pair[0], pair[1]);+            if same_xy(a, b) {+                continue;+            }+            if existing_seg(a, b, &w.layer) {+                out[i].skipped_existing += 1;+                continue;+            }+            out[i].segments.push(PlanSegment { start: a, end: b, layer: w.layer.clone(), width: w.width });+        }+    }+    for v in &session.vias {+        if !wanted(&v.net) {+            continue;+        }+        let i = entry(&mut out, &v.net);+        if existing_via(v.at) {+            out[i].skipped_existing += 1;+            continue;+        }+        let (_, _, rs, rd) = rules.class_for(&v.net);+        let size = v.size.unwrap_or(rs);+        let mut drill = v.drill.unwrap_or(rd);+        if drill >= size {+            drill = pcb::round6(size / 2.0);+        }+        out[i].vias.push(PlanVia { at: v.at, layers: ["F.Cu".into(), "B.Cu".into()], size, drill });+    }+    out.retain(|c| !c.segments.is_empty() || !c.vias.is_empty());+    out+}++// ---------------------------------------------------------------------------+// Tests+// ---------------------------------------------------------------------------++#[cfg(test)]+mod tests {+    use super::*;+    use crate::pcb::tests::fixture_text;++    fn fixture() -> (String, Board) {+        let text = fixture_text();+        let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();+        (text, board)+    }++    #[test]+    fn fixture_dsn_has_boundary_layers_pins_nets_and_rules() {+        let (text, board) = fixture();+        let rules = Rules::default();+        let dsn = write_dsn(&board, &text, "live-routing", &rules, None).unwrap();+        let t = &dsn.text;+        assert!(t.starts_with("(pcb live-routing.dsn\n  (parser\n    (string_quote \")\n    (space_in_quoted_tokens on)\n    (host_cad \"KiCad's Pcbnew\")\n    (host_version \"10.0\")"), "{t}");+        assert!(t.contains("(resolution um 10)"));+        assert!(t.contains("(layer F.Cu\n      (type signal)\n      (property\n        (index 0)"));+        assert!(t.contains("(layer B.Cu\n      (type signal)\n      (property\n        (index 1)"));+        // Edge.Cuts rect (98,78)-(152,122): y flipped, closed.+        assert!(t.contains("(boundary\n      (path pcb 0 98000 -78000 152000 -78000 152000 -122000 98000 -122000 98000 -78000)"), "{t}");+        assert_eq!(dsn.stats.boundary_source, "Edge.Cuts");+        assert_eq!(dsn.stats.components, 3);+        assert_eq!(dsn.stats.pins, 13);+        assert_eq!(dsn.stats.routed_nets, (1..=6).map(|i| format!("NET_{i}")).collect::<Vec<_>>());+        // 2 mm through-hole circle on both layers, pins in board frame relative to the origin.+        assert!(t.contains("(padstack Round[A]Pad_2000_um\n      (shape (circle F.Cu 2000 0 0))\n      (shape (circle B.Cu 2000 0 0))\n      (attach off)"));+        assert!(t.contains("(image J1\n      (pin Round[A]Pad_2000_um 1 0 0)\n      (pin Round[A]Pad_2000_um 2 0 -5000)"));+        assert!(t.contains("(component J1\n      (place J1 105000 -85000 front 0)"));+        assert!(t.contains("(net NET_6\n      (pins J1-6 J2-6 J3-1)"));+        assert!(t.contains("(via Via[0-1]_800:400_um)"));+        assert!(t.contains("(class kicad_default NET_1 NET_2 NET_3 NET_4 NET_5 NET_6\n      (circuit (use_via Via[0-1]_800:400_um))\n      (rule (width 250) (clearance 200.1))"));+        assert!(!t.contains("(wiring"), "no copper yet, no wiring section");+        assert!(dsn.stats.approximated_pads.is_empty());+        assert_eq!(dsn.via_padstack, "Via[0-1]_800:400_um");+    }++    #[test]+    fn net_subset_leaves_other_pads_as_obstacles_and_keeps_existing_copper() {+        let (text, board) = fixture();+        let net = pcb::resolve_net(&board, Some(&serde_json::json!("NET_1"))).unwrap();+        let seg = pcb::segment_sexpr((105.0, 85.0), (112.0, 85.0), 0.25, "F.Cu", &net);+        let via = pcb::via_sexpr((112.0, 85.0), 0.6, 0.3, ("F.Cu", "B.Cu"), &net);+        let text = pcb::splice(&text, &[seg, via]);+        let board = pcb::parse_pcb_text(&text, "x").unwrap();+        let dsn = write_dsn(&board, &text, "x", &Rules::default(), Some(&["NET_2".into(), "NOPE".into()])).unwrap();+        assert_eq!(dsn.stats.routed_nets, vec!["NET_2"]);+        assert_eq!(dsn.stats.unknown_nets, vec!["NOPE"]);+        assert!(dsn.text.contains("(net NET_2\n      (pins J1-2 J2-2)"));+        assert!(!dsn.text.contains("(net NET_1\n"));+        assert_eq!(dsn.stats.pins, 13, "every pad is still an obstacle");+        assert!(dsn.text.contains("(wire (path F.Cu 250 105000 -85000 112000 -85000) (net NET_1) (type protect))"));+        assert!(dsn.text.contains("(via Via[0-1]_600:300_um 112000 -85000 (net NET_1) (type protect))"));+        assert!(dsn.text.contains("(padstack Via[0-1]_600:300_um\n"));+        assert_eq!((dsn.stats.existing_wires, dsn.stats.existing_vias), (1, 1));+    }++    #[test]+    fn rect_pads_rotate_and_ovals_are_declared_approximate() {+        let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")+  (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+  (gr_line (start 0 0) (end 30 0) (layer "Edge.Cuts"))+  (gr_line (start 30 0) (end 30 20) (layer "Edge.Cuts"))+  (gr_arc (start 30 20) (mid 15 25) (end 0 20) (layer "Edge.Cuts"))+  (gr_line (start 0 20) (end 0 0) (layer "Edge.Cuts"))+  (footprint "X:R1" (layer "F.Cu") (at 10 10 90) (property "Reference" "R1")+    (pad "1" smd rect (at -1 0 90) (size 1 2) (layers "F.Cu" "F.Paste" "F.Mask") (net "A"))+    (pad "2" smd oval (at 1 0 90) (size 1 2) (layers "F.Cu") (net "B")))+  (footprint "X:U1" (layer "B.Cu") (at 20 10) (property "Reference" "U1")+    (pad "1" smd roundrect (at 0 0) (size 1 1) (layers "B.Cu") (net "A"))+    (pad "2" thru_hole circle (at 2 0) (size 1.5 1.5) (drill 0.8) (layers "*.Cu" "*.Mask") (net "B"))+    (pad "" np_thru_hole circle (at 4 0) (size 2 2) (drill 2) (layers "*.Cu" "*.Mask")))+  (zone (net "A") (net_name "A") (layer "B.Cu") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5))))+  (zone (net "") (net_name "") (layers "F.Cu" "B.Cu") (keepout (tracks not_allowed) (vias allowed)) (polygon (pts (xy 20 0) (xy 25 0) (xy 25 5))))+)"#;+        let board = pcb::parse_pcb_text(text, "x").unwrap();+        let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();+        let t = &dsn.text;+        // Pin 1 of R1: local (-1,0) rotated 90 -> (0, 1) in a y-down frame -> DSN y = -1 mm.+        assert!(t.contains("(pin Rect[T]Pad_1000x2000_r90_um 1 0 -1000)"), "{t}");+        assert!(t.contains("(pin Oval[T]Pad_1000x2000_r90_um 2 0 1000)"), "{t}");+        // A 1x2 rect at 90 degrees is a 2x1 polygon: corners at x = +-1 mm, y = +-0.5 mm.+        assert!(t.contains("(padstack Rect[T]Pad_1000x2000_r90_um\n      (shape (polygon F.Cu 0 -1000 -500 -1000 500 1000 500 1000 -500))"), "{t}");+        assert!(t.contains("(pin Round[A]Pad_2000_um NP1 4000 0)"), "an unnamed hole still gets a pin id: {t}");+        assert!(t.contains("(padstack RoundRect[B]Pad_1000x1000_um\n      (shape (polygon B.Cu 0 -500 500 500 500 500 -500 -500 -500))"), "{t}");+        assert!(t.contains("(padstack Round[A]Pad_2000_um\n      (shape (circle F.Cu 2000 0 0))"), "NPTH hole is an obstacle on every layer: {t}");+        assert_eq!(dsn.stats.approximated_pads, vec!["R1.2 (oval)", "U1.1 (roundrect)"]);+        assert!(t.contains("(net A\n      (pins R1-1 U1-1)"));+        assert!(t.contains("(net B\n      (pins R1-2 U1-2)"));+        assert!(t.contains("(plane A (polygon B.Cu 0 0 0 5000 0 5000 -5000))"));+        assert!(t.contains("(keepout \"\" (polygon F.Cu 0 20000 0 25000 0 25000 -5000))"));+        assert!(t.contains("(keepout \"\" (polygon B.Cu 0 20000 0 25000 0 25000 -5000))"));+        assert_eq!((dsn.stats.planes, dsn.stats.keepouts), (1, 2));+        // Three lines and an arc chain into one loop; the arc became segments.+        assert_eq!(dsn.stats.boundary_source, "Edge.Cuts");+        assert!(dsn.stats.boundary_points > 10, "{}", dsn.stats.boundary_points);+        assert!(t.contains("(place U1 20000 -10000 front 0)"));+    }++    #[test]+    fn no_outline_falls_back_to_pads_with_a_note() {+        let text = r#"(kicad_pcb (version 20260206) (layers (0 "F.Cu" signal) (2 "B.Cu" signal))+  (footprint "X:R1" (layer "F.Cu") (at 10 10) (property "Reference" "R1")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "A"))))"#;+        let board = pcb::parse_pcb_text(text, "x").unwrap();+        let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();+        assert!(dsn.stats.boundary_source.starts_with("pad bounding box"));+        assert!(dsn.text.contains("(path pcb 0 5000 -5000 15000 -5000 15000 -15000 5000 -15000 5000 -5000)"), "{}", dsn.text);+        assert_eq!(dsn.stats.notes.len(), 1);+    }++    #[test]+    fn quoting_matches_pcbnew() {+        assert_eq!(q("NET_1"), "NET_1");+        assert_eq!(q("/SDA"), "/SDA");+        assert_eq!(q("Net-(R1-Pad1)"), "\"Net-(R1-Pad1)\"");+        assert_eq!(q("a b"), "\"a b\"");+        assert_eq!(q(""), "\"\"");+        assert_eq!(via_dims("Via[0-1]_800:400_um"), Some((0.8, 0.4)));+        assert_eq!(via_dims("Round[A]Pad_2000_um"), None);+    }++    #[test]+    fn rules_come_from_the_project_file_or_defaults() {+        let dir = std::env::temp_dir().join(format!("adom-dsn-rules-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let (text, board) = fixture();+        let pcb_path = dir.join("b.kicad_pcb");+        std::fs::write(&pcb_path, &text).unwrap();+        let r = rules_for(&pcb_path, &board);+        assert_eq!(r, Rules::default());+        std::fs::write(dir.join("b.kicad_pro"), r#"{+          "board": {"design_settings": {"rules": {"min_clearance": 0.15, "min_track_width": 0.3}}},+          "net_settings": {+            "classes": [+              {"name": "Default", "clearance": 0.1, "track_width": 0.2, "via_diameter": 0.7, "via_drill": 0.35},+              {"name": "Power", "clearance": 0.3, "track_width": 0.5, "via_diameter": 1.0, "via_drill": 0.5},+              {"name": "Unused", "clearance": 0.3, "track_width": 0.5}+            ],+            "netclass_assignments": {"NET_6": "Power"},+            "netclass_patterns": [{"netclass": "Power", "pattern": "NET_5"}]+          }}"#).unwrap();+        let r = rules_for(&pcb_path, &board);+        assert_eq!((r.clearance, r.track_width, r.via_size, r.via_drill), (0.15, 0.3, 0.7, 0.35));+        assert_eq!(r.classes.len(), 1);+        assert_eq!(r.classes[0].name, "Power");+        assert_eq!(r.classes[0].nets, vec!["NET_5", "NET_6"]);+        assert_eq!(r.class_for("NET_6"), (0.3, 0.5, 1.0, 0.5));+        assert_eq!(r.class_for("NET_1"), (0.15, 0.3, 0.7, 0.35));+        let dsn = write_dsn(&board, &text, "b", &r, None).unwrap();+        assert!(dsn.text.contains("(class Power NET_5 NET_6\n      (circuit (use_via Via[0-1]_1000:500_um))\n      (rule (width 500) (clearance 300.1))"), "{}", dsn.text);+        assert!(dsn.text.contains("(class kicad_default NET_1 NET_2 NET_3 NET_4\n"));+        let _ = std::fs::remove_dir_all(&dir);+    }++    const SES: &str = r#"(session "live-routing.ses"+  (base_design "live-routing.dsn")+  (placement+    (resolution um 10)+    (component J1+      (place J1 105000 -85000 front 0)+    )+  )+  (was_is+  )+  (routes+    (resolution um 10)+    (parser+      (string_quote ")+      (space_in_quoted_tokens on)+      (host_cad "Adom KiCad Bridge")+      (host_version "1.0.0")+    )+    (library_out+      (padstack "Via[0-1]_800:400_um"+        (shape+          (circle F.Cu 8000 0 0)+        )+        (shape+          (circle B.Cu 8000 0 0)+        )+        (attach off)+      )+    )+    (network_out+      (net NET_3+        (wire+          (path F.Cu 2500+            1050000 -950000+            1120000 -950000+          )+        )+        (via "Via[0-1]_800:400_um" 1120000 -950000+        )+        (wire+          (path B.Cu 2500+            1120000 -950000+            1380000 -950000+            1380000 -950000+          )+        )+      )+      (net "Net-(R1-Pad1)"+        (wire (path F.Cu 2000 0 0 10000 -10000) (type protect))+      )+    )+  )+)"#;++    #[test]+    fn reads_freerouting_session_wires_and_vias_back_to_mm() {+        let s = read_ses(SES).unwrap();+        assert_eq!((s.unit.as_str(), s.resolution), ("um", 10.0));+        assert_eq!(s.nets, vec!["NET_3", "Net-(R1-Pad1)"]);+        assert_eq!(s.wires.len(), 3);+        assert_eq!(s.wires[0], SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.25, points: vec![[105.0, 95.0], [112.0, 95.0]] });+        assert_eq!(s.wires[1].points, vec![[112.0, 95.0], [138.0, 95.0]], "duplicate point dropped");+        assert_eq!(s.wires[2], SesWire { net: "Net-(R1-Pad1)".into(), layer: "F.Cu".into(), width: 0.2, points: vec![[0.0, 0.0], [1.0, 1.0]] });+        assert_eq!(s.vias, vec![SesVia { net: "NET_3".into(), padstack: "Via[0-1]_800:400_um".into(), at: [112.0, 95.0], size: Some(0.8), drill: Some(0.4) }]);+        let (_, board) = fixture();+        let copper = copper_for(&s, &board, None, &Rules::default());+        assert_eq!(copper.len(), 1, "the unknown net's wire is dropped by the net filter: {copper:?}");+        assert_eq!(copper[0].net, "NET_3");+        assert_eq!(copper[0].segments.len(), 2);+        assert_eq!(copper[0].vias[0].layers, ["F.Cu", "B.Cu"]);+        assert_eq!((copper[0].vias[0].size, copper[0].vias[0].drill), (0.8, 0.4));+        // Copper already on the board is skipped, and a net subset filters.+        let net = pcb::resolve_net(&board, Some(&serde_json::json!("NET_3"))).unwrap();+        let text = pcb::splice(&fixture_text(), &[pcb::segment_sexpr((112.0, 95.0), (105.0, 95.0), 0.25, "F.Cu", &net)]);+        let board2 = pcb::parse_pcb_text(&text, "x").unwrap();+        let copper = copper_for(&s, &board2, Some(&["NET_3".into()]), &Rules::default());+        assert_eq!(copper[0].segments.len(), 1);+        assert_eq!(copper[0].skipped_existing, 1);+        assert!(copper_for(&s, &board2, Some(&["NET_1".into()]), &Rules::default()).is_empty());+    }++    #[test]+    fn specctra_tokenizer_handles_string_quote_and_bad_input() {+        let sx = parse_specctra("(a (string_quote \") (b \"x y\") c)").unwrap();+        assert_eq!(sx.find("string_quote").unwrap().value(), Some("\""));+        assert_eq!(sx.find("b").unwrap().value(), Some("x y"));+        assert!(parse_specctra("(a (b)").is_err());+        assert!(parse_specctra("(a))").is_err());+        assert!(read_ses("(pcb x)").is_err());+        assert!(read_ses("(session x (routes (resolution parsec 1) (network_out)))").is_err());+    }+}
rust/crates/kicad-core/src/freerouting.rsadded+627
@@ -0,0 +1,627 @@+//! The Freerouting engine behind `kicad_freerouting` and `kicad_autoroute {engine:+//! "freerouting"}`. Freerouting 2.4.1 ships its own Java runtime in every platform+//! build, so there is no system Java and no elevation anywhere in here.+//!+//! Contract (John's terms): always offered, installed only when the caller asks+//! (`kicad_freerouting {action:"install"}`), removable on its own (`uninstall` deletes the+//! bridge's Freerouting folder and nothing else), and nothing in this module downloads or+//! runs a process unless the caller asked for an install or a Freerouting route. `status`+//! only looks at the disk.+//!+//! Every process here has a deadline (`cli::run` for the version probe, the reader loop in+//! `run` for the router) and the download is streamed with the size check+//! `install_kicad::stream_download` already does for the KiCad installer.++use std::collections::VecDeque;+use std::io::BufRead;+use std::path::{Path, PathBuf};+use std::process::{Command, Stdio};+use std::sync::mpsc;+use std::time::{Duration, Instant};++use serde_json::{json, Value};++use crate::{cli, dsn, install_kicad, pcb, pcm};+use kicad_platform::native;++pub const VERSION: &str = "2.4.1";+/// Environment override for the executable (tests and boxes with their own copy).+pub const ENV_EXE: &str = "ADOM_FREEROUTING_EXE";+pub const DEFAULT_PASSES: u32 = 20;+pub const DEFAULT_TIMEOUT_SECS: u64 = 600;+/// JVM start plus the help text; generous for a cold Windows box.+const HELP_TIMEOUT: Duration = Duration::from_secs(60);+const TAIL_LINES: usize = 40;++/// The flags every headless run gets: no GUI (or Freerouting warns about the screen+/// resolution and, on a box without a display, may not start), no analytics.+pub const HEADLESS_FLAGS: [&str; 2] = ["--gui.enabled=false", "--usage_and_diagnostic_data.disable_analytics=true"];++/// Where the bundle unpacks to, in decimal MB, per archive kind (measured 2026-09-12:+/// the msi admin install on Windows, the zip on Linux).+pub fn on_disk_mb(kind: &str) -> u64 {+    match kind {+        "msi" => 141,+        "zip" => 163,+        _ => 150,+    }+}++// ---------------------------------------------------------------------------+// Status+// ---------------------------------------------------------------------------++pub fn dir() -> PathBuf {+    native().freerouting_dir()+}++/// The executable: `ADOM_FREEROUTING_EXE` when it points at a file, else the bundle+/// layout under `dir` (Windows `freerouting\freerouting.exe` from the msi admin install,+/// Linux `freerouting-<ver>-linux-x64/bin/freerouting` from the zip).+pub fn find_exe(dir: &Path) -> Option<PathBuf> {+    if let Some(p) = std::env::var_os(ENV_EXE).map(PathBuf::from).filter(|p| p.is_file()) {+        return Some(p);+    }+    let mut candidates = vec![dir.join("freerouting").join("freerouting.exe"), dir.join("freerouting.exe"), dir.join("bin").join("freerouting")];+    if let Ok(rd) = std::fs::read_dir(dir) {+        for e in rd.flatten().filter(|e| e.path().is_dir()) {+            candidates.push(e.path().join("bin").join("freerouting"));+            candidates.push(e.path().join("freerouting.exe"));+        }+    }+    candidates.into_iter().find(|p| p.is_file())+}++/// `freerouting --help`'s first line: `... Freerouting v2.4.1 (build-date: ...)`.+pub fn parse_version(output: &str) -> Option<String> {+    let i = output.find("Freerouting v")?;+    let rest = &output[i + "Freerouting v".len()..];+    let v: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+    if v.is_empty() { None } else { Some(v) }+}++/// Run `--help` with a deadline and read the version it prints. Err when the exe does not+/// start or does not identify itself.+pub fn probe_version(exe: &Path) -> Result<String, String> {+    let r = cli::run(exe, &["--gui.enabled=false", "-h"], HELP_TIMEOUT);+    if r.timed_out {+        return Err(format!("{} did not answer --help within {} s", exe.display(), HELP_TIMEOUT.as_secs()));+    }+    let text = format!("{}\n{}", r.stdout, r.stderr);+    parse_version(&text).ok_or_else(|| format!("{} did not identify itself (exit {}): {}", exe.display(), r.exit_code, text.lines().next().unwrap_or("").chars().take(200).collect::<String>()))+}++fn dir_bytes(p: &Path) -> u64 {+    let Ok(rd) = std::fs::read_dir(p) else { return 0 };+    rd.flatten()+        .map(|e| {+            let path = e.path();+            if path.is_dir() {+                dir_bytes(&path)+            } else {+                e.metadata().map(|m| m.len()).unwrap_or(0)+            }+        })+        .sum()+}++/// Decimal megabytes (what the release page and the user's download dialog show).+fn mb(bytes: u64) -> f64 {+    (bytes as f64 / 1_000_000.0 * 10.0).round() / 10.0+}++pub struct Status {+    pub dir: PathBuf,+    pub exe: Option<PathBuf>,+    pub bundle: Option<(String, u64, &'static str)>,+}++impl Status {+    pub fn installed(&self) -> bool {+        self.exe.is_some()+    }+}++/// Disk facts only: no process runs, nothing downloads.+pub fn status() -> Status {+    let dir = dir();+    Status { exe: find_exe(&dir), bundle: native().freerouting_bundle(), dir }+}++/// The `kicad_freerouting {action:"status"}` reply.+pub fn status_json() -> Value {+    let s = status();+    let mut out = json!({+        "success": true,+        "installed": s.installed(),+        "supported": s.bundle.is_some(),+        "dir": s.dir.to_string_lossy().replace('\\', "/"),+        "exe": s.exe.as_ref().map(|p| p.to_string_lossy().replace('\\', "/")),+        "bundledJava": true,+        "install": {"call": "kicad_freerouting {\"action\":\"install\"}", "elevation": "none", "runsOnlyWhenAsked": true},+        "uninstall": {"call": "kicad_freerouting {\"action\":\"uninstall\"}", "removes": "the bridge's Freerouting folder only"},+    });+    if let Some((url, bytes, kind)) = &s.bundle {+        out["bundle"] = json!({"version": VERSION, "url": url, "bytes": bytes, "downloadMb": mb(*bytes), "onDiskMb": on_disk_mb(kind), "kind": kind});+    } else {+        out["unsupportedReason"] = json!(format!("Freerouting {VERSION} ships a dmg for macOS; the bridge does not drive dmg installs. Install Freerouting yourself and point {ENV_EXE} at the executable, or route with engine ai."));+    }+    if s.installed() {+        out["diskMb"] = json!(mb(dir_bytes(&s.dir)));+        out["version"] = json!(VERSION);+        out["versionSource"] = json!("bundle (kicad_freerouting install verified --help at install time; autoroute reports the live version)");+        out["_hint"] = json!("Freerouting is installed. kicad_autoroute {\"engine\":\"freerouting\",\"filePath\":\"...\"} routes with it; engine ai stays the recommendation for a board whose intent matters. Ask the user which engine they want. uninstall removes the folder.");+    } else if s.bundle.is_some() {+        let (_, bytes, kind) = s.bundle.as_ref().unwrap();+        out["_hint"] = json!(format!("Freerouting is not installed. It is a {} MB download ({} MB on disk, bundled Java, no elevation) that the bridge only fetches when asked: ASK THE USER before calling kicad_freerouting {{\"action\":\"install\"}}. Engine ai needs nothing installed.", mb(*bytes).round() as u64, on_disk_mb(kind)));+    } else {+        out["_hint"] = json!("Freerouting is not available on this OS through the bridge; route with engine ai, or install Freerouting yourself and set ADOM_FREEROUTING_EXE.");+    }+    out+}++/// The `freerouting_not_installed` reply `kicad_autoroute` gives.+pub fn not_installed(s: &Status) -> Value {+    let mut v = json!({+        "success": false, "errorCode": "freerouting_not_installed", "engine": "freerouting", "mutated": false,+        "error": "Freerouting is not installed on this box",+        "install": {"call": "kicad_freerouting {\"action\":\"install\"}", "elevation": "none"},+        "dir": s.dir.to_string_lossy().replace('\\', "/"),+    });+    match &s.bundle {+        Some((url, bytes, kind)) => {+            v["download"] = json!({"url": url, "bytes": bytes, "mb": mb(*bytes).round() as u64, "onDiskMb": on_disk_mb(kind), "kind": kind, "bundledJava": true});+            v["_hint"] = json!(format!(+                "ASK THE USER BEFORE INSTALLING: Freerouting {VERSION} is a {} MB download and {} MB on disk (its own Java runtime, no UAC, into {}). If they say yes, call kicad_freerouting {{\"action\":\"install\"}} (about a minute), then this autoroute call again. If they would rather not install anything, engine ai routes now with kicad_route_net: kicad_autoroute {{\"engine\":\"ai\"}}.",+                mb(*bytes).round() as u64,+                on_disk_mb(kind),+                s.dir.to_string_lossy().replace('\\', "/")+            ));+        }+        None => {+            v["errorCode"] = json!("freerouting_unsupported_platform");+            v["_hint"] = json!(format!("The bridge has no Freerouting bundle for this OS (macOS ships a dmg). Install Freerouting yourself and set {ENV_EXE} to the executable, or route with engine ai."));+        }+    }+    v+}++// ---------------------------------------------------------------------------+// Install and uninstall+// ---------------------------------------------------------------------------++fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {+    json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})+}++fn log(msg: &str) {+    eprintln!("[kicad-bridge] freerouting: {msg}");+}++/// Unpack a zip (the std-only reader from `pcm`) under `dest`, zip-slip guarded.+pub fn unzip_to(archive: &Path, dest: &Path) -> Result<(), String> {+    let bytes = std::fs::read(archive).map_err(|e| format!("cannot read {}: {e}", archive.display()))?;+    let zf = pcm::ZipFile::parse(&bytes)?;+    for entry in &zf.entries {+        let name = entry.name.replace('\\', "/");+        if name.is_empty() || name.starts_with('/') || name.split('/').any(|seg| seg == "..") {+            return Err(format!("unsafe zip entry: {}", entry.name));+        }+        let target = dest.join(&name);+        if name.ends_with('/') {+            std::fs::create_dir_all(&target).map_err(|e| format!("cannot create {}: {e}", target.display()))?;+            continue;+        }+        if let Some(parent) = target.parent() {+            std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;+        }+        let data = zf.read(entry)?;+        std::fs::write(&target, data).map_err(|e| format!("cannot write {}: {e}", target.display()))?;+    }+    Ok(())+}++/// Extract an already downloaded bundle into `dest` and return the executable. The OS+/// layer decides how (msiexec /a on Windows, the zip reader plus chmod on Linux).+pub fn extract_bundle(archive: &Path, dest: &Path) -> Result<PathBuf, String> {+    native().freerouting_extract(archive, dest, &unzip_to)+}++/// Download, extract, verify. Runs ONLY from `kicad_freerouting {action:"install"}`.+/// `force`: re-download even when an install already answers `--help`.+pub fn install(force: bool, mut progress: impl FnMut(&str, Option<i64>)) -> Value {+    let started = Instant::now();+    let s = status();+    let Some((url, bytes, kind)) = s.bundle.clone() else {+        let mut v = not_installed(&s);+        v["errorCode"] = json!("freerouting_unsupported_platform");+        return v;+    };+    if !force {+        if let Some(exe) = &s.exe {+            if let Ok(v) = probe_version(exe) {+                return json!({+                    "success": true, "installed": true, "alreadyInstalled": true, "version": v,+                    "exe": exe.to_string_lossy().replace('\\', "/"), "dir": s.dir.to_string_lossy().replace('\\', "/"),+                    "diskMb": mb(dir_bytes(&s.dir)),+                    "_hint": "Already installed and answering --help; nothing downloaded. Pass force:true to re-download. kicad_autoroute {\"engine\":\"freerouting\"} routes with it.",+                });+            }+        }+    }+    let dir = s.dir.clone();+    if let Err(e) = std::fs::create_dir_all(&dir) {+        return fail("install_failed", format!("cannot create {}: {e}", dir.display()), "Check that the folder is writable; nothing was downloaded.");+    }+    // A clean slate: a half-extracted tree or an older bundle would confuse find_exe.+    for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {+        let p = e.path();+        let _ = if p.is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) };+    }+    let archive = dir.join(format!("download.{kind}"));+    let mut timings = json!({});+    // ---- download (streamed to download.<kind>.part, renamed on completion, size checked)+    progress(&format!("downloading Freerouting {VERSION} ({} MB)", mb(bytes).round() as u64), Some(5));+    log(&format!("downloading {url} -> {}", archive.display()));+    let t = Instant::now();+    let total = match install_kicad::stream_download(&url, &archive, Some(bytes)) {+        Ok((total, _)) => total,+        Err(e) => {+            let _ = std::fs::remove_file(&archive);+            return fail("download_failed", format!("download failed: {e}"), "Nothing was installed. Check the box's internet access and retry kicad_freerouting {\"action\":\"install\"}; engine ai needs no download.");+        }+    };+    timings["download"] = json!((t.elapsed().as_secs_f64() * 100.0).round() / 100.0);+    if total != bytes {+        let _ = std::fs::remove_file(&archive);+        let mut v = fail("download_truncated", format!("download truncated: got {total} of {bytes} bytes; the file was deleted"), "Retry kicad_freerouting {\"action\":\"install\"}. A repeat means a flaky link or a changed release asset.");+        v["timings"] = timings;+        return v;+    }+    // ---- extract+    progress("extracting", Some(60));+    log(&format!("extracting {} ({kind}) into {}", archive.display(), dir.display()));+    let t = Instant::now();+    let exe = match extract_bundle(&archive, &dir) {+        Ok(p) => p,+        Err(e) => {+            let _ = std::fs::remove_file(&archive);+            let mut v = fail("extract_failed", format!("extraction failed: {e}"), "The archive was deleted. Retry the install; if it repeats, the release asset layout changed and the bridge needs an update.");+            v["timings"] = timings;+            return v;+        }+    };+    timings["extract"] = json!((t.elapsed().as_secs_f64() * 100.0).round() / 100.0);+    let _ = std::fs::remove_file(&archive);+    // ---- verify+    progress("verifying (--help)", Some(90));+    let t = Instant::now();+    let version = match probe_version(&exe) {+        Ok(v) => v,+        Err(e) => {+            let mut v = fail("verify_failed", format!("Freerouting was extracted but does not start: {e}"), "The folder is left in place for inspection; kicad_freerouting {\"action\":\"uninstall\"} removes it. Engine ai routes without it.");+            v["exe"] = json!(exe.to_string_lossy().replace('\\', "/"));+            v["timings"] = timings;+            return v;+        }+    };+    timings["verify"] = json!((t.elapsed().as_secs_f64() * 100.0).round() / 100.0);+    timings["total"] = json!((started.elapsed().as_secs_f64() * 100.0).round() / 100.0);+    let disk = mb(dir_bytes(&dir));+    log(&format!("installed Freerouting {version} at {} ({disk} MB) in {} s", exe.display(), timings["total"]));+    json!({+        "success": true, "installed": true, "version": version,+        "exe": exe.to_string_lossy().replace('\\', "/"), "dir": dir.to_string_lossy().replace('\\', "/"),+        "downloadedBytes": total, "diskMb": disk, "kind": kind, "timings": timings,+        "_hint": "Freerouting is installed and answered --help. kicad_autoroute {\"engine\":\"freerouting\",\"filePath\":\"...\"} routes with it; kicad_freerouting {\"action\":\"uninstall\"} removes it again. Report to the user what was installed and where.",+    })+}++/// Remove the bridge's Freerouting folder and nothing else.+pub fn uninstall() -> Value {+    let dir = dir();+    if !dir.exists() {+        return json!({"success": true, "removed": false, "alreadyClean": true, "dir": dir.to_string_lossy().replace('\\', "/"), "_hint": "Nothing to remove: Freerouting was not installed by the bridge."});+    }+    let size = mb(dir_bytes(&dir));+    match std::fs::remove_dir_all(&dir) {+        Ok(()) => json!({"success": true, "removed": true, "dir": dir.to_string_lossy().replace('\\', "/"), "freedMb": size, "_hint": "Freerouting removed (the bridge's folder only). kicad_freerouting {\"action\":\"install\"} brings it back when the user asks; engine ai needs nothing."}),+        Err(e) => {+            let mut v = fail("uninstall_failed", format!("could not remove {}: {e}", dir.display()), "A Freerouting process may still be running (a route in flight); wait for it and retry.");+            v["dir"] = json!(dir.to_string_lossy().replace('\\', "/"));+            v+        }+    }+}++// ---------------------------------------------------------------------------+// Run+// ---------------------------------------------------------------------------++pub struct RunOutcome {+    pub passes_seen: u32,+    /// From the last `... (N unrouted and M violations)` line Freerouting printed.+    pub unrouted: Option<u32>,+    pub violations: Option<u32>,+    pub seconds: f64,+    pub stdout_tail: Vec<String>,+    pub exit_code: i32,+    pub timed_out: bool,+    pub version: Option<String>,+    pub args: Vec<String>,+}++/// `... pass 3 ...`, `Pass #3`, `pass 3 of 20`: the first integer after the word "pass".+pub fn parse_pass(line: &str) -> Option<u32> {+    let lower = line.to_ascii_lowercase();+    let i = lower.find("pass")?;+    let rest = &lower[i + 4..];+    let rest = rest.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '\n').trim_start();+    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+    if digits.is_empty() || !rest.starts_with(|c: char| c.is_ascii_digit()) {+        return None;+    }+    // Ignore "passed", "bypass" and friends: the word must end right after "pass".+    let after = &lower[i + 4..i + 5.min(lower.len() - i)];+    if after.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {+        return None;+    }+    digits.parse().ok()+}++/// `... final score: 999.99 (0 unrouted and 0 violations)` -> (0, 0).+pub fn parse_score(line: &str) -> Option<(u32, u32)> {+    let i = line.rfind(" unrouted and ")?;+    let before = &line[..i];+    let unrouted: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect::<Vec<_>>().into_iter().rev().collect();+    let after = &line[i + " unrouted and ".len()..];+    let violations: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();+    Some((unrouted.parse().ok()?, violations.parse().ok()?))+}++/// Run Freerouting headless: `-de <dsn> -do <ses> -mp <passes> [-mt <threads>]` plus the+/// headless flags, working directory `work`, killed at `timeout`. `on_line` sees every+/// stdout line as it arrives (the caller turns pass lines into progress).+pub fn run(exe: &Path, dsn_path: &Path, ses_path: &Path, work: &Path, passes: u32, threads: Option<u32>, timeout: Duration, mut on_line: impl FnMut(&str)) -> Result<RunOutcome, String> {+    let mut args: Vec<String> = HEADLESS_FLAGS.iter().map(|s| s.to_string()).collect();+    args.extend(["-de".into(), dsn_path.to_string_lossy().to_string(), "-do".into(), ses_path.to_string_lossy().to_string(), "-mp".into(), passes.to_string()]);+    if let Some(t) = threads {+        args.extend(["-mt".into(), t.to_string()]);+    }+    let mut cmd = Command::new(exe);+    cmd.args(&args).current_dir(work);+    cli::quiet(&mut cmd);+    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());+    let t0 = Instant::now();+    let mut child = cmd.spawn().map_err(|e| format!("cannot start {}: {e}", exe.display()))?;+    let (tx, rx) = mpsc::channel::<String>();+    let mut readers = Vec::new();+    if let Some(out) = child.stdout.take() {+        let tx = tx.clone();+        readers.push(std::thread::spawn(move || {+            for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {+                if tx.send(line).is_err() {+                    break;+                }+            }+        }));+    }+    if let Some(err) = child.stderr.take() {+        let tx = tx.clone();+        readers.push(std::thread::spawn(move || {+            for line in std::io::BufReader::new(err).lines().map_while(Result::ok) {+                if tx.send(format!("[stderr] {line}")).is_err() {+                    break;+                }+            }+        }));+    }+    drop(tx);+    let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_LINES);+    let mut passes_seen = 0u32;+    let mut version = None;+    let mut score: Option<(u32, u32)> = None;+    let mut push = |line: String, tail: &mut VecDeque<String>| {+        if version.is_none() {+            version = parse_version(&line);+        }+        if let Some(p) = parse_pass(&line) {+            passes_seen = passes_seen.max(p);+        }+        if let Some(sc) = parse_score(&line) {+            score = Some(sc);+        }+        on_line(&line);+        if tail.len() == TAIL_LINES {+            tail.pop_front();+        }+        tail.push_back(line);+    };+    let exit_code;+    let mut timed_out = false;+    loop {+        match rx.recv_timeout(Duration::from_millis(100)) {+            Ok(line) => push(line, &mut tail),+            Err(mpsc::RecvTimeoutError::Disconnected) => {+                // Both pipes closed: the process is done or about to be.+                match child.wait() {+                    Ok(st) => exit_code = st.code().unwrap_or(-1),+                    Err(e) => return Err(format!("waiting on Freerouting failed: {e}")),+                }+                break;+            }+            Err(mpsc::RecvTimeoutError::Timeout) => {}+        }+        if let Ok(Some(st)) = child.try_wait() {+            exit_code = st.code().unwrap_or(-1);+            // Drain what the readers still hold.+            while let Ok(line) = rx.recv_timeout(Duration::from_millis(200)) {+                push(line, &mut tail);+            }+            break;+        }+        if t0.elapsed() > timeout {+            let _ = child.kill();+            let _ = child.wait();+            timed_out = true;+            exit_code = -2;+            break;+        }+    }+    for r in readers {+        let _ = r.join();+    }+    Ok(RunOutcome { passes_seen, unrouted: score.map(|s| s.0), violations: score.map(|s| s.1), seconds: (t0.elapsed().as_secs_f64() * 100.0).round() / 100.0, stdout_tail: tail.into_iter().collect(), exit_code, timed_out, version, args })+}++// ---------------------------------------------------------------------------+// Apply and check+// ---------------------------------------------------------------------------++/// Splice the session's copper into the closed board file (backup `.adom-bak`), the way+/// `kicad_route` writes. Returns (backup path, segments, vias).+pub fn apply_to_file(loaded: &pcb::Loaded, copper: &[dsn::NetCopper], backup: bool) -> Result<(Option<String>, usize, usize), Value> {+    let board = &loaded.board;+    let mut blocks = Vec::new();+    let (mut segs, mut vias) = (0usize, 0usize);+    for c in copper {+        let net = pcb::resolve_net(board, Some(&json!(c.net)))?;+        for s in &c.segments {+            blocks.push(pcb::segment_sexpr((s.start[0], s.start[1]), (s.end[0], s.end[1]), s.width, &s.layer, &net));+            segs += 1;+        }+        for v in &c.vias {+            blocks.push(pcb::via_sexpr((v.at[0], v.at[1]), v.size, v.drill, (&v.layers[0], &v.layers[1]), &net));+            vias += 1;+        }+    }+    if blocks.is_empty() {+        return Ok((None, 0, 0));+    }+    let bak = pcb::write_board(&loaded.path, &pcb::splice(&loaded.text, &blocks), backup, Some(&loaded.text))?;+    Ok((bak, segs, vias))+}++/// KiCad's verdict on the file through kicad-cli when one is installed (the same flags+/// `kicad_run_drc` uses); `{"available": false}` otherwise.+pub fn drc_file(kicad_cli: Option<&Path>, board: &Path) -> Value {+    let Some(exe) = kicad_cli.filter(|p| p.is_file()) else {+        return json!({"available": false, "reason": "no local kicad-cli", "_hint": "Run kicad_run_drc through the service backend, or open the board and call kicad_routing_validate."});+    };+    let report = std::env::temp_dir().join(format!("adom-freerouting-drc-{}-{}.json", std::process::id(), crate::schematic::new_uuid()));+    let report_s = report.to_string_lossy().to_string();+    let board_s = board.to_string_lossy().to_string();+    let r = cli::run(exe, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--output", &report_s, &board_s], Duration::from_secs(180));+    let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());+    let _ = std::fs::remove_file(&report);+    match parsed {+        Some(rep) => {+            let mut out = cli::summarize_report(&rep);+            out["available"] = json!(true);+            out["elapsedMs"] = json!(r.elapsed_ms);+            out+        }+        None => json!({"available": false, "reason": if r.timed_out { "kicad-cli DRC timed out".to_string() } else { format!("kicad-cli exit {}: {}", r.exit_code, r.stderr.trim().chars().take(300).collect::<String>()) }}),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn version_and_pass_lines_parse() {+        assert_eq!(parse_version("2026-09-12 13:09:22.106 INFO   Freerouting v2.4.1 (build-date: 2026-09-03)"), Some("2.4.1".into()));+        assert_eq!(parse_version("nothing here"), None);+        assert_eq!(parse_pass("INFO  Starting auto-router pass 3 of 20 on board x"), Some(3));+        assert_eq!(parse_pass("Pass #12: 4 items routed"), Some(12));+        assert_eq!(parse_pass("Autorouter pass 7"), Some(7));+        assert_eq!(parse_pass("all tests passed 5 times"), None);+        assert_eq!(parse_pass("bypass 3"), Some(3), "a prefixed word is tolerated");+        assert_eq!(parse_pass("no number after pass"), None);+        assert_eq!(parse_pass("routing finished"), None);+        assert_eq!(parse_score("INFO  [x] Auto-routing stage completed: started with 7 unrouted nets, completed in 0.40 seconds, final score: 999.99 (0 unrouted and 0 violations), using"), Some((0, 0)));+        assert_eq!(parse_score("Auto-routing pass #4 on board 'x' was completed in 1.2 seconds with score 812.5 (3 unrouted and 1 violations)."), Some((3, 1)));+        assert_eq!(parse_score("no score here"), None);+    }++    #[test]+    fn status_never_runs_anything_and_reports_the_bundle() {+        let v = status_json();+        assert!(v["success"].as_bool().unwrap());+        assert!(v["installed"].is_boolean());+        assert!(v["install"]["call"].as_str().unwrap().contains("kicad_freerouting"));+        if cfg!(target_os = "macos") {+            assert_eq!(v["supported"], false);+        } else {+            assert_eq!(v["supported"], true);+            assert_eq!(v["bundle"]["version"], VERSION);+            assert!(v["bundle"]["url"].as_str().unwrap().starts_with("https://github.com/freerouting/freerouting/releases/download/v2.4.1/"));+        }+        let s = Status { dir: PathBuf::from("/x"), exe: None, bundle: Some(("u".into(), 88_235_097, "msi")) };+        let n = not_installed(&s);+        assert_eq!(n["errorCode"], "freerouting_not_installed");+        assert_eq!(n["download"]["mb"], 88);+        assert_eq!(n["download"]["onDiskMb"], 141);+        assert!(n["_hint"].as_str().unwrap().starts_with("ASK THE USER BEFORE INSTALLING"));+        assert!(n["_hint"].as_str().unwrap().contains("kicad_freerouting {\"action\":\"install\"}"));+    }++    #[test]+    fn find_exe_sees_the_bundle_layouts() {+        let dir = std::env::temp_dir().join(format!("adom-fr-find-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        assert!(find_exe(&dir).is_none());+        let linux = dir.join("freerouting-2.4.1-linux-x64").join("bin");+        std::fs::create_dir_all(&linux).unwrap();+        std::fs::write(linux.join("freerouting"), "x").unwrap();+        assert_eq!(find_exe(&dir).unwrap(), linux.join("freerouting"));+        let win = dir.join("freerouting");+        std::fs::create_dir_all(&win).unwrap();+        std::fs::write(win.join("freerouting.exe"), "x").unwrap();+        assert_eq!(find_exe(&dir).unwrap(), win.join("freerouting.exe"));+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn unzip_refuses_zip_slip() {+        let dir = std::env::temp_dir().join(format!("adom-fr-zip-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        // A stored zip with one entry named ../evil.+        let name = b"../evil";+        let data = b"x";+        let mut z: Vec<u8> = Vec::new();+        z.extend_from_slice(b"PK\x03\x04");+        z.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);+        z.extend_from_slice(&[0, 0, 0, 0]); // crc+        z.extend_from_slice(&(data.len() as u32).to_le_bytes());+        z.extend_from_slice(&(data.len() as u32).to_le_bytes());+        z.extend_from_slice(&(name.len() as u16).to_le_bytes());+        z.extend_from_slice(&[0, 0]);+        z.extend_from_slice(name);+        z.extend_from_slice(data);+        let cd = z.len() as u32;+        z.extend_from_slice(b"PK\x01\x02");+        z.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);+        z.extend_from_slice(&[0, 0, 0, 0]);+        z.extend_from_slice(&(data.len() as u32).to_le_bytes());+        z.extend_from_slice(&(data.len() as u32).to_le_bytes());+        z.extend_from_slice(&(name.len() as u16).to_le_bytes());+        z.extend_from_slice(&[0; 8]);+        z.extend_from_slice(&[0; 4]);+        z.extend_from_slice(&0u32.to_le_bytes());+        z.extend_from_slice(name);+        let cd_len = z.len() as u32 - cd;+        z.extend_from_slice(b"PK\x05\x06");+        z.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]);+        z.extend_from_slice(&cd_len.to_le_bytes());+        z.extend_from_slice(&cd.to_le_bytes());+        z.extend_from_slice(&[0, 0]);+        let archive = dir.join("evil.zip");+        std::fs::write(&archive, &z).unwrap();+        let err = unzip_to(&archive, &dir.join("out")).unwrap_err();+        assert!(err.contains("unsafe zip entry"), "{err}");+        assert!(!dir.join("evil").exists());+        let _ = std::fs::remove_dir_all(&dir);+    }+}
rust/crates/kicad-core/src/ipc.rs+34
@@ -672,6 +672,40 @@ pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {     Ok(result) } +/// Commit copper the caller already planned (a Freerouting session's wires and vias for+/// one net) as ONE native Undo step named `message`, with the same revision guard as+/// `route_net` (`expectedRevision` must match the live board). No DRC preflight: the+/// caller checks the whole result afterwards with `routing_validate`. Used by+/// `kicad_autoroute {engine:"freerouting", apply:"ipc"}`; `route_net` is untouched.+pub fn commit_copper(ctx: &Ctx, args: &Value, plan: &Plan, message: &str) -> RResult<Value> {+    let s = connect(ctx, args)?;+    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, message, || {+        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", "netName": plan.net_name,+        "itemIds": ids, "undoSteps": 1, "undoLabel": message,+        "segmentCount": plan.segments.len(), "viaCount": plan.vias.len(), "saved": false,+    });+    match s.revision() {+        Ok(r) => result["revision"] = json!(r),+        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)?;
rust/crates/kicad-core/src/lib.rs+2
@@ -23,3 +23,5 @@ pub mod windows_model; pub mod progress; pub mod install_kicad; pub mod demo;+pub mod dsn;+pub mod freerouting;