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.
Add verified live per-net routing and trace demo #2
feature/per-net-live-routing → masterKiCad lacked live, undoable per-net copper edits. This adds IPC verbs for routing state, per-net or per-segment commits, removal by item ID, and DRC validation against the unsaved editor snapshot. Callers supply explicit waypoints; this does not implement obstacle search or push-and-shove routing.
Mutations require the current board revision, validate candidate copper by default, and use one native KiCad Undo transaction. The inherited file-routing verbs refuse open boards instead of invoking File > Revert. The six-net demo includes branching and through-via layer changes. Its development overlay preserves unrelated runtime files and updates ab's cache version marker.
Verified on AdomLapper with KiCad 10.0.5 and ab upgraded to 2.1.39: both routing modes connected all six nets (13 pads, 20 segments, two vias), with zero DRC errors and zero unconnected items. Three warnings concern the synthetic fixture's uninstalled footprint library. Wrong-net, stale-revision and crossing-route rejection, dry-run immutability, native Undo/Redo, route removal, and Undo restoring all 22 copper items passed. A background recording was finalized and decoded successfully. Evidence is in demo/routing/evidence.
Container validation: 25 routing tests and four companion ab guard tests pass. The live removal tests found KiCad's empty per-item deletion response; the implementation accepts that documented compatibility case and verifies removal after commit. Pinned refresh preserved the development cache's source hash on the live host.
Keep this as a draft: it is a development branch, not a release. The companion ab source patch fixes explicit bridge_install bypassing the development pin and serializes pin changes with cache replacement, but still needs a complete Windows build and live installation tests. Default branch and published releases are unchanged.
Diff Skip to comments (1)
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+83@@ -0,0 +1,83 @@+# 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.
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+52@@ -0,0 +1,52 @@+{+ "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"+ ]+}
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" ]-}+}
Comments
Log in to comment.
Integrated into master by hand rather than merged: the branch was 30 commits behind master (it predates the 0.9.318 to 0.9.339 tour work), so the routing files and registrations were applied on top of current master and shipped as 0.9.340 to the insiders tier (commit 744e5db, zip sha256 84c6b60b8cc484c502e2cc78274c9016957f0aeb9f5a34ec82b83b89f7dce68f). What landed: handlers/live_routing.py, handlers/route.py, parsers/pcb.py (name-based nets), tests/, demo/routing/, requirements-routing.txt, the server registrations and the SKILL.md sections. Additions on top of the branch: the IPC client is vendored in the zip (routing_deps/cp311..cp313, Windows wheels), a kicad_ipc_api verb flips KiCad's IPC switch, run_demo.py accepts release builds. Not on the wiki branch and therefore not shipped: the later DRC completeness commit (b373e5e, capped reports as lower bounds, zone refill) named in the handoff; push it to a branch and it will be merged as a follow-up. Verified on arav-rog (KiCad 10.0.3, ab 2.1.66, Python 3.12): both demo modes, rejections, remove, native Undo/Redo; evidence in demo/routing/evidence/arav-rog-2026-09-07.json.