← Commit history

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

John Lauer ·471d9cff52 ·26d ago ·parent b691a93
6 files changed +353−2
rust/crates/kicad-core/src/uninstall.rs+1
@@ -160,6 +160,7 @@ pub fn handle(installs: &[crate::detect::KicadInstall], args: &Value) -> Value {         r.rm_file(&f, "plugin discovery file");     }     r.rm_tree(Path::new("C:/tmp/adom-desktop-screenshots"), "screenshot cache");+    r.rm_tree(&plat.freerouting_dir(), "Freerouting engine (kicad_freerouting install)");     if let Some(docs) = plat.documents_dir() {         r.rm_tree(&docs.join("adom-kicad-demo"), "demo project");         r.rm_tree(&docs.join("adom-part-exports"), "one-off part exports");
rust/crates/kicad-core/tests/freerouting_route.rsadded+177
@@ -0,0 +1,177 @@+//! Freerouting end to end on the six-net fixture from `demo/routing/make_fixture.py`+//! (rebuilt here from the blank template, as tests/routing_file.rs does): board -> DSN+//! (`dsn::write_dsn`) -> Freerouting headless (`freerouting::run`) -> SES (`dsn::read_ses`)+//! -> copper applied to a copy of the board (`freerouting::apply_to_file`) -> a real KiCad+//! 10 DRC through `service-kicad` that must report zero errors and zero unconnected items+//! (the three `lib_footprint_issues` warnings are the deliberately uninstalled fixture+//! library). Skipped, loudly, when no Freerouting executable is around: the+//! `ADOM_FREEROUTING_EXE` override, the bridge's own install folder, or the scratchpad+//! bundle unpacked while this engine was built.++use std::path::{Path, PathBuf};+use std::process::Command;+use std::time::Duration;++use serde_json::Value;++use kicad_core::schematic::new_uuid;+use kicad_core::{dsn, freerouting, pcb};++const SCRATCH_BUNDLE: &str = "/tmp/claude-1001/-home-adom-project/a82462cd-99a1-4a08-b142-383081e6ec8a/scratchpad/fr/freerouting-2.4.1-linux-x64/bin/freerouting";++fn repo_file(rel: &str) -> PathBuf {+    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+}++fn temp_dir(tag: &str) -> PathBuf {+    let d = std::env::temp_dir().join(format!("kicad-core-freerouting-{tag}-{}-{}", std::process::id(), new_uuid()));+    std::fs::create_dir_all(&d).unwrap();+    d+}++fn on_path(name: &str) -> Option<PathBuf> {+    let path = std::env::var_os("PATH")?;+    std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())+}++fn freerouting_exe() -> Option<PathBuf> {+    if let Some(p) = std::env::var_os(freerouting::ENV_EXE).map(PathBuf::from).filter(|p| p.is_file()) {+        return Some(p);+    }+    if let Some(p) = freerouting::find_exe(&freerouting::dir()) {+        return Some(p);+    }+    Some(PathBuf::from(SCRATCH_BUNDLE)).filter(|p| p.is_file())+}++/// `demo/routing/make_fixture.py`: J1 and J2 with six pads each on NET_1..NET_6, J3 with+/// one pad on NET_6, an Edge.Cuts rectangle, KiCad 10 dialect.+fn fixture_text() -> String {+    let base = std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).expect("blank-board template");+    let mut blocks = String::new();+    let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {+        blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));+        blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));+        blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");+        for (n, px, py, net) in pads {+            blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));+        }+        blocks.push_str(")\n");+    };+    let six = || (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();+    footprint("J1", 105.0, 85.0, six());+    footprint("J2", 145.0, 85.0, six());+    footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);+    blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));+    let idx = base.rfind(')').unwrap();+    format!("{}{}\n)\n", &base[..idx], blocks)+}++fn drc(svc: &Path, board: &Path, report: &Path) -> Value {+    let out = Command::new(svc).args(["pcb", "drc"]).arg(board).args(["--format", "json", "--out"]).arg(report).output().expect("service-kicad runs");+    assert!(report.is_file(), "service-kicad pcb drc wrote no report: {}\n{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));+    let rep: Value = serde_json::from_str(&std::fs::read_to_string(report).unwrap()).expect("DRC report is JSON");+    assert!(rep.get("kicad_version").is_some(), "not a DRC report: {rep}");+    rep+}++#[test]+fn freerouting_routes_the_six_net_fixture_to_a_clean_drc() {+    let Some(exe) = freerouting_exe() else {+        eprintln!("SKIPPED: no Freerouting executable ({} unset, nothing under {}, no scratchpad bundle at {SCRATCH_BUNDLE})", freerouting::ENV_EXE, freerouting::dir().display());+        return;+    };+    let dir = temp_dir("route");+    eprintln!("work dir {}", dir.display());+    let path = dir.join("live-routing.kicad_pcb");+    std::fs::write(&path, fixture_text()).unwrap();+    let loaded = pcb::load(&serde_json::json!({"filePath": path.to_string_lossy()}), false).unwrap();+    let board = &loaded.board;+    assert_eq!(pcb::pads(board).len(), 13);++    // ---- DSN+    let rules = dsn::rules_for(&path, board);+    assert_eq!(rules.source, "kicad-defaults");+    let out = dsn::write_dsn(board, &loaded.text, "live-routing", &rules, None).unwrap();+    assert_eq!(out.stats.routed_nets.len(), 6);+    let dsn_path = dir.join("live-routing.dsn");+    std::fs::write(&dsn_path, &out.text).unwrap();++    // ---- Freerouting+    let version = freerouting::probe_version(&exe).expect("--help answers");+    assert!(version.starts_with("2."), "{version}");+    let ses_path = dir.join("live-routing.ses");+    let mut lines = 0usize;+    let run = freerouting::run(&exe, &dsn_path, &ses_path, &dir, 20, Some(2), Duration::from_secs(300), |line| {+        lines += 1;+        if lines <= 60 {+            eprintln!("  fr> {line}");+        }+    })+    .expect("Freerouting starts");+    eprintln!("Freerouting {} exit {} timedOut {} passes {} seconds {} ({} log lines)", run.version.as_deref().unwrap_or("?"), run.exit_code, run.timed_out, run.passes_seen, run.seconds, lines);+    for l in &run.stdout_tail {+        eprintln!("  tail> {l}");+    }+    assert!(!run.timed_out, "Freerouting hit the deadline");+    assert!(ses_path.is_file(), "Freerouting wrote no session file (exit {})", run.exit_code);+    assert_eq!(run.version.as_deref(), Some(version.as_str()));++    // ---- SES -> copper -> file+    let ses_text = std::fs::read_to_string(&ses_path).unwrap();+    let session = dsn::read_ses(&ses_text).expect("session parses");+    let copper = dsn::copper_for(&session, board, None, &rules);+    let mut routed: Vec<&str> = copper.iter().map(|c| c.net.as_str()).collect();+    routed.sort();+    assert_eq!(routed, ["NET_1", "NET_2", "NET_3", "NET_4", "NET_5", "NET_6"], "every net got copper: {:?}", session.nets);+    let (bak, segs, vias) = freerouting::apply_to_file(&loaded, &copper, true).expect("write accepted");+    eprintln!("applied {segs} segments and {vias} vias; backup {:?}", bak);+    assert!(segs >= 6, "at least one segment per net");+    assert!(bak.as_ref().map(|b| Path::new(b).is_file()).unwrap_or(false));+    let after = pcb::parse_pcb(&path).unwrap();+    assert_eq!(after.segments.len(), segs);+    assert_eq!(after.vias.len(), vias);+    assert_eq!(pcb::pads(&after), pcb::pads(board), "the splice never touches footprints");+    for v in &after.vias {+        assert_eq!(v.layers.as_ref().unwrap(), &["F.Cu", "B.Cu"]);+        assert!(v.drill.unwrap() < v.size.unwrap());+    }++    // ---- KiCad's verdict+    let Some(svc) = on_path("service-kicad") else {+        eprintln!("service-kicad not on PATH; copper applied but not DRC-checked here");+        return;+    };+    let report = drc(&svc, &path, &dir.join("r.json"));+    let result = kicad_core::cli::summarize_report(&report);+    eprintln!("service-kicad DRC on the Freerouting result: {} error(s), {} warning(s), {} unconnected; kicad {}", result["errors"], result["warnings"], result["unconnected"], report["kicad_version"]);+    for v in result["first"].as_array().unwrap() {+        eprintln!("  {} {}: {}", v["severity"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["description"].as_str().unwrap_or(""));+    }+    assert_eq!(result["unconnected"], 0, "every net is connected by the router's copper");+    assert_eq!(result["errors"], 0, "no DRC errors: {}", result["first"]);+    let types: Vec<&str> = result["first"].as_array().unwrap().iter().filter_map(|v| v["type"].as_str()).collect();+    assert!(types.iter().all(|t| *t == "lib_footprint_issues"), "only the fixture library warnings expected: {types:?}");+    eprintln!("RESULT passes={} seconds={} segments={segs} vias={vias} drcErrors=0 unconnected=0 warnings={}", run.passes_seen, run.seconds, result["warnings"]);+    let _ = std::fs::remove_dir_all(&dir);+}++/// The Linux install path without the network: `ADOM_FREEROUTING_ZIP` names a local copy+/// of the release zip; the std-only zip reader unpacks it, the platform layer fixes the+/// modes, and the launcher answers `--help`. Skipped when the variable is unset.+#[test]+fn extracts_a_local_bundle_and_verifies_it() {+    let Some(zip) = std::env::var_os("ADOM_FREEROUTING_ZIP").map(PathBuf::from).filter(|p| p.is_file()) else {+        eprintln!("SKIPPED: ADOM_FREEROUTING_ZIP unset");+        return;+    };+    let dir = temp_dir("extract");+    let t = std::time::Instant::now();+    let exe = freerouting::extract_bundle(&zip, &dir).expect("extract");+    eprintln!("extracted to {} in {:.1} s", exe.display(), t.elapsed().as_secs_f64());+    assert!(exe.is_file());+    let v = freerouting::probe_version(&exe).expect("--help answers");+    eprintln!("Freerouting {v}");+    assert_eq!(v, freerouting::VERSION);+    let _ = std::fs::remove_dir_all(&dir);+}
rust/crates/kicad-platform/src/lib.rs+18
@@ -250,6 +250,24 @@ pub trait Platform: Sync + Send {     /// Play an audio file without a window (narration). Returns a handle for tour_audio_stop.     fn tour_audio_play(&self, _path: &Path, _volume: f32) -> Result<u64, String> { nope("tour_audio_play") }     fn tour_audio_stop(&self, _handle: u64) -> Result<(), String> { nope("tour_audio_stop") }++    // ---- Freerouting (kicad_autoroute engine "freerouting"). The core orchestrates+    // (status, download, verify, run, apply); the OS file supplies the release asset,+    // how it unpacks, and where it lives. Nothing here runs unless the core was asked.++    /// The Freerouting 2.4.1 release asset for this OS as (url, exact byte size, kind),+    /// kind "msi" (Windows) or "zip" (Linux). None where the bridge has no non-interactive+    /// path (macOS ships a dmg). Every build bundles its own Java runtime.+    fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> { None }+    /// Unpack a downloaded bundle into `dest` and return the executable. `unzip` is the+    /// core's std-only zip reader (extract archive into a directory) for the zip kinds;+    /// Windows runs `msiexec /a` instead. Must finish within a deadline of its own.+    fn freerouting_extract(&self, _archive: &Path, _dest: &Path, _unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> { nope("freerouting_extract") }+    /// Where the bridge keeps Freerouting: `%LOCALAPPDATA%\Adom Bridge\freerouting` on+    /// Windows, `~/.adom/freerouting` elsewhere (a temp dir when HOME is unset).+    fn freerouting_dir(&self) -> PathBuf {+        std::env::var_os("HOME").map(PathBuf::from).filter(|h| !h.as_os_str().is_empty()).unwrap_or_else(std::env::temp_dir).join(".adom").join("freerouting")+    } }  /// Standard "this build cannot do that here" reply.
rust/crates/kicad-platform/src/linux.rs+39−1
@@ -3,7 +3,7 @@ //! Window control on Linux depends on the session (X11 versus Wayland portals); add it //! here in phase 3 and flip the capability flags. -use std::path::PathBuf;+use std::path::{Path, PathBuf}; use std::process::Command;  use serde_json::{json, Value};@@ -106,4 +106,42 @@ impl Platform for Native {     fn installer_download_url(&self, _version: &str) -> Option<String> {         None     }++    // ---- Freerouting: the linux-x64 zip (bundled Java). Size verified 2026-09-12.++    fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> {+        Some(("https://github.com/freerouting/freerouting/releases/download/v2.4.1/freerouting-2.4.1-linux-x64.zip".into(), 91_927_019, "zip"))+    }+    fn freerouting_extract(&self, archive: &Path, dest: &Path, unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> {+        unzip(archive, dest)?;+        // The zip carries unix modes the std-only reader does not restore: the launcher,+        // and the runtime's helpers, need +x.+        let root = std::fs::read_dir(dest)+            .map_err(|e| e.to_string())?+            .flatten()+            .map(|e| e.path())+            .find(|p| p.is_dir() && p.join("bin").join("freerouting").is_file())+            .ok_or_else(|| format!("no <folder>/bin/freerouting under {} after unzip", dest.display()))?;+        for rel in ["bin/freerouting", "lib/runtime/lib/jexec", "lib/runtime/lib/jspawnhelper"] {+            let p = root.join(rel);+            if p.is_file() {+                chmod_x(&p)?;+            }+        }+        if let Ok(rd) = std::fs::read_dir(root.join("lib").join("runtime").join("bin")) {+            for e in rd.flatten() {+                if e.path().is_file() {+                    chmod_x(&e.path())?;+                }+            }+        }+        Ok(root.join("bin").join("freerouting"))+    }+}++fn chmod_x(p: &Path) -> Result<(), String> {+    use std::os::unix::fs::PermissionsExt;+    let mut perm = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.permissions();+    perm.set_mode(perm.mode() | 0o755);+    std::fs::set_permissions(p, perm).map_err(|e| format!("chmod +x {}: {e}", p.display())) }
rust/crates/kicad-platform/src/macos.rs+12−1
@@ -3,7 +3,7 @@ //! on macOS is `open -a`, osascript and the Accessibility API; add them here in phase 3 //! and flip the capability flags. -use std::path::PathBuf;+use std::path::{Path, PathBuf}; use std::process::Command;  use serde_json::{json, Value};@@ -97,4 +97,15 @@ impl Platform for Native {     fn installer_download_url(&self, _version: &str) -> Option<String> {         None     }++    // ---- Freerouting: the 2.4.1 macOS release is a dmg, which the bridge does not mount+    // or drive. Kyle: a zip or a plain app bundle asset would slot in here like linux.rs.+    // Until then the user installs Freerouting themselves and sets ADOM_FREEROUTING_EXE.++    fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> {+        None+    }+    fn freerouting_extract(&self, _archive: &Path, _dest: &Path, _unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> {+        Err("Freerouting on macOS ships as a dmg; the bridge does not drive dmg installs (owner: Kyle). Install it yourself and set ADOM_FREEROUTING_EXE.".into())+    } }
rust/crates/kicad-platform/src/windows.rs+106
@@ -297,6 +297,112 @@ impl Platform for Native {     fn tour_audio_stop(&self, handle: u64) -> Result<(), String> {         win::tour_input::audio_stop(handle)     }+    // ---- Freerouting: the windows-x64 msi (bundled Java). `msiexec /a` is an+    // administrative (extract-only) install: no registry, no shortcuts, no elevation, two+    // seconds; it leaves `<dest>\freerouting\freerouting.exe` plus `runtime\`. Size+    // verified 2026-09-12.++    fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> {+        Some(("https://github.com/freerouting/freerouting/releases/download/v2.4.1/freerouting-2.4.1-windows-x64.msi".into(), 88_235_097, "msi"))+    }+    fn freerouting_extract(&self, archive: &Path, dest: &Path, _unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> {+        if !archive.is_file() {+            return Err(format!("{} does not exist", archive.display()));+        }+        // msiexec wants the .msi extension on the package.+        let msi = if archive.extension().map(|e| e.eq_ignore_ascii_case("msi")).unwrap_or(false) {+            archive.to_path_buf()+        } else {+            let renamed = archive.with_extension("msi");+            std::fs::rename(archive, &renamed).map_err(|e| format!("rename to .msi: {e}"))?;+            renamed+        };+        // The administrative image goes into a FRESH, EMPTY subfolder that does not hold the+        // package: pointing TARGETDIR at the folder the .msi sits in returned 1603 (fatal),+        // while the probe that worked on ConfRoomROG extracted into an empty "extract" dir.+        let image = dest.join("image");+        let _ = std::fs::remove_dir_all(&image);+        std::fs::create_dir_all(&image).map_err(|e| format!("cannot create {}: {e}", image.display()))?;+        let mut cmd = Command::new("msiexec");+        cmd.arg("/a").arg(&msi).arg("/qn");+        {+            // Property syntax is TARGETDIR="C:\path with spaces": the quotes belong INSIDE the+            // token. Letting Command quote the whole token made msiexec show its usage box+            // (hidden) and wait forever (ConfRoomROG, 2026-09-12).+            use std::os::windows::process::CommandExt;+            cmd.raw_arg(format!("TARGETDIR=\"{}\"", image.display()));+        }+        cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+        {+            use std::os::windows::process::CommandExt;+            const CREATE_NO_WINDOW: u32 = 0x0800_0000;+            cmd.creation_flags(CREATE_NO_WINDOW);+        }+        let mut child = cmd.spawn().map_err(|e| format!("msiexec launch failed: {e}"))?;+        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);+        let code = loop {+            match child.try_wait() {+                Ok(Some(status)) => break status.code().unwrap_or(-1),+                Ok(None) => {}+                Err(e) => return Err(format!("waiting on msiexec failed: {e}")),+            }+            if std::time::Instant::now() >= deadline {+                let _ = child.kill();+                let _ = child.wait();+                return Err("msiexec /a did not finish within 300 s; killed".into());+            }+            std::thread::sleep(std::time::Duration::from_millis(250));+        };+        if code != 0 {+            return Err(format!("msiexec /a exited with {code} (1619 = package unreadable, 1602 = cancelled, 1603 = fatal)"));+        }+        // The admin install also drops a copy of the package next to the tree; it is+        // 88 MB of nothing the router needs.+        if let Ok(rd) = std::fs::read_dir(dest) {+            for e in rd.flatten() {+                let p = e.path();+                if p != msi && p.extension().map(|x| x.eq_ignore_ascii_case("msi")).unwrap_or(false) {+                    let _ = std::fs::remove_file(&p);+                }+            }+        }+        // Lift the app folder out of the image dir so the bundle sits at <dest>\freerouting,+        // where status() and find_exe look; drop the image dir (it also holds a copy of the msi).+        let lifted = dest.join("freerouting");+        let _ = std::fs::remove_dir_all(&lifted);+        if image.join("freerouting").is_dir() {+            std::fs::rename(image.join("freerouting"), &lifted).map_err(|e| format!("move extracted app: {e}"))?;+        }+        let _ = std::fs::remove_dir_all(&image);+        let exe = dest.join("freerouting").join("freerouting.exe");+        if exe.is_file() {+            return Ok(exe);+        }+        // Fall back to a shallow search in case the package layout moved.+        let mut stack = vec![dest.to_path_buf()];+        let mut depth = 0;+        while !stack.is_empty() && depth < 3 {+            let mut next = Vec::new();+            for d in stack {+                if let Ok(rd) = std::fs::read_dir(&d) {+                    for e in rd.flatten() {+                        let p = e.path();+                        if p.is_dir() {+                            next.push(p);+                        } else if p.file_name().map(|n| n.eq_ignore_ascii_case("freerouting.exe")).unwrap_or(false) {+                            return Ok(p);+                        }+                    }+                }+            }+            stack = next;+            depth += 1;+        }+        Err(format!("msiexec /a succeeded but no freerouting.exe under {}", dest.display()))+    }+    fn freerouting_dir(&self) -> PathBuf {+        localappdata().unwrap_or_else(std::env::temp_dir).join("Adom Bridge").join("freerouting")+    }     fn init_process(&self) {         // Per-monitor DPI awareness so every rect is in physical pixels (kicad_ui.py did         // this at import). Fails harmlessly if the manifest or an earlier call already set it.