← All Pull Requests

kicad-lane: 1.0.64, live adds through KiCad's protobuf API, #126/#129, 17 standard core verbs #25

Merged opened by Colby Knox 2026-10-27

From kicad-lane. KiCad bridge 1.0.64.

Adding items to an open board. KiCad 10.0.x creates nothing from text over its API, so the bridge now encodes the new items as KiCad's protobuf items itself (commit:"create-items", one Undo step):

  • board_outline and import_svg are created live;
  • add_footprint (with duplicateOf and count) and add_mounting_hole create a part live when the same library footprint is already on that side of the board with the same pads and graphics: the bridge copies KiCad's own encoding of it, with fresh ids, the new pose, reference, value and pad nets;
  • anything else, a footprint the board lacks and update_footprints_from_library included, still refuses with live_create_unsupported, now with liveCreateWhy.

Footprints KiCad rebuilds over its API (#126, #129). KiCad stores every footprint it rebuilds without a polygon's repeated closing point, which DRC then reports as lib_footprint_mismatch:

  • moves, live field edits and live part edits now name those parts in closingVerticesDroppedByKiCad, with how to restore them;
  • a live field move (silk_fields_batch) no longer fails otherBoardDataVerified because of it.

New standard verbs:

  • footprint libraries: copy_footprint, rename_footprint, remove_footprint, xcopy_footprint, add_body;
  • symbol libraries: clone_symbol, rename_symbol, set_params, set_visibility, symbol_prims;
  • sheets: place_on_sch, doc_params, sheet_params (the BOM write-back onto placed parts), part_parameters;
  • the board: layers, silk_state, ripup_nets.

Tests:

  • cargo test --workspace: 522 pass; the 7 known schematic_edit tests fail because their fixture was never pushed.
  • Verb battery on DESKTOP-BAR6S0F (KiCad 10.0.7), signed dev build: 260 pass, 0 fail, 1 skip; coverage complete; 0 foreground grabs. The live SVG and the live 0402 add both landed through CreateItems on the real ESC board.
  • Live checks on scratch boards: #126 reproduced (any move drops the closing vertex; a control save keeps it); #129's field move now passes otherBoardDataVerified.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/bridge.json+++ b/bridge.json@@ -1,358 +1,376 @@⋯ 252 unchanged lines ⋯     "kicad_remove_graphics",     "kicad_read_footprint",     "kicad_export_library_part",-    "kicad_create_part_project"+    "kicad_create_part_project",+    "kicad_add_body",+    "kicad_clone_symbol",+    "kicad_copy_footprint",+    "kicad_doc_params",+    "kicad_layers",+    "kicad_part_parameters",+    "kicad_place_on_sch",+    "kicad_remove_footprint",+    "kicad_rename_footprint",+    "kicad_rename_symbol",+    "kicad_ripup_nets",+    "kicad_set_params",+    "kicad_set_visibility",+    "kicad_sheet_params",+    "kicad_silk_state",+    "kicad_symbol_prims",+    "kicad_xcopy_footprint"   ],   "statusVerb": "kicad_status",   "timeouts": {⋯ 96 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1297 +1,1298 @@⋯ 309 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.63"+version = "1.0.64" dependencies = [  "kicad-core",  "kicad-platform",⋯ 4 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.63"+version = "1.0.64" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.63"+version = "1.0.64" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.63"+version = "1.0.64" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/verbs_board.rs+++ b/rust/crates/kicad-bridge/src/verbs_board.rs@@ -1,855 +1,856 @@⋯ 21 unchanged lines ⋯ 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.",+    "Live, new items go to KiCad as protobuf items (CreateItems) with fresh UUIDs: board graphics always, a footprint by copying KiCad's own encoding of the same library footprint already on the board. Anything else refuses with live_create_unsupported (KiCad 10.0.x does not create items from text) and changes nothing.",     "Never replay a timed-out edit (mutation_outcome_unknown): read the board first.", ]; ⋯ 6 unchanged lines ⋯         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: &["Adding items to a board OPEN in the PCB editor needs KiCad to create them over its API, which KiCad 10.0.x does not do yet: on an open board the verb refuses with live_create_unsupported and changes nothing. Close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "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]],+        pitfalls: &["On a board OPEN in the PCB editor the shapes are created live through KiCad's protobuf API (commit:\"create-items\", one Undo step); a closed board is edited on disk.", "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",⋯ 23 unchanged lines ⋯         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,+        pitfalls: &["On a board OPEN in the PCB editor a hole is created live (commit:\"create-items\", one Undo step) when the same mounting-hole footprint is already on that side of the board; otherwise KiCad 10.0.x cannot create it over its API: the verb refuses with live_create_unsupported, says why in liveCreateWhy and changes nothing. Save and close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", LIVE_PITFALLS[0], LIVE_PITFALLS[1], LIVE_PITFALLS[2], LIVE_PITFALLS[3]],     },     Verb {         name: "kicad_import_svg",⋯ 3 unchanged lines ⋯         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: &["Adding items to a board OPEN in the PCB editor needs KiCad to create them over its API, which KiCad 10.0.x does not do yet: on an open board the verb refuses with live_create_unsupported and changes nothing. Close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "Adding items to a board OPEN in the PCB editor needs KiCad to create them over its API, which KiCad 10.0.x does not do yet: on an open board the verb refuses with live_create_unsupported and changes nothing. Close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "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]],+        pitfalls: &["On a board OPEN in the PCB editor the shapes are created live through KiCad's protobuf API (commit:\"create-items\", one Undo step); a closed board is edited on disk.", "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",⋯ 23 unchanged lines ⋯         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: &["Adding items to a board OPEN in the PCB editor needs KiCad to create them over its API, which KiCad 10.0.x does not do yet: on an open board the verb refuses with live_create_unsupported and changes nothing. Close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "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]],+        pitfalls: &["On a board OPEN in the PCB editor a footprint is created live (commit:\"create-items\", one Undo step) when the same library footprint is already on that side of the board with the same pads and graphics: KiCad's own encoding of it is copied. Otherwise KiCad 10.0.x cannot create it over its API: the verb refuses with live_create_unsupported, says why in liveCreateWhy and changes nothing; save and close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "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",⋯ 43 unchanged lines ⋯         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: &["Adding items to a board OPEN in the PCB editor needs KiCad to create them over its API, which KiCad 10.0.x does not do yet: on an open board the verb refuses with live_create_unsupported and changes nothing. Close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "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]],+        pitfalls: &["On a board OPEN in the PCB editor KiCad 10.0.x cannot create the library's new copy over its API: the verb refuses with live_create_unsupported and changes nothing. Save and close the board (kicad_save_board, kicad_close) and the verb edits the file instead.", "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",⋯ 691 unchanged lines ⋯         let _ = std::fs::remove_dir_all(&d);     } }+--- a/rust/crates/kicad-bridge/src/verbs_libedit.rs+++ b/rust/crates/kicad-bridge/src/verbs_libedit.rs@@ -1,460 +1,912 @@⋯ 78 unchanged lines ⋯         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"],     },+    // the EDA Bridge Standard's library housekeeping (core tier), on .pretty folders and .kicad_sym files+    Verb {+        name: "kicad_copy_footprint",+        summary: "Duplicate a footprint inside a .pretty library under a new name (every pad, graphic, text and 3D model copied).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"from\": \"SOT-23\", \"to\": \"SOT-23_Wide\", \"overwrite\"?: false, \"dryRun\"?: bool}",+        example: "kicad_copy_footprint {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"from\":\"SOT-23\",\"to\":\"SOT-23_Wide\"}",+        hint: "The copy is <to>.kicad_mod beside the original with its name changed inside; edit it with kicad_fp_edit.",+        related: &["kicad_rename_footprint", "kicad_xcopy_footprint", "kicad_fp_edit"],+        pitfalls: &["an existing footprint of that name is refused unless overwrite:true"],+    },+    Verb {+        name: "kicad_rename_footprint",+        summary: "Rename a footprint in a .pretty library: the file and the name inside it.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"from\": \"SOT-23\", \"to\": \"SOT-23-3\", \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_rename_footprint {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"from\":\"SOT-23\",\"to\":\"SOT-23-3\"}",+        hint: "Boards and symbols that name the old Lib:Name keep pointing at it: update their Footprint field (kicad_set_params, kicad_sch_set_property) or swap them on the board.",+        related: &["kicad_copy_footprint", "kicad_remove_footprint"],+        pitfalls: &["an existing footprint named to is refused", "backup:true (default) leaves the old file as <from>.kicad_mod.adom-bak"],+    },+    Verb {+        name: "kicad_remove_footprint",+        summary: "Delete one footprint from a .pretty library (a library file, not a part on the board: that is kicad_remove_parts).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"footprintName\": \"SOT-23\", \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_remove_footprint {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"footprintName\":\"SOT-23_Old\"}",+        hint: "Boards that placed it keep their own copy. backup:true (default) keeps <name>.kicad_mod.adom-bak beside the library.",+        related: &["kicad_copy_footprint", "kicad_rename_footprint"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_xcopy_footprint",+        summary: "Copy a footprint ACROSS libraries: from fromLib into toLib (created when missing), optionally under a new name.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"fromLib\": \"C:/.../Vendor.pretty\", \"from\": \"QFN-16\", \"toLib\": \"C:/.../Mine.pretty\", \"to\"?: \"QFN-16_Mine\", \"overwrite\"?: false, \"dryRun\"?: bool}",+        example: "kicad_xcopy_footprint {\"fromLib\":\"C:/Users/john/Downloads/Vendor.pretty\",\"from\":\"QFN-16\",\"toLib\":\"C:/Users/john/kicad/Mine.pretty\"}",+        hint: "3D model paths are copied as written: a model path relative to the source library may need ${KIPRJMOD} or an absolute path (kicad_fp_edit models).",+        related: &["kicad_copy_footprint", "kicad_install_library", "kicad_fp_info"],+        pitfalls: &["an existing footprint in toLib is refused unless overwrite:true"],+    },+    Verb {+        name: "kicad_clone_symbol",+        summary: "Copy a symbol inside a .kicad_sym under a new name, every unit and body style included.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"from\": \"LM1117\", \"to\": \"LM1117-3.3\", \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_clone_symbol {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"from\":\"LM1117\",\"to\":\"LM1117-3.3\"}",+        hint: "The clone sits right after the original; change its fields with kicad_set_params.",+        related: &["kicad_rename_symbol", "kicad_set_params", "kicad_sym_info"],+        pitfalls: &["an existing name is refused", "a symbol that extends another keeps extending it"],+    },+    Verb {+        name: "kicad_rename_symbol",+        summary: "Rename a symbol in a .kicad_sym (its units too); symbols that extend it follow the new name.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"from\": \"LM1117\", \"to\": \"LM1117_ADJ\", \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_rename_symbol {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"from\":\"LM1117\",\"to\":\"LM1117_ADJ\"}",+        hint: "Placed copies in schematics keep the old lib id and their embedded copy; kicad_sch_update_from_library with the new id brings them over.",+        related: &["kicad_clone_symbol", "kicad_sym_info"],+        pitfalls: &["an existing name is refused"],+    },+    Verb {+        name: "kicad_set_params",+        summary: "Set parameters on a library symbol (fields; a missing one is added hidden), optionally its value (the standard's comment) and its footprint link.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\"?: \"LM1117\" (default the first symbol), \"params\"?: {\"MPN\": \"...\"} | \"MPN=...;LCSC=C123\", \"comment\"?: \"value text\", \"footprint\"?: \"Lib:Footprint\", \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_set_params {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"symbolName\":\"LM1117\",\"params\":{\"MPN\":\"LM1117IMPX-3.3\",\"LCSC\":\"C23345\"},\"footprint\":\"Package_TO_SOT_SMD:SOT-223-3_TabPin2\"}",+        hint: "fields says per name whether it was set, added or unchanged. comment writes KiCad's Value field; footprint writes the Footprint field as Lib:Name.",+        related: &["kicad_set_visibility", "kicad_sym_info", "kicad_sheet_params"],+        pitfalls: &["placed symbols keep their own fields: kicad_sheet_params writes onto parts on the sheets"],+    },+    Verb {+        name: "kicad_set_visibility",+        summary: "Show or hide one field of a library symbol.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\", \"param\": \"MPN\", \"visible\"?: true, \"backup\"?: true, \"dryRun\"?: bool}",+        example: "kicad_set_visibility {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"symbolName\":\"LM1117\",\"param\":\"MPN\",\"visible\":false}",+        hint: "changed:false means the field already showed (or hid) as asked; nothing was written.",+        related: &["kicad_set_params", "kicad_sym_info"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_symbol_prims",+        summary: "A library symbol's drawing: primitives counted by kind, the body rectangle, the polylines' bounding box, units and body styles, the footprint link and filters. Read only.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\"}",+        example: "kicad_symbol_prims {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"symbolName\":\"LM1117\"}",+        hint: "Mm, KiCad's symbol frame (y up). A derived symbol (extends) is read flattened, the way KiCad draws it.",+        related: &["kicad_sym_info", "kicad_set_params"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_add_body",+        summary: "Put a 3D body (a STEP or VRML model) on a library footprint. It REPLACES the footprint's models unless keepExisting:true, so a re-import never stacks a second body.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.pretty\", \"footprintName\"?: \"SOT-23\" (default the library's only footprint), \"step\": \"C:/.../part.step\" | \"${KICAD10_3DMODEL_DIR}/...\", \"offset\"?: [x, y, z] mm, \"rotate\"?: [x, y, z] deg, \"scale\"?: [x, y, z], \"keepExisting\"?: false, \"dryRun\"?: bool}",+        example: "kicad_add_body {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"footprintName\":\"VL53L5CX\",\"step\":\"C:/lib/VL53L5CX.step\"}",+        hint: "bodiesBefore and bodiesNow count the footprint's models. Boards that already use the footprint keep their copy: kicad_update_footprints_from_library brings the body onto them. kicad_bodies measures the result on a board.",+        related: &["kicad_fp_edit", "kicad_fp_info", "kicad_bodies", "kicad_model_check"],+        pitfalls: &["A box without a model file is not made here: give step (a STEP from the maker or the wiki part page).", "The path is stored as given: prefer a path variable or a path every machine shares."],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 5 unchanged lines ⋯         "kicad_sym_delete" => sym_delete(args),         "kicad_sym_info" => sym_info(state, args),         "kicad_search_library" => search(state, args),+        "kicad_copy_footprint" => copy_footprint(args, false),+        "kicad_xcopy_footprint" => copy_footprint(args, true),+        "kicad_rename_footprint" => rename_footprint(args),+        "kicad_remove_footprint" => remove_footprint(args),+        "kicad_clone_symbol" | "kicad_rename_symbol" => clone_or_rename_symbol(args, command == "kicad_rename_symbol"),+        "kicad_set_params" => set_params(args),+        "kicad_set_visibility" => set_visibility(args),+        "kicad_symbol_prims" => symbol_prims(args),+        "kicad_add_body" => add_body(state, args),         _ => return None,     }) }⋯ 362 unchanged lines ⋯         let _ = std::fs::remove_dir_all(&d);     } }++// ---------------------------------------------------------------------------+// Library housekeeping (the standard's core tier)+// ---------------------------------------------------------------------------++fn pretty_dir(args: &Value, key: &str) -> Result<PathBuf, Value> {+    let Some(l) = arg_str(args, key) else {+        return Err(fail("missing_arg", format!("{key} is required"), "Pass the .pretty folder's absolute path."));+    };+    let p = PathBuf::from(l);+    if !p.to_string_lossy().trim_end_matches(['/', '\\']).ends_with(".pretty") {+        return Err(fail("bad_arg", format!("{key} must be a .pretty folder"), "KiCad footprint libraries are .pretty folders of .kicad_mod files."));+    }+    Ok(p)+}++fn fp_file(lib: &Path, name: &str) -> PathBuf {+    lib.join(format!("{name}.kicad_mod"))+}++/// kicad_copy_footprint (inside one library) and kicad_xcopy_footprint (fromLib into toLib).+fn copy_footprint(args: &Value, across: bool) -> Value {+    let (src_lib, dst_lib) = if across {+        match (pretty_dir(args, "fromLib"), pretty_dir(args, "toLib")) {+            (Ok(a), Ok(b)) => (a, b),+            (Err(e), _) | (_, Err(e)) => return e,+        }+    } else {+        match pretty_dir(args, "libraryPath") {+            Ok(a) => (a.clone(), a),+            Err(e) => return e,+        }+    };+    let Some(from) = arg_str(args, "from") else { return fail("missing_arg", "from is required", "The footprint to copy.") };+    let to = arg_str(args, "to").unwrap_or(from);+    if !across && to == from {+        return fail("bad_arg", "to must differ from from", "Pass the new name.");+    }+    let src = fp_file(&src_lib, from);+    let text = match std::fs::read_to_string(&src) {+        Ok(t) => t,+        Err(_) => return fail("footprint_not_found", format!("no {from}.kicad_mod in {}", norm(&src_lib)), "kicad_list_footprints lists a library's footprints."),+    };+    let dst = fp_file(&dst_lib, to);+    if dst.exists() && !args.get("overwrite").and_then(Value::as_bool).unwrap_or(false) {+        return fail("footprint_exists", format!("{} already exists", norm(&dst)), "Pass overwrite:true to replace it, or another name.");+    }+    let out = match kicad_core::libops::rename_footprint_text(&text, to) {+        Ok(o) => o,+        Err(e) => return fail("parse_failed", e, "The source file is not a KiCad footprint."),+    };+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would write {}", norm(&dst)), "path": norm(&dst)});+    }+    let _ = std::fs::create_dir_all(&dst_lib);+    let bak = backup(&dst, args);+    if let Err(e) = write_atomic(&dst, out.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library folder.");+    }+    json!({"success": true, "output": format!("Copied {from} to {}", norm(&dst)), "path": norm(&dst), "backup": bak,+           "_hint": "Edit the copy with kicad_fp_edit; register the library with kicad_install_library to use it on a board."})+}++fn rename_footprint(args: &Value) -> Value {+    let lib = match pretty_dir(args, "libraryPath") {+        Ok(l) => l,+        Err(e) => return e,+    };+    let (Some(from), Some(to)) = (arg_str(args, "from"), arg_str(args, "to")) else { return fail("missing_arg", "from and to are required", "The old and new names.") };+    let (src, dst) = (fp_file(&lib, from), fp_file(&lib, to));+    let text = match std::fs::read_to_string(&src) {+        Ok(t) => t,+        Err(_) => return fail("footprint_not_found", format!("no {from}.kicad_mod in {}", norm(&lib)), "kicad_list_footprints lists a library's footprints."),+    };+    if dst.exists() {+        return fail("footprint_exists", format!("{} already exists", norm(&dst)), "Pick another name, or remove that footprint first.");+    }+    let out = match kicad_core::libops::rename_footprint_text(&text, to) {+        Ok(o) => o,+        Err(e) => return fail("parse_failed", e, "The file is not a KiCad footprint."),+    };+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would rename {} to {}", norm(&src), norm(&dst))});+    }+    let bak = backup(&src, args);+    if let Err(e) = write_atomic(&dst, out.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library folder.");+    }+    if let Err(e) = std::fs::remove_file(&src) {+        return fail("write_failed", format!("wrote {} but could not remove {}: {e}", norm(&dst), norm(&src)), "Remove the old file by hand.");+    }+    json!({"success": true, "output": format!("Renamed {from} to {to} in {}", norm(&lib)), "path": norm(&dst), "backup": bak,+           "_hint": "Boards and symbols that name the old Lib:Name keep pointing at it: update their Footprint field."})+}++fn remove_footprint(args: &Value) -> Value {+    let lib = match pretty_dir(args, "libraryPath") {+        Ok(l) => l,+        Err(e) => return e,+    };+    let Some(name) = arg_any(args, &["footprintName", "name"]) else { return fail("missing_arg", "footprintName is required", "The footprint to delete.") };+    let f = fp_file(&lib, name);+    if !f.is_file() {+        return fail("footprint_not_found", format!("no {name}.kicad_mod in {}", norm(&lib)), "kicad_list_footprints lists a library's footprints.");+    }+    if dry(args) {+        return json!({"success": true, "dryRun": true, "output": format!("Would delete {}", norm(&f))});+    }+    let bak = backup(&f, args);+    if let Err(e) = std::fs::remove_file(&f) {+        return fail("write_failed", e.to_string(), "Is the file locked?");+    }+    json!({"success": true, "output": format!("Deleted {name} from {}", norm(&lib)), "backup": bak, "_hint": "Boards that placed it keep their own copy."})+}++/// Read a .kicad_sym, run `edit` on its text, write it back (unless a dry run) and answer.+fn sym_edit(args: &Value, edit: &dyn Fn(&str) -> Result<(String, Value), String>, code: &str) -> Value {+    let path = match sym_lib_arg(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_sym's absolute path."),+    };+    let (out, mut reply) = match edit(&text) {+        Ok(x) => x,+        Err(e) => return fail(if e.contains("no symbol") { "symbol_not_found" } else if e.contains("already exists") { "symbol_exists" } else { code }, e, "Nothing was written. kicad_list_symbols shows the names in a library."),+    };+    reply["success"] = json!(true);+    reply["path"] = json!(norm(&path));+    if out == text {+        reply["changed"] = json!(false);+        return reply;+    }+    if dry(args) {+        reply["dryRun"] = json!(true);+        return reply;+    }+    reply["backup"] = json!(backup(&path, args));+    if let Err(e) = write_atomic(&path, out.as_bytes()) {+        return fail("write_failed", e, "Check permissions on the library file.");+    }+    reply["changed"] = json!(true);+    reply+}++/// The symbol a verb names (`symbolName`, or the library's first).+fn symbol_name(args: &Value, text: &str) -> Result<String, String> {+    match arg_any(args, &["symbolName", "name"]) {+        Some(n) => Ok(n.to_string()),+        None => kicad_core::libraries::list_symbols_in_text(text).into_iter().next().ok_or_else(|| "the library has no symbols".to_string()),+    }+}++fn clone_or_rename_symbol(args: &Value, rename: bool) -> Value {+    let (Some(from), Some(to)) = (arg_str(args, "from"), arg_str(args, "to")) else { return fail("missing_arg", "from and to are required", "The existing symbol and the new name.") };+    sym_edit(args, &|t| {+        if rename {+            let (out, children) = kicad_core::libops::rename_symbol(t, from, to)?;+            Ok((out, json!({"output": format!("Renamed {from} to {to}"), "extendedBy": children})))+        } else {+            Ok((kicad_core::libops::clone_symbol(t, from, to)?, json!({"output": format!("Cloned {from} as {to}")})))+        }+    }, "bad_arg")+}++fn set_params(args: &Value) -> Value {+    let mut params = std::collections::BTreeMap::new();+    match args.get("params") {+        Some(Value::Object(o)) => {+            for (k, v) in o {+                params.insert(k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string()));+            }+        }+        Some(Value::String(s)) => {+            for kv in s.split(';').filter(|x| !x.trim().is_empty()) {+                let Some((k, v)) = kv.split_once('=') else { return fail("bad_arg", format!("params entry '{kv}' is not Key=Value"), "Pass {\"Key\": \"Value\"} or 'Key=Value;Key=Value'.") };+                params.insert(k.trim().to_string(), v.trim().to_string());+            }+        }+        _ => {}+    }+    if let Some(c) = arg_str(args, "comment") {+        params.insert("Value".into(), c.to_string());+    }+    if let Some(f) = arg_str(args, "footprint") {+        params.insert("Footprint".into(), f.to_string());+    }+    if params.is_empty() {+        return fail("missing_arg", "nothing to set", "Pass params, comment or footprint.");+    }+    sym_edit(args, &|t| {+        let name = symbol_name(args, t)?;+        let (out, what) = kicad_core::libops::set_params(t, &name, &params)?;+        Ok((out, json!({"output": format!("{} field(s) on {name}", what.len()), "symbol": name, "fields": what})))+    }, "bad_arg")+}++fn set_visibility(args: &Value) -> Value {+    let Some(param) = arg_any(args, &["param", "field"]) else { return fail("missing_arg", "param is required", "The field to show or hide.") };+    let visible = args.get("visible").and_then(Value::as_bool).unwrap_or(true);+    sym_edit(args, &|t| {+        let name = symbol_name(args, t)?;+        let (out, changed) = kicad_core::libops::set_visibility(t, &name, param, visible)?;+        Ok((out, json!({"output": format!("{param} on {name} {}", if !changed { "already as asked" } else if visible { "shown" } else { "hidden" }), "symbol": name, "visible": visible})))+    }, "bad_arg")+}++fn symbol_prims(args: &Value) -> Value {+    let path = match sym_lib_arg(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_sym's absolute path."),+    };+    let name = match symbol_name(args, &text) {+        Ok(n) => n,+        Err(e) => return fail("symbol_not_found", e, "Pass symbolName."),+    };+    match kicad_core::libops::symbol_prims(&text, &name) {+        Ok(mut v) => {+            v["success"] = json!(true);+            v["output"] = json!(format!("{name}: {} pin(s), {} unit(s)", v["pins"], v["units"]));+            v+        }+        Err(e) => fail("symbol_not_found", e, "kicad_list_symbols shows the names in a library."),+    }+}++#[cfg(test)]+mod housekeeping_tests {+    use super::*;++    fn fixtures() -> PathBuf {+        Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures")+    }++    fn tmp(tag: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("kicad-bridge-libhk-{tag}-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    fn call(verb: &str, args: Value) -> Value {+        dispatch(&mut State::new(), verb, &args).unwrap_or_else(|| panic!("{verb} not dispatched"))+    }++    #[test]+    fn footprints_copy_rename_cross_copy_and_remove() {+        let d = tmp("fp");+        let lib = d.join("Mine.pretty");+        std::fs::create_dir_all(&lib).unwrap();+        std::fs::copy(fixtures().join("konnect-route/Fixture.pretty/R_0603_1608Metric.kicad_mod"), lib.join("R_0603_1608Metric.kicad_mod")).unwrap();+        let l = norm(&lib);+        let v = call("kicad_copy_footprint", json!({"libraryPath": l, "from": "R_0603_1608Metric", "to": "R_0603_Wide"}));+        assert_eq!(v["success"], true, "{v}");+        assert!(std::fs::read_to_string(lib.join("R_0603_Wide.kicad_mod")).unwrap().starts_with("(footprint \"R_0603_Wide\""));+        assert_eq!(call("kicad_copy_footprint", json!({"libraryPath": l, "from": "R_0603_1608Metric", "to": "R_0603_Wide"}))["errorCode"], "footprint_exists");+        let v = call("kicad_rename_footprint", json!({"libraryPath": l, "from": "R_0603_Wide", "to": "R_0603_W"}));+        assert_eq!(v["success"], true, "{v}");+        assert!(!lib.join("R_0603_Wide.kicad_mod").exists() && lib.join("R_0603_W.kicad_mod").exists());+        let other = d.join("Other.pretty");+        let v = call("kicad_xcopy_footprint", json!({"fromLib": l, "from": "R_0603_W", "toLib": norm(&other)}));+        assert_eq!(v["success"], true, "{v}");+        assert!(other.join("R_0603_W.kicad_mod").exists(), "toLib created and the name kept");+        let v = call("kicad_add_body", json!({"libraryPath": l, "footprintName": "R_0603_W", "step": "${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step"}));+        assert_eq!(v["success"], true, "{v}");+        assert_eq!(v["bodiesNow"], 1, "{v}");+        let v = call("kicad_add_body", json!({"libraryPath": l, "footprintName": "R_0603_W", "step": "C:/x/second.step", "keepExisting": true}));+        assert_eq!(v["bodiesNow"], 2, "{v}");+        assert_eq!(call("kicad_add_body", json!({"libraryPath": l, "footprintName": "R_0603_W"}))["errorCode"], "not_supported");+        let v = call("kicad_remove_footprint", json!({"libraryPath": l, "footprintName": "R_0603_W"}));+        assert_eq!(v["success"], true, "{v}");+        assert!(!lib.join("R_0603_W.kicad_mod").exists());+    }++    #[test]+    fn symbols_clone_rename_params_visibility_and_prims() {+        let d = tmp("sym");+        let lib = d.join("MiniLib.kicad_sym");+        std::fs::copy(fixtures().join("sch-edit/MiniLib.kicad_sym"), &lib).unwrap();+        let l = norm(&lib);+        let first = kicad_core::libraries::list_symbols_in_text(&std::fs::read_to_string(&lib).unwrap())[0].clone();+        assert_eq!(call("kicad_clone_symbol", json!({"libraryPath": l, "from": first, "to": "R_copy"}))["success"], true);+        assert_eq!(call("kicad_rename_symbol", json!({"libraryPath": l, "from": "R_copy", "to": "R_new"}))["success"], true);+        let v = call("kicad_set_params", json!({"libraryPath": l, "symbolName": "R_new", "params": "MPN=RC0603;LCSC=C22", "comment": "10k", "footprint": "Resistor_SMD:R_0603_1608Metric"}));+        assert_eq!(v["fields"]["MPN"], "added", "{v}");+        assert_eq!(v["fields"]["Value"], "set", "{v}");+        let info = kicad_core::symedit::symbol_info(&std::fs::read_to_string(&lib).unwrap(), "R_new").unwrap();+        assert_eq!(info["fields"]["Footprint"], "Resistor_SMD:R_0603_1608Metric");+        let v = call("kicad_set_visibility", json!({"libraryPath": l, "symbolName": "R_new", "param": "MPN", "visible": true}));+        assert_eq!(v["changed"], true, "{v}");+        let v = call("kicad_symbol_prims", json!({"libraryPath": l, "symbolName": "R_new"}));+        assert_eq!(v["success"], true, "{v}");+        assert_eq!(v["footprint"], "Resistor_SMD:R_0603_1608Metric");+    }+}++/// `kicad_add_body`: a model file onto a library footprint, replacing its models by default.+fn add_body(state: &mut State, args: &Value) -> Value {+    let lib = match pretty_dir(args, "libraryPath") {+        Ok(l) => l,+        Err(e) => return e,+    };+    let Some(step) = arg_any(args, &["step", "model", "path"]) else {+        return fail("not_supported", "A body without a model file (the standard's box) is not made by this bridge", "Pass step: a STEP or VRML file (from the maker, or the wiki part page), or a ${KICAD10_3DMODEL_DIR} path.");+    };+    let name = match arg_any(args, &["footprintName", "name"]) {+        Some(n) => n.to_string(),+        None => {+            let mods: Vec<String> = std::fs::read_dir(&lib).into_iter().flatten().flatten().filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".kicad_mod")).map(String::from)).collect();+            match mods.as_slice() {+                [one] => one.clone(),+                _ => return fail("name_required", format!("{} holds {} footprints", norm(&lib), mods.len()), "Pass footprintName."),+            }+        }+    };+    let file = fp_file(&lib, &name);+    let before = std::fs::read_to_string(&file).map(|t| t.matches("(model ").count()).unwrap_or(0);+    let mut model = json!({"path": step});+    for k in ["offset", "rotate", "scale"] {+        if let Some(v) = args.get(k) {+            model[k] = v.clone();+        }+    }+    let keep = args.get("keepExisting").and_then(Value::as_bool).unwrap_or(false);+    let edit = json!({"filePath": norm(&file), "models": {"mode": if keep { "append" } else { "replace" }, "list": [model]}, "dryRun": dry(args), "backup": args.get("backup").cloned().unwrap_or(json!(true))});+    let mut out = fp_edit(state, &edit);+    if out["success"] == true {+        let now = std::fs::read_to_string(&file).map(|t| t.matches("(model ").count()).unwrap_or(0);+        out["footprint"] = json!(name);+        out["bodiesBefore"] = json!(before);+        out["bodiesNow"] = json!(if dry(args) { if keep { before + 1 } else { 1 } } else { now });+        out["replacedBodies"] = json!(if keep { 0 } else { before });+        out["keptExisting"] = json!(keep);+    }+    out+}+--- a/rust/crates/kicad-bridge/src/verbs_routing.rs+++ b/rust/crates/kicad-bridge/src/verbs_routing.rs@@ -1,1577 +1,1591 @@⋯ 86 unchanged lines ⋯         pitfalls: &["Does not save the file or delete a whole net implicitly."],     },     Verb {+        name: "kicad_ripup_nets",+        summary: "Delete every unlocked track, arc and via of the named nets live, as one Undo step (pads, pours and locked copper stay).",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\", \"expectedRevision\", \"nets\": [\"GND\", \"/SDA\"], \"dryRun\"?: true (lists what would go and KiCad's DRC without it), \"validate\"?: false}",+        example: "kicad_ripup_nets {\"filePath\":\"C:/p/b.kicad_pcb\",\"expectedRevision\":\"<rev>\",\"nets\":[\"/SDA\",\"/SCL\"],\"dryRun\":true}",+        hint: "perNet counts what each net loses; lockedKept is locked copper left in place. Dry-run first: unconnectedAfter is what you will have to route again.",+        related: &["kicad_remove_route", "kicad_route_net", "kicad_routing_state"],+        pitfalls: &["Needs the board open in the PCB editor with the IPC API on.", "Pours stay: kicad_remove_zone removes a net's zones."],+    },+    Verb {         name: "kicad_add_zone",         summary: "Add copper zones (pours) live as one Undo step: DRC preflight with the zone filled, then created and filled through the IPC API.",         mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,⋯ 135 unchanged lines ⋯         "kicad_text_bounds" => live(state,args,Live::TextBounds),         "kicad_route_net" => live(state, args, Live::RouteNet),         "kicad_remove_route" => live(state, args, Live::RemoveRoute),+        "kicad_ripup_nets" => live(state, args, Live::RipupNets),         "kicad_routing_validate" => live(state, args, Live::Validate),         "kicad_add_zone" => live(state, args, Live::AddZone),         "kicad_apply_zone_plan" => live(state,args,Live::ApplyZonePlan),⋯ 24 unchanged lines ⋯     TextBounds,     RouteNet,     RemoveRoute,+    RipupNets,     Validate,     AddZone,     ApplyZonePlan,⋯ 21 unchanged lines ⋯         Live::SilkTextBatch => edit(ipc::silk_text_batch),         Live::RouteNet => edit(ipc::route_net),         Live::RemoveRoute => edit(ipc::remove_route),+        Live::RipupNets => edit(ipc::ripup_nets),         Live::Validate => ipc::routing_validate(&ctx, args),         Live::AddZone => edit(ipc::add_zone),         Live::ApplyZonePlan => edit(ipc::apply_zone_plan),⋯ 1284 unchanged lines ⋯         assert!(v["jobs"].is_array());     } }+--- a/rust/crates/kicad-bridge/src/verbs_sch_hier/tests.rs+++ b/rust/crates/kicad-bridge/src/verbs_sch_hier/tests.rs@@ -1,492 +1,493 @@⋯ 38 unchanged lines ⋯  #[test] fn catalog_entries_are_complete_and_dash_free() {-    assert_eq!(VERBS.len(), 10);+    assert_eq!(VERBS.len(), 14);     for v in VERBS {         assert!(v.name.starts_with("kicad_"));         for t in [v.summary, v.input, v.example, v.hint] {⋯ 444 unchanged lines ⋯     assert_eq!(v["errorCode"], "bad_arg");     let _ = std::fs::remove_dir_all(&d); }+--- a/rust/crates/kicad-bridge/src/verbs_sch_hier.rs+++ b/rust/crates/kicad-bridge/src/verbs_sch_hier.rs@@ -1,1078 +1,1492 @@⋯ 127 unchanged lines ⋯         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."],     },+    Verb {+        name: "kicad_doc_params",+        summary: "Read or set a sheet's document parameters: KiCad's title block (Title, Revision, Date, Company, Comment1 to Comment9).",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"sheet\": \"C:/.../x.kicad_sch\" (or filePath), \"set\"?: {\"Title\": \"Main board\", \"Revision\": \"B\", \"Comment1\": \"...\"}, \"save\"?: true, \"dryRun\"?: bool}",+        example: "kicad_doc_params {\"sheet\":\"C:/Users/john/proj/board.kicad_sch\",\"set\":{\"Title\":\"Main board\",\"Revision\":\"B\"}}",+        hint: "Without set it only reads. Names match case-insensitively; CompanyName and Rev are taken too. KiCad's title block has no slot for other names (DocumentNumber, DrawnBy...): they are listed in notStored, nothing is written for them.",+        related: &["kicad_sch_page", "kicad_sheet_params"],+        pitfalls: &["the edit is on disk: an open Schematic Editor does not see it until reloaded"],+    },+    Verb {+        name: "kicad_sheet_params",+        summary: "Write parameters onto PLACED parts on the schematic sheets (the BOM write-back: MPN, Manufacturer, Mouser/DigiKey/LCSC numbers, anything): every unit of each designator, on whichever sheet of the project holds it. A missing parameter is added hidden.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"designators\": [\"R1\", \"R2\"] | \"R1,R2\", \"params\": {\"MPN\": \"...\"} | \"MPN=...;LCSC=C123\", \"projectPath\": \"C:/.../p.kicad_pro\" | \"sheet\": \"C:/.../x.kicad_sch\", \"save\"?: true, \"dryRun\"?: bool}",+        example: "kicad_sheet_params {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\",\"designators\":[\"R1\",\"R2\"],\"params\":{\"MPN\":\"RC0603FR-0710KL\",\"LCSC\":\"C98220\"}}",+        hint: "Each touched sheet is written once (a .bak beside it). notFound lists designators no sheet holds. The board picks the fields up with kicad_update_pcb_from_schematic.",+        related: &["kicad_part_parameters", "kicad_sch_set_property", "kicad_update_pcb_from_schematic"],+        pitfalls: &["the edit is on disk: an open Schematic Editor does not see it until reloaded", "Reference is not set here: rename a part with kicad_sch_set_property"],+    },+    Verb {+        name: "kicad_part_parameters",+        summary: "The parameter (field) names a project's placed parts carry, with how many parts carry each; or, with name, that parameter's value on every part. Read only.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"projectPath\": \"C:/.../p.kicad_pro\" | \"sheet\": \"C:/.../x.kicad_sch\", \"name\"?: \"MPN\"}",+        example: "kicad_part_parameters {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\",\"name\":\"LCSC\"}",+        hint: "Parts are counted once per designator (all units of a part are one part). With name, missing lists the parts that do not carry it, the gap list for a BOM write-back with kicad_sheet_params.",+        related: &["kicad_sheet_params", "kicad_sch_components", "kicad_bom"],+        pitfalls: &["power symbols (#PWR..) and flags (#FLG..) are left out"],+    },+    Verb {+        name: "kicad_place_on_sch",+        summary: "Place one symbol or a ';' list of symbols from a .kicad_sym on ONE new sheet, sized from the measured parts (12.7 mm gaps, 15.24 mm margins, A4 at least), each with the next free designator and its name as the value; fills the title block.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\" | \"LM1117;AMS1117;TLV1117\", \"schPath\"?: \"C:/.../showcase.kicad_sch\" (default <library>-sheet.kicad_sch beside it), \"title\"?, \"revision\"?, \"number\"?, \"drawnBy\"?, \"description\"?, \"dryRun\"?: bool}",+        example: "kicad_place_on_sch {\"libraryPath\":\"C:/Users/john/kicad/Mine.kicad_sym\",\"symbolName\":\"LM1117;AMS1117\",\"title\":\"LDO options\"}",+        hint: "A showcase or review sheet for library parts. Each symbol is measured (body and pin ends) and placed in rows, anchors on the 2.54 mm grid. Then render it with kicad_render_schematic.",+        related: &["kicad_sch_create", "kicad_sch_place_symbol", "kicad_render_schematic", "kicad_symbol_prims"],+        pitfalls: &["an existing schPath is refused (it is a new sheet)", "the sheet stands alone: add it to a project with kicad_sch_create {projectPath} first if it belongs to one"],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 8 unchanged lines ⋯         "kicad_sch_fix_connectivity" => fix_connectivity(args),         "kicad_render_schematic" => render(state, args),         "kicad_design_review" => design_review(state, args),+        "kicad_doc_params" => doc_params(args),+        "kicad_sheet_params" => sheet_params(args),+        "kicad_part_parameters" => part_parameters(args),+        "kicad_place_on_sch" => place_on_sch(state, args),         _ => return None,     }) }⋯ 928 unchanged lines ⋯  #[cfg(test)] mod tests;++// ---------------------------------------------------------------------------+// The EDA Bridge Standard's document and part parameters (core tier)+// ---------------------------------------------------------------------------++/// `kicad_doc_params`: the title block under the standard's parameter names.+fn doc_params(args: &Value) -> Value {+    let mut a = args.clone();+    if let Some(sheet) = args.get("sheet").cloned() {+        a.as_object_mut().map(|o| o.entry("filePath").or_insert(sheet));+    }+    let mut not_stored = Vec::new();+    if let Some(set) = args.get("set").and_then(Value::as_object) {+        let mut comments = serde_json::Map::new();+        for (k, v) in set {+            let val = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());+            let key = k.to_ascii_lowercase();+            match key.as_str() {+                "title" => a["title"] = json!(val),+                "revision" | "rev" => a["rev"] = json!(val),+                "date" => a["date"] = json!(val),+                "company" | "companyname" => a["company"] = json!(val),+                _ => match key.strip_prefix("comment").and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=9).contains(n)) {+                    Some(n) => {+                        comments.insert(n.to_string(), json!(val));+                    }+                    None => not_stored.push(k.clone()),+                },+            }+        }+        if !comments.is_empty() {+            a["comments"] = Value::Object(comments);+        }+    }+    if let Some(false) = args.get("save").and_then(Value::as_bool) {+        a["dryRun"] = json!(true);+    }+    let mut out = page(&a);+    if out["success"] != true {+        return out;+    }+    let tb = &out["titleBlock"];+    let mut params = serde_json::Map::new();+    for (k, std) in [("title", "Title"), ("rev", "Revision"), ("date", "Date"), ("company", "Company")] {+        if let Some(v) = tb.get(k) {+            params.insert(std.into(), v.clone());+        }+    }+    if let Some(c) = tb.get("comments").and_then(Value::as_object) {+        for (n, v) in c {+            params.insert(format!("Comment{n}"), v.clone());+        }+    }+    out["parameters"] = Value::Object(params);+    out["notStored"] = json!(not_stored);+    if !not_stored.is_empty() {+        out["_hint"] = json!("KiCad's title block has Title, Revision, Date, Company and Comment1-9 only; the names in notStored were not written. Put them in a comment line, or in the project's text variables.");+    }+    out+}++/// The sheet files a call names: projectPath's whole hierarchy, or one sheet.+fn param_sheets(args: &Value) -> Result<Vec<(PathBuf, Schematic)>, Value> {+    if let Some(root) = root_arg(args)? {+        let h = Hierarchy::load(&root).map_err(|e| fail("parse_failed", e, "The root sheet could not be read; run kicad_lint_schematic on it."))?;+        return Ok(h.docs);+    }+    let path = match arg_any(args, &["sheet", "filePath"]) {+        Some(p) => PathBuf::from(p.trim()),+        None => return Err(fail("missing_arg", "projectPath or sheet is required", "projectPath covers every sheet of the project.")),+    };+    let sch = Schematic::load(&path).map_err(|e| fail("parse_failed", e, "Not a schematic this bridge can read; run kicad_lint_schematic."))?;+    Ok(vec![(path, sch)])+}++fn list_arg(v: Option<&Value>) -> Vec<String> {+    match v {+        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),+        Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+        _ => Vec::new(),+    }+}++/// `kicad_sheet_params`: parameters onto placed parts, every unit, on whichever sheet holds each.+fn sheet_params(args: &Value) -> Value {+    let designators = list_arg(args.get("designators"));+    if designators.is_empty() {+        return fail("missing_arg", "designators is required", "Pass [\"R1\", \"R2\"] or \"R1,R2\".");+    }+    let mut params: Vec<(String, String)> = Vec::new();+    match args.get("params") {+        Some(Value::Object(o)) => params.extend(o.iter().map(|(k, v)| (k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string())))),+        Some(Value::String(s)) => {+            for kv in s.split(';').filter(|x| !x.trim().is_empty()) {+                let Some((k, v)) = kv.split_once('=') else { return fail("bad_arg", format!("params entry '{kv}' is not Key=Value"), "Pass {\"Key\": \"Value\"} or 'Key=Value;Key=Value'.") };+                params.push((k.trim().to_string(), v.trim().to_string()));+            }+        }+        _ => {}+    }+    if params.is_empty() {+        return fail("missing_arg", "params is required", "Pass {\"MPN\": \"...\"} or 'MPN=...;LCSC=C123'.");+    }+    let mut docs = match param_sheets(args) {+        Ok(d) => d,+        Err(v) => return v,+    };+    let dry_run = dry(args, false) || args.get("save") == Some(&json!(false));+    let mut written = Vec::new();+    let mut found: std::collections::BTreeSet<String> = Default::default();+    let mut rows = Vec::new();+    for (path, sch) in docs.iter_mut() {+        let mut touched = false;+        for d in &designators {+            if sch.find_symbol_index(d).is_none() {+                continue;+            }+            found.insert(d.clone());+            for (k, v) in &params {+                match sch.set_property_all_units(d, k, v) {+                    Ok((units, old)) => {+                        touched = true;+                        rows.push(json!({"designator": d, "name": k, "value": v, "previous": old, "units": units, "sheet": norm(path)}));+                    }+                    Err(e) => return fail("edit_failed", e, "Nothing was written."),+                }+            }+        }+        if touched && !dry_run {+            match sch.save_with_backup(path) {+                Ok(b) => written.push(json!({"path": norm(path), "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> = written.iter().filter_map(|w| w["path"].as_str().map(PathBuf::from)).collect();+    if let Err(v) = reparse(&paths) {+        return v;+    }+    let missing: Vec<&String> = designators.iter().filter(|d| !found.contains(*d)).collect();+    json!({+        "success": true, "dryRun": dry_run, "changes": rows, "files": written, "notFound": missing,+        "output": format!("{} parameter write(s) on {} part(s){}", rows.len(), found.len(), if missing.is_empty() { String::new() } else { format!("; not found: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")) }),+        "reloadHint": RELOAD,+        "_hint": "The board takes the new fields with kicad_update_pcb_from_schematic. kicad_part_parameters {name} reads them back.",+    })+}++/// `kicad_part_parameters`: which parameters the project's parts carry, or one parameter's values.+fn part_parameters(args: &Value) -> Value {+    let docs = match param_sheets(args) {+        Ok(d) => d,+        Err(v) => return v,+    };+    let want = arg_str(args, "name");+    // designator -> (field name -> value), units merged+    let mut parts: std::collections::BTreeMap<String, (String, std::collections::BTreeMap<String, String>)> = Default::default();+    for (path, sch) in &docs {+        for n in sch.top().iter().filter(|n| n.head() == Some("symbol") && n.child("lib_id").is_some()) {+            let props: Vec<(String, String)> = n.children().iter().filter(|c| c.head() == Some("property")).filter_map(|c| Some((c.arg(0)?, c.arg(1)?))).collect();+            let Some(r) = props.iter().find(|(k, _)| k == "Reference").map(|(_, v)| v.clone()) else { continue };+            if r.starts_with('#') {+                continue;+            }+            let e = parts.entry(r).or_insert_with(|| (norm(path), Default::default()));+            for (k, v) in props {+                e.1.entry(k).or_insert(v);+            }+        }+    }+    if let Some(name) = want {+        let values: Vec<Value> = parts.iter().filter_map(|(d, (sheet, f))| f.get(name).map(|v| json!({"designator": d, "value": v, "sheet": sheet}))).collect();+        let missing: Vec<&String> = parts.iter().filter(|(_, (_, f))| !f.contains_key(name)).map(|(d, _)| d).collect();+        return json!({"success": true, "name": name, "values": values, "missing": missing, "parts": parts.len(),+                      "output": format!("{name} on {} of {} part(s)", values.len(), parts.len())});+    }+    let mut counts: std::collections::BTreeMap<String, usize> = Default::default();+    for (_, (_, f)) in &parts {+        for k in f.keys() {+            *counts.entry(k.clone()).or_default() += 1;+        }+    }+    json!({"success": true, "parts": parts.len(), "parameters": counts, "sheets": docs.len(),+           "output": format!("{} parameter name(s) across {} part(s) on {} sheet(s)", counts.len(), parts.len(), docs.len()),+           "_hint": "Pass name to read one parameter's value on every part; missing then lists the parts without it."})+}++#[cfg(test)]+mod param_tests {+    use super::*;++    fn project() -> PathBuf {+        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/sch_hier");+        let d = std::env::temp_dir().join(format!("kicad-bridge-params-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        for e in std::fs::read_dir(&src).unwrap().flatten() {+            std::fs::copy(e.path(), d.join(e.file_name())).unwrap();+        }+        d.join("hier.kicad_pro")+    }++    fn call(verb: &str, args: Value) -> Value {+        dispatch(&mut State::new(), verb, &args).unwrap_or_else(|| panic!("{verb} not dispatched"))+    }++    #[test]+    fn parameters_are_listed_written_on_every_unit_and_read_back() {+        let pro = project();+        let p = norm(&pro);+        let before = call("kicad_part_parameters", json!({"projectPath": p}));+        assert_eq!(before["success"], true, "{before}");+        let parts = before["parts"].as_u64().unwrap();+        assert!(parts > 0 && before["parameters"]["Reference"].as_u64() == Some(parts), "{before}");+        let first = call("kicad_part_parameters", json!({"projectPath": p, "name": "Value"}))["values"][0]["designator"].as_str().unwrap().to_string();+        let w = call("kicad_sheet_params", json!({"projectPath": p, "designators": [first.clone(), "NOPE9"], "params": "MPN=TEST-123;LCSC=C1"}));+        assert_eq!(w["success"], true, "{w}");+        assert_eq!(w["notFound"], json!(["NOPE9"]));+        let after = call("kicad_part_parameters", json!({"projectPath": p, "name": "MPN"}));+        assert_eq!(after["values"], json!([{"designator": first, "value": "TEST-123", "sheet": after["values"][0]["sheet"]}]), "{after}");+        let root = pro.with_extension("kicad_sch");+        let d = call("kicad_doc_params", json!({"sheet": norm(&root), "set": {"Title": "Params test", "Revision": "C", "Comment2": "two", "DrawnBy": "x"}}));+        assert_eq!(d["success"], true, "{d}");+        assert_eq!(d["parameters"]["Title"], "Params test");+        assert_eq!(d["parameters"]["Revision"], "C");+        assert_eq!(d["parameters"]["Comment2"], "two");+        assert_eq!(d["notStored"], json!(["DrawnBy"]));+    }+}++/// `kicad_place_on_sch`: library symbols on one new sheet sized to them.+fn place_on_sch(state: &mut State, args: &Value) -> Value {+    let Some(lib) = arg_str(args, "libraryPath").map(PathBuf::from) else {+        return fail("missing_arg", "libraryPath is required", "Pass the .kicad_sym file.");+    };+    let names: Vec<String> = arg_str(args, "symbolName").map(|s| s.split(';').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();+    if names.is_empty() {+        return fail("missing_arg", "symbolName is required", "One symbol, or several as 'A;B;C'.");+    }+    let text = match std::fs::read_to_string(&lib) {+        Ok(t) => t,+        Err(e) => return fail("file_not_found", format!("cannot read {}: {e}", norm(&lib)), "Pass the .kicad_sym's absolute path."),+    };+    let stem = lib.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Lib".into());+    // measure: body and pin ends, in the symbol's frame (y up)+    struct Part {+        name: String,+        prefix: String,+        bbox: [f64; 4],+    }+    let mut parts = Vec::new();+    for n in &names {+        let info = match kicad_core::symedit::symbol_info(&text, n) {+            Ok(i) => i,+            Err(e) => return fail("symbol_not_found", e, "kicad_list_symbols shows the names in a library."),+        };+        let prims = kicad_core::libops::symbol_prims(&text, n).unwrap_or(json!({}));+        let mut pts: Vec<(f64, f64)> = Vec::new();+        for b in [&prims["bodyRectangle"], &prims["polylineBox"]] {+            if let Some(a) = b.as_array().filter(|a| a.len() == 4) {+                let f = |i: usize| a[i].as_f64().unwrap_or(0.0);+                pts.push((f(0), f(1)));+                pts.push((f(2), f(3)));+            }+        }+        for p in info["pins"].as_array().into_iter().flatten().filter(|p| p["unit"].as_u64().unwrap_or(0) <= 1) {+            if let (Some(x), Some(y)) = (p["at"][0].as_f64(), p["at"][1].as_f64()) {+                pts.push((x, y));+            }+        }+        if pts.is_empty() {+            pts.push((-2.54, -2.54));+            pts.push((2.54, 2.54));+        }+        let bbox = [pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), pts.iter().map(|p| p.0).fold(f64::MIN, f64::max), pts.iter().map(|p| p.1).fold(f64::MIN, f64::max)];+        let prefix = info["fields"]["Reference"].as_str().unwrap_or("U").trim_end_matches(|c: char| c.is_ascii_digit() || c == '?').to_string();+        parts.push(Part { name: n.clone(), prefix: if prefix.is_empty() { "U".into() } else { prefix }, bbox });+    }+    // rows of up to 6 parts, 12.7 mm apart, 15.24 mm margins (the standard's 500 and 600 mil)+    let (gap, margin, per_row) = (12.7, 15.24, 6usize);+    let snap = |v: f64| (v / 2.54).round() * 2.54;+    let mut placements = Vec::new();+    let (mut y_top, mut width) = (margin, 0.0f64);+    for row in parts.chunks(per_row) {+        let row_h = row.iter().map(|p| p.bbox[3] - p.bbox[1]).fold(0.0, f64::max);+        let mut x = margin;+        for p in row {+            // on the sheet y points down: the symbol's top (its largest y) lands on the row's top+            let ax = snap(x - p.bbox[0]);+            let ay = snap(y_top + p.bbox[3]);+            placements.push((p, ax, ay));+            x += (p.bbox[2] - p.bbox[0]) + gap;+        }+        width = width.max(x - gap + margin);+        y_top += row_h + gap;+    }+    let height = y_top - gap + margin;+    let paper = [("A4", 297.0, 210.0), ("A3", 420.0, 297.0), ("A2", 594.0, 420.0), ("A1", 841.0, 594.0), ("A0", 1189.0, 841.0)]+        .iter()+        .find(|(_, w, h)| width <= *w && height <= *h)+        .map(|(n, _, _)| json!({"paper": n}))+        .unwrap_or_else(|| json!({"paper": "custom", "width": (width / 10.0).ceil() * 10.0, "height": (height / 10.0).ceil() * 10.0}));+    let sch = arg_str(args, "schPath").map(PathBuf::from).unwrap_or_else(|| lib.with_file_name(format!("{stem}-sheet.kicad_sch")));+    let mut comments = serde_json::Map::new();+    for (i, k) in [(1, "description"), (2, "drawnBy"), (3, "number")] {+        if let Some(v) = arg_str(args, k) {+            comments.insert(i.to_string(), json!(match k { "drawnBy" => format!("Drawn by {v}"), "number" => format!("Document {v}"), _ => v.to_string() }));+        }+    }+    let mut create_args = json!({"filePath": norm(&sch), "title": arg_str(args, "title").unwrap_or(&names.join(", ")), "dryRun": dry(args, false)});+    for (k, v) in paper.as_object().unwrap() {+        create_args[k] = v.clone();+    }+    if let Some(r) = arg_str(args, "revision") {+        create_args["rev"] = json!(r);+    }+    if !comments.is_empty() {+        create_args["comments"] = Value::Object(comments);+    }+    let mut counters: std::collections::HashMap<String, u32> = Default::default();+    let ops: Vec<Value> = placements+        .iter()+        .map(|(p, x, y)| {+            let n = counters.entry(p.prefix.clone()).or_insert(0);+            *n += 1;+            json!({"op": "place_symbol", "libId": format!("{stem}:{}", p.name), "libraryPath": norm(&lib), "reference": format!("{}{}", p.prefix, n), "value": p.name, "at": [*x, *y]})+        })+        .collect();+    if dry(args, false) {+        return json!({"success": true, "dryRun": true, "sheet": norm(&sch), "page": paper, "sizeMm": [width, height], "placements": ops,+                      "output": format!("Would place {} symbol(s) on a new {} sheet", ops.len(), paper["paper"].as_str().unwrap_or("custom"))});+    }+    let created = create(&create_args);+    if created["success"] != true {+        return created;+    }+    let placed = match crate::verbs_sch_edit::dispatch(state, "kicad_sch_batch", &json!({"filePath": norm(&sch), "ops": ops})) {+        Some(v) => v,+        None => return fail("internal", "kicad_sch_batch is not available", "Report this reply."),+    };+    if placed["success"] != true {+        return placed;+    }+    json!({"success": true, "sheet": norm(&sch), "page": paper, "sizeMm": [width, height], "placed": placed["results"],+           "output": format!("Placed {} symbol(s) on {}", names.len(), norm(&sch)),+           "_hint": "kicad_render_schematic {filePath} shows it; kicad_open_schematic opens it in KiCad."})+}++#[cfg(test)]+mod place_on_sch_tests {+    use super::*;++    #[test]+    fn library_symbols_land_on_one_new_sheet() {+        let d = std::env::temp_dir().join(format!("kicad-bridge-pos-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        let lib = d.join("MiniLib.kicad_sym");+        std::fs::copy(Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/sch-edit/MiniLib.kicad_sym"), &lib).unwrap();+        let names = kicad_core::libraries::list_symbols_in_text(&std::fs::read_to_string(&lib).unwrap());+        let two = format!("{};{}", names[0], names[1]);+        let v = dispatch(&mut State::new(), "kicad_place_on_sch", &json!({"libraryPath": norm(&lib), "symbolName": two, "title": "Showcase", "drawnBy": "kicad-lane"})).unwrap();+        assert_eq!(v["success"], true, "{v}");+        assert_eq!(v["page"]["paper"], "A4");+        let sch = Schematic::load(&d.join("MiniLib-sheet.kicad_sch")).unwrap();+        assert_eq!(sch.symbols().len(), 2, "both placed");+        let tb = hier::title_block_json(&sch);+        assert_eq!(tb["title"], "Showcase");+        assert_eq!(dispatch(&mut State::new(), "kicad_place_on_sch", &json!({"libraryPath": norm(&lib), "symbolName": names[0]})).unwrap()["success"], false, "an existing sheet is refused");+    }+}+--- a/rust/crates/kicad-bridge/src/verbs_standard.rs+++ b/rust/crates/kicad-bridge/src/verbs_standard.rs@@ -1,378 +1,447 @@⋯ 111 unchanged lines ⋯         related: &["kicad_routing_state", "kicad_board_summary", "kicad_component_data"],         pitfalls: &["A file read does not see unsaved edits in an open editor."],     },+    Verb {+        name: "kicad_layers",+        summary: "The board's whole layer set: KiCad's id, name, kind (signal, power, mixed, user) and the user's name for each, and whether the open PCB editor shows it.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\"}",+        example: "kicad_layers {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "displayed is filled from the live editor; a file read leaves it null. Copper layers alone: kicad_copper_layers; copper that carries anything: kicad_layers_in_use.",+        related: &["kicad_copper_layers", "kicad_layers_in_use", "kicad_layer_stack"],+        pitfalls: &["A file read does not see unsaved edits in an open editor."],+    },+    Verb {+        name: "kicad_silk_state",+        summary: "The silkscreen as it stands: every part's body box, its designator and value as placed (text, position, size, rotation, shown), and the free texts and graphics on both silkscreen layers.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}",+        example: "kicad_silk_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "mm in the standard's frame (y up; frame:\"kicad\" for y down). bodyBox comes from the part's fab drawing (bodyFrom says when it fell back to the courtyard). The ids feed kicad_silk_fields_batch, kicad_silk_text_batch and kicad_silk_graphics_batch.",+        related: &["kicad_silk_fields_batch", "kicad_silk_text_batch", "kicad_silk_graphics_batch", "kicad_text_bounds"],+        pitfalls: &["Text sizes are the font size, not the measured ink: kicad_text_bounds measures text as KiCad draws it."],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 8 unchanged lines ⋯         "kicad_layers_in_use" => view(state, args, "layers_in_use"),         "kicad_component_data" => view(state, args, "component_data"),         "kicad_component_pins" => view(state, args, "component_pins"),+        "kicad_layers" => layers(state, args),+        "kicad_silk_state" => silk(state, args),         _ => return None,     }) }⋯ 244 unchanged lines ⋯         assert!(e["y1"].as_f64().unwrap() <= -58.0 + 1e-6 && e["y0"].as_f64().unwrap() <= -132.0 + 1e-6, "{e}");     } }++/// `kicad_layers`: the layer table, with the live editor's shown layers when it serves the board.+fn layers(state: &mut State, args: &Value) -> Value {+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let visible = if r.live { visible_layers(state, args) } else { None };+    match kicad_core::board_reads::layers(&r.text, visible.as_deref()) {+        Ok(mut v) => {+            stamp(&mut v, &r);+            v["output"] = json!(format!("{} layers, {} copper", v["count"], v["copper"]));+            v["_hint"] = json!(format!("{}displayed is {}.", file_note(&r), if visible.is_some() { "what the PCB editor shows now" } else { "null: only the live editor knows it" }));+            v+        }+        Err(e) => fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),+    }+}++#[cfg(feature = "ipc")]+fn visible_layers(state: &mut State, args: &Value) -> Option<Vec<String>> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = 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)),+    };+    ipc::visible_layer_names(&ctx, args).ok()+}++#[cfg(not(feature = "ipc"))]+fn visible_layers(_state: &mut State, _args: &Value) -> Option<Vec<String>> {+    None+}++/// `kicad_silk_state`: the silkscreen as it stands.+fn silk(state: &mut State, args: &Value) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    match kicad_core::board_reads::silk_state(&r.text, o.y_up) {+        Ok(mut v) => {+            stamp(&mut v, &r);+            v["output"] = json!(format!("{} parts, {} silkscreen texts, {} silkscreen graphics", v["counts"]["parts"], v["counts"]["texts"], v["counts"]["graphics"]));+            v["_hint"] = json!(format!("{}Move fields with kicad_silk_fields_batch (by designator.id), free text with kicad_silk_text_batch, lines with kicad_silk_graphics_batch.", file_note(&r)));+            v+        }+        Err(e) => fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),+    }+}+--- a/rust/crates/kicad-core/src/board_reads.rs+++ b/rust/crates/kicad-core/src/board_reads.rs@@ -0,0 +1,203 @@+//! Two board reads the EDA Bridge Standard names in its core tier, from the board's text (the live+//! editor's serialisation or the file): `layers`, the board's whole layer set, and `silk_state`,+//! the silkscreen as it stands. Millimetres; y up unless `y_up` is false (KiCad's own frame).++use serde_json::{json, Value};++use crate::placement::to_board;+use crate::schematic::{Document, Node};++fn root(text: &str) -> Result<Node, String> {+    let doc = Document::parse(text)?;+    if doc.root.head() != Some("kicad_pcb") {+        return Err("not a board (the top-level list is not kicad_pcb)".into());+    }+    Ok(doc.root)+}++/// Every layer the board defines: KiCad's id, name, kind (signal, power, mixed, jumper, user) and+/// the user's name for it. `visible`, when the live editor said which layers it shows.+pub fn layers(text: &str, visible: Option<&[String]>) -> Result<Value, String> {+    let r = root(text)?;+    let list = r.child("layers").ok_or("the board has no (layers ..) table")?;+    let rows: Vec<Value> = list+        .children()+        .iter()+        .skip(1)+        .filter(|c| c.is_list())+        .map(|c| {+            let name = c.arg(0).unwrap_or_default();+            let kind = c.arg(1).unwrap_or_default();+            json!({+                "id": c.head().and_then(|h| h.parse::<i64>().ok()),+                "name": name,+                "kind": kind,+                "copper": name.ends_with(".Cu"),+                "userName": c.arg(2),+                "displayed": visible.map(|v| v.iter().any(|x| x == &name)),+            })+        })+        .collect();+    Ok(json!({"count": rows.len(), "copper": rows.iter().filter(|r| r["copper"] == true).count(), "layers": rows}))+}++fn pt(n: &Node, tag: &str) -> Option<(f64, f64)> {+    let p = n.child(tag)?;+    Some((p.num(0)?, p.num(1)?))+}++/// Board point in the requested frame.+fn out(p: (f64, f64), y_up: bool) -> Value {+    json!([round(p.0), round(if y_up { -p.1 } else { p.1 })])+}++fn round(v: f64) -> f64 {+    (v * 1e4).round() / 1e4 + 0.0+}++/// A footprint field (or legacy fp_text) as the silkscreen shows it.+fn field(f: &Node, origin: (f64, f64), rot: f64, y_up: bool) -> Value {+    let at = f.child("at");+    let local = at.and_then(|a| Some((a.num(0)?, a.num(1)?))).unwrap_or((0.0, 0.0));+    let font = f.child("effects").and_then(|e| e.child("font"));+    let size = font.and_then(|ft| ft.child("size"));+    let hidden = f.child_value("hide").as_deref() == Some("yes") || f.child("effects").map(|e| e.children().iter().any(|c| c.raw() == Some("hide"))).unwrap_or(false);+    json!({+        "text": f.arg(1).or_else(|| f.arg(0)),+        "id": f.child_value("uuid"),+        "layer": f.child_value("layer"),+        "at": out(to_board(origin, rot, local), y_up),+        "rotation": at.and_then(|a| a.num(2)).unwrap_or(0.0),+        "heightMm": size.and_then(|s| s.num(0)),+        "widthMm": size.and_then(|s| s.num(1)),+        "thicknessMm": font.and_then(|ft| ft.child("thickness")).and_then(|t| t.num(0)),+        "shown": !hidden,+    })+}++fn on_silk(n: &Node) -> bool {+    matches!(n.child_value("layer").as_deref(), Some("F.SilkS") | Some("B.SilkS"))+}++/// Every point a graphic draws through (start/mid/end/center, a polygon's points), board frame.+fn graphic_points(g: &Node) -> Vec<(f64, f64)> {+    let mut p: Vec<(f64, f64)> = ["start", "mid", "end", "center"].iter().filter_map(|t| pt(g, t)).collect();+    if let Some(pts) = g.child("pts") {+        p.extend(pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))));+    }+    p+}++fn bbox(points: &[(f64, f64)]) -> Option<[f64; 4]> {+    let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    for (x, y) in points {+        x0 = x0.min(*x);+        y0 = y0.min(*y);+        x1 = x1.max(*x);+        y1 = y1.max(*y);+    }+    (x0 <= x1).then_some([x0, y0, x1, y1])+}++/// The silkscreen as it stands: every part's body box (its fab drawing, else its courtyard), its+/// designator and value as placed (text, position, size, rotation, shown), and the free texts and+/// graphics on both silkscreen layers.+pub fn silk_state(text: &str, y_up: bool) -> Result<Value, String> {+    let r = root(text)?;+    let mut parts = Vec::new();+    for fp in r.children().iter().filter(|c| c.head() == Some("footprint")) {+        let at = fp.child("at");+        let (x, y, rot) = (at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0));+        let side = fp.child_value("layer").unwrap_or_default();+        let fab = if side.starts_with('B') { "B.Fab" } else { "F.Fab" };+        let crt = if side.starts_with('B') { "B.CrtYd" } else { "F.CrtYd" };+        let pts_on = |layer: &str| -> Vec<(f64, f64)> {+            fp.children()+                .iter()+                .filter(|c| c.head().map(|h| h.starts_with("fp_")).unwrap_or(false) && c.head() != Some("fp_text") && c.child_value("layer").as_deref() == Some(layer))+                .flat_map(graphic_points)+                .map(|p| to_board((x, y), rot, p))+                .collect()+        };+        let mut body = pts_on(fab);+        let from = if body.is_empty() {+            body = pts_on(crt);+            "courtyard"+        } else {+            "fab"+        };+        let prop = |name: &str| fp.children().iter().find(|c| (c.head() == Some("property") && c.arg(0).as_deref() == Some(name)) || (c.head() == Some("fp_text") && c.arg(0).as_deref() == Some(if name == "Reference" { "reference" } else { "value" })));+        let bx = bbox(&body).map(|b| {+            let (a, c) = (out((b[0], b[1]), y_up), out((b[2], b[3]), y_up));+            let (ay, cy) = (a[1].as_f64().unwrap_or(0.0), c[1].as_f64().unwrap_or(0.0));+            json!([a[0], ay.min(cy), c[0], ay.max(cy)])+        });+        parts.push(json!({+            "ref": prop("Reference").and_then(|f| f.arg(1)),+            "side": if side.starts_with('B') { "bottom" } else { "top" },+            "at": out((x, y), y_up),+            "rotation": rot,+            "bodyBox": bx,+            "bodyFrom": if bx.is_some() { json!(from) } else { Value::Null },+            "designator": prop("Reference").map(|f| field(f, (x, y), rot, y_up)),+            "comment": prop("Value").map(|f| field(f, (x, y), rot, y_up)),+        }));+    }+    let mut texts = Vec::new();+    let mut graphics = Vec::new();+    for c in r.children().iter().filter(|c| on_silk(c)) {+        match c.head() {+            Some("gr_text") => {+                let at = c.child("at");+                let size = c.child("effects").and_then(|e| e.child("font")).and_then(|f| f.child("size"));+                texts.push(json!({+                    "id": c.child_value("uuid"), "text": c.arg(0), "layer": c.child_value("layer"),+                    "at": out(at.and_then(|a| Some((a.num(0)?, a.num(1)?))).unwrap_or((0.0, 0.0)), y_up),+                    "rotation": at.and_then(|a| a.num(2)).unwrap_or(0.0), "heightMm": size.and_then(|s| s.num(0)),+                }));+            }+            Some(h) if h.starts_with("gr_") => {+                graphics.push(json!({+                    "id": c.child_value("uuid"), "kind": h.trim_start_matches("gr_"), "layer": c.child_value("layer"),+                    "points": graphic_points(c).into_iter().map(|p| out(p, y_up)).collect::<Vec<_>>(),+                    "widthMm": c.child("stroke").and_then(|s| s.child("width")).and_then(|w| w.num(0)),+                }));+            }+            _ => {}+        }+    }+    Ok(json!({"frame": if y_up { "standard" } else { "kicad" }, "parts": parts, "texts": texts, "graphics": graphics,+              "counts": {"parts": parts.len(), "texts": texts.len(), "graphics": graphics.len()}}))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> String {+        std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route/route-fixture.kicad_pcb")).unwrap()+    }++    #[test]+    fn the_layer_table_reads_with_kinds() {+        let v = layers(&board(), Some(&["F.Cu".to_string()])).unwrap();+        let f = v["layers"].as_array().unwrap().iter().find(|l| l["name"] == "F.Cu").unwrap().clone();+        assert_eq!(f["copper"], true);+        assert_eq!(f["displayed"], true);+        assert!(v["copper"].as_u64().unwrap() >= 2, "{v}");+    }++    #[test]+    fn silk_state_places_the_designator_on_the_board() {+        let v = silk_state(&board(), false).unwrap();+        let u1 = v["parts"].as_array().unwrap().iter().find(|p| p["ref"] == "U1").cloned().unwrap();+        // U1 sits at (20, 20) with its Reference 3 mm above, in KiCad's frame+        assert_eq!(u1["designator"]["at"], json!([20.0, 17.0]), "{u1}");+        assert_eq!(u1["designator"]["text"], "U1");+        assert!(u1["bodyBox"].is_array(), "{u1}");+        let up = silk_state(&board(), true).unwrap();+        let u1 = up["parts"].as_array().unwrap().iter().find(|p| p["ref"] == "U1").cloned().unwrap();+        assert_eq!(u1["designator"]["at"], json!([20.0, -17.0]));+    }+}+--- a/rust/crates/kicad-core/src/ipc/board_live.rs+++ b/rust/crates/kicad-core/src/ipc/board_live.rs@@ -1,730 +1,761 @@⋯ 9 unchanged lines ⋯ //! answers it, and the client-side mirror otherwise.  use super::*;+use super::live_create; use crate::board_edit::{self, Change}; use crate::placement::{parse_fields, varint_value, wire_message, wire_varint}; ⋯ 68 unchanged lines ⋯     // DRC ran outside KiCad: an edit in between invalidates this change.     check_revision(args, &s.revision()?)?;     let before_ids: HashSet<String> = board_edit::top_level_ids(text).into_iter().collect();+    // footprints already on the board, as templates for new ones of the same library footprint+    let templates: Vec<kicad_ipc_rs::FootprintItem> = if change.create.iter().any(|b| b.trim_start().starts_with("(footprint")) {+        s.client.get_editable_items_by_type_codes(vec![PcbObjectTypeCode::new_footprint().code]).map_err(map_err)?+            .into_iter().filter_map(|i| match i { EditablePcbItem::Footprint(f) => Some(f), _ => None }).collect()+    } else {+        Vec::new()+    };     let wrapped = board_edit::create_text(text, &change.create);     let want = change.create.len();     let remove = change.remove.clone();     let mut how = "delete-and-create";+    let text_path_why = std::cell::RefCell::new(String::new());     let mut native_detail = Value::Null;     transaction(s, &change.label, || {         if let Some(f) = native {⋯ 7 unchanged lines ⋯             s.client.delete_items(remove.clone())?;         }         if want > 0 {+            // KiCad's protobuf CreateItems, for every block live_create.rs can encode (board graphics today)+            match live_create::encode_blocks(&change.create, &|block| live_create::find_template(block, &templates, text, &remove)) {+                live_create::Encoded::Items(items) => {+                    let created = s.client.create_items(items, None)?;+                    if created.len() != want {+                        return Err(IpcFailure::from(format!("KiCad created {} of {want} items; rollback requested", created.len())));+                    }+                    how = "create-items";+                    return Ok(());+                }+                live_create::Encoded::Failed(e) => return Err(IpcFailure::from(format!("could not encode the new items: {e}"))),+                live_create::Encoded::TextPath(why) => *text_path_why.borrow_mut() = why,+            }             // decoded here, not by the client: KiCad 10.0.4 answers ParseAndCreateItemsFromString with the             // request status unset (IRS_UNKNOWN) while every item reports ISC_OK, and the client turns that             // into an error, so a good create was rolled back (1.0.61's kicad_add_footprint, kicad_import_svg)⋯ 15 unchanged lines ⋯     })     .map_err(|e| {         if e.message.contains(LIVE_CREATE_UNSUPPORTED) {+            let why = text_path_why.borrow().clone();+            let e = if why.is_empty() { e } else { e.with("liveCreateWhy", json!(format!("Not created through KiCad's protobuf API: {why}. The bridge creates a footprint live by copying KiCad's own encoding of the same library footprint already on the board (same side, same pads and graphics); board graphics are always created live."))) };             let mut e = e.with("_hint", json!("KiCad's API does not create board items from text yet (ParseAndCreateItemsFromString answers empty on KiCad 10.0.4 and 10.0.7). Nothing was changed. Save the board in KiCad and close it (kicad_save_board, then kicad_close), then call this verb again: with no editor holding the board, the bridge edits the closed file (a backup is kept) and KiCad shows the change when the board is reopened."))                 .with("kicadVersion", json!(s.version.full_version)).with("mutated", json!(false));             e.code = LIVE_CREATE_UNSUPPORTED.into();⋯ 358 unchanged lines ⋯             if !ok {                 out["postCommitError"] = json!("the live footprint does not show every planned change; inspect it and use Edit > Undo if it is wrong");             }+            let ids: Vec<String> = change.report["edited"].as_array().into_iter().flatten().filter_map(|r| r["after"]["uuid"].as_str().map(String::from)).collect();+            let dropped = crate::silk_fields::dropped_closing_vertices(&text, &after_text, &ids);+            if !dropped.is_empty() {+                out["closingVerticesDroppedByKiCad"] = json!(dropped);+                out["_libraryParityHint"] = json!(crate::silk_fields::CLOSING_VERTEX_HINT);+            }         }         Err(e) => out["postCommitError"] = json!(e.message),     }⋯ 236 unchanged lines ⋯         assert_eq!(edit_footprint_proto(&mut item, &json!({"newReference": "R9"})).unwrap_err().code, "ipc_error");     } }+--- a/rust/crates/kicad-core/src/ipc/live_create.rs+++ b/rust/crates/kicad-core/src/ipc/live_create.rs@@ -0,0 +1,473 @@+//! Board items created live through KiCad's protobuf `CreateItems`. KiCad 10.0.x answers+//! `ParseAndCreateItemsFromString` with an empty reply and creates nothing (checked on 10.0.4 and+//! 10.0.7), so the blocks a board edit builds as KiCad text are encoded here as the protobuf items+//! `CreateItems` takes. A block this module cannot encode yet answers None, and the edit keeps the+//! text path (which refuses with live_create_unsupported on those KiCad versions).+//!+//! Encoded today:+//! - the board graphics `gr_line`, `gr_arc`, `gr_rect`, `gr_circle` and `gr_poly`+//!   (`kiapi.board.types.BoardGraphicShape`) with their uuid, layer, net, stroke and fill;+//! - a `footprint` whose library footprint is already on the board on the same side: KiCad's own+//!   encoding of that part is cloned (fresh ids for it and every child, the new pose, reference,+//!   value, pad nets and user fields from the new block, no schematic link). A footprint the board+//!   does not have yet keeps the text path.++use super::*;+use crate::placement::{distance_bytes, parse_fields, put_varint, vector2_bytes, wire_message, wire_varint};+use crate::schematic::{new_uuid, parse_node, Node};+use kicad_ipc_rs::FootprintItem;++pub const GRAPHIC_TYPE_URL: &str = "type.googleapis.com/kiapi.board.types.BoardGraphicShape";++fn nm(v: f64) -> i64 {+    (v * 1e6).round() as i64+}++fn point(n: &Node, tag: &str) -> Result<(i64, i64), String> {+    let p = n.child(tag).ok_or_else(|| format!("({tag} ..) missing"))?;+    Ok((nm(p.num(0).ok_or("bad x")?), nm(p.num(1).ok_or("bad y")?)))+}++fn vec2(p: (i64, i64)) -> Vec<u8> {+    vector2_bytes(p.0, p.1)+}++/// KiCad's `(stroke (type ..))` names to `StrokeLineStyle`.+fn stroke_style(n: &Node) -> u64 {+    match n.child("stroke").and_then(|s| s.child_value("type")).as_deref() {+        Some("dash") => 3,+        Some("dot") => 4,+        Some("dash_dot") => 5,+        Some("dash_dot_dot") => 6,+        Some("default") => 1,+        _ => 2,+    }+}++fn stroke_width(n: &Node) -> f64 {+    n.child("stroke").and_then(|s| s.child("width")).and_then(|w| w.num(0)).or_else(|| n.child("width").and_then(|w| w.num(0))).unwrap_or(0.0)+}++/// `(fill yes|solid)` is filled; `no`, `none` or nothing is not.+fn filled(n: &Node) -> bool {+    matches!(n.child_value("fill").as_deref(), Some("yes") | Some("solid"))+}++/// A `(pts ..)` list as `kiapi.common.types.PolyLine` (closed): points and start-mid-end arcs.+fn polyline(pts: &Node) -> Result<Vec<u8>, String> {+    let mut line = Vec::new();+    for c in pts.children().iter().skip(1) {+        let mut node = Vec::new();+        match c.head() {+            Some("xy") => wire_message(&mut node, 1, &vec2((nm(c.num(0).ok_or("bad x")?), nm(c.num(1).ok_or("bad y")?)))),+            Some("arc") => {+                let mut arc = Vec::new();+                wire_message(&mut arc, 1, &vec2(point(c, "start")?));+                wire_message(&mut arc, 2, &vec2(point(c, "mid")?));+                wire_message(&mut arc, 3, &vec2(point(c, "end")?));+                wire_message(&mut node, 2, &arc);+            }+            other => return Err(format!("unknown point node {other:?}")),+        }+        wire_message(&mut line, 1, &node);+    }+    wire_varint(&mut line, 2, 1);+    Ok(line)+}++/// One `gr_*` block as `kiapi.board.types.BoardGraphicShape` bytes. `layer_id` maps KiCad's+/// layer names to the API's ids.+pub fn graphic_bytes(n: &Node, layer_id: &dyn Fn(&str) -> Option<i32>) -> Result<Vec<u8>, String> {+    let head = n.head().unwrap_or("");+    let mut stroke = Vec::new();+    wire_message(&mut stroke, 1, &distance_bytes(nm(stroke_width(n))));+    wire_varint(&mut stroke, 2, stroke_style(n));+    let mut fill = Vec::new();+    wire_varint(&mut fill, 1, if filled(n) { 2 } else { 1 });+    let mut attrs = Vec::new();+    wire_message(&mut attrs, 1, &stroke);+    wire_message(&mut attrs, 2, &fill);+    let mut shape = Vec::new();+    wire_message(&mut shape, 3, &attrs);+    let mut geom = Vec::new();+    let field = match head {+        "gr_line" => {+            wire_message(&mut geom, 1, &vec2(point(n, "start")?));+            wire_message(&mut geom, 2, &vec2(point(n, "end")?));+            4+        }+        "gr_rect" => {+            wire_message(&mut geom, 1, &vec2(point(n, "start")?));+            wire_message(&mut geom, 2, &vec2(point(n, "end")?));+            5+        }+        "gr_arc" => {+            wire_message(&mut geom, 1, &vec2(point(n, "start")?));+            wire_message(&mut geom, 2, &vec2(point(n, "mid")?));+            wire_message(&mut geom, 3, &vec2(point(n, "end")?));+            6+        }+        "gr_circle" => {+            wire_message(&mut geom, 1, &vec2(point(n, "center")?));+            wire_message(&mut geom, 2, &vec2(point(n, "end")?));+            7+        }+        "gr_poly" => {+            let pts = n.child("pts").ok_or("(pts ..) missing")?;+            let mut polygon = Vec::new();+            wire_message(&mut polygon, 1, &polyline(pts)?);+            wire_message(&mut geom, 1, &polygon);+            8+        }+        other => return Err(format!("{other} is not a board graphic")),+    };+    wire_message(&mut shape, field, &geom);+    let layer_name = n.child_value("layer").ok_or("(layer ..) missing")?;+    let layer = layer_id(&layer_name).ok_or_else(|| format!("KiCad has no layer named {layer_name}"))?;+    let mut out = Vec::new();+    wire_message(&mut out, 1, &shape);+    wire_varint(&mut out, 2, layer as u64);+    if let Some(net) = n.child("net") {+        // (net "GND") or the older (net 3 "GND")+        let name = net.arg(1).or_else(|| net.arg(0)).filter(|s| s.parse::<i64>().is_err()).unwrap_or_default();+        if !name.is_empty() {+            let mut nb = Vec::new();+            wire_message(&mut nb, 2, name.as_bytes());+            wire_message(&mut out, 3, &nb);+        }+    }+    if let Some(id) = n.child_value("uuid") {+        let mut k = Vec::new();+        wire_message(&mut k, 1, id.as_bytes());+        wire_message(&mut out, 4, &k);+    }+    // LockedState: LS_UNLOCKED 1, LS_LOCKED 2+    wire_varint(&mut out, 5, if n.child_value("locked").as_deref() == Some("yes") { 2 } else { 1 });+    Ok(out)+}++// -- footprints, cloned from one already on the board ---------------------------------------------++enum Edit {+    Keep,+    Drop,+    Bytes(Vec<u8>),+}++/// A message with some of its fields replaced or dropped (`f` decides per field); `extra` fields+/// (number, length-delimited bytes) are appended.+fn rewrite(msg: &[u8], f: &dyn Fn(u32, u8, &[u8]) -> Result<Edit, String>, extra: &[(u32, Vec<u8>)]) -> Result<Vec<u8>, String> {+    let mut out = Vec::with_capacity(msg.len() + 64);+    for fld in parse_fields(msg)? {+        match f(fld.number, fld.wire, fld.data)? {+            Edit::Drop => {}+            Edit::Bytes(b) => wire_message(&mut out, fld.number, &b),+            Edit::Keep => {+                put_varint(&mut out, ((fld.number as u64) << 3) | fld.wire as u64);+                if fld.wire == 2 {+                    put_varint(&mut out, fld.data.len() as u64);+                }+                out.extend_from_slice(fld.data);+            }+        }+    }+    for (n, b) in extra {+        wire_message(&mut out, *n, b);+    }+    Ok(out)+}++fn kiid(id: &str) -> Vec<u8> {+    let mut k = Vec::new();+    wire_message(&mut k, 1, id.as_bytes());+    k+}++fn string_field(msg: &[u8], number: u32) -> Option<String> {+    parse_fields(msg).ok()?.into_iter().find(|f| f.number == number && f.wire == 2).and_then(|f| String::from_utf8(f.data.to_vec()).ok())+}++/// `BoardText` with a fresh id and, when given, new text (`Text.text`, field 5).+fn board_text(bt: &[u8], text: Option<&str>) -> Result<Vec<u8>, String> {+    rewrite(bt, &|n, _w, d| match n {+        1 => Ok(Edit::Bytes(kiid(&new_uuid()))),+        2 => match text {+            Some(t) => Ok(Edit::Bytes(rewrite(d, &|n2, _w2, _d2| Ok(if n2 == 5 { Edit::Bytes(t.as_bytes().to_vec()) } else { Edit::Keep }), &[])?)),+            None => Ok(Edit::Keep),+        },+        _ => Ok(Edit::Keep),+    }, &[])+}++/// One child of the template's definition for the new part, or None to leave it out (a user+/// field the new part does not carry).+fn clone_child(type_url: &str, bytes: &[u8], nets: &std::collections::HashMap<String, String>, props: &std::collections::HashMap<String, String>) -> Result<Option<Vec<u8>>, String> {+    let name = type_url.rsplit('.').next().unwrap_or("");+    match name {+        "Pad" => {+            let number = string_field(bytes, 3).unwrap_or_default();+            let net = nets.get(&number).cloned();+            let extra: Vec<(u32, Vec<u8>)> = net.iter().map(|n| {+                let mut nb = Vec::new();+                wire_message(&mut nb, 2, n.as_bytes());+                (4, nb)+            }).collect();+            Ok(Some(rewrite(bytes, &|n, _w, _d| Ok(match n {+                1 => Edit::Bytes(kiid(&new_uuid())),+                4 => Edit::Drop,+                _ => Edit::Keep,+            }), &extra)?))+        }+        "Field" => {+            // a user field: kept only when the new part has it, with the new part's value+            let fname = string_field(bytes, 2).unwrap_or_default();+            let Some(v) = props.get(&fname) else { return Ok(None) };+            Ok(Some(rewrite(bytes, &|n, _w, d| Ok(if n == 3 { Edit::Bytes(board_text(d, Some(v))?) } else { Edit::Keep }), &[])?))+        }+        "BoardText" => Ok(Some(board_text(bytes, None)?)),+        "BoardGraphicShape" => Ok(Some(rewrite(bytes, &|n, _w, _d| Ok(if n == 4 { Edit::Bytes(kiid(&new_uuid())) } else { Edit::Keep }), &[])?)),+        "Footprint3DModel" => Ok(Some(bytes.to_vec())),+        "BoardTextBox" | "Zone" | "Dimension" | "ReferenceImage" | "Barcode" => {+            Ok(Some(rewrite(bytes, &|n, _w, _d| Ok(if n == 1 { Edit::Bytes(kiid(&new_uuid())) } else { Edit::Keep }), &[])?))+        }+        // a group lists its members by id; the fresh ids no longer match it+        other => Err(format!("the template carries a {other} the clone cannot renumber")),+    }+}++/// The library id `(footprint "Lib:Name" ...)` names, split.+fn lib_id(n: &Node) -> Option<(String, String)> {+    let v = n.arg(0)?;+    let (lib, name) = v.split_once(':')?;+    Some((lib.to_string(), name.to_string()))+}++/// The template's library id and layer, to match a new block against.+pub fn template_key(fp: &FootprintItem) -> Option<(String, String, i32)> {+    let p = fp.proto();+    let id = p.definition.as_ref()?.id.as_ref()?;+    Some((id.library_nickname.clone(), id.entry_name.clone(), p.layer))+}++fn round4(v: f64) -> String {+    format!("{:.4}", v + 0.0)+}++/// What makes two placed copies of a footprint the same part, in board-file form: every pad+/// (number, kind, shape, local position, angle relative to the part, size, drill, layers, corner+/// ratio), the graphics per kind and layer, and the 3D models. Pose, ids, fields and nets differ.+fn definition_signature(fp: &Node) -> Vec<String> {+    let fp_angle = fp.child("at").and_then(|a| a.num(2)).unwrap_or(0.0);+    let mut sig = Vec::new();+    for c in fp.children() {+        match c.head() {+            Some("pad") => {+                let at = c.child("at");+                let rel = (at.and_then(|a| a.num(2)).unwrap_or(0.0) - fp_angle).rem_euclid(360.0);+                let num = |n: &str, i: usize| c.child(n).and_then(|x| x.num(i)).map(round4).unwrap_or_default();+                sig.push(format!(+                    "pad {} {} {} at {} {} rot {} size {} {} drill {} {} layers {} rr {}",+                    c.arg(0).unwrap_or_default(), c.arg(1).unwrap_or_default(), c.arg(2).unwrap_or_default(),+                    num("at", 0), num("at", 1), round4(rel), num("size", 0), num("size", 1),+                    c.child("drill").and_then(|d| d.arg(0)).unwrap_or_default(), c.child("drill").and_then(|d| d.arg(1)).unwrap_or_default(),+                    c.child("layers").map(|l| (0..8).filter_map(|i| l.arg(i)).collect::<Vec<_>>().join(",")).unwrap_or_default(),+                    // KiCad writes the default corner ratio (0.25) out; a library may leave it out+                    if c.arg(2).as_deref() == Some("roundrect") && c.child("roundrect_rratio").is_none() { round4(0.25) } else { num("roundrect_rratio", 0) },+                ));+            }+            Some(h @ ("fp_line" | "fp_arc" | "fp_circle" | "fp_rect" | "fp_poly" | "fp_curve" | "fp_text" | "fp_text_box")) => {+                sig.push(format!("{h} {}", c.child_value("layer").unwrap_or_default()));+            }+            Some("model") => sig.push(format!("model {}", c.arg(0).unwrap_or_default())),+            _ => {}+        }+    }+    sig.sort();+    sig+}++/// A footprint on the board to clone for `block`: same library footprint, same side, the same+/// definition (definition_signature), and not one this change removes (an update from the library+/// must not bring the old copy back). Err says why none fits, for the reply.+pub fn find_template(block: &Node, templates: &[FootprintItem], board_text: &str, removed: &[String]) -> Result<FootprintItem, String> {+    let (lib, name) = lib_id(block).ok_or("the new part names no library footprint")?;+    let layer = BoardLayerInfo::id_from_name(&block.child_value("layer").unwrap_or_else(|| "F.Cu".into())).ok_or("unknown layer")?;+    let want = definition_signature(block);+    let board = crate::schematic::Document::parse(board_text).map_err(|e| e.to_string())?;+    let same: Vec<&FootprintItem> = templates.iter().filter(|t| template_key(t) == Some((lib.clone(), name.clone(), layer))).collect();+    if same.is_empty() {+        return Err(format!("no {lib}:{name} on this side of the board to copy KiCad's own encoding from"));+    }+    let mut why = String::new();+    for t in same {+        let Some(id) = t.id() else { continue };+        if removed.iter().any(|r| r == id) {+            why = "the only copy is the footprint this edit replaces".into();+            continue;+        }+        let Some(n) = board.root.children().iter().find(|c| c.head() == Some("footprint") && c.child_value("uuid").as_deref() == Some(id)) else { continue };+        let have = definition_signature(n);+        if have == want {+            return Ok(t.clone());+        }+        let lib_only = want.iter().find(|l| !have.contains(l)).cloned().unwrap_or_default();+        let board_only = have.iter().find(|l| !want.contains(l)).cloned().unwrap_or_default();+        why = format!("{} on the board differs from the library footprint (library: {lib_only}; board: {board_only})", fp_reference(n));+    }+    Err(why)+}++fn fp_reference(n: &Node) -> String {+    n.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")).and_then(|c| c.arg(1)).unwrap_or_else(|| "its copy".into())+}++/// A new footprint (`block`, the text the file edit would write) built from `template`, KiCad's own+/// encoding of the same library footprint already on the board.+pub fn clone_footprint(template: &FootprintItem, block: &Node) -> Result<FootprintItem, String> {+    let at = block.child("at").ok_or("(at ..) missing")?;+    let (x, y, rot) = (nm(at.num(0).ok_or("bad x")?), nm(at.num(1).ok_or("bad y")?), at.num(2).unwrap_or(0.0));+    let reference = block.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")).and_then(|c| c.arg(1)).ok_or("the new part has no Reference")?;+    let mut props = std::collections::HashMap::new();+    for c in block.children().iter().filter(|c| c.head() == Some("property")) {+        if let (Some(k), Some(v)) = (c.arg(0), c.arg(1)) {+            props.insert(k, v);+        }+    }+    let mut nets = std::collections::HashMap::new();+    for pad in block.children().iter().filter(|c| c.head() == Some("pad")) {+        if let (Some(num), Some(net)) = (pad.arg(0), pad.child("net")) {+            if let Some(name) = net.arg(1).or_else(|| net.arg(0)).filter(|s| s.parse::<i64>().is_err()) {+                nets.insert(num, name);+            }+        }+    }+    let mut fp = template.clone();+    super::place_footprint_proto(&mut fp, (x, y, rot), &reference).map_err(|e| e.message)?;+    let p = fp.proto_mut();+    p.id = Some(Default::default());+    if let Some(k) = p.id.as_mut() {+        k.value = block.child_value("uuid").unwrap_or_else(new_uuid);+    }+    p.locked = 1;+    p.symbol_path = None;+    p.symbol_sheet_name.clear();+    p.symbol_sheet_filename.clear();+    for (field, key) in [(&mut p.reference_field, "Reference"), (&mut p.value_field, "Value"), (&mut p.datasheet_field, "Datasheet"), (&mut p.description_field, "Description")] {+        if let Some(bt) = field.as_mut().and_then(|f| f.text.as_mut()) {+            if let Some(k) = bt.id.as_mut() {+                k.value = new_uuid();+            }+            if let (Some(t), Some(v)) = (bt.text.as_mut(), props.get(key)) {+                t.text = v.clone();+            }+        }+    }+    if let Some(def) = p.definition.as_mut() {+        let mut items = Vec::with_capacity(def.items.len());+        for it in def.items.drain(..) {+            if let Some(v) = clone_child(&it.type_url, &it.value, &nets, &props)? {+                items.push(prost_types::Any { type_url: it.type_url, value: v });+            }+        }+        def.items = items;+    }+    Ok(fp)+}++/// Every block of a change as `CreateItems` payloads, or None when any block is of a kind this+/// module does not encode (the caller then keeps the text path for the whole change).+/// `template` finds a footprint on the board with the block's library id on the same side.+pub fn encode_blocks(blocks: &[String], template: &dyn Fn(&Node) -> Result<FootprintItem, String>) -> Encoded {+    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name);+    let mut out = Vec::with_capacity(blocks.len());+    for b in blocks {+        let Ok(n) = parse_node(b.trim()) else { return Encoded::TextPath("a block KiCad's text parser must read".into()) };+        match n.head() {+            Some("gr_line" | "gr_arc" | "gr_rect" | "gr_circle" | "gr_poly") => match graphic_bytes(&n, &layer_id) {+                Ok(v) => out.push(prost_types::Any { type_url: GRAPHIC_TYPE_URL.into(), value: v }),+                Err(e) => return Encoded::Failed(e),+            },+            Some("footprint") => {+                let t = match template(&n) {+                    Ok(t) => t,+                    Err(why) => return Encoded::TextPath(why),+                };+                match clone_footprint(&t, &n) {+                    Ok(fp) => out.push(EditablePcbItem::Footprint(fp).into_any()),+                    Err(e) => return Encoded::Failed(e),+                }+            }+            other => return Encoded::TextPath(format!("a {} the bridge cannot build through KiCad's API yet", other.unwrap_or("block"))),+        }+    }+    Encoded::Items(out)+}++/// What encode_blocks made of a change.+pub enum Encoded {+    /// every block, as CreateItems payloads+    Items(Vec<prost_types::Any>),+    /// a block this module cannot encode, and why: the change keeps the text path+    TextPath(String),+    /// a block that should have encoded and did not+    Failed(String),+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::placement::parse_fields;++    fn field<'a>(b: &'a [u8], n: u32) -> Option<&'a [u8]> {+        parse_fields(b).ok()?.into_iter().find(|f| f.number == n).map(|f| f.data)+    }++    #[test]+    fn a_filled_svg_polygon_on_copper_keeps_its_net_and_id() {+        let n = parse_node("(gr_poly (pts (xy 1 2) (xy 3 2) (arc (start 3 2) (mid 4 3) (end 3 4))) (stroke (width 0) (type solid)) (fill yes) (layer \"F.Cu\") (net \"GND\") (uuid \"abc\"))").unwrap();+        let b = graphic_bytes(&n, &|l| (l == "F.Cu").then_some(3)).unwrap();+        let shape = field(&b, 1).unwrap();+        let fill = field(field(shape, 3).unwrap(), 2).unwrap();+        assert_eq!(fill, &[8, 2], "filled");+        let poly = field(field(shape, 8).unwrap(), 1).unwrap();+        let line = field(poly, 1).unwrap();+        assert_eq!(parse_fields(line).unwrap().iter().filter(|f| f.number == 1).count(), 3, "two points and an arc");+        assert_eq!(field(field(&b, 3).unwrap(), 2).unwrap(), b"GND");+        assert_eq!(field(field(&b, 4).unwrap(), 1).unwrap(), b"abc");+    }++    #[test]+    fn outline_shapes_encode_and_unknown_blocks_keep_the_text_path() {+        let blocks: Vec<String> = ["(gr_line (start 0 0) (end 10 0) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"a\"))",+            "(gr_arc (start 10 0) (mid 12 2) (end 10 4) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"b\"))",+            "(gr_rect (start 0 0) (end 5 5) (stroke (width 0.1) (type solid)) (fill no) (layer \"Edge.Cuts\") (uuid \"c\"))",+            "(gr_circle (center 2 2) (end 3 2) (stroke (width 0.1) (type solid)) (fill no) (layer \"Edge.Cuts\") (uuid \"d\"))"]+            .iter().map(|s| s.to_string()).collect();+        let Encoded::Items(anys) = encode_blocks(&blocks, &|_| Err("none".into())) else { panic!("graphics encode") };+        assert_eq!(anys.len(), 4);+        assert!(anys.iter().all(|a| a.type_url == GRAPHIC_TYPE_URL));+        let mut with_fp = blocks.clone();+        with_fp.push("(footprint \"X:Y\" (at 1 1) (layer \"F.Cu\"))".into());+        assert!(matches!(encode_blocks(&with_fp, &|_| Err("none".into())), Encoded::TextPath(_)), "a footprint the board does not have keeps the text path");+    }++    #[test]+    fn a_library_block_matches_the_same_footprint_on_the_board_at_any_rotation() {+        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route");+        let board = std::fs::read_to_string(dir.join("route-fixture.kicad_pcb")).unwrap();+        let lib = std::fs::read_to_string(dir.join("Fixture.pretty/R_0603_1608Metric.kicad_mod")).unwrap();+        let doc = crate::schematic::Document::parse(&board).unwrap();+        let r1 = doc.root.children().iter().find(|c| c.head() == Some("footprint") && c.arg(0).as_deref() == Some("Fixture:R_0603_1608Metric")).unwrap();+        for rot in [0, 90, 180] {+            let c = crate::board_edit::add_footprint(&board, Some((&lib, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "rotation": rot, "reference": "R", "value": "4k7"})).unwrap();+            let block = parse_node(c.create[0].trim()).unwrap();+            assert_eq!(definition_signature(&block), definition_signature(r1), "rotation {rot}");+        }+        // a library that changed since the part was placed is not the board's copy+        let bigger = lib.replacen("(size 0.8 0.95)", "(size 0.9 0.95)", 1);+        assert_ne!(bigger, lib, "the fixture's pad size changed");+        let c = crate::board_edit::add_footprint(&board, Some((&bigger, "Fixture:R_0603_1608Metric")), &serde_json::json!({"x": 40, "y": 30, "reference": "R"})).unwrap();+        assert_ne!(definition_signature(&parse_node(c.create[0].trim()).unwrap()), definition_signature(r1));+    }+}+--- a/rust/crates/kicad-core/src/ipc/silk_fields.rs+++ b/rust/crates/kicad-core/src/ipc/silk_fields.rs@@ -1,355 +1,362 @@⋯ 244 unchanged lines ⋯                 out["preservationDiff"] = json!(diff);             }             out["otherBoardDataVerified"] = json!(preserved);+            let fps: Vec<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();+            let dropped = fields::dropped_closing_vertices(&text, &current, &fps);+            if !dropped.is_empty() {+                out["closingVerticesDroppedByKiCad"] = json!(dropped);+                out["_libraryParityHint"] = json!(fields::CLOSING_VERTEX_HINT);+            }             if !preserved {                 out["success"] = json!(false);                 out["postCommitPreservationError"]=json!("Board data outside the requested fields changed; this may also indicate concurrent editing");⋯ 102 unchanged lines ⋯         assert!(apply_geometry(bold, &e).is_err());     } }+--- a/rust/crates/kicad-core/src/ipc.rs+++ b/rust/crates/kicad-core/src/ipc.rs@@ -1,2240 +1,2322 @@⋯ 45 unchanged lines ⋯ mod route_edit; pub use route_edit::{apply_track_change, copy_routing, modify_track, route_diff_pair}; mod board_live;-pub use board_live::*;--type RResult<T> = Result<T, RoutingError>;--/// What the verbs know that the operations need: where kicad-cli is (for the DRC-/// snapshot) and KiCad's config dir (to say `api_server_off` honestly).-#[derive(Clone, Debug, Default)]-pub struct Ctx {-    pub kicad_cli: Option<PathBuf>,-    pub config_dir: Option<PathBuf>,-}--// ----------------------------------------------------------------------------// Errors and the response shape-// -----------------------------------------------------------------------------const HINT: &str = "Read kicad_routing_state with the exact filePath and socketPath. Enable Preferences > Plugins > Enable IPC API server in KiCad 10.0.1+ (kicad_ipc_api {\"enable\":true} writes the switch; KiCad must be closed and relaunched). After a mutation timeout inspect the live board; never blindly replay it.";--/// The Python `_error`: `success:false`, `errorCode`, `error`, every detail key, `_hint`.-pub fn error_response(e: &RoutingError) -> Value {-    let mut out = Map::new();-    out.insert("success".into(), json!(false));-    out.insert("errorCode".into(), json!(e.code));-    out.insert("error".into(), json!(e.message));-    for (k, v) in &e.detail {-        out.insert(k.clone(), v.clone());-    }-    out.entry("_hint").or_insert(json!(HINT));-    Value::Object(out)-}--/// Map a crate error to the routing contract. Timeouts become `ipc_timeout` (nothing of-/// ours is still running; KiCad may answer nobody later), transport failures-/// `ipc_unavailable`, no open board `no_board_open`, an ambiguous board `board_mismatch`,-/// and any KiCad status or decode failure `ipc_error`.-fn map_err(e: KiCadError) -> RoutingError {-    match e {-        KiCadError::Timeout { timeout } => RoutingError::new("ipc_timeout", format!("KiCad did not answer within {} ms; its UI thread may be blocked by a dialog or a long operation", timeout.as_millis()))-            .with("ipcTimeoutMs", json!(timeout.as_millis() as u64))-            .with("stillRunning", json!(false))-            .with("mutated", json!(false)),-        KiCadError::SocketUnavailable { socket_uri } => RoutingError::new("ipc_unavailable", format!("KiCad IPC socket not available at {socket_uri}; open the board in the PCB editor with the API server enabled")).with("socket", json!(socket_uri)),-        KiCadError::Connection { socket_uri, reason } => RoutingError::new("ipc_unavailable", format!("connection failed for {socket_uri}: {reason}")).with("socket", json!(socket_uri)),-        KiCadError::TransportSend { .. } | KiCadError::TransportReceive { .. } | KiCadError::TransportClosed | KiCadError::BlockingRuntimeClosed | KiCadError::RuntimeJoin(_) => RoutingError::new("ipc_unavailable", e.to_string()),-        KiCadError::BoardNotOpen => RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])),-        KiCadError::AmbiguousBoardSelection { boards } => RoutingError::new("board_mismatch", "This IPC endpoint exposes more than one board").with("openBoards", json!(boards)),-        // KiCad runs ONE API server per machine (the first KiCad process takes api.sock; a-        // later standalone pcbnew.exe gets none, measured on arav-rog 2026-09-11). When that-        // first process is the project manager with no PCB frame, board commands come back-        // AS_UNHANDLED. Name the situation instead of a bare status code.-        KiCadError::ApiStatus { code, message } if code == "AS_UNHANDLED" => RoutingError::new(-            "no_pcb_frame",-            "The KiCad process that owns the API socket has no PCB editor frame (a project manager alone, or the board is open in a second, standalone pcbnew.exe that got no API server)",-        )-        .with("apiStatus", json!({"code": code, "message": message}))-        .with("_hint", json!("KiCad serves one API socket per machine and the first KiCad process wins. Either close the project manager and open the board with kicad_open_board (standalone pcbnew then owns the socket), or open the board from within the project manager (kicad_launch with the project, then the PCB Editor button) so the frame lives in the process that has the socket. Then read kicad_routing_state again.")),-        other => RoutingError::new("ipc_error", other.to_string()),-    }-}--/// A failure inside a commit: the message plus whether it was a deadline, so the-/// transaction can say `mutation_outcome_unknown` with `stillRunning`.-#[derive(Clone, Debug, PartialEq)]-pub struct IpcFailure {-    pub message: String,-    pub timed_out: bool,-}--impl From<KiCadError> for IpcFailure {-    fn from(e: KiCadError) -> Self {-        IpcFailure { timed_out: matches!(e, KiCadError::Timeout { .. }), message: e.to_string() }-    }-}--impl From<String> for IpcFailure {-    fn from(message: String) -> Self {-        IpcFailure { message, timed_out: false }-    }-}--// ----------------------------------------------------------------------------// Pure helpers (tested without KiCad)-// -----------------------------------------------------------------------------/// The board revision: SHA-256 of the editor's own serialisation of the board, the way-/// `live_routing._snapshot` derives it. Any edit (ours or the user's) changes it.-/// Wiki #104: what to do about a silk refusal. A text_height / text_thickness finding comes from-/// the BOARD's own minimum, not from the bridge: say where that minimum lives.-pub fn silk_drc_hint(added: &[Value]) -> String {-    let types: Vec<&str> = added.iter().filter_map(|v| v.get("type").and_then(Value::as_str)).collect();-    if types.iter().any(|t| *t == "text_height" || *t == "text_thickness") {-        "Refused by the BOARD's minimum text size, not by the bridge: KiCad's DRC finds the new text smaller than Board Setup > Design Rules > Constraints (Minimum text height / thickness), or than a (constraint text_height (min ...)) rule in the board's .kicad_dru. Use text at or above that minimum, or lower the board minimum first if smaller text is intended; nothing was changed.".to_string()-    } else {-        "Each violation names the rule and the items involved: move the candidate (a dry run per spot is cheap), then submit again; nothing was changed.".to_string()-    }-}--pub fn revision_of(text: &str) -> String {-    crate::pcm::sha256_hex(text.as_bytes())-}--/// `_revision`: `expectedRevision` must match the live board (`stale_board`).-pub fn check_revision(args: &Value, revision: &str) -> RResult<()> {-    if args.get("expectedRevision").and_then(Value::as_str) != Some(revision) {-        return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board").with("currentRevision", json!(revision)).with("mutated", json!(false)));-    }-    Ok(())-}--/// `_path`: an absolute `.kicad_pcb` path, normalised for comparison (forward slashes,-/// no `\\?\` prefix, case folded, resolved through the filesystem when it exists).-pub fn normalize_board_path(value: Option<&Value>) -> RResult<String> {-    let Some(s) = value.and_then(Value::as_str) else {-        return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));-    };-    let looks_absolute = Path::new(s).is_absolute() || s.as_bytes().get(1) == Some(&b':') && s.as_bytes().get(2).map(|c| *c == b'/' || *c == b'\\').unwrap_or(false);-    if !looks_absolute {-        return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));-    }-    if !s.to_ascii_lowercase().ends_with(".kicad_pcb") {-        return Err(RoutingError::new("invalid_board_path", "filePath must name a .kicad_pcb"));-    }-    Ok(normalize_existing(s))-}--fn normalize_existing(s: &str) -> String {-    let resolved = std::fs::canonicalize(s).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| s.to_string());-    let resolved = resolved.replace('\\', "/");-    let resolved = resolved.strip_prefix("//?/").unwrap_or(&resolved).to_string();-    resolved.to_lowercase()-}--/// The absolute paths of every board open in the PCB editor that answers on the IPC-/// socket, or Err when no editor answers (API off, schematic-only, KiCad not running).-/// Wiki #122: the window title shows only the file name, so two boards with the same-/// basename in different folders are told apart by asking KiCad, never by the title.-pub fn open_board_paths(timeout: std::time::Duration) -> Result<Vec<String>, String> {-    let name = format!("adom-openpaths-{}", std::process::id());-    let client = KiCadClientBlocking::builder().client_name(name).timeout(timeout).connect().map_err(|e| format!("{e:?}"))?;-    let docs = client.get_open_documents(DocumentType::Pcb).map_err(|e| format!("{e:?}"))?;-    Ok(docs.iter().map(document_path).collect())-}--/// True when two board paths name the same file (case, separators and symlinks folded).-pub fn same_board_path(a: &str, b: &str) -> bool {-    normalize_existing(a) == normalize_existing(b)-}--/// The absolute path of an open document (`document_path` in the Python).-pub fn document_path(d: &DocumentSpecifier) -> String {-    let filename = d.board_filename.clone().unwrap_or_default();-    let p = Path::new(&filename);-    let absolute = p.is_absolute() || filename.as_bytes().get(1) == Some(&b':');-    if absolute {-        filename-    } else {-        match &d.project.path {-            Some(dir) => dir.join(&filename).to_string_lossy().to_string(),-            None => filename,-        }-    }-}--/// `_report_limits`: KiCad 10.0.3 DRC_ENGINE caps 199 markers per type, 499 for-/// clearance and unconnected items. At the boundary completeness is unknown.-pub fn report_limits(violations: &[Value], unconnected_len: usize) -> Value {-    let mut counts: Vec<(String, usize)> = Vec::new();-    for v in violations {-        let kind = match v.get("type") {-            Some(Value::String(s)) => s.clone(),-            Some(Value::Null) | None => "None".to_string(),-            Some(other) => other.to_string(),-        };-        match counts.iter_mut().find(|(k, _)| *k == kind) {-            Some(c) => c.1 += 1,-            None => counts.push((kind, 1)),-        }-    }-    let mut capped: Vec<String> = counts.into_iter().filter(|(k, n)| *n >= if k == "clearance" { 499 } else { 199 }).map(|(k, _)| k).collect();-    capped.sort();-    json!({-        "unconnectedReportMayBeTruncated": unconnected_len >= 499,-        "unconnectedCountIsLowerBound": unconnected_len >= 499,-        "violationReportMayBeTruncated": !capped.is_empty(),-        "possiblyTruncatedViolationTypes": capped,-    })-}--fn fingerprint(v: &Value) -> String {-    let mut positions: Vec<String> = v.get("items").and_then(Value::as_array).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null).to_string()).collect()).unwrap_or_default();-    positions.sort();-    json!([v.get("type"), v.get("severity"), v.get("description"), positions]).to_string()-}--/// `_new_errors`: the error-severity violations in `after` that `before` does not-/// account for, one to one (an existing error never masks a second identical one).-/// Refuses to certify a delta when either list may be capped (`drc_incomplete`).-pub fn new_errors(before: &Value, after: &Value) -> RResult<Vec<Value>> {-    let truncated = |r: &Value| r.get("violationReportMayBeTruncated").and_then(Value::as_bool).unwrap_or(false);-    if truncated(before) || truncated(after) {-        return Err(RoutingError::new("drc_incomplete", "DRC violation lists may be capped; cannot certify the error delta. No live copper was changed").with("mutated", json!(false)));-    }-    let errors = |r: &Value| -> Vec<Value> { r.get("violations").and_then(Value::as_array).map(|a| a.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).cloned().collect()).unwrap_or_default() };-    let mut existing: Vec<(String, usize)> = Vec::new();-    for v in errors(before) {-        let key = fingerprint(&v);-        match existing.iter_mut().find(|(k, _)| *k == key) {-            Some(c) => c.1 += 1,-            None => existing.push((key, 1)),-        }-    }-    let mut added = Vec::new();-    for v in errors(after) {-        let key = fingerprint(&v);-        match existing.iter_mut().find(|(k, n)| *k == key && *n > 0) {-            Some(c) => c.1 -= 1,-            None => added.push(v),-        }-    }-    Ok(added)-}--/// Copy a KiCad library table, replacing ${KIPRJMOD} with the original project folder so-/// the copy resolves the same libraries from wherever it is read.-pub fn copy_lib_table(src: &Path, dst: &Path, project_dir: &Path) -> std::io::Result<()> {-    let text = std::fs::read_to_string(src)?;-    let dir = project_dir.to_string_lossy().replace('\\', "/");-    let dir = dir.trim_end_matches('/');-    std::fs::write(dst, text.replace("${KIPRJMOD}", dir))-}--/// `_drc`: KiCad DRC on a disposable snapshot of the live board text, with the project's-/// own rules copied alongside and zones refilled so connectivity reflects the proposed-/// copper. The live document and the source file are never touched.-pub fn drc_snapshot(kicad_cli: Option<&Path>, source: &Path, text: &str) -> RResult<Value> {-    let Some(exe) = kicad_cli.filter(|p| p.is_file()) else {-        return Err(RoutingError::new("drc_unavailable", "kicad-cli is required for routing validation"));-    };-    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);-    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);-    let tmp = std::env::temp_dir().join(format!("adom-routing-drc-{}-{n}", std::process::id()));-    let result = drc_in(exe, source, text, &tmp);-    let _ = std::fs::remove_dir_all(&tmp);-    result-}--fn drc_in(exe: &Path, source: &Path, text: &str, tmp: &Path) -> RResult<Value> {-    std::fs::create_dir_all(tmp).map_err(|e| RoutingError::new("drc_failed", format!("cannot create snapshot dir: {e}")))?;-    let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "board.kicad_pcb".into());-    let candidate = tmp.join(&name);-    std::fs::write(&candidate, text).map_err(|e| RoutingError::new("drc_failed", format!("cannot write snapshot: {e}")))?;-    // Preserve the actual project's rules and exclusions; never save/revert the live-    // document merely to get a headless DRC snapshot.-    let mut copied: Vec<String> = Vec::new();-    for ext in ["kicad_pro", "kicad_dru"] {-        let sibling = source.with_extension(ext);-        if sibling.exists() {-            std::fs::copy(&sibling, candidate.with_extension(ext)).map_err(|e|RoutingError::new("drc_rules_copy_failed",format!("Cannot preserve project rules {}: {e}",sibling.display())).with("mutated",json!(false)))?;-            copied.push(sibling.to_string_lossy().to_string());-        }-    }-    // Wiki #124: the project's library tables travel with the snapshot too, with ${KIPRJMOD}-    // pointed back at the real project folder, so project-local footprint libraries resolve-    // and DRC does not report them missing.-    if let Some(project_dir) = source.parent() {-        for table in ["fp-lib-table", "sym-lib-table"] {-            let src = project_dir.join(table);-            if src.is_file() {-                copy_lib_table(&src, &tmp.join(table), project_dir).map_err(|e| RoutingError::new("drc_rules_copy_failed", format!("Cannot preserve library table {}: {e}", src.display())).with("mutated", json!(false)))?;-                copied.push(src.to_string_lossy().to_string());-            }-        }-    }-    let report = tmp.join("drc.json");-    let report_s = report.to_string_lossy().to_string();-    let candidate_s = candidate.to_string_lossy().to_string();-    let r = cli::run(exe, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--refill-zones", "--output", &report_s, &candidate_s], DRC_TIMEOUT);-    if r.timed_out {-        return Err(RoutingError::new("drc_timeout", "kicad-cli DRC on the snapshot timed out").with("cliElapsedMs", json!(r.elapsed_ms)));-    }-    let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());-    let Some(result) = parsed.filter(|_| r.exit_code == 0) else {-        let msg = if !r.stderr.trim().is_empty() { r.stderr.trim().to_string() } else if !r.stdout.trim().is_empty() { r.stdout.trim().to_string() } else { "No DRC report".into() };-        return Err(RoutingError::new("drc_failed", msg).with("cliExitCode", json!(r.exit_code)));-    };-    Ok(drc_result(&result, copied))-}--/// Shape the raw kicad-cli report the way `_drc` returns it, plus `summary`: the same-/// `kicad_run_drc` summary (which counts unconnected items among its errors).-pub fn drc_result(report: &Value, project_rules_copied: Vec<String>) -> Value {-    let violations: Vec<Value> = report.get("violations").and_then(Value::as_array).cloned().unwrap_or_default();-    let unconnected: Vec<Value> = report.get("unconnected_items").and_then(Value::as_array).cloned().unwrap_or_default();-    let sev = |s: &str| violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some(s)).count();-    let mut out = json!({-        "errors": sev("error"),-        "warnings": sev("warning"),-        "unconnected": unconnected.len(),-        "violations": violations,-        "unconnectedItems": unconnected,-        "clean": violations.is_empty() && unconnected.is_empty(),-        "source": "live-editor-snapshot",-        "projectRulesCopied": project_rules_copied,-        "zonesRefilled": true,-        "summary": cli::summarize_report(report),-    });-    if let (Some(o), Some(limits)) = (out.as_object_mut(), report_limits(&violations, unconnected.len()).as_object()) {-        for (k, v) in limits {-            o.insert(k.clone(), v.clone());-        }-    }-    out-}--/// A KiCad commit session, abstracted so the transaction logic is testable without KiCad.-pub trait CommitTarget {-    fn begin(&self) -> Result<String, IpcFailure>;-    fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure>;-    fn drop_commit(&self, id: &str) -> Result<(), IpcFailure>;-}--/// `_transaction`: begin, run the operation, push. On any failure drop the commit and say-/// exactly what is known: `mutation_rolled_back` (nothing landed), or-/// `mutation_outcome_unknown` (`mutated: null`) when the push or the rollback itself-/// failed, with `stillRunning: true` when a deadline expired mid-commit.-pub fn transaction<T>(target: &dyn CommitTarget, message: &str, op: impl FnOnce() -> Result<T, IpcFailure>) -> RResult<T> {-    let id = target.begin().map_err(|e| RoutingError::new("mutation_rolled_back", e.message).with("mutated", json!(false)))?;-    let (failure, pushing) = match op() {-        Ok(result) => match target.push(&id, message) {-            Ok(()) => return Ok(result),-            Err(e) => (e, true),-        },-        Err(e) => (e, false),-    };-    let base = |code: &str| {-        let mut err = RoutingError::new(code, failure.message.clone());-        if failure.timed_out {-            err = err.with("ipcTimeout", json!(true));-        }-        err-    };-    match target.drop_commit(&id) {-        Err(rollback) => Err(base("mutation_outcome_unknown").with("rollbackError", json!(rollback.message)).with("mutated", Value::Null).with("stillRunning", json!(failure.timed_out || rollback.timed_out))),-        Ok(()) if pushing => Err(base("mutation_outcome_unknown").with("mutated", Value::Null).with("rollbackAttempted", json!(true)).with("stillRunning", json!(failure.timed_out))),-        Ok(()) => Err(base("mutation_rolled_back").with("mutated", json!(false))),-    }-}--// ----------------------------------------------------------------------------// Connection-// -----------------------------------------------------------------------------/// One connection to the PCB editor showing the requested board.-pub struct Session {-    client: KiCadClientBlocking,-    pub file_path: String,-    pub version: VersionInfo,-    pub socket: String,-    pub timeout: Duration,-}--impl CommitTarget for Session {-    fn begin(&self) -> Result<String, IpcFailure> {-        Ok(self.client.begin_commit()?.id)-    }-    fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure> {-        Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Commit, message)?)-    }-    fn drop_commit(&self, id: &str) -> Result<(), IpcFailure> {-        Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Drop, "")?)-    }-}--fn timeout_of(args: &Value) -> Duration {-    let ms = args.get("ipcTimeoutMs").and_then(Value::as_u64).unwrap_or(DEFAULT_TIMEOUT_MS).clamp(1000, 60_000);-    Duration::from_millis(ms)-}--/// `_connect`: validate the arguments, connect with a deadline, probe responsiveness-/// (`GetVersion`), require KiCad 10.0.1+, and require that the endpoint exposes exactly-/// the requested board.-pub fn connect(ctx: &Ctx, args: &Value) -> RResult<Session> {-    let wanted = normalize_board_path(args.get("filePath"))?;-    let file_path = args.get("filePath").and_then(Value::as_str).unwrap_or_default().to_string();-    let socket = match args.get("socketPath") {-        None | Some(Value::Null) => None,-        Some(Value::String(s)) if s.starts_with("ipc://") => Some(s.clone()),-        Some(_) => return Err(RoutingError::new("invalid_socket", "socketPath must be a local ipc:// socket")),-    };-    let timeout = timeout_of(args);-    let name = format!("adom-routing-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));-    let mut builder = KiCadClientBlocking::builder().client_name(name).timeout(timeout);-    if let Some(s) = &socket {-        builder = builder.socket_path(s.clone());-    }-    let client = match builder.connect() {-        Ok(c) => c,-        Err(e) => {-            let mut err = map_err(e);-            if err.code == "ipc_unavailable" {-                if crate::kicad_common::api_server_enabled(ctx.config_dir.as_deref()) == Some(false) {-                    err.code = "api_server_off".into();-                    err.message = format!("{}. KiCad's IPC API server switch is off in kicad_common.json", err.message);-                    err = err.with("_hint", json!("Save work and kicad_close, then kicad_ipc_api {\"enable\":true} (backup kept), then relaunch KiCad and open the board in the PCB editor. A running KiCad rewrites the file on exit, so close it first."));-                } else {-                    err = err.with("_hint", json!("Open the board in the PCB editor (KiCad 10.0.1+) with Preferences > Plugins > Enable IPC API server on. kicad_ipc_api {} reports the switch; the socket appears only while the PCB editor is running."));-                }-            }-            return Err(err);-        }-    };-    let socket_uri = client.socket_uri().to_string();-    // Responsiveness probe: the first round trip with the deadline.-    let version = client.get_version().map_err(map_err)?;-    if (version.major, version.minor, version.patch) < (10, 0, 1) {-        return Err(RoutingError::new("unsupported_kicad", "Live routing requires KiCad 10.0.1 or newer").with("kicadVersion", json!(version.full_version)));-    }-    let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;-    let open: Vec<String> = docs.iter().map(document_path).collect();-    if docs.is_empty() {-        return Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])).with("kicadVersion", json!(version.full_version)));-    }-    let matches = open.iter().filter(|p| normalize_existing(p) == wanted).count();-    if matches != 1 {-        return Err(RoutingError::new("board_mismatch", "This IPC endpoint does not expose exactly the requested board").with("openBoards", json!(open)).with("kicadVersion", json!(version.full_version)));-    }-    Ok(Session { client, file_path, version, socket: socket_uri, timeout })-}--impl Session {-    /// `_snapshot`: the editor's own serialisation, its parse, and the revision.-    pub fn snapshot(&self) -> RResult<(String, Board, String)> {-        let text = self.client.get_board_as_string().map_err(map_err)?;-        let board = pcb::parse_pcb_text(&text, &self.file_path).map_err(|e| RoutingError::new("board_unparsable", format!("could not parse the live board: {e}")))?;-        let revision = revision_of(&text);-        Ok((text, board, revision))-    }--    fn revision(&self) -> RResult<String> {-        Ok(revision_of(&self.client.get_board_as_string().map_err(map_err)?))-    }--    fn items_of(&self, codes: &[PcbObjectTypeCode]) -> RResult<Vec<PcbItem>> {-        self.client.get_items_by_type_codes(codes.iter().map(|c| c.code).collect()).map_err(map_err)-    }--    pub fn source_path(&self) -> PathBuf {-        PathBuf::from(&self.file_path)-    }-}--fn item_id(item: &PcbItem) -> Option<&str> {-    match item {-        PcbItem::Track(t) => t.id.as_deref(),-        PcbItem::Arc(a) => a.id.as_deref(),-        PcbItem::Via(v) => v.id.as_deref(),-        PcbItem::Footprint(f) => f.id.as_deref(),-        PcbItem::Pad(p) => p.id.as_deref(),-        PcbItem::BoardGraphicShape(s) => s.id.as_deref(),-        PcbItem::BoardText(t) => t.id.as_deref(),-        PcbItem::BoardTextBox(t) => t.id.as_deref(),-        PcbItem::Zone(z) => z.id.as_deref(),-        PcbItem::Dimension(d) => d.id.as_deref(),-        PcbItem::ReferenceImage(r) => r.id.as_deref(),-        PcbItem::Barcode(b) => b.id.as_deref(),-        PcbItem::Group(g) => g.id.as_deref(),-        PcbItem::Field(_) | PcbItem::Unknown(_) => None,-    }-}--fn item_locked(item: &PcbItem) -> bool {-    match item {-        PcbItem::Track(t) => t.locked.is_locked(),-        PcbItem::Arc(a) => a.locked.is_locked(),-        PcbItem::Via(v) => v.locked.is_locked(),-        _ => false,-    }-}--// ----------------------------------------------------------------------------// Items-// -----------------------------------------------------------------------------fn nm(mm: f64) -> i64 {-    (mm * 1_000_000.0).round() as i64-}--fn vector(p: [f64; 2]) -> Vector2Nm {-    Vector2Nm { x_nm: nm(p[0]), y_nm: nm(p[1]) }-}--/// `_items`: the plan as protobuf Track and Via items on the live net. Layer ids come-/// from the crate's canonical name table; via padstacks are what kipy builds (through,-/// normal padstack, one circular copper entry, F.Cu to B.Cu drill).-pub fn build_items(plan: &Plan, net: &BoardNet) -> RResult<Vec<EditablePcbItem>> {-    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));-    let f_cu = layer_id("F.Cu")?;-    let b_cu = layer_id("B.Cu")?;-    let mut items = Vec::with_capacity(plan.segments.len() + plan.vias.len());-    for seg in &plan.segments {-        let mut track = TrackItem::from_proto(Default::default());-        track.set_start_nm(vector(seg.start));-        track.set_end_nm(vector(seg.end));-        track.set_layer_id(layer_id(&seg.layer)?);-        let p = track.proto_mut();-        p.width = Some(Default::default());-        if let Some(w) = p.width.as_mut() {-            w.value_nm = nm(seg.width);-        }-        p.net = Some(Default::default());-        if let Some(n) = p.net.as_mut() {-            n.name = net.name.clone();-            n.code = Some(Default::default());-            if let Some(c) = n.code.as_mut() {-                c.value = net.code;-            }-        }-        items.push(EditablePcbItem::Track(track));-    }-    for v in &plan.vias {-        let mut via = ViaItem::from_proto(Default::default());-        via.set_position_nm(vector(v.at));-        let p = via.proto_mut();-        p.r#type = 1; // ViaType::VT_THROUGH-        p.net = Some(Default::default());-        if let Some(n) = p.net.as_mut() {-            n.name = net.name.clone();-            n.code = Some(Default::default());-            if let Some(c) = n.code.as_mut() {-                c.value = net.code;-            }-        }-        p.pad_stack = Some(Default::default());-        if let Some(ps) = p.pad_stack.as_mut() {-            ps.r#type = 1; // PadStackType::PST_NORMAL-            ps.copper_layers.push(Default::default());-            if let Some(cl) = ps.copper_layers.last_mut() {-                cl.layer = f_cu;-                cl.shape = 1; // PadStackShape::PSS_CIRCLE-                cl.size = Some(Default::default());-                if let Some(s) = cl.size.as_mut() {-                    s.x_nm = nm(v.size);-                    s.y_nm = nm(v.size);-                }-            }-            ps.drill = Some(Default::default());-            if let Some(d) = ps.drill.as_mut() {-                d.start_layer = f_cu;-                d.end_layer = b_cu;-                d.diameter = Some(Default::default());-                if let Some(s) = d.diameter.as_mut() {-                    s.x_nm = nm(v.drill);-                    s.y_nm = nm(v.drill);-                }-            }-        }-        items.push(EditablePcbItem::Via(via));-    }-    Ok(items)-}--// ----------------------------------------------------------------------------// The four operations-// ------------------------------------------------------------------------------/// `kicad_select_net`: select every copper item of one net on the live board (tracks, arcs, vias,-/// zones and pads), so the editor shows the net's pours and traces together, highlighted, on-/// every layer; no cursor, no hotkey. Returns the ids selected per kind.-pub fn select_net(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let net = args.get("net").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("missing_arg", "Pass net: the net name as KiCad writes it (e.g. \"+VBAT\", \"/DRV_SHA\")"))?;-    let items = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via(), PcbObjectTypeCode::new_pad()])?;-    let mut ids: Vec<String> = Vec::new();-    let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default();-    let mut nets_seen: std::collections::BTreeSet<String> = Default::default();-    for it in &items {-        let (kind, item_net, id): (&str, Option<String>, Option<String>) = match it {-            PcbItem::Track(t) => ("tracks", t.net.as_ref().map(|n| n.name.clone()), t.id.clone()),-            PcbItem::Arc(a) => ("tracks", a.net.as_ref().map(|n| n.name.clone()), a.id.clone()),-            PcbItem::Via(v) => ("vias", v.net.as_ref().map(|n| n.name.clone()), v.id.clone()),-            PcbItem::Pad(p) => ("pads", p.net.as_ref().map(|n| n.name.clone()), p.id.clone()),-            _ => continue,-        };-        if let Some(n) = &item_net { if !n.is_empty() { nets_seen.insert(n.clone()); } }-        if item_net.as_deref() == Some(net) {-            if let Some(id) = id { ids.push(id); *kinds.entry(kind).or_insert(0) += 1; }-        }-    }-    // zones: the model has no net field; the raw proto does-    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;-    for a in &raws {-        if let Ok(z) = zones::decode_zone(&a.value) {-            if !z.net.is_empty() { nets_seen.insert(z.net.clone()); }-            if z.net == net && !z.rule_area { ids.push(z.id.clone()); *kinds.entry("zones").or_insert(0) += 1; }-        }-    }-    if ids.is_empty() {-        return Err(RoutingError::new("unknown_net", format!("No copper item on the live board carries net '{net}'")).with("netsOnBoard", json!(nets_seen.into_iter().take(200).collect::<Vec<_>>())));-    }-    if args.get("add").and_then(Value::as_bool) != Some(true) {-        s.client.clear_selection().map_err(map_err)?;-    }-    let count = ids.len();-    s.client.add_to_selection(ids.clone()).map_err(map_err)?;-    Ok(json!({-        "success": true, "mutated": false, "source": "live-editor", "net": net, "selected": count, "byKind": kinds, "itemIds": ids,-        "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_send_key {hwnd, menu:\"Zoom to Selected Objects\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",-    }))-}--/// `kicad_select_clear`: clear the live editor's selection.-pub fn select_clear(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    s.client.clear_selection().map_err(map_err)?;-    Ok(json!({"success": true, "mutated": false, "source": "live-editor", "_hint": "Selection cleared."}))-}--/// `kicad_routing_state`: pads, copper, revision and KiCad-measured connectivity.-pub fn routing_state(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (_text, data, revision) = s.snapshot()?;-    let pads = pcb::pads(&data);-    let front_pads = pcb::back_footprints_with_front_pads(&data);-    let live_pads: Vec<(String, String)> = s-        .items_of(&[PcbObjectTypeCode::new_pad()])?-        .into_iter()-        .filter_map(|i| match i {-            PcbItem::Pad(p) => Some((p.id?, p.net.map(|n| n.name).unwrap_or_default())),-            _ => None,-        })-        .collect();-    let mut names: Vec<String> = data.nets.iter().map(|n| n.name.clone()).filter(|n| !n.is_empty()).collect();-    names.sort();-    names.dedup();-    let mut nets = Vec::new();-    for name in names {-        let net_pads: Vec<&str> = live_pads.iter().filter(|(_, n)| *n == name).map(|(id, _)| id.as_str()).collect();-        let mut remaining: Vec<&str> = net_pads.clone();-        let mut groups: Vec<Vec<&str>> = Vec::new();-        while let Some(seed) = remaining.first().copied() {-            let mut seen: HashSet<String> = HashSet::new();-            seen.insert(seed.to_string());-            let mut frontier: Vec<String> = vec![seed.to_string()];-            while !frontier.is_empty() {-                let neighbors = s.client.get_connected_items(frontier.clone(), Vec::new()).map_err(map_err)?;-                frontier = neighbors.iter().filter_map(item_id).filter(|id| !seen.contains(*id)).map(str::to_string).collect();-                seen.extend(frontier.iter().cloned());-                if seen.len() > MAX_WALK {-                    return Err(RoutingError::new("board_too_large", format!("Connectivity walk exceeds {MAX_WALK} items")));-                }-            }-            let group: Vec<&str> = remaining.iter().copied().filter(|id| seen.contains(*id)).collect();-            remaining.retain(|id| !seen.contains(*id));-            groups.push(group);-        }-        nets.push(json!({-            "name": name, "padCount": net_pads.len(), "padGroups": groups,-            "connected": groups.len() <= 1, "missingConnections": groups.len().saturating_sub(1),-        }));-    }-    let current = s.revision()?;-    if current != revision {-        return Err(RoutingError::new("stale_board", "Board changed during connectivity inspection; read again").with("currentRevision", json!(current)));-    }-    let remaining: Vec<Value> = nets.iter().filter(|n| n["connected"] == json!(false)).map(|n| n["name"].clone()).collect();-    let mut out = json!({-        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",-        "kicadVersion": s.version.full_version, "socket": s.socket,-        "pads": pads, "nets": nets, "netsRemaining": remaining,-        "segments": data.segments, "vias": data.vias, "copperLayers": pcb::copper_layers(&data),-        "_hint": "Use this revision as expectedRevision in kicad_route_net. Coordinates are mm, +y down. Connectivity is measured by KiCad; kicad_routing_validate checks clearance and unconnected items.",-    });-    if !front_pads.is_empty() {-        out["backFootprintsWithFrontPads"] = json!(front_pads);-        out["_frontPadsHint"] = json!(pcb::FRONT_PADS_HINT);-    }-    Ok(out)-}--/// The one board open in the PCB editor, when the caller named none (the standard's routing_state-/// reads the current board, as altium_routing_state does).-fn with_open_board(args: &Value) -> RResult<Value> {-    if args.get("filePath").and_then(Value::as_str).is_some() {-        return Ok(args.clone());-    }-    let mut builder = KiCadClientBlocking::builder().client_name(format!("adom-model-{}", std::process::id())).timeout(timeout_of(args));-    if let Some(sock) = args.get("socketPath").and_then(Value::as_str) {-        builder = builder.socket_path(sock.to_string());-    }-    let client = builder.connect().map_err(map_err)?;-    let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;-    let open: Vec<String> = docs.iter().map(document_path).collect();-    match open.as_slice() {-        [one] => {-            let mut a = args.clone();-            a["filePath"] = json!(one);-            Ok(a)-        }-        [] => Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([]))),-        _ => Err(RoutingError::new("board_mismatch", "More than one board is open: name one with filePath").with("openBoards", json!(open))),-    }-}--/// The live board's text as the editor serialises it, with its path and KiCad's version: what the-/// standard verbs (model, stack-up, pours) build from. `filePath` may be left out when one board is open.-pub fn board_text(_ctx: &Ctx, args: &Value) -> RResult<(String, String, String)> {-    let args = with_open_board(args)?;-    let s = connect(_ctx, &args)?;-    let text = s.client.get_board_as_string().map_err(map_err)?;-    Ok((text, s.file_path.clone(), s.version.full_version.clone()))-}--/// A standard revision (16 hex digits, `board_model::revision_of_text`) as opposed to the editor's-/// text hash (64) the edit verbs compare.-pub fn is_standard_revision(r: &str) -> bool {-    r.len() == 16 && r.bytes().all(|b| b.is_ascii_hexdigit())-}--/// Let an edit verb take the standard revision: when `expectedRevision` is one, read the live board;-/// if its copper, parts and outline are what the caller read, hand the verb the editor's current text-/// hash (its own guard then runs unchanged), else refuse with `stale_board`. Returns the arguments to-/// use and whether they were translated.-pub fn accept_standard_revision(ctx: &Ctx, args: &Value) -> RResult<(Value, bool)> {-    let Some(want) = args.get("expectedRevision").and_then(Value::as_str).filter(|r| is_standard_revision(r)) else {-        return Ok((args.clone(), false));-    };-    let s = connect(ctx, args)?;-    let text = s.client.get_board_as_string().map_err(map_err)?;-    let now = crate::board_model::revision_of_text(&text).map_err(|e| RoutingError::new("board_unparsable", e))?;-    if now != want {-        return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board: its copper, parts or outline changed since it was read")-            .with("currentRevision", json!(now)).with("mutated", json!(false)));-    }-    let mut a = args.clone();-    a["expectedRevision"] = json!(revision_of(&text));-    Ok((a, true))-}--/// Run an edit verb so it accepts either revision; after a translated call the reply's `revision` is-/// the standard one (the editor's text hash stays as `kicadRevision`).-pub fn with_standard_revision(ctx: &Ctx, args: &Value, run: impl FnOnce(&Value) -> RResult<Value>) -> RResult<Value> {-    let (args, translated) = accept_standard_revision(ctx, args)?;-    let mut v = run(&args)?;-    if translated && v["success"] == json!(true) {-        if let Ok(s) = connect(ctx, &args) {-            if let Ok(text) = s.client.get_board_as_string() {-                if let Ok(r) = crate::board_model::revision_of_text(&text) {-                    v["kicadRevision"] = json!(revision_of(&text));-                    v["revision"] = json!(r);-                }-            }-        }-    }-    Ok(v)-}--/// `kicad_route_net`: DRC preflight on a snapshot, then one commit of the planned copper.-pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (text, data, revision) = s.snapshot()?;-    check_revision(args, &revision)?;-    let plan = pcb::plan(&data, args)?;-    let mut check = Value::Null;-    if args.get("validate") != Some(&json!(false)) {-        let source = s.source_path();-        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;-        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&text, &plan.blocks))?;-        let added = new_errors(&before, &after)?;-        if !added.is_empty() {-            return Err(RoutingError::new("drc_rejected", "Candidate route adds DRC errors; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));-        }-        check = after;-    }-    let public = plan.public();-    if args.get("dryRun") == Some(&json!(true)) {-        let mut out = json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "_hint": "Plan validated without editing. Submit with dryRun:false and the same revision."});-        merge(&mut out, &public);-        return Ok(out);-    }-    // DRC runs outside KiCad. Re-read immediately before begin_commit so another-    // caller's or the user's edits during validation invalidate this plan.-    check_revision(args, &s.revision()?)?;-    let nets = s.client.get_nets().map_err(map_err)?;-    let Some(net) = nets.into_iter().find(|n| n.name == plan.net_name) else {-        return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", plan.net_name)).with("mutated", json!(false)));-    };-    let items = build_items(&plan, &net)?;-    let count = items.len();-    let created = transaction(&s, &format!("Adom route {}", plan.net_name), || {-        let created = s.client.create_editable_items(items, None)?;-        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {-            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));-        }-        Ok(created)-    })?;-    let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();-    let mut result = json!({-        "success": true, "mutated": true, "source": "live-editor",-        "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,-        "_hint": "Copper committed live as one KiCad Undo step. Inspect routing_state before the next route. The file is saved only with save:true; routing_validate checks the unsaved live board.",-    });-    merge(&mut result, &public);-    // Commit succeeded. A subsequent read/save failure must NEVER be reported as a failed-    // route inviting the caller to add it twice.-    match s.revision() {-        Ok(r) => {-            result["revision"] = json!(r);-            if args.get("save") == Some(&json!(true)) {-                match s.client.save_document() {-                    Ok(()) => result["saved"] = json!(true),-                    Err(e) => result["postCommitError"] = json!(e.to_string()),-                }-            }-        }-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    Ok(result)-}--/// Commit copper the caller already planned (a Freerouting session's wires and vias for-/// one net) as ONE native Undo step named `message`, with the same revision guard as-/// `route_net` (`expectedRevision` must match the live board). No DRC preflight: the-/// caller checks the whole result afterwards with `routing_validate`. Used by-/// `kicad_autoroute {engine:"freerouting", apply:"ipc"}`; `route_net` is untouched.-pub fn commit_copper(ctx: &Ctx, args: &Value, plan: &Plan, message: &str) -> RResult<Value> {-    let s = connect(ctx, args)?;-    check_revision(args, &s.revision()?)?;-    let nets = s.client.get_nets().map_err(map_err)?;-    let Some(net) = nets.into_iter().find(|n| n.name == plan.net_name) else {-        return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", plan.net_name)).with("mutated", json!(false)));-    };-    let items = build_items(plan, &net)?;-    let count = items.len();-    let created = transaction(&s, message, || {-        let created = s.client.create_editable_items(items, None)?;-        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {-            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));-        }-        Ok(created)-    })?;-    let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();-    let mut result = json!({-        "success": true, "mutated": true, "source": "live-editor", "netName": plan.net_name,-        "itemIds": ids, "undoSteps": 1, "undoLabel": message,-        "segmentCount": plan.segments.len(), "viaCount": plan.vias.len(), "saved": false,-    });-    match s.revision() {-        Ok(r) => result["revision"] = json!(r),-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    Ok(result)-}--/// `kicad_remove_route`: delete specific unlocked copper items as one Undo step.-pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (text, data, revision) = s.snapshot()?;-    check_revision(args, &revision)?;-    let ids: Vec<String> = match args.get("itemIds").and_then(Value::as_array) {-        Some(a) if !a.is_empty() && a.len() <= 1024 && a.iter().all(Value::is_string) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),-        _ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..1024 copper itemIds returned by route_net")),-    };-    let wanted: HashSet<String> = ids.iter().cloned().collect();-    let copper = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via()])?;-    let items: Vec<&PcbItem> = copper.iter().filter(|i| item_id(i).map(|id| wanted.contains(id)).unwrap_or(false)).collect();-    if items.len() != wanted.len() || items.iter().any(|i| item_locked(i)) {-        return Err(RoutingError::new("invalid_item_ids", "Some IDs are missing, locked, or are not copper; nothing removed"));-    }-    if args.get("dryRun") == Some(&json!(true)) {-        return Ok(remove_preview(ctx, &s, &text, &data, &revision, &ids, args));-    }-    check_revision(args, &s.revision()?)?;-    let to_delete: Vec<String> = wanted.iter().cloned().collect();-    transaction(&s, "Adom remove route", || {-        let deleted: HashSet<String> = s.client.delete_items(to_delete.clone())?.into_iter().collect();-        if deleted != wanted {-            return Err(IpcFailure::from("KiCad did not accept every requested copper deletion".to_string()));-        }-        Ok(())-    })?;-    let mut result = json!({-        "success": true, "mutated": true, "removedIds": ids, "undoSteps": 1,-        "_hint": "Copper removed live. Use KiCad Undo to restore it, or inspect routing_state and route again.",-    });-    match s.snapshot() {-        Ok((_, after, rev)) => {-            result["revision"] = json!(rev);-            let present: HashSet<String> = after.segments.iter().filter_map(|x| x.uuid.clone()).chain(after.vias.iter().filter_map(|x| x.uuid.clone())).collect();-            let remaining: Vec<String> = wanted.iter().filter(|id| present.contains(*id)).cloned().collect();-            if !remaining.is_empty() {-                let mut removed: Vec<String> = wanted.iter().filter(|id| !present.contains(*id)).cloned().collect();-                removed.sort();-                result["removedIds"] = json!(removed);-                result["postCommitError"] = json!("Some requested copper remains after commit; inspect the live board");-            }-            result["verified"] = json!(remaining.is_empty());-        }-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    Ok(result)-}--/// `kicad_remove_route {dryRun:true}` (wiki #134): what would go and KiCad's DRC of the snapshot-/// without it. Nothing is written.-fn remove_preview(ctx: &Ctx, s: &Session, text: &str, data: &Board, revision: &str, ids: &[String], args: &Value) -> Value {-    let items: Vec<Value> = ids.iter().map(|id| {-        if let Some(t) = data.segments.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {-            json!({"id": id, "kind": "track", "net": t.net_name, "layer": t.layer, "start": t.start, "end": t.end, "width": t.width})-        } else if let Some(v) = data.vias.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {-            json!({"id": id, "kind": "via", "net": v.net_name, "position": v.position, "size": v.size, "drill": v.drill})-        } else {-            json!({"id": id, "kind": "arc"})-        }-    }).collect();-    let (without, cut) = strip_items(text, ids);-    let mut out = json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "wouldRemove": items,-        "_hint": "Nothing was removed. drc is KiCad's DRC of the board without these items: unconnectedAfter is what removing them leaves to route. Submit with dryRun:false and the same revision to remove them as one Undo step."});-    if args.get("validate") == Some(&json!(false)) {-        return out;-    }-    if cut != ids.len() {-        out["drc"] = json!({"available": false, "why": format!("{} of {} items were found in the board text", cut, ids.len())});-        return out;-    }-    let source = s.source_path();-    match (drc_snapshot(ctx.kicad_cli.as_deref(), &source, text), drc_snapshot(ctx.kicad_cli.as_deref(), &source, &without)) {-        (Ok(before), Ok(after)) => {-            out["unconnectedBefore"] = before["unconnected"].clone();-            out["unconnectedAfter"] = after["unconnected"].clone();-            out["newErrors"] = json!(new_errors(&before, &after).unwrap_or_default());-            out["drc"] = after;-        }-        (Err(e), _) | (_, Err(e)) => out["drc"] = json!({"available": false, "errorCode": e.code, "why": e.message}),-    }-    out-}--/// The board text with the top-level items carrying these UUIDs cut out, and how many were cut.-pub fn strip_items(text: &str, ids: &[String]) -> (String, usize) {-    // the spans of the root list's direct children, quotes respected-    let mut spans: Vec<(usize, usize)> = Vec::new();-    let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);-    for (i, c) in text.char_indices() {-        if quoted {-            if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { quoted = false }-            continue;-        }-        match c {-            '"' => quoted = true,-            '(' => {-                depth += 1;-                if depth == 2 { start = i; }-            }-            ')' => {-                if depth == 2 { spans.push((start, i + 1)); }-                depth -= 1;-            }-            _ => {}-        }-    }-    let mut out = String::with_capacity(text.len());-    let (mut last, mut cut) = (0, 0);-    for (a, b) in spans {-        let item = &text[a..b];-        let kind_ok = item.starts_with("(segment") || item.starts_with("(arc") || item.starts_with("(via");-        if kind_ok && ids.iter().any(|id| item.contains(&format!("(uuid \"{id}\")")) || item.contains(&format!("(tstamp \"{id}\")"))) {-            out.push_str(&text[last..a]);-            last = b;-            cut += 1;-        }-    }-    out.push_str(&text[last..]);-    (out, cut)-}--/// `kicad_routing_validate`: KiCad DRC on the live, unsaved board snapshot.-pub fn routing_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (text, _, revision) = s.snapshot()?;-    let drc = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;-    let current = s.revision()?;-    let mut out = json!({-        "success": true, "revision": revision, "currentRevision": current, "stale": current != revision,-        "_hint": "This is KiCad DRC on the live editor snapshot with sibling project rules. A stale result is superseded by newer edits. clean requires no violations or unconnected items.",-    });-    merge(&mut out, &drc);-    Ok(out)-}--// ----------------------------------------------------------------------------// Zones: kicad_add_zone, kicad_zone_state-// -----------------------------------------------------------------------------/// `kicad_add_zone`: one or more copper zones (pours) as ONE native Undo step, with the-/// same revision guard and DRC preflight as `route_net`: the zones' s-expressions are-/// spliced into the editor's snapshot, KiCad's DRC (zones refilled) must add no error, or-/// nothing lands. After the commit the new zones are filled (`refill:false` skips that).-pub fn add_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (text, data, revision) = s.snapshot()?;-    check_revision(args, &revision)?;-    let specs = zones::parse_specs(&data, args)?;-    let blocks: Vec<String> = specs.iter().map(zones::zone_sexpr).collect();-    let mut check = Value::Null;-    if args.get("validate") != Some(&json!(false)) {-        let source = s.source_path();-        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;-        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&text, &blocks))?;-        let added = new_errors(&before, &after)?;-        if !added.is_empty() {-            return Err(RoutingError::new("drc_rejected", "The zone adds DRC errors once filled; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));-        }-        check = after;-    }-    let public: Vec<Value> = specs.iter().map(zones::ZoneSpec::public).collect();-    if args.get("dryRun") == Some(&json!(true)) {-        return Ok(json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "zones": public,-            "_hint": "Zones validated (filled and DRC-checked on a snapshot) without editing. Submit with dryRun:false and the same revision."}));-    }-    check_revision(args, &s.revision()?)?;-    let nets = s.client.get_nets().map_err(map_err)?;-    let mut items = Vec::with_capacity(specs.len());-    for z in &specs {-        let Some(net) = nets.iter().find(|n| n.name == z.net.name) else {-            return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", z.net.name)).with("mutated", json!(false)));-        };-        let mut ids = Vec::with_capacity(z.layers.len());-        for l in &z.layers {-            ids.push(BoardLayerInfo::id_from_name(l).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {l}")))?);-        }-        items.push(prost_types::Any { type_url: zones::ZONE_TYPE_URL.to_string(), value: zones::zone_bytes(z, &ids, net.code) });-    }-    let count = items.len();-    let label = format!("Adom zone {}", specs.iter().map(|z| z.net.name.clone()).collect::<Vec<_>>().join(", "));-    let created = transaction(&s, &label, || {-        let created = s.client.create_items(items, None)?;-        if created.len() != count {-            return Err(IpcFailure::from("KiCad did not create every requested zone".to_string()));-        }-        Ok(created)-    })?;-    let ids: Vec<String> = created.iter().filter_map(|a| zones::zone_id(&a.value)).collect();-    let mut result = json!({-        "success": true, "mutated": true, "source": "live-editor",-        "itemIds": ids, "undoSteps": 1, "zones": public, "drc": check, "saved": false, "refilled": false,-        "_hint": "Zones committed live as one KiCad Undo step and filled. kicad_zone_state reads the filled copper per layer; kicad_routing_validate is the DRC on the live board. The file is saved only with save:true.",-    });-    if ids.len() != count {-        result["postCommitError"] = json!(format!("KiCad answered with {} zone ids for {} zones", ids.len(), count));-    }-    // KiCad 10.0.x answers AS_UNIMPLEMENTED to RefillZones with a list of zone ids; an empty-    // list means "every zone" and works, so that is what the bridge sends.-    let mut settled = true;-    if args.get("refill") != Some(&json!(false)) && !ids.is_empty() {-        let before = s.revision().ok();-        match s.client.refill_zones(Vec::new()) {-            Ok(()) => {-                // Wiki #115: KiCad fills ASYNCHRONOUSLY. Reading the revision straight after the-                // request returned the pre-fill board, the fill then changed it, and the caller's-                // next edit was refused as stale_board. Wait for the fill to land and the revision-                // to hold still, bounded; say so honestly if it never does.-                let (_, ok) = settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message));-                settled = ok;-                result["refilled"] = json!(ok);-            }-            Err(e) => result["postCommitError"] = json!(format!("zones created but not filled: {e}")),-        }-    }-    result["revisionSettled"] = json!(settled);-    if !settled {-        result["_hint"] = json!("Zones committed as one Undo step, but KiCad was still filling when this reply was written, so the revision below may already be stale. Call kicad_zone_state (refill:false) for a settled revision before the next edit; a stale one is refused with stale_board and changes nothing.");-    }-    match s.revision() {-        Ok(r) => {-            result["revision"] = json!(r);-            if args.get("save") == Some(&json!(true)) {-                match s.client.save_document() {-                    Ok(()) => result["saved"] = json!(true),-                    Err(e) => result["postCommitError"] = json!(e.to_string()),-                }-            }-        }-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    Ok(result)-}--/// Reconcile only explicitly owned zones in one native undo transaction. Always DRC-/// the complete candidate first; preserve unrelated zones and require a revision.-pub fn apply_zone_plan(ctx: &Ctx, args: &Value) -> RResult<Value> {-    if args["expectedRevision"]-        .as_str()-        .filter(|s| !s.is_empty())-        .is_none()-    {-        return Err(RoutingError::new(-            "revision_required",-            "Read kicad_zone_state first and pass its expectedRevision",-        ));-    }-    // Refill is a separate asynchronous native edit and Undo step. Keep this-    // transaction's revision usable by leaving refill to an explicit caller step.-    if args["refill"] == true {-        return Err(RoutingError::new("separate_refill_required", "Apply with refill:false (the default), then call kicad_zone_state with refill:true and read its final revision").with("mutated", json!(false)));-    }-    let s = connect(ctx, args)?;-    let (text, board, revision) = s.snapshot()?;-    check_revision(args, &revision)?;-    let plan = crate::zone_plan::prepare(&text, &board, args)?;-    if plan.remove.is_empty() && plan.create.is_empty() {-        // No candidate differs from the current board. A capped inherited DRC-        // report must not turn an idempotent read into a failed mutation.-        return Ok(json!({-            "success":true,"mutated":false,"undoSteps":0,"revision":revision,-            "dryRun":args["dryRun"]==true,"unchangedIds":plan.unchanged,-            "ownedItemIds":plan.unchanged,"itemIds":[],"removedIds":[],-            "drcRerun":false,"_hint":"Native zone definitions already match; no edit or DRC rerun. This is not a DRC-clean certification."-        }));-    }--    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;-    let check = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;-    let added = new_errors(&before, &check)?;-    if !added.is_empty() {-        return Err(RoutingError::new(-            "drc_rejected",-            "Full zone plan adds native DRC errors; nothing changed",-        )-        .with("violations", json!(added))-        .with("mutated", json!(false)));-    }-    if args["dryRun"] == true {-        return Ok(-            json!({"success":true,"mutated":false,"dryRun":args["dryRun"]==true,"revision":revision,"unchangedIds":plan.unchanged,"wouldRemoveIds":plan.remove,"wouldCreate":plan.create.iter().map(zones::ZoneSpec::public).collect::<Vec<_>>(),"drc":check,"undoSteps":0}),-        );-    }-    let nets = s.client.get_nets().map_err(map_err)?;-    let mut items = Vec::new();-    for z in &plan.create {-        let net_code = if z.keepout.is_some() {-            0-        } else {-            nets.iter()-                .find(|n| n.name == z.net.name)-                .ok_or_else(|| {-                    RoutingError::new("unknown_net", format!("No native net {:?}", z.net.name))-                })?-                .code-        };-        let layers = z-            .layers-            .iter()-            .map(|l| {-                BoardLayerInfo::id_from_name(l)-                    .ok_or_else(|| RoutingError::new("unknown_layer", l.clone()))-            })-            .collect::<RResult<Vec<_>>>()?;-        items.push(prost_types::Any {-            type_url: zones::ZONE_TYPE_URL.into(),-            value: zones::zone_bytes(z, &layers, net_code),-        });-    }-    check_revision(args, &s.revision()?)?;-    let created = transaction(&s, "Adom apply zone plan", || {-        if !plan.remove.is_empty() {-            s.client.delete_items(plan.remove.clone())?;-            // KiCad applies deletions at commit. Readback here sees the old board.-        }-        let created = if items.is_empty() {-            Vec::new()-        } else {-            s.client.create_items(items.clone(), None)?-        };-        if created.len() != items.len() {-            return Err(IpcFailure::from(-                "Not every planned zone was created; rollback requested".to_string(),-            ));-        }-        Ok(created)-    })?;-    let ids: Vec<String> = created-        .iter()-        .filter_map(|a| zones::zone_id(&a.value))-        .collect();-    let mut owned = plan.unchanged.clone();-    owned.extend(ids.clone());-    let mut out = json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"itemIds":ids,"ownedItemIds":owned,"unchangedIds":plan.unchanged,"removedIds":plan.remove,"drc":check,"saved":false,"refilled":false,"_hint":"Full plan committed as one native Undo step. Inspect postCommitError and kicad_zone_state before retrying. Ownership is only the returned ownedItemIds; never infer it from a zone name or age."});-    if ids.len() != plan.create.len() {-        out["postCommitError"] = json!(-            "Native creation returned incomplete zone IDs; inspect the board before any retry"-        );-    }-    out["refillRequired"] = json!(true);-    if !plan.remove.is_empty() {-        match s-            .client-            .get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code])-        {-            Ok(rows) => {-                let remaining: Vec<_> = rows-                    .iter()-                    .filter_map(|a| zones::zone_id(&a.value))-                    .filter(|id| plan.remove.contains(id))-                    .collect();-                out["deletionVerified"] = json!(remaining.is_empty());-                if !remaining.is_empty() {-                    out["postCommitDeletionError"] = json!({"remainingIds": remaining});-                    out["success"] = json!(false);-                }-            }-            Err(e) => {-                out["postCommitDeletionError"] = json!(e.to_string());-                out["success"] = json!(false);-            }-        }-    }-    match s.revision() {-        Ok(r) => out["revision"] = json!(r),-        Err(e) => out["postCommitError"] = json!(e.message),-    }-    if args["save"] == true {-        match s.client.save_document() {-            Ok(()) => out["saved"] = json!(true),-            Err(e) => out["postCommitError"] = json!(e.to_string()),-        }-    }-    Ok(out)-}--/// `kicad_remove_zone`: delete zones (copper pours or keepouts) by id as one Undo step. Only-/// zone items are accepted, so a pad, track or footprint id is refused with nothing removed.-pub fn remove_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    check_revision(args, &s.revision()?)?;-    let ids: Vec<String> = match args.get("itemIds").or_else(|| args.get("zoneIds")) {-        Some(Value::Array(a)) if !a.is_empty() && a.len() <= 256 => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),-        _ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..256 zone itemIds returned by kicad_add_zone or kicad_zone_state")),-    };-    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;-    let mut found = Vec::new();-    for a in &raws {-        if let Ok(z) = zones::decode_zone(&a.value) {-            if ids.contains(&z.id) {-                found.push(z);-            }-        }-    }-    if found.len() != ids.len() {-        let have: Vec<&str> = found.iter().map(|z| z.id.as_str()).collect();-        let missing: Vec<&String> = ids.iter().filter(|i| !have.contains(&i.as_str())).collect();-        return Err(RoutingError::new("invalid_item_ids", "Some IDs are not zones on the live board; nothing removed").with("missing", json!(missing)).with("mutated", json!(false)));-    }-    let count = ids.len();-    let label = format!("Adom remove {} zone(s)", count);-    let removed = transaction(&s, &label, || {-        let removed = s.client.delete_items(ids.clone())?;-        if removed.len() != count {-            return Err(IpcFailure::from(format!("KiCad removed {} of {} zones", removed.len(), count)));-        }-        Ok(removed)-    })?;-    let mut result = json!({-        "success": true, "mutated": true, "source": "live-editor", "removed": removed, "undoSteps": 1, "saved": false,-        "zones": found.iter().map(|z| json!({"id": z.id, "name": z.name, "net": z.net, "type": if z.rule_area { "keepout" } else { "copper" }})).collect::<Vec<_>>(),-        "_hint": "Zones removed as one KiCad Undo step. Other zones' fills may be stale: kicad_zone_state {refill:true} refills them.",-    });-    match s.revision() {-        Ok(r) => result["revision"] = json!(r),-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    Ok(result)-}--/// Wait for an asynchronous zone fill to land: the revision must first move away from `before`-/// (a new zone always changes the board when it fills), then read identically three times in a-/// row. A fill that changes nothing is accepted once the revision has stayed at `before` for 3 s.-/// Read errors (KiCad busy mid-fill) count as "not yet". Bounded at 12 s; returns-/// (last revision, settled).-pub fn settle_revision(before: Option<&str>, mut read: impl FnMut() -> Result<String, String>) -> (Option<String>, bool) {-    settle_revision_with(before, &mut read, std::time::Duration::from_millis(300), std::time::Duration::from_secs(3), std::time::Duration::from_secs(12))-}--fn settle_revision_with(before: Option<&str>, read: &mut dyn FnMut() -> Result<String, String>, step: std::time::Duration, quiet: std::time::Duration, limit: std::time::Duration) -> (Option<String>, bool) {-    let start = std::time::Instant::now();-    let (mut last, mut same, mut moved): (Option<String>, u32, bool) = (None, 0, false);-    loop {-        if let Ok(r) = read() {-            moved |= before != Some(r.as_str());-            if last.as_deref() == Some(r.as_str()) { same += 1 } else { same = 0 }-            last = Some(r);-            if moved && same >= 2 {-                return (last, true);-            }-            if !moved && same >= 2 && start.elapsed() >= quiet {-                return (last, true);-            }-        }-        if start.elapsed() >= limit {-            return (last, false);-        }-        std::thread::sleep(step);-    }-}--fn refill_requested(board_path: &str) -> Value {-    json!({-        "success": true, "boardPath": board_path, "source": "live-editor",-        "refillRequested": true, "refilled": false, "revisionUsable": false,-        "_hint": "Refill was requested, not confirmed complete. KiCad fills asynchronously. Wait and call kicad_zone_state with refill:false; retry read-only calls while busy. Use the later snapshot's revision, never replay refill after a timeout. No revision or copper measurements are returned in this acknowledgement."-    })-}--/// `kicad_zone_state`: every zone on the live board with its filled copper area per layer-/// (KiCad's own fill polygons), the sum per layer and the coverage against the outline-/// box. `refill:true` refills every zone first (the fill can be stale after routing).-pub fn zone_state(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    if args.get("refill") == Some(&json!(true)) {-        s.client.refill_zones(Vec::new()).map_err(map_err)?;-        // Acknowledgement precedes asynchronous fill; no stale snapshot escapes.-        return Ok(refill_requested(&s.file_path));-    }-    let (text, data, revision) = s.snapshot()?;-    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;-    let mut read = Vec::with_capacity(raws.len());-    for a in &raws {-        read.push(zones::decode_zone(&a.value).map_err(|e| RoutingError::new("zone_undecodable", format!("KiCad returned a zone the bridge could not decode: {e}")))?);-    }-    let mut out = json!({-        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",-        "kicadVersion": s.version.full_version, "socket": s.socket, "refilled": false,-        "_hint": "filledAreaByLayerMm2 is the copper KiCad's zones keep on each layer: for a copper-ablation process, what does not have to be milled away. Add pours with kicad_add_zone; kicad_routing_validate is the DRC.",-    });-    merge(&mut out, &zones::state_json(&read, &data));-    out["definitions"] = crate::zone_plan::readback(&text).map_err(|e|RoutingError::new("zone_undecodable",e))?;-    Ok(out)-}--// ----------------------------------------------------------------------------// Placement: kicad_placement_state, kicad_move_footprint, kicad_placement_validate-// -----------------------------------------------------------------------------pub const PLACEMENT_STATE_HINT: &str = "Use this revision as expectedRevision in kicad_move_footprint. Coordinates are mm, +y down; courtyard boxes are board-frame and per side. Cluster by nets[].refs, place decoupling caps beside their IC, keep the power stage together, and watch ratsnest.totalMm fall. kicad_placement_validate is the check.";--/// `kicad_placement_state`, live: the editor's own board text parsed for footprints,-/// courtyards, nets and the ratsnest estimate. Read-only.-pub fn placement_state(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (_text, board, revision) = s.snapshot()?;-    let detail = args.get("detail") == Some(&json!(true));-    let mut out = json!({-        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",-        "kicadVersion": s.version.full_version, "socket": s.socket,-        "_hint": PLACEMENT_STATE_HINT,-    });-    merge(&mut out, &placement::state(&board, detail));-    if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {-        out["stale"] = json!(exp != revision);-    }-    Ok(out)-}--/// The moved footprints as KiCad will store them. KiCad's `UpdateItems` does not edit a-/// footprint in place: it builds a new one from this proto (`FOOTPRINT::Deserialize`),-/// removes the old one and adds the new one, and Deserialize takes every child (pads,-/// fields, shapes, texts, zones, ...) at the ABSOLUTE board coordinates the proto-/// carries. So besides `position` and `orientation` this moves every child with the-/// same rigid-body transform (`placement::ChildTransform`): the four mandatory fields-/// through the typed proto, every `definition.items` payload on the wire-/// (`placement::transform_child`). A child type without a transform rule refuses the-/// whole move (`unsupported_child_item`); a side change is refused-/// (`side_change_not_supported`), so `layer` is only ever re-set to what it was. Every-/// move must have its item, or the editor no longer holds that footprint-/// (`footprint_not_found`).-pub fn build_footprint_updates(items: Vec<EditablePcbItem>, moves: &[placement::Move]) -> RResult<Vec<EditablePcbItem>> {-    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));-    let mut out = Vec::with_capacity(moves.len());-    for m in moves {-        if m.flipped || m.after.side != m.before.side {-            return Err(placement::side_change_error(&m.reference, &m.before.side, &m.after.side));-        }-        let found = items.iter().find(|i| i.id() == Some(m.uuid.as_str()));-        let Some(EditablePcbItem::Footprint(fp)) = found else {-            return Err(RoutingError::new("footprint_not_found", format!("The live board has no footprint with id {} ({}); read kicad_placement_state again", m.uuid, m.reference)).with("mutated", json!(false)));-        };-        let mut fp = fp.clone();-        let p = fp.proto_mut();-        // The old pose is the proto's own: that is the frame the children are in.-        let (ox, oy) = p.position.as_ref().map(|v| (v.x_nm, v.y_nm)).unwrap_or((0, 0));-        let orot = p.orientation.as_ref().map(|a| a.value_degrees).unwrap_or(0.0);-        let xf = placement::ChildTransform::new((ox, oy, orot), (nm(m.after.x), nm(m.after.y), m.after.rotation));-        for field in [&mut p.reference_field, &mut p.value_field, &mut p.datasheet_field, &mut p.description_field].into_iter().flatten() {-            if let Some(text) = field.text.as_mut().and_then(|t| t.text.as_mut()) {-                if let Some(v) = text.position.as_mut() {-                    let (x, y) = xf.point(v.x_nm, v.y_nm);-                    v.x_nm = x;-                    v.y_nm = y;-                }-                if let Some(a) = text.attributes.as_mut().and_then(|a| a.angle.as_mut()) {-                    a.value_degrees = xf.angle(a.value_degrees);-                }-            }-        }-        if let Some(def) = p.definition.as_mut() {-            for item in def.items.iter_mut() {-                item.value = placement::transform_child(&xf, &item.type_url, &item.value).map_err(|e| {-                    RoutingError::new("unsupported_child_item", format!("Footprint {} carries a {} item the bridge cannot move ({}); nothing was moved", m.reference, e.type_url, e.reason))-                        .with("typeUrl", json!(e.type_url))-                        .with("ref", json!(m.reference))-                        .with("supportedTypes", json!(placement::CHILD_TYPES))-                        .with("mutated", json!(false))-                        .with("_hint", json!("KiCad replaces the whole footprint on an IPC update, so the bridge moves every child of the proto itself and refuses rather than move one wrongly. Move this footprint in the PCB editor, or rotate by a multiple of 90 degrees when the reason names one."))-                })?;-            }-        }-        p.position = Some(Default::default());-        if let Some(pos) = p.position.as_mut() {-            pos.x_nm = nm(m.after.x);-            pos.y_nm = nm(m.after.y);-        }-        p.orientation = Some(Default::default());-        if let Some(o) = p.orientation.as_mut() {-            o.value_degrees = m.after.rotation;-        }-        p.layer = layer_id(&m.after.side)?;-        out.push(EditablePcbItem::Footprint(fp));-    }-    Ok(out)-}--/// `kicad_move_footprint`: one footprint or a batch, checked (revision, courtyards,-/// outline) then committed through `update_items` as ONE native Undo step. `dryRun`-/// runs the checks and reports without touching the editor.-pub fn move_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (_text, board, revision) = s.snapshot()?;-    check_revision(args, &revision)?;-    let plan = placement::plan_moves(&board, args)?;-    let public = placement::plan_json(&plan);-    if args.get("dryRun") == Some(&json!(true)) {-        let mut out = json!({-            "success": true, "dryRun": true, "mutated": false, "revision": revision, "source": "live-editor", "undoSteps": 0,-            "_hint": "Checks passed without editing. Submit with dryRun:false and the same revision.",-        });-        merge(&mut out, &public);-        return Ok(out);-    }-    // The checks ran on a snapshot; re-read right before the commit so an edit in between-    // (the user's, or another caller's) invalidates this plan instead of racing it.-    check_revision(args, &s.revision()?)?;-    let ids: Vec<String> = plan.moves.iter().map(|m| m.uuid.clone()).collect();-    let items = s.client.get_editable_items_by_id(ids).map_err(map_err)?;-    let updates = build_footprint_updates(items, &plan.moves)?;-    let count = updates.len();-    transaction(&s, &plan.undo_label, || {-        let updated = s.client.update_editable_items(updates)?;-        if updated.len() != count {-            return Err(IpcFailure::from("KiCad did not update every requested footprint".to_string()));-        }-        Ok(())-    })?;-    let mut result = json!({-        "success": true, "mutated": true, "source": "live-editor", "undoSteps": 1, "saved": false,-        "_hint": "Footprint(s) moved live as one KiCad Undo step; verified means the read-back courtyard (or first pad) moved with the anchor, not just the anchor. Read kicad_placement_state for the new revision before the next move; kicad_placement_validate checks courtyards, the outline and KiCad DRC. The file is saved only with save:true.",-    });-    merge(&mut result, &public);-    // Commit succeeded. A read or save failure after it is reported as postCommitError,-    // never as a failed move that invites a replay.-    match s.snapshot() {-        Ok((_, after, rev)) => {-            result["revision"] = json!(rev);-            let places = placement::placements(&after);-            let close = |a: f64, b: f64| (a - b).abs() < 0.0005;-            let mut all_match = true;-            let mut stayed: Vec<String> = Vec::new();-            let verified: Vec<Value> = plan-                .moves-                .iter()-                .map(|m| match places.iter().zip(after.footprints.iter()).find(|(p, _)| p.reference == m.reference) {-                    Some((p, after_fp)) => {-                        let pose_ok = close(p.x, m.after.x) && close(p.y, m.after.y) && close(placement_angle(p.rotation), placement_angle(m.after.rotation)) && p.side == m.after.side;-                        // The anchor alone proves nothing (KiCad rebuilds the footprint from-                        // the proto): the courtyard or first pad must have moved with it.-                        let children = board.footprints.iter().find(|f| f.reference == m.reference).map(|before_fp| placement::check_children(m, before_fp, after_fp));-                        let children_ok = children.as_ref().map(|c| c.ok).unwrap_or(false);-                        if !children_ok {-                            stayed.push(children.as_ref().map(|c| c.describe(&m.reference)).unwrap_or_else(|| format!("{} was not on the board before the move", m.reference)));-                        }-                        let ok = pose_ok && children_ok;-                        all_match &= ok;-                        json!({"ref": m.reference, "x": p.x, "y": p.y, "rotation": p.rotation, "side": p.side, "courtyard": p.courtyard, "insideOutline": p.inside_outline, "matches": ok, "poseMatches": pose_ok, "children": children})-                    }-                    None => {-                        all_match = false;-                        json!({"ref": m.reference, "matches": false, "missing": true})-                    }-                })-                .collect();-            result["after"] = json!(verified);-            result["verified"] = json!(all_match);-            if !stayed.is_empty() {-                result["postCommitError"] = json!(format!("children did not move: {}", stayed.join("; ")));-            } else if !all_match {-                result["postCommitError"] = json!("The live board does not show every footprint at its requested pose; inspect kicad_placement_state");-            }-            let (_, total) = placement::nets(&after);-            result["ratsnest"] = json!({"totalMm": total});-            if args.get("save") == Some(&json!(true)) {-                match s.client.save_document() {-                    Ok(()) => result["saved"] = json!(true),-                    Err(e) => result["postCommitError"] = json!(e.to_string()),-                }-            }-        }-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    Ok(result)-}--fn placement_angle(d: f64) -> f64 {-    let mut r = d % 360.0;-    if r < 0.0 {-        r += 360.0;-    }-    r-}--/// `kicad_placement_validate`, live: courtyard overlaps, footprints outside the outline-/// or parked, the ratsnest total, and KiCad DRC on the live snapshot filtered to the-/// placement-class violations.-pub fn placement_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {-    let s = connect(ctx, args)?;-    let (text, board, revision) = s.snapshot()?;-    let mut out = json!({-        "success": true, "revision": revision, "source": "live-editor",-        "_hint": "placed requires zero courtyard overlaps and nothing outside the outline; drc.placementViolations is KiCad's own verdict on courtyards, edge clearance, silk and pad clearance. A stale result is superseded by newer edits.",-    });-    merge(&mut out, &placement::validate(&board));-    out["drc"] = match drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text) {-        Ok(d) => placement::drc_filter(&d),-        Err(e) => json!({"available": false, "errorCode": e.code, "reason": e.message}),-    };-    let current = s.revision()?;-    out["currentRevision"] = json!(current);-    out["stale"] = json!(current != revision);-    Ok(out)-}--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());-        }-    }-}--// ----------------------------------------------------------------------------// Tests (no live KiCad)-// -----------------------------------------------------------------------------#[cfg(test)]-mod tests {-    #[test]-    fn a_fill_that_lands_late_is_waited_for_and_a_stuck_one_is_reported() {-        use std::time::Duration;-        let ms = Duration::from_millis;-        // the pre-fill revision twice, a busy read, then the filled board that holds still-        let mut seq = vec![Ok("A".to_string()), Ok("A".to_string()), Err("busy".to_string()),-                           Ok("B".to_string()), Ok("B".to_string()), Ok("B".to_string())].into_iter();-        let (r, ok) = settle_revision_with(Some("A"), &mut || seq.next().unwrap_or(Ok("B".into())), ms(1), ms(50), ms(500));-        assert_eq!((r.as_deref(), ok), (Some("B"), true)); // never the stale "A"-        // a fill that changes nothing: accepted once quiet long enough-        let (r, ok) = settle_revision_with(Some("A"), &mut || Ok("A".to_string()), ms(1), ms(20), ms(500));-        assert_eq!((r.as_deref(), ok), (Some("A"), true));-        // a board that never stops changing: reported unsettled at the limit-        let mut n = 0;-        let (_, ok) = settle_revision_with(Some("A"), &mut || { n += 1; Ok(format!("R{n}")) }, ms(1), ms(20), ms(60));-        assert!(!ok);-    }--    #[test]-    fn refill_acknowledgement_never_certifies_a_stale_snapshot() {-        let reply = super::refill_requested("board.kicad_pcb");-        assert_eq!(reply["refillRequested"], true);-        assert_eq!(reply["refilled"], false);-        assert_eq!(reply["revisionUsable"], false);-        assert!(reply.get("revision").is_none());-        assert!(reply.get("filledAreaByLayerMm2").is_none());-    }--    #[test]-    fn zone_plan_refill_refuses_before_ipc_or_mutation() {-        let error = super::apply_zone_plan(&super::Ctx::default(), &serde_json::json!({-            "expectedRevision": "test", "refill": true-        })).unwrap_err();-        assert_eq!(error.code, "separate_refill_required");-        assert_eq!(super::error_response(&error)["mutated"], false);-    }--    use super::*;-    use std::cell::RefCell;--    #[test]-    fn strip_items_cuts_only_the_named_copper() {-        let text = "(kicad_pcb (version 1)\n\t(segment (start 0 0) (end 1 0) (uuid \"a\"))\n\t(via (at 1 0) (uuid \"b\"))\n\t(gr_text \"a (uuid \\\"a\\\")\" (uuid \"c\"))\n\t(segment (start 2 0) (end 3 0) (uuid \"d\"))\n)";-        let (out, n) = strip_items(text, &["a".to_string(), "b".to_string()]);-        assert_eq!(n, 2);-        assert!(!out.contains("(uuid \"a\"))") && !out.contains("(uuid \"b\")"));-        assert!(out.contains("(uuid \"c\")") && out.contains("(uuid \"d\")"));-    }--    #[test]-    fn revision_is_sha256_of_the_board_text() {-        assert_eq!(revision_of("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");-        assert_ne!(revision_of("(kicad_pcb)"), revision_of("(kicad_pcb)\n"));-        assert!(check_revision(&json!({"expectedRevision": "x"}), "x").is_ok());-        for rev in [Value::Null, json!("old"), json!(1)] {-            let e = check_revision(&json!({"expectedRevision": rev}), "new").unwrap_err();-            assert_eq!(e.code, "stale_board");-            assert_eq!(e.detail["currentRevision"], "new");-            assert_eq!(e.detail["mutated"], false);-        }-        assert_eq!(check_revision(&json!({}), "new").unwrap_err().code, "stale_board");-    }--    #[test]-    fn board_path_validation_and_normalisation() {-        assert_eq!(normalize_board_path(None).unwrap_err().code, "invalid_board_path");-        assert_eq!(normalize_board_path(Some(&json!("relative/x.kicad_pcb"))).unwrap_err().code, "invalid_board_path");-        assert_eq!(normalize_board_path(Some(&json!("C:/x/y.kicad_sch"))).unwrap_err().code, "invalid_board_path");-        assert_eq!(normalize_board_path(Some(&json!("C:\\Users\\J\\Board.kicad_pcb"))).unwrap(), "c:/users/j/board.kicad_pcb");-        assert_eq!(normalize_board_path(Some(&json!("/tmp/nope/Board.kicad_pcb"))).unwrap(), "/tmp/nope/board.kicad_pcb");-        let d = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };-        assert_eq!(normalize_existing(&document_path(&d)), "/tmp/proj/live.kicad_pcb");-        let abs = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("C:/p/live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };-        assert_eq!(document_path(&abs), "C:/p/live.kicad_pcb");-    }--    #[test]-    fn capped_opens_are_lower_bound_without_blocking_error_comparison() {-        let limits = report_limits(&[], 499);-        assert_eq!(limits["unconnectedCountIsLowerBound"], true);-        assert_eq!(limits["violationReportMayBeTruncated"], false);-        let mut before = json!({"violations": []});-        merge(&mut before, &limits);-        assert_eq!(new_errors(&before, &before).unwrap(), Vec::<Value>::new());-        assert_eq!(report_limits(&[], 498)["unconnectedCountIsLowerBound"], false);-    }--    #[test]-    fn per_type_caps_block_error_delta_even_when_reports_match() {-        for (kind, cap) in [("clearance", 499usize), ("shorting_items", 199)] {-            let v = json!({"type": kind, "severity": "error", "items": []});-            let under: Vec<Value> = vec![v.clone(); cap - 1];-            assert_eq!(report_limits(&under, 0)["violationReportMayBeTruncated"], false);-            let at: Vec<Value> = vec![v.clone(); cap];-            let mut report = json!({"violations": at});-            let limits = report_limits(report["violations"].as_array().unwrap(), 0);-            assert_eq!(limits["possiblyTruncatedViolationTypes"], json!([kind]));-            merge(&mut report, &limits);-            let e = new_errors(&report, &report).unwrap_err();-            assert_eq!(e.code, "drc_incomplete");-            assert_eq!(e.detail["mutated"], false);-        }-    }--    #[test]-    fn existing_error_does_not_mask_additional_same_error() {-        let v = json!({"type": "clearance", "severity": "error", "description": "collision", "items": [{"pos": {"x": 1, "y": 2}}]});-        let w = json!({"type": "clearance", "severity": "warning", "description": "collision", "items": []});-        let added = new_errors(&json!({"violations": [v, w]}), &json!({"violations": [v, v, w, w]})).unwrap();-        assert_eq!(added, vec![v.clone()]);-        // Item order inside a violation does not change its identity.-        let a = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 1}}, {"pos": {"x": 2}}]});-        let b = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 2}}, {"pos": {"x": 1}}]});-        assert!(new_errors(&json!({"violations": [a]}), &json!({"violations": [b]})).unwrap().is_empty());-    }--    #[test]-    fn drc_result_has_the_python_shape() {-        let report = json!({-            "$schema": "x", "kicad_version": "10.0.5", "source": "board.kicad_pcb",-            "violations": [-                {"type": "clearance", "severity": "error", "description": "Clearance violation", "items": [{"description": "Track", "pos": {"x": 1.0, "y": 2.0}}]},-                {"type": "lib_footprint_issues", "severity": "warning", "description": "Footprint not found in libraries", "items": []},-            ],-            "unconnected_items": [{"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": []}],-        });-        let r = drc_result(&report, vec!["/p/board.kicad_pro".into()]);-        assert_eq!(r["errors"], 1);-        assert_eq!(r["warnings"], 1);-        assert_eq!(r["unconnected"], 1);-        assert_eq!(r["clean"], false);-        assert_eq!(r["zonesRefilled"], true);-        assert_eq!(r["source"], "live-editor-snapshot");-        assert_eq!(r["projectRulesCopied"], json!(["/p/board.kicad_pro"]));-        assert_eq!(r["violationReportMayBeTruncated"], false);-        assert_eq!(r["unconnectedItems"].as_array().unwrap().len(), 1);-        // `summary` is the bridge's kicad_run_drc summary, which counts unconnected items as errors too.-        assert_eq!(r["summary"]["errors"], 2);-        assert_eq!(r["summary"]["unconnected"], 1);-        assert_eq!(drc_result(&json!({"violations": [], "unconnected_items": []}), vec![])["clean"], true);-    }--    #[test]-    fn drc_snapshot_needs_kicad_cli() {-        let e = drc_snapshot(None, Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();-        assert_eq!(e.code, "drc_unavailable");-        let e = drc_snapshot(Some(Path::new("/definitely/not/kicad-cli")), Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();-        assert_eq!(e.code, "drc_unavailable");-    }--    /// A fake commit session recording begin/push/drop like tests/test_live_routing.py.-    struct Fake {-        ops: RefCell<Vec<&'static str>>,-        push_fails: Option<IpcFailure>,-        drop_fails: Option<IpcFailure>,-    }--    impl Fake {-        fn new() -> Self {-            Fake { ops: RefCell::new(Vec::new()), push_fails: None, drop_fails: None }-        }-    }--    impl CommitTarget for Fake {-        fn begin(&self) -> Result<String, IpcFailure> {-            self.ops.borrow_mut().push("begin");-            Ok("commit".into())-        }-        fn push(&self, _id: &str, _message: &str) -> Result<(), IpcFailure> {-            self.ops.borrow_mut().push("push");-            self.push_fails.clone().map(Err).unwrap_or(Ok(()))-        }-        fn drop_commit(&self, _id: &str) -> Result<(), IpcFailure> {-            self.ops.borrow_mut().push("drop");-            self.drop_fails.clone().map(Err).unwrap_or(Ok(()))-        }-    }--    #[test]-    fn success_is_one_undo_step() {-        let b = Fake::new();-        assert_eq!(transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap(), 42);-        assert_eq!(*b.ops.borrow(), vec!["begin", "push"]);-    }--    #[test]-    fn partial_create_failure_rolls_back() {-        let b = Fake::new();-        let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("partial create".to_string()))).unwrap_err();-        assert_eq!(e.code, "mutation_rolled_back");-        assert_eq!(e.detail["mutated"], false);-        assert_eq!(*b.ops.borrow(), vec!["begin", "drop"]);-    }--    #[test]-    fn push_timeout_is_unknown_even_if_drop_acknowledged() {-        let mut b = Fake::new();-        b.push_fails = Some(IpcFailure { message: "commit response lost".into(), timed_out: true });-        let e = transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap_err();-        assert_eq!(e.code, "mutation_outcome_unknown");-        assert_eq!(e.detail["mutated"], Value::Null);-        assert_eq!(e.detail["rollbackAttempted"], true);-        assert_eq!(e.detail["stillRunning"], true);-        assert_eq!(e.detail["ipcTimeout"], true);-        assert_eq!(*b.ops.borrow(), vec!["begin", "push", "drop"]);-    }--    #[test]-    fn failed_rollback_is_unknown_not_safe_to_retry() {-        let mut b = Fake::new();-        b.drop_fails = Some(IpcFailure::from("lost IPC".to_string()));-        let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("create failed".to_string()))).unwrap_err();-        assert_eq!(e.code, "mutation_outcome_unknown");-        assert_eq!(e.detail["mutated"], Value::Null);-        assert_eq!(e.detail["rollbackError"], "lost IPC");-        assert_eq!(e.detail["stillRunning"], false);-    }--    #[test]-    fn error_response_carries_detail_and_hint() {-        let e = RoutingError::new("stale_board", "old").with("currentRevision", json!("abc"));-        let v = error_response(&e);-        assert_eq!(v["success"], false);-        assert_eq!(v["errorCode"], "stale_board");-        assert_eq!(v["currentRevision"], "abc");-        assert!(v["_hint"].as_str().unwrap().contains("kicad_routing_state"));-        let custom = error_response(&RoutingError::new("api_server_off", "x").with("_hint", json!("enable it")));-        assert_eq!(custom["_hint"], "enable it");-    }--    #[test]-    fn timeout_maps_to_ipc_timeout_with_no_mutation() {-        let e = map_err(KiCadError::Timeout { timeout: Duration::from_millis(5000) });-        assert_eq!(e.code, "ipc_timeout");-        assert_eq!(e.detail["ipcTimeoutMs"], 5000);-        assert_eq!(e.detail["stillRunning"], false);-        assert_eq!(e.detail["mutated"], false);-        assert_eq!(map_err(KiCadError::BoardNotOpen).code, "no_board_open");-        assert_eq!(map_err(KiCadError::SocketUnavailable { socket_uri: "ipc:///x".into() }).code, "ipc_unavailable");-        assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_BAD_REQUEST".into(), message: "nope".into() }).code, "ipc_error");-        assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_UNHANDLED".into(), message: "no handler".into() }).code, "no_pcb_frame");-        assert_eq!(timeout_of(&json!({})), Duration::from_millis(5000));-        assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 10})), Duration::from_millis(1000));-        assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));-    }--    #[test]-    fn footprint_updates_move_the_children_with_the_anchor() {-        use kicad_ipc_rs::FootprintItem;-        use placement::{angle_bytes, vector2_bytes, wire_message, wire_varint};-        const MM: i64 = 1_000_000;-        // An `Any` child without naming prost's type: the crate's own into_any builds one.-        let any = |name: &str, value: Vec<u8>| {-            let mut a = EditablePcbItem::Group(kicad_ipc_rs::GroupItem::new("x", vec![])).into_any();-            a.type_url = format!("type.googleapis.com/kiapi.board.types.{name}");-            a.value = value;-            a-        };-        // U1 at (10, 20) mm, unrotated, with its reference text 2 mm above the anchor,-        // pad 1 one millimetre to the left (padstack angle 0) and a 4 x 2 mm courtyard.-        let mut fp = FootprintItem::from_proto(Default::default());-        {-            let p = fp.proto_mut();-            p.id = Some(Default::default());-            if let Some(id) = p.id.as_mut() {-                id.value = "u1-uuid".into();-            }-            p.position = Some(Default::default());-            if let Some(v) = p.position.as_mut() {-                v.x_nm = 10 * MM;-                v.y_nm = 20 * MM;-            }-            p.layer = BoardLayerInfo::id_from_name("F.Cu").unwrap();-            p.locked = 1;-            p.symbol_sheet_name = "Root".into();-            p.reference_field = Some(Default::default());-            if let Some(f) = p.reference_field.as_mut() {-                f.name = "Reference".into();-                f.text = Some(Default::default());-                if let Some(bt) = f.text.as_mut() {-                    bt.text = Some(Default::default());-                    if let Some(t) = bt.text.as_mut() {-                        t.text = "U1".into();-                        t.position = Some(Default::default());-                        if let Some(v) = t.position.as_mut() {-                            v.x_nm = 10 * MM;-                            v.y_nm = 18 * MM;-                        }-                        t.attributes = Some(Default::default());-                        if let Some(a) = t.attributes.as_mut() {-                            a.angle = Some(Default::default());-                            a.keep_upright = true;-                        }-                    }-                }-            }-            let mut pad = Vec::new();-            wire_message(&mut pad, 3, b"1");-            let mut stack = Vec::new();-            wire_message(&mut stack, 6, &angle_bytes(0.0));-            wire_message(&mut pad, 6, &stack);-            wire_message(&mut pad, 7, &vector2_bytes(9 * MM, 20 * MM));-            let mut rect = Vec::new();-            wire_message(&mut rect, 1, &vector2_bytes(8 * MM, 19 * MM));-            wire_message(&mut rect, 2, &vector2_bytes(12 * MM, 21 * MM));-            let mut shape = Vec::new();-            wire_message(&mut shape, 5, &rect);-            let mut courtyard = Vec::new();-            wire_message(&mut courtyard, 1, &shape);-            wire_varint(&mut courtyard, 2, BoardLayerInfo::id_from_name("F.CrtYd").unwrap() as u64);-            p.definition = Some(Default::default());-            if let Some(def) = p.definition.as_mut() {-                def.items.push(any("Pad", pad));-                def.items.push(any("BoardGraphicShape", courtyard));-                def.items.push(any("Group", vec![0x12, 0x01, b'g']));-            }-        }-        let mv = placement::Move {-            reference: "U1".into(),-            uuid: "u1-uuid".into(),-            before: placement::Pose { x: 10.0, y: 20.0, rotation: 0.0, side: "F.Cu".into() },-            after: placement::Pose { x: 30.0, y: 40.0, rotation: 90.0, side: "F.Cu".into() },-            courtyard_before: placement::Rect { min_x: 8.0, min_y: 19.0, max_x: 12.0, max_y: 21.0 },-            courtyard_after: placement::Rect { min_x: 29.0, min_y: 38.0, max_x: 31.0, max_y: 42.0 },-            courtyard_source: "courtyard",-            flipped: false,-            outline_after: Vec::new(),-        };-        let out = build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[mv.clone()]).unwrap();-        assert_eq!(out.len(), 1);-        let EditablePcbItem::Footprint(f) = &out[0] else { panic!("expected a footprint, got {:?}", out[0]) };-        let p = f.proto();-        assert_eq!(p.id.as_ref().unwrap().value, "u1-uuid");-        let pos = p.position.as_ref().unwrap();-        assert_eq!((pos.x_nm, pos.y_nm), (30 * MM, 40 * MM));-        assert_eq!(p.orientation.as_ref().unwrap().value_degrees, 90.0);-        assert_eq!(p.layer, BoardLayerInfo::id_from_name("F.Cu").unwrap());-        // Everything else the editor sent rides along untouched.-        assert_eq!(p.locked, 1);-        assert_eq!(p.symbol_sheet_name, "Root");-        // The reference text turned about the anchor: 2 mm above becomes 2 mm to the left.-        let t = p.reference_field.as_ref().unwrap().text.as_ref().unwrap().text.as_ref().unwrap();-        let tp = t.position.as_ref().unwrap();-        assert_eq!((tp.x_nm, tp.y_nm), (28 * MM, 40 * MM));-        assert_eq!(t.attributes.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);-        assert!(t.attributes.as_ref().unwrap().keep_upright);-        assert_eq!(t.text, "U1");-        // The children, decoded by the IPC crate's own protobuf code: the pad 1 mm left of-        // the anchor is now 1 mm below it and turned by 90; the courtyard corners turned.-        let items = &p.definition.as_ref().unwrap().items;-        assert_eq!(items.len(), 3);-        match EditablePcbItem::from_any(items[0].clone()).unwrap() {-            EditablePcbItem::Pad(pad) => {-                let pp = pad.proto();-                assert_eq!(pp.number, "1");-                let v = pp.position.as_ref().unwrap();-                assert_eq!((v.x_nm, v.y_nm), (30 * MM, 41 * MM));-                assert_eq!(pp.pad_stack.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);-            }-            other => panic!("expected a pad, got {other:?}"),-        }-        match EditablePcbItem::from_any(items[1].clone()).unwrap() {-            EditablePcbItem::BoardGraphicShape(shape) => {-                assert_eq!(shape.proto().layer, BoardLayerInfo::id_from_name("F.CrtYd").unwrap());-                let corners: Vec<(i64, i64)> = format!("{:?}", shape.proto().shape.as_ref().unwrap().geometry)-                    .split("Vector2 { x_nm: ")-                    .skip(1)-                    .map(|s| {-                        let (x, rest) = s.split_once(", y_nm: ").unwrap();-                        (x.parse().unwrap(), rest.split(' ').next().unwrap().trim_end_matches(',').parse().unwrap())-                    })-                    .collect();-                assert_eq!(corners, vec![(29 * MM, 42 * MM), (31 * MM, 38 * MM)]);-            }-            other => panic!("expected a shape, got {other:?}"),-        }-        assert_eq!(items[2].value, vec![0x12, 0x01, b'g'], "a group passes through");-        // A child the bridge cannot move refuses the whole batch before any commit.-        let mut odd = fp.clone();-        odd.proto_mut().definition.as_mut().unwrap().items.push(any("Track", vec![]));-        let e = build_footprint_updates(vec![EditablePcbItem::Footprint(odd)], &[mv.clone()]).unwrap_err();-        assert_eq!(e.code, "unsupported_child_item");-        assert_eq!(e.detail["typeUrl"], "type.googleapis.com/kiapi.board.types.Track");-        assert_eq!(e.detail["mutated"], false);-        // A flip is refused too.-        let mut flip = mv.clone();-        flip.after.side = "B.Cu".into();-        flip.flipped = true;-        assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[flip]).unwrap_err().code, "side_change_not_supported");-        // A move whose footprint the editor no longer has is refused before any commit.-        let mut gone = mv.clone();-        gone.uuid = "nope".into();-        assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp)], &[gone]).unwrap_err().code, "footprint_not_found");-    }--    #[test]-    fn items_carry_kipy_units_net_and_layers() {-        let text = crate::pcb::tests::fixture_text();-        let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();-        let plan = pcb::plan(&board, &json!({"net": "NET_1", "points": ["J1.1", {"x": 112, "y": 85, "layer": "B.Cu"}, "J2.1"]})).unwrap();-        let items = build_items(&plan, &BoardNet { code: 7, name: "NET_1".into() }).unwrap();-        assert_eq!(items.len(), 3);-        match &items[0] {-            EditablePcbItem::Track(t) => {-                assert_eq!(t.start_nm().unwrap(), Vector2Nm { x_nm: 105_000_000, y_nm: 85_000_000 });-                assert_eq!(t.proto().width.as_ref().unwrap().value_nm, 250_000);-                assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("F.Cu").unwrap());-                assert_eq!(t.proto().net.as_ref().unwrap().name, "NET_1");-            }-            other => panic!("expected a track, got {other:?}"),-        }-        match &items[1] {-            EditablePcbItem::Track(t) => assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("B.Cu").unwrap()),-            other => panic!("expected a track, got {other:?}"),-        }-        match &items[2] {-            EditablePcbItem::Via(v) => {-                assert_eq!(v.position_nm().unwrap(), Vector2Nm { x_nm: 112_000_000, y_nm: 85_000_000 });-                let ps = v.proto().pad_stack.as_ref().unwrap();-                assert_eq!(ps.copper_layers[0].size.as_ref().unwrap().x_nm, 600_000);-                assert_eq!(ps.drill.as_ref().unwrap().diameter.as_ref().unwrap().x_nm, 300_000);-                assert_eq!(v.proto().net.as_ref().unwrap().name, "NET_1");-            }-            other => panic!("expected a via, got {other:?}"),-        }-        // The Python contract: a KiCad 10 board with an unknown layer name is refused before any IPC.-        let bad = Plan { net_name: "NET_1".into(), segments: vec![pcb::PlanSegment { start: [0.0, 0.0], end: [1.0, 0.0], layer: "Nope.Cu".into(), width: 0.2 }], vias: vec![], blocks: vec![] };-        assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer");-    }-}--/// Native rendered text bounds without changing the board.-fn text_spec_from_json(v:&Value)->RResult<kicad_ipc_rs::TextSpec> {- use kicad_ipc_rs::{TextSpec,TextAttributesSpec,TextHorizontalAlignment as H,TextVerticalAlignment as V};- let text=v["text"].as_str().ok_or_else(||RoutingError::new("invalid_text","text is required"))?;- let number=|k:&str,d:f64|->RResult<f64>{let n=v.get(k).map(|x|x.as_f64().unwrap_or(f64::NAN)).unwrap_or(d);if n.is_finite(){Ok(n)}else{Err(RoutingError::new("invalid_text",format!("{k} must be finite")))}};- let height=number("height",1.0)?;let width=number("width",height)?;let stroke=number("stroke",height*0.15)?;- if height<=0.0 || width<=0.0 || stroke<=0.0 || height>1000.0 || width>1000.0{return Err(RoutingError::new("invalid_text","positive dimensions in mm required; maximum text size 1000 mm"))}- let align=match v["align"].as_str().unwrap_or("center"){"left"=>H::Left,"center"=>H::Center,"right"=>H::Right,_=>return Err(RoutingError::new("invalid_text","align must be left, center or right"))};- let vertical=match v["verticalAlign"].as_str().unwrap_or("center"){"top"=>V::Top,"center"=>V::Center,"bottom"=>V::Bottom,_=>return Err(RoutingError::new("invalid_text","verticalAlign must be top, center or bottom"))};- Ok(TextSpec{text:text.into(),position_nm:Some(Vector2Nm{x_nm:(number("x",0.0)?*1e6).round() as i64,y_nm:(number("y",0.0)?*1e6).round() as i64}),attributes:Some(TextAttributesSpec{font_name:v["font"].as_str().map(str::to_string),horizontal_alignment:align,vertical_alignment:vertical,angle_degrees:Some(number("rotation",0.0)?),stroke_width_nm:Some((stroke*1e6).round() as i64),size_nm:Some(Vector2Nm{x_nm:(width*1e6).round() as i64,y_nm:(height*1e6).round() as i64}),mirrored:v["mirrored"].as_bool().unwrap_or(false),bold:v["bold"].as_bool().unwrap_or(false),italic:v["italic"].as_bool().unwrap_or(false),multiline:text.contains('\n'),..Default::default()}),hyperlink:None})-}-pub fn text_bounds(ctx:&Ctx,args:&Value)->RResult<Value>{- let rows=args["texts"].as_array().ok_or_else(||RoutingError::new("invalid_text","texts must be a nonempty array"))?;- if rows.is_empty() || rows.len()>2000{return Err(RoutingError::new("invalid_text","texts must contain 1..2000 entries"))}- let specs=rows.iter().map(text_spec_from_json).collect::<RResult<Vec<_>>>()?;- let s=connect(ctx,args)?;let revision=s.revision()?;if args.get("expectedRevision").is_some(){check_revision(args,&revision)?;}- let mut bounds=Vec::new();- for (row,spec) in rows.iter().zip(specs){let b=s.client.get_text_extents(spec).map_err(map_err)?;bounds.push(json!({"id":row["id"],"text":row["text"],"box":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]}));}- Ok(json!({"success":true,"mutated":false,"revision":revision,"source":"native-GetTextExtents","units":"mm","texts":bounds,"_hint":"Native axis-aligned rendered bounds at the requested rotation and justification. Keep holes, exposed pads and component bodies as separate obstacles. This does not place text or certify readability."}))-}-#[cfg(test)] mod text_metric_tests {- use super::*;- #[test] fn alignment_and_bottom_mirroring(){-  let s=text_spec_from_json(&json!({"text":"MC10\nDSHOT","x":12.5,"height":0.3,"stroke":0.05,"align":"right","mirrored":true,"rotation":90})).unwrap();assert_eq!(s.position_nm.unwrap().x_nm,12_500_000);let a=s.attributes.unwrap();assert_eq!(a.horizontal_alignment,kicad_ipc_rs::TextHorizontalAlignment::Right);assert!(a.mirrored&&a.multiline);assert_eq!(a.size_nm.unwrap().y_nm,300_000);-  assert!(text_spec_from_json(&json!({"text":"x","height":0})).is_err());assert!(text_spec_from_json(&json!({"text":"x","align":"random"})).is_err());- }-}--/// Update standalone silk text by stable UUID, in one native Undo transaction.-pub fn silk_text_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {-    use kicad_ipc_rs::BoardTextItem;-    let s = connect(ctx, args)?;-    let (text, _, revision) = s.snapshot()?;-    check_revision(args, &revision)?;-    let plan = crate::silk_text::prepare(&text, args)?;-    // Silkscreen clearance findings are often warnings. Compare all severities, not only errors.-    let all_severities = |mut v: Value| {-        if let Some(rows) = v["violations"].as_array_mut() {-            for row in rows {-                row["severity"] = json!("error");-            }-        }-        v-    };-    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;-    let after = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;-    let added = new_errors(&all_severities(before), &all_severities(after.clone()))?;-    if !added.is_empty() {-        return Err(RoutingError::new(-            "drc_rejected",-            "Silkscreen candidate adds native DRC findings; no live text changed",-        )-        .with("_hint", json!(silk_drc_hint(&added)))-        .with("violations", json!(added))-        .with("mutated", json!(false)));-    }-    if args["dryRun"] == true {-        return Ok(-            json!({"success":true,"mutated":false,"dryRun":true,"revision":revision,"drc":after}),-        );-    }-    let item = |t: &crate::silk_text::Text| {-        let mut item = BoardTextItem::from_proto(Default::default());-        item.set_layer_id(BoardLayerInfo::id_from_name(&t.layer).unwrap());-        let p = item.proto_mut();-        p.id = Some(Default::default());-        p.id.as_mut().unwrap().value = t.id.clone();-        p.locked = 1;-        p.knockout = false;-        p.text = Some(Default::default());-        let text = p.text.as_mut().unwrap();-        text.text = t.text.clone();-        text.position = Some(Default::default());-        let pos = text.position.as_mut().unwrap();-        pos.x_nm = nm(t.x);-        pos.y_nm = nm(t.y);-        text.attributes = Some(Default::default());-        let a = text.attributes.as_mut().unwrap();-        a.horizontal_alignment = match t.align.as_str() {-            "left" => 1,-            "right" => 3,-            _ => 2,-        };-        a.vertical_alignment = 2;-        a.angle = Some(Default::default());-        a.angle.as_mut().unwrap().value_degrees = t.rotation;-        a.line_spacing = 1.;-        a.stroke_width = Some(Default::default());-        a.stroke_width.as_mut().unwrap().value_nm = nm(t.stroke);-        a.size = Some(Default::default());-        a.size.as_mut().unwrap().x_nm = nm(t.width);-        a.size.as_mut().unwrap().y_nm = nm(t.height);-        a.mirrored = t.mirrored;-        a.multiline = t.text.contains('\n');-        a.visible = true;-        EditablePcbItem::BoardText(item)-    };-    let created_ids: Vec<String> = plan.create.iter().map(|t| t.id.clone()).collect();-    let updated_ids: Vec<String> = plan.update.iter().map(|t| t.id.clone()).collect();-    let validate_reply =-        |actual: Vec<EditablePcbItem>, wanted: &[String]| -> Result<(), IpcFailure> {-            let actual: HashSet<_> = actual-                .iter()-                .filter_map(|t| t.id().map(str::to_string))-                .collect();-            if actual != wanted.iter().cloned().collect() {-                return Err(IpcFailure::from(-                    "KiCad reply did not preserve every requested text UUID".to_string(),-                ));-            }-            Ok(())-        };-    check_revision(args, &s.revision()?)?;-    transaction(&s, "Adom silkscreen text", || {-        if !plan.remove.is_empty() {-            s.client.delete_items(plan.remove.clone())?;-        }-        if !plan.update.is_empty() {-            validate_reply(-                s.client-                    .update_editable_items(plan.update.iter().map(&item).collect())?,-                &updated_ids,-            )?;-        }-        if !plan.create.is_empty() {-            validate_reply(-                s.client-                    .create_editable_items(plan.create.iter().map(&item).collect(), None)?,-                &created_ids,-            )?;-        }-        Ok(())-    })?;-    let mut result = json!({"success":true,"mutated":true,"committed":true,"undoSteps":1,"createdIds":created_ids,"updatedIds":updated_ids,"removedIds":plan.remove,"drc":after,"saved":false,"source":"live-editor","_hint":"One native Undo step. Updates require full text definitions and preserve UUIDs; no content-based replacement. Inspect returned revision/bounds and refresh the linked 3D viewer. A postCommitError means committed: inspect state, never blindly replay."});-    if !plan.remove.is_empty() {-        match s-            .client-            .get_items_by_type_codes(vec![PcbObjectTypeCode::new_text().code])-        {-            Ok(rows) => {-                let remaining: Vec<_> = rows-                    .iter()-                    .filter_map(item_id)-                    .filter(|id| plan.remove.iter().any(|wanted| wanted == id))-                    .collect();-                result["deletionVerified"] = json!(remaining.is_empty());-                if !remaining.is_empty() {-                    result["postCommitDeletionError"] = json!({"remainingIds": remaining});-                    result["success"] = json!(false);-                }-            }-            Err(e) => {-                result["postCommitDeletionError"] = json!(e.to_string());-                result["success"] = json!(false);-            }-        }-    }--    match s.revision() {-        Ok(rev) => result["revision"] = json!(rev),-        Err(e) => result["postCommitError"] = json!(e.message),-    }-    let ids: Vec<_> = created_ids.iter().chain(&updated_ids).cloned().collect();-    if !ids.is_empty() {-        match s.client.get_item_bounding_boxes(ids,false){Ok(boxes)=>result["bounds"]=json!(boxes.iter().map(|b|json!({"id":b.item_id,"boxMm":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]})).collect::<Vec<_>>()),Err(e)=>result["postCommitBoundsError"]=json!(e.to_string())}-    }-    if args["save"] == true {-        match s.client.save_document() {-            Ok(()) => result["saved"] = json!(true),-            Err(e) => result["postCommitSaveError"] = json!(e.to_string()),-        }-    }-    Ok(result)-}---mod silk_fields;-pub use silk_fields::silk_fields_batch;--#[cfg(test)]-mod lib_table_tests {-    #[test]-    fn a_copied_library_table_points_kiprjmod_back_at_the_real_project() {-        let base = std::env::temp_dir().join(format!("adom-libtable-test-{}", std::process::id()));-        let proj = base.join("proj");-        let snap = base.join("snap");-        std::fs::create_dir_all(&proj).unwrap();-        std::fs::create_dir_all(&snap).unwrap();-        let table = "(fp_lib_table\n  (version 7)\n  (lib (name \"Load\")(type \"KiCad\")(uri \"${KIPRJMOD}/lib/Load.pretty\")(options \"\")(descr \"\"))\n)\n";-        std::fs::write(proj.join("fp-lib-table"), table).unwrap();-        super::copy_lib_table(&proj.join("fp-lib-table"), &snap.join("fp-lib-table"), &proj).unwrap();-        let out = std::fs::read_to_string(snap.join("fp-lib-table")).unwrap();-        let expect = format!("{}/lib/Load.pretty", proj.to_string_lossy().replace('\\', "/"));-        assert!(out.contains(&expect), "{out}");-        assert!(!out.contains("${KIPRJMOD}"));-        let _ = std::fs::remove_dir_all(&base);-    }-}+mod live_create;+pub use board_live::*;++type RResult<T> = Result<T, RoutingError>;++/// What the verbs know that the operations need: where kicad-cli is (for the DRC+/// snapshot) and KiCad's config dir (to say `api_server_off` honestly).+#[derive(Clone, Debug, Default)]+pub struct Ctx {+    pub kicad_cli: Option<PathBuf>,+    pub config_dir: Option<PathBuf>,+}++// ---------------------------------------------------------------------------+// Errors and the response shape+// ---------------------------------------------------------------------------++const HINT: &str = "Read kicad_routing_state with the exact filePath and socketPath. Enable Preferences > Plugins > Enable IPC API server in KiCad 10.0.1+ (kicad_ipc_api {\"enable\":true} writes the switch; KiCad must be closed and relaunched). After a mutation timeout inspect the live board; never blindly replay it.";++/// The Python `_error`: `success:false`, `errorCode`, `error`, every detail key, `_hint`.+pub fn error_response(e: &RoutingError) -> Value {+    let mut out = Map::new();+    out.insert("success".into(), json!(false));+    out.insert("errorCode".into(), json!(e.code));+    out.insert("error".into(), json!(e.message));+    for (k, v) in &e.detail {+        out.insert(k.clone(), v.clone());+    }+    out.entry("_hint").or_insert(json!(HINT));+    Value::Object(out)+}++/// Map a crate error to the routing contract. Timeouts become `ipc_timeout` (nothing of+/// ours is still running; KiCad may answer nobody later), transport failures+/// `ipc_unavailable`, no open board `no_board_open`, an ambiguous board `board_mismatch`,+/// and any KiCad status or decode failure `ipc_error`.+fn map_err(e: KiCadError) -> RoutingError {+    match e {+        KiCadError::Timeout { timeout } => RoutingError::new("ipc_timeout", format!("KiCad did not answer within {} ms; its UI thread may be blocked by a dialog or a long operation", timeout.as_millis()))+            .with("ipcTimeoutMs", json!(timeout.as_millis() as u64))+            .with("stillRunning", json!(false))+            .with("mutated", json!(false)),+        KiCadError::SocketUnavailable { socket_uri } => RoutingError::new("ipc_unavailable", format!("KiCad IPC socket not available at {socket_uri}; open the board in the PCB editor with the API server enabled")).with("socket", json!(socket_uri)),+        KiCadError::Connection { socket_uri, reason } => RoutingError::new("ipc_unavailable", format!("connection failed for {socket_uri}: {reason}")).with("socket", json!(socket_uri)),+        KiCadError::TransportSend { .. } | KiCadError::TransportReceive { .. } | KiCadError::TransportClosed | KiCadError::BlockingRuntimeClosed | KiCadError::RuntimeJoin(_) => RoutingError::new("ipc_unavailable", e.to_string()),+        KiCadError::BoardNotOpen => RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])),+        KiCadError::AmbiguousBoardSelection { boards } => RoutingError::new("board_mismatch", "This IPC endpoint exposes more than one board").with("openBoards", json!(boards)),+        // KiCad runs ONE API server per machine (the first KiCad process takes api.sock; a+        // later standalone pcbnew.exe gets none, measured on arav-rog 2026-09-11). When that+        // first process is the project manager with no PCB frame, board commands come back+        // AS_UNHANDLED. Name the situation instead of a bare status code.+        KiCadError::ApiStatus { code, message } if code == "AS_UNHANDLED" => RoutingError::new(+            "no_pcb_frame",+            "The KiCad process that owns the API socket has no PCB editor frame (a project manager alone, or the board is open in a second, standalone pcbnew.exe that got no API server)",+        )+        .with("apiStatus", json!({"code": code, "message": message}))+        .with("_hint", json!("KiCad serves one API socket per machine and the first KiCad process wins. Either close the project manager and open the board with kicad_open_board (standalone pcbnew then owns the socket), or open the board from within the project manager (kicad_launch with the project, then the PCB Editor button) so the frame lives in the process that has the socket. Then read kicad_routing_state again.")),+        other => RoutingError::new("ipc_error", other.to_string()),+    }+}++/// A failure inside a commit: the message plus whether it was a deadline, so the+/// transaction can say `mutation_outcome_unknown` with `stillRunning`.+#[derive(Clone, Debug, PartialEq)]+pub struct IpcFailure {+    pub message: String,+    pub timed_out: bool,+}++impl From<KiCadError> for IpcFailure {+    fn from(e: KiCadError) -> Self {+        IpcFailure { timed_out: matches!(e, KiCadError::Timeout { .. }), message: e.to_string() }+    }+}++impl From<String> for IpcFailure {+    fn from(message: String) -> Self {+        IpcFailure { message, timed_out: false }+    }+}++// ---------------------------------------------------------------------------+// Pure helpers (tested without KiCad)+// ---------------------------------------------------------------------------++/// The board revision: SHA-256 of the editor's own serialisation of the board, the way+/// `live_routing._snapshot` derives it. Any edit (ours or the user's) changes it.+/// Wiki #104: what to do about a silk refusal. A text_height / text_thickness finding comes from+/// the BOARD's own minimum, not from the bridge: say where that minimum lives.+pub fn silk_drc_hint(added: &[Value]) -> String {+    let types: Vec<&str> = added.iter().filter_map(|v| v.get("type").and_then(Value::as_str)).collect();+    if types.iter().any(|t| *t == "text_height" || *t == "text_thickness") {+        "Refused by the BOARD's minimum text size, not by the bridge: KiCad's DRC finds the new text smaller than Board Setup > Design Rules > Constraints (Minimum text height / thickness), or than a (constraint text_height (min ...)) rule in the board's .kicad_dru. Use text at or above that minimum, or lower the board minimum first if smaller text is intended; nothing was changed.".to_string()+    } else {+        "Each violation names the rule and the items involved: move the candidate (a dry run per spot is cheap), then submit again; nothing was changed.".to_string()+    }+}++pub fn revision_of(text: &str) -> String {+    crate::pcm::sha256_hex(text.as_bytes())+}++/// `_revision`: `expectedRevision` must match the live board (`stale_board`).+pub fn check_revision(args: &Value, revision: &str) -> RResult<()> {+    if args.get("expectedRevision").and_then(Value::as_str) != Some(revision) {+        return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board").with("currentRevision", json!(revision)).with("mutated", json!(false)));+    }+    Ok(())+}++/// `_path`: an absolute `.kicad_pcb` path, normalised for comparison (forward slashes,+/// no `\\?\` prefix, case folded, resolved through the filesystem when it exists).+pub fn normalize_board_path(value: Option<&Value>) -> RResult<String> {+    let Some(s) = value.and_then(Value::as_str) else {+        return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));+    };+    let looks_absolute = Path::new(s).is_absolute() || s.as_bytes().get(1) == Some(&b':') && s.as_bytes().get(2).map(|c| *c == b'/' || *c == b'\\').unwrap_or(false);+    if !looks_absolute {+        return Err(RoutingError::new("invalid_board_path", "filePath must be an absolute .kicad_pcb path"));+    }+    if !s.to_ascii_lowercase().ends_with(".kicad_pcb") {+        return Err(RoutingError::new("invalid_board_path", "filePath must name a .kicad_pcb"));+    }+    Ok(normalize_existing(s))+}++fn normalize_existing(s: &str) -> String {+    let resolved = std::fs::canonicalize(s).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| s.to_string());+    let resolved = resolved.replace('\\', "/");+    let resolved = resolved.strip_prefix("//?/").unwrap_or(&resolved).to_string();+    resolved.to_lowercase()+}++/// The absolute paths of every board open in the PCB editor that answers on the IPC+/// socket, or Err when no editor answers (API off, schematic-only, KiCad not running).+/// Wiki #122: the window title shows only the file name, so two boards with the same+/// basename in different folders are told apart by asking KiCad, never by the title.+pub fn open_board_paths(timeout: std::time::Duration) -> Result<Vec<String>, String> {+    let name = format!("adom-openpaths-{}", std::process::id());+    let client = KiCadClientBlocking::builder().client_name(name).timeout(timeout).connect().map_err(|e| format!("{e:?}"))?;+    let docs = client.get_open_documents(DocumentType::Pcb).map_err(|e| format!("{e:?}"))?;+    Ok(docs.iter().map(document_path).collect())+}++/// True when two board paths name the same file (case, separators and symlinks folded).+pub fn same_board_path(a: &str, b: &str) -> bool {+    normalize_existing(a) == normalize_existing(b)+}++/// The absolute path of an open document (`document_path` in the Python).+pub fn document_path(d: &DocumentSpecifier) -> String {+    let filename = d.board_filename.clone().unwrap_or_default();+    let p = Path::new(&filename);+    let absolute = p.is_absolute() || filename.as_bytes().get(1) == Some(&b':');+    if absolute {+        filename+    } else {+        match &d.project.path {+            Some(dir) => dir.join(&filename).to_string_lossy().to_string(),+            None => filename,+        }+    }+}++/// `_report_limits`: KiCad 10.0.3 DRC_ENGINE caps 199 markers per type, 499 for+/// clearance and unconnected items. At the boundary completeness is unknown.+pub fn report_limits(violations: &[Value], unconnected_len: usize) -> Value {+    let mut counts: Vec<(String, usize)> = Vec::new();+    for v in violations {+        let kind = match v.get("type") {+            Some(Value::String(s)) => s.clone(),+            Some(Value::Null) | None => "None".to_string(),+            Some(other) => other.to_string(),+        };+        match counts.iter_mut().find(|(k, _)| *k == kind) {+            Some(c) => c.1 += 1,+            None => counts.push((kind, 1)),+        }+    }+    let mut capped: Vec<String> = counts.into_iter().filter(|(k, n)| *n >= if k == "clearance" { 499 } else { 199 }).map(|(k, _)| k).collect();+    capped.sort();+    json!({+        "unconnectedReportMayBeTruncated": unconnected_len >= 499,+        "unconnectedCountIsLowerBound": unconnected_len >= 499,+        "violationReportMayBeTruncated": !capped.is_empty(),+        "possiblyTruncatedViolationTypes": capped,+    })+}++fn fingerprint(v: &Value) -> String {+    let mut positions: Vec<String> = v.get("items").and_then(Value::as_array).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null).to_string()).collect()).unwrap_or_default();+    positions.sort();+    json!([v.get("type"), v.get("severity"), v.get("description"), positions]).to_string()+}++/// `_new_errors`: the error-severity violations in `after` that `before` does not+/// account for, one to one (an existing error never masks a second identical one).+/// Refuses to certify a delta when either list may be capped (`drc_incomplete`).+pub fn new_errors(before: &Value, after: &Value) -> RResult<Vec<Value>> {+    let truncated = |r: &Value| r.get("violationReportMayBeTruncated").and_then(Value::as_bool).unwrap_or(false);+    if truncated(before) || truncated(after) {+        return Err(RoutingError::new("drc_incomplete", "DRC violation lists may be capped; cannot certify the error delta. No live copper was changed").with("mutated", json!(false)));+    }+    let errors = |r: &Value| -> Vec<Value> { r.get("violations").and_then(Value::as_array).map(|a| a.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).cloned().collect()).unwrap_or_default() };+    let mut existing: Vec<(String, usize)> = Vec::new();+    for v in errors(before) {+        let key = fingerprint(&v);+        match existing.iter_mut().find(|(k, _)| *k == key) {+            Some(c) => c.1 += 1,+            None => existing.push((key, 1)),+        }+    }+    let mut added = Vec::new();+    for v in errors(after) {+        let key = fingerprint(&v);+        match existing.iter_mut().find(|(k, n)| *k == key && *n > 0) {+            Some(c) => c.1 -= 1,+            None => added.push(v),+        }+    }+    Ok(added)+}++/// Copy a KiCad library table, replacing ${KIPRJMOD} with the original project folder so+/// the copy resolves the same libraries from wherever it is read.+pub fn copy_lib_table(src: &Path, dst: &Path, project_dir: &Path) -> std::io::Result<()> {+    let text = std::fs::read_to_string(src)?;+    let dir = project_dir.to_string_lossy().replace('\\', "/");+    let dir = dir.trim_end_matches('/');+    std::fs::write(dst, text.replace("${KIPRJMOD}", dir))+}++/// `_drc`: KiCad DRC on a disposable snapshot of the live board text, with the project's+/// own rules copied alongside and zones refilled so connectivity reflects the proposed+/// copper. The live document and the source file are never touched.+pub fn drc_snapshot(kicad_cli: Option<&Path>, source: &Path, text: &str) -> RResult<Value> {+    let Some(exe) = kicad_cli.filter(|p| p.is_file()) else {+        return Err(RoutingError::new("drc_unavailable", "kicad-cli is required for routing validation"));+    };+    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+    let tmp = std::env::temp_dir().join(format!("adom-routing-drc-{}-{n}", std::process::id()));+    let result = drc_in(exe, source, text, &tmp);+    let _ = std::fs::remove_dir_all(&tmp);+    result+}++fn drc_in(exe: &Path, source: &Path, text: &str, tmp: &Path) -> RResult<Value> {+    std::fs::create_dir_all(tmp).map_err(|e| RoutingError::new("drc_failed", format!("cannot create snapshot dir: {e}")))?;+    let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "board.kicad_pcb".into());+    let candidate = tmp.join(&name);+    std::fs::write(&candidate, text).map_err(|e| RoutingError::new("drc_failed", format!("cannot write snapshot: {e}")))?;+    // Preserve the actual project's rules and exclusions; never save/revert the live+    // document merely to get a headless DRC snapshot.+    let mut copied: Vec<String> = Vec::new();+    for ext in ["kicad_pro", "kicad_dru"] {+        let sibling = source.with_extension(ext);+        if sibling.exists() {+            std::fs::copy(&sibling, candidate.with_extension(ext)).map_err(|e|RoutingError::new("drc_rules_copy_failed",format!("Cannot preserve project rules {}: {e}",sibling.display())).with("mutated",json!(false)))?;+            copied.push(sibling.to_string_lossy().to_string());+        }+    }+    // Wiki #124: the project's library tables travel with the snapshot too, with ${KIPRJMOD}+    // pointed back at the real project folder, so project-local footprint libraries resolve+    // and DRC does not report them missing.+    if let Some(project_dir) = source.parent() {+        for table in ["fp-lib-table", "sym-lib-table"] {+            let src = project_dir.join(table);+            if src.is_file() {+                copy_lib_table(&src, &tmp.join(table), project_dir).map_err(|e| RoutingError::new("drc_rules_copy_failed", format!("Cannot preserve library table {}: {e}", src.display())).with("mutated", json!(false)))?;+                copied.push(src.to_string_lossy().to_string());+            }+        }+    }+    let report = tmp.join("drc.json");+    let report_s = report.to_string_lossy().to_string();+    let candidate_s = candidate.to_string_lossy().to_string();+    let r = cli::run(exe, &["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors", "--refill-zones", "--output", &report_s, &candidate_s], DRC_TIMEOUT);+    if r.timed_out {+        return Err(RoutingError::new("drc_timeout", "kicad-cli DRC on the snapshot timed out").with("cliElapsedMs", json!(r.elapsed_ms)));+    }+    let parsed = std::fs::read_to_string(&report).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok());+    let Some(result) = parsed.filter(|_| r.exit_code == 0) else {+        let msg = if !r.stderr.trim().is_empty() { r.stderr.trim().to_string() } else if !r.stdout.trim().is_empty() { r.stdout.trim().to_string() } else { "No DRC report".into() };+        return Err(RoutingError::new("drc_failed", msg).with("cliExitCode", json!(r.exit_code)));+    };+    Ok(drc_result(&result, copied))+}++/// Shape the raw kicad-cli report the way `_drc` returns it, plus `summary`: the same+/// `kicad_run_drc` summary (which counts unconnected items among its errors).+pub fn drc_result(report: &Value, project_rules_copied: Vec<String>) -> Value {+    let violations: Vec<Value> = report.get("violations").and_then(Value::as_array).cloned().unwrap_or_default();+    let unconnected: Vec<Value> = report.get("unconnected_items").and_then(Value::as_array).cloned().unwrap_or_default();+    let sev = |s: &str| violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some(s)).count();+    let mut out = json!({+        "errors": sev("error"),+        "warnings": sev("warning"),+        "unconnected": unconnected.len(),+        "violations": violations,+        "unconnectedItems": unconnected,+        "clean": violations.is_empty() && unconnected.is_empty(),+        "source": "live-editor-snapshot",+        "projectRulesCopied": project_rules_copied,+        "zonesRefilled": true,+        "summary": cli::summarize_report(report),+    });+    if let (Some(o), Some(limits)) = (out.as_object_mut(), report_limits(&violations, unconnected.len()).as_object()) {+        for (k, v) in limits {+            o.insert(k.clone(), v.clone());+        }+    }+    out+}++/// A KiCad commit session, abstracted so the transaction logic is testable without KiCad.+pub trait CommitTarget {+    fn begin(&self) -> Result<String, IpcFailure>;+    fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure>;+    fn drop_commit(&self, id: &str) -> Result<(), IpcFailure>;+}++/// `_transaction`: begin, run the operation, push. On any failure drop the commit and say+/// exactly what is known: `mutation_rolled_back` (nothing landed), or+/// `mutation_outcome_unknown` (`mutated: null`) when the push or the rollback itself+/// failed, with `stillRunning: true` when a deadline expired mid-commit.+pub fn transaction<T>(target: &dyn CommitTarget, message: &str, op: impl FnOnce() -> Result<T, IpcFailure>) -> RResult<T> {+    let id = target.begin().map_err(|e| RoutingError::new("mutation_rolled_back", e.message).with("mutated", json!(false)))?;+    let (failure, pushing) = match op() {+        Ok(result) => match target.push(&id, message) {+            Ok(()) => return Ok(result),+            Err(e) => (e, true),+        },+        Err(e) => (e, false),+    };+    let base = |code: &str| {+        let mut err = RoutingError::new(code, failure.message.clone());+        if failure.timed_out {+            err = err.with("ipcTimeout", json!(true));+        }+        err+    };+    match target.drop_commit(&id) {+        Err(rollback) => Err(base("mutation_outcome_unknown").with("rollbackError", json!(rollback.message)).with("mutated", Value::Null).with("stillRunning", json!(failure.timed_out || rollback.timed_out))),+        Ok(()) if pushing => Err(base("mutation_outcome_unknown").with("mutated", Value::Null).with("rollbackAttempted", json!(true)).with("stillRunning", json!(failure.timed_out))),+        Ok(()) => Err(base("mutation_rolled_back").with("mutated", json!(false))),+    }+}++// ---------------------------------------------------------------------------+// Connection+// ---------------------------------------------------------------------------++/// One connection to the PCB editor showing the requested board.+pub struct Session {+    client: KiCadClientBlocking,+    pub file_path: String,+    pub version: VersionInfo,+    pub socket: String,+    pub timeout: Duration,+}++impl CommitTarget for Session {+    fn begin(&self) -> Result<String, IpcFailure> {+        Ok(self.client.begin_commit()?.id)+    }+    fn push(&self, id: &str, message: &str) -> Result<(), IpcFailure> {+        Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Commit, message)?)+    }+    fn drop_commit(&self, id: &str) -> Result<(), IpcFailure> {+        Ok(self.client.end_commit(kicad_ipc_rs::CommitSession { id: id.to_string() }, kicad_ipc_rs::CommitAction::Drop, "")?)+    }+}++fn timeout_of(args: &Value) -> Duration {+    let ms = args.get("ipcTimeoutMs").and_then(Value::as_u64).unwrap_or(DEFAULT_TIMEOUT_MS).clamp(1000, 60_000);+    Duration::from_millis(ms)+}++/// `_connect`: validate the arguments, connect with a deadline, probe responsiveness+/// (`GetVersion`), require KiCad 10.0.1+, and require that the endpoint exposes exactly+/// the requested board.+pub fn connect(ctx: &Ctx, args: &Value) -> RResult<Session> {+    let wanted = normalize_board_path(args.get("filePath"))?;+    let file_path = args.get("filePath").and_then(Value::as_str).unwrap_or_default().to_string();+    let socket = match args.get("socketPath") {+        None | Some(Value::Null) => None,+        Some(Value::String(s)) if s.starts_with("ipc://") => Some(s.clone()),+        Some(_) => return Err(RoutingError::new("invalid_socket", "socketPath must be a local ipc:// socket")),+    };+    let timeout = timeout_of(args);+    let name = format!("adom-routing-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));+    let mut builder = KiCadClientBlocking::builder().client_name(name).timeout(timeout);+    if let Some(s) = &socket {+        builder = builder.socket_path(s.clone());+    }+    let client = match builder.connect() {+        Ok(c) => c,+        Err(e) => {+            let mut err = map_err(e);+            if err.code == "ipc_unavailable" {+                if crate::kicad_common::api_server_enabled(ctx.config_dir.as_deref()) == Some(false) {+                    err.code = "api_server_off".into();+                    err.message = format!("{}. KiCad's IPC API server switch is off in kicad_common.json", err.message);+                    err = err.with("_hint", json!("Save work and kicad_close, then kicad_ipc_api {\"enable\":true} (backup kept), then relaunch KiCad and open the board in the PCB editor. A running KiCad rewrites the file on exit, so close it first."));+                } else {+                    err = err.with("_hint", json!("Open the board in the PCB editor (KiCad 10.0.1+) with Preferences > Plugins > Enable IPC API server on. kicad_ipc_api {} reports the switch; the socket appears only while the PCB editor is running."));+                }+            }+            return Err(err);+        }+    };+    let socket_uri = client.socket_uri().to_string();+    // Responsiveness probe: the first round trip with the deadline.+    let version = client.get_version().map_err(map_err)?;+    if (version.major, version.minor, version.patch) < (10, 0, 1) {+        return Err(RoutingError::new("unsupported_kicad", "Live routing requires KiCad 10.0.1 or newer").with("kicadVersion", json!(version.full_version)));+    }+    let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;+    let open: Vec<String> = docs.iter().map(document_path).collect();+    if docs.is_empty() {+        return Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])).with("kicadVersion", json!(version.full_version)));+    }+    let matches = open.iter().filter(|p| normalize_existing(p) == wanted).count();+    if matches != 1 {+        return Err(RoutingError::new("board_mismatch", "This IPC endpoint does not expose exactly the requested board").with("openBoards", json!(open)).with("kicadVersion", json!(version.full_version)));+    }+    Ok(Session { client, file_path, version, socket: socket_uri, timeout })+}++impl Session {+    /// `_snapshot`: the editor's own serialisation, its parse, and the revision.+    pub fn snapshot(&self) -> RResult<(String, Board, String)> {+        let text = self.client.get_board_as_string().map_err(map_err)?;+        let board = pcb::parse_pcb_text(&text, &self.file_path).map_err(|e| RoutingError::new("board_unparsable", format!("could not parse the live board: {e}")))?;+        let revision = revision_of(&text);+        Ok((text, board, revision))+    }++    fn revision(&self) -> RResult<String> {+        Ok(revision_of(&self.client.get_board_as_string().map_err(map_err)?))+    }++    fn items_of(&self, codes: &[PcbObjectTypeCode]) -> RResult<Vec<PcbItem>> {+        self.client.get_items_by_type_codes(codes.iter().map(|c| c.code).collect()).map_err(map_err)+    }++    pub fn source_path(&self) -> PathBuf {+        PathBuf::from(&self.file_path)+    }+}++fn item_id(item: &PcbItem) -> Option<&str> {+    match item {+        PcbItem::Track(t) => t.id.as_deref(),+        PcbItem::Arc(a) => a.id.as_deref(),+        PcbItem::Via(v) => v.id.as_deref(),+        PcbItem::Footprint(f) => f.id.as_deref(),+        PcbItem::Pad(p) => p.id.as_deref(),+        PcbItem::BoardGraphicShape(s) => s.id.as_deref(),+        PcbItem::BoardText(t) => t.id.as_deref(),+        PcbItem::BoardTextBox(t) => t.id.as_deref(),+        PcbItem::Zone(z) => z.id.as_deref(),+        PcbItem::Dimension(d) => d.id.as_deref(),+        PcbItem::ReferenceImage(r) => r.id.as_deref(),+        PcbItem::Barcode(b) => b.id.as_deref(),+        PcbItem::Group(g) => g.id.as_deref(),+        PcbItem::Field(_) | PcbItem::Unknown(_) => None,+    }+}++fn item_locked(item: &PcbItem) -> bool {+    match item {+        PcbItem::Track(t) => t.locked.is_locked(),+        PcbItem::Arc(a) => a.locked.is_locked(),+        PcbItem::Via(v) => v.locked.is_locked(),+        _ => false,+    }+}++// ---------------------------------------------------------------------------+// Items+// ---------------------------------------------------------------------------++fn nm(mm: f64) -> i64 {+    (mm * 1_000_000.0).round() as i64+}++fn vector(p: [f64; 2]) -> Vector2Nm {+    Vector2Nm { x_nm: nm(p[0]), y_nm: nm(p[1]) }+}++/// `_items`: the plan as protobuf Track and Via items on the live net. Layer ids come+/// from the crate's canonical name table; via padstacks are what kipy builds (through,+/// normal padstack, one circular copper entry, F.Cu to B.Cu drill).+pub fn build_items(plan: &Plan, net: &BoardNet) -> RResult<Vec<EditablePcbItem>> {+    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));+    let f_cu = layer_id("F.Cu")?;+    let b_cu = layer_id("B.Cu")?;+    let mut items = Vec::with_capacity(plan.segments.len() + plan.vias.len());+    for seg in &plan.segments {+        let mut track = TrackItem::from_proto(Default::default());+        track.set_start_nm(vector(seg.start));+        track.set_end_nm(vector(seg.end));+        track.set_layer_id(layer_id(&seg.layer)?);+        let p = track.proto_mut();+        p.width = Some(Default::default());+        if let Some(w) = p.width.as_mut() {+            w.value_nm = nm(seg.width);+        }+        p.net = Some(Default::default());+        if let Some(n) = p.net.as_mut() {+            n.name = net.name.clone();+            n.code = Some(Default::default());+            if let Some(c) = n.code.as_mut() {+                c.value = net.code;+            }+        }+        items.push(EditablePcbItem::Track(track));+    }+    for v in &plan.vias {+        let mut via = ViaItem::from_proto(Default::default());+        via.set_position_nm(vector(v.at));+        let p = via.proto_mut();+        p.r#type = 1; // ViaType::VT_THROUGH+        p.net = Some(Default::default());+        if let Some(n) = p.net.as_mut() {+            n.name = net.name.clone();+            n.code = Some(Default::default());+            if let Some(c) = n.code.as_mut() {+                c.value = net.code;+            }+        }+        p.pad_stack = Some(Default::default());+        if let Some(ps) = p.pad_stack.as_mut() {+            ps.r#type = 1; // PadStackType::PST_NORMAL+            ps.copper_layers.push(Default::default());+            if let Some(cl) = ps.copper_layers.last_mut() {+                cl.layer = f_cu;+                cl.shape = 1; // PadStackShape::PSS_CIRCLE+                cl.size = Some(Default::default());+                if let Some(s) = cl.size.as_mut() {+                    s.x_nm = nm(v.size);+                    s.y_nm = nm(v.size);+                }+            }+            ps.drill = Some(Default::default());+            if let Some(d) = ps.drill.as_mut() {+                d.start_layer = f_cu;+                d.end_layer = b_cu;+                d.diameter = Some(Default::default());+                if let Some(s) = d.diameter.as_mut() {+                    s.x_nm = nm(v.drill);+                    s.y_nm = nm(v.drill);+                }+            }+        }+        items.push(EditablePcbItem::Via(via));+    }+    Ok(items)+}++// ---------------------------------------------------------------------------+// The four operations+// ---------------------------------------------------------------------------+++/// `kicad_select_net`: select every copper item of one net on the live board (tracks, arcs, vias,+/// zones and pads), so the editor shows the net's pours and traces together, highlighted, on+/// every layer; no cursor, no hotkey. Returns the ids selected per kind.+pub fn select_net(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let net = args.get("net").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("missing_arg", "Pass net: the net name as KiCad writes it (e.g. \"+VBAT\", \"/DRV_SHA\")"))?;+    let items = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via(), PcbObjectTypeCode::new_pad()])?;+    let mut ids: Vec<String> = Vec::new();+    let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default();+    let mut nets_seen: std::collections::BTreeSet<String> = Default::default();+    for it in &items {+        let (kind, item_net, id): (&str, Option<String>, Option<String>) = match it {+            PcbItem::Track(t) => ("tracks", t.net.as_ref().map(|n| n.name.clone()), t.id.clone()),+            PcbItem::Arc(a) => ("tracks", a.net.as_ref().map(|n| n.name.clone()), a.id.clone()),+            PcbItem::Via(v) => ("vias", v.net.as_ref().map(|n| n.name.clone()), v.id.clone()),+            PcbItem::Pad(p) => ("pads", p.net.as_ref().map(|n| n.name.clone()), p.id.clone()),+            _ => continue,+        };+        if let Some(n) = &item_net { if !n.is_empty() { nets_seen.insert(n.clone()); } }+        if item_net.as_deref() == Some(net) {+            if let Some(id) = id { ids.push(id); *kinds.entry(kind).or_insert(0) += 1; }+        }+    }+    // zones: the model has no net field; the raw proto does+    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;+    for a in &raws {+        if let Ok(z) = zones::decode_zone(&a.value) {+            if !z.net.is_empty() { nets_seen.insert(z.net.clone()); }+            if z.net == net && !z.rule_area { ids.push(z.id.clone()); *kinds.entry("zones").or_insert(0) += 1; }+        }+    }+    if ids.is_empty() {+        return Err(RoutingError::new("unknown_net", format!("No copper item on the live board carries net '{net}'")).with("netsOnBoard", json!(nets_seen.into_iter().take(200).collect::<Vec<_>>())));+    }+    if args.get("add").and_then(Value::as_bool) != Some(true) {+        s.client.clear_selection().map_err(map_err)?;+    }+    let count = ids.len();+    s.client.add_to_selection(ids.clone()).map_err(map_err)?;+    Ok(json!({+        "success": true, "mutated": false, "source": "live-editor", "net": net, "selected": count, "byKind": kinds, "itemIds": ids,+        "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_send_key {hwnd, menu:\"Zoom to Selected Objects\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",+    }))+}++/// `kicad_select_clear`: clear the live editor's selection.+pub fn select_clear(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    s.client.clear_selection().map_err(map_err)?;+    Ok(json!({"success": true, "mutated": false, "source": "live-editor", "_hint": "Selection cleared."}))+}++/// `kicad_routing_state`: pads, copper, revision and KiCad-measured connectivity.+pub fn routing_state(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (_text, data, revision) = s.snapshot()?;+    let pads = pcb::pads(&data);+    let front_pads = pcb::back_footprints_with_front_pads(&data);+    let live_pads: Vec<(String, String)> = s+        .items_of(&[PcbObjectTypeCode::new_pad()])?+        .into_iter()+        .filter_map(|i| match i {+            PcbItem::Pad(p) => Some((p.id?, p.net.map(|n| n.name).unwrap_or_default())),+            _ => None,+        })+        .collect();+    let mut names: Vec<String> = data.nets.iter().map(|n| n.name.clone()).filter(|n| !n.is_empty()).collect();+    names.sort();+    names.dedup();+    let mut nets = Vec::new();+    for name in names {+        let net_pads: Vec<&str> = live_pads.iter().filter(|(_, n)| *n == name).map(|(id, _)| id.as_str()).collect();+        let mut remaining: Vec<&str> = net_pads.clone();+        let mut groups: Vec<Vec<&str>> = Vec::new();+        while let Some(seed) = remaining.first().copied() {+            let mut seen: HashSet<String> = HashSet::new();+            seen.insert(seed.to_string());+            let mut frontier: Vec<String> = vec![seed.to_string()];+            while !frontier.is_empty() {+                let neighbors = s.client.get_connected_items(frontier.clone(), Vec::new()).map_err(map_err)?;+                frontier = neighbors.iter().filter_map(item_id).filter(|id| !seen.contains(*id)).map(str::to_string).collect();+                seen.extend(frontier.iter().cloned());+                if seen.len() > MAX_WALK {+                    return Err(RoutingError::new("board_too_large", format!("Connectivity walk exceeds {MAX_WALK} items")));+                }+            }+            let group: Vec<&str> = remaining.iter().copied().filter(|id| seen.contains(*id)).collect();+            remaining.retain(|id| !seen.contains(*id));+            groups.push(group);+        }+        nets.push(json!({+            "name": name, "padCount": net_pads.len(), "padGroups": groups,+            "connected": groups.len() <= 1, "missingConnections": groups.len().saturating_sub(1),+        }));+    }+    let current = s.revision()?;+    if current != revision {+        return Err(RoutingError::new("stale_board", "Board changed during connectivity inspection; read again").with("currentRevision", json!(current)));+    }+    let remaining: Vec<Value> = nets.iter().filter(|n| n["connected"] == json!(false)).map(|n| n["name"].clone()).collect();+    let mut out = json!({+        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",+        "kicadVersion": s.version.full_version, "socket": s.socket,+        "pads": pads, "nets": nets, "netsRemaining": remaining,+        "segments": data.segments, "vias": data.vias, "copperLayers": pcb::copper_layers(&data),+        "_hint": "Use this revision as expectedRevision in kicad_route_net. Coordinates are mm, +y down. Connectivity is measured by KiCad; kicad_routing_validate checks clearance and unconnected items.",+    });+    if !front_pads.is_empty() {+        out["backFootprintsWithFrontPads"] = json!(front_pads);+        out["_frontPadsHint"] = json!(pcb::FRONT_PADS_HINT);+    }+    Ok(out)+}++/// The one board open in the PCB editor, when the caller named none (the standard's routing_state+/// reads the current board, as altium_routing_state does).+fn with_open_board(args: &Value) -> RResult<Value> {+    if args.get("filePath").and_then(Value::as_str).is_some() {+        return Ok(args.clone());+    }+    let mut builder = KiCadClientBlocking::builder().client_name(format!("adom-model-{}", std::process::id())).timeout(timeout_of(args));+    if let Some(sock) = args.get("socketPath").and_then(Value::as_str) {+        builder = builder.socket_path(sock.to_string());+    }+    let client = builder.connect().map_err(map_err)?;+    let docs = client.get_open_documents(DocumentType::Pcb).map_err(map_err)?;+    let open: Vec<String> = docs.iter().map(document_path).collect();+    match open.as_slice() {+        [one] => {+            let mut a = args.clone();+            a["filePath"] = json!(one);+            Ok(a)+        }+        [] => Err(RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([]))),+        _ => Err(RoutingError::new("board_mismatch", "More than one board is open: name one with filePath").with("openBoards", json!(open))),+    }+}++/// The live board's text as the editor serialises it, with its path and KiCad's version: what the+/// standard verbs (model, stack-up, pours) build from. `filePath` may be left out when one board is open.+pub fn board_text(_ctx: &Ctx, args: &Value) -> RResult<(String, String, String)> {+    let args = with_open_board(args)?;+    let s = connect(_ctx, &args)?;+    let text = s.client.get_board_as_string().map_err(map_err)?;+    Ok((text, s.file_path.clone(), s.version.full_version.clone()))+}++/// A standard revision (16 hex digits, `board_model::revision_of_text`) as opposed to the editor's+/// text hash (64) the edit verbs compare.+pub fn is_standard_revision(r: &str) -> bool {+    r.len() == 16 && r.bytes().all(|b| b.is_ascii_hexdigit())+}++/// Let an edit verb take the standard revision: when `expectedRevision` is one, read the live board;+/// if its copper, parts and outline are what the caller read, hand the verb the editor's current text+/// hash (its own guard then runs unchanged), else refuse with `stale_board`. Returns the arguments to+/// use and whether they were translated.+pub fn accept_standard_revision(ctx: &Ctx, args: &Value) -> RResult<(Value, bool)> {+    let Some(want) = args.get("expectedRevision").and_then(Value::as_str).filter(|r| is_standard_revision(r)) else {+        return Ok((args.clone(), false));+    };+    let s = connect(ctx, args)?;+    let text = s.client.get_board_as_string().map_err(map_err)?;+    let now = crate::board_model::revision_of_text(&text).map_err(|e| RoutingError::new("board_unparsable", e))?;+    if now != want {+        return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board: its copper, parts or outline changed since it was read")+            .with("currentRevision", json!(now)).with("mutated", json!(false)));+    }+    let mut a = args.clone();+    a["expectedRevision"] = json!(revision_of(&text));+    Ok((a, true))+}++/// Run an edit verb so it accepts either revision; after a translated call the reply's `revision` is+/// the standard one (the editor's text hash stays as `kicadRevision`).+pub fn with_standard_revision(ctx: &Ctx, args: &Value, run: impl FnOnce(&Value) -> RResult<Value>) -> RResult<Value> {+    let (args, translated) = accept_standard_revision(ctx, args)?;+    let mut v = run(&args)?;+    if translated && v["success"] == json!(true) {+        if let Ok(s) = connect(ctx, &args) {+            if let Ok(text) = s.client.get_board_as_string() {+                if let Ok(r) = crate::board_model::revision_of_text(&text) {+                    v["kicadRevision"] = json!(revision_of(&text));+                    v["revision"] = json!(r);+                }+            }+        }+    }+    Ok(v)+}++/// `kicad_route_net`: DRC preflight on a snapshot, then one commit of the planned copper.+pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = pcb::plan(&data, args)?;+    let mut check = Value::Null;+    if args.get("validate") != Some(&json!(false)) {+        let source = s.source_path();+        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;+        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&text, &plan.blocks))?;+        let added = new_errors(&before, &after)?;+        if !added.is_empty() {+            return Err(RoutingError::new("drc_rejected", "Candidate route adds DRC errors; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));+        }+        check = after;+    }+    let public = plan.public();+    if args.get("dryRun") == Some(&json!(true)) {+        let mut out = json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "_hint": "Plan validated without editing. Submit with dryRun:false and the same revision."});+        merge(&mut out, &public);+        return Ok(out);+    }+    // DRC runs outside KiCad. Re-read immediately before begin_commit so another+    // caller's or the user's edits during validation invalidate this plan.+    check_revision(args, &s.revision()?)?;+    let nets = s.client.get_nets().map_err(map_err)?;+    let Some(net) = nets.into_iter().find(|n| n.name == plan.net_name) else {+        return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", plan.net_name)).with("mutated", json!(false)));+    };+    let items = build_items(&plan, &net)?;+    let count = items.len();+    let created = transaction(&s, &format!("Adom route {}", plan.net_name), || {+        let created = s.client.create_editable_items(items, None)?;+        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {+            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));+        }+        Ok(created)+    })?;+    let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor",+        "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,+        "_hint": "Copper committed live as one KiCad Undo step. Inspect routing_state before the next route. The file is saved only with save:true; routing_validate checks the unsaved live board.",+    });+    merge(&mut result, &public);+    // Commit succeeded. A subsequent read/save failure must NEVER be reported as a failed+    // route inviting the caller to add it twice.+    match s.revision() {+        Ok(r) => {+            result["revision"] = json!(r);+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => result["saved"] = json!(true),+                    Err(e) => result["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// Commit copper the caller already planned (a Freerouting session's wires and vias for+/// one net) as ONE native Undo step named `message`, with the same revision guard as+/// `route_net` (`expectedRevision` must match the live board). No DRC preflight: the+/// caller checks the whole result afterwards with `routing_validate`. Used by+/// `kicad_autoroute {engine:"freerouting", apply:"ipc"}`; `route_net` is untouched.+pub fn commit_copper(ctx: &Ctx, args: &Value, plan: &Plan, message: &str) -> RResult<Value> {+    let s = connect(ctx, args)?;+    check_revision(args, &s.revision()?)?;+    let nets = s.client.get_nets().map_err(map_err)?;+    let Some(net) = nets.into_iter().find(|n| n.name == plan.net_name) else {+        return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", plan.net_name)).with("mutated", json!(false)));+    };+    let items = build_items(plan, &net)?;+    let count = items.len();+    let created = transaction(&s, message, || {+        let created = s.client.create_editable_items(items, None)?;+        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {+            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));+        }+        Ok(created)+    })?;+    let ids: Vec<String> = created.iter().filter_map(|i| i.id().map(str::to_string)).collect();+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor", "netName": plan.net_name,+        "itemIds": ids, "undoSteps": 1, "undoLabel": message,+        "segmentCount": plan.segments.len(), "viaCount": plan.vias.len(), "saved": false,+    });+    match s.revision() {+        Ok(r) => result["revision"] = json!(r),+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// `kicad_remove_route`: delete specific unlocked copper items as one Undo step.+pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let ids: Vec<String> = match args.get("itemIds").and_then(Value::as_array) {+        Some(a) if !a.is_empty() && a.len() <= 1024 && a.iter().all(Value::is_string) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        _ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..1024 copper itemIds returned by route_net")),+    };+    let wanted: HashSet<String> = ids.iter().cloned().collect();+    let copper = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via()])?;+    let items: Vec<&PcbItem> = copper.iter().filter(|i| item_id(i).map(|id| wanted.contains(id)).unwrap_or(false)).collect();+    if items.len() != wanted.len() || items.iter().any(|i| item_locked(i)) {+        return Err(RoutingError::new("invalid_item_ids", "Some IDs are missing, locked, or are not copper; nothing removed"));+    }+    if args.get("dryRun") == Some(&json!(true)) {+        return Ok(remove_preview(ctx, &s, &text, &data, &revision, &ids, args));+    }+    check_revision(args, &s.revision()?)?;+    let to_delete: Vec<String> = wanted.iter().cloned().collect();+    transaction(&s, "Adom remove route", || {+        let deleted: HashSet<String> = s.client.delete_items(to_delete.clone())?.into_iter().collect();+        if deleted != wanted {+            return Err(IpcFailure::from("KiCad did not accept every requested copper deletion".to_string()));+        }+        Ok(())+    })?;+    let mut result = json!({+        "success": true, "mutated": true, "removedIds": ids, "undoSteps": 1,+        "_hint": "Copper removed live. Use KiCad Undo to restore it, or inspect routing_state and route again.",+    });+    match s.snapshot() {+        Ok((_, after, rev)) => {+            result["revision"] = json!(rev);+            let present: HashSet<String> = after.segments.iter().filter_map(|x| x.uuid.clone()).chain(after.vias.iter().filter_map(|x| x.uuid.clone())).collect();+            let remaining: Vec<String> = wanted.iter().filter(|id| present.contains(*id)).cloned().collect();+            if !remaining.is_empty() {+                let mut removed: Vec<String> = wanted.iter().filter(|id| !present.contains(*id)).cloned().collect();+                removed.sort();+                result["removedIds"] = json!(removed);+                result["postCommitError"] = json!("Some requested copper remains after commit; inspect the live board");+            }+            result["verified"] = json!(remaining.is_empty());+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// The layers the live PCB editor shows, by name (`kicad_layers`' displayed flags).+pub fn visible_layer_names(ctx: &Ctx, args: &Value) -> RResult<Vec<String>> {+    let s = connect(ctx, args)?;+    Ok(s.client.get_visible_layers().map_err(map_err)?.into_iter().map(|l| l.name).collect())+}++/// `kicad_ripup_nets`: every unlocked track, arc and via of the named nets, removed live as one+/// Undo step through remove_route (its dry run, DRC preview and read-back included).+pub fn ripup_nets(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let nets: Vec<String> = match args.get("nets") {+        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        Some(Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+        _ => Vec::new(),+    };+    if nets.is_empty() {+        return Err(RoutingError::new("missing_arg", "nets is required: [\"GND\", \"/SDA\"]").with("mutated", json!(false)));+    }+    let s = connect(ctx, args)?;+    let (_text, _data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let copper = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via()])?;+    let net_of = |i: &PcbItem| -> Option<String> {+        match i {+            PcbItem::Track(t) => t.net.as_ref().map(|n| n.name.clone()),+            PcbItem::Arc(a) => a.net.as_ref().map(|n| n.name.clone()),+            PcbItem::Via(v) => v.net.as_ref().map(|n| n.name.clone()),+            _ => None,+        }+    };+    let mut per_net: std::collections::BTreeMap<String, usize> = nets.iter().map(|n| (n.clone(), 0)).collect();+    let mut locked = 0usize;+    let mut ids = Vec::new();+    for i in &copper {+        let Some(n) = net_of(i) else { continue };+        let Some(key) = nets.iter().find(|w| w.eq_ignore_ascii_case(&n)) else { continue };+        if item_locked(i) {+            locked += 1;+            continue;+        }+        if let Some(id) = item_id(i) {+            ids.push(id.to_string());+            *per_net.entry(key.clone()).or_default() += 1;+        }+    }+    if ids.is_empty() {+        return Ok(json!({"success": true, "mutated": false, "undoSteps": 0, "revision": revision, "perNet": per_net, "lockedKept": locked,+            "_hint": "No unlocked copper on those nets: nothing to rip up. Net names match case-insensitively, as kicad_list_nets spells them."}));+    }+    let mut a = args.clone();+    a["itemIds"] = json!(ids);+    let mut out = remove_route(ctx, &a)?;+    out["nets"] = json!(nets);+    out["perNet"] = json!(per_net);+    out["lockedKept"] = json!(locked);+    Ok(out)+}++/// `kicad_remove_route {dryRun:true}` (wiki #134): what would go and KiCad's DRC of the snapshot+/// without it. Nothing is written.+fn remove_preview(ctx: &Ctx, s: &Session, text: &str, data: &Board, revision: &str, ids: &[String], args: &Value) -> Value {+    let items: Vec<Value> = ids.iter().map(|id| {+        if let Some(t) = data.segments.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {+            json!({"id": id, "kind": "track", "net": t.net_name, "layer": t.layer, "start": t.start, "end": t.end, "width": t.width})+        } else if let Some(v) = data.vias.iter().find(|x| x.uuid.as_deref() == Some(id.as_str())) {+            json!({"id": id, "kind": "via", "net": v.net_name, "position": v.position, "size": v.size, "drill": v.drill})+        } else {+            json!({"id": id, "kind": "arc"})+        }+    }).collect();+    let (without, cut) = strip_items(text, ids);+    let mut out = json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "wouldRemove": items,+        "_hint": "Nothing was removed. drc is KiCad's DRC of the board without these items: unconnectedAfter is what removing them leaves to route. Submit with dryRun:false and the same revision to remove them as one Undo step."});+    if args.get("validate") == Some(&json!(false)) {+        return out;+    }+    if cut != ids.len() {+        out["drc"] = json!({"available": false, "why": format!("{} of {} items were found in the board text", cut, ids.len())});+        return out;+    }+    let source = s.source_path();+    match (drc_snapshot(ctx.kicad_cli.as_deref(), &source, text), drc_snapshot(ctx.kicad_cli.as_deref(), &source, &without)) {+        (Ok(before), Ok(after)) => {+            out["unconnectedBefore"] = before["unconnected"].clone();+            out["unconnectedAfter"] = after["unconnected"].clone();+            out["newErrors"] = json!(new_errors(&before, &after).unwrap_or_default());+            out["drc"] = after;+        }+        (Err(e), _) | (_, Err(e)) => out["drc"] = json!({"available": false, "errorCode": e.code, "why": e.message}),+    }+    out+}++/// The board text with the top-level items carrying these UUIDs cut out, and how many were cut.+pub fn strip_items(text: &str, ids: &[String]) -> (String, usize) {+    // the spans of the root list's direct children, quotes respected+    let mut spans: Vec<(usize, usize)> = Vec::new();+    let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);+    for (i, c) in text.char_indices() {+        if quoted {+            if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { quoted = false }+            continue;+        }+        match c {+            '"' => quoted = true,+            '(' => {+                depth += 1;+                if depth == 2 { start = i; }+            }+            ')' => {+                if depth == 2 { spans.push((start, i + 1)); }+                depth -= 1;+            }+            _ => {}+        }+    }+    let mut out = String::with_capacity(text.len());+    let (mut last, mut cut) = (0, 0);+    for (a, b) in spans {+        let item = &text[a..b];+        let kind_ok = item.starts_with("(segment") || item.starts_with("(arc") || item.starts_with("(via");+        if kind_ok && ids.iter().any(|id| item.contains(&format!("(uuid \"{id}\")")) || item.contains(&format!("(tstamp \"{id}\")"))) {+            out.push_str(&text[last..a]);+            last = b;+            cut += 1;+        }+    }+    out.push_str(&text[last..]);+    (out, cut)+}++/// `kicad_routing_validate`: KiCad DRC on the live, unsaved board snapshot.+pub fn routing_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, _, revision) = s.snapshot()?;+    let drc = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;+    let current = s.revision()?;+    let mut out = json!({+        "success": true, "revision": revision, "currentRevision": current, "stale": current != revision,+        "_hint": "This is KiCad DRC on the live editor snapshot with sibling project rules. A stale result is superseded by newer edits. clean requires no violations or unconnected items.",+    });+    merge(&mut out, &drc);+    Ok(out)+}++// ---------------------------------------------------------------------------+// Zones: kicad_add_zone, kicad_zone_state+// ---------------------------------------------------------------------------++/// `kicad_add_zone`: one or more copper zones (pours) as ONE native Undo step, with the+/// same revision guard and DRC preflight as `route_net`: the zones' s-expressions are+/// spliced into the editor's snapshot, KiCad's DRC (zones refilled) must add no error, or+/// nothing lands. After the commit the new zones are filled (`refill:false` skips that).+pub fn add_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let specs = zones::parse_specs(&data, args)?;+    let blocks: Vec<String> = specs.iter().map(zones::zone_sexpr).collect();+    let mut check = Value::Null;+    if args.get("validate") != Some(&json!(false)) {+        let source = s.source_path();+        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;+        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&text, &blocks))?;+        let added = new_errors(&before, &after)?;+        if !added.is_empty() {+            return Err(RoutingError::new("drc_rejected", "The zone adds DRC errors once filled; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));+        }+        check = after;+    }+    let public: Vec<Value> = specs.iter().map(zones::ZoneSpec::public).collect();+    if args.get("dryRun") == Some(&json!(true)) {+        return Ok(json!({"success": true, "dryRun": true, "revision": revision, "drc": check, "zones": public,+            "_hint": "Zones validated (filled and DRC-checked on a snapshot) without editing. Submit with dryRun:false and the same revision."}));+    }+    check_revision(args, &s.revision()?)?;+    let nets = s.client.get_nets().map_err(map_err)?;+    let mut items = Vec::with_capacity(specs.len());+    for z in &specs {+        let Some(net) = nets.iter().find(|n| n.name == z.net.name) else {+            return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", z.net.name)).with("mutated", json!(false)));+        };+        let mut ids = Vec::with_capacity(z.layers.len());+        for l in &z.layers {+            ids.push(BoardLayerInfo::id_from_name(l).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {l}")))?);+        }+        items.push(prost_types::Any { type_url: zones::ZONE_TYPE_URL.to_string(), value: zones::zone_bytes(z, &ids, net.code) });+    }+    let count = items.len();+    let label = format!("Adom zone {}", specs.iter().map(|z| z.net.name.clone()).collect::<Vec<_>>().join(", "));+    let created = transaction(&s, &label, || {+        let created = s.client.create_items(items, None)?;+        if created.len() != count {+            return Err(IpcFailure::from("KiCad did not create every requested zone".to_string()));+        }+        Ok(created)+    })?;+    let ids: Vec<String> = created.iter().filter_map(|a| zones::zone_id(&a.value)).collect();+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor",+        "itemIds": ids, "undoSteps": 1, "zones": public, "drc": check, "saved": false, "refilled": false,+        "_hint": "Zones committed live as one KiCad Undo step and filled. kicad_zone_state reads the filled copper per layer; kicad_routing_validate is the DRC on the live board. The file is saved only with save:true.",+    });+    if ids.len() != count {+        result["postCommitError"] = json!(format!("KiCad answered with {} zone ids for {} zones", ids.len(), count));+    }+    // KiCad 10.0.x answers AS_UNIMPLEMENTED to RefillZones with a list of zone ids; an empty+    // list means "every zone" and works, so that is what the bridge sends.+    let mut settled = true;+    if args.get("refill") != Some(&json!(false)) && !ids.is_empty() {+        let before = s.revision().ok();+        match s.client.refill_zones(Vec::new()) {+            Ok(()) => {+                // Wiki #115: KiCad fills ASYNCHRONOUSLY. Reading the revision straight after the+                // request returned the pre-fill board, the fill then changed it, and the caller's+                // next edit was refused as stale_board. Wait for the fill to land and the revision+                // to hold still, bounded; say so honestly if it never does.+                let (_, ok) = settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message));+                settled = ok;+                result["refilled"] = json!(ok);+            }+            Err(e) => result["postCommitError"] = json!(format!("zones created but not filled: {e}")),+        }+    }+    result["revisionSettled"] = json!(settled);+    if !settled {+        result["_hint"] = json!("Zones committed as one Undo step, but KiCad was still filling when this reply was written, so the revision below may already be stale. Call kicad_zone_state (refill:false) for a settled revision before the next edit; a stale one is refused with stale_board and changes nothing.");+    }+    match s.revision() {+        Ok(r) => {+            result["revision"] = json!(r);+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => result["saved"] = json!(true),+                    Err(e) => result["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// Reconcile only explicitly owned zones in one native undo transaction. Always DRC+/// the complete candidate first; preserve unrelated zones and require a revision.+pub fn apply_zone_plan(ctx: &Ctx, args: &Value) -> RResult<Value> {+    if args["expectedRevision"]+        .as_str()+        .filter(|s| !s.is_empty())+        .is_none()+    {+        return Err(RoutingError::new(+            "revision_required",+            "Read kicad_zone_state first and pass its expectedRevision",+        ));+    }+    // Refill is a separate asynchronous native edit and Undo step. Keep this+    // transaction's revision usable by leaving refill to an explicit caller step.+    if args["refill"] == true {+        return Err(RoutingError::new("separate_refill_required", "Apply with refill:false (the default), then call kicad_zone_state with refill:true and read its final revision").with("mutated", json!(false)));+    }+    let s = connect(ctx, args)?;+    let (text, board, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = crate::zone_plan::prepare(&text, &board, args)?;+    if plan.remove.is_empty() && plan.create.is_empty() {+        // No candidate differs from the current board. A capped inherited DRC+        // report must not turn an idempotent read into a failed mutation.+        return Ok(json!({+            "success":true,"mutated":false,"undoSteps":0,"revision":revision,+            "dryRun":args["dryRun"]==true,"unchangedIds":plan.unchanged,+            "ownedItemIds":plan.unchanged,"itemIds":[],"removedIds":[],+            "drcRerun":false,"_hint":"Native zone definitions already match; no edit or DRC rerun. This is not a DRC-clean certification."+        }));+    }++    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;+    let check = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;+    let added = new_errors(&before, &check)?;+    if !added.is_empty() {+        return Err(RoutingError::new(+            "drc_rejected",+            "Full zone plan adds native DRC errors; nothing changed",+        )+        .with("violations", json!(added))+        .with("mutated", json!(false)));+    }+    if args["dryRun"] == true {+        return Ok(+            json!({"success":true,"mutated":false,"dryRun":args["dryRun"]==true,"revision":revision,"unchangedIds":plan.unchanged,"wouldRemoveIds":plan.remove,"wouldCreate":plan.create.iter().map(zones::ZoneSpec::public).collect::<Vec<_>>(),"drc":check,"undoSteps":0}),+        );+    }+    let nets = s.client.get_nets().map_err(map_err)?;+    let mut items = Vec::new();+    for z in &plan.create {+        let net_code = if z.keepout.is_some() {+            0+        } else {+            nets.iter()+                .find(|n| n.name == z.net.name)+                .ok_or_else(|| {+                    RoutingError::new("unknown_net", format!("No native net {:?}", z.net.name))+                })?+                .code+        };+        let layers = z+            .layers+            .iter()+            .map(|l| {+                BoardLayerInfo::id_from_name(l)+                    .ok_or_else(|| RoutingError::new("unknown_layer", l.clone()))+            })+            .collect::<RResult<Vec<_>>>()?;+        items.push(prost_types::Any {+            type_url: zones::ZONE_TYPE_URL.into(),+            value: zones::zone_bytes(z, &layers, net_code),+        });+    }+    check_revision(args, &s.revision()?)?;+    let created = transaction(&s, "Adom apply zone plan", || {+        if !plan.remove.is_empty() {+            s.client.delete_items(plan.remove.clone())?;+            // KiCad applies deletions at commit. Readback here sees the old board.+        }+        let created = if items.is_empty() {+            Vec::new()+        } else {+            s.client.create_items(items.clone(), None)?+        };+        if created.len() != items.len() {+            return Err(IpcFailure::from(+                "Not every planned zone was created; rollback requested".to_string(),+            ));+        }+        Ok(created)+    })?;+    let ids: Vec<String> = created+        .iter()+        .filter_map(|a| zones::zone_id(&a.value))+        .collect();+    let mut owned = plan.unchanged.clone();+    owned.extend(ids.clone());+    let mut out = json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"itemIds":ids,"ownedItemIds":owned,"unchangedIds":plan.unchanged,"removedIds":plan.remove,"drc":check,"saved":false,"refilled":false,"_hint":"Full plan committed as one native Undo step. Inspect postCommitError and kicad_zone_state before retrying. Ownership is only the returned ownedItemIds; never infer it from a zone name or age."});+    if ids.len() != plan.create.len() {+        out["postCommitError"] = json!(+            "Native creation returned incomplete zone IDs; inspect the board before any retry"+        );+    }+    out["refillRequired"] = json!(true);+    if !plan.remove.is_empty() {+        match s+            .client+            .get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code])+        {+            Ok(rows) => {+                let remaining: Vec<_> = rows+                    .iter()+                    .filter_map(|a| zones::zone_id(&a.value))+                    .filter(|id| plan.remove.contains(id))+                    .collect();+                out["deletionVerified"] = json!(remaining.is_empty());+                if !remaining.is_empty() {+                    out["postCommitDeletionError"] = json!({"remainingIds": remaining});+                    out["success"] = json!(false);+                }+            }+            Err(e) => {+                out["postCommitDeletionError"] = json!(e.to_string());+                out["success"] = json!(false);+            }+        }+    }+    match s.revision() {+        Ok(r) => out["revision"] = json!(r),+        Err(e) => out["postCommitError"] = json!(e.message),+    }+    if args["save"] == true {+        match s.client.save_document() {+            Ok(()) => out["saved"] = json!(true),+            Err(e) => out["postCommitError"] = json!(e.to_string()),+        }+    }+    Ok(out)+}++/// `kicad_remove_zone`: delete zones (copper pours or keepouts) by id as one Undo step. Only+/// zone items are accepted, so a pad, track or footprint id is refused with nothing removed.+pub fn remove_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    check_revision(args, &s.revision()?)?;+    let ids: Vec<String> = match args.get("itemIds").or_else(|| args.get("zoneIds")) {+        Some(Value::Array(a)) if !a.is_empty() && a.len() <= 256 => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        _ => return Err(RoutingError::new("invalid_item_ids", "Supply 1..256 zone itemIds returned by kicad_add_zone or kicad_zone_state")),+    };+    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;+    let mut found = Vec::new();+    for a in &raws {+        if let Ok(z) = zones::decode_zone(&a.value) {+            if ids.contains(&z.id) {+                found.push(z);+            }+        }+    }+    if found.len() != ids.len() {+        let have: Vec<&str> = found.iter().map(|z| z.id.as_str()).collect();+        let missing: Vec<&String> = ids.iter().filter(|i| !have.contains(&i.as_str())).collect();+        return Err(RoutingError::new("invalid_item_ids", "Some IDs are not zones on the live board; nothing removed").with("missing", json!(missing)).with("mutated", json!(false)));+    }+    let count = ids.len();+    let label = format!("Adom remove {} zone(s)", count);+    let removed = transaction(&s, &label, || {+        let removed = s.client.delete_items(ids.clone())?;+        if removed.len() != count {+            return Err(IpcFailure::from(format!("KiCad removed {} of {} zones", removed.len(), count)));+        }+        Ok(removed)+    })?;+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor", "removed": removed, "undoSteps": 1, "saved": false,+        "zones": found.iter().map(|z| json!({"id": z.id, "name": z.name, "net": z.net, "type": if z.rule_area { "keepout" } else { "copper" }})).collect::<Vec<_>>(),+        "_hint": "Zones removed as one KiCad Undo step. Other zones' fills may be stale: kicad_zone_state {refill:true} refills them.",+    });+    match s.revision() {+        Ok(r) => result["revision"] = json!(r),+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++/// Wait for an asynchronous zone fill to land: the revision must first move away from `before`+/// (a new zone always changes the board when it fills), then read identically three times in a+/// row. A fill that changes nothing is accepted once the revision has stayed at `before` for 3 s.+/// Read errors (KiCad busy mid-fill) count as "not yet". Bounded at 12 s; returns+/// (last revision, settled).+pub fn settle_revision(before: Option<&str>, mut read: impl FnMut() -> Result<String, String>) -> (Option<String>, bool) {+    settle_revision_with(before, &mut read, std::time::Duration::from_millis(300), std::time::Duration::from_secs(3), std::time::Duration::from_secs(12))+}++fn settle_revision_with(before: Option<&str>, read: &mut dyn FnMut() -> Result<String, String>, step: std::time::Duration, quiet: std::time::Duration, limit: std::time::Duration) -> (Option<String>, bool) {+    let start = std::time::Instant::now();+    let (mut last, mut same, mut moved): (Option<String>, u32, bool) = (None, 0, false);+    loop {+        if let Ok(r) = read() {+            moved |= before != Some(r.as_str());+            if last.as_deref() == Some(r.as_str()) { same += 1 } else { same = 0 }+            last = Some(r);+            if moved && same >= 2 {+                return (last, true);+            }+            if !moved && same >= 2 && start.elapsed() >= quiet {+                return (last, true);+            }+        }+        if start.elapsed() >= limit {+            return (last, false);+        }+        std::thread::sleep(step);+    }+}++fn refill_requested(board_path: &str) -> Value {+    json!({+        "success": true, "boardPath": board_path, "source": "live-editor",+        "refillRequested": true, "refilled": false, "revisionUsable": false,+        "_hint": "Refill was requested, not confirmed complete. KiCad fills asynchronously. Wait and call kicad_zone_state with refill:false; retry read-only calls while busy. Use the later snapshot's revision, never replay refill after a timeout. No revision or copper measurements are returned in this acknowledgement."+    })+}++/// `kicad_zone_state`: every zone on the live board with its filled copper area per layer+/// (KiCad's own fill polygons), the sum per layer and the coverage against the outline+/// box. `refill:true` refills every zone first (the fill can be stale after routing).+pub fn zone_state(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    if args.get("refill") == Some(&json!(true)) {+        s.client.refill_zones(Vec::new()).map_err(map_err)?;+        // Acknowledgement precedes asynchronous fill; no stale snapshot escapes.+        return Ok(refill_requested(&s.file_path));+    }+    let (text, data, revision) = s.snapshot()?;+    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;+    let mut read = Vec::with_capacity(raws.len());+    for a in &raws {+        read.push(zones::decode_zone(&a.value).map_err(|e| RoutingError::new("zone_undecodable", format!("KiCad returned a zone the bridge could not decode: {e}")))?);+    }+    let mut out = json!({+        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",+        "kicadVersion": s.version.full_version, "socket": s.socket, "refilled": false,+        "_hint": "filledAreaByLayerMm2 is the copper KiCad's zones keep on each layer: for a copper-ablation process, what does not have to be milled away. Add pours with kicad_add_zone; kicad_routing_validate is the DRC.",+    });+    merge(&mut out, &zones::state_json(&read, &data));+    out["definitions"] = crate::zone_plan::readback(&text).map_err(|e|RoutingError::new("zone_undecodable",e))?;+    Ok(out)+}++// ---------------------------------------------------------------------------+// Placement: kicad_placement_state, kicad_move_footprint, kicad_placement_validate+// ---------------------------------------------------------------------------++pub const PLACEMENT_STATE_HINT: &str = "Use this revision as expectedRevision in kicad_move_footprint. Coordinates are mm, +y down; courtyard boxes are board-frame and per side. Cluster by nets[].refs, place decoupling caps beside their IC, keep the power stage together, and watch ratsnest.totalMm fall. kicad_placement_validate is the check.";++/// `kicad_placement_state`, live: the editor's own board text parsed for footprints,+/// courtyards, nets and the ratsnest estimate. Read-only.+pub fn placement_state(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (_text, board, revision) = s.snapshot()?;+    let detail = args.get("detail") == Some(&json!(true));+    let mut out = json!({+        "success": true, "boardPath": s.file_path, "revision": revision, "source": "live-editor",+        "kicadVersion": s.version.full_version, "socket": s.socket,+        "_hint": PLACEMENT_STATE_HINT,+    });+    merge(&mut out, &placement::state(&board, detail));+    if let Some(exp) = args.get("expectedRevision").and_then(Value::as_str) {+        out["stale"] = json!(exp != revision);+    }+    Ok(out)+}++/// The moved footprints as KiCad will store them. KiCad's `UpdateItems` does not edit a+/// footprint in place: it builds a new one from this proto (`FOOTPRINT::Deserialize`),+/// removes the old one and adds the new one, and Deserialize takes every child (pads,+/// fields, shapes, texts, zones, ...) at the ABSOLUTE board coordinates the proto+/// carries. So besides `position` and `orientation` this moves every child with the+/// same rigid-body transform (`placement::ChildTransform`): the four mandatory fields+/// through the typed proto, every `definition.items` payload on the wire+/// (`placement::transform_child`). A child type without a transform rule refuses the+/// whole move (`unsupported_child_item`); a side change is refused+/// (`side_change_not_supported`), so `layer` is only ever re-set to what it was. Every+/// move must have its item, or the editor no longer holds that footprint+/// (`footprint_not_found`).+pub fn build_footprint_updates(items: Vec<EditablePcbItem>, moves: &[placement::Move]) -> RResult<Vec<EditablePcbItem>> {+    let layer_id = |name: &str| BoardLayerInfo::id_from_name(name).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {name}")));+    let mut out = Vec::with_capacity(moves.len());+    for m in moves {+        if m.flipped || m.after.side != m.before.side {+            return Err(placement::side_change_error(&m.reference, &m.before.side, &m.after.side));+        }+        let found = items.iter().find(|i| i.id() == Some(m.uuid.as_str()));+        let Some(EditablePcbItem::Footprint(fp)) = found else {+            return Err(RoutingError::new("footprint_not_found", format!("The live board has no footprint with id {} ({}); read kicad_placement_state again", m.uuid, m.reference)).with("mutated", json!(false)));+        };+        let mut fp = fp.clone();+        place_footprint_proto(&mut fp, (nm(m.after.x), nm(m.after.y), m.after.rotation), &m.reference)?;+        let p = fp.proto_mut();+        p.layer = layer_id(&m.after.side)?;+        out.push(EditablePcbItem::Footprint(fp));+    }+    Ok(out)+}++/// Put a footprint proto at a new pose (`to`: x and y in nm, rotation in degrees) the way KiCad's+/// `UpdateItems` and `CreateItems` need it: KiCad rebuilds the footprint from the proto and takes+/// every child at the ABSOLUTE board coordinates it carries, so the children move with the same+/// rigid-body transform. The layer is left as it is.+pub(crate) fn place_footprint_proto(fp: &mut kicad_ipc_rs::FootprintItem, to: (i64, i64, f64), reference: &str) -> RResult<()> {+    let p = fp.proto_mut();+    // The old pose is the proto's own: that is the frame the children are in.+    let (ox, oy) = p.position.as_ref().map(|v| (v.x_nm, v.y_nm)).unwrap_or((0, 0));+    let orot = p.orientation.as_ref().map(|a| a.value_degrees).unwrap_or(0.0);+    let xf = placement::ChildTransform::new((ox, oy, orot), to);+    for field in [&mut p.reference_field, &mut p.value_field, &mut p.datasheet_field, &mut p.description_field].into_iter().flatten() {+        if let Some(text) = field.text.as_mut().and_then(|t| t.text.as_mut()) {+            if let Some(v) = text.position.as_mut() {+                let (x, y) = xf.point(v.x_nm, v.y_nm);+                v.x_nm = x;+                v.y_nm = y;+            }+            if let Some(a) = text.attributes.as_mut().and_then(|a| a.angle.as_mut()) {+                a.value_degrees = xf.angle(a.value_degrees);+            }+        }+    }+    if let Some(def) = p.definition.as_mut() {+        for item in def.items.iter_mut() {+            item.value = placement::transform_child(&xf, &item.type_url, &item.value).map_err(|e| {+                RoutingError::new("unsupported_child_item", format!("Footprint {} carries a {} item the bridge cannot move ({}); nothing was moved", reference, e.type_url, e.reason))+                    .with("typeUrl", json!(e.type_url))+                    .with("ref", json!(reference))+                    .with("supportedTypes", json!(placement::CHILD_TYPES))+                    .with("mutated", json!(false))+                    .with("_hint", json!("KiCad replaces the whole footprint on an IPC update, so the bridge moves every child of the proto itself and refuses rather than move one wrongly. Move this footprint in the PCB editor, or rotate by a multiple of 90 degrees when the reason names one."))+            })?;+        }+    }+    p.position = Some(Default::default());+    if let Some(pos) = p.position.as_mut() {+        pos.x_nm = to.0;+        pos.y_nm = to.1;+    }+    p.orientation = Some(Default::default());+    if let Some(o) = p.orientation.as_mut() {+        o.value_degrees = to.2;+    }+    Ok(())+}++/// `kicad_move_footprint`: one footprint or a batch, checked (revision, courtyards,+/// outline) then committed through `update_items` as ONE native Undo step. `dryRun`+/// runs the checks and reports without touching the editor.+pub fn move_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text_before, board, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = placement::plan_moves(&board, args)?;+    let public = placement::plan_json(&plan);+    if args.get("dryRun") == Some(&json!(true)) {+        let mut out = json!({+            "success": true, "dryRun": true, "mutated": false, "revision": revision, "source": "live-editor", "undoSteps": 0,+            "_hint": "Checks passed without editing. Submit with dryRun:false and the same revision.",+        });+        merge(&mut out, &public);+        return Ok(out);+    }+    // The checks ran on a snapshot; re-read right before the commit so an edit in between+    // (the user's, or another caller's) invalidates this plan instead of racing it.+    check_revision(args, &s.revision()?)?;+    let ids: Vec<String> = plan.moves.iter().map(|m| m.uuid.clone()).collect();+    let items = s.client.get_editable_items_by_id(ids).map_err(map_err)?;+    let updates = build_footprint_updates(items, &plan.moves)?;+    let count = updates.len();+    transaction(&s, &plan.undo_label, || {+        let updated = s.client.update_editable_items(updates)?;+        if updated.len() != count {+            return Err(IpcFailure::from("KiCad did not update every requested footprint".to_string()));+        }+        Ok(())+    })?;+    let mut result = json!({+        "success": true, "mutated": true, "source": "live-editor", "undoSteps": 1, "saved": false,+        "_hint": "Footprint(s) moved live as one KiCad Undo step; verified means the read-back courtyard (or first pad) moved with the anchor, not just the anchor. Read kicad_placement_state for the new revision before the next move; kicad_placement_validate checks courtyards, the outline and KiCad DRC. The file is saved only with save:true.",+    });+    merge(&mut result, &public);+    // Commit succeeded. A read or save failure after it is reported as postCommitError,+    // never as a failed move that invites a replay.+    match s.snapshot() {+        Ok((text_after, after, rev)) => {+            result["revision"] = json!(rev);+            let moved: Vec<String> = plan.moves.iter().map(|m| m.uuid.clone()).collect();+            let dropped: Vec<Value> = crate::silk_fields::dropped_closing_vertices(&text_before, &text_after, &moved)+                .into_iter()+                .map(|mut d| {+                    let r = plan.moves.iter().find(|m| Some(m.uuid.as_str()) == d["id"].as_str()).map(|m| m.reference.clone());+                    d["ref"] = json!(r);+                    d+                })+                .collect();+            if !dropped.is_empty() {+                result["closingVerticesDroppedByKiCad"] = json!(dropped);+                result["_libraryParityHint"] = json!(crate::silk_fields::CLOSING_VERTEX_HINT);+            }+            let places = placement::placements(&after);+            let close = |a: f64, b: f64| (a - b).abs() < 0.0005;+            let mut all_match = true;+            let mut stayed: Vec<String> = Vec::new();+            let verified: Vec<Value> = plan+                .moves+                .iter()+                .map(|m| match places.iter().zip(after.footprints.iter()).find(|(p, _)| p.reference == m.reference) {+                    Some((p, after_fp)) => {+                        let pose_ok = close(p.x, m.after.x) && close(p.y, m.after.y) && close(placement_angle(p.rotation), placement_angle(m.after.rotation)) && p.side == m.after.side;+                        // The anchor alone proves nothing (KiCad rebuilds the footprint from+                        // the proto): the courtyard or first pad must have moved with it.+                        let children = board.footprints.iter().find(|f| f.reference == m.reference).map(|before_fp| placement::check_children(m, before_fp, after_fp));+                        let children_ok = children.as_ref().map(|c| c.ok).unwrap_or(false);+                        if !children_ok {+                            stayed.push(children.as_ref().map(|c| c.describe(&m.reference)).unwrap_or_else(|| format!("{} was not on the board before the move", m.reference)));+                        }+                        let ok = pose_ok && children_ok;+                        all_match &= ok;+                        json!({"ref": m.reference, "x": p.x, "y": p.y, "rotation": p.rotation, "side": p.side, "courtyard": p.courtyard, "insideOutline": p.inside_outline, "matches": ok, "poseMatches": pose_ok, "children": children})+                    }+                    None => {+                        all_match = false;+                        json!({"ref": m.reference, "matches": false, "missing": true})+                    }+                })+                .collect();+            result["after"] = json!(verified);+            result["verified"] = json!(all_match);+            if !stayed.is_empty() {+                result["postCommitError"] = json!(format!("children did not move: {}", stayed.join("; ")));+            } else if !all_match {+                result["postCommitError"] = json!("The live board does not show every footprint at its requested pose; inspect kicad_placement_state");+            }+            let (_, total) = placement::nets(&after);+            result["ratsnest"] = json!({"totalMm": total});+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => result["saved"] = json!(true),+                    Err(e) => result["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    Ok(result)+}++fn placement_angle(d: f64) -> f64 {+    let mut r = d % 360.0;+    if r < 0.0 {+        r += 360.0;+    }+    r+}++/// `kicad_placement_validate`, live: courtyard overlaps, footprints outside the outline+/// or parked, the ratsnest total, and KiCad DRC on the live snapshot filtered to the+/// placement-class violations.+pub fn placement_validate(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, board, revision) = s.snapshot()?;+    let mut out = json!({+        "success": true, "revision": revision, "source": "live-editor",+        "_hint": "placed requires zero courtyard overlaps and nothing outside the outline; drc.placementViolations is KiCad's own verdict on courtyards, edge clearance, silk and pad clearance. A stale result is superseded by newer edits.",+    });+    merge(&mut out, &placement::validate(&board));+    out["drc"] = match drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text) {+        Ok(d) => placement::drc_filter(&d),+        Err(e) => json!({"available": false, "errorCode": e.code, "reason": e.message}),+    };+    let current = s.revision()?;+    out["currentRevision"] = json!(current);+    out["stale"] = json!(current != revision);+    Ok(out)+}++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());+        }+    }+}++// ---------------------------------------------------------------------------+// Tests (no live KiCad)+// ---------------------------------------------------------------------------++#[cfg(test)]+mod tests {+    #[test]+    fn a_fill_that_lands_late_is_waited_for_and_a_stuck_one_is_reported() {+        use std::time::Duration;+        let ms = Duration::from_millis;+        // the pre-fill revision twice, a busy read, then the filled board that holds still+        let mut seq = vec![Ok("A".to_string()), Ok("A".to_string()), Err("busy".to_string()),+                           Ok("B".to_string()), Ok("B".to_string()), Ok("B".to_string())].into_iter();+        let (r, ok) = settle_revision_with(Some("A"), &mut || seq.next().unwrap_or(Ok("B".into())), ms(1), ms(50), ms(500));+        assert_eq!((r.as_deref(), ok), (Some("B"), true)); // never the stale "A"+        // a fill that changes nothing: accepted once quiet long enough+        let (r, ok) = settle_revision_with(Some("A"), &mut || Ok("A".to_string()), ms(1), ms(20), ms(500));+        assert_eq!((r.as_deref(), ok), (Some("A"), true));+        // a board that never stops changing: reported unsettled at the limit+        let mut n = 0;+        let (_, ok) = settle_revision_with(Some("A"), &mut || { n += 1; Ok(format!("R{n}")) }, ms(1), ms(20), ms(60));+        assert!(!ok);+    }++    #[test]+    fn refill_acknowledgement_never_certifies_a_stale_snapshot() {+        let reply = super::refill_requested("board.kicad_pcb");+        assert_eq!(reply["refillRequested"], true);+        assert_eq!(reply["refilled"], false);+        assert_eq!(reply["revisionUsable"], false);+        assert!(reply.get("revision").is_none());+        assert!(reply.get("filledAreaByLayerMm2").is_none());+    }++    #[test]+    fn zone_plan_refill_refuses_before_ipc_or_mutation() {+        let error = super::apply_zone_plan(&super::Ctx::default(), &serde_json::json!({+            "expectedRevision": "test", "refill": true+        })).unwrap_err();+        assert_eq!(error.code, "separate_refill_required");+        assert_eq!(super::error_response(&error)["mutated"], false);+    }++    use super::*;+    use std::cell::RefCell;++    #[test]+    fn strip_items_cuts_only_the_named_copper() {+        let text = "(kicad_pcb (version 1)\n\t(segment (start 0 0) (end 1 0) (uuid \"a\"))\n\t(via (at 1 0) (uuid \"b\"))\n\t(gr_text \"a (uuid \\\"a\\\")\" (uuid \"c\"))\n\t(segment (start 2 0) (end 3 0) (uuid \"d\"))\n)";+        let (out, n) = strip_items(text, &["a".to_string(), "b".to_string()]);+        assert_eq!(n, 2);+        assert!(!out.contains("(uuid \"a\"))") && !out.contains("(uuid \"b\")"));+        assert!(out.contains("(uuid \"c\")") && out.contains("(uuid \"d\")"));+    }++    #[test]+    fn revision_is_sha256_of_the_board_text() {+        assert_eq!(revision_of("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");+        assert_ne!(revision_of("(kicad_pcb)"), revision_of("(kicad_pcb)\n"));+        assert!(check_revision(&json!({"expectedRevision": "x"}), "x").is_ok());+        for rev in [Value::Null, json!("old"), json!(1)] {+            let e = check_revision(&json!({"expectedRevision": rev}), "new").unwrap_err();+            assert_eq!(e.code, "stale_board");+            assert_eq!(e.detail["currentRevision"], "new");+            assert_eq!(e.detail["mutated"], false);+        }+        assert_eq!(check_revision(&json!({}), "new").unwrap_err().code, "stale_board");+    }++    #[test]+    fn board_path_validation_and_normalisation() {+        assert_eq!(normalize_board_path(None).unwrap_err().code, "invalid_board_path");+        assert_eq!(normalize_board_path(Some(&json!("relative/x.kicad_pcb"))).unwrap_err().code, "invalid_board_path");+        assert_eq!(normalize_board_path(Some(&json!("C:/x/y.kicad_sch"))).unwrap_err().code, "invalid_board_path");+        assert_eq!(normalize_board_path(Some(&json!("C:\\Users\\J\\Board.kicad_pcb"))).unwrap(), "c:/users/j/board.kicad_pcb");+        assert_eq!(normalize_board_path(Some(&json!("/tmp/nope/Board.kicad_pcb"))).unwrap(), "/tmp/nope/board.kicad_pcb");+        let d = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };+        assert_eq!(normalize_existing(&document_path(&d)), "/tmp/proj/live.kicad_pcb");+        let abs = DocumentSpecifier { document_type: DocumentType::Pcb, board_filename: Some("C:/p/live.kicad_pcb".into()), project: kicad_ipc_rs::model::common::ProjectInfo { name: None, path: Some(PathBuf::from("/tmp/proj")) } };+        assert_eq!(document_path(&abs), "C:/p/live.kicad_pcb");+    }++    #[test]+    fn capped_opens_are_lower_bound_without_blocking_error_comparison() {+        let limits = report_limits(&[], 499);+        assert_eq!(limits["unconnectedCountIsLowerBound"], true);+        assert_eq!(limits["violationReportMayBeTruncated"], false);+        let mut before = json!({"violations": []});+        merge(&mut before, &limits);+        assert_eq!(new_errors(&before, &before).unwrap(), Vec::<Value>::new());+        assert_eq!(report_limits(&[], 498)["unconnectedCountIsLowerBound"], false);+    }++    #[test]+    fn per_type_caps_block_error_delta_even_when_reports_match() {+        for (kind, cap) in [("clearance", 499usize), ("shorting_items", 199)] {+            let v = json!({"type": kind, "severity": "error", "items": []});+            let under: Vec<Value> = vec![v.clone(); cap - 1];+            assert_eq!(report_limits(&under, 0)["violationReportMayBeTruncated"], false);+            let at: Vec<Value> = vec![v.clone(); cap];+            let mut report = json!({"violations": at});+            let limits = report_limits(report["violations"].as_array().unwrap(), 0);+            assert_eq!(limits["possiblyTruncatedViolationTypes"], json!([kind]));+            merge(&mut report, &limits);+            let e = new_errors(&report, &report).unwrap_err();+            assert_eq!(e.code, "drc_incomplete");+            assert_eq!(e.detail["mutated"], false);+        }+    }++    #[test]+    fn existing_error_does_not_mask_additional_same_error() {+        let v = json!({"type": "clearance", "severity": "error", "description": "collision", "items": [{"pos": {"x": 1, "y": 2}}]});+        let w = json!({"type": "clearance", "severity": "warning", "description": "collision", "items": []});+        let added = new_errors(&json!({"violations": [v, w]}), &json!({"violations": [v, v, w, w]})).unwrap();+        assert_eq!(added, vec![v.clone()]);+        // Item order inside a violation does not change its identity.+        let a = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 1}}, {"pos": {"x": 2}}]});+        let b = json!({"type": "t", "severity": "error", "description": "d", "items": [{"pos": {"x": 2}}, {"pos": {"x": 1}}]});+        assert!(new_errors(&json!({"violations": [a]}), &json!({"violations": [b]})).unwrap().is_empty());+    }++    #[test]+    fn drc_result_has_the_python_shape() {+        let report = json!({+            "$schema": "x", "kicad_version": "10.0.5", "source": "board.kicad_pcb",+            "violations": [+                {"type": "clearance", "severity": "error", "description": "Clearance violation", "items": [{"description": "Track", "pos": {"x": 1.0, "y": 2.0}}]},+                {"type": "lib_footprint_issues", "severity": "warning", "description": "Footprint not found in libraries", "items": []},+            ],+            "unconnected_items": [{"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": []}],+        });+        let r = drc_result(&report, vec!["/p/board.kicad_pro".into()]);+        assert_eq!(r["errors"], 1);+        assert_eq!(r["warnings"], 1);+        assert_eq!(r["unconnected"], 1);+        assert_eq!(r["clean"], false);+        assert_eq!(r["zonesRefilled"], true);+        assert_eq!(r["source"], "live-editor-snapshot");+        assert_eq!(r["projectRulesCopied"], json!(["/p/board.kicad_pro"]));+        assert_eq!(r["violationReportMayBeTruncated"], false);+        assert_eq!(r["unconnectedItems"].as_array().unwrap().len(), 1);+        // `summary` is the bridge's kicad_run_drc summary, which counts unconnected items as errors too.+        assert_eq!(r["summary"]["errors"], 2);+        assert_eq!(r["summary"]["unconnected"], 1);+        assert_eq!(drc_result(&json!({"violations": [], "unconnected_items": []}), vec![])["clean"], true);+    }++    #[test]+    fn drc_snapshot_needs_kicad_cli() {+        let e = drc_snapshot(None, Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();+        assert_eq!(e.code, "drc_unavailable");+        let e = drc_snapshot(Some(Path::new("/definitely/not/kicad-cli")), Path::new("/tmp/x.kicad_pcb"), "(kicad_pcb)").unwrap_err();+        assert_eq!(e.code, "drc_unavailable");+    }++    /// A fake commit session recording begin/push/drop like tests/test_live_routing.py.+    struct Fake {+        ops: RefCell<Vec<&'static str>>,+        push_fails: Option<IpcFailure>,+        drop_fails: Option<IpcFailure>,+    }++    impl Fake {+        fn new() -> Self {+            Fake { ops: RefCell::new(Vec::new()), push_fails: None, drop_fails: None }+        }+    }++    impl CommitTarget for Fake {+        fn begin(&self) -> Result<String, IpcFailure> {+            self.ops.borrow_mut().push("begin");+            Ok("commit".into())+        }+        fn push(&self, _id: &str, _message: &str) -> Result<(), IpcFailure> {+            self.ops.borrow_mut().push("push");+            self.push_fails.clone().map(Err).unwrap_or(Ok(()))+        }+        fn drop_commit(&self, _id: &str) -> Result<(), IpcFailure> {+            self.ops.borrow_mut().push("drop");+            self.drop_fails.clone().map(Err).unwrap_or(Ok(()))+        }+    }++    #[test]+    fn success_is_one_undo_step() {+        let b = Fake::new();+        assert_eq!(transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap(), 42);+        assert_eq!(*b.ops.borrow(), vec!["begin", "push"]);+    }++    #[test]+    fn partial_create_failure_rolls_back() {+        let b = Fake::new();+        let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("partial create".to_string()))).unwrap_err();+        assert_eq!(e.code, "mutation_rolled_back");+        assert_eq!(e.detail["mutated"], false);+        assert_eq!(*b.ops.borrow(), vec!["begin", "drop"]);+    }++    #[test]+    fn push_timeout_is_unknown_even_if_drop_acknowledged() {+        let mut b = Fake::new();+        b.push_fails = Some(IpcFailure { message: "commit response lost".into(), timed_out: true });+        let e = transaction(&b, "route", || Ok::<_, IpcFailure>(42)).unwrap_err();+        assert_eq!(e.code, "mutation_outcome_unknown");+        assert_eq!(e.detail["mutated"], Value::Null);+        assert_eq!(e.detail["rollbackAttempted"], true);+        assert_eq!(e.detail["stillRunning"], true);+        assert_eq!(e.detail["ipcTimeout"], true);+        assert_eq!(*b.ops.borrow(), vec!["begin", "push", "drop"]);+    }++    #[test]+    fn failed_rollback_is_unknown_not_safe_to_retry() {+        let mut b = Fake::new();+        b.drop_fails = Some(IpcFailure::from("lost IPC".to_string()));+        let e = transaction(&b, "route", || Err::<(), _>(IpcFailure::from("create failed".to_string()))).unwrap_err();+        assert_eq!(e.code, "mutation_outcome_unknown");+        assert_eq!(e.detail["mutated"], Value::Null);+        assert_eq!(e.detail["rollbackError"], "lost IPC");+        assert_eq!(e.detail["stillRunning"], false);+    }++    #[test]+    fn error_response_carries_detail_and_hint() {+        let e = RoutingError::new("stale_board", "old").with("currentRevision", json!("abc"));+        let v = error_response(&e);+        assert_eq!(v["success"], false);+        assert_eq!(v["errorCode"], "stale_board");+        assert_eq!(v["currentRevision"], "abc");+        assert!(v["_hint"].as_str().unwrap().contains("kicad_routing_state"));+        let custom = error_response(&RoutingError::new("api_server_off", "x").with("_hint", json!("enable it")));+        assert_eq!(custom["_hint"], "enable it");+    }++    #[test]+    fn timeout_maps_to_ipc_timeout_with_no_mutation() {+        let e = map_err(KiCadError::Timeout { timeout: Duration::from_millis(5000) });+        assert_eq!(e.code, "ipc_timeout");+        assert_eq!(e.detail["ipcTimeoutMs"], 5000);+        assert_eq!(e.detail["stillRunning"], false);+        assert_eq!(e.detail["mutated"], false);+        assert_eq!(map_err(KiCadError::BoardNotOpen).code, "no_board_open");+        assert_eq!(map_err(KiCadError::SocketUnavailable { socket_uri: "ipc:///x".into() }).code, "ipc_unavailable");+        assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_BAD_REQUEST".into(), message: "nope".into() }).code, "ipc_error");+        assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_UNHANDLED".into(), message: "no handler".into() }).code, "no_pcb_frame");+        assert_eq!(timeout_of(&json!({})), Duration::from_millis(5000));+        assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 10})), Duration::from_millis(1000));+        assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));+    }++    #[test]+    fn footprint_updates_move_the_children_with_the_anchor() {+        use kicad_ipc_rs::FootprintItem;+        use placement::{angle_bytes, vector2_bytes, wire_message, wire_varint};+        const MM: i64 = 1_000_000;+        // An `Any` child without naming prost's type: the crate's own into_any builds one.+        let any = |name: &str, value: Vec<u8>| {+            let mut a = EditablePcbItem::Group(kicad_ipc_rs::GroupItem::new("x", vec![])).into_any();+            a.type_url = format!("type.googleapis.com/kiapi.board.types.{name}");+            a.value = value;+            a+        };+        // U1 at (10, 20) mm, unrotated, with its reference text 2 mm above the anchor,+        // pad 1 one millimetre to the left (padstack angle 0) and a 4 x 2 mm courtyard.+        let mut fp = FootprintItem::from_proto(Default::default());+        {+            let p = fp.proto_mut();+            p.id = Some(Default::default());+            if let Some(id) = p.id.as_mut() {+                id.value = "u1-uuid".into();+            }+            p.position = Some(Default::default());+            if let Some(v) = p.position.as_mut() {+                v.x_nm = 10 * MM;+                v.y_nm = 20 * MM;+            }+            p.layer = BoardLayerInfo::id_from_name("F.Cu").unwrap();+            p.locked = 1;+            p.symbol_sheet_name = "Root".into();+            p.reference_field = Some(Default::default());+            if let Some(f) = p.reference_field.as_mut() {+                f.name = "Reference".into();+                f.text = Some(Default::default());+                if let Some(bt) = f.text.as_mut() {+                    bt.text = Some(Default::default());+                    if let Some(t) = bt.text.as_mut() {+                        t.text = "U1".into();+                        t.position = Some(Default::default());+                        if let Some(v) = t.position.as_mut() {+                            v.x_nm = 10 * MM;+                            v.y_nm = 18 * MM;+                        }+                        t.attributes = Some(Default::default());+                        if let Some(a) = t.attributes.as_mut() {+                            a.angle = Some(Default::default());+                            a.keep_upright = true;+                        }+                    }+                }+            }+            let mut pad = Vec::new();+            wire_message(&mut pad, 3, b"1");+            let mut stack = Vec::new();+            wire_message(&mut stack, 6, &angle_bytes(0.0));+            wire_message(&mut pad, 6, &stack);+            wire_message(&mut pad, 7, &vector2_bytes(9 * MM, 20 * MM));+            let mut rect = Vec::new();+            wire_message(&mut rect, 1, &vector2_bytes(8 * MM, 19 * MM));+            wire_message(&mut rect, 2, &vector2_bytes(12 * MM, 21 * MM));+            let mut shape = Vec::new();+            wire_message(&mut shape, 5, &rect);+            let mut courtyard = Vec::new();+            wire_message(&mut courtyard, 1, &shape);+            wire_varint(&mut courtyard, 2, BoardLayerInfo::id_from_name("F.CrtYd").unwrap() as u64);+            p.definition = Some(Default::default());+            if let Some(def) = p.definition.as_mut() {+                def.items.push(any("Pad", pad));+                def.items.push(any("BoardGraphicShape", courtyard));+                def.items.push(any("Group", vec![0x12, 0x01, b'g']));+            }+        }+        let mv = placement::Move {+            reference: "U1".into(),+            uuid: "u1-uuid".into(),+            before: placement::Pose { x: 10.0, y: 20.0, rotation: 0.0, side: "F.Cu".into() },+            after: placement::Pose { x: 30.0, y: 40.0, rotation: 90.0, side: "F.Cu".into() },+            courtyard_before: placement::Rect { min_x: 8.0, min_y: 19.0, max_x: 12.0, max_y: 21.0 },+            courtyard_after: placement::Rect { min_x: 29.0, min_y: 38.0, max_x: 31.0, max_y: 42.0 },+            courtyard_source: "courtyard",+            flipped: false,+            outline_after: Vec::new(),+        };+        let out = build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[mv.clone()]).unwrap();+        assert_eq!(out.len(), 1);+        let EditablePcbItem::Footprint(f) = &out[0] else { panic!("expected a footprint, got {:?}", out[0]) };+        let p = f.proto();+        assert_eq!(p.id.as_ref().unwrap().value, "u1-uuid");+        let pos = p.position.as_ref().unwrap();+        assert_eq!((pos.x_nm, pos.y_nm), (30 * MM, 40 * MM));+        assert_eq!(p.orientation.as_ref().unwrap().value_degrees, 90.0);+        assert_eq!(p.layer, BoardLayerInfo::id_from_name("F.Cu").unwrap());+        // Everything else the editor sent rides along untouched.+        assert_eq!(p.locked, 1);+        assert_eq!(p.symbol_sheet_name, "Root");+        // The reference text turned about the anchor: 2 mm above becomes 2 mm to the left.+        let t = p.reference_field.as_ref().unwrap().text.as_ref().unwrap().text.as_ref().unwrap();+        let tp = t.position.as_ref().unwrap();+        assert_eq!((tp.x_nm, tp.y_nm), (28 * MM, 40 * MM));+        assert_eq!(t.attributes.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);+        assert!(t.attributes.as_ref().unwrap().keep_upright);+        assert_eq!(t.text, "U1");+        // The children, decoded by the IPC crate's own protobuf code: the pad 1 mm left of+        // the anchor is now 1 mm below it and turned by 90; the courtyard corners turned.+        let items = &p.definition.as_ref().unwrap().items;+        assert_eq!(items.len(), 3);+        match EditablePcbItem::from_any(items[0].clone()).unwrap() {+            EditablePcbItem::Pad(pad) => {+                let pp = pad.proto();+                assert_eq!(pp.number, "1");+                let v = pp.position.as_ref().unwrap();+                assert_eq!((v.x_nm, v.y_nm), (30 * MM, 41 * MM));+                assert_eq!(pp.pad_stack.as_ref().unwrap().angle.as_ref().unwrap().value_degrees, 90.0);+            }+            other => panic!("expected a pad, got {other:?}"),+        }+        match EditablePcbItem::from_any(items[1].clone()).unwrap() {+            EditablePcbItem::BoardGraphicShape(shape) => {+                assert_eq!(shape.proto().layer, BoardLayerInfo::id_from_name("F.CrtYd").unwrap());+                let corners: Vec<(i64, i64)> = format!("{:?}", shape.proto().shape.as_ref().unwrap().geometry)+                    .split("Vector2 { x_nm: ")+                    .skip(1)+                    .map(|s| {+                        let (x, rest) = s.split_once(", y_nm: ").unwrap();+                        (x.parse().unwrap(), rest.split(' ').next().unwrap().trim_end_matches(',').parse().unwrap())+                    })+                    .collect();+                assert_eq!(corners, vec![(29 * MM, 42 * MM), (31 * MM, 38 * MM)]);+            }+            other => panic!("expected a shape, got {other:?}"),+        }+        assert_eq!(items[2].value, vec![0x12, 0x01, b'g'], "a group passes through");+        // A child the bridge cannot move refuses the whole batch before any commit.+        let mut odd = fp.clone();+        odd.proto_mut().definition.as_mut().unwrap().items.push(any("Track", vec![]));+        let e = build_footprint_updates(vec![EditablePcbItem::Footprint(odd)], &[mv.clone()]).unwrap_err();+        assert_eq!(e.code, "unsupported_child_item");+        assert_eq!(e.detail["typeUrl"], "type.googleapis.com/kiapi.board.types.Track");+        assert_eq!(e.detail["mutated"], false);+        // A flip is refused too.+        let mut flip = mv.clone();+        flip.after.side = "B.Cu".into();+        flip.flipped = true;+        assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[flip]).unwrap_err().code, "side_change_not_supported");+        // A move whose footprint the editor no longer has is refused before any commit.+        let mut gone = mv.clone();+        gone.uuid = "nope".into();+        assert_eq!(build_footprint_updates(vec![EditablePcbItem::Footprint(fp)], &[gone]).unwrap_err().code, "footprint_not_found");+    }++    #[test]+    fn items_carry_kipy_units_net_and_layers() {+        let text = crate::pcb::tests::fixture_text();+        let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();+        let plan = pcb::plan(&board, &json!({"net": "NET_1", "points": ["J1.1", {"x": 112, "y": 85, "layer": "B.Cu"}, "J2.1"]})).unwrap();+        let items = build_items(&plan, &BoardNet { code: 7, name: "NET_1".into() }).unwrap();+        assert_eq!(items.len(), 3);+        match &items[0] {+            EditablePcbItem::Track(t) => {+                assert_eq!(t.start_nm().unwrap(), Vector2Nm { x_nm: 105_000_000, y_nm: 85_000_000 });+                assert_eq!(t.proto().width.as_ref().unwrap().value_nm, 250_000);+                assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("F.Cu").unwrap());+                assert_eq!(t.proto().net.as_ref().unwrap().name, "NET_1");+            }+            other => panic!("expected a track, got {other:?}"),+        }+        match &items[1] {+            EditablePcbItem::Track(t) => assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("B.Cu").unwrap()),+            other => panic!("expected a track, got {other:?}"),+        }+        match &items[2] {+            EditablePcbItem::Via(v) => {+                assert_eq!(v.position_nm().unwrap(), Vector2Nm { x_nm: 112_000_000, y_nm: 85_000_000 });+                let ps = v.proto().pad_stack.as_ref().unwrap();+                assert_eq!(ps.copper_layers[0].size.as_ref().unwrap().x_nm, 600_000);+                assert_eq!(ps.drill.as_ref().unwrap().diameter.as_ref().unwrap().x_nm, 300_000);+                assert_eq!(v.proto().net.as_ref().unwrap().name, "NET_1");+            }+            other => panic!("expected a via, got {other:?}"),+        }+        // The Python contract: a KiCad 10 board with an unknown layer name is refused before any IPC.+        let bad = Plan { net_name: "NET_1".into(), segments: vec![pcb::PlanSegment { start: [0.0, 0.0], end: [1.0, 0.0], layer: "Nope.Cu".into(), width: 0.2 }], vias: vec![], blocks: vec![] };+        assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer");+    }+}++/// Native rendered text bounds without changing the board.+fn text_spec_from_json(v:&Value)->RResult<kicad_ipc_rs::TextSpec> {+ use kicad_ipc_rs::{TextSpec,TextAttributesSpec,TextHorizontalAlignment as H,TextVerticalAlignment as V};+ let text=v["text"].as_str().ok_or_else(||RoutingError::new("invalid_text","text is required"))?;+ let number=|k:&str,d:f64|->RResult<f64>{let n=v.get(k).map(|x|x.as_f64().unwrap_or(f64::NAN)).unwrap_or(d);if n.is_finite(){Ok(n)}else{Err(RoutingError::new("invalid_text",format!("{k} must be finite")))}};+ let height=number("height",1.0)?;let width=number("width",height)?;let stroke=number("stroke",height*0.15)?;+ if height<=0.0 || width<=0.0 || stroke<=0.0 || height>1000.0 || width>1000.0{return Err(RoutingError::new("invalid_text","positive dimensions in mm required; maximum text size 1000 mm"))}+ let align=match v["align"].as_str().unwrap_or("center"){"left"=>H::Left,"center"=>H::Center,"right"=>H::Right,_=>return Err(RoutingError::new("invalid_text","align must be left, center or right"))};+ let vertical=match v["verticalAlign"].as_str().unwrap_or("center"){"top"=>V::Top,"center"=>V::Center,"bottom"=>V::Bottom,_=>return Err(RoutingError::new("invalid_text","verticalAlign must be top, center or bottom"))};+ Ok(TextSpec{text:text.into(),position_nm:Some(Vector2Nm{x_nm:(number("x",0.0)?*1e6).round() as i64,y_nm:(number("y",0.0)?*1e6).round() as i64}),attributes:Some(TextAttributesSpec{font_name:v["font"].as_str().map(str::to_string),horizontal_alignment:align,vertical_alignment:vertical,angle_degrees:Some(number("rotation",0.0)?),stroke_width_nm:Some((stroke*1e6).round() as i64),size_nm:Some(Vector2Nm{x_nm:(width*1e6).round() as i64,y_nm:(height*1e6).round() as i64}),mirrored:v["mirrored"].as_bool().unwrap_or(false),bold:v["bold"].as_bool().unwrap_or(false),italic:v["italic"].as_bool().unwrap_or(false),multiline:text.contains('\n'),..Default::default()}),hyperlink:None})+}+pub fn text_bounds(ctx:&Ctx,args:&Value)->RResult<Value>{+ let rows=args["texts"].as_array().ok_or_else(||RoutingError::new("invalid_text","texts must be a nonempty array"))?;+ if rows.is_empty() || rows.len()>2000{return Err(RoutingError::new("invalid_text","texts must contain 1..2000 entries"))}+ let specs=rows.iter().map(text_spec_from_json).collect::<RResult<Vec<_>>>()?;+ let s=connect(ctx,args)?;let revision=s.revision()?;if args.get("expectedRevision").is_some(){check_revision(args,&revision)?;}+ let mut bounds=Vec::new();+ for (row,spec) in rows.iter().zip(specs){let b=s.client.get_text_extents(spec).map_err(map_err)?;bounds.push(json!({"id":row["id"],"text":row["text"],"box":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]}));}+ Ok(json!({"success":true,"mutated":false,"revision":revision,"source":"native-GetTextExtents","units":"mm","texts":bounds,"_hint":"Native axis-aligned rendered bounds at the requested rotation and justification. Keep holes, exposed pads and component bodies as separate obstacles. This does not place text or certify readability."}))+}+#[cfg(test)] mod text_metric_tests {+ use super::*;+ #[test] fn alignment_and_bottom_mirroring(){+  let s=text_spec_from_json(&json!({"text":"MC10\nDSHOT","x":12.5,"height":0.3,"stroke":0.05,"align":"right","mirrored":true,"rotation":90})).unwrap();assert_eq!(s.position_nm.unwrap().x_nm,12_500_000);let a=s.attributes.unwrap();assert_eq!(a.horizontal_alignment,kicad_ipc_rs::TextHorizontalAlignment::Right);assert!(a.mirrored&&a.multiline);assert_eq!(a.size_nm.unwrap().y_nm,300_000);+  assert!(text_spec_from_json(&json!({"text":"x","height":0})).is_err());assert!(text_spec_from_json(&json!({"text":"x","align":"random"})).is_err());+ }+}++/// Update standalone silk text by stable UUID, in one native Undo transaction.+pub fn silk_text_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {+    use kicad_ipc_rs::BoardTextItem;+    let s = connect(ctx, args)?;+    let (text, _, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = crate::silk_text::prepare(&text, args)?;+    // Silkscreen clearance findings are often warnings. Compare all severities, not only errors.+    let all_severities = |mut v: Value| {+        if let Some(rows) = v["violations"].as_array_mut() {+            for row in rows {+                row["severity"] = json!("error");+            }+        }+        v+    };+    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;+    let after = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;+    let added = new_errors(&all_severities(before), &all_severities(after.clone()))?;+    if !added.is_empty() {+        return Err(RoutingError::new(+            "drc_rejected",+            "Silkscreen candidate adds native DRC findings; no live text changed",+        )+        .with("_hint", json!(silk_drc_hint(&added)))+        .with("violations", json!(added))+        .with("mutated", json!(false)));+    }+    if args["dryRun"] == true {+        return Ok(+            json!({"success":true,"mutated":false,"dryRun":true,"revision":revision,"drc":after}),+        );+    }+    let item = |t: &crate::silk_text::Text| {+        let mut item = BoardTextItem::from_proto(Default::default());+        item.set_layer_id(BoardLayerInfo::id_from_name(&t.layer).unwrap());+        let p = item.proto_mut();+        p.id = Some(Default::default());+        p.id.as_mut().unwrap().value = t.id.clone();+        p.locked = 1;+        p.knockout = false;+        p.text = Some(Default::default());+        let text = p.text.as_mut().unwrap();+        text.text = t.text.clone();+        text.position = Some(Default::default());+        let pos = text.position.as_mut().unwrap();+        pos.x_nm = nm(t.x);+        pos.y_nm = nm(t.y);+        text.attributes = Some(Default::default());+        let a = text.attributes.as_mut().unwrap();+        a.horizontal_alignment = match t.align.as_str() {+            "left" => 1,+            "right" => 3,+            _ => 2,+        };+        a.vertical_alignment = 2;+        a.angle = Some(Default::default());+        a.angle.as_mut().unwrap().value_degrees = t.rotation;+        a.line_spacing = 1.;+        a.stroke_width = Some(Default::default());+        a.stroke_width.as_mut().unwrap().value_nm = nm(t.stroke);+        a.size = Some(Default::default());+        a.size.as_mut().unwrap().x_nm = nm(t.width);+        a.size.as_mut().unwrap().y_nm = nm(t.height);+        a.mirrored = t.mirrored;+        a.multiline = t.text.contains('\n');+        a.visible = true;+        EditablePcbItem::BoardText(item)+    };+    let created_ids: Vec<String> = plan.create.iter().map(|t| t.id.clone()).collect();+    let updated_ids: Vec<String> = plan.update.iter().map(|t| t.id.clone()).collect();+    let validate_reply =+        |actual: Vec<EditablePcbItem>, wanted: &[String]| -> Result<(), IpcFailure> {+            let actual: HashSet<_> = actual+                .iter()+                .filter_map(|t| t.id().map(str::to_string))+                .collect();+            if actual != wanted.iter().cloned().collect() {+                return Err(IpcFailure::from(+                    "KiCad reply did not preserve every requested text UUID".to_string(),+                ));+            }+            Ok(())+        };+    check_revision(args, &s.revision()?)?;+    transaction(&s, "Adom silkscreen text", || {+        if !plan.remove.is_empty() {+            s.client.delete_items(plan.remove.clone())?;+        }+        if !plan.update.is_empty() {+            validate_reply(+                s.client+                    .update_editable_items(plan.update.iter().map(&item).collect())?,+                &updated_ids,+            )?;+        }+        if !plan.create.is_empty() {+            validate_reply(+                s.client+                    .create_editable_items(plan.create.iter().map(&item).collect(), None)?,+                &created_ids,+            )?;+        }+        Ok(())+    })?;+    let mut result = json!({"success":true,"mutated":true,"committed":true,"undoSteps":1,"createdIds":created_ids,"updatedIds":updated_ids,"removedIds":plan.remove,"drc":after,"saved":false,"source":"live-editor","_hint":"One native Undo step. Updates require full text definitions and preserve UUIDs; no content-based replacement. Inspect returned revision/bounds and refresh the linked 3D viewer. A postCommitError means committed: inspect state, never blindly replay."});+    if !plan.remove.is_empty() {+        match s+            .client+            .get_items_by_type_codes(vec![PcbObjectTypeCode::new_text().code])+        {+            Ok(rows) => {+                let remaining: Vec<_> = rows+                    .iter()+                    .filter_map(item_id)+                    .filter(|id| plan.remove.iter().any(|wanted| wanted == id))+                    .collect();+                result["deletionVerified"] = json!(remaining.is_empty());+                if !remaining.is_empty() {+                    result["postCommitDeletionError"] = json!({"remainingIds": remaining});+                    result["success"] = json!(false);+                }+            }+            Err(e) => {+                result["postCommitDeletionError"] = json!(e.to_string());+                result["success"] = json!(false);+            }+        }+    }++    match s.revision() {+        Ok(rev) => result["revision"] = json!(rev),+        Err(e) => result["postCommitError"] = json!(e.message),+    }+    let ids: Vec<_> = created_ids.iter().chain(&updated_ids).cloned().collect();+    if !ids.is_empty() {+        match s.client.get_item_bounding_boxes(ids,false){Ok(boxes)=>result["bounds"]=json!(boxes.iter().map(|b|json!({"id":b.item_id,"boxMm":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]})).collect::<Vec<_>>()),Err(e)=>result["postCommitBoundsError"]=json!(e.to_string())}+    }+    if args["save"] == true {+        match s.client.save_document() {+            Ok(()) => result["saved"] = json!(true),+            Err(e) => result["postCommitSaveError"] = json!(e.to_string()),+        }+    }+    Ok(result)+}+++mod silk_fields;+pub use silk_fields::silk_fields_batch;++#[cfg(test)]+mod lib_table_tests {+    #[test]+    fn a_copied_library_table_points_kiprjmod_back_at_the_real_project() {+        let base = std::env::temp_dir().join(format!("adom-libtable-test-{}", std::process::id()));+        let proj = base.join("proj");+        let snap = base.join("snap");+        std::fs::create_dir_all(&proj).unwrap();+        std::fs::create_dir_all(&snap).unwrap();+        let table = "(fp_lib_table\n  (version 7)\n  (lib (name \"Load\")(type \"KiCad\")(uri \"${KIPRJMOD}/lib/Load.pretty\")(options \"\")(descr \"\"))\n)\n";+        std::fs::write(proj.join("fp-lib-table"), table).unwrap();+        super::copy_lib_table(&proj.join("fp-lib-table"), &snap.join("fp-lib-table"), &proj).unwrap();+        let out = std::fs::read_to_string(snap.join("fp-lib-table")).unwrap();+        let expect = format!("{}/lib/Load.pretty", proj.to_string_lossy().replace('\\', "/"));+        assert!(out.contains(&expect), "{out}");+        assert!(!out.contains("${KIPRJMOD}"));+        let _ = std::fs::remove_dir_all(&base);+    }+}+--- a/rust/crates/kicad-core/src/lib.rs+++ b/rust/crates/kicad-core/src/lib.rs@@ -1,63 +1,66 @@⋯ 48 unchanged lines ⋯ pub mod project; pub mod fpedit; pub mod symedit;+pub mod libops;+pub mod board_reads; pub mod libsearch; pub mod sxedit; pub mod fp_text;⋯ 6 unchanged lines ⋯ pub mod sch_review; pub mod sch_render; pub mod sch_sheets;+--- a/rust/crates/kicad-core/src/libops.rs+++ b/rust/crates/kicad-core/src/libops.rs@@ -0,0 +1,288 @@+//! Library housekeeping the EDA Bridge Standard names in its core tier: rename a footprint's+//! text (for copy, rename and cross-library copy of `.kicad_mod` files), clone and rename a symbol+//! in a `.kicad_sym`, set a symbol's parameters and their visibility, and read a symbol's drawing.+//! Every edit goes through the lossless document model, so the rest of the file stays+//! byte-identical, and the result is checked like the other library writers.++use std::collections::BTreeMap;++use serde_json::{json, Map, Value};++use crate::schematic::{parse_node, pretty, quote, Document, Node};++fn symbols(doc: &Document) -> Vec<(usize, String)> {+    doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("symbol")).filter_map(|(i, c)| c.arg(0).map(|n| (i, n))).collect()+}++fn library(text: &str) -> Result<Document, String> {+    let doc = Document::parse(text)?;+    if doc.root.head() != Some("kicad_symbol_lib") {+        return Err("not a symbol library (the top-level list is not kicad_symbol_lib)".into());+    }+    Ok(doc)+}++fn find(doc: &Document, name: &str) -> Result<usize, String> {+    let syms = symbols(doc);+    syms.iter().find(|(_, n)| n == name).map(|(i, _)| *i).ok_or_else(|| {+        let names: Vec<&str> = syms.iter().map(|(_, n)| n.as_str()).take(40).collect();+        format!("no symbol {name} in the library (it has: {})", names.join(", "))+    })+}++fn checked(doc: &Document, expect: &[&str]) -> Result<String, String> {+    let out = doc.to_text();+    crate::lint::check_symbol_lib_structure(&out)?;+    let listed = crate::libraries::list_symbols_in_text(&out);+    for n in expect {+        if !listed.iter().any(|l| l == n) {+            return Err(format!("{n} does not read back from the edited library"));+        }+    }+    Ok(out)+}++/// A `.kicad_mod`'s text with its footprint named `to` (the name after `(footprint`).+pub fn rename_footprint_text(text: &str, to: &str) -> Result<String, String> {+    let mut doc = Document::parse(text)?;+    if doc.root.head() != Some("footprint") {+        return Err("not a footprint file (the top-level list is not footprint)".into());+    }+    doc.root.set_arg(0, Node::atom(quote(to)));+    Ok(doc.to_text())+}++/// Rename a symbol node and its unit sub-symbols (`<name>_<unit>_<style>`).+fn rename_node(n: &mut Node, from: &str, to: &str) {+    n.set_arg(0, Node::atom(quote(to)));+    if let Some(kids) = n.children_mut() {+        for sub in kids.iter_mut().filter(|c| c.head() == Some("symbol")) {+            if let Some(s) = sub.arg(0) {+                if let Some(rest) = s.strip_prefix(&format!("{from}_")) {+                    sub.set_arg(0, Node::atom(quote(&format!("{to}_{rest}"))));+                }+            }+        }+    }+}++/// A copy of symbol `from` named `to`, every unit and body style included, placed after it.+pub fn clone_symbol(text: &str, from: &str, to: &str) -> Result<String, String> {+    let mut doc = library(text)?;+    let i = find(&doc, from)?;+    if symbols(&doc).iter().any(|(_, n)| n == to) {+        return Err(format!("symbol {to} already exists in the library"));+    }+    let kids = doc.root.children_mut().ok_or("empty library")?;+    let mut copy = kids[i].clone();+    rename_node(&mut copy, from, to);+    kids.insert(i + 1, copy);+    checked(&doc, &[from, to])+}++/// Rename symbol `from` to `to`, and every symbol that extends it with it.+pub fn rename_symbol(text: &str, from: &str, to: &str) -> Result<(String, Vec<String>), String> {+    let mut doc = library(text)?;+    let i = find(&doc, from)?;+    if symbols(&doc).iter().any(|(_, n)| n == to) {+        return Err(format!("symbol {to} already exists in the library"));+    }+    let kids = doc.root.children_mut().ok_or("empty library")?;+    rename_node(&mut kids[i], from, to);+    let mut children = Vec::new();+    for k in kids.iter_mut().filter(|c| c.head() == Some("symbol")) {+        if k.child_value("extends").as_deref() == Some(from) {+            if let Some(e) = k.child_mut("extends") {+                e.set_arg(0, Node::atom(quote(to)));+            }+            children.extend(k.arg(0));+        }+    }+    Ok((checked(&doc, &[to])?, children))+}++/// A new hidden property block, KiCad 10's layout.+fn new_property(name: &str, value: &str) -> Result<Node, String> {+    let mut n = parse_node(&format!(+        "(property {} {} (at 0 0 0) (show_name no) (do_not_autoplace no) (hide yes) (effects (font (size 1.27 1.27))))",+        quote(name),+        quote(value)+    ))?;+    pretty(&mut n, 2);+    Ok(n)+}++/// Set (or add, hidden) properties on symbol `name`. Returns the text and per property what+/// happened: "set", "added" or "unchanged".+pub fn set_params(text: &str, name: &str, params: &BTreeMap<String, String>) -> Result<(String, Map<String, Value>), String> {+    let mut doc = library(text)?;+    let i = find(&doc, name)?;+    let sym = &mut doc.root.children_mut().ok_or("empty library")?[i];+    let mut what = Map::new();+    for (k, v) in params {+        let kids = sym.children_mut().ok_or("empty symbol")?;+        match kids.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(k.as_str())) {+            Some(p) => {+                let same = p.arg(1).as_deref() == Some(v.as_str());+                if !same {+                    p.set_arg(1, Node::atom(quote(v)));+                }+                what.insert(k.clone(), json!(if same { "unchanged" } else { "set" }));+            }+            None => {+                let mut node = new_property(k, v)?;+                let at = kids.iter().rposition(|c| c.head() == Some("property")).map(|j| j + 1).unwrap_or(1);+                node.lead = kids.get(at.min(kids.len() - 1)).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t\t".into());+                kids.insert(at, node);+                what.insert(k.clone(), json!("added"));+            }+        }+    }+    Ok((checked(&doc, &[name])?, what))+}++/// Show or hide property `param` of symbol `name`. Returns the text and whether it changed.+pub fn set_visibility(text: &str, name: &str, param: &str, visible: bool) -> Result<(String, bool), String> {+    let mut doc = library(text)?;+    let i = find(&doc, name)?;+    let sym = &mut doc.root.children_mut().ok_or("empty library")?[i];+    let kids = sym.children_mut().ok_or("empty symbol")?;+    let p = kids.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(param)).ok_or_else(|| format!("{name} has no property {param}"))?;+    // KiCad 10 keeps (hide yes) on the property; older files keep it inside (effects ...)+    let hidden_now = p.child_value("hide").as_deref() == Some("yes") || p.child("effects").map(|e| e.children().iter().any(|c| c.raw() == Some("hide") || (c.head() == Some("hide") && c.arg(0).as_deref() != Some("no")))).unwrap_or(false);+    if hidden_now != visible {+        return Ok((text.to_string(), false));+    }+    let pk = p.children_mut().ok_or("empty property")?;+    pk.retain(|c| c.head() != Some("hide"));+    if let Some(e) = pk.iter_mut().find(|c| c.head() == Some("effects")) {+        if let Some(ek) = e.children_mut() {+            ek.retain(|c| c.raw() != Some("hide") && c.head() != Some("hide"));+        }+    }+    if !visible {+        let at = pk.iter().position(|c| c.head() == Some("effects")).unwrap_or(pk.len());+        let mut h = parse_node("(hide yes)")?;+        h.lead = pk.get(at).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t\t\t".into());+        pk.insert(at, h);+    }+    Ok((checked(&doc, &[name])?, true))+}++fn bbox(points: &[(f64, f64)]) -> Option<[f64; 4]> {+    let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    for (x, y) in points {+        x0 = x0.min(*x);+        y0 = y0.min(*y);+        x1 = x1.max(*x);+        y1 = y1.max(*y);+    }+    (x0 <= x1).then_some([x0, y0, x1, y1])+}++/// A symbol's drawing: primitives counted by kind, the body rectangle, the polylines' bounding box,+/// units and body styles, and the footprint link and filters. Read-only.+pub fn symbol_prims(text: &str, name: &str) -> Result<Value, String> {+    let doc = library(text)?;+    find(&doc, name)?;+    let flat = crate::schematic::flatten_lib_symbol(text, name)?;+    let mut counts: BTreeMap<String, usize> = BTreeMap::new();+    let mut rects: Vec<[f64; 4]> = Vec::new();+    let mut poly_pts: Vec<(f64, f64)> = Vec::new();+    let (mut units, mut styles) = (std::collections::BTreeSet::new(), std::collections::BTreeSet::new());+    for sub in flat.children().iter().filter(|c| c.head() == Some("symbol")) {+        let sname = sub.arg(0).unwrap_or_default();+        let mut it = sname.rsplitn(3, '_');+        styles.insert(it.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(1));+        let unit = it.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);+        if unit > 0 {+            units.insert(unit);+        }+        for p in sub.children().iter().skip(1) {+            let Some(h) = p.head() else { continue };+            *counts.entry(h.to_string()).or_default() += 1;+            match h {+                "rectangle" => {+                    if let (Some(s), Some(e)) = (p.child("start"), p.child("end")) {+                        if let (Some(a), Some(b), Some(c), Some(d)) = (s.num(0), s.num(1), e.num(0), e.num(1)) {+                            rects.push([a.min(c), b.min(d), a.max(c), b.max(d)]);+                        }+                    }+                }+                "polyline" => {+                    if let Some(pts) = p.child("pts") {+                        poly_pts.extend(pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))));+                    }+                }+                _ => {}+            }+        }+    }+    let body = rects.iter().copied().max_by(|a, b| ((a[2] - a[0]) * (a[3] - a[1])).total_cmp(&((b[2] - b[0]) * (b[3] - b[1]))));+    let prop = |k: &str| flat.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(k)).and_then(|c| c.arg(1));+    let filters: Vec<String> = flat.child("fp_filters").map(|f| f.children().iter().skip(1).filter_map(|c| c.value()).collect()).unwrap_or_default();+    Ok(json!({+        "symbol": name,+        "counts": counts,+        "pins": counts.get("pin").copied().unwrap_or(0),+        "bodyRectangle": body,+        "polylineBox": bbox(&poly_pts),+        "units": units.len().max(1),+        "bodyStyles": styles.len().max(1),+        "footprint": prop("Footprint"),+        "footprintFilters": filters,+        "extends": doc.root.children().iter().find(|c| c.head() == Some("symbol") && c.arg(0).as_deref() == Some(name)).and_then(|c| c.child_value("extends")),+    }))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn lib() -> String {+        std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch-edit/MiniLib.kicad_sym")).unwrap()+    }++    #[test]+    fn clone_and_rename_keep_units_and_the_rest_of_the_file() {+        let t = lib();+        let first = crate::libraries::list_symbols_in_text(&t)[0].clone();+        let c = clone_symbol(&t, &first, "R_copy").unwrap();+        assert!(c.contains("(symbol \"R_copy\"") && c.contains(&format!("(symbol \"{first}\"")));+        assert!(c.contains("\"R_copy_0_1\"") || c.contains("\"R_copy_1_1\""), "unit sub-symbols renamed");+        assert!(clone_symbol(&c, &first, "R_copy").is_err(), "an existing name is refused");+        let (r, _) = rename_symbol(&c, "R_copy", "R_new").unwrap();+        assert!(!r.contains("\"R_copy") && r.contains("(symbol \"R_new\""));+        assert_eq!(r, c.replace("\"R_copy", "\"R_new"), "only the names changed");+    }++    #[test]+    fn params_set_add_and_hide() {+        let t = lib();+        let first = crate::libraries::list_symbols_in_text(&t)[0].clone();+        let mut p = BTreeMap::new();+        p.insert("Footprint".to_string(), "Resistor_SMD:R_0603_1608Metric".to_string());+        p.insert("MPN".to_string(), "RC0603FR-0710KL".to_string());+        let (t2, what) = set_params(&t, &first, &p).unwrap();+        assert_eq!(what["Footprint"], "set");+        assert_eq!(what["MPN"], "added");+        let info = crate::symedit::symbol_info(&t2, &first).unwrap();+        assert_eq!(info["fields"]["MPN"], "RC0603FR-0710KL");+        let (t3, changed) = set_visibility(&t2, &first, "MPN", true).unwrap();+        assert!(changed);+        let (_, again) = set_visibility(&t3, &first, "MPN", true).unwrap();+        assert!(!again, "already shown");+        let (t4, _) = set_visibility(&t3, &first, "MPN", false).unwrap();+        assert!(t4.contains("(property \"MPN\""));+        let prims = symbol_prims(&t4, &first).unwrap();+        assert!(prims["pins"].as_u64().unwrap() >= 2, "{prims}");+    }++    #[test]+    fn a_footprint_file_takes_a_new_name() {+        let t = "(footprint \"Old\"\n\t(version 20241229)\n\t(layer \"F.Cu\")\n)\n";+        let r = rename_footprint_text(t, "New name").unwrap();+        assert!(r.starts_with("(footprint \"New name\"\n\t(version"));+    }+}+--- a/rust/crates/kicad-core/src/schematic.rs+++ b/rust/crates/kicad-core/src/schematic.rs@@ -1,1153 +1,1190 @@⋯ 827 unchanged lines ⋯     }      /// Set (or add) a field on a symbol. Reference also updates the instances block.+    /// Set (or add, hidden) field `name` on every unit of the part `reference` placed on this sheet+    /// (a multi-unit part is one top-level symbol per unit). Answers how many units changed, and the+    /// value the first one had before (None when the field was added).+    pub fn set_property_all_units(&mut self, reference: &str, name: &str, value: &str) -> Result<(usize, Option<String>), String> {+        if name == "Reference" {+            return Err("Reference is renamed with sch_set_property, one part at a time".into());+        }+        let idx: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)).map(|(i, _)| i).collect();+        if idx.is_empty() {+            return Err(format!("no symbol with reference {reference} on this sheet"));+        }+        let mut first_old: Option<Option<String>> = None;+        for i in idx.iter().copied() {+            let node = &mut self.top_mut()[i];+            let (ax, ay) = node.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 children = node.children_mut().expect("list");+            let old = match children.iter_mut().find(|n| n.head() == Some("property") && n.arg(0).as_deref() == Some(name)) {+                Some(prop) => {+                    let o = prop.arg(1);+                    prop.set_arg(1, Node::quoted(value));+                    o+                }+                None => {+                    let mut p = property_node(name, value, (ax, ay), None, true);+                    pretty(&mut p, 2);+                    let last_prop = children.iter().rposition(|n| n.head() == Some("property"));+                    let at = last_prop.map(|i| i + 1).unwrap_or_else(|| children.iter().position(|n| n.head() == Some("pin") || n.head() == Some("instances")).unwrap_or(children.len()));+                    children.insert(at, p);+                    None+                }+            };+            first_old.get_or_insert(old);+        }+        Ok((idx.len(), first_old.flatten()))+    }+     pub fn set_property(&mut self, reference: &str, name: &str, value: &str) -> Result<(String, Option<String>), String> {         if name.trim().is_empty() {             return Err("property name is required".into());⋯ 317 unchanged lines ⋯         assert!("89ab".contains(&a[19..20]));     } }+--- a/rust/crates/kicad-core/src/silk_fields.rs+++ b/rust/crates/kicad-core/src/silk_fields.rs@@ -1,228 +1,298 @@⋯ 118 unchanged lines ⋯         edits,     }) }+/// True for a `(pts ...)` list whose last point repeats its first: a closed polygon written+/// with its closing vertex, as KiCad's libraries and file format keep it.+fn repeats_first_point(n: &Node) -> bool {+    let pts = n.children();+    if n.head() != Some("pts") || pts.len() < 5 {+        return false;+    }+    let (first, last) = (&pts[1], &pts[pts.len() - 1]);+    first.head() == Some("xy") && last.head() == Some("xy") && first.arg(0) == last.arg(0) && first.arg(1) == last.arg(1)+}++/// Per footprint uuid, how many of its polygons carry the repeated closing vertex. KiCad's IPC+/// API rebuilds every footprint it updates (a move, a field edit) and stores each closed polygon+/// without that vertex: the same geometry, but KiCad's DRC then reports the part as differing from+/// its library (wiki #126). Comparing this count before and after an update names those parts.+pub fn closing_vertices(source: &str) -> std::collections::HashMap<String, usize> {+    fn count(n: &Node) -> usize {+        usize::from(repeats_first_point(n)) + n.children().iter().map(count).sum::<usize>()+    }+    let mut out = std::collections::HashMap::new();+    if let Ok(doc) = Document::parse(source) {+        for fp in doc.root.children().iter().filter(|c| c.head() == Some("footprint")) {+            if let Some(id) = fp.child_value("uuid") {+                out.insert(id, count(fp));+            }+        }+    }+    out+}++/// What a reply says when KiCad's IPC API dropped closing vertices from footprints it rebuilt.+pub const CLOSING_VERTEX_HINT: &str = "KiCad's IPC API rebuilds every footprint it updates and stores each closed polygon without its repeated closing point: the same geometry, KiCad's behaviour (not an edit by the bridge). KiCad's DRC may then report lib_footprint_mismatch for these footprints. Tools > Update Footprints from Library in the PCB editor (or kicad_update_footprints_from_library on the saved, closed board) brings back the library's copy; check its options so it keeps your placement and fields.";++/// The footprints among `ids` that lost closing vertices between `before` and `after`, with how+/// many: `[{id, dropped}]`, empty when none did.+pub fn dropped_closing_vertices(before: &str, after: &str, ids: &[String]) -> Vec<Value> {+    let (b, a) = (closing_vertices(before), closing_vertices(after));+    ids.iter()+        .filter_map(|id| {+            let lost = b.get(id).copied().unwrap_or(0).saturating_sub(a.get(id).copied().unwrap_or(0));+            (lost > 0).then(|| json!({"id": id, "dropped": lost}))+        })+        .collect()+}+ /// Ignore only the explicitly edited property nodes, never pads, model data or other text. pub fn preserved_geometry(source: &str, ids: &HashSet<String>) -> Result<Value, RoutingError> {     fn visit(n: &Node, ids: &HashSet<String>) -> Value {         match &n.kind {             Kind::Atom(a) => json!(a),-            Kind::List { children, .. } => Value::Array(-                children-                    .iter()-                    .filter(|c| {-                        !(c.head() == Some("property")-                            && c.child_value("uuid")-                                .map(|id| ids.contains(&id))-                                .unwrap_or(false))-                    })-                    .map(|c| visit(c, ids))-                    .collect(),-            ),+            Kind::List { children, .. } => {+                // a polygon's repeated closing vertex is the same geometry: KiCad's IPC API drops+                // it whenever it rebuilds a footprint, so it is not a change to the board+                let kept = if repeats_first_point(n) { &children[..children.len() - 1] } else { &children[..] };+                Value::Array(+                    kept.iter()+                        .filter(|c| {+                            !(c.head() == Some("property")+                                && c.child_value("uuid")+                                    .map(|id| ids.contains(&id))+                                    .unwrap_or(false))+                        })+                        .map(|c| visit(c, ids))+                        .collect(),+                )+            }         }     }     Ok(visit(&Document::parse(source).map_err(fail)?.root, ids))⋯ 84 unchanged lines ⋯         }     } }++#[cfg(test)]+mod closing_vertex_tests {+    use super::*;++    const BEFORE: &str = "(kicad_pcb\n\t(footprint \"X\"\n\t\t(uuid \"fp1\")\n\t\t(fp_poly\n\t\t\t(pts\n\t\t\t\t(xy -2.4 -2.4) (xy -2.9 -2.4) (xy -2.4 -2.9) (xy -2.4 -2.4)\n\t\t\t)\n\t\t)\n\t)\n)\n";++    #[test]+    fn a_dropped_closing_vertex_is_the_same_geometry_and_is_counted() {+        let after = BEFORE.replace(" (xy -2.4 -2.4)\n", "\n");+        assert_ne!(BEFORE, after);+        let none = HashSet::new();+        assert_eq!(preserved_geometry(BEFORE, &none).unwrap(), preserved_geometry(&after, &none).unwrap());+        assert_eq!(closing_vertices(BEFORE).get("fp1"), Some(&1));+        assert_eq!(closing_vertices(&after).get("fp1"), Some(&0));+        // a real change is still one+        let moved = after.replace("(xy -2.9 -2.4)", "(xy -3.0 -2.4)");+        assert_ne!(preserved_geometry(BEFORE, &none).unwrap(), preserved_geometry(&moved, &none).unwrap());+    }+}+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1452 +1,1496 @@⋯ 185 unchanged lines ⋯         "layers_in_use":     dict(phase=2, args={"filePath": B, "source": "file"}, check="layers_in_use"),         "component_data":    dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_data"),         "component_pins":    dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_pins"),+        # ---- phase 9: the standard's core verbs added in 1.0.64, run in order by std_phase() on scratch files+        **{v: dict(phase=9, scenario=True) for v in ("copy_footprint", "rename_footprint", "xcopy_footprint", "remove_footprint", "add_body",+            "clone_symbol", "rename_symbol", "set_params", "set_visibility", "symbol_prims", "place_on_sch", "doc_params", "sheet_params",+            "part_parameters", "layers", "silk_state", "ripup_nets")},         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),         "add_mounting_hole": dict(phase=8, scenario=True),⋯ 1041 unchanged lines ⋯         # runs on the closed copy below (the file path), which works on every KiCad         lv = E("add_footprint", {"libraryId": "Capacitor_SMD:C_0402_1005Metric", "x": round(cx, 2), "y": round(cy, 2), "reference": "C", "value": "100n", "validate": False})         live_create = "landed" if ok(lv) else ("refused" if lv.get("errorCode") == "live_create_unsupported" and lv.get("mutated") is False else "broken")+        # 1.0.64: board graphics are created live through KiCad's protobuf CreateItems+        svg0 = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10'/></svg>"+        r = E("import_svg", {"svg": svg0, "x": round(cx, 2), "y": round(cy, 2), "width": 2, "layer": "Dwgs.User", "validate": False})+        record("import_svg_live", r, ok(r) and r.get("commit") == "create-items" and r.get("verified") is True, "a live SVG was not created through CreateItems")         r = E("add_mounting_hole", {"x": round(cx, 2), "y": round(cy, 2), "drill": 2.2, "dryRun": True}, chain=False)         rec("add_mounting_hole", r, ok(r) or refused_cleanly(r), "no answer")         r = E("board_outline", {"shape": "rectangle", "width": 70, "height": 60, "x": 100, "y": 125, "replace": True, "dryRun": True}, chain=False)⋯ 141 unchanged lines ⋯         r = S("export_library_part", {"part": "__no_such_part__"}); rec("export_library_part", r, r.get("success") is False)         r = S("create_part_project", {"part": "__no_such_part__"}); rec("create_part_project", r, r.get("success") is False) +        # 9.3 the standard's core verbs (1.0.64), recorded under their own names+        print("[9.3] library housekeeping, part parameters, layers, silkscreen, rip-up")+        def own(verb, r, good, why=None):+            record(verb, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))+        lib, lib2, slib = f"{OUT}/KHK{tag}.pretty", f"{OUT}/KHK2{tag}.pretty", f"{OUT}/KHK{tag}.kicad_sym"+        S("create_footprint", {"libraryPath": lib, "name": "KHK_FP", "pads": [{"number": 1, "size": [1, 1], "at": [-1, 0]}, {"number": 2, "size": [1, 1], "at": [1, 0]}]})+        r = S("copy_footprint", {"libraryPath": lib, "from": "KHK_FP", "to": "KHK_FP2"}); own("copy_footprint", r, ok(r))+        r = S("rename_footprint", {"libraryPath": lib, "from": "KHK_FP2", "to": "KHK_FP3"}); own("rename_footprint", r, ok(r))+        r = S("xcopy_footprint", {"fromLib": lib, "from": "KHK_FP3", "toLib": lib2}); own("xcopy_footprint", r, ok(r))+        r = S("add_body", {"libraryPath": lib, "footprintName": "KHK_FP", "step": "${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step"})+        own("add_body", r, ok(r) and r.get("bodiesNow") == 1)+        r = S("remove_footprint", {"libraryPath": lib, "footprintName": "KHK_FP3"}); own("remove_footprint", r, ok(r))+        S("create_symbol", {"libraryPath": slib, "name": "KHK1", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "side": "right"}]})+        r = S("clone_symbol", {"libraryPath": slib, "from": "KHK1", "to": "KHK2"}); own("clone_symbol", r, ok(r))+        r = S("rename_symbol", {"libraryPath": slib, "from": "KHK2", "to": "KHK3"}); own("rename_symbol", r, ok(r))+        r = S("set_params", {"libraryPath": slib, "symbolName": "KHK3", "params": {"MPN": "KHK-3"}, "footprint": "KHK:KHK_FP"}); own("set_params", r, ok(r) and (r.get("fields") or {}).get("MPN") == "added")+        r = S("set_visibility", {"libraryPath": slib, "symbolName": "KHK3", "param": "MPN", "visible": True}); own("set_visibility", r, ok(r))+        r = S("symbol_prims", {"libraryPath": slib, "symbolName": "KHK3"}); own("symbol_prims", r, ok(r) and r.get("pins") == 2)+        r = S("place_on_sch", {"libraryPath": slib, "symbolName": "KHK1;KHK3", "schPath": f"{OUT}/khk-{tag}.kicad_sch", "title": "housekeeping"})+        own("place_on_sch", r, ok(r) and len(r.get("placed") or []) == 2)+        r = S("doc_params", {"sheet": sch, "set": {"Title": "kstd params", "Revision": "B"}}); own("doc_params", r, ok(r) and (r.get("parameters") or {}).get("Revision") == "B")+        r = S("sheet_params", {"projectPath": pro, "designators": ["R1"], "params": {"MPN": "KSTD-R1"}}); own("sheet_params", r, ok(r) and not r.get("notFound"))+        r = S("part_parameters", {"projectPath": pro, "name": "MPN"}); own("part_parameters", r, ok(r) and any(v.get("value") == "KSTD-R1" for v in r.get("values") or []))+        r = S("layers", {"filePath": B, "source": "file"}); own("layers", r, ok(r) and (r.get("copper") or 0) >= 2)+        r = S("silk_state", {"filePath": B, "source": "file"}); own("silk_state", r, ok(r) and (r.get("counts") or {}).get("parts", 0) > 0)+        ab(a.target, "kicad_close", {"force": True}, timeout=120)+        time.sleep(4)+        o = ab(a.target, "kicad_open_board", {"filePath": B, "reason": WHY}, timeout=200)+        time.sleep(8)+        st2 = ab(a.target, "kicad_routing_state", {"filePath": B}, timeout=150)+        net = next((t.get("netName") for t in (st2.get("tracks") or st2.get("segments") or []) if t.get("netName")), None) or "GND"+        r = S("ripup_nets", {"filePath": B, "expectedRevision": st2.get("revision"), "nets": [net], "dryRun": True}) if ok(o) else o+        own("ripup_nets", r, ok(r) and r.get("mutated") is not True, "the board did not open live, or the dry run changed something")+        ab(a.target, "kicad_close", {"force": True}, timeout=120)+     live_phase()     k161_phase()     std_phase()⋯ 64 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+--- a/skills/kicad-schematic-and-board-edits/SKILL.md+++ b/skills/kicad-schematic-and-board-edits/SKILL.md@@ -1,81 +1,105 @@⋯ 37 unchanged lines ⋯   guarded by `expectedRevision` and checked by KiCad's DRC first (`dryRun` checks without editing). With no editor   holding the board it edits the file, keeping a backup. A lock file left by a KiCad that is no longer running   is cleared; a lock from another machine is honoured.-- **Adding items to an open board does not work yet.** KiCad 10.0.x does not create items from text over its-  API (checked on 10.0.4 and 10.0.7), so `add_footprint`, `add_mounting_hole`, `import_svg`, `board_outline`-  and `update_footprints_from_library` refuse an open board with `live_create_unsupported` and change nothing.-  Save and close the board (`kicad_save_board`, `kicad_close`) and call again: the file is edited. Moving,-  editing, routing and deleting work live.+- **Adding items to an open board (from 1.0.64).** KiCad 10.0.x does not create items from text over its API+  (checked on 10.0.4 and 10.0.7), so the bridge encodes them as KiCad's protobuf items itself:+  - `board_outline` and `import_svg` create their shapes live (`commit:"create-items"`, one Undo step);+  - `add_footprint` (also `duplicateOf` and `count`) and `add_mounting_hole` create a part live when the same+    library footprint is already on that side of the board with the same pads and graphics: KiCad's own+    encoding of it is copied, with fresh ids, the new pose, reference, value and pad nets;+  - otherwise (a footprint the board does not have yet, and `update_footprints_from_library`) the verb refuses+    with `live_create_unsupported`, says why in `liveCreateWhy` and changes nothing: save and close the board+    (`kicad_save_board`, `kicad_close`) and call again, and the file is edited. +  Moving, editing, routing and deleting work live. KiCad's API stores every footprint it rebuilds (a move, a+  field or part edit) without a polygon's repeated closing point; replies name those parts in+  `closingVerticesDroppedByKiCad` (wiki #126).+ ## Libraries and projects  `sym_create`, `sym_info`, `sym_delete`, `fp_create`, `fp_info`, `fp_edit`, `search_library` (every registered library, PCM ones included), `project_create`, `project_info`, `project_rename`, `project_snapshot`. +From 1.0.64, the EDA Bridge Standard's library housekeeping:+- **Footprint libraries:** `copy_footprint`, `rename_footprint`, `remove_footprint`, `xcopy_footprint` (across+  libraries), `add_body` (a STEP or VRML model onto a library footprint, replacing its models unless+  `keepExisting:true`).+- **Symbol libraries:** `clone_symbol`, `rename_symbol` (symbols that extend it follow), `set_params` (fields,+  the value and the footprint link; a missing field is added hidden), `set_visibility`, `symbol_prims` (the+  drawing, read only).+- **Sheets and parts:** `place_on_sch` (library symbols on one new sheet sized to them), `doc_params` (the title+  block under the standard's names), `sheet_params` (the BOM write-back: fields onto placed parts, every unit, on+  whichever sheet holds each), `part_parameters` (which fields the parts carry, or one field's values with the+  parts that lack it).+- **The board:** `layers` (the whole layer set, with what the open editor shows), `silk_state` (every part's body+  box, designator and value as placed, and the free silkscreen), `ripup_nets` (every unlocked track, arc and via+  of the named nets, live, one Undo step; dry-run first).+ ## Fab outputs  `export_fab_package` (Gerbers, drill, BOM, CPL, a README, zipped; `fab:"jlcpcb"` for its columns), `export_odb`,⋯ 24 unchanged lines ⋯   `prices` per quantity.  Every verb here runs in the verb battery on a real Windows box before each release.+

Comments

No comments yet.

Log in to comment.