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.
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.