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