← Commit history
bridge.json+18−1
@@ -253,7 +253,24 @@     "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": {
rust/Cargo.lock+3−3
@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.63"+version = "1.0.64" dependencies = [  "kicad-core",  "kicad-platform",@@ -321,7 +321,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.63"+version = "1.0.64" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -348,7 +348,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.63"+version = "1.0.64" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.63"+version = "1.0.64" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_board.rs+6−6
@@ -22,7 +22,7 @@ use kicad_core::pcb::{self, RoutingError}; const LIVE_PITFALLS: &[&str] = &[     "Live when the PCB editor serves the board on the IPC API (one native Undo step; expectedRevision required, from kicad_routing_state, kicad_placement_state or the previous edit; KiCad's DRC on a snapshot first when copper changes), otherwise the closed file (atomic write, <board>.adom-bak beside it, source:\"file\"); source:\"file\" or \"live\" forces one. dryRun:true changes nothing.",     "A board open in KiCad without the IPC API is refused (board_open): the editor would overwrite a file edit. Turn the API on (kicad_ipc_api {\"enable\":true}, relaunch) or close the board.",-    "Live, the new items are created from text by KiCad's own parser (ParseAndCreateItemsFromString) with fresh UUIDs; a KiCad that does not serve that call refuses with nothing changed.",+    "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.", ]; @@ -35,7 +35,7 @@ pub static VERBS: &[Verb] = &[         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",@@ -65,7 +65,7 @@ pub static VERBS: &[Verb] = &[         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",@@ -75,7 +75,7 @@ pub static VERBS: &[Verb] = &[         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",@@ -105,7 +105,7 @@ pub static VERBS: &[Verb] = &[         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",@@ -155,7 +155,7 @@ pub static VERBS: &[Verb] = &[         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",
rust/crates/kicad-bridge/src/verbs_libedit.rs+451
@@ -79,6 +79,107 @@ pub static VERBS: &[Verb] = &[         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> {@@ -90,6 +191,15 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "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,     }) }@@ -458,3 +568,344 @@ mod tests {         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+}
rust/crates/kicad-bridge/src/verbs_routing.rs+13
@@ -86,6 +86,16 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_route_net", "kicad_routing_state"],         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.",@@ -228,6 +238,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "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),@@ -258,6 +269,7 @@ enum Live {     TextBounds,     RouteNet,     RemoveRoute,+    RipupNets,     Validate,     AddZone,     ApplyZonePlan,@@ -285,6 +297,7 @@ fn live(state: &mut State, args: &Value, which: Live) -> Value {         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),
rust/crates/kicad-bridge/src/verbs_sch_hier.rs+413
@@ -128,6 +128,46 @@ pub static VERBS: &[Verb] = &[         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> {@@ -142,6 +182,10 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "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,     }) }@@ -1076,3 +1120,372 @@ fn design_review(state: &mut State, args: &Value) -> Value {  #[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");+    }+}
rust/crates/kicad-bridge/src/verbs_sch_hier/tests.rs+1−1
@@ -39,7 +39,7 @@ fn parses(path: &Path) {  #[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] {
rust/crates/kicad-bridge/src/verbs_standard.rs+68
@@ -112,6 +112,26 @@ pub static VERBS: &[Verb] = &[         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> {@@ -126,6 +146,8 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "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,     }) }@@ -376,3 +398,49 @@ mod summary_extra_tests {         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."),+    }+}
rust/crates/kicad-core/src/board_reads.rsadded+202
@@ -0,0 +1,202 @@+//! 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]));+    }+}
rust/crates/kicad-core/src/ipc.rs+119−38
@@ -46,6 +46,7 @@ pub use silk_graphics::silk_graphics_batch; mod route_edit; pub use route_edit::{apply_track_change, copy_routing, modify_track, route_diff_pair}; mod board_live;+mod live_create; pub use board_live::*;  type RResult<T> = Result<T, RoutingError>;@@ -949,6 +950,63 @@ pub fn remove_route(ctx: &Ctx, args: &Value) -> RResult<Value> {     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 {@@ -1433,48 +1491,58 @@ pub fn build_footprint_updates(items: Vec<EditablePcbItem>, moves: &[placement::             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();-        // 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);-                }+        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(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."))-                })?;+            if let Some(a) = text.attributes.as_mut().and_then(|a| a.angle.as_mut()) {+                a.value_degrees = xf.angle(a.value_degrees);             }         }-        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;+    }+    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.layer = layer_id(&m.after.side)?;-        out.push(EditablePcbItem::Footprint(fp));     }-    Ok(out)+    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,@@ -1482,7 +1550,7 @@ pub fn build_footprint_updates(items: Vec<EditablePcbItem>, moves: &[placement:: /// 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()?;+    let (text_before, board, revision) = s.snapshot()?;     check_revision(args, &revision)?;     let plan = placement::plan_moves(&board, args)?;     let public = placement::plan_json(&plan);@@ -1516,8 +1584,21 @@ pub fn move_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {     // 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)) => {+        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;
rust/crates/kicad-core/src/ipc/board_live.rs+30
@@ -10,6 +10,7 @@ //! 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}; @@ -84,10 +85,18 @@ fn commit_built(ctx: &Ctx, args: &Value, s: &Session, text: &str, revision: &str     // 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 {@@ -101,6 +110,19 @@ fn commit_built(ctx: &Ctx, args: &Value, s: &Session, text: &str, revision: &str             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)@@ -122,6 +144,8 @@ fn commit_built(ctx: &Ctx, args: &Value, s: &Session, text: &str, revision: &str     })     .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();@@ -486,6 +510,12 @@ pub fn edit_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {             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),     }
rust/crates/kicad-core/src/ipc/live_create.rsadded+472
@@ -0,0 +1,472 @@+//! 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));+    }+}
rust/crates/kicad-core/src/ipc/silk_fields.rs+6
@@ -245,6 +245,12 @@ pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {                 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");
rust/crates/kicad-core/src/lib.rs+2
@@ -49,6 +49,8 @@ pub mod dfm; 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;
rust/crates/kicad-core/src/libops.rsadded+287
@@ -0,0 +1,287 @@+//! 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"));+    }+}
rust/crates/kicad-core/src/schematic.rs+36
@@ -828,6 +828,42 @@ impl Schematic {     }      /// 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());
rust/crates/kicad-core/src/silk_fields.rs+81−12
@@ -119,23 +119,72 @@ pub fn prepare(source: &str, args: &Value) -> Result<Plan, RoutingError> {         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))@@ -226,3 +275,23 @@ mod tests {         }     } }++#[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());+    }+}
skills/kicad-bridge-test/run_verb_tests.py+43
@@ -186,6 +186,10 @@ def matrix(w: dict) -> dict:         "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),@@ -1233,6 +1237,10 @@ def main():         # 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)@@ -1380,6 +1388,41 @@ def main():         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()
skills/kicad-schematic-and-board-edits/SKILL.md+28−5
@@ -38,17 +38,40 @@ Every verb here edits files or the live board the way KiCad would, answers what   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`,