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.
← All Pull Requests
kicad-lane: 1.0.94, export_3d, import_design, edit_pours, tool_update, import_step; schematic part verbs in the standard's arguments #56
Merged opened by Colby Knox 2026-10-30
1.0.94: the standard's export_3d, import_design, edit_pours, tool_update and import_step, and its schematic part verbs in their own arguments (standard 0.29.5).
- kicad_export_3d: writes the board's 3D model through kicad-cli in stl, glb, vrml, ply, brep, xao, u3d, stpz or 3dpdf.
- components:false exports the bare board. copper, silkscreen and soldermask add those layers.
- obj, 3mf, iges and other formats KiCad can't write are refused by name (not_supported), with what to use instead.
- kicad_import_design: brings an EAGLE, Altium, PADS, CADSTAR, Fabmaster, P-CAD or SolidWorks PCB board into a new KiCad project through kicad-cli's importer. The reply counts the parts and nets and gives the importer's warnings.
- kicad-cli imports the board only, and the reply says so (schematicImported: false). The schematic comes in through KiCad's File > Import > Non-KiCad Project.
- Formats KiCad has no importer for, such as OrCAD, are refused by name.
- kicad_edit_pours: changes existing pours in place: clearance, minimum width, thermal reliefs (on/off, spoke, gap) or the pad connection, priority, net, and whether islands are kept. Each pour comes back with its settings before and after.
- A closed board is written (a .adom-bak is kept) and refilled by kicad-cli, and pourState is the copper after the refill.
- On a board open in the PCB editor, each zone KiCad holds is updated in place through its API (UpdateItems: id, outline and every other setting kept) as one Undo step. The board is then refilled, and the settings are read back from KiCad (verified).
- expectedRevision is optional, and KiCad's revision or pour_state's are both accepted.
- kicad_pour_state: each pour now carries its id (the zone's uuid), which kicad_edit_pours takes.
- kicad_sch_place_part, kicad_sch_connect, kicad_sch_edit, kicad_sch_swap_part: the standard's schematic part verbs, under their own names and with their own arguments. They work alone and as kicad_sch_batch ops, and KiCad's own arguments still work too.
- Arguments: positions in mil from the grid point at the sheet's lower-left corner (y up, landing on KiCad's 50 mil grid), parts by designator, and a library as a .kicad_sym path or a table nickname plus the symbol's name.
- sch_connect takes connections [{designator, pin, net, kind: label|power|wire|noerc|port}]. sch_edit takes edits [{designator, action: move|moveBy|rotate|designator|comment|delete}].
- Each list is one write, all or nothing. The reply is in the standard's terms: connected/applied, failed, results, atMil, endMil.
- sch_swap_part wires each pin the new symbol puts somewhere else back to where the old pin ended (pinsJoined).
- kicad_sch_connect_to_net: kind "wire" draws the stub alone, with no label.
- kicad_sch_replace_symbol: joinMoved:true rejoins moved pins.
- kicad_tool_update: the standard's tool_update.
- check compares the installed KiCad with the latest stable (current, offered).
- apply installs the offered version with the official installer, per user with no UAC. It refuses while KiCad is open and lists the open documents, unsaved ones marked.
- auto reads or sets KiCad's own update check.
- kicad_import_step: answers not_supported with the reason: KiCad places 3D models only as a footprint's own model. It suggests kicad_add_body or kicad_export_step instead.
Battery 340 pass / 0 fail / 1 skip, no foreground grabs. Its coverage check named tool_update and import_step (added after the battery began) as untested: their checks are now in the battery and pass on the laptop with the same conditions (tool_update reports 10.0.7 with 10.0.8 offered; import_step refuses by name).
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,446 +1,456 @@⋯ 31 unchanged lines ⋯ "kicad_export_pos", "kicad_render_board", "kicad_export_step",+ "kicad_export_3d",+ "kicad_import_design",+ "kicad_import_step", "kicad_export_bom_csv", "kicad_uninstall", "kicad_extract_netlist",⋯ 23 unchanged lines ⋯ "kicad_pcm_remove_repository", "kicad_check_for_updates", "kicad_upgrade",+ "kicad_tool_update", "kicad_sch_place_symbol",+ "kicad_sch_place_part", "kicad_sch_wire", "kicad_sch_label", "kicad_sch_move", "kicad_sch_delete", "kicad_sch_set_property",+ "kicad_sch_edit", "kicad_sch_no_connect", "kicad_sch_components", "kicad_sch_pins",⋯ 2 unchanged lines ⋯ "kicad_sch_move_region", "kicad_sch_annotate", "kicad_sch_replace_symbol",+ "kicad_sch_swap_part", "kicad_sch_update_from_library", "kicad_sch_reset_fields", "kicad_sch_delete_wire",⋯ 1 unchanged line ⋯ "kicad_sch_edit_label", "kicad_sch_junction", "kicad_sch_connect_to_net",+ "kicad_sch_connect", "kicad_sch_connect_pins", "kicad_sch_text", "kicad_sch_bus",⋯ 70 unchanged lines ⋯ "kicad_align_footprints", "kicad_update_footprints_from_library", "kicad_swap_footprint",+ "kicad_edit_pours", "kicad_plate_vias", "kicad_normalize_bodies", "kicad_symbol_mode",⋯ 280 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.93"+version = "1.0.94" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.93"+version = "1.0.94" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.93"+version = "1.0.94" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.93"+version = "1.0.94" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,361 +1,374 @@⋯ 212 unchanged lines ⋯ ("kicad_read_footprint", "kicad_fp_info"), ("kicad_export_library_part", "kicad_export_part"), ("kicad_create_part_project", "kicad_make_part_project"),+ // the standard's schematic part verbs: their own arguments (Altium's reference, mils from the lower-left) are+ // rewritten in std_shapes.rs; KiCad's own arguments still work under these names+ ("kicad_sch_place_part", "kicad_sch_place_symbol"),+ ("kicad_sch_swap_part", "kicad_sch_replace_symbol"),+ ("kicad_sch_connect", "kicad_sch_connect_to_net"),+ ("kicad_sch_edit", "kicad_sch_set_property"), ]; /// The verb a standard name stands for, when it is an alias.⋯ 67 unchanged lines ⋯ if crate::std_shapes::accepts(alias_target(v.name).unwrap_or(v.name)) { e["standardShape"] = json!(crate::std_shapes::SHAPE_NOTE); }+ if let Some((summary, input, example)) = crate::std_shapes::std_doc(v.name) {+ e["kicadInput"] = json!(v.input);+ e["summary"] = json!(summary);+ e["input"] = json!(input);+ e["example"] = json!(example);+ } e }) .collect();⋯ 67 unchanged lines ⋯ assert_eq!(doc["prewarm"]["verb"], "kicad_prewarm"); } }+@@ -1,61 +1,63 @@⋯ 47 unchanged lines ⋯ 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: "interop", verbs: || crate::verbs_interop::VERBS, dispatch: crate::verbs_interop::dispatch }, Group { name: "unsupported", verbs: || crate::verbs_unsupported::VERBS, dispatch: crate::verbs_unsupported::dispatch }, ]; ⋯ 5 unchanged lines ⋯ } None }+@@ -1,110 +1,104 @@⋯ 27 unchanged lines ⋯ kicad_errors kicad_export_bom_csv kicad_export_molecule-kicad_export_pos kicad_export_svg kicad_extract_netlist kicad_find_connections⋯ 10 unchanged lines ⋯ kicad_install_symbol kicad_ipc_api kicad_lint_board-kicad_lint_library kicad_lint_schematic kicad_list_design_rules kicad_list_footprints kicad_list_symbols kicad_list_versions-kicad_log_tail kicad_menu_items kicad_model_check kicad_modify_track⋯ 13 unchanged lines ⋯ kicad_placement_state kicad_placement_validate kicad_plugin_diagnose-kicad_prewarm kicad_refresh_board_views kicad_remove_route kicad_remove_zone kicad_rescan_libraries kicad_route kicad_routing_validate-kicad_sch_batch kicad_sch_check kicad_sch_components kicad_sch_connect_to_net⋯ 18 unchanged lines ⋯ kicad_taskbar_repair kicad_text_bounds kicad_trace_net-kicad_update_footprints_from_library kicad_upgrade kicad_validate_for_fab-kicad_verb_times kicad_zone_state+@@ -1,290 +1,292 @@⋯ 43 unchanged lines ⋯ mod verbs_library_views; mod verbs_sim; mod verbs_app;+mod verbs_interop; mod verbs_unsupported; mod verbs_build; mod verbs_windows;⋯ 238 unchanged lines ⋯ } } }+@@ -1,258 +1,272 @@-# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.79). Generated by tools/sync_standard_names.py; do not edit.+# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.87). Generated by tools/sync_standard_names.py; do not edit. a365_configure a365_files_upload a365_library_overview⋯ 67 unchanged lines ⋯ document_info edit_footprint edit_parts+edit_pours ensure_licensed estimate_cost eval+export_3d export_bom export_dxf export_gencad⋯ 4 unchanged lines ⋯ export_manufacturing export_odb export_pdf+export_pos export_spice export_step extension_ping⋯ 7 unchanged lines ⋯ generate_body height_check impedance+import_design import_kicad import_part+import_step import_svg install install_extension⋯ 10 unchanged lines ⋯ library_views license license_status+lint_library list_commands list_designators list_installed_libraries list_library list_nets+log_tail move_parts net_classes net_connections⋯ 17 unchanged lines ⋯ plan_route plate_vias pour_state+prewarm progress project_create project_info⋯ 14 unchanged lines ⋯ remove_symbol rename_footprint rename_symbol+render_board render_board_3d render_board_layers render_schematic⋯ 13 unchanged lines ⋯ run_outjob run_script save+sch_batch sch_bus sch_bus_entry sch_connect⋯ 57 unchanged lines ⋯ teardrops test_board thermal_vias+tool_update uninstall uninstall_library+update_footprints_from_library update_pcb variants+verb_times via_fence window_info workspace_place workspace_search xcopy_footprint zoom_to+@@ -1,644 +1,985 @@⋯ 26 unchanged lines ⋯ batched: bool, /// the board verb took the standard frame (its reply stays in KiCad's) board_frame: bool,+ /// one of the standard's schematic part verbs, and the sheet height its mils are measured with+ std_sch: Option<(String, f64)>,+ /// the call's own arguments (the standard reply lists them back)+ args_in: Value, } /// The verbs whose standard shape this module knows: the bridge's own name, after aliases.⋯ 248 unchanged lines ⋯ } } +// -- the standard's schematic part verbs (sch_place_part, sch_connect, sch_edit, sch_swap_part) ----------------+//+// Their reference is Altium's: positions in mils from the sheet's lower-left corner, y up, parts by designator, a+// library by path plus a symbol name. KiCad's verbs take mm from the top-left, y down, by reference and libId; these+// rewrite the call (lists of connections or edits become one kicad_sch_batch) and put the reply in the standard's terms.++const MIL: f64 = 0.0254;+/// KiCad's schematic grid (50 mil): positions land on it so pins meet wires.+const GRID: f64 = 1.27;++fn on_grid(mm: f64) -> f64 {+ ((mm / GRID).round() * GRID * 10000.0).round() / 10000.0+}++/// Where y = 0 is: the grid point at the sheet's lower-left corner, from its `(paper ...)` (A4 when the file cannot be+/// read). On the grid, so a part placed at whole mils lands with its pins on KiCad's grid and reads back the same mils.+fn page_height(path: Option<&str>) -> f64 {+ let h = paper_height(path);+ ((h / GRID).floor() * GRID * 10000.0).round() / 10000.0+}++fn paper_height(path: Option<&str>) -> f64 {+ let text = path.and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default();+ let Some(i) = text.find("(paper ") else { return 210.0 };+ let line: String = text[i + 7..].chars().take_while(|c| *c != ')').collect();+ let words: Vec<&str> = line.split_whitespace().map(|w| w.trim_matches('"')).collect();+ let portrait = words.contains(&"portrait");+ let (w, h) = match words.first().copied().unwrap_or("A4") {+ "User" => (words.get(1).and_then(|v| v.parse().ok()).unwrap_or(297.0), words.get(2).and_then(|v| v.parse().ok()).unwrap_or(210.0)),+ "A5" => (210.0, 148.0), "A3" => (420.0, 297.0), "A2" => (594.0, 420.0), "A1" => (841.0, 594.0), "A0" => (1189.0, 841.0),+ "A" | "USLetter" => (279.4, 215.9), "USLegal" => (355.6, 215.9), "B" | "USLedger" => (431.8, 279.4),+ "C" => (558.8, 431.8), "D" => (863.6, 558.8), "E" => (1117.6, 863.6),+ _ => (297.0, 210.0),+ };+ if portrait && words.first() != Some(&"User") { w } else { h }+}++/// A point in mils from the lower-left (y up) as KiCad's mm from the top-left, on the grid.+fn mil_to_sheet(x: f64, y: f64, height: f64) -> (f64, f64) {+ (on_grid(x * MIL), on_grid(height - y * MIL))+}++fn sheet_to_mil(at: &Value, height: f64) -> Value {+ match (at.get(0).and_then(Value::as_f64), at.get(1).and_then(Value::as_f64)) {+ (Some(x), Some(y)) => json!([(x / MIL).round(), ((height - y) / MIL).round()]),+ _ => Value::Null,+ }+}++/// `library` as KiCad takes it: a .kicad_sym path (libraryPath, the nickname from its file name) or a library+/// nickname from the symbol library table.+fn lib_args(o: &mut Map<String, Value>) -> Result<(), Value> {+ let lib = o.remove("library").and_then(|v| v.as_str().map(str::to_string));+ let sym = o.remove("symbol").and_then(|v| v.as_str().map(str::to_string));+ match (lib, sym) {+ (Some(l), Some(s)) => {+ if path_like(&json!(l)) || l.to_ascii_lowercase().ends_with(".kicad_sym") {+ let nick = std::path::Path::new(&l.replace('\\', "/")).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();+ o.insert("libId".into(), json!(format!("{nick}:{s}")));+ o.insert("libraryPath".into(), json!(l));+ } else {+ o.insert("libId".into(), json!(format!("{l}:{s}")));+ }+ Ok(())+ }+ (None, Some(s)) if s.contains(':') => {+ o.insert("libId".into(), json!(s));+ Ok(())+ }+ _ => Err(fail("missing_arg", "library and symbol are required", "library is the .kicad_sym path (or a nickname from the symbol library table), symbol the name in it, e.g. {library: \"C:/p/Lib.kicad_sym\", symbol: \"TPS54202\"}.")),+ }+}++fn num(o: &Map<String, Value>, k: &str) -> Option<f64> {+ o.get(k).and_then(Value::as_f64)+}++/// describe's summary, input and example for the four under their standard names (KiCad's own input is listed beside).+pub fn std_doc(name: &str) -> Option<(&'static str, &'static str, &'static str)> {+ Some(match name {+ "kicad_sch_place_part" => ("Place a library symbol on an existing sheet (the standard's sch_place_part): x, y in mil from the grid point at the sheet's lower-left corner, y up, landing on KiCad's 50 mil grid; designator, comment (KiCad's Value), footprint, partId (unit).",+ "{\"sheet\": \"C:/p/x.kicad_sch\", \"library\": \"C:/p/Lib.kicad_sym\" | \"Device\" (a library table nickname), \"symbol\": \"R\", \"designator\"?: \"R9\", \"comment\"?: \"10k\", \"footprint\"?: \"Resistor_SMD:R_0603_1608Metric\", \"partId\"?: 1, \"x\"?: 1000, \"y\"?: 1000, \"rotation\"?: 0|90|180|270} -> {designator, atMil, libref, pins, saved}",+ "kicad_sch_place_part {\"sheet\":\"C:/p/x.kicad_sch\",\"library\":\"Device\",\"symbol\":\"R\",\"designator\":\"R9\",\"x\":3000,\"y\":4000}"),+ "kicad_sch_connect" => ("Connect pins of placed parts (the standard's sch_connect): a wire stub from each pin plus a net label (kind label), a power symbol (power), a hierarchical label (port, io its shape), the stub alone (wire), or a no-connect flag (noerc). One write, all or nothing.",+ "{\"sheet\", \"connections\": [{\"designator\": \"U2\", \"pin\": \"1\" | \"VIN\", \"net\"?: \"GND\", \"kind\"?: \"label\"|\"power\"|\"wire\"|\"noerc\"|\"port\", \"io\"?: \"input\"|\"output\"|\"bidirectional\", \"stubMil\"?: 200}], \"dryRun\"?} -> {connected, failed, results[{designator, pin, net, kind, result, endMil}], saved}",+ "kicad_sch_connect {\"sheet\":\"C:/p/x.kicad_sch\",\"connections\":[{\"designator\":\"U2\",\"pin\":\"1\",\"net\":\"GND\",\"kind\":\"power\"}]}"),+ "kicad_sch_edit" => ("Change placed parts (the standard's sch_edit): move to x, y mil (from the grid point at the lower-left corner, y up), moveBy dx, dy, rotate to degrees (the angle the part ends at), rename (designator), re-comment (comment, KiCad's Value), delete. One write, all or nothing.",+ "{\"sheet\", \"edits\": [{\"designator\": \"R5\", \"action\": \"move\"|\"moveBy\"|\"rotate\"|\"designator\"|\"comment\"|\"delete\", \"x\", \"y\" | \"dx\", \"dy\" | \"degrees\" | \"to\" | \"text\"}], \"dryRun\"?} -> {applied, failed, results[{designator, action, result}], saved}",+ "kicad_sch_edit {\"sheet\":\"C:/p/x.kicad_sch\",\"edits\":[{\"designator\":\"R5\",\"action\":\"comment\",\"text\":\"10k\"},{\"designator\":\"C9\",\"action\":\"delete\"}]}"),+ "kicad_sch_swap_part" => ("Replace a placed part with another library symbol at the same place, designator and rotation (the standard's sch_swap_part): pins matched by number, and a pin that now ends somewhere else is wired back to where the old one ended.",+ "{\"sheet\", \"designator\": \"C10\", \"library\": \"C:/p/Lib.kicad_sym\" | \"Device\", \"symbol\": \"C_Small\", \"footprint\"?: \"Capacitor_SMD:C_0402_1005Metric\", \"keepComment\"?: false} -> {now{libReference, footprints}, newPinsNotOnOld, oldPinsNotOnNew, pinsJoined[{pin, from, to}]}",+ "kicad_sch_swap_part {\"sheet\":\"C:/p/x.kicad_sch\",\"designator\":\"C10\",\"library\":\"Device\",\"symbol\":\"C_Small\"}"),+ _ => return None,+ })+}++/// True when a call to one of the four (named without kicad_) carries the standard's arguments rather than KiCad's.+pub fn standard_form(verb: &str, args: &Value) -> bool {+ let has = |k: &str| args.get(k).is_some_and(|v| !v.is_null());+ match verb.trim_start_matches("kicad_") {+ "sch_place_part" => has("symbol") || has("designator") || has("x"),+ "sch_swap_part" => has("designator") || has("symbol"),+ "sch_connect" => has("connections"),+ "sch_edit" => has("edits"),+ _ => false,+ }+}++/// The standard's form of one of the four, as KiCad's call; None when the call is in KiCad's own shape.+fn std_sch(called: &str, args: &Value) -> Option<Result<Shaped, Value>> {+ let o0 = args.as_object()?;+ let verb = called.trim_start_matches("kicad_");+ if !standard_form(verb, args) {+ return None;+ }+ let mut o = o0.clone();+ if o.get("sheet").map(path_like).unwrap_or(false) && !o.contains_key("filePath") {+ let p = o.remove("sheet").unwrap_or(Value::Null);+ o.insert("filePath".into(), p);+ }+ let path = o.get("filePath").and_then(Value::as_str).map(str::to_string);+ let height = page_height(path.as_deref());+ // what a batch carries over from the call+ let base: Map<String, Value> = ["filePath", "dryRun", "backup"].iter().filter_map(|k| o.get(*k).map(|v| (k.to_string(), v.clone()))).collect();+ let batch = |ops: Vec<Value>| {+ let mut b = base.clone();+ b.insert("ops".into(), Value::Array(ops));+ Value::Object(b)+ };+ let shaped = |command: &str, args: Value, batched: bool| Shaped { command: command.into(), args, flip_reply: false, batched, board_frame: false, std_sch: Some((verb.to_string(), height)), args_in: Value::Null };+ Some(match verb {+ "sch_place_part" => {+ if let Err(e) = lib_args(&mut o) {+ return Some(Err(e));+ }+ rename(&mut o, "designator", "reference");+ rename(&mut o, "comment", "value");+ rename(&mut o, "partId", "unit");+ let (x, y) = (num(&o, "x").unwrap_or(1000.0), num(&o, "y").unwrap_or(1000.0));+ o.remove("x");+ o.remove("y");+ o.remove("pcbLib");+ o.remove("save");+ let at = mil_to_sheet(x, y, height);+ o.insert("at".into(), json!([at.0, at.1]));+ Ok(shaped("kicad_sch_place_symbol", Value::Object(o), false))+ }+ "sch_swap_part" => {+ if let Err(e) = lib_args(&mut o) {+ return Some(Err(e));+ }+ let Some(d) = o.get("designator").and_then(Value::as_str).map(str::to_string) else {+ return Some(Err(fail("missing_arg", "designator is required", "The part to replace, e.g. C10.")));+ };+ let lib_id = o["libId"].as_str().unwrap_or("").to_string();+ let mut rep = json!({"op": "replace_symbol", "reference": d, "libId": lib_id, "force": true, "joinMoved": true});+ if let Some(lp) = o.get("libraryPath") {+ rep["libraryPath"] = lp.clone();+ }+ let mut ops = vec![rep];+ if let Some(fp) = o.get("footprint").and_then(Value::as_str) {+ ops.push(json!({"op": "set_property", "reference": d, "name": "Footprint", "value": fp}));+ }+ if o.get("keepComment").and_then(Value::as_bool) != Some(true) {+ // the new part's own comment: KiCad's Value field, which a symbol sets to its own name+ let own = lib_id.split(':').nth(1).unwrap_or("").to_string();+ ops.push(json!({"op": "set_property", "reference": d, "name": "Value", "value": own}));+ }+ Ok(shaped("kicad_sch_batch", batch(ops), true))+ }+ "sch_connect" => {+ let Some(list) = o.get("connections").and_then(Value::as_array).filter(|a| !a.is_empty()) else {+ return Some(Err(fail("missing_arg", "connections is required", "[{designator, pin, net, kind: label|power|wire|noerc|port}]")));+ };+ let mut ops = Vec::new();+ for (i, c) in list.iter().enumerate() {+ let s = |k: &str| c.get(k).and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())));+ let (Some(d), Some(p)) = (s("designator"), s("pin")) else {+ return Some(Err(fail("bad_args", format!("connections[{i}] needs designator and pin"), "Each connection is {designator, pin (number or name), net, kind}.")));+ };+ let pin = format!("{d}.{p}");+ let stub = on_grid(c.get("stubMil").and_then(Value::as_f64).unwrap_or(200.0) * MIL).max(GRID);+ let op = match s("kind").as_deref().unwrap_or("label") {+ "noerc" => json!({"op": "no_connect", "pin": pin}),+ "wire" => json!({"op": "connect_to_net", "pin": pin, "kind": "wire", "length": stub}),+ k @ ("label" | "power" | "port") => {+ let Some(net) = s("net") else {+ return Some(Err(fail("bad_args", format!("connections[{i}] ({pin}) has no net"), "kind label, power and port name a net.")));+ };+ let mut op = json!({"op": "connect_to_net", "pin": pin, "net": net, "length": stub,+ "kind": match k { "power" => "power", "port" => "hierarchical", _ => "local" }});+ if let (true, Some(io)) = (k == "port", s("io")) {+ op["shape"] = json!(io);+ }+ op+ }+ other => return Some(Err(fail("bad_args", format!("connections[{i}]: kind {other} is not label, power, wire, noerc or port"), "label is the default."))),+ };+ ops.push(op);+ }+ Ok(shaped("kicad_sch_batch", batch(ops), true))+ }+ "sch_edit" => {+ let Some(list) = o.get("edits").and_then(Value::as_array).filter(|a| !a.is_empty()) else {+ return Some(Err(fail("missing_arg", "edits is required", "[{designator, action: move|moveBy|rotate|designator|comment|delete, ...}]")));+ };+ let mut ops = Vec::new();+ for (i, e) in list.iter().enumerate() {+ let Some(d) = e.get("designator").and_then(Value::as_str) else {+ return Some(Err(fail("bad_args", format!("edits[{i}] has no designator"), "Each edit names its part by designator.")));+ };+ let n = |k: &str| e.get(k).and_then(Value::as_f64);+ let text = |k: &str| e.get(k).and_then(Value::as_str);+ let op = match e.get("action").and_then(Value::as_str).unwrap_or("") {+ "move" => match (n("x"), n("y")) {+ (Some(x), Some(y)) => { let at = mil_to_sheet(x, y, height); json!({"op": "move", "reference": d, "to": [at.0, at.1]}) }+ _ => return Some(Err(fail("bad_args", format!("edits[{i}] move needs x and y (mil)"), "x, y in mil from the sheet's lower-left corner."))),+ },+ "moveBy" => json!({"op": "move", "reference": d, "by": [on_grid(n("dx").unwrap_or(0.0) * MIL), on_grid(-n("dy").unwrap_or(0.0) * MIL)]}),+ "rotate" => match n("degrees") {+ Some(a) => json!({"op": "rotate", "reference": d, "rotation": a}),+ None => return Some(Err(fail("bad_args", format!("edits[{i}] rotate needs degrees"), "degrees: 0, 90, 180 or 270, the angle the part ends at."))),+ },+ "designator" => match text("to") {+ Some(t) => json!({"op": "set_property", "reference": d, "name": "Reference", "value": t}),+ None => return Some(Err(fail("bad_args", format!("edits[{i}] designator needs to"), "to is the new designator."))),+ },+ "comment" => match text("text") {+ Some(t) => json!({"op": "set_property", "reference": d, "name": "Value", "value": t}),+ None => return Some(Err(fail("bad_args", format!("edits[{i}] comment needs text"), "text is the new comment (KiCad's Value)."))),+ },+ "delete" => json!({"op": "delete", "reference": d}),+ other => return Some(Err(fail("bad_args", format!("edits[{i}]: action {other:?} is not move, moveBy, rotate, designator, comment or delete"), "One action per edit."))),+ };+ ops.push(op);+ }+ Ok(shaped("kicad_sch_batch", batch(ops), true))+ }+ _ => return None,+ })+}++/// The standard's reply for one of the four (after the batch is merged).+fn std_sch_reply(verb: &str, height: f64, args_in: &Value, out: &mut Value) {+ let ok = out.get("success") == Some(&json!(true));+ let results = out.get("results").and_then(Value::as_array).cloned().unwrap_or_default();+ match verb {+ "sch_place_part" => {+ if let Some(at) = out.get("at").cloned().or_else(|| out.pointer("/data/at").cloned()) {+ out["atMil"] = sheet_to_mil(&at, height);+ }+ if let Some(r) = out.get("reference").cloned() {+ out["designator"] = r;+ }+ if let Some(l) = out.get("libId").cloned() {+ out["libref"] = l;+ }+ }+ "sch_swap_part" => {+ let r = results.first().and_then(|r| r.get("result")).cloned().unwrap_or(Value::Null);+ out["newPinsNotOnOld"] = r.get("pinsAdded").cloned().unwrap_or(json!([]));+ out["oldPinsNotOnNew"] = r.get("pinsRemoved").cloned().unwrap_or(json!([]));+ out["pinsJoined"] = r.get("pinsJoined").cloned().unwrap_or(json!([]));+ out["now"] = json!({"libReference": r.get("libId"), "footprints": args_in.get("footprint").map(|f| json!([f])).unwrap_or(json!([]))});+ }+ "sch_connect" => {+ let rows: Vec<Value> = args_in["connections"].as_array().cloned().unwrap_or_default().iter().zip(results.iter()).map(|(c, r)| {+ let end = r.pointer("/result/connected/0/end").cloned().unwrap_or(Value::Null);+ json!({"designator": c.get("designator"), "pin": c.get("pin"), "net": c.get("net"), "kind": c.get("kind").cloned().unwrap_or(json!("label")),+ "result": r.get("output"), "endMil": sheet_to_mil(&end, height)})+ }).collect();+ out["connected"] = json!(if ok { rows.len() } else { 0 });+ out["results"] = json!(rows);+ out["failed"] = if ok { json!([]) } else { json!([{"index": out.get("failedOp"), "error": out.get("error")}]) };+ }+ "sch_edit" => {+ let rows: Vec<Value> = args_in["edits"].as_array().cloned().unwrap_or_default().iter().zip(results.iter()).map(|(e, r)| {+ json!({"designator": e.get("designator"), "action": e.get("action"), "result": r.get("output")})+ }).collect();+ out["applied"] = json!(if ok { rows.len() } else { 0 });+ out["results"] = json!(rows);+ out["failed"] = if ok { json!([]) } else { json!([{"index": out.get("failedOp"), "error": out.get("error")}]) };+ }+ _ => {}+ }+ if ok {+ out["saved"] = json!(out.get("dryRun") != Some(&json!(true)));+ }+}+ /// The call in KiCad's shape. None when the verb has no standard shape here; Err is a refusal. /// `called` is the name the caller used (kicad_ prefixed), `command` the verb it resolved to. pub fn to_native(called: &str, command: &str, args: &Value) -> Option<Result<Shaped, Value>> {+ if let Some(r) = std_sch(called, args) {+ return Some(r.map(|mut s| {+ s.args_in = args.clone();+ s+ }));+ } let mut command = command.to_string(); let Some(o0) = args.as_object() else { return None }; if command == "kicad_edit_footprint" && is_library_footprint_edit(o0) {⋯ 148 unchanged lines ⋯ batched = true; } }- Some(Ok(Shaped { command, args: a, flip_reply: frame && sheet, batched, board_frame: frame && board_frame }))+ Some(Ok(Shaped { command, args: a, flip_reply: frame && sheet, batched, board_frame: frame && board_frame, std_sch: None, args_in: Value::Null })) } /// Merge each batched edit's result into the reply's top level: lists join, counts add up.⋯ 61 unchanged lines ⋯ } _ => {} }+ if let Some((verb, height)) = &s.std_sch {+ std_sch_reply(verb, *height, &s.args_in, out);+ } add_ids(out); if s.flip_reply { flip_reply(out);⋯ 37 unchanged lines ⋯ } #[test]+ fn the_standard_schematic_part_verbs_take_mils_designators_and_lists() {+ let p = temp_copy("parts", "sch-edit/edit10.kicad_sch");+ let lib = p.parent().unwrap().join("MiniLib.kicad_sym");+ std::fs::copy(fixtures().join("sch-edit/MiniLib.kicad_sym"), &lib).unwrap();+ let (sheet, lib) = (norm(&p), norm(&lib));+ // A4: 2000 mil from the left, 4000 mil up from the bottom+ let v = ok("sch_place_part", json!({"sheet": sheet, "library": lib, "symbol": "R", "designator": "R9", "comment": "10k", "x": 2000, "y": 4000}));+ assert_eq!((v["designator"].as_str(), v["atMil"][0].as_f64(), v["atMil"][1].as_f64(), v["saved"].as_bool()), (Some("R9"), Some(2000.0), Some(4000.0), Some(true)), "{v}");+ let c = ok("sch_connect", json!({"sheet": sheet, "connections": [{"designator": "R9", "pin": 1, "net": "VIN"}, {"designator": "R9", "pin": "2", "net": "GND", "kind": "power"}]}));+ assert_eq!((c["connected"].as_u64(), c["results"][1]["kind"].as_str()), (Some(2), Some("power")), "{c}");+ assert!(c["results"][0]["endMil"][0].as_f64().is_some(), "{c}");+ let e = ok("sch_edit", json!({"sheet": sheet, "edits": [{"designator": "R9", "action": "comment", "text": "4k7"}, {"designator": "R9", "action": "moveBy", "dx": 500, "dy": 0}, {"designator": "R9", "action": "designator", "to": "R10"}]}));+ assert_eq!((e["applied"].as_u64(), e["results"][2]["action"].as_str()), (Some(3), Some("designator")), "{e}");+ let comps = ok("kicad_sch_components", json!({"filePath": sheet}));+ let r10 = comps.to_string();+ assert!(r10.contains("R10") && r10.contains("4k7") && !r10.contains("\"R9\""), "{comps}");+ let s = ok("sch_swap_part", json!({"sheet": sheet, "designator": "R10", "library": lib, "symbol": "R_Small"}));+ assert_eq!(s["now"]["libReference"], "MiniLib:R_Small", "{s}");+ assert!(s["pinsJoined"].is_array() && s["oldPinsNotOnNew"].is_array(), "{s}");+ // R_Small's pins end closer to the body than R's: each moved pin is wired back to where R's ended+ assert_eq!(s["pinsJoined"].as_array().unwrap().len(), 2, "{s}");+ // a failing edit writes nothing and names the edit+ let before = std::fs::read_to_string(&p).unwrap();+ let f = call("sch_edit", json!({"sheet": sheet, "edits": [{"designator": "R10", "action": "comment", "text": "1k"}, {"designator": "NOPE", "action": "delete"}]}));+ assert_eq!((f["success"].as_bool(), f["failedOp"].as_u64(), f["applied"].as_u64()), (Some(false), Some(1), Some(0)), "{f}");+ assert_eq!(std::fs::read_to_string(&p).unwrap(), before);+ // the same ops inside sch_batch, standard-shaped+ let b = ok("sch_batch", json!({"filePath": sheet, "ops": [+ {"op": "sch_place_part", "library": lib, "symbol": "C", "designator": "C9", "x": 3000, "y": 3000},+ {"op": "sch_connect", "connections": [{"designator": "C9", "pin": 1, "net": "VIN"}, {"designator": "C9", "pin": 2, "kind": "noerc"}]},+ {"op": "sch_edit", "edits": [{"designator": "C9", "action": "rotate", "degrees": 90}]}]}));+ assert_eq!(b["results"].as_array().map(|r| r.len()), Some(4), "{b}");+ // KiCad's own arguments still work under the standard names+ ok("sch_edit", json!({"filePath": sheet, "reference": "C9", "name": "Value", "value": "100n"}));+ }++ #[test] fn a_list_of_pairs_is_one_batched_write() { let p = temp_copy("pairs", "sch-edit/edit10.kicad_sch"); let before = std::fs::read_to_string(&p).unwrap();⋯ 87 unchanged lines ⋯ assert!(to_native("kicad_route_net", "kicad_route_net", &json!({})).is_none(), "verbs outside the 47 are left alone"); } }+@@ -1,893 +1,966 @@⋯ 147 unchanged lines ⋯ 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_edit_pours",+ summary: "Change existing copper pours (zones) in place without redrawing them: clearance, minimum width, thermal reliefs (on/off, spoke width, gap) or the pad connection, priority, the net, and whether islands are kept; then refill (the standard's edit_pours).",+ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 300,+ input: "{\"filePath\", \"pours\": [{\"id\" (kicad_pour_state's id, the zone uuid) | \"pourIndex\" | \"name\" | \"net\" + \"layer\"?, \"clearanceMm\"?, \"minWidthMm\"?, \"thermals\"?: {\"on\", \"spokeMm\", \"gapMm\"}, \"connection\"?: \"thermal\"|\"solid\"|\"none\"|\"reliefsOnThroughHole\", \"priority\"?, \"net\"?, \"keepIslands\"?}], \"refill\"?: true, \"expectedRevision\"?, \"dryRun\"?}",+ example: "kicad_edit_pours {\"filePath\":\"C:/p/b.kicad_pcb\",\"pours\":[{\"net\":\"GND\",\"layer\":\"B.Cu\",\"clearanceMm\":0.3}]}",+ hint: "edited[] gives each pour's settings before and after. A closed board is written (a .adom-bak kept) and refilled by kicad-cli, and pourState is the copper after the refill. On a board open in the PCB editor it is one Undo step and KiCad refills in the background: read kicad_pour_state a moment later. net+layer edits the whole zone, every layer it is on.",+ related: &["kicad_pour_state", "kicad_zone_state", "kicad_add_zone", "kicad_refill_zones", "kicad_run_drc"],+ pitfalls: &["Changing a pour's net reconnects copper: run kicad_run_drc afterwards.", "A pour that refills to nothing shows filledMm2 0 in pourState; it is reported, not removed."],+ },+ 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,⋯ 81 unchanged lines ⋯ "kicad_align_footprints" => align(state, args), "kicad_update_footprints_from_library" => update_from_library(state, args), "kicad_swap_footprint" => swap_footprint(state, args),+ "kicad_edit_pours" => edit_pours(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),⋯ 340 unchanged lines ⋯ edit(state, args, &build) } +/// `kicad_edit_pours`: the zone edit (live or the closed file), then the refill: KiCad's own in the editor, kicad-cli's+/// for a closed file, whose copper comes back as pourState.+fn edit_pours(state: &mut State, args: &Value) -> Value {+ let source = match source_of(args) {+ Ok(s) => s.to_string(),+ Err(e) => return e,+ };+ // live: the zones KiCad holds, updated in place (KiCad 10 creates no board item from text, so the generic+ // delete-and-create live path cannot serve a zone edit)+ #[allow(unused_mut)]+ let mut live_err = Value::Null;+ #[cfg(feature = "ipc")]+ if source != "file" {+ match kicad_core::ipc::edit_pours(&ctx(state), args) {+ Ok(v) => return v,+ Err(e) => {+ if source == "live" || !NO_EDITOR.contains(&e.code.as_str()) {+ return kicad_core::ipc::error_response(&e);+ }+ live_err = json!({"errorCode": e.code, "error": e.message});+ }+ }+ }+ let mut out = file_edit(args, &|t| board_edit::edit_pours(t, args));+ if !live_err.is_null() {+ out["live"] = live_err;+ }+ if out["success"] != json!(true) || out["mutated"] != json!(true) {+ return out;+ }+ if args.get("refill") == Some(&json!(false)) {+ out["refilled"] = json!(false);+ out["_hint"] = json!("Edited without a refill (refill:false): the copper still has the old settings until a refill (kicad-cli, or KiCad's B).");+ return out;+ }+ let Some(path) = out["boardPath"].as_str().map(str::to_string) else { return out };+ let Some(cli) = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()) else {+ out["refilled"] = json!(false);+ out["_hint"] = json!("Written, but kicad-cli was not found to refill: open the board in KiCad and refill (B) before trusting the copper.");+ return out;+ };+ let report = std::env::temp_dir().join(format!("kb-edit-pours-{}.json", std::process::id()));+ let rep = norm(&report);+ let r = kicad_core::cli::run(&cli, &["pcb", "drc", "--refill-zones", "--save-board", "--format", "json", "--output", rep.as_str(), path.as_str()], std::time::Duration::from_secs(240));+ let _ = std::fs::remove_file(&report);+ if r.exit_code != 0 {+ out["refilled"] = json!(false);+ out["refillError"] = json!(format!("kicad-cli could not refill: {}", r.stderr.trim()));+ return out;+ }+ out["refilled"] = json!(true);+ if let Ok(text) = std::fs::read_to_string(&path) {+ out["revision"] = json!(kicad_core::placement::file_revision(&text));+ if let Ok(p) = kicad_core::board_model::pours(&text, &kicad_core::board_model::Opts { y_up: true }, false) {+ out["pourState"] = p;+ }+ }+ out["_hint"] = json!("Written to the closed board (the previous file is <board>.adom-bak) and refilled by kicad-cli: pourState is the copper now. Run kicad_run_drc after a net change.");+ out+}+ #[derive(Clone, Copy)] enum Listed { Graphics,⋯ 304 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -0,0 +1,317 @@+//! The board in other tools' formats, through kicad-cli: the standard's export_3d (every 3D format kicad-cli writes+//! beside STEP) and import_design (kicad-cli's board importers: EAGLE, Altium, PADS, CADSTAR, Fabmaster, P-CAD,+//! SolidWorks PCB). Formats KiCad has no writer or reader for answer not_supported by name.++use std::path::{Path, PathBuf};+use std::time::Duration;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::cli;++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_export_3d",+ summary: "Export the board's 3D model through kicad-cli in a format other than STEP: stl, glb, vrml, ply, brep, xao, u3d, stpz or 3dpdf (the standard's export_3d). obj, 3mf, iges and other formats KiCad cannot write are refused by name.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"format\": \"stl\"|\"glb\"|\"vrml\"|\"ply\"|\"brep\"|\"xao\"|\"u3d\"|\"stpz\"|\"3dpdf\", \"outputPath\"?: \"C:/out/board.glb\", \"components\"?: true, \"copper\"?: false, \"silkscreen\"?: false, \"soldermask\"?: false, \"variant\"?: \"Lite\"}",+ example: "kicad_export_3d {\"filePath\":\"C:/p/b.kicad_pcb\",\"format\":\"glb\"}",+ hint: "One call per format; path, bytes and components come back. components:false is the bare board. copper:true adds tracks, pads and pours, and silkscreen/soldermask add those layers as flat faces (not for vrml). outputPath defaults to <board folder>/3d_output/<board>.<ext>.",+ related: &["kicad_export_step", "kicad_render_board", "kicad_model_check"],+ pitfalls: &["Mesh formats are triangulated: use kicad_export_step for mechanical CAD that needs exact surfaces.", "A footprint whose 3D model is missing exports without its body; kicad_model_check lists them first.", "web:true asks for a web-optimized glb, which KiCad does not make: the glb is written as kicad-cli writes it and webOptimized says false."],+ },+ Verb {+ name: "kicad_import_design",+ summary: "Bring a board from another tool into a NEW KiCad project through kicad-cli's importer: eagle, altium, pads, cadstar, fabmaster, pcad or solidworks (the standard's import_design). The source files are never changed.",+ mechanism: Mechanism::Cli, risk: "write", timeout_sec: 600,+ input: "{\"format\": \"eagle\"|\"altium\"|\"pads\"|\"cadstar\"|\"fabmaster\"|\"pcad\"|\"solidworks\"|\"auto\"|\"kicad\", \"filePath\": \"C:/proj/board.brd\" (or the .sch/.PrjPcb beside it), \"boardPath\"?: \"C:/proj/board.brd\", \"name\"?: \"board\", \"outputDir\"?: \"C:/proj/board-kicad\", \"overwrite\"?: false}",+ example: "kicad_import_design {\"format\":\"eagle\",\"filePath\":\"C:/proj/board.sch\",\"boardPath\":\"C:/proj/board.brd\"}",+ hint: "design is the new .kicad_pro, board the .kicad_pcb; parts and nets count what came over and warnings are what the importer reported. kicad-cli imports the BOARD only: schematicImported is false, and the schematic comes in through KiCad's File > Import > Non-KiCad Project. Check the board with kicad_run_drc and kicad_routing_state.",+ related: &["kicad_project_info", "kicad_run_drc", "kicad_routing_state", "kicad_open_board"],+ pitfalls: &["An EAGLE or Altium schematic given alone is not imported: pass the board (boardPath, or a .brd/.PcbDoc beside it with the same name).", "Formats KiCad has no importer for (orcad, allegro's native .brd, ...) are refused by name."],+ },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ Some(match command {+ "kicad_export_3d" => export_3d(state, args),+ "kicad_import_design" => import_design(state, args),+ _ => return None,+ })+}++/// (format and its aliases, kicad-cli's subcommand, extension, takes the STEP-style options)+const WRITES: &[(&[&str], &str, &str, bool)] = &[+ (&["stl"], "stl", "stl", true),+ (&["glb", "gltf"], "glb", "glb", true),+ (&["vrml", "wrl"], "vrml", "wrl", false),+ (&["ply"], "ply", "ply", true),+ (&["brep"], "brep", "brep", true),+ (&["xao"], "xao", "xao", true),+ (&["u3d"], "u3d", "u3d", true),+ (&["stpz"], "stpz", "stpz", true),+ (&["3dpdf", "pdf3d"], "3dpdf", "pdf", true),+ (&["step", "stp"], "step", "step", true),+];++/// Formats other tools write that kicad-cli cannot, and what to use instead.+fn cannot_write(format: &str) -> Option<&'static str> {+ Some(match format {+ "obj" | "3mf" | "fbx" | "usdz" | "dae" | "x3d" => "stl or glb (both meshes kicad-cli writes; convert from them)",+ "iges" | "igs" | "sat" | "acis" | "parasolid" | "x_t" => "kicad_export_step (STEP is the exact-surface CAD format KiCad writes), or brep",+ _ => return None,+ })+}++fn cli_path(state: &mut State) -> Result<(PathBuf, String), Value> {+ let info = state.kicad_info();+ match info.primary() {+ Some(p) if Path::new(&p.kicad_cli).is_file() => Ok((PathBuf::from(&p.kicad_cli), p.cli_version.clone())),+ _ => {+ let mut v = fail("not_installed", "KiCad (kicad-cli) is not installed", "Install KiCad 10 (kicad_install).");+ v["alternatives"] = alternatives();+ Err(v)+ }+ }+}++fn export_3d(state: &mut State, args: &Value) -> Value {+ let Some(board) = arg_any(args, &["filePath", "boardPath"]) else {+ return fail("missing_arg", "filePath is required", "Pass the board's absolute .kicad_pcb path.");+ };+ let board = PathBuf::from(board);+ if !board.is_file() {+ return fail("file_not_found", format!("board not found: {}", norm(&board)), "Use the desktop's absolute path.");+ }+ let Some(format) = arg_str(args, "format").map(str::to_ascii_lowercase) else {+ return fail("missing_arg", "format is required", "One of stl, glb, vrml, ply, brep, xao, u3d, stpz, 3dpdf.");+ };+ let Some((_, sub, ext, step_like)) = WRITES.iter().find(|w| w.0.contains(&format.as_str())).copied() else {+ let why = format!("kicad-cli has no {format} writer: it writes stl, glb, vrml, ply, brep, xao, u3d, stpz, 3dpdf and step");+ let mut v = fail("not_supported", &why, match cannot_write(&format) { Some(i) => format!("Instead: {i}."), None => "Pick one of the formats KiCad writes.".into() });+ if let Some(i) = cannot_write(&format) {+ v["instead"] = json!(i);+ }+ v["formats"] = json!(WRITES.iter().map(|w| w.0[0]).collect::<Vec<_>>());+ return v;+ };+ let components = args.get("components").and_then(Value::as_bool).unwrap_or(true);+ let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);+ if !step_like && (!components || flag("copper") || flag("silkscreen") || flag("soldermask")) {+ return fail("bad_args", "vrml always carries the parts' models and the board as KiCad's 3D viewer shows it", "Leave components, copper, silkscreen and soldermask out for vrml, or export stl or glb with them.");+ }+ let (cli, version) = match cli_path(state) { Ok(c) => c, Err(e) => return e };+ let stem = board.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+ let out = arg_any(args, &["outputPath", "output"]).map(PathBuf::from)+ .unwrap_or_else(|| board.parent().unwrap_or(Path::new(".")).join("3d_output").join(format!("{stem}.{ext}")));+ if let Some(dir) = out.parent() {+ if let Err(e) = std::fs::create_dir_all(dir) {+ return fail("output_dir", format!("cannot create {}: {e}", norm(dir)), "Pass an outputPath the desktop user can write.");+ }+ }+ let out_s = norm(&out);+ let mut argv: Vec<String> = vec!["pcb".into(), "export".into(), sub.into(), "--force".into(), "--output".into(), out_s.clone()];+ if step_like {+ argv.push("--subst-models".into());+ if !components {+ argv.push("--no-components".into());+ }+ if flag("copper") {+ argv.extend(["--include-tracks", "--include-pads", "--include-zones"].map(String::from));+ }+ if flag("silkscreen") {+ argv.push("--include-silkscreen".into());+ }+ if flag("soldermask") {+ argv.push("--include-soldermask".into());+ }+ } else {+ argv.extend(["--units", "mm"].map(String::from));+ }+ if let Some(v) = arg_str(args, "variant") {+ argv.extend(["--variant".to_string(), v.to_string()]);+ }+ argv.push(norm(&board));+ let refs: Vec<&str> = argv.iter().map(String::as_str).collect();+ let r = cli::run(&cli, &refs, Duration::from_secs(280));+ let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);+ if r.exit_code != 0 || bytes == 0 {+ let mut v = fail(if r.timed_out { "cli_timeout" } else { "cli_failed" }, format!("{sub} export failed: {}", r.stderr.trim()), "kicad-cli's stderr names the reason (file version, a missing 3D model, an unwritable outputPath).");+ v["stdout"] = json!(r.stdout);+ return v;+ }+ let web = flag("web");+ json!({+ "success": true, "path": out_s, "format": sub, "bytes": bytes, "components": components,+ "webOptimized": false, "webRequested": web,+ "board": norm(&board), "cliVersion": version, "cliElapsedMs": r.elapsed_ms,+ "_hint": if web && sub == "glb" { "KiCad writes one glb with no web optimization (webOptimized false): it loads in any glTF viewer as it is. Pull it with ab pull_file." } else { "Pull the file with ab pull_file to bring it into the container." },+ })+}++/// (format, kicad-cli's --format, the board file extensions it reads)+const READS: &[(&str, &str, &[&str])] = &[+ ("eagle", "eagle", &["brd"]),+ ("altium", "altium", &["pcbdoc"]),+ ("pads", "pads", &["asc"]),+ ("cadstar", "cadstar", &["cpa"]),+ ("fabmaster", "fabmaster", &["txt", "fab"]),+ ("pcad", "pcad", &["pcb"]),+ ("solidworks", "solidworks", &["sldprt", "pcbdoc"]),+ ("auto", "auto", &[]),+];++fn ext_of(p: &Path) -> String {+ p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()+}++/// The board file to import: boardPath, the file itself when it is a board, a same-name board beside a schematic, or+/// the PcbDoc an Altium .PrjPcb lists.+fn board_of(file: &Path, board: Option<&str>, exts: &[&str]) -> Result<PathBuf, Value> {+ if let Some(b) = board {+ return Ok(PathBuf::from(b));+ }+ let ext = ext_of(file);+ if exts.contains(&ext.as_str()) || exts.is_empty() && !matches!(ext.as_str(), "sch" | "schdoc" | "prjpcb") {+ return Ok(file.to_path_buf());+ }+ if ext == "prjpcb" {+ let text = std::fs::read_to_string(file).map_err(|e| fail("file_not_found", format!("cannot read {}: {e}", norm(file)), "Pass the .PrjPcb's absolute path."))?;+ let doc = text.lines().filter_map(|l| l.trim().strip_prefix("DocumentPath=")).find(|d| d.to_ascii_lowercase().ends_with(".pcbdoc"));+ return doc.map(|d| file.parent().unwrap_or(Path::new(".")).join(d.replace('\\', "/")))+ .ok_or_else(|| fail("no_board", format!("{} lists no .PcbDoc", norm(file)), "Pass boardPath: the project's .PcbDoc."));+ }+ for e in exts.iter().copied().chain(["brd", "PcbDoc"]) {+ let c = file.with_extension(e);+ if c.is_file() {+ return Ok(c);+ }+ }+ Err(fail("no_board", format!("{} is a schematic and no board of the same name is beside it", norm(file)), "Pass boardPath: kicad-cli imports the board (.brd, .PcbDoc, ...); the schematic comes in through KiCad's File > Import > Non-KiCad Project."))+}++/// Short importer messages from kicad-cli's JSON report, whatever list it keeps them in.+fn report_messages(rep: &Value) -> Vec<String> {+ let mut out = Vec::new();+ fn walk(v: &Value, out: &mut Vec<String>) {+ match v {+ Value::Object(m) => {+ if let Some(s) = m.get("message").or_else(|| m.get("description")).and_then(Value::as_str) {+ let sev = m.get("severity").and_then(Value::as_str).map(|s| format!("{s}: ")).unwrap_or_default();+ out.push(format!("{sev}{s}"));+ return;+ }+ m.values().for_each(|x| walk(x, out));+ }+ Value::Array(a) => a.iter().for_each(|x| match x {+ Value::String(s) => out.push(s.clone()),+ x => walk(x, out),+ }),+ _ => {}+ }+ }+ walk(rep, &mut out);+ out+}++fn import_design(state: &mut State, args: &Value) -> Value {+ let Some(format) = arg_str(args, "format").map(str::to_ascii_lowercase) else {+ return fail("missing_arg", "format is required", "eagle, altium, pads, cadstar, fabmaster, pcad, solidworks, or auto to let kicad-cli tell.");+ };+ let Some(file) = arg_any(args, &["filePath", "path"]).map(PathBuf::from) else {+ return fail("missing_arg", "filePath is required", "The design's board, schematic or project file.");+ };+ if !file.is_file() {+ return fail("file_not_found", format!("not found: {}", norm(&file)), "Use the desktop's absolute path.");+ }+ if format == "kicad" {+ let pro = if ext_of(&file) == "kicad_pro" { file.clone() } else { file.with_extension("kicad_pro") };+ return json!({"success": true, "imported": false, "design": norm(&pro), "board": norm(&file.with_extension("kicad_pcb")), "parts": null, "nets": null, "warnings": [],+ "_hint": "A KiCad design is already native: nothing to import. Open it with kicad_open_board or kicad_open_schematic."});+ }+ let Some((_, cli_format, exts)) = READS.iter().find(|r| r.0 == format).copied() else {+ let mut v = fail("not_supported", format!("kicad-cli has no {format} importer: it reads eagle, altium, pads, cadstar, fabmaster, pcad and solidworks boards"), "Export the design from its tool in one of those formats (EAGLE .brd is the common one), or use KiCad's GUI importers for EasyEDA.");+ v["formats"] = json!(READS.iter().map(|r| r.0).collect::<Vec<_>>());+ return v;+ };+ let src = match board_of(&file, arg_str(args, "boardPath"), exts) { Ok(b) => b, Err(e) => return e };+ if !src.is_file() {+ return fail("file_not_found", format!("board not found: {}", norm(&src)), "Pass boardPath: the design's board file.");+ }+ let name = arg_str(args, "name").map(str::to_string).unwrap_or_else(|| src.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "imported".into()));+ if !kicad_core::project::valid_name(&name) {+ return fail("bad_args", format!("'{name}' is not a usable project name"), "Letters, digits, '-', '_' and '.' only.");+ }+ let dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| src.parent().unwrap_or(Path::new(".")).join(format!("{name}-kicad")));+ let (pcb, pro) = (dir.join(format!("{name}.kicad_pcb")), dir.join(format!("{name}.kicad_pro")));+ if (pcb.exists() || pro.exists()) && args.get("overwrite") != Some(&json!(true)) {+ return fail("project_exists", format!("{} already holds {name}.kicad_pcb or .kicad_pro", norm(&dir)), "Pick another name or outputDir, or pass overwrite:true.");+ }+ if let Err(e) = std::fs::create_dir_all(&dir) {+ return fail("output_dir", format!("cannot create {}: {e}", norm(&dir)), "Pass an outputDir the desktop user can write.");+ }+ let (cli, version) = match cli_path(state) { Ok(c) => c, Err(e) => return e };+ let report = dir.join(format!("{name}.import-report.json"));+ let (pcb_s, rep_s, src_s) = (norm(&pcb), norm(&report), norm(&src));+ let argv = ["pcb", "import", "--format", cli_format, "--output", pcb_s.as_str(), "--report-format", "json", "--report-file", rep_s.as_str(), src_s.as_str()];+ let r = cli::run(&cli, &argv, Duration::from_secs(560));+ let rep: Value = std::fs::read_to_string(&report).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+ let text = std::fs::read_to_string(&pcb).unwrap_or_default();+ if r.exit_code != 0 || text.is_empty() {+ let mut v = fail(if r.timed_out { "cli_timeout" } else { "import_failed" }, format!("kicad-cli could not import {}: {}", norm(&src), r.stderr.trim()), "Check format matches the file (format:auto lets kicad-cli tell) and that the file opens in its own tool.");+ v["stdout"] = json!(r.stdout);+ v["report"] = rep;+ return v;+ }+ let root_uuid = kicad_core::schematic::new_uuid();+ if let Err(e) = std::fs::write(&pro, kicad_core::project::project_json(&name, &root_uuid, &Default::default())) {+ return fail("write_failed", format!("imported the board but could not write {}: {e}", norm(&pro)), "The board is at board; open it in KiCad to make its project.");+ }+ let (parts, nets) = match kicad_core::pcb::parse_pcb_text(&text, &pcb_s) {+ Ok(b) => {+ let mut n: Vec<&str> = b.footprints.iter().flat_map(|f| f.pads.iter().filter_map(|p| p.net_name.as_deref())).filter(|n| !n.is_empty()).collect();+ n.sort();+ n.dedup();+ (json!(b.footprints.len()), json!(n.len()))+ }+ Err(_) => (Value::Null, Value::Null),+ };+ json!({+ "success": true, "imported": true, "format": format, "source": src_s, "design": norm(&pro), "board": pcb_s,+ "parts": parts, "nets": nets, "warnings": report_messages(&rep), "importReport": rep_s, "importStats": rep.get("statistics").cloned().unwrap_or(Value::Null),+ "schematicImported": false, "cliVersion": version, "cliElapsedMs": r.elapsed_ms,+ "_hint": "kicad-cli brings in the board only: the schematic comes in through KiCad's File > Import > Non-KiCad Project (the GUI). Check the board with kicad_run_drc and kicad_routing_state; the source files were not changed.",+ })+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn formats_kicad_cannot_write_are_refused_with_what_to_use_instead() {+ assert!(cannot_write("obj").unwrap().contains("stl"));+ assert!(cannot_write("iges").unwrap().contains("kicad_export_step"));+ assert!(cannot_write("glb").is_none());+ assert!(WRITES.iter().any(|w| w.0.contains(&"wrl") && w.1 == "vrml" && !w.3));+ }++ #[test]+ fn the_board_is_found_beside_a_schematic_or_in_an_altium_project() {+ let d = std::env::temp_dir().join(format!("kb-interop-{}", std::process::id()));+ std::fs::create_dir_all(&d).unwrap();+ std::fs::write(d.join("b.sch"), "x").unwrap();+ std::fs::write(d.join("b.brd"), "x").unwrap();+ assert_eq!(board_of(&d.join("b.sch"), None, &["brd"]).unwrap(), d.join("b.brd"));+ std::fs::write(d.join("p.PrjPcb"), "[Document1]\r\nDocumentPath=Sub\\p.PcbDoc\r\n").unwrap();+ assert_eq!(board_of(&d.join("p.PrjPcb"), None, &["pcbdoc"]).unwrap(), d.join("Sub/p.PcbDoc"));+ assert_eq!(board_of(&d.join("lone.sch"), None, &["brd"]).unwrap_err()["errorCode"], "no_board");+ assert_eq!(report_messages(&json!({"messages": [{"severity": "warning", "message": "layer 200 skipped"}]})), vec!["warning: layer 200 skipped"]);+ // kicad-cli 10's report: {errors: [], warnings: [], layer_mapping: {..}, statistics: {..}}+ assert_eq!(report_messages(&json!({"errors": [], "warnings": ["no footprint for X1"], "layer_mapping": {"B.Cu": {"kicad_layer": "B.Cu", "method": "auto"}}})), vec!["no footprint for X1"]);+ let _ = std::fs::remove_dir_all(&d);+ }+}+@@ -1,258 +1,374 @@⋯ 118 unchanged lines ⋯ pitfalls: &["KiCad reads the list at start; a running KiCad keeps the old list until relaunched"], }, Verb {+ name: "kicad_tool_update",+ summary: "KiCad's own updates (the standard's tool_update): check whether a newer stable KiCad is offered, apply it with the official installer when KiCad is closed, or read or set KiCad's own automatic update check.",+ mechanism: Mechanism::Cli, risk: "exec", timeout_sec: 900,+ input: "{\"action\": \"check\"|\"apply\"|\"auto\", \"on\"?: bool (auto: turn KiCad's update check on or off; omitted, it is read)}",+ example: "kicad_tool_update {\"action\":\"check\"}",+ hint: "check answers current, offered (null when current) and auto. apply runs kicad_upgrade for the offered version: a silent per-user install, no UAC, refused while KiCad runs (the open documents are listed, unsaved ones marked: save and kicad_close first). Updating KiCad changes the user's machine: say so before apply. auto is KiCad's 'check for updates' setting (kicad.json system.check_for_kicad_updates); KiCad itself only offers updates, never installs them.",+ related: &["kicad_check_for_updates", "kicad_upgrade", "kicad_close", "kicad_readiness"],+ pitfalls: &["apply refuses while KiCad runs: it never closes unsaved work.", "auto refuses while KiCad runs too: KiCad writes its settings back when it exits, over the change."],+ },+ Verb { name: "kicad_check_for_updates", summary: "Read-only: installed KiCad vs latest stable.", mechanism: Mechanism::File, risk: "read", timeout_sec: 45,⋯ 60 unchanged lines ⋯ } out }+ "kicad_tool_update" => tool_update(state, args), "kicad_upgrade" => { let info = state.kicad_info(); let out = install_kicad::handle(&info.installs, args, &updates::http_fetch);⋯ 65 unchanged lines ⋯ Err(e) => json!({"success": false, "errorCode": if e.contains("not supported") { "not_supported_on_platform" } else { "platform_error" }, "error": e}), } }++/// `kicad_tool_update`: the standard's tool_update over kicad_check_for_updates, kicad_upgrade and KiCad's own+/// update-check setting.+fn tool_update(state: &mut State, args: &Value) -> Value {+ let info = state.kicad_info();+ let Some(inst) = info.primary().cloned() else {+ let mut v = crate::util::fail("not_installed", "KiCad is not installed", "kicad_upgrade installs it (say so first: it changes the user's machine).");+ v["current"] = Value::Null;+ return v;+ };+ let current = if inst.cli_version.is_empty() { inst.version.clone() } else { inst.cli_version.clone() };+ let settings = kicad_core::detect::config_dir(&inst.version).map(|d| d.join("kicad.json"));+ let read_auto = || -> Option<bool> {+ let t = std::fs::read_to_string(settings.as_ref()?).ok()?;+ serde_json::from_str::<Value>(&t).ok()?.pointer("/system/check_for_kicad_updates")?.as_bool()+ };+ let running = || install_kicad::kicad_running().1;+ let action = args.get("action").and_then(Value::as_str).unwrap_or("check");+ let check = updates::check_for_updates(Some(¤t), &updates::http_fetch, None);+ let offered = if check["upgradeAvailable"] == json!(true) { check["latestVersion"].clone() } else { Value::Null };+ // KiCad's default is to check: the setting reads true until the user (or Adom's first-run setup) turns it off+ let auto = read_auto().unwrap_or(true);+ let mut out = json!({"success": true, "action": action, "current": current, "offered": offered, "applied": false, "auto": auto, "needsRestart": false});+ match action {+ "check" => {+ if check["success"] == json!(false) {+ out["offered"] = Value::Null;+ out["checkError"] = check["error"].clone();+ }+ out["_hint"] = json!(if offered.is_null() { "KiCad is current." } else { "A newer KiCad is offered: kicad_tool_update {action:\"apply\"} installs it once KiCad is closed (say so to the user first)." });+ out+ }+ "apply" => {+ if offered.is_null() {+ out["_hint"] = json!("Nothing to apply: KiCad is current.");+ return out;+ }+ let pids = running();+ if !pids.is_empty() {+ let st = crate::verbs::call(state, "kicad_state", &json!({}));+ let mut docs = Vec::new();+ fn titles(v: &Value, out: &mut Vec<String>) {+ match v {+ Value::Object(m) => {+ if let Some(t) = m.get("title").and_then(Value::as_str) { out.push(t.to_string()); }+ m.values().for_each(|x| titles(x, out));+ }+ Value::Array(a) => a.iter().for_each(|x| titles(x, out)),+ _ => {}+ }+ }+ titles(&st, &mut docs);+ docs.sort();+ docs.dedup();+ let mut v = crate::util::fail("kicad_running", "KiCad is open: the update waits until it is closed, so no work is lost", "Save the documents (a title starting with * is unsaved), kicad_close, then apply again.");+ v["documents"] = json!(docs);+ v["unsaved"] = json!(docs.iter().filter(|t| t.starts_with('*')).collect::<Vec<_>>());+ v["current"] = json!(current);+ v["offered"] = offered;+ return v;+ }+ let r = install_kicad::handle(&info.installs, &json!({"version": offered}), &updates::http_fetch);+ let applied = r.get("upgraded").and_then(Value::as_bool).unwrap_or(false);+ if applied {+ state.refresh();+ }+ if r.get("success") == Some(&json!(false)) {+ let mut v = r.clone();+ v["current"] = json!(current);+ v["offered"] = offered;+ v["applied"] = json!(false);+ return v;+ }+ out["applied"] = json!(applied);+ out["install"] = r;+ out["_hint"] = json!(if applied { "KiCad was updated; it starts on the new version next time (kicad_readiness shows it)." } else { "The installer ran but the new version is not detected: kicad_upgrade {diagnoseOnly:true} shows why." });+ out+ }+ "auto" => {+ let Some(on) = args.get("on").and_then(Value::as_bool) else {+ out["_hint"] = json!("auto is KiCad's own 'check for updates' setting; pass on:true or on:false to set it.");+ return out;+ };+ if !running().is_empty() {+ return crate::util::fail("kicad_running", "KiCad is open and writes its settings back when it exits, over this change", "kicad_close, then set it again.");+ }+ let Some(path) = settings else {+ return crate::util::fail("no_config_dir", "KiCad's settings folder was not found", "Start KiCad once so it writes its settings, then set it again.");+ };+ let mut doc: Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| json!({}));+ if !doc.is_object() { doc = json!({}); }+ if !doc["system"].is_object() { doc["system"] = json!({}); }+ doc["system"]["check_for_kicad_updates"] = json!(on);+ if let Err(e) = std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new("."))).and_then(|_| std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap_or_default())) {+ return crate::util::fail("write_failed", format!("could not write {}: {e}", path.display()), "Check the folder's permissions.");+ }+ out["auto"] = json!(on);+ out["settings"] = json!(crate::util::norm(&path));+ out["_hint"] = json!("Set. KiCad only offers updates when it starts, never installs them: kicad_tool_update {action:\"apply\"} does that.");+ out+ }+ other => crate::util::fail("bad_args", format!("action must be check, apply or auto, not {other}"), "check is the default."),+ }+}+@@ -1,1420 +1,1446 @@⋯ 564 unchanged lines ⋯ library_path = Some(lp); Some(b) };+ let join = flag(ctx.args, "joinMoved", false);+ let before = if join { ctx.sch.pins_of(&reference).unwrap_or_default() } else { Vec::new() }; let r = ctx.sch.replace_symbol(&reference, &lib_id, block.as_ref(), flag(ctx.args, "force", false)).map_err(|e| { let hint = if e.code == "pins_lost" { "Nothing was written. Pick a symbol with the same pin numbers, or pass force:true and rewire the lost pins." } else { "Nothing was written. kicad_sch_components lists the references and units." }; oe(e, hint) })?;+ // joinMoved: a pin the new symbol puts somewhere else gets a wire back to where the old pin ended, so its+ // connection holds (the standard's sch_swap_part)+ let mut joined = Vec::new();+ if join {+ let after = ctx.sch.pins_of(&reference).unwrap_or_default();+ for old in &before {+ let Some(new) = after.iter().find(|p| p.number == old.number && p.unit == old.unit) else { continue };+ if (new.at.0 - old.at.0).abs() > 1e-6 || (new.at.1 - old.at.1).abs() > 1e-6 {+ ctx.sch.add_wire_auto(&[new.at, old.at]).map_err(|e| oe(e, "Nothing was written."))?;+ joined.push(json!({"pin": old.number, "from": pt_json(new.at), "to": pt_json(old.at)}));+ }+ }+ } Ok(Done { output: format!("Replaced {reference} ({} unit(s)) {} -> {lib_id} in {}", r.units.len(), r.old_lib_id, file_name(ctx.path)), uuid: r.units[0].clone(),- extra: json!({"reference": reference, "oldLibId": r.old_lib_id, "libId": lib_id, "units": r.units, "libSymbolAdded": r.lib_symbol_added, "pinsAdded": r.pins_added, "pinsRemoved": r.pins_removed, "oldEntryPruned": r.old_entry_pruned, "libraryPath": library_path}),+ extra: json!({"reference": reference, "oldLibId": r.old_lib_id, "libId": lib_id, "units": r.units, "libSymbolAdded": r.lib_symbol_added, "pinsAdded": r.pins_added, "pinsRemoved": r.pins_removed, "oldEntryPruned": r.old_entry_pruned, "libraryPath": library_path, "pinsJoined": joined}), }) } ⋯ 278 unchanged lines ⋯ if specs.is_empty() { return Err(fail("missing_arg", "pin or pins is required", "Name the pins as Ref.Pin, e.g. U1.3 or [\"U1.3\", \"R5.1\"].")); }- let net = arg_any(ctx.args, &["net", "text", "name"]).map(|s| s.trim().to_string()).ok_or_else(|| fail("missing_arg", "net is required", "Pass the net name, e.g. VOUT or GND."))?; let kind_s = arg_str(ctx.args, "kind").unwrap_or("local").trim().to_ascii_lowercase();+ // kind wire: the stub alone, no label (the standard's sch_connect kind wire)+ let stub_only = kind_s == "wire";+ let net = match arg_any(ctx.args, &["net", "text", "name"]) {+ Some(n) => n.trim().to_string(),+ None if stub_only => String::new(),+ None => return Err(fail("missing_arg", "net is required", "Pass the net name, e.g. VOUT or GND.")),+ }; let power = kind_s == "power" || kind_s == "power_symbol";- let kind = if power { LabelKind::Local } else { LabelKind::parse(&kind_s).ok_or_else(|| fail("bad_arg", format!("kind {kind_s} is not local, global, hierarchical or power"), "Use local, global, hierarchical, or power."))? };+ let kind = if power || stub_only { LabelKind::Local } else { LabelKind::parse(&kind_s).ok_or_else(|| fail("bad_arg", format!("kind {kind_s} is not local, global, hierarchical, power or wire"), "Use local, global, hierarchical, power, or wire (the stub alone)."))? }; let len = ctx.args.get("length").and_then(Value::as_f64).unwrap_or(2.54); if !(len > 0.0) { return Err(fail("bad_arg", "length must be positive", "A stub of 2.54 mm (default) or another multiple of 1.27."));⋯ 11 unchanged lines ⋯ let already = ctx.sch.wires().iter().any(|w| se::on_segment(pin.at, w.a, w.b)); let end = rp((pin.at.0 + pin.out.0 * len, pin.at.1 + pin.out.1 * len)); let (wires, junctions) = ctx.sch.add_wire_auto(&[pin.at, end]).map_err(|e| oe(e, "Nothing was written."))?;- let (label_uuid, reference) = if let Some((entry, block)) = &power_lib {+ let (label_uuid, reference) = if stub_only {+ (String::new(), None)+ } else if let Some((entry, block)) = &power_lib { let lib_id = format!("power:{net}"); let reference = ctx.sch.next_reference("#PWR"); let rot = power_rotation(entry, &lib_id, pin.out);⋯ 8 unchanged lines ⋯ connected.push(json!({"pin": pin.spec(), "from": pt_json(pin.at), "end": pt_json(end), "direction": se::dir_name(pin.out), "wire": wires[0], "label": label_uuid, "powerReference": reference, "alreadyWired": already, "junctionsAdded": junction_json(&junctions)})); } Ok(Done {- output: format!("Connected {} pin(s) to {net} ({}) in {}", connected.len(), if power { "power symbol" } else { kind.token() }, file_name(ctx.path)),- uuid: connected[0]["label"].as_str().unwrap_or("").to_string(),+ output: if stub_only { format!("Drew a stub from {} pin(s) in {}", connected.len(), file_name(ctx.path)) } else { format!("Connected {} pin(s) to {net} ({}) in {}", connected.len(), if power { "power symbol" } else { kind.token() }, file_name(ctx.path)) },+ uuid: connected[0]["label"].as_str().filter(|s| !s.is_empty()).or(connected[0]["wire"].as_str()).unwrap_or("").to_string(), extra: json!({"net": net, "kind": if power { "power".to_string() } else { kind_s }, "connected": connected}), }) }⋯ 177 unchanged lines ⋯ // the four standard schematic verbs KiCad answers under its own names (equivalents/kicad.json): the op runs as // KiCad's verb, chosen by the arguments where one standard verb is several KiCad ones, with KiCad's arguments let has = |k: &str| opv.get(k).is_some_and(|v| !v.is_null());- let mapped = match name.trim_start_matches("kicad_") {+ // in the standard's own arguments (designator, library + symbol, connections, edits) the op goes through+ // std_shapes under its standard name+ let mapped = if crate::std_shapes::standard_form(name, opv) { None } else { match name.trim_start_matches("kicad_") { "sch_place_part" => Some("sch_place_symbol"), "sch_swap_part" => Some("sch_replace_symbol"), "sch_connect" if has("net") && (has("pin") || has("pins") || has("reference")) => Some("sch_connect_to_net"),⋯ 5 unchanged lines ⋯ "sch_edit" if has("to") || has("by") || has("at") => Some("sch_move"), "sch_edit" => Some("sch_set_property"), _ => None,- };+ } }; let name = mapped.unwrap_or(name); let called = if name.starts_with("kicad_") { name.to_string() } else { format!("kicad_{name}") }; let full = crate::catalog::alias_target(&called).map(String::from).unwrap_or_else(|| called.clone());⋯ 321 unchanged lines ⋯ err("kicad_sch_batch", &p, json!({}), "missing_arg"); } }+@@ -1,158 +1,170 @@⋯ 8 unchanged lines ⋯ /// (verb, why KiCad has no form of it, what to call instead). const WHY: &[(&str, &str, &str)] = &[+ ("kicad_import_step", "KiCad places 3D models only as a footprint's own model: a board has no free 3D geometry of its own, so an enclosure, bracket or mating part cannot be placed with it as a STEP.", "kicad_add_body (the STEP as a footprint's model, e.g. on a pad-less mechanical footprint placed at the part's origin), or kicad_export_step and the fit check in an MCAD tool"), ("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)"),⋯ 9 unchanged lines ⋯ pub static VERBS: &[Verb] = &[ Verb {+ name: "kicad_import_step",+ summary: "Place a STEP as free 3D geometry with the board (the standard's import_step). Not in KiCad: KiCad places 3D models only as a footprint's own model.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_import_step {}",+ hint: "Answers not_supported with why and instead: kicad_add_body puts a STEP on a footprint (a pad-less mechanical footprint at the enclosure's origin works for fit checks), and kicad_export_step takes the board to an MCAD tool.",+ related: &["kicad_add_body", "kicad_export_step", "kicad_height_check"],+ 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,⋯ 126 unchanged lines ⋯ } } }+@@ -1,1601 +1,1884 @@⋯ 1569 unchanged lines ⋯ s } +// -- edit_pours ---------------------------------------------------------------------------------++/// A zone's copper layers as the file names them (`layer` or `layers`).+fn zone_layers(z: &Node) -> Vec<String> {+ let l = z.kid("layers").or_else(|| z.kid("layer"));+ l.map(|l| l.kids.iter().skip(1).filter_map(|k| k.atom.clone()).collect()).unwrap_or_default()+}++/// A zone's net name: KiCad 10's `(net "GND")`, or an older file's `(net 3) (net_name "GND")`.+fn zone_net(z: &Node) -> String {+ z.value_of("net_name").or_else(|| z.kid("net").filter(|n| n.kids.get(1).map_or(false, |k| k.quoted)).and_then(|n| n.atom(1))).unwrap_or("").to_string()+}++/// How pads join the pour: `connect_pads` with no mode is thermal reliefs.+fn zone_connection(z: &Node) -> &'static str {+ match z.kid("connect_pads").and_then(|c| c.atom(1)) {+ Some("yes") => "solid",+ Some("no") => "none",+ Some("thru_hole_only") => "reliefsOnThroughHole",+ _ => "thermal",+ }+}++/// One pour's settings as the file holds them, in the standard's names (edit_pours' before and after).+pub fn pour_settings(z: &Node) -> Value {+ let fill = z.kid("fill");+ let fnum = |tag: &str| fill.and_then(|f| f.kid(tag)).and_then(|k| k.f64_at(1));+ let connection = zone_connection(z);+ let islands = fnum("island_removal_mode").unwrap_or(0.0) as i64;+ let mut v = json!({+ "id": z.uuid(), "net": zone_net(z), "layers": zone_layers(z), "name": z.value_of("name"),+ "clearanceMm": z.kid("connect_pads").and_then(|c| c.kid("clearance")).and_then(|k| k.f64_at(1)),+ "minWidthMm": z.kid("min_thickness").and_then(|k| k.f64_at(1)),+ "thermals": {"on": matches!(connection, "thermal" | "reliefsOnThroughHole"), "spokeMm": fnum("thermal_bridge_width"), "gapMm": fnum("thermal_gap")},+ "connection": connection,+ "priority": z.kid("priority").and_then(|k| k.f64_at(1)).unwrap_or(0.0) as i64,+ "keepIslands": islands == 1,+ });+ if islands == 2 {+ v["islandAreaMinMm2"] = json!(fnum("island_area_min"));+ }+ v+}++/// Every net name the board declares or uses.+fn board_net_names(root: &Node) -> Vec<String> {+ let mut names = Vec::new();+ root.walk(&mut |k| {+ if k.tag() == Some("net") || k.tag() == Some("net_name") {+ if let Some(s) = k.kids.iter().skip(1).find(|a| a.quoted).and_then(|a| a.atom.clone()) {+ names.push(s);+ }+ }+ });+ names.sort();+ names.dedup();+ names+}++fn layer_matches(zone: &[String], want: &str) -> bool {+ zone.iter().any(|l| l == want || l == "*.Cu" && want.ends_with(".Cu") || l == "F&B.Cu" && (want == "F.Cu" || want == "B.Cu"))+}++/// The board's own zones (not footprint zones, not keepouts) one pour selector names: `id`, `pourIndex` (as pour_state+/// numbers them), `name`, or `net` with an optional `layer`.+fn select_zones<'a>(root: &'a Node, text: &str, sel: &Value) -> RResult<Vec<&'a Node>> {+ let zones: Vec<&Node> = root.kids_tagged("zone").filter(|z| z.kid("keepout").is_none()).collect();+ let s = |k: &str| sel.get(k).and_then(Value::as_str);+ let id = match (s("id"), sel.get("pourIndex").and_then(Value::as_u64)) {+ (Some(id), _) => Some(id.to_string()),+ (None, Some(k)) => {+ let p = crate::board_model::pours(text, &crate::board_model::Opts { y_up: true }, false).map_err(|e| err("board_unparsable", e))?;+ let hit = p["zones"].as_array().into_iter().flatten().find(|z| z["pourIndex"].as_u64() == Some(k)).and_then(|z| z["id"].as_str().map(str::to_string));+ Some(hit.ok_or_else(|| err("pour_not_found", format!("no pour has pourIndex {k}")))?)+ }+ _ => None,+ };+ let hits: Vec<&Node> = zones.iter().copied().filter(|z| match (&id, s("name"), s("net")) {+ (Some(id), _, _) => z.uuid() == Some(id.as_str()),+ (None, Some(name), _) => z.value_of("name") == Some(name),+ (None, None, Some(net)) => zone_net(z) == net && s("layer").map_or(true, |l| layer_matches(&zone_layers(z), l)),+ _ => false,+ }).collect();+ if id.is_none() && s("name").is_none() && s("net").is_none() {+ return Err(err("invalid_args", "each pour needs id (from kicad_pour_state), pourIndex, name, or net with an optional layer"));+ }+ if hits.is_empty() {+ let have: Vec<Value> = zones.iter().map(|z| json!({"id": z.uuid(), "net": zone_net(z), "layers": zone_layers(z), "name": z.value_of("name")})).collect();+ return Err(err("pour_not_found", format!("no pour matches {sel}")).with("pours", json!(have)));+ }+ Ok(hits)+}++fn mm_arg(p: &Value, key: &str) -> RResult<Option<f64>> {+ match p.get(key) {+ None | Some(Value::Null) => Ok(None),+ Some(v) => match v.as_f64() {+ Some(x) if x >= 0.0 && x < 100.0 => Ok(Some(x)),+ _ => Err(err("invalid_args", format!("{key} must be a distance in mm (0 to 100), not {v}"))),+ },+ }+}++/// One zone's text with the requested settings changed; untouched settings keep KiCad's bytes.+fn edit_zone(block: &str, p: &Value, root: &Node, old_format: bool) -> RResult<String> {+ let z = sxedit::parse(block).map_err(|e| err("edit_failed", e))?;+ let mut e = Edits::new();+ // the last child's end: new children go before the closing paren+ let close = |k: &Node| k.end - 1;+ let set_kid = |e: &mut Edits, parent: &Node, tag: &str, val: String| match parent.kid(tag) {+ Some(k) => e.replace_node(k, format!("({tag} {val})")),+ None => e.insert(close(parent), format!(" ({tag} {val})")),+ };+ let clearance = mm_arg(p, "clearanceMm")?;+ let thermals = p.get("thermals").filter(|t| !t.is_null());+ let mut connection = p.get("connection").and_then(Value::as_str).map(str::to_string);+ if let Some(on) = thermals.and_then(|t| t.get("on")).and_then(Value::as_bool) {+ let now = zone_connection(&z);+ connection.get_or_insert_with(|| if on { if now == "reliefsOnThroughHole" { now.into() } else { "thermal".into() } } else { "solid".into() });+ }+ if clearance.is_some() || connection.is_some() {+ let cp = z.kid("connect_pads");+ let mode = match connection.as_deref().unwrap_or(zone_connection(&z)) {+ "thermal" => "",+ "solid" => " yes",+ "none" => " no",+ "reliefsOnThroughHole" => " thru_hole_only",+ o => return Err(err("invalid_args", format!("connection must be thermal, solid, none or reliefsOnThroughHole, not '{o}'"))),+ };+ let c = clearance.or_else(|| cp.and_then(|c| c.kid("clearance")).and_then(|k| k.f64_at(1))).unwrap_or(0.5);+ let text = format!("(connect_pads{mode} (clearance {}))", n(c));+ match cp {+ Some(cp) => e.replace_node(cp, text),+ None => e.insert(z.kid("min_thickness").or_else(|| z.kid("polygon")).map_or(close(&z), |k| k.start), format!("{text} ")),+ }+ }+ if let Some(w) = mm_arg(p, "minWidthMm")? {+ set_kid(&mut e, &z, "min_thickness", n(w));+ }+ if let Some(pr) = p.get("priority").filter(|v| !v.is_null()) {+ let pr = pr.as_u64().filter(|x| *x < 1000).ok_or_else(|| err("invalid_args", format!("priority must be a whole number 0 to 999, not {pr}")))?;+ match z.kid("priority") {+ Some(k) => e.replace_node(k, format!("(priority {pr})")),+ None if pr == 0 => {}+ None => e.insert(z.kid("connect_pads").or_else(|| z.kid("min_thickness")).map_or(close(&z), |k| k.start), format!("(priority {pr}) ")),+ }+ }+ let spoke = thermals.map(|t| mm_arg(t, "spokeMm")).transpose()?.flatten();+ let gap = thermals.map(|t| mm_arg(t, "gapMm")).transpose()?.flatten();+ let islands = p.get("keepIslands").filter(|v| !v.is_null()).map(|v| v.as_bool().ok_or_else(|| err("invalid_args", "keepIslands is true or false"))).transpose()?;+ if spoke.is_some() || gap.is_some() || islands.is_some() {+ let rows: Vec<(&str, String)> = [("thermal_gap", gap.map(n)), ("thermal_bridge_width", spoke.map(n)), ("island_removal_mode", islands.map(|k| if k { "1".into() } else { "0".into() }))]+ .into_iter().filter_map(|(t, v)| v.map(|v| (t, v))).collect();+ match z.kid("fill") {+ Some(f) => rows.into_iter().for_each(|(t, v)| set_kid(&mut e, f, t, v)),+ None => {+ let inner: String = rows.iter().map(|(t, v)| format!(" ({t} {v})")).collect();+ e.insert(z.kid("polygon").map_or(close(&z), |k| k.start), format!("(fill{inner}) "));+ }+ }+ }+ if let Some(net) = p.get("net").and_then(Value::as_str) {+ let known = board_net_names(root);+ if !net.is_empty() && !known.iter().any(|k| k == net) {+ return Err(err("unknown_net", format!("the board has no net '{net}'")).with("nets", json!(known)));+ }+ if old_format {+ let num = root.kids_tagged("net").find(|k| k.atom(2) == Some(net)).and_then(|k| k.atom(1)).unwrap_or("0").to_string();+ if let Some(k) = z.kid("net") { e.replace_node(k, format!("(net {num})")); }+ match z.kid("net_name") {+ Some(k) => e.replace_node(k, format!("(net_name {})", q(net))),+ None => e.insert(close(&z), format!(" (net_name {})", q(net))),+ }+ } else {+ match z.kid("net") {+ Some(k) => e.replace_node(k, format!("(net {})", q(net))),+ None => e.insert(z.kids.get(1).map_or(close(&z), |k| k.start), format!("(net {}) ", q(net))),+ }+ }+ }+ let out = e.apply(block).map_err(|x| err("edit_failed", x))?;+ sxedit::parse(&out).map_err(|x| err("edit_failed", format!("the edited zone does not parse: {x}")))?;+ Ok(out)+}++/// Every board pour's settings (pour_settings), for reading an edit back.+pub fn all_pour_settings(text: &str) -> RResult<Vec<Value>> {+ let root = parse_root(text)?;+ Ok(root.kids_tagged("zone").filter(|z| z.kid("keepout").is_none()).map(pour_settings).collect())+}++/// The pours[] entries of a kicad_edit_pours call (a single pour may be given at the top level).+pub fn pour_rows(args: &Value) -> Vec<Value> {+ match args.get("pours") {+ Some(Value::Array(a)) => a.clone(),+ _ => vec![args.clone()],+ }+}++const POUR_FIELDS: &[&str] = &["clearanceMm", "minWidthMm", "thermals", "connection", "priority", "net", "keepIslands"];++/// `kicad_edit_pours`: change existing zones in place (clearance, minimum width, thermal reliefs, pad connection,+/// priority, net, islands). Each zone keeps its outline and uuid in the file; a live commit recreates it in one Undo step.+pub fn edit_pours(text: &str, args: &Value) -> RResult<Change> {+ let root = parse_root(text)?;+ let rows: Vec<Value> = match args.get("pours") {+ Some(Value::Array(a)) if !a.is_empty() => a.clone(),+ None if args.get("id").is_some() || args.get("net").is_some() || args.get("name").is_some() => vec![args.clone()],+ _ => return Err(err("invalid_args", "pours is required: [{id or net+layer, clearanceMm?, minWidthMm?, thermals?: {on, spokeMm, gapMm}, priority?, net?, keepIslands?}]")),+ };+ let old_format = root.kids_tagged("net").any(|k| k.atom(2).is_some());+ let mut e = Edits::new();+ let (mut remove, mut create, mut edited) = (Vec::new(), Vec::new(), Vec::new());+ let mut done: Vec<String> = Vec::new();+ for (entry, p) in rows.iter().enumerate() {+ if !POUR_FIELDS.iter().any(|f| p.get(*f).map_or(false, |v| !v.is_null())) {+ return Err(err("invalid_args", format!("pour {p} changes nothing: give one of {}", POUR_FIELDS.join(", "))));+ }+ for z in select_zones(&root, text, p)? {+ let id = z.uuid().ok_or_else(|| err("zone_without_uuid", "a zone has no uuid; open and save the board in KiCad once"))?.to_string();+ if done.contains(&id) {+ return Err(err("invalid_args", format!("pour {id} is named by two entries; give each pour once")));+ }+ let block = edit_zone(z.src(text), p, &root, old_format)?;+ let after = sxedit::parse(&block).map_err(|x| err("edit_failed", x))?;+ edited.push(json!({"id": id, "entry": entry, "before": pour_settings(z), "after": pour_settings(&after)}));+ if block != z.src(text) {+ e.replace_node(z, block.clone());+ remove.push(id.clone());+ create.push(fp_text::fresh_uuids(&block).map_err(|x| err("edit_failed", x))?);+ }+ done.push(id);+ }+ }+ let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;+ parse_board(&candidate)?;+ Ok(Change {+ candidate, remove, create, copper: true,+ report: json!({"edited": edited, "count": edited.len()}),+ label: format!("Adom edit {} pour(s)", edited.len()),+ ..Default::default()+ })+}+ /// Top-level item UUIDs of a board text (footprints, graphics, copper, zones). pub fn top_level_ids(text: &str) -> Vec<String> { sxedit::parse(text).map(|r| r.kids.iter().filter_map(|k| k.uuid().map(str::to_string)).collect()).unwrap_or_default()⋯ 3 unchanged lines ⋯ mod tests { use super::*; + const ZONES10: &str = "(kicad_pcb (version 20260206) (generator \"pcbnew\")\n\t(footprint \"R\" (layer \"F.Cu\") (uuid \"f1\") (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"GND\") (uuid \"p1\")) (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net \"VCC\") (uuid \"p2\")))\n\t(zone\n\t\t(net \"GND\")\n\t\t(layer \"B.Cu\")\n\t\t(uuid \"z1\")\n\t\t(name \"GND plane\")\n\t\t(hatch edge 0.5)\n\t\t(connect_pads\n\t\t\t(clearance 0.2)\n\t\t)\n\t\t(min_thickness 0.25)\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n\t\t(polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))\n\t)\n\t(zone (net \"VCC\") (layers \"F.Cu\" \"B.Cu\") (uuid \"z2\") (hatch edge 0.5) (priority 1) (connect_pads yes (clearance 0.3)) (min_thickness 0.2) (fill yes (thermal_gap 0.4) (thermal_bridge_width 0.4) (island_removal_mode 1)) (polygon (pts (xy 2 2) (xy 4 2) (xy 4 4))))\n)\n";+ #[test]+ fn edit_pours_changes_only_what_it_is_asked_and_keeps_the_rest_of_the_zone() {+ let c = edit_pours(ZONES10, &json!({"pours": [{"net": "GND", "layer": "B.Cu", "clearanceMm": 0.3, "thermals": {"spokeMm": 0.6}, "priority": 2, "keepIslands": true}]})).unwrap();+ let e = &c.report["edited"][0];+ assert_eq!((e["id"].as_str(), e["before"]["clearanceMm"].as_f64(), e["after"]["clearanceMm"].as_f64()), (Some("z1"), Some(0.2), Some(0.3)));+ assert_eq!((e["after"]["thermals"]["spokeMm"].as_f64(), e["after"]["thermals"]["gapMm"].as_f64(), e["after"]["priority"].as_i64(), e["after"]["keepIslands"].as_bool()), (Some(0.6), Some(0.5), Some(2), Some(true)));+ assert!(c.candidate.contains("(name \"GND plane\")\n\t\t(hatch edge 0.5)\n\t\t(priority 2) (connect_pads (clearance 0.3))"), "{}", c.candidate);+ assert!(c.candidate.contains("(min_thickness 0.25)\n\t\t(filled_areas_thickness no)"));+ // the other zone is byte-for-byte untouched; the live commit recreates only z1+ assert!(c.candidate.contains("(zone (net \"VCC\") (layers \"F.Cu\" \"B.Cu\") (uuid \"z2\")"));+ assert_eq!((c.remove.clone(), c.create.len()), (vec!["z1".to_string()], 1));+ }++ #[test]+ fn edit_pours_switches_thermals_net_and_refuses_unknown_nets_and_pours() {+ let c = edit_pours(ZONES10, &json!({"pours": [{"id": "z2", "thermals": {"on": true}, "net": "GND", "minWidthMm": 0.3}]})).unwrap();+ let a = &c.report["edited"][0]["after"];+ assert_eq!((a["connection"].as_str(), a["thermals"]["on"].as_bool(), a["net"].as_str(), a["minWidthMm"].as_f64()), (Some("thermal"), Some(true), Some("GND"), Some(0.3)));+ assert_eq!(c.report["edited"][0]["before"]["connection"], "solid");+ let off = edit_pours(ZONES10, &json!({"pours": [{"name": "GND plane", "thermals": {"on": false}}]})).unwrap();+ assert_eq!(off.report["edited"][0]["after"]["connection"], "solid");+ assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"id": "z1", "net": "NOPE"}]})).unwrap_err().code, "unknown_net");+ assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"net": "GND", "layer": "F.Cu", "priority": 1}]})).unwrap_err().code, "pour_not_found");+ assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"id": "z1"}]})).unwrap_err().code, "invalid_args");+ // a multi-layer zone is found by any of its layers+ assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"net": "VCC", "layer": "F.Cu", "priority": 3}]})).unwrap().report["edited"][0]["id"], "z2");+ }++ #[test]+ fn edit_pours_writes_an_older_files_net_number_and_name() {+ let t = "(kicad_pcb (version 20240108) (net 0 \"\") (net 1 \"GND\") (net 2 \"VCC\")\n (zone (net 1) (net_name \"GND\") (layer \"F.Cu\") (tstamp \"z9\") (hatch edge 0.5) (connect_pads (clearance 0.2)) (min_thickness 0.25) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))\n)\n";+ let c = edit_pours(t, &json!({"pours": [{"id": "z9", "net": "VCC"}]})).unwrap();+ assert!(c.candidate.contains("(net 2) (net_name \"VCC\")"), "{}", c.candidate);+ assert_eq!(c.report["edited"][0]["after"]["net"], "VCC");+ }++ #[test] fn plan_refs_takes_the_next_free_numbers_and_refuses_a_taken_one() { let t = "(kicad_pcb (footprint \"a\" (layer \"F.Cu\") (property \"Reference\" \"R1\")) (footprint \"a\" (layer \"F.Cu\") (property \"Reference\" \"R3\")))"; let r = sxedit::parse(t).unwrap();⋯ 13 unchanged lines ⋯ assert_eq!(super::lib_name("Package_SO:SOIC-8"), ("Package_SO".to_string(), "SOIC-8".to_string())); } }+@@ -1,1852 +1,1859 @@⋯ 352 unchanged lines ⋯ fills: Vec<Vec<P>>, owner: Option<String>, graphic: bool,+ /// the zone's uuid (one zone on several layers is one id), None for a graphic+ id: Option<String>, } #[derive(Clone, Debug)]⋯ 286 unchanged lines ⋯ let name = z.find("name").and_then(Sx::value).map(str::to_string); let priority = z.find("priority").and_then(|p| num(p, 1)).unwrap_or(0.0) as i64; let hatched = z.find("fill").and_then(|f| f.find("mode")).and_then(Sx::value) == Some("hatch");+ let id = z.find("uuid").or_else(|| z.find("tstamp")).and_then(Sx::value).map(str::to_string); let mut fills: HashMap<String, Vec<Vec<P>>> = HashMap::new(); for f in z.find_all("filled_polygon") { let layer = f.find("layer").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| copper[0].clone());⋯ 4 unchanged lines ⋯ } for layer in copper { let f = fills.remove(&layer).unwrap_or_default();- m.polys.push(Poly { net: net.clone(), layer, points: points.clone(), name: name.clone(), priority, hatched, fills: f, owner: owner.map(str::to_string), graphic: false });+ m.polys.push(Poly { net: net.clone(), layer, points: points.clone(), name: name.clone(), priority, hatched, fills: f, owner: owner.map(str::to_string), graphic: false, id: id.clone() }); } } ⋯ 100 unchanged lines ⋯ m.arcs.push(ArcT { arc: a, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone() }); } if let Some(points) = poly {- m.polys.push(Poly { net, layer: layer.to_string(), fills: vec![points.clone()], points, name: None, priority: 0, hatched: false, owner: owner_s, graphic: true });+ m.polys.push(Poly { net, layer: layer.to_string(), fills: vec![points.clone()], points, name: None, priority: 0, hatched: false, owner: owner_s, graphic: true, id: None }); } } ⋯ 220 unchanged lines ⋯ if let Some(o) = &g.owner { z["ref"] = json!(o); }+ if let Some(id) = &g.id {+ z["id"] = json!(id);+ } if geometry { z["outline"] = json!(g.points.iter().map(|p| pa(*p, opts)).collect::<Vec<_>>()); z["fills"] = json!(g.fills.iter().map(|f| json!({"outline": f.iter().map(|p| pa(*p, opts)).collect::<Vec<_>>(), "holes": [], "fractured": true})).collect::<Vec<_>>());⋯ 855 unchanged lines ⋯ assert_eq!(v["polygons"].as_array().unwrap().len(), 2, "a two-layer zone is one polygon per layer"); } }+@@ -1,2500 +1,2582 @@⋯ 1280 unchanged lines ⋯ /// same revision guard and DRC preflight as `route_net`: the zones' s-expressions are /// spliced into the editor's snapshot, KiCad's DRC (zones refilled) must add no error, or /// nothing lands. After the commit the new zones are filled (`refill:false` skips that).+/// `kicad_edit_pours` on the board open in the PCB editor: each zone KiCad holds is sent back through `UpdateItems`+/// with only the requested settings changed (its id, outline and everything else kept), as ONE native Undo step,+/// then refilled and read back. KiCad 10 creates no item from text, so the file path's delete-and-create cannot+/// serve a live board; this does.+pub fn edit_pours(ctx: &Ctx, args: &Value) -> RResult<Value> {+ let s = connect(ctx, args)?;+ let (text, _board, revision) = s.snapshot()?;+ // expectedRevision is optional here (the standard's edit_pours), and pour_state's standard revision is accepted+ // beside KiCad's own+ let expected = args.get("expectedRevision").and_then(Value::as_str);+ let std_rev = crate::board_model::revision_of_text(&text).ok();+ if let Some(e) = expected {+ if e != revision && Some(e) != std_rev.as_deref() {+ return Err(RoutingError::new("stale_board", "expectedRevision must match the current live board (KiCad's revision or pour_state's)").with("currentRevision", json!(revision)).with("standardRevision", json!(std_rev)).with("mutated", json!(false)));+ }+ }+ // the same validation, selection and before/after as the file edit+ let change = crate::board_edit::edit_pours(&text, args)?;+ let rows = crate::board_edit::pour_rows(args);+ let edited = change.report["edited"].as_array().cloned().unwrap_or_default();+ if args.get("dryRun") == Some(&json!(true)) {+ return Ok(json!({"success": true, "source": "live-editor", "dryRun": true, "mutated": false, "undoSteps": 0, "revision": revision, "edited": edited,+ "_hint": "Checked without editing. Call again without dryRun (and the same expectedRevision) to apply it as one Undo step."}));+ }+ let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;+ let nets = s.client.get_nets().map_err(map_err)?;+ let mut items = Vec::new();+ for e in &edited {+ if e["before"] == e["after"] {+ continue;+ }+ let id = e["id"].as_str().unwrap_or("");+ let p = &rows[e["entry"].as_u64().unwrap_or(0) as usize];+ let Some(raw) = raws.iter().find(|a| zones::zone_id(&a.value).as_deref() == Some(id)) else {+ return Err(RoutingError::new("pour_not_found", format!("the PCB editor holds no zone {id}")).with("mutated", json!(false)));+ };+ let code = match p.get("net").and_then(Value::as_str) {+ Some(n) if !n.is_empty() => Some(nets.iter().find(|x| x.name == n).map(|x| x.code).ok_or_else(|| RoutingError::new("unknown_net", format!("The live board has no net named '{n}'")).with("mutated", json!(false)))?),+ _ => None,+ };+ let value = zones::rewrite_zone(&raw.value, p, code).map_err(|e| RoutingError::new("zone_undecodable", e).with("mutated", json!(false)))?;+ items.push(prost_types::Any { type_url: raw.type_url.clone(), value });+ }+ if items.is_empty() {+ return Ok(json!({"success": true, "source": "live-editor", "mutated": false, "undoSteps": 0, "revision": revision, "edited": edited, "_hint": "Nothing to change: the pours already have these settings."}));+ }+ if s.revision()? != revision {+ return Err(RoutingError::new("stale_board", "the board changed while the edit was prepared; nothing was changed").with("mutated", json!(false)));+ }+ let count = items.len();+ transaction(&s, &change.label, || {+ let updated = s.client.update_items(items)?;+ if updated.len() != count {+ return Err(IpcFailure::from(format!("KiCad updated {} of {count} zones; rollback requested", updated.len())));+ }+ Ok(())+ })?;+ let mut out = json!({"success": true, "source": "live-editor", "boardPath": s.file_path, "mutated": true, "undoSteps": 1, "edited": edited, "refilled": false});+ if args.get("refill") != Some(&json!(false)) {+ let before = s.revision().ok();+ match s.client.refill_zones(Vec::new()) {+ Ok(()) => out["refilled"] = json!(settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message)).1),+ Err(e) => out["refillError"] = json!(format!("edited but not refilled: {e}")),+ }+ }+ // read back what KiCad now holds: the settings and the copper+ let (after_text, _, after_rev) = s.snapshot()?;+ out["revision"] = json!(after_rev);+ let now = crate::board_edit::all_pour_settings(&after_text).unwrap_or_default();+ let verified = edited.iter().all(|e| now.iter().any(|z| z["id"] == e["id"] && z == &e["after"]));+ out["verified"] = json!(verified);+ if !verified {+ out["readback"] = json!(now);+ }+ if let Ok(p) = crate::board_model::pours(&after_text, &crate::board_model::Opts { y_up: true }, false) {+ out["pourState"] = p;+ }+ out["_hint"] = json!(if verified { "One Undo step in the PCB editor, refilled; pourState is the copper now and KiCad's board shows the new settings. The file is saved only by kicad_save_board. Run kicad_run_drc after a net change." } else { "Applied, but the read-back differs from the request: readback lists what KiCad holds now." });+ Ok(out)+}+ pub fn add_zone(ctx: &Ctx, args: &Value) -> RResult<Value> { let s = connect(ctx, args)?; let (text, data, revision) = s.snapshot()?;⋯ 1211 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&base); } }+@@ -1,599 +1,725 @@⋯ 333 unchanged lines ⋯ out } +/// One protobuf message with field `number` replaced: every other field is copied byte for byte, and `with` (the+/// replacement field's complete encoding, or nothing to drop it) is appended. Field order does not matter in protobuf.+fn replace_field(msg: &[u8], number: u32, with: Option<Vec<u8>>) -> Result<Vec<u8>, String> {+ let mut out = Vec::with_capacity(msg.len() + 16);+ for f in parse_fields(msg)? {+ if f.number == number {+ continue;+ }+ crate::placement::put_varint(&mut out, ((f.number as u64) << 3) | f.wire as u64);+ if f.wire == 2 {+ crate::placement::put_varint(&mut out, f.data.len() as u64);+ }+ out.extend_from_slice(f.data);+ }+ if let Some(w) = with {+ out.extend_from_slice(&w);+ }+ Ok(out)+}++/// A nested message field's body (empty when absent).+fn sub_message<'a>(msg: &'a [u8], number: u32) -> Result<&'a [u8], String> {+ Ok(parse_fields(msg)?.into_iter().find(|f| f.number == number && f.wire == 2).map(|f| f.data).unwrap_or(&[]))+}++fn enc_message(number: u32, body: &[u8]) -> Vec<u8> {+ let mut v = Vec::new();+ wire_message(&mut v, number, body);+ v+}++fn enc_varint(number: u32, value: u64) -> Vec<u8> {+ let mut v = Vec::new();+ wire_varint(&mut v, number, value);+ v+}++/// `kicad_edit_pours` live: a zone KiCad returned (`kiapi.board.types.Zone`, id and outline kept) with the requested+/// settings changed, for `UpdateItems`. `p` is one pours[] entry in the standard's names; `net_code` is the live code+/// of `p.net` when it changes the net. Only the fields `p` names are touched.+pub fn rewrite_zone(zone: &[u8], p: &Value, net_code: Option<i32>) -> Result<Vec<u8>, String> {+ let mm = |k: &str| p.get(k).and_then(Value::as_f64);+ let th = p.get("thermals").filter(|t| t.is_object());+ let thm = |k: &str| th.and_then(|t| t.get(k)).and_then(Value::as_f64);+ let mut settings = sub_message(zone, 6)?.to_vec();+ // connection {1 style, 2 thermal_spokes {1 width, 3 gap}}+ let mut conn = sub_message(&settings, 1)?.to_vec();+ let style_now = parse_fields(&conn)?.into_iter().find(|f| f.number == 1 && f.wire == 0).map(|f| varint_value(f.data)).transpose()?.unwrap_or(0);+ let style = match p.get("connection").and_then(Value::as_str) {+ Some("thermal") => Some(3),+ Some("solid") => Some(4),+ Some("none") => Some(2),+ Some("reliefsOnThroughHole") => Some(5),+ Some(o) => return Err(format!("connection {o} is not thermal, solid, none or reliefsOnThroughHole")),+ None => match th.and_then(|t| t.get("on")).and_then(Value::as_bool) {+ Some(true) => Some(if style_now == 5 { 5 } else { 3 }),+ Some(false) => Some(4),+ None => None,+ },+ };+ if let Some(st) = style {+ conn = replace_field(&conn, 1, Some(enc_varint(1, st)))?;+ }+ if thm("spokeMm").is_some() || thm("gapMm").is_some() {+ let mut spokes = sub_message(&conn, 2)?.to_vec();+ if let Some(w) = thm("spokeMm") {+ spokes = replace_field(&spokes, 1, Some(enc_message(1, &distance_bytes(nm(w)))))?;+ }+ if let Some(g) = thm("gapMm") {+ spokes = replace_field(&spokes, 3, Some(enc_message(3, &distance_bytes(nm(g)))))?;+ }+ conn = replace_field(&conn, 2, Some(enc_message(2, &spokes)))?;+ }+ settings = replace_field(&settings, 1, Some(enc_message(1, &conn)))?;+ if let Some(c) = mm("clearanceMm") {+ settings = replace_field(&settings, 2, Some(enc_message(2, &distance_bytes(nm(c)))))?;+ }+ if let Some(w) = mm("minWidthMm") {+ settings = replace_field(&settings, 3, Some(enc_message(3, &distance_bytes(nm(w)))))?;+ }+ if let Some(keep) = p.get("keepIslands").and_then(Value::as_bool) {+ settings = replace_field(&settings, 4, Some(enc_varint(4, if keep { 2 } else { 1 })))?; // IRM_NEVER | IRM_ALWAYS+ }+ if let Some(net) = p.get("net").and_then(Value::as_str) {+ let mut code = Vec::new();+ if let Some(c) = net_code.filter(|c| *c != 0) {+ wire_varint(&mut code, 1, c as u64);+ }+ let mut n = Vec::new();+ wire_message(&mut n, 1, &code);+ wire_message(&mut n, 2, net.as_bytes());+ settings = replace_field(&settings, 8, Some(enc_message(8, &n)))?;+ }+ let mut out = replace_field(zone, 6, Some(enc_message(6, &settings)))?;+ if let Some(pr) = p.get("priority").and_then(Value::as_u64) {+ out = replace_field(&out, 8, if pr > 0 { Some(enc_varint(8, pr)) } else { None })?;+ }+ Ok(out)+}+ /// Copper layer names for the `BoardLayer` enum values (F.Cu = 3, In1..In30 = 4..33, B.Cu = 34). pub fn copper_layer_name(id: i32) -> String { match id {⋯ 228 unchanged lines ⋯ } #[test]+ fn rewrite_zone_changes_only_the_requested_settings() {+ let b = board();+ let specs = parse_specs(&b, &json!({"net": "GND", "layers": ["F.Cu"], "polygon": [[1, 1], [10, 1], [10, 8], [1, 8]], "priority": 2, "connection": "solid"})).unwrap();+ let bytes = zone_bytes(&specs[0], &[3], 7);+ let out = rewrite_zone(&bytes, &json!({"clearanceMm": 0.35, "priority": 4, "thermals": {"on": true, "spokeMm": 0.6}, "keepIslands": true, "net": "VCC"}), Some(9)).unwrap();+ let back = decode_zone(&out).unwrap();+ assert_eq!((back.priority, back.net.as_str(), back.layers.clone()), (4, "VCC", vec![3]));+ assert!((back.outline_area_mm2 - 63.0).abs() < 1e-6, "the outline is untouched");+ let settings = sub_message(&out, 6).unwrap();+ let dist = |m: &[u8], n: u32| varint_value(parse_fields(sub_message(m, n).unwrap()).unwrap()[0].data).unwrap();+ assert_eq!(dist(settings, 2), 350_000);+ assert_eq!(dist(settings, 3), nm(specs[0].min_thickness) as u64, "min width kept");+ let conn = sub_message(settings, 1).unwrap();+ let field = |m: &[u8], n: u32| parse_fields(m).unwrap().into_iter().find(|f| f.number == n).map(|f| varint_value(f.data).unwrap());+ assert_eq!(field(conn, 1), Some(3), "thermal reliefs on");+ let spokes = sub_message(conn, 2).unwrap();+ assert_eq!((dist(spokes, 1), dist(spokes, 3)), (600_000, nm(specs[0].thermal_gap) as u64), "spoke changed, gap kept");+ assert_eq!(field(settings, 4), Some(2), "IRM_NEVER keeps islands");+ assert_eq!(sub_message(&out, 5).unwrap(), sub_message(&bytes, 5).unwrap(), "name kept");+ // priority 0 drops the field; nothing asked changes nothing+ assert!(decode_zone(&rewrite_zone(&bytes, &json!({"priority": 0}), None).unwrap()).unwrap().priority == 0);+ assert_eq!(decode_zone(&rewrite_zone(&bytes, &json!({}), None).unwrap()).unwrap().net, "GND");+ }++ #[test] fn keepout_zone_round_trips() { let b = board(); let specs = parse_specs(&b, &json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": [[1, 1], [5, 1], [5, 3], [1, 3]], "keepout": {"copperPour": true}, "name": "kelvin"})).unwrap();⋯ 23 unchanged lines ⋯ assert_eq!(v["filledCoverageByLayerPercent"]["F.Cu"], json!(4.0)); } }+@@ -0,0 +1,83 @@+<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE eagle SYSTEM "eagle.dtd">+<eagle version="9.6.2">+<drawing>+<settings>+<setting alwaysvectorfont="no"/>+</settings>+<grid distance="0.1" unitdist="mm" unit="mm" style="lines" multiple="1" display="no" altdistance="0.01" altunitdist="mm" altunit="mm"/>+<layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>+<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="24" fill="1" visible="yes" active="yes"/>+<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>+<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>+<layer number="51" name="tDocu" color="7" fill="1" visible="yes" active="yes"/>+</layers>+<board>+<plain>+<wire x1="0" y1="0" x2="20" y2="0" width="0" layer="20"/>+<wire x1="20" y1="0" x2="20" y2="12" width="0" layer="20"/>+<wire x1="20" y1="12" x2="0" y2="12" width="0" layer="20"/>+<wire x1="0" y1="12" x2="0" y2="0" width="0" layer="20"/>+</plain>+<libraries>+<library name="adom-test">+<packages>+<package name="R0805">+<smd name="1" x="-0.95" y="0" dx="1.0" dy="1.3" layer="1"/>+<smd name="2" x="0.95" y="0" dx="1.0" dy="1.3" layer="1"/>+<wire x1="-1.6" y1="0.9" x2="1.6" y2="0.9" width="0.12" layer="21"/>+<wire x1="-1.6" y1="-0.9" x2="1.6" y2="-0.9" width="0.12" layer="21"/>+<text x="-1.5" y="1.2" size="0.8" layer="25">>NAME</text>+</package>+<package name="PINHEAD3">+<pad name="1" x="-2.54" y="0" drill="1.0" diameter="1.7" shape="square"/>+<pad name="2" x="0" y="0" drill="1.0" diameter="1.7"/>+<pad name="3" x="2.54" y="0" drill="1.0" diameter="1.7"/>+<text x="-3.8" y="1.4" size="0.8" layer="25">>NAME</text>+</package>+</packages>+</library>+</libraries>+<attributes/>+<variantdefs/>+<classes>+<class number="0" name="default" width="0" drill="0"/>+</classes>+<designrules name="default"/>+<autorouter/>+<elements>+<element name="R1" library="adom-test" package="R0805" value="10k" x="6" y="8"/>+<element name="R2" library="adom-test" package="R0805" value="10k" x="14" y="8"/>+<element name="J1" library="adom-test" package="PINHEAD3" value="HDR" x="10" y="3"/>+</elements>+<signals>+<signal name="VIN">+<contactref element="J1" pad="1"/>+<contactref element="R1" pad="1"/>+<wire x1="7.46" y1="3" x2="5.05" y2="3" width="0.4" layer="1"/>+<wire x1="5.05" y1="3" x2="5.05" y2="8" width="0.4" layer="1"/>+</signal>+<signal name="MID">+<contactref element="R1" pad="2"/>+<contactref element="R2" pad="1"/>+<contactref element="J1" pad="2"/>+<wire x1="6.95" y1="8" x2="13.05" y2="8" width="0.3" layer="1"/>+<wire x1="10" y1="8" x2="10" y2="3" width="0.3" layer="1"/>+</signal>+<signal name="GND">+<contactref element="R2" pad="2"/>+<contactref element="J1" pad="3"/>+<wire x1="14.95" y1="8" x2="14.95" y2="3" width="0.4" layer="16"/>+<wire x1="14.95" y1="3" x2="12.54" y2="3" width="0.4" layer="16"/>+</signal>+</signals>+</board>+</drawing>+</eagle>+@@ -1,1909 +1,1957 @@⋯ 203 unchanged lines ⋯ "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", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs")},+ "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs",+ "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step")}, "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),⋯ 456 unchanged lines ⋯ ab(a.target, "send_files", {"filePaths": [str(REPO / "demo" / "routing" / "esc" / "esc-g431-fable-placed-planes.kicad_pcb")], "dest": base + "/checks"}, timeout=200) # and a small routed board for length tuning ab(a.target, "send_files", {"filePaths": [str(REPO / "rust" / "crates" / "kicad-core" / "tests" / "fixtures" / "konnect-route" / "route-fixture.kicad_pcb")], "dest": base + "/checks"}, timeout=200)+ # an EAGLE board for import_design (1.0.94)+ ab(a.target, "send_files", {"filePaths": [str(REPO / "rust" / "crates" / "kicad-core" / "tests" / "fixtures" / "eagle" / "divider.brd")], "dest": base + "/import"}, timeout=120) # scratch symbol library for install_library (written locally, pushed over) import tempfile as _tf tl = Path(_tf.gettempdir()) / "AdomVerbTest.kicad_sym"⋯ 1020 unchanged lines ⋯ "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")+ # 1.0.94 (standard 0.29.0): export_3d (glb with the parts, a bare-board stl, obj refused by name), edit_pours on a+ # closed copy of the ESC's planes (refilled by kicad-cli), import_design of a small EAGLE board+ sb = sw_dir + "/rp2040-breakout.kicad_pcb"+ g3 = C("export_3d", {"filePath": sb, "format": "glb", "outputPath": w["out"] + "/3d/board.glb"}, 320)+ s3 = C("export_3d", {"filePath": sb, "format": "stl", "components": False, "outputPath": w["out"] + "/3d/bare.stl"}, 320)+ o3 = C("export_3d", {"filePath": sb, "format": "obj"}, 60)+ own("export_3d", {"glb": g3, "stl": s3, "obj": o3}, ok(g3) and g3.get("bytes", 0) > 10000 and g3.get("components") is True+ and ok(s3) and s3.get("components") is False and s3.get("bytes", 0) > 1000 and o3.get("errorCode") == "not_supported" and o3.get("instead"),+ "the glb or bare stl was not written, or obj was not refused by name")+ pd = base + "/pours"+ ab(a.target, "shell_execute", {"command": "rmdir /s /q \"" + pd.replace("/", "\\") + "\" 2>nul & mkdir \"" + pd.replace("/", "\\") + "\" & copy /y \"" + CK.replace("/", "\\") + "\" \"" + pd.replace("/", "\\") + "\"",+ "reason": "kicad-bridge verb test: a scratch copy of the ESC board's planes for kicad_edit_pours"}, timeout=120)+ pb = pd + "/esc-g431-fable-placed-planes.kicad_pcb"+ ps = ab(a.target, "kicad_pour_state", {"filePath": pb, "source": "file", "geometry": False}, timeout=120)+ gz = next((z for z in ps.get("zones") or [] if z.get("net") == "GND" and z.get("id")), {})+ ep = C("edit_pours", {"filePath": pb, "source": "file", "pours": [{"id": gz.get("id"), "clearanceMm": 0.3, "thermals": {"spokeMm": 0.6}}]}, 300)+ ed = (ep.get("edited") or [{}])[0]+ after = [z for z in (ep.get("pourState") or {}).get("zones") or [] if z.get("id") == gz.get("id")]+ own("edit_pours", ep, ok(ep) and gz and ed.get("after", {}).get("clearanceMm") == 0.3 and (ed.get("after", {}).get("thermals") or {}).get("spokeMm") == 0.6+ and ep.get("refilled") is True and after and all(z.get("filledMm2", 0) > 0 for z in after),+ "the GND pour's clearance and spoke did not change, or kicad-cli did not refill it")+ # the standard's schematic part verbs in their own arguments (mils from the lower-left, designators, lists), on the+ # swap copy's sheet with KiCad's own Device library+ ss = sw_dir + "/rp2040-breakout.kicad_sch"+ pp = C("sch_place_part", {"sheet": ss, "library": "Device", "symbol": "R", "designator": "R99", "comment": "10k", "x": 1000, "y": 1000}, 120)+ pc = C("sch_connect", {"sheet": ss, "connections": [{"designator": "R99", "pin": 1, "net": "TEST_NET"}, {"designator": "R99", "pin": 2, "net": "GND", "kind": "power"}]}, 120)+ pe = C("sch_edit", {"sheet": ss, "edits": [{"designator": "R99", "action": "comment", "text": "4k7"}, {"designator": "R99", "action": "moveBy", "dx": 0, "dy": 0}]}, 120)+ pw = C("sch_swap_part", {"sheet": ss, "designator": "R99", "library": "Device", "symbol": "R_Small"}, 120)+ own("sch_part_verbs_standard", {"place": pp, "connect": pc, "edit": pe, "swap": pw},+ ok(pp) and pp.get("atMil") == [1000, 1000] and ok(pc) and pc.get("connected") == 2 and ok(pe) and pe.get("applied") == 2+ and ok(pw) and (pw.get("now") or {}).get("libReference") == "Device:R_Small" and isinstance(pw.get("pinsJoined"), list),+ "a standard-shaped schematic part verb failed, or answered outside the standard's terms (atMil, connected, applied, now, pinsJoined)")+ # tool_update: check and the auto setting read (never apply: that installs KiCad); import_step refused with why+ tu = C("tool_update", {"action": "check"}, 120)+ ta = C("tool_update", {"action": "auto"}, 60)+ own("tool_update", {"check": tu, "auto": ta}, ok(tu) and tu.get("current") and "offered" in tu and isinstance(tu.get("auto"), bool) and ok(ta) and isinstance(ta.get("auto"), bool),+ "tool_update did not report the installed version, the offered one and the auto setting")+ st = C("import_step", {"filePath": "C:/x/enclosure.step"}, 30)+ own("import_step", st, st.get("errorCode") == "not_supported" and st.get("instead"), "import_step did not refuse by name with what to use instead")+ im = C("import_design", {"format": "eagle", "filePath": base + "/import/divider.brd", "overwrite": True}, 300)+ io = C("import_design", {"format": "orcad", "filePath": base + "/import/divider.brd"}, 60)+ own("import_design", {"eagle": im, "orcad": io}, ok(im) and im.get("parts") == 3 and im.get("nets") == 3 and im.get("schematicImported") is False+ and str(im.get("design", "")).endswith("divider.kicad_pro") and io.get("errorCode") == "not_supported",+ "the EAGLE divider did not come over as 3 parts on 3 nets, or orcad was not refused by name") 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)⋯ 208 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+
Comments
Log in to comment.
No comments yet.