app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #357: kicad-lane: 1.0.90, swap_footprint, plate_vias, normalize_bodies, symbol_mode, explained refusals (#29)
14 files changed
+674−6
bridge.json+18rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/groups.rs+1rust/crates/kicad-bridge/src/main.rs+1rust/crates/kicad-bridge/src/verbs_board.rs+34rust/crates/kicad-bridge/src/verbs_libedit.rs+65rust/crates/kicad-bridge/src/verbs_library_views.rs+60rust/crates/kicad-bridge/src/verbs_standard.rs+66rust/crates/kicad-bridge/src/verbs_unsupported.rs+191rust/crates/kicad-core/src/board_edit.rs+43−1rust/crates/kicad-core/src/fpedit.rs+144rust/crates/kicad-core/tests/board_edit_fixture.rs+27skills/kicad-bridge-test/run_verb_tests.py+20−1bridge.json+18@@ -157,6 +157,24 @@ "kicad_edit_footprint", "kicad_align_footprints", "kicad_update_footprints_from_library",+ "kicad_swap_footprint",+ "kicad_plate_vias",+ "kicad_normalize_bodies",+ "kicad_symbol_mode",+ "kicad_add_symbol_art",+ "kicad_cleanup_libs",+ "kicad_build_symbol_modes",+ "kicad_license",+ "kicad_signin",+ "kicad_connect_workspace",+ "kicad_agent_start",+ "kicad_agent_stop",+ "kicad_agent_status",+ "kicad_agent_recover",+ "kicad_selftest_script_error",+ "kicad_extension_ping",+ "kicad_install_extension",+ "kicad_install_receipt", "kicad_footprint_graphics", "kicad_footprint_models", "kicad_get_selection",
rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.89"+version = "1.0.90" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.89"+version = "1.0.90" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.89"+version = "1.0.90" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.89"+version = "1.0.90" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/groups.rs+1@@ -48,6 +48,7 @@ pub static GROUPS: &[Group] = &[ Group { name: "sim", verbs: || crate::verbs_sim::VERBS, dispatch: crate::verbs_sim::dispatch }, Group { name: "app", verbs: || crate::verbs_app::VERBS, dispatch: crate::verbs_app::dispatch }, Group { name: "build", verbs: || crate::verbs_build::VERBS, dispatch: crate::verbs_build::dispatch },+ Group { name: "unsupported", verbs: || crate::verbs_unsupported::VERBS, dispatch: crate::verbs_unsupported::dispatch }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {
rust/crates/kicad-bridge/src/main.rs+1@@ -44,6 +44,7 @@ mod verbs_land_pattern; mod verbs_library_views; mod verbs_sim; mod verbs_app;+mod verbs_unsupported; mod verbs_build; mod verbs_windows;
rust/crates/kicad-bridge/src/verbs_board.rs+34@@ -147,6 +147,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_move_footprint", "kicad_placement_state", "kicad_placement_validate"], pitfalls: &["Live only for a real move (dryRun works on a closed file), like kicad_move_footprint.", "Rotation and side are kept; align parts of the same orientation for a clean row."], },+ Verb {+ name: "kicad_swap_footprint",+ summary: "Put another library footprint on one placed part at the same position, rotation and side, keeping its reference, fields, schematic link, DNP/BOM flags and lock; pads keep their nets by number and assigns sets nets on any pad. Tracks are not moved.",+ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+ input: "{\"filePath\", \"ref\": \"U1\", \"footprint\": \"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm\" | \"libraryPath\": \"C:/.../Mine.pretty\" + \"footprintName\": \"SOIC-8\", \"assigns\"?: {\"9\": \"GND\"} | \"9=GND;10=VCC\", \"expectedRevision\"?, \"dryRun\"?}",+ example: "kicad_swap_footprint {\"filePath\":\"C:/p/b.kicad_pcb\",\"ref\":\"U1\",\"footprint\":\"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm\"}",+ hint: "netsKeptOnPads lists the new footprint's pads that took the old pad's net by number; padsWithoutNet lists pads no net reached (give them one with assigns). from and to name the library ids. One Undo step live, or one write. kicad_update_footprints_from_library brings a part up to its OWN library footprint instead.",+ related: &["kicad_update_footprints_from_library", "kicad_footprint_models", "kicad_routing_state"],+ pitfalls: &["On a board OPEN in the PCB editor KiCad 10.0.x cannot create the new footprint over its API: the verb refuses with live_create_unsupported and changes nothing. Save and close the board and the verb edits the file instead.", "Pads are matched by number: a footprint numbered differently keeps no nets unless assigns gives them.", "Tracks are not moved to follow pads that moved."],+ }, Verb { name: "kicad_update_footprints_from_library", summary: "Compare placed footprints with their current library definition and, with apply:true, replace the ones that differ, keeping position, side, reference, value, fields, pad nets and the schematic link.",@@ -224,6 +234,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_edit_footprint" => edit_with(state, args, &|t| board_edit::edit_footprints(t, args), Special::Edit), "kicad_align_footprints" => align(state, args), "kicad_update_footprints_from_library" => update_from_library(state, args),+ "kicad_swap_footprint" => swap_footprint(state, args), "kicad_footprint_graphics" => listed_edit(state, args, Listed::Graphics), "kicad_footprint_models" => listed_edit(state, args, Listed::Models), "kicad_get_selection" => live_only(state, args, LiveOnly::GetSelection),@@ -547,6 +558,29 @@ fn update_from_library(state: &mut State, args: &Value) -> Value { edit(state, args, &build) } +/// `kicad_swap_footprint`: one part onto another library footprint (by Lib:Name, or a .pretty and a name).+fn swap_footprint(state: &mut State, args: &Value) -> Value {+ let ctx = lib_ctx(state);+ let Some(board) = board_path(args) else { return fail("missing_arg", "filePath is required", "Pass the board's absolute .kicad_pcb path.") };+ let (lib_id, lib_text) = match (arg_str(args, "footprint"), arg_str(args, "libraryPath"), arg_str(args, "footprintName")) {+ (Some(id), _, _) => match resolve_footprint(&ctx, &board, id) {+ Ok(t) => (id.to_string(), t),+ Err(e) => return fail("footprint_not_found", e, "footprint is Library:Footprint as KiCad's footprint library table names it, or pass libraryPath (a .pretty) and footprintName."),+ },+ (None, Some(lib), Some(name)) => {+ let dir = PathBuf::from(lib.trim_end_matches(['/', '\\']));+ let file = dir.join(format!("{name}.kicad_mod"));+ match std::fs::read_to_string(&file) {+ Ok(t) => (format!("{}:{name}", dir.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()), t),+ Err(e) => return fail("footprint_not_found", format!("cannot read {}: {e}", norm(&file)), "footprintName is the .kicad_mod's name in that .pretty folder."),+ }+ }+ _ => return fail("missing_arg", "footprint (Library:Footprint) or libraryPath + footprintName is required", "The footprint the part should take."),+ };+ let build = |t: &str| board_edit::swap_footprint(t, args, &lib_text, &lib_id);+ edit(state, args, &build)+}+ #[derive(Clone, Copy)] enum Listed { Graphics,
rust/crates/kicad-bridge/src/verbs_libedit.rs+65@@ -32,6 +32,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_fp_info", "kicad_fp_create", "kicad_lint_library"], pitfalls: &["boards that already use the footprint keep their own copy: update them from the library in KiCad (Update Footprints from Library)", "graphics replace/delete touch shapes only, never text on that layer"], },+ Verb {+ name: "kicad_plate_vias",+ summary: "Make every drilled pad in a footprint library a plated hole through every copper layer (thru_hole, *.Cu *.Mask): a thru_hole pad on only some copper layers, or an smd pad with a drill (thermal vias written as surface pads). Numbered unplated holes are listed, and plated only with includeNpth.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"libraryPath\": \"C:/.../Mine.pretty\" | \"C:/.../x.kicad_mod\", \"footprintName\"?: \"QFN-16\", \"includeNpth\"?: false, \"dryRun\"?: false}",+ example: "kicad_plate_vias {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\"}",+ hint: "fixed is Footprint:Pad;... (fixedPads has what each pad was and its old layers); holes counts the drilled pads seen, plated the ones changed. Each file changed is written once with a .bak beside it. unplated lists numbered np_thru_hole pads left alone (a mounting hole is meant to be unplated): includeNpth:true plates them too. Running it again changes nothing.",+ related: &["kicad_fp_info", "kicad_fp_edit", "kicad_lint_library"],+ pitfalls: &["Edits the library files in place: a board that already uses the footprint takes the change through kicad_update_footprints_from_library."],+ }, Verb { name: "kicad_fp_info", summary: "Full details of one footprint: pads (number, type, shape, position, size, drill, layers), layers used, courtyard box, graphics per layer, attributes, properties and 3D models.",@@ -187,6 +197,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_fp_create" => fp_create(args), "kicad_fp_edit" => fp_edit(state, args), "kicad_fp_info" => fp_info(state, args),+ "kicad_plate_vias" => plate_vias(args), "kicad_sym_create" => sym_create(args), "kicad_sym_delete" => sym_delete(args), "kicad_sym_info" => sym_info(state, args),@@ -573,6 +584,60 @@ mod tests { // Library housekeeping (the standard's core tier) // --------------------------------------------------------------------------- +/// `kicad_plate_vias`: the standard's plate_vias over a .pretty (or one .kicad_mod).+fn plate_vias(args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "libraryPath is required", "A .pretty folder or one .kicad_mod.");+ };+ let only = arg_str(args, "footprintName");+ let files: Vec<PathBuf> = if lib.is_dir() {+ let mut v: Vec<PathBuf> = std::fs::read_dir(&lib).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)).collect();+ v.sort();+ v+ } else if lib.is_file() {+ vec![lib.clone()]+ } else {+ return fail("file_not_found", format!("{} does not exist", norm(&lib)), "A .pretty folder or one .kicad_mod.");+ };+ let files: Vec<PathBuf> = files.into_iter().filter(|f| only.is_none_or(|n| f.file_stem().map(|s| s.to_string_lossy() == n).unwrap_or(false))).collect();+ if files.is_empty() {+ return fail("footprint_not_found", format!("no footprint {} in {}", only.unwrap_or("*"), norm(&lib)), "footprintName is the .kicad_mod's name.");+ }+ let dry = args.get("dryRun") == Some(&json!(true));+ let npth = args.get("includeNpth") == Some(&json!(true));+ let (mut fixed, mut rows, mut unplated, mut written) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+ let mut holes = 0;+ for f in &files {+ let name = f.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+ let Ok(text) = std::fs::read_to_string(f) else { continue };+ let (new, fx, h, un) = match kicad_core::fpedit::plate_vias(&text, npth) {+ Ok(r) => r,+ Err(e) => return fail("parse_failed", format!("{}: {e}", norm(f)), "kicad_lint_library says what is wrong with the file."),+ };+ holes += h;+ unplated.extend(un.iter().map(|n| format!("{name}:{n}")));+ if fx.is_empty() {+ continue;+ }+ for x in &fx {+ fixed.push(format!("{name}:{}", x["pad"].as_str().unwrap_or("")));+ let mut x = x.clone();+ x["footprint"] = json!(name);+ rows.push(x);+ }+ if !dry {+ let bak = f.with_extension("kicad_mod.bak");+ if let Err(e) = std::fs::copy(f, &bak).map_err(|e| e.to_string()).and_then(|_| kicad_core::libraries::write_atomic(f, new.as_bytes())) {+ return fail("write_failed", format!("{}: {e}", norm(f)), "Check the library is writable; .bak files hold the previous content of any already written.");+ }+ written.push(norm(f));+ }+ }+ json!({"success": true, "dryRun": dry, "fixed": fixed.join(";"), "fixedPads": rows, "holes": holes, "plated": fixed.len(), "unplated": unplated, "files": written,+ "output": format!("{} drilled pad(s) plated through every copper layer, of {holes} drilled pad(s) in {} footprint(s){}", fixed.len(), files.len(), if dry { " (dry run: nothing written)" } else { "" }),+ "_hint": "Boards already using these footprints take the change through kicad_update_footprints_from_library."})+}+ 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."));
rust/crates/kicad-bridge/src/verbs_library_views.rs+60@@ -26,6 +26,15 @@ pub static VERBS: &[Verb] = &[Verb { hint: "stepPath is the file; with libraryPath it goes in <Lib>.3dshapes beside the library, and footprintName attaches it (attached is kicad_add_body's reply, which replaces the footprint's models). dimensionsMm lists every dimension used, defaults included: compare bboxMm with the datasheet's body before trusting it. An unknown family answers with the families the engine makes. The standard's generate_body, written by its engine (engine says which).", related: &["kicad_add_body", "kicad_footprint_from_land_pattern", "kicad_library_views", "kicad_model_check"], pitfalls: &["A wrong dimension gives a plausible but wrong body: nothing checks it against a datasheet.", "The body sits in the package's own frame (pin 1 top-left, seated on z = 0): a footprint whose origin is not the package centre needs an offset (kicad_add_body offset).", "An existing STEP file is kept unless overwrite:true."],+}, Verb {+ name: "kicad_symbol_mode",+ summary: "Show one body style of a library symbol as a picture: mode 0 the standard body, mode 1 the De Morgan alternate (KiCad's two display modes), for one unit, plotted by KiCad itself (kicad-cli) without opening an editor.",+ mechanism: Mechanism::Cli, risk: "read", timeout_sec: 120,+ input: "{\"libraryPath\": \"C:/.../x.kicad_sym\", \"symbol\": \"74LS00\", \"mode\"?: 0, \"unit\"?: 1, \"width\"?: 800, \"outputDir\"?: \"C:/...\"}",+ example: "kicad_symbol_mode {\"libraryPath\":\"C:/Program Files/KiCad/10.0/share/kicad/symbols/74xx.kicad_sym\",\"symbol\":\"74LS00\",\"mode\":1}",+ hint: "png is the picture; modes says which body styles the symbol has ([0] or [0, 1]) and units how many units it has. Mode 1 on a symbol without a De Morgan body is refused (no_such_mode). kicad_library_views shows symbol, footprint and 3D together.",+ related: &["kicad_library_views", "kicad_sym_info", "kicad_show_symbol"],+ pitfalls: &["KiCad symbols have two body styles at most: there is no mode 2."], }, Verb { name: "kicad_library_views", summary: "Pictures of library parts for review, each as an image file: the schematic symbol, the footprint (pads with their numbers), the 3D body seated on the footprint, and the device view (every symbol pin mapped to a pad).",@@ -37,8 +46,59 @@ pub static VERBS: &[Verb] = &[Verb { pitfalls: &["The 3d view needs a model on the footprint whose file resolves: a part without one is ok:false, not drawn empty.", "KiCad's 3D viewer settings can hide whole classes of bodies (kicad_3d_viewer_settings): a body that resolves but does not show is hidden there.", "A symbol with several units gives one symbol picture per unit (paths)."], }]; +/// `kicad_symbol_mode` (the standard's symbol_mode): one body style of one unit, plotted by kicad-cli.+fn symbol_mode(state: &mut State, args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(PathBuf::from) else {+ return fail("missing_arg", "libraryPath is required", "The .kicad_sym that holds the symbol.");+ };+ let Some(name) = arg_any(args, &["symbol", "symbolName", "name"]) else {+ return fail("missing_arg", "symbol is required", "The symbol's name in the library.");+ };+ let mode = args.get("mode").and_then(Value::as_u64).unwrap_or(0);+ if mode > 1 {+ return fail("no_such_mode", format!("mode {mode}: KiCad symbols have two body styles (0 standard, 1 De Morgan)"), "Pass mode 0 or 1.");+ }+ let unit = args.get("unit").and_then(Value::as_u64).unwrap_or(1).max(1);+ let Some(cli_path) = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()) else {+ return fail("not_installed", "kicad-cli not found", "The plot comes from KiCad's own kicad-cli.");+ };+ let out = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir().join("adom-kicad-symbol-mode"));+ let svg_dir = out.join(format!("svg-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&svg_dir);+ let _ = std::fs::create_dir_all(&svg_dir);+ let r = cli::run(&cli_path, &["sym", "export", "svg", "-s", name, "-o", &norm(&svg_dir), &norm(&lib)], Duration::from_secs(100));+ let files: Vec<String> = std::fs::read_dir(&svg_dir).into_iter().flatten().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();+ if files.is_empty() {+ let _ = std::fs::remove_dir_all(&svg_dir);+ return fail("symbol_not_found", format!("kicad-cli plotted nothing for {name} (rc {}): {}", r.exit_code, r.stderr.trim()), "kicad_sym_info lists the library's symbols.");+ }+ let units = files.iter().filter(|f| !f.contains("_demorgan")).count().max(1);+ let has_alt = files.iter().any(|f| f.contains("_demorgan"));+ let want = if mode == 1 { format!("_unit{unit}_demorgan.svg") } else { format!("_unit{unit}.svg") };+ let Some(file) = files.iter().find(|f| f.ends_with(&want)) else {+ let _ = std::fs::remove_dir_all(&svg_dir);+ return if mode == 1 && !has_alt {+ fail("no_such_mode", format!("{name} has no De Morgan body style"), "Mode 0 is its only body style.")+ } else {+ fail("no_such_unit", format!("{name} has no unit {unit} (it has {units})"), "Pass a unit from 1 to the symbol's unit count.")+ };+ };+ let bytes = std::fs::read(svg_dir.join(file)).unwrap_or_default();+ let width = args.get("width").and_then(Value::as_u64).unwrap_or(800).clamp(64, 4000) as u32;+ let png = out.join(format!("{}-unit{unit}-mode{mode}.png", name.replace(['/', '\\', ':'], "_")));+ let saved = sch_render::rasterize(&bytes, width).and_then(|r| sch_render::save_png(&r.pixmap, &png));+ let _ = std::fs::remove_dir_all(&svg_dir);+ if let Err(e) = saved {+ return fail("render_failed", e, "Pass an outputDir the desktop user can write.");+ }+ json!({"success": true, "symbol": name, "mode": mode, "unit": unit, "modes": if has_alt { json!([0, 1]) } else { json!([0]) }, "units": units, "png": norm(&png),+ "output": format!("{name} unit {unit}, {} body", if mode == 1 { "De Morgan" } else { "standard" }),+ "_hint": "Read png to see the body style. KiCad plotted it; no editor was opened."})+}+ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { match command {+ "kicad_symbol_mode" => Some(symbol_mode(state, args)), "kicad_library_views" => Some(library_views(state, args)), "kicad_generate_body" => Some(generate_body(state, args)), _ => None,
rust/crates/kicad-bridge/src/verbs_standard.rs+66@@ -142,6 +142,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_select_net", "kicad_select_items", "kicad_render_board", "kicad_screenshot_all"], pitfalls: &["KiCad frames the selection with its own margin; there is no margin to set.", "A rectangle is refused (not_supported): KiCad's API zooms to a selection, so name the parts in it."], },+ Verb {+ name: "kicad_normalize_bodies",+ summary: "Move each 3D model of a footprint library (or one footprint) onto its pad cluster when its body sits more than minShiftMm from the pads' centre, by its offset; models already on their pads are left alone.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"libraryPath\": \"C:/.../Mine.pretty\" | \"C:/.../x.kicad_mod\", \"footprintName\"?: \"SOIC-8\", \"minShiftMm\"?: 2, \"dryRun\"?: false}",+ example: "kicad_normalize_bodies {\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\"}",+ hint: "moved[{footprint, path, shiftMm, offsetBefore, offsetAfter}] are the models moved (offset in mm, KiCad's 3D frame, y up); kept says why each other model stayed (within minShiftMm, hidden, or its file could not be read). Each file changed is written once with a .bak beside it. Running it again changes nothing.",+ related: &["kicad_footprint_models", "kicad_bodies", "kicad_fp_info"],+ pitfalls: &["It centres the body on the pads in the plane: a body offset on purpose further than minShiftMm (a connector overhanging the edge) is moved too, so raise minShiftMm or name the footprint.", "Model paths resolve through KiCad's path variables (Preferences > Configure Paths) and the library folder."],+ }, Verb { name: "kicad_animate_view", summary: "Camera choreography on the open board, run inside the bridge so the timing is tight enough to narrate over: zooms on the PCB editor (KiCad's own actions through its API), and in the 3D Viewer orbits, rolls, views, pans and flips (its View-menu commands), all in the background.",@@ -181,6 +191,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_set_layer_visibility" => set_layer_visibility(state, args), "kicad_zoom_to" => zoom_to(state, args), "kicad_animate_view" => animate_view(state, args),+ "kicad_normalize_bodies" => normalize_bodies(state, args), _ => return None, }) }@@ -563,6 +574,61 @@ fn kicad_vars(state: &mut State) -> std::collections::HashMap<String, String> { doc["environment"]["vars"].as_object().map(|m| m.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()).unwrap_or_default() } +/// `kicad_normalize_bodies`: the standard's normalize_bodies over a .pretty (or one .kicad_mod).+fn normalize_bodies(state: &mut State, args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryPath", "filePath"]).map(std::path::PathBuf::from) else {+ return fail("missing_arg", "libraryPath is required", "A .pretty folder or one .kicad_mod.");+ };+ let only = arg_str(args, "footprintName");+ let mut files: Vec<std::path::PathBuf> = if lib.is_dir() {+ std::fs::read_dir(&lib).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_mod").unwrap_or(false)).collect()+ } else if lib.is_file() {+ vec![lib.clone()]+ } else {+ return fail("file_not_found", format!("{} does not exist", norm(&lib)), "A .pretty folder or one .kicad_mod.");+ };+ files.sort();+ files.retain(|f| only.is_none_or(|n| f.file_stem().map(|s| s.to_string_lossy() == n).unwrap_or(false)));+ if files.is_empty() {+ return fail("footprint_not_found", format!("no footprint {} in {}", only.unwrap_or("*"), norm(&lib)), "footprintName is the .kicad_mod's name.");+ }+ let min_shift = args.get("minShiftMm").and_then(Value::as_f64).unwrap_or(2.0).max(0.0);+ let dry = args.get("dryRun") == Some(&json!(true));+ let models = models_dir(state);+ let vars = kicad_vars(state);+ let (mut moved, mut kept, mut written) = (Vec::new(), Vec::new(), Vec::new());+ for f in &files {+ let name = f.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+ let dir = f.parent().map(|p| p.to_path_buf()).unwrap_or_default();+ let Ok(text) = std::fs::read_to_string(f) else { continue };+ let mut resolve = |path: &str| -> Result<kicad_core::bodies::Bounds, String> {+ let expanded = kicad_core::bodies::expand(path, models.as_deref(), &dir, &vars).ok_or_else(|| format!("a path variable in {path} is not set (KiCad: Preferences > Configure Paths)"))?;+ let file = kicad_core::bodies::find_model(&expanded, &dir).ok_or_else(|| format!("model file not found: {expanded}"))?;+ kicad_core::bodies::file_bounds(&file)+ };+ let (new, mv, kp) = match kicad_core::fpedit::normalize_bodies(&text, min_shift, &mut resolve) {+ Ok(r) => r,+ Err(e) => return fail("parse_failed", format!("{}: {e}", norm(f)), "kicad_lint_library says what is wrong with the file."),+ };+ kept.extend(kp.into_iter().map(|mut k| { k["footprint"] = json!(name); k }));+ if mv.is_empty() {+ continue;+ }+ moved.extend(mv.into_iter().map(|mut m| { m["footprint"] = json!(name); m }));+ if !dry {+ let bak = f.with_extension("kicad_mod.bak");+ if let Err(e) = std::fs::copy(f, &bak).map_err(|e| e.to_string()).and_then(|_| kicad_core::libraries::write_atomic(f, new.as_bytes())) {+ return fail("write_failed", format!("{}: {e}", norm(f)), "Check the library is writable; .bak files hold the previous content of any already written.");+ }+ written.push(norm(f));+ }+ }+ json!({"success": true, "dryRun": dry, "moved": moved, "kept": kept, "files": written,+ "data": {"moved": moved.len(), "shifts": moved.iter().map(|m| format!("{}:{} {} mm", m["footprint"].as_str().unwrap_or(""), m["index"], m["shiftMm"])).collect::<Vec<_>>().join("; ")},+ "output": format!("{} model(s) moved onto their pads in {} footprint(s){}", moved.len(), files.len(), if dry { " (dry run: nothing written)" } else { "" }),+ "_hint": "Boards already using these footprints take the change through kicad_update_footprints_from_library."})+}+ /// `kicad_bodies`: each part's 3D bodies as boxes, from the model files. fn bodies(state: &mut State, args: &Value) -> Value { let o = match opts(args) { Ok(o) => o, Err(e) => return e };
rust/crates/kicad-bridge/src/verbs_unsupported.rsadded+191@@ -0,0 +1,191 @@+//! The standard's verbs KiCad has no form of (EDA Bridge Standard #29: every standard verb answered on every bridge).+//! Each answers `not_supported` with the reason and what to call instead, so an AI that calls one learns why rather+//! than meeting `unknown_verb`.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;++/// (verb, why KiCad has no form of it, what to call instead).+const WHY: &[(&str, &str, &str)] = &[+ ("kicad_add_symbol_art", "The chip outline is baked into the KiCad symbol itself by adom-symbol: KiCad shows it with no second copy to draw (Altium needs this because its symbol is rebuilt from KiCad's).", "kicad_symbol_mode or kicad_library_views (see the symbol as KiCad draws it)"),+ ("kicad_cleanup_libs", "A new KiCad library starts empty, so there is no factory-default stray to remove.", "kicad_sym_delete / kicad_lint_library"),+ ("kicad_build_symbol_modes", "KiCad symbols have two body styles (standard and De Morgan), not N display modes, and nothing in KiCad reads Altium's symbol_modes spec.", "kicad_sym_create (the standard body), kicad_symbol_mode (see either body style)"),+ ("kicad_license", "KiCad is GPL-3.0-or-later and has no licence page to drive.", "kicad_license_status"),+ ("kicad_signin", "KiCad has no account to sign in to.", "kicad_license_status"),+ ("kicad_connect_workspace", "KiCad has no cloud workspace to connect to.", "kicad_install_library (KiCad's libraries are local files and PCM packages)"),+ ("kicad_agent_start", "KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.", "kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python)"),+ ("kicad_agent_stop", "KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.", "kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python)"),+ ("kicad_agent_status", "KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.", "kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python)"),+ ("kicad_agent_recover", "KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.", "kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python)"),+ ("kicad_selftest_script_error", "KiCad runs no resident script engine to test: a failing kicad_eval snippet already comes back as eval_failed with its traceback.", "kicad_eval"),+ ("kicad_extension_ping", "KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for.", "kicad_readiness (ipcApi says whether the PCB editor's API is on)"),+ ("kicad_install_extension", "KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for.", "kicad_readiness (ipcApi says whether the PCB editor's API is on)"),+ ("kicad_install_receipt", "KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for.", "kicad_readiness (ipcApi says whether the PCB editor's API is on)"),+];++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_add_symbol_art",+ summary: "Draw adom-symbol's chip outline into a symbol (the standard's add_symbol_art). Not in KiCad: The chip outline is baked into the KiCad symbol itself by adom-symbol: KiCad shows it with no second copy to draw (Altium needs this because its symbol is rebuilt from KiCad's).",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_add_symbol_art {}",+ hint: "Answers not_supported with why and instead: kicad_symbol_mode or kicad_library_views (see the symbol as KiCad draws it).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_cleanup_libs",+ summary: "Remove a new library's factory-default stray part (the standard's cleanup_libs). Not in KiCad: A new KiCad library starts empty, so there is no factory-default stray to remove.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_cleanup_libs {}",+ hint: "Answers not_supported with why and instead: kicad_sym_delete / kicad_lint_library.",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_build_symbol_modes",+ summary: "Build a symbol with N display modes from a symbol_modes spec (the standard's build_symbol_modes). Not in KiCad: KiCad symbols have two body styles (standard and De Morgan), not N display modes, and nothing in KiCad reads Altium's symbol_modes spec.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_build_symbol_modes {}",+ hint: "Answers not_supported with why and instead: kicad_sym_create (the standard body), kicad_symbol_mode (see either body style).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_license",+ summary: "Drive the tool's licence page (the standard's license). Not in KiCad: KiCad is GPL-3.0-or-later and has no licence page to drive.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_license {}",+ hint: "Answers not_supported with why and instead: kicad_license_status.",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_signin",+ summary: "Sign in to the tool's account (the standard's signin). Not in KiCad: KiCad has no account to sign in to.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_signin {}",+ hint: "Answers not_supported with why and instead: kicad_license_status.",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_connect_workspace",+ summary: "Connect the tool to a cloud workspace (the standard's connect_workspace). Not in KiCad: KiCad has no cloud workspace to connect to.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_connect_workspace {}",+ hint: "Answers not_supported with why and instead: kicad_install_library (KiCad's libraries are local files and PCM packages).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_agent_start",+ summary: "Start the tool's resident script agent (the standard's agent_start). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_agent_start {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_agent_stop",+ summary: "Stop the tool's resident script agent (the standard's agent_stop). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_agent_stop {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_agent_status",+ summary: "Report the tool's resident script agent (the standard's agent_status). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_agent_status {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_agent_recover",+ summary: "Recover a halted script agent (the standard's agent_recover). Not in KiCad: KiCad has no resident script agent to manage: the bridge reaches KiCad through kicad-cli, its built-in IPC API and KiCad's own Python per call, so there is nothing to start, stop, check or recover.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_agent_recover {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (is KiCad usable), kicad_eval (run KiCad's Python).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_selftest_script_error",+ summary: "Self-test the script-error safety net (the standard's selftest_script_error). Not in KiCad: KiCad runs no resident script engine to test: a failing kicad_eval snippet already comes back as eval_failed with its traceback.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_selftest_script_error {}",+ hint: "Answers not_supported with why and instead: kicad_eval.",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_extension_ping",+ summary: "Ping the bridge's extension inside the tool (the standard's extension_ping). Not in KiCad: KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_extension_ping {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (ipcApi says whether the PCB editor's API is on).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_install_extension",+ summary: "Install the bridge's extension into the tool (the standard's install_extension). Not in KiCad: KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_install_extension {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (ipcApi says whether the PCB editor's API is on).",+ related: &[],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_install_receipt",+ summary: "The last extension install's receipt (the standard's install_receipt). Not in KiCad: KiCad needs no extension installed inside it: its IPC API is built in, so there is nothing to install, ping or keep a receipt for.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_install_receipt {}",+ hint: "Answers not_supported with why and instead: kicad_readiness (ipcApi says whether the PCB editor's API is on).",+ related: &[],+ pitfalls: &[],+ },+];++pub fn dispatch(_state: &mut State, command: &str, _args: &Value) -> Option<Value> {+ let (_, why, instead) = WHY.iter().find(|(v, _, _)| *v == command)?;+ let mut v = fail("not_supported", *why, &format!("Instead: {instead}."));+ v["instead"] = json!(instead);+ Some(v)+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn every_unsupported_verb_explains_itself() {+ assert_eq!(VERBS.len(), WHY.len());+ for v in VERBS {+ let r = dispatch(&mut State::new(), v.name, &json!({})).unwrap();+ assert_eq!(r["errorCode"], "not_supported", "{}", v.name);+ assert!(r["error"].as_str().unwrap().len() > 20 && r["instead"].as_str().unwrap().starts_with("kicad_"), "{}", v.name);+ }+ }+}
rust/crates/kicad-core/src/board_edit.rs+43−1@@ -1112,9 +1112,14 @@ fn graphic_sig(fp: &Node) -> Vec<String> { /// with the board's fields, symbol link, pad nets and pin data, lock state, uuid and the /// DNP / BOM / position-file flags kept. pub fn updated_block(text: &str, fp: &Node, lib_text: &str, copper: usize) -> RResult<String> {+ updated_block_as(text, fp, lib_text, fp.atom(1).unwrap_or(""), copper)+}++/// `updated_block` with another library footprint (`lib_id`, its `.kicad_mod` in `lib_text`) in the part's place: the+/// swap. Pads keep their nets by number; a pad number the old footprint did not have gets none.+pub fn updated_block_as(text: &str, fp: &Node, lib_text: &str, lib_id: &str, copper: usize) -> RResult<String> { let (x, y, rot) = fp_text::pose(fp); let back = fp_text::side(fp) == "B.Cu";- let lib_id = fp.atom(1).unwrap_or(""); let fresh = fp_text::place_library(lib_text, lib_id, x, y, rot, back, &fp_ref(fp), None, copper).map_err(|e| err("footprint_unusable", e))?; let nf = sxedit::parse(&fresh).map_err(|e| err("edit_failed", e))?; let mut e = Edits::new();@@ -1239,6 +1244,43 @@ pub fn footprint_diff(old: &Node, new: &Node) -> Value { json!({"identical": changes.is_empty(), "changes": changes, "padsAdded": added, "padsRemoved": removed, "padsChanged": changed_pads, "graphicsChanged": gfx_changed, "modelsBoard": mo, "modelsLibrary": mn}) } +/// `kicad_swap_footprint` (the standard's swap_footprint): one part (`ref`) gets another library footprint (`lib_id`,+/// its text in `lib_text`) at the same pose and side, keeping its reference, fields, symbol link, flags and lock; pads keep+/// their nets by number, and `assigns` ({"3": "GND"} or "3=GND;4=VCC") sets nets on any pad.+pub fn swap_footprint(text: &str, args: &Value, lib_text: &str, lib_id: &str) -> RResult<Change> {+ let root = parse_root(text)?;+ let board = parse_board(text)?;+ let refs = refs_arg(args)?;+ if refs.len() != 1 {+ return Err(err("invalid_args", "swap_footprint takes one ref: the part whose footprint changes"));+ }+ let fp = find_refs(&root, &refs)?[0];+ let copper = fp_text::copper_count(&root);+ let mut block = updated_block_as(text, fp, lib_text, lib_id, copper)?;+ let mut assigns = Map::new();+ match args.get("assigns") {+ Some(Value::Object(o)) => assigns = o.clone(),+ Some(Value::String(s)) => {+ for kv in s.split(';').map(str::trim).filter(|x| !x.is_empty()) {+ let (k, v) = kv.split_once('=').ok_or_else(|| err("invalid_args", format!("assigns entry '{kv}' is not pad=NET")))?;+ assigns.insert(k.trim().to_string(), json!(v.trim()));+ }+ }+ _ => {}+ }+ if !assigns.is_empty() {+ block = set_pad_nets(&block, &board, &assigns)?;+ }+ let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;+ let old_nums: std::collections::BTreeSet<&str> = fp.kids_tagged("pad").filter_map(|p| p.atom(1)).filter(|n| !n.is_empty()).collect();+ let new_pads: Vec<&Node> = nf.kids_tagged("pad").collect();+ let kept: Vec<&str> = new_pads.iter().filter_map(|p| p.atom(1)).filter(|n| old_nums.contains(n)).collect();+ let no_net: Vec<&str> = new_pads.iter().filter(|p| p.kid("net").is_none() && p.atom(2) != Some("np_thru_hole")).filter_map(|p| p.atom(1)).filter(|n| !n.is_empty()).collect();+ let report = json!({"ref": refs[0], "from": fp.atom(1).unwrap_or(""), "to": lib_id, "padsBefore": fp.kids_tagged("pad").count(), "padsAfter": new_pads.len(),+ "netsKeptOnPads": kept, "padsWithoutNet": no_net, "assigned": assigns});+ replace_blocks(text, &root, &[(fp, block)], format!("Adom swap {} to {lib_id}", refs[0]), true, report)+}+ /// `kicad_update_footprints_from_library`: per footprint, what the library version changes; /// with `apply:true` the change replaces every footprint that differs. `resolve` maps a /// library id to the `.kicad_mod` text (Err: why it could not be found).
rust/crates/kicad-core/src/fpedit.rs+144@@ -446,6 +446,115 @@ pub fn info(text: &str) -> Result<Value, String> { })) } +/// The standard's plate_vias on one footprint: every drilled pad becomes a plated hole through every copper layer+/// (`thru_hole`, layers `*.Cu` `*.Mask`) when it is not one already: a `thru_hole` pad on only some copper layers, or an+/// `smd` pad with a drill. A numbered unplated hole (`np_thru_hole`) is plated too when `include_npth`; otherwise it is+/// listed in `unplated` (a mounting hole is meant to be unplated). Returns the new text, the pads fixed (with what each+/// was), the holes seen and the unplated numbered holes left alone.+pub fn plate_vias(text: &str, include_npth: bool) -> Result<(String, Vec<Value>, usize, Vec<String>), String> {+ let mut doc = Document::parse(text)?;+ let mut fixed = Vec::new();+ let mut unplated = Vec::new();+ let mut holes = 0;+ for c in doc.root.children_mut().into_iter().flatten().filter(|c| c.head() == Some("pad")) {+ if c.child("drill").is_none() {+ continue;+ }+ holes += 1;+ let num = c.arg(0).unwrap_or_default();+ let kind = c.arg(1).unwrap_or_default();+ let layers: Vec<String> = c.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();+ let through = layers.iter().any(|l| l == "*.Cu") || (layers.iter().any(|l| l == "F.Cu") && layers.iter().any(|l| l == "B.Cu"));+ let fix = match kind.as_str() {+ "thru_hole" => !through,+ "smd" | "connect" => true,+ "np_thru_hole" => {+ if num.is_empty() {+ false+ } else if include_npth {+ true+ } else {+ unplated.push(num.clone());+ false+ }+ }+ _ => false,+ };+ if !fix {+ continue;+ }+ c.set_arg(1, Node::atom("thru_hole"));+ let mut l = Node::list("layers", vec![Node::quoted("*.Cu"), Node::quoted("*.Mask")]);+ if let Some(old) = c.child_mut("layers") {+ l.lead = old.lead.clone();+ *old = l;+ }+ fixed.push(json!({"pad": num, "was": kind, "layers": layers}));+ }+ Ok((doc.to_text(), fixed, holes, unplated))+}++/// The standard's normalize_bodies on one footprint: a 3D model whose body sits more than `min_shift` mm from the+/// footprint's pad cluster (centre to centre, in the plane) is moved onto it by its offset. `resolve` gives a model+/// file's bounds. Returns the new text, the models moved ({index, path, shiftMm, offsetBefore, offsetAfter}) and the+/// models left as they are (with why).+pub fn normalize_bodies(text: &str, min_shift: f64, resolve: &mut dyn FnMut(&str) -> Result<crate::bodies::Bounds, String>) -> Result<(String, Vec<Value>, Vec<Value>), String> {+ let inf = info(text)?;+ let mut pb: Option<(f64, f64, f64, f64)> = None;+ for p in inf["pads"].as_array().into_iter().flatten() {+ let (x, y) = (p["at"][0].as_f64().unwrap_or(0.0), p["at"][1].as_f64().unwrap_or(0.0));+ let (w, h) = (p["size"][0].as_f64().unwrap_or(0.0) / 2.0, p["size"][1].as_f64().unwrap_or(0.0) / 2.0);+ let e = (x - w, y - h, x + w, y + h);+ pb = Some(pb.map_or(e, |o| (o.0.min(e.0), o.1.min(e.1), o.2.max(e.2), o.3.max(e.3))));+ }+ let Some(pb) = pb else { return Ok((text.to_string(), vec![], vec![json!({"why": "the footprint has no pads to centre on"})])) };+ // the 3D frame is y up; the footprint file's y is down+ let (pcx, pcy) = ((pb.0 + pb.2) / 2.0, -(pb.1 + pb.3) / 2.0);+ let sx = crate::model_check::parse_sexpr(text).ok_or("empty footprint text")?;+ let refs = crate::bodies::model_refs(&sx);+ let mut doc = Document::parse(text)?;+ let (mut moved, mut kept) = (Vec::new(), Vec::new());+ let r4 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;+ let mut model_nodes: Vec<&mut Node> = doc.root.children_mut().into_iter().flatten().filter(|c| c.head() == Some("model")).collect();+ for (i, m) in refs.iter().enumerate() {+ if m.hidden {+ kept.push(json!({"index": i, "path": m.path, "why": "hidden"}));+ continue;+ }+ let b = match resolve(&m.path) {+ Ok(b) => b,+ Err(e) => {+ kept.push(json!({"index": i, "path": m.path, "why": e}));+ continue;+ }+ };+ let local = crate::bodies::in_footprint(&b, m);+ let (bcx, bcy) = ((local.min[0] + local.max[0]) / 2.0, (local.min[1] + local.max[1]) / 2.0);+ let (dx, dy) = (pcx - bcx, pcy - bcy);+ let dist = dx.hypot(dy);+ if dist <= min_shift {+ kept.push(json!({"index": i, "path": m.path, "why": format!("its body is {:.3} mm from the pads' centre (within {min_shift} mm)", dist)}));+ continue;+ }+ let after = [r4(m.offset[0] + dx), r4(m.offset[1] + dy), m.offset[2]];+ let Some(node) = model_nodes.get_mut(i) else { continue };+ let mut off = Node::list("offset", vec![Node::list("xyz", vec![Node::number(after[0]), Node::number(after[1]), Node::number(after[2])])]);+ pretty(&mut off, 2);+ match node.child_mut("offset") {+ Some(o) => {+ *o = off;+ }+ None => {+ if let Some(c) = node.children_mut() {+ c.insert(1.min(c.len()) + 1, off);+ }+ }+ }+ moved.push(json!({"index": i, "path": m.path, "shiftMm": r4(dist), "offsetBefore": m.offset, "offsetAfter": after}));+ }+ Ok((doc.to_text(), moved, kept))+}+ // --------------------------------------------------------------------------- // Edit // ---------------------------------------------------------------------------@@ -853,5 +962,40 @@ mod tests { assert_eq!(i["models"].as_array().unwrap().len(), 1); assert!(info("(kicad_symbol_lib)").is_err()); }++ #[test]+ fn plate_vias_plates_drilled_pads_through_every_copper_layer() {+ let fp = "(footprint \"QFN_vias\"\n\t(layer \"F.Cu\")\n\t(pad \"17\" smd rect (at 0 0) (size 3 3) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\"))\n\t(pad \"17\" thru_hole circle (at 0.5 0.5) (size 0.6 0.6) (drill 0.3) (layers \"F.Cu\"))\n\t(pad \"17\" smd circle (at -0.5 0.5) (size 0.6 0.6) (drill 0.3) (layers \"F.Cu\" \"F.Mask\"))\n\t(pad \"1\" thru_hole circle (at 2 2) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\" \"*.Mask\"))\n\t(pad \"\" np_thru_hole circle (at 3 3) (size 3 3) (drill 3) (layers \"*.Cu\" \"*.Mask\"))\n\t(pad \"5\" np_thru_hole circle (at 4 4) (size 1 1) (drill 1) (layers \"*.Cu\" \"*.Mask\"))\n)\n";+ let (t, fixed, holes, unplated) = plate_vias(fp, false).unwrap();+ assert_eq!(holes, 5);+ assert_eq!(fixed.len(), 2, "{fixed:?}");+ assert_eq!(fixed[0]["was"], "thru_hole");+ assert_eq!(fixed[1]["was"], "smd");+ assert_eq!(unplated, vec!["5".to_string()]);+ let info = info(&t).unwrap();+ let vias: Vec<&Value> = info["pads"].as_array().unwrap().iter().filter(|p| p["drill"].is_array() && p["number"] == "17").collect();+ assert!(vias.iter().all(|p| p["type"] == "thru_hole" && p["layers"] == json!(["*.Cu", "*.Mask"])), "{vias:?}");+ let (_, fixed2, _, unplated2) = plate_vias(fp, true).unwrap();+ assert_eq!((fixed2.len(), unplated2.len()), (3, 0));+ let (_, again, _, _) = plate_vias(&t, false).unwrap();+ assert!(again.is_empty(), "plating is idempotent");+ }++ #[test]+ fn normalize_bodies_moves_a_far_body_onto_the_pads_and_leaves_a_close_one() {+ // a 2-pad part centred at (1, 0) mm; its model's offset puts the body 50 mm away+ let fp = "(footprint \"R_test\"\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))\n\t(pad \"2\" smd rect (at 2 0) (size 1 1) (layers \"F.Cu\"))\n\t(model \"far.step\"\n\t\t(offset\n\t\t\t(xyz 51 0 0)\n\t\t)\n\t\t(scale\n\t\t\t(xyz 1 1 1)\n\t\t)\n\t\t(rotate\n\t\t\t(xyz 0 0 0)\n\t\t)\n\t)\n\t(model \"near.step\"\n\t\t(offset\n\t\t\t(xyz 1.5 0 0)\n\t\t)\n\t)\n)\n";+ let b = crate::bodies::Bounds { min: [-1.0, -0.5, 0.0], max: [1.0, 0.5, 0.5] };+ let mut resolve = |_: &str| -> Result<crate::bodies::Bounds, String> { Ok(b) };+ let (t, moved, kept) = normalize_bodies(fp, 2.0, &mut resolve).unwrap();+ assert_eq!(moved.len(), 1, "{moved:?}");+ assert_eq!(moved[0]["path"], "far.step");+ assert_eq!(moved[0]["offsetAfter"], json!([1.0, 0.0, 0.0]));+ assert_eq!(moved[0]["shiftMm"], 50.0);+ assert_eq!(kept[0]["path"], "near.step");+ assert!(t.contains("(xyz 1 0 0)") && t.contains("(xyz 1.5 0 0)"), "{t}");+ let (_, again, _) = normalize_bodies(&t, 2.0, &mut resolve).unwrap();+ assert!(again.is_empty());+ } }
rust/crates/kicad-core/tests/board_edit_fixture.rs+27@@ -354,6 +354,33 @@ fn update_from_library_plans_then_applies_keeping_nets_fields_and_pose() { } } +#[test]+fn swap_footprint_puts_another_footprint_on_one_part_keeping_nets_by_number() {+ let e = esc();+ // C7's footprint with a third pad: a different library footprint+ let lib = c7_as_library(&e);+ let root = sxedit::parse(&lib).unwrap();+ let pad2 = root.kids_tagged("pad").find(|p| p.atom(1) == Some("2")).unwrap().src(&lib).to_string();+ let pad3 = pad2.replacen("(pad \"2\"", "(pad \"3\"", 1);+ let three = lib.replacen(&pad2, &format!("{pad2}\n\t{pad3}"), 1);+ let c = be::swap_footprint(&e, &json!({"ref": "C7", "assigns": "3=GND"}), &three, "Mine:C_3pad").unwrap();+ assert_eq!(c.report["from"], "Capacitor_SMD:C_0805_2012Metric", "{}", c.report);+ assert_eq!(c.report["to"], "Mine:C_3pad");+ assert_eq!(c.report["netsKeptOnPads"], json!(["1", "2"]));+ assert_eq!(c.report["padsWithoutNet"], json!([]));+ let (before, after) = (parse(&e), parse(&c.candidate));+ let (b7, a7) = (before.footprints.iter().find(|f| f.reference == "C7").unwrap(), after.footprints.iter().find(|f| f.reference == "C7").unwrap());+ assert_eq!(a7.pads.len(), 3);+ assert_eq!(a7.pads[0].net_name, b7.pads[0].net_name);+ assert_eq!(a7.pads[2].net_name.as_deref(), Some("GND"));+ assert_eq!((a7.position.as_ref().unwrap().x, a7.position.as_ref().unwrap().y), (b7.position.as_ref().unwrap().x, b7.position.as_ref().unwrap().y));+ assert_eq!(a7.uuid, b7.uuid);+ assert!(c.candidate.contains("(footprint \"Mine:C_3pad\""));+ // one part only, and an unknown net is refused+ assert!(be::swap_footprint(&e, &json!({"refs": ["C7", "C8"]}), &three, "Mine:C_3pad").is_err());+ assert!(be::swap_footprint(&e, &json!({"ref": "C7", "assigns": {"3": "NO_SUCH_NET"}}), &three, "Mine:C_3pad").is_err());+}+ #[test] fn footprint_graphics_and_models_list_and_edit() { let e = esc();
skills/kicad-bridge-test/run_verb_tests.py+20−1@@ -83,6 +83,8 @@ CHECKS = { "screenshot_files": lambda r: ok(r) and (r.get("screenshots") or r.get("savedTo") or (r.get("data") or {}).get("screenshots")), "symbol_verified": lambda r: ok(r) and (r.get("via") == "plugin-background" or "loaded" in str(r.get("output", "")).lower()), "dry_report": lambda r: r.get("dryRun") is True and "removed" in r,+ # 1.0.90 (#29): a standard verb KiCad has no form of refuses with the reason and what to call instead+ "explains_unsupported": lambda r: r.get("errorCode") == "not_supported" and str(r.get("instead") or "").startswith("kicad_") and len(str(r.get("error") or "")) > 20, "diagnose_facts": lambda r: ok(r) or "facts" in json.dumps(r)[:2000] or "uacEnableLUA" in json.dumps(r), "launched_or_running": lambda r: ok(r) and (r.get("launched") or r.get("alreadyRunning")), "demo_beat": lambda r: ok(r) and (r.get("say") or (r.get("data") or {}).get("say") or r.get("stage")),@@ -125,6 +127,9 @@ def matrix(w: dict) -> dict: M = { # ---- phase 1: read-only / meta (no KiCad needed) "describe": dict(phase=1, args={}, check="verbs>=47"),+ **{v: dict(phase=1, args={}, check="explains_unsupported") for v in (+ "add_symbol_art", "cleanup_libs", "build_symbol_modes", "license", "signin", "connect_workspace", "agent_start", "agent_stop",+ "agent_status", "agent_recover", "selftest_script_error", "extension_ping", "install_extension", "install_receipt")}, "status": dict(phase=1, args={}), "readiness": dict(phase=1, args={}), "list_versions": dict(phase=1, args={}),@@ -197,7 +202,7 @@ def matrix(w: dict) -> dict: "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators", "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic", "library_views", "generate_body", "plan_route", "export_spice", "simulate",- "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom")},+ "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode")}, "panelize": dict(phase=8, scenario=True), # ---- 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),@@ -1667,6 +1672,20 @@ def main(): ok(v1) and (v1.get("verify") or {}).get("kicadNotFitted") == ["C1", "C2"] and ok(v2) and (v2.get("verify") or {}).get("kicadNotFitted") == ["C1"] and ok(v3) and ok(vl) and not any(x.get("name") == "Lite" for x in vl.get("variants") or []), "KiCad's netlist for the variant did not match the parts marked not fitted, or the variant was not removed")+ # 1.0.90 (#29): swap_footprint on a copy of the rp2040 board (C1 onto a 0402 capacitor, nets kept by pad number);+ # plate_vias and normalize_bodies on the land-pattern library; symbol_mode's two body styles on KiCad's 74LS00+ sw_dir = base + "/swap"+ ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + sw_dir.replace("/", "\\") + "\" 2>nul & xcopy /i /q /y \"" + base.replace("/", "\\") + "\\rp2040-breakout.*\" \"" + sw_dir.replace("/", "\\") + "\"",+ "reason": "kicad-bridge verb test: a scratch copy of the rp2040 board for kicad_swap_footprint"}, timeout=120)+ sp = C("swap_footprint", {"filePath": sw_dir + "/rp2040-breakout.kicad_pcb", "ref": "C1", "footprint": "Capacitor_SMD:C_0402_1005Metric"}, 200)+ own("swap_footprint", sp, ok(sp) and sp.get("to") == "Capacitor_SMD:C_0402_1005Metric" and sp.get("netsKeptOnPads") == ["1", "2"], "C1 did not take the 0402 footprint with its nets")+ pv = C("plate_vias", {"libraryPath": w["out"] + "/LandTest.pretty", "dryRun": True}, 120)+ own("plate_vias", pv, ok(pv) and pv.get("plated") == 0 and isinstance(pv.get("holes"), int), "the plate_vias dry run did not answer")+ nb = C("normalize_bodies", {"libraryPath": w["out"] + "/LandTest.pretty", "dryRun": True}, 120)+ own("normalize_bodies", nb, ok(nb) and nb.get("moved") == [], "a body the land-pattern verb seated on its pads was moved")+ kbase = ((ab(a.target, "kicad_readiness", {}, timeout=60).get("installs") or [{}])[0]).get("baseDir") or "C:/Program Files/KiCad/10.0"+ m1 = C("symbol_mode", {"libraryPath": kbase + "/share/kicad/symbols/74xx.kicad_sym", "symbol": "74LS00", "mode": 1}, 120)+ own("symbol_mode", m1, ok(m1) and m1.get("modes") == [0, 1] and str(m1.get("png") or "").endswith(".png"), "74LS00's De Morgan body was not plotted") # 1.0.88: the standard's sch_zoom: where parts sit on a sheet and KiCad's plot framed on them sz = C("sch_zoom", {"sheet": w["sch"], "designators": ["U1", "C1", "ZZ9"]}, 240) own("sch_zoom", sz, ok(sz) and {p.get("designator") for p in sz.get("parts") or []} == {"U1", "C1"} and sz.get("notOnSheet") == ["ZZ9"]