← Commit history

1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schematic: components, pins, items, rotate, region move, annotate, replace and update symbols, field reset, wire delete/split, labels, junctions, net stubs, pin-to-pin wiring, text, buses, atomic batches, sheets and hierarchy, sheet pins, connectivity trace, checks, connectivity fixes, PNG render with visual baselines, design review. Board: outline, layer count, graphics, mounting holes, SVG import, refill, save, add/flip/delete/edit/align footprints, update footprints from library, footprint graphics and 3D models, selection and cross-probe, update PCB from schematic. Routing and rules: modify track, diff pairs, copy routing, netclasses, board rules, custom rules, clearance check, placement score, decoupling placement, fanout plan. Fab: DXF, GenCAD, IPC-2581, ODB++, IPC-D-356, one-zip fab package, fab pre-flight, cost numbers. Projects and libraries: create, info, rename, snapshot; footprint and symbol create, edit, info; library search. PCM: file:// and private wiki repositories, zipPath installs (#132).

John Lauer ·9eba82a0b9 ·14d ago ·parent 6c2c171
22 files changed +7862−92
rust/Cargo.lock+236−6
@@ -23,6 +23,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]]+name = "arrayref"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"++[[package]]+name = "arrayvec"+version = "0.7.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"+ [[package]] name = "ascii" version = "1.1.0"@@ -35,18 +47,36 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"+ [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]]+name = "bitflags"+version = "1.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"+ [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]]+name = "bytemuck"+version = "1.25.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"+ [[package]] name = "bytes" version = "1.12.1"@@ -112,12 +142,36 @@ dependencies = [  "cfg-if", ] +[[package]]+name = "data-url"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"+ [[package]] name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]]+name = "euclid"+version = "0.22.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"+dependencies = [+ "num-traits",+]++[[package]]+name = "fdeflate"+version = "0.3.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"+dependencies = [+ "simd-adler32",+]+ [[package]] name = "find-msvc-tools" version = "0.1.12"@@ -131,10 +185,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [  "crc32fast",- "miniz_oxide",+ "miniz_oxide 0.9.1",  "zlib-rs", ] +[[package]]+name = "float-cmp"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"+ [[package]] name = "futures-core" version = "0.3.34"@@ -216,6 +276,12 @@ dependencies = [  "cc", ] +[[package]]+name = "imagesize"+version = "0.13.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"+ [[package]] name = "itertools" version = "0.14.0"@@ -244,7 +310,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.59"+version = "1.0.61" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,12 +321,13 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.59"+version = "1.0.61" dependencies = [  "kicad-ipc-rs",  "kicad-platform",  "prost-types",  "quick-xml",+ "resvg",  "serde",  "serde_json",  "ureq",@@ -281,7 +348,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.59"+version = "1.0.61" dependencies = [  "serde",  "serde_json",@@ -290,6 +357,17 @@ dependencies = [  "winreg", ] +[[package]]+name = "kurbo"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"+dependencies = [+ "arrayvec",+ "euclid",+ "smallvec",+]+ [[package]] name = "libc" version = "0.2.189"@@ -308,6 +386,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]+ [[package]] name = "miniz_oxide" version = "0.9.1"@@ -359,12 +447,31 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]]+name = "pico-args"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"+ [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]]+name = "png"+version = "0.17.16"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"+dependencies = [+ "bitflags",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9",+]+ [[package]] name = "proc-macro2" version = "1.0.107"@@ -424,6 +531,29 @@ dependencies = [  "proc-macro2", ] +[[package]]+name = "resvg"+version = "0.45.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"+dependencies = [+ "log",+ "pico-args",+ "rgb",+ "svgtypes",+ "tiny-skia",+ "usvg",+]++[[package]]+name = "rgb"+version = "0.8.53"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"+dependencies = [+ "bytemuck",+]+ [[package]] name = "ring" version = "0.17.14"@@ -438,6 +568,12 @@ dependencies = [  "windows-sys 0.52.0", ] +[[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"+ [[package]] name = "rustls" version = "0.23.44"@@ -534,18 +670,58 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]]+name = "simplecss"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"+dependencies = [+ "log",+]++[[package]]+name = "siphasher"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"+ [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]]+name = "smallvec"+version = "1.16.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"++[[package]]+name = "strict-num"+version = "0.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"+dependencies = [+ "float-cmp",+]+ [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]]+name = "svgtypes"+version = "0.15.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"+dependencies = [+ "kurbo",+ "siphasher",+]+ [[package]] name = "syn" version = "2.0.119"@@ -588,6 +764,32 @@ dependencies = [  "syn 3.0.5", ] +[[package]]+name = "tiny-skia"+version = "0.11.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"+dependencies = [+ "arrayref",+ "arrayvec",+ "bytemuck",+ "cfg-if",+ "log",+ "png",+ "tiny-skia-path",+]++[[package]]+name = "tiny-skia-path"+version = "0.11.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"+dependencies = [+ "arrayref",+ "bytemuck",+ "strict-num",+]+ [[package]] name = "tiny_http" version = "0.12.0"@@ -662,7 +864,7 @@ version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" dependencies = [- "base64",+ "base64 0.23.1",  "flate2",  "log",  "percent-encoding",@@ -679,12 +881,34 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" dependencies = [- "base64",+ "base64 0.23.1",  "http",  "httparse",  "log", ] +[[package]]+name = "usvg"+version = "0.45.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"+dependencies = [+ "base64 0.22.1",+ "data-url",+ "flate2",+ "imagesize",+ "kurbo",+ "log",+ "pico-args",+ "roxmltree",+ "simplecss",+ "siphasher",+ "strict-num",+ "svgtypes",+ "tiny-skia-path",+ "xmlwriter",+]+ [[package]] name = "utf8-zero" version = "0.8.1"@@ -1048,6 +1272,12 @@ dependencies = [  "windows-sys 0.59.0", ] +[[package]]+name = "xmlwriter"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"+ [[package]] name = "zeroize" version = "1.9.0"
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.59"+version = "1.0.61" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/groups.rs+10
@@ -20,13 +20,23 @@ pub static GROUPS: &[Group] = &[     Group { name: "libraries", verbs: || crate::verbs_libraries::VERBS, dispatch: crate::verbs_libraries::dispatch },     Group { name: "maint", verbs: || crate::verbs_maint::VERBS, dispatch: crate::verbs_maint::dispatch },     Group { name: "schematic", verbs: || crate::verbs_schematic::VERBS, dispatch: crate::verbs_schematic::dispatch },+    Group { name: "sch_edit", verbs: || crate::verbs_sch_edit::VERBS, dispatch: crate::verbs_sch_edit::dispatch },+    Group { name: "sch_hier", verbs: || crate::verbs_sch_hier::VERBS, dispatch: crate::verbs_sch_hier::dispatch },     Group { name: "misc", verbs: || crate::verbs_misc::VERBS, dispatch: crate::verbs_misc::dispatch },     Group { name: "install", verbs: || crate::verbs_install::VERBS, dispatch: crate::verbs_install::dispatch },     Group { name: "routing", verbs: || crate::verbs_routing::VERBS, dispatch: crate::verbs_routing::dispatch },     Group { name: "standard", verbs: || crate::verbs_standard::VERBS, dispatch: crate::verbs_standard::dispatch },+    Group { name: "board", verbs: || crate::verbs_board::VERBS, dispatch: crate::verbs_board::dispatch },     Group { name: "placement", verbs: || crate::verbs_placement::VERBS, dispatch: crate::verbs_placement::dispatch },     Group { name: "windows", verbs: || crate::verbs_windows::VERBS, dispatch: crate::verbs_windows::dispatch },     Group { name: "show", verbs: || crate::verbs_show::VERBS, dispatch: crate::verbs_show::dispatch },+    Group { name: "fab", verbs: || crate::verbs_fab::VERBS, dispatch: crate::verbs_fab::dispatch },+    Group { name: "project", verbs: || crate::verbs_project::VERBS, dispatch: crate::verbs_project::dispatch },+    Group { name: "libedit", verbs: || crate::verbs_libedit::VERBS, dispatch: crate::verbs_libedit::dispatch },+    Group { name: "rules", verbs: || crate::verbs_rules::VERBS, dispatch: crate::verbs_rules::dispatch },+    Group { name: "route_edit", verbs: || crate::verbs_route_edit::VERBS, dispatch: crate::verbs_route_edit::dispatch },+    Group { name: "place_helpers", verbs: || crate::verbs_place_helpers::VERBS, dispatch: crate::verbs_place_helpers::dispatch },+    Group { name: "pcb_sync", verbs: || crate::verbs_pcb_sync::VERBS, dispatch: crate::verbs_pcb_sync::dispatch },     Group { name: "demo", verbs: || crate::verbs_demo::VERBS, dispatch: crate::verbs_demo::dispatch }, ]; 
rust/crates/kicad-bridge/src/main.rs+10
@@ -11,7 +11,11 @@ mod catalog; mod groups; mod util; mod verbs;+mod verbs_board; mod verbs_demo;+mod verbs_fab;+mod verbs_libedit;+mod verbs_project; mod verbs_install; mod taskbar; mod verbs_libraries;@@ -20,8 +24,14 @@ mod verbs_maint; mod verbs_misc; mod verbs_netlist; mod verbs_placement;+mod verbs_place_helpers;+mod verbs_pcb_sync;+mod verbs_route_edit;+mod verbs_rules; mod verbs_routing; mod verbs_schematic;+mod verbs_sch_edit;+mod verbs_sch_hier; mod verbs_show; mod verbs_standard; mod verbs_windows;
rust/crates/kicad-bridge/src/verbs.rs+17−4
@@ -67,7 +67,8 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal         reply_hints(command, &mut out);         prog.attach(&mut out);         if let Some(o) = out.as_object_mut() {-            o.entry("mechanism").or_insert(json!(verb.mechanism.as_str()));+            let m = actual_mechanism(o, verb.mechanism.as_str());+            o.entry("mechanism").or_insert(json!(m));         }         return out;     }@@ -92,7 +93,8 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal     reply_hints(command, &mut out);     prog.attach(&mut out);     if let Some(o) = out.as_object_mut() {-        o.entry("mechanism").or_insert(json!(verb.mechanism.as_str()));+        let m = actual_mechanism(o, verb.mechanism.as_str());+            o.entry("mechanism").or_insert(json!(m));     }     out }@@ -259,7 +261,7 @@ fn run_check(state: &mut State, args: &Value, drc: bool) -> Value {  /// kicad-cli exports, same contract as the Python handlers: filePath, outputDir (default /// <file dir>/<type>_output), layers for svg/pdf; returns data.output_path and data.files.-fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label: &str) -> Value {+pub(crate) fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label: &str) -> Value {     let Some(path) = arg_any(args, &["filePath", "boardPath", "schematicPath"]) else {         return fail("missing_arg", "No filePath specified", format!("Pass filePath: the absolute path of the {}.", if doc == "pcb" { ".kicad_pcb" } else { ".kicad_sch" }));     };@@ -382,7 +384,7 @@ fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label: }  /// kicad_export_pos: the component position (pick-and-place / CPL) file, optionally in a fab's columns.-fn export_pos(state: &mut State, args: &Value) -> Value {+pub(crate) fn export_pos(state: &mut State, args: &Value) -> Value {     let Some(path) = arg_any(args, &["filePath", "boardPath"]) else {         return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the .kicad_pcb.");     };@@ -587,3 +589,14 @@ mod pos_tests {         assert_eq!(jlcpcb_cpl("nonsense\n1,2,3\n", "mm"), "Designator,Mid X,Mid Y,Layer,Rotation\n");     } }++/// The mechanism a reply reports: the path the verb actually took when it says so (`source`:+/// "file" or "live"), else the catalog's. A live-capable verb that fell back to the closed file+/// must not answer "ipc" (seen on ConfRoomROG, 1.0.60).+fn actual_mechanism(o: &serde_json::Map<String, Value>, catalog: &str) -> String {+    match o.get("source").and_then(Value::as_str) {+        Some("file") => "file".into(),+        Some("live") | Some("ipc") => "ipc".into(),+        _ => catalog.into(),+    }+}
rust/crates/kicad-bridge/src/verbs_board.rsadded+854
@@ -0,0 +1,854 @@+//! Verb group "board": the board outline, the copper layer count, board graphics, mounting+//! holes, SVG artwork, zone refill and save, footprints added, flipped, deleted, edited,+//! aligned and updated from their library, a placed footprint's graphics and 3D models, the+//! PCB editor's selection, and schematic cross-probing.+//!+//! The edits are `kicad_core::board_edit` text transforms. When the PCB editor serves the+//! board on the IPC API they are committed live as ONE native Undo step+//! (`kicad_core::ipc::commit_change`: exact board, expectedRevision, KiCad's DRC on a+//! snapshot when copper changes, dryRun, a verified read-back); otherwise the closed+//! `.kicad_pcb` is edited atomically with a `.adom-bak` beside it, and an open board (its+//! lock file) is refused.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::board_edit::{self, Change};+use kicad_core::pcb::{self, RoutingError};++const LIVE_PITFALLS: &[&str] = &[+    "Live when the PCB editor serves the board on the IPC API (one native Undo step; expectedRevision required, from kicad_routing_state, kicad_placement_state or the previous edit; KiCad's DRC on a snapshot first when copper changes), otherwise the closed file (atomic write, <board>.adom-bak beside it, source:\"file\"); source:\"file\" or \"live\" forces one. dryRun:true changes nothing.",+    "A board open in KiCad without the IPC API is refused (board_open): the editor would overwrite a file edit. Turn the API on (kicad_ipc_api {\"enable\":true}, relaunch) or close the board.",+    "Live, the new items are created from text by KiCad's own parser (ParseAndCreateItemsFromString) with fresh UUIDs; a KiCad that does not serve that call refuses with nothing changed.",+    "Never replay a timed-out edit (mutation_outcome_unknown): read the board first.",+];++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_board_outline",+        summary: "Draw the board outline on Edge.Cuts: a rectangle, a rounded rectangle with true arcs, or a polygon; replace:true removes the old outline first.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"shape\"?: \"rectangle|rounded|polygon\", \"width\", \"height\", \"x\"?, \"y\"? (top-left corner, mm) | \"center\"?: [x, y], \"radius\"?: 2, \"points\"?: [[x,y],...], \"lineWidth\"?: 0.05, \"replace\"?: false, \"append\"?: false, ...edit args}",+        example: "kicad_board_outline {\"filePath\":\"C:/p/b.kicad_pcb\",\"shape\":\"rounded\",\"width\":50,\"height\":30,\"x\":100,\"y\":80,\"radius\":3,\"replace\":true,\"expectedRevision\":\"<rev>\"}",+        hint: "mm, KiCad's frame (+y down). A board that already has Edge.Cuts items is refused (outline_exists, listing them) unless replace:true (they are deleted in the same Undo step) or append:true (a cutout). The reply's box and outlineAfter are the new extents.",+        related: &["kicad_board_summary", "kicad_delete_graphics", "kicad_placement_validate"],+        pitfalls: &["Moving the outline does not move parts: check kicad_placement_validate afterwards.", "A rounded corner radius cannot exceed half the shorter side.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[3]],+    },+    Verb {+        name: "kicad_set_layer_count",+        summary: "Set the copper layer count (2, 4, 6 or 8) of a closed board: the layers table and, when the board has one, the physical stack-up (board thickness kept).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"count\": 4, \"dryRun\"?: false, \"expectedRevision\"?: \"<file revision>\"}",+        example: "kicad_set_layer_count {\"filePath\":\"C:/p/b.kicad_pcb\",\"count\":4}",+        hint: "Closed file only (KiCad has no IPC call for the layer count): a board open in KiCad is refused (board_open). New inner layers are signal layers In1.Cu..; the stack-up's dielectrics are regenerated as FR4 (Dk 4.5) sharing the board thickness. Removing an inner layer that still carries tracks, zones or graphics is refused (layers_in_use with counts per layer).",+        related: &["kicad_copper_layers", "kicad_layer_stack", "kicad_layers_in_use"],+        pitfalls: &["Re-check impedance-controlled stack-ups in Board Setup > Physical Stackup afterwards: the dielectrics are generic.", "Net classes and design rules are untouched."],+    },+    Verb {+        name: "kicad_delete_graphics",+        summary: "Delete board graphics (lines, rects, circles, arcs, polygons, curves, texts, text boxes, dimensions) by uuid, or every one on a layer and/or of a type.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"uuids\"?: [\"...\"], \"layer\"?: \"F.SilkS\", \"type\"?: \"line|rect|circle|arc|polygon|bezier|text|textbox|dimension\" | [...], ...edit args}",+        example: "kicad_delete_graphics {\"filePath\":\"C:/p/b.kicad_pcb\",\"layer\":\"Dwgs.User\",\"dryRun\":true}",+        hint: "Top-level board graphics only (footprint graphics: kicad_footprint_graphics). dryRun:true lists exactly what would go (deleted[] with uuid, kind, layer, points). An unknown uuid refuses the whole call (unknown_ids).",+        related: &["kicad_board_outline", "kicad_footprint_graphics", "kicad_silk_graphics_batch"],+        pitfalls: &["Deleting Edge.Cuts items removes the board outline.", "A filter with no uuids deletes every match: dry-run it first.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[3]],+    },+    Verb {+        name: "kicad_add_mounting_hole",+        summary: "Add a mounting hole footprint at x, y: NPTH by default, or plated with a pad (and a net); KiCad's MountingHole library footprint when one matches the drill, else a generated one.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"x\", \"y\", \"drill\": 3.2, \"plated\"?: false, \"padDiameter\"?: 6.4, \"net\"?: \"GND\", \"reference\"?: \"H\" | \"H4\", ...edit args}",+        example: "kicad_add_mounting_hole {\"filePath\":\"C:/p/b.kicad_pcb\",\"x\":104,\"y\":62,\"drill\":3.2,\"expectedRevision\":\"<rev>\"}",+        hint: "Stock drills (2.2 M2, 2.7 M2.5, 3.2 M3, 3.7, 4.3 M4, 5.3 M5, 6.4 M6, 8.4 M8) use MountingHole:MountingHole_<d>mm_<M> (_Pad when plated) from your KiCad library when it is installed and no padDiameter is given; the reply's footprint says which was used. A net makes it plated. reference \"H\" takes the next free H number.",+        related: &["kicad_add_footprint", "kicad_placement_validate", "kicad_board_outline"],+        pitfalls: LIVE_PITFALLS,+    },+    Verb {+        name: "kicad_import_svg",+        summary: "Import an SVG logo or artwork as filled polygons on a silkscreen, copper, mask or user layer, at a position and size; curves flattened, holes kept.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"svgPath\": \"C:/.../logo.svg\" | \"svg\": \"<svg ...>\", \"x\", \"y\", \"width\"?: 12 (mm) | \"scale\"?: 1, \"anchor\"?: \"topleft|center\", \"layer\"?: \"F.SilkS\", \"mirror\"?: (default true on B.*), \"net\"?: (copper), \"toleranceMm\"?: 0.02, ...edit args}",+        example: "kicad_import_svg {\"filePath\":\"C:/p/b.kicad_pcb\",\"svgPath\":\"C:/p/logo.svg\",\"x\":120,\"y\":95,\"width\":10,\"layer\":\"F.SilkS\",\"expectedRevision\":\"<rev>\"}",+        hint: "Paths (every command), rect, circle, ellipse, polygon and polyline with their group transforms; the root viewBox and width/height set the natural size (px are 1/96 in). Shapes with fill none are skipped; text and images are listed in skipped (convert text to paths first). A shape inside another becomes a hole, joined to its outline by a zero-width cut like KiCad's own fills.",+        related: &["kicad_silk_text_batch", "kicad_delete_graphics", "kicad_render_board"],+        pitfalls: &["Stroke-only artwork imports nothing: give the SVG filled shapes.", "On copper the polygons are graphics (no thermal relief); give net to tie them to a net, and run DRC.", "On a B.* layer the artwork is mirrored so it reads correctly from the back; mirror:false keeps it as drawn.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[3]],+    },+    Verb {+        name: "kicad_refill_zones",+        summary: "Refill every copper zone (or the listed ones when KiCad accepts a list) in the open PCB editor, wait for the fill to settle, and report the filled copper per zone and layer.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"zoneIds\"?: [\"...\"], \"save\"?: false, \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_refill_zones {\"filePath\":\"C:/p/b.kicad_pcb\"}",+        hint: "Live only. KiCad fills asynchronously: the bridge waits (up to 12 s) for the revision to hold still and says revisionSettled. KiCad 10.0.x refuses a zone list (AS_UNIMPLEMENTED); the bridge then refills every zone and says so in note. filledAreaByLayerMm2 is the copper KiCad poured.",+        related: &["kicad_zone_state", "kicad_pour_state", "kicad_add_zone"],+        pitfalls: &["The refill is its own KiCad Undo step.", "revisionSettled:false means read kicad_zone_state for a settled revision before the next edit."],+    },+    Verb {+        name: "kicad_save_board",+        summary: "Save the board open in the PCB editor (File > Save through the IPC API) and confirm the file on disk matches the editor.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_save_board {\"filePath\":\"C:/p/b.kicad_pcb\"}",+        hint: "Live only. fileMatchesEditor compares the copper, parts and outline of the saved file with the editor's board; fileWritten says the file's modification time moved. The edit verbs also take save:true.",+        related: &["kicad_routing_state", "kicad_close"],+        pitfalls: &["Saving overwrites the file on disk: the edit verbs' .adom-bak backups are from closed-file edits only."],+    },+    Verb {+        name: "kicad_add_footprint",+        summary: "Add a library footprint at x, y, rotation and side with a reference and value, a row or grid of them (count, pitch), or a copy of a placed one (duplicateOf); optional pad nets.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"libraryId\": \"Resistor_SMD:R_0603_1608Metric\" | \"footprintPath\": \"C:/.../X.pretty/Y.kicad_mod\" | \"duplicateOf\": \"R5\", \"x\", \"y\", \"rotation\"?: 0, \"side\"?: \"F.Cu|B.Cu\", \"reference\": \"R\" | \"R12\", \"value\"?: \"10k\", \"count\"?: 1, \"pitch\"?: [dx, dy], \"columns\"?: n, \"nets\"?: {\"1\": \"GND\"}, \"keepNets\"?: false, ...edit args}",+        example: "kicad_add_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"libraryId\":\"Capacitor_SMD:C_0402_1005Metric\",\"x\":130,\"y\":90,\"reference\":\"C\",\"value\":\"100n\",\"count\":4,\"pitch\":[1.5,0],\"expectedRevision\":\"<rev>\"}",+        hint: "libraryId resolves through the project's fp-lib-table, then your global one (KiCad's stock libraries included). reference \"C\" takes the next free numbers; \"C12\" starts there and refuses a taken one (duplicate_reference names the next free). A back-side part is flipped the way KiCad flips (left/right), then turned to rotation. duplicateOf copies the part with fresh UUIDs, without its nets (keepNets:true keeps them) and without its schematic link. The reply lists each added ref with its uuid and pose.",+        related: &["kicad_move_footprint", "kicad_placement_state", "kicad_add_mounting_hole", "kicad_update_pcb_from_schematic"],+        pitfalls: &["A part added on the board is not in the schematic: Update PCB from Schematic would remove it unless the schematic gains the symbol.", "No courtyard check here: run kicad_placement_validate, or place with kicad_move_footprint's checks.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2]],+    },+    Verb {+        name: "kicad_flip_footprint",+        summary: "Move footprints to the other side of the board, mirrored about their anchors: KiCad's own FlipItems live on 10.0.6+, a full client-side mirror otherwise (and on a closed file).",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"ref\": \"U3\" | \"refs\": [\"U3\", \"C9\"], \"direction\"?: \"left_right|top_bottom\", \"allowLocked\"?: false, \"native\"?: true, ...edit args}",+        example: "kicad_flip_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"C9\",\"expectedRevision\":\"<rev>\"}",+        hint: "left_right is KiCad's default (orientation becomes 180 minus the old one). The mirror swaps every side-specific layer (F.x and B.x, inner copper In1 and In(n-2)), negates every local y, mirrors pads, drill offsets, chamfers and custom shapes, and toggles text mirroring, exactly as FOOTPRINT::Flip does; KiCad itself draws the result where the bridge put it (tested against KiCad 10's Gerbers). commit says native (FlipItems) or delete-and-create; nativeAttempt says why FlipItems was not used. DRC runs first (copper moves to the other side).",+        related: &["kicad_move_footprint", "kicad_placement_state", "kicad_placement_validate"],+        pitfalls: &["Tracks that met the pads stay where they were: flip before routing.", "A footprint with a per-layer padstack, a text box, a dimension, or a 3D model with a y offset or x/y rotation is refused for the client-side mirror (flip_unsupported): flip it in KiCad (F).", "The client-side mirror gives the footprint and its children fresh UUIDs live; FlipItems keeps them.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[3]],+    },+    Verb {+        name: "kicad_delete_footprint",+        summary: "Delete footprints by reference, as one native Undo step live or one atomic write to a closed file.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"ref\": \"R7\" | \"refs\": [\"R7\", \"R8\"], \"allowLocked\"?: false, ...edit args}",+        example: "kicad_delete_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"refs\":[\"TP3\"],\"expectedRevision\":\"<rev>\"}",+        hint: "The reply lists each deleted part with the nets its pads carried. A locked part is refused (footprint_locked) unless allowLocked:true. KiCad Undo restores it live.",+        related: &["kicad_add_footprint", "kicad_routing_state", "kicad_remove_route"],+        pitfalls: &["Tracks and vias that met the pads stay: kicad_routing_state shows what is left dangling.", "A part still in the schematic comes back on the next Update PCB from Schematic.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[3]],+    },+    Verb {+        name: "kicad_edit_footprint",+        summary: "Edit placed footprints: value, reference, any field (add, change, remove), DNP, exclude from BOM or position files and other attributes, and the lock.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"ref\": \"R7\" | \"refs\": [...], \"value\"?: \"4k7\", \"newReference\"?: \"R70\", \"fields\"?: {\"LCSC\": \"C25804\", \"Note\": null}, \"dnp\"?: true, \"excludeFromBom\"?: true, \"excludeFromPosFiles\"?: true, \"attributes\"?: {\"type\": \"smd|through_hole|unspecified\", ...}, \"locked\"?: true, ...edit args}",+        example: "kicad_edit_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"R7\",\"value\":\"4k7\",\"dnp\":true,\"expectedRevision\":\"<rev>\"}",+        hint: "Live, value, newReference, the attribute flags and the lock update the footprint in place (UpdateItems, the UUID is kept); fields or the mounting type go through delete-and-create. A null field value removes a user field (the mandatory ones are refused). edited[] carries each part's before and after.",+        related: &["kicad_component_data", "kicad_export_bom_csv", "kicad_sch_set_property"],+        pitfalls: &["Board-side field edits are overwritten by the next Update PCB from Schematic: change the symbol (kicad_sch_set_property) for anything the schematic owns.", "newReference renames one footprint; a taken reference is refused.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[3]],+    },+    Verb {+        name: "kicad_align_footprints",+        summary: "Align footprints on a common left, right, top, bottom or centre line, or distribute them evenly, by courtyard box or anchor, through kicad_move_footprint's checked move.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"refs\": [\"C1\",\"C2\",\"C3\"], \"mode\": \"left|right|top|bottom|centerX|centerY|distributeX|distributeY\", \"by\"?: \"courtyard|anchor\", \"to\"?: mm, \"gap\"?: mm (distribute), \"expectedRevision\", \"dryRun\"?: false, \"allowOverlap\"?: false, \"allowOutside\"?: false, \"save\"?: false}",+        example: "kicad_align_footprints {\"filePath\":\"C:/p/b.kicad_pcb\",\"refs\":[\"C4\",\"C5\",\"C6\"],\"mode\":\"top\",\"expectedRevision\":\"<rev>\"}",+        hint: "The targets are computed from the board, then moved exactly like kicad_move_footprint (revision, courtyard collisions, outline, one Undo step, children verified). Without to, left/top take the group's minimum edge, right/bottom its maximum, centerX/centerY the mean centre; distribute keeps the outer two and spaces the boxes evenly (or by gap). align.targets is the plan.",+        related: &["kicad_move_footprint", "kicad_placement_state", "kicad_placement_validate"],+        pitfalls: &["Live only for a real move (dryRun works on a closed file), like kicad_move_footprint.", "Rotation and side are kept; align parts of the same orientation for a clean row."],+    },+    Verb {+        name: "kicad_update_footprints_from_library",+        summary: "Compare placed footprints with their current library definition and, with apply:true, replace the ones that differ, keeping position, side, reference, value, fields, pad nets and the schematic link.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"refs\"?: [\"U1\"] (default every footprint), \"apply\"?: false, ...edit args}",+        example: "kicad_update_footprints_from_library {\"filePath\":\"C:/p/b.kicad_pcb\",\"refs\":[\"U1\",\"J2\"]}",+        hint: "Plan by default: footprints[] says per ref up_to_date, differs (diff: pads added, removed or changed in shape, size, position, drill or layers; graphics; 3D models) or library_not_found (why). apply:true replaces every differing part as one Undo step live (or one write): pads take their old pad's net, pin function and type by number, the board's own fields and their positions are kept, and DNP, BOM and position-file exclusions stay.",+        related: &["kicad_install_library", "kicad_list_footprints", "kicad_lint_board"],+        pitfalls: &["A pad that is new in the library has no net; padsRemoved pads lose theirs: read the diff before apply.", "Tracks are not moved to follow pads that moved.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2]],+    },+    Verb {+        name: "kicad_footprint_graphics",+        summary: "List a placed footprint's graphics (silkscreen, fab, courtyard lines, rects, circles, arcs, polygons, texts) and edit one by uuid: vertices, width, layer, fill, or delete it.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"ref\": \"U1\", \"uuid\"?: \"<graphic uuid>\", \"change\"?: {\"width\"?: 0.12, \"layer\"?: \"F.Fab\", \"points\"?: [[x,y],...], \"start\"?: [x,y], \"end\"?: [x,y], \"mid\"?, \"center\"?, \"fill\"?: true, \"delete\"?: true}, ...edit args}",+        example: "kicad_footprint_graphics {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"U1\"}",+        hint: "Without uuid it is a read (live editor or file). Points are footprint-local mm, unrotated, as KiCad stores them. With uuid and change the footprint is rewritten as one Undo step (before/after in the reply).",+        related: &["kicad_footprint_models", "kicad_silk_graphics_batch", "kicad_delete_graphics"],+        pitfalls: &["The edit is to this placed copy only; the library is untouched (and kicad_update_footprints_from_library would undo it).", LIVE_PITFALLS[0], LIVE_PITFALLS[2]],+    },+    Verb {+        name: "kicad_footprint_models",+        summary: "List, add, replace or remove the 3D model entries (path, offset, scale, rotation, visibility) of a placed footprint.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"ref\": \"U1\", \"ops\"?: [{\"action\": \"add|replace|remove\", \"index\"?: 0, \"path\"?: \"${KICAD10_3DMODEL_DIR}/...step\", \"newPath\"?, \"offset\"?: [x,y,z], \"scale\"?: [1,1,1], \"rotate\"?: [0,0,90], \"visible\"?: true}] | \"action\"..., ...edit args}",+        example: "kicad_footprint_models {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"U1\",\"ops\":[{\"action\":\"replace\",\"index\":0,\"offset\":[0,0,0.2]}]}",+        hint: "Without ops it is a read. A replace keeps what it does not name; remove takes index or path. Offsets are mm, rotations degrees, in the footprint's frame. kicad_bodies and kicad_show_3d_board show the result.",+        related: &["kicad_bodies", "kicad_show_3d_board", "kicad_footprint_graphics"],+        pitfalls: &["The path is not checked here: kicad_bodies lists models it cannot read in noBody.", LIVE_PITFALLS[0], LIVE_PITFALLS[2]],+    },+    Verb {+        name: "kicad_get_selection",+        summary: "What is selected in the PCB editor right now: ids, types, footprint references, nets and positions.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_get_selection {\"filePath\":\"C:/p/b.kicad_pcb\"}",+        hint: "Live only (IPC GetSelection). references lists the selected footprints, nets the nets of selected copper; items[] has every item with its type and id, usable with kicad_select_items, kicad_remove_route or kicad_delete_graphics.",+        related: &["kicad_select_items", "kicad_select_net", "kicad_select_clear", "kicad_cross_probe"],+        pitfalls: &["The user may be changing the selection while you read it: read once, act on the ids."],+    },+    Verb {+        name: "kicad_select_items",+        summary: "Set the PCB editor's selection to items by id and/or footprints by reference: replace (default), add or remove.",+        mechanism: Mechanism::Ipc, risk: "input", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"itemIds\"?: [\"...\"], \"refs\"?: [\"U1\", \"C3\"], \"mode\"?: \"replace|add|remove\", \"socketPath\"?: \"ipc://...\"}",+        example: "kicad_select_items {\"filePath\":\"C:/p/b.kicad_pcb\",\"refs\":[\"U1\",\"C3\"]}",+        hint: "Live only. verified says every id took (notApplied lists the ones that did not); the reply carries the selection as kicad_get_selection reads it. replace with nothing clears it. Selection is not an edit: no Undo step, the revision is unchanged.",+        related: &["kicad_get_selection", "kicad_select_net", "kicad_select_clear"],+        pitfalls: &["This changes what the user sees selected: say so if someone is working in the editor."],+    },+    Verb {+        name: "kicad_cross_probe",+        summary: "From a schematic symbol (reference, uuid or sheet path) to the board footprint linked to it, and back, by the footprint's symbol path link.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"reference\"?: \"U1\", \"symbolUuid\"?: \"...\", \"path\"?: \"/<sheet uuid>/<symbol uuid>\", \"schematicPath\"?: \"C:/.../x.kicad_sch\", \"select\"?: false, \"source\"?: \"auto|live|file\"}",+        example: "kicad_cross_probe {\"filePath\":\"C:/p/b.kicad_pcb\",\"reference\":\"U3\"}",+        hint: "Reads the live editor's board when it serves it, else the file, and every .kicad_sch beside it (or schematicPath's folder). matches[] carry the footprint (ref, uuid, side, pose), its symbolPath, sheet, and the schematic symbol (file, uuid, reference, libId, value). symbolWithoutFootprint names a symbol the board has no part for. select:true also selects the footprints in the live editor.",+        related: &["kicad_select_items", "kicad_netlist", "kicad_update_pcb_from_schematic"],+        pitfalls: &["linked:false means the footprint has no symbol path (placed on the board by hand, or Update PCB from Schematic never ran)."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_board_outline" => edit(state, args, &|t| board_edit::outline(t, args)),+        "kicad_set_layer_count" => set_layer_count(state, args),+        "kicad_delete_graphics" => edit(state, args, &|t| board_edit::delete_graphics(t, args)),+        "kicad_add_mounting_hole" => mounting_hole(state, args),+        "kicad_import_svg" => import_svg(state, args),+        "kicad_refill_zones" => live_only(state, args, LiveOnly::Refill),+        "kicad_save_board" => live_only(state, args, LiveOnly::Save),+        "kicad_add_footprint" => add_footprint(state, args),+        "kicad_flip_footprint" => edit_with(state, args, &|t| board_edit::flip_footprints(t, args), Special::Flip),+        "kicad_delete_footprint" => edit(state, args, &|t| board_edit::delete_footprints(t, args)),+        "kicad_edit_footprint" => edit_with(state, args, &|t| board_edit::edit_footprints(t, args), Special::Edit),+        "kicad_align_footprints" => align(state, args),+        "kicad_update_footprints_from_library" => update_from_library(state, args),+        "kicad_footprint_graphics" => listed_edit(state, args, Listed::Graphics),+        "kicad_footprint_models" => listed_edit(state, args, Listed::Models),+        "kicad_get_selection" => live_only(state, args, LiveOnly::GetSelection),+        "kicad_select_items" => live_only(state, args, LiveOnly::Select),+        "kicad_cross_probe" => cross_probe(state, args),+        _ => return None,+    })+}++/// Errors that mean no PCB editor serves this board here.+const NO_EDITOR: &[&str] = &["ipc_unavailable", "api_server_off", "no_board_open", "board_mismatch", "no_pcb_frame", "unsupported_kicad", "ipc_feature_off"];++fn routing_fail(e: &RoutingError) -> Value {+    let mut out = json!({"success": false, "errorCode": e.code, "error": e.message});+    for (k, v) in &e.detail {+        out[k] = v.clone();+    }+    if out.get("mutated").is_none() {+        out["mutated"] = json!(false);+    }+    if out.get("_hint").is_none() {+        out["_hint"] = json!(match e.code.as_str() {+            "stale_board" => "Read the board again (kicad_placement_state or kicad_routing_state) and pass its revision.",+            "unknown_footprint" => "kicad_list_designators names every part on the board.",+            "board_open" => "Close the board in KiCad, or enable the IPC API (kicad_ipc_api {\"enable\":true}, relaunch) so the bridge edits it live.",+            _ => "Nothing was changed. Correct the arguments (kicad_describe lists them) and call again.",+        });+    }+    out+}++fn source_of(args: &Value) -> Result<&str, Value> {+    match args.get("source").and_then(Value::as_str).unwrap_or("auto") {+        s @ ("auto" | "live" | "file") => Ok(s),+        _ => Err(fail("bad_args", "source must be auto, live or file", "auto edits the live editor when it serves the board and the closed file otherwise.")),+    }+}++#[cfg(feature = "ipc")]+fn ctx(state: &mut State) -> kicad_core::ipc::Ctx {+    let info = state.kicad_info();+    kicad_core::ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| kicad_core::detect::config_dir(&p.version)),+    }+}++#[derive(Clone, Copy, PartialEq)]+enum Special {+    None,+    Flip,+    Edit,+}++#[cfg(feature = "ipc")]+fn live_edit(state: &mut State, args: &Value, build: &dyn Fn(&str) -> Result<Change, RoutingError>, special: Special) -> Value {+    use kicad_core::ipc;+    let c = ctx(state);+    let r = ipc::with_standard_revision(&c, args, |a| match special {+        Special::Flip => ipc::flip_footprint(&c, a),+        Special::Edit => ipc::edit_footprint(&c, a),+        Special::None => ipc::commit_change(&c, a, |t| build(t)),+    });+    match r {+        Ok(v) => v,+        Err(e) => ipc::error_response(&e),+    }+}++#[cfg(not(feature = "ipc"))]+fn live_edit(_state: &mut State, _args: &Value, _build: &dyn Fn(&str) -> Result<Change, RoutingError>, _special: Special) -> Value {+    fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "The closed board file is edited instead.")+}++fn edit(state: &mut State, args: &Value, build: &dyn Fn(&str) -> Result<Change, RoutingError>) -> Value {+    edit_with(state, args, build, Special::None)+}++/// Live when the editor serves the board, the closed file otherwise.+fn edit_with(state: &mut State, args: &Value, build: &dyn Fn(&str) -> Result<Change, RoutingError>, special: Special) -> Value {+    let source = match source_of(args) {+        Ok(s) => s,+        Err(e) => return e,+    };+    let mut live_err = Value::Null;+    if source != "file" {+        let v = live_edit(state, args, build, special);+        let code = v["errorCode"].as_str().unwrap_or("");+        if v["success"] == json!(true) || source == "live" || !NO_EDITOR.contains(&code) {+            return v;+        }+        live_err = json!({"errorCode": v["errorCode"], "error": v["error"]});+    }+    let mut out = file_edit(args, build);+    if !live_err.is_null() {+        out["live"] = live_err;+    }+    out+}++/// The closed-file path: load (refused while KiCad holds the lock), build, write, read back.+fn file_edit(args: &Value, build: &dyn Fn(&str) -> Result<Change, RoutingError>) -> Value {+    let dry = args.get("dryRun") == Some(&json!(true));+    let loaded = match pcb::load(args, dry) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let revision = kicad_core::placement::file_revision(&loaded.text);+    if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {+        if !revision_matches(exp, &revision, &loaded.text) {+            let mut v = fail("stale_board", "expectedRevision must match the board file", "Read the file again (kicad_placement_state reads it when no editor serves the board) or omit expectedRevision for a closed-file edit.");+            v["currentRevision"] = json!(revision);+            v["mutated"] = json!(false);+            return v;+        }+    }+    let change = match build(&loaded.text) {+        Ok(c) => c,+        Err(e) => {+            let mut v = routing_fail(&e);+            v["source"] = json!("file");+            return v;+        }+    };+    let mut out = json!({"success": true, "source": "file", "boardPath": norm(&loaded.path)});+    merge(&mut out, &change.report);+    if change.candidate == loaded.text {+        out["mutated"] = json!(false);+        out["revision"] = json!(revision);+        out["_hint"] = json!("Nothing to change: the board already matches.");+        return out;+    }+    if dry {+        out["dryRun"] = json!(true);+        out["mutated"] = json!(false);+        out["revision"] = json!(revision);+        out["_hint"] = json!("Checked against the closed file; nothing was written. Call again without dryRun to write it (a .adom-bak backup is kept).");+        return out;+    }+    match board_edit::write_closed(&loaded.path, &loaded.text, &change.candidate) {+        Ok(bak) => {+            out["mutated"] = json!(true);+            out["backup"] = json!(bak);+            out["revision"] = json!(kicad_core::placement::file_revision(&change.candidate));+            let after = pcb::parse_pcb_text(&change.candidate, &loaded.path.to_string_lossy());+            let (ok, rows) = match &after {+                Ok(b) => board_edit::verify(b, &change.expect),+                Err(_) => (false, vec![]),+            };+            out["readback"] = json!(rows);+            out["verified"] = json!(ok);+            out["_hint"] = json!(if ok { "Written to the closed board (the previous file is <board>.adom-bak). kicad_run_drc checks it; open it in KiCad to see it." } else { "Written, but the read-back does not show every planned footprint: inspect the board, and restore <board>.adom-bak if it is wrong." });+            out+        }+        Err(e) => {+            let mut v = routing_fail(&e);+            v["source"] = json!("file");+            v+        }+    }+}++/// A closed file's revision is its text hash; the board model's 16-digit standard revision+/// (kicad_routing_state {model:"standard"}) is accepted too.+fn revision_matches(expected: &str, file_revision: &str, text: &str) -> bool {+    if expected == file_revision {+        return true;+    }+    expected.len() == 16 && expected.bytes().all(|b| b.is_ascii_hexdigit()) && kicad_core::board_model::revision_of_text(text).map(|r| r == expected).unwrap_or(false)+}++fn merge(into: &mut Value, from: &Value) {+    if let (Some(a), Some(b)) = (into.as_object_mut(), from.as_object()) {+        for (k, v) in b {+            a.insert(k.clone(), v.clone());+        }+    }+}++// ---------------------------------------------------------------------------+// Footprint libraries+// ---------------------------------------------------------------------------++fn lib_ctx(state: &mut State) -> kicad_core::libraries::LibCtx {+    let info = state.kicad_info();+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let plat = kicad_platform::native();+    let mut ctx = kicad_core::libraries::LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { plat.config_dir(&version) },+        user_dir: if version.is_empty() { None } else { plat.user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// `Lib:Name` to the `.kicad_mod` text: the project's fp-lib-table (beside the board), then+/// the global one (nested KiCad tables expanded).+fn resolve_footprint(ctx: &kicad_core::libraries::LibCtx, board: &Path, lib_id: &str) -> Result<String, String> {+    use kicad_core::libraries::{expand_kicad_uri, LibTable};+    let (lib, name) = lib_id.split_once(':').ok_or_else(|| format!("'{lib_id}' is not a Library:Footprint id"))?;+    let project_dir = board.parent().unwrap_or(Path::new("."));+    let mut tables: Vec<(String, PathBuf)> = vec![("project".into(), project_dir.join("fp-lib-table"))];+    if let Some(g) = ctx.fp_lib_table() {+        tables.push(("global".into(), g));+    }+    let mut tried = Vec::new();+    for (scope, path) in tables {+        let Ok(t) = LibTable::parse_file_resolved(&path) else { continue };+        let Some(e) = t.entries.iter().find(|e| e.name == lib && !e.disabled) else { continue };+        let uri = e.uri.replace("${KIPRJMOD}", &project_dir.to_string_lossy().replace('\\', "/"));+        let Some(dir) = expand_kicad_uri(&uri, ctx) else {+            tried.push(format!("{scope} table: {lib} -> {} (a path variable is not set)", e.uri));+            continue;+        };+        let file = Path::new(&dir).join(format!("{name}.kicad_mod"));+        match std::fs::read_to_string(&file) {+            Ok(t) => return Ok(t),+            Err(_) => tried.push(format!("{scope} table: {}", norm(&file))),+        }+    }+    if tried.is_empty() {+        Err(format!("no library named {lib} in the project or global fp-lib-table"))+    } else {+        Err(format!("{name} not found ({})", tried.join("; ")))+    }+}++fn board_path(args: &Value) -> Option<PathBuf> {+    arg_any(args, &["filePath", "boardPath"]).map(PathBuf::from)+}++/// The library footprint an add names: `footprintPath`, or `libraryId` through the tables.+fn library_source(state: &mut State, args: &Value) -> Result<Option<(String, String)>, Value> {+    if let Some(p) = arg_str(args, "footprintPath") {+        let path = Path::new(p);+        let text = std::fs::read_to_string(path).map_err(|e| fail("footprint_not_found", format!("could not read {p}: {e}"), "Pass the absolute path of a .kicad_mod, or libraryId (\"Lib:Name\") from your fp-lib-table."))?;+        let lib = path.parent().and_then(|d| d.file_stem()).map(|s| s.to_string_lossy().trim_end_matches(".pretty").to_string()).unwrap_or_else(|| "Local".into());+        let name = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+        let id = arg_str(args, "libraryId").map(str::to_string).unwrap_or(format!("{lib}:{name}"));+        return Ok(Some((text, id)));+    }+    let Some(id) = arg_any(args, &["libraryId", "footprint"]) else { return Ok(None) };+    let Some(board) = board_path(args) else { return Err(fail("missing_arg", "filePath is required", "Pass the board's absolute .kicad_pcb path.")) };+    let ctx = lib_ctx(state);+    match resolve_footprint(&ctx, &board, id) {+        Ok(t) => Ok(Some((t, id.to_string()))),+        Err(why) => Err(fail("footprint_not_found", why, "kicad_list_footprints lists what your libraries hold; install one with kicad_install_library, or pass footprintPath to a .kicad_mod.")),+    }+}++fn add_footprint(state: &mut State, args: &Value) -> Value {+    let lib = if args.get("duplicateOf").is_some() {+        None+    } else {+        match library_source(state, args) {+            Ok(Some(l)) => Some(l),+            Ok(None) => return fail("missing_arg", "Pass libraryId, footprintPath or duplicateOf", "libraryId is Library:Footprint as in your fp-lib-table, e.g. Resistor_SMD:R_0603_1608Metric."),+            Err(e) => return e,+        }+    };+    edit(state, args, &|t| board_edit::add_footprint(t, lib.as_ref().map(|(a, b)| (a.as_str(), b.as_str())), args))+}++fn mounting_hole(state: &mut State, args: &Value) -> Value {+    let drill = args.get("drill").and_then(Value::as_f64).unwrap_or(0.0);+    let plated = args.get("plated") == Some(&json!(true)) || args.get("net").is_some();+    let mut lib: Option<(String, String)> = None;+    if let (Some(id), Some(board)) = (board_edit::mounting_hole_lib_id(drill, plated), board_path(args)) {+        let ctx = lib_ctx(state);+        if let Ok(t) = resolve_footprint(&ctx, &board, &id) {+            lib = Some((t, id));+        }+    }+    edit(state, args, &|t| board_edit::add_mounting_hole(t, lib.as_ref().map(|(a, b)| (a.as_str(), b.as_str())), args))+}++fn import_svg(state: &mut State, args: &Value) -> Value {+    let svg = match (arg_str(args, "svg"), arg_str(args, "svgPath")) {+        (Some(s), _) => s.to_string(),+        (None, Some(p)) => match std::fs::read_to_string(p) {+            Ok(s) => s,+            Err(e) => return fail("svg_not_found", format!("could not read {p}: {e}"), "Pass svgPath (absolute) or the SVG text as svg."),+        },+        _ => return fail("missing_arg", "Pass svgPath or svg", "svgPath is an absolute path on this machine; svg is the document text."),+    };+    edit(state, args, &|t| board_edit::import_svg(t, &svg, args))+}++fn update_from_library(state: &mut State, args: &Value) -> Value {+    let ctx = lib_ctx(state);+    let Some(board) = board_path(args) else { return fail("missing_arg", "filePath is required", "Pass the board's absolute .kicad_pcb path.") };+    if args.get("apply") != Some(&json!(true)) {+        // a plan is a read: the live board (or the file), no revision needed+        let (text, live, live_err) = match read_text(state, args) {+            Ok(r) => r,+            Err(e) => return e,+        };+        let mut resolve = |id: &str| resolve_footprint(&ctx, &board, id);+        return match board_edit::update_from_library(&text, args, &mut resolve) {+            Ok(c) => {+                let mut v = json!({"success": true, "plan": true, "mutated": false, "source": if live { "live-editor" } else { "file" },+                    "_hint": "Plan only: footprints[] says what the library version changes per part. Call again with apply:true (and expectedRevision live) to replace every part that differs as one Undo step."});+                merge(&mut v, &c.report);+                if !live_err.is_null() {+                    v["live"] = live_err;+                }+                v+            }+            Err(e) => routing_fail(&e),+        };+    }+    let build = |t: &str| {+        let mut resolve = |id: &str| resolve_footprint(&ctx, &board, id);+        board_edit::update_from_library(t, args, &mut resolve)+    };+    edit(state, args, &build)+}++#[derive(Clone, Copy)]+enum Listed {+    Graphics,+    Models,+}++/// The board text for a read: the live editor's, else the file.+fn read_text(state: &mut State, args: &Value) -> Result<(String, bool, Value), Value> {+    let source = source_of(args)?;+    #[allow(unused_mut)]+    let mut live_err = Value::Null;+    #[cfg(feature = "ipc")]+    if source != "file" {+        let c = ctx(state);+        match kicad_core::ipc::board_text(&c, args) {+            Ok((t, _, _)) => return Ok((t, true, Value::Null)),+            Err(e) => {+                if source == "live" || !NO_EDITOR.contains(&e.code.as_str()) {+                    return Err(kicad_core::ipc::error_response(&e));+                }+                live_err = json!({"errorCode": e.code, "error": e.message});+            }+        }+    }+    #[cfg(not(feature = "ipc"))]+    let _ = (state, source);+    match pcb::load(args, true) {+        Ok(l) => Ok((l.text, false, live_err)),+        Err(e) => Err(e),+    }+}++fn listed_edit(state: &mut State, args: &Value, which: Listed) -> Value {+    let is_edit = match which {+        Listed::Graphics => args.get("uuid").is_some(),+        Listed::Models => args.get("ops").is_some() || args.get("action").and_then(Value::as_str).map(|a| a != "list").unwrap_or(false),+    };+    if !is_edit {+        let (text, live, live_err) = match read_text(state, args) {+            Ok(r) => r,+            Err(e) => return e,+        };+        let r = match which {+            Listed::Graphics => board_edit::footprint_graphics(&text, args),+            Listed::Models => board_edit::footprint_models(&text, args),+        };+        return match r {+            Ok((mut list, _)) => {+                list["success"] = json!(true);+                list["mutated"] = json!(false);+                list["source"] = json!(if live { "live-editor" } else { "file" });+                if !live_err.is_null() {+                    list["live"] = live_err;+                }+                list["_hint"] = json!(match which {+                    Listed::Graphics => "Edit one with uuid and change ({width, layer, points, start, end, mid, center, fill, delete}). Points are footprint-local mm, unrotated.",+                    Listed::Models => "Change them with ops: [{action: add|replace|remove, index?, path?, offset?, scale?, rotate?, visible?}].",+                });+                list+            }+            Err(e) => routing_fail(&e),+        };+    }+    edit(state, args, &|t| {+        let r = match which {+            Listed::Graphics => board_edit::footprint_graphics(t, args),+            Listed::Models => board_edit::footprint_models(t, args),+        }?;+        r.1.ok_or_else(|| RoutingError::new("missing_arg", "nothing to change").with("mutated", json!(false)))+    })+}++fn set_layer_count(state: &mut State, args: &Value) -> Value {+    let _ = state;+    // An editor holding the board would overwrite the file: refuse when the IPC API names it+    // (the lock file covers the rest, in write_closed).+    #[cfg(feature = "ipc")]+    if let (Some(p), Ok(open)) = (board_path(args), kicad_core::ipc::open_board_paths(std::time::Duration::from_millis(1500))) {+        if open.iter().any(|o| kicad_core::ipc::same_board_path(o, &p.to_string_lossy())) {+            return fail("board_open", "This board is open in the PCB editor; the layer count is a closed-file edit", "Save and close the board in KiCad (kicad_save_board, then close it), call again, then reopen it. Or change it in Board Setup > Board Stackup.");+        }+    }+    file_edit(args, &|t| board_edit::set_layer_count(t, args))+}++fn align(state: &mut State, args: &Value) -> Value {+    let source = match source_of(args) {+        Ok(s) => s,+        Err(e) => return e,+    };+    #[cfg(feature = "ipc")]+    if source != "file" {+        use kicad_core::ipc;+        let c = ctx(state);+        match ipc::board_text(&c, args) {+            Ok((text, _, _)) => {+                let board = match pcb::parse_pcb_text(&text, "live.kicad_pcb") {+                    Ok(b) => b,+                    Err(e) => return fail("board_unparsable", e, "Read kicad_placement_state."),+                };+                let (moves, report) = match board_edit::align_moves(&board, args) {+                    Ok(m) => m,+                    Err(e) => return routing_fail(&e),+                };+                let mut a = args.clone();+                a["refs"] = json!(moves);+                a.as_object_mut().map(|o| o.remove("ref"));+                let r = ipc::with_standard_revision(&c, &a, |x| ipc::move_footprint(&c, x));+                let mut v = match r {+                    Ok(v) => v,+                    Err(e) => ipc::error_response(&e),+                };+                v["align"] = report;+                return v;+            }+            Err(e) => {+                if source == "live" || !NO_EDITOR.contains(&e.code.as_str()) {+                    return ipc::error_response(&e);+                }+            }+        }+    }+    #[cfg(not(feature = "ipc"))]+    let _ = (state, source);+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let (moves, report) = match board_edit::align_moves(&loaded.board, args) {+        Ok(m) => m,+        Err(e) => return routing_fail(&e),+    };+    if args.get("dryRun") != Some(&json!(true)) {+        let mut v = fail("live_required", "A real move is live only (like kicad_move_footprint): no PCB editor serves this board on the IPC API", "Open the board in the PCB editor with the IPC API on (kicad_ipc_api {\"enable\":true}, relaunch), or pass dryRun:true to check the alignment against the file.");+        v["align"] = report;+        v["mutated"] = json!(false);+        return v;+    }+    let mut a = args.clone();+    a["refs"] = json!(moves);+    match kicad_core::placement::plan_moves(&loaded.board, &a) {+        Ok(plan) => {+            let mut v = json!({"success": true, "dryRun": true, "mutated": false, "source": "file", "boardPath": norm(&loaded.path), "revision": kicad_core::placement::file_revision(&loaded.text), "align": report,+                "_hint": "Checked against the file; nothing moved. Live (board open with the IPC API on) the same call moves them as one Undo step."});+            merge(&mut v, &kicad_core::placement::plan_json(&plan));+            v+        }+        Err(e) => {+            let mut v = routing_fail(&e);+            v["align"] = report;+            v+        }+    }+}++fn cross_probe(state: &mut State, args: &Value) -> Value {+    let (text, live, live_err) = match read_text(state, args) {+        Ok(r) => r,+        Err(e) => return e,+    };+    let dir = arg_str(args, "schematicPath").map(|p| Path::new(p).parent().map(Path::to_path_buf).unwrap_or_default()).or_else(|| board_path(args).and_then(|b| b.parent().map(Path::to_path_buf)));+    let mut sheets: Vec<(String, String)> = Vec::new();+    if let Some(d) = &dir {+        if let Ok(rd) = std::fs::read_dir(d) {+            for e in rd.flatten() {+                let p = e.path();+                if p.extension().map(|x| x == "kicad_sch").unwrap_or(false) {+                    if let Ok(t) = std::fs::read_to_string(&p) {+                        sheets.push((p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), t));+                    }+                }+            }+        }+    }+    sheets.sort_by(|a, b| a.0.cmp(&b.0));+    let mut v = match board_edit::cross_probe(&text, &sheets, args) {+        Ok(v) => v,+        Err(e) => return routing_fail(&e),+    };+    v["success"] = json!(true);+    v["mutated"] = json!(false);+    v["source"] = json!(if live { "live-editor" } else { "file" });+    if !live_err.is_null() {+        v["live"] = live_err;+    }+    v["_hint"] = json!("matches[] are the board footprints linked to the symbol (by the footprint's symbol path). select:true selects them in the live editor; kicad_select_items does it by refs.");+    #[cfg(feature = "ipc")]+    if live && args.get("select") == Some(&json!(true)) {+        let refs: Vec<Value> = v["matches"].as_array().into_iter().flatten().map(|m| m["ref"].clone()).collect();+        if !refs.is_empty() {+            let mut a = args.clone();+            a["refs"] = json!(refs);+            a["mode"] = json!("replace");+            let c = ctx(state);+            v["selected"] = match kicad_core::ipc::select_items(&c, &a) {+                Ok(s) => s["verified"].clone(),+                Err(e) => json!({"errorCode": e.code, "error": e.message}),+            };+        }+    }+    v+}++#[derive(Clone, Copy)]+enum LiveOnly {+    Refill,+    Save,+    GetSelection,+    Select,+}++#[cfg(feature = "ipc")]+fn live_only(state: &mut State, args: &Value, which: LiveOnly) -> Value {+    use kicad_core::ipc;+    let c = ctx(state);+    let r = match which {+        LiveOnly::Refill => ipc::refill_zones(&c, args),+        LiveOnly::Save => ipc::save_board(&c, args),+        LiveOnly::GetSelection => ipc::get_selection(&c, args),+        LiveOnly::Select => ipc::select_items(&c, args),+    };+    match r {+        Ok(v) => v,+        Err(e) => {+            let mut v = ipc::error_response(&e);+            if NO_EDITOR.contains(&e.code.as_str()) {+                v["_hint"] = json!(format!("{} This verb is live only: open the board in the PCB editor with the IPC API on (kicad_ipc_api {{\"enable\":true}}, relaunch).", v["_hint"].as_str().unwrap_or("")));+            }+            v+        }+    }+}++#[cfg(not(feature = "ipc"))]+fn live_only(_state: &mut State, _args: &Value, _which: LiveOnly) -> Value {+    fail("ipc_feature_off", "This build of the bridge has the ipc feature off; this verb needs the live PCB editor", "Use a build with the ipc feature (the default).")+}++#[cfg(test)]+mod tests {+    use super::*;++    fn temp(tag: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("kb-board-{tag}-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    #[test]+    fn every_verb_is_in_the_catalog_without_em_dashes() {+        for v in VERBS {+            for s in [v.summary, v.input, v.example, v.hint].iter().chain(v.pitfalls.iter()) {+                assert!(!s.contains('\u{2014}'), "{} carries an em dash", v.name);+            }+            assert!(dispatch(&mut State::new(), v.name, &json!({"source": "nonsense"})).is_some(), "{} is not dispatched", v.name);+        }+    }++    #[test]+    fn a_closed_file_is_edited_with_a_backup_and_an_open_one_is_refused() {+        let d = temp("file");+        let path = d.join("b.kicad_pcb");+        let blank = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../templates/blank-board.kicad_pcb")).unwrap();+        std::fs::write(&path, &blank).unwrap();+        let args = json!({"filePath": norm(&path), "source": "file", "width": 40, "height": 30, "x": 100, "y": 100});+        let dry = file_edit(&json!({"filePath": norm(&path), "width": 40, "height": 30, "dryRun": true}), &|t| board_edit::outline(t, &json!({"width": 40, "height": 30})));+        assert_eq!(dry["mutated"], false, "{dry}");+        assert_eq!(std::fs::read_to_string(&path).unwrap(), blank);+        let v = dispatch(&mut State::new(), "kicad_board_outline", &args).unwrap();+        assert_eq!(v["success"], true, "{v}");+        assert_eq!(v["mutated"], true);+        assert_eq!(v["source"], "file");+        assert!(Path::new(v["backup"].as_str().unwrap()).is_file());+        let b = pcb::parse_pcb(&path).unwrap();+        assert_eq!(b.dimensions.unwrap().width, 40.0);+        // a stale revision is refused, the current one accepted+        let stale = dispatch(&mut State::new(), "kicad_set_layer_count", &json!({"filePath": norm(&path), "source": "file", "count": 4, "expectedRevision": "abc"})).unwrap();+        assert_eq!(stale["errorCode"], "stale_board");+        let rev = stale["currentRevision"].as_str().unwrap().to_string();+        let four = dispatch(&mut State::new(), "kicad_set_layer_count", &json!({"filePath": norm(&path), "count": 4, "expectedRevision": rev})).unwrap();+        assert_eq!(four["countAfter"], 4, "{four}");+        // KiCad's lock file: refused, nothing written+        std::fs::write(d.join("~b.kicad_pcb.lck"), "{}").unwrap();+        let before = std::fs::read_to_string(&path).unwrap();+        let open = dispatch(&mut State::new(), "kicad_delete_graphics", &json!({"filePath": norm(&path), "source": "file", "layer": "Edge.Cuts"})).unwrap();+        assert_eq!(open["errorCode"], "board_open", "{open}");+        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn a_footprint_resolves_through_the_project_table() {+        let d = temp("lib");+        let pretty = d.join("Local.pretty");+        std::fs::create_dir_all(&pretty).unwrap();+        std::fs::write(pretty.join("Pad1.kicad_mod"), "(footprint \"Pad1\" (layer \"F.Cu\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\")))").unwrap();+        std::fs::write(d.join("fp-lib-table"), "(fp_lib_table\n  (version 7)\n  (lib (name \"Local\")(type \"KiCad\")(uri \"${KIPRJMOD}/Local.pretty\")(options \"\")(descr \"\"))\n)\n").unwrap();+        let ctx = kicad_core::libraries::LibCtx::default();+        let t = resolve_footprint(&ctx, &d.join("b.kicad_pcb"), "Local:Pad1").unwrap();+        assert!(t.contains("Pad1"));+        assert!(resolve_footprint(&ctx, &d.join("b.kicad_pcb"), "Local:Nope").unwrap_err().contains("not found"));+        assert!(resolve_footprint(&ctx, &d.join("b.kicad_pcb"), "Other:Pad1").unwrap_err().contains("no library"));+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-bridge/src/verbs_fab.rsadded+487
@@ -0,0 +1,487 @@+//! Verb group "fab": the interchange exports (DXF, GenCAD, IPC-2581, ODB++, IPC-D-356),+//! the one-call fab package (Gerbers, drill, BOM, CPL, README, one zip), the+//! manufacturability pre-flight and the board numbers a quote needs. The pure parts live+//! in `kicad_core::fab` and `kicad_core::dfm`; this file runs kicad-cli and shapes replies.++use std::path::{Path, PathBuf};+use std::time::{Duration, SystemTime};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::{cli, dfm, fab};++const INTERCHANGE_PITFALLS: &[&str] = &["needs kicad-cli from KiCad 9 or newer (the subcommand is missing on KiCad 8)", "run kicad_run_drc first: the file carries the board's errors with it"];++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_export_dxf",+        summary: "Export board layers to DXF via kicad-cli, one .dxf per layer (mechanical outline, panel drawings, laser stencils).",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"layers\": [\"Edge.Cuts\", \"F.Cu\"], \"outputDir\"?: \"C:/...\", \"units\"?: \"mm\"|\"in\", \"useContours\"?: bool, \"includeBorderTitle\"?: bool, \"excludeRefdes\"?: bool, \"excludeValue\"?: bool, \"useDrillOrigin\"?: bool}",+        example: "kicad_export_dxf {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"layers\":[\"Edge.Cuts\"]}",+        hint: "layers is required (there is no sensible default). Files land in outputDir (default <board dir>/dxf_output) as <board>-<layer>.dxf; data.files lists what this run wrote. useContours joins outline segments into polylines, which most CAM and laser tools want.",+        related: &["kicad_export_svg", "kicad_export_gerber"], pitfalls: INTERCHANGE_PITFALLS,+    },+    Verb {+        name: "kicad_export_gencad",+        summary: "Export the board as GenCAD 1.4 via kicad-cli (test-fixture and some assembly houses' CAM input).",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"flipBottomPads\"?: bool, \"uniquePins\"?: bool, \"uniqueFootprints\"?: bool, \"useDrillOrigin\"?: bool, \"storeOriginCoord\"?: bool}",+        example: "kicad_export_gencad {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"uniquePins\":true}",+        hint: "Writes <board>.cad into outputDir (default <board dir>/gencad_output). uniquePins renames duplicate pad numbers, which several fixture tools need.",+        related: &["kicad_export_ipc356", "kicad_export_ipc2581"], pitfalls: INTERCHANGE_PITFALLS,+    },+    Verb {+        name: "kicad_export_ipc2581",+        summary: "Export the board as IPC-2581 (one XML with copper, drill, stackup, netlist and BOM) via kicad-cli.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"units\"?: \"mm\"|\"in\", \"version\"?: \"B\"|\"C\", \"precision\"?: 6, \"compress\"?: bool, \"bomColMfgPn\"?: \"MPN\", \"bomColMfg\"?: \"Manufacturer\", \"bomColDistPn\"?: \"LCSC\", \"bomColDist\"?: \"LCSC\", \"bomColIntId\"?: \"Reference\"}",+        example: "kicad_export_ipc2581 {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"bomColMfgPn\":\"MPN\"}",+        hint: "Writes <board>-ipc2581.xml (or .zip with compress). The bomCol* options name the footprint FIELDS that carry the manufacturer part number etc., so the embedded BOM is useful to the fab.",+        related: &["kicad_export_odb", "kicad_export_fab_package"], pitfalls: INTERCHANGE_PITFALLS,+    },+    Verb {+        name: "kicad_export_odb",+        summary: "Export the board as ODB++ via kicad-cli (zip by default).",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"units\"?: \"mm\"|\"in\", \"compression\"?: \"zip\"|\"tgz\"|\"none\", \"precision\"?: 2}",+        example: "kicad_export_odb {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Writes <board>-odb.zip (tgz, or a folder with compression:\"none\") into outputDir (default <board dir>/odb_output).",+        related: &["kicad_export_ipc2581", "kicad_export_fab_package"], pitfalls: INTERCHANGE_PITFALLS,+    },+    Verb {+        name: "kicad_export_ipc356",+        summary: "Export the IPC-D-356 netlist (bare-board electrical test) via kicad-cli.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\"}",+        example: "kicad_export_ipc356 {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Writes <board>.d356 into outputDir (default <board dir>/ipcd356_output). Send it with the Gerbers when the fab offers flying-probe test against the netlist.",+        related: &["kicad_export_gerber", "kicad_export_fab_package"], pitfalls: INTERCHANGE_PITFALLS,+    },+    Verb {+        name: "kicad_export_fab_package",+        summary: "One-call fab package: Gerbers and drill, a BOM grouped from the board (value + footprint + MPN), the CPL, a README listing every file, all zipped. fab:\"jlcpcb\" writes JLCPCB's BOM and CPL columns (LCSC part field included).",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 600,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"fab\"?: \"generic\"|\"jlcpcb\", \"outputDir\"?: \"C:/...\", \"assembly\"?: true, \"dryRun\"?: bool}",+        example: "kicad_export_fab_package {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"fab\":\"jlcpcb\"}",+        hint: "outputDir defaults to <board dir>/fab_<fab>; the zip is <board>-<fab>.zip in it, next to the gerbers/, bom/ and assembly/ folders it was built from. assembly:false leaves out the BOM and CPL. The BOM comes from the BOARD's footprint fields (Value, MPN, LCSC), so fill those fields in the schematic and update the board first. bomGaps names lines with no LCSC part (jlcpcb) or no MPN (generic). Run kicad_validate_for_fab before ordering.",+        related: &["kicad_validate_for_fab", "kicad_export_gerber", "kicad_export_pos", "kicad_estimate_cost"],+        pitfalls: &["DNP and exclude-from-BOM footprints are left out of the BOM and the CPL", "check every part's rotation in the fab's assembly preview: some packages need a per-package correction"],+    },+    Verb {+        name: "kicad_validate_for_fab",+        summary: "Manufacturability pre-flight: closed outline, layer count, min track, space, drill and ring vs a fab profile, copper near the edge, silkscreen on pads, vias in pads, acid traps, footprints without 3D models, fiducials (assembly), plus KiCad DRC errors and unconnected items. Pass or fail with findings.",+        mechanism: Mechanism::Cli, risk: "read", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"fab\"?: \"generic\"|\"jlcpcb\", \"assembly\"?: bool, \"runDrc\"?: true, \"druPath\"?: \"C:/.../x.kicad_dru\", \"minTrack\"?: mm, \"minSpace\"?: mm, \"minDrill\"?: mm, \"minAnnular\"?: mm, \"minEdgeClearance\"?: mm, \"maxItems\"?: 20}",+        example: "kicad_validate_for_fab {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"fab\":\"jlcpcb\",\"assembly\":true}",+        hint: "pass is true only with zero error findings; warnings (silk on pads, via in pad, acid traps, edge clearance) are listed but do not fail the board. The limits come from the built-in profile, then the board's staged <stem>.kicad_dru (kicad_set_design_rules), then your min* args; profile.source says which won. Without kicad-cli the DRC finding is a warning and the pass is provisional.",+        related: &["kicad_set_design_rules", "kicad_run_drc", "kicad_export_fab_package", "kicad_estimate_cost"],+        pitfalls: &["spacing is measured with pads as rounded shapes (rectangle corners slightly rounded off), so it can read a little generous near pad corners; KiCad's DRC is the authority", "pads of one footprint are not checked against each other (the land pattern's pitch is the part's), and zones are not in the spacing check (DRC covers them)"],+    },+    Verb {+        name: "kicad_estimate_cost",+        summary: "The board's numbers in the shape a quote tool needs (size, layers, thickness, min track/space/drill, holes, SMD/THT parts and joints, unique parts) plus a rough JLCPCB-style price table with every assumption stated.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"quantities\"?: [5, 10, 30, 100], \"assembly\"?: bool, \"basicParts\"?: n}",+        example: "kicad_estimate_cost {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"quantities\":[5,50]}",+        hint: "Read-only, no KiCad needed. The table is a rough order of magnitude, not a quote: component cost, shipping and options are excluded. For a real quote run adom-instapcb-quote on the kicad_export_fab_package output.",+        related: &["kicad_export_fab_package", "kicad_validate_for_fab"],+        pitfalls: &["prices change; the rates in assumptions are the bridge's, not the fab's live price list"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_export_dxf" => interchange(state, args, "dxf", "DXF"),+        "kicad_export_gencad" => interchange(state, args, "gencad", "GenCAD"),+        "kicad_export_ipc2581" => interchange(state, args, "ipc2581", "IPC-2581"),+        "kicad_export_odb" => interchange(state, args, "odb", "ODB++"),+        "kicad_export_ipc356" => interchange(state, args, "ipcd356", "IPC-D-356"),+        "kicad_export_fab_package" => fab_package(state, args),+        "kicad_validate_for_fab" => validate(state, args),+        "kicad_estimate_cost" => estimate(args),+        _ => return None,+    })+}++/// The board path from filePath/boardPath, checked to exist and be a .kicad_pcb.+pub(crate) fn board_arg(args: &Value) -> Result<PathBuf, Value> {+    let Some(p) = arg_any(args, &["filePath", "boardPath"]) else {+        return Err(fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the .kicad_pcb on the desktop."));+    };+    let path = PathBuf::from(p);+    if !path.to_string_lossy().to_lowercase().ends_with(".kicad_pcb") {+        return Err(fail("not_a_board", format!("not a board file: {p}"), "These verbs take a .kicad_pcb."));+    }+    if !path.is_file() {+        return Err(fail("file_not_found", format!("PCB file not found: {p}"), "Use the desktop's absolute path, not the container's (push_file first if the board is in the container)."));+    }+    Ok(path)+}++/// kicad-cli path and version, or the not-installed reply.+pub(crate) fn cli_or_fail(state: &mut State) -> Result<(PathBuf, String), Value> {+    let info = state.kicad_info();+    match info.primary() {+        Some(inst) if Path::new(&inst.kicad_cli).is_file() => Ok((PathBuf::from(&inst.kicad_cli), inst.cli_version.clone())),+        _ => {+            let mut v = fail("not_installed", "KiCad (kicad-cli) not installed on this machine", "This verb runs kicad-cli. Install KiCad on the desktop (kicad_upgrade), or see alternatives.");+            v["alternatives"] = alternatives();+            Err(v)+        }+    }+}++fn interchange(state: &mut State, args: &Value, format: &str, label: &str) -> Value {+    let board = match board_arg(args) {+        Ok(b) => b,+        Err(e) => return e,+    };+    let plan = match fab::plan_interchange(format, &board, args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let (cli_path, cli_version) = match cli_or_fail(state) {+        Ok(c) => c,+        Err(e) => return e,+    };+    if let Err(e) = std::fs::create_dir_all(&plan.out_dir) {+        return fail("output_dir", format!("cannot create {}: {e}", norm(&plan.out_dir)), "Pass an outputDir the desktop user can write.");+    }+    let since = SystemTime::now() - Duration::from_secs(2);+    let argv: Vec<&str> = plan.argv.iter().map(String::as_str).collect();+    let r = cli::run(&cli_path, &argv, Duration::from_secs(280));+    let written = fab::files_written(&plan.out_dir, &plan.ext, since);+    let mut out = fab::export_reply(&plan, r.exit_code, r.timed_out, &r.stderr, &written, label);+    out["path"] = json!(norm(&board));+    out["cliElapsedMs"] = json!(r.elapsed_ms);+    out["cliVersion"] = json!(cli_version);+    out+}++fn fab_package(state: &mut State, args: &Value) -> Value {+    let board = match board_arg(args) {+        Ok(b) => b,+        Err(e) => return e,+    };+    let fab_name = arg_str(args, "fab").unwrap_or("generic").to_lowercase();+    if fab_name != "generic" && fab_name != "jlcpcb" {+        return fail("bad_arg", format!("fab must be generic or jlcpcb (got {fab_name})"), "generic writes neutral BOM columns; jlcpcb writes JLCPCB's assembly columns.");+    }+    let assembly = args.get("assembly").and_then(Value::as_bool).unwrap_or(true);+    let dry = args.get("dryRun").and_then(Value::as_bool).unwrap_or(false);+    let stem = board.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+    let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| board.parent().unwrap_or(Path::new(".")).join(format!("fab_{fab_name}")));+    let text = match std::fs::read_to_string(&board) {+        Ok(t) => t,+        Err(e) => return fail("read_failed", format!("cannot read the board: {e}"), "Is the file locked?"),+    };+    let parts = match fab::parts_from_board(&text) {+        Ok(p) => p,+        Err(e) => return fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),+    };+    let lines = fab::group_bom(&parts);+    let gaps = fab::bom_gaps(&lines, &fab_name);+    let numbers = fab::board_numbers(&text).unwrap_or(json!({}));+    let zip_path = out_dir.join(format!("{stem}-{fab_name}.zip"));+    let bom_name = if fab_name == "jlcpcb" { format!("bom/{stem}_BOM_JLCPCB.csv") } else { format!("bom/{stem}_BOM.csv") };+    if dry {+        let mut planned = vec!["gerbers/ (Gerber X2 per layer, Excellon drill PTH and NPTH, drill maps)".to_string()];+        if assembly {+            planned.push(bom_name.clone());+            planned.push(if fab_name == "jlcpcb" { format!("assembly/{stem}_CPL_JLCPCB.csv") } else { format!("assembly/{stem}-pos.csv") });+        }+        planned.push("README.txt".into());+        return json!({"success": true, "dryRun": true, "output": format!("Would write {} item(s) and zip them to {}", planned.len(), norm(&zip_path)), "planned": planned, "zip": norm(&zip_path), "bomLines": lines.len(), "bomGaps": gaps, "board": numbers, "_hint": "Run again without dryRun to build the package (needs kicad-cli on the desktop)."});+    }+    if let Err(e) = cli_or_fail(state) {+        return e;+    }+    // A clean folder so the zip holds only this run's files.+    for sub in ["gerbers", "bom", "assembly"] {+        let _ = std::fs::remove_dir_all(out_dir.join(sub));+    }+    if let Err(e) = std::fs::create_dir_all(&out_dir) {+        return fail("output_dir", format!("cannot create {}: {e}", norm(&out_dir)), "Pass an outputDir the desktop user can write.");+    }+    let mut readme_files: Vec<(String, String)> = Vec::new();+    let mut notes: Vec<String> = Vec::new();+    let gerber = crate::verbs::export(state, &json!({"filePath": norm(&board), "outputDir": norm(&out_dir.join("gerbers")), "drill": true}), "pcb", "gerbers", "Gerber");+    if gerber["success"] != true {+        let mut v = fail(gerber["errorCode"].as_str().unwrap_or("gerber_failed"), format!("Gerber export failed: {}", gerber["error"].as_str().unwrap_or("")), "Fix the Gerber export first (kicad_export_gerber on its own shows the full reply).");+        v["gerber"] = gerber;+        return v;+    }+    if let Some(e) = gerber["drillError"].as_str() {+        notes.push(format!("Drill export reported: {e}"));+    }+    for f in gerber["data"]["files"].as_array().cloned().unwrap_or_default() {+        let name = f.as_str().unwrap_or("").to_string();+        let what = if name.ends_with(".drl") { "Excellon drill file" } else if name.contains("drl_map") { "drill map" } else if name.ends_with(".gbrjob") { "Gerber job file (layer stack for the fab's CAM)" } else { "Gerber X2 layer" };+        readme_files.push((format!("gerbers/{name}"), what.into()));+    }+    let mut cpl_reply = Value::Null;+    if assembly {+        let bom_path = out_dir.join(&bom_name);+        let _ = std::fs::create_dir_all(bom_path.parent().unwrap());+        if let Err(e) = kicad_core::libraries::write_atomic(&bom_path, fab::bom_csv(&lines, &fab_name).as_bytes()) {+            return fail("write_failed", e, "Check permissions on outputDir.");+        }+        readme_files.push((bom_name.clone(), format!("Bill of materials, {} line(s), grouped by value, footprint and MPN{}", lines.iter().filter(|l| fab_name != "jlcpcb" || !l.dnp).count(), if fab_name == "jlcpcb" { "; JLCPCB columns (Comment, Designator, Footprint, LCSC Part #)" } else { "" })));+        let mut pos_args = json!({"filePath": norm(&board), "outputDir": norm(&out_dir.join("assembly")), "excludeDnp": true});+        if fab_name == "jlcpcb" {+            pos_args["fab"] = json!("jlcpcb");+        }+        cpl_reply = crate::verbs::export_pos(state, &pos_args);+        if cpl_reply["success"] == true {+            for f in cpl_reply["data"]["files"].as_array().cloned().unwrap_or_default() {+                let p = f.as_str().unwrap_or("");+                let name = p.rsplit('/').next().unwrap_or(p).to_string();+                let what = if name.contains("_CPL_JLCPCB") { "Component placement list in JLCPCB's CPL columns (Designator, Mid X, Mid Y, Layer, Rotation)" } else { "KiCad component position file (CSV)" };+                readme_files.push((format!("assembly/{name}"), what.into()));+            }+        } else {+            notes.push(format!("CPL export failed: {}", cpl_reply["error"].as_str().unwrap_or("")));+        }+        if !gaps.is_empty() {+            notes.push(format!("{} BOM line(s) have no {}: {}", gaps.len(), if fab_name == "jlcpcb" { "LCSC part number" } else { "MPN" }, gaps.iter().take(10).cloned().collect::<Vec<_>>().join("; ")));+        }+    }+    readme_files.push(("README.txt".into(), "This file".into()));+    let readme = fab::readme(&stem, &fab_name, &readme_files, &numbers, &notes);+    if let Err(e) = kicad_core::libraries::write_atomic(&out_dir.join("README.txt"), readme.as_bytes()) {+        return fail("write_failed", e, "Check permissions on outputDir.");+    }+    let mut entries: Vec<(String, Vec<u8>)> = Vec::new();+    for sub in ["gerbers", "bom", "assembly"] {+        for (rel, bytes) in fab::collect_tree(&out_dir.join(sub)) {+            entries.push((format!("{sub}/{rel}"), bytes));+        }+    }+    entries.push(("README.txt".into(), readme.into_bytes()));+    let zip = match fab::zip_stored(&entries) {+        Ok(z) => z,+        Err(e) => return fail("zip_failed", e, "The package is unzipped in outputDir; zip it by hand."),+    };+    if let Err(e) = kicad_core::libraries::write_atomic(&zip_path, &zip) {+        return fail("write_failed", e, "Check permissions on outputDir.");+    }+    json!({+        "success": true,+        "output": format!("Fab package for {fab_name}: {} file(s) zipped to {} ({} bytes)", entries.len(), norm(&zip_path), zip.len()),+        "data": {"zip": norm(&zip_path), "outputDir": norm(&out_dir), "files": entries.iter().map(|(n, b)| json!({"name": n, "bytes": b.len()})).collect::<Vec<_>>(), "bytes": zip.len()},+        "fab": fab_name,+        "bomLines": lines.len(),+        "bomGaps": gaps,+        "notes": notes,+        "board": numbers,+        "cpl": if cpl_reply.is_null() { Value::Null } else { json!({"success": cpl_reply["success"], "parts": cpl_reply["data"]["parts"]}) },+        "_hint": "Pull the zip with ab pull_file and upload it to the fab. Before ordering: kicad_validate_for_fab on the board, and check part rotations in the fab's assembly preview. For a real quote run adom-instapcb-quote on this zip.",+    })+}++/// The fab profile: built-in, then the staged .kicad_dru, then explicit args.+fn profile_for(board: &Path, args: &Value) -> Result<dfm::Profile, Value> {+    let name = arg_str(args, "fab").unwrap_or("generic").to_lowercase();+    let Some(mut p) = dfm::builtin_profile(&name) else {+        return Err(fail("bad_arg", format!("no built-in profile for fab {name}"), format!("Use one of {:?}, and stage the fab's own rules with kicad_set_design_rules: a <board>.kicad_dru next to the board overrides the profile's limits.", dfm::PROFILES)));+    };+    let dru = arg_str(args, "druPath").map(PathBuf::from).unwrap_or_else(|| board.with_extension("kicad_dru"));+    if dru.is_file() {+        if let Ok(t) = std::fs::read_to_string(&dru) {+            dfm::apply_dru(&mut p, &t, &norm(&dru));+        }+    } else if arg_str(args, "druPath").is_some() {+        return Err(fail("file_not_found", format!("druPath not found: {}", norm(&dru)), "Pass an existing .kicad_dru or leave druPath out."));+    }+    let mut overridden = Vec::new();+    for (k, slot) in [("minTrack", &mut p.min_track), ("minSpace", &mut p.min_space), ("minDrill", &mut p.min_drill), ("minAnnular", &mut p.min_annular), ("minEdgeClearance", &mut p.min_edge)] {+        if let Some(v) = args.get(k).and_then(Value::as_f64) {+            if v <= 0.0 {+                return Err(fail("bad_arg", format!("{k} must be positive (mm)"), "Limits are millimetres."));+            }+            *slot = v;+            overridden.push(k);+        }+    }+    if !overridden.is_empty() {+        p.source.push(format!("arguments: {}", overridden.join(", ")));+    }+    Ok(p)+}++fn validate(state: &mut State, args: &Value) -> Value {+    let board = match board_arg(args) {+        Ok(b) => b,+        Err(e) => return e,+    };+    let profile = match profile_for(&board, args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let text = match std::fs::read_to_string(&board) {+        Ok(t) => t,+        Err(e) => return fail("read_failed", format!("cannot read the board: {e}"), "Is the file locked?"),+    };+    let geom = match dfm::Geometry::from_text(&text) {+        Ok(g) => g,+        Err(e) => return fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),+    };+    let run_drc = args.get("runDrc").and_then(Value::as_bool).unwrap_or(true);+    let mut drc_meta = json!({"ran": false});+    let report: Result<Value, String> = if !run_drc {+        Err("runDrc:false".into())+    } else {+        match cli_or_fail(state) {+            Err(_) => Err("kicad-cli is not installed on this machine".into()),+            Ok((cli_path, ver)) => {+                let out = std::env::temp_dir().join(format!("adom-kicad-fabdrc-{}-{}.json", std::process::id(), kicad_core::schematic::new_uuid()));+                let out_s = norm(&out);+                let b = norm(&board);+                let r = cli::run(&cli_path, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--output", out_s.as_str(), b.as_str()], Duration::from_secs(280));+                let parsed = std::fs::read_to_string(&out).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+                let _ = std::fs::remove_file(&out);+                drc_meta = json!({"ran": parsed.is_some(), "cliExitCode": r.exit_code, "cliElapsedMs": r.elapsed_ms, "cliVersion": ver});+                parsed.ok_or_else(|| format!("kicad-cli wrote no DRC report: {}", r.stderr.trim()))+            }+        }+    };+    let opts = dfm::Options { assembly: args.get("assembly").and_then(Value::as_bool).unwrap_or(false), max_items: args.get("maxItems").and_then(Value::as_u64).unwrap_or(20) as usize };+    let mut v = dfm::validate(&geom, &profile, &opts, report.as_ref().map_err(String::as_str));+    let pass = v["pass"].as_bool().unwrap_or(false);+    let (e, w) = (v["errors"].as_u64().unwrap_or(0), v["warnings"].as_u64().unwrap_or(0));+    v["success"] = json!(true);+    v["path"] = json!(norm(&board));+    v["drc"] = drc_meta;+    v["output"] = json!(format!("{}: {e} error finding(s), {w} warning(s) against the {} profile", if pass { "PASS" } else { "FAIL" }, profile.name));+    let checks: Vec<String> = v["findings"].as_array().map(|a| a.iter().filter(|f| f["severity"] == "error").filter_map(|f| f["check"].as_str().map(str::to_string)).collect()).unwrap_or_default();+    v["_hint"] = json!(if pass {+        "No blocking findings. Read the warnings, then kicad_export_fab_package. Stage the fab's own rules (kicad_set_design_rules) for the exact limits if profile.source shows only the built-in profile."+    } else if checks.iter().any(|c| c == "drc" || c == "unconnected") {+        "Blocking findings, including KiCad DRC: fix those first (kicad_run_drc lists every violation with positions), then re-run this check."+    } else {+        "Blocking findings: each one names the check, the limit and up to maxItems locations. Fix them on the board (or relax the limit only if the fab's published capability allows it), then re-run."+    });+    v+}++fn estimate(args: &Value) -> Value {+    let board = match board_arg(args) {+        Ok(b) => b,+        Err(e) => return e,+    };+    let text = match std::fs::read_to_string(&board) {+        Ok(t) => t,+        Err(e) => return fail("read_failed", format!("cannot read the board: {e}"), "Is the file locked?"),+    };+    let numbers = match fab::board_numbers(&text) {+        Ok(n) => n,+        Err(e) => return fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),+    };+    let quantities: Vec<u64> = match args.get("quantities") {+        Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).filter(|q| *q > 0).collect(),+        _ => vec![5, 10, 30, 100],+    };+    if quantities.is_empty() {+        return fail("bad_arg", "quantities must be positive integers", "e.g. [5, 10, 100]");+    }+    let assembly = args.get("assembly").and_then(Value::as_bool).unwrap_or(numbers["totalParts"].as_u64().unwrap_or(0) > 0);+    let table = fab::cost_table(&numbers, &quantities, assembly, args.get("basicParts").and_then(Value::as_u64));+    let mut warnings = Vec::new();+    if numbers["outlineClosed"] != true {+        warnings.push("The Edge.Cuts outline is missing or open: the size (and every price) is unreliable.");+    }+    json!({+        "success": true,+        "output": format!("{} x {} mm, {} layer(s), {} SMD + {} THT parts ({} unique)", numbers["widthMm"], numbers["heightMm"], numbers["layers"], numbers["smdParts"], numbers["thtParts"], numbers["uniqueParts"]),+        "path": norm(&board),+        "board": numbers,+        "estimate": table,+        "warnings": warnings,+        "_hint": "This is a rough estimate with the assumptions listed, not a quote. For a real one: kicad_export_fab_package {fab:\"jlcpcb\"}, then adom-instapcb-quote on the zip (it prices the BOM too). adom-parts-search prices single parts.",+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn tmp() -> PathBuf {+        let d = std::env::temp_dir().join(format!("kb-fab-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    const BOARD: &str = r#"(kicad_pcb (version 20260206) (generator "pcbnew") (general (thickness 1.6))+  (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user) (5 "F.SilkS" user "F.Silkscreen"))+  (footprint "R:R_0603" (layer "F.Cu") (at 10 10) (property "Reference" "R1") (property "Value" "10k") (property "LCSC" "C25804") (attr smd)+    (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu" "F.Mask") (net "A")) (pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu" "F.Mask") (net "B")))+  (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts"))+  (segment (start 10.8 10) (end 20 10) (width 0.1) (layer "F.Cu") (net "B"))+)"#;++    fn board_file(d: &Path) -> PathBuf {+        let b = d.join("t.kicad_pcb");+        std::fs::write(&b, BOARD).unwrap();+        b+    }++    #[test]+    fn interchange_verbs_check_their_args_before_kicad() {+        let mut s = State::new();+        let d = tmp();+        let b = board_file(&d);+        let r = dispatch(&mut s, "kicad_export_dxf", &json!({"filePath": norm(&b)})).unwrap();+        assert_eq!(r["errorCode"], "missing_arg", "{r}");+        assert_eq!(dispatch(&mut s, "kicad_export_gencad", &json!({})).unwrap()["errorCode"], "missing_arg");+        assert_eq!(dispatch(&mut s, "kicad_export_odb", &json!({"filePath": norm(&d.join("nope.kicad_pcb"))})).unwrap()["errorCode"], "file_not_found");+        assert_eq!(dispatch(&mut s, "kicad_export_ipc2581", &json!({"filePath": norm(&b), "version": "Z"})).unwrap()["errorCode"], "bad_arg");+        let r = dispatch(&mut s, "kicad_export_ipc356", &json!({"filePath": norm(&b)})).unwrap();+        assert_eq!(r["success"], false, "no kicad-cli in the test container, or a real failure: never a silent success");+        assert!(dispatch(&mut s, "kicad_list_symbols", &json!({})).is_none(), "other groups' verbs are not ours");+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn validate_without_drc_runs_the_board_checks() {+        let mut s = State::new();+        let d = tmp();+        let b = board_file(&d);+        let r = dispatch(&mut s, "kicad_validate_for_fab", &json!({"filePath": norm(&b), "fab": "jlcpcb", "runDrc": false, "assembly": true})).unwrap();+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(r["pass"], false, "0.1 mm track under 0.127");+        let checks: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap()).collect();+        assert!(checks.contains(&"min_track") && checks.contains(&"fiducials") && checks.contains(&"drc"), "{checks:?}");+        assert!(r["output"].as_str().unwrap().starts_with("FAIL"));+        // a staged .kicad_dru relaxes the limit+        std::fs::write(d.join("t.kicad_dru"), "(version 1)\n(rule \"w\" (constraint track_width (min 0.09mm)))\n").unwrap();+        let r = dispatch(&mut s, "kicad_validate_for_fab", &json!({"filePath": norm(&b), "fab": "jlcpcb", "runDrc": false})).unwrap();+        assert_eq!(r["pass"], true, "{r}");+        assert!(r["profile"]["source"].as_array().unwrap().iter().any(|x| x.as_str().unwrap().contains("t.kicad_dru")));+        let r = dispatch(&mut s, "kicad_validate_for_fab", &json!({"filePath": norm(&b), "runDrc": false, "minTrack": 0.2})).unwrap();+        assert_eq!(r["pass"], false);+        assert_eq!(dispatch(&mut s, "kicad_validate_for_fab", &json!({"filePath": norm(&b), "fab": "acme"})).unwrap()["errorCode"], "bad_arg");+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn estimate_and_fab_package_dry_run_need_no_kicad() {+        let mut s = State::new();+        let d = tmp();+        let b = board_file(&d);+        let r = dispatch(&mut s, "kicad_estimate_cost", &json!({"filePath": norm(&b), "quantities": [5, 100]})).unwrap();+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(r["board"]["widthMm"], 30.0);+        assert_eq!(r["estimate"]["rows"].as_array().unwrap().len(), 2);+        assert!(r["_hint"].as_str().unwrap().contains("adom-instapcb-quote"));+        let p = dispatch(&mut s, "kicad_export_fab_package", &json!({"filePath": norm(&b), "fab": "jlcpcb", "dryRun": true})).unwrap();+        assert_eq!(p["success"], true, "{p}");+        assert!(p["planned"].as_array().unwrap().iter().any(|x| x.as_str().unwrap().contains("_BOM_JLCPCB.csv")));+        assert!(p["bomGaps"].as_array().unwrap().is_empty(), "R1 has an LCSC part");+        assert!(!d.join("fab_jlcpcb").exists(), "dry run writes nothing");+        assert_eq!(dispatch(&mut s, "kicad_export_fab_package", &json!({"filePath": norm(&b), "fab": "pcbway"})).unwrap()["errorCode"], "bad_arg");+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-bridge/src/verbs_libedit.rsadded+460
@@ -0,0 +1,460 @@+//! Verb group "libedit": footprint and symbol authoring on library files, one-item+//! details, and a ranked search across every registered library. The file work is in+//! `kicad_core::{fpedit, symedit, libsearch}`; every write is atomic and parsed back.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::libraries::write_atomic;+use kicad_core::{fpedit, libsearch, symedit};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_fp_create",+        summary: "Write a new footprint (.kicad_mod, KiCad 10) into a .pretty folder from a pad list: courtyard generated with a margin, fab outline, silkscreen box clear of the pads with a pin-1 dot, reference and value text, optional 3D model.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"name\": \"SOT-23_Mine\", \"pads\": [{\"number\": 1, \"type\"?: \"smd\"|\"thru_hole\"|\"np_thru_hole\", \"shape\"?: \"roundrect\"|\"rect\"|\"circle\"|\"oval\", \"size\": [w, h] | d, \"drill\"?: d | [w, h], \"at\": [x, y], \"rotation\"?: deg, \"layers\"?: [...], \"roundrectRatio\"?: 0.25}], \"bodySize\"?: [w, h], \"courtyardMargin\"?: 0.25, \"silk\"?: true, \"side\"?: \"front\"|\"back\", \"description\"?: \"\", \"tags\"?: [], \"attr\"?: \"smd\"|\"through_hole\", \"model\"?: {\"path\": \"...\", \"offset\"?: [x,y,z], \"scale\"?: [x,y,z], \"rotate\"?: [x,y,z]}, \"overwrite\"?: false, \"dryRun\"?: bool}",+        example: "kicad_fp_create {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"name\":\"SOT-23_Mine\",\"bodySize\":[1.3,2.9],\"pads\":[{\"number\":1,\"size\":[0.9,0.8],\"at\":[-1.1,-0.95]},{\"number\":2,\"size\":[0.9,0.8],\"at\":[-1.1,0.95]},{\"number\":3,\"size\":[0.9,0.8],\"at\":[1.1,0]}]}",+        hint: "Millimetres, footprint origin at the part centre, +y down (KiCad's footprint frame). Pad layers default by type (smd: Cu, Mask, Paste on the chosen side; thru_hole: *.Cu *.Mask). The .pretty folder is created when missing; register it with kicad_install_library to use it in a board. dryRun returns the text without writing.",+        related: &["kicad_fp_edit", "kicad_fp_info", "kicad_lint_library", "kicad_install_library"],+        pitfalls: &["an existing footprint of that name is refused unless overwrite:true", "the silkscreen box is generated around every pad; adjust it with kicad_fp_edit graphics if the body outline matters"],+    },+    Verb {+        name: "kicad_fp_edit",+        summary: "Edit a .kicad_mod in place: edit, delete, add or renumber pads; append, replace or delete graphics on a layer (line, arc, rect, circle, poly); set description, tags and attributes; replace, append or delete 3D models (path, offset, scale, rotate). Everything not edited stays byte-identical.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../X.kicad_mod\" | \"libraryPath\" + \"name\", \"pads\"?: [{\"number\": \"1\", \"set\"?: {size, at, shape, drill, layers, rotation, type, roundrectRatio, newNumber}, \"delete\"?: true}], \"addPads\"?: [pad], \"renumber\"?: {\"map\": {\"1\": \"A1\"}} | {\"sequential\": true, \"start\"?: 1}, \"graphics\"?: {\"layer\": \"F.SilkS\", \"mode\"?: \"append\"|\"replace\"|\"delete\", \"width\"?: mm, \"shapes\": [{\"type\": \"line\", \"start\": [x,y], \"end\": [x,y]}]} | [...], \"metadata\"?: {\"description\"?, \"tags\"?, \"attributes\"?: {\"type\": \"smd\", \"excludeFromBom\"?: bool, \"excludeFromPos\"?: bool, \"boardOnly\"?: bool, \"dnp\"?: bool}}, \"models\"?: [{path, offset, scale, rotate}] | {\"mode\": \"replace\"|\"append\"|\"delete\", \"list\": [...]}, \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_fp_edit {\"filePath\":\"C:/Users/john/kicad/Mine.pretty/SOT-23_Mine.kicad_mod\",\"pads\":[{\"number\":\"1\",\"set\":{\"size\":[1.0,0.8]}}],\"models\":[{\"path\":\"${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.step\"}]}",+        hint: "Operations run in the order pads, addPads, renumber, graphics, metadata, models. An edited pad keeps its uuid and every child the bridge does not model (net, pinfunction, clearances). backup:true (default) leaves <file>.adom-bak. changes lists what was done.",+        related: &["kicad_fp_info", "kicad_fp_create", "kicad_lint_library"],+        pitfalls: &["boards that already use the footprint keep their own copy: update them from the library in KiCad (Update Footprints from Library)", "graphics replace/delete touch shapes only, never text on that layer"],+    },+    Verb {+        name: "kicad_fp_info",+        summary: "Full details of one footprint: pads (number, type, shape, position, size, drill, layers), layers used, courtyard box, graphics per layer, attributes, properties and 3D models.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../X.kicad_mod\" | \"libraryPath\": \"C:/.../Lib.pretty\" + \"name\" | \"libId\": \"Lib:Name\", \"projectPath\"?: \"C:/.../x.kicad_pro\"}",+        example: "kicad_fp_info {\"libId\":\"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm\"}",+        hint: "libId resolves through the project's fp-lib-table (projectPath) and then the global one, URIs expanded like kicad_list_footprints.",+        related: &["kicad_search_library", "kicad_fp_edit", "kicad_list_footprints"], pitfalls: &[],+    },+    Verb {+        name: "kicad_sym_create",+        summary: "Append a symbol to a .kicad_sym (KiCad 10) from a pin list: rectangle body sized to the pins and names, pins on the 2.54 mm grid by side, Reference/Value/Footprint/Datasheet/Description/keywords and custom fields, multi-unit when pins name units.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"name\": \"LM1117\", \"pins\": [{\"number\": 1, \"name\": \"GND\", \"type\"?: \"passive\"|\"input\"|\"output\"|\"bidirectional\"|\"tri_state\"|\"power_in\"|\"power_out\"|\"open_collector\"|\"open_emitter\"|\"free\"|\"unspecified\"|\"no_connect\", \"side\"?: \"left\"|\"right\"|\"top\"|\"bottom\", \"position\"?: n, \"length\"?: 2.54, \"unit\"?: 1, \"shape\"?: \"line\", \"hidden\"?: bool}], \"reference\"?: \"U\", \"value\"?: name, \"footprint\"?: \"Lib:Fp\", \"datasheet\"?: url, \"description\"?: \"\", \"keywords\"?: [], \"fpFilters\"?: [], \"fields\"?: {\"MPN\": \"...\"}, \"hidePinNumbers\"?: bool, \"overwrite\"?: false, \"dryRun\"?: bool}",+        example: "kicad_sym_create {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"name\":\"LM1117\",\"footprint\":\"Package_TO_SOT_SMD:SOT-223-3_TabPin2\",\"pins\":[{\"number\":3,\"name\":\"VI\",\"type\":\"power_in\"},{\"number\":2,\"name\":\"VO\",\"type\":\"power_out\"},{\"number\":1,\"name\":\"GND\",\"type\":\"power_in\",\"side\":\"bottom\"}]}",+        hint: "Side defaults: outputs right, everything else left; position orders pins along a side. Pins with unit 2, 3... make a multi-unit symbol (one body per unit). The library file is created when missing; every other symbol in it stays byte-identical. Register it with kicad_install_library.",+        related: &["kicad_sym_info", "kicad_sym_delete", "kicad_lint_library", "kicad_sch_place_symbol"],+        pitfalls: &["an existing symbol of that name is refused unless overwrite:true", "the same pin number twice in one unit is refused"],+    },+    Verb {+        name: "kicad_sym_delete",+        summary: "Delete one symbol from a .kicad_sym; refuses when other symbols extend it unless force:true.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"name\": \"LM1117\", \"force\"?: false, \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_sym_delete {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"name\":\"LM1117\"}",+        hint: "Schematics that placed the symbol keep their embedded copy; only the library loses it. backup:true (default) leaves <file>.adom-bak.",+        related: &["kicad_sym_create", "kicad_list_symbols"], pitfalls: &[],+    },+    Verb {+        name: "kicad_sym_info",+        summary: "Full details of one symbol: every pin (number, name, electrical type, shape, position, length, unit, hidden), unit and body-style counts, fields, power flag, extends (resolved).",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"libraryPath\": \"C:/.../X.kicad_sym\" + \"name\" | \"libId\": \"Lib:Name\", \"projectPath\"?: \"C:/.../x.kicad_pro\"}",+        example: "kicad_sym_info {\"libId\":\"Device:R\"}",+        hint: "A derived symbol (extends) is reported with its parent's pins and drawings and its own fields, the way KiCad flattens it.",+        related: &["kicad_search_library", "kicad_list_symbols", "kicad_sch_place_symbol"], pitfalls: &[],+    },+    Verb {+        name: "kicad_search_library",+        summary: "Search symbols and footprints across every registered library (project and global sym-lib-table / fp-lib-table, stock and PCM libraries included) by name, keyword and description; ranked hits with lib ids.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 120,+        input: "{\"query\": \"ldo 3.3v\", \"kind\"?: \"symbol\"|\"footprint\"|\"both\", \"projectPath\"?: \"C:/.../x.kicad_pro\", \"libraries\"?: [\"Device\"], \"limit\"?: 25}",+        example: "kicad_search_library {\"query\":\"soic 8\",\"kind\":\"footprint\"}",+        hint: "Every word must match the name, keywords, description or library nickname; exact and prefix name matches rank first. The first search over the stock libraries reads every file (a few seconds); later ones use a cache keyed by file time. Use a hit's libId with kicad_sym_info / kicad_fp_info or kicad_sch_place_symbol.",+        related: &["kicad_sym_info", "kicad_fp_info", "kicad_list_symbols", "kicad_list_footprints"],+        pitfalls: &["footprint descriptions are read from the first 4 KB of each file", "rows whose URI uses a path variable the bridge cannot resolve are listed in skippedLibraries"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_fp_create" => fp_create(args),+        "kicad_fp_edit" => fp_edit(state, args),+        "kicad_fp_info" => fp_info(state, args),+        "kicad_sym_create" => sym_create(args),+        "kicad_sym_delete" => sym_delete(args),+        "kicad_sym_info" => sym_info(state, args),+        "kicad_search_library" => search(state, args),+        _ => return None,+    })+}++fn dry(args: &Value) -> bool {+    args.get("dryRun").and_then(Value::as_bool).unwrap_or(false)+}++fn backup(path: &Path, args: &Value) -> Option<String> {+    if !args.get("backup").and_then(Value::as_bool).unwrap_or(true) || !path.is_file() {+        return None;+    }+    let b = PathBuf::from(format!("{}.adom-bak", path.to_string_lossy()));+    std::fs::copy(path, &b).ok().map(|_| norm(&b))+}++fn project_dir(args: &Value) -> Option<PathBuf> {+    let p = arg_str(args, "projectPath")?;+    let p = Path::new(p);+    Some(if p.is_dir() { p.to_path_buf() } else { p.parent()?.to_path_buf() })+}++/// Resolve `Nick:Name` through the registered libraries of `kind`.+fn resolve_lib_id(state: &mut State, args: &Value, kind: &'static str, lib_id: &str) -> Result<(PathBuf, String), Value> {+    let Some((nick, name)) = lib_id.split_once(':') else {+        return Err(fail("bad_arg", format!("libId must be Library:Name (got {lib_id})"), "e.g. Device:R or Package_SO:SOIC-8_3.9x4.9mm_P1.27mm."));+    };+    let info = state.kicad_info();+    let ctx = crate::verbs_libraries::lib_ctx(&info);+    let (libs, _) = libsearch::registered(&ctx, project_dir(args).as_deref(), kind);+    let Some(lib) = libs.iter().find(|l| l.nickname == nick) else {+        let names: Vec<&str> = libs.iter().map(|l| l.nickname.as_str()).take(60).collect();+        let mut v = fail("library_not_registered", format!("no {kind} library named {nick} in the project or global table"), "Pick one of registeredLibraries, pass projectPath for project libraries, or pass libraryPath directly.");+        v["registeredLibraries"] = json!(names);+        return Err(v);+    };+    Ok((lib.path.clone(), name.to_string()))+}++/// The .kicad_mod path for fp verbs: filePath, or libraryPath + name, or libId.+fn fp_path(state: &mut State, args: &Value) -> Result<PathBuf, Value> {+    if let Some(f) = arg_str(args, "filePath") {+        return Ok(PathBuf::from(f));+    }+    if let Some(id) = arg_str(args, "libId") {+        let (lib, name) = resolve_lib_id(state, args, "footprint", id)?;+        return Ok(lib.join(format!("{name}.kicad_mod")));+    }+    match (arg_str(args, "libraryPath"), arg_str(args, "name")) {+        (Some(l), Some(n)) => Ok(Path::new(l).join(format!("{n}.kicad_mod"))),+        _ => Err(fail("missing_arg", "Pass filePath (a .kicad_mod), libraryPath + name, or libId", "e.g. {\"libId\":\"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm\"}.")),+    }+}++fn fp_create(args: &Value) -> Value {+    let Some(lib) = arg_any(args, &["libraryPath", "library"]) else {+        return fail("missing_arg", "libraryPath is required", "Pass the .pretty folder to write into (created when missing).");+    };+    let Some(name) = arg_str(args, "name") else {+        return fail("missing_arg", "name is required", "Pass name: the footprint name (also the file stem).");+    };+    let lib = PathBuf::from(lib);+    if !lib.to_string_lossy().ends_with(".pretty") {+        return fail("bad_arg", "libraryPath must be a .pretty folder", "KiCad footprint libraries are folders named <Lib>.pretty.");+    }+    let path = lib.join(format!("{name}.kicad_mod"));+    if path.exists() && !args.get("overwrite").and_then(Value::as_bool).unwrap_or(false) {+        return fail("footprint_exists", format!("{} already exists", norm(&path)), "Pass overwrite:true to replace it, or kicad_fp_edit to change it.");+    }+    let (text, summary) = match fpedit::create(name, args) {+        Ok(x) => x,+        Err(e) => return fail("bad_arg", e, "Each pad needs number, size and at; thru_hole pads need a drill smaller than the pad."),+    };+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would write {}", norm(&path)), "path": norm(&path), "text": text, "summary": summary});+    }+    if let Err(e) = std::fs::create_dir_all(&lib) {+        return fail("write_failed", format!("cannot create {}: {e}", norm(&lib)), "Pass a folder the desktop user can write.");+    }+    let bak = backup(&path, args);+    if let Err(e) = write_atomic(&path, text.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library folder.");+    }+    json!({+        "success": true,+        "output": format!("Footprint {name} written: {} pad(s)", summary["pads"]),+        "path": norm(&path),+        "summary": summary,+        "backup": bak,+        "_hint": "kicad_lint_library on the .pretty folder gets KiCad's own verdict; kicad_show_footprint opens it in the Footprint Editor. Register the folder with kicad_install_library to use it on boards.",+    })+}++fn fp_edit(state: &mut State, args: &Value) -> Value {+    let path = match fp_path(state, args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let text = match std::fs::read_to_string(&path) {+        Ok(t) => t,+        Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Pass the .kicad_mod's absolute path (kicad_list_footprints shows them)."),+    };+    let (out, changes) = match fpedit::edit(&text, args) {+        Ok(x) => x,+        Err(e) => return fail("edit_refused", e, "Nothing was written. kicad_fp_info shows the pad numbers and layers the edit can address."),+    };+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would apply {} change(s)", changes.len()), "changes": changes, "path": norm(&path), "info": fpedit::info(&out).ok()});+    }+    let bak = backup(&path, args);+    if let Err(e) = write_atomic(&path, out.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library folder.");+    }+    json!({+        "success": true,+        "output": format!("{}: {}", path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), changes.join("; ")),+        "changes": changes,+        "path": norm(&path),+        "backup": bak,+        "info": fpedit::info(&out).ok(),+        "_hint": "A Footprint Editor that has this footprint open keeps its old copy: reopen it. Boards keep their own copy until updated from the library.",+    })+}++fn fp_info(state: &mut State, args: &Value) -> Value {+    let path = match fp_path(state, args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let text = match std::fs::read_to_string(&path) {+        Ok(t) => t,+        Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "The name is the .kicad_mod file stem; kicad_search_library or kicad_list_footprints shows the names."),+    };+    match fpedit::info(&text) {+        Ok(mut v) => {+            v["success"] = json!(true);+            v["path"] = json!(norm(&path));+            v["output"] = json!(format!("{}: {} pad(s), {} model(s)", v["name"].as_str().unwrap_or(""), v["padCount"], v["models"].as_array().map(|a| a.len()).unwrap_or(0)));+            v+        }+        Err(e) => fail("bad_footprint", e, "kicad_lint_library gets KiCad's own verdict on the file."),+    }+}++fn sym_lib_arg(args: &Value) -> Result<PathBuf, Value> {+    let Some(l) = arg_any(args, &["libraryPath", "filePath"]) else {+        return Err(fail("missing_arg", "libraryPath is required", "Pass the .kicad_sym file."));+    };+    let p = PathBuf::from(l);+    if !p.to_string_lossy().ends_with(".kicad_sym") {+        return Err(fail("bad_arg", "libraryPath must be a .kicad_sym file", "KiCad symbol libraries are single .kicad_sym files."));+    }+    Ok(p)+}++fn sym_create(args: &Value) -> Value {+    let path = match sym_lib_arg(args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let Some(name) = arg_str(args, "name") else {+        return fail("missing_arg", "name is required", "Pass name: the symbol name.");+    };+    let existed = path.is_file();+    let text = if existed {+        match std::fs::read_to_string(&path) {+            Ok(t) => t,+            Err(e) => return fail("read_failed", e.to_string(), "Is the file locked?"),+        }+    } else {+        symedit::empty_library()+    };+    let overwrite = args.get("overwrite").and_then(Value::as_bool).unwrap_or(false);+    let (out, replaced) = match symedit::add_symbol(&text, name, args, overwrite) {+        Ok(x) => x,+        Err(e) => return fail(if e.contains("already exists") { "symbol_exists" } else { "bad_arg" }, e, "Each pin needs a number; types and sides take the documented values. Pass overwrite:true to replace an existing symbol."),+    };+    let info = symedit::symbol_info(&out, name).ok();+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would {} {name} in {}", if replaced { "replace" } else { "add" }, norm(&path)), "info": info, "libraryCreated": !existed});+    }+    if let Some(parent) = path.parent() {+        let _ = std::fs::create_dir_all(parent);+    }+    let bak = backup(&path, args);+    if let Err(e) = write_atomic(&path, out.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library file.");+    }+    json!({+        "success": true,+        "output": format!("Symbol {name} {} in {} ({} pin(s))", if replaced { "replaced" } else { "added" }, norm(&path), info.as_ref().map(|i| i["pinCount"].clone()).unwrap_or(json!(0))),+        "path": norm(&path),+        "libraryCreated": !existed,+        "replaced": replaced,+        "backup": bak,+        "info": info,+        "_hint": "kicad_lint_library gets KiCad's own verdict; kicad_show_library opens it in the Symbol Editor. Register the file with kicad_install_library, then place it with kicad_sch_place_symbol {libId:\"<Nick>:<name>\"}.",+    })+}++fn sym_delete(args: &Value) -> Value {+    let path = match sym_lib_arg(args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let Some(name) = arg_str(args, "name") else {+        return fail("missing_arg", "name is required", "Pass name: the symbol to delete.");+    };+    let text = match std::fs::read_to_string(&path) {+        Ok(t) => t,+        Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Pass the .kicad_sym's absolute path."),+    };+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let (out, orphans) = match symedit::delete_symbol(&text, name, force) {+        Ok(x) => x,+        Err(e) => return fail(if e.contains("extend") { "symbol_in_use" } else { "symbol_not_found" }, e, "Nothing was written."),+    };+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would delete {name} from {}", norm(&path)), "orphaned": orphans});+    }+    let bak = backup(&path, args);+    if let Err(e) = write_atomic(&path, out.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library file.");+    }+    json!({"success": true, "output": format!("Deleted {name} from {}", norm(&path)), "path": norm(&path), "orphaned": orphans, "backup": bak,+           "_hint": "Placed copies in schematics are embedded and unaffected. A Symbol Editor with this library open keeps its old copy until it reloads."})+}++fn sym_info(state: &mut State, args: &Value) -> Value {+    let (path, name) = if let Some(id) = arg_str(args, "libId") {+        match resolve_lib_id(state, args, "symbol", id) {+            Ok(x) => x,+            Err(e) => return e,+        }+    } else {+        let path = match sym_lib_arg(args) {+            Ok(p) => p,+            Err(e) => return e,+        };+        let Some(name) = arg_str(args, "name") else {+            return fail("missing_arg", "name is required with libraryPath (or pass libId)", "kicad_list_symbols shows the names in a library.");+        };+        (path, name.to_string())+    };+    let text = match std::fs::read_to_string(&path) {+        Ok(t) => t,+        Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&path)), "Check the path or the library table row."),+    };+    match symedit::symbol_info(&text, &name) {+        Ok(mut v) => {+            v["success"] = json!(true);+            v["libraryPath"] = json!(norm(&path));+            v["output"] = json!(format!("{name}: {} pin(s) in {} unit(s)", v["pinCount"], v["unitCount"]));+            v+        }+        Err(e) => fail("symbol_not_found", e, "kicad_search_library finds symbols by name or keyword."),+    }+}++fn search(state: &mut State, args: &Value) -> Value {+    let Some(query) = arg_any(args, &["query", "q"]) else {+        return fail("missing_arg", "query is required", "Pass query: words to match in names, keywords and descriptions, e.g. \"ldo 3.3\".");+    };+    let kind = arg_str(args, "kind").unwrap_or("both");+    if !["symbol", "footprint", "both"].contains(&kind) {+        return fail("bad_arg", "kind must be symbol, footprint or both", "Omit it to search both.");+    }+    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(25).clamp(1, 500) as usize;+    let info = state.kicad_info();+    let ctx = crate::verbs_libraries::lib_ctx(&info);+    let pdir = project_dir(args);+    let mut libs = Vec::new();+    let mut skipped = Vec::new();+    for k in ["symbol", "footprint"] {+        if kind == k || kind == "both" {+            let (l, s) = libsearch::registered(&ctx, pdir.as_deref(), k);+            libs.extend(l);+            skipped.extend(s);+        }+    }+    if let Some(filter) = args.get("libraries").and_then(Value::as_array) {+        let names: Vec<String> = filter.iter().filter_map(Value::as_str).map(str::to_lowercase).collect();+        libs.retain(|l| names.contains(&l.nickname.to_lowercase()));+    }+    if libs.is_empty() {+        return fail("no_libraries", "no registered libraries to search", if info.installed { "The library tables are empty or filtered out; pass projectPath for project libraries." } else { "KiCad is not installed here, so there is no global table; pass projectPath for a project's own tables." });+    }+    let (hits, scanned_libs, scanned) = libsearch::search(&libs, query, limit);+    json!({+        "success": true,+        "output": format!("{} hit(s) for {query:?} in {scanned} item(s) across {scanned_libs} librar(ies)", hits.len()),+        "query": query,+        "hits": hits,+        "librariesSearched": scanned_libs,+        "itemsSearched": scanned,+        "skippedLibraries": skipped,+        "_hint": if hits.is_empty() { "No hit: try fewer or different words (every word must match), or adom-wiki discover find / chip-fetcher for parts not in your libraries." } else { "Use a hit's libId with kicad_sym_info / kicad_fp_info, or kicad_sch_place_symbol {libId} to place a symbol." },+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn tmp() -> PathBuf {+        let d = std::env::temp_dir().join(format!("kb-libedit-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    #[test]+    fn footprint_create_edit_info_round_trip() {+        let mut s = State::new();+        let d = tmp();+        let lib = d.join("Mine.pretty");+        let args = json!({"libraryPath": norm(&lib), "name": "SOT23", "bodySize": [1.3, 2.9], "pads": [{"number": 1, "size": [0.9, 0.8], "at": [-1.1, -0.95]}, {"number": 2, "size": [0.9, 0.8], "at": [-1.1, 0.95]}, {"number": 3, "size": [0.9, 0.8], "at": [1.1, 0]}]});+        let mut dry_args = args.clone();+        dry_args["dryRun"] = json!(true);+        let dry = dispatch(&mut s, "kicad_fp_create", &dry_args).unwrap();+        assert_eq!(dry["dryRun"], true, "{dry}");+        assert!(!lib.exists());+        let r = dispatch(&mut s, "kicad_fp_create", &args).unwrap();+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(dispatch(&mut s, "kicad_fp_create", &args).unwrap()["errorCode"], "footprint_exists");+        let file = lib.join("SOT23.kicad_mod");+        let e = dispatch(&mut s, "kicad_fp_edit", &json!({"filePath": norm(&file), "metadata": {"description": "SOT-23 test"}, "models": [{"path": "C:/m/sot23.step"}]})).unwrap();+        assert_eq!(e["success"], true, "{e}");+        assert!(e["backup"].as_str().unwrap().ends_with(".adom-bak"));+        let i = dispatch(&mut s, "kicad_fp_info", &json!({"libraryPath": norm(&lib), "name": "SOT23"})).unwrap();+        assert_eq!(i["description"], "SOT-23 test");+        assert_eq!(i["padCount"], 3);+        assert_eq!(i["models"][0]["path"], "C:/m/sot23.step");+        assert_eq!(dispatch(&mut s, "kicad_fp_edit", &json!({"filePath": norm(&file), "pads": [{"number": "9", "delete": true}]})).unwrap()["errorCode"], "edit_refused");+        assert_eq!(dispatch(&mut s, "kicad_fp_info", &json!({"libId": "nocolon"})).unwrap()["errorCode"], "bad_arg");+        let lint = kicad_core::lint::lint_library(&lib, None, std::time::Duration::from_secs(5));+        assert_eq!(lint["ok"], true, "{lint}");+        assert_eq!(lint["data"]["summary"]["footprintCount"], 1, "the .adom-bak backup is not a footprint");+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn symbol_create_info_delete_and_search() {+        let mut s = State::new();+        let d = tmp();+        let lib = d.join("Mine.kicad_sym");+        let r = dispatch(&mut s, "kicad_sym_create", &json!({"libraryPath": norm(&lib), "name": "LDO33", "description": "3.3 V LDO regulator", "keywords": "ldo regulator", "pins": [{"number": 1, "name": "GND", "type": "power_in", "side": "bottom"}, {"number": 2, "name": "VO", "type": "power_out"}, {"number": 3, "name": "VI", "type": "power_in"}]})).unwrap();+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(r["libraryCreated"], true);+        let r2 = dispatch(&mut s, "kicad_sym_create", &json!({"libraryPath": norm(&lib), "name": "R_Mine", "reference": "R", "pins": [{"number": 1}, {"number": 2, "side": "right"}]})).unwrap();+        assert_eq!(r2["success"], true, "{r2}");+        let i = dispatch(&mut s, "kicad_sym_info", &json!({"libraryPath": norm(&lib), "name": "LDO33"})).unwrap();+        assert_eq!(i["pinCount"], 3);+        assert_eq!(i["fields"]["Description"], "3.3 V LDO regulator");+        std::fs::write(d.join("sym-lib-table"), format!("(sym_lib_table (version 7) (lib (name \"Mine\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/Mine.kicad_sym\") (options \"\") (descr \"\")))")).unwrap();+        let hit = dispatch(&mut s, "kicad_search_library", &json!({"query": "ldo", "kind": "symbol", "projectPath": norm(&d)})).unwrap();+        assert_eq!(hit["success"], true, "{hit}");+        assert_eq!(hit["hits"][0]["libId"], "Mine:LDO33");+        let by_id = dispatch(&mut s, "kicad_sym_info", &json!({"libId": "Mine:R_Mine", "projectPath": norm(&d)})).unwrap();+        assert_eq!(by_id["pinCount"], 2, "{by_id}");+        let del = dispatch(&mut s, "kicad_sym_delete", &json!({"libraryPath": norm(&lib), "name": "LDO33"})).unwrap();+        assert_eq!(del["success"], true, "{del}");+        assert_eq!(kicad_core::libraries::list_symbols(&lib), vec!["R_Mine"]);+        assert_eq!(dispatch(&mut s, "kicad_sym_delete", &json!({"libraryPath": norm(&lib), "name": "LDO33"})).unwrap()["errorCode"], "symbol_not_found");+        assert_eq!(dispatch(&mut s, "kicad_search_library", &json!({})).unwrap()["errorCode"], "missing_arg");+        assert_eq!(dispatch(&mut s, "kicad_sym_create", &json!({"libraryPath": norm(&d.join("x.lib")), "name": "A"})).unwrap()["errorCode"], "bad_arg");+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-bridge/src/verbs_libraries.rs+1−1
@@ -118,7 +118,7 @@ pub fn apply_background_guard() {  /// The library context for the KiCad this bridge drives: version, install root, /// config and user dirs from the platform, Configure Paths overrides loaded.-fn lib_ctx(info: &KicadInfo) -> LibCtx {+pub(crate) fn lib_ctx(info: &KicadInfo) -> LibCtx {     let inst = info.primary();     let version = inst.map(|i| i.version.clone()).unwrap_or_default();     let mut ctx = LibCtx {
rust/crates/kicad-bridge/src/verbs_maint.rs+2−2
@@ -74,9 +74,9 @@ pub static VERBS: &[Verb] = &[         name: "kicad_pcm_install",         summary: "Install or update a package from a KiCad PCM repository on disk, exactly as the Plugin and Content Manager would (no dialog).",         mechanism: Mechanism::File, risk: "write", timeout_sec: 900,-        input: "{\"repositoryUrl\"?: \"https://.../repository.json\", \"identifier\": \"com.adom.kicad-bridge.sample-lib\", \"version\"?: \"1.1.0\", \"update\"?: bool, \"libPrefix\"?: \"PCM_\", \"force\"?: bool}",+        input: "{\"repositoryUrl\"?: \"https://.../repository.json\" | \"file:///C:/.../repository.json\", \"identifier\": \"com.adom.kicad-bridge.sample-lib\", \"version\"?: \"1.1.0\", \"update\"?: bool, \"libPrefix\"?: \"PCM_\", \"force\"?: bool} | {\"zipPath\": \"C:/.../package.zip\"}",         example: "kicad_pcm_install {\"repositoryUrl\":\"https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json\",\"identifier\":\"com.adom.kicad-bridge.sample-lib\"}",-        hint: "repositoryUrl (a repository.json) + identifier installs the newest compatible version (or version). update:true is PCM's Update button: repositoryUrl may be omitted for an installed package (its own repository is used), an installed newest version answers upToDate, otherwise the old files are replaced and installed_packages.json rewritten (previousVersion, updated:true). Extracts to $KICAD_3RD_PARTY/<folder>/<pkgid>/. KiCad registers PCM_<lib> rows itself on next load, so a running KiCad must restart (reported, never done). To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",+        hint: "repositoryUrl (a repository.json, https or file://) + identifier installs the newest compatible version (or version). A private Adom wiki page that answers 404 to an anonymous read is fetched again with the user's wiki session through Adom Bridge. zipPath installs one local PCM package zip (its metadata.json gives the identifier), with the same registry row; KiCad cannot check such a package for updates. update:true is PCM's Update button: repositoryUrl may be omitted for an installed package (its own repository is used), an installed newest version answers upToDate, otherwise the old files are replaced and installed_packages.json rewritten (previousVersion, updated:true). Extracts to $KICAD_3RD_PARTY/<folder>/<pkgid>/. KiCad registers PCM_<lib> rows itself on next load, so a running KiCad must restart (reported, never done). To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",         related: &["kicad_pcm_list", "kicad_pcm_uninstall", "kicad_pcm_add_repository", "kicad_install_library_bundle"],         pitfalls: &[             "a RUNNING KiCad does not see the new libraries until restarted",
rust/crates/kicad-bridge/src/verbs_pcb_sync.rsadded+171
@@ -0,0 +1,171 @@+//! Verb group "pcb_sync": `kicad_update_pcb_from_schematic` on `kicad_core::pcb_sync`.+//! The schematic's netlist (a kicadxml file, or kicad-cli on the schematic) against the+//! board file. Plan by default; `apply:true` edits the board FILE and only while no KiCad+//! editor has it open. There is no live path: the IPC API cannot load a library footprint+//! into the editor, and a partial live update (values and nets without new parts) would be+//! a different board from the plan, so a live apply is refused with KiCad's own F8 as the+//! alternative.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::detect::KicadInfo;+use kicad_core::libraries::LibCtx;+use kicad_core::netlist::{self, Netlist};+use kicad_core::{pcb, pcb_sync};+use kicad_platform::native;++pub static VERBS: &[Verb] = &[Verb {+    name: "kicad_update_pcb_from_schematic",+    summary: "Compare the schematic's netlist with the board (missing, extra and changed footprints, changed values, pad net changes) and, on a closed board file, apply it: add missing footprints from the libraries in a row below the board, update values and links, assign pad nets, mark extras.",+    mechanism: Mechanism::File, risk: "write", timeout_sec: 180,+    input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"netlistPath\"?: \"C:/.../x.net\" | \"schematicPath\"?: \"C:/.../x.kicad_sch\" | \"projectPath\"?, \"apply\"?: false, \"updateFootprints\"?: true, \"deleteExtra\"?: false}",+    example: "kicad_update_pcb_from_schematic {\"filePath\":\"C:/p/b.kicad_pcb\"}",+    hint: "Plan by default (dryRun): missingFootprints, extraFootprints (boardOnlyFootprints are left alone), changedValues, changedFootprints, padNetChanges, relinks, inSync. The netlist comes from netlistPath (a KiCad XML netlist: File > Export > Netlist) or kicad-cli on the schematic beside the board. apply:true needs the board CLOSED: new parts land in a row 5 mm below the outline (place them next), replaced footprints keep their pose, uuid and link, every pad net is set, extras get an Adom_NotInSchematic property (deleteExtra:true removes them). The file is replaced atomically with a .adom-bak; done[] says what landed and notApplied[] what could not (a footprint not in any fp-lib-table).",+    related: &["kicad_netlist", "kicad_extract_netlist", "kicad_placement_state", "kicad_move_footprint", "kicad_run_drc"],+    pitfalls: &["Footprints are matched by reference, then linked by symbol uuid: annotate the schematic before syncing.", "Live apply is refused (board_open) while KiCad has the board open: use KiCad's Tools > Update PCB from Schematic (F8) there, or save and close the board and apply here.", "A replaced footprint's old tracks stay where they were; DRC shows the ones that no longer reach a pad.", "Back-side footprints are not replaced (they would need a flip); they appear in notApplied."],+}];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_update_pcb_from_schematic" => update(state, args),+        _ => return None,+    })+}++fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// The netlist the call names: a kicadxml file, or kicad-cli on the schematic.+fn netlist_for(state: &mut State, args: &Value, board: &Path) -> Result<(Netlist, String), Value> {+    if let Some(p) = arg_str(args, "netlistPath") {+        let p = PathBuf::from(p.trim());+        let xml = std::fs::read_to_string(&p).map_err(|e| fail("netlist_unreadable", format!("cannot read {}: {e}", norm(&p)), "netlistPath is a KiCad XML netlist (Eeschema File > Export > Netlist, KiCad XML)."))?;+        let nl = netlist::parse_kicadxml(&xml).map_err(|e| fail("netlist_unparsable", e, "netlistPath must be KiCad's XML netlist (kicadxml), not the s-expression or another tool's format."))?;+        return Ok((nl, norm(&p)));+    }+    let sch = arg_str(args, "schematicPath").map(|s| PathBuf::from(s.trim())).or_else(|| arg_str(args, "projectPath").map(|s| PathBuf::from(s.trim()).with_extension("kicad_sch"))).unwrap_or_else(|| board.with_extension("kicad_sch"));+    if !sch.is_file() {+        return Err(fail("schematic_not_found", format!("no schematic at {}", norm(&sch)), "Pass schematicPath (the root .kicad_sch) or netlistPath (a KiCad XML netlist)."));+    }+    let cli = state.kicad_info().primary().map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file());+    let Some(cli) = cli else {+        let mut v = fail("not_installed", "kicad-cli is needed to read the schematic's netlist and was not found", "Pass netlistPath: a KiCad XML netlist exported from Eeschema (File > Export > Netlist > KiCad XML), or run this on a box with KiCad.");+        v["alternatives"] = alternatives();+        return Err(v);+    };+    let nl = netlist::load(&cli, &sch).map_err(|e| {+        let mut v = fail(e.code, e.error, e.hint);+        if let Some(s) = e.stderr {+            v["stderr"] = json!(s);+        }+        v+    })?;+    Ok((nl, norm(&sch)))+}++fn update(state: &mut State, args: &Value) -> Value {+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let (nl, from) = match netlist_for(state, args, &loaded.path) {+        Ok(x) => x,+        Err(v) => return v,+    };+    let plan = pcb_sync::compare(&loaded.board, &loaded.text, &nl);+    let apply = args.get("apply") == Some(&json!(true));+    let mut out = json!({"success": true, "boardPath": norm(&loaded.path), "netlistFrom": from, "plan": plan, "mutated": false, "dryRun": !apply});+    if !apply {+        out["_hint"] = json!(if plan["inSync"] == json!(true) { "The board matches the schematic; nothing to apply." } else { "Plan only. apply:true writes it to the board file (the board must be closed in KiCad). In an open KiCad, Tools > Update PCB from Schematic (F8) does the same live." });+        return out;+    }+    if plan["inSync"] == json!(true) {+        out["_hint"] = json!("The board matches the schematic; nothing was written.");+        return out;+    }+    if let Some(lck) = pcb::lock_file(&loaded.path) {+        let mut v = fail("board_open", "KiCad has this board open: the file cannot be updated under it, and a live update is not offered (the IPC API cannot load library footprints into the editor)", "In that KiCad run Tools > Update PCB from Schematic (F8); or save and close the board (kicad_close) and call again with apply:true.");+        v["lock"] = json!(norm(&lck));+        v["plan"] = plan;+        v["mutated"] = json!(false);+        return v;+    }+    let ctx = lib_ctx(&state.kicad_info());+    let dir = loaded.path.parent().map(Path::to_path_buf).unwrap_or_default();+    let module = |id: &str| pcb_sync::resolve_footprint(id, &dir, &ctx).and_then(|p| std::fs::read_to_string(&p).map_err(|e| format!("cannot read {}: {e}", p.display())));+    let opts = pcb_sync::ApplyOpts { delete_extra: args.get("deleteExtra") == Some(&json!(true)), update_footprints: args.get("updateFootprints") != Some(&json!(false)) };+    let (text, done) = match pcb_sync::apply(&loaded.text, &loaded.board, &nl, &opts, &module) {+        Ok(x) => x,+        Err(e) => return crate::verbs_place_helpers::rerr(e),+    };+    let bak = match pcb::write_board(&loaded.path, &text, true, Some(&loaded.text)) {+        Ok(b) => b,+        Err(v) => return v,+    };+    let after = pcb::parse_pcb_text(&text, &loaded.path.to_string_lossy()).map(|b| pcb_sync::compare(&b, &text, &nl)).unwrap_or(Value::Null);+    out["mutated"] = json!(true);+    out["done"] = done;+    out["backup"] = json!(bak);+    out["after"] = after;+    out["_hint"] = json!("Written to the closed board file. Next: kicad_placement_state shows the new parts in their row below the board; place them with kicad_move_footprint once the board is open, then route. after.changeCount is what is still different (marked extras stay until deleteExtra:true).");+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    fn scratch() -> PathBuf {+        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route");+        let d = std::env::temp_dir().join(format!("kb-sync-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(d.join("Fixture.pretty")).unwrap();+        for f in ["route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table", "Fixture.pretty/R_0603_1608Metric.kicad_mod", "Fixture.pretty/PinHeader_1x04_P2.00mm_Vertical.kicad_mod"] {+            std::fs::copy(src.join(f), d.join(f)).unwrap();+        }+        d+    }++    fn call(args: Value) -> Value {+        dispatch(&mut State::new(), "kicad_update_pcb_from_schematic", &args).unwrap()+    }++    #[test]+    fn plan_then_apply_on_the_closed_file_and_refuse_an_open_one() {+        let d = scratch();+        let board = norm(&d.join("route-fixture.kicad_pcb"));+        let net = norm(&d.join("route-fixture.net"));+        let p = call(json!({"filePath": board, "netlistPath": net}));+        assert_eq!((p["success"].as_bool(), p["dryRun"].as_bool(), p["mutated"].as_bool()), (Some(true), Some(true), Some(false)), "{p}");+        assert_eq!(p["plan"]["missingFootprints"][0]["ref"], "R2");+        std::fs::write(d.join("~route-fixture.kicad_pcb.lck"), "").unwrap();+        let open = call(json!({"filePath": board, "netlistPath": net, "apply": true}));+        assert_eq!((open["errorCode"].as_str(), open["mutated"].as_bool()), (Some("board_open"), Some(false)), "{open}");+        std::fs::remove_file(d.join("~route-fixture.kicad_pcb.lck")).unwrap();+        let a = call(json!({"filePath": board, "netlistPath": net, "apply": true}));+        assert_eq!(a["mutated"], true, "{a}");+        assert_eq!(a["after"]["changeCount"], 1, "only the marked extra: {a}");+        assert!(Path::new(a["backup"].as_str().unwrap()).is_file());+        let again = call(json!({"filePath": board, "netlistPath": net, "apply": true, "deleteExtra": true}));+        assert_eq!(again["after"]["inSync"], true, "{again}");+        let none = call(json!({"filePath": board}));+        assert_eq!(none["errorCode"], "schematic_not_found");+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-bridge/src/verbs_place_helpers.rsadded+274
@@ -0,0 +1,274 @@+//! Verb group "place_helpers": `kicad_check_clearance`, `kicad_score_placement`,+//! `kicad_place_decoupling` and `kicad_plan_fanout` on `kicad_core::place_tools`. They read+//! the live board when the PCB editor serves it on the IPC API (`source: "live-editor"`)+//! and the file otherwise (`source: "file"`). Only `kicad_place_decoupling` with+//! `dryRun:false` edits, and it does so through `kicad_move_footprint`'s own commit.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::pcb::{self, Board, RoutingError};+use kicad_core::{place_tools, placement};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_check_clearance",+        summary: "Measure the gap between footprints, courtyard to courtyard or pad copper to pad copper, for the listed refs or the whole board, and report every pair under a threshold, closest first.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"refs\"?: [\"U1\", \"C3\"], \"mode\"?: \"courtyard\" | \"pads\", \"threshold\"?: 0.25, \"includeSameNet\"?: false, \"maxPairs\"?: 200}",+        example: "kicad_check_clearance {\"filePath\":\"C:/p/b.kicad_pcb\",\"refs\":[\"U1\"],\"mode\":\"pads\",\"threshold\":0.2}",+        hint: "pairs[] are {a, b, distanceMm, overlapping|touching}, sorted closest first; clean is true when none is under thresholdMm (default 0.25 courtyard, 0.2 pads). refs keeps pairs that involve at least one listed footprint. Courtyard mode follows the real courtyard outline when both parts have one (measuredOn says which) and only compares parts on the same side. Pad mode skips pads on the same net unless includeSameNet, and only compares copper that shares a side.",+        related: &["kicad_placement_validate", "kicad_score_placement", "kicad_move_footprint", "kicad_run_drc"],+        pitfalls: &["Round-rect and oval pads are measured as their full rectangle, so a reported pad gap can be a little smaller than KiCad's; KiCad DRC (kicad_run_drc) is the authority.", "Live reads need the board open in the PCB editor with the IPC API on; otherwise the file on disk is read and unsaved edits are not in it."],+    },+    Verb {+        name: "kicad_score_placement",+        summary: "Score a placement 0 to 100 with named deductions: courtyard overlaps, parts off the board, connectors away from the edge, decoupling caps far from their IC's power pin, and ratsnest length.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"connectorEdgeMm\"?: 3, \"decouplingMm\"?: 3}",+        example: "kicad_score_placement {\"filePath\":\"C:/p/b.kicad_pcb\"}",+        hint: "deductions[] (largest first) name the rule, count, points, cap and the items: courtyard_overlap 10 each (cap 40), outside_board 10 (30), connector_off_edge 5 (15), decoupling_far 3 (15), ratsnest_length 0 to 20 (the mean MST edge as a share of the board diagonal, 0 at 10 %, 20 at 40 %). Fix the biggest deduction, move, score again; kicad_place_decoupling fixes decoupling_far.",+        related: &["kicad_placement_state", "kicad_place_decoupling", "kicad_check_clearance", "kicad_placement_validate"],+        pitfalls: &["Connectors and caps are recognised by reference prefix (J, P, CN, USB; C with two pads across ground and a power net) and connector footprint names; a part named otherwise is not judged.", "The score is a heuristic for comparing placements of the same board, not a sign-off: kicad_placement_validate and DRC are the checks."],+    },+    Verb {+        name: "kicad_place_decoupling",+        summary: "Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, power pad facing its pin), dryRun by default; apply moves them through kicad_move_footprint's live commit as one Undo step.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"ic\": \"U1\", \"caps\"?: [\"C1\", \"C2\"], \"gap\"?: 0.3, \"dryRun\"?: true, \"expectedRevision\"?: \"<rev>\", \"save\"?: false}",+        example: "kicad_place_decoupling {\"filePath\":\"C:/p/b.kicad_pcb\",\"ic\":\"U1\"}",+        hint: "caps defaults to every capacitor across ground and one of the IC's power nets. placements[] gives each cap's pin, side, x, y, rotation and padToPinMm; skipped[] says why a cap was left (other_side, no_shared_power_net, no_free_spot). moveArgs is the exact kicad_move_footprint batch and validation is that verb's own courtyard and outline check. dryRun:false with expectedRevision (from kicad_placement_state) moves them live; without an editor it is refused and the plan still stands.",+        related: &["kicad_move_footprint", "kicad_placement_state", "kicad_score_placement"],+        pitfalls: &["Caps on the other side of the board are skipped: side changes are not moved (flip them in KiCad first).", "The plan aligns each power pad with its pin; it does not route. Route the short stubs with kicad_route_net afterwards."],+    },+    Verb {+        name: "kicad_plan_fanout",+        summary: "Plan the fanout of a BGA or QFN/QFP: detect the pad pitch, pick dogbone or via-in-pad per ball, the escape layer, surface stubs for the outer ring, a via grid for an exposed pad, returned as kicad_route_net-ready calls.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"ref\": \"U3\", \"style\"?: \"auto\" | \"dogbone\" | \"via-in-pad\", \"escapeLayer\"?: \"In1.Cu\", \"viaSize\"?, \"viaDrill\"?, \"width\"?, \"clearance\"?: 0.1, \"pitch\"?, \"viaPerimeter\"?: false, \"stubMm\"?: 0.4, \"surfaceOuterRing\"?: true}",+        example: "kicad_plan_fanout {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"U3\"}",+        hint: "package is bga (pads inside the grid) or perimeter. pitchMm is the median nearest-neighbour spacing. style auto picks dogbone when a via fits between four balls with the clearance (dogboneRoomMm), else via-in-pad. pads[] has one row per pad (surface, dogbone, via-in-pad, stub+via, thermal-via-grid, skip). calls[] are kicad_route_net arguments, one per net: add filePath and expectedRevision and send each (dryRun first). minViaToPadMm is the closest via ring to another pad.",+        related: &["kicad_route_net", "kicad_board_pads", "kicad_routing_state", "kicad_board_rules"],+        pitfalls: &["Via-in-pad needs filled and capped vias from the fab (IPC-4761 type VII); say so to the user before committing it.", "Escape layer defaults to the first inner layer, else the opposite outer layer; pass escapeLayer for a plane-heavy stack-up.", "The plan does not check the stubs against other parts' copper; each kicad_route_net call runs KiCad DRC before it commits."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_check_clearance" => read(state, args, |b, a| place_tools::check_clearance(b, a)),+        "kicad_score_placement" => read(state, args, |b, a| Ok(place_tools::score(b, a))),+        "kicad_plan_fanout" => read(state, args, place_tools::plan_fanout),+        "kicad_place_decoupling" => decoupling(state, args),+        _ => return None,+    })+}++/// Errors that mean "no PCB editor serves this board here": the file is next.+pub(crate) const NO_EDITOR: &[&str] = &["ipc_unavailable", "api_server_off", "no_board_open", "board_mismatch", "no_pcb_frame", "unsupported_kicad"];++/// The board as the verbs read it: live from the editor when it serves this board, else+/// the file on disk.+pub(crate) struct Src {+    pub board: Board,+    pub path: String,+    pub source: &'static str,+    pub revision: String,+    pub live: Option<Value>,+}++pub(crate) fn rerr(e: RoutingError) -> Value {+    let mut v = json!({"success": false, "errorCode": e.code, "error": e.message});+    for (k, val) in e.detail {+        v[k] = val;+    }+    if v.get("_hint").is_none() {+        v["_hint"] = json!("Read kicad_placement_state (or kicad_routing_state) for the refs, pads and nets of this board, then call again.");+    }+    v+}++#[cfg(feature = "ipc")]+pub(crate) fn ipc_ctx(state: &mut State) -> kicad_core::ipc::Ctx {+    use kicad_core::detect;+    let info = state.kicad_info();+    kicad_core::ipc::Ctx {+        kicad_cli: info.primary().map(|p| std::path::PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    }+}++#[cfg(feature = "ipc")]+fn live_source(state: &mut State, args: &Value) -> Result<Option<Src>, Value> {+    use kicad_core::ipc;+    if args.get("source").and_then(Value::as_str) == Some("file") {+        return Ok(None);+    }+    let ctx = ipc_ctx(state);+    match ipc::board_text(&ctx, args) {+        Ok((text, path, _ver)) => {+            let board = pcb::parse_pcb_text(&text, &path).map_err(|e| fail("board_unparsable", format!("could not parse the live board: {e}"), "Report this with the board attached."))?;+            let revision = ipc::revision_of(&text);+            Ok(Some(Src { board, path, source: "live-editor", revision, live: None }))+        }+        Err(e) if NO_EDITOR.contains(&e.code.as_str()) => Err(json!({"errorCode": e.code, "error": e.message})),+        Err(e) => Err(ipc::error_response(&e)),+    }+}++#[cfg(not(feature = "ipc"))]+fn live_source(_state: &mut State, _args: &Value) -> Result<Option<Src>, Value> {+    Err(json!({"errorCode": "ipc_unavailable", "error": "this build has the ipc feature off"}))+}++pub(crate) fn board_source(state: &mut State, args: &Value) -> Result<Src, Value> {+    let live_err = match live_source(state, args) {+        Ok(Some(s)) => return Ok(s),+        Ok(None) => None,+        Err(v) if v.get("success") == Some(&json!(false)) => return Err(v),+        Err(v) => Some(v),+    };+    let loaded = pcb::load(args, true)?;+    let revision = placement::file_revision(&loaded.text);+    Ok(Src { path: norm(&loaded.path), board: loaded.board, source: "file", revision, live: live_err })+}++fn stamp(v: &mut Value, src: &Src) {+    v["boardPath"] = json!(src.path);+    v["source"] = json!(src.source);+    v["revision"] = json!(src.revision);+    if let Some(l) = &src.live {+        v["live"] = l.clone();+    }+}++fn read(state: &mut State, args: &Value, run: fn(&Board, &Value) -> Result<Value, RoutingError>) -> Value {+    let src = match board_source(state, args) {+        Ok(s) => s,+        Err(v) => return v,+    };+    match run(&src.board, args) {+        Ok(mut v) => {+            v["success"] = json!(true);+            v["mutated"] = json!(false);+            stamp(&mut v, &src);+            if v.get("_hint").is_none() {+                v["_hint"] = json!(if src.source == "file" { "Read from the file (no PCB editor serves this board on the IPC API); unsaved edits in an open editor are not in it." } else { "Read live from the PCB editor." });+            }+            v+        }+        Err(e) => {+            let mut v = rerr(e);+            stamp(&mut v, &src);+            v+        }+    }+}++fn decoupling(state: &mut State, args: &Value) -> Value {+    let src = match board_source(state, args) {+        Ok(s) => s,+        Err(v) => return v,+    };+    let mut plan = match place_tools::plan_decoupling(&src.board, args) {+        Ok(p) => p,+        Err(e) => {+            let mut v = rerr(e);+            stamp(&mut v, &src);+            return v;+        }+    };+    stamp(&mut plan, &src);+    plan["success"] = json!(true);+    let apply = args.get("dryRun") == Some(&json!(false));+    if !apply {+        plan["dryRun"] = json!(true);+        plan["mutated"] = json!(false);+        plan["_hint"] = json!("Plan only (dryRun is the default). Apply with dryRun:false and expectedRevision from kicad_placement_state, or send moveArgs to kicad_move_footprint yourself (it checks courtyards and the outline again).");+        return plan;+    }+    if plan["validation"]["ok"] != json!(true) {+        let mut v = fail("plan_invalid", "the planned positions fail kicad_move_footprint's checks; nothing was moved", "Read validation and skipped, pass caps or a larger gap, or move the blocking parts first.");+        v["plan"] = plan;+        v["mutated"] = json!(false);+        return v;+    }+    if src.source != "live-editor" {+        let mut v = fail("live_required", "moving footprints is live only: no PCB editor serves this board on the IPC API", "Open the board in the PCB editor with the IPC API on (kicad_ipc_api {\"enable\":true}, relaunch), then call again with dryRun:false and the live revision. The plan below still stands.");+        v["plan"] = plan;+        v["mutated"] = json!(false);+        return v;+    }+    if args.get("expectedRevision").and_then(Value::as_str).is_none() {+        let mut v = fail("revision_required", "dryRun:false needs expectedRevision", "Pass expectedRevision from kicad_placement_state (or this plan's revision) so a board edited meanwhile is not moved blindly.");+        v["plan"] = plan;+        v["mutated"] = json!(false);+        return v;+    }+    let mut margs = json!({"filePath": src.path, "expectedRevision": args["expectedRevision"], "refs": plan["moveArgs"]["refs"]});+    for k in ["socketPath", "save", "ipcTimeoutMs", "allowLocked"] {+        if let Some(v) = args.get(k) {+            margs[k] = v.clone();+        }+    }+    let mut moved = move_live(state, &margs);+    if moved["success"] == json!(true) {+        moved["plan"] = plan;+    }+    moved+}++#[cfg(feature = "ipc")]+fn move_live(state: &mut State, margs: &Value) -> Value {+    use kicad_core::ipc;+    let ctx = ipc_ctx(state);+    match ipc::with_standard_revision(&ctx, margs, |a| ipc::move_footprint(&ctx, a)) {+        Ok(v) => v,+        Err(e) => ipc::error_response(&e),+    }+}++#[cfg(not(feature = "ipc"))]+fn move_live(_state: &mut State, _margs: &Value) -> Value {+    fail("ipc_unavailable", "this build has the ipc feature off", "Use a build with the ipc feature (the default).")+}++#[cfg(test)]+mod tests {+    use super::*;++    fn fixture() -> String {+        norm(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_pcb").canonicalize().unwrap())+    }++    fn call(cmd: &str, args: Value) -> Value {+        dispatch(&mut State::new(), cmd, &args).unwrap()+    }++    #[test]+    fn the_read_verbs_answer_from_the_file_without_an_editor() {+        let b = fixture();+        let c = call("kicad_check_clearance", json!({"filePath": b, "threshold": 3}));+        assert_eq!((c["success"].as_bool(), c["source"].as_str()), (Some(true), Some("file")), "{c}");+        assert!(c["live"]["errorCode"].is_string(), "the live miss is reported");+        let s = call("kicad_score_placement", json!({"filePath": b}));+        assert!(s["score"].as_f64().unwrap() < 100.0);+        let f = call("kicad_plan_fanout", json!({"filePath": b, "ref": "U2"}));+        assert_eq!(f["package"], "bga", "{f}");+        let e = call("kicad_plan_fanout", json!({"filePath": b, "ref": "ZZ9"}));+        assert_eq!((e["success"].as_bool(), e["errorCode"].as_str()), (Some(false), Some("unknown_footprint")));+        assert!(e["_hint"].is_string());+    }++    #[test]+    fn decoupling_plans_by_default_and_refuses_a_file_apply() {+        let b = fixture();+        let p = call("kicad_place_decoupling", json!({"filePath": b, "ic": "U1"}));+        assert_eq!((p["dryRun"].as_bool(), p["mutated"].as_bool()), (Some(true), Some(false)), "{p}");+        assert_eq!(p["moveArgs"]["refs"].as_array().unwrap().len(), 2);+        let a = call("kicad_place_decoupling", json!({"filePath": b, "ic": "U1", "dryRun": false, "expectedRevision": p["revision"]}));+        assert_eq!(a["errorCode"], "live_required", "{a}");+        assert_eq!(a["mutated"], false);+        assert!(a["plan"]["placements"].is_array());+    }+}
rust/crates/kicad-bridge/src/verbs_project.rsadded+258
@@ -0,0 +1,258 @@+//! Verb group "project": create, describe, rename and snapshot a KiCad project on disk.+//! The file work is `kicad_core::project`; this file adds the "KiCad has it open" refusal+//! and the snapshot PDFs through kicad-cli.++use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::{cli, project};+use kicad_platform::native;++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_project_create",+        summary: "Create a new KiCad 10 project folder: <name>.kicad_pro, a blank .kicad_sch and a blank .kicad_pcb (2 to 32 copper layers), optional title and text variables; a fab rules name is recorded to stage later.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"parentDir\": \"C:/Users/john/Documents/KiCad\", \"name\": \"widget\", \"title\"?: \"Widget v1\", \"layers\"?: 2, \"fabRules\"?: \"jlcpcb\", \"textVariables\"?: {\"REV\": \"A\"}, \"dryRun\"?: bool}",+        example: "kicad_project_create {\"parentDir\":\"C:/Users/john/Documents/KiCad\",\"name\":\"widget\",\"layers\":4,\"fabRules\":\"jlcpcb\"}",+        hint: "Makes <parentDir>/<name>/ and refuses a folder that already holds a .kicad_pro. fabRules is stored as the FAB_RULES text variable; stage the rules with kicad_set_design_rules {fab, boardPath} once the board exists (it is in _next). Every file is parsed back before it is written.",+        related: &["kicad_project_info", "kicad_set_design_rules", "kicad_sch_place_symbol"],+        pitfalls: &["the board has no outline yet: draw Edge.Cuts before routing or exporting"],+    },+    Verb {+        name: "kicad_project_info",+        summary: "A project at a glance: name, files with sizes and dates, the schematic sheet tree, the board (layers, footprints, nets, size), text variables, net classes, last modified, and KiCad lock files.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | the project folder | any of its .kicad_sch/.kicad_pcb}",+        example: "kicad_project_info {\"projectPath\":\"C:/Users/john/proj/board.kicad_pro\"}",+        hint: "Read-only. locks lists ~*.lck files: a document of this project is open in KiCad.",+        related: &["kicad_project_create", "kicad_project_snapshot", "kicad_netlist"], pitfalls: &[],+    },+    Verb {+        name: "kicad_project_rename",+        summary: "Rename a project: the .kicad_pro, root .kicad_sch, .kicad_pcb, .kicad_prl (and .kicad_dru, backups folder), with the references inside updated (project file entries, every schematic's instance headers, the board's sheet references and title). Refused while KiCad has the project open.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"projectPath\": \"C:/.../old.kicad_pro\", \"newName\": \"new\", \"dryRun\"?: bool}",+        example: "kicad_project_rename {\"projectPath\":\"C:/Users/john/proj/old.kicad_pro\",\"newName\":\"widget-v2\",\"dryRun\":true}",+        hint: "dryRun:true returns the plan (files written, removed, moved, and what changed in each). The folder keeps its name. Close the project in KiCad first: the verb refuses while KiCad lock files exist or a KiCad window shows the project.",+        related: &["kicad_project_info", "kicad_project_snapshot", "kicad_close"],+        pitfalls: &["child sheet files keep their names (only the root sheet is named after the project)", "take a kicad_project_snapshot first if the project is not under version control"],+    },+    Verb {+        name: "kicad_project_snapshot",+        summary: "Timestamped copy of the project's files into <project>/snapshots/<stamp>[-label]/, plus schematic and board PDFs made with kicad-cli.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"projectPath\": \"C:/.../x.kicad_pro\", \"label\"?: \"before-routing\", \"pdfs\"?: true}",+        example: "kicad_project_snapshot {\"projectPath\":\"C:/Users/john/proj/board.kicad_pro\",\"label\":\"before-routing\"}",+        hint: "Copies the top-level KiCad documents and local lib tables (libraries in sub-folders are referenced, not copied). Without kicad-cli the copy still happens and pdfs says why they were skipped.",+        related: &["kicad_project_info", "kicad_export_pdf"],+        pitfalls: &["a snapshot of an open, unsaved project holds what is on DISK, not the editor's unsaved changes"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_project_create" => create(state, args),+        "kicad_project_info" => info(args),+        "kicad_project_rename" => rename(args),+        "kicad_project_snapshot" => snapshot(state, args),+        _ => return None,+    })+}++fn pro_arg(args: &Value) -> Result<PathBuf, Value> {+    let Some(p) = arg_any(args, &["projectPath", "filePath", "path"]) else {+        return Err(fail("missing_arg", "projectPath is required", "Pass the absolute path of the .kicad_pro (or its folder)."));+    };+    project::resolve_pro(Path::new(p))+}++fn create(_state: &mut State, args: &Value) -> Value {+    let Some(parent) = arg_any(args, &["parentDir", "directory", "dir"]) else {+        return fail("missing_arg", "parentDir is required", "Pass parentDir: the folder the new project folder goes in.");+    };+    let Some(name) = arg_str(args, "name") else {+        return fail("missing_arg", "name is required", "Pass name: the project name (also the file stem).");+    };+    let layers = args.get("layers").and_then(Value::as_u64).unwrap_or(2) as u32;+    let text_vars: BTreeMap<String, String> = args.get("textVariables").and_then(Value::as_object).map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()).unwrap_or_default();+    let fab_rules = arg_str(args, "fabRules");+    let opts = project::CreateOpts { title: arg_str(args, "title"), layers, fab_rules, text_vars, dry_run: args.get("dryRun").and_then(Value::as_bool).unwrap_or(false) };+    match project::create(Path::new(parent), name, &opts) {+        Err(e) => e,+        Ok(r) => {+            let board = format!("{}/{name}.kicad_pcb", r["projectDir"].as_str().unwrap_or(""));+            let mut next = vec![json!({"verb": "kicad_project_info", "args": {"projectPath": format!("{}/{name}.kicad_pro", r["projectDir"].as_str().unwrap_or(""))}})];+            if let Some(f) = fab_rules {+                next.push(json!({"verb": "kicad_set_design_rules", "args": {"fab": f, "layers": layers, "boardPath": board, "runDrc": false}}));+            }+            json!({+                "success": true,+                "output": format!("{} project {name} ({layers} copper layers) in {}", if opts.dry_run { "Would create" } else { "Created" }, r["projectDir"].as_str().unwrap_or("")),+                "data": r,+                "_next": next,+                "_hint": "Add symbols with kicad_sch_place_symbol, draw the board outline (Edge.Cuts), then update the board from the schematic in KiCad. Open it on screen with kicad_show_project.",+            })+        }+    }+}++fn info(args: &Value) -> Value {+    let pro = match pro_arg(args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    match project::info(&pro) {+        Err(e) => e,+        Ok(mut v) => {+            let locks = v["locks"].as_array().map(|a| a.len()).unwrap_or(0);+            v["success"] = json!(true);+            v["output"] = json!(format!("{}: {} sheet(s), {} file(s)", v["name"].as_str().unwrap_or(""), v["sheets"].as_array().map(|a| a.len()).unwrap_or(0), v["files"].as_array().map(|a| a.len()).unwrap_or(0)));+            v["_hint"] = json!(if locks > 0 { "KiCad lock files exist: a document of this project is open in KiCad. File edits made now are overwritten when the editor saves." } else { "Nothing of this project is open in KiCad (no lock files)." });+            v+        }+    }+}++/// KiCad windows whose title names the project (best effort; empty where windows cannot be listed).+fn kicad_windows_for(stem: &str) -> Vec<String> {+    let Ok(wins) = native().list_windows() else { return Vec::new() };+    let needle = stem.to_lowercase();+    wins.into_iter()+        .filter(|w| ["kicad", "eeschema", "pcbnew"].iter().any(|e| w.exe.to_lowercase().starts_with(e)))+        .filter(|w| {+            let t = w.title.to_lowercase();+            t.split(|c: char| c == ' ' || c == '[' || c == ']' || c == '/' || c == '\\' || c == '\u{2014}' || c == '-').any(|tok| tok == needle || tok.trim_end_matches(".kicad_pro") == needle || tok.trim_end_matches(".kicad_sch") == needle || tok.trim_end_matches(".kicad_pcb") == needle)+        })+        .map(|w| w.title)+        .collect()+}++fn rename(args: &Value) -> Value {+    let pro = match pro_arg(args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let Some(new) = arg_any(args, &["newName", "name"]) else {+        return fail("missing_arg", "newName is required", "Pass newName: the new project name (file stem).");+    };+    let dir = pro.parent().unwrap_or(Path::new(".")).to_path_buf();+    let stem = pro.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let dry = args.get("dryRun").and_then(Value::as_bool).unwrap_or(false);+    let locks = project::open_locks(&dir);+    let windows = kicad_windows_for(&stem);+    let plan = match project::rename_plan(&pro, new) {+        Ok(p) => p,+        Err(e) => return e,+    };+    if !dry && (!locks.is_empty() || !windows.is_empty()) {+        let mut v = fail("project_open", format!("KiCad has this project open (locks: {}; windows: {})", if locks.is_empty() { "none".into() } else { locks.join(", ") }, if windows.is_empty() { "none".into() } else { windows.join(" | ") }), "Close the project in KiCad (save first), then retry. A stale ~*.lck left by a crash can be deleted once no KiCad is running.");+        v["plan"] = plan.public();+        return v;+    }+    if dry {+        return json!({"success": true, "dryRun": true, "output": format!("Would rename {stem} to {new}"), "plan": plan.public(), "locks": locks, "windows": windows, "_hint": "Run again without dryRun to apply. Close the project in KiCad first."});+    }+    if let Err(e) = project::apply(&plan) {+        let mut v = fail("rename_failed", e, "Some files may already have their new names; kicad_project_info on the folder shows what is there.");+        v["plan"] = plan.public();+        return v;+    }+    let new_pro = dir.join(format!("{new}.kicad_pro"));+    json!({+        "success": true,+        "output": format!("Renamed {stem} to {new}"),+        "plan": plan.public(),+        "projectPath": norm(&new_pro),+        "_hint": "Open the renamed project with kicad_show_project. Anything outside this folder that names the old files (scripts, jobsets, a parent project's sheet reference) still uses the old names.",+    })+}++fn snapshot(state: &mut State, args: &Value) -> Value {+    let pro = match pro_arg(args) {+        Ok(p) => p,+        Err(e) => return e,+    };+    let stamp = kicad_core::design_rules::timestamp();+    let (dest, copied) = match project::snapshot(&pro, &stamp, arg_str(args, "label")) {+        Ok(x) => x,+        Err(e) => return fail("snapshot_failed", e, "Check that the project folder is writable."),+    };+    let stem = pro.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let dir = pro.parent().unwrap_or(Path::new(".")).to_path_buf();+    let want_pdfs = args.get("pdfs").and_then(Value::as_bool).unwrap_or(true);+    let mut pdfs: Vec<Value> = Vec::new();+    let mut pdf_note: Option<String> = None;+    if want_pdfs {+        match crate::verbs_fab::cli_or_fail(state) {+            Err(_) => pdf_note = Some("kicad-cli is not installed on this machine; the files were copied without PDFs".into()),+            Ok((cli_path, _)) => {+                let root_sch = dir.join(format!("{stem}.kicad_sch"));+                let board = dir.join(format!("{stem}.kicad_pcb"));+                let jobs: Vec<(PathBuf, PathBuf, Vec<String>)> = [+                    (root_sch.clone(), dest.join(format!("{stem}-schematic.pdf")), vec!["sch".to_string(), "export".into(), "pdf".into()]),+                    (board.clone(), dest.join(format!("{stem}-board.pdf")), vec!["pcb".to_string(), "export".into(), "pdf".into(), "--layers".into(), "F.Cu,B.Cu,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts".into(), "--mode-single".into()]),+                ]+                .into_iter()+                .filter(|(src, _, _)| src.is_file())+                .collect();+                for (src, out, mut argv) in jobs {+                    argv.push("--output".into());+                    argv.push(norm(&out));+                    argv.push(norm(&src));+                    let a: Vec<&str> = argv.iter().map(String::as_str).collect();+                    let r = cli::run(&cli_path, &a, Duration::from_secs(140));+                    pdfs.push(json!({"file": norm(&out), "ok": r.exit_code == 0 && out.is_file(), "error": if r.exit_code == 0 { Value::Null } else { json!(r.stderr.trim()) }}));+                }+            }+        }+    }+    json!({+        "success": true,+        "output": format!("Snapshot of {stem}: {} file(s) in {}", copied.len(), norm(&dest)),+        "data": {"snapshotDir": norm(&dest), "files": copied, "pdfs": pdfs},+        "pdfNote": pdf_note,+        "_hint": "Restore by copying the files back over the project (close it in KiCad first). Snapshots live inside the project folder, so a project zip or git commit includes them unless ignored.",+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn create_info_snapshot_rename_round_trip() {+        let mut s = State::new();+        let d = std::env::temp_dir().join(format!("kb-proj-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        assert_eq!(dispatch(&mut s, "kicad_project_create", &json!({"name": "x"})).unwrap()["errorCode"], "missing_arg");+        let r = dispatch(&mut s, "kicad_project_create", &json!({"parentDir": norm(&d), "name": "demo", "layers": 4, "fabRules": "jlcpcb", "title": "Demo"})).unwrap();+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(r["_next"][1]["verb"], "kicad_set_design_rules");+        let pro = d.join("demo/demo.kicad_pro");+        let i = dispatch(&mut s, "kicad_project_info", &json!({"projectPath": norm(&d.join("demo"))})).unwrap();+        assert_eq!(i["success"], true, "{i}");+        assert_eq!(i["board"]["copperLayers"].as_array().unwrap().len(), 4);+        assert_eq!(i["textVariables"]["FAB_RULES"], "jlcpcb");+        let snap = dispatch(&mut s, "kicad_project_snapshot", &json!({"projectPath": norm(&pro), "label": "first"})).unwrap();+        assert_eq!(snap["success"], true, "{snap}");+        assert_eq!(snap["data"]["files"].as_array().unwrap().len(), 3);+        let dry = dispatch(&mut s, "kicad_project_rename", &json!({"projectPath": norm(&pro), "newName": "demo2", "dryRun": true})).unwrap();+        assert_eq!(dry["dryRun"], true, "{dry}");+        assert!(pro.is_file(), "dry run moves nothing");+        std::fs::write(d.join("demo/~demo.kicad_pcb.lck"), "{}").unwrap();+        let refused = dispatch(&mut s, "kicad_project_rename", &json!({"projectPath": norm(&pro), "newName": "demo2"})).unwrap();+        assert_eq!(refused["errorCode"], "project_open");+        std::fs::remove_file(d.join("demo/~demo.kicad_pcb.lck")).unwrap();+        let done = dispatch(&mut s, "kicad_project_rename", &json!({"projectPath": norm(&pro), "newName": "demo2"})).unwrap();+        assert_eq!(done["success"], true, "{done}");+        assert!(d.join("demo/demo2.kicad_pcb").is_file() && !pro.exists());+        assert!(d.join("demo/snapshots").is_dir(), "the snapshot folder is untouched by the rename");+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-bridge/src/verbs_route_edit.rsadded+186
@@ -0,0 +1,186 @@+//! Verb group "route_edit": `kicad_modify_track`, `kicad_route_diff_pair` and+//! `kicad_copy_routing`. Live through `kicad_core::ipc` (DRC preflight on a snapshot, the+//! revision guard, one native Undo step); without a PCB editor a `dryRun` plans against+//! the file and a real edit is refused, as `kicad_route_net` does.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs_place_helpers::{rerr, NO_EDITOR};+use kicad_core::pcb::{self, RoutingError};+use kicad_core::{project_rules, route_tools};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_modify_track",+        summary: "Change the width, layer or endpoints of existing tracks by id, live in the PCB editor as ONE Undo step after a KiCad DRC preflight.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"changes\": [{\"id\": \"<segment uuid>\", \"width\"?: 0.3, \"layer\"?: \"B.Cu\", \"start\"?: [x,y] | \"U1.3\", \"end\"?: [x,y] | \"R1.1\"}] | \"itemIds\": [..] with \"width\"?/\"layer\"?, \"dryRun\"?: false, \"validate\"?: true, \"allowLocked\"?: false, \"save\"?: false}",+        example: "kicad_modify_track {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"itemIds\":[\"<uuid>\"],\"width\":0.4}",+        hint: "Ids are segments[].uuid from kicad_routing_state. The candidate board (every change applied) goes through KiCad DRC against the current one; new errors refuse it (drc_rejected, mutated:false). The commit updates KiCad's own track items (net, lock and id kept). changes[] shows before and after; verified is the read-back. dryRun:true checks without editing and, with no editor, plans against the file.",+        related: &["kicad_routing_state", "kicad_route_net", "kicad_remove_route", "kicad_routing_validate"],+        pitfalls: &["Straight tracks only: an arc id is refused (unknown_track says so); remove and route it again.", "Moving an endpoint does not drag the tracks joined to it; a dangling end shows in kicad_routing_validate.", "A locked track is refused (track_locked) unless allowLocked:true.", "Never retry a timed-out commit blindly (mutation_outcome_unknown); read kicad_routing_state first."],+    },+    Verb {+        name: "kicad_route_diff_pair",+        summary: "Route a differential pair as two coupled parallel paths offset from one centerline (gap, width, layer, vias placed as pairs), each half through kicad_route_net's planner, DRC preflight, ONE Undo step, with the length skew reported.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"netP\": \"USB_D+\", \"netN\"?: \"USB_D-\" | \"pair\": \"USB_D\", \"centerline\": [[x,y], {\"x\",\"y\",\"layer\"?: \"B.Cu\"}, ...], \"gap\"?, \"width\"?, \"layer\"?, \"viaSize\"?: 0.6, \"viaDrill\"?: 0.3, \"viaGap\"?, \"pSide\"?: \"left\" | \"right\", \"pStart\"?, \"nStart\"?, \"pEnd\"?, \"nEnd\"?: \"J1.3\", \"dryRun\"?: false, \"validate\"?: true, \"save\"?: false}",+        example: "kicad_route_diff_pair {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"pair\":\"USB_D\",\"centerline\":[[20,24],[26,24],[28,26]],\"pEnd\":\"J1.2\",\"nEnd\":\"J1.3\"}",+        hint: "The centerline is the middle of the pair; P runs on its left (in travel direction, +y down) unless pSide:right. gap and width default to the P net's netclass diff pair values in the .kicad_pro (sizesFrom says where they came from). A layer on a centerline point changes layer there with a via pair spaced viaSize + viaGap apart. pStart/nStart/pEnd/nEnd join pads to the offset ends (checked to be on the right net). skewMm and longer report the length mismatch.",+        related: &["kicad_route_net", "kicad_netclasses", "kicad_routing_state", "kicad_routing_validate"],+        pitfalls: &["Corners sharper than about 130 degrees of turn are refused (corner_too_sharp): split them into two 45 degree bends.", "The pad joins are plain stubs, not coupled; keep the centerline ends close to the pads.", "No length tuning: skewMm is reported, tune in KiCad (Route > Tune Skew) or adjust the centerline."],+    },+    Verb {+        name: "kicad_copy_routing",+        summary: "Copy the tracks and vias inside a source box to a target offset, optionally remapping nets by pairing footprints (refMap or a reference pattern), live with DRC preflight as ONE Undo step.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"source\": {\"minX\",\"minY\",\"maxX\",\"maxY\"}, \"offset\": {\"dx\",\"dy\"} | [dx,dy], \"refMap\"?: {\"U1\": \"U5\"}, \"refPattern\"?: {\"from\": \"CH1_*\", \"to\": \"CH2_*\"}, \"netMap\"?: {\"SIG\": \"SIG2\"}, \"keepUnmappedNets\"?: bool, \"layers\"?: [\"F.Cu\"], \"dryRun\"?: false, \"validate\"?: true, \"save\"?: false}",+        example: "kicad_copy_routing {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"source\":{\"minX\":10,\"minY\":10,\"maxX\":30,\"maxY\":25},\"offset\":{\"dx\":25,\"dy\":0},\"refPattern\":{\"from\":\"CH1_*\",\"to\":\"CH2_*\"}}",+        hint: "A track is copied when both ends are in the box, a via when its centre is. With refMap or refPattern each source pad's net maps to the same pad number's net on the partner footprint (netMap overrides); copper whose net has no mapping is skipped (skipped[] says why) unless keepUnmappedNets:true. Without a remap the copy keeps its nets. netMap and refMap are echoed so you can check the pairing before committing; dryRun first.",+        related: &["kicad_routing_state", "kicad_route_net", "kicad_remove_route", "kicad_placement_state"],+        pitfalls: &["Only through vias are copied (blind or buried ones are skipped).", "The copy connects only where the target pads really sit at the same offset; place the target channel identically first.", "One source net mapping to two target nets is refused (net_map_conflict) unless netMap settles it."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_modify_track" => run(state, args, Which::Modify),+        "kicad_route_diff_pair" => run(state, args, Which::DiffPair),+        "kicad_copy_routing" => run(state, args, Which::Copy),+        _ => return None,+    })+}++#[derive(Clone, Copy, PartialEq)]+enum Which {+    Modify,+    DiffPair,+    Copy,+}++fn run(state: &mut State, args: &Value, which: Which) -> Value {+    let live = live(state, args, which);+    if live["success"] == json!(true) {+        return live;+    }+    let code = live["errorCode"].as_str().unwrap_or("").to_string();+    if !NO_EDITOR.contains(&code.as_str()) {+        return live;+    }+    if args.get("dryRun") != Some(&json!(true)) {+        let mut v = live;+        v["mutated"] = json!(false);+        v["_hint"] = json!(format!("{} This edit is live only: open the board in the PCB editor with the IPC API on (kicad_ipc_api {{\"enable\":true}}, relaunch). dryRun:true plans and checks against the file without an editor.", v["_hint"].as_str().unwrap_or("")));+        return v;+    }+    let mut out = from_file(state, args, which);+    out["live"] = json!({"errorCode": live["errorCode"], "error": live["error"]});+    out+}++#[cfg(feature = "ipc")]+fn live(state: &mut State, args: &Value, which: Which) -> Value {+    use kicad_core::ipc;+    let ctx = crate::verbs_place_helpers::ipc_ctx(state);+    let r = match which {+        Which::Modify => ipc::with_standard_revision(&ctx, args, |a| ipc::modify_track(&ctx, a)),+        Which::DiffPair => ipc::with_standard_revision(&ctx, args, |a| ipc::route_diff_pair(&ctx, a)),+        Which::Copy => ipc::with_standard_revision(&ctx, args, |a| ipc::copy_routing(&ctx, a)),+    };+    match r {+        Ok(v) => v,+        Err(e) => ipc::error_response(&e),+    }+}++#[cfg(not(feature = "ipc"))]+fn live(_state: &mut State, _args: &Value, _which: Which) -> Value {+    fail("ipc_unavailable", "This build of the bridge has the ipc feature off", "Use a build with the ipc feature (the default); dryRun:true still plans against the file.")+}++/// DRC of the file text with and without the change, when kicad-cli is here.+#[cfg(feature = "ipc")]+fn file_drc(state: &mut State, path: &std::path::Path, before: &str, after: &str) -> Value {+    use kicad_core::ipc;+    let ctx = crate::verbs_place_helpers::ipc_ctx(state);+    match (ipc::drc_snapshot(ctx.kicad_cli.as_deref(), path, before), ipc::drc_snapshot(ctx.kicad_cli.as_deref(), path, after)) {+        (Ok(b), Ok(mut a)) => {+            a["newErrors"] = json!(ipc::new_errors(&b, &a).unwrap_or_default());+            a["source"] = json!("file-snapshot");+            a+        }+        (Err(e), _) | (_, Err(e)) => json!({"available": false, "errorCode": e.code, "why": e.message}),+    }+}++#[cfg(not(feature = "ipc"))]+fn file_drc(_state: &mut State, _path: &std::path::Path, _before: &str, _after: &str) -> Value {+    json!({"available": false, "why": "this build has the ipc feature off"})+}++fn from_file(state: &mut State, args: &Value, which: Which) -> Value {+    let loaded = match pcb::load(args, true) {+        Ok(l) => l,+        Err(e) => return e,+    };+    let b = &loaded.board;+    let planned: Result<(Value, String), RoutingError> = match which {+        Which::Modify => route_tools::plan_track_changes(b, &loaded.text, args).map(|(c, t)| (json!({"changes": c}), t)),+        Which::DiffPair => {+            let pro = project_rules::read_json(&loaded.path.with_extension("kicad_pro")).ok();+            route_tools::plan_diff_pair(b, pro.as_ref(), args).map(|p| (p.public(), pcb::splice(&loaded.text, &p.blocks())))+        }+        Which::Copy => route_tools::plan_copy(b, args).map(|p| (p.public(), pcb::splice(&loaded.text, &p.blocks()))),+    };+    let (mut v, candidate) = match planned {+        Ok(x) => x,+        Err(e) => {+            let mut v = rerr(e);+            v["source"] = json!("file");+            return v;+        }+    };+    v["success"] = json!(true);+    v["dryRun"] = json!(true);+    v["mutated"] = json!(false);+    v["source"] = json!("file");+    v["boardPath"] = json!(norm(&loaded.path));+    v["revision"] = json!(kicad_core::placement::file_revision(&loaded.text));+    v["drc"] = if args.get("validate") == Some(&json!(false)) { json!({"skipped": true}) } else { file_drc(state, &loaded.path, &loaded.text, &candidate) };+    v["_hint"] = json!("Planned against the file (no PCB editor serves this board); nothing was edited. The real edit is live only: open the board in the PCB editor with the IPC API on and call again with expectedRevision from kicad_routing_state.");+    v+}++#[cfg(test)]+mod tests {+    use super::*;++    fn fixture() -> String {+        norm(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_pcb").canonicalize().unwrap())+    }++    fn call(cmd: &str, args: Value) -> Value {+        dispatch(&mut State::new(), cmd, &args).unwrap()+    }++    #[test]+    fn without_an_editor_a_dry_run_plans_on_the_file_and_an_edit_is_refused() {+        let b = fixture();+        let m = call("kicad_modify_track", json!({"filePath": b, "dryRun": true, "itemIds": ["00000000-0000-4000-8000-000000000074"], "width": 0.35}));+        assert_eq!((m["success"].as_bool(), m["source"].as_str(), m["mutated"].as_bool()), (Some(true), Some("file"), Some(false)), "{m}");+        assert_eq!(m["changes"][0]["after"]["width"], 0.35);+        assert!(m["drc"]["available"] == false || m["drc"]["source"] == "file-snapshot");+        let real = call("kicad_modify_track", json!({"filePath": b, "expectedRevision": "x", "itemIds": ["00000000-0000-4000-8000-000000000074"], "width": 0.35}));+        assert_eq!((real["success"].as_bool(), real["mutated"].as_bool()), (Some(false), Some(false)), "{real}");+        assert!(real["_hint"].as_str().unwrap().contains("live only"));+        let dp = call("kicad_route_diff_pair", json!({"filePath": b, "dryRun": true, "pair": "USB_D", "centerline": [[15, 36], [25, 36]]}));+        assert_eq!(dp["sizesFrom"], "netclass", "{dp}");+        assert_eq!(dp["skewMm"], 0.0);+        let cp = call("kicad_copy_routing", json!({"filePath": b, "dryRun": true, "source": {"minX": 9, "minY": 19, "maxX": 19, "maxY": 29}, "offset": [30, -15]}));+        assert_eq!(cp["viaCount"], 1, "{cp}");+        let bad = call("kicad_copy_routing", json!({"filePath": b, "dryRun": true, "source": {"minX": 9, "minY": 19, "maxX": 19, "maxY": 29}, "offset": [0, 0]}));+        assert_eq!(bad["errorCode"], "invalid_geometry");+    }+}
rust/crates/kicad-bridge/src/verbs_rules.rsadded+327
@@ -0,0 +1,327 @@+//! Verb group "rules": the project's netclasses and board constraints in the `.kicad_pro`+//! (`kicad_netclasses`, `kicad_board_rules`) and its custom rules in the `.kicad_dru`+//! (`kicad_custom_rules`). File edits through `kicad_core::project_rules`: atomic replace+//! with a `.adom-bak`, refused while a KiCad editor holds the project (it would write its+//! own copy back on save) unless `allowOpen:true`.++use std::path::PathBuf;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::pcb::{self, RoutingError};+use kicad_core::project_rules as rules;++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_netclasses",+        summary: "Read every netclass in the project (.kicad_pro) with its clearance, widths, via and diff pair sizes, its patterns and the board nets they match; create or update a class, assign nets by pattern, remove a class.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"projectPath\": \"C:/.../x.kicad_pro\" | \"filePath\": \"C:/.../x.kicad_pcb\", \"set\"?: {\"name\", \"clearance\"?, \"trackWidth\"?, \"viaDiameter\"?, \"viaDrill\"?, \"microviaDiameter\"?, \"microviaDrill\"?, \"diffPairWidth\"?, \"diffPairGap\"?, \"diffPairViaGap\"?, \"priority\"?} | [..], \"assign\"?: {\"netclass\", \"patterns\": [\"USB_*\", \"GND\"]}, \"unassign\"?: {\"patterns\": [..], \"netclass\"?}, \"remove\"?: \"Name\", \"dryRun\"?: false, \"allowOpen\"?: false}",+        example: "kicad_netclasses {\"filePath\":\"C:/p/b.kicad_pcb\",\"set\":{\"name\":\"USB\",\"diffPairWidth\":0.2,\"diffPairGap\":0.15},\"assign\":{\"netclass\":\"USB\",\"patterns\":[\"USB_*\"]}}",+        hint: "With no set/assign/unassign/remove it only reads: classes[] carry camelCase fields (mm), patterns[] with the nets each matches, nets[] that resolve to the class (KiCad's order: explicit assignment, then the matching pattern of lowest priority number, else Default). An edit lists changes[] and writes the .kicad_pro atomically (backup <file>.adom-bak); mutated:false when nothing changed. A new class starts from the Default class's values. dryRun:true reports the changes and writes nothing.",+        related: &["kicad_board_rules", "kicad_custom_rules", "kicad_route_diff_pair", "kicad_run_drc"],+        pitfalls: &["KiCad holds the project file in memory and writes it back on save: the edit is refused (project_open) while a KiCad lock file exists; close the project first, or pass allowOpen:true and do not save from that KiCad before reopening.", "Patterns are KiCad wildcards (* and ?), anchored. A pattern with regex syntax is kept and reported with regex:true and matches:null: the bridge does not evaluate regular expressions.", "An exact net name is a valid pattern; that is how a single net is assigned."],+    },+    Verb {+        name: "kicad_board_rules",+        summary: "Read or write the project's board constraints (Board Setup > Constraints: min clearance, track width, via, annular ring, hole sizes and spacing, copper to edge, silk, text) and the predefined track widths, via sizes and diff pairs in the .kicad_pro.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"projectPath\" | \"filePath\", \"rules\"?: {\"minClearance\"?, \"minTrackWidth\"?, \"minConnection\"?, \"minViaDiameter\"?, \"minViaAnnularWidth\"?, \"minThroughHoleDiameter\"?, \"minHoleClearance\"?, \"minHoleToHole\"?, \"minCopperEdgeClearance\"?, \"minMicroviaDiameter\"?, \"minMicroviaDrill\"?, \"minSilkClearance\"?, \"minTextHeight\"?, \"minTextThickness\"?}, \"trackWidths\"?: [0.2, 0.3], \"viaSizes\"?: [{\"diameter\", \"drill\"}], \"diffPairs\"?: [{\"width\", \"gap\", \"viaGap\"?}], \"dryRun\"?: false, \"allowOpen\"?: false}",+        example: "kicad_board_rules {\"filePath\":\"C:/p/b.kicad_pcb\",\"rules\":{\"minClearance\":0.15,\"minTrackWidth\":0.127},\"trackWidths\":[0.15,0.25,0.5]}",+        hint: "With no rules/trackWidths/viaSizes/diffPairs it only reads (constraints in camelCase mm, the lists without KiCad's hidden zero row). A list you pass replaces that list. KiCad's snake_case keys (min_clearance) are accepted too. changes[] names every value that moved; the .kicad_pro is replaced atomically with a .adom-bak. For a fab's whole rule pack use kicad_set_design_rules.",+        related: &["kicad_netclasses", "kicad_custom_rules", "kicad_set_design_rules", "kicad_run_drc"],+        pitfalls: &["Same reload rule as kicad_netclasses: refused while KiCad has the project open (project_open) unless allowOpen:true.", "Netclass values (per-class clearance and widths) are kicad_netclasses; these are the board-wide floors DRC applies to everything."],+    },+    Verb {+        name: "kicad_custom_rules",+        summary: "List, add, replace, remove and validate the named rules in the project's .kicad_dru (per-layer width or clearance, netclass pairs, any KiCad constraint).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"projectPath\" | \"filePath\" | \"rulesPath\": \"C:/.../x.kicad_dru\", \"add\"?: {\"name\", \"layer\"?: \"F.Cu\"|\"inner\"|\"outer\", \"condition\"?: \"A.NetClass == 'HV'\", \"netclassPair\"?: [\"HV\", \"LV\"], \"severity\"?, \"constraints\": [{\"type\": \"clearance\", \"min\"?: 0.5, \"opt\"?, \"max\"?}]} | {\"text\": \"(rule ...)\"} | [..], \"replace\"?: false, \"remove\"?: [\"name\"], \"validate\"?: \"<.kicad_dru text>\", \"dryRun\"?: false}",+        example: "kicad_custom_rules {\"filePath\":\"C:/p/b.kicad_pcb\",\"add\":{\"name\":\"HV to LV\",\"netclassPair\":[\"HV\",\"LV\"],\"constraints\":[{\"type\":\"clearance\",\"min\":1.0}]}}",+        hint: "No add/remove: lists rules[] (name, layer, condition, constraints, text) and problems[] (syntax KiCad would refuse). Numbers are mm (written as 0.5mm); a string with a unit (\"8mil\") is kept. The whole file must parse after the edit or nothing is written; a duplicate name is rule_exists unless replace:true. validate checks a text you pass without touching the file. Run kicad_run_drc afterwards: KiCad reads the .kicad_dru when DRC runs.",+        related: &["kicad_netclasses", "kicad_board_rules", "kicad_run_drc"],+        pitfalls: &["Conditions are KiCad expressions; the bridge checks they are quoted, not that they evaluate. DRC reports a bad expression.", "An open Board Setup > Custom Rules page writes its own text back when you press OK; close it before editing."],+    },+];++pub fn dispatch(_state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_netclasses" => project_edit(args, Which::Netclasses),+        "kicad_board_rules" => project_edit(args, Which::BoardRules),+        "kicad_custom_rules" => custom_rules(args),+        _ => return None,+    })+}++fn err(e: RoutingError) -> Value {+    let mut v = json!({"success": false, "errorCode": e.code, "error": e.message});+    for (k, val) in e.detail {+        v[k] = val;+    }+    if v.get("_hint").is_none() {+        v["_hint"] = json!("Read the project with no edit arguments first; every value is in mm.");+    }+    v+}++#[derive(Clone, Copy, PartialEq)]+enum Which {+    Netclasses,+    BoardRules,+}++fn has_any(args: &Value, keys: &[&str]) -> bool {+    keys.iter().any(|k| args.get(*k).map(|v| !v.is_null()).unwrap_or(false))+}++fn board_nets(pro: &std::path::Path) -> Vec<String> {+    let b = pro.with_extension("kicad_pcb");+    match pcb::parse_pcb(&b) {+        Ok(board) => board.nets.into_iter().map(|n| n.name).filter(|n| !n.is_empty()).collect(),+        Err(_) => Vec::new(),+    }+}++fn project_edit(args: &Value, which: Which) -> Value {+    let pro_path = match rules::project_path(args) {+        Ok(p) => p,+        Err(e) => return err(e),+    };+    let text = match std::fs::read_to_string(&pro_path) {+        Ok(t) => t,+        Err(e) => return fail("project_unreadable", format!("cannot read {}: {e}", norm(&pro_path)), "Is the file locked by another program?"),+    };+    let mut pro: Value = match serde_json::from_str(&text) {+        Ok(v) => v,+        Err(e) => return fail("project_unparsable", format!("{} is not valid JSON: {e}", norm(&pro_path)), "Open the project in KiCad and save it to rewrite the file."),+    };+    let edit_keys: &[&str] = match which {+        Which::Netclasses => &["set", "assign", "unassign", "remove"],+        Which::BoardRules => &["rules", "trackWidths", "viaSizes", "diffPairs"],+    };+    let locks = rules::open_locks(&pro_path);+    let report = |pro: &Value| match which {+        Which::Netclasses => rules::netclasses_report(pro, &board_nets(&pro_path)),+        Which::BoardRules => rules::board_rules_report(pro),+    };+    if !has_any(args, edit_keys) {+        let mut v = json!({"success": true, "mutated": false, "projectPath": norm(&pro_path), "kicadOpen": !locks.is_empty()});+        merge(&mut v, &report(&pro));+        v["_hint"] = json!(match which {+            Which::Netclasses => "Read only. Edit with set / assign / unassign / remove (several in one call land as one write).",+            Which::BoardRules => "Read only. Edit with rules / trackWidths / viaSizes / diffPairs.",+        });+        return v;+    }+    let changes = match which {+        Which::Netclasses => rules::edit_netclasses(&mut pro, args),+        Which::BoardRules => rules::edit_board_rules(&mut pro, args),+    };+    let changes = match changes {+        Ok(c) => c,+        Err(e) => return err(e),+    };+    let dry = args.get("dryRun") == Some(&json!(true));+    let mut v = json!({"success": true, "projectPath": norm(&pro_path), "changes": changes, "changeCount": changes.len(), "dryRun": dry, "mutated": false, "kicadOpen": !locks.is_empty()});+    if changes.is_empty() {+        v["_hint"] = json!("Nothing to change: the project already has these values. Nothing was written.");+        merge(&mut v, &report(&pro));+        return v;+    }+    if dry {+        v["_hint"] = json!("dryRun: the changes above were computed and nothing was written. Submit without dryRun to write them.");+        merge(&mut v, &report(&pro));+        return v;+    }+    if !locks.is_empty() && args.get("allowOpen") != Some(&json!(true)) {+        let mut f = fail("project_open", "KiCad has this project open; it keeps the project settings in memory and would overwrite this edit on its next save", "Save and close the project in KiCad (kicad_close), then repeat the call; or pass allowOpen:true and reopen the project without saving it first.");+        f["locks"] = json!(locks);+        f["mutated"] = json!(false);+        f["changes"] = json!(changes);+        return f;+    }+    match rules::write_atomic(&pro_path, &rules::project_text(&pro), Some(&text)) {+        Ok(bak) => {+            v["mutated"] = json!(true);+            v["backup"] = json!(bak);+            v["kicadMustReload"] = json!(true);+            v["_hint"] = json!(format!("Written. {}", rules::RELOAD_NOTE));+            merge(&mut v, &report(&pro));+            v+        }+        Err(e) => err(e),+    }+}++fn dru_path(args: &Value) -> Result<PathBuf, Value> {+    if let Some(p) = arg_str(args, "rulesPath") {+        return Ok(PathBuf::from(p.trim()));+    }+    rules::project_path(args).map(|p| p.with_extension("kicad_dru")).map_err(err)+}++fn custom_rules(args: &Value) -> Value {+    if let Some(t) = args.get("validate").and_then(Value::as_str) {+        return match rules::parse_dru(t) {+            Ok((ver, rs)) => json!({"success": true, "valid": true, "version": ver, "rules": rs.iter().map(rules::rule_json).collect::<Vec<_>>(), "problems": [], "mutated": false, "_hint": "The text parses; nothing was written."}),+            Err(p) => json!({"success": true, "valid": false, "problems": p, "mutated": false, "_hint": "KiCad would refuse this text: fix each problem. Nothing was written."}),+        };+    }+    let path = match dru_path(args) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let exists = path.is_file();+    let text = if exists { std::fs::read_to_string(&path).unwrap_or_default() } else { String::new() };+    if !has_any(args, &["add", "remove"]) {+        if !exists {+            return json!({"success": true, "rulesPath": norm(&path), "exists": false, "rules": [], "problems": [], "mutated": false, "_hint": "The project has no .kicad_dru yet; add creates it with (version 1)."});+        }+        return match rules::parse_dru(&text) {+            Ok((ver, rs)) => json!({"success": true, "rulesPath": norm(&path), "exists": true, "version": ver, "rules": rs.iter().map(rules::rule_json).collect::<Vec<_>>(), "ruleCount": rs.len(), "problems": [], "mutated": false, "_hint": "Read only. add / remove edit the file."}),+            Err(p) => json!({"success": true, "rulesPath": norm(&path), "exists": true, "valid": false, "rules": [], "problems": p, "mutated": false, "_hint": "The file has syntax KiCad refuses; DRC fails on it. Fix it (add with replace, remove, or rewrite it) before relying on these rules."}),+        };+    }+    let mut out = if exists { text.clone() } else { String::new() };+    let mut changes = Vec::new();+    if let Some(names) = args.get("remove") {+        let names: Vec<String> = match names {+            Value::String(s) => vec![s.clone()],+            Value::Array(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+            _ => Vec::new(),+        };+        match rules::remove_rules(&out, &names) {+            Ok((t, removed)) => {+                let missing: Vec<&String> = names.iter().filter(|n| !removed.contains(n)).collect();+                if !missing.is_empty() {+                    return fail("unknown_rule", format!("no rule named {missing:?}"), "kicad_custom_rules with no edit lists every rule name.");+                }+                out = t;+                changes.extend(removed.into_iter().map(|n| json!({"removed": n})));+            }+            Err(e) => return err(e),+        }+    }+    if let Some(add) = args.get("add") {+        let specs: Vec<Value> = match add {+            Value::Array(a) => a.clone(),+            other => vec![other.clone()],+        };+        let replace = args.get("replace") == Some(&json!(true));+        for s in specs {+            let t = match rules::rule_text(&s) {+                Ok(t) => t,+                Err(e) => return err(e),+            };+            match rules::add_rule(&out, &t, replace) {+                Ok(n) => {+                    out = n;+                    changes.push(json!({"added": rules::parse_dru(&format!("(version 1)\n{t}")).ok().and_then(|(_, r)| r.first().map(|r| r.name.clone())), "text": t}));+                }+                Err(e) => return err(e),+            }+        }+    }+    let dry = args.get("dryRun") == Some(&json!(true));+    let parsed = rules::parse_dru(&out).map(|(_, r)| r.iter().map(rules::rule_json).collect::<Vec<_>>()).unwrap_or_default();+    let mut v = json!({"success": true, "rulesPath": norm(&path), "changes": changes, "rules": parsed, "dryRun": dry, "mutated": false, "created": !exists});+    if dry {+        v["text"] = json!(out);+        v["_hint"] = json!("dryRun: the new file text is in text; nothing was written.");+        return v;+    }+    let write = if exists { rules::write_atomic(&path, &out, Some(&text)) } else { std::fs::write(&path, &out).map(|_| String::new()).map_err(|e| RoutingError::new("write_failed", e.to_string())) };+    match write {+        Ok(bak) => {+            v["mutated"] = json!(true);+            if !bak.is_empty() {+                v["backup"] = json!(bak);+            }+            let locks = rules::open_locks(&path.with_extension("kicad_pro"));+            v["kicadOpen"] = json!(!locks.is_empty());+            v["_hint"] = json!("Written. KiCad reads the .kicad_dru when DRC runs: run kicad_run_drc (or DRC in the editor) to apply it. A Board Setup dialog that was open writes its own copy back on OK.");+            v+        }+        Err(e) => err(e),+    }+}++fn merge(into: &mut Value, from: &Value) {+    if let (Some(a), Some(b)) = (into.as_object_mut(), from.as_object()) {+        for (k, v) in b {+            a.insert(k.clone(), v.clone());+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn scratch(tag: &str) -> PathBuf {+        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route");+        let d = std::env::temp_dir().join(format!("kb-rules-{tag}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        for f in ["route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.kicad_dru"] {+            std::fs::copy(src.join(f), d.join(f)).unwrap();+        }+        d+    }++    fn call(cmd: &str, args: Value) -> Value {+        dispatch(&mut State::new(), cmd, &args).unwrap()+    }++    #[test]+    fn netclasses_read_edit_and_refuse_while_open() {+        let d = scratch("nc");+        let board = norm(&d.join("route-fixture.kicad_pcb"));+        let r = call("kicad_netclasses", json!({"filePath": board}));+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(r["mutated"], false);+        assert_eq!(r["classCount"], 2);+        let dry = call("kicad_netclasses", json!({"filePath": board, "set": {"name": "HV", "clearance": 0.5}, "dryRun": true}));+        assert_eq!((dry["mutated"].as_bool(), dry["changeCount"].as_u64()), (Some(false), Some(1)));+        assert_eq!(call("kicad_netclasses", json!({"filePath": board}))["classCount"], 2, "dryRun wrote nothing");+        let w = call("kicad_netclasses", json!({"filePath": board, "set": {"name": "HV", "clearance": 0.5}, "assign": {"netclass": "HV", "patterns": ["SIG_*"]}}));+        assert_eq!(w["mutated"], true, "{w}");+        assert_eq!(w["kicadMustReload"], true);+        let hv = w["classes"].as_array().unwrap().iter().find(|c| c["name"] == "HV").unwrap().clone();+        assert_eq!(hv["nets"], json!(["SIG_A", "SIG_B"]));+        std::fs::write(d.join("~route-fixture.kicad_pcb.lck"), "").unwrap();+        let refused = call("kicad_netclasses", json!({"filePath": board, "remove": "HV"}));+        assert_eq!(refused["errorCode"], "project_open");+        assert_eq!(refused["mutated"], false);+        let forced = call("kicad_netclasses", json!({"filePath": board, "remove": "HV", "allowOpen": true}));+        assert_eq!(forced["mutated"], true);+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn board_rules_and_custom_rules_round_trip() {+        let d = scratch("br");+        let board = norm(&d.join("route-fixture.kicad_pcb"));+        let r = call("kicad_board_rules", json!({"filePath": board}));+        assert_eq!(r["constraints"]["minClearance"], 0.15, "{r}");+        let w = call("kicad_board_rules", json!({"filePath": board, "rules": {"minClearance": 0.2}, "trackWidths": [0.25]}));+        assert_eq!(w["changeCount"], 2, "{w}");+        assert_eq!(call("kicad_board_rules", json!({"filePath": board}))["trackWidths"], json!([0.25]));+        let same = call("kicad_board_rules", json!({"filePath": board, "rules": {"minClearance": 0.2}}));+        assert_eq!((same["mutated"].as_bool(), same["changeCount"].as_u64()), (Some(false), Some(0)));+        let l = call("kicad_custom_rules", json!({"filePath": board}));+        assert_eq!(l["ruleCount"], 2, "{l}");+        let a = call("kicad_custom_rules", json!({"filePath": board, "add": {"name": "Outer 8mil", "layer": "outer", "constraints": [{"type": "track_width", "min": "8mil"}]}}));+        assert_eq!(a["mutated"], true, "{a}");+        assert_eq!(a["rules"].as_array().unwrap().len(), 3);+        assert_eq!(call("kicad_custom_rules", json!({"filePath": board, "add": {"name": "Outer 8mil", "constraints": [{"type": "track_width", "min": 0.2}]}}))["errorCode"], "rule_exists");+        let rm = call("kicad_custom_rules", json!({"filePath": board, "remove": ["HV clearance"]}));+        assert_eq!(rm["rules"].as_array().unwrap().len(), 2);+        assert_eq!(call("kicad_custom_rules", json!({"filePath": board, "remove": ["nope"]}))["errorCode"], "unknown_rule");+        let bad = call("kicad_custom_rules", json!({"validate": "(version 1)\n(rule \"x\" (constraint nope (min 1mm)))"}));+        assert_eq!(bad["valid"], false);+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-bridge/src/verbs_sch_edit.rsadded+1356
@@ -0,0 +1,1356 @@+//! Verb group "sch_edit": the schematic verbs that close the Konnect gap (docs/konnect-gap.md,+//! "Schematic: components and wiring"): listing components, pins and items; rotate, region+//! move, annotate, symbol replace and library refresh, field reset; wire delete and split,+//! label edit, junctions, stubs to a net, pin to pin routes, text, buses; and kicad_sch_batch,+//! which applies any list of the edit operations in one read and one atomic write.+//!+//! The edit verbs share the pipeline in `verbs_schematic` (ERC before and after, `.bak`,+//! atomic write, dryRun, reload hint); the geometry lives in `kicad_core::sch_edit`. The+//! extended kicad_sch_move and kicad_sch_no_connect operations are here too and dispatched+//! from `verbs_schematic`.++use std::collections::BTreeSet;+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs_schematic::{self as vs, edit, file_name, library_block, point, Ctx, Done, Op};+use kicad_core::sch_edit::{self as se, describe_pt, label_rotation_for, l_route, project_sheets, pt_json, rp, same, OpError, Pt, SymXf};+use kicad_core::schematic::{self, LabelKind, PlaceSymbol, Schematic};++const P_RELOAD: &str = "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit";+const P_BAK: &str = "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it); dryRun:true reports the plan and writes nothing";+const P_ERC: &str = "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change; with no kicad-cli erc.ran is false and the file is unvalidated";+const P_GRID: &str = "KiCad joins items only where coordinates match exactly; keep points on the 1.27 mm grid (pin ends from kicad_sch_pins are already on it)";+const P_READ: &str = "reads the file on disk: unsaved changes in an open Schematic Editor are not seen; save in KiCad first";++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_sch_components",+        summary: "List the placed symbols of a .kicad_sch: reference, value, footprint, lib id, every unit with position, rotation and mirror, and every field.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\"?: \"U1\" | \"references\"?: [\"U1\", \"R5\"]}",+        example: "kicad_sch_components {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"U1\"}",+        hint: "One entry per reference; units lists every placed unit (unit, uuid, at, rotation, mirror, inBom, onBoard, dnp) and fields every property with its value, position and hidden flag. unitCount comes from the embedded library symbol. Read only.",+        related: &["kicad_sch_pins", "kicad_sch_items", "kicad_sch_set_property", "kicad_sch_move", "kicad_sch_rotate"],+        pitfalls: &[P_READ, "one sheet only: a hierarchical design has one file per sheet (kicad_sch_items {kinds:[\"sheets\"]} names them)", "power symbols (#PWR..) and flags (#FLG..) are symbols too and are listed"],+    },+    Verb {+        name: "kicad_sch_pins",+        summary: "Every pin's absolute (x, y) on a .kicad_sch for the given references (rotation and mirror applied), with number, name, electrical type, outward direction, and the wires, labels and net touching it.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\"?: \"U1\" | \"references\"?: [\"U1\", \"R5\"] (default every symbol), \"connections\"?: true}",+        example: "kicad_sch_pins {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"references\":[\"U1\",\"R5\"]}",+        hint: "at is the pin's connection point (where a wire or label must land); direction is the side the pin leaves the body (right, left, up, down as seen). With connections (default true) each pin carries touching (wires ending or passing there, labels, noConnect, junction), nets (label and power names reached through the wires) and connectedPins (other Ref.Pin on the same copper). Use the at values with kicad_sch_wire, or let kicad_sch_connect_pins and kicad_sch_connect_to_net do it.",+        related: &["kicad_sch_connect_pins", "kicad_sch_connect_to_net", "kicad_sch_no_connect", "kicad_sch_components", "kicad_netlist"],+        pitfalls: &[P_READ, "nets here come from this sheet's wires and labels only; kicad_netlist gives the project-wide netlist from kicad-cli", "hidden pins (power pins of some parts) are listed with hidden:true and connect by name, not by wire"],+    },+    Verb {+        name: "kicad_sch_items",+        summary: "List the wiring items of a .kicad_sch with uuids and coordinates: wires, buses, bus entries, labels (local, global, hierarchical), power symbols, junctions, no-connects, texts, sheets.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"kinds\"?: [\"wires\", \"buses\", \"busEntries\", \"labels\", \"powerSymbols\", \"junctions\", \"noConnects\", \"texts\", \"sheets\"]}",+        example: "kicad_sch_items {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"kinds\":[\"wires\",\"labels\"]}",+        hint: "The uuids feed kicad_sch_delete_wire, kicad_sch_edit_label, kicad_sch_junction {remove:true} and kicad_sch_delete. counts sums each list. Read only.",+        related: &["kicad_sch_components", "kicad_sch_delete_wire", "kicad_sch_edit_label", "kicad_sch_delete"],+        pitfalls: &[P_READ, "a wire is one straight segment: a polyline drawn in the editor is several wires sharing ends"],+    },+    Verb {+        name: "kicad_sch_rotate",+        summary: "Rotate a placed symbol to an absolute angle (0, 90, 180, 270); every unit turns by the same delta about its own anchor and the fields turn with it (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\", \"rotation\": 90, \"dryRun\"?: false}",+        example: "kicad_sch_rotate {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"R5\",\"rotation\":90}",+        hint: "rotation is the stored angle KiCad shows in Symbol Properties (counterclockwise as seen). A mirrored symbol keeps its mirror. detachedPins lists pins that had a wire end on them before the turn and moved off it: redraw those (kicad_sch_delete_wire, then kicad_sch_connect_pins or kicad_sch_wire).",+        related: &["kicad_sch_move", "kicad_sch_pins", "kicad_sch_reset_fields", "kicad_sch_components"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "wires do not follow a rotation; check detachedPins", "fields keep their text angle; kicad_sch_reset_fields puts Reference and Value back at the library's offsets afterwards"],+    },+    Verb {+        name: "kicad_sch_move_region",+        summary: "Move everything inside a box by (dx, dy): symbols by their anchor, wire ends inside the box (wires crossing the edge stretch), labels, junctions, no-connects, texts, bus entries, sheets (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"box\": [x1_mm, y1_mm, x2_mm, y2_mm], \"by\": [dx_mm, dy_mm], \"wires\"?: true, \"labels\"?: true, \"junctions\"?: true, \"dryRun\"?: false}",+        example: "kicad_sch_move_region {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"box\":[90,40,160,120],\"by\":[25.4,0]}",+        hint: "Units are taken one by one by their anchor, so a multi-unit part whose units sit on both sides of the edge splits up. wires:false leaves wires, buses and bus entries alone; labels:false leaves labels and texts; junctions:false leaves junctions and no-connect flags. moved lists the uuids per kind.",+        related: &["kicad_sch_move", "kicad_sch_items", "kicad_sch_batch"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "a wire with one end inside the box stretches diagonally when dy and dx are both non-zero; move by a pure dx or dy to keep it straight", "keep the delta on the 1.27 mm grid"],+    },+    Verb {+        name: "kicad_sch_annotate",+        summary: "Number every X? reference on a .kicad_sch the way eeschema's Annotate does: the first free number per prefix across the project's sheets, in position order, units of one multi-unit part sharing a designator (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"order\"?: \"x\"|\"y\", \"dryRun\"?: false}",+        example: "kicad_sch_annotate {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"dryRun\":true}",+        hint: "order x (default) sorts by x then y, y by y then x. References already numbered on this sheet and on every sheet of the project (the root <project>.kicad_sch next to the .kicad_pro and the sheets it reaches) are kept and their numbers skipped. Unannotated units of a multi-unit part (U? unit 1, U? unit 2, same libId and value) are packed into one designator. plan lists every from/to with unit and position; dryRun shows it without writing. Power symbols get #PWR01 style numbers.",+        related: &["kicad_sch_place_symbol", "kicad_sch_components", "kicad_sch_set_property"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "only X? references are touched; nothing already numbered is renumbered", "one sheet is written per call; annotate each sheet file in turn (numbers already used elsewhere in the project are skipped)", "a sheet used by several sheet instances gets the same reference in every instance record; annotate such designs in eeschema"],+    },+    Verb {+        name: "kicad_sch_replace_symbol",+        summary: "Change the library symbol of a placed part (lib id), keeping its units, fields and position; the new lib_symbols entry is copied from the library (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\", \"libId\": \"Device:R_Small\", \"libraryPath\"?: \"C:/.../Lib.kicad_sym\", \"force\"?: false, \"dryRun\"?: false}",+        example: "kicad_sch_replace_symbol {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"R5\",\"libId\":\"Device:R_Small\"}",+        hint: "Refuses with pins_lost when the new symbol lacks a pin number the placed units carry (wires there would dangle); force:true replaces anyway. Every unit is repointed; pin entries follow the new symbol (pinsAdded, pinsRemoved). The old lib_symbols entry is dropped when nothing else uses it. Fields, including Value and Footprint, stay as they were: set them with kicad_sch_set_property.",+        related: &["kicad_sch_update_from_library", "kicad_sch_set_property", "kicad_sch_place_symbol", "kicad_list_symbols"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "pins are matched by number; a new symbol whose pins sit elsewhere leaves wires off its pins (kicad_sch_pins shows the new points)", "libId resolves through the project then the global sym-lib-table; pass libraryPath for a library file that is not installed"],+    },+    Verb {+        name: "kicad_sch_update_from_library",+        summary: "Re-embed the lib_symbols definitions of a .kicad_sch from their libraries (all, or the listed lib ids) and report which changed (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 180,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"libIds\"?: [\"Device:R\", ...], \"libraryPath\"?: \"C:/.../Lib.kicad_sym\", \"force\"?: false, \"dryRun\"?: false}",+        example: "kicad_sch_update_from_library {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"dryRun\":true}",+        hint: "Each embedded entry is resolved like kicad_sch_place_symbol does (project sym-lib-table, then global, or libraryPath for all), flattened, and compared token by token: changed lists the ones replaced with pinsAdded/pinsRemoved, unchanged the ones that already match, notFound the ones no library has (left as they are), refused the ones whose new version drops a placed pin (force:true updates them anyway). The placed units' pin entries are resynced.",+        related: &["kicad_sch_replace_symbol", "kicad_sch_components", "kicad_run_erc"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "a changed pin position moves the pin off its wire; run kicad_sch_pins or ERC after an update that reports changes", "libraryPath applies to every entry in the call; pass libIds with it"],+    },+    Verb {+        name: "kicad_sch_reset_fields",+        summary: "Put a symbol's Reference and Value (or the named fields) back at the library symbol's offsets, angle and justify, through the unit's rotation and mirror (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\" | \"references\": [\"R5\", \"C3\"] | \"all\": true, \"fields\"?: [\"Reference\", \"Value\"], \"dryRun\"?: false}",+        example: "kicad_sch_reset_fields {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"references\":[\"R5\",\"C3\"]}",+        hint: "Every unit's fields are reset; font and hide stay. reset lists unit:field pairs. all:true resets every symbol on the sheet.",+        related: &["kicad_sch_rotate", "kicad_sch_move", "kicad_sch_set_property"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "the library offsets are what the symbol editor drew; eeschema's Autoplace Fields may put them elsewhere"],+    },+    Verb {+        name: "kicad_sch_delete_wire",+        summary: "Delete wires (or bus segments) by uuid, by their two endpoints, or every wire through a point, and prune junction dots that no longer join three or more items (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"uuid\": \"...\" | \"uuids\": [\"...\"] | \"from\": [x, y], \"to\": [x, y] | \"at\": [x, y], \"dryRun\"?: false}",+        example: "kicad_sch_delete_wire {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"from\":[100.33,60.96],\"to\":[120.65,60.96]}",+        hint: "deleted lists each wire's uuid and ends; junctionsPruned the dots removed because KiCad's rule no longer wants them (fewer than three exits). from/to matches a wire with exactly those ends in either order; at removes every wire passing through the point.",+        related: &["kicad_sch_items", "kicad_sch_wire", "kicad_sch_split_wire", "kicad_sch_junction"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "a polyline is several wires: from/to names one segment, not a whole route"],+    },+    Verb {+        name: "kicad_sch_split_wire",+        summary: "Split the wire passing through a point into two wires there, and put a junction dot at the cut (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"at\": [x_mm, y_mm], \"uuid\"?: \"...\" (when several wires pass there), \"junction\"?: true, \"dryRun\"?: false}",+        example: "kicad_sch_split_wire {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"at\":[110.49,60.96]}",+        hint: "The first half keeps the wire's uuid; the second gets a new one (uuids). junction:false skips the dot. Splitting lets a new branch end on a wire end instead of a wire's interior.",+        related: &["kicad_sch_delete_wire", "kicad_sch_junction", "kicad_sch_wire"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "the point must lie on the wire and not at either end (not_on_wire otherwise)", "a dot where only two wires meet is legal but redundant; kicad_sch_delete_wire prunes such dots when it runs"],+    },+    Verb {+        name: "kicad_sch_edit_label",+        summary: "Edit net labels on a .kicad_sch: rotate (with the justify KiCad pairs with the angle), move to a point or by a delta, rename, or move every label of a net by (dx, dy) (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"uuid\": \"...\" | \"text\": \"VOUT\", \"at\"?: [x, y] (with text) | \"net\": \"VOUT\" (every label of it), \"rotation\"?: 0|90|180|270, \"to\"?: [x, y], \"by\"?: [dx, dy], \"rename\"?: \"VOUT2\", \"dryRun\"?: false}",+        example: "kicad_sch_edit_label {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"net\":\"VOUT\",\"by\":[0,-2.54]}",+        hint: "Pick labels by uuid, by text (every label with it, or the one at at), or net (same as text, all of them). Then any of rotation, to (one label only), by, rename. Local labels at 0 and 90 justify left bottom, at 180 and 270 right bottom; global and hierarchical left or right. edited lists the uuids.",+        related: &["kicad_sch_label", "kicad_sch_items", "kicad_sch_delete"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "a label connects only where its point sits on a wire or pin; moving it off the wire disconnects it", "rename changes the net: every other label with the old name stays on the old net"],+    },+    Verb {+        name: "kicad_sch_junction",+        summary: "Add junction dots at one or many points, or remove them by uuid or point (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"at\": [x, y] | \"points\": [[x, y], ...] | \"uuid\": \"...\" (remove), \"remove\"?: false, \"dryRun\"?: false}",+        example: "kicad_sch_junction {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"points\":[[110.49,60.96]]}",+        hint: "A point that already has a dot is skipped. notNeeded lists added dots KiCad's rule would not ask for (fewer than three exits there); they are legal but join nothing extra. The wiring verbs add the dots they need on their own.",+        related: &["kicad_sch_wire", "kicad_sch_split_wire", "kicad_sch_delete_wire", "kicad_sch_items"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, P_GRID],+    },+    Verb {+        name: "kicad_sch_connect_to_net",+        summary: "Connect one or many pins to a named net: a short stub wire away from the body plus a net label (local, global, hierarchical) or a power symbol at its end (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"pin\": \"U1.3\" | \"pins\": [\"U1.3\", \"R5.1\"], \"net\": \"VOUT\", \"kind\"?: \"local\"|\"global\"|\"hierarchical\"|\"power\", \"length\"?: 2.54, \"shape\"?: \"input|output|bidirectional|tri_state|passive\", \"libraryPath\"?: \"C:/.../power.kicad_sym\", \"dryRun\"?: false}",+        example: "kicad_sch_connect_to_net {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"pins\":[\"U1.4\",\"C1.2\"],\"net\":\"GND\",\"kind\":\"power\"}",+        hint: "The stub leaves each pin in its outward direction (the side the pin is on), length mm long (default 2.54, keep it a multiple of 1.27). The label reads away from the body; a power symbol (power:<net>, next free #PWRnn) is turned so its body points away from the pin. connected lists pin, stub uuid, label or power uuid and the end point per pin. A pin already wired still gets the stub (alreadyWired:true): pass only open pins.",+        related: &["kicad_sch_pins", "kicad_sch_label", "kicad_sch_connect_pins", "kicad_sch_no_connect"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "kind power needs the power library resolvable (KiCad installed, or libraryPath to power.kicad_sym)", "two pins facing each other closer than two stubs get overlapping stubs; use kicad_sch_connect_pins between them instead"],+    },+    Verb {+        name: "kicad_sch_connect_pins",+        summary: "Wire two pins by Ref.Pin with a horizontal and vertical (L-shaped) route, adding junction dots where the route makes a T with existing wires (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"from\": \"U1.1\", \"to\": \"R5.2\", \"first\"?: \"h\"|\"v\", \"via\"?: [[x, y], ...] | \"pairs\": [[\"U1.1\", \"R5.2\"], ...], \"dryRun\"?: false}",+        example: "kicad_sch_connect_pins {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"from\":\"U1.1\",\"to\":\"R2.1\"}",+        hint: "Pins are matched by number, then name. Aligned pins get one straight wire; otherwise the route leaves from along the axis it points (first forces h or v), turns once, and ends on to. via adds waypoints (each leg is routed the same way). pairs wires many in one write. routes lists the points and wire uuids; junctionsAdded the dots placed.",+        related: &["kicad_sch_pins", "kicad_sch_wire", "kicad_sch_connect_to_net", "kicad_sch_delete_wire"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "the route does not avoid symbol bodies or other wires; a leg running along another wire merges the nets. Check with kicad_sch_pins (connectedPins) or ERC, and use via or first to steer"],+    },+    Verb {+        name: "kicad_sch_text",+        summary: "Add free text (a note, a title, a design comment) to a .kicad_sch with size, angle, justify, bold and italic (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"text\": \"Note\", \"at\": [x, y], \"rotation\"?: 0|90|180|270, \"size\"?: 1.27, \"justify\"?: \"left bottom\" | [\"left\", \"bottom\"], \"bold\"?: false, \"italic\"?: false, \"dryRun\"?: false}",+        example: "kicad_sch_text {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"text\":\"USB input stage\",\"at\":[50.8,30.48],\"size\":2.54,\"bold\":true}",+        hint: "Writes (text ..) the way eeschema does; justify defaults to left bottom. Text has no electrical meaning: use kicad_sch_label for net names.",+        related: &["kicad_sch_label", "kicad_sch_items", "kicad_sch_delete"],+        pitfalls: &[P_RELOAD, P_BAK, "newlines in text are written as \\n and shown as line breaks"],+    },+    Verb {+        name: "kicad_sch_bus",+        summary: "Draw a bus polyline on a .kicad_sch (one bus segment per pair of points), optionally with a bus label such as D[0..7] at its first point (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"points\": [[x, y], [x, y], ...], \"label\"?: \"D[0..7]\", \"dryRun\"?: false}",+        example: "kicad_sch_bus {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"points\":[[203.2,50.8],[203.2,101.6]],\"label\":\"D[0..7]\"}",+        hint: "Members join a bus by name: the bus label (D[0..7] or a group {SDA SCL}) says which nets it carries, and each member wire needs its own label (D0, D1 ...). kicad_sch_connect_to_bus does stubs, entries and member labels for a set of pins.",+        related: &["kicad_sch_bus_entry", "kicad_sch_connect_to_bus", "kicad_sch_delete_wire", "kicad_sch_items"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "a bus without a bus label carries nothing; ERC reports the members as unconnected"],+    },+    Verb {+        name: "kicad_sch_bus_entry",+        summary: "Add a 45-degree bus entry (the short diagonal between a member wire and a bus) at a point (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"at\": [x, y] (the wire end), \"direction\"?: \"up-right\"|\"down-right\"|\"up-left\"|\"down-left\" | \"size\"?: [2.54, -2.54], \"dryRun\"?: false}",+        example: "kicad_sch_bus_entry {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"at\":[200.66,63.5],\"direction\":\"up-right\"}",+        hint: "The entry runs from at to at+size (default down-right, 2.54 mm each way); end is the bus side and onBus says whether it lands on a bus segment.",+        related: &["kicad_sch_bus", "kicad_sch_connect_to_bus", "kicad_sch_items"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "the bus side must touch a bus and the other end a wire end, exactly, or ERC reports the entry unconnected"],+    },+    Verb {+        name: "kicad_sch_connect_to_bus",+        summary: "Fan a set of pins onto a bus: per pin a wire to just short of the bus, a 45-degree bus entry onto it, and a member label (D0, D1 ...) on the wire (edits the file on disk).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"pins\": [\"U1.1\", \"U1.2\"], \"bus\"?: \"<bus uuid>\" (default the only bus on the sheet), \"members\"?: [\"D0\", \"D1\"] | \"prefix\"?: \"D\", \"start\"?: 0, \"entry\"?: \"up\"|\"down\" (vertical bus) | \"left\"|\"right\" (horizontal), \"busLabel\"?: \"D[0..7]\", \"dryRun\"?: false}",+        example: "kicad_sch_connect_to_bus {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"pins\":[\"U1.1\",\"U1.7\"],\"prefix\":\"D\",\"busLabel\":\"D[0..1]\"}",+        hint: "Each pin's wire runs straight across to the bus (horizontal for a vertical bus, vertical for a horizontal one), stops 2.54 mm short, and a bus entry finishes the hop (entry picks the lean, default up or right). Members name the nets: members lists them in pin order, or prefix plus start counts up. busLabel puts the bus label on the bus. Refuses entry_off_bus when an entry would miss the bus segment: extend the bus first (kicad_sch_bus).",+        related: &["kicad_sch_bus", "kicad_sch_bus_entry", "kicad_sch_pins", "kicad_sch_connect_to_net"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "pins should face the bus; a pin facing away gets a wire across its own body", "two pins at the same height on a vertical bus share an entry point and collide: offset them or use different entry leans"],+    },+    Verb {+        name: "kicad_sch_batch",+        summary: "Apply a list of schematic edit operations to one .kicad_sch in ONE read and ONE atomic write: all or nothing, one ERC before and after, dryRun reports the plan.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"ops\": [{\"op\": \"place_symbol\"|\"wire\"|\"label\"|\"move\"|\"rotate\"|\"delete\"|\"set_property\"|\"no_connect\"|\"move_region\"|\"annotate\"|\"replace_symbol\"|\"update_from_library\"|\"reset_fields\"|\"delete_wire\"|\"split_wire\"|\"edit_label\"|\"junction\"|\"connect_to_net\"|\"connect_pins\"|\"text\"|\"bus\"|\"bus_entry\"|\"connect_to_bus\", ...that verb's arguments}], \"dryRun\"?: false}",+        example: "kicad_sch_batch {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"ops\":[{\"op\":\"place_symbol\",\"libId\":\"Device:R\",\"reference\":\"R9\",\"at\":[60.96,50.8]},{\"op\":\"connect_pins\",\"from\":\"R9.2\",\"to\":\"R1.1\"},{\"op\":\"connect_to_net\",\"pin\":\"R9.1\",\"net\":\"VIN\"}]}",+        hint: "Each op takes the arguments of the kicad_sch_<op> verb (the kicad_sch_ prefix is optional) and sees the sheet as the earlier ops left it, so a symbol placed in op 0 can be wired in op 1. A failing op aborts the whole batch with failedOp (its index) and nothing is written. results lists each op's output and uuid.",+        related: &["kicad_sch_place_symbol", "kicad_sch_connect_pins", "kicad_sch_connect_to_net", "kicad_sch_delete"],+        pitfalls: &[P_RELOAD, P_BAK, P_ERC, "ops cannot nest a batch", "dryRun applies to the whole batch, not per op"],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    let out = match command {+        "kicad_sch_components" => read(args, components),+        "kicad_sch_pins" => read(args, pins),+        "kicad_sch_items" => read(args, items),+        "kicad_sch_batch" => edit(state, args, batch),+        _ if VERBS.iter().any(|v| v.name == command) => edit(state, args, op_for(command.strip_prefix("kicad_sch_")?)?),+        _ => return None,+    };+    Some(out)+}++/// The edit operation behind `kicad_sch_<name>` (also the batch op names).+pub(crate) fn op_for(name: &str) -> Option<Op> {+    Some(match name {+        "place_symbol" => vs::place_symbol,+        "wire" => vs::wire,+        "label" => vs::label,+        "delete" => vs::delete,+        "set_property" => vs::set_property,+        "move" => move_symbol,+        "no_connect" => no_connect,+        "rotate" => rotate,+        "move_region" => move_region,+        "annotate" => annotate,+        "replace_symbol" => replace_symbol,+        "update_from_library" => update_from_library,+        "reset_fields" => reset_fields,+        "delete_wire" => delete_wire,+        "split_wire" => split_wire,+        "edit_label" => edit_label,+        "junction" => junction,+        "connect_to_net" => connect_to_net,+        "connect_pins" => connect_pins,+        "text" => text,+        "bus" => bus,+        "bus_entry" => bus_entry,+        "connect_to_bus" => connect_to_bus,+        _ => return None,+    })+}++// ---------------------------------------------------------------------------+// Shared helpers+// ---------------------------------------------------------------------------++fn oe(e: OpError, hint: &str) -> Value {+    fail(e.code, e.msg, hint)+}++fn pt_of(v: &Value) -> Option<Pt> {+    let a = v.as_array().filter(|a| a.len() == 2)?;+    Some((a[0].as_f64()?, a[1].as_f64()?))+}++fn opt_point(args: &Value, key: &str) -> Result<Option<Pt>, Value> {+    match args.get(key) {+        None | Some(Value::Null) => Ok(None),+        Some(_) => point(args, key).map(Some),+    }+}++fn points_arg(args: &Value, key: &str) -> Result<Vec<Pt>, Value> {+    let Some(v) = args.get(key) else { return Ok(vec![]) };+    let arr = v.as_array().ok_or_else(|| fail("bad_arg", format!("{key} must be [[x_mm, y_mm], ...]"), "A list of points, each two numbers in millimetres."))?;+    arr.iter().enumerate().map(|(i, p)| pt_of(p).ok_or_else(|| fail("bad_arg", format!("{key}[{i}] is not [x_mm, y_mm]"), "Every point is two numbers in millimetres."))).collect()+}++/// A string or a list of strings under any of `keys`.+fn str_list(args: &Value, keys: &[&str]) -> Vec<String> {+    for k in keys {+        match args.get(*k) {+            Some(Value::String(s)) if !s.trim().is_empty() => return vec![s.trim().to_string()],+            Some(Value::Array(a)) => return a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),+            _ => {}+        }+    }+    vec![]+}++fn flag(args: &Value, key: &str, default: bool) -> bool {+    args.get(key).and_then(Value::as_bool).unwrap_or(default)+}++fn reference_arg(args: &Value, what: &str) -> Result<String, Value> {+    arg_any(args, &["reference", "ref"]).map(|s| s.trim().to_string()).ok_or_else(|| fail("missing_arg", "reference is required", format!("Pass the designator of the symbol to {what}, e.g. R5 (kicad_sch_components lists them).")))+}++fn delta_arg(args: &Value) -> Result<Pt, Value> {+    if args.get("by").is_some() {+        return point(args, "by");+    }+    match (args.get("dx").and_then(Value::as_f64), args.get("dy").and_then(Value::as_f64)) {+        (None, None) => Err(fail("missing_arg", "by is required", "Pass by as [dx_mm, dy_mm] (or dx and dy).")),+        (dx, dy) => Ok((dx.unwrap_or(0.0), dy.unwrap_or(0.0))),+    }+}++fn pin_json(p: &se::PinInfo) -> Value {+    json!({"pin": p.spec(), "at": pt_json(p.at), "direction": se::dir_name(p.out)})+}++fn junction_json(j: &[(String, Pt)]) -> Vec<Value> {+    j.iter().map(|(u, p)| json!({"uuid": u, "at": pt_json(*p)})).collect()+}++/// Validated `.kicad_sch` path from the args.+fn sheet_path(args: &Value) -> Result<PathBuf, Value> {+    let Some(p) = arg_any(args, &["filePath", "schematicPath", "path"]) else {+        return Err(fail("missing_arg", "filePath is required", "Pass the absolute path of the .kicad_sch to read."));+    };+    let path = PathBuf::from(p.trim());+    if !path.to_string_lossy().to_ascii_lowercase().ends_with(".kicad_sch") {+        return Err(fail("wrong_file_type", format!("{} is not a .kicad_sch", norm(&path)), "These verbs read schematic sheets only."));+    }+    if !path.is_file() {+        return Err(fail("file_not_found", format!("{} does not exist", norm(&path)), "Check the path; the sheet must already exist."));+    }+    Ok(path)+}++type ReadFn = fn(&Schematic, &Path, &Value) -> Result<Value, Value>;++/// Read-only verbs: parse, answer, never write.+fn read(args: &Value, f: ReadFn) -> Value {+    let path = match sheet_path(args) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let sch = match Schematic::load(&path) {+        Ok(s) => s,+        Err(e) => return fail("parse_failed", format!("cannot parse {}: {e}", norm(&path)), "The file is not a schematic this bridge can read; open it in KiCad and save it again, or run kicad_lint_schematic."),+    };+    match f(&sch, &path, args) {+        Ok(mut v) => {+            v["success"] = json!(true);+            v["path"] = json!(norm(&path));+            v+        }+        Err(v) => v,+    }+}++// ---------------------------------------------------------------------------+// Read-only verbs+// ---------------------------------------------------------------------------++fn components(sch: &Schematic, path: &Path, args: &Value) -> Result<Value, Value> {+    let filter = str_list(args, &["references", "reference", "ref"]);+    let list = sch.components(if filter.is_empty() { None } else { Some(&filter) });+    if !filter.is_empty() {+        let found: BTreeSet<String> = list.iter().filter_map(|c| c["reference"].as_str().map(str::to_string)).collect();+        let missing: Vec<&String> = filter.iter().filter(|r| !found.contains(*r)).collect();+        if list.is_empty() {+            return Err(fail("not_found", format!("no symbol {} on {}", filter.join(", "), file_name(path)), "kicad_sch_components with no reference lists every symbol on the sheet."));+        }+        if !missing.is_empty() {+            return Ok(json!({"output": format!("{} of {} reference(s) found on {}; missing {:?}", list.len(), filter.len(), file_name(path), missing), "count": list.len(), "components": list, "missing": missing, "_hint": "Some references are not on this sheet; a hierarchical design keeps each sheet's symbols in its own file."}));+        }+    }+    let units: usize = list.iter().map(|c| c["units"].as_array().map_or(0, |u| u.len())).sum();+    Ok(json!({+        "output": format!("{} symbol(s), {} placed unit(s) on {}", list.len(), units, file_name(path)),+        "count": list.len(),+        "components": list,+        "_hint": "kicad_sch_pins gives the pin points of any of these; kicad_sch_set_property, kicad_sch_move and kicad_sch_rotate edit them.",+        "_next": ["kicad_sch_pins", "kicad_sch_set_property", "kicad_sch_move"],+    }))+}++fn pins(sch: &Schematic, path: &Path, args: &Value) -> Result<Value, Value> {+    let mut refs = str_list(args, &["references", "reference", "ref"]);+    if refs.is_empty() {+        let mut seen = BTreeSet::new();+        refs = sch.symbols().into_iter().map(|s| s.reference).filter(|r| seen.insert(r.clone())).collect();+    }+    let connections = flag(args, "connections", true);+    let all = if connections { sch.all_pins() } else { vec![] };+    let mut out = Vec::new();+    for r in &refs {+        let ps = sch.pins_of(r).map_err(|e| oe(e, "kicad_sch_components lists the references on this sheet."))?;+        let list: Vec<Value> = ps+            .iter()+            .map(|p| {+                let mut v = json!({+                    "number": p.number, "name": p.name, "type": p.etype, "unit": p.unit,+                    "at": pt_json(p.at), "direction": se::dir_name(p.out), "length": p.length, "hidden": p.hidden,+                });+                if connections {+                    let (nets, others) = sch.net_at(p.at, &all);+                    v["touching"] = sch.touching(p.at);+                    v["nets"] = json!(nets);+                    v["connectedPins"] = json!(others.into_iter().filter(|o| *o != p.spec()).collect::<Vec<_>>());+                }+                v+            })+            .collect();+        out.push(json!({"reference": r, "libId": ps.first().map(|p| p.lib_id.clone()), "pins": list}));+    }+    let n: usize = out.iter().map(|c| c["pins"].as_array().map_or(0, |p| p.len())).sum();+    Ok(json!({+        "output": format!("{n} pin(s) on {} symbol(s) in {}", out.len(), file_name(path)),+        "symbols": out,+        "_hint": "at is where a wire end or label must land exactly. kicad_sch_connect_pins and kicad_sch_connect_to_net use these points for you.",+        "_next": ["kicad_sch_connect_pins", "kicad_sch_connect_to_net", "kicad_sch_no_connect"],+    }))+}++fn items(sch: &Schematic, path: &Path, args: &Value) -> Result<Value, Value> {+    let kinds = str_list(args, &["kinds", "kind"]);+    let known = ["wires", "buses", "busEntries", "labels", "powerSymbols", "junctions", "noConnects", "texts", "sheets"];+    if let Some(bad) = kinds.iter().find(|k| !known.contains(&k.as_str())) {+        return Err(fail("bad_arg", format!("unknown kind {bad}"), format!("kinds takes any of {}.", known.join(", "))));+    }+    let mut v = sch.items(if kinds.is_empty() { None } else { Some(&kinds) });+    let counts: serde_json::Map<String, Value> = v.as_object().unwrap().iter().map(|(k, l)| (k.clone(), json!(l.as_array().map_or(0, |a| a.len())))).collect();+    let summary: Vec<String> = counts.iter().map(|(k, n)| format!("{n} {k}")).collect();+    v["output"] = json!(format!("{} in {}", summary.join(", "), file_name(path)));+    v["counts"] = Value::Object(counts);+    v["_hint"] = json!("uuids feed kicad_sch_delete_wire, kicad_sch_edit_label, kicad_sch_junction {remove:true} and kicad_sch_delete.");+    Ok(v)+}++// ---------------------------------------------------------------------------+// Symbols+// ---------------------------------------------------------------------------++/// kicad_sch_move: every unit by one delta, optional wire stretch.+pub(crate) fn move_symbol(ctx: &mut Ctx) -> Result<Done, Value> {+    let reference = reference_arg(ctx.args, "move")?;+    let idxs = ctx.sch.unit_indices(&reference);+    if idxs.is_empty() {+        return Err(fail("not_found", format!("no symbol with reference {reference} on this sheet"), "kicad_sch_components lists the references on the sheet."));+    }+    let want_unit = ctx.args.get("unit").and_then(Value::as_u64).map(|u| u as u32);+    let anchor_idx = match want_unit {+        Some(u) => *idxs.iter().find(|&&i| ctx.sch.top()[i].child("unit").and_then(|n| n.num(0)).unwrap_or(1.0) as u32 == u).ok_or_else(|| fail("not_found", format!("{reference} has no placed unit {u}"), "kicad_sch_components lists the placed units."))?,+        None => idxs[0],+    };+    let xf = SymXf::of(&ctx.sch.top()[anchor_idx]);+    let from = (xf.x, xf.y);+    let d = if ctx.args.get("to").is_some() {+        let to = point(ctx.args, "to")?;+        (to.0 - from.0, to.1 - from.1)+    } else if ctx.args.get("by").is_some() || ctx.args.get("dx").is_some() || ctx.args.get("dy").is_some() {+        delta_arg(ctx.args)?+    } else {+        return Err(fail("missing_arg", "to or by is required", "Pass to as [x_mm, y_mm] (where the anchor lands) or by as [dx_mm, dy_mm]."));+    };+    let stretch = flag(ctx.args, "stretchWires", false) || flag(ctx.args, "stretch", false);+    let (uuids, st) = ctx.sch.move_units(&reference, d, stretch).map_err(|e| oe(e, "kicad_sch_components lists the references on the sheet."))?;+    let to = rp((from.0 + d.0, from.1 + d.1));+    let wl = if stretch { format!("; {} wire(s) stretched, {} added", st.wires_moved.len(), st.wires_added.len()) } else { String::new() };+    Ok(Done {+        output: format!("Moved {reference} ({} unit(s)) from {} to {}{wl} in {}", uuids.len(), describe_pt(from), describe_pt(to), file_name(ctx.path)),+        uuid: uuids[0].clone(),+        extra: json!({+            "reference": reference, "from": pt_json(from), "to": pt_json(to), "delta": pt_json(d), "units": uuids,+            "stretchWires": stretch, "wiresMoved": st.wires_moved, "wiresAdded": st.wires_added,+            "junctionsAdded": junction_json(&st.junctions), "itemsMoved": st.flags_moved,+        }),+    })+}++fn rotate(ctx: &mut Ctx) -> Result<Done, Value> {+    let reference = reference_arg(ctx.args, "rotate")?;+    let angle = ctx.args.get("rotation").or_else(|| ctx.args.get("angle")).and_then(Value::as_f64).ok_or_else(|| fail("missing_arg", "rotation is required", "Pass rotation as 0, 90, 180 or 270 (the absolute angle)."))?;+    let wires = ctx.sch.wires();+    let before: Vec<se::PinInfo> = ctx.sch.pins_of(&reference).unwrap_or_default();+    let wired: Vec<&se::PinInfo> = before.iter().filter(|p| wires.iter().any(|w| same(w.a, p.at) || same(w.b, p.at))).collect();+    let (old, new, uuids) = ctx.sch.rotate_units(&reference, angle).map_err(|e| oe(e, "rotation is 0, 90, 180 or 270; kicad_sch_components lists the references."))?;+    let after = ctx.sch.pins_of(&reference).unwrap_or_default();+    let detached: Vec<Value> = wired+        .iter()+        .filter(|p| after.iter().find(|q| q.number == p.number && q.unit == p.unit).map_or(false, |q| !same(q.at, p.at)))+        .map(|p| json!({"pin": p.spec(), "was": pt_json(p.at)}))+        .collect();+    Ok(Done {+        output: format!("Rotated {reference} ({} unit(s)) from {} to {} degrees in {}{}", uuids.len(), schematic::fmt_num(old), schematic::fmt_num(new), file_name(ctx.path), if detached.is_empty() { String::new() } else { format!("; {} wired pin(s) moved off their wires", detached.len()) }),+        uuid: uuids[0].clone(),+        extra: json!({"reference": reference, "from": old, "rotation": new, "units": uuids, "detachedPins": detached}),+    })+}++fn move_region(ctx: &mut Ctx) -> Result<Done, Value> {+    let bx = ctx.args.get("box").and_then(Value::as_array).filter(|a| a.len() == 4).and_then(|a| Some([a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?, a[3].as_f64()?])).ok_or_else(|| fail("missing_arg", "box is required as [x1, y1, x2, y2]", "Two opposite corners in millimetres, sheet coordinates."))?;+    let d = delta_arg(ctx.args)?;+    let moved = ctx.sch.move_region(bx, d, flag(ctx.args, "wires", true), flag(ctx.args, "labels", true), flag(ctx.args, "junctions", true));+    let total: usize = moved.as_object().map_or(0, |o| o.values().map(|l| l.as_array().map_or(0, |a| a.len())).sum());+    if total == 0 {+        return Err(fail("nothing_in_box", format!("nothing lies inside [{}, {}, {}, {}]", bx[0], bx[1], bx[2], bx[3]), "kicad_sch_components and kicad_sch_items give the coordinates of what is on the sheet."));+    }+    let summary: Vec<String> = moved.as_object().unwrap().iter().map(|(k, l)| format!("{} {k}", l.as_array().map_or(0, |a| a.len()))).collect();+    Ok(Done { output: format!("Moved {} by {} in {}", summary.join(", "), describe_pt(d), file_name(ctx.path)), uuid: String::new(), extra: json!({"moved": moved, "delta": pt_json(d)}) })+}++fn annotate(ctx: &mut Ctx) -> Result<Done, Value> {+    let by_y = matches!(arg_str(ctx.args, "order").map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("y"));+    let mut used = ctx.sch.references_used();+    let mut scanned = vec![norm(ctx.path)];+    let me = std::fs::canonicalize(ctx.path).unwrap_or_else(|_| ctx.path.to_path_buf());+    for p in project_sheets(ctx.path) {+        if std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone()) == me {+            continue;+        }+        if let Ok(s) = Schematic::load(&p) {+            used.extend(s.references_used());+            scanned.push(norm(&p));+        }+    }+    let plan = ctx.sch.annotate_plan(&used, by_y);+    for step in &plan {+        ctx.sch.set_unit_reference(&step.uuid, &step.to).map_err(|e| oe(e, "Nothing was written."))?;+    }+    let refs: BTreeSet<&str> = plan.iter().map(|s| s.to.as_str()).collect();+    Ok(Done {+        output: if plan.is_empty() { format!("Nothing to annotate: no X? references on {}", file_name(ctx.path)) } else { format!("Annotated {} unit(s) as {} reference(s) in {}", plan.len(), refs.len(), file_name(ctx.path)) },+        uuid: plan.first().map(|s| s.uuid.clone()).unwrap_or_default(),+        extra: json!({"plan": plan, "annotated": plan.len(), "sheetsScanned": scanned, "order": if by_y { "y" } else { "x" }}),+    })+}++fn replace_symbol(ctx: &mut Ctx) -> Result<Done, Value> {+    let reference = reference_arg(ctx.args, "replace")?;+    let lib_id = arg_any(ctx.args, &["libId", "lib_id", "symbol"]).map(|s| s.trim().to_string()).ok_or_else(|| fail("missing_arg", "libId is required", "Pass the new libId, e.g. Device:R_Small."))?;+    if !lib_id.contains(':') {+        return Err(fail("bad_arg", format!("libId {lib_id} is not Library:Symbol"), "Use the nickname and symbol name the Symbol Chooser shows, e.g. Device:C."));+    }+    let mut library_path = None;+    let block = if ctx.sch.has_lib_symbol(&lib_id) {+        None+    } else {+        let (b, lp) = library_block(ctx, &lib_id)?;+        library_path = Some(lp);+        Some(b)+    };+    let r = ctx.sch.replace_symbol(&reference, &lib_id, block.as_ref(), flag(ctx.args, "force", false)).map_err(|e| {+        let hint = if e.code == "pins_lost" { "Nothing was written. Pick a symbol with the same pin numbers, or pass force:true and rewire the lost pins." } else { "Nothing was written. kicad_sch_components lists the references and units." };+        oe(e, hint)+    })?;+    Ok(Done {+        output: format!("Replaced {reference} ({} unit(s)) {} -> {lib_id} in {}", r.units.len(), r.old_lib_id, file_name(ctx.path)),+        uuid: r.units[0].clone(),+        extra: json!({"reference": reference, "oldLibId": r.old_lib_id, "libId": lib_id, "units": r.units, "libSymbolAdded": r.lib_symbol_added, "pinsAdded": r.pins_added, "pinsRemoved": r.pins_removed, "oldEntryPruned": r.old_entry_pruned, "libraryPath": library_path}),+    })+}++fn update_from_library(ctx: &mut Ctx) -> Result<Done, Value> {+    let mut keys = str_list(ctx.args, &["libIds", "libId", "lib_ids"]);+    let all_keys = ctx.sch.lib_symbol_keys();+    if keys.is_empty() {+        keys = all_keys.clone();+    } else if let Some(k) = keys.iter().find(|k| !all_keys.contains(k)) {+        return Err(fail("not_found", format!("{k} is not embedded in this sheet's lib_symbols"), "kicad_sch_components shows each symbol's libId; omit libIds to refresh them all."));+    }+    let force = flag(ctx.args, "force", false);+    let (mut changed, mut unchanged, mut not_found, mut refused) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+    for key in &keys {+        if !key.contains(':') {+            not_found.push(json!({"libId": key, "error": "not a Library:Symbol id (a locally edited copy); left as it is"}));+            continue;+        }+        let block = match library_block(ctx, key) {+            Ok((b, _)) => b,+            Err(v) => {+                not_found.push(json!({"libId": key, "error": v["error"]}));+                continue;+            }+        };+        match ctx.sch.update_lib_symbol(key, &block, force) {+            Ok(None) => unchanged.push(json!(key)),+            Ok(Some(sync)) => changed.push(json!({"libId": key, "pinsAdded": sync.added, "pinsRemoved": sync.removed})),+            Err(e) if e.code == "pins_lost" => refused.push(json!({"libId": key, "error": e.msg})),+            Err(e) => return Err(oe(e, "Nothing was written.")),+        }+    }+    Ok(Done {+        output: format!("{} of {} library symbol(s) updated ({} unchanged, {} not found, {} refused) in {}", changed.len(), keys.len(), unchanged.len(), not_found.len(), refused.len(), file_name(ctx.path)),+        uuid: String::new(),+        extra: json!({"changed": changed, "unchanged": unchanged, "notFound": not_found, "refused": refused}),+    })+}++fn reset_fields(ctx: &mut Ctx) -> Result<Done, Value> {+    let mut refs = str_list(ctx.args, &["references", "reference", "ref"]);+    if refs.is_empty() && flag(ctx.args, "all", false) {+        let mut seen = BTreeSet::new();+        refs = ctx.sch.symbols().into_iter().map(|s| s.reference).filter(|r| seen.insert(r.clone())).collect();+    }+    if refs.is_empty() {+        return Err(fail("missing_arg", "reference is required", "Pass reference, references, or all:true."));+    }+    let mut fields = str_list(ctx.args, &["fields", "field"]);+    if fields.is_empty() {+        fields = vec!["Reference".into(), "Value".into()];+    }+    let mut out = Vec::new();+    for r in &refs {+        let done = ctx.sch.reset_fields(r, &fields).map_err(|e| oe(e, "Nothing was written. kicad_sch_components lists the references."))?;+        out.push(json!({"reference": r, "reset": done}));+    }+    Ok(Done { output: format!("Reset {} on {} symbol(s) in {}", fields.join(", "), refs.len(), file_name(ctx.path)), uuid: String::new(), extra: json!({"symbols": out, "fields": fields}) })+}++// ---------------------------------------------------------------------------+// Wiring+// ---------------------------------------------------------------------------++fn delete_wire(ctx: &mut Ctx) -> Result<Done, Value> {+    let mut uuids = str_list(ctx.args, &["uuids", "uuid"]);+    if uuids.is_empty() {+        if let (Some(a), Some(b)) = (opt_point(ctx.args, "from")?, opt_point(ctx.args, "to")?) {+            uuids = ctx.sch.wires_between(a, b);+            if uuids.is_empty() {+                return Err(fail("not_found", format!("no wire runs from {} to {}", describe_pt(a), describe_pt(b)), "kicad_sch_items {kinds:[\"wires\"]} lists every wire with its ends and uuid."));+            }+        } else if let Some(p) = opt_point(ctx.args, "at")? {+            uuids = ctx.sch.wires().into_iter().filter(|s| se::on_segment(p, s.a, s.b)).map(|s| s.uuid).collect();+            if uuids.is_empty() {+                return Err(fail("not_found", format!("no wire passes through {}", describe_pt(p)), "kicad_sch_items {kinds:[\"wires\"]} lists every wire with its ends and uuid."));+            }+        } else {+            return Err(fail("missing_arg", "uuid, uuids, from+to, or at is required", "Name the wires by uuid (kicad_sch_items lists them), by their two ends, or by a point they pass through."));+        }+    }+    let (gone, pruned) = ctx.sch.delete_wires(&uuids).map_err(|e| oe(e, "Nothing was written. kicad_sch_items {kinds:[\"wires\"]} lists the wire uuids."))?;+    Ok(Done {+        output: format!("Deleted {} wire(s) and pruned {} junction(s) in {}", gone.len(), pruned.len(), file_name(ctx.path)),+        uuid: gone[0].uuid.clone(),+        extra: json!({"deleted": gone.iter().map(|s| json!({"uuid": s.uuid, "from": pt_json(s.a), "to": pt_json(s.b)})).collect::<Vec<_>>(), "junctionsPruned": pruned}),+    })+}++fn split_wire(ctx: &mut Ctx) -> Result<Done, Value> {+    let at = point(ctx.args, "at")?;+    let (a, b, j) = ctx.sch.split_wire(at, arg_str(ctx.args, "uuid"), flag(ctx.args, "junction", true)).map_err(|e| oe(e, "kicad_sch_items {kinds:[\"wires\"]} lists every wire with its ends; the point must be strictly between them."))?;+    Ok(Done { output: format!("Split wire {a} at {} in {}", describe_pt(at), file_name(ctx.path)), uuid: b.clone(), extra: json!({"uuids": [a, b], "junction": j, "at": pt_json(at)}) })+}++fn edit_label(ctx: &mut Ctx) -> Result<Done, Value> {+    let uuid = arg_str(ctx.args, "uuid");+    let text = arg_any(ctx.args, &["text", "net", "name"]).map(str::trim);+    let at = opt_point(ctx.args, "at")?;+    if uuid.is_none() && text.is_none() {+        return Err(fail("missing_arg", "uuid, text or net is required", "Pick the labels by uuid (kicad_sch_items lists them), by text (+ at), or by net."));+    }+    let idxs = ctx.sch.label_indices(uuid, text, at);+    if idxs.is_empty() {+        return Err(fail("not_found", format!("no label matches {}", uuid.or(text).unwrap_or("")), "kicad_sch_items {kinds:[\"labels\"]} lists every label with its text, point and uuid."));+    }+    let rotation = ctx.args.get("rotation").and_then(Value::as_f64);+    let to = opt_point(ctx.args, "to")?;+    let by = if ctx.args.get("by").is_some() || ctx.args.get("dx").is_some() || ctx.args.get("dy").is_some() { Some(delta_arg(ctx.args)?) } else { None };+    let rename = arg_any(ctx.args, &["rename", "newText", "new_text"]).map(str::trim).map(str::to_string);+    if rotation.is_none() && to.is_none() && by.is_none() && rename.is_none() {+        return Err(fail("missing_arg", "nothing to change", "Pass rotation, to, by, or rename."));+    }+    if to.is_some() && idxs.len() > 1 {+        return Err(fail("ambiguous", format!("{} labels match and to names one point", idxs.len()), "Pick one label (uuid, or text + at), or use by to move them all."));+    }+    let mut edited = Vec::new();+    for &i in &idxs {+        if let Some(r) = rotation {+            ctx.sch.set_label_rotation(i, r).map_err(|e| oe(e, "rotation is 0, 90, 180 or 270."))?;+        }+        let n = &mut ctx.sch.top_mut()[i];+        let cur = n.child("at").map(|a| (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0))).unwrap_or((0.0, 0.0));+        let d = match (to, by) {+            (Some(t), _) => Some((t.0 - cur.0, t.1 - cur.1)),+            (None, Some(b)) => Some(b),+            _ => None,+        };+        if let Some(d) = d {+            se::shift_node(n, d.0, d.1);+        }+        if let Some(name) = &rename {+            if name.is_empty() {+                return Err(fail("bad_arg", "rename cannot be empty", "Pass the new net name."));+            }+            n.set_arg(0, schematic::Node::quoted(name));+        }+        edited.push(n.child_value("uuid").unwrap_or_default());+    }+    let mut what = Vec::new();+    if let Some(r) = rotation {+        what.push(format!("rotated to {}", schematic::fmt_num(r)));+    }+    if let Some(t) = to {+        what.push(format!("moved to {}", describe_pt(t)));+    }+    if let Some(b) = by {+        what.push(format!("moved by {}", describe_pt(b)));+    }+    if let Some(n) = &rename {+        what.push(format!("renamed to {n}"));+    }+    Ok(Done { output: format!("{} label(s) {} in {}", edited.len(), what.join(", "), file_name(ctx.path)), uuid: edited[0].clone(), extra: json!({"edited": edited}) })+}++fn junction(ctx: &mut Ctx) -> Result<Done, Value> {+    let mut pts = points_arg(ctx.args, "points")?;+    if let Some(p) = opt_point(ctx.args, "at")? {+        pts.push(p);+    }+    if flag(ctx.args, "remove", false) {+        let mut gone = Vec::new();+        for u in str_list(ctx.args, &["uuids", "uuid"]) {+            ctx.sch.remove_kind("junction", &u).map_err(|e| oe(e, "kicad_sch_items {kinds:[\"junctions\"]} lists every junction."))?;+            gone.push(u);+        }+        for p in &pts {+            let g = ctx.sch.remove_point_items("junction", *p);+            if g.is_empty() {+                return Err(fail("not_found", format!("no junction at {}", describe_pt(*p)), "kicad_sch_items {kinds:[\"junctions\"]} lists every junction."));+            }+            gone.extend(g);+        }+        if gone.is_empty() {+            return Err(fail("missing_arg", "uuid, at or points is required", "Name the junctions to remove."));+        }+        return Ok(Done { output: format!("Removed {} junction(s) from {}", gone.len(), file_name(ctx.path)), uuid: gone[0].clone(), extra: json!({"removed": gone}) });+    }+    if pts.is_empty() {+        return Err(fail("missing_arg", "at or points is required", "Pass at as [x, y] or points as [[x, y], ...]."));+    }+    let (mut added, mut skipped, mut not_needed) = (Vec::new(), Vec::new(), Vec::new());+    for p in pts {+        if ctx.sch.junction_at(p).is_some() {+            skipped.push(pt_json(p));+            continue;+        }+        if !ctx.sch.junction_needed(p) {+            not_needed.push(pt_json(p));+        }+        added.push(json!({"uuid": ctx.sch.add_junction(p), "at": pt_json(p)}));+    }+    if added.is_empty() {+        return Err(fail("already_there", "every point already has a junction", "Nothing to add; kicad_sch_items {kinds:[\"junctions\"]} lists them."));+    }+    Ok(Done {+        output: format!("Added {} junction(s) in {}{}", added.len(), file_name(ctx.path), if not_needed.is_empty() { String::new() } else { format!("; {} of them join fewer than three exits", not_needed.len()) }),+        uuid: added[0]["uuid"].as_str().unwrap_or("").to_string(),+        extra: json!({"added": added, "skipped": skipped, "notNeeded": not_needed}),+    })+}++/// kicad_sch_no_connect: flags on pins or points (arrays welcome), or remove:true.+pub(crate) fn no_connect(ctx: &mut Ctx) -> Result<Done, Value> {+    let specs = str_list(ctx.args, &["pins", "pin"]);+    let mut targets: Vec<(Pt, Option<String>)> = Vec::new();+    for s in &specs {+        let p = ctx.sch.find_pin(s).map_err(|e| oe(e, "Pins are matched by number first, then by name; kicad_sch_pins lists them."))?;+        targets.push((p.at, Some(s.clone())));+    }+    if let Some(p) = opt_point(ctx.args, "at")? {+        targets.push((p, None));+    }+    for p in points_arg(ctx.args, "points")? {+        targets.push((p, None));+    }+    if flag(ctx.args, "remove", false) {+        let mut gone = Vec::new();+        for u in str_list(ctx.args, &["uuids", "uuid"]) {+            ctx.sch.remove_kind("no_connect", &u).map_err(|e| oe(e, "kicad_sch_items {kinds:[\"noConnects\"]} lists every flag."))?;+            gone.push(u);+        }+        for (p, pin) in &targets {+            let g = ctx.sch.remove_point_items("no_connect", *p);+            if g.is_empty() {+                return Err(fail("not_found", format!("no no-connect flag at {}{}", pin.as_ref().map(|s| format!("{s} ")).unwrap_or_default(), describe_pt(*p)), "kicad_sch_items {kinds:[\"noConnects\"]} lists every flag."));+            }+            gone.extend(g);+        }+        if gone.is_empty() {+            return Err(fail("missing_arg", "pin, pins, at, points or uuid is required", "Name the flags to remove."));+        }+        return Ok(Done { output: format!("Removed {} no-connect flag(s) from {}", gone.len(), file_name(ctx.path)), uuid: gone[0].clone(), extra: json!({"removed": gone}) });+    }+    if targets.is_empty() {+        return Err(fail("missing_arg", "pin, pins, at or points is required", "Name the pin (U1.12 or U1.PA10) or pass at as [x_mm, y_mm]."));+    }+    let (mut added, mut skipped) = (Vec::new(), Vec::new());+    for (p, pin) in &targets {+        if ctx.sch.point_items("no_connect").iter().any(|(_, q)| same(*q, *p)) {+            skipped.push(json!({"pin": pin, "at": pt_json(*p)}));+            continue;+        }+        let u = ctx.sch.add_no_connect(rp(*p)).map_err(|e| vs::op_fail("edit_failed", e, "Nothing was written."))?;+        added.push(json!({"uuid": u, "pin": pin, "at": pt_json(*p)}));+    }+    if added.is_empty() {+        return Err(fail("already_flagged", "every target already has a no-connect flag", "Nothing to add; kicad_sch_items {kinds:[\"noConnects\"]} lists the flags."));+    }+    let first = &added[0];+    let desc = if targets.len() == 1 {+        format!("Added a no-connect flag{} at {}", targets[0].1.as_ref().map(|p| format!(" on {p}")).unwrap_or_default(), describe_pt(targets[0].0))+    } else {+        format!("Added {} no-connect flag(s)", added.len())+    };+    Ok(Done {+        output: format!("{desc} in {}", file_name(ctx.path)),+        uuid: first["uuid"].as_str().unwrap_or("").to_string(),+        extra: json!({"at": first["at"], "pin": first["pin"], "added": added, "skipped": skipped}),+    })+}++/// Rotation that turns a power symbol so its body points along `out`.+fn power_rotation(block: &schematic::Node, lib_id: &str, out: Pt) -> f64 {+    let base = se::base_name(lib_id);+    let pins = se::lib_unit_pins(block, &base, 1, 1);+    let Some(p) = pins.first() else { return 0.0 };+    let a = p.angle.to_radians();+    let body = (a.cos(), a.sin());+    for rot in [0.0, 90.0, 180.0, 270.0] {+        let xf = SymXf { x: 0.0, y: 0.0, rot, mirror: None };+        let v = xf.vec(body);+        if (v.0 - out.0).abs() < 0.01 && (v.1 - out.1).abs() < 0.01 {+            return rot;+        }+    }+    0.0+}++fn connect_to_net(ctx: &mut Ctx) -> Result<Done, Value> {+    let specs = str_list(ctx.args, &["pins", "pin"]);+    if specs.is_empty() {+        return Err(fail("missing_arg", "pin or pins is required", "Name the pins as Ref.Pin, e.g. U1.3 or [\"U1.3\", \"R5.1\"]."));+    }+    let net = arg_any(ctx.args, &["net", "text", "name"]).map(|s| s.trim().to_string()).ok_or_else(|| fail("missing_arg", "net is required", "Pass the net name, e.g. VOUT or GND."))?;+    let kind_s = arg_str(ctx.args, "kind").unwrap_or("local").trim().to_ascii_lowercase();+    let power = kind_s == "power" || kind_s == "power_symbol";+    let kind = if power { LabelKind::Local } else { LabelKind::parse(&kind_s).ok_or_else(|| fail("bad_arg", format!("kind {kind_s} is not local, global, hierarchical or power"), "Use local, global, hierarchical, or power."))? };+    let len = ctx.args.get("length").and_then(Value::as_f64).unwrap_or(2.54);+    if !(len > 0.0) {+        return Err(fail("bad_arg", "length must be positive", "A stub of 2.54 mm (default) or another multiple of 1.27."));+    }+    let shape = arg_str(ctx.args, "shape").map(str::to_string);+    let power_lib = if power {+        let lib_id = format!("power:{net}");+        Some(if ctx.sch.has_lib_symbol(&lib_id) { (ctx.sch.lib_symbol(&lib_id).unwrap().clone(), None) } else { let (b, _) = library_block(ctx, &lib_id)?; (b.clone(), Some(b)) })+    } else {+        None+    };+    let mut connected = Vec::new();+    for s in &specs {+        let pin = ctx.sch.find_pin(s).map_err(|e| oe(e, "Nothing was written. kicad_sch_pins lists every pin."))?;+        let already = ctx.sch.wires().iter().any(|w| se::on_segment(pin.at, w.a, w.b));+        let end = rp((pin.at.0 + pin.out.0 * len, pin.at.1 + pin.out.1 * len));+        let (wires, junctions) = ctx.sch.add_wire_auto(&[pin.at, end]).map_err(|e| oe(e, "Nothing was written."))?;+        let (label_uuid, reference) = if let Some((entry, block)) = &power_lib {+            let lib_id = format!("power:{net}");+            let reference = ctx.sch.next_reference("#PWR");+            let rot = power_rotation(entry, &lib_id, pin.out);+            let has = ctx.sch.has_lib_symbol(&lib_id);+            let p = PlaceSymbol { lib_id: &lib_id, reference: &reference, at: end, rotation: rot, unit: 1, value: Some(&net), footprint: None };+            let placed = ctx.sch.place_symbol(&p, if has { None } else { block.as_ref() }).map_err(|e| vs::op_fail("edit_failed", e, "Nothing was written."))?;+            (placed.uuid, Some(reference))+        } else {+            let u = ctx.sch.add_label(&net, kind, end, label_rotation_for(pin.out), shape.as_deref()).map_err(|e| vs::op_fail("edit_failed", e, "Nothing was written."))?;+            (u, None)+        };+        connected.push(json!({"pin": pin.spec(), "from": pt_json(pin.at), "end": pt_json(end), "direction": se::dir_name(pin.out), "wire": wires[0], "label": label_uuid, "powerReference": reference, "alreadyWired": already, "junctionsAdded": junction_json(&junctions)}));+    }+    Ok(Done {+        output: format!("Connected {} pin(s) to {net} ({}) in {}", connected.len(), if power { "power symbol" } else { kind.token() }, file_name(ctx.path)),+        uuid: connected[0]["label"].as_str().unwrap_or("").to_string(),+        extra: json!({"net": net, "kind": if power { "power".to_string() } else { kind_s }, "connected": connected}),+    })+}++fn connect_pins(ctx: &mut Ctx) -> Result<Done, Value> {+    let mut pairs: Vec<(String, String)> = Vec::new();+    if let Some(arr) = ctx.args.get("pairs").and_then(Value::as_array) {+        for (i, p) in arr.iter().enumerate() {+            let pr = p.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a[0].as_str()?.to_string(), a[1].as_str()?.to_string())));+            pairs.push(pr.ok_or_else(|| fail("bad_arg", format!("pairs[{i}] is not [\"Ref.Pin\", \"Ref.Pin\"]"), "Each pair names two pins."))?);+        }+    } else {+        let a = arg_str(ctx.args, "from").ok_or_else(|| fail("missing_arg", "from is required", "Pass from and to as Ref.Pin, e.g. U1.1 and R5.2."))?;+        let b = arg_str(ctx.args, "to").ok_or_else(|| fail("missing_arg", "to is required", "Pass from and to as Ref.Pin, e.g. U1.1 and R5.2."))?;+        pairs.push((a.trim().to_string(), b.trim().to_string()));+    }+    let first = arg_str(ctx.args, "first").map(|s| s.trim().to_ascii_lowercase());+    let via = points_arg(ctx.args, "via")?;+    let mut routes = Vec::new();+    let mut all_j = Vec::new();+    for (a, b) in &pairs {+        let pa = ctx.sch.find_pin(a).map_err(|e| oe(e, "Nothing was written. kicad_sch_pins lists every pin."))?;+        let pb = ctx.sch.find_pin(b).map_err(|e| oe(e, "Nothing was written. kicad_sch_pins lists every pin."))?;+        if same(pa.at, pb.at) {+            return Err(fail("same_point", format!("{a} and {b} already share the point {}", describe_pt(pa.at)), "They are connected already."));+        }+        let mut stops = vec![pa.at];+        stops.extend(via.iter().copied());+        stops.push(pb.at);+        let mut pts: Vec<Pt> = vec![pa.at];+        let mut out = pa.out;+        for w in stops.windows(2) {+            let leg = l_route(w[0], out, w[1], first.as_deref());+            if leg.len() == 3 {+                out = se::rp(((leg[2].0 - leg[1].0).signum(), (leg[2].1 - leg[1].1).signum()));+            }+            pts.extend(leg.into_iter().skip(1));+        }+        let (wires, j) = ctx.sch.add_wire_auto(&pts).map_err(|e| oe(e, "Nothing was written."))?;+        all_j.extend(j.clone());+        routes.push(json!({"from": pin_json(&pa), "to": pin_json(&pb), "points": pts.iter().map(|p| pt_json(*p)).collect::<Vec<_>>(), "wires": wires, "junctionsAdded": junction_json(&j)}));+    }+    Ok(Done {+        output: format!("Wired {} pin pair(s) ({} junction(s) added) in {}", routes.len(), all_j.len(), file_name(ctx.path)),+        uuid: routes[0]["wires"][0].as_str().unwrap_or("").to_string(),+        extra: json!({"routes": routes, "junctionsAdded": junction_json(&all_j)}),+    })+}++fn text(ctx: &mut Ctx) -> Result<Done, Value> {+    let t = ctx.args.get("text").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| fail("missing_arg", "text is required", "Pass the text to write."))?.to_string();+    let at = point(ctx.args, "at")?;+    let size = ctx.args.get("size").and_then(Value::as_f64).unwrap_or(1.27);+    let justify = match ctx.args.get("justify") {+        Some(Value::String(s)) => s.split_whitespace().map(|x| x.to_ascii_lowercase()).collect(),+        Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).map(|x| x.to_ascii_lowercase()).collect(),+        _ => vec!["left".to_string(), "bottom".to_string()],+    };+    let uuid = ctx.sch.add_text(&t, at, vs::rotation(ctx.args), size, &justify, flag(ctx.args, "bold", false), flag(ctx.args, "italic", false)).map_err(|e| oe(e, "Nothing was written."))?;+    Ok(Done { output: format!("Added text {:?} at {} in {}", t, describe_pt(at), file_name(ctx.path)), uuid, extra: json!({"at": pt_json(at), "size": size, "justify": justify}) })+}++fn bus(ctx: &mut Ctx) -> Result<Done, Value> {+    let pts = points_arg(ctx.args, "points")?;+    let uuids = ctx.sch.add_segments("bus", &pts).map_err(|e| oe(e, "Pass points as [[x, y], [x, y], ...] with at least two distinct entries."))?;+    let label = match arg_str(ctx.args, "label") {+        Some(l) => Some(ctx.sch.add_label(l.trim(), LabelKind::Local, pts[0], 0.0, None).map_err(|e| vs::op_fail("edit_failed", e, "Nothing was written."))?),+        None => None,+    };+    Ok(Done { output: format!("Drew {} bus segment(s){} in {}", uuids.len(), if label.is_some() { " with a bus label" } else { "" }, file_name(ctx.path)), uuid: uuids[0].clone(), extra: json!({"uuids": uuids, "label": label}) })+}++fn bus_entry(ctx: &mut Ctx) -> Result<Done, Value> {+    let at = point(ctx.args, "at")?;+    let size = if ctx.args.get("size").is_some() {+        point(ctx.args, "size")?+    } else {+        match arg_str(ctx.args, "direction").map(|s| s.trim().to_ascii_lowercase()).as_deref() {+            None | Some("down-right") => (2.54, 2.54),+            Some("up-right") => (2.54, -2.54),+            Some("down-left") => (-2.54, 2.54),+            Some("up-left") => (-2.54, -2.54),+            Some(o) => return Err(fail("bad_arg", format!("direction {o} is not up-right, down-right, up-left or down-left"), "Or pass size as [dx, dy].")),+        }+    };+    let uuid = ctx.sch.add_bus_entry(at, size).map_err(|e| oe(e, "Nothing was written."))?;+    let end = rp((at.0 + size.0, at.1 + size.1));+    let on_bus = ctx.sch.segments("bus").iter().any(|s| se::on_segment(end, s.a, s.b));+    Ok(Done {+        output: format!("Added a bus entry from {} to {}{} in {}", describe_pt(at), describe_pt(end), if on_bus { "" } else { " (the end is not on a bus yet)" }, file_name(ctx.path)),+        uuid,+        extra: json!({"at": pt_json(at), "end": pt_json(end), "size": pt_json(size), "onBus": on_bus}),+    })+}++fn connect_to_bus(ctx: &mut Ctx) -> Result<Done, Value> {+    let specs = str_list(ctx.args, &["pins", "pin"]);+    if specs.is_empty() {+        return Err(fail("missing_arg", "pins is required", "Name the pins as Ref.Pin, e.g. [\"U1.1\", \"U1.2\"]."));+    }+    let buses = ctx.sch.segments("bus");+    let bus = match arg_str(ctx.args, "bus") {+        Some(u) => buses.iter().find(|s| s.uuid == u.trim()).cloned().ok_or_else(|| fail("not_found", format!("no bus segment {u}"), "kicad_sch_items {kinds:[\"buses\"]} lists the bus segments."))?,+        None if buses.len() == 1 => buses[0].clone(),+        None => return Err(fail("missing_arg", format!("bus is required ({} bus segments on the sheet)", buses.len()), "Pass the uuid of the bus segment to land on (kicad_sch_items {kinds:[\"buses\"]}); draw one with kicad_sch_bus.")),+    };+    let vertical = (bus.a.0 - bus.b.0).abs() < se::EPS;+    let horizontal = (bus.a.1 - bus.b.1).abs() < se::EPS;+    if !vertical && !horizontal {+        return Err(fail("bad_bus", "the bus segment is diagonal", "Land pins on a horizontal or vertical bus segment."));+    }+    let mut members = str_list(ctx.args, &["members"]);+    if members.is_empty() {+        let prefix = arg_str(ctx.args, "prefix").ok_or_else(|| fail("missing_arg", "members or prefix is required", "Name the member nets: members [\"D0\", \"D1\"] in pin order, or prefix \"D\" (+ start)."))?;+        let start = ctx.args.get("start").and_then(Value::as_u64).unwrap_or(0);+        members = (0..specs.len() as u64).map(|i| format!("{}{}", prefix.trim(), start + i)).collect();+    }+    if members.len() != specs.len() {+        return Err(fail("bad_arg", format!("{} members for {} pins", members.len(), specs.len()), "Give one member name per pin, in the same order."));+    }+    let lean = arg_str(ctx.args, "entry").map(|s| s.trim().to_ascii_lowercase());+    let lean_sign = match lean.as_deref() {+        None => -1.0,+        Some("up") | Some("left") => -1.0,+        Some("down") | Some("right") => 1.0,+        Some(o) => return Err(fail("bad_arg", format!("entry {o} is not up, down, left or right"), "up or down for a vertical bus, left or right for a horizontal one.")),+    };+    // a horizontal bus leans right by default+    let lean_sign = if horizontal && lean.is_none() { 1.0 } else { lean_sign };+    let step = 2.54;+    let mut out = Vec::new();+    for (s, member) in specs.iter().zip(members.iter()) {+        let pin = ctx.sch.find_pin(s).map_err(|e| oe(e, "Nothing was written. kicad_sch_pins lists every pin."))?;+        let (e0, e1, rot) = if vertical {+            let x = bus.a.0;+            let side = (x - pin.at.0).signum();+            if (x - pin.at.0).abs() < step + se::EPS {+                return Err(fail("too_close", format!("{s} is within 2.54 mm of the bus"), "Move the part or the bus so there is room for a stub and an entry."));+            }+            let e0 = rp((x - side * step, pin.at.1));+            (e0, rp((x, pin.at.1 + lean_sign * step)), if side > 0.0 { 180.0 } else { 0.0 })+        } else {+            let y = bus.a.1;+            let side = (y - pin.at.1).signum();+            if (y - pin.at.1).abs() < step + se::EPS {+                return Err(fail("too_close", format!("{s} is within 2.54 mm of the bus"), "Move the part or the bus so there is room for a stub and an entry."));+            }+            let e0 = rp((pin.at.0, y - side * step));+            (e0, rp((pin.at.0 + lean_sign * step, y)), if side > 0.0 { 90.0 } else { 270.0 })+        };+        if !se::on_segment(e1, bus.a, bus.b) {+            return Err(fail("entry_off_bus", format!("the entry for {s} would end at {}, off the bus segment {} to {}", describe_pt(e1), describe_pt(bus.a), describe_pt(bus.b)), "Nothing was written. Extend the bus past every pin (kicad_sch_bus) or flip entry."));+        }+        let (wires, j) = ctx.sch.add_wire_auto(&[pin.at, e0]).map_err(|e| oe(e, "Nothing was written."))?;+        let entry = ctx.sch.add_bus_entry(e0, (e1.0 - e0.0, e1.1 - e0.1)).map_err(|e| oe(e, "Nothing was written."))?;+        let label = ctx.sch.add_label(member, LabelKind::Local, e0, rot, None).map_err(|e| vs::op_fail("edit_failed", e, "Nothing was written."))?;+        out.push(json!({"pin": pin.spec(), "member": member, "wire": wires[0], "entry": entry, "label": label, "entryFrom": pt_json(e0), "entryTo": pt_json(e1), "junctionsAdded": junction_json(&j)}));+    }+    let bus_label = match arg_str(ctx.args, "busLabel") {+        Some(l) => Some(ctx.sch.add_label(l.trim(), LabelKind::Local, bus.a, if vertical { 90.0 } else { 0.0 }, None).map_err(|e| vs::op_fail("edit_failed", e, "Nothing was written."))?),+        None => None,+    };+    Ok(Done {+        output: format!("Fanned {} pin(s) onto the bus with member labels {} in {}", out.len(), members.join(", "), file_name(ctx.path)),+        uuid: out[0]["wire"].as_str().unwrap_or("").to_string(),+        extra: json!({"bus": bus.uuid, "connected": out, "busLabel": bus_label}),+    })+}++// ---------------------------------------------------------------------------+// Batch+// ---------------------------------------------------------------------------++fn batch(ctx: &mut Ctx) -> Result<Done, Value> {+    let ops = ctx.args.get("ops").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| fail("missing_arg", "ops is required", "Pass ops as a list of {\"op\": \"wire\", ...that verb's arguments}."))?.clone();+    let mut results = Vec::new();+    for (i, opv) in ops.iter().enumerate() {+        let name = opv.get("op").and_then(Value::as_str).map(|s| s.trim().trim_start_matches("kicad_sch_").to_string()).ok_or_else(|| fail("bad_arg", format!("ops[{i}] has no op"), "Every op names its operation: {\"op\": \"wire\", ...}."))?;+        if name == "batch" {+            return Err(fail("bad_arg", format!("ops[{i}] is a batch; batches do not nest"), "Flatten the ops into one list."));+        }+        let Some(f) = op_for(&name) else {+            return Err(fail("bad_arg", format!("ops[{i}]: unknown op {name}"), "Ops: place_symbol, wire, label, move, rotate, delete, set_property, no_connect, move_region, annotate, replace_symbol, update_from_library, reset_fields, delete_wire, split_wire, edit_label, junction, connect_to_net, connect_pins, text, bus, bus_entry, connect_to_bus."));+        };+        let mut sub = Ctx { sch: &mut *ctx.sch, path: ctx.path, lib: ctx.lib, args: opv };+        match f(&mut sub) {+            Ok(d) => results.push(json!({"op": name, "output": d.output, "uuid": d.uuid, "result": d.extra})),+            Err(mut v) => {+                let err = v["error"].as_str().unwrap_or("").to_string();+                v["error"] = json!(format!("ops[{i}] ({name}) failed: {err}"));+                v["failedOp"] = json!(i);+                v["op"] = json!(name);+                v["completedBeforeFailure"] = json!(results.len());+                let h = v["_hint"].as_str().unwrap_or("").to_string();+                v["_hint"] = json!(format!("Nothing was written: the batch is all or nothing. {h}"));+                return Err(v);+            }+        }+    }+    Ok(Done {+        output: format!("Applied {} op(s) ({}) in one write to {}", results.len(), results.iter().filter_map(|r| r["op"].as_str()).collect::<Vec<_>>().join(", "), file_name(ctx.path)),+        uuid: results.first().and_then(|r| r["uuid"].as_str()).unwrap_or("").to_string(),+        extra: json!({"results": results, "ops": results.len()}),+    })+}++#[cfg(test)]+mod tests {+    //! Every verb of the group (and the extended move, wire, delete and no_connect) through the+    //! real dispatch path on a temp copy of the KiCad 10 fixture; the written file must parse+    //! again. ERC runs only where kicad-cli is installed, so here it reports ran:false.+    use super::*;+    use crate::util::State;++    fn fixtures() -> PathBuf {+        Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/sch-edit")+    }++    fn temp_sheet(tag: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("kicad-bridge-schedit-{tag}-{}-{}", std::process::id(), schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        let p = d.join("edit10.kicad_sch");+        std::fs::copy(fixtures().join("edit10.kicad_sch"), &p).unwrap();+        p+    }++    fn call(verb: &str, path: &Path, mut args: Value) -> Value {+        args["filePath"] = json!(norm(path));+        crate::groups::dispatch(&mut State::new(), verb, &args).unwrap_or_else(|| panic!("{verb} not dispatched"))+    }++    fn ok(verb: &str, path: &Path, args: Value) -> Value {+        let v = call(verb, path, args);+        assert_eq!(v["success"], true, "{verb}: {v}");+        assert!(!v["output"].as_str().unwrap_or("").is_empty(), "{verb} has an output line");+        Schematic::load(path).unwrap_or_else(|e| panic!("{verb} left a file that does not parse: {e}"));+        v+    }++    fn err(verb: &str, path: &Path, args: Value, code: &str) -> Value {+        let before = std::fs::read_to_string(path).unwrap();+        let v = call(verb, path, args);+        assert_eq!(v["success"], false, "{verb}: {v}");+        assert_eq!(v["errorCode"], code, "{verb}: {v}");+        assert!(!v["_hint"].as_str().unwrap_or("").is_empty(), "{verb} error has a hint");+        assert_eq!(std::fs::read_to_string(path).unwrap(), before, "{verb}: a refusal writes nothing");+        v+    }++    fn sheet(path: &Path) -> Schematic {+        Schematic::load(path).unwrap()+    }++    #[test]+    fn every_verb_is_catalogued_with_a_full_entry() {+        for v in VERBS {+            assert!(v.name.starts_with("kicad_sch_"));+            assert!(!v.summary.is_empty() && !v.input.is_empty() && !v.hint.is_empty() && !v.related.is_empty() && !v.pitfalls.is_empty(), "{} entry incomplete", v.name);+            assert!(v.example.starts_with(v.name), "{} example names the verb", v.name);+            for text in [v.summary, v.input, v.hint].iter().chain(v.pitfalls.iter()) {+                assert!(!text.contains('\u{2014}'), "{}: no em-dashes", v.name);+            }+            if v.name != "kicad_sch_batch" && v.risk == "write" {+                assert!(op_for(v.name.trim_start_matches("kicad_sch_")).is_some(), "{} has an op", v.name);+            }+        }+    }++    #[test]+    fn read_verbs() {+        let p = temp_sheet("read");+        let before = std::fs::read_to_string(&p).unwrap();+        let v = ok("kicad_sch_components", &p, json!({"reference": "U1"}));+        assert_eq!(v["components"][0]["units"].as_array().unwrap().len(), 3);+        err("kicad_sch_components", &p, json!({"reference": "Q9"}), "not_found");+        let v = ok("kicad_sch_pins", &p, json!({"references": ["R1", "R2"]}));+        let r1 = &v["symbols"][0]["pins"];+        assert_eq!(r1[1]["at"], json!([100.33, 54.61]));+        assert_eq!(r1[1]["nets"], json!(["VOUT"]));+        assert_eq!(v["symbols"][1]["pins"][0]["touching"]["noConnect"], true);+        err("kicad_sch_pins", &p, json!({"reference": "Q9"}), "not_found");+        let v = ok("kicad_sch_items", &p, json!({"kinds": ["wires", "junctions"]}));+        assert_eq!(v["counts"]["wires"], 4);+        assert_eq!(v["counts"]["junctions"], 1);+        err("kicad_sch_items", &p, json!({"kinds": ["stuff"]}), "bad_arg");+        let v = call("kicad_sch_items", Path::new("/nope/x.kicad_sch"), json!({}));+        assert_eq!(v["errorCode"], "file_not_found");+        assert_eq!(std::fs::read_to_string(&p).unwrap(), before, "read verbs never write");+    }++    #[test]+    fn move_rotate_region_and_dry_run() {+        let p = temp_sheet("move");+        let before = std::fs::read_to_string(&p).unwrap();+        let v = ok("kicad_sch_move", &p, json!({"reference": "U1", "by": [2.54, 0], "dryRun": true}));+        assert_eq!(v["dryRun"], true);+        assert_eq!(std::fs::read_to_string(&p).unwrap(), before, "dry run writes nothing");+        let v = ok("kicad_sch_move", &p, json!({"reference": "U1", "to": [154.94, 81.28]}));+        assert_eq!(v["units"].as_array().unwrap().len(), 3);+        assert_eq!(sheet(&p).find_pin("U1.4").unwrap().at, (182.88, 102.87));+        let v = ok("kicad_sch_move", &p, json!({"reference": "R1", "by": [5.08, 0], "stretchWires": true}));+        assert_eq!(v["wiresAdded"].as_array().unwrap().len(), 1);+        err("kicad_sch_move", &p, json!({"reference": "R1"}), "missing_arg");+        err("kicad_sch_move", &p, json!({"reference": "R9", "by": [1, 1]}), "not_found");+        let v = ok("kicad_sch_rotate", &p, json!({"reference": "R2", "rotation": 0}));+        assert_eq!(v["rotation"], 0.0);+        assert_eq!(v["detachedPins"].as_array().unwrap().len(), 0, "R2.1 carries a flag, not a wire");+        let v = ok("kicad_sch_rotate", &p, json!({"reference": "R1", "rotation": 90}));+        assert_eq!(v["detachedPins"].as_array().unwrap().len(), 1, "R1.2 had a wire");+        err("kicad_sch_rotate", &p, json!({"reference": "R1", "rotation": 30}), "bad_arg");+    }++    #[test]+    fn region_annotate_reset() {+        let p = temp_sheet("annot");+        let v = ok("kicad_sch_move_region", &p, json!({"box": [45, 95, 70, 130], "by": [0, -12.7]}));+        assert_eq!(v["moved"]["symbols"].as_array().unwrap().len(), 3);+        err("kicad_sch_move_region", &p, json!({"box": [0, 0, 1, 1], "by": [1, 0]}), "nothing_in_box");+        let v = ok("kicad_sch_annotate", &p, json!({"dryRun": true}));+        assert_eq!(v["plan"].as_array().unwrap().len(), 5);+        assert!(sheet(&p).components(None).iter().any(|c| c["reference"] == "R?"));+        let v = ok("kicad_sch_annotate", &p, json!({}));+        assert_eq!(v["annotated"], 5);+        assert!(sheet(&p).components(None).iter().all(|c| !c["reference"].as_str().unwrap().ends_with('?')));+        let v = ok("kicad_sch_annotate", &p, json!({}));+        assert_eq!(v["annotated"], 0);+        let v = ok("kicad_sch_reset_fields", &p, json!({"all": true}));+        assert!(v["symbols"].as_array().unwrap().len() >= 8);+        err("kicad_sch_reset_fields", &p, json!({}), "missing_arg");+    }++    #[test]+    fn replace_and_update_from_library() {+        let p = temp_sheet("lib");+        let lib = norm(&fixtures().join("MiniLib.kicad_sym"));+        let v = ok("kicad_sch_replace_symbol", &p, json!({"reference": "R1", "libId": "Device:R_Small", "libraryPath": lib}));+        assert_eq!(v["libSymbolAdded"], true);+        assert_eq!(sheet(&p).components(Some(&["R1".to_string()]))[0]["libId"], "Device:R_Small");+        err("kicad_sch_replace_symbol", &p, json!({"reference": "R1", "libId": "Device:Nope", "libraryPath": lib}), "symbol_not_found");+        err("kicad_sch_replace_symbol", &p, json!({"reference": "U1", "libId": "Device:R_Small"}), "unit_missing");+        let v = ok("kicad_sch_update_from_library", &p, json!({"libIds": ["Device:R", "Device:R_Small"], "libraryPath": lib}));+        assert_eq!(v["unchanged"].as_array().unwrap().len(), 2, "{v}");+        let v = ok("kicad_sch_update_from_library", &p, json!({"libraryPath": lib}));+        assert!(v["notFound"].as_array().unwrap().iter().any(|n| n["libId"] == "power:GND"), "GND is not in MiniLib: {v}");+        err("kicad_sch_update_from_library", &p, json!({"libIds": ["Foo:Bar"]}), "not_found");+    }++    #[test]+    fn wiring_verbs() {+        let p = temp_sheet("wire");+        // kicad_sch_wire adds the T dot+        let v = ok("kicad_sch_wire", &p, json!({"points": [[115.57, 60.96], [115.57, 66.04]]}));+        assert_eq!(v["junctionsAdded"].as_array().unwrap().len(), 1);+        let v = ok("kicad_sch_delete_wire", &p, json!({"from": [110.49, 71.12], "to": [110.49, 60.96]}));+        assert_eq!(v["junctionsPruned"].as_array().unwrap().len(), 1, "the T at 110.49 is gone, so is its dot");+        err("kicad_sch_delete_wire", &p, json!({"from": [0, 0], "to": [1, 1]}), "not_found");+        let v = ok("kicad_sch_split_wire", &p, json!({"at": [105.41, 60.96]}));+        assert!(v["junction"].is_string());+        err("kicad_sch_split_wire", &p, json!({"at": [0, 0]}), "not_on_wire");+        let v = ok("kicad_sch_junction", &p, json!({"points": [[100.33, 60.96], [105.41, 60.96]]}));+        assert_eq!(v["added"].as_array().unwrap().len(), 1);+        assert_eq!(v["skipped"].as_array().unwrap().len(), 1);+        assert_eq!(v["notNeeded"].as_array().unwrap().len(), 1, "a corner does not need a dot");+        let v = ok("kicad_sch_junction", &p, json!({"at": [100.33, 60.96], "remove": true}));+        assert_eq!(v["removed"].as_array().unwrap().len(), 1);+        err("kicad_sch_junction", &p, json!({"at": [1, 1], "remove": true}), "not_found");+        // pins to pins and pins to nets+        let v = ok("kicad_sch_connect_pins", &p, json!({"from": "U1.1", "to": "R2.2"}));+        assert_eq!(v["routes"][0]["points"].as_array().unwrap().len(), 3);+        let s = sheet(&p);+        let all = s.all_pins();+        assert!(s.net_at(s.find_pin("U1.1").unwrap().at, &all).1.contains(&"R2.2".to_string()));+        err("kicad_sch_connect_pins", &p, json!({"from": "U1.1", "to": "R2.99"}), "pin_not_found");+        let v = ok("kicad_sch_connect_to_net", &p, json!({"pins": ["U1.8", "U1.4"], "net": "VCC", "kind": "global"}));+        assert_eq!(v["connected"][0]["end"], json!([180.34, 85.09]));+        assert_eq!(v["connected"][1]["direction"], "down");+        let v = ok("kicad_sch_connect_to_net", &p, json!({"pin": "C1.1", "net": "GND", "kind": "power"}));+        assert!(v["connected"][0]["powerReference"].as_str().unwrap().starts_with("#PWR"));+        let s = sheet(&p);+        let all = s.all_pins();+        assert!(s.net_at(s.find_pin("C1.1").unwrap().at, &all).0.contains(&"GND".to_string()));+        // no-connects: arrays, skip, remove+        let v = ok("kicad_sch_no_connect", &p, json!({"pins": ["U1.2", "U1.3", "R2.1"]}));+        assert_eq!(v["added"].as_array().unwrap().len(), 2);+        assert_eq!(v["skipped"].as_array().unwrap().len(), 1);+        let v = ok("kicad_sch_no_connect", &p, json!({"pin": "U1.2", "remove": true}));+        assert_eq!(v["removed"].as_array().unwrap().len(), 1);+        let v = ok("kicad_sch_no_connect", &p, json!({"pin": "U1.6"}));+        assert_eq!(v["pin"], "U1.6", "the single-pin reply keeps its shape");+        err("kicad_sch_no_connect", &p, json!({"pin": "U1.6"}), "already_flagged");+        // labels+        let v = ok("kicad_sch_edit_label", &p, json!({"net": "VOUT", "by": [0, -2.54]}));+        assert_eq!(v["edited"].as_array().unwrap().len(), 2);+        ok("kicad_sch_edit_label", &p, json!({"text": "SDA", "rotation": 180, "rename": "SDA0"}));+        assert!(std::fs::read_to_string(&p).unwrap().contains("(global_label \"SDA0\""));+        err("kicad_sch_edit_label", &p, json!({"net": "VOUT", "to": [1, 1]}), "ambiguous");+        err("kicad_sch_edit_label", &p, json!({"text": "NOPE", "by": [1, 1]}), "not_found");+        // delete by reference+        let v = ok("kicad_sch_delete", &p, json!({"reference": "U1"}));+        assert_eq!(v["uuids"].as_array().unwrap().len(), 3);+    }++    #[test]+    fn text_and_buses() {+        let p = temp_sheet("bus");+        ok("kicad_sch_text", &p, json!({"text": "Note", "at": [25.4, 25.4], "size": 2.54, "justify": "left top"}));+        err("kicad_sch_text", &p, json!({"text": "Note", "at": [25.4, 25.4], "rotation": 45}), "bad_arg");+        let v = ok("kicad_sch_bus_entry", &p, json!({"at": [200.66, 63.5], "direction": "up-right"}));+        assert_eq!(v["onBus"], true);+        let v = ok("kicad_sch_bus", &p, json!({"points": [[220.98, 50.8], [220.98, 120.65]], "label": "D[0..1]"}));+        let bus = v["uuid"].as_str().unwrap().to_string();+        err("kicad_sch_connect_to_bus", &p, json!({"pins": ["U1.1"], "prefix": "D"}), "missing_arg");+        let v = ok("kicad_sch_connect_to_bus", &p, json!({"pins": ["U1.1", "U1.7"], "bus": bus, "prefix": "D"}));+        let c = &v["connected"];+        assert_eq!(c[0]["member"], "D0");+        assert_eq!(c[1]["member"], "D1");+        assert_eq!(c[0]["entryFrom"], json!([218.44, 81.28]));+        assert_eq!(c[0]["entryTo"], json!([220.98, 78.74]));+        let s = sheet(&p);+        assert!(s.labels().iter().any(|l| l.text == "D1" && l.at == (218.44, 109.22) && l.rotation == 180.0));+        err("kicad_sch_connect_to_bus", &p, json!({"pins": ["U1.6"], "bus": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "members": ["X"], "entry": "up"}), "entry_off_bus");+    }++    #[test]+    fn batch_is_all_or_nothing() {+        let p = temp_sheet("batch");+        let lib = norm(&fixtures().join("MiniLib.kicad_sym"));+        let before = std::fs::read_to_string(&p).unwrap();+        let ops = json!([+            {"op": "place_symbol", "libId": "Device:R", "reference": "R9", "at": [60.96, 50.8]},+            {"op": "connect_pins", "from": "R9.2", "to": "R1.1"},+            {"op": "kicad_sch_connect_to_net", "pin": "R9.1", "net": "VIN"},+            {"op": "rotate", "reference": "C1", "rotation": 90},+            {"op": "replace_symbol", "reference": "R2", "libId": "Device:R_Small", "libraryPath": lib},+            {"op": "text", "text": "batched", "at": [30, 30]},+            {"op": "annotate"},+        ]);+        let v = ok("kicad_sch_batch", &p, json!({"ops": ops, "dryRun": true}));+        assert_eq!(v["ops"], 7);+        assert_eq!(std::fs::read_to_string(&p).unwrap(), before);+        let v = ok("kicad_sch_batch", &p, json!({"ops": ops}));+        assert_eq!(v["results"].as_array().unwrap().len(), 7);+        let s = sheet(&p);+        let all = s.all_pins();+        let (nets, pins) = s.net_at(s.find_pin("R9.1").unwrap().at, &all);+        assert_eq!(nets, vec!["VIN".to_string()]);+        assert!(pins.contains(&"R9.1".to_string()));+        assert!(s.net_at(s.find_pin("R9.2").unwrap().at, &all).1.contains(&"R1.1".to_string()));+        assert!(s.components(None).iter().all(|c| !c["reference"].as_str().unwrap().ends_with('?')));+        // a failing op aborts everything+        let after = std::fs::read_to_string(&p).unwrap();+        let v = err("kicad_sch_batch", &p, json!({"ops": [{"op": "text", "text": "x", "at": [1, 1]}, {"op": "move", "reference": "Q1", "by": [1, 1]}]}), "not_found");+        assert_eq!(v["failedOp"], 1);+        assert_eq!(std::fs::read_to_string(&p).unwrap(), after);+        err("kicad_sch_batch", &p, json!({"ops": [{"op": "batch", "ops": []}]}), "bad_arg");+        err("kicad_sch_batch", &p, json!({"ops": [{"op": "frobnicate"}]}), "bad_arg");+        err("kicad_sch_batch", &p, json!({}), "missing_arg");+    }+}
rust/crates/kicad-bridge/src/verbs_sch_hier.rsadded+1078
@@ -0,0 +1,1078 @@+//! Verb group "sch_hier": schematic files, hierarchy, analysis, render and design review.+//! kicad_sch_create, kicad_sch_page, kicad_sch_sheet, kicad_sch_hierarchy,+//! kicad_sch_sheet_pins, kicad_sch_trace_point, kicad_sch_check,+//! kicad_sch_fix_connectivity, kicad_render_schematic, kicad_design_review.+//!+//! The logic lives in kicad-core (sch_model, sch_hier, sch_sheets, sch_check, sch_review,+//! sch_render); this file is arguments, replies and the kicad-cli lookup. Every mutating verb+//! takes dryRun, writes atomically with a .bak beside each changed file, parses every written+//! file back before answering, and never overwrites an existing file it was asked to create.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::detect::KicadInfo;+use kicad_core::sch_check::{self, CheckOpts};+use kicad_core::sch_hier::{self as hier, Hierarchy, Paper, Side, TitleEdit};+use kicad_core::sch_model::{self, Connectivity};+use kicad_core::sch_render::{self, BaselineMode, RenderJob, SvgOpts};+use kicad_core::sch_review;+use kicad_core::sch_sheets::Workspace;+use kicad_core::schematic::Schematic;++const RELOAD: &str = "Edits are on disk only: an open Schematic Editor keeps its own copy and its next save overwrites them. Close and reopen the sheet (kicad_close, then kicad_open_schematic) before looking at it in KiCad.";++const DISK: &str = "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit";++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_sch_create",+        summary: "Create a blank KiCad 10 schematic (.kicad_sch) with a paper size and title block; optionally add it to an existing project as a hierarchical sheet of the root, or make it the root of a new project.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"paper\"?: \"A4\"|\"A3\"|...|\"USLetter\"|\"custom\", \"portrait\"?: bool, \"width\"?: mm, \"height\"?: mm, \"title\"?, \"date\"?, \"rev\"?, \"company\"?, \"comments\"?: [\"line 1\", ...], \"projectPath\"?: \"C:/.../p.kicad_pro\", \"sheetName\"?: \"Power\", \"at\"?: [x, y], \"createProject\"?: bool, \"dryRun\"?: bool}",+        example: "kicad_sch_create {\"filePath\":\"C:/Users/john/proj/power.kicad_sch\",\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\",\"sheetName\":\"Power\",\"title\":\"Power\"}",+        hint: "Without projectPath the file stands alone (createProject:true also writes <stem>.kicad_pro beside it, making it a project root). With projectPath the new file is linked from the project's root sheet as a hierarchical sheet (sheetName, at) and gets the next page number; when filePath IS the project's root (same stem as the .kicad_pro) it is simply created. Never overwrites: an existing file is refused.",+        related: &["kicad_sch_page", "kicad_sch_sheet", "kicad_sch_hierarchy", "kicad_sch_place_symbol"],+        pitfalls: &["The file is written in KiCad 10 format (version 20260306); KiCad 9 and older cannot open it.", "A sheet added to the root has no pins yet: add hierarchical labels inside, then kicad_sch_sheet_pins {import:true}.", DISK],+    },+    Verb {+        name: "kicad_sch_page",+        summary: "Read or set a schematic's page: paper size (A5 to A0, A to E, USLetter, USLegal, USLedger, custom mm), orientation and title block (title, date, rev, company, comments 1 to 9).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"paper\"?: \"A3\", \"portrait\"?: bool | \"orientation\"?: \"portrait\"|\"landscape\", \"width\"?: mm, \"height\"?: mm, \"title\"?, \"date\"?, \"rev\"?, \"company\"?, \"comments\"?: {\"1\": \"...\"} | [\"...\"], \"dryRun\"?: bool}",+        example: "kicad_sch_page {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"paper\":\"A3\",\"title\":\"Main board\",\"rev\":\"B\"}",+        hint: "With no change arguments it only reports the current page (paper, size in mm, title block). An empty string removes a title block field. custom paper takes width and height in mm. Each sheet of a hierarchy has its own page settings.",+        related: &["kicad_sch_create", "kicad_render_schematic"],+        pitfalls: &["Shrinking the paper does not move anything: items outside the new frame stay where they are (kicad_render_schematic shows it).", DISK],+    },+    Verb {+        name: "kicad_sch_sheet",+        summary: "Hierarchical sheets on a page: add (creates the child file when missing and links it), edit (name, file, size, position), move, delete (the child file is kept), duplicate (a new child file with fresh uuids and free references).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../parent.kicad_sch\", \"action\": \"add\"|\"edit\"|\"move\"|\"delete\"|\"duplicate\", \"sheet\"?: \"Power\" | uuid, \"name\"?: \"Power\", \"file\"?: \"power.kicad_sch\", \"at\"?: [x, y], \"size\"?: [w, h], \"newName\"?, \"to\"?: [x, y], \"by\"?: [dx, dy], \"importPins\"?: bool, \"projectPath\"?: \"C:/.../p.kicad_pro\", \"dryRun\"?: bool}",+        example: "kicad_sch_sheet {\"filePath\":\"C:/Users/john/proj/proj.kicad_sch\",\"action\":\"add\",\"name\":\"Power\",\"file\":\"power.kicad_sch\",\"at\":[50.8,38.1],\"size\":[30.48,20.32]}",+        hint: "file is relative to the parent sheet's folder (eeschema's convention). add without at places the sheet right of the existing ones; importPins:true adds a pin per hierarchical label already in an existing child. duplicate copies the child file to <stem>_copy.kicad_sch (or file), copies the pins, and renumbers the copy's parts so no reference repeats. Pages continue after the highest page in the design; kicad_sch_hierarchy {renumber:true} tidies them.",+        related: &["kicad_sch_sheet_pins", "kicad_sch_hierarchy", "kicad_sch_create", "kicad_sch_check"],+        pitfalls: &["move and edit at do not drag wires: wires that ended on the sheet pins dangle afterwards (kicad_sch_check finds them).", "delete keeps the child file on disk and in other sheets that use it; delete the file yourself if nothing else needs it.", "A sheet reused in several places is one file: editing the child changes every use.", DISK],+    },+    Verb {+        name: "kicad_sch_hierarchy",+        summary: "The sheet tree of a design, recursively: each sheet's name, file, uuid, instance path, page, position, size and pins; renumber:true reassigns page numbers depth-first (root 1).",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../root.kicad_sch\" | \"projectPath\": \"C:/.../p.kicad_pro\", \"renumber\"?: bool, \"dryRun\"?: bool}",+        example: "kicad_sch_hierarchy {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\",\"renumber\":true}",+        hint: "Pass the root sheet (or the project). problems lists sheets whose file is missing, unreadable or recursive; duplicatePages lists page numbers used twice. renumber writes only when a number changes (dryRun shows the plan).",+        related: &["kicad_sch_sheet", "kicad_sch_sheet_pins", "kicad_design_review"],+        pitfalls: &["A sheet file used by two sheet symbols appears twice in the tree (one instance each) but is one file.", "renumber rewrites page numbers in the parent files' sheet symbols, not in the children.", DISK],+    },+    Verb {+        name: "kicad_sch_sheet_pins",+        summary: "A sheet's pins: list, add, edit (rename, shape, move along an edge), delete; import:true adds a pin for every hierarchical label in the child not yet on the sheet; validate:true reports labels without pins, pins without labels and shape mismatches.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../parent.kicad_sch\", \"sheet\"?: \"Power\" | uuid, \"action\"?: \"list\"|\"add\"|\"edit\"|\"delete\", \"name\"?: \"VIN\", \"shape\"?: \"input\"|\"output\"|\"bidirectional\"|\"tri_state\"|\"passive\", \"side\"?: \"left\"|\"right\"|\"top\"|\"bottom\", \"at\"?: [x, y], \"newName\"?, \"import\"?: bool, \"validate\"?: bool, \"dryRun\"?: bool}",+        example: "kicad_sch_sheet_pins {\"filePath\":\"C:/Users/john/proj/proj.kicad_sch\",\"sheet\":\"Power\",\"import\":true}",+        hint: "Inputs go on the left edge and outputs on the right by default, in the first free 2.54 mm slot; at snaps onto the nearest edge point. validate without sheet checks every sheet on the page. A pin connects to the child's hierarchical label of the same name; wire the pin's point (at in the reply) on the parent side.",+        related: &["kicad_sch_sheet", "kicad_sch_label", "kicad_sch_wire", "kicad_sch_hierarchy"],+        pitfalls: &["Renaming a pin does not rename the label inside the child; validate afterwards.", "A pin whose wire is deleted or moved leaves the wire dangling.", DISK],+    },+    Verb {+        name: "kicad_sch_trace_point",+        summary: "Everything electrically connected at a point on a sheet: the wires, labels, pins, junctions, sheet pins and no-connect flags there, and for each net they belong to the whole net (pins, labels, power symbols, wires) with its name.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"at\": [x_mm, y_mm], \"tolerance\"?: 0.05}",+        example: "kicad_sch_trace_point {\"filePath\":\"C:/Users/john/proj/power.kicad_sch\",\"at\":[109.22,73.66]}",+        hint: "Read from the file with eeschema's rules: wire ends join wires and wire interiors (a T), labels and junction dots join a wire anywhere along it, pins join wire ends (mid-wire only through a junction). crossingWithoutJunction:true means two wires cross at the point without connecting. Nothing within tolerance: the reply names the nearest connectable point.",+        related: &["kicad_sch_check", "kicad_netlist", "kicad_trace_net"],+        pitfalls: &["One sheet only: a net that leaves through a global, hierarchical or power label (leavesSheet:true) continues on other sheets; kicad_netlist gives the compiled net.", "Reads the saved file: unsaved edits in an open editor are not in it."],+    },+    Verb {+        name: "kicad_sch_check",+        summary: "Connectivity and layout check of a sheet from the file: dangling wire ends, floating labels, unconnected non-passive pins, nets that reach only one pin, distinct net names shorted together, no-connect flags on wired pins, and with overlaps:true symbol, label and text collisions (symbol body and pin extents). Each finding has a location and a fix.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"overlaps\"?: bool, \"checks\"?: [\"dangling_wire_end\", \"floating_label\", \"unconnected_pin\", \"single_pin_net\", \"shorted_nets\", \"no_connect_connected\", \"overlap\"], \"hierarchy\"?: bool}",+        example: "kicad_sch_check {\"filePath\":\"C:/Users/john/proj/power.kicad_sch\",\"overlaps\":true}",+        hint: "Fast and needs no kicad-cli, so run it after every batch of kicad_sch_* edits; kicad_run_erc is the authoritative check. hierarchy:true checks every sheet of the project (findings carry the sheet). Near-miss wire ends say how far they are from what they should touch: kicad_sch_fix_connectivity snaps them.",+        related: &["kicad_sch_fix_connectivity", "kicad_sch_trace_point", "kicad_run_erc", "kicad_design_review"],+        pitfalls: &["Single-pin judgement stops at the sheet edge: nets with a global, hierarchical or power name are counted in crossSheetSkipped, not flagged.", "Passive pins left open are not flagged here (ERC flags them).", "Overlap extents are estimates for text (KiCad's stroke font metrics are approximated)."],+    },+    Verb {+        name: "kicad_sch_fix_connectivity",+        summary: "Snap near-miss wire ends onto the pin, label, sheet pin or wire end they almost touch (within a tolerance). dryRun (the default) lists the moves; dryRun:false writes them.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"tolerance\"?: 0.5, \"dryRun\"?: true}",+        example: "kicad_sch_fix_connectivity {\"filePath\":\"C:/Users/john/proj/power.kicad_sch\",\"tolerance\":0.5,\"dryRun\":false}",+        hint: "Only dangling wire ends move, each to the nearest connectable point within tolerance (mm). Read moves before applying: diagonal:true means the snapped segment is no longer horizontal or vertical. unresolved lists dangling ends with nothing close. Run kicad_sch_check afterwards.",+        related: &["kicad_sch_check", "kicad_sch_trace_point", "kicad_sch_wire"],+        pitfalls: &["dryRun defaults to TRUE: nothing is written until dryRun:false.", "A wide tolerance can snap a stub onto the wrong pin; keep it under half the grid (1.27 mm).", DISK],+    },+    Verb {+        name: "kicad_render_schematic",+        summary: "Render a schematic sheet to PNG (kicad-cli sch export svg, rasterised in the bridge), and keep visual baselines: baseline:\"set\" stores the image under <project>/.adom/baselines/, baseline:\"compare\" reports the pixel drift percentage, the changed region and a diff image.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\" | \"projectPath\": \"C:/.../p.kicad_pro\", \"sheet\"?: \"Power\", \"width\"?: 2000, \"outputDir\"?: \"C:/...\", \"baseline\"?: \"set\"|\"compare\", \"threshold\"?: 0.5, \"drawingSheet\"?: true, \"blackAndWhite\"?: bool, \"theme\"?: \"KiCad Default\"}",+        example: "kicad_render_schematic {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\",\"sheet\":\"Power\",\"baseline\":\"compare\"}",+        hint: "kicad-cli plots from the project's root so a sub-sheet renders with its real references; filePath may be any sheet of the project (or name it with sheet). The PNG lands in outputDir (default <project>/.adom/render/). compare renders at the baseline's width and passes when driftPercent <= threshold (percent of pixels, default 0.5); changedRegionMm is in sheet millimetres, diffPng paints the changed pixels red. sourceChanged and rendererChanged say whether the files or the kicad-cli differ from when the baseline was set.",+        related: &["kicad_export_svg", "kicad_sch_check", "kicad_sch_page"],+        pitfalls: &["Needs kicad-cli on this box (KiCad 8 or newer); with none it answers not_installed with the alternatives.", "A baseline is per sheet and per width; a paper size change is reported as sizeChanged (100 percent drift).", "Anti-aliasing differs between kicad-cli versions: rendererChanged:true means drift may not be a design change."],+    },+    Verb {+        name: "kicad_design_review",+        summary: "Schematic design review from the netlist: ICs without a decoupling capacitor on each power-pin net, I2C lines without pull-ups, floating or undriven reset and enable pins, LEDs without a series resistor, regulators without input or output capacitors, rails without bulk capacitance, unannotated or duplicate references. Each finding has severity, refs, nets, why and how to fix.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 150,+        input: "{\"filePath\": \"C:/.../root.kicad_sch\" | \"projectPath\": \"C:/.../p.kicad_pro\", \"source\"?: \"auto\"|\"netlist\"|\"file\", \"checks\"?: [\"decoupling\", \"i2c_pullups\", \"reset_enable\", \"led_resistor\", \"regulator_caps\", \"bulk_capacitance\", \"annotation\"]}",+        example: "kicad_design_review {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+        hint: "Pass the root sheet. source auto uses kicad-cli's compiled netlist when kicad-cli is installed and the bridge's own compile of the files otherwise (the reply says which). Findings are heuristics from names, pin types and values: read why before acting. Pair with kicad_run_erc (rules) and Adom Fields (board physics).",+        related: &["kicad_run_erc", "kicad_sch_check", "kicad_netlist", "kicad_sch_place_symbol"],+        pitfalls: &["Parts are classified by reference prefix and library (C, R, D+LED, U, Regulator_*): odd prefixes are reviewed less.", "Capacitor values are read from the Value field (100n, 4.7uF, 4u7); an unreadable value counts as present but unsized.", "A pull-up or decoupling part on another board is invisible here; say so in the schematic (a note) and ignore the finding."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_sch_create" => create(args),+        "kicad_sch_page" => page(args),+        "kicad_sch_sheet" => sheet(args),+        "kicad_sch_hierarchy" => hierarchy(args),+        "kicad_sch_sheet_pins" => sheet_pins(args),+        "kicad_sch_trace_point" => trace_point(args),+        "kicad_sch_check" => check(args),+        "kicad_sch_fix_connectivity" => fix_connectivity(args),+        "kicad_render_schematic" => render(state, args),+        "kicad_design_review" => design_review(state, args),+        _ => return None,+    })+}++// ---------------------------------------------------------------------------+// Arguments+// ---------------------------------------------------------------------------++fn sch_arg(args: &Value, must_exist: bool) -> Result<PathBuf, Value> {+    let p = match arg_any(args, &["filePath", "schematicPath", "path"]) {+        Some(p) => PathBuf::from(p.trim()),+        None => match arg_str(args, "projectPath") {+            Some(pro) => PathBuf::from(pro.trim()).with_extension("kicad_sch"),+            None => return Err(fail("missing_arg", "filePath is required", "Pass the absolute path of the .kicad_sch (or projectPath for the project's root sheet).")),+        },+    };+    if !p.to_string_lossy().to_ascii_lowercase().ends_with(".kicad_sch") {+        return Err(fail("wrong_file_type", format!("{} is not a .kicad_sch", norm(&p)), "These verbs work on schematic sheets; boards have their own verbs."));+    }+    if must_exist && !p.is_file() {+        return Err(fail("file_not_found", format!("{} does not exist", norm(&p)), "Check the path (absolute, on this machine). kicad_sch_create makes a new sheet."));+    }+    Ok(p)+}++/// The project root override: projectPath's root sheet when given.+fn root_arg(args: &Value) -> Result<Option<PathBuf>, Value> {+    match arg_str(args, "projectPath") {+        Some(pro) => {+            let pro = PathBuf::from(pro.trim());+            let root = pro.with_extension("kicad_sch");+            if !root.is_file() {+                return Err(fail("file_not_found", format!("the project's root sheet {} does not exist", norm(&root)), "projectPath names the .kicad_pro; its root sheet is the .kicad_sch with the same name beside it."));+            }+            Ok(Some(root))+        }+        None => Ok(None),+    }+}++fn point(args: &Value, key: &str) -> Result<Option<(f64, f64)>, Value> {+    let Some(v) = args.get(key) else { return Ok(None) };+    if v.is_null() {+        return Ok(None);+    }+    let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| fail("bad_arg", format!("{key} must be [x_mm, y_mm]"), "Two numbers in millimetres, sheet coordinates (origin top-left, y down)."))?;+    match (a[0].as_f64(), a[1].as_f64()) {+        (Some(x), Some(y)) => Ok(Some((x, y))),+        _ => Err(fail("bad_arg", format!("{key} must hold two numbers"), "Millimetres as numbers, not strings.")),+    }+}++fn need_point(args: &Value, key: &str) -> Result<(f64, f64), Value> {+    point(args, key)?.ok_or_else(|| fail("missing_arg", format!("{key} is required"), format!("Pass {key} as [x_mm, y_mm].")))+}++fn dry(args: &Value, default: bool) -> bool {+    args.get("dryRun").and_then(Value::as_bool).unwrap_or(default)+}++fn title_edit(args: &Value) -> Result<TitleEdit, Value> {+    let s = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);+    let mut e = TitleEdit { title: s("title"), date: s("date"), rev: s("rev"), company: s("company"), comments: vec![] };+    match args.get("comments") {+        Some(Value::Array(a)) => {+            for (i, c) in a.iter().enumerate().take(9) {+                if let Some(t) = c.as_str() {+                    e.comments.push((i as u32 + 1, t.to_string()));+                }+            }+        }+        Some(Value::Object(o)) => {+            for (k, v) in o {+                let n: u32 = k.trim().parse().map_err(|_| fail("bad_arg", format!("comment key {k} is not a number 1 to 9"), "comments is {\"1\": \"text\", ...} or an array of lines."))?;+                if !(1..=9).contains(&n) {+                    return Err(fail("bad_arg", format!("comment {n} is out of range"), "KiCad has comments 1 to 9."));+                }+                e.comments.push((n, v.as_str().unwrap_or("").to_string()));+            }+        }+        _ => {}+    }+    for n in 1..=9u32 {+        if let Some(t) = s(&format!("comment{n}")) {+            e.comments.push((n, t));+        }+    }+    Ok(e)+}++fn paper_arg(args: &Value, current: Option<&Paper>) -> Result<Option<Paper>, Value> {+    let name = arg_str(args, "paper");+    let w = args.get("width").and_then(Value::as_f64);+    let h = args.get("height").and_then(Value::as_f64);+    let portrait = match (args.get("portrait").and_then(Value::as_bool), arg_str(args, "orientation")) {+        (Some(p), _) => Some(p),+        (None, Some(o)) => match o.trim().to_ascii_lowercase().as_str() {+            "portrait" => Some(true),+            "landscape" => Some(false),+            other => return Err(fail("bad_arg", format!("orientation {other} is not portrait or landscape"), "Pass orientation:\"portrait\" or \"landscape\" (or portrait:true).")),+        },+        _ => None,+    };+    if name.is_none() && w.is_none() && portrait.is_none() {+        return Ok(None);+    }+    let base_name = name.map(str::to_string).or_else(|| current.map(|c| c.name.clone())).unwrap_or_else(|| "A4".into());+    let base_portrait = portrait.or_else(|| current.map(|c| c.portrait)).unwrap_or(false);+    let (w, h) = match (w, h, current.and_then(|c| c.custom)) {+        (None, None, Some(c)) if name.is_none() => (Some(c.0), Some(c.1)),+        x => (x.0, x.1),+    };+    let p = Paper::parse(&base_name, w, h, base_portrait).map_err(|e| fail("bad_arg", e, "paper is A5..A0, A..E, USLetter, USLegal, USLedger, or custom with width and height in mm."))?;+    Ok(Some(p))+}++/// Parse every file back after writing, so a reply never claims a file KiCad could not load.+fn reparse(files: &[PathBuf]) -> Result<(), Value> {+    for f in files {+        if let Err(e) = Schematic::load(f) {+            return Err(fail("write_verification_failed", format!("{} was written but does not parse back: {e}", norm(f)), "Restore it from the .bak beside it and report this with the request (adom-wiki issue create adom/kicad-bridge)."));+        }+    }+    Ok(())+}++/// Commit a workspace (unless dry), parse the files back, and build the files block.+fn commit(ws: &Workspace, dry_run: bool) -> Result<Value, Value> {+    let plan: Vec<Value> = ws.plan().iter().map(|(p, new)| json!({"path": norm(p), "new": new})).collect();+    if dry_run {+        return Ok(json!({"dryRun": true, "wouldWrite": plan}));+    }+    let w = ws.commit().map_err(|e| fail("write_failed", e, "Check the files are writable and not locked; .bak files beside changed ones hold the previous content."))?;+    let mut all: Vec<PathBuf> = w.changed.iter().map(|(p, _)| p.clone()).collect();+    all.extend(w.created.iter().cloned());+    reparse(&all)?;+    Ok(json!({+        "dryRun": false,+        "changed": w.changed.iter().map(|(p, b)| json!({"path": norm(p), "backup": norm(b)})).collect::<Vec<_>>(),+        "created": w.created.iter().map(|p| norm(p)).collect::<Vec<_>>(),+        "reparsed": true,+    }))+}++fn merge(mut a: Value, b: Value) -> Value {+    if let (Some(o), Some(e)) = (a.as_object_mut(), b.as_object()) {+        for (k, v) in e {+            o.insert(k.clone(), v.clone());+        }+    }+    a+}++fn open_ws(file: &Path, args: &Value) -> Result<Workspace, Value> {+    let root = root_arg(args)?;+    Workspace::open(file, root.as_deref()).map_err(|e| fail("parse_failed", e, "The sheet (or its project's root) could not be read; run kicad_lint_schematic on it."))+}++// ---------------------------------------------------------------------------+// kicad_sch_create / kicad_sch_page+// ---------------------------------------------------------------------------++fn create(args: &Value) -> Value {+    let path = match sch_arg(args, false) {+        Ok(p) => p,+        Err(v) => return v,+    };+    if path.exists() {+        return fail("file_exists", format!("{} already exists", norm(&path)), "kicad_sch_create never overwrites. Use kicad_sch_page to change its paper or title block, or pick another file name.");+    }+    let paper = match paper_arg(args, None) {+        Ok(p) => p.unwrap_or(Paper { name: "A4".into(), custom: None, portrait: false }),+        Err(v) => return v,+    };+    let title = match title_edit(args) {+        Ok(t) => t,+        Err(v) => return v,+    };+    let (text, uuid) = hier::blank_sheet(&paper, &title);+    let dry_run = dry(args, false);+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let mut out = json!({"success": true, "path": norm(&path), "uuid": uuid, "page": paper.json(), "titleBlock": hier::title_block_json(&Schematic::parse(&text).expect("blank parses"))});++    if let Some(pro) = arg_str(args, "projectPath") {+        let pro = PathBuf::from(pro.trim());+        if !pro.is_file() {+            return fail("file_not_found", format!("project {} does not exist", norm(&pro)), "Pass an existing .kicad_pro, or omit projectPath and use createProject:true to start a new project.");+        }+        let root = pro.with_extension("kicad_sch");+        if same_path(&root, &path) {+            // the project's own root sheet+            out["role"] = json!("root");+        } else {+            if !root.is_file() {+                return fail("file_not_found", format!("the project has no root sheet {}", norm(&root)), "Create the root first: kicad_sch_create {filePath: <project stem>.kicad_sch}, then add this sheet.");+            }+            let mut ws = match Workspace::open(&root, Some(&root)) {+                Ok(w) => w,+                Err(e) => return fail("parse_failed", e, "The project's root sheet could not be read; run kicad_lint_schematic on it."),+            };+            let rel = match path.strip_prefix(root.parent().unwrap_or(Path::new("."))) {+                Ok(r) => r.to_string_lossy().replace('\\', "/"),+                Err(_) => norm(&path),+            };+            let name = arg_str(args, "sheetName").map(str::to_string).unwrap_or_else(|| stem.clone());+            let at = match point(args, "at") {+                Ok(a) => a,+                Err(v) => return v,+            };+            let added = match ws.add_sheet(&name, &rel, at, None, false) {+                Ok(a) => a,+                Err(e) => return fail("edit_failed", e, "Nothing was written. Fix the argument the error names (sheetName must be unique on the root page)."),+            };+            // our blank (paper, title block) replaces the default one add_sheet queued+            for nf in ws.new_files.iter_mut() {+                if same_path(&nf.0, &path) {+                    nf.1 = text.clone();+                }+            }+            out["role"] = json!("sheet");+            out["sheet"] = added;+            out["root"] = json!(norm(&root));+            let files = match commit(&ws, dry_run) {+                Ok(f) => f,+                Err(v) => return v,+            };+            out["files"] = files;+            out["output"] = json!(format!("{} {} and linked it as sheet {name} of {}", if dry_run { "Would create" } else { "Created" }, norm(&path), file_name(&root)));+            out["reloadHint"] = json!(RELOAD);+            out["_hint"] = json!("The sheet has no pins yet: draw its circuit with hierarchical labels (kicad_sch_label kind:\"hierarchical\"), then kicad_sch_sheet_pins {import:true} on the root and wire the pins there.");+            out["_next"] = json!(["kicad_sch_place_symbol", "kicad_sch_label", "kicad_sch_sheet_pins", "kicad_sch_hierarchy"]);+            return out;+        }+    }+    let mut created = vec![];+    if !dry_run {+        if let Some(d) = path.parent() {+            if let Err(e) = std::fs::create_dir_all(d) {+                return fail("write_failed", format!("cannot create {}: {e}", d.display()), "Pass a folder the desktop user can write.");+            }+        }+        if let Err(e) = kicad_core::libraries::write_atomic(&path, text.as_bytes()) {+            return fail("write_failed", e, "Pass a folder the desktop user can write.");+        }+        created.push(path.clone());+    }+    if args.get("createProject").and_then(Value::as_bool).unwrap_or(false) {+        let pro = path.with_extension("kicad_pro");+        if pro.exists() {+            out["project"] = json!({"path": norm(&pro), "created": false, "note": "already there"});+        } else {+            if !dry_run {+                if let Err(e) = kicad_core::libraries::write_atomic(&pro, hier::blank_project(&stem).as_bytes()) {+                    return fail("write_failed", e, "The schematic was written; the .kicad_pro could not be.");+                }+            }+            out["project"] = json!({"path": norm(&pro), "created": !dry_run});+        }+        out["role"] = json!("root");+    }+    if let Err(v) = reparse(&created) {+        return v;+    }+    out["dryRun"] = json!(dry_run);+    out["output"] = json!(format!("{} {} ({} {})", if dry_run { "Would create" } else { "Created" }, norm(&path), paper.name, if paper.portrait { "portrait" } else { "landscape" }));+    out["_hint"] = json!("A blank KiCad 10 sheet. Place parts with kicad_sch_place_symbol, wire with kicad_sch_wire; to make it part of a project pass projectPath (or createProject:true for a new one).");+    out["_next"] = json!(["kicad_sch_place_symbol", "kicad_sch_page", "kicad_open_schematic"]);+    out+}++fn same_path(a: &Path, b: &Path) -> bool {+    let n = |p: &Path| norm(p).to_ascii_lowercase();+    n(a) == n(b) || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).map_or(false, |(x, y)| x == y)+}++fn file_name(p: &Path) -> String {+    p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| norm(p))+}++fn page(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let mut sch = match Schematic::load(&path) {+        Ok(s) => s,+        Err(e) => return fail("parse_failed", e, "Not a schematic this bridge can read; run kicad_lint_schematic."),+    };+    let current = Paper::of(&sch);+    let paper = match paper_arg(args, Some(&current)) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let title = match title_edit(args) {+        Ok(t) => t,+        Err(v) => return v,+    };+    if paper.is_none() && title.is_empty() {+        return json!({"success": true, "path": norm(&path), "page": current.json(), "titleBlock": hier::title_block_json(&sch), "changed": false, "_hint": "Read only: pass paper, portrait or title block fields to change them."});+    }+    let mut changes = Vec::new();+    if let Some(p) = &paper {+        if *p != current {+            hier::set_paper(&mut sch, p);+            changes.push(format!("paper {} -> {}", describe_paper(&current), describe_paper(p)));+        }+    }+    if !title.is_empty() {+        changes.extend(hier::set_title_block(&mut sch, &title));+    }+    let dry_run = dry(args, false);+    let mut out = json!({"success": true, "path": norm(&path), "page": Paper::of(&sch).json(), "titleBlock": hier::title_block_json(&sch), "changes": changes, "dryRun": dry_run});+    if !dry_run && !changes.is_empty() {+        match sch.save_with_backup(&path) {+            Ok(b) => out["backup"] = json!(norm(&b)),+            Err(e) => return fail("write_failed", e, "Check the file is writable and not locked; nothing was changed."),+        }+        if let Err(v) = reparse(&[path.clone()]) {+            return v;+        }+        out["reloadHint"] = json!(RELOAD);+    }+    out["output"] = json!(if changes.is_empty() { "Page already as asked; nothing written".to_string() } else { format!("{} {}", if dry_run { "Would apply:" } else { "Applied:" }, changes.join("; ")) });+    out["_hint"] = json!("Each sheet of a hierarchy has its own paper and title block. kicad_render_schematic shows the result.");+    out+}++fn describe_paper(p: &Paper) -> String {+    let (w, h) = p.size_mm();+    format!("{} {} ({} x {} mm)", p.name, if p.portrait { "portrait" } else { "landscape" }, kicad_core::schematic::fmt_num(w), kicad_core::schematic::fmt_num(h))+}++// ---------------------------------------------------------------------------+// kicad_sch_sheet / kicad_sch_sheet_pins+// ---------------------------------------------------------------------------++fn sheet(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let Some(action) = arg_str(args, "action").map(|a| a.trim().to_ascii_lowercase()) else {+        return fail("missing_arg", "action is required", "action is add, edit, move, delete or duplicate.");+    };+    let mut ws = match open_ws(&path, args) {+        Ok(w) => w,+        Err(v) => return v,+    };+    let key = arg_any(args, &["sheet", "uuid"]).map(|s| s.trim().to_string());+    let need_key = || key.clone().ok_or_else(|| fail("missing_arg", "sheet is required", "Name the sheet (its Sheetname) or pass its uuid; kicad_sch_hierarchy lists them."));+    let pts = |k: &str| point(args, k);+    let size = match pts("size") {+        Ok(s) => s,+        Err(v) => return v,+    };+    let at = match pts("at") {+        Ok(s) => s,+        Err(v) => return v,+    };+    let result = match action.as_str() {+        "add" => {+            let Some(name) = arg_str(args, "name").map(str::trim) else {+                return fail("missing_arg", "name is required to add a sheet", "Pass name (shown on the sheet) and optionally file (default <name>.kicad_sch).");+            };+            let file = arg_str(args, "file").map(str::to_string).unwrap_or_else(|| format!("{}.kicad_sch", name.to_ascii_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "_")));+            ws.add_sheet(name, &file, at, size, args.get("importPins").and_then(Value::as_bool).unwrap_or(false))+        }+        "edit" => match need_key() {+            Ok(k) => ws.edit_sheet(&k, arg_any(args, &["newName", "name"]), arg_str(args, "file"), size, at),+            Err(v) => return v,+        },+        "move" => match need_key() {+            Ok(k) => {+                let to = match pts("to") {+                    Ok(t) => t.or(at),+                    Err(v) => return v,+                };+                let by = match pts("by") {+                    Ok(b) => b,+                    Err(v) => return v,+                };+                ws.move_sheet(&k, to, by)+            }+            Err(v) => return v,+        },+        "delete" => match need_key() {+            Ok(k) => ws.delete_sheet(&k),+            Err(v) => return v,+        },+        "duplicate" => match need_key() {+            Ok(k) => ws.duplicate_sheet(&k, arg_any(args, &["newName", "name"]), arg_str(args, "file"), at),+            Err(v) => return v,+        },+        other => return fail("bad_arg", format!("action {other} is not add, edit, move, delete or duplicate"), "Pick one of the five actions."),+    };+    let detail = match result {+        Ok(d) => d,+        Err(e) => return fail("edit_failed", e, "Nothing was written. Fix the argument the error names and retry."),+    };+    let dry_run = dry(args, false);+    let files = match commit(&ws, dry_run) {+        Ok(f) => f,+        Err(v) => return v,+    };+    let verb = match action.as_str() {+        "add" => "Added",+        "edit" => "Edited",+        "move" => "Moved",+        "delete" => "Deleted",+        _ => "Duplicated",+    };+    let name = detail["name"].as_str().unwrap_or("").to_string();+    let mut out = json!({+        "success": true,+        "action": action,+        "path": norm(&path),+        "output": format!("{}{} sheet {name} on {}", if dry_run { "Dry run: would have " } else { "" }, if dry_run { verb.to_lowercase() } else { verb.to_string() }, file_name(&path)),+        "sheet": detail,+        "files": files,+        "notes": ws.notes,+        "reloadHint": RELOAD,+        "_hint": match action.as_str() {+            "add" => "Next: hierarchical labels inside the child, then kicad_sch_sheet_pins {import:true} here and wire the pins (their points are in the reply).",+            "duplicate" => "The copy has its own file and fresh references (renamed lists them). Wire its pins like the original's.",+            "move" | "edit" => "Wires do not follow a moved sheet or pin: kicad_sch_check lists the ends left dangling.",+            _ => "The child file stays on disk; kicad_sch_hierarchy shows the tree now.",+        },+        "_next": ["kicad_sch_hierarchy", "kicad_sch_sheet_pins", "kicad_sch_check"],+    });+    if dry_run {+        out["reloadHint"] = Value::Null;+    }+    out+}++fn sheet_pins(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let mut ws = match open_ws(&path, args) {+        Ok(w) => w,+        Err(v) => return v,+    };+    let key = arg_any(args, &["sheet", "uuid"]).map(|s| s.trim().to_string());+    let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);+    if flag("validate") {+        return match ws.validate_pins(key.as_deref()) {+            Ok(v) => merge(+                json!({"success": true, "path": norm(&path), "_hint": if v["ok"] == json!(true) { "Every pin meets a label of the same name and shape inside its sheet." } else { "labelsWithoutPin: kicad_sch_sheet_pins {import:true}. pinsWithoutLabel: add the label inside (kicad_sch_label kind:\"hierarchical\") or delete the pin. shapeMismatch: edit the pin's shape." }}),+                v,+            ),+            Err(e) => fail("not_found", e, "kicad_sch_hierarchy lists the sheets on this page."),+        };+    }+    let Some(key) = key else {+        return fail("missing_arg", "sheet is required", "Name the sheet (its Sheetname) or pass its uuid; validate:true without sheet checks every sheet on the page.");+    };+    let action = if flag("import") { "import".to_string() } else { arg_str(args, "action").unwrap_or("list").trim().to_ascii_lowercase() };+    let side = match arg_str(args, "side") {+        Some(s) => match Side::parse(s) {+            Some(x) => Some(x),+            None => return fail("bad_arg", format!("side {s} is not left, right, top or bottom"), "Pick an edge of the sheet."),+        },+        None => None,+    };+    let at = match point(args, "at") {+        Ok(a) => a,+        Err(v) => return v,+    };+    let r = match action.as_str() {+        "list" => {+            return match ws.list_pins(&key) {+                Ok(v) => merge(json!({"success": true, "path": norm(&path), "_hint": "Read only. add, edit, delete, import:true or validate:true change or check them."}), v),+                Err(e) => fail("not_found", e, "kicad_sch_hierarchy lists the sheets on this page."),+            };+        }+        "add" => {+            let Some(name) = arg_str(args, "name") else { return fail("missing_arg", "name is required", "The pin's name, matching the hierarchical label inside the child.") };+            ws.add_pin(&key, name, arg_str(args, "shape").unwrap_or("input"), side, at)+        }+        "edit" => {+            let Some(name) = arg_any(args, &["name", "pin"]) else { return fail("missing_arg", "name is required", "The pin to edit (its name or uuid).") };+            ws.edit_pin(&key, name, arg_str(args, "newName"), arg_str(args, "shape"), side, at)+        }+        "delete" => {+            let Some(name) = arg_any(args, &["name", "pin"]) else { return fail("missing_arg", "name is required", "The pin to delete (its name or uuid).") };+            ws.delete_pin(&key, name)+        }+        "import" => ws.import_pins(&key),+        other => return fail("bad_arg", format!("action {other} is not list, add, edit or delete"), "Or pass import:true / validate:true."),+    };+    let detail = match r {+        Ok(d) => d,+        Err(e) => return fail("edit_failed", e, "Nothing was written. Fix the argument the error names and retry."),+    };+    let dry_run = dry(args, false);+    let nothing = action == "import" && detail["added"].as_array().map_or(false, |a| a.is_empty());+    let files = if nothing { json!({"dryRun": dry_run, "wouldWrite": []}) } else {+        match commit(&ws, dry_run) {+            Ok(f) => f,+            Err(v) => return v,+        }+    };+    json!({+        "success": true,+        "action": action,+        "path": norm(&path),+        "output": if nothing { "Every hierarchical label already has a pin; nothing written".to_string() } else { format!("{}{} pin(s) on sheet {}", if dry_run { "Dry run: " } else { "" }, action, detail["sheet"].as_str().unwrap_or("")) },+        "pins": detail,+        "files": files,+        "reloadHint": if dry_run || nothing { Value::Null } else { json!(RELOAD) },+        "_hint": "Wire each new pin on this page at its at point (kicad_sch_wire), then kicad_sch_sheet_pins {validate:true}.",+        "_next": ["kicad_sch_wire", "kicad_sch_sheet_pins", "kicad_sch_check"],+    })+}++// ---------------------------------------------------------------------------+// kicad_sch_hierarchy+// ---------------------------------------------------------------------------++fn hierarchy(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let mut h = match Hierarchy::load(&path) {+        Ok(h) => h,+        Err(e) => return fail("parse_failed", e, "The root sheet could not be read; run kicad_lint_schematic on it."),+    };+    let renumber = args.get("renumber").and_then(Value::as_bool).unwrap_or(false);+    let mut out = json!({"success": true, "path": norm(&path)});+    if renumber {+        let plan = h.renumber();+        let changed: Vec<Value> = plan.iter().filter(|(_, o, n)| o.as_deref() != Some(n.as_str())).map(|(p, o, n)| json!({"path": p, "from": o, "to": n})).collect();+        let dry_run = dry(args, false);+        out["renumbered"] = json!(changed);+        if !changed.is_empty() && !dry_run {+            let mut written = Vec::new();+            for &d in &h.changed {+                let (p, s) = &h.docs[d];+                match s.save_with_backup(p) {+                    Ok(b) => written.push(json!({"path": norm(p), "backup": norm(&b)})),+                    Err(e) => return fail("write_failed", e, "Check the files are writable; .bak files hold the previous content of any already written."),+                }+            }+            let paths: Vec<PathBuf> = h.changed.iter().map(|&d| h.docs[d].0.clone()).collect();+            if let Err(v) = reparse(&paths) {+                return v;+            }+            out["files"] = json!({"changed": written, "reparsed": true});+            out["reloadHint"] = json!(RELOAD);+        }+        out["dryRun"] = json!(dry_run);+    }+    let mut pages: std::collections::BTreeMap<String, Vec<String>> = Default::default();+    for i in &h.instances {+        if let Some(p) = &i.page {+            pages.entry(p.clone()).or_default().push(i.human.clone());+        }+    }+    let dup: Vec<Value> = pages.iter().filter(|(_, v)| v.len() > 1).map(|(p, v)| json!({"page": p, "sheets": v})).collect();+    let problems: Vec<Value> = h.instances.iter().filter_map(|i| i.problem.as_ref().map(|p| json!({"sheetPath": i.human, "file": norm(&i.file), "problem": p}))).collect();+    let files: Vec<String> = h.docs.iter().map(|(p, _)| norm(p)).collect();+    out["tree"] = h.tree_json();+    out["sheetCount"] = json!(h.instances.len());+    out["fileCount"] = json!(files.len());+    out["files"] = out.get("files").cloned().unwrap_or(json!(files));+    out["duplicatePages"] = json!(dup);+    out["problems"] = json!(problems);+    out["output"] = json!(format!("{} sheet instance(s) in {} file(s){}{}", h.instances.len(), h.docs.len(), if dup.is_empty() { String::new() } else { format!("; {} page number(s) used twice", dup.len()) }, if problems.is_empty() { String::new() } else { format!("; {} problem(s)", problems.len()) }));+    out["_hint"] = json!(if !dup.is_empty() && !renumber { "Page numbers repeat: kicad_sch_hierarchy {renumber:true} numbers them depth-first." } else { "tree.sheets nests the children. Sheet pins are listed per sheet; kicad_sch_sheet_pins {validate:true} checks them against the labels inside." });+    out+}++// ---------------------------------------------------------------------------+// analysis+// ---------------------------------------------------------------------------++fn load_model(path: &Path) -> Result<(sch_model::SheetModel, Connectivity), Value> {+    let s = Schematic::load(path).map_err(|e| fail("parse_failed", e, "Not a schematic this bridge can read; run kicad_lint_schematic."))?;+    // the reference shown for a reused sheet: its first instance in the project, when known+    let path_hint = hier::project_root_for(path).and_then(|root| Hierarchy::load(&root).ok()).and_then(|h| h.paths_of(path).into_iter().next());+    let m = sch_model::extract(&s, path_hint.as_deref());+    let c = Connectivity::build(&m);+    Ok((m, c))+}++fn trace_point(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let at = match need_point(args, "at") {+        Ok(a) => a,+        Err(v) => return v,+    };+    let tol = args.get("tolerance").and_then(Value::as_f64).unwrap_or(0.05).clamp(0.0005, 10.0);+    let (m, c) = match load_model(&path) {+        Ok(x) => x,+        Err(v) => return v,+    };+    let t = sch_check::trace_point(&m, &c, at, tol);+    let empty = t["itemsHere"].as_array().map_or(true, |a| a.is_empty());+    let mut out = merge(json!({"success": true, "path": norm(&path)}), t);+    if empty {+        let near = sch_check::nearest_target(&m, at, 5.08, "");+        out["nearest"] = near.as_ref().map(|(p, w)| json!({"at": [p.0, p.1], "what": w, "distance": (sch_model::dist(*p, at) * 1e4).round() / 1e4})).unwrap_or(Value::Null);+        out["output"] = json!("Nothing connectable at that point");+        out["_hint"] = json!(match near {+            Some((p, w)) => format!("Nothing within {tol} mm. The nearest connectable point is {w} at ({}, {}); trace there, or widen tolerance.", p.0, p.1),+            None => format!("Nothing within {tol} mm and nothing connectable within 5.08 mm."),+        });+    } else {+        let nets: Vec<String> = out["nets"].as_array().into_iter().flatten().filter_map(|n| n["net"].as_str().map(str::to_string)).collect();+        out["output"] = json!(format!("{} item(s) at the point, on net(s) {}", out["itemsHere"].as_array().map_or(0, |a| a.len()), nets.join(", ")));+        out["_hint"] = json!(if out["crossingWithoutJunction"] == json!(true) { "Two wires cross here WITHOUT connecting (no junction dot). If they should connect, add a junction or end one wire on the other." } else { "Read from the file on this sheet only; a net with leavesSheet:true continues elsewhere (kicad_netlist has the compiled net)." });+    }+    out+}++const CHECK_NAMES: &[&str] = &["dangling_wire_end", "floating_label", "unconnected_pin", "single_pin_net", "shorted_nets", "no_connect_connected", "overlap"];++fn check(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let only: Option<Vec<String>> = args.get("checks").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());+    if let Some(o) = &only {+        if let Some(bad) = o.iter().find(|c| !CHECK_NAMES.contains(&c.as_str())) {+            let mut v = fail("bad_arg", format!("unknown check {bad}"), "Pick from `allowed`.");+            v["allowed"] = json!(CHECK_NAMES);+            return v;+        }+    }+    let overlaps = args.get("overlaps").and_then(Value::as_bool).unwrap_or(false) || only.as_ref().map_or(false, |o| o.iter().any(|c| c == "overlap"));+    let opts = CheckOpts { overlaps };+    let mut sheets: Vec<(String, PathBuf, Option<String>)> = Vec::new();+    if args.get("hierarchy").and_then(Value::as_bool).unwrap_or(false) {+        let root = match root_arg(args) {+            Ok(Some(r)) => r,+            Ok(None) => hier::project_root_for(&path).unwrap_or_else(|| path.clone()),+            Err(v) => return v,+        };+        let h = match Hierarchy::load(&root) {+            Ok(h) => h,+            Err(e) => return fail("parse_failed", e, "The root sheet could not be read; run kicad_lint_schematic on it."),+        };+        for i in h.instances.iter().filter(|i| i.doc.is_some()) {+            sheets.push((i.human.clone(), i.file.clone(), Some(i.path.clone())));+        }+    } else {+        sheets.push((String::new(), path.clone(), None));+    }+    let mut findings: Vec<Value> = Vec::new();+    let mut cross = 0;+    let mut seen_files: Vec<PathBuf> = Vec::new();+    for (human, file, ipath) in &sheets {+        let reused = seen_files.iter().any(|f| same_path(f, file));+        seen_files.push(file.clone());+        let (m, c) = match ipath {+            Some(p) => match Schematic::load(file) {+                Ok(s) => {+                    let m = sch_model::extract(&s, Some(p));+                    let c = Connectivity::build(&m);+                    (m, c)+                }+                Err(e) => return fail("parse_failed", e, "Not a schematic this bridge can read; run kicad_lint_schematic."),+            },+            None => match load_model(file) {+                Ok(x) => x,+                Err(v) => return v,+            },+        };+        if reused {+            continue; // a reused file is the same drawing: check it once+        }+        let r = sch_check::check(&m, &c, &opts);+        cross += r.cross_sheet_skipped;+        for f in r.findings {+            if only.as_ref().map_or(false, |o| !o.iter().any(|x| x == f.check)) {+                continue;+            }+            let mut v = json!(f);+            if !human.is_empty() {+                v["sheet"] = json!(human);+                v["file"] = json!(norm(file));+            }+            findings.push(v);+        }+    }+    let count = |s: &str| findings.iter().filter(|f| f["severity"] == s).count();+    let mut by = serde_json::Map::new();+    for c in CHECK_NAMES {+        let n = findings.iter().filter(|f| f["check"] == *c).count();+        if n > 0 {+            by.insert(c.to_string(), json!(n));+        }+    }+    let (e, w, i) = (count("error"), count("warning"), count("info"));+    json!({+        "success": true,+        "path": norm(&path),+        "ok": e == 0 && w == 0,+        "errors": e,+        "warnings": w,+        "info": i,+        "byCheck": by,+        "crossSheetSkipped": cross,+        "sheetsChecked": seen_files.len(),+        "findings": findings,+        "output": format!("{e} error(s), {w} warning(s), {i} note(s)"),+        "_hint": if e + w == 0 { "Clean by the file-level rules. kicad_run_erc is the authoritative check; kicad_design_review looks at the circuit itself." } else { "Each finding has at (sheet mm) and fix. Near-miss wire ends: kicad_sch_fix_connectivity. Look at a point: kicad_sch_trace_point." },+        "_next": ["kicad_sch_fix_connectivity", "kicad_sch_trace_point", "kicad_run_erc"],+    })+}++fn fix_connectivity(args: &Value) -> Value {+    let path = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let tol = args.get("tolerance").and_then(Value::as_f64).unwrap_or(0.5);+    if !(0.001..=5.08).contains(&tol) {+        return fail("bad_arg", format!("tolerance {tol} mm is out of range"), "Use 0.001 to 5.08 mm; keep it under 1.27 mm to avoid snapping onto the wrong pin.");+    }+    let mut sch = match Schematic::load(&path) {+        Ok(s) => s,+        Err(e) => return fail("parse_failed", e, "Not a schematic this bridge can read; run kicad_lint_schematic."),+    };+    let m = sch_model::extract(&sch, None);+    let (moves, left) = sch_check::plan_fixes(&m, tol);+    let dry_run = dry(args, true);+    let mut out = json!({"success": true, "path": norm(&path), "tolerance": tol, "dryRun": dry_run, "moves": moves, "unresolved": left});+    if moves.is_empty() {+        out["output"] = json!(format!("No dangling wire end is within {tol} mm of anything connectable; nothing to do"));+        out["_hint"] = json!(if left.is_empty() { "No dangling wire ends at all." } else { "unresolved ends have nothing close: extend or delete those wires (kicad_sch_check gives each a fix)." });+        return out;+    }+    if dry_run {+        out["output"] = json!(format!("Would snap {} wire end(s); dryRun:false applies", moves.len()));+        out["_hint"] = json!("Read each move (target, distance, diagonal) and re-run with dryRun:false to write them.");+        out["_next"] = json!(["kicad_sch_fix_connectivity"]);+        return out;+    }+    let n = sch_check::apply_moves(&mut sch, &moves);+    match sch.save_with_backup(&path) {+        Ok(b) => out["backup"] = json!(norm(&b)),+        Err(e) => return fail("write_failed", e, "Check the file is writable and not locked; nothing was changed."),+    }+    if let Err(v) = reparse(&[path.clone()]) {+        return v;+    }+    // what is left after the snap+    let after = match Schematic::load(&path) {+        Ok(s) => sch_model::extract(&s, None),+        Err(_) => m,+    };+    out["applied"] = json!(n);+    out["danglingAfter"] = json!(sch_check::dangling_ends(&after).len());+    out["output"] = json!(format!("Snapped {n} wire end(s)"));+    out["reloadHint"] = json!(RELOAD);+    out["_hint"] = json!("Run kicad_sch_check to confirm; moves with diagonal:true may want straightening.");+    out["_next"] = json!(["kicad_sch_check", "kicad_run_erc"]);+    out+}++// ---------------------------------------------------------------------------+// render and review (kicad-cli when present)+// ---------------------------------------------------------------------------++fn cli_path(info: &KicadInfo) -> Option<PathBuf> {+    info.primary().and_then(|i| kicad_core::lint::cli_exe(&i.kicad_cli, &i.bin_dir))+}++fn render(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    match cli_path(&info) {+        Some(cli) => {+            let renderer = format!("kicad-cli {}", info.primary().map(|i| i.version.clone()).unwrap_or_default());+            let runner = kicad_core::render::cli_runner(&cli);+            render_with(args, Some(renderer), &runner)+        }+        None => render_with(args, None, &|_: &[String], _: std::time::Duration| unreachable!("no kicad-cli")),+    }+}++/// The verb with the kicad-cli injected: `renderer` None means no kicad-cli here.+fn render_with(args: &Value, renderer: Option<String>, run: kicad_core::render::Runner) -> Value {+    let file = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let root = match root_arg(args) {+        Ok(r) => r.or_else(|| hier::project_root_for(&file)).unwrap_or_else(|| file.clone()),+        Err(v) => return v,+    };+    let h = match Hierarchy::load(&root) {+        Ok(h) => h,+        Err(e) => return fail("parse_failed", e, "The root sheet could not be read; run kicad_lint_schematic on it."),+    };+    // which sheet: named, else the instance of filePath, else the root+    let want = arg_str(args, "sheet").map(str::trim);+    let inst = match want {+        Some(w) => h.instances.iter().position(|i| i.name.eq_ignore_ascii_case(w) || i.human.trim_matches('/').eq_ignore_ascii_case(w.trim_matches('/'))),+        None => h.instances.iter().position(|i| i.doc.is_some() && same_path(&i.file, &file)).or(Some(0)),+    };+    let Some(inst) = inst else {+        let mut v = fail("sheet_not_found", format!("no sheet named {} in {}", want.unwrap_or(""), file_name(&root)), "Pass a sheet name from `sheets` (or omit sheet for the root page).");+        v["sheets"] = json!(h.instances.iter().map(|i| i.human.clone()).collect::<Vec<_>>());+        return v;+    };+    let Some(renderer) = renderer else {+        let mut v = fail("not_installed", "kicad-cli not found on this machine, so the schematic cannot be plotted", "Install KiCad 8 or newer here, or plot elsewhere (service-kicad sch export svg) and keep the baseline there.");+        v["alternatives"] = alternatives();+        return v;+    };+    let project_dir = root.parent().unwrap_or(Path::new(".")).to_path_buf();+    let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| project_dir.join(".adom").join("render"));+    let mode = match arg_str(args, "baseline").map(|b| b.trim().to_ascii_lowercase()) {+        None => BaselineMode::None,+        Some(b) if b == "set" => BaselineMode::Set,+        Some(b) if b == "compare" => BaselineMode::Compare,+        Some(b) => return fail("bad_arg", format!("baseline {b} is not set or compare"), "baseline:\"set\" stores the image, baseline:\"compare\" measures drift against it."),+    };+    let width = args.get("width").and_then(Value::as_u64).unwrap_or(2000).clamp(16, 16384) as u32;+    let threshold = args.get("threshold").and_then(Value::as_f64).unwrap_or(0.5);+    let svg = SvgOpts {+        drawing_sheet: args.get("drawingSheet").and_then(Value::as_bool).unwrap_or(true),+        black_and_white: args.get("blackAndWhite").and_then(Value::as_bool).unwrap_or(false),+        theme: arg_str(args, "theme").map(str::to_string),+    };+    let i = &h.instances[inst];+    let sheet = if i.parent.is_some() { Some((i.name.as_str(), i.human.as_str())) } else { None };+    let sources: Vec<PathBuf> = h.docs.iter().map(|(p, _)| p.clone()).collect();+    let job = RenderJob { root: &root, sheet, width, out_dir: &out_dir, project_dir: &project_dir, svg, mode, threshold, renderer, sources };+    match sch_render::render_sheet(run, &job) {+        Ok(v) => {+            let mut out = merge(json!({"success": true, "path": norm(&file), "root": norm(&root)}), v);+            let b = out.get("baseline").cloned();+            out["output"] = json!(match (&mode, &b) {+                (BaselineMode::Compare, Some(b)) => format!("Drift {}% (threshold {}%): {}", b["driftPercent"], threshold, if b["pass"] == json!(true) { "pass" } else { "CHANGED" }),+                (BaselineMode::Set, _) => "Rendered and stored as the baseline".to_string(),+                _ => format!("Rendered {}", out["png"].as_str().unwrap_or("")),+            });+            out["_hint"] = json!(match mode {+                BaselineMode::Compare => "pass:false means the picture moved more than threshold: open diffPng (red = changed) and changedRegionMm to see where. rendererChanged:true means a different kicad-cli drew it.",+                BaselineMode::Set => "Edit the schematic, then kicad_render_schematic {baseline:\"compare\"} on the same sheet.",+                BaselineMode::None => "Open png to look at the sheet; baseline:\"set\" keeps it for later comparison.",+            });+            out+        }+        Err(e) => fail(e.code, e.error, e.hint),+    }+}++fn design_review(state: &mut State, args: &Value) -> Value {+    let file = match sch_arg(args, true) {+        Ok(p) => p,+        Err(v) => return v,+    };+    let only: Option<Vec<String>> = args.get("checks").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());+    if let Some(o) = &only {+        if let Some(bad) = o.iter().find(|c| !sch_review::CHECKS.contains(&c.as_str())) {+            let mut v = fail("bad_arg", format!("unknown check {bad}"), "Pick from `allowed`.");+            v["allowed"] = json!(sch_review::CHECKS);+            return v;+        }+    }+    let source = arg_str(args, "source").unwrap_or("auto").trim().to_ascii_lowercase();+    if !["auto", "netlist", "file"].contains(&source.as_str()) {+        return fail("bad_arg", format!("source {source} is not auto, netlist or file"), "auto picks kicad-cli's netlist when kicad-cli is installed, else the bridge's own compile.");+    }+    let root = match root_arg(args) {+        Ok(r) => r.unwrap_or_else(|| file.clone()),+        Err(v) => return v,+    };+    let info = state.kicad_info();+    let cli = cli_path(&info);+    let mut fallback_note = None;+    let model = if source == "netlist" || (source == "auto" && cli.is_some()) {+        let Some(cli) = cli else {+            let mut v = fail("not_installed", "kicad-cli not found, so the netlist cannot be compiled", "Use source:\"file\" (the bridge's own compile of the files) or source:\"auto\".");+            v["alternatives"] = alternatives();+            return v;+        };+        match kicad_core::netlist::load(&cli, &root) {+            Ok(n) => {+                let mut m = sch_review::from_netlist(&n);+                if let Ok(h) = Hierarchy::load(&root) {+                    m.occurrences = sch_review::occurrences(&h);+                }+                m+            }+            Err(e) if source == "auto" => {+                fallback_note = Some(format!("kicad-cli netlist failed ({}: {}); reviewed from the files instead", e.code, e.error));+                match Hierarchy::load(&root) {+                    Ok(h) => sch_review::from_hierarchy(&h),+                    Err(e) => return fail("parse_failed", e, "The root sheet could not be read; run kicad_lint_schematic on it."),+                }+            }+            Err(e) => return fail(e.code, e.error, e.hint),+        }+    } else {+        match Hierarchy::load(&root) {+            Ok(h) => sch_review::from_hierarchy(&h),+            Err(e) => return fail("parse_failed", e, "The root sheet could not be read; run kicad_lint_schematic on it."),+        }+    };+    let findings = sch_review::review(&model, only.as_deref());+    let summary = sch_review::summary(&model, &findings);+    let mut notes = model.notes.clone();+    if let Some(n) = fallback_note {+        notes.push(n);+    }+    let (e, w) = (summary["errors"].as_u64().unwrap_or(0), summary["warnings"].as_u64().unwrap_or(0));+    json!({+        "success": true,+        "path": norm(&root),+        "source": model.source,+        "sheetsReviewed": if model.source == "file" { json!(model.sheets) } else { Value::Null },+        "checks": only.unwrap_or_else(|| sch_review::CHECKS.iter().map(|s| s.to_string()).collect()),+        "summary": summary,+        "findings": findings,+        "notes": notes,+        "output": format!("{e} error(s), {w} warning(s), {} note(s) over {} part(s) ({} netlist)", summary["info"], model.parts.len(), if model.source == "file" { "bridge-compiled" } else { "kicad-cli" }),+        "_hint": if e + w == 0 { "No findings from these heuristics. kicad_run_erc checks the electrical rules; Adom Fields covers the board physics." } else { "Each finding says why it matters and how to fix it; refs and nets point at the parts. Fix in the schematic (kicad_sch_place_symbol, kicad_sch_wire) and review again." },+        "_next": ["kicad_sch_place_symbol", "kicad_sch_wire", "kicad_run_erc"],+    })+}++#[cfg(test)]+mod tests;
rust/crates/kicad-bridge/src/verbs_sch_hier/tests.rsadded+492
@@ -0,0 +1,492 @@+//! Every verb of the group against the KiCad 10 fixture project in+//! kicad-core/tests/fixtures/sch_hier (root hier.kicad_sch with sheets Power and MCU, and Filter+//! nested in Power: a 2-level hierarchy; checks.kicad_sch is a standalone sheet with one of+//! each connectivity defect). Each test works on a private copy.++use super::*;+use kicad_core::cli::CliResult;++fn fixtures() -> PathBuf {+    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/sch_hier")+}++/// A private copy of the fixture project.+fn project(tag: &str) -> PathBuf {+    let d = std::env::temp_dir().join(format!("kb-schhier-{tag}-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+    std::fs::create_dir_all(&d).unwrap();+    for e in std::fs::read_dir(fixtures()).unwrap().flatten() {+        std::fs::copy(e.path(), d.join(e.file_name())).unwrap();+    }+    d+}++fn call(cmd: &str, args: Value) -> Value {+    let mut st = State::new();+    dispatch(&mut st, cmd, &args).expect("verb served")+}++fn p(d: &Path, f: &str) -> String {+    norm(&d.join(f))+}++fn ok(v: &Value) {+    assert_eq!(v["success"], json!(true), "{v:#}");+}++fn parses(path: &Path) {+    Schematic::load(path).unwrap_or_else(|e| panic!("{} does not parse: {e}", path.display()));+}++#[test]+fn catalog_entries_are_complete_and_dash_free() {+    assert_eq!(VERBS.len(), 10);+    for v in VERBS {+        assert!(v.name.starts_with("kicad_"));+        for t in [v.summary, v.input, v.example, v.hint] {+            assert!(!t.is_empty(), "{} has an empty field", v.name);+            assert!(!t.contains('\u{2014}') && !t.contains('\u{2013}'), "{} has a long dash", v.name);+        }+        assert!(!v.related.is_empty() && !v.pitfalls.is_empty(), "{} needs related and pitfalls", v.name);+        for x in v.pitfalls {+            assert!(!x.contains('\u{2014}'), "{} pitfall has an em-dash", v.name);+        }+        assert!(v.example.starts_with(v.name), "{} example must call itself", v.name);+        let json_part = v.example[v.name.len()..].trim();+        serde_json::from_str::<Value>(json_part).unwrap_or_else(|e| panic!("{} example is not JSON: {e}", v.name));+    }+    assert!(dispatch(&mut State::new(), "kicad_not_ours", &json!({})).is_none());+}++#[test]+fn create_standalone_project_and_linked() {+    let d = project("create");+    // standalone, A3 portrait with a title block; dry run writes nothing+    let f = d.join("new/blank.kicad_sch");+    let v = call("kicad_sch_create", json!({"filePath": norm(&f), "paper": "A3", "portrait": true, "title": "Blank", "rev": "B", "comments": ["first", "second"], "dryRun": true}));+    ok(&v);+    assert!(!f.exists());+    let v = call("kicad_sch_create", json!({"filePath": norm(&f), "paper": "A3", "portrait": true, "title": "Blank", "rev": "B", "comments": ["first", "second"], "createProject": true}));+    ok(&v);+    parses(&f);+    assert_eq!(v["page"]["paper"], "A3");+    assert_eq!(v["page"]["widthMm"], 297.0);+    assert_eq!(v["titleBlock"]["comments"]["2"], "second");+    assert!(d.join("new/blank.kicad_pro").is_file());+    let text = std::fs::read_to_string(&f).unwrap();+    assert!(text.contains("(version 20260306)") && text.contains("(paper \"A3\" portrait)") && text.contains("(generator_version \"10.0\")"));+    // never overwrites+    let v = call("kicad_sch_create", json!({"filePath": norm(&f)}));+    assert_eq!(v["errorCode"], "file_exists");+    // bad paper+    let v = call("kicad_sch_create", json!({"filePath": p(&d, "x.kicad_sch"), "paper": "A11"}));+    assert_eq!(v["errorCode"], "bad_arg");+    // into the existing project: linked from the root as a sheet with the next page+    let v = call("kicad_sch_create", json!({"filePath": p(&d, "usb.kicad_sch"), "projectPath": p(&d, "hier.kicad_pro"), "sheetName": "USB", "paper": "A4", "title": "USB"}));+    ok(&v);+    assert_eq!(v["role"], "sheet");+    assert_eq!(v["sheet"]["pages"][0]["page"], "5");+    assert_eq!(v["files"]["reparsed"], true);+    parses(&d.join("usb.kicad_sch"));+    assert!(std::fs::read_to_string(d.join("usb.kicad_sch")).unwrap().contains("(title \"USB\")"));+    assert!(d.join("hier.kicad_sch.bak").is_file());+    let h = call("kicad_sch_hierarchy", json!({"filePath": p(&d, "hier.kicad_sch")}));+    let names: Vec<&str> = h["tree"]["sheets"].as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap()).collect();+    assert_eq!(names, vec!["Power", "MCU", "USB"]);+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn page_reads_and_sets() {+    let d = project("page");+    let f = p(&d, "mcu.kicad_sch");+    let v = call("kicad_sch_page", json!({"filePath": f}));+    ok(&v);+    assert_eq!(v["changed"], false);+    assert_eq!(v["page"]["paper"], "A4");+    assert_eq!(v["titleBlock"]["title"], "MCU");+    let v = call("kicad_sch_page", json!({"filePath": f, "paper": "USLetter", "orientation": "portrait", "title": "Sensor", "company": "Adom", "rev": "", "comments": {"3": "third"}}));+    ok(&v);+    assert_eq!(v["page"]["paper"], "USLetter");+    assert_eq!(v["page"]["portrait"], true);+    assert_eq!(v["titleBlock"]["company"], "Adom");+    assert!(v["titleBlock"].get("rev").is_none(), "empty string removes rev");+    parses(&d.join("mcu.kicad_sch"));+    let v = call("kicad_sch_page", json!({"filePath": f, "paper": "custom", "width": 400, "height": 250}));+    ok(&v);+    assert_eq!(v["page"]["paper"], "User");+    assert!(std::fs::read_to_string(d.join("mcu.kicad_sch")).unwrap().contains("(paper \"User\" 400 250)"));+    let v = call("kicad_sch_page", json!({"filePath": f, "orientation": "sideways"}));+    assert_eq!(v["errorCode"], "bad_arg");+    let v = call("kicad_sch_page", json!({"filePath": p(&d, "nope.kicad_sch")}));+    assert_eq!(v["errorCode"], "file_not_found");+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn hierarchy_tree_and_renumber() {+    let d = project("tree");+    let v = call("kicad_sch_hierarchy", json!({"projectPath": p(&d, "hier.kicad_pro")}));+    ok(&v);+    assert_eq!(v["sheetCount"], 4);+    let t = &v["tree"];+    assert_eq!(t["page"], "1");+    let power = &t["sheets"][0];+    assert_eq!(power["name"], "Power");+    assert_eq!(power["page"], "2");+    assert_eq!(power["pins"][0]["name"], "VOUT");+    assert_eq!(power["sheets"][0]["name"], "Filter");+    assert_eq!(power["sheets"][0]["depth"], 2);+    assert_eq!(power["sheets"][0]["sheetPath"], "/Power/Filter/");+    assert_eq!(t["sheets"][1]["page"], "4");+    assert!(v["problems"].as_array().unwrap().is_empty());+    // scramble two pages, then renumber depth first+    let root = d.join("hier.kicad_sch");+    let text = std::fs::read_to_string(&root).unwrap().replace("(page \"4\")", "(page \"2\")");+    std::fs::write(&root, text).unwrap();+    let v = call("kicad_sch_hierarchy", json!({"filePath": norm(&root)}));+    assert_eq!(v["duplicatePages"][0]["page"], "2");+    let v = call("kicad_sch_hierarchy", json!({"filePath": norm(&root), "renumber": true, "dryRun": true}));+    assert_eq!(v["renumbered"].as_array().unwrap().len(), 1);+    assert!(std::fs::read_to_string(&root).unwrap().matches("(page \"2\")").count() == 2, "dry run wrote nothing");+    let v = call("kicad_sch_hierarchy", json!({"filePath": norm(&root), "renumber": true}));+    ok(&v);+    assert_eq!(v["renumbered"][0]["to"], "4");+    assert!(v["duplicatePages"].as_array().unwrap().is_empty());+    parses(&root);+    // a missing child is a problem, not a failure+    std::fs::remove_file(d.join("filter.kicad_sch")).unwrap();+    let v = call("kicad_sch_hierarchy", json!({"filePath": norm(&root)}));+    ok(&v);+    assert_eq!(v["problems"][0]["sheetPath"], "/Power/Filter/");+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn sheet_add_edit_move_delete() {+    let d = project("sheet");+    let root = p(&d, "hier.kicad_sch");+    // add a sheet whose file does not exist yet: created blank, page 5, placed right of the others+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "add", "name": "Radio"}));+    ok(&v);+    assert_eq!(v["sheet"]["childCreated"], true);+    assert_eq!(v["sheet"]["file"], "radio.kicad_sch");+    assert_eq!(v["sheet"]["pages"][0]["page"], "5");+    assert!(v["sheet"]["at"][0].as_f64().unwrap() > 157.0);+    parses(&d.join("radio.kicad_sch"));+    // the same name again is refused+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "add", "name": "radio"}));+    assert_eq!(v["errorCode"], "edit_failed");+    // a sheet cannot contain its own parent+    let v = call("kicad_sch_sheet", json!({"filePath": p(&d, "filter.kicad_sch"), "action": "add", "name": "Loop", "file": "power.kicad_sch"}));+    assert_eq!(v["errorCode"], "edit_failed", "{v}");+    // edit: rename and resize (right-edge pins follow)+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "edit", "sheet": "Power", "newName": "Supply", "size": [35.56, 20.32]}));+    ok(&v);+    assert_eq!(v["sheet"]["name"], "Supply");+    let h = call("kicad_sch_hierarchy", json!({"filePath": root}));+    let supply = &h["tree"]["sheets"][0];+    assert_eq!(supply["name"], "Supply");+    assert_eq!(supply["pins"][0]["at"][0], 99.06);+    // move by a delta: pins and fields come along+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "move", "sheet": "MCU", "by": [0, 25.4], "dryRun": true}));+    ok(&v);+    assert_eq!(v["files"]["dryRun"], true);+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "move", "sheet": "MCU", "by": [0, 25.4]}));+    ok(&v);+    assert_eq!(v["sheet"]["to"], json!([127.0, 71.12]));+    let h = call("kicad_sch_hierarchy", json!({"filePath": root}));+    assert_eq!(h["tree"]["sheets"][1]["pins"][0]["at"], json!([127.0, 76.2]));+    // the wires stayed: kicad_sch_check reports their ends+    let c = call("kicad_sch_check", json!({"filePath": root, "checks": ["dangling_wire_end"]}));+    assert!(c["warnings"].as_u64().unwrap() >= 3, "{c:#}");+    // delete keeps the file+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "delete", "sheet": "Radio"}));+    ok(&v);+    assert!(d.join("radio.kicad_sch").is_file());+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "delete", "sheet": "Nope"}));+    assert_eq!(v["errorCode"], "edit_failed");+    assert!(v["error"].as_str().unwrap().contains("Supply"), "lists the sheets that exist: {v}");+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "fly"}));+    assert_eq!(v["errorCode"], "bad_arg");+    parses(&d.join("hier.kicad_sch"));+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn sheet_duplicate_gets_fresh_uuids_and_references() {+    let d = project("dup");+    let root = p(&d, "hier.kicad_sch");+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "duplicate", "sheet": "MCU", "newName": "MCU_B"}));+    ok(&v);+    let copy = d.join("mcu_copy.kicad_sch");+    assert!(copy.is_file());+    parses(&copy);+    assert_eq!(v["sheet"]["pinsCopied"], 3);+    let renamed: Vec<(String, String)> = v["sheet"]["renamed"].as_array().unwrap().iter().map(|r| (r["from"].as_str().unwrap().to_string(), r["to"].as_str().unwrap().to_string())).collect();+    assert!(renamed.contains(&("U2".into(), "U3".into())), "{renamed:?}");+    assert!(renamed.iter().any(|(f, _)| f == "R1"));+    // no uuid of the original survives in the copy+    let orig = std::fs::read_to_string(d.join("mcu.kicad_sch")).unwrap();+    let new = std::fs::read_to_string(&copy).unwrap();+    let uuids = |t: &str| -> std::collections::BTreeSet<String> { t.match_indices("(uuid \"").map(|(i, _)| t[i + 7..i + 43].to_string()).collect() };+    assert!(uuids(&orig).is_disjoint(&uuids(&new)));+    // nested: duplicating Power shares filter.kicad_sch, which gains instances with free references+    let v = call("kicad_sch_sheet", json!({"filePath": root, "action": "duplicate", "sheet": "Power"}));+    ok(&v);+    assert_eq!(v["sheet"]["name"], "Power2");+    assert_eq!(v["sheet"]["sharedNestedSheets"].as_array().unwrap().len(), 1);+    let h = call("kicad_sch_hierarchy", json!({"filePath": root}));+    assert_eq!(h["sheetCount"], 7, "{:#}", h["tree"]);+    // the review sees no duplicate references across the whole design now+    let r = call("kicad_design_review", json!({"filePath": root, "source": "file", "checks": ["annotation"]}));+    ok(&r);+    assert_eq!(r["findings"].as_array().unwrap().len(), 0, "{:#}", r["findings"]);+    // and the copy's parts are reviewed as their own: U3's VDDIO lacks decoupling like U2's+    let r = call("kicad_design_review", json!({"filePath": root, "source": "file", "checks": ["decoupling"]}));+    let refs: Vec<&str> = r["findings"].as_array().unwrap().iter().flat_map(|f| f["refs"].as_array().unwrap().iter().filter_map(Value::as_str)).collect();+    assert!(refs.contains(&"U2") && refs.contains(&"U3"), "{refs:?}");+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn sheet_pins_list_add_import_validate_edit_delete() {+    let d = project("pins");+    let root = p(&d, "hier.kicad_sch");+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU"}));+    ok(&v);+    assert_eq!(v["pins"].as_array().unwrap().len(), 3);+    assert_eq!(v["pins"][0]["side"], "left");+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "validate": true}));+    ok(&v);+    assert_eq!(v["ok"], true, "{v:#}");+    // a new label inside MCU: validate flags it, import adds it on the right (output)+    let mcu = d.join("mcu.kicad_sch");+    let mut s = Schematic::load(&mcu).unwrap();+    s.add_label("INT", kicad_core::schematic::LabelKind::Hierarchical, (162.56, 81.28), 0.0, Some("output")).unwrap();+    s.save_with_backup(&mcu).unwrap();+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "validate": true}));+    assert_eq!(v["ok"], false);+    assert_eq!(v["sheets"][0]["labelsWithoutPin"][0]["name"], "INT");+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "import": true}));+    ok(&v);+    let added = &v["pins"]["added"][0];+    assert_eq!(added["name"], "INT");+    assert_eq!(added["side"], "right");+    assert_eq!(added["at"], json!([157.48, 48.26]));+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "import": true}));+    assert_eq!(v["pins"]["added"].as_array().unwrap().len(), 0, "second import adds nothing");+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "validate": true}));+    assert_eq!(v["ok"], true, "{v:#}");+    // add by hand at a point: snapped onto the left edge+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "action": "add", "name": "RST", "shape": "input", "at": [126.5, 63.0]}));+    ok(&v);+    assert_eq!(v["pins"]["at"], json!([127.0, 63.5]));+    assert_eq!(v["pins"]["childHasLabel"], false);+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "action": "add", "name": "RST2", "at": [126.5, 63.0]}));+    assert_eq!(v["errorCode"], "edit_failed", "slot taken");+    // edit: rename and shape; validate shows the stray pin+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "action": "edit", "name": "SCL", "shape": "bidirectional"}));+    ok(&v);+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "validate": true}));+    assert_eq!(v["sheets"][0]["shapeMismatch"][0]["name"], "SCL");+    assert_eq!(v["sheets"][0]["pinsWithoutLabel"][0]["name"], "RST");+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "action": "delete", "name": "RST"}));+    ok(&v);+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU", "action": "edit", "name": "SCL", "shape": "input", "side": "right"}));+    ok(&v);+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "sheet": "MCU"}));+    let scl = v["pins"].as_array().unwrap().iter().find(|p| p["name"] == "SCL").unwrap().clone();+    assert_eq!(scl["side"], "right");+    let v = call("kicad_sch_sheet_pins", json!({"filePath": root, "validate": true}));+    assert_eq!(v["ok"], true, "{v:#}");+    parses(&d.join("hier.kicad_sch"));+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn trace_point_reports_the_net() {+    let d = project("trace");+    // U1 VOUT in the power sheet: +3V3 power symbol and the VOUT hierarchical label on one net+    let v = call("kicad_sch_trace_point", json!({"filePath": p(&d, "power.kicad_sch"), "at": [109.22, 73.66]}));+    ok(&v);+    assert_eq!(v["itemsHere"].as_array().unwrap().iter().filter(|i| i["type"] == "pin").count(), 1);+    let net = &v["nets"][0];+    assert_eq!(net["net"], "+3V3");+    assert_eq!(net["leavesSheet"], true);+    assert_eq!(net["pins"][0]["pin"], "U1.5");+    assert_eq!(net["labels"][0]["text"], "VOUT");+    assert_eq!(net["powerSymbols"], json!(["+3V3"]));+    // a T with a junction in the filter sheet: C3 joins the VIN wire+    let v = call("kicad_sch_trace_point", json!({"filePath": p(&d, "filter.kicad_sch"), "at": [55.88, 50.8]}));+    let pins: Vec<&str> = v["nets"][0]["pins"].as_array().unwrap().iter().map(|p| p["pin"].as_str().unwrap()).collect();+    assert_eq!(pins, vec!["D1.2", "C3.1"]);+    assert_eq!(v["nets"][0]["net"], "VIN");+    // nothing there: the nearest connectable point is named+    let v = call("kicad_sch_trace_point", json!({"filePath": p(&d, "checks.kicad_sch"), "at": [127.0, 46.5]}));+    ok(&v);+    assert_eq!(v["netCount"], 1, "the wire runs through this point");+    let v = call("kicad_sch_trace_point", json!({"filePath": p(&d, "checks.kicad_sch"), "at": [200.0, 150.0]}));+    assert_eq!(v["netCount"], 0);+    assert!(v["nearest"].is_null());+    let v = call("kicad_sch_trace_point", json!({"filePath": p(&d, "checks.kicad_sch"), "at": [101.0, 30.48]}));+    assert_eq!(v["nearest"]["what"], "local label FLOAT");+    let v = call("kicad_sch_trace_point", json!({"filePath": p(&d, "checks.kicad_sch")}));+    assert_eq!(v["errorCode"], "missing_arg");+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn check_finds_each_defect() {+    let d = project("check");+    let v = call("kicad_sch_check", json!({"filePath": p(&d, "checks.kicad_sch"), "overlaps": true}));+    ok(&v);+    let by = &v["byCheck"];+    assert_eq!(by["dangling_wire_end"], 2, "{v:#}");+    assert_eq!(by["floating_label"], 1);+    assert_eq!(by["unconnected_pin"], 1);+    assert_eq!(by["shorted_nets"], 1);+    assert_eq!(by["single_pin_net"], 3);+    assert!(by["overlap"].as_u64().unwrap() >= 2);+    let f = v["findings"].as_array().unwrap();+    let find = |c: &str| f.iter().find(|x| x["check"] == c).unwrap().clone();+    assert_eq!(find("floating_label")["at"], json!([101.6, 30.48]));+    assert_eq!(find("unconnected_pin")["refs"], json!(["U1"]));+    assert!(find("unconnected_pin")["message"].as_str().unwrap().contains("U1.3 EN"));+    assert_eq!(find("shorted_nets")["nets"], json!(["NETA", "NETB"]));+    let near = f.iter().find(|x| x["check"] == "dangling_wire_end" && x["message"].as_str().unwrap().contains("R3.1")).unwrap();+    assert!(near["message"].as_str().unwrap().contains("0.38 mm"));+    assert!(f.iter().any(|x| x["check"] == "overlap" && x["refs"] == json!(["R4", "R5"])));+    for x in f {+        assert!(!x["fix"].as_str().unwrap().is_empty());+    }+    // filtered, and the hierarchy mode visits every sheet+    let v = call("kicad_sch_check", json!({"filePath": p(&d, "checks.kicad_sch"), "checks": ["floating_label"]}));+    assert_eq!(v["findings"].as_array().unwrap().len(), 1);+    let v = call("kicad_sch_check", json!({"filePath": p(&d, "checks.kicad_sch"), "checks": ["nope"]}));+    assert_eq!(v["errorCode"], "bad_arg");+    let v = call("kicad_sch_check", json!({"filePath": p(&d, "hier.kicad_sch"), "hierarchy": true}));+    ok(&v);+    assert_eq!(v["sheetsChecked"], 4);+    let f = v["findings"].as_array().unwrap();+    // EN of the regulator, CSB and SDO of the sensor: open non-passive pins, each tagged with its sheet+    let open: Vec<String> = f.iter().filter(|x| x["check"] == "unconnected_pin").map(|x| format!("{} {}", x["sheet"].as_str().unwrap(), x["refs"][0].as_str().unwrap())).collect();+    assert_eq!(open.len(), 3, "{open:?}");+    assert!(open.contains(&"/Power/ U1".to_string()) && open.contains(&"/MCU/ U2".to_string()));+    assert!(v["crossSheetSkipped"].as_u64().unwrap() >= 1);+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn fix_connectivity_dry_run_then_apply() {+    let d = project("fix");+    let f = p(&d, "checks.kicad_sch");+    let v = call("kicad_sch_fix_connectivity", json!({"filePath": f}));+    ok(&v);+    assert_eq!(v["dryRun"], true);+    assert_eq!(v["moves"].as_array().unwrap().len(), 1);+    assert_eq!(v["moves"][0]["to"], json!([127.0, 46.99]));+    assert_eq!(v["moves"][0]["target"], "pin R3.1");+    assert_eq!(v["unresolved"].as_array().unwrap().len(), 1);+    let before = std::fs::read_to_string(d.join("checks.kicad_sch")).unwrap();+    assert!(before.contains("(xy 127 46.61)"));+    let v = call("kicad_sch_fix_connectivity", json!({"filePath": f, "dryRun": false}));+    ok(&v);+    assert_eq!(v["applied"], 1);+    assert_eq!(v["danglingAfter"], 1);+    let after = std::fs::read_to_string(d.join("checks.kicad_sch")).unwrap();+    assert!(after.contains("(xy 127 46.99)") && !after.contains("(xy 127 46.61)"));+    assert!(d.join("checks.kicad_sch.bak").is_file());+    let c = call("kicad_sch_check", json!({"filePath": f, "checks": ["dangling_wire_end"]}));+    assert_eq!(c["findings"].as_array().unwrap().len(), 1);+    let t = call("kicad_sch_trace_point", json!({"filePath": f, "at": [127.0, 46.99]}));+    let pins: Vec<&str> = t["nets"][0]["pins"].as_array().unwrap().iter().map(|p| p["pin"].as_str().unwrap()).collect();+    assert_eq!(pins, vec!["R3.1", "R3.2"], "the NEAR labels now join both pins");+    let v = call("kicad_sch_fix_connectivity", json!({"filePath": f, "tolerance": 50}));+    assert_eq!(v["errorCode"], "bad_arg");+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn design_review_from_the_files() {+    let d = project("review");+    let v = call("kicad_design_review", json!({"projectPath": p(&d, "hier.kicad_pro"), "source": "file"}));+    ok(&v);+    assert_eq!(v["source"], "file");+    assert_eq!(v["sheetsReviewed"], 4);+    let f = v["findings"].as_array().unwrap();+    let has = |check: &str, r: &str, net: &str| f.iter().any(|x| x["check"] == check && x["refs"].as_array().unwrap().iter().any(|y| y == r) && x["nets"].as_array().unwrap().iter().any(|n| n.as_str().unwrap().contains(net)));+    assert!(has("decoupling", "U2", "VIO"), "{f:#?}");+    assert!(has("i2c_pullups", "U2", "SCL"));+    assert!(!f.iter().any(|x| x["check"] == "i2c_pullups" && x["nets"][0].as_str().unwrap().contains("SDA")), "SDA has R1");+    assert!(has("reset_enable", "U1", "unconnected-(U1-Pad3)"));+    assert!(has("led_resistor", "D1", "+5V"));+    assert!(has("regulator_caps", "U1", "+3V3"));+    assert!(!f.iter().any(|x| x["check"] == "regulator_caps" && x["nets"][0] == "+5V"), "C1 is the input cap");+    assert!(has("bulk_capacitance", "C2", "+3V3"));+    for x in f {+        for k in ["severity", "why", "fix", "message"] {+            assert!(!x[k].as_str().unwrap_or("").is_empty(), "{k} missing in {x}");+        }+    }+    assert_eq!(v["summary"]["errors"], 1);+    // only some checks+    let v = call("kicad_design_review", json!({"filePath": p(&d, "hier.kicad_sch"), "source": "file", "checks": ["led_resistor"]}));+    assert_eq!(v["findings"].as_array().unwrap().len(), 1);+    let v = call("kicad_design_review", json!({"filePath": p(&d, "hier.kicad_sch"), "checks": ["vibes"]}));+    assert_eq!(v["errorCode"], "bad_arg");+    // annotation: an unannotated part and a duplicate+    let mcu = d.join("mcu.kicad_sch");+    let t = std::fs::read_to_string(&mcu).unwrap().replace("(reference \"R1\")", "(reference \"R?\")").replace("(reference \"C2\")", "(reference \"C1\")");+    std::fs::write(&mcu, t).unwrap();+    let v = call("kicad_design_review", json!({"filePath": p(&d, "hier.kicad_sch"), "source": "file", "checks": ["annotation"]}));+    let msgs: Vec<&str> = v["findings"].as_array().unwrap().iter().map(|x| x["message"].as_str().unwrap()).collect();+    assert!(msgs.iter().any(|m| m.contains("R? is not annotated")), "{msgs:?}");+    assert!(msgs.iter().any(|m| m.contains("Reference C1 is used by 2")), "{msgs:?}");+    let _ = std::fs::remove_dir_all(&d);+}++#[test]+fn render_with_canned_kicad_cli() {+    let d = project("render");+    let root = p(&d, "hier.kicad_sch");+    // no kicad-cli: an honest refusal with the alternatives+    let v = render_with(&json!({"filePath": root}), None, &|_: &[String], _: std::time::Duration| unreachable!());+    assert_eq!(v["errorCode"], "not_installed");+    assert!(v["alternatives"].is_array());+    let svg = std::fs::read(d.join("mcu-kicad10.svg")).unwrap();+    let calls = std::sync::Mutex::new(Vec::<Vec<String>>::new());+    let run = |argv: &[String], _t: std::time::Duration| -> CliResult {+        calls.lock().unwrap().push(argv.to_vec());+        let out = PathBuf::from(&argv[4]);+        for n in ["hier.svg", "hier-Power.svg", "hier-Power-Filter.svg", "hier-MCU.svg"] {+            std::fs::write(out.join(n), &svg).unwrap();+        }+        CliResult { exit_code: 0, stdout: String::new(), stderr: String::new(), elapsed_ms: 3, timed_out: false }+    };+    let ver = Some("kicad-cli 10.0.2".to_string());+    // filePath of a sub-sheet: kicad-cli plots the project root, the sub-sheet's SVG is picked+    let v = render_with(&json!({"filePath": p(&d, "mcu.kicad_sch"), "width": 800}), ver.clone(), &run);+    ok(&v);+    assert_eq!(v["sheet"], "MCU");+    assert!(v["svg"].as_str().unwrap().ends_with("hier-MCU.svg"));+    assert_eq!(calls.lock().unwrap()[0].last().unwrap(), &root, "plots from the root");+    assert!(PathBuf::from(v["png"].as_str().unwrap()).is_file());+    assert!(v["png"].as_str().unwrap().contains("/.adom/render/"));+    // baseline set and compare by sheet name+    let v = render_with(&json!({"filePath": root, "sheet": "Filter", "baseline": "set", "width": 500}), ver.clone(), &run);+    ok(&v);+    assert!(d.join(".adom/baselines/hier-Power-Filter.png").is_file());+    let v = render_with(&json!({"filePath": root, "sheet": "Filter", "baseline": "compare"}), ver.clone(), &run);+    ok(&v);+    assert_eq!(v["baseline"]["driftPercent"], 0.0);+    assert_eq!(v["baseline"]["pass"], true);+    assert!(v["output"].as_str().unwrap().contains("pass"));+    let v = render_with(&json!({"filePath": root, "sheet": "MCU", "baseline": "compare"}), ver.clone(), &run);+    assert_eq!(v["errorCode"], "no_baseline");+    let v = render_with(&json!({"filePath": root, "sheet": "Nope"}), ver.clone(), &run);+    assert_eq!(v["errorCode"], "sheet_not_found");+    assert!(v["sheets"].as_array().unwrap().len() == 4);+    let v = render_with(&json!({"filePath": root, "baseline": "maybe"}), ver, &run);+    assert_eq!(v["errorCode"], "bad_arg");+    let _ = std::fs::remove_dir_all(&d);+}
rust/crates/kicad-bridge/src/verbs_schematic.rs+103−77
@@ -33,7 +33,7 @@ pub static VERBS: &[Verb] = &[             "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",             "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",             "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",-            "reference must be free on the sheet; the verb refuses a duplicate instead of renumbering",+            "reference must be free on the sheet, except for another unit of the same part (same libId, a unit not yet placed: place U1 unit 2 beside unit 1) and for unannotated X? references; the verb refuses any other duplicate instead of renumbering (kicad_sch_annotate numbers X? parts)",             "the symbol lands with fields_autoplaced and pins unconnected: follow with kicad_sch_wire or kicad_sch_label, or ERC reports pin_not_connected",             "a libId whose library is not in any sym-lib-table fails with the tables that were checked; pass libraryPath for a library file that is not installed",         ],@@ -42,16 +42,17 @@ pub static VERBS: &[Verb] = &[         name: "kicad_sch_wire",         summary: "Draw a wire polyline on a .kicad_sch (one wire segment per pair of points; edits the file on disk).",         mechanism: Mechanism::File, risk: "write", timeout_sec: 120,-        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"points\": [[x_mm, y_mm], [x_mm, y_mm], ...]}",+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"points\": [[x_mm, y_mm], [x_mm, y_mm], ...], \"junctions\"?: true, \"dryRun\"?: false}",         example: "kicad_sch_wire {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"points\":[[127,77.47],[127,70],[140,70]]}",-        hint: "Each consecutive pair becomes one (wire) with its own uuid; uuid is the first, uuids lists them all. Pin ends are at the symbol anchor plus the pin offset (a Device:R at (x,y) has pins at y-3.81 and y+3.81). On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        hint: "Each consecutive pair becomes one (wire) with its own uuid; uuid is the first, uuids lists them all. Junction dots are added where KiCad wants one (a wire end landing on another wire's interior, or three or more exits at a point) and listed in junctionsAdded; junctions:false skips that. Pin ends are at the symbol anchor plus the pin offset (a Device:R at (x,y) has pins at y-3.81 and y+3.81). On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",         related: &["kicad_sch_place_symbol", "kicad_sch_label", "kicad_sch_delete", "kicad_run_erc"],         pitfalls: &[             "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",             "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",             "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",             "KiCad connects a wire end to a pin only when the coordinates match exactly; stay on the 1.27 mm grid",-            "no junctions are added; a T off the middle of another wire needs one for KiCad to join them",+            "a T off the middle of another wire needs a junction for KiCad to join them; the verb adds it (junctionsAdded) unless junctions:false",+            "kicad_sch_connect_pins routes pin to pin by reference.pin, so you do not have to work out pin coordinates",         ],     },     Verb {@@ -72,26 +73,28 @@ pub static VERBS: &[Verb] = &[     },     Verb {         name: "kicad_sch_move",-        summary: "Move a placed symbol (by reference) so its anchor lands on a new point; its fields move with it (edits the file on disk).",+        summary: "Move a placed symbol (by reference) so its anchor lands on a new point or by a delta; every unit of a multi-unit part and every field move with it, and stretchWires keeps the attached wires on the pins (edits the file on disk).",         mechanism: Mechanism::File, risk: "write", timeout_sec: 120,-        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\", \"to\": [x_mm, y_mm]}",-        example: "kicad_sch_move {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"R5\",\"to\":[150,90]}",-        hint: "Shifts the symbol's (at) and every property's (at) by the same delta; wires do not follow. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"reference\": \"R5\", \"to\": [x_mm, y_mm] | \"by\": [dx_mm, dy_mm], \"unit\"?: 1 (the unit that lands on to; default the first placed), \"stretchWires\"?: false, \"dryRun\"?: false}",+        example: "kicad_sch_move {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"reference\":\"R5\",\"to\":[150,90],\"stretchWires\":true}",+        hint: "Every unit of the reference moves by the same delta (to minus the chosen unit's anchor, or by), fields included. stretchWires:true moves the wire ends that sit on the symbol's pins with them: a horizontal or vertical wire gets a dogleg so it stays horizontal or vertical (wiresAdded), a wire between two of its own pins moves whole, and no-connect flags, labels and junctions on those pins travel too. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",         related: &["kicad_sch_place_symbol", "kicad_sch_wire", "kicad_sch_delete"],         pitfalls: &[             "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",             "every write copies the previous file to <file>.bak next to it (one generation; the next write replaces it)",             "ERC runs before and after each edit when kicad-cli is installed and erc.delta reports the change in errors and warnings; with no kicad-cli erc.ran is false and the file is unvalidated",-            "wires attached to the symbol stay where they were; move them (delete and redraw) or ERC reports the pins unconnected",+            "without stretchWires the wires stay where they were and ERC reports the pins unconnected; pass stretchWires:true to keep them attached",+            "stretchWires follows only wire ends that sit exactly on a pin; a wire that merely passes a pin is not dragged",+            "a delta off the 1.27 mm grid puts pins off grid; keep moves on multiples of 1.27",         ],     },     Verb {         name: "kicad_sch_delete",-        summary: "Delete any top-level item (symbol, wire, label, junction, sheet ...) from a .kicad_sch by uuid (edits the file on disk).",+        summary: "Delete any top-level item (symbol, wire, label, junction, sheet ...) from a .kicad_sch by uuid, every unit of a symbol by reference, or labels by name (edits the file on disk).",         mechanism: Mechanism::File, risk: "write", timeout_sec: 120,-        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"uuid\": \"f8e0a1ff-...\"}",+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"uuid\": \"f8e0a1ff-...\" | \"reference\": \"R5\" | \"label\": \"VOUT\", \"at\"?: [x_mm, y_mm] (with label), \"dryRun\"?: false}",         example: "kicad_sch_delete {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"uuid\":\"f8e0a1ff-cdf3-4b10-8a45-de5c5807717e\"}",-        hint: "uuids come from the create verbs' responses, from kicad_extract_netlist, or from ERC items. Returns the kind of item removed. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",+        hint: "uuids come from the create verbs' responses, kicad_sch_items, kicad_sch_components, or ERC items. Returns the kind of item removed. reference removes every unit of that symbol; label removes every label with that text (or only the one at at). For wires prefer kicad_sch_delete_wire, which also prunes junction dots left with nothing to join. On disk only: reload the editor (kicad_close, kicad_open_schematic) to see it. .bak written; ERC delta reported when kicad-cli is present.",         related: &["kicad_sch_place_symbol", "kicad_sch_wire", "kicad_sch_label", "kicad_extract_netlist"],         pitfalls: &[             "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",@@ -118,11 +121,11 @@ pub static VERBS: &[Verb] = &[     },     Verb {         name: "kicad_sch_no_connect",-        summary: "Put a no-connect flag on a pin of a .kicad_sch (an intentionally open pin: a spare GPIO, an unused connector pin), so ERC stops reporting it (edits the file on disk).",+        summary: "Put no-connect flags on pins of a .kicad_sch (an intentionally open pin: a spare GPIO, an unused connector pin) so ERC stops reporting them, or remove flags (edits the file on disk).",         mechanism: Mechanism::File, risk: "write", timeout_sec: 120,-        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"pin\": \"U1.PA10\" | \"U1.12\" | \"at\": [x_mm, y_mm]}",+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"pin\": \"U1.PA10\" | \"U1.12\" | \"pins\": [\"U1.12\", ...] | \"at\": [x_mm, y_mm] | \"points\": [[x, y], ...] | \"uuid\": \"...\", \"remove\"?: false, \"dryRun\"?: false}",         example: "kicad_sch_no_connect {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\",\"pin\":\"U1.12\"}",-        hint: "pin names the symbol and its pin number (or pin name), and the flag lands on that pin's connection point, worked out from the symbol's position, angle and mirror; at places it at a point you already know. Writes (no_connect (at x y) (uuid ..)) as eeschema does. On disk only: reload the editor to see it. .bak written; the ERC delta shows the pin_not_connected error gone.",+        hint: "pin names the symbol and its pin number (or pin name), and the flag lands on that pin's connection point, worked out from the symbol's position, angle and mirror; at places it at a point you already know. pins and points take arrays (one flag each, one write); a pin that already has a flag is skipped (skipped lists it). remove:true deletes the flags at those pins or points (or by uuid) instead. Writes (no_connect (at x y) (uuid ..)) as eeschema does. On disk only: reload the editor to see it. .bak written; the ERC delta shows the pin_not_connected error gone.",         related: &["kicad_sch_label", "kicad_sch_wire", "kicad_run_erc", "kicad_netlist"],         pitfalls: &[             "the edit is on disk: an open Schematic Editor does not see it until reloaded (kicad_close, then kicad_open_schematic); saving from the stale editor overwrites the edit",@@ -137,8 +140,8 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_sch_place_symbol" => edit(state, args, place_symbol),         "kicad_sch_wire" => edit(state, args, wire),         "kicad_sch_label" => edit(state, args, label),-        "kicad_sch_no_connect" => edit(state, args, no_connect),-        "kicad_sch_move" => edit(state, args, move_symbol),+        "kicad_sch_no_connect" => edit(state, args, crate::verbs_sch_edit::no_connect),+        "kicad_sch_move" => edit(state, args, crate::verbs_sch_edit::move_symbol),         "kicad_sch_delete" => edit(state, args, delete),         "kicad_sch_set_property" => edit(state, args, set_property),         _ => return None,@@ -148,26 +151,26 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>  /// What an operation hands back: the one-line output, the uuid of the created (or /// touched) item, and any extra fields for the response.-struct Done {-    output: String,-    uuid: String,-    extra: Value,+pub(crate) struct Done {+    pub output: String,+    pub uuid: String,+    pub extra: Value, }  /// The context an operation gets: the parsed sheet, the sheet's path and the library /// context (for lib id resolution).-struct Ctx<'a> {-    sch: &'a mut Schematic,-    path: &'a Path,-    lib: &'a LibCtx,-    args: &'a Value,+pub(crate) struct Ctx<'a> {+    pub sch: &'a mut Schematic,+    pub path: &'a Path,+    pub lib: &'a LibCtx,+    pub args: &'a Value, } -type Op = fn(&mut Ctx) -> Result<Done, Value>;+pub(crate) type Op = fn(&mut Ctx) -> Result<Done, Value>;  /// Shared pipeline: validate the path, ERC before, load, apply, backup + write, ERC /// after, and the response every schematic verb shares.-fn edit(state: &mut State, args: &Value, op: Op) -> Value {+pub(crate) fn edit(state: &mut State, args: &Value, op: Op) -> Value {     let Some(p) = arg_any(args, &["filePath", "schematicPath", "path"]) else {         return fail("missing_arg", "filePath is required", "Pass the absolute path of the .kicad_sch to edit.");     };@@ -181,7 +184,7 @@ fn edit(state: &mut State, args: &Value, op: Op) -> Value {     let info = state.kicad_info();     let cli_path = cli_exe(&info);     let lib = lib_ctx(&info);-    let before = run_erc(cli_path.as_deref(), &path);+    let before = if dry_run(args) { Erc { counts: None, reason: Some("dry run".into()), elapsed_ms: 0 } } else { run_erc(cli_path.as_deref(), &path) };      let mut sch = match Schematic::load(&path) {         Ok(s) => s,@@ -194,6 +197,23 @@ fn edit(state: &mut State, args: &Value, op: Op) -> Value {             Err(v) => return v,         }     };+    if dry_run(args) {+        let mut v = json!({+            "success": true,+            "dryRun": true,+            "output": format!("Dry run, nothing written: {}", done.output),+            "path": norm(&path),+            "uuid": done.uuid,+            "_hint": "Dry run: the plan above was computed on an in-memory copy and the file is unchanged. Repeat the call without dryRun to write it.",+            "_next": [],+        });+        if let (Some(o), Some(e)) = (v.as_object_mut(), done.extra.as_object()) {+            for (k, val) in e {+                o.insert(k.clone(), val.clone());+            }+        }+        return v;+    }     let backup = match sch.save_with_backup(&path) {         Ok(b) => b,         Err(e) => return fail("write_failed", e, "Check the file is writable and not locked by another process; nothing was changed."),@@ -227,7 +247,7 @@ fn edit(state: &mut State, args: &Value, op: Op) -> Value { // Operations // --------------------------------------------------------------------------- -fn point(args: &Value, key: &str) -> Result<(f64, f64), Value> {+pub(crate) fn point(args: &Value, key: &str) -> Result<(f64, f64), Value> {     let v = args.get(key).ok_or_else(|| fail("missing_arg", format!("{key} is required"), format!("Pass {key} as [x_mm, y_mm].")))?;     let arr = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| fail("bad_arg", format!("{key} must be [x_mm, y_mm]"), "Two numbers in millimetres, sheet coordinates (origin top-left, y down)."))?;     let x = arr[0].as_f64().ok_or_else(|| fail("bad_arg", format!("{key}[0] is not a number"), "Millimetres as numbers, not strings."))?;@@ -235,17 +255,22 @@ fn point(args: &Value, key: &str) -> Result<(f64, f64), Value> {     Ok((x, y)) } -fn rotation(args: &Value) -> f64 {+/// `dryRun: true` (also `dry_run`): compute the edit, report it, write nothing.+pub(crate) fn dry_run(args: &Value) -> bool {+    args.get("dryRun").or_else(|| args.get("dry_run")).and_then(Value::as_bool).unwrap_or(false)+}++pub(crate) fn rotation(args: &Value) -> f64 {     args.get("rotation").and_then(Value::as_f64).unwrap_or(0.0) } -fn op_fail(code: &str, e: String, hint: &str) -> Value {+pub(crate) fn op_fail(code: &str, e: String, hint: &str) -> Value {     fail(code, e, hint) }  /// The library block for `lib_id`: from an explicit libraryPath, else through the /// project and global sym-lib-tables.-fn library_block(ctx: &Ctx, lib_id: &str) -> Result<(schematic::Node, String), Value> {+pub(crate) fn library_block(ctx: &Ctx, lib_id: &str) -> Result<(schematic::Node, String), Value> {     let name = lib_id.split_once(':').map(|(_, n)| n).unwrap_or(lib_id);     if let Some(lp) = arg_str(ctx.args, "libraryPath") {         let r = schematic::load_lib_symbol(Path::new(lp.trim()), name).map_err(|e| op_fail("symbol_not_found", e, "Check the library file and the symbol name (case-sensitive); kicad_list_symbols lists what a library holds."))?;@@ -261,7 +286,7 @@ fn library_block(ctx: &Ctx, lib_id: &str) -> Result<(schematic::Node, String), V     Ok((r.block, r.library_path)) } -fn place_symbol(ctx: &mut Ctx) -> Result<Done, Value> {+pub(crate) fn place_symbol(ctx: &mut Ctx) -> Result<Done, Value> {     let lib_id = arg_any(ctx.args, &["libId", "lib_id", "symbol"]).ok_or_else(|| fail("missing_arg", "libId is required", "Pass libId like \"Device:R\"."))?.trim().to_string();     if !lib_id.contains(':') {         return Err(fail("bad_arg", format!("libId {lib_id} is not Library:Symbol"), "Use the nickname and symbol name the Symbol Chooser shows, e.g. Device:C."));@@ -296,22 +321,28 @@ fn place_symbol(ctx: &mut Ctx) -> Result<Done, Value> {     }) } -fn wire(ctx: &mut Ctx) -> Result<Done, Value> {+pub(crate) fn wire(ctx: &mut Ctx) -> Result<Done, Value> {     let pts = ctx.args.get("points").and_then(Value::as_array).ok_or_else(|| fail("missing_arg", "points is required", "Pass points as [[x_mm, y_mm], [x_mm, y_mm], ...] with at least two entries."))?;     let mut points = Vec::with_capacity(pts.len());     for (i, p) in pts.iter().enumerate() {         let pair = p.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a[0].as_f64()?, a[1].as_f64()?)));         points.push(pair.ok_or_else(|| fail("bad_arg", format!("points[{i}] is not [x_mm, y_mm]"), "Every point is two numbers in millimetres."))?);     }-    let uuids = ctx.sch.add_wire(&points).map_err(|e| op_fail("edit_failed", e, "Nothing was written. Pass at least two distinct points."))?;+    let auto = ctx.args.get("junctions").and_then(Value::as_bool).unwrap_or(true);+    let (uuids, junctions) = if auto {+        ctx.sch.add_wire_auto(&points).map_err(|e| op_fail(e.code, e.msg, "Nothing was written. Pass at least two distinct points."))?+    } else {+        (ctx.sch.add_wire(&points).map_err(|e| op_fail("edit_failed", e, "Nothing was written. Pass at least two distinct points."))?, vec![])+    };+    let jl = if junctions.is_empty() { String::new() } else { format!(" and {} junction(s)", junctions.len()) };     Ok(Done {-        output: format!("Drew {} wire segment(s) through {} point(s) in {}", uuids.len(), points.len(), file_name(ctx.path)),+        output: format!("Drew {} wire segment(s) through {} point(s){jl} in {}", uuids.len(), points.len(), file_name(ctx.path)),         uuid: uuids[0].clone(),-        extra: json!({"uuids": uuids, "segments": uuids.len(), "points": points.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>()}),+        extra: json!({"uuids": uuids, "segments": uuids.len(), "points": points.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(), "junctionsAdded": junctions.iter().map(|(u, p)| json!({"uuid": u, "at": [p.0, p.1]})).collect::<Vec<_>>()}),     }) } -fn label(ctx: &mut Ctx) -> Result<Done, Value> {+pub(crate) fn label(ctx: &mut Ctx) -> Result<Done, Value> {     let text = arg_any(ctx.args, &["text", "name", "net"]).ok_or_else(|| fail("missing_arg", "text is required", "Pass the net name, e.g. VIN or GND."))?.trim().to_string();     let at = point(ctx.args, "at")?;     let kind_s = arg_str(ctx.args, "kind").unwrap_or("local").trim().to_ascii_lowercase();@@ -338,47 +369,42 @@ fn label(ctx: &mut Ctx) -> Result<Done, Value> {     }) } -/// `kicad_sch_no_connect` (wiki #131): a flag on a pin's connection point, by pin or by point.-fn no_connect(ctx: &mut Ctx) -> Result<Done, Value> {-    let (at, pin) = if let Some(spec) = arg_any(ctx.args, &["pin", "pins"]) {-        let (reference, want) = spec.trim().split_once('.').ok_or_else(|| fail("bad_arg", format!("pin {spec} is not Ref.Pin"), "Name the symbol and its pin: U1.12 or U1.PA10."))?;-        let points = ctx.sch.pin_points(reference).map_err(|e| op_fail("not_found", e, "kicad_netlist {parts:true} lists every part with its pins."))?;-        // the pin by number, else by name-        let hit = points.iter().find(|(n, _)| n == want).map(|(_, p)| *p).or_else(|| {-            let names = ctx.sch.pin_names(reference);-            names.iter().find(|(_, name)| name.eq_ignore_ascii_case(want)).and_then(|(num, _)| points.iter().find(|(n, _)| n == num).map(|(_, p)| *p))-        });-        let at = hit.ok_or_else(|| fail("pin_not_found", format!("{reference} has no pin {want}"), "Pins are matched by number first, then by name; kicad_netlist {parts:true} lists them."))?;-        (at, Some(spec.trim().to_string()))-    } else {-        (point(ctx.args, "at")?, None)-    };-    let uuid = ctx.sch.add_no_connect(at).map_err(|e| op_fail("edit_failed", e, "Nothing was written."))?;-    Ok(Done {-        output: format!("Added a no-connect flag{} at ({}, {}) in {}", pin.as_ref().map(|p| format!(" on {p}")).unwrap_or_default(), schematic::fmt_num(at.0), schematic::fmt_num(at.1), file_name(ctx.path)),-        uuid,-        extra: json!({"at": [at.0, at.1], "pin": pin}),-    })-} -fn move_symbol(ctx: &mut Ctx) -> Result<Done, Value> {-    let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator of the symbol to move, e.g. R5."))?.trim().to_string();-    let to = point(ctx.args, "to")?;-    let (uuid, from) = ctx.sch.move_symbol(&reference, to).map_err(|e| op_fail("not_found", e, "kicad_extract_netlist lists the references on the sheet."))?;-    Ok(Done {-        output: format!("Moved {reference} from ({}, {}) to ({}, {}) in {}", schematic::fmt_num(from.0), schematic::fmt_num(from.1), schematic::fmt_num(to.0), schematic::fmt_num(to.1), file_name(ctx.path)),-        uuid,-        extra: json!({"reference": reference, "from": [from.0, from.1], "to": [to.0, to.1]}),-    })-} -fn delete(ctx: &mut Ctx) -> Result<Done, Value> {-    let uuid = arg_str(ctx.args, "uuid").ok_or_else(|| fail("missing_arg", "uuid is required", "Pass the uuid of the item; the create verbs return it, and ERC items carry it."))?.trim().to_string();+pub(crate) fn delete(ctx: &mut Ctx) -> Result<Done, Value> {+    if arg_str(ctx.args, "uuid").is_none() {+        if let Some(r) = arg_any(ctx.args, &["reference", "ref"]) {+            let r = r.trim().to_string();+            let mut uuids = Vec::new();+            for i in ctx.sch.unit_indices(&r).into_iter().rev() {+                uuids.push(ctx.sch.top_mut().remove(i).child_value("uuid").unwrap_or_default());+            }+            if uuids.is_empty() {+                return Err(fail("not_found", format!("no symbol with reference {r} on this sheet"), "kicad_sch_components lists the references on the sheet."));+            }+            uuids.reverse();+            return Ok(Done { output: format!("Deleted {r} ({} unit(s)) from {}", uuids.len(), file_name(ctx.path)), uuid: uuids[0].clone(), extra: json!({"kind": "symbol", "reference": r, "uuids": uuids}) });+        }+        if let Some(t) = arg_any(ctx.args, &["label", "text"]) {+            let at = if ctx.args.get("at").is_some() { Some(point(ctx.args, "at")?) } else { None };+            let idx = ctx.sch.label_indices(None, Some(t.trim()), at);+            if idx.is_empty() {+                return Err(fail("not_found", format!("no label {t} on this sheet{}", at.map(|p| format!(" at ({}, {})", p.0, p.1)).unwrap_or_default()), "kicad_sch_items {kinds:[\"labels\"]} lists every label with its point and uuid."));+            }+            let mut uuids = Vec::new();+            for i in idx.into_iter().rev() {+                uuids.push(ctx.sch.top_mut().remove(i).child_value("uuid").unwrap_or_default());+            }+            uuids.reverse();+            return Ok(Done { output: format!("Deleted {} label(s) {t} from {}", uuids.len(), file_name(ctx.path)), uuid: uuids[0].clone(), extra: json!({"kind": "label", "text": t, "uuids": uuids}) });+        }+    }+    let uuid = arg_str(ctx.args, "uuid").ok_or_else(|| fail("missing_arg", "uuid (or reference, or label) is required", "Pass the uuid of the item (the create verbs return it, kicad_sch_items lists them), reference to delete every unit of a symbol, or label (+ at) to delete labels by name."))?.trim().to_string();     let kind = ctx.sch.delete(&uuid).map_err(|e| op_fail("not_found", e, "uuids are per item; check it against the create verb's response or kicad_extract_netlist."))?;     Ok(Done { output: format!("Deleted {kind} {uuid} from {}", file_name(ctx.path)), uuid: uuid.clone(), extra: json!({"kind": kind}) }) } -fn set_property(ctx: &mut Ctx) -> Result<Done, Value> {+pub(crate) fn set_property(ctx: &mut Ctx) -> Result<Done, Value> {     let reference = arg_any(ctx.args, &["reference", "ref"]).ok_or_else(|| fail("missing_arg", "reference is required", "Pass the designator of the symbol, e.g. R5."))?.trim().to_string();     let name = arg_any(ctx.args, &["name", "property", "field"]).ok_or_else(|| fail("missing_arg", "name is required", "Pass the field name: Value, Footprint, Datasheet, Reference or a custom field."))?.trim().to_string();     let value = ctx.args.get("value").and_then(Value::as_str).ok_or_else(|| fail("missing_arg", "value is required", "Pass the new field text as a string (empty string clears it)."))?.to_string();@@ -403,7 +429,7 @@ fn capitalize(s: &str) -> String {     } } -fn file_name(p: &Path) -> String {+pub(crate) fn file_name(p: &Path) -> String {     p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| norm(p)) } @@ -411,13 +437,13 @@ fn file_name(p: &Path) -> String { // KiCad context and ERC // --------------------------------------------------------------------------- -fn cli_exe(info: &KicadInfo) -> Option<PathBuf> {+pub(crate) fn cli_exe(info: &KicadInfo) -> Option<PathBuf> {     info.primary().and_then(|i| kicad_core::lint::cli_exe(&i.kicad_cli, &i.bin_dir)) }  /// Same construction as the libraries group: version, install root, config and user /// dirs from the platform, Configure Paths overrides loaded.-fn lib_ctx(info: &KicadInfo) -> LibCtx {+pub(crate) fn lib_ctx(info: &KicadInfo) -> LibCtx {     let inst = info.primary();     let version = inst.map(|i| i.version.clone()).unwrap_or_default();     let mut ctx = LibCtx {
rust/crates/kicad-bridge/src/verbs_standard.rs+31−1
@@ -48,7 +48,7 @@ pub static VERBS: &[Verb] = &[         mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,         input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",         example: "kicad_board_summary {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",-        hint: "Start here on an unknown board, then kicad_routing_state {model:\"standard\"} for the whole of it, kicad_component_data or kicad_list_nets for detail. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",+        hint: "summary.titleBlock is the title block (title, date, rev, company, comments); summary.extents is the box around everything on the board (outline, courtyards, copper), summary.outline the Edge.Cuts box. Start here on an unknown board, then kicad_routing_state {model:\"standard\"} for the whole of it, kicad_component_data or kicad_list_nets for detail. Reads the live PCB editor when it serves the board, else the file (source says which); filePath may be left out when one board is open.",         related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],         pitfalls: &["A file read does not see unsaved edits in an open editor."],     },@@ -340,9 +340,39 @@ fn view(state: &mut State, args: &Value, which: &str) -> Value {             stamp(&mut v, &r);             v["revision"] = model["revision"].clone();             v["frame"] = model["frame"].clone();+            if which == "board_summary" {+                summary_extras(&mut v, &r.text, o.y_up);+            }             v["_hint"] = json!(format!("{}mm, {} frame. kicad_routing_state {{model:\"standard\"}} is the whole board in one read.", file_note(&r), if o.y_up { "the standard's y-up" } else { "KiCad's y-down" }));             v         }         Err((code, msg)) => fail(&code, msg, "kicad_list_designators names every part on the board."),     } }++/// `kicad_board_summary` also carries the title block and the extents of everything on the+/// board (outline, courtyards, copper), in the reply's frame.+fn summary_extras(v: &mut Value, text: &str, y_up: bool) {+    v["summary"]["titleBlock"] = kicad_core::board_edit::title_block(text);+    let mut x = kicad_core::pcb::parse_pcb_text(text, "board.kicad_pcb").map(|b| kicad_core::board_edit::extents(&b)).unwrap_or(Value::Null);+    if y_up && x.is_object() {+        let (y0, y1) = (x["y0"].as_f64().unwrap_or(0.0), x["y1"].as_f64().unwrap_or(0.0));+        x["y0"] = json!(-y1);+        x["y1"] = json!(-y0);+        x["frame"] = json!("standard (y up)");+    }+    v["summary"]["extents"] = x;+}++#[cfg(test)]+mod summary_extra_tests {+    #[test]+    fn the_summary_names_the_title_block_and_the_extents_in_the_standard_frame() {+        let text = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/placement/esc-g431-unrouted.kicad_pcb")).unwrap();+        let mut v = serde_json::json!({"summary": {}});+        super::summary_extras(&mut v, &text, true);+        assert_eq!(v["summary"]["titleBlock"]["title"], "ESC: G431");+        let e = &v["summary"]["extents"];+        assert!(e["y1"].as_f64().unwrap() <= -58.0 + 1e-6 && e["y0"].as_f64().unwrap() <= -132.0 + 1e-6, "{e}");+    }+}
rust/crates/kicad-core/Cargo.toml+3
@@ -17,6 +17,9 @@ ureq = { version = "3", default-features = false, features = ["rustls", "gzip"], # (cmake), so a bare cross type-check can turn it off. kicad-ipc-rs = { version = "0.5", features = ["blocking"], optional = true } prost-types = { version = "0.14", optional = true }+# SVG to PNG for kicad_render_schematic: pure Rust (usvg + tiny-skia), no system libs, so it+# cross-builds for x86_64-pc-windows-gnu. Text features off: KiCad plots text as strokes.+resvg = { version = "0.45", default-features = false }  [features] # net: HTTPS fetches (wiki design rules, PCM, KiCad update check) through ureq + rustls.
rust/crates/kicad-core/src/board_edit.rsadded+1505
@@ -0,0 +1,1505 @@+//! Board and footprint edits as pure text transforms: the board outline, the copper layer+//! count, board graphics, mounting holes, SVG artwork, footprints added, flipped, deleted,+//! edited, updated from their library, their graphics and 3D models, alignment plans,+//! schematic cross-probing, the title block and the board's extents.+//!+//! Every edit returns a [`Change`]: the whole candidate board text (what a closed file+//! becomes, and what KiCad's DRC checks before a live commit), the top-level item ids to+//! delete and the blocks to create when the same change is committed live through the IPC+//! API, and the per-verb report. The file and the live path therefore make exactly the+//! same edit, and a live commit is checked against the very text a file would get.++use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++use crate::fp_text;+use crate::pcb::{self, Board, NetFormat, RoutingError};+use crate::placement;+use crate::schematic::new_uuid;+use crate::sxedit::{self, n, q, Edits, Node};++pub type RResult<T> = Result<T, RoutingError>;++/// One edit, ready for a closed file or a live commit.+#[derive(Clone, Debug, Default)]+pub struct Change {+    /// The whole board after the edit.+    pub candidate: String,+    /// Top-level item UUIDs a live commit deletes.+    pub remove: Vec<String>,+    /// Top-level blocks a live commit creates (fresh UUIDs, so they never collide with an+    /// item the same commit deletes).+    pub create: Vec<String>,+    /// The edit can change copper (so a live commit runs KiCad's DRC first).+    pub copper: bool,+    /// What the verb reports.+    pub report: Value,+    /// Footprints the read-back must show (or not show) afterwards.+    pub expect: Vec<Expect>,+    /// The native Undo entry.+    pub label: String,+}++/// A read-back expectation: the footprint `reference` is present (with this pose and side)+/// or gone.+#[derive(Clone, Debug, PartialEq)]+pub struct Expect {+    pub reference: String,+    pub present: bool,+    pub pose: Option<(f64, f64, f64)>,+    pub side: Option<String>,+}++fn err(code: &str, msg: impl Into<String>) -> RoutingError {+    RoutingError::new(code, msg).with("mutated", json!(false))+}++fn parse_root(text: &str) -> RResult<Node> {+    let r = sxedit::parse(text).map_err(|e| err("board_unparsable", format!("could not read the board: {e}")))?;+    if r.tag() != Some("kicad_pcb") {+        return Err(err("not_a_board", "the text is not a (kicad_pcb ...)"));+    }+    Ok(r)+}++fn parse_board(text: &str) -> RResult<Board> {+    pcb::parse_pcb_text(text, "board.kicad_pcb").map_err(|e| err("board_unparsable", e))+}++/// The footprint's reference (the Reference property, or a KiCad 6 `fp_text reference`).+pub fn fp_ref(fp: &Node) -> String {+    fp.kids_tagged("property")+        .find(|p| p.atom(1) == Some("Reference"))+        .and_then(|p| p.atom(2))+        .or_else(|| fp.kids_tagged("fp_text").find(|t| t.atom(1) == Some("reference")).and_then(|t| t.atom(2)))+        .unwrap_or("")+        .to_string()+}++pub fn footprints(root: &Node) -> Vec<&Node> {+    root.kids_tagged("footprint").collect()+}++fn refs_arg(args: &Value) -> RResult<Vec<String>> {+    let list: Vec<String> = match (args.get("refs"), args.get("ref")) {+        (Some(Value::Array(a)), _) => a.iter().filter_map(|v| v.as_str().or_else(|| v.get("ref").and_then(Value::as_str))).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),+        (_, Some(Value::String(s))) if !s.trim().is_empty() => vec![s.trim().to_string()],+        _ => Vec::new(),+    };+    if list.is_empty() {+        return Err(err("missing_ref", "Pass ref (\"U1\") or refs ([\"U1\",\"C3\"])"));+    }+    if list.len() > 256 {+        return Err(err("invalid_refs", "At most 256 refs per call"));+    }+    Ok(list)+}++fn find_refs<'a>(root: &'a Node, refs: &[String]) -> RResult<Vec<&'a Node>> {+    let fps = footprints(root);+    let mut out = Vec::new();+    let mut missing = Vec::new();+    for r in refs {+        match fps.iter().find(|f| fp_ref(f).eq_ignore_ascii_case(r)) {+            Some(f) => {+                if out.iter().any(|o: &&Node| o.start == f.start) {+                    return Err(err("invalid_refs", format!("{r} is listed twice")));+                }+                out.push(*f)+            }+            None => missing.push(r.clone()),+        }+    }+    if !missing.is_empty() {+        let mut all: Vec<String> = fps.iter().map(|f| fp_ref(f)).collect();+        all.sort_by_key(|r| placement::ref_key(r));+        return Err(err("unknown_footprint", format!("No footprint with reference {}", missing.join(", "))).with("missing", json!(missing)).with("candidates", json!(all.into_iter().take(60).collect::<Vec<_>>())));+    }+    Ok(out)+}++fn check_unlocked(fps: &[&Node], args: &Value) -> RResult<()> {+    if args.get("allowLocked") == Some(&json!(true)) {+        return Ok(());+    }+    let locked: Vec<String> = fps.iter().filter(|f| fp_text::is_locked(f)).map(|f| fp_ref(f)).collect();+    if !locked.is_empty() {+        return Err(err("footprint_locked", format!("{} locked; pass allowLocked:true to edit it anyway", locked.join(", "))).with("lockedRefs", json!(locked)));+    }+    Ok(())+}++fn num_arg(args: &Value, key: &str) -> RResult<Option<f64>> {+    match args.get(key) {+        None | Some(Value::Null) => Ok(None),+        Some(v) => pcb::number(Some(v), key, false).map(Some),+    }+}++fn need(args: &Value, key: &str) -> RResult<f64> {+    num_arg(args, key)?.ok_or_else(|| err("missing_arg", format!("{key} is required (mm)")))+}++fn side_arg(args: &Value) -> RResult<bool> {+    match args.get("side").and_then(Value::as_str).map(|s| s.trim().to_ascii_lowercase()) {+        None => Ok(false),+        Some(s) if matches!(s.as_str(), "f.cu" | "f" | "front" | "top") => Ok(false),+        Some(s) if matches!(s.as_str(), "b.cu" | "b" | "back" | "bottom") => Ok(true),+        Some(s) => Err(err("invalid_side", format!("side must be F.Cu or B.Cu, not '{s}'"))),+    }+}++fn with_change(text: &str, root: &Node, edits: Edits, append: &[String]) -> RResult<String> {+    let edited = edits.apply(text).map_err(|e| err("edit_failed", e))?;+    if append.is_empty() {+        return Ok(edited);+    }+    let r = parse_root(&edited)?;+    let _ = root;+    Ok(sxedit::append_top_level(&edited, &r, append))+}++fn stroke(width: f64) -> String {+    format!("(stroke (width {}) (type solid))", n(width))+}++// ---------------------------------------------------------------------------+// Outline+// ---------------------------------------------------------------------------++const GRAPHIC_TAGS: &[(&str, &str)] = &[+    ("gr_line", "line"),+    ("gr_rect", "rect"),+    ("gr_circle", "circle"),+    ("gr_arc", "arc"),+    ("gr_poly", "polygon"),+    ("gr_curve", "bezier"),+    ("gr_text", "text"),+    ("gr_text_box", "textbox"),+    ("dimension", "dimension"),+];++fn graphic_kind(tag: &str) -> Option<&'static str> {+    GRAPHIC_TAGS.iter().find(|(t, _)| *t == tag).map(|(_, k)| *k)+}++/// Board graphics (top level) as rows: uuid, kind, layer and their points.+pub fn board_graphics(root: &Node) -> Vec<Value> {+    root.kids+        .iter()+        .filter_map(|k| {+            let kind = graphic_kind(k.tag()?)?;+            let pt = |t: &str| k.kid(t).and_then(|p| Some(json!([p.f64_at(1)?, p.f64_at(2)?])));+            let mut row = json!({"uuid": k.uuid(), "kind": kind, "layer": k.value_of("layer")});+            for t in ["start", "mid", "end", "center"] {+                if let Some(p) = pt(t) {+                    row[t] = p;+                }+            }+            if let Some(pts) = k.kid("pts") {+                row["pointCount"] = json!(pts.kids_tagged("xy").count());+            }+            if k.tag() == Some("gr_text") {+                row["text"] = json!(k.atom(1));+            }+            Some(row)+        })+        .collect()+}++/// `kicad_board_outline`: a rectangle, a rounded rectangle (true arcs) or a polygon on+/// Edge.Cuts. `replace:true` removes every existing Edge.Cuts graphic first; without it an+/// existing outline is refused (`outline_exists`) unless `append:true`.+pub fn outline(text: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let board = parse_board(text)?;+    let width = num_arg(args, "lineWidth")?.unwrap_or(0.05);+    if !(width > 0.0 && width < 5.0) {+        return Err(err("invalid_args", "lineWidth must be between 0 and 5 mm"));+    }+    let existing: Vec<&Node> = root.kids.iter().filter(|k| k.tag().map(|t| t.starts_with("gr_")).unwrap_or(false) && k.value_of("layer") == Some("Edge.Cuts")).collect();+    let replace = args.get("replace") == Some(&json!(true));+    if !existing.is_empty() && !replace && args.get("append") != Some(&json!(true)) {+        return Err(err("outline_exists", format!("The board already has {} Edge.Cuts item(s); pass replace:true to replace the outline (or append:true to add a cutout)", existing.len())).with("existing", json!(board_graphics(&root).into_iter().filter(|g| g["layer"] == "Edge.Cuts").collect::<Vec<_>>())));+    }+    let shape = args.get("shape").and_then(Value::as_str).unwrap_or(if args.get("points").is_some() { "polygon" } else if args.get("radius").is_some() { "rounded" } else { "rectangle" });+    let tail = |w: f64| format!("{} (layer \"Edge.Cuts\") (uuid {})", stroke(w), q(&new_uuid()));+    let mut blocks: Vec<String> = Vec::new();+    let (x0, y0, x1, y1);+    match shape {+        "rectangle" | "rect" | "rounded" | "rounded_rectangle" => {+            let w = need(args, "width")?;+            let h = need(args, "height")?;+            if w <= 0.0 || h <= 0.0 {+                return Err(err("invalid_args", "width and height must be positive"));+            }+            let (ox, oy) = match (num_arg(args, "x")?, num_arg(args, "y")?, args.get("center")) {+                (_, _, Some(Value::Array(c))) if c.len() == 2 => (c[0].as_f64().unwrap_or(0.0) - w / 2.0, c[1].as_f64().unwrap_or(0.0) - h / 2.0),+                (Some(x), Some(y), _) => (x, y),+                _ => board.dimensions.as_ref().filter(|_| replace).map(|d| (d.min_x, d.min_y)).unwrap_or((100.0, 100.0)),+            };+            x0 = ox;+            y0 = oy;+            x1 = ox + w;+            y1 = oy + h;+            let r = if shape.starts_with("rounded") { need(args, "radius")? } else { num_arg(args, "radius")?.unwrap_or(0.0) };+            if r < 0.0 || r * 2.0 > w.min(h) + 1e-9 {+                return Err(err("invalid_args", "radius must be between 0 and half the shorter side"));+            }+            if r <= 0.0 {+                blocks.push(format!("(gr_rect (start {} {}) (end {} {}) {} (fill no) (layer \"Edge.Cuts\") (uuid {}))", n(x0), n(y0), n(x1), n(y1), stroke(width), q(&new_uuid())));+            } else {+                let line = |a: (f64, f64), b: (f64, f64)| format!("(gr_line (start {} {}) (end {} {}) {})", n(a.0), n(a.1), n(b.0), n(b.1), tail(width));+                let k = r * (1.0 - std::f64::consts::FRAC_1_SQRT_2);+                let arc = |s: (f64, f64), m: (f64, f64), e: (f64, f64)| format!("(gr_arc (start {} {}) (mid {} {}) (end {} {}) {})", n(s.0), n(s.1), n(m.0), n(m.1), n(e.0), n(e.1), tail(width));+                if w - 2.0 * r > 1e-9 {+                    blocks.push(line((x0 + r, y0), (x1 - r, y0)));+                    blocks.push(line((x1 - r, y1), (x0 + r, y1)));+                }+                if h - 2.0 * r > 1e-9 {+                    blocks.push(line((x1, y0 + r), (x1, y1 - r)));+                    blocks.push(line((x0, y1 - r), (x0, y0 + r)));+                }+                blocks.push(arc((x0, y0 + r), (x0 + k, y0 + k), (x0 + r, y0)));+                blocks.push(arc((x1 - r, y0), (x1 - k, y0 + k), (x1, y0 + r)));+                blocks.push(arc((x1, y1 - r), (x1 - k, y1 - k), (x1 - r, y1)));+                blocks.push(arc((x0 + r, y1), (x0 + k, y1 - k), (x0, y1 - r)));+            }+        }+        "polygon" => {+            let pts = args.get("points").and_then(Value::as_array).ok_or_else(|| err("missing_arg", "points: [[x,y], ...] (3 or more, mm)"))?;+            if pts.len() < 3 || pts.len() > 10_000 {+                return Err(err("invalid_args", "points needs 3 to 10000 vertices"));+            }+            let mut ps = Vec::new();+            for p in pts {+                let a = p.as_array().filter(|a| a.len() == 2).ok_or_else(|| err("invalid_args", "each point is [x, y]"))?;+                ps.push((pcb::number(Some(&a[0]), "x", false)?, pcb::number(Some(&a[1]), "y", false)?));+            }+            x0 = ps.iter().map(|p| p.0).fold(f64::MAX, f64::min);+            y0 = ps.iter().map(|p| p.1).fold(f64::MAX, f64::min);+            x1 = ps.iter().map(|p| p.0).fold(f64::MIN, f64::max);+            y1 = ps.iter().map(|p| p.1).fold(f64::MIN, f64::max);+            let body: Vec<String> = ps.iter().map(|p| format!("(xy {} {})", n(p.0), n(p.1))).collect();+            blocks.push(format!("(gr_poly (pts {}) {} (fill no) (layer \"Edge.Cuts\") (uuid {}))", body.join(" "), stroke(width), q(&new_uuid())));+        }+        other => return Err(err("invalid_args", format!("shape must be rectangle, rounded or polygon, not '{other}'"))),+    }+    let mut e = Edits::new();+    let mut removed = Vec::new();+    if replace {+        for k in &existing {+            e.remove_node(text, k);+            if let Some(u) = k.uuid() {+                removed.push(u.to_string());+            }+        }+    }+    let candidate = with_change(text, &root, e, &blocks)?;+    let after = parse_board(&candidate)?;+    if replace && existing.iter().any(|k| k.uuid().is_none()) {+        return Err(err("item_without_uuid", "An existing Edge.Cuts item has no uuid, so a live commit could not delete it; replace the outline in the closed file or in KiCad"));+    }+    let report = json!({"shape": shape, "box": {"x0": x0, "y0": y0, "x1": x1, "y1": y1, "widthMm": pcb::round6(x1 - x0), "heightMm": pcb::round6(y1 - y0)},+        "itemsAdded": blocks.len(), "itemsRemoved": removed.len(), "removedIds": removed, "outlineAfter": after.dimensions});+    Ok(Change { candidate, remove: removed, create: blocks, copper: false, report, expect: vec![], label: "Adom board outline".into() })+}++// ---------------------------------------------------------------------------+// Copper layer count+// ---------------------------------------------------------------------------++fn inner_name(k: usize) -> String {+    format!("In{k}.Cu")+}++/// `kicad_set_layer_count`: 2, 4, 6 or 8 copper layers. Rewrites the `(layers ...)` table+/// (KiCad 10 ids: F.Cu 0, B.Cu 2, In<k>.Cu 2k+2) and, when the board has one, the physical+/// stack-up (copper and dielectric between F.Cu and B.Cu, keeping the board thickness).+/// Removing an inner layer that carries anything is refused (`layers_in_use`).+pub fn set_layer_count(text: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let want = args.get("count").or_else(|| args.get("layers")).and_then(Value::as_u64).ok_or_else(|| err("missing_arg", "count: 2, 4, 6 or 8"))? as usize;+    if ![2, 4, 6, 8].contains(&want) {+        return Err(err("invalid_args", "count must be 2, 4, 6 or 8"));+    }+    let layers = root.kid("layers").ok_or_else(|| err("board_unparsable", "the board has no (layers ...) table"))?;+    let copper: Vec<&Node> = layers.kids.iter().skip(1).filter(|l| l.atom(1).map(|n| n.ends_with(".Cu")).unwrap_or(false)).collect();+    let have = copper.len();+    if have == want {+        return Ok(Change { candidate: text.to_string(), report: json!({"countBefore": have, "countAfter": want, "unchanged": true}), label: "Adom layer count".into(), ..Default::default() });+    }+    let keep_inner = want - 2;+    // inner layers that would go+    let gone: Vec<String> = (keep_inner + 1..=have.saturating_sub(2)).map(inner_name).collect();+    if !gone.is_empty() {+        let mut used: Map<String, Value> = Map::new();+        for g in &gone {+            let mut count = 0usize;+            let quoted = q(g);+            root.walk(&mut |nd| {+                if nd.tag() == Some("layer") && nd.atom(1) == Some(g.as_str()) {+                    count += 1;+                }+                if matches!(nd.tag(), Some("layers")) && nd.kids.iter().skip(1).any(|k| k.atom.as_deref() == Some(g.as_str())) && !std::ptr::eq(nd, layers) {+                    count += 1;+                }+            });+            let _ = quoted;+            if count > 0 {+                used.insert(g.clone(), json!(count));+            }+        }+        // the stack-up mentions every layer; it is rewritten, not "use"+        let stack_uses = root.kid("setup").and_then(|s| s.kid("stackup")).map(|s| s.kids_tagged("layer").filter(|l| gone.iter().any(|g| l.atom(1) == Some(g.as_str()))).count()).unwrap_or(0);+        for g in &gone {+            if let Some(v) = used.get_mut(g) {+                let c = v.as_u64().unwrap_or(0) as usize;+                let minus = if stack_uses > 0 { 1 } else { 0 };+                *v = json!(c.saturating_sub(minus));+            }+        }+        used.retain(|_, v| v.as_u64().unwrap_or(0) > 0);+        if !used.is_empty() {+            return Err(err("layers_in_use", format!("{} still carry items; move or delete them before removing the layer", used.keys().cloned().collect::<Vec<_>>().join(", "))).with("itemsPerLayer", Value::Object(used)));+        }+    }+    let mut e = Edits::new();+    // the layers table: keep F.Cu and the surviving inner rows as they are, add the new ones+    let f = copper.iter().find(|l| l.atom(1) == Some("F.Cu")).ok_or_else(|| err("board_unparsable", "no F.Cu in the layers table"))?;+    for l in &copper {+        let name = l.atom(1).unwrap_or("");+        if let Some(k) = name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()) {+            if k > keep_inner {+                e.remove_node(text, l);+            }+        }+    }+    let last_inner = copper.iter().filter(|l| l.atom(1).map(|n| n.starts_with("In")).unwrap_or(false)).last().copied();+    let mut add = String::new();+    for k in (have.saturating_sub(2) + 1)..=keep_inner {+        add.push_str(&format!("\n\t\t({} {} signal)", 2 * k + 2, q(&inner_name(k))));+    }+    if !add.is_empty() {+        e.insert(last_inner.unwrap_or(f).end, add);+    }+    // the physical stack-up, when present+    let mut stack_note = Value::Null;+    if let Some(stack) = root.kid("setup").and_then(|s| s.kid("stackup")) {+        let rows: Vec<&Node> = stack.kids_tagged("layer").collect();+        let first = rows.iter().position(|l| l.atom(1) == Some("F.Cu"));+        let last = rows.iter().position(|l| l.atom(1) == Some("B.Cu"));+        if let (Some(a), Some(b)) = (first, last) {+            let thick = root.kid("general").and_then(|g| g.kid("thickness")).and_then(|t| t.f64_at(1)).unwrap_or(1.6);+            let cu_of = |name: &str| rows.iter().find(|l| l.atom(1) == Some(name)).and_then(|l| l.kid("thickness")).and_then(|t| t.f64_at(1)).unwrap_or(0.035);+            let masks: f64 = rows.iter().filter(|l| matches!(l.atom(1), Some("F.Mask") | Some("B.Mask"))).filter_map(|l| l.kid("thickness").and_then(|t| t.f64_at(1))).sum();+            let names: Vec<String> = std::iter::once("F.Cu".to_string()).chain((1..=keep_inner).map(inner_name)).chain(std::iter::once("B.Cu".to_string())).collect();+            let cu_total: f64 = names.iter().map(|n| cu_of(n)).sum();+            let diel = ((thick - cu_total - masks) / (want as f64 - 1.0) * 10000.0).round() / 10000.0;+            if diel <= 0.0 {+                return Err(err("stackup_too_thin", format!("A {thick} mm board cannot hold {want} copper layers with these copper thicknesses")));+            }+            let mut s = String::new();+            for (i, name) in names.iter().enumerate() {+                match rows.iter().find(|l| l.atom(1) == Some(name.as_str())) {+                    Some(existing) => s.push_str(existing.src(text)),+                    None => s.push_str(&format!("(layer {}\n\t\t\t\t(type \"copper\")\n\t\t\t\t(thickness 0.035)\n\t\t\t)", q(name))),+                }+                if i + 1 < names.len() {+                    let kind = if i % 2 == 0 { "prepreg" } else { "core" };+                    s.push_str(&format!("\n\t\t\t(layer {}\n\t\t\t\t(type {})\n\t\t\t\t(thickness {})\n\t\t\t\t(material \"FR4\")\n\t\t\t\t(epsilon_r 4.5)\n\t\t\t\t(loss_tangent 0.02)\n\t\t\t)\n\t\t\t", q(&format!("dielectric {}", i + 1)), q(kind), n(diel)));+                }+            }+            e.replace(rows[a].start, rows[b].end, s);+            stack_note = json!({"rewritten": true, "dielectricMm": diel, "boardThicknessMm": thick});+        }+    }+    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+    let after = parse_board(&candidate)?;+    let names = pcb::copper_layers(&after);+    if names.len() != want {+        return Err(err("edit_failed", format!("the rewritten board reads back {} copper layers, not {want}", names.len())));+    }+    Ok(Change {+        candidate,+        report: json!({"countBefore": have, "countAfter": want, "copperLayers": names, "removedLayers": gone, "stackup": stack_note}),+        label: "Adom layer count".into(),+        ..Default::default()+    })+}++// ---------------------------------------------------------------------------+// Board graphics: delete, SVG import+// ---------------------------------------------------------------------------++/// `kicad_delete_graphics`: top-level graphics by uuid, or every one matching `layer`+/// and/or `type` (line, rect, circle, arc, polygon, bezier, text, textbox, dimension).+pub fn delete_graphics(text: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let ids: Vec<String> = args.get("uuids").or_else(|| args.get("itemIds")).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();+    let layer = args.get("layer").and_then(Value::as_str);+    let kinds: Vec<String> = match args.get("type") {+        Some(Value::String(s)) => vec![s.clone()],+        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        _ => Vec::new(),+    };+    for k in &kinds {+        if !GRAPHIC_TAGS.iter().any(|(_, kk)| kk == k) {+            return Err(err("invalid_args", format!("type '{k}' is not one of line, rect, circle, arc, polygon, bezier, text, textbox, dimension")));+        }+    }+    if ids.is_empty() && layer.is_none() && kinds.is_empty() {+        return Err(err("missing_arg", "Pass uuids, or a layer and/or type filter (dryRun:true lists what would go)"));+    }+    let mut hits: Vec<&Node> = Vec::new();+    for k in &root.kids {+        let Some(kind) = k.tag().and_then(graphic_kind) else { continue };+        let by_id = !ids.is_empty() && k.uuid().map(|u| ids.iter().any(|i| i == u)).unwrap_or(false);+        let by_filter = ids.is_empty() && layer.map(|l| k.value_of("layer") == Some(l) || k.kid("layers").map(|ls| ls.kids.iter().any(|a| a.atom.as_deref() == Some(l))).unwrap_or(false)).unwrap_or(true) && (kinds.is_empty() || kinds.iter().any(|kk| kk == kind));+        if by_id || by_filter {+            hits.push(k);+        }+    }+    if !ids.is_empty() {+        let found: Vec<&str> = hits.iter().filter_map(|h| h.uuid()).collect();+        let missing: Vec<&String> = ids.iter().filter(|i| !found.contains(&i.as_str())).collect();+        if !missing.is_empty() {+            return Err(err("unknown_ids", format!("{} uuid(s) are not board graphics", missing.len())).with("missing", json!(missing)));+        }+    }+    if hits.iter().any(|h| h.uuid().is_none()) {+        return Err(err("item_without_uuid", "A matching graphic has no uuid; a live commit could not address it"));+    }+    let rows: Vec<Value> = board_graphics(&root).into_iter().filter(|g| hits.iter().any(|h| h.uuid() == g["uuid"].as_str())).collect();+    let mut e = Edits::new();+    for h in &hits {+        e.remove_node(text, h);+    }+    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+    let copper = hits.iter().any(|h| h.value_of("layer").map(|l| l.ends_with(".Cu")).unwrap_or(false));+    Ok(Change {+        candidate,+        remove: hits.iter().filter_map(|h| h.uuid()).map(str::to_string).collect(),+        copper,+        report: json!({"deleted": rows, "count": rows.len()}),+        label: "Adom delete graphics".into(),+        ..Default::default()+    })+}++/// `kicad_import_svg`: an SVG's filled shapes as filled polygons on one layer.+/// `x`, `y` place the artwork's top-left corner (or its centre with `anchor:"center"`);+/// `width` (mm) scales it to that width, else `scale` multiplies its own size.+pub fn import_svg(text: &str, svg: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let board = parse_board(text)?;+    let layer = args.get("layer").and_then(Value::as_str).unwrap_or("F.SilkS").to_string();+    let allowed = ["F.SilkS", "B.SilkS", "F.Cu", "B.Cu", "F.Mask", "B.Mask", "F.Fab", "B.Fab", "Dwgs.User", "Cmts.User", "User.1", "User.2", "User.3", "User.4"];+    if !allowed.contains(&layer.as_str()) {+        return Err(err("invalid_layer", format!("layer must be one of {}", allowed.join(", "))));+    }+    let art = crate::svg_import::parse_svg(svg, args.get("toleranceMm").and_then(Value::as_f64).unwrap_or(0.02)).map_err(|e| err("svg_unreadable", e))?;+    if art.rings.is_empty() {+        return Err(err("svg_empty", "The SVG has no filled shape (text must be converted to paths first)").with("skipped", json!(art.skipped)));+    }+    let polys = crate::svg_import::fill_polygons(&art.rings);+    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    for p in polys.iter().flatten() {+        minx = minx.min(p.0);+        miny = miny.min(p.1);+        maxx = maxx.max(p.0);+        maxy = maxy.max(p.1);+    }+    let natural_w = (maxx - minx) * art.mm_per_unit;+    let k = match (num_arg(args, "width")?, num_arg(args, "scale")?) {+        (Some(w), _) if w > 0.0 && natural_w > 0.0 => w / natural_w,+        (Some(_), _) => return Err(err("invalid_args", "width must be positive")),+        (None, Some(s)) if s > 0.0 => s,+        (None, Some(_)) => return Err(err("invalid_args", "scale must be positive")),+        (None, None) => 1.0,+    } * art.mm_per_unit;+    let (w, h) = ((maxx - minx) * k, (maxy - miny) * k);+    if w > 1000.0 || h > 1000.0 {+        return Err(err("invalid_args", format!("the artwork would be {w:.1} x {h:.1} mm; pass width or scale")));+    }+    let x = need(args, "x")?;+    let y = need(args, "y")?;+    let center = args.get("anchor").and_then(Value::as_str) == Some("center");+    let (ox, oy) = if center { (x - w / 2.0, y - h / 2.0) } else { (x, y) };+    let mirror = args.get("mirror") == Some(&json!(true)) || layer.starts_with("B.") && args.get("mirror") != Some(&json!(false));+    let net = if layer.ends_with(".Cu") {+        match args.get("net") {+            Some(v) => Some(pcb::resolve_net(&board, Some(v)).map_err(|e| err("unknown_net", e["error"].as_str().unwrap_or("unknown net").to_string()).with("nets", e["nets"].clone()))?),+            None => None,+        }+    } else {+        None+    };+    let mut blocks = Vec::new();+    let mut vertices = 0;+    for p in &polys {+        let pts: Vec<String> = p+            .iter()+            .map(|(px, py)| {+                let lx = (px - minx) * k;+                let lx = if mirror { w - lx } else { lx };+                format!("(xy {} {})", n(ox + lx), n(oy + (py - miny) * k))+            })+            .collect();+        vertices += pts.len();+        let net_text = net.as_ref().filter(|n| !n.name.is_empty()).map(|nr| match nr.format {+            NetFormat::Name => format!(" (net {})", q(&nr.name)),+            NetFormat::Number => format!(" (net {})", nr.number.unwrap_or(0)),+        }).unwrap_or_default();+        blocks.push(format!("(gr_poly (pts {}) (stroke (width 0) (type solid)) (fill yes) (layer {}){net_text} (uuid {}))", pts.join(" "), q(&layer), q(&new_uuid())));+    }+    if vertices > 200_000 {+        return Err(err("svg_too_complex", format!("{vertices} vertices; simplify the artwork or raise toleranceMm")));+    }+    let candidate = sxedit::append_top_level(text, &root, &blocks);+    Ok(Change {+        candidate,+        create: blocks.clone(),+        copper: layer.ends_with(".Cu"),+        report: json!({"layer": layer, "polygons": blocks.len(), "vertices": vertices, "box": {"x0": pcb::round6(ox), "y0": pcb::round6(oy), "x1": pcb::round6(ox + w), "y1": pcb::round6(oy + h)},+            "sizeMm": [pcb::round6(w), pcb::round6(h)], "mirrored": mirror, "skipped": art.skipped}),+        label: "Adom import SVG".into(),+        ..Default::default()+    })+}++// ---------------------------------------------------------------------------+// Footprints: add, mounting hole, flip, delete, edit+// ---------------------------------------------------------------------------++fn ref_split(r: &str) -> (String, Option<u64>) {+    let digits = r.chars().rev().take_while(|c| c.is_ascii_digit()).count();+    let (p, d) = r.split_at(r.len() - digits);+    (p.to_string(), d.parse().ok())+}++fn existing_refs(root: &Node) -> Vec<String> {+    footprints(root).iter().map(|f| fp_ref(f)).collect()+}++fn next_free(prefix: &str, taken: &[String], start: u64) -> u64 {+    let mut k = start.max(1);+    while taken.iter().any(|t| t.eq_ignore_ascii_case(&format!("{prefix}{k}"))) {+        k += 1;+    }+    k+}++/// The references an add makes: `reference` "R" (prefix: the next free numbers) or "R7"+/// (from 7 on; a taken one is refused), `count` of them.+fn plan_refs(root: &Node, reference: &str, count: usize) -> RResult<Vec<String>> {+    let taken = existing_refs(root);+    let (prefix, start) = ref_split(reference);+    if prefix.is_empty() {+        return Err(err("invalid_args", "reference needs a letter prefix (R, C7, H1)"));+    }+    let mut out = Vec::new();+    match start {+        None => {+            let mut k = 1;+            for _ in 0..count {+                k = next_free(&prefix, &taken, k);+                out.push(format!("{prefix}{k}"));+                k += 1;+            }+        }+        Some(s) => {+            for i in 0..count as u64 {+                let r = format!("{prefix}{}", s + i);+                if taken.iter().any(|t| t.eq_ignore_ascii_case(&r)) {+                    return Err(err("duplicate_reference", format!("{r} is already on the board")).with("nextFree", json!(format!("{prefix}{}", next_free(&prefix, &taken, 1)))));+                }+                out.push(r);+            }+        }+    }+    Ok(out)+}++fn pad_net_text(board: &Board, net: &str) -> RResult<String> {+    let nr = pcb::resolve_net(board, Some(&json!(net))).map_err(|e| err("unknown_net", e["error"].as_str().unwrap_or("unknown net").to_string()).with("nets", e["nets"].clone()))?;+    Ok(match nr.format {+        NetFormat::Name => format!("(net {})", q(&nr.name)),+        NetFormat::Number => format!("(net {} {})", nr.number.unwrap_or(0), q(&nr.name)),+    })+}++/// Assign nets to pads by number: `nets` maps pad number to net name.+fn set_pad_nets(block: &str, board: &Board, nets: &Map<String, Value>) -> RResult<String> {+    let fp = sxedit::parse(block).map_err(|e| err("edit_failed", e))?;+    let mut e = Edits::new();+    for (pad_no, net) in nets {+        let net = net.as_str().ok_or_else(|| err("invalid_args", "nets maps pad numbers to net names"))?;+        let pads: Vec<&Node> = fp.kids_tagged("pad").filter(|p| p.atom(1) == Some(pad_no.as_str())).collect();+        if pads.is_empty() {+            return Err(err("unknown_pad", format!("the footprint has no pad {pad_no}")));+        }+        let t = pad_net_text(board, net)?;+        for p in pads {+            match p.kid("net") {+                Some(old) => e.replace_node(old, t.clone()),+                None => e.insert(p.kid("uuid").map(|u| u.start).unwrap_or(p.end - 1), format!("{t} ")),+            }+        }+    }+    e.apply(block).map_err(|x| err("edit_failed", x))+}++fn expect_of(block: &str) -> Expect {+    let fp = sxedit::parse(block).unwrap_or_else(|_| Node { start: 0, end: 0, atom: None, quoted: false, kids: vec![] });+    Expect { reference: fp_ref(&fp), present: true, pose: Some(fp_text::pose(&fp)), side: Some(fp_text::side(&fp)) }+}++/// `kicad_add_footprint`: a library footprint (its `.kicad_mod` text in `lib_text`, named+/// `lib_id`) or a copy of `duplicateOf`, at `x`, `y`, `rotation`, `side`, with `reference`+/// and `value`; `count` with `pitch` [dx, dy] makes a row or grid of copies.+pub fn add_footprint(text: &str, lib: Option<(&str, &str)>, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let board = parse_board(text)?;+    let copper = fp_text::copper_count(&root);+    let x = need(args, "x")?;+    let y = need(args, "y")?;+    let rot = num_arg(args, "rotation")?.unwrap_or(0.0);+    let back = side_arg(args)?;+    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1) as usize;+    if count == 0 || count > 100 {+        return Err(err("invalid_args", "count must be 1..100"));+    }+    let pitch = match args.get("pitch") {+        Some(Value::Array(a)) if a.len() == 2 => (pcb::number(Some(&a[0]), "pitch x", false)?, pcb::number(Some(&a[1]), "pitch y", false)?),+        Some(Value::Number(v)) => (v.as_f64().unwrap_or(0.0), 0.0),+        None | Some(Value::Null) if count == 1 => (0.0, 0.0),+        _ => return Err(err("missing_arg", "count > 1 needs pitch: [dx, dy] in mm")),+    };+    let cols = args.get("columns").and_then(Value::as_u64).unwrap_or(count as u64).max(1) as usize;+    let value = args.get("value").and_then(Value::as_str);+    let nets = args.get("nets").and_then(Value::as_object);+    if nets.is_some() && count > 1 {+        return Err(err("invalid_args", "nets is for a single footprint (count 1)"));+    }+    let (source, refs) = match (lib, args.get("duplicateOf").and_then(Value::as_str)) {+        (_, Some(dup)) => {+            let src = find_refs(&root, &[dup.to_string()])?[0];+            let reference = args.get("reference").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ref_split(&fp_ref(src)).0);+            (Err(src), plan_refs(&root, &reference, count)?)+        }+        (Some(l), None) => {+            let reference = args.get("reference").and_then(Value::as_str).ok_or_else(|| err("missing_arg", "reference is required (\"R\" for the next free number, or \"R7\")"))?;+            (Ok(l), plan_refs(&root, reference, count)?)+        }+        (None, None) => return Err(err("missing_arg", "Pass libraryId (\"Resistor_SMD:R_0603_1608Metric\"), footprintPath, or duplicateOf")),+    };+    let mut blocks = Vec::new();+    for (i, r) in refs.iter().enumerate() {+        let (cx, cy) = (x + pitch.0 * (i % cols) as f64, y + pitch.1 * (i / cols) as f64);+        let block = match &source {+            Ok((lib_text, lib_id)) => fp_text::place_library(lib_text, lib_id, cx, cy, rot, back, r, value, copper).map_err(|e| err("footprint_unusable", e))?,+            Err(src) => {+                let mut b = fp_text::fresh_uuids(src.src(text)).map_err(|e| err("edit_failed", e))?;+                if args.get("keepNets") != Some(&json!(true)) {+                    b = fp_text::strip_nets(&b).map_err(|e| err("edit_failed", e))?;+                }+                b = fp_text::strip_symbol_link(&b).map_err(|e| err("edit_failed", e))?;+                b = fp_text::set_property(&b, "Reference", r).map_err(|e| err("edit_failed", e))?;+                if let Some(v) = value {+                    b = fp_text::set_property(&b, "Value", v).map_err(|e| err("edit_failed", e))?;+                }+                let src_back = fp_text::side(src) == "B.Cu";+                let want_back = if args.get("side").is_some() { back } else { src_back };+                if want_back != src_back {+                    b = fp_text::flip_block(&b, true, copper).map_err(|e| err("flip_unsupported", e))?;+                }+                let r = if args.get("rotation").is_some() { rot } else { fp_text::pose(src).2 };+                fp_text::move_block(&b, cx, cy, r).map_err(|e| err("edit_failed", e))?+            }+        };+        let block = match nets {+            Some(m) => set_pad_nets(&block, &board, m)?,+            None => block,+        };+        blocks.push(block);+    }+    let candidate = sxedit::append_top_level(text, &root, &blocks);+    let after = parse_board(&candidate)?;+    let added: Vec<Value> = refs.iter().filter_map(|r| after.footprints.iter().find(|f| &f.reference == r)).map(|f| json!({"ref": f.reference, "footprint": f.footprint, "value": f.value, "uuid": f.uuid, "at": f.position, "side": f.layer, "pads": f.pad_count})).collect();+    if added.len() != refs.len() {+        return Err(err("edit_failed", "the new footprints did not read back"));+    }+    Ok(Change {+        expect: blocks.iter().map(|b| expect_of(b)).collect(),+        create: blocks,+        candidate,+        copper: true,+        report: json!({"added": added, "count": refs.len()}),+        label: format!("Adom add {}", refs.join(", ")),+        ..Default::default()+    })+}++/// Stock KiCad MountingHole library names for a drill, when one exists.+pub fn mounting_hole_lib_id(drill: f64, plated: bool) -> Option<String> {+    let table = [(2.2, "M2"), (2.7, "M2.5"), (3.2, "M3"), (3.7, "M3.5"), (4.3, "M4"), (5.3, "M5"), (6.4, "M6"), (8.4, "M8")];+    let (d, m) = table.iter().find(|(d, _)| (d - drill).abs() < 1e-6)?;+    Some(format!("MountingHole:MountingHole_{}mm_{m}{}", n(*d), if plated { "_Pad" } else { "" }))+}++fn generated_hole(drill: f64, pad: f64, plated: bool, reference: &str) -> String {+    let r = (if plated { pad } else { drill }) / 2.0;+    let court = r + 0.25;+    let pad_text = if plated {+        format!("(pad \"1\" thru_hole circle (at 0 0) (size {p} {p}) (drill {d}) (layers \"*.Cu\" \"*.Mask\") (remove_unused_layers no) (uuid {u}))", p = n(pad), d = n(drill), u = q(&new_uuid()))+    } else {+        format!("(pad \"\" np_thru_hole circle (at 0 0) (size {d} {d}) (drill {d}) (layers \"*.Cu\" \"*.Mask\") (uuid {u}))", d = n(drill), u = q(&new_uuid()))+    };+    format!(+        "(footprint {name}\n\t\t(layer \"F.Cu\")\n\t\t(uuid {u})\n\t\t(at 0 0)\n\t\t(descr \"Mounting hole, {kind}, drill {d} mm (generated by the Adom KiCad Bridge)\")\n\t\t(tags \"mounting hole\")\n\t\t(property \"Reference\" {rf} (at 0 {ry}) (layer \"F.SilkS\") (uuid {u1}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t(property \"Value\" \"MountingHole\" (at 0 {vy}) (layer \"F.Fab\") (uuid {u2}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t(attr exclude_from_pos_files exclude_from_bom)\n\t\t(fp_circle (center 0 0) (end {r} 0) (stroke (width 0.15) (type solid)) (fill no) (layer \"Cmts.User\") (uuid {u3}))\n\t\t(fp_circle (center 0 0) (end {c} 0) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid {u4}))\n\t\t{pad_text}\n\t)",+        name = q(&format!("MountingHole:MountingHole_{}mm{}", n(drill), if plated { "_Pad" } else { "" })),+        u = q(&new_uuid()),+        kind = if plated { "plated" } else { "NPTH" },+        d = n(drill),+        rf = q(reference),+        ry = n(-(court + 1.0)),+        vy = n(court + 1.0),+        u1 = q(&new_uuid()),+        u2 = q(&new_uuid()),+        u3 = q(&new_uuid()),+        u4 = q(&new_uuid()),+        r = n(r),+        c = n(court),+    )+}++/// `kicad_add_mounting_hole`: NPTH (default) or plated (`plated:true`, `padDiameter`,+/// optional `net`) at `x`, `y`. `lib_text` is the stock MountingHole footprint when the+/// caller found one for this drill (`mounting_hole_lib_id`); else one is generated.+pub fn add_mounting_hole(text: &str, lib: Option<(&str, &str)>, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let board = parse_board(text)?;+    let x = need(args, "x")?;+    let y = need(args, "y")?;+    let drill = need(args, "drill")?;+    if !(0.1..=50.0).contains(&drill) {+        return Err(err("invalid_args", "drill must be 0.1 to 50 mm"));+    }+    let plated = args.get("plated") == Some(&json!(true)) || args.get("net").is_some();+    let pad = num_arg(args, "padDiameter")?.unwrap_or(if plated { pcb::round6(drill * 2.0) } else { drill });+    if plated && pad <= drill {+        return Err(err("invalid_args", "padDiameter must exceed the drill for a plated hole"));+    }+    let reference = args.get("reference").and_then(Value::as_str).unwrap_or("H");+    let r = plan_refs(&root, reference, 1)?.remove(0);+    let use_lib = lib.filter(|_| args.get("padDiameter").is_none() || !plated);+    let (block, source) = match use_lib {+        Some((lt, id)) => (fp_text::place_library(lt, id, x, y, 0.0, false, &r, None, 2).map_err(|e| err("footprint_unusable", e))?, id.to_string()),+        None => (fp_text::move_block(&generated_hole(drill, pad, plated, &r), x, y, 0.0).map_err(|e| err("edit_failed", e))?, "generated".to_string()),+    };+    let block = match (plated, args.get("net").and_then(Value::as_str)) {+        (true, Some(net)) => {+            let mut m = Map::new();+            m.insert("1".into(), json!(net));+            set_pad_nets(&block, &board, &m)?+        }+        _ => block,+    };+    let candidate = sxedit::append_top_level(text, &root, &[block.clone()]);+    Ok(Change {+        expect: vec![expect_of(&block)],+        create: vec![block],+        candidate,+        copper: plated,+        report: json!({"ref": r, "x": x, "y": y, "drill": drill, "plated": plated, "padDiameter": if plated { json!(pad) } else { Value::Null }, "net": args.get("net"), "footprint": source}),+        label: format!("Adom mounting hole {r}"),+        ..Default::default()+    })+}++/// Replace footprint blocks: the candidate keeps each footprint's text position (and its+/// UUIDs) for a file, the live create list carries fresh-UUID copies.+fn replace_blocks(text: &str, root: &Node, rows: &[(&Node, String)], label: String, copper: bool, report: Value) -> RResult<Change> {+    let mut e = Edits::new();+    let mut remove = Vec::new();+    let mut create = Vec::new();+    let mut expect = Vec::new();+    for (old, new_block) in rows {+        e.replace_node(old, new_block.clone());+        remove.push(old.uuid().ok_or_else(|| err("footprint_without_uuid", format!("{} has no uuid", fp_ref(old))))?.to_string());+        create.push(fp_text::fresh_uuids(new_block).map_err(|x| err("edit_failed", x))?);+        expect.push(expect_of(new_block));+    }+    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+    parse_board(&candidate)?;+    let _ = root;+    Ok(Change { candidate, remove, create, copper, report, expect, label })+}++/// `kicad_flip_footprint`: move footprints to the other side, mirrored about their anchors.+pub fn flip_footprints(text: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let refs = refs_arg(args)?;+    let fps = find_refs(&root, &refs)?;+    check_unlocked(&fps, args)?;+    let left_right = match args.get("direction").and_then(Value::as_str).unwrap_or("left_right") {+        "left_right" | "leftRight" | "lr" => true,+        "top_bottom" | "topBottom" | "tb" => false,+        o => return Err(err("invalid_args", format!("direction must be left_right or top_bottom, not '{o}'"))),+    };+    let copper = fp_text::copper_count(&root);+    let mut rows = Vec::new();+    let mut moved = Vec::new();+    for f in &fps {+        let block = fp_text::flip_block(f.src(text), left_right, copper).map_err(|e| err("flip_unsupported", format!("{}: {e}", fp_ref(f))).with("ref", json!(fp_ref(f))))?;+        let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;+        moved.push(json!({"ref": fp_ref(f), "from": fp_text::side(f), "to": fp_text::side(&nf), "rotationBefore": fp_text::pose(f).2, "rotationAfter": fp_text::pose(&nf).2}));+        rows.push((*f, block));+    }+    replace_blocks(text, &root, &rows, format!("Adom flip {}", refs.join(", ")), true, json!({"flipped": moved, "direction": if left_right { "left_right" } else { "top_bottom" }}))+}++/// `kicad_delete_footprint`: remove footprints by reference.+pub fn delete_footprints(text: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let board = parse_board(text)?;+    let refs = refs_arg(args)?;+    let fps = find_refs(&root, &refs)?;+    check_unlocked(&fps, args)?;+    let mut e = Edits::new();+    let mut remove = Vec::new();+    let mut rows = Vec::new();+    for f in &fps {+        let r = fp_ref(f);+        e.remove_node(text, f);+        remove.push(f.uuid().ok_or_else(|| err("footprint_without_uuid", format!("{r} has no uuid")))?.to_string());+        let bf = board.footprints.iter().find(|b| b.reference == r);+        let mut nets: Vec<String> = bf.map(|b| b.pads.iter().filter_map(|p| p.net_name.clone()).filter(|n| !n.is_empty()).collect()).unwrap_or_default();+        nets.sort();+        nets.dedup();+        rows.push(json!({"ref": r, "footprint": f.atom(1), "uuid": f.uuid(), "nets": nets}));+    }+    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+    Ok(Change {+        candidate,+        remove,+        copper: true,+        expect: refs.iter().map(|r| Expect { reference: fp_ref(fps.iter().find(|f| fp_ref(f).eq_ignore_ascii_case(r)).unwrap()), present: false, pose: None, side: None }).collect(),+        report: json!({"deleted": rows, "count": rows.len(), "note": "Tracks and vias that met these pads stay on the board; kicad_routing_state shows what is left dangling."}),+        label: format!("Adom delete {}", refs.join(", ")),+        ..Default::default()+    })+}++/// The edit `kicad_edit_footprint` makes to one footprint, from the arguments.+pub fn edit_one(block: &str, args: &Value, taken: &[String]) -> RResult<(String, Vec<String>)> {+    let mut b = block.to_string();+    let mut changed = Vec::new();+    let fe = |e: String| err("edit_failed", e);+    if let Some(v) = args.get("value") {+        let v = v.as_str().ok_or_else(|| err("invalid_args", "value is a string"))?;+        b = fp_text::set_property(&b, "Value", v).map_err(fe)?;+        changed.push("value".into());+    }+    if let Some(r) = args.get("newReference").or_else(|| args.get("reference")) {+        let r = r.as_str().filter(|s| !s.trim().is_empty()).ok_or_else(|| err("invalid_args", "newReference is a non-empty string"))?;+        if taken.iter().any(|t| t.eq_ignore_ascii_case(r)) {+            return Err(err("duplicate_reference", format!("{r} is already on the board")));+        }+        b = fp_text::set_property(&b, "Reference", r).map_err(fe)?;+        changed.push("reference".into());+    }+    if let Some(fields) = args.get("fields") {+        let m = fields.as_object().ok_or_else(|| err("invalid_args", "fields maps names to values (null removes a user field)"))?;+        for (k, v) in m {+            match v {+                Value::Null => b = fp_text::remove_property(&b, k).map_err(|e| err("invalid_args", e))?,+                Value::String(s) => b = fp_text::set_property(&b, k, s).map_err(fe)?,+                other => b = fp_text::set_property(&b, k, &other.to_string()).map_err(fe)?,+            }+            changed.push(format!("field {k}"));+        }+    }+    let mut attrs = args.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default();+    for (k, _) in fp_text::ATTR_FLAGS {+        if let Some(v) = args.get(*k) {+            attrs.insert(k.to_string(), v.clone());+        }+    }+    if !attrs.is_empty() {+        b = fp_text::set_attrs(&b, &attrs).map_err(|e| err("invalid_args", e))?;+        changed.push("attributes".into());+    }+    if let Some(l) = args.get("locked") {+        let l = l.as_bool().ok_or_else(|| err("invalid_args", "locked is true or false"))?;+        b = fp_text::set_locked(&b, l).map_err(fe)?;+        changed.push("locked".into());+    }+    if changed.is_empty() {+        return Err(err("missing_arg", "Nothing to change: pass value, newReference, fields, attributes (dnp, excludeFromBom, excludeFromPosFiles, ...) or locked"));+    }+    Ok((b, changed))+}++/// What a footprint looks like in a reply: reference, value, fields, attributes, lock.+pub fn footprint_info(fp: &Node) -> Value {+    let props: Map<String, Value> = fp_text::properties(fp).into_iter().map(|(k, v)| (k, json!(v))).collect();+    json!({"ref": fp_ref(fp), "footprint": fp.atom(1), "uuid": fp.uuid(), "side": fp_text::side(fp), "at": fp_text::pose(fp), "fields": props, "attributes": fp_text::attrs(fp), "locked": fp_text::is_locked(fp)})+}++/// `kicad_edit_footprint` (file form): value, reference, fields, attributes and lock.+pub fn edit_footprints(text: &str, args: &Value) -> RResult<Change> {+    let root = parse_root(text)?;+    let refs = refs_arg(args)?;+    let fps = find_refs(&root, &refs)?;+    if fps.len() > 1 && args.get("newReference").or_else(|| args.get("reference")).is_some() {+        return Err(err("invalid_args", "newReference renames one footprint; pass a single ref"));+    }+    let taken = existing_refs(&root);+    let mut rows = Vec::new();+    let mut report = Vec::new();+    for f in &fps {+        let (b, changed) = edit_one(f.src(text), args, &taken)?;+        let nf = sxedit::parse(&b).map_err(|e| err("edit_failed", e))?;+        report.push(json!({"ref": fp_ref(f), "changed": changed, "before": footprint_info(f), "after": footprint_info(&nf)}));+        rows.push((*f, b));+    }+    let mut c = replace_blocks(text, &root, &rows, format!("Adom edit {}", refs.join(", ")), false, json!({"edited": report}))?;+    c.copper = args.get("attributes").is_some() || fp_text::ATTR_FLAGS.iter().any(|(k, _)| args.get(*k).is_some());+    Ok(c)+}++// ---------------------------------------------------------------------------+// Alignment+// ---------------------------------------------------------------------------++/// `kicad_align_footprints`: the moves that line `refs` up on one edge or centre line+/// (`mode` left, right, top, bottom, centerX, centerY) or spread them evenly+/// (distributeX, distributeY), by courtyard box (default) or by anchor (`by:"anchor"`).+/// `to` fixes the target coordinate; otherwise the group's own extreme (or mean centre).+/// Returns `refs: [{ref, x, y}]` for kicad_move_footprint and the report.+pub fn align_moves(board: &Board, args: &Value) -> RResult<(Vec<Value>, Value)> {+    let refs = refs_arg(args)?;+    if refs.len() < 2 {+        return Err(err("invalid_refs", "Align at least two footprints"));+    }+    let mode = args.get("mode").and_then(Value::as_str).ok_or_else(|| err("missing_arg", "mode: left, right, top, bottom, centerX, centerY, distributeX or distributeY"))?;+    let by_anchor = args.get("by").and_then(Value::as_str) == Some("anchor");+    let places = placement::placements(board);+    let mut rows = Vec::new();+    for r in &refs {+        let p = places.iter().find(|p| p.reference.eq_ignore_ascii_case(r)).ok_or_else(|| err("unknown_footprint", format!("No footprint with reference '{r}'")))?;+        rows.push(p);+    }+    let bx = |p: &placement::Placement| if by_anchor { (p.x, p.y, p.x, p.y) } else { (p.courtyard.min_x, p.courtyard.min_y, p.courtyard.max_x, p.courtyard.max_y) };+    let to = num_arg(args, "to")?;+    let mut moves: Vec<(String, f64, f64)> = Vec::new();+    let pick = |f: &dyn Fn(&(f64, f64, f64, f64)) -> f64, agg: &dyn Fn(f64, f64) -> f64, init: f64| rows.iter().map(|p| f(&bx(p))).fold(init, agg);+    match mode {+        "left" | "right" | "top" | "bottom" | "centerX" | "centerY" => {+            let target = match mode {+                "left" => to.unwrap_or_else(|| pick(&|b| b.0, &f64::min, f64::MAX)),+                "right" => to.unwrap_or_else(|| pick(&|b| b.2, &f64::max, f64::MIN)),+                "top" => to.unwrap_or_else(|| pick(&|b| b.1, &f64::min, f64::MAX)),+                "bottom" => to.unwrap_or_else(|| pick(&|b| b.3, &f64::max, f64::MIN)),+                "centerX" => to.unwrap_or_else(|| rows.iter().map(|p| (bx(p).0 + bx(p).2) / 2.0).sum::<f64>() / rows.len() as f64),+                _ => to.unwrap_or_else(|| rows.iter().map(|p| (bx(p).1 + bx(p).3) / 2.0).sum::<f64>() / rows.len() as f64),+            };+            for p in &rows {+                let b = bx(p);+                let (dx, dy) = match mode {+                    "left" => (target - b.0, 0.0),+                    "right" => (target - b.2, 0.0),+                    "top" => (0.0, target - b.1),+                    "bottom" => (0.0, target - b.3),+                    "centerX" => (target - (b.0 + b.2) / 2.0, 0.0),+                    _ => (0.0, target - (b.1 + b.3) / 2.0),+                };+                moves.push((p.reference.clone(), pcb::round6(p.x + dx), pcb::round6(p.y + dy)));+            }+        }+        "distributeX" | "distributeY" => {+            if rows.len() < 3 {+                return Err(err("invalid_refs", "Distribute needs at least three footprints"));+            }+            let x_axis = mode == "distributeX";+            let lo = |b: (f64, f64, f64, f64)| if x_axis { b.0 } else { b.1 };+            let hi = |b: (f64, f64, f64, f64)| if x_axis { b.2 } else { b.3 };+            let mut order: Vec<&placement::Placement> = rows.clone();+            order.sort_by(|a, b| (lo(bx(a)) + hi(bx(a))).partial_cmp(&(lo(bx(b)) + hi(bx(b)))).unwrap_or(std::cmp::Ordering::Equal));+            let first = lo(bx(order[0]));+            let last = hi(bx(order[order.len() - 1]));+            let sizes: f64 = order.iter().map(|p| hi(bx(p)) - lo(bx(p))).sum();+            let gap = match num_arg(args, "gap")? {+                Some(g) => g,+                None => (last - first - sizes) / (order.len() as f64 - 1.0),+            };+            let mut cursor = first;+            for p in &order {+                let b = bx(p);+                let d = cursor - lo(b);+                let (dx, dy) = if x_axis { (d, 0.0) } else { (0.0, d) };+                moves.push((p.reference.clone(), pcb::round6(p.x + dx), pcb::round6(p.y + dy)));+                cursor += hi(b) - lo(b) + gap;+            }+        }+        other => return Err(err("invalid_args", format!("mode '{other}' is not left, right, top, bottom, centerX, centerY, distributeX or distributeY"))),+    }+    let out: Vec<Value> = moves.iter().map(|(r, x, y)| json!({"ref": r, "x": x, "y": y})).collect();+    let moving = moves.iter().filter(|(r, x, y)| rows.iter().any(|p| &p.reference == r && ((p.x - x).abs() > 1e-6 || (p.y - y).abs() > 1e-6))).count();+    Ok((out.clone(), json!({"mode": mode, "by": if by_anchor { "anchor" } else { "courtyard" }, "targets": out, "moving": moving})))+}++// ---------------------------------------------------------------------------+// Update from library+// ---------------------------------------------------------------------------++fn pad_sig(p: &Node) -> String {+    let at = p.kid("at");+    let f = |v: Option<f64>| format!("{:.4}", v.unwrap_or(0.0));+    let layers: Vec<&str> = p.kid("layers").map(|l| l.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect()).unwrap_or_default();+    format!(+        "{} {} at({},{},{}) size({},{}) drill({}) layers({})",+        p.atom(2).unwrap_or(""),+        p.atom(3).unwrap_or(""),+        f(at.and_then(|a| a.f64_at(1))),+        f(at.and_then(|a| a.f64_at(2))),+        f(at.and_then(|a| a.f64_at(3)).map(fp_text::norm_deg)),+        f(p.kid("size").and_then(|s| s.f64_at(1))),+        f(p.kid("size").and_then(|s| s.f64_at(2))),+        p.kid("drill").map(|d| d.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),+        layers.join(" ")+    )+}++fn graphic_sig(fp: &Node) -> Vec<String> {+    let mut v: Vec<String> = fp_text::graphics(fp).iter().filter(|g| g["kind"] != "text").map(|g| {+        let mut g = g.clone();+        g.as_object_mut().map(|o| o.remove("uuid"));+        g.to_string()+    }).collect();+    v.sort();+    v+}++/// The updated block for one placed footprint: the library's geometry at the board pose,+/// with the board's fields, symbol link, pad nets and pin data, lock state, uuid and the+/// DNP / BOM / position-file flags kept.+pub fn updated_block(text: &str, fp: &Node, lib_text: &str, copper: usize) -> RResult<String> {+    let (x, y, rot) = fp_text::pose(fp);+    let back = fp_text::side(fp) == "B.Cu";+    let lib_id = fp.atom(1).unwrap_or("");+    let fresh = fp_text::place_library(lib_text, lib_id, x, y, rot, back, &fp_ref(fp), None, copper).map_err(|e| err("footprint_unusable", e))?;+    let nf = sxedit::parse(&fresh).map_err(|e| err("edit_failed", e))?;+    let mut e = Edits::new();+    // fields: the board's own property blocks, verbatim+    let mut extra = String::new();+    for p in fp.kids_tagged("property") {+        let name = p.atom(1).unwrap_or("");+        match nf.kids_tagged("property").find(|np| np.atom(1) == Some(name)) {+            Some(np) => e.replace_node(np, p.src(text)),+            None => {+                extra.push_str("\n\t\t");+                extra.push_str(p.src(text));+            }+        }+    }+    for t in ["path", "sheetname", "sheetfile", "units"] {+        if let Some(k) = fp.kid(t) {+            extra.push_str("\n\t\t");+            extra.push_str(k.src(text));+        }+    }+    if let Some(last) = nf.kids_tagged("property").last() {+        e.insert(last.end, extra);+    }+    if let (Some(old), Some(new)) = (fp.uuid(), nf.kid("uuid")) {+        e.replace_node(new, format!("(uuid {})", q(old)));+    }+    // pad nets and pin data by pad number+    for np in nf.kids_tagged("pad") {+        let num = np.atom(1).unwrap_or("");+        if num.is_empty() {+            continue;+        }+        let Some(op) = fp.kids_tagged("pad").find(|op| op.atom(1) == Some(num)) else { continue };+        let mut add = String::new();+        for t in ["net", "pinfunction", "pintype"] {+            if let Some(k) = op.kid(t) {+                if np.kid(t).is_none() {+                    add.push_str(k.src(text));+                    add.push(' ');+                }+            }+        }+        if !add.is_empty() {+            e.insert(np.kid("uuid").map(|u| u.start).unwrap_or(np.end - 1), add);+        }+    }+    let mut b = e.apply(&fresh).map_err(|x| err("edit_failed", x))?;+    let old_attrs = fp_text::attrs(fp);+    let mut keep = Map::new();+    for k in ["dnp", "excludeFromBom", "excludeFromPosFiles"] {+        if old_attrs[k] == json!(true) {+            keep.insert(k.into(), json!(true));+        }+    }+    if !keep.is_empty() {+        b = fp_text::set_attrs(&b, &keep).map_err(|x| err("edit_failed", x))?;+    }+    if fp_text::is_locked(fp) {+        b = fp_text::set_locked(&b, true).map_err(|x| err("edit_failed", x))?;+    }+    Ok(b)+}++/// The differences between a placed footprint and its library version, in words.+pub fn footprint_diff(old: &Node, new: &Node) -> Value {+    let pads = |f: &Node| -> Vec<(String, String)> { f.kids_tagged("pad").map(|p| (p.atom(1).unwrap_or("").to_string(), pad_sig(p))).collect() };+    let (po, pn) = (pads(old), pads(new));+    let nums = |v: &Vec<(String, String)>| { let mut s: Vec<String> = v.iter().map(|p| p.0.clone()).collect(); s.sort(); s.dedup(); s };+    let (no, nn) = (nums(&po), nums(&pn));+    let added: Vec<&String> = nn.iter().filter(|k| !no.contains(k)).collect();+    let removed: Vec<&String> = no.iter().filter(|k| !nn.contains(k)).collect();+    let mut changed_pads = Vec::new();+    for k in nn.iter().filter(|k| no.contains(k)) {+        let a: Vec<&String> = po.iter().filter(|p| &p.0 == k).map(|p| &p.1).collect();+        let b: Vec<&String> = pn.iter().filter(|p| &p.0 == k).map(|p| &p.1).collect();+        if a != b {+            changed_pads.push(json!({"pad": k, "board": a, "library": b}));+        }+    }+    let gfx_changed = graphic_sig(old) != graphic_sig(new);+    let mo: Vec<Value> = fp_text::models(old).iter().map(|m| m["path"].clone()).collect();+    let mn: Vec<Value> = fp_text::models(new).iter().map(|m| m["path"].clone()).collect();+    let mut changes: Vec<String> = Vec::new();+    if !added.is_empty() {+        changes.push(format!("pads added: {}", added.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));+    }+    if !removed.is_empty() {+        changes.push(format!("pads removed: {}", removed.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));+    }+    if !changed_pads.is_empty() {+        changes.push(format!("{} pad(s) changed shape, size, position, drill or layers", changed_pads.len()));+    }+    if gfx_changed {+        changes.push("graphics (silk, fab, courtyard) differ".into());+    }+    if mo != mn {+        changes.push("3D models differ".into());+    }+    json!({"identical": changes.is_empty(), "changes": changes, "padsAdded": added, "padsRemoved": removed, "padsChanged": changed_pads, "graphicsChanged": gfx_changed, "modelsBoard": mo, "modelsLibrary": mn})+}++/// `kicad_update_footprints_from_library`: per footprint, what the library version changes;+/// with `apply:true` the change replaces every footprint that differs. `resolve` maps a+/// library id to the `.kicad_mod` text (Err: why it could not be found).+pub fn update_from_library(text: &str, args: &Value, resolve: &mut dyn FnMut(&str) -> Result<String, String>) -> RResult<Change> {+    let root = parse_root(text)?;+    let fps: Vec<&Node> = match (args.get("refs"), args.get("ref")) {+        (None, None) => footprints(&root),+        _ => find_refs(&root, &refs_arg(args)?)?,+    };+    let copper = fp_text::copper_count(&root);+    let mut plan = Vec::new();+    let mut rows: Vec<(&Node, String)> = Vec::new();+    let mut unresolved = 0;+    for f in &fps {+        let lib_id = f.atom(1).unwrap_or("").to_string();+        let lib = match resolve(&lib_id) {+            Ok(t) => t,+            Err(why) => {+                unresolved += 1;+                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "status": "library_not_found", "why": why}));+                continue;+            }+        };+        let block = match updated_block(text, f, &lib, copper) {+            Ok(b) => b,+            Err(e) => {+                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "status": "unusable", "why": e.message}));+                continue;+            }+        };+        let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;+        let diff = footprint_diff(f, &nf);+        let identical = diff["identical"] == json!(true);+        plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "status": if identical { "up_to_date" } else { "differs" }, "diff": diff}));+        if !identical {+            rows.push((*f, block));+        }+    }+    let differs = rows.len();+    let report = json!({"footprints": plan, "checked": fps.len(), "differ": differs, "unresolved": unresolved});+    if rows.is_empty() {+        return Ok(Change { candidate: text.to_string(), report, label: "Adom update footprints".into(), ..Default::default() });+    }+    let names: Vec<String> = rows.iter().map(|(f, _)| fp_ref(f)).collect();+    replace_blocks(text, &root, &rows, format!("Adom update {} from library", if names.len() <= 4 { names.join(", ") } else { format!("{} footprints", names.len()) }), true, report)+}++// ---------------------------------------------------------------------------+// Footprint graphics and 3D models+// ---------------------------------------------------------------------------++/// `kicad_footprint_graphics`: list one footprint's graphics, or edit one by uuid.+pub fn footprint_graphics(text: &str, args: &Value) -> RResult<(Value, Option<Change>)> {+    let root = parse_root(text)?;+    let f = find_refs(&root, &refs_arg(args)?)?[0];+    let list = json!({"ref": fp_ref(f), "graphics": fp_text::graphics(f), "frame": "footprint-local mm, unrotated (add the footprint's at and rotation for board coordinates)"});+    let Some(uuid) = args.get("uuid").and_then(Value::as_str) else {+        return Ok((list, None));+    };+    let change = args.get("change").cloned().unwrap_or_else(|| args.clone());+    let block = fp_text::edit_graphic(f.src(text), uuid, &change).map_err(|e| err("invalid_args", e))?;+    let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;+    let report = json!({"ref": fp_ref(f), "uuid": uuid, "before": fp_text::graphics(f).into_iter().find(|g| g["uuid"] == uuid), "after": fp_text::graphics(&nf).into_iter().find(|g| g["uuid"] == uuid)});+    let copper = fp_text::graphics(f).iter().any(|g| g["uuid"] == uuid && g["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false));+    Ok((list, Some(replace_blocks(text, &root, &[(f, block)], format!("Adom edit {} graphic", fp_ref(f)), copper, report)?)))+}++/// `kicad_footprint_models`: list one footprint's 3D models, or apply `ops`.+pub fn footprint_models(text: &str, args: &Value) -> RResult<(Value, Option<Change>)> {+    let root = parse_root(text)?;+    let f = find_refs(&root, &refs_arg(args)?)?[0];+    let list = json!({"ref": fp_ref(f), "models": fp_text::models(f)});+    let ops: Vec<Value> = match (args.get("ops"), args.get("action")) {+        (Some(Value::Array(a)), _) => a.clone(),+        (_, Some(Value::String(a))) if a != "list" => vec![args.clone()],+        _ => return Ok((list, None)),+    };+    let block = fp_text::edit_models(f.src(text), &ops).map_err(|e| err("invalid_args", e))?;+    let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;+    let report = json!({"ref": fp_ref(f), "before": fp_text::models(f), "after": fp_text::models(&nf)});+    Ok((list, Some(replace_blocks(text, &root, &[(f, block)], format!("Adom 3D models {}", fp_ref(f)), false, report)?)))+}++// ---------------------------------------------------------------------------+// Cross-probe, title block, extents+// ---------------------------------------------------------------------------++/// Every symbol instance in a schematic file: (symbol uuid, reference, lib id, value).+pub fn schematic_symbols(sch_text: &str) -> Vec<(String, String, String, String, Vec<String>)> {+    let Ok(root) = sxedit::parse(sch_text) else { return Vec::new() };+    root.kids_tagged("symbol")+        .filter_map(|s| {+            let uuid = s.uuid()?.to_string();+            let prop = |name: &str| s.kids_tagged("property").find(|p| p.atom(1) == Some(name)).and_then(|p| p.atom(2)).unwrap_or("").to_string();+            let mut instance_refs = Vec::new();+            if let Some(inst) = s.kid("instances") {+                inst.walk(&mut |nd| {+                    if nd.tag() == Some("path") {+                        if let (Some(p), Some(r)) = (nd.atom(1), nd.value_of("reference")) {+                            instance_refs.push(format!("{p}|{r}"));+                        }+                    }+                });+            }+            Some((uuid, prop("Reference"), s.value_of("lib_id").unwrap_or("").to_string(), prop("Value"), instance_refs))+        })+        .collect()+}++/// `kicad_cross_probe`: from a footprint reference, a symbol uuid or a symbol path to the+/// matching board footprint(s) and schematic symbol, by the footprint's `(path ...)` link.+/// `schematics` are (file name, text) of the project's sheets.+pub fn cross_probe(board_text: &str, schematics: &[(String, String)], args: &Value) -> RResult<Value> {+    let root = parse_root(board_text)?;+    let fps = footprints(&root);+    let want_ref = args.get("reference").or_else(|| args.get("ref")).and_then(Value::as_str);+    let want_uuid = args.get("symbolUuid").and_then(Value::as_str);+    let want_path = args.get("path").and_then(Value::as_str);+    if want_ref.is_none() && want_uuid.is_none() && want_path.is_none() {+        return Err(err("missing_arg", "Pass reference (\"U1\"), symbolUuid or path (\"/<sheet uuid>/<symbol uuid>\")"));+    }+    let last = |p: &str| p.rsplit('/').next().unwrap_or("").to_string();+    let symbols: Vec<(String, (String, String, String, String, Vec<String>))> = schematics.iter().flat_map(|(f, t)| schematic_symbols(t).into_iter().map(move |s| (f.clone(), s))).collect();+    let matches: Vec<&Node> = fps+        .iter()+        .copied()+        .filter(|f| {+            let path = f.value_of("path").unwrap_or("");+            want_ref.map(|r| fp_ref(f).eq_ignore_ascii_case(r)).unwrap_or(false)+                || want_uuid.map(|u| !path.is_empty() && last(path) == u).unwrap_or(false)+                || want_path.map(|p| path == p).unwrap_or(false)+        })+        .collect();+    let rows: Vec<Value> = matches+        .iter()+        .map(|f| {+            let path = f.value_of("path").unwrap_or("").to_string();+            let sym_uuid = last(&path);+            let sym = symbols.iter().find(|(_, s)| !sym_uuid.is_empty() && s.0 == sym_uuid);+            json!({+                "ref": fp_ref(f), "footprintUuid": f.uuid(), "footprint": f.atom(1), "side": fp_text::side(f), "at": fp_text::pose(f),+                "symbolPath": if path.is_empty() { Value::Null } else { json!(path) }, "symbolUuid": if sym_uuid.is_empty() { Value::Null } else { json!(sym_uuid) },+                "sheetName": f.value_of("sheetname"), "sheetFile": f.value_of("sheetfile"),+                "symbol": sym.map(|(file, s)| json!({"file": file, "uuid": s.0, "reference": s.1, "libId": s.2, "value": s.3})),+                "linked": !path.is_empty(),+            })+        })+        .collect();+    // a symbol named by uuid or reference that has no footprint+    let orphan = if rows.is_empty() {+        symbols.iter().find(|(_, s)| want_uuid.map(|u| s.0 == u).unwrap_or(false) || want_ref.map(|r| s.1.eq_ignore_ascii_case(r) || s.4.iter().any(|i| i.ends_with(&format!("|{r}")))).unwrap_or(false))+            .map(|(file, s)| json!({"file": file, "uuid": s.0, "reference": s.1, "libId": s.2, "value": s.3}))+    } else {+        None+    };+    Ok(json!({"matches": rows, "count": rows.len(), "symbolWithoutFootprint": orphan, "schematicsRead": schematics.iter().map(|(f, _)| f.clone()).collect::<Vec<_>>()}))+}++/// The board's title block (title, date, rev, company, comments).+pub fn title_block(text: &str) -> Value {+    let Ok(root) = sxedit::parse(text) else { return Value::Null };+    let Some(tb) = root.kid("title_block") else { return json!({}) };+    let mut out = json!({});+    for f in ["title", "date", "rev", "company"] {+        if let Some(v) = tb.value_of(f) {+            out[f] = json!(v);+        }+    }+    let comments: Vec<Value> = tb.kids_tagged("comment").filter_map(|c| Some(json!({"n": c.f64_at(1)?, "text": c.atom(2)?}))).collect();+    if !comments.is_empty() {+        out["comments"] = json!(comments);+    }+    out+}++/// The box around everything on the board (outline, courtyards, copper), in KiCad's frame.+pub fn extents(board: &Board) -> Value {+    let mut pts: Vec<(f64, f64)> = Vec::new();+    if let Some(d) = &board.dimensions {+        pts.push((d.min_x, d.min_y));+        pts.push((d.max_x, d.max_y));+    }+    for p in placement::placements(board) {+        pts.push((p.courtyard.min_x, p.courtyard.min_y));+        pts.push((p.courtyard.max_x, p.courtyard.max_y));+    }+    for s in &board.segments {+        for p in [&s.start, &s.end].into_iter().flatten() {+            pts.push((p.x, p.y));+        }+    }+    for v in &board.vias {+        if let Some(p) = &v.position {+            pts.push((p.x, p.y));+        }+    }+    match placement::Rect::from_points(pts) {+        Some(r) => json!({"x0": pcb::round6(r.min_x), "y0": pcb::round6(r.min_y), "x1": pcb::round6(r.max_x), "y1": pcb::round6(r.max_y), "widthMm": pcb::round6(r.width()), "heightMm": pcb::round6(r.height()), "frame": "kicad (y down)"}),+        None => Value::Null,+    }+}++// ---------------------------------------------------------------------------+// Closed-file writes and the live create text+// ---------------------------------------------------------------------------++/// Write `candidate` over the closed board at `path`, which must still hold `original`:+/// refused while KiCad has it open (its lock file), backed up to `<board>.adom-bak`,+/// replaced through a temp file, and read back.+pub fn write_closed(path: &Path, original: &str, candidate: &str) -> RResult<String> {+    if pcb::lock_file(path).is_some() {+        return Err(err("board_open", "KiCad has this board open (its lock file is present); a file edit would be overwritten by the editor").with("_hint", json!("Close the board in KiCad, or turn on the IPC API (kicad_ipc_api {\"enable\":true}, relaunch) so the bridge edits it live.")));+    }+    sxedit::parse(candidate).map_err(|e| err("edit_failed", format!("the edited board does not parse: {e}")))?;+    pcb::parse_pcb_text(candidate, &path.to_string_lossy()).map_err(|e| err("edit_failed", format!("the edited board does not parse: {e}")))?;+    let current = std::fs::read_to_string(path).map_err(|e| err("board_unreadable", e.to_string()))?;+    if current != original {+        return Err(err("stale_board", "The board file changed while this edit was prepared; read it again"));+    }+    let bak = PathBuf::from(format!("{}.adom-bak", path.to_string_lossy()));+    std::fs::copy(path, &bak).map_err(|e| err("board_write_refused", format!("cannot back up the board: {e}")))?;+    let dir = path.parent().unwrap_or(Path::new("."));+    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    let tmp = dir.join(format!("{name}.{}.{}.adom-tmp", std::process::id(), new_uuid().split('-').next().unwrap_or("x")));+    let result = (|| -> Result<(), String> {+        use std::io::Write;+        let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;+        f.write_all(candidate.as_bytes()).map_err(|e| e.to_string())?;+        f.sync_all().map_err(|e| e.to_string())?;+        drop(f);+        std::fs::rename(&tmp, path).map_err(|e| e.to_string())+    })();+    let _ = std::fs::remove_file(&tmp);+    result.map_err(|e| err("board_write_refused", e))?;+    let back = std::fs::read_to_string(path).map_err(|e| err("board_unreadable", e.to_string()))?;+    if back != candidate {+        return Err(RoutingError::new("write_unverified", "The board file does not read back as written").with("mutated", Value::Null));+    }+    Ok(bak.to_string_lossy().replace('\\', "/"))+}++/// Read-back of the footprint expectations on an edited board.+pub fn verify(board: &Board, expect: &[Expect]) -> (bool, Vec<Value>) {+    let mut ok = true;+    let rows = expect+        .iter()+        .map(|x| {+            let f = board.footprints.iter().find(|f| f.reference == x.reference);+            let good = match (x.present, f) {+                (false, None) => true,+                (false, Some(_)) | (true, None) => false,+                (true, Some(f)) => {+                    let pose_ok = x.pose.map(|(px, py, pr)| f.position.as_ref().map(|p| (p.x - px).abs() < 1e-4 && (p.y - py).abs() < 1e-4 && (fp_text::norm_deg(p.rotation.unwrap_or(0.0)) - fp_text::norm_deg(pr)).abs() < 1e-3).unwrap_or(false)).unwrap_or(true);+                    let side_ok = x.side.as_ref().map(|s| f.layer.as_deref() == Some(s.as_str())).unwrap_or(true);+                    pose_ok && side_ok+                }+            };+            ok &= good;+            json!({"ref": x.reference, "present": f.is_some(), "expected": x.present, "at": f.and_then(|f| f.position.clone()), "side": f.and_then(|f| f.layer.clone()), "ok": good})+        })+        .collect();+    (ok, rows)+}++/// The text a live `ParseAndCreateItemsFromString` gets: the blocks inside a board header+/// with this board's version, layer table and (pre-KiCad 10) net table.+pub fn create_text(board_text: &str, blocks: &[String]) -> String {+    let root = sxedit::parse(board_text).ok();+    let mut s = String::from("(kicad_pcb");+    if let Some(r) = &root {+        for t in ["version", "generator", "generator_version", "layers"] {+            if let Some(k) = r.kid(t) {+                s.push_str("\n\t");+                s.push_str(k.src(board_text));+            }+        }+        for k in r.kids_tagged("net") {+            s.push_str("\n\t");+            s.push_str(k.src(board_text));+        }+    }+    for b in blocks {+        s.push_str("\n\t");+        s.push_str(b.trim());+    }+    s.push_str("\n)\n");+    s+}++/// Top-level item UUIDs of a board text (footprints, graphics, copper, zones).+pub fn top_level_ids(text: &str) -> Vec<String> {+    sxedit::parse(text).map(|r| r.kids.iter().filter_map(|k| k.uuid().map(str::to_string)).collect()).unwrap_or_default()+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn plan_refs_takes_the_next_free_numbers_and_refuses_a_taken_one() {+        let t = "(kicad_pcb (footprint \"a\" (layer \"F.Cu\") (property \"Reference\" \"R1\")) (footprint \"a\" (layer \"F.Cu\") (property \"Reference\" \"R3\")))";+        let r = sxedit::parse(t).unwrap();+        assert_eq!(plan_refs(&r, "R", 3).unwrap(), vec!["R2", "R4", "R5"]);+        assert_eq!(plan_refs(&r, "R7", 2).unwrap(), vec!["R7", "R8"]);+        assert_eq!(plan_refs(&r, "R3", 1).unwrap_err().code, "duplicate_reference");+        assert_eq!(mounting_hole_lib_id(3.2, false).as_deref(), Some("MountingHole:MountingHole_3.2mm_M3"));+        assert_eq!(mounting_hole_lib_id(3.2, true).as_deref(), Some("MountingHole:MountingHole_3.2mm_M3_Pad"));+        assert_eq!(mounting_hole_lib_id(3.0, false), None);+    }+}