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.
Comparing master ← feature/per-net-live-routing
Changes on feature/per-net-live-routing that are not yet on master (three-dot, from the merge base).
21 files changed, 6594 insertions(+), 42 deletions(-)
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.317+0.10.0-dev.per-net.1
SKILL.md+49@@ -146,6 +146,53 @@ Unlabeled nets are auto-named (`Net-(R1-Pad2)`, `unconnected-(C1-Pad1)`); single nets are usually a missed wire but can be intentional — `kicad_run_erc` is the authoritative check and knows about no-connect flags. +## Live routing++Use KiCad 10.0.1+ with Preferences > Plugins > Enable IPC API server enabled.+Install `requirements-routing.txt` into the bridge Python environment. The live+verbs connect to the exact `filePath` on the selected IPC endpoint (`socketPath`+is optional). They edit the unsaved board in the editor without GUI clicks.++| Verb | Purpose | Required args |+|---|---|---|+| `kicad_routing_state` | Live pads, copper, per-net connected pad groups and revision | `filePath` |+| `kicad_route_net` | Commit one segment, a polyline, or branches on one net as one native Undo step | `filePath`, `expectedRevision`, exact `net`, `points` or `paths` |+| `kicad_remove_route` | Remove specific unlocked copper items as one Undo step | `filePath`, `expectedRevision`, `itemIds` |+| `kicad_routing_validate` | KiCad DRC on the unsaved editor snapshot | `filePath` |++Read routing_state, choose waypoints, then route with its revision. Points may be+`"J1.1"`, `[x,y]`, or `{x,y,layer}`; `{pad:"J2.1",layer:"B.Cu"}` also works.+Coordinates are mm, +y down. A layer marker changes the outgoing layer at that+point using a through via, including at the final waypoint. Width defaults to+0.25 mm, viaSize to 0.6 mm and viaDrill to 0.3 mm. `paths` groups branches on+one net into one Undo step. Only through vias are supported.++`dryRun:true` returns the proposed geometry without editing. By default the+candidate must add no DRC errors relative to the original snapshot. `validate:false`+explicitly skips that preflight. Existing violations and unconnected items may+remain while routing is in progress; routing_validate is the final check.+Sibling on-disk project/rule files are copied for DRC; unsaved project setting+changes and external relative rule resources are not captured. DRC does not+repair clearance errors, choose paths, or shove existing copper.++Success returns itemIds and a fresh revision. `save:true` also saves; default is+unsaved live edits. Stale revisions are refused. After a timeout or an unknown+mutation result, inspect the board before deciding what to do; never blindly+replay a mutation. A postCommitError means the copper was committed but the+subsequent read or save failed. Revision checking is optimistic: avoid concurrent+manual edits while committing. Connectivity state that changes mid-read is refused.++For a visible trace-by-trace demo, call route_net once per segment/via; for a+net-by-net demo, send each whole path. See [demo/routing/README.md](demo/routing/README.md).++## Offline copper editing++`kicad_board_pads` reads pads and existing copper from the saved file.+`hasCopper` means some track exists; it does not prove connectivity.+`kicad_add_track`, `kicad_add_via`, and `kicad_route` edit **closed board files**.+They refuse an open board, preserve a `.adom-bak` by default, and never invoke+File > Revert. Use the live verbs above for anything the user should watch.+ ## Exports, linting and settings | CLI form | Purpose | Key args |@@ -165,6 +212,8 @@ authoritative check and knows about no-connect flags. | `kicad_get_settings` | Read the bridge's user-visible settings (overlay badges, ...) | — | | `kicad_set_settings` | Change bridge settings; applies immediately to live windows | settings keys | | `kicad_place_footprint` | Deterministically place a footprint into a board preview | `fileName`, `fileContent`, `footprintName`, `x`, `y`, `open` |+| `kicad_board_pads` | Pads in board coordinates with their nets — the input for routing | `filePath` |+| `kicad_add_track` / `kicad_add_via` / `kicad_route` | Draw copper (see "Drawing copper" above) | `filePath`, `net`, points | | `kicad_list_footprints` | List footprints in a library | `libraryName` | | `kicad_make_part_project` | Generate a small real project (pro/sch/pcb) AROUND an installed Adom-library part | part name | | `kicad_install_library_bundle` | Install a wiki Cloud Library bundle zip (symbols + footprints + 3D models) in ONE call | `zipPath`, optional `libraryName`, `capture`, `closeEditor` |
bridge.json+11−3@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.317",+ "version": "0.10.0-dev.per-net.1", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",@@ -41,6 +41,10 @@ "kicad_install_footprint", "kicad_install_plugin", "kicad_place_footprint",+ "kicad_board_pads",+ "kicad_add_track",+ "kicad_add_via",+ "kicad_route", "kicad_run_drc", "kicad_run_erc", "kicad_lint_board",@@ -74,7 +78,11 @@ "kicad_show_2d_board", "kicad_show_3d_board", "kicad_show_library",- "kicad_show_project"+ "kicad_show_project",+ "kicad_routing_state",+ "kicad_route_net",+ "kicad_remove_route",+ "kicad_routing_validate" ], "statusVerb": "kicad_status", "timeouts": {@@ -164,4 +172,4 @@ ], "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall"-}+}
demo/routing/README.mdadded+89@@ -0,0 +1,89 @@+# Live routing development demo++This branch adds explicit copper editing via the official KiCad IPC API. The AI+or caller chooses the route; this is not a general obstacle-avoiding autorouter.+The six-net fixture exercises 13 pads, a branched net and two layer changes.++The [Astra promotion](https://openai.com/index/gpt-6-astra/?video=1223245030)+shows a condensed KiCad placement/routing sequence, but does not document its+routing API. This demo implements the visible copper-editing portion ourselves.++## Container checks++```sh+python3 -m pip install --target /tmp/kicad-routing-deps -r requirements-routing.txt+PYTHONPATH=/tmp/kicad-routing-deps python3 -m unittest discover -s tests -v+python3 tools/build_routing_dev.py /tmp/kicad-routing-overlay.zip+python3 demo/routing/make_fixture.py /tmp/kicad-routing-fixture+```++## Development installation++Use a dedicated test PCB and wait for other demonstrations to finish. Pin the+KiCad bridge using `bridge_dev_mode {"name":"kicad","on":true}`. Stop its runtime,+then run `tools/install_routing_dev.py CACHE OVERLAY.zip` with the target's Python.+Install requirements-routing.txt into `CACHE/routing_deps` using that Python's+`pip install --target`. Restart the bridge and verify routing_state returns a+revision and bridgeVersion `0.10.0-dev.per-net.1` before any routing mutation.++The overlay preserves unrelated newer runtime code and creates a timestamped+backup beside the cache. It is a development artifact, not a release manifest.+Current released ab has an explicit-install path that bypasses `.dev-pin`; the+companion `fix/respect-dev-pin-on-install` branch fixes that path. Until that ab+fix is deployed, coordinate with other installers: pinning alone is insufficient.++Open the generated PCB in KiCad 10.0.1+ with its IPC API enabled. Do not regenerate+or overwrite it while open. Use a separate config for a shared test machine.++```sh+ADOM_API=https://RELAY/proxy/8766 python3 demo/routing/run_demo.py \+ --target arav-rog --board 'C:/Users/arav/Documents/adom-routing-dev/live-routing.kicad_pcb' \+ --plan /tmp/kicad-routing-fixture/plan.json --per-trace --output /tmp/routing-evidence+```++Omit `--per-trace` to commit one whole net per call. The script refuses preexisting+copper, records responses, and requires zero remaining nets, zero DRC errors and+zero unconnected items. Warnings are reported separately. It leaves edits unsaved+unless `--save` is supplied. Each call is a native Undo step. The board need not+be foregrounded for IPC edits; visual evidence requires a window capture.++The current test host is AdomLapper, authorized while arav-rog runs another demo.+Before merging, run both modes on the authorized test host, verify vias/branches and native Undo,+exercise wrong-net/stale-revision/clearance rejection, and confirm the installed+ab fix preserves a pinned cache during refresh and explicit install attempts.++The companion [ab source patch](ab-dev-pin-fix.patch) applies to ab commit+`792b5c0a` (2.1.38). It includes the production guard and its standalone Rust test+harness. It has not been built as a complete Windows application or deployed;+the explicit-install protection must not be claimed until that is done.++All routing and recording run in the background. For UI verification, inspect+`desktop_ui_tree` and invoke `desktop_ui_click`; use exact Win32 menu commands+for Undo (`Ctrl+Z`) and Redo (`Ctrl+Y`). Do not use `Ctrl+Shift+Z` in KiCad (it+activates Draw Filled Zones), or foreground the editor to dismiss a dialog.++## Verified result (2026-09-05)++Both modes passed on AdomLapper with KiCad 10.0.5: six connected nets, 13 pads,+20 segments and two through vias; zero DRC errors and zero unconnected items.+Three warnings identify the deliberately uninstalled RoutingFixture library.+Wrong-net, stale-revision and crossing-route rejection, dry-run immutability,+native Undo/Redo, removal, and Undo restoring all 22 removed items passed.++The final per-trace recording was captured in the background, finalized at 565+frames (1984x1250, 15fps, 37.67 seconds) and decoded without errors. In per-trace+mode the full net is DRC-checked once before its exact segments/vias are committed+against consecutive board revisions. Final DRC checks the complete live board.+The final PCB was saved and pulled back for inspection. Structured results:+[evidence/AdomLapper-2026-09-05.json](evidence/AdomLapper-2026-09-05.json).++Pinned refresh returned `pinned_dev`; the live routing source hash was identical+before and after. The development build also survived the host ab upgrade to+2.1.39. This does not prove protection against explicit bridge_install: the+companion patch remains necessary and has not been deployed.++The companion ab patch also passed `cargo check --locked` against current+2.1.39 source in an isolated Windows checkout on AdomLapper. All four pin guard+tests passed there as well as in the container. The running ab executable was+not replaced; live explicit-install rejection remains a deployment acceptance+check, and no signed/packaged ab release was built.
demo/routing/ab-dev-pin-fix.patchadded+470@@ -0,0 +1,470 @@+From 4da3e46d9627c31115390b26e386690cd0a859bd Mon Sep 17 00:00:00 2001+From: Codex <codex@localhost>+Date: Sat, 5 Sep 2026 12:53:14 -0500+Subject: [PATCH] Respect development pins during explicit installs and cache+ replacement++---+ cli/src/commands.rs | 2 +-+ src-tauri/src/bridge_cache.rs | 47 ++++++++++---+ src-tauri/src/cache_mutation.rs | 111 ++++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 9 ++-+ src-tauri/src/lib.rs | 3 +-+ tests/dev-pin-harness/Cargo.lock | 72 +++++++++++++++++++++ tests/dev-pin-harness/Cargo.toml | 6 +++ tests/dev-pin-harness/README.md | 23 ++++++++ tests/dev-pin-harness/src/lib.rs | 3 ++ 9 files changed, 259 insertions(+), 17 deletions(-)+ create mode 100644 src-tauri/src/cache_mutation.rs+ create mode 100644 tests/dev-pin-harness/Cargo.lock+ create mode 100644 tests/dev-pin-harness/Cargo.toml+ create mode 100644 tests/dev-pin-harness/README.md+ create mode 100644 tests/dev-pin-harness/src/lib.rs++diff --git a/cli/src/commands.rs b/cli/src/commands.rs+index 6f3c1c32..f62ab8e2 100644+--- a/cli/src/commands.rs+@@ -1534,7 +1534,7 @@ pub fn list_commands() -> Value {+ "returns": "{success, name, paused:true, _hint}"+ },+ "bridge_dev_mode": {+- "description": "Freeze a bridge's cache against ALL automatic updates so you can develop it without ab clobbering your changes. {name, on:true} writes a .dev-pin in the cache dir; while pinned the 4h sweep, on-launch sync, on-use auto-update AND refresh_bridges all skip it (each says outcome:'skipped: dev pin'). {name, on:false} removes the pin and updates resume. The pin survives ab restarts and shows as devPinned:true in bridge_list. bridge_install with force:true is the one deliberate override.",++ "description": "Freeze a bridge's cache against ALL automatic updates so you can develop it without ab clobbering your changes. {name, on:true} writes a .dev-pin in the cache dir; while pinned the 4h sweep, on-launch sync, on-use auto-update AND refresh_bridges all skip it (each says outcome:'skipped: dev pin'). {name, on:false} removes the pin and updates resume. The pin survives ab restarts and shows as devPinned:true in bridge_list. Explicit bridge_install respects the pin, including force:true. Unpin with bridge_dev_mode before installing a release.",+ "args": {"name": "bridge name (from bridge_list)", "on": "true to pin (freeze updates), false to unpin"},+ "returns": "{ok, name, devPinned, pinPath?}",+ },+diff --git a/src-tauri/src/bridge_cache.rs b/src-tauri/src/bridge_cache.rs+index 9665b2ae..06002c3e 100644+--- a/src-tauri/src/bridge_cache.rs+@@ -573,7 +573,7 @@ fn hex_encode(bytes: &[u8]) -> String {+ /// needed (cache invalid AND a bundled seed exists). Returns whether it bootstrapped.+ /// Used by the offline / bundled≥wiki branches of `sync_one` — NOT the update-download+ /// path, which would discard the (huge, for a node bridge) seed copy anyway.+-fn bootstrap_if_invalid(app: &AppHandle, bridge: &str) -> Result<bool, String> {++async fn bootstrap_if_invalid(app: &AppHandle, bridge: &str) -> Result<bool, String> {+ let cache = cache_dir(bridge);+ if cache_is_valid(&cache) {+ return Ok(false);+@@ -584,7 +584,7 @@ fn bootstrap_if_invalid(app: &AppHandle, bridge: &str) -> Result<bool, String> {+ if !has_bundled {+ return Ok(false);+ }+- bootstrap_cache_from_bundled(app, bridge).map_err(|e| format!("bootstrap {bridge} cache: {e}"))?;++ bootstrap_cache_from_bundled(app, bridge).await.map_err(|e| format!("bootstrap {bridge} cache: {e}"))?;+ Ok(true)+ }+ +@@ -634,7 +634,8 @@ pub fn is_dev_pinned(bridge: &str) -> bool {+ /// resulting pinned state; unpinning an already-unpinned bridge is Ok(false).+ /// Every flip is written to the bridge's lifecycle audit so "why did/didn't my+ /// bridge update?" is answerable from one bridge_info read.+-pub fn set_dev_pin(bridge: &str, on: bool, source: &str) -> Result<bool, String> {++pub async fn set_dev_pin(bridge: &str, on: bool, source: &str) -> Result<bool, String> {++ let _mutation = crate::cache_mutation::lock(&cache_dir(bridge)).await;+ let pin = dev_pin_path(bridge);+ if on {+ if let Some(parent) = pin.parent() {+@@ -727,7 +728,7 @@ pub async fn sync_one(app: &AppHandle, bridge: &str) -> Result<(String, String,+ let manifest: BridgeManifest = match crate::with_session_auth(client.get(&manifest_url)).send().await {+ Ok(resp) => {+ if !resp.status().is_success() {+- let bootstrapped = bootstrap_if_invalid(app, bridge)?;++ let bootstrapped = bootstrap_if_invalid(app, bridge).await?;+ let action = if bootstrapped { "bootstrapped" } else { "current_offline" };+ record_sync_detail(bridge, detail(None,+ "failed: manifest not served at the probed tier (bridge not published there, or the page refused)"));+@@ -737,7 +738,7 @@ pub async fn sync_one(app: &AppHandle, bridge: &str) -> Result<(String, String,+ Ok(m) => m,+ Err(e) => {+ log::warn!("[bridge_cache] {bridge} manifest parse failed: {e}");+- let bootstrapped = bootstrap_if_invalid(app, bridge)?;++ let bootstrapped = bootstrap_if_invalid(app, bridge).await?;+ let action = if bootstrapped { "bootstrapped" } else { "current_offline" };+ record_sync_detail(bridge, detail(None, "failed: manifest unparseable"));+ return Ok((from.clone(), from, action.to_string()));+@@ -746,7 +747,7 @@ pub async fn sync_one(app: &AppHandle, bridge: &str) -> Result<(String, String,+ }+ Err(e) => {+ log::warn!("[bridge_cache] {bridge} manifest fetch failed (offline?): {e}");+- let bootstrapped = bootstrap_if_invalid(app, bridge)?;++ let bootstrapped = bootstrap_if_invalid(app, bridge).await?;+ let action = if bootstrapped { "bootstrapped" } else { "current_offline" };+ record_sync_detail(bridge, detail(None, "failed: wiki unreachable (network/timeout)"));+ return Ok((from.clone(), from, action.to_string()));+@@ -771,7 +772,7 @@ pub async fn sync_one(app: &AppHandle, bridge: &str) -> Result<(String, String,+ // "current_bundled_newer"; equal is "current_bundled".+ let bundled = bundled_version(app, bridge).unwrap_or_default();+ if !bundled.is_empty() && !version_gt(&manifest.version, &bundled) {+- let _ = bootstrap_if_invalid(app, bridge);++ let _ = bootstrap_if_invalid(app, bridge).await;+ let action = if version_gt(&bundled, &manifest.version) {+ "current_bundled_newer"+ } else {+@@ -818,7 +819,9 @@ pub async fn sync_one(app: &AppHandle, bridge: &str) -> Result<(String, String,+ + /// Copy the bundled bridge dir into the cache and write a .bridge-version+ /// marker.+-fn bootstrap_cache_from_bundled(app: &AppHandle, bridge: &str) -> Result<(), String> {++async fn bootstrap_cache_from_bundled(app: &AppHandle, bridge: &str) -> Result<(), String> {++ let _mutation = crate::cache_mutation::lock(&cache_dir(bridge)).await;++ crate::cache_mutation::require_unpinned(&cache_dir(bridge))?;+ let bundled = bundled_dir(app, bridge)?;+ if !bundled.is_dir() {+ return Err(format!(+@@ -887,7 +890,8 @@ async fn download_and_install(+ // client-level 20s timeout, and (c) reported truncation as+ // "error decoding response body" — a misleading message. Streaming+ // avoids all three.+- let zip_path = cache_dir(bridge).with_extension("downloading.zip");++ let pending_id = uuid::Uuid::new_v4();++ let zip_path = cache_dir(bridge).with_extension(format!("{pending_id}.downloading.zip"));+ let _ = std::fs::remove_file(&zip_path);+ if let Some(parent) = zip_path.parent() {+ std::fs::create_dir_all(parent).map_err(|e| format!("create cache root: {e}"))?;+@@ -905,7 +909,7 @@ async fn download_and_install(+ }+ + // Extract to a temp dir, then atomically swap with cache.+- let temp = cache_dir(bridge).with_extension("new");++ let temp = cache_dir(bridge).with_extension(format!("{pending_id}.new"));+ if temp.exists() {+ let _ = std::fs::remove_dir_all(&temp);+ }+@@ -965,6 +969,15 @@ async fn download_and_install(+ std::fs::write(temp.join(".bridge-version"), manifest.version.trim())+ .map_err(|e| format!("write .bridge-version: {e}"))?;+ ++ // A pin may have been set while downloading. Serialize the check, reap,++ // and replacement with the Pin/Unpin verb and other cache writers.++ let _mutation = crate::cache_mutation::lock(&cache_dir(bridge)).await;++ if let Err(error) = crate::cache_mutation::require_unpinned(&cache_dir(bridge)) {++ let _ = std::fs::remove_dir_all(&temp);++ let _ = std::fs::remove_file(&zip_path);++ return Err(error);++ }+++ // Atomic-ish swap: rename current cache out, rename temp in, delete old.+ // v1.9.27: if the rename fails with Windows ERROR_SHARING_VIOLATION (os 32)+ // because the bridge is RUNNING and holding the cache dir as its cwd (its+@@ -1822,6 +1835,20 @@ pub async fn install_from_manifest_url(+ });+ }+ ++ // Resolve the actual bridge name from the verified archive, not the URL.++ // Explicit installs obey the same pin as automatic updates. Unpin is a++ // separate deliberate action; force/retries never erase development work.++ let _mutation = crate::cache_mutation::lock(&cache_dir(&canonical_name)).await;++ if let Err(error) = crate::cache_mutation::require_unpinned(&cache_dir(&canonical_name)) {++ let _ = std::fs::remove_dir_all(&temp);++ let _ = std::fs::remove_file(&zip_path);++ return serde_json::json!({++ "ok": false, "errorCode": "dev_pinned", "devPinned": true,++ "bridge": canonical_name, "error": error,++ "_hint": "Development cache preserved. Use bridge_dev_mode {name, on:false} explicitly before installing a release."++ });++ }+++ // Downloaded zip is no longer needed — bytes are now in the temp tree.+ let _ = std::fs::remove_file(&zip_path);+ +diff --git a/src-tauri/src/cache_mutation.rs b/src-tauri/src/cache_mutation.rs+new file mode 100644+index 00000000..80a2e6b2+--- /dev/null+@@ -0,0 +1,111 @@++//! Serialize in-process cache replacement with development pin changes.++//! Manual marker-file writes are observed at the commit boundary; callers that++//! require race-free pinning must use bridge_dev_mode or the GUI pin button.++use std::collections::HashMap;++use std::path::{Path, PathBuf};++use std::sync::{Arc, Mutex, OnceLock};++use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};++++pub async fn lock(cache: &Path) -> OwnedMutexGuard<()> {++ static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>> = OnceLock::new();++ // Windows cache names are case-insensitive, even before the directory exists.++ let key = if cfg!(windows) {++ PathBuf::from(cache.to_string_lossy().to_lowercase())++ } else {++ cache.to_path_buf()++ };++ let mutex = {++ let mut locks = LOCKS++ .get_or_init(Mutex::default)++ .lock()++ .unwrap_or_else(|e| e.into_inner());++ locks.entry(key).or_default().clone()++ };++ mutex.lock_owned().await++}++++pub fn require_unpinned(cache: &Path) -> Result<(), String> {++ match std::fs::symlink_metadata(cache.join(".dev-pin")) {++ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),++ Err(e) => Err(format!("Cannot inspect development pin; cache preserved: {e}")),++ Ok(_) => Err(format!(++ "{} is DEV-PINNED; cache and running bridge preserved. Explicitly unpin with bridge_dev_mode before installing a release.",++ cache.display()++ )),++ }++}++++#[cfg(test)]++mod tests {++ use super::*;++ fn fixture() -> PathBuf {++ let path = std::env::temp_dir().join(format!(++ "ab-pin-{}-{}",++ std::process::id(),++ std::time::SystemTime::now()++ .duration_since(std::time::UNIX_EPOCH)++ .unwrap()++ .as_nanos()++ ));++ std::fs::create_dir_all(&path).unwrap();++ path++ }++++ #[tokio::test]++ async fn pin_set_during_download_prevents_commit() {++ let cache = fixture();++ std::fs::write(cache.join("server.py"), "development work").unwrap();++ require_unpinned(&cache).unwrap(); // download starts++ {++ let _pin = lock(&cache).await;++ std::fs::write(cache.join(".dev-pin"), "pinned").unwrap();++ }++ let _commit = lock(&cache).await;++ assert!(require_unpinned(&cache).is_err());++ assert_eq!(++ std::fs::read_to_string(cache.join("server.py")).unwrap(),++ "development work"++ );++ std::fs::remove_dir_all(cache).unwrap();++ }++++ #[tokio::test]++ async fn pin_cannot_acknowledge_during_replacement() {++ let cache = fixture();++ let commit = lock(&cache).await;++ require_unpinned(&cache).unwrap();++ let other = cache.clone();++ let pin = tokio::spawn(async move {++ let _guard = lock(&other).await;++ std::fs::write(other.join(".dev-pin"), "pinned").unwrap();++ });++ tokio::task::yield_now().await;++ assert!(!pin.is_finished());++ drop(commit);++ pin.await.unwrap();++ assert!(require_unpinned(&cache).is_err());++ std::fs::remove_file(cache.join(".dev-pin")).unwrap();++ require_unpinned(&cache).unwrap();++ std::fs::remove_dir_all(cache).unwrap();++ }++++ #[tokio::test]++ async fn independent_bridges_do_not_block_each_other() {++ let a = fixture();++ let b = fixture();++ let _a = lock(&a).await;++ let _b = tokio::time::timeout(std::time::Duration::from_secs(1), lock(&b))++ .await++ .unwrap();++ std::fs::remove_dir_all(a).unwrap();++ std::fs::remove_dir_all(b).unwrap();++ }++++ #[test]++ fn even_a_directory_marker_blocks_install() {++ let cache = fixture();++ std::fs::create_dir(cache.join(".dev-pin")).unwrap();++ assert!(require_unpinned(&cache).is_err());++ std::fs::remove_dir_all(cache).unwrap();++ }++}+diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs+index 1b7a2698..0ddb1c81 100644+--- a/src-tauri/src/commands.rs+@@ -5957,9 +5957,8 @@ async fn handle_restart_bridge(+ /// refresh_bridges). While pinned a developer can modify the cache dir freely+ /// without ab pulling the insiders/public release over their work. It writes a+ /// `.dev-pin` marker in the cache dir, so the pin survives ab restarts and is+-/// visible (bridge_list rows show `devPinned:true`). `bridge_install ... force`+-/// is the one deliberate override — a human explicitly asking to replace the+-/// cache — but the passive sweep never wins.++/// visible (bridge_list rows show `devPinned:true`). Explicit installs also respect the pin;++/// unpin with bridge_dev_mode before installing a release.+ async fn handle_bridge_dev_mode(+ app: &AppHandle,+ ctx: &WsContext,+@@ -5991,11 +5990,11 @@ async fn handle_bridge_dev_mode(+ let caller_tag = crate::caller::Caller::from_args(args, Some(&ctx.server_name))+ .ai_thread+ .unwrap_or_else(|| ctx.server_name.clone());+- let result_json = match crate::bridge_cache::set_dev_pin(&name, on, &format!("bridge_dev_mode verb, {caller_tag}")) {++ let result_json = match crate::bridge_cache::set_dev_pin(&name, on, &format!("bridge_dev_mode verb, {caller_tag}")).await {+ Ok(true) => serde_json::json!({+ "ok": true, "name": name, "devPinned": true,+ "pinPath": crate::bridge_cache::dev_pin_path(&name).to_string_lossy(),+- "_hint": "DEV MODE ON: every automatic update for this bridge is frozen (the 4h sweep, on-launch sync, on-use auto-update, refresh_bridges all skip it). Modify the cache dir freely. Call bridge_dev_mode {\"name\":\"...\",\"on\":false} to resume updates. bridge_install with force:true is the one deliberate override. The bridge's card in the ab window shows an amber 'dev pin' badge while pinned.",++ "_hint": "DEV MODE ON: every automatic update for this bridge is frozen (the 4h sweep, on-launch sync, on-use auto-update, refresh_bridges all skip it). Modify the cache dir freely. Call bridge_dev_mode {\"name\":\"...\",\"on\":false} to resume updates. Explicit bridge_install also respects the pin, including force:true; unpin before installing a release. The bridge's card in the ab window shows an amber 'dev pin' badge while pinned.",+ }),+ Ok(false) => serde_json::json!({+ "ok": true, "name": name, "devPinned": false,+diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs+index 8df61912..713fd96d 100644+--- a/src-tauri/src/lib.rs+@@ -2,6 +2,7 @@ mod ai_thread;+ mod auth;+ mod bridge_autoupdate;+ mod bridge_cache;++mod cache_mutation;+ mod bridge_langs;+ mod bridge_readiness;+ mod bridge_registry;+@@ -3008,7 +3009,7 @@ async fn decline_generic_prompts(app: AppHandle) -> Result<(), String> {+ /// with one click instead of a CLI call. Returns the resulting pinned state.+ #[tauri::command]+ async fn set_bridge_dev_pin(app: AppHandle, name: String, on: bool) -> Result<bool, String> {+- let pinned = bridge_cache::set_dev_pin(&name, on, "ab window button")?;++ let pinned = bridge_cache::set_dev_pin(&name, on, "ab window button").await?;+ let _ = app.emit(+ "bridge-registry-changed",+ serde_json::json!({"reason": "dev-pin", "name": name, "devPinned": pinned}),+diff --git a/tests/dev-pin-harness/Cargo.lock b/tests/dev-pin-harness/Cargo.lock+new file mode 100644+index 00000000..1b950821+--- /dev/null+@@ -0,0 +1,72 @@++# This file is automatically @generated by Cargo.++# It is not intended for manual editing.++version = 4++++[[package]]++name = "ab-dev-pin-tests"++version = "0.1.0"++dependencies = [++ "tokio",++]++++[[package]]++name = "pin-project-lite"++version = "0.2.17"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++++[[package]]++name = "proc-macro2"++version = "1.0.107"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"++dependencies = [++ "unicode-ident",++]++++[[package]]++name = "quote"++version = "1.0.47"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"++dependencies = [++ "proc-macro2",++]++++[[package]]++name = "syn"++version = "3.0.5"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"++dependencies = [++ "proc-macro2",++ "quote",++ "unicode-ident",++]++++[[package]]++name = "tokio"++version = "1.53.1"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"++dependencies = [++ "pin-project-lite",++ "tokio-macros",++]++++[[package]]++name = "tokio-macros"++version = "2.7.2"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"++dependencies = [++ "proc-macro2",++ "quote",++ "syn",++]++++[[package]]++name = "unicode-ident"++version = "1.0.24"++source = "registry+https://github.com/rust-lang/crates.io-index"++checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"+diff --git a/tests/dev-pin-harness/Cargo.toml b/tests/dev-pin-harness/Cargo.toml+new file mode 100644+index 00000000..9fa1dd7c+--- /dev/null+@@ -0,0 +1,6 @@++[package]++name = "ab-dev-pin-tests"++version = "0.1.0"++edition = "2021"++[dependencies]++tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }+diff --git a/tests/dev-pin-harness/README.md b/tests/dev-pin-harness/README.md+new file mode 100644+index 00000000..16b65d9e+--- /dev/null+@@ -0,0 +1,23 @@++# Development pin regression checks++++Run `cargo test --manifest-path tests/dev-pin-harness/Cargo.toml` on Linux or++Windows. This harness compiles the production cache mutation guard directly,++without the Windows/Tauri GUI dependency tree.++++The fix serializes Pin/Unpin, bundled bootstrap, automatic cache replacement,++and explicit manifest installation. Explicit installation resolves the actual++bridge name from the verified archive before checking the pin, and returns++`dev_pinned` before reaping or changing the cache. `force:true` is not a bypass;++unpinning is a separate deliberate operation. Downloads use independent staging++paths so overlapping automatic downloads cannot overwrite each other's staging.++++Tests cover pins set during download, pin acknowledgement waiting for replacement,++independent bridges, and malformed marker directories. The synchronization covers++one ab process; hand-written marker files are checked at the commit boundary but++cannot participate in the in-process lock. Use the verb or GUI pin button.++++Validation still required before release: full Windows application compilation++and live refresh/install checks, including an install request with force:true.++Verify cache hashes and running PID are preserved for a pinned bridge, then++explicitly unpin and confirm a normal install succeeds. The standalone guard tests++do not substitute for those integration checks.+diff --git a/tests/dev-pin-harness/src/lib.rs b/tests/dev-pin-harness/src/lib.rs+new file mode 100644+index 00000000..cfeb943a+--- /dev/null+@@ -0,0 +1,3 @@++// Compile the production guard directly without Windows/Tauri dependencies.++#[path = "../../../src-tauri/src/cache_mutation.rs"]++pub mod cache_mutation;+-- +2.43.0+
demo/routing/evidence/AdomLapper-2026-09-05.jsonadded+60@@ -0,0 +1,60 @@+{+ "date": "2026-09-05",+ "target": "AdomLapper",+ "kicadVersion": "10.0.5",+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "abVersionAfterUpgrade": "2.1.39",+ "localRoutingTests": 25,+ "abGuardTests": 4,+ "modes": [+ "per-net",+ "per-trace"+ ],+ "segments": 20,+ "vias": 2,+ "nets": 6,+ "pads": 13,+ "drc": {+ "errors": 0,+ "warnings": 3,+ "unconnected": 0,+ "clean": false,+ "stale": false+ },+ "warningExplanation": "Three lib_footprint_issues warnings for the synthetic RoutingFixture library; not installed as a library",+ "checks": {+ "wrongNetRejected": true,+ "staleRevisionRejected": true,+ "dryRunUnchanged": true,+ "crossNetCollisionRejectedWithoutMutation": true,+ "nativeUndoRemovedLastSegment": true,+ "nativeRedoRestoredLastSegment": true,+ "removeAll22ItemsVerified": true,+ "oneUndoRestoredAll22RemovedItems": true,+ "refreshSkippedPinnedCache": true,+ "pinHashBeforeEqualsAfter": true,+ "savedFinalBoard": true+ },+ "recording": {+ "durationSeconds": 37.67,+ "frames": 565,+ "width": 1984,+ "height": 1250,+ "fps": 15,+ "decodedWithoutErrors": true,+ "sha256": "d71543c251948204e49ccefd33ac133a200e528ef27bd3bff31e4709c709247b"+ },+ "limitations": [+ "Caller supplies waypoints; no obstacle search or shove router",+ "Project rules are copied from disk, not unsaved project settings",+ "ab explicit-install pin fix is prepared and unit-tested but not deployed or fully Windows-built"+ ],+ "abWindowsValidation": {+ "sourceVersion": "2.1.39",+ "cargoCheckExitCode": 0,+ "guardTestExitCode": 0,+ "guardTestsPassed": 4,+ "isolatedCheckout": "C:/Users/john/Documents/adom-routing-dev/ab-pin-check-20260905",+ "runningInstallationChanged": false+ }+}
demo/routing/evidence/per-net-results.jsonadded+2095@@ -0,0 +1,2095 @@+[+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_1",+ "NET_2",+ "NET_3",+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "0ffe7515313503db5e6c2fbac3e22eae54ca87f85099b204f657f33924aca35e",+ "segments": [],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_1",+ "revision": "0ffe7515313503db5e6c2fbac3e22eae54ca87f85099b204f657f33924aca35e",+ "segments": [+ {+ "end": [+ 112.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 85.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 85.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 85.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef"+ ],+ "mutated": true,+ "netName": "NET_1",+ "revision": "afd2dd657f31fa440c4fd2a78307fd43e0337276a34ab79c19178af13b92d429",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 85.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 85.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 85.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_2",+ "NET_3",+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "afd2dd657f31fa440c4fd2a78307fd43e0337276a34ab79c19178af13b92d429",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_2",+ "revision": "afd2dd657f31fa440c4fd2a78307fd43e0337276a34ab79c19178af13b92d429",+ "segments": [+ {+ "end": [+ 112.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 90.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 90.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 90.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b"+ ],+ "mutated": true,+ "netName": "NET_2",+ "revision": "5f92f2bee79ae7d7dbd933e153ec0964ce982b63f2d49b87d61476dd16804616",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 90.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 90.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 90.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_3",+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "5f92f2bee79ae7d7dbd933e153ec0964ce982b63f2d49b87d61476dd16804616",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_3",+ "revision": "5f92f2bee79ae7d7dbd933e153ec0964ce982b63f2d49b87d61476dd16804616",+ "segments": [+ {+ "end": [+ 112.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 95.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 95.0+ ],+ "layer": "B.Cu",+ "start": [+ 112.0,+ 95.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 95.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "at": [+ 112.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ },+ {+ "at": [+ 138.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "4c072f57-a7d9-4ce1-ad25-51bf8f08aa62",+ "a1cb0b55-242e-4ab4-b735-14540e110574",+ "9ac411ac-cca5-4431-968c-b2d433512e65",+ "1f83a460-caa7-4283-b709-82fb4bdca7ad",+ "6bd8c4fc-3c05-44b9-b216-be6b941ddebe"+ ],+ "mutated": true,+ "netName": "NET_3",+ "revision": "b36e117ec51d01b86a554ba211b687f16718fd308eaf327c2bc2df338f270f61",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 95.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 95.0+ ],+ "layer": "B.Cu",+ "start": [+ 112.0,+ 95.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 95.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": [+ {+ "at": [+ 112.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ },+ {+ "at": [+ 138.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ }+ ]+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "b36e117ec51d01b86a554ba211b687f16718fd308eaf327c2bc2df338f270f61",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "4c072f57-a7d9-4ce1-ad25-51bf8f08aa62",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "9ac411ac-cca5-4431-968c-b2d433512e65",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "a1cb0b55-242e-4ab4-b735-14540e110574",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "1f83a460-caa7-4283-b709-82fb4bdca7ad"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "6bd8c4fc-3c05-44b9-b216-be6b941ddebe"+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_4",+ "revision": "b36e117ec51d01b86a554ba211b687f16718fd308eaf327c2bc2df338f270f61",+ "segments": [+ {+ "end": [+ 112.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 100.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 100.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 100.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "fa807969-4c0d-4f72-8dbd-82f341efd04e",+ "e800fab9-d023-42c4-a1f1-7dc26cb251de",+ "020dd159-1f74-410b-b2f6-5069da44802a"+ ],+ "mutated": true,+ "netName": "NET_4",+ "revision": "8d5300d3268942a6e6d0fcfa9d43bfc0ce14def1d5e5a6743e79f6abc013af6d",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 100.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 100.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 100.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_5",+ "NET_6"+ ],+ "revision": "8d5300d3268942a6e6d0fcfa9d43bfc0ce14def1d5e5a6743e79f6abc013af6d",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "4c072f57-a7d9-4ce1-ad25-51bf8f08aa62",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "9ac411ac-cca5-4431-968c-b2d433512e65",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "a1cb0b55-242e-4ab4-b735-14540e110574",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "020dd159-1f74-410b-b2f6-5069da44802a",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "e800fab9-d023-42c4-a1f1-7dc26cb251de",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "fa807969-4c0d-4f72-8dbd-82f341efd04e",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "1f83a460-caa7-4283-b709-82fb4bdca7ad"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "6bd8c4fc-3c05-44b9-b216-be6b941ddebe"+ }+ ]+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_5",+ "NET_6"+ ],+ "revision": "8d5300d3268942a6e6d0fcfa9d43bfc0ce14def1d5e5a6743e79f6abc013af6d",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "4c072f57-a7d9-4ce1-ad25-51bf8f08aa62",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "9ac411ac-cca5-4431-968c-b2d433512e65",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "a1cb0b55-242e-4ab4-b735-14540e110574",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "020dd159-1f74-410b-b2f6-5069da44802a",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "e800fab9-d023-42c4-a1f1-7dc26cb251de",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "fa807969-4c0d-4f72-8dbd-82f341efd04e",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "1f83a460-caa7-4283-b709-82fb4bdca7ad"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "6bd8c4fc-3c05-44b9-b216-be6b941ddebe"+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_5",+ "revision": "8d5300d3268942a6e6d0fcfa9d43bfc0ce14def1d5e5a6743e79f6abc013af6d",+ "segments": [+ {+ "end": [+ 112.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 105.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 105.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 105.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "ac62b492-98d0-49bd-8079-87c140ff71ca",+ "0bd8cf96-5af0-4a37-89c0-48d7f003a103",+ "2da2145a-9bf9-478f-a32d-1235cb0c1346"+ ],+ "mutated": true,+ "netName": "NET_5",+ "revision": "51e658f211d53f071b6894b0b768ce4314e90a6ae06c42092f504786679fca33",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 105.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 105.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 105.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_6"+ ],+ "revision": "51e658f211d53f071b6894b0b768ce4314e90a6ae06c42092f504786679fca33",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "4c072f57-a7d9-4ce1-ad25-51bf8f08aa62",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "9ac411ac-cca5-4431-968c-b2d433512e65",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "a1cb0b55-242e-4ab4-b735-14540e110574",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "020dd159-1f74-410b-b2f6-5069da44802a",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "e800fab9-d023-42c4-a1f1-7dc26cb251de",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "fa807969-4c0d-4f72-8dbd-82f341efd04e",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 112.0,+ "y": 105.0+ },+ "uuid": "0bd8cf96-5af0-4a37-89c0-48d7f003a103",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 138.0,+ "y": 105.0+ },+ "uuid": "2da2145a-9bf9-478f-a32d-1235cb0c1346",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 105.0,+ "y": 105.0+ },+ "uuid": "ac62b492-98d0-49bd-8079-87c140ff71ca",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "1f83a460-caa7-4283-b709-82fb4bdca7ad"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "6bd8c4fc-3c05-44b9-b216-be6b941ddebe"+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_6",+ "revision": "51e658f211d53f071b6894b0b768ce4314e90a6ae06c42092f504786679fca33",+ "segments": [+ {+ "end": [+ 112.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 125.0,+ 113.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 125.0,+ 116.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 113.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "ac6f9476-0dec-4e5a-9e53-a20c12f0b5b6",+ "f5e48ca4-74f6-42c3-973d-787986fb4d79",+ "76c20bf0-bd6f-49b0-af9b-6c0c4071ddbd",+ "dc719f86-8b4f-4503-b1cc-cd08646f5b88",+ "bc85f774-cb96-4d85-a425-d43c3a8f01d4"+ ],+ "mutated": true,+ "netName": "NET_6",+ "revision": "c4e968cd87284e5419fb411ae65522b3c41065be2fee967a79812cc93d0cdb74",+ "saved": true,+ "segments": [+ {+ "end": [+ 112.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 125.0,+ 113.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 125.0,+ 116.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 113.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [],+ "revision": "c4e968cd87284e5419fb411ae65522b3c41065be2fee967a79812cc93d0cdb74",+ "segments": [+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "60d205cb-2157-4b1e-9e71-c156d5ab6e1b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "7f462971-23e0-4ce9-b63d-514be8ff4f92",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "f480c34a-cd2d-4d23-9b7a-8ebf6b6fb8ef",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "825b4b2a-6284-492e-bbcf-c9eb269ce542",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "a5a0b151-85e1-40e5-b21e-9cf3bdddcd5b",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "c290ead2-6ee8-4f4b-ad28-a53982307225",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "4c072f57-a7d9-4ce1-ad25-51bf8f08aa62",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "9ac411ac-cca5-4431-968c-b2d433512e65",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "a1cb0b55-242e-4ab4-b735-14540e110574",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "020dd159-1f74-410b-b2f6-5069da44802a",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "e800fab9-d023-42c4-a1f1-7dc26cb251de",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "fa807969-4c0d-4f72-8dbd-82f341efd04e",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 112.0,+ "y": 105.0+ },+ "uuid": "0bd8cf96-5af0-4a37-89c0-48d7f003a103",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 138.0,+ "y": 105.0+ },+ "uuid": "2da2145a-9bf9-478f-a32d-1235cb0c1346",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 105.0,+ "y": 105.0+ },+ "uuid": "ac62b492-98d0-49bd-8079-87c140ff71ca",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 110.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 138.0,+ "y": 110.0+ },+ "uuid": "76c20bf0-bd6f-49b0-af9b-6c0c4071ddbd",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 110.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 105.0,+ "y": 110.0+ },+ "uuid": "ac6f9476-0dec-4e5a-9e53-a20c12f0b5b6",+ "width": 0.25+ },+ {+ "end": {+ "x": 125.0,+ "y": 116.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 125.0,+ "y": 113.0+ },+ "uuid": "bc85f774-cb96-4d85-a425-d43c3a8f01d4",+ "width": 0.25+ },+ {+ "end": {+ "x": 125.0,+ "y": 113.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 125.0,+ "y": 110.0+ },+ "uuid": "dc719f86-8b4f-4503-b1cc-cd08646f5b88",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 110.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 112.0,+ "y": 110.0+ },+ "uuid": "f5e48ca4-74f6-42c3-973d-787986fb4d79",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "1f83a460-caa7-4283-b709-82fb4bdca7ad"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "6bd8c4fc-3c05-44b9-b216-be6b941ddebe"+ }+ ]+ }+ },+ {+ "verb": "kicad_routing_validate",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "clean": false,+ "errors": 0,+ "revision": "c4e968cd87284e5419fb411ae65522b3c41065be2fee967a79812cc93d0cdb74",+ "stale": false,+ "success": true,+ "unconnected": 0,+ "warnings": 3+ }+ }+]
demo/routing/evidence/per-trace-results.jsonadded+2154@@ -0,0 +1,2154 @@+[+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_1",+ "NET_2",+ "NET_3",+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "0ffe7515313503db5e6c2fbac3e22eae54ca87f85099b204f657f33924aca35e",+ "segments": [],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_1",+ "revision": "0ffe7515313503db5e6c2fbac3e22eae54ca87f85099b204f657f33924aca35e",+ "segments": [+ {+ "end": [+ 112.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 85.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 85.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 85.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "204516c1-6cc1-4f13-81dd-97e88d0f6341"+ ],+ "mutated": true,+ "netName": "NET_1",+ "revision": "2c8b91cafe5db4ae0cdced7ec8b8697a2afc78193ede1cc25e8c13af20c23d34",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 85.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "8bf10672-a0ee-4cf3-b847-1731ea4a9625"+ ],+ "mutated": true,+ "netName": "NET_1",+ "revision": "78da52680db7640a9fc70a4d66c00d80c5b603e50d7e398dc48210eea7017d9f",+ "saved": false,+ "segments": [+ {+ "end": [+ 138.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 85.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "c5729098-056b-416c-9034-382de0363fc9"+ ],+ "mutated": true,+ "netName": "NET_1",+ "revision": "da2986116d728c8751251453795c4d17999f8c1a542684fbc037738b91f44524",+ "saved": false,+ "segments": [+ {+ "end": [+ 145.0,+ 85.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 85.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_2",+ "NET_3",+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "da2986116d728c8751251453795c4d17999f8c1a542684fbc037738b91f44524",+ "segments": [+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "204516c1-6cc1-4f13-81dd-97e88d0f6341",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "8bf10672-a0ee-4cf3-b847-1731ea4a9625",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "c5729098-056b-416c-9034-382de0363fc9",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_2",+ "revision": "da2986116d728c8751251453795c4d17999f8c1a542684fbc037738b91f44524",+ "segments": [+ {+ "end": [+ 112.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 90.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 90.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 90.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "e58f20a2-d5c8-4aea-90f4-2b8062a849d3"+ ],+ "mutated": true,+ "netName": "NET_2",+ "revision": "4d5a5bb12ac3113ad5b1d4c7c727399b844ca56d62faa9fa9ad05da17df20257",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 90.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "20a19c21-7d51-4f0e-b08b-0e7b308a0dfd"+ ],+ "mutated": true,+ "netName": "NET_2",+ "revision": "98ed1b61125c6dfd29f6605348b7c41788c98a9829f61046afc49281e8a550ee",+ "saved": false,+ "segments": [+ {+ "end": [+ 138.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 90.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "93c32a75-9aa1-4f46-a79b-313d3bc3b9cb"+ ],+ "mutated": true,+ "netName": "NET_2",+ "revision": "4fe70a8d853c9af9ce88c8de9bb0c4f13acfcfa7f1906d5c1a2699682457f147",+ "saved": false,+ "segments": [+ {+ "end": [+ 145.0,+ 90.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 90.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_3",+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "4fe70a8d853c9af9ce88c8de9bb0c4f13acfcfa7f1906d5c1a2699682457f147",+ "segments": [+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "204516c1-6cc1-4f13-81dd-97e88d0f6341",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "8bf10672-a0ee-4cf3-b847-1731ea4a9625",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "c5729098-056b-416c-9034-382de0363fc9",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "20a19c21-7d51-4f0e-b08b-0e7b308a0dfd",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "93c32a75-9aa1-4f46-a79b-313d3bc3b9cb",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "e58f20a2-d5c8-4aea-90f4-2b8062a849d3",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_3",+ "revision": "4fe70a8d853c9af9ce88c8de9bb0c4f13acfcfa7f1906d5c1a2699682457f147",+ "segments": [+ {+ "end": [+ 112.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 95.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 95.0+ ],+ "layer": "B.Cu",+ "start": [+ 112.0,+ 95.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 95.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "at": [+ 112.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ },+ {+ "at": [+ 138.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "7b0015e4-2eea-465d-a520-fc57a8905027"+ ],+ "mutated": true,+ "netName": "NET_3",+ "revision": "f95504196f4e404dca3130231a5f035352684a8b51e1adb91990545814cfb4d2",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 95.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "528f8cfc-8c27-4e95-b4b7-4c02f31f6a7e"+ ],+ "mutated": true,+ "netName": "NET_3",+ "revision": "863c731b7d99636373e76b583a415e9c5a60049481087a1f0de2422d55571554",+ "saved": false,+ "segments": [+ {+ "end": [+ 138.0,+ 95.0+ ],+ "layer": "B.Cu",+ "start": [+ 112.0,+ 95.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "38f4bf28-7160-46f5-b177-084997b9b0a7"+ ],+ "mutated": true,+ "netName": "NET_3",+ "revision": "9c7870163719f0b45e341108c488bc9fe4bef70d4b64bfc83c7638baccdbbbf2",+ "saved": false,+ "segments": [+ {+ "end": [+ 145.0,+ 95.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 95.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "a121391f-e430-4bb6-9b13-4d104d5d554c"+ ],+ "mutated": true,+ "netName": "NET_3",+ "revision": "278200e4aef84ef3728eb8001dfb51213d3a6b9ffcd3f6837256dd3268b210da",+ "saved": false,+ "segments": [],+ "success": true,+ "undoSteps": 1,+ "vias": [+ {+ "at": [+ 112.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "702da6ac-2582-4507-98de-76a296f6d55d"+ ],+ "mutated": true,+ "netName": "NET_3",+ "revision": "c0dc2136c57759e297fba6576d657d863e0f844450ccbe360c210a67151b6f3d",+ "saved": false,+ "segments": [],+ "success": true,+ "undoSteps": 1,+ "vias": [+ {+ "at": [+ 138.0,+ 95.0+ ],+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "size": 0.6+ }+ ]+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_4",+ "NET_5",+ "NET_6"+ ],+ "revision": "c0dc2136c57759e297fba6576d657d863e0f844450ccbe360c210a67151b6f3d",+ "segments": [+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "204516c1-6cc1-4f13-81dd-97e88d0f6341",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "8bf10672-a0ee-4cf3-b847-1731ea4a9625",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "c5729098-056b-416c-9034-382de0363fc9",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "20a19c21-7d51-4f0e-b08b-0e7b308a0dfd",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "93c32a75-9aa1-4f46-a79b-313d3bc3b9cb",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "e58f20a2-d5c8-4aea-90f4-2b8062a849d3",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "38f4bf28-7160-46f5-b177-084997b9b0a7",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "7b0015e4-2eea-465d-a520-fc57a8905027",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "528f8cfc-8c27-4e95-b4b7-4c02f31f6a7e",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "702da6ac-2582-4507-98de-76a296f6d55d"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "a121391f-e430-4bb6-9b13-4d104d5d554c"+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_4",+ "revision": "c0dc2136c57759e297fba6576d657d863e0f844450ccbe360c210a67151b6f3d",+ "segments": [+ {+ "end": [+ 112.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 100.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 100.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 100.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "953ecdff-1d63-4671-83db-6437c2cff3e3"+ ],+ "mutated": true,+ "netName": "NET_4",+ "revision": "dd65dace30efce842eb9cec62bcb768e448cbc5ae0dfa12c2805a7bf7e96a7fa",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 100.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "bba3aceb-fc4a-4ae5-b50d-9871bdd26bef"+ ],+ "mutated": true,+ "netName": "NET_4",+ "revision": "faa1626a71ce2832f32fcbfcbe21863891ed0283cb1b39fb715df3cd80ee70f5",+ "saved": false,+ "segments": [+ {+ "end": [+ 138.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 100.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "8ce6ae36-b30a-4774-94e1-b4f2174e2712"+ ],+ "mutated": true,+ "netName": "NET_4",+ "revision": "ab80f4f5a4477cd95ea353f44152aed8a723c883baf3c7f73bdb53bf2cbb47d8",+ "saved": false,+ "segments": [+ {+ "end": [+ 145.0,+ 100.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 100.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_5",+ "NET_6"+ ],+ "revision": "ab80f4f5a4477cd95ea353f44152aed8a723c883baf3c7f73bdb53bf2cbb47d8",+ "segments": [+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "204516c1-6cc1-4f13-81dd-97e88d0f6341",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "8bf10672-a0ee-4cf3-b847-1731ea4a9625",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "c5729098-056b-416c-9034-382de0363fc9",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "20a19c21-7d51-4f0e-b08b-0e7b308a0dfd",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "93c32a75-9aa1-4f46-a79b-313d3bc3b9cb",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "e58f20a2-d5c8-4aea-90f4-2b8062a849d3",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "38f4bf28-7160-46f5-b177-084997b9b0a7",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "7b0015e4-2eea-465d-a520-fc57a8905027",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "528f8cfc-8c27-4e95-b4b7-4c02f31f6a7e",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "8ce6ae36-b30a-4774-94e1-b4f2174e2712",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "953ecdff-1d63-4671-83db-6437c2cff3e3",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "bba3aceb-fc4a-4ae5-b50d-9871bdd26bef",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "702da6ac-2582-4507-98de-76a296f6d55d"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "a121391f-e430-4bb6-9b13-4d104d5d554c"+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_5",+ "revision": "ab80f4f5a4477cd95ea353f44152aed8a723c883baf3c7f73bdb53bf2cbb47d8",+ "segments": [+ {+ "end": [+ 112.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 105.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 105.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 105.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "5d31746f-f547-4caf-9593-67b195e258d6"+ ],+ "mutated": true,+ "netName": "NET_5",+ "revision": "dc1d00a95899244294471a36365894952705bffbc4ea98173987ae6d5994d2c3",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 105.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "469156d1-1678-4e49-940c-50ff05882b83"+ ],+ "mutated": true,+ "netName": "NET_5",+ "revision": "f93f258ebcbe7c119a9b779ea19eb44eadea38082cc543291745189dd397e491",+ "saved": false,+ "segments": [+ {+ "end": [+ 138.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 105.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "0179ec36-c803-483f-884f-576bcb0eac51"+ ],+ "mutated": true,+ "netName": "NET_5",+ "revision": "39370a641c6b77708b7c7449a5702049ec9e1d7be88210875cf07f6f2a2130ce",+ "saved": false,+ "segments": [+ {+ "end": [+ 145.0,+ 105.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 105.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [+ "NET_6"+ ],+ "revision": "39370a641c6b77708b7c7449a5702049ec9e1d7be88210875cf07f6f2a2130ce",+ "segments": [+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "204516c1-6cc1-4f13-81dd-97e88d0f6341",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "8bf10672-a0ee-4cf3-b847-1731ea4a9625",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "c5729098-056b-416c-9034-382de0363fc9",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "20a19c21-7d51-4f0e-b08b-0e7b308a0dfd",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "93c32a75-9aa1-4f46-a79b-313d3bc3b9cb",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "e58f20a2-d5c8-4aea-90f4-2b8062a849d3",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "38f4bf28-7160-46f5-b177-084997b9b0a7",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "7b0015e4-2eea-465d-a520-fc57a8905027",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "528f8cfc-8c27-4e95-b4b7-4c02f31f6a7e",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "8ce6ae36-b30a-4774-94e1-b4f2174e2712",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "953ecdff-1d63-4671-83db-6437c2cff3e3",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "bba3aceb-fc4a-4ae5-b50d-9871bdd26bef",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 138.0,+ "y": 105.0+ },+ "uuid": "0179ec36-c803-483f-884f-576bcb0eac51",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 112.0,+ "y": 105.0+ },+ "uuid": "469156d1-1678-4e49-940c-50ff05882b83",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 105.0,+ "y": 105.0+ },+ "uuid": "5d31746f-f547-4caf-9593-67b195e258d6",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "702da6ac-2582-4507-98de-76a296f6d55d"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "a121391f-e430-4bb6-9b13-4d104d5d554c"+ }+ ]+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "dryRun": true,+ "netName": "NET_6",+ "revision": "39370a641c6b77708b7c7449a5702049ec9e1d7be88210875cf07f6f2a2130ce",+ "segments": [+ {+ "end": [+ 112.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 138.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 145.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 125.0,+ 113.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 110.0+ ],+ "width": 0.25+ },+ {+ "end": [+ 125.0,+ 116.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 113.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "d69a2efd-9c3f-43b2-a6ea-d6ebb96fed7f"+ ],+ "mutated": true,+ "netName": "NET_6",+ "revision": "4ffc5ec1d7e00106f0bf944611a247c752e6e7b51ad32b9695d59aa1c98011b4",+ "saved": false,+ "segments": [+ {+ "end": [+ 112.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 105.0,+ 110.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "776a005b-2c14-41c4-b8c0-bc6275afd081"+ ],+ "mutated": true,+ "netName": "NET_6",+ "revision": "cfc783531549ad3a8cec5356bd89f406e28e5bf7a14dbde58a473acadde8851e",+ "saved": false,+ "segments": [+ {+ "end": [+ 138.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 112.0,+ 110.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "95875e34-9dba-4966-934e-c7c2f76ceca0"+ ],+ "mutated": true,+ "netName": "NET_6",+ "revision": "2ddc0faa3c8a73565730aaae62216c65948258b58097d57f5e77acc71e0dec75",+ "saved": false,+ "segments": [+ {+ "end": [+ 145.0,+ 110.0+ ],+ "layer": "F.Cu",+ "start": [+ 138.0,+ 110.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "974edd43-d2f4-4766-806a-d1143b8be7f7"+ ],+ "mutated": true,+ "netName": "NET_6",+ "revision": "55952ed763dac07788d8d59f2284a13cf5c1f69b3db1f77af25f7901e1eb689a",+ "saved": false,+ "segments": [+ {+ "end": [+ 125.0,+ 113.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 110.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_route_net",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "itemIds": [+ "92fe9465-3af5-482c-9136-9869f0ae8452"+ ],+ "mutated": true,+ "netName": "NET_6",+ "revision": "2a370857996036674c8c3ef17eacc87e7ac8c19f48a993ea87bdb69151fa51f4",+ "saved": true,+ "segments": [+ {+ "end": [+ 125.0,+ 116.0+ ],+ "layer": "F.Cu",+ "start": [+ 125.0,+ 113.0+ ],+ "width": 0.25+ }+ ],+ "success": true,+ "undoSteps": 1,+ "vias": []+ }+ },+ {+ "verb": "kicad_routing_state",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "netsRemaining": [],+ "revision": "2a370857996036674c8c3ef17eacc87e7ac8c19f48a993ea87bdb69151fa51f4",+ "segments": [+ {+ "end": {+ "x": 112.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 105.0,+ "y": 85.0+ },+ "uuid": "204516c1-6cc1-4f13-81dd-97e88d0f6341",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 112.0,+ "y": 85.0+ },+ "uuid": "8bf10672-a0ee-4cf3-b847-1731ea4a9625",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 85.0+ },+ "layer": "F.Cu",+ "net_name": "NET_1",+ "start": {+ "x": 138.0,+ "y": 85.0+ },+ "uuid": "c5729098-056b-416c-9034-382de0363fc9",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 112.0,+ "y": 90.0+ },+ "uuid": "20a19c21-7d51-4f0e-b08b-0e7b308a0dfd",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 138.0,+ "y": 90.0+ },+ "uuid": "93c32a75-9aa1-4f46-a79b-313d3bc3b9cb",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 90.0+ },+ "layer": "F.Cu",+ "net_name": "NET_2",+ "start": {+ "x": 105.0,+ "y": 90.0+ },+ "uuid": "e58f20a2-d5c8-4aea-90f4-2b8062a849d3",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 138.0,+ "y": 95.0+ },+ "uuid": "38f4bf28-7160-46f5-b177-084997b9b0a7",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 95.0+ },+ "layer": "F.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 105.0,+ "y": 95.0+ },+ "uuid": "7b0015e4-2eea-465d-a520-fc57a8905027",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 95.0+ },+ "layer": "B.Cu",+ "net_name": "NET_3",+ "start": {+ "x": 112.0,+ "y": 95.0+ },+ "uuid": "528f8cfc-8c27-4e95-b4b7-4c02f31f6a7e",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 138.0,+ "y": 100.0+ },+ "uuid": "8ce6ae36-b30a-4774-94e1-b4f2174e2712",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 105.0,+ "y": 100.0+ },+ "uuid": "953ecdff-1d63-4671-83db-6437c2cff3e3",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 100.0+ },+ "layer": "F.Cu",+ "net_name": "NET_4",+ "start": {+ "x": 112.0,+ "y": 100.0+ },+ "uuid": "bba3aceb-fc4a-4ae5-b50d-9871bdd26bef",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 138.0,+ "y": 105.0+ },+ "uuid": "0179ec36-c803-483f-884f-576bcb0eac51",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 112.0,+ "y": 105.0+ },+ "uuid": "469156d1-1678-4e49-940c-50ff05882b83",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 105.0+ },+ "layer": "F.Cu",+ "net_name": "NET_5",+ "start": {+ "x": 105.0,+ "y": 105.0+ },+ "uuid": "5d31746f-f547-4caf-9593-67b195e258d6",+ "width": 0.25+ },+ {+ "end": {+ "x": 138.0,+ "y": 110.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 112.0,+ "y": 110.0+ },+ "uuid": "776a005b-2c14-41c4-b8c0-bc6275afd081",+ "width": 0.25+ },+ {+ "end": {+ "x": 125.0,+ "y": 116.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 125.0,+ "y": 113.0+ },+ "uuid": "92fe9465-3af5-482c-9136-9869f0ae8452",+ "width": 0.25+ },+ {+ "end": {+ "x": 145.0,+ "y": 110.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 138.0,+ "y": 110.0+ },+ "uuid": "95875e34-9dba-4966-934e-c7c2f76ceca0",+ "width": 0.25+ },+ {+ "end": {+ "x": 125.0,+ "y": 113.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 125.0,+ "y": 110.0+ },+ "uuid": "974edd43-d2f4-4766-806a-d1143b8be7f7",+ "width": 0.25+ },+ {+ "end": {+ "x": 112.0,+ "y": 110.0+ },+ "layer": "F.Cu",+ "net_name": "NET_6",+ "start": {+ "x": 105.0,+ "y": 110.0+ },+ "uuid": "d69a2efd-9c3f-43b2-a6ea-d6ebb96fed7f",+ "width": 0.25+ }+ ],+ "success": true,+ "vias": [+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 138.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "702da6ac-2582-4507-98de-76a296f6d55d"+ },+ {+ "drill": 0.3,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "net_name": "NET_3",+ "position": {+ "x": 112.0,+ "y": 95.0+ },+ "size": 0.6,+ "uuid": "a121391f-e430-4bb6-9b13-4d104d5d554c"+ }+ ]+ }+ },+ {+ "verb": "kicad_routing_validate",+ "result": {+ "bridgeVersion": "0.10.0-dev.per-net.1",+ "clean": false,+ "errors": 0,+ "revision": "2a370857996036674c8c3ef17eacc87e7ac8c19f48a993ea87bdb69151fa51f4",+ "stale": false,+ "success": true,+ "unconnected": 0,+ "warnings": 3+ }+ }+]
demo/routing/make_fixture.pyadded+42@@ -0,0 +1,42 @@+"""Create a small, entirely unrouted two-layer routing regression board."""+from pathlib import Path+import json+import uuid+++def make(directory):+ directory = Path(directory)+ directory.mkdir(parents=True, exist_ok=True)+ base = Path(__file__).resolve().parents[2] / 'templates/blank-board.kicad_pcb'+ text = base.read_text()+ blocks=[]+ def footprint(ref,x,y,pads):+ body=f'(footprint "RoutingFixture:{ref}" (layer "F.Cu") (uuid "{uuid.uuid4()}") (at {x} {y})\n'+ body+=f'(property "Reference" "{ref}" (at 0 -3) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))\n'+ body+=f'(property "Value" "ROUTING_TEST" (at 0 -5) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))\n'+ body+='(attr through_hole exclude_from_pos_files exclude_from_bom)\n'+ for number, px, py, net in pads:+ body+=f'(pad "{number}" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers "*.Cu" "*.Mask") (net "{net}") (uuid "{uuid.uuid4()}"))\n'+ blocks.append(body+')\n')+ footprint('J1',105,85,[(str(i),0,(i-1)*5,f'NET_{i}') for i in range(1,7)])+ footprint('J2',145,85,[(str(i),0,(i-1)*5,f'NET_{i}') for i in range(1,7)])+ footprint('J3',125,116,[('1',0,0,'NET_6')])+ blocks.append(f'(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer "Edge.Cuts") (uuid "{uuid.uuid4()}"))\n')+ output=directory/'live-routing.kicad_pcb'+ output.write_text(text[:text.rfind(')')]+''.join(blocks)+')\n')+ plan=[]+ for i in range(1,7):+ y=85+(i-1)*5+ points=[f'J1.{i}',[112,y],[138,y],f'J2.{i}']+ if i==3:+ points=[f'J1.{i}',{'x':112,'y':y,'layer':'B.Cu'},{'x':138,'y':y,'layer':'F.Cu'},f'J2.{i}']+ row={'net':f'NET_{i}','width':0.25,'points':points}+ if i==6:+ row.pop('points'); row['paths']=[points,[[125,y], [125,113], 'J3.1']]+ plan.append(row)+ (directory/'plan.json').write_text(json.dumps(plan,indent=2)+'\n')+ return output++if __name__=='__main__':+ import sys+ print(make(sys.argv[1] if len(sys.argv)>1 else Path(__file__).parent/'fixture'))
demo/routing/run_demo.pyadded+78@@ -0,0 +1,78 @@+"""Replay explicit net waypoints through ab in a live KiCad editor.++Require --target and ADOM_API for a remote test. Never mutates anything unless+routing_state proves the correct board and the expected dev build is installed.+"""+import argparse+import json+import os+from pathlib import Path+import subprocess+import time+++def call(target, verb, args):+ proc=subprocess.run(['adom-bridge','--ai-thread','KiCad per-net routing','--target',target,+ verb,json.dumps(args)],capture_output=True,text=True,timeout=240)+ result=json.loads(proc.stdout)+ if result.get('success') is False or result.get('status') == 'error':+ raise RuntimeError(f'{verb}: {result.get("errorCode")}: {result.get("error")}')+ return result+++def main():+ p=argparse.ArgumentParser(description=__doc__)+ p.add_argument('--target',required=True)+ p.add_argument('--board',required=True)+ p.add_argument('--plan',required=True)+ p.add_argument('--output',default='routing-evidence')+ p.add_argument('--per-trace',action='store_true')+ p.add_argument('--socket')+ p.add_argument('--save',action='store_true')+ a=p.parse_args()+ out=Path(a.output);out.mkdir(parents=True,exist_ok=True)+ common={'filePath':a.board}+ if a.socket:common['socketPath']=a.socket+ records=[]+ def invoke(verb,args):+ r=call(a.target,verb,{**common,**args})+ records.append({'verb':verb,'result':r})+ (out/'report.json').write_text(json.dumps(records,indent=2))+ return r+ state=invoke('kicad_routing_state',{})+ if not state.get('revision') or not state.get('bridgeVersion','').startswith('0.10.0-dev.per-net.'):+ raise RuntimeError('Expected live routing development build; refusing to continue')+ if state['segments'] or state['vias']:+ raise RuntimeError('Demo requires an unrouted scratch board; will not remove existing copper')+ (out/'before.json').write_text(json.dumps(state,indent=2))+ plans=json.loads(Path(a.plan).read_text())+ for index,plan in enumerate(plans):+ revision=state['revision']+ preview=invoke('kicad_route_net',{**plan,'expectedRevision':revision,'dryRun':True})+ work=[plan]+ if a.per_trace:+ work=[{'net':plan['net'],'width':s['width'],'layer':s['layer'],'points':[s['start'],s['end']]}+ for s in preview['segments']]+ work.extend({'net':plan['net'],'layer':'F.Cu','viaSize':v['size'],'viaDrill':v['drill'],+ 'points':[v['at'],{'x':v['at'][0],'y':v['at'][1],'layer':'B.Cu'}]}+ for v in preview['vias'])+ for item_index,item in enumerate(work):+ # The complete net was DRC-checked above. Replay its exact steps+ # against consecutive revisions, then check final board DRC.+ result=invoke('kicad_route_net',{**item,'expectedRevision':revision,+ 'validate':not a.per_trace,+ 'save':a.save and index==len(plans)-1 and item_index==len(work)-1})+ if not result.get('mutated') or not result.get('revision'):+ raise RuntimeError('Mutation outcome incomplete; inspect the board before retrying')+ revision=result['revision']+ print(f"{plan['net']}: step {item_index+1}/{len(work)}, {len(result['itemIds'])} copper items committed",flush=True)+ time.sleep(0.4)+ state=invoke('kicad_routing_state',{})+ print('Nets remaining:',len(state['netsRemaining']),flush=True)+ final=invoke('kicad_routing_validate',{})+ (out/'after.json').write_text(json.dumps(state,indent=2))+ if state['netsRemaining'] or final['unconnected'] or final['errors'] or final['stale']:+ raise RuntimeError('Final connectivity/DRC failed: '+str({k:final[k] for k in ['errors','warnings','unconnected','stale']}))+ print(json.dumps({k:final[k] for k in ['errors','warnings','unconnected','clean']}),flush=True)++if __name__=='__main__':main()
handlers/live_routing.pyadded+420@@ -0,0 +1,420 @@+"""Live, undoable copper edits through the official KiCad IPC API (KiCad 10.0.1+).++The caller plans the path. KiCad checks candidate copper using a snapshot before+we commit it. No File/Revert, background clicks, or writes to the open board file.+"""+from __future__ import annotations++from collections import Counter+from contextlib import contextmanager+import hashlib+import json+import math+import os+from pathlib import Path+import shutil+import subprocess+import sys+import tempfile+import threading++from handlers import route+from parsers.pcb import parse_pcb_text++_LOCK = threading.RLock()+_DEPS = Path(__file__).resolve().parents[1] / "routing_deps"+if _DEPS.is_dir():+ sys.path.insert(0, str(_DEPS))+++class RoutingError(Exception):+ def __init__(self, code, message, **detail):+ super().__init__(message)+ self.code, self.detail = code, detail+++def _error(exc):+ return {"success": False, "errorCode": getattr(exc, "code", "ipc_unavailable"),+ "error": str(exc), **getattr(exc, "detail", {}),+ "_hint": "Read kicad_routing_state with the exact filePath and socketPath. "+ "Enable Preferences > Plugins > Enable IPC API server in KiCad 10.0.1+. "+ "Install requirements-routing.txt with the bridge's Python. "+ "After a mutation timeout inspect the live board; never blindly replay it."}+++def _path(value):+ if not isinstance(value, str) or not Path(value).is_absolute():+ raise RoutingError("invalid_board_path", "filePath must be an absolute .kicad_pcb path")+ if Path(value).suffix.lower() != ".kicad_pcb":+ raise RoutingError("invalid_board_path", "filePath must name a .kicad_pcb")+ return os.path.normcase(os.path.realpath(value))+++@contextmanager+def _connect(args):+ from kipy import KiCad+ from kipy.board import Board+ from kipy.proto.common.types.base_types_pb2 import DocumentType+ wanted = _path(args.get("filePath"))+ socket = args.get("socketPath")+ if socket is not None and (not isinstance(socket, str) or not socket.startswith("ipc://")):+ raise RoutingError("invalid_socket", "socketPath must be a local ipc:// socket")+ api = KiCad(socket_path=socket, client_name="adom-routing-" + os.urandom(8).hex(), timeout_ms=5000)+ try:+ version = api.get_version()+ if (version.major, version.minor, version.patch) < (10, 0, 1):+ raise RoutingError("unsupported_kicad", "Live routing requires KiCad 10.0.1 or newer")+ docs = api.get_open_documents(DocumentType.DOCTYPE_PCB)+ def document_path(d):+ filename = Path(d.board_filename)+ return str(filename if filename.is_absolute() else Path(d.project.path) / filename)+ matches = [d for d in docs if _path(document_path(d)) == wanted]+ if len(matches) != 1:+ raise RoutingError("board_mismatch", "This IPC endpoint does not expose exactly the requested board",+ openBoards=[document_path(d) for d in docs])+ yield Board(api._client, matches[0])+ finally:+ # kicad-python 0.8 has no public close/context-manager API.+ if api._client.connected:+ api._client._conn.close()+++def _snapshot(board, args):+ text = board.get_as_string()+ data = parse_pcb_text(text, args["filePath"])+ return text, data, hashlib.sha256(text.encode()).hexdigest()+++def _revision(args, revision):+ if args.get("expectedRevision") != revision:+ raise RoutingError("stale_board", "expectedRevision must match the current live board",+ currentRevision=revision, mutated=False)+++def _number(value, label, positive=False):+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):+ raise RoutingError("invalid_geometry", f"{label} must be a finite number")+ if abs(value) > 2000 or (positive and value <= 0):+ raise RoutingError("invalid_geometry", f"{label} is outside the supported board range")+ rounded = round(value, 6)+ if positive and rounded <= 0:+ raise RoutingError("invalid_geometry", f"{label} is below KiCad nanometre precision")+ return rounded+++def _plan(data, args):+ """Pure planning/validation; every waypoint is checked before any mutation."""+ netname = args.get("net")+ if not isinstance(netname, str) or not netname:+ raise RoutingError("missing_net", "net must be an exact, nonempty net name")+ nets = [n for n in data["nets"] if n["name"] == netname]+ if len(nets) != 1:+ raise RoutingError("unknown_net", f"No unique net named {netname!r}")+ net = {"name": netname, "number": nets[0].get("number"), "format": data["net_format"]}+ pads = route._pads(data)+ copper = route._copper_layers(data)+ width = _number(args.get("width", 0.25), "width", True)+ size = _number(args.get("viaSize", 0.6), "viaSize", True)+ drill = _number(args.get("viaDrill", 0.3), "viaDrill", True)+ if drill >= size:+ raise RoutingError("invalid_geometry", "viaDrill must be smaller than viaSize")+ paths = args.get("paths", [args.get("points")])+ if not isinstance(paths, list) or not paths or len(paths) > 128:+ raise RoutingError("invalid_path", "Supply points or up to 128 paths for one net")+ segments, vias, blocks = [], [], []+ for path in paths:+ if not isinstance(path, list) or not 2 <= len(path) <= 512:+ raise RoutingError("invalid_path", "Each path needs 2..512 waypoints")+ resolved = []+ for spec in path:+ coordinates = None+ if isinstance(spec, (list, tuple)):+ if len(spec) != 2:+ raise RoutingError("invalid_geometry", "Coordinates require exactly [x, y]")+ coordinates = spec+ elif isinstance(spec, dict) and not spec.get("pad"):+ coordinates = [spec.get("x"), spec.get("y")]+ if coordinates is not None:+ for value in coordinates:+ _number(value, "coordinate")+ point, pad, err = route._resolve_point(spec, pads)+ if err:+ raise RoutingError(err["errorCode"], err["error"])+ point = [_number(v, "coordinate") for v in point]+ if pad and pad["netName"] != netname:+ raise RoutingError("net_mismatch", f"Pad {pad['name']} is not on {netname}")+ layer = spec.get("layer") if isinstance(spec, dict) else None+ if layer is not None and layer not in copper:+ raise RoutingError("unknown_layer", f"Unknown copper layer: {layer}")+ resolved.append((point, pad, layer))+ layer = args.get("layer", resolved[0][2] or (resolved[0][1] or {}).get("side") or "F.Cu")+ if layer not in copper:+ raise RoutingError("unknown_layer", f"Unknown copper layer: {layer}")+ for i, (point, pad, explicit_layer) in enumerate(resolved):+ next_layer = explicit_layer or layer+ if i:+ previous = resolved[i - 1][0]+ if previous != point:+ segments.append({"start": previous, "end": point, "layer": layer, "width": width})+ blocks.append(route._segment_sexpr(previous, point, width, layer, net))+ # A layer marker changes the outgoing layer AT this point, including+ # the final waypoint (where a via can reach a back-side pad).+ if next_layer != layer:+ vias.append({"at": point, "layers": ["F.Cu", "B.Cu"], "size": size, "drill": drill})+ blocks.append(route._via_sexpr(point, size, drill, ["F.Cu", "B.Cu"], net))+ layer = next_layer+ if pad and not (layer in pad["layers"] or "*.Cu" in pad["layers"]):+ raise RoutingError("pad_layer_mismatch", f"Pad {pad['name']} has no copper on {layer}")+ if not segments and not vias:+ raise RoutingError("zero_length", "No nonzero segments or layer transitions")+ if len(segments) + len(vias) > 1024:+ raise RoutingError("route_too_large", "Limit each call to 1024 copper items")+ return {"netName": netname, "segments": segments, "vias": vias, "blocks": blocks}+++def _drc(info, args, text):+ exe = info.get("kicad_cli_exe")+ if not exe or not Path(exe).is_file():+ raise RoutingError("drc_unavailable", "kicad-cli is required for routing validation")+ source = Path(args["filePath"])+ with tempfile.TemporaryDirectory(prefix="adom-routing-drc-") as tmp:+ candidate = Path(tmp) / source.name+ candidate.write_text(text, encoding="utf-8")+ # Preserve the actual project's rules and exclusions; never save/revert+ # the live document merely to get a headless DRC snapshot.+ copied = []+ for suffix in (".kicad_pro", ".kicad_dru"):+ sibling = source.with_suffix(suffix)+ if sibling.exists():+ shutil.copy2(sibling, candidate.with_suffix(suffix))+ copied.append(str(sibling))+ report = Path(tmp) / "drc.json"+ proc = subprocess.run([exe, "pcb", "drc", "--format", "json", "--severity-all",+ "--all-track-errors", "--output", str(report), str(candidate)],+ capture_output=True, text=True, timeout=90)+ if proc.returncode or not report.is_file():+ raise RoutingError("drc_failed", proc.stderr or proc.stdout or "No DRC report")+ result = json.loads(report.read_text(encoding="utf-8"))+ violations = result.get("violations", [])+ unconnected = result.get("unconnected_items", [])+ return {"errors": sum(v.get("severity") == "error" for v in violations),+ "warnings": sum(v.get("severity") == "warning" for v in violations),+ "unconnected": len(unconnected), "violations": violations,+ "unconnectedItems": unconnected,+ "clean": not violations and not unconnected,+ "source": "live-editor-snapshot", "projectRulesCopied": copied}+++def _fingerprint(v):+ return json.dumps([v.get("type"), v.get("severity"), v.get("description"),+ sorted(json.dumps(i.get("pos"), sort_keys=True) for i in v.get("items", []))], sort_keys=True)+++def _new_errors(before, after):+ existing = Counter(_fingerprint(v) for v in before["violations"] if v.get("severity") == "error")+ added = []+ for v in after["violations"]:+ if v.get("severity") != "error":+ continue+ key = _fingerprint(v)+ if existing[key]:+ existing[key] -= 1+ else:+ added.append(v)+ return added+++def _items(plan, board):+ from kipy.board_types import Track, Via+ from kipy.geometry import Vector2+ from kipy.proto.board.board_types_pb2 import BoardLayer+ net = next(n for n in board.get_nets() if n.name == plan["netName"])+ def vector(point):+ return Vector2.from_xy(*(round(x * 1_000_000) for x in point))+ items = []+ for row in plan["segments"]:+ item = Track()+ item.start, item.end = vector(row["start"]), vector(row["end"])+ item.width = round(row["width"] * 1_000_000)+ item.layer = BoardLayer.Value("BL_" + row["layer"].replace(".", "_"))+ item.net = net+ items.append(item)+ for row in plan["vias"]:+ item = Via()+ item.position = vector(row["at"])+ item.diameter = round(row["size"] * 1_000_000)+ item.drill_diameter = round(row["drill"] * 1_000_000)+ item.net = net+ items.append(item)+ return items+++def _transaction(board, operation, message):+ commit = board.begin_commit()+ pushing = False+ try:+ result = operation()+ pushing = True+ board.push_commit(commit, message)+ return result+ except Exception as exc:+ try:+ board.drop_commit(commit)+ except Exception as rollback:+ raise RoutingError("mutation_outcome_unknown", str(exc), rollbackError=str(rollback),+ mutated=None) from exc+ if pushing:+ raise RoutingError("mutation_outcome_unknown", str(exc), mutated=None,+ rollbackAttempted=True) from exc+ raise RoutingError("mutation_rolled_back", str(exc), mutated=False) from exc+++def handle_routing_state(info, args):+ try:+ with _LOCK, _connect(args) as board:+ text, data, revision = _snapshot(board, args)+ pads = route._pads(data)+ nets = []+ live_pads = board.get_pads()+ for name in sorted(n["name"] for n in data["nets"] if n["name"]):+ net_pads = [p for p in live_pads if p.net.name == name]+ remaining = {p.id.value: p for p in net_pads}+ groups = []+ while remaining:+ seed = next(iter(remaining.values()))+ seen, frontier = {seed.id.value}, [seed]+ while frontier:+ neighbors = board.get_connected_items(frontier)+ frontier = [n for n in neighbors if n.id.value not in seen]+ seen.update(n.id.value for n in frontier)+ if len(seen) > 100000:+ raise RoutingError("board_too_large", "Connectivity walk exceeds 100000 items")+ group = [key for key in remaining if key in seen]+ for key in group:+ remaining.pop(key)+ groups.append(group)+ nets.append({"name": name, "padCount": len(net_pads), "padGroups": groups,+ "connected": len(groups) <= 1, "missingConnections": max(0, len(groups) - 1)})+ current = _snapshot(board, args)[2]+ if current != revision:+ raise RoutingError("stale_board", "Board changed during connectivity inspection; read again", currentRevision=current)+ return {"success": True, "boardPath": args["filePath"], "revision": revision,+ "source": "live-editor", "pads": pads, "nets": nets,+ "netsRemaining": [n["name"] for n in nets if not n["connected"]],+ "segments": data["segments"], "vias": data["vias"],+ "copperLayers": route._copper_layers(data),+ "_hint": "Use this revision as expectedRevision in kicad_route_net. Coordinates are mm, +y down. "+ "Connectivity is measured by KiCad; kicad_routing_validate checks clearance and unconnected items."}+ except Exception as exc:+ return _error(exc)+++def handle_route_net(info, args):+ try:+ with _LOCK, _connect(args) as board:+ text, data, revision = _snapshot(board, args)+ _revision(args, revision)+ plan = _plan(data, args)+ check = None+ if args.get("validate", True) is not False:+ before = _drc(info, args, text)+ check = _drc(info, args, route._splice(text, plan["blocks"]))+ added = _new_errors(before, check)+ if added:+ raise RoutingError("drc_rejected", "Candidate route adds DRC errors; no live copper was changed",+ violations=added, mutated=False)+ public = {k: v for k, v in plan.items() if k != "blocks"}+ if args.get("dryRun") is True:+ return {"success": True, "dryRun": True, "revision": revision, **public,+ "drc": check, "_hint": "Plan validated without editing. Submit with dryRun:false and the same revision."}+ # DRC runs outside KiCad. Re-read immediately before begin_commit so+ # another caller/user's edits during validation invalidate this plan.+ _revision(args, _snapshot(board, args)[2])+ items = _items(plan, board)+ def create():+ created = board.create_items(items)+ if len(created) != len(items) or any(not i.id.value for i in created):+ raise RuntimeError("KiCad did not create every requested copper item")+ return created+ created = _transaction(board, create, "Adom route " + plan["netName"])+ result = {"success": True, "mutated": True, "source": "live-editor", **public,+ "itemIds": [i.id.value for i in created], "undoSteps": 1,+ "drc": check, "saved": False,+ "_hint": "Copper committed live as one KiCad Undo step. Inspect routing_state before the next route. "+ "The file is saved only with save:true; routing_validate checks the unsaved live board."}+ # Commit succeeded. A subsequent read/save failure must NEVER be+ # reported as a failed route inviting the caller to add it twice.+ try:+ result["revision"] = _snapshot(board, args)[2]+ if args.get("save") is True:+ board.save()+ result["saved"] = True+ except Exception as exc:+ result["postCommitError"] = str(exc)+ return result+ except Exception as exc:+ return _error(exc)+++def _delete_items(board, items):+ # kipy 0.8 discards DeleteItemsResponse. Validate every result before+ # pushing: pending deletions remain visible to GetItems until commit.+ from kipy.proto.common.commands.editor_commands_pb2 import DeleteItems, DeleteItemsResponse, IDS_OK+ from kipy.proto.common.types.base_types_pb2 import IRS_OK+ request = DeleteItems()+ request.header.document.CopyFrom(board._doc)+ request.item_ids.extend(i.id for i in items)+ response = board._kicad.send(request, DeleteItemsResponse)+ expected = {i.id.value for i in items}+ received = {r.id.value for r in response.deleted_items if r.status == IDS_OK}+ if response.status != IRS_OK:+ raise RuntimeError("KiCad rejected the copper deletion request")+ # KiCad 10.0.5 returns an empty per-item list (upstream MR !2583).+ # We precheck all IDs/locks and verify the board after push in that case.+ if response.deleted_items and (len(response.deleted_items) != len(items) or received != expected):+ raise RuntimeError("KiCad did not accept every requested copper deletion")+++def handle_remove_route(info, args):+ try:+ with _LOCK, _connect(args) as board:+ _, _, revision = _snapshot(board, args)+ _revision(args, revision)+ ids = args.get("itemIds")+ if not isinstance(ids, list) or not ids or len(ids) > 1024 or any(not isinstance(i, str) for i in ids):+ raise RoutingError("invalid_item_ids", "Supply 1..1024 copper itemIds returned by route_net")+ wanted = set(ids)+ copper = list(board.get_tracks()) + list(board.get_vias())+ items = [i for i in copper if i.id.value in wanted]+ if len(items) != len(wanted) or any(i.locked for i in items):+ raise RoutingError("invalid_item_ids", "Some IDs are missing, locked, or are not copper; nothing removed")+ _revision(args, _snapshot(board, args)[2])+ _transaction(board, lambda: _delete_items(board, items), "Adom remove route")+ result = {"success": True, "mutated": True, "removedIds": ids, "undoSteps": 1,+ "_hint": "Copper removed live. Use KiCad Undo to restore it, or inspect routing_state and route again."}+ try:+ _, after, result["revision"] = _snapshot(board, args)+ remaining = wanted & {i.get("uuid") for key in ("segments", "vias") for i in after[key]}+ if remaining:+ result["removedIds"] = sorted(wanted - remaining)+ result["postCommitError"] = "Some requested copper remains after commit; inspect the live board"+ result["verified"] = not remaining+ except Exception as exc:+ result["postCommitError"] = str(exc)+ return result+ except Exception as exc:+ return _error(exc)+++def handle_routing_validate(info, args):+ try:+ with _LOCK, _connect(args) as board:+ text, _, revision = _snapshot(board, args)+ result = _drc(info, args, text)+ current = _snapshot(board, args)[2]+ return {"success": True, "revision": revision, "currentRevision": current,+ "stale": current != revision, **result,+ "_hint": "This is KiCad DRC on the live editor snapshot with sibling project rules. "+ "A stale result is superseded by newer edits. clean requires no violations or unconnected items."}+ except Exception as exc:+ return _error(exc)
handlers/route.pyadded+618@@ -0,0 +1,618 @@+"""Handlers for the copper-drawing verbs: add_track, add_via, route, board_pads.++DETERMINISTIC routing -- the same s-expression approach `place_footprint` proved,+pointed at the user's REAL board instead of a preview. A track is+`(segment (start ..) (end ..) (width ..) (layer ..) (net ..) (uuid ..))` spliced+in before the board's final paren; a via is `(via (at ..) (size ..) (drill ..)+(layers ..) (net ..) (uuid ..))`. No GUI, no protobuf, no autorouter.++The AI is the router. `board_pads` hands it pad centres in BOARD coordinates+with their nets; it picks the path; `add_track` (one segment at a time, for a+demo you can watch) or `route` (a whole polyline in one write) lays the copper.++Coordinates are millimetres in KiCad's board frame: +x right, **+y DOWN**.++Open boards are refused before writes. Use kicad_route_net for live editor+transactions; these file verbs never invoke File > Revert.+"""++from __future__ import annotations++import math+import json+import os+import shutil+import sys+import tempfile+import uuid+from collections import Counter+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent.parent))+from parsers.pcb import parse_pcb_text # noqa: E402++IS_WINDOWS = sys.platform == "win32"++DEFAULT_WIDTH = 0.2+DEFAULT_VIA_SIZE = 0.6+DEFAULT_VIA_DRILL = 0.3+DEFAULT_LAYER = "F.Cu"+++# ── numbers / geometry ───────────────────────────────────────────────++def _num(v) -> str:+ """KiCad-style trimmed float ('1.5', not '1.500000')."""+ s = f"{float(v):.6f}".rstrip("0").rstrip(".")+ return "0" if s in ("", "-", "-0") else s+++def _dist(a, b) -> float:+ return math.hypot(b[0] - a[0], b[1] - a[1])+++# ── board loading ────────────────────────────────────────────────────++def _load(args: dict, *, read_only=False):+ """(path, text, board, err). `err` is a ready-to-return dict on failure."""+ raw = args.get("filePath") or args.get("boardPath") or ""+ if not raw:+ return None, "", None, {+ "success": False, "error": "No filePath specified", "errorCode": "missing_arg",+ "_hint": "Pass the absolute path to a .kicad_pcb, e.g. "+ '{"filePath":"C:/designs/foo/foo.kicad_pcb"}. '+ "Call kicad_board_pads on it first to see pads, nets and copper layers."}+ path = Path(raw)+ if not read_only and (_lock_file(path) or _editor_hwnd(path)):+ return None, "", None, {"success": False, "errorCode": "board_open",+ "error": "This board is open in KiCad; file routing would conflict with live edits",+ "_hint": "Use kicad_routing_state and kicad_route_net for live edits, or close this board before offline routing."}+ if path.suffix.lower() != ".kicad_pcb":+ return None, "", None, {+ "success": False, "error": f"not a board file: {path.name}", "errorCode": "not_a_board",+ "_hint": "These verbs edit a .kicad_pcb. For a schematic there is nothing to route."}+ if not path.exists():+ return None, "", None, {+ "success": False, "error": f"board not found: {path}", "errorCode": "board_not_found",+ "_hint": "Absolute path, no %VAR% expansion. kicad_place_footprint can make a scratch "+ "board to route on if you have no project yet."}+ try:+ text = path.read_text(encoding="utf-8")+ except OSError as e:+ return None, "", None, {"success": False, "error": f"could not read board: {e}",+ "errorCode": "board_unreadable", "_hint": "Is the file locked by another process?"}+ if not text.lstrip().startswith("(kicad_pcb"):+ return None, "", None, {+ "success": False, "error": "file does not start with '(kicad_pcb'",+ "errorCode": "not_a_board",+ "_hint": "A legacy .brd or a truncated file. kicad_format_upgrade converts old formats."}+ try:+ board = parse_pcb_text(text, path)+ except Exception as e: # pylint: disable=broad-except+ return None, "", None, {+ "success": False, "error": f"could not parse board: {e}", "errorCode": "board_unparsable",+ "_hint": "Our reader is not KiCad's parser. kicad_lint_board gets KiCad's own verdict."}+ return path, text, board, None+++def _copper_layers(board: dict) -> list[str]:+ return [l.get("name", "") for l in board.get("layers", []) if l.get("name", "").endswith(".Cu")]+++# ── nets ─────────────────────────────────────────────────────────────++def _net_key(board: dict, number, name):+ """The identity of a net on THIS board: its number pre-KiCad-10, else its name."""+ return number if board.get("net_format") == "number" else (name or "")+++def _resolve_net(board: dict, spec) -> tuple[dict | None, dict | None]:+ """Resolve a net name or number to a reference this board can carry.++ Never invents one: a track on a net the board does not know is copper KiCad+ will not connect to anything.+ """+ nets = board.get("nets", [])+ fmt = board.get("net_format", "number")+ names = [n.get("name", "") for n in nets]++ # Strings are always exact names, including numeric names such as "123".+ if spec is None:+ spec = ""+ if isinstance(spec, str):+ for n in nets:+ if n.get("name", "") == spec:+ return {"format": fmt, "number": n.get("number"), "name": spec,+ "key": _net_key(board, n.get("number"), spec)}, None+ elif not isinstance(spec, bool) and isinstance(spec, (int, float)) and math.isfinite(spec) and spec == int(spec):+ if fmt == "name" and spec == 0:+ return {"format": fmt, "number": None, "name": "", "key": ""}, None+ if fmt == "number":+ for n in nets:+ if n.get("number") == spec:+ return {"format": fmt, "number": int(spec), "name": n.get("name", ""), "key": int(spec)}, None+ return None, {"success": False, "errorCode": "unknown_net", "error": f"No exact net matching {spec!r}",+ "nets": names[:60], "_hint": "Use an exact net name. Integer net codes are supported only for pre-KiCad-10 boards."}+++def _net_sexpr(net: dict) -> str:+ """The (net ...) line in the dialect this board speaks -- or nothing at all+ for deliberately unconnected copper on a KiCad 10 board, which has no net 0+ to name."""+ if net["format"] == "number":+ return f'\t\t(net {int(net["number"] or 0)})\n'+ if not net["name"]:+ return ""+ return f'\t\t(net {json.dumps(net["name"], ensure_ascii=False)})\n'+++def _default_width(board: dict, net_key=None) -> float:+ """Match the board's own copper: this net's width, else the commonest width."""+ on_net = [s["width"] for s in board.get("segments", [])+ if s.get("width") and (net_key is None+ or _net_key(board, s.get("net"), s.get("net_name")) == net_key)]+ if on_net:+ return Counter(on_net).most_common(1)[0][0]+ any_w = [s["width"] for s in board.get("segments", []) if s.get("width")]+ if any_w:+ return Counter(any_w).most_common(1)[0][0]+ return DEFAULT_WIDTH+++# ── pads in board coordinates ────────────────────────────────────────++def _pads(board: dict) -> list[dict]:+ """Every pad, transformed from footprint-local into BOARD coordinates.++ KiCad rotates a footprint's pads by the footprint orientation in a y-DOWN+ frame (trigo.cpp RotatePoint): x' = x·cos+y·sin, y' = -x·sin+y·cos.+ """+ out = []+ for fp in board.get("footprints", []):+ pos = fp.get("position") or {}+ fx, fy = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))+ ang = math.radians(float(pos.get("rotation", 0.0) or 0.0))+ cos_a, sin_a = math.cos(ang), math.sin(ang)+ ref = fp.get("reference", "") or ""+ for pad in fp.get("pads", []):+ if "x" not in pad or "y" not in pad:+ continue+ px, py = float(pad["x"]), float(pad["y"])+ out.append({+ "ref": ref,+ "pad": pad.get("number", ""),+ "name": f"{ref}.{pad.get('number', '')}",+ "x": round(fx + px * cos_a + py * sin_a, 6),+ "y": round(fy - px * sin_a + py * cos_a, 6),+ "net": pad.get("net_number"),+ "netName": pad.get("net_name", ""),+ "netKey": _net_key(board, pad.get("net_number"), pad.get("net_name")),+ "type": pad.get("type", ""),+ "layers": pad.get("layers", []),+ "side": fp.get("layer", ""),+ "footprint": fp.get("footprint", ""),+ })+ return out+++def _pad_reaches(pad: dict | None, layer: str) -> bool:+ """Is this pad actually on that copper layer?++ An SMD pad lives on ONE side; a track that ends on it from the other side+ looks connected on screen and is a dangling track to DRC. Through-hole pads+ carry the "*.Cu" wildcard and are reachable from every layer.+ """+ if not pad:+ return True+ layers = pad.get("layers") or []+ if not layers:+ return True # nothing to judge it by; let DRC have the last word+ for l in layers:+ if l == layer or l == "*.Cu" or (l.endswith("*") and layer.startswith(l[:-1])):+ return True+ return not any(l.endswith(".Cu") or l == "*.Cu" for l in layers)+++def _pad_net(board: dict, pad: dict | None) -> dict | None:+ """The net a pad sits on, as a reference the writers can emit -- or None for+ a pad with no net (a mounting hole, a fiducial)."""+ if not pad or pad.get("netKey") in (None, 0, ""):+ return None+ return {"format": board.get("net_format", "number"), "number": pad.get("net"),+ "name": pad.get("netName") or "", "key": pad.get("netKey")}+++def _find_pad(pads: list[dict], spec: str) -> tuple[dict | None, dict | None]:+ """'R1.2' / 'R1-2' / 'R1 2' -> that pad."""+ key = str(spec).strip().replace("-", ".").replace(" ", ".").upper()+ for p in pads:+ if p["name"].upper() == key:+ return p, None+ ref = key.split(".")[0]+ near = [p["name"] for p in pads if p["ref"].upper() == ref]+ return None, {"success": False, "error": f"no pad '{spec}' on this board",+ "errorCode": "unknown_pad",+ "candidates": near[:20] or [p["name"] for p in pads][:20],+ "_hint": "Pads are addressed 'REF.PAD' (e.g. 'U1.7'). "+ "kicad_board_pads lists every pad with its board coordinates."}+++def _resolve_point(spec, pads: list[dict]) -> tuple[tuple | None, dict | None, dict | None]:+ """(x, y), the pad it came from (or None), error. Accepts [x,y], {x,y},+ 'REF.PAD' or {"pad":"REF.PAD"}."""+ if isinstance(spec, str):+ pad, err = _find_pad(pads, spec)+ if err:+ return None, None, err+ return (pad["x"], pad["y"]), pad, None+ if isinstance(spec, dict):+ if spec.get("pad"):+ pad, err = _find_pad(pads, spec["pad"])+ if err:+ return None, None, err+ return (pad["x"], pad["y"]), pad, None+ if "x" in spec and "y" in spec:+ try:+ return (float(spec["x"]), float(spec["y"])), None, None+ except (TypeError, ValueError):+ pass+ if isinstance(spec, (list, tuple)) and len(spec) >= 2:+ try:+ return (float(spec[0]), float(spec[1])), None, None+ except (TypeError, ValueError):+ pass+ return None, None, {"success": False, "error": f"could not read a point from {spec!r}",+ "errorCode": "bad_point",+ "_hint": 'A point is [x,y] in mm, {"x":..,"y":..}, or a pad name like "R1.2". '+ "+y is DOWN in KiCad board coordinates."}+++# ── s-expression emission ────────────────────────────────────────────++def _segment_sexpr(a, b, width, layer, net: dict) -> str:+ return ('\t(segment\n'+ f'\t\t(start {_num(a[0])} {_num(a[1])})\n'+ f'\t\t(end {_num(b[0])} {_num(b[1])})\n'+ f'\t\t(width {_num(width)})\n'+ f'\t\t(layer "{layer}")\n'+ + _net_sexpr(net) ++ f'\t\t(uuid "{uuid.uuid4()}")\n'+ '\t)')+++def _via_sexpr(at, size, drill, layers, net: dict) -> str:+ return ('\t(via\n'+ f'\t\t(at {_num(at[0])} {_num(at[1])})\n'+ f'\t\t(size {_num(size)})\n'+ f'\t\t(drill {_num(drill)})\n'+ f'\t\t(layers "{layers[0]}" "{layers[1]}")\n'+ + _net_sexpr(net) ++ f'\t\t(uuid "{uuid.uuid4()}")\n'+ '\t)')+++def _splice(text: str, blocks: list[str]) -> str:+ """Insert board items before the closing paren (where KiCad keeps copper)."""+ idx = text.rstrip().rfind(")")+ return text[:idx].rstrip() + "\n" + "\n".join(blocks) + "\n)\n"+++def _write(path: Path, new_text: str, backup: bool, expected: str = None) -> tuple[str | None, dict | None]:+ """Validate copper, refuse live/changed boards, and replace using a unique temp file."""+ bak, tmp = None, None+ try:+ data = parse_pcb_text(new_text, path)+ original = parse_pcb_text(expected, path) if expected is not None else {"segments": [], "vias": []}+ old_ids = {item.get("uuid") for key in ("segments", "vias") for item in original[key]}+ for segment in data["segments"]:+ if segment.get("uuid") in old_ids:+ continue+ values = [segment["width"], *segment["start"].values(), *segment["end"].values()]+ if not all(math.isfinite(v) for v in values) or segment["width"] <= 0:+ raise ValueError("Track coordinates must be finite and width must be positive")+ for via in data["vias"]:+ if via.get("uuid") in old_ids:+ continue+ values = [via["size"], via["drill"], *via["position"].values()]+ if not all(math.isfinite(v) for v in values) or not 0 < via["drill"] < via["size"]:+ raise ValueError("Via geometry must be finite, with 0 < drill < size")+ if set(via["layers"]) != {"F.Cu", "B.Cu"}:+ raise ValueError("Only through vias spanning F.Cu to B.Cu are supported")+ if _lock_file(path) or _editor_hwnd(path):+ raise ValueError("Board was opened while planning; use live routing")+ if expected is not None and path.read_text(encoding="utf-8") != expected:+ raise ValueError("Board changed while planning; inspect it and retry")+ if backup:+ bak = str(path) + ".adom-bak"+ shutil.copy2(path, bak)+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,+ prefix=path.name + ".", suffix=".adom-tmp", delete=False) as f:+ tmp = Path(f.name)+ f.write(new_text)+ f.flush()+ os.fsync(f.fileno())+ os.replace(tmp, path)+ except (OSError, ValueError, KeyError) as e:+ return None, {"success": False, "error": f"Board write refused: {e}",+ "errorCode": "board_write_refused",+ "_hint": "No board replacement was performed. Reinspect geometry and the live editor before retrying."}+ finally:+ if tmp is not None:+ tmp.unlink(missing_ok=True)+ return bak, None+++# ── the running pcbnew ───────────────────────────────────────────────++def _lock_file(path: Path) -> Path | None:+ """KiCad's own open-file marker, `~<name>.kicad_pcb.lck` beside the board.++ It is the one signal that works with no window list at all, and it is what+ tells us a write is about to race a live editor.+ """+ lck = path.parent / f"~{path.name}.lck"+ return lck if lck.exists() else None+++def _editor_hwnd(path: Path) -> int | None:+ """The PCB Editor window showing THIS board, if one is up."""+ try:+ from handlers import kicad_windows+ for row in kicad_windows.find(fresh=True):+ title = (row.get("title") or "")+ if path.stem.lower() in title.lower() and "pcb editor" in title.lower():+ return int(row.get("hwnd") or 0) or None+ except Exception: # pylint: disable=broad-except+ pass+ return None+++def _after_write(path: Path, args: dict, result: dict) -> dict:+ """File writes never reload or revert an editor (which can lose user edits)."""+ result["source"] = "file"+ result["reloaded"] = False+ result["_hint"] += " Open the saved board to inspect it. For live editing use kicad_route_net."+ return result+++# ── verbs ────────────────────────────────────────────────────────────++def handle_board_pads(kicad_info: dict, args: dict) -> dict:+ """Read-only routing intelligence: pads in board coordinates, nets, layers."""+ path, _text, board, err = _load(args, read_only=True)+ if err:+ return err++ pads = _pads(board)+ net_filter = args.get("net")+ if net_filter not in (None, ""):+ net, nerr = _resolve_net(board, net_filter)+ if nerr:+ return nerr+ pads = [p for p in pads if p.get("netKey") == net["key"]]+ ref_filter = args.get("reference")+ if ref_filter:+ pads = [p for p in pads if p["ref"].upper() == str(ref_filter).upper()]++ by_net: dict[str, list[str]] = {}+ for p in _pads(board):+ if p.get("netKey") not in (None, 0, ""):+ by_net.setdefault(p.get("netName") or f"net{p['net']}", []).append(p["name"])++ segs = board.get("segments", [])+ routed = {_net_key(board, s.get("net"), s.get("net_name")) for s in segs}+ nets = [{"number": n.get("number"), "name": n.get("name", ""),+ "pads": len(by_net.get(n.get("name", ""), [])),+ "hasCopper": _net_key(board, n.get("number"), n.get("name")) in routed}+ for n in board.get("nets", []) if n.get("name")]+ needs_copper = sorted(n["name"] for n in nets if n["pads"] > 1 and not n["hasCopper"])++ return {+ "success": True,+ "boardPath": str(path),+ "pads": pads,+ "padCount": len(pads),+ "nets": nets,+ "copperLayers": _copper_layers(board),+ "netFormat": board.get("net_format"),+ "defaultWidth": _default_width(board),+ "existingTracks": len(segs),+ "existingVias": len(board.get("vias", [])),+ "boardDimensions": board.get("dimensions"),+ "netsWithoutTracks": needs_copper[:60],+ "source": "file", "connectivityChecked": False,+ "output": f"{len(pads)} pads, {len(nets)} nets, {len(segs)} tracks on "+ f"{len(_copper_layers(board))} copper layers",+ "_hint": "Pad x/y are BOARD millimetres (+y is DOWN) with footprint rotation applied -- "+ "feed them straight to kicad_route or kicad_add_track. Route a net by name and "+ "pick your own waypoints. hasCopper means a track exists, not that the net is connected. "+ "Use kicad_routing_state for live connectivity. "+ "kicad_run_drc is the authority on clearance.",+ }+++def handle_add_track(kicad_info: dict, args: dict) -> dict:+ """Write one segment to a closed board file."""+ path, text, board, err = _load(args)+ if err:+ return err+ pads = _pads(board)++ start_spec = args.get("start", args.get("from"))+ end_spec = args.get("end", args.get("to"))+ if start_spec is None or end_spec is None:+ return {"success": False, "error": "add_track needs both start and end",+ "errorCode": "missing_arg",+ "_hint": 'kicad_add_track {"filePath":"...","start":"R1.2","end":[120.5,90],'+ '"net":"GND","layer":"F.Cu"}. Points are pad names or [x,y] in mm.'}+ a, pad_a, err = _resolve_point(start_spec, pads)+ if err:+ return err+ b, pad_b, err = _resolve_point(end_spec, pads)+ if err:+ return err++ net_spec = args.get("net")+ if net_spec in (None, ""):+ net = _pad_net(board, pad_a) or _pad_net(board, pad_b)+ if net is None:+ return {"success": False, "error": "no net given and neither endpoint is a pad on a net",+ "errorCode": "missing_net",+ "_hint": 'Pass "net":"GND" (a name from kicad_board_pads), or "net":0 for '+ "deliberately unconnected copper. Copper on the wrong net is a DRC error."}+ else:+ net, nerr = _resolve_net(board, net_spec)+ if nerr:+ return nerr++ for pad in (pad_a, pad_b):+ pad_net = _pad_net(board, pad)+ if pad_net and pad_net["key"] != net["key"]:+ return {"success": False,+ "error": f"pad {pad['name']} is on net '{pad.get('netName')}', "+ f"not the net you asked to route ('{net['name']}')",+ "errorCode": "net_mismatch",+ "_hint": "Routing a track to a pad on a different net is a short. Check the "+ "pad's net with kicad_board_pads, or drop the net arg and let the "+ "endpoints decide it."}++ if _dist(a, b) < 1e-6:+ return {"success": False, "error": "start and end are the same point",+ "errorCode": "zero_length",+ "_hint": "A zero-length track is a DRC error in KiCad. Give the segment a real span, "+ "or use kicad_add_via for a layer change at one point."}++ layer = args.get("layer") or (pad_a or {}).get("side") or DEFAULT_LAYER+ copper = _copper_layers(board)+ if layer not in copper:+ return {"success": False, "error": f"'{layer}' is not a copper layer on this board",+ "errorCode": "unknown_layer", "copperLayers": copper,+ "_hint": "Tracks only live on copper. This board's copper layers are listed above; "+ "a 2-layer board has only F.Cu and B.Cu."}+ width = float(args.get("width", _default_width(board, net["key"])))++ block = _segment_sexpr(a, b, width, layer, net)+ bak, werr = _write(path, _splice(text, [block]), args.get("backup", True) is not False, expected=text)+ if werr:+ return werr++ warnings = [f"pad {p['name']} is not on {layer} (it is on {', '.join(p.get('layers') or []) or 'no copper'}) "+ f"-- this end of the track is dangling, not connected"+ for p in (pad_a, pad_b) if not _pad_reaches(p, layer)]++ net_name = net["name"]+ result = {+ "success": True, "boardPath": str(path), "backupPath": bak, "warnings": warnings,+ "start": list(a), "end": list(b), "layer": layer, "width": width,+ "net": net["number"], "netName": net_name,+ "lengthMm": round(_dist(a, b), 4),+ "tracksOnBoard": len(board.get("segments", [])) + 1,+ "output": f"Track on {net_name or 'no net'}: ({_num(a[0])}, {_num(a[1])}) -> "+ f"({_num(b[0])}, {_num(b[1])}) on {layer}, {_num(width)} mm wide",+ "_hint": "One segment written. Call again for the next one -- that is the trace-by-trace "+ "loop. For a whole path in a single write use kicad_route. Nothing here checks "+ "clearance: run kicad_run_drc when the net is done.",+ }+ return _after_write(path, args, result)+++def handle_add_via(kicad_info: dict, args: dict) -> dict:+ """One via -- how a route changes layer."""+ path, text, board, err = _load(args)+ if err:+ return err+ pads = _pads(board)++ at_spec = args.get("at", args.get("position"))+ if at_spec is None:+ return {"success": False, "error": "add_via needs `at`", "errorCode": "missing_arg",+ "_hint": 'kicad_add_via {"filePath":"...","at":[120,90],"net":"GND"}. '+ "Place it where the track changes layer."}+ at, pad, err = _resolve_point(at_spec, pads)+ if err:+ return err++ net_spec = args.get("net")+ if net_spec in (None, ""):+ net = _pad_net(board, pad)+ if net is None:+ return {"success": False, "error": "no net given and `at` is not a pad on a net",+ "errorCode": "missing_net",+ "_hint": 'Pass "net":"GND". A via must carry the net of the track it joins.'}+ else:+ net, nerr = _resolve_net(board, net_spec)+ if nerr:+ return nerr++ copper = _copper_layers(board)+ layers = args.get("layers") or [copper[0] if copper else "F.Cu",+ copper[-1] if copper else "B.Cu"]+ if len(layers) < 2 or any(l not in copper for l in layers[:2]):+ return {"success": False, "error": f"via layers {layers} are not both copper on this board",+ "errorCode": "unknown_layer", "copperLayers": copper,+ "_hint": 'A via spans two copper layers, e.g. "layers":["F.Cu","B.Cu"].'}++ size = float(args.get("size", DEFAULT_VIA_SIZE))+ drill = float(args.get("drill", DEFAULT_VIA_DRILL))+ if drill >= size:+ return {"success": False, "error": f"drill {drill} is not smaller than pad size {size}",+ "errorCode": "bad_via_geometry",+ "_hint": "A via's drill must be smaller than its pad, or there is no annular ring."}++ block = _via_sexpr(at, size, drill, layers[:2], net)+ bak, werr = _write(path, _splice(text, [block]), args.get("backup", True) is not False, expected=text)+ if werr:+ return werr++ result = {+ "success": True, "boardPath": str(path), "backupPath": bak,+ "at": list(at), "size": size, "drill": drill, "layers": layers[:2],+ "net": net["number"], "netName": net["name"],+ "viasOnBoard": len(board.get("vias", [])) + 1,+ "output": f"Via at ({_num(at[0])}, {_num(at[1])}) joining {layers[0]}->{layers[1]} "+ f"on {net['name'] or 'no net'}",+ "_hint": "The via is copper on its own -- the tracks either side of it still have to be "+ "drawn (kicad_add_track), or let kicad_route place vias for you by giving a "+ "waypoint a different layer. Check the size against the fab's rules: "+ "kicad_list_design_rules.",+ }+ return _after_write(path, args, result)+++def handle_route(kicad_info: dict, args: dict) -> dict:+ """Offline counterpart of route_net. A closed board and explicit path are required."""+ from handlers.live_routing import _plan, RoutingError+ path, text, board, err = _load(args)+ if err:+ return err+ raw = args.get("points", args.get("path", []))+ if not isinstance(raw, list):+ return {"success": False, "errorCode": "invalid_path", "error": "points must be an array",+ "_hint": "Use points:[REF.PAD,[x,y],...] in board millimetres."}+ raw = list(raw)+ if args.get("from") is not None:+ raw.insert(0, args["from"])+ if args.get("to") is not None:+ raw.append(args["to"])+ net_spec = args.get("net")+ if net_spec is None:+ for spec in raw:+ _, pad, _ = _resolve_point(spec, _pads(board))+ if pad and pad.get("netName"):+ net_spec = pad["netName"]+ break+ net, err = _resolve_net(board, net_spec)+ if err:+ return err+ try:+ plan = _plan(board, {**args, "net": net["name"], "points": raw})+ except RoutingError as e:+ return {"success": False, "errorCode": e.code, "error": str(e),+ "_hint": "Inspect kicad_board_pads and correct the route before retrying."}+ bak, err = _write(path, _splice(text, plan["blocks"]), args.get("backup", True) is not False, expected=text)+ if err:+ return err+ return _after_write(path, args, {"success": True, "boardPath": str(path), "backupPath": bak,+ "netName": net["name"], "segments": plan["segments"], "vias": plan["vias"],+ "segmentCount": len(plan["segments"]), "viaCount": len(plan["vias"]),+ "_hint": "Copper written to the closed board. Run kicad_run_drc for clearance and connectivity."})
parsers/pcb.py+85−35@@ -8,7 +8,26 @@ from __future__ import annotations from pathlib import Path -from .sexpr import parse_file, find_node, find_nodes, node_value+from .sexpr import parse, parse_file, find_node, find_nodes, node_value+++def _net_ref(node: list | None, name_based: bool = False) -> tuple[int | None, str | None]:+ """Read a (net ...) reference in either board format.++ KiCad <=9 numbers its nets and declares them at the top of the board:+ `(net 3 "GND")` on a pad, `(net 3)` on a segment. KiCad 10 (file format+ 20260206) dropped the table and the numbers entirely -- every reference is+ just `(net "GND")`. Both shapes appear in the wild, so read both.+ """+ if not node or len(node) < 2:+ return None, None+ first = node[1]+ if name_based:+ return None, first if isinstance(first, str) else None+ if isinstance(first, str) and first.lstrip("-").isdigit():+ name = node[2] if len(node) >= 3 and isinstance(node[2], str) else None+ return int(first), name+ return None, first if isinstance(first, str) else None def parse_pcb(filepath: str | Path) -> dict:@@ -17,8 +36,47 @@ def parse_pcb(filepath: str | Path) -> dict: return _extract_pcb(tree, filepath) +def parse_pcb_text(text: str, filepath: str | Path = "") -> dict:+ """Same as parse_pcb, but for board text already in memory.++ The routing verbs splice new copper into the board TEXT, so they hold the+ string anyway; re-reading the file to inspect it would race their own write.+ """+ return _extract_pcb(parse(text), filepath)++ def _extract_pcb(tree: list, filepath: str | Path) -> dict: """Extract all relevant data from a parsed PCB tree."""+ data = _extract_pcb_raw(tree, filepath)+ return _resolve_net_format(data)+++def _resolve_net_format(data: dict) -> dict:+ """Say which net dialect the board speaks, and give it a net list either way.++ A KiCad 10 board has no net table to read, so the only inventory of its nets+ is the names its pads and copper mention. Callers that write copper need+ this: the (net ...) they emit has to match the shape of the file.+ """+ numbered = bool(data.get("nets")) or any(+ p.get("net_number") is not None+ for fp in data.get("footprints", []) for p in fp.get("pads", []))+ data["net_format"] = "number" if numbered else "name"+ if not data.get("nets"):+ names, seen = [], set()+ for name in ([p.get("net_name") for fp in data.get("footprints", [])+ for p in fp.get("pads", [])]+ + [s.get("net_name") for s in data.get("segments", [])]+ + [v.get("net_name") for v in data.get("vias", [])]+ + [z.get("net_name") for z in data.get("zones", [])]):+ if name and name not in seen:+ seen.add(name)+ names.append(name)+ data["nets"] = [{"number": None, "name": n} for n in names]+ return data+++def _extract_pcb_raw(tree: list, filepath: str | Path) -> dict: return { "file": str(filepath), "version": node_value(find_node(tree, "version")),@@ -85,17 +143,12 @@ def _extract_layers(tree: list) -> list[dict]: def _extract_nets(tree: list) -> list[dict]:- """Extract net definitions."""+ """Net definitions from the board's own table (KiCad <=9 only).""" nets = [] for node in find_nodes(tree, "net"):- if len(node) >= 3:- try:- nets.append({- "number": int(node[1]),- "name": node[2] if isinstance(node[2], str) else "",- })- except (ValueError, TypeError):- pass+ num, name = _net_ref(node, int(node_value(find_node(tree, "version")) or 0) >= 20260206)+ if num is not None:+ nets.append({"number": num, "name": name or ""}) return nets @@ -192,13 +245,13 @@ def _extract_footprints(tree: list) -> list[dict]: pad_info["height"] = float(size_node[2]) except (ValueError, TypeError): pass- net_node = find_node(pad, "net")- if net_node and len(net_node) >= 3:- try:- pad_info["net_number"] = int(net_node[1])- pad_info["net_name"] = net_node[2] if isinstance(net_node[2], str) else ""- except (ValueError, TypeError):- pass+ pad_layers = find_node(pad, "layers")+ if pad_layers:+ pad_info["layers"] = [l for l in pad_layers[1:] if isinstance(l, str)]+ num, name = _net_ref(find_node(pad, "net"), int(node_value(find_node(tree, "version")) or 0) >= 20260206)+ if num is not None or name is not None:+ pad_info["net_number"] = num+ pad_info["net_name"] = name or "" pad_list.append(pad_info) fp["pads"] = pad_list @@ -232,12 +285,11 @@ def _extract_segments(tree: list) -> list[dict]: layer = find_node(node, "layer") if layer: seg["layer"] = node_value(layer)- net = find_node(node, "net")- if net:- try:- seg["net"] = int(node_value(net))- except (ValueError, TypeError):- pass+ num, name = _net_ref(find_node(node, "net"), int(node_value(find_node(tree, "version")) or 0) >= 20260206)+ if num is not None:+ seg["net"] = num+ if name is not None:+ seg["net_name"] = name segments.append(seg) return segments @@ -274,12 +326,11 @@ def _extract_vias(tree: list) -> list[dict]: layers = find_node(node, "layers") if layers: via["layers"] = [l for l in layers[1:] if isinstance(l, str)]- net = find_node(node, "net")- if net:- try:- via["net"] = int(node_value(net))- except (ValueError, TypeError):- pass+ num, name = _net_ref(find_node(node, "net"), int(node_value(find_node(tree, "version")) or 0) >= 20260206)+ if num is not None:+ via["net"] = num+ if name is not None:+ via["net_name"] = name vias.append(via) return vias @@ -289,12 +340,11 @@ def _extract_zones(tree: list) -> list[dict]: zones = [] for node in find_nodes(tree, "zone"): zone = {"uuid": node_value(find_node(node, "uuid"))}- net = find_node(node, "net")- if net:- try:- zone["net"] = int(node_value(net))- except (ValueError, TypeError):- pass+ num, name = _net_ref(find_node(node, "net"), int(node_value(find_node(tree, "version")) or 0) >= 20260206)+ if num is not None:+ zone["net"] = num+ if name is not None:+ zone["net_name"] = name net_name = find_node(node, "net_name") if net_name: zone["net_name"] = node_value(net_name)
requirements-routing.txtadded+1@@ -0,0 +1 @@+kicad-python==0.8.0
server.py+34−1@@ -44,6 +44,10 @@ from handlers.install_library_bundle import handle_install_library_bundle from handlers.install_symbol import handle_install_symbol from handlers.install_footprint import handle_install_footprint, handle_list_footprints from handlers.place_footprint import handle_place_footprint+from handlers.live_routing import (handle_routing_state, handle_route_net,+ handle_remove_route, handle_routing_validate)+from handlers.route import (handle_add_track, handle_add_via, handle_route,+ handle_board_pads) from handlers.run_drc import handle_run_drc from handlers.fix_keyboard import handle_fix_keyboard from handlers.kicad_ui import handle_screenshot_all, handle_send_key, handle_click@@ -440,6 +444,26 @@ _VERB_CATALOG = { "install_plugin": {"summary": "Install the reverse-bridge plugin into KiCad (idempotent).", "long": False, "hint": "Normally auto-runs on your first kicad_* call; call explicitly only to force/repair.", "related": ["kicad_bridge_status", "kicad_bridge_call"], "pitfalls": ["needs a restart of an already-running KiCad to load the freshly-installed plugin"]},+ "routing_state": {'summary': 'Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity.', 'hint': 'Pass exact filePath and optional local ipc:// socketPath. Use revision as expectedRevision for edits.', 'related': ['kicad_route_net', 'kicad_routing_validate'], 'pitfalls': ['Requires KiCad 10.0.1+ IPC enabled and requirements-routing.txt installed; no file fallback.'], 'long': False},+ "route_net": {'summary': 'Commit a waypoint path or branching paths for ONE net live with native Undo and DRC preflight.', 'hint': 'Pass filePath, expectedRevision from routing_state, exact net name, points or paths, optional width/layer/viaSize/viaDrill. dryRun validates only; save:true explicitly saves.', 'related': ['kicad_routing_state', 'kicad_remove_route', 'kicad_routing_validate'], 'pitfalls': ['Caller chooses the route: this is not a shove router or an autonomous autorouter.', 'DRC rejects new errors by default; validate:false explicitly skips it.', 'Do not blindly retry a timed-out mutation; inspect live state.'], 'long': False},+ "remove_route": {'summary': 'Remove specific unlocked copper item IDs live as one Undo step.', 'hint': 'filePath, expectedRevision, itemIds from route_net. Refuses missing IDs, pads, footprints and locked copper.', 'related': ['kicad_route_net', 'kicad_routing_state'], 'pitfalls': ['Does not save the file or delete a whole net implicitly.'], 'long': False},+ "routing_validate": {'summary': 'Run KiCad DRC against the current unsaved live board snapshot.', 'hint': 'filePath and optional socketPath. Reports errors, warnings, unconnected, revision and stale.', 'related': ['kicad_route_net', 'kicad_routing_state'], 'pitfalls': ['A stale report is superseded by newer edits; clean requires zero violations and zero unconnected items.'], 'long': False},+ "board_pads": {"summary": "Every pad in BOARD coordinates with its net — the input a router needs.", "long": False,+ "hint": "kicad_board_pads {\"filePath\":\"C:/d/x.kicad_pcb\"} (optional net/reference filter). Read-only. x/y are mm with footprint rotation applied, +y DOWN; hasCopper and netsWithoutTracks are file inventory only; use kicad_routing_state for connectivity.",+ "related": ["kicad_route", "kicad_add_track", "kicad_extract_netlist"],+ "pitfalls": ["pad coordinates are the pad CENTRE — a track to it still has to clear other nets", "KiCad 10 boards have no net numbers; address nets by name"]},+ "add_track": {"summary": "Write one copper segment to a CLOSED board file.", "long": False,+ "hint": "kicad_add_track {\"filePath\":\"...\",\"start\":\"R1.2\",\"end\":[120.5,90],\"net\":\"GND\",\"layer\":\"F.Cu\"}. Endpoints are pad names or [x,y] mm. Width defaults to the board's own.",+ "related": ["kicad_route", "kicad_add_via", "kicad_board_pads", "kicad_run_drc"],+ "pitfalls": ["there is no autorouter: YOU choose the path, and nothing checks clearance until kicad_run_drc", "refuses open boards; use kicad_route_net for live, undoable routing"]},+ "add_via": {"summary": "Drop one via so a route can change layer.", "long": False,+ "hint": "kicad_add_via {\"filePath\":\"...\",\"at\":[120,90],\"net\":\"GND\",\"layers\":[\"F.Cu\",\"B.Cu\"]}. Defaults 0.6 mm pad / 0.3 mm drill.",+ "related": ["kicad_add_track", "kicad_route", "kicad_list_design_rules"],+ "pitfalls": ["the via alone connects nothing — the tracks either side still have to be drawn", "check size/drill against the fab's rules"]},+ "route": {"summary": "Lay a whole path for one net in a single write, vias included.", "long": False,+ "hint": "kicad_route {\"filePath\":\"...\",\"net\":\"GND\",\"points\":[\"R1.2\",[120,88],{\"x\":130,\"y\":88,\"layer\":\"B.Cu\"},\"U1.7\"]}. A waypoint with a different layer drops a via there.",+ "related": ["kicad_add_track", "kicad_board_pads", "kicad_run_drc"],+ "pitfalls": ["draws exactly the waypoints you give — it does not search for a path or avoid other nets", "for a visible demo, call kicad_route_net per segment"]}, "place_footprint": {"summary": "Deterministically place a footprint into a board preview.", "long": False, "hint": "Splices a footprint into a board via s-expr (no GUI); good for previews.", "related": ["kicad_open_board", "kicad_install_footprint"], "pitfalls": ["operates on the file — close the board in KiCad first or the edit races the GUI"]},@@ -909,6 +933,15 @@ COMMAND_HANDLERS = { "install_symbol": handle_install_symbol, "install_footprint": handle_install_footprint, "place_footprint": handle_place_footprint,+ # copper: the AI picks the path, these verbs lay the trace (see handlers/route.py)+ "routing_state": handle_routing_state,+ "route_net": handle_route_net,+ "remove_route": handle_remove_route,+ "routing_validate": handle_routing_validate,+ "board_pads": handle_board_pads,+ "add_track": handle_add_track,+ "add_via": handle_add_via,+ "route": handle_route, "run_drc": handle_run_drc, "fix_keyboard": handle_fix_keyboard, "screenshot_all": handle_screenshot_all,@@ -1134,7 +1167,7 @@ def dispatch_command(command: str, args: dict) -> dict: _PASSIVE_VERBS = {"status", "bridge_status", "state", "readiness", "window_info", "open_editors", "screenshot_all", "plugin_diagnose", "describe", "get_settings", "diagnostics", "check_for_updates"}- _NO_GUI = {"run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library",+ _NO_GUI = {"routing_state", "route_net", "remove_route", "routing_validate", "board_pads", "run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library", "format_upgrade", "export_gerber", "export_gerbers", "export_pdf", "export_svg", "export_step", "export_bom_csv", "adom_library_status", "list_versions", "install_library", "progress", "list_footprints", "verb_times"}
skills/kicad-bridge-test/run_verb_tests.py+5@@ -166,6 +166,11 @@ def matrix(w: dict) -> dict: "close_3d_viewer": dict(phase=6, args={}), "close": dict(phase=6, args={"force": True}, timeout=120, note="final teardown — leaves the box with no KiCad windows"),+ # Dedicated routing fixtures require KiCad IPC and native Undo checks.+ # Run demo/routing/run_demo.py on the dedicated test host before release.+ **{verb: dict(skip="Covered by tests/test_routing_ipc.py and demo/routing/run_demo.py; requires a dedicated unrouted IPC board")+ for verb in ("routing_state", "route_net", "remove_route", "routing_validate",+ "board_pads", "add_track", "add_via", "route")}, # ---- explicit skips (reason required — counted as covered) "enable_software_opengl": dict(skip="mutates the user's KiCad bin/ (mesa deploy) — only for GPU-less " "VMs; run manually on ADOMBASELINE when testing that path"),
tests/test_live_routing.pyadded+109@@ -0,0 +1,109 @@+import copy+from pathlib import Path+import sys+import tempfile+import unittest+from unittest.mock import patch++sys.path.insert(0, str(Path(__file__).resolve().parents[1]))+from handlers import live_routing as live, route+++def board():+ return {'net_format':'name', 'nets':[{'name':'GND'}, {'name':'VCC'}, {'name':'123'}],+ 'layers':[{'name':s} for s in ['F.Cu','In1.Cu','B.Cu']],+ 'footprints':[{'reference':'J1','layer':'F.Cu','position':{'x':10,'y':20,'rotation':90},+ 'pads':[{'number':'1','x':2,'y':0,'net_name':'GND','layers':['*.Cu'],'type':'thru_hole'},+ {'number':'2','x':0,'y':0,'net_name':'VCC','layers':['F.Cu'],'type':'smd'}]},+ {'reference':'J2','layer':'B.Cu','position':{'x':30,'y':20},+ 'pads':[{'number':'1','x':0,'y':0,'net_name':'GND','layers':['B.Cu'],'type':'smd'}]}]}+++class Planning(unittest.TestCase):+ def test_rotated_pad_coordinates_and_final_layer_transition(self):+ p=live._plan(board(), {'net':'GND','points':['J1.1',{'pad':'J2.1','layer':'B.Cu'}]})+ self.assertEqual(p['segments'][0]['start'],[10,18])+ self.assertEqual(p['vias'][0]['at'],[30,20])+ self.assertEqual(p['vias'][0]['layers'],['F.Cu','B.Cu'])++ def test_branches_on_one_net(self):+ p=live._plan(board(), {'net':'GND','paths':[['J1.1',[20,18]],[[20,18],[20,25]]]})+ self.assertEqual(len(p['segments']),2)++ def test_reject_wrong_net_and_layer(self):+ for args,code in [({'net':'GND','points':['J1.1','J1.2']},'net_mismatch'),+ ({'net':'GND','points':['J1.1','J2.1']},'pad_layer_mismatch'),+ ({'net':'GND','points':['J1.1',{'x':20,'y':20,'layer':'F.SilkS'}]},'unknown_layer')]:+ with self.subTest(code=code),self.assertRaises(live.RoutingError) as cm: live._plan(board(),args)+ self.assertEqual(cm.exception.code,code)++ def test_invalid_geometry_is_rejected_before_mutation(self):+ for args in [{'width':0},{'width':-1},{'width':float('nan')},{'width':True},{'points':[[True,0],[1,1]]},{'points':[[0,0,9],[1,1]]},+ {'viaDrill':0.8,'viaSize':0.6},{'points':[[0,0],[float('inf'),0]]},+ {'points':[[0,0],[0,0]]}]:+ with self.subTest(args=args),self.assertRaises(live.RoutingError):+ live._plan(board(),{'net':'GND','points':[[0,0],[1,1]],**args})++ def test_numeric_net_name_in_kicad_10_file(self):+ from parsers.pcb import parse_pcb_text+ data=parse_pcb_text('(kicad_pcb (version 20260206) (footprint "X" (layer "F.Cu") (pad "1" thru_hole circle (net "123"))))')+ self.assertEqual(data["net_format"],"name")+ self.assertEqual(data["nets"],[{"number":None,"name":"123"}])++ def test_exact_numeric_net_name(self):+ self.assertEqual(live._plan(board(),{'net':'123','points':[[0,0],[1,1]]})['netName'],'123')++ def test_quoted_net_serialization(self):+ self.assertIn('"a\\"b\\\\c"',route._net_sexpr({'format':'name','name':'a"b\\c'}))++ def test_stale_revision(self):+ for rev in [None,'old']:+ with self.assertRaises(live.RoutingError): live._revision({'expectedRevision':rev},'new')++ def test_existing_error_does_not_mask_additional_same_error(self):+ v={'type':'clearance','severity':'error','description':'collision','items':[]}+ self.assertEqual(live._new_errors({'violations':[v]},{'violations':[v,v]}),[v])++ def test_open_file_refused_even_with_private_arg(self):+ with patch.object(route,'_lock_file',return_value=Path('lock')):+ self.assertEqual(route._load({'filePath':'test.kicad_pcb','_readOnly':True})[-1]['errorCode'],'board_open')+++class Transactions(unittest.TestCase):+ class Board:+ def __init__(self): self.ops=[]+ def begin_commit(self): self.ops.append('begin'); return 'commit'+ def push_commit(self,c,m): self.ops.append('push')+ def drop_commit(self,c): self.ops.append('drop')++ def test_success_is_one_undo_step(self):+ b=self.Board()+ self.assertEqual(live._transaction(b,lambda:42,'route'),42)+ self.assertEqual(b.ops,['begin','push'])++ def test_partial_create_failure_rolls_back(self):+ b=self.Board()+ def fail(): raise RuntimeError('partial create')+ with self.assertRaises(live.RoutingError) as cm: live._transaction(b,fail,'route')+ self.assertEqual(cm.exception.code,'mutation_rolled_back')+ self.assertEqual(b.ops,['begin','drop'])++ def test_push_timeout_is_unknown_even_if_drop_acknowledged(self):+ b = self.Board()+ def push(*args): raise TimeoutError('commit response lost')+ b.push_commit = push+ with self.assertRaises(live.RoutingError) as cm:+ live._transaction(b, lambda: 42, 'route')+ self.assertEqual(cm.exception.code, 'mutation_outcome_unknown')+ self.assertIsNone(cm.exception.detail['mutated'])++ def test_failed_rollback_is_unknown_not_safe_to_retry(self):+ b=self.Board()+ def fail(*a): raise RuntimeError('lost IPC')+ b.drop_commit=fail+ with self.assertRaises(live.RoutingError) as cm: live._transaction(b,fail,'route')+ self.assertEqual(cm.exception.code,'mutation_outcome_unknown')+ self.assertIsNone(cm.exception.detail['mutated'])+++if __name__=='__main__': unittest.main()
tests/test_routing_ipc.pyadded+151@@ -0,0 +1,151 @@+"""Exercise public handlers with real kipy copper types and a simulated editor.++IPC transport and DRC execution are mocked here; Windows live tests remain required.+"""+from contextlib import contextmanager+from pathlib import Path+from types import SimpleNamespace+import tempfile+import unittest+from unittest.mock import patch+import sys+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))+from handlers import live_routing as live, route+from demo.routing.make_fixture import make+try:+ from kipy.board_types import Net+except ImportError:+ Net = None++[email protected](Net is None, 'Install requirements-routing.txt for protocol tests')+class IPCHandlers(unittest.TestCase):+ def setUp(self):+ self.tmp = tempfile.TemporaryDirectory()+ self.path = make(self.tmp.name)+ text = self.path.read_text()+ self.args = {'filePath': str(self.path), 'net': 'NET_1', 'points': ['J1.1', 'J2.1'], 'validate': False}+ class Board:+ def __init__(b):+ b.text = text; b.items = []; b.ops = []; b.partial = False; b.fail_read = False; b.fail_save = False; b.keep_removed = False+ def get_as_string(b):+ if b.fail_read: raise RuntimeError('read failed after commit')+ return b.text+ def get_nets(b):+ n = Net(); n.name = 'NET_1'; return [n]+ def begin_commit(b): b.ops.append('begin'); return 'commit'+ def create_items(b, items):+ for i, item in enumerate(items): item.id.value = 'created-'+str(i)+ b.items += items+ return [] if b.partial else items+ def push_commit(b, *_): b.ops.append('push'); b.text += '\n'+ def drop_commit(b, *_): b.ops.append('drop'); b.items = []+ def save(b):+ if b.fail_save: raise RuntimeError('save failed')+ b.ops.append('save')+ def get_tracks(b): return b.items+ def get_vias(b): return []+ def remove_items(b, items):+ if not b.keep_removed: b.items = []+ self.board = Board()+ @contextmanager+ def connect(args): yield self.board+ self.patch = patch.object(live, '_connect', connect); self.patch.start()+ self.args['expectedRevision'] = live._snapshot(self.board, self.args)[2]++ def tearDown(self): self.patch.stop(); self.tmp.cleanup()++ def test_copper_protocol_units_net_and_layer(self):+ result = live.handle_route_net({}, self.args)+ self.assertTrue(result['success'], result)+ self.assertEqual(self.board.items[0].width, 250000)+ self.assertEqual(self.board.items[0].start.x, 105000000)+ self.assertEqual(self.board.items[0].net.name, 'NET_1')+ self.assertEqual(self.board.ops, ['begin', 'push'])+ self.assertNotEqual(result['revision'], self.args['expectedRevision'])+ self.assertFalse(result['saved'])++ def test_dry_run_does_not_start_transaction(self):+ result = live.handle_route_net({}, {**self.args, 'dryRun': True})+ self.assertTrue(result['success'], result)+ self.assertEqual(self.board.ops, [])++ def test_drc_rejection_and_edit_during_preflight_do_not_mutate(self):+ ok = {'violations': []}+ bad = {'violations': [{'severity': 'error', 'type': 'clearance'}]}+ with patch.object(live, '_drc', side_effect=[ok, bad]):+ r = live.handle_route_net({}, {**self.args, 'validate': True})+ self.assertEqual(r['errorCode'], 'drc_rejected')+ def drc(*a): self.board.text += '\n'; return ok+ with patch.object(live, '_drc', side_effect=drc):+ r = live.handle_route_net({}, {**self.args, 'validate': True})+ self.assertEqual(r['errorCode'], 'stale_board')+ self.assertEqual(self.board.ops, [])++ def test_partial_create_is_rolled_back(self):+ self.board.partial = True+ r = live.handle_route_net({}, self.args)+ self.assertEqual(r['errorCode'], 'mutation_rolled_back')+ self.assertEqual(self.board.ops, ['begin', 'drop'])+ self.assertFalse(self.board.items)++ def test_post_commit_save_failure_is_not_failed_mutation(self):+ self.board.fail_save = True+ r = live.handle_route_net({}, {**self.args, 'save': True})+ self.assertTrue(r['success'], r); self.assertTrue(r['mutated'])+ self.assertFalse(r['saved']); self.assertIn('save failed', r['postCommitError'])+ self.assertEqual(self.board.ops, ['begin', 'push'])++ def test_replay_rejected(self):+ self.assertTrue(live.handle_route_net({}, self.args)['success'])+ self.assertEqual(live.handle_route_net({}, self.args)['errorCode'], 'stale_board')+ self.assertEqual(self.board.ops, ['begin', 'push'])++ def test_remove_readback_detects_ignored_deletion(self):+ r = live.handle_route_net({}, self.args)+ self.board.keep_removed = True+ with patch.object(live, '_delete_items', side_effect=RuntimeError('deletion rejected')):+ r = live.handle_remove_route({}, {**self.args, 'expectedRevision': r['revision'], 'itemIds': r['itemIds']})+ self.assertEqual(r['errorCode'], 'mutation_rolled_back')+ self.assertEqual(self.board.ops[-2:], ['begin', 'drop'])++ def test_deletion_protocol_requires_each_id_accepted(self):+ from kipy.proto.common.commands.editor_commands_pb2 import DeleteItemsResponse, IDS_OK, IDS_IMMUTABLE+ from kipy.proto.common.types.base_types_pb2 import DocumentSpecifier, IRS_OK+ live.handle_route_net({}, self.args)+ response = DeleteItemsResponse(status=IRS_OK)+ row = response.deleted_items.add(); row.id.CopyFrom(self.board.items[0].id); row.status = IDS_OK+ self.board._doc = DocumentSpecifier()+ self.board._kicad = SimpleNamespace(send=lambda *a: response)+ live._delete_items(self.board, self.board.items)+ row.status = IDS_IMMUTABLE+ with self.assertRaises(RuntimeError): live._delete_items(self.board, self.board.items)+ del response.deleted_items[:]+ live._delete_items(self.board, self.board.items) # KiCad 10.0.5 compatibility+ response.status = 0+ with self.assertRaises(RuntimeError): live._delete_items(self.board, self.board.items)++ def test_wrong_or_locked_item_never_deleted(self):+ r = live.handle_remove_route({}, {**self.args, 'itemIds': ['not-copper']})+ self.assertEqual(r['errorCode'], 'invalid_item_ids')+ self.assertEqual(self.board.ops, [])+++class OfflineTests(unittest.TestCase):+ def test_numeric_net_name_and_fractional_code(self):+ b = {'nets': [{'number': 1, 'name': '123'}], 'net_format': 'number'}+ self.assertEqual(route._resolve_net(b, '123')[0]['number'], 1)+ for spec in [1.5, float('nan'), True, '123 '.strip()+'x']:+ self.assertIsNotNone(route._resolve_net(b, spec)[1])++ def test_atomic_file_edit_and_open_board_refusal(self):+ with tempfile.TemporaryDirectory() as d:+ path = make(d); before = path.read_bytes()+ r = route.handle_route({}, {'filePath':str(path),'net':'NET_1','points':['J1.1','J2.1']})+ self.assertTrue(r['success'], r)+ self.assertEqual(Path(str(path)+'.adom-bak').read_bytes(), before)+ path.with_name('~'+path.name+'.lck').write_text('open')+ after = path.read_bytes()+ r = route.handle_add_track({}, {'filePath':str(path),'net':'NET_1','start':[1,1],'end':[2,2]})+ self.assertEqual(r['errorCode'], 'board_open')+ self.assertEqual(path.read_bytes(), after)
tools/build_routing_dev.pyadded+17@@ -0,0 +1,17 @@+"""Build only the routing overlay; does not publish or alter release manifests."""+import ast+import json+from pathlib import Path+import sys+import zipfile+from install_routing_dev import FILES, NAMES++root=Path(__file__).resolve().parents[1]+tree=ast.parse((root/'server.py').read_text())+node=next(n for n in tree.body if isinstance(n,ast.Assign) and any(getattr(t,'id','')=='_VERB_CATALOG' for t in n.targets))+catalog=ast.literal_eval(node.value)+output=Path(sys.argv[1] if len(sys.argv)>1 else root/'kicad-routing-overlay.zip')+with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as z:+ for name in FILES: z.write(root/name,name)+ z.writestr('routing-catalog.json',json.dumps({n:catalog[n] for n in NAMES}))+print(output)
tools/install_routing_dev.pyadded+92@@ -0,0 +1,92 @@+"""Apply the routing development overlay to an existing PINNED bridge cache.++Run with the bridge stopped: python install_routing_dev.py CACHE OVERLAY.zip.+Only the routing files and registration entries change; unrelated newer bridge+fixes stay installed. A timestamped backup beside the cache permits restoration.+"""+import ast+from datetime import datetime, timezone+import json+from pathlib import Path+import shutil+import sys+import zipfile++FILES = ['handlers/live_routing.py', 'handlers/route.py', 'parsers/pcb.py', 'requirements-routing.txt']+NAMES = ['routing_state', 'route_net', 'remove_route', 'routing_validate', 'board_pads', 'add_track', 'add_via', 'route']+++def after_assignment(source, name, addition):+ node = next(n for n in ast.walk(ast.parse(source)) if isinstance(n, ast.Assign)+ and any(isinstance(t, ast.Name) and t.id == name for t in n.targets))+ lines = source.splitlines(keepends=True)+ indentation = ' ' * node.col_offset+ lines.insert(node.end_lineno, '\n' + '\n'.join(indentation + line for line in addition.splitlines()) + '\n')+ return ''.join(lines)+++def install(cache, overlay):+ cache = Path(cache)+ if not (cache / '.dev-pin').is_file():+ raise ValueError('Refusing: use ab bridge_dev_mode {name:kicad,on:true} first')+ # Read and compile the entire overlay before touching the running cache.+ with zipfile.ZipFile(overlay) as z:+ contents = {name: z.read(name) for name in FILES}+ catalog = json.loads(z.read('routing-catalog.json'))+ if set(catalog) != set(NAMES):+ raise ValueError('Incomplete routing verb catalog')+ for name, data in contents.items():+ if name.endswith('.py'):+ compile(data, name, 'exec')+ source = (cache / 'server.py').read_text(encoding='utf-8')+ # Remove only our delimited registrations so reinstallation updates help too.+ import re+ source = re.sub(r'\n[ \t]*# ADOM_LIVE_ROUTING_DEV_BEGIN.*?# ADOM_LIVE_ROUTING_DEV_END\n', '\n', source, flags=re.S)+ if '# ADOM_LIVE_ROUTING_DEV' in source:+ raise ValueError('Old development overlay found; restore server.py from its backup before upgrading')+ def marked(text):+ return '# ADOM_LIVE_ROUTING_DEV_BEGIN\n' + text + '\n# ADOM_LIVE_ROUTING_DEV_END'+ source = after_assignment(source, '_VERB_CATALOG', marked('_VERB_CATALOG.update(' + repr(catalog) + ')'))+ addition = ('from handlers.live_routing import handle_routing_state, handle_route_net, handle_remove_route, handle_routing_validate\n'+ 'from handlers.route import handle_board_pads, handle_add_track, handle_add_via, handle_route\n'+ 'COMMAND_HANDLERS.update({' + ', '.join(repr(n) + ': handle_' + n for n in NAMES) + '})')+ source = after_assignment(source, 'COMMAND_HANDLERS', marked(addition))+ source = after_assignment(source, '_NO_GUI', marked('_NO_GUI.update(' + repr(set(NAMES)) + ')'))+ compile(source, str(cache / 'server.py'), 'exec')+ contents['server.py'] = source.encode('utf-8')+ manifest = json.loads((cache / 'bridge.json').read_text())+ for name in NAMES:+ if 'kicad_' + name not in manifest['verbs']:+ manifest['verbs'].append('kicad_' + name)+ manifest['version'] = '0.10.0-dev.per-net.1'+ contents['bridge.json'] = (json.dumps(manifest, indent=2) + '\n').encode()+ contents['BRIDGE_VERSION'] = manifest['version'].encode()+ contents['.bridge-version'] = manifest['version'].encode()+ backup = cache.parent / ('kicad-routing-backup-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%f'))+ backup.mkdir()+ originals = {}+ for name in contents:+ original = cache / name+ originals[name] = original.read_bytes() if original.is_file() else None+ if originals[name] is not None:+ target = backup / name+ target.parent.mkdir(parents=True, exist_ok=True)+ target.write_bytes(originals[name])+ try:+ for name, data in contents.items():+ target = cache / name+ target.parent.mkdir(parents=True, exist_ok=True)+ target.write_bytes(data)+ except Exception:+ for name, data in originals.items():+ if data is None:+ (cache / name).unlink(missing_ok=True)+ else:+ (cache / name).write_bytes(data)+ raise+ result = {'installed': str(cache), 'backup': str(backup), 'version': manifest['version']}+ print(json.dumps(result))+ return result++if __name__ == '__main__':+ install(*sys.argv[1:])
tools/release_files.json+13−2@@ -38,6 +38,7 @@ "handlers/plugin_diagnose.py", "handlers/png_stdlib.py", "handlers/progress.py",+ "handlers/route.py", "handlers/run_drc.py", "handlers/settings.py", "handlers/show.py",@@ -71,6 +72,16 @@ "start.bat", "start.sh", "templates/blank-board.kicad_pcb",- "uninstall.sh"+ "uninstall.sh",+ "handlers/live_routing.py",+ "requirements-routing.txt",+ "handlers/demo_motion.py",+ "handlers/design_rules.py",+ "handlers/kicad_log.py",+ "handlers/kicad_parse_check.py",+ "handlers/kicad_windows.py",+ "handlers/list_symbols.py",+ "handlers/pcm.py",+ "handlers/win_paths.py" ]-}+}