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.63, the EDA Bridge Standard's shapes for KiCad's 47 standard verbs #24
Merged opened by Colby Knox 2026-10-27
From kicad-lane. KiCad bridge 1.0.63 takes the EDA Bridge Standard's shapes for the 47 verbs KiCad brought to the standard (standard 0.3.0). KiCad's own names and arguments keep working.
std_shapes.rs(new): one layer between the call and the verb.- sheet and project paths;
- id/ids for uuids;
- mm-named arguments;
- list forms as one
kicad_sch_batchwrite; frame:"standard"for y up (sheet replies come back y up; board replies stay in KiCad's frame);kicad_edit_footprintwith library arguments goes tokicad_fp_edit;sch_pinsalso answers a flatpinslist;describemarks the verbs withstandardShape.
catalog.rs: 8 standard-name aliases: render_board_3d, remove_parts, edit_parts, align_parts, remove_graphics, read_footprint, export_library_part, create_part_project.standard_names.txtis re-synced from standard 0.3.0, and 47 verbs move out ofkicad_only.txt.board_edit.rs,board_live.rs:edits: [{ref, ...}]gives each part its own edit in one undo step, on the file and live.verbs_fab.rs:estimate_costnames its pricesourceand listsprices. Its assumptions and hint say the rates are built in and were never checked against the fab's page.install.rs:create_part_projectrefuses an existing project unlessoverwrite:true.tools/build_native_release.py: in the same push as the reference, it drops any exact KiCad equivalent the release now answers by name through an alias.- Battery: phase 9 runs the standard shapes on KiCad 10.0.7.
- Docs: the edits skill, the dev and publish skills, and
rust/README.md.
Tests:
cargo test --workspace: every test passes except the 7 knownschematic_edittests (their fixture was never pushed), with 6 new standard-shape tests.- Verb battery on DESKTOP-BAR6S0F (KiCad 10.0.7), signed dev build: 242 pass, 0 fail, 1 skip; coverage complete; 0 foreground grabs.
- Phase 9 alone, after the last change: 31 pass.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,350 +1,359 @@⋯ 244 unchanged lines ⋯ "kicad_fab_check", "kicad_find_symbol", "kicad_export_manufacturing",- "kicad_add_rule"+ "kicad_add_rule",+ "kicad_render_board_3d",+ "kicad_remove_parts",+ "kicad_edit_parts",+ "kicad_align_parts",+ "kicad_remove_graphics",+ "kicad_read_footprint",+ "kicad_export_library_part",+ "kicad_create_part_project" ], "statusVerb": "kicad_status", "timeouts": {⋯ 96 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1297 +1,1298 @@⋯ 309 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.62"+version = "1.0.63" dependencies = [ "kicad-core", "kicad-platform",⋯ 4 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.62"+version = "1.0.63" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.62"+version = "1.0.63" 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.62"+version = "1.0.63" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,59 +1,63 @@⋯ 41 unchanged lines ⋯ - **The standard has the job**: name the verb after it (`kicad_net_classes`, not `kicad_netclasses`), or keep your name and add `(standard name, your verb)` to `catalog::ALIASES`, so both names are served. - **It covers part of the job**: keep your name, list the verb in `kicad_only.txt`, and record it as partial in the- standard's `tools/equivalents.json`.-- **The standard has no such job**: list the verb in `rust/crates/kicad-bridge/src/kicad_only.txt`.+ standard's `tools/equivalents/kicad.json`.+- **The standard has no such job**: list the verb in `rust/crates/kicad-bridge/src/kicad_only.txt`. If other EDA+ tools could do the job, propose it to the standard (an issue titled `kicad-lane: propose ...` on its page; once+ the standard-lane thread agrees, the entry goes in `tools/proposed/kicad-lane.json`). When it lands, take the+ verb out of `kicad_only.txt` and give it the standard's arguments in `std_shapes.rs`. `catalog.rs`'s test `every_verb_has_a_standard_decision` fails until every verb is one of the three, and `bridge_json_declares_exactly_the_catalog` fails until `bridge.json` lists it. Each new verb also needs an entry in⋯ 7 unchanged lines ⋯ 2. Add the match arm in `verbs.rs::dispatch`. 3. Add it to `bridge.json` and give it an entry in the verb battery (`skills/kicad-bridge-test/run_verb_tests.py`). 4. Keep the Python contract: same arg names, same `success`/`output`/`data` shape, so the verb runner in `skills/kicad-bridge-test` passes unchanged.+@@ -1,349 +1,363 @@⋯ 203 unchanged lines ⋯ ("kicad_find_symbol", "kicad_search_library"), ("kicad_export_manufacturing", "kicad_export_fab_package"), ("kicad_add_rule", "kicad_custom_rules"),+ // standard 0.3.0: jobs KiCad proposed under the standard's names (kicad_edit_footprint with+ // library arguments goes to kicad_fp_edit in std_shapes.rs: that name is already a KiCad verb)+ ("kicad_render_board_3d", "kicad_render_board"),+ ("kicad_remove_parts", "kicad_delete_footprint"),+ ("kicad_edit_parts", "kicad_edit_footprint"),+ ("kicad_align_parts", "kicad_align_footprints"),+ ("kicad_remove_graphics", "kicad_delete_graphics"),+ ("kicad_read_footprint", "kicad_fp_info"),+ ("kicad_export_library_part", "kicad_export_part"),+ ("kicad_create_part_project", "kicad_make_part_project"), ]; /// The verb a standard name stands for, when it is an alias.⋯ 64 unchanged lines ⋯ if let Some(t) = alias_target(v.name) { e["aliasOf"] = json!(t); }+ if crate::std_shapes::accepts(alias_target(v.name).unwrap_or(v.name)) {+ e["standardShape"] = json!(crate::std_shapes::SHAPE_NOTE);+ } e }) .collect();⋯ 67 unchanged lines ⋯ assert_eq!(doc["prewarm"]["verb"], "kicad_prewarm"); } }+@@ -1,156 +1,110 @@⋯ 11 unchanged lines ⋯ kicad_add_zone kicad_adom_library_heal kicad_adom_library_status-kicad_align_footprints kicad_analyze_connections kicad_apply_zone_plan kicad_board_pads kicad_board_rules-kicad_bodies kicad_bridge_call kicad_bridge_status-kicad_check_clearance kicad_check_for_updates kicad_click kicad_close_3d_viewer kicad_close_footprint_editor kicad_close_symbol_editor-kicad_copy_routing-kicad_cross_probe-kicad_delete_footprint-kicad_delete_graphics kicad_design_review-kicad_edit_footprint kicad_enable_software_opengl kicad_errors-kicad_estimate_cost kicad_export_bom_csv-kicad_export_dxf-kicad_export_gencad-kicad_export_ipc2581-kicad_export_ipc356 kicad_export_molecule-kicad_export_odb-kicad_export_part kicad_export_pos kicad_export_svg kicad_extract_netlist⋯ 4 unchanged lines ⋯ kicad_footprint_models kicad_format_upgrade kicad_fp_edit-kicad_fp_info kicad_freerouting kicad_get_settings-kicad_import_svg kicad_install_footprint kicad_install_plugin kicad_install_symbol⋯ 6 unchanged lines ⋯ kicad_list_symbols kicad_list_versions kicad_log_tail-kicad_make_part_project kicad_menu_items kicad_model_check kicad_modify_track⋯ 9 unchanged lines ⋯ kicad_pcm_list kicad_pcm_remove_repository kicad_pcm_uninstall-kicad_place_decoupling kicad_place_footprint kicad_placement_state kicad_placement_validate-kicad_plan_fanout kicad_plugin_diagnose kicad_prewarm-kicad_project_create-kicad_project_info-kicad_project_rename-kicad_project_snapshot kicad_refresh_board_views kicad_remove_route kicad_remove_zone-kicad_render_board-kicad_render_schematic kicad_rescan_libraries kicad_route kicad_routing_validate kicad_sch_batch-kicad_sch_bus-kicad_sch_bus_entry kicad_sch_check kicad_sch_components-kicad_sch_connect_pins-kicad_sch_connect_to_bus kicad_sch_connect_to_net-kicad_sch_create kicad_sch_delete-kicad_sch_delete_wire-kicad_sch_edit_label-kicad_sch_fix_connectivity-kicad_sch_hierarchy-kicad_sch_items-kicad_sch_junction kicad_sch_label kicad_sch_move-kicad_sch_move_region kicad_sch_no_connect-kicad_sch_page-kicad_sch_pins kicad_sch_place_symbol kicad_sch_replace_symbol-kicad_sch_reset_fields kicad_sch_rotate kicad_sch_set_property-kicad_sch_sheet_pins-kicad_sch_split_wire-kicad_sch_text-kicad_sch_trace_point-kicad_sch_update_from_library kicad_sch_wire kicad_score_placement kicad_screenshot_all kicad_select_clear kicad_select_items kicad_send_key-kicad_set_layer_count kicad_set_settings kicad_silk_fields_batch kicad_silk_graphics_batch⋯ 5 unchanged lines ⋯ kicad_upgrade kicad_verb_times kicad_zone_state+@@ -1,228 +1,230 @@⋯ 33 unchanged lines ⋯ mod verbs_sch_hier; mod verbs_show; mod verbs_standard;+mod std_shapes; mod verbs_windows; use std::sync::atomic::{AtomicU64, Ordering};⋯ 186 unchanged lines ⋯ }; let _ = req.respond(json_response(body, code)); }+@@ -1,202 +1,257 @@-# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.75). 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.76). Generated by tools/sync_standard_names.py; do not edit. a365_configure a365_files_upload a365_library_overview⋯ 14 unchanged lines ⋯ agent_start agent_status agent_stop+align_parts animate_view annotate apply_pours⋯ 6 unchanged lines ⋯ board_report board_shape board_summary+bodies bom build_board build_footprint build_schematic build_symbol_modes+check_clearance check_dialogs check_orientation cleanup_libs clone_symbol close+close_all_documents close_document compile compile_intlib⋯ 4 unchanged lines ⋯ copper_balance copper_layers copy_footprint+copy_routing create_footprint+create_part_project create_symbol cross_check+cross_probe current_capacity demo describe⋯ 2 unchanged lines ⋯ diff_pairs dismiss_dialogs doc_params+document_info+edit_footprint+edit_parts ensure_licensed+estimate_cost eval export_bom+export_dxf+export_gencad export_gerber+export_ipc2581+export_ipc356+export_library_part export_manufacturing+export_odb export_pdf export_step extension_ping⋯ 4 unchanged lines ⋯ footprint_from_land_pattern footprint_primitives foreground+generate_body height_check impedance import_kicad import_part+import_svg install install_extension install_library⋯ 6 unchanged lines ⋯ layers layers_in_use length_tune+library_views license license_status list_commands⋯ 15 unchanged lines ⋯ part_parameters pcb_report pin_pad_check+place_decoupling place_on_pcb place_on_sch place_vias placement_check+plan_fanout+plan_route plate_vias pour_state progress+project_create+project_info+project_rename+project_snapshot read_board+read_footprint read_rules read_schematic read_sheet_file read_symbol readiness remove_footprint+remove_graphics remove_items+remove_parts remove_pours remove_symbol rename_footprint rename_symbol+render_board_3d+render_board_layers+render_schematic replicate_layout repour return_path⋯ 10 unchanged lines ⋯ run_outjob run_script save+sch_bus+sch_bus_entry sch_connect+sch_connect_pins+sch_connect_to_bus+sch_create+sch_delete_wire sch_edit+sch_edit_label+sch_fix_connectivity+sch_hierarchy+sch_items+sch_junction+sch_move_region+sch_page+sch_pins sch_place_part+sch_reset_fields+sch_sheet_pins sch_sheet_symbol+sch_split_wire sch_swap_part+sch_text+sch_trace_point+sch_update_from_library sch_zoom schematic_data schematic_review⋯ 1 unchanged line ⋯ selection selftest_script_error set_design_rules+set_layer_count set_layer_visibility set_models set_outline⋯ 33 unchanged lines ⋯ workspace_search xcopy_footprint zoom_to+@@ -0,0 +1,645 @@+//! The EDA Bridge Standard's argument shapes for the verbs this bridge proposed to it (standard 0.2.0+//! and 0.3.0: the schematic wiring verbs, the extra exports, the board edits, projects and library+//! footprints). Each verb keeps its KiCad arguments; this module also accepts the standard's, written+//! in `tools/proposed/kicad-lane.json` of adom/eda-bridge-standard:+//!+//! - `sheet` and `project` (paths) for `filePath` and `projectPath`, `id`/`ids` for KiCad's uuids,+//! unit-named numbers (`toleranceMm`, `gapMm` ...) for KiCad's bare ones;+//! - a list of edits (`pairs`, `entries`, `edits`, `texts`, junction `remove`) runs as one+//! `kicad_sch_batch`: one read, one atomic write, all or nothing;+//! - `frame:"standard"` takes coordinates with y up (KiCad's y negated). On a sheet verb the reply's+//! points come back in the same frame; a board verb's reply stays in KiCad's frame, as for the+//! other board edit verbs, and `kicad_routing_state {model:"standard"}` reads the result.+//!+//! Replies gain `id` beside every `uuid` (and `ids` beside `uuids`).++use serde_json::{json, Map, Value};++use crate::util::fail;++/// A call rewritten into KiCad's own shape, and what the reply needs on the way back.+pub struct Shaped {+ pub command: String,+ pub args: Value,+ /// the sheet verb's points come back with y up+ flip_reply: bool,+ /// the call ran as a kicad_sch_batch of this many edits: merge their results+ batched: bool,+ /// the board verb took the standard frame (its reply stays in KiCad's)+ board_frame: bool,+}++/// The verbs whose standard shape this module knows: the bridge's own name, after aliases.+const SHEET_VERBS: &[&str] = &[+ "kicad_sch_connect_pins", "kicad_sch_connect_to_bus", "kicad_sch_junction", "kicad_sch_bus", "kicad_sch_bus_entry",+ "kicad_sch_split_wire", "kicad_sch_delete_wire", "kicad_sch_edit_label", "kicad_sch_move_region", "kicad_sch_text",+ "kicad_sch_page", "kicad_sch_items", "kicad_sch_pins", "kicad_sch_trace_point", "kicad_sch_hierarchy",+ "kicad_sch_sheet_pins", "kicad_sch_reset_fields", "kicad_sch_update_from_library", "kicad_sch_fix_connectivity",+ "kicad_render_schematic", "kicad_sch_create",+];+const BOARD_FRAME_VERBS: &[&str] = &["kicad_align_footprints", "kicad_import_svg", "kicad_copy_routing"];+const OTHER_VERBS: &[&str] = &[+ "kicad_export_odb", "kicad_export_ipc2581", "kicad_export_ipc356", "kicad_export_gencad", "kicad_export_dxf",+ "kicad_render_board", "kicad_estimate_cost", "kicad_delete_footprint", "kicad_edit_footprint", "kicad_set_layer_count",+ "kicad_delete_graphics", "kicad_cross_probe", "kicad_check_clearance", "kicad_place_decoupling", "kicad_plan_fanout",+ "kicad_project_create", "kicad_project_info", "kicad_project_rename", "kicad_project_snapshot", "kicad_fp_info",+ "kicad_fp_edit", "kicad_export_part", "kicad_make_part_project",+];++/// What describe says on each verb this module shapes.+pub const SHAPE_NOTE: &str = "Also takes the EDA Bridge Standard's arguments (adom/eda-bridge-standard, tools/proposed/kicad-lane.json): sheet and project paths, id/ids for uuids, mm-named numbers, a list of edits as one write, and frame:\"standard\" for y-up coordinates (a sheet verb's reply then comes back y up too).";++/// True when the verb (KiCad's own name) takes the standard's shape here.+pub fn accepts(command: &str) -> bool {+ SHEET_VERBS.contains(&command) || BOARD_FRAME_VERBS.contains(&command) || OTHER_VERBS.contains(&command)+}++/// Argument renames per verb: (the standard's name, KiCad's). Applied only when KiCad's is absent.+fn renames(command: &str) -> &'static [(&'static str, &'static str)] {+ match command {+ "kicad_sch_split_wire" => &[("id", "uuid")],+ "kicad_sch_delete_wire" => &[("ids", "uuids"), ("id", "uuid")],+ "kicad_sch_page" | "kicad_sch_create" => &[("widthMm", "width"), ("heightMm", "height")],+ "kicad_sch_trace_point" | "kicad_sch_fix_connectivity" => &[("toleranceMm", "tolerance")],+ "kicad_sch_sheet_pins" => &[("kind", "shape")],+ "kicad_sch_update_from_library" => &[("symbols", "libIds"), ("library", "libraryPath")],+ "kicad_export_dxf" => &[("contours", "useContours"), ("titleBlock", "includeBorderTitle"), ("excludeDesignators", "excludeRefdes"), ("excludeValues", "excludeValue")],+ "kicad_align_footprints" | "kicad_place_decoupling" => &[("gapMm", "gap")],+ "kicad_delete_graphics" => &[("ids", "uuids"), ("kinds", "type")],+ "kicad_import_svg" => &[("widthMm", "width")],+ "kicad_cross_probe" => &[("ref", "reference"), ("symbolId", "symbolUuid"), ("schematic", "schematicPath")],+ "kicad_check_clearance" => &[("thresholdMm", "threshold")],+ "kicad_plan_fanout" => &[("viaMm", "viaSize"), ("viaDrillMm", "viaDrill"), ("widthMm", "width"), ("clearanceMm", "clearance"), ("pitchMm", "pitch")],+ "kicad_export_part" | "kicad_make_part_project" => &[("part", "partName"), ("footprint", "footprintName")],+ _ => &[],+ }+}++fn path_like(v: &Value) -> bool {+ v.as_str().map(|s| s.contains('/') || s.contains('\\') || s.to_ascii_lowercase().ends_with(".kicad_sch") || s.to_ascii_lowercase().ends_with(".kicad_pro")).unwrap_or(false)+}++fn rename(o: &mut Map<String, Value>, from: &str, to: &str) {+ if !o.contains_key(to) {+ if let Some(v) = o.remove(from) {+ o.insert(to.into(), v);+ }+ }+}++fn neg(v: &Value) -> Value {+ json!(-v.as_f64().unwrap_or(0.0) + 0.0)+}++/// [x, y] with y negated; anything else left alone (a "U1.3" pin name, a uuid).+fn flip_pt(v: &mut Value) {+ if let Value::Array(a) = v {+ if a.len() == 2 && a[0].is_number() && a[1].is_number() {+ a[1] = neg(&a[1]);+ }+ }+}++/// A box [x1, y1, x2, y2] in the other frame: y negated, so the two y's swap to stay low-high.+fn flip_box(v: &mut Value) {+ if let Value::Array(a) = v {+ if a.len() == 4 && a.iter().all(Value::is_number) {+ let (y1, y2) = (a[1].clone(), a[3].clone());+ a[1] = neg(&y2);+ a[3] = neg(&y1);+ }+ }+}++/// A sheet verb's arguments (or one of its list items) from y up to KiCad's y down.+fn flip_sheet_args(v: &mut Value) {+ let Some(o) = v.as_object_mut() else { return };+ for k in ["at", "to", "from", "by"] {+ if let Some(p) = o.get_mut(k) {+ flip_pt(p);+ }+ }+ for k in ["points", "via"] {+ if let Some(Value::Array(a)) = o.get_mut(k) {+ a.iter_mut().for_each(flip_pt);+ }+ }+ if let Some(b) = o.get_mut("box") {+ flip_box(b);+ }+ for k in ["pairs", "entries", "edits", "texts", "remove"] {+ if let Some(Value::Array(a)) = o.get_mut(k) {+ for it in a.iter_mut() {+ if it.is_object() {+ flip_sheet_args(it);+ } else {+ flip_pt(it);+ }+ }+ }+ }+}++/// A sheet verb's reply from KiCad's y down to y up: every point under a point key, every list+/// of points under a list key, every box. Pin names and uuids are not numbers and stay.+fn flip_reply(v: &mut Value) {+ const PT: &[&str] = &["at", "end", "from", "to", "start", "nearest", "mid", "anchor", "position"];+ const LIST: &[&str] = &["points", "junctionsAdded", "junctionsPruned", "junctionsRemoved", "notNeeded", "unresolved", "skipped", "added", "removed", "dots"];+ const BOX: &[&str] = &["box", "changedRegionMm", "bbox", "bounds"];+ match v {+ Value::Object(o) => {+ for (k, x) in o.iter_mut() {+ if PT.contains(&k.as_str()) {+ flip_pt(x);+ } else if LIST.contains(&k.as_str()) {+ if let Value::Array(a) = x {+ for it in a.iter_mut() {+ if it.is_array() {+ flip_pt(it);+ }+ }+ }+ } else if BOX.contains(&k.as_str()) {+ flip_box(x);+ }+ flip_reply(x);+ }+ }+ Value::Array(a) => a.iter_mut().for_each(flip_reply),+ _ => {}+ }+}++/// `id` beside every `uuid`, `ids` beside every `uuids`, all the way down.+fn add_ids(v: &mut Value) {+ match v {+ Value::Object(o) => {+ if let Some(u) = o.get("uuid").cloned() {+ o.entry("id").or_insert(u);+ }+ if let Some(u) = o.get("uuids").cloned() {+ o.entry("ids").or_insert(u);+ }+ o.values_mut().for_each(add_ids);+ }+ Value::Array(a) => a.iter_mut().for_each(add_ids),+ _ => {}+ }+}++/// The batch ops for a standard list form, or None when the call is not one.+fn batch_ops(command: &str, o: &Map<String, Value>) -> Option<Vec<Value>> {+ let objects = |k: &str| o.get(k).and_then(Value::as_array).filter(|a| !a.is_empty() && a.iter().all(Value::is_object)).cloned();+ let with_op = |items: Vec<Value>, op: &str, f: &dyn Fn(&mut Map<String, Value>)| -> Vec<Value> {+ items+ .into_iter()+ .map(|mut it| {+ if let Some(m) = it.as_object_mut() {+ f(m);+ m.insert("op".into(), json!(op));+ }+ it+ })+ .collect()+ };+ match command {+ "kicad_sch_connect_pins" => objects("pairs").map(|a| with_op(a, "connect_pins", &|_| {})),+ "kicad_sch_bus_entry" => objects("entries").map(|a| with_op(a, "bus_entry", &|_| {})),+ "kicad_sch_edit_label" => objects("edits").map(|a| with_op(a, "edit_label", &|m| rename(m, "id", "uuid"))),+ "kicad_sch_text" => objects("texts").map(|a| with_op(a, "text", &|m| rename(m, "sizeMm", "size"))),+ "kicad_sch_junction" => {+ let remove = o.get("remove").and_then(Value::as_array)?;+ let mut ops = Vec::new();+ if let Some(p) = o.get("points").filter(|p| p.as_array().map(|a| !a.is_empty()).unwrap_or(false)) {+ ops.push(json!({"op": "junction", "points": p}));+ }+ let ids: Vec<&Value> = remove.iter().filter(|r| r.is_string()).collect();+ let pts: Vec<&Value> = remove.iter().filter(|r| r.is_array()).collect();+ if !ids.is_empty() || !pts.is_empty() {+ ops.push(json!({"op": "junction", "remove": true, "uuids": ids, "points": pts}));+ }+ Some(ops)+ }+ _ => None,+ }+}++/// `kicad_edit_footprint` is the board verb (edit placed parts: the standard's edit_parts); the+/// standard's `edit_footprint` edits a LIBRARY footprint, which is kicad_fp_edit. A call with+/// library arguments and no part names goes there.+fn is_library_footprint_edit(o: &Map<String, Value>) -> bool {+ let lib = o.contains_key("libraryPath") || o.contains_key("libId") || o.get("filePath").and_then(Value::as_str).map(|p| p.to_ascii_lowercase().ends_with(".kicad_mod")).unwrap_or(false);+ lib && !o.contains_key("ref") && !o.contains_key("refs") && !o.contains_key("edits")+}++/// `edits: [{ref, ...}]` for edit_parts, in KiCad's keys.+fn edit_parts_items(o: &mut Map<String, Value>) {+ if let Some(Value::Array(edits)) = o.get_mut("edits") {+ for e in edits.iter_mut() {+ let Some(m) = e.as_object_mut() else { continue };+ rename(m, "newRef", "newReference");+ rename(m, "excludeFromPos", "excludeFromPosFiles");+ if let Some(mount) = m.remove("mount") {+ let t = match mount.as_str() {+ Some("smd") => "smd",+ Some("through_hole") => "through_hole",+ _ => "unspecified",+ };+ let attrs = m.entry("attributes").or_insert(json!({}));+ if let Some(a) = attrs.as_object_mut() {+ a.insert("type".into(), json!(t));+ }+ }+ }+ }+}++/// Library footprint edits in KiCad's keys: pad sizes and drills, graphics widths, the mount type.+fn fp_edit_items(o: &mut Map<String, Value>) {+ if let Some(Value::Array(pads)) = o.get_mut("pads") {+ for p in pads.iter_mut() {+ if let Some(s) = p.get_mut("set").and_then(Value::as_object_mut) {+ rename(s, "sizeMm", "size");+ rename(s, "drillMm", "drill");+ }+ }+ }+ if let Some(Value::Array(pads)) = o.get_mut("addPads") {+ for p in pads.iter_mut().filter_map(Value::as_object_mut) {+ rename(p, "sizeMm", "size");+ rename(p, "drillMm", "drill");+ }+ }+ match o.get_mut("graphics") {+ Some(Value::Array(g)) => g.iter_mut().filter_map(Value::as_object_mut).for_each(|m| rename(m, "widthMm", "width")),+ Some(Value::Object(m)) => rename(m, "widthMm", "width"),+ _ => {}+ }+ if let Some(a) = o.get_mut("metadata").and_then(|m| m.get_mut("attributes")).and_then(Value::as_object_mut) {+ rename(a, "mount", "type");+ rename(a, "excludeFromPos", "excludeFromPosFiles");+ }+}++/// 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>> {+ 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) {+ command = "kicad_fp_edit".into();+ }+ let c = command.as_str();+ let sheet = SHEET_VERBS.contains(&c);+ let board_frame = BOARD_FRAME_VERBS.contains(&c);+ if !sheet && !board_frame && !OTHER_VERBS.contains(&c) {+ return None;+ }+ let mut a = args.clone();+ let o = a.as_object_mut()?;+ let frame = match o.remove("frame").as_ref().map(|f| f.as_str()) {+ None | Some(Some("kicad")) => false,+ Some(Some("standard")) | Some(Some("yup")) => true,+ Some(other) => {+ return Some(Err(fail("bad_args", format!("frame must be \"standard\" (y up) or \"kicad\" (y down), not {}", other.map(|s| format!("\"{s}\"")).unwrap_or_else(|| "that".into())), "Leave frame out for KiCad's own y-down coordinates.")));+ }+ };++ // sheet and project: the standard's paths. Where KiCad's own `sheet` is a sheet NAME+ // (sch_sheet_pins, render_schematic), only a path moves, and sheetSymbol takes its place.+ 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);+ }+ if c == "kicad_sch_sheet_pins" {+ rename(o, "sheetSymbol", "sheet");+ }+ if o.get("project").map(path_like).unwrap_or(false) && !o.contains_key("projectPath") {+ let p = o.remove("project").unwrap_or(Value::Null);+ o.insert("projectPath".into(), p);+ }+ for (from, to) in renames(c) {+ rename(o, from, to);+ }++ match c {+ "kicad_sch_items" => {+ if let Some(Value::Array(k)) = o.get_mut("kinds") {+ for x in k.iter_mut() {+ if x.as_str() == Some("powerPorts") {+ *x = json!("powerSymbols");+ }+ }+ }+ }+ "kicad_sch_reset_fields" => {+ if let Some(Value::Array(f)) = o.get_mut("fields") {+ for x in f.iter_mut() {+ match x.as_str().map(str::to_ascii_lowercase).as_deref() {+ Some("designator") | Some("reference") => *x = json!("Reference"),+ Some("value") => *x = json!("Value"),+ _ => {}+ }+ }+ }+ }+ "kicad_sch_create" => {+ if let Some(or) = o.remove("orientation") {+ o.entry("portrait").or_insert(json!(or.as_str() == Some("portrait")));+ }+ }+ "kicad_export_ipc2581" => {+ if let Some(Value::Object(b)) = o.remove("bomFields") {+ for (from, to) in [("mpn", "bomColMfgPn"), ("manufacturer", "bomColMfg"), ("distributorPn", "bomColDistPn"), ("distributor", "bomColDist")] {+ if let Some(v) = b.get(from) {+ o.entry(to).or_insert(v.clone());+ }+ }+ }+ }+ "kicad_edit_footprint" => edit_parts_items(o),+ "kicad_fp_edit" => fp_edit_items(o),+ "kicad_make_part_project" if called.ends_with("create_part_project") => {+ // the standard never overwrites without being told; kicad_make_part_project regenerates in place+ if o.get("overwrite").and_then(Value::as_bool) != Some(true) {+ o.insert("_refuseExisting".into(), json!(true));+ }+ }+ _ => {}+ }++ if frame && sheet {+ flip_sheet_args(&mut a);+ }+ let o = a.as_object_mut()?;+ if frame && board_frame {+ match c {+ "kicad_align_footprints" => {+ let ymode = matches!(o.get("mode").and_then(Value::as_str), Some("top") | Some("bottom") | Some("centerY"));+ if ymode {+ if let Some(t) = o.get("to").cloned() {+ o.insert("to".into(), neg(&t));+ }+ }+ }+ "kicad_import_svg" => {+ if let Some(p) = o.get_mut("at") {+ flip_pt(p);+ }+ }+ "kicad_copy_routing" => {+ if let Some(b) = o.get_mut("box") {+ flip_box(b);+ }+ if let Some(p) = o.get_mut("offset") {+ flip_pt(p);+ }+ }+ _ => {}+ }+ }+ // board shapes KiCad spells differently+ match c {+ "kicad_import_svg" => {+ if let Some(Value::Array(p)) = o.get("at").cloned() {+ if p.len() == 2 {+ o.entry("x").or_insert(p[0].clone());+ o.entry("y").or_insert(p[1].clone());+ o.remove("at");+ }+ }+ }+ "kicad_copy_routing" => {+ if !o.contains_key("source") {+ if let Some(Value::Array(b)) = o.remove("box") {+ if b.len() == 4 {+ let f = |i: usize| b[i].as_f64().unwrap_or(0.0);+ o.insert("source".into(), json!({"minX": f(0).min(f(2)), "minY": f(1).min(f(3)), "maxX": f(0).max(f(2)), "maxY": f(1).max(f(3))}));+ }+ }+ }+ }+ _ => {}+ }++ // a list of edits: one kicad_sch_batch, one write+ let mut batched = false;+ if sheet {+ if let Some(ops) = batch_ops(c, o) {+ let mut b = Map::new();+ for k in ["filePath", "dryRun", "backup"] {+ if let Some(v) = o.get(k) {+ b.insert(k.into(), v.clone());+ }+ }+ b.insert("ops".into(), Value::Array(ops));+ a = Value::Object(b);+ command = "kicad_sch_batch".into();+ batched = true;+ }+ }+ Some(Ok(Shaped { command, args: a, flip_reply: frame && sheet, batched, board_frame: frame && board_frame }))+}++/// Merge each batched edit's result into the reply's top level: lists join, counts add up.+fn merge_batch(out: &mut Value) {+ let Some(results) = out.get("results").and_then(Value::as_array).cloned() else { return };+ let mut merged = Map::new();+ for r in &results {+ let Some(m) = r.get("result").and_then(Value::as_object) else { continue };+ for (k, v) in m {+ match (merged.get_mut(k), v) {+ (Some(Value::Array(acc)), Value::Array(more)) => acc.extend(more.iter().cloned()),+ (Some(acc), Value::Number(n)) if acc.is_number() => *acc = json!(acc.as_f64().unwrap_or(0.0) + n.as_f64().unwrap_or(0.0)),+ (Some(acc), _) => *acc = v.clone(),+ (None, _) => {+ merged.insert(k.clone(), v.clone());+ }+ }+ }+ }+ if let Some(o) = out.as_object_mut() {+ for (k, v) in merged {+ o.entry(k).or_insert(v);+ }+ o.insert("batchedAs".into(), json!("kicad_sch_batch"));+ }+}++/// The reply in the standard's terms (ids, the frame, the batch merged, the 3D image's path).+pub fn from_native(s: &Shaped, out: &mut Value) {+ if s.batched {+ merge_batch(out);+ }+ match s.command.as_str() {+ "kicad_render_board" => {+ if let Some(p) = out.pointer("/data/output_path").cloned() {+ out["image"] = p;+ }+ }+ "kicad_make_part_project" => {+ for (std, own) in [("project", "projectFile"), ("schematic", "schematicFile"), ("board", "boardFile")] {+ if let Some(v) = out.get(own).cloned() {+ out[std] = v;+ }+ }+ }+ "kicad_sch_pins" => {+ // the standard answers one flat list, each pin naming its part+ let flat: Vec<Value> = out.get("symbols").and_then(Value::as_array).map(|syms| {+ syms.iter().flat_map(|s| {+ let r = s.get("reference").or_else(|| s.get("ref")).cloned().unwrap_or(Value::Null);+ s.get("pins").and_then(Value::as_array).cloned().unwrap_or_default().into_iter().map(move |mut p| {+ p["ref"] = r.clone();+ p+ })+ }).collect()+ }).unwrap_or_default();+ if out.get("pins").is_none() {+ out["pins"] = json!(flat);+ }+ }+ "kicad_sch_items" => {+ if let Some(p) = out.get("powerSymbols").cloned() {+ out["powerPorts"] = p;+ }+ }+ _ => {}+ }+ add_ids(out);+ if s.flip_reply {+ flip_reply(out);+ out["frame"] = json!("standard");+ }+ if s.board_frame {+ out["inputFrame"] = json!("standard");+ out["_frameNote"] = json!("Your coordinates were taken in the standard's y-up frame; positions in this reply are KiCad's own (y down). kicad_routing_state {model:\"standard\"} reads the result back in the standard's frame.");+ }+}++#[cfg(test)]+mod tests {+ //! The standard's shapes through the real dispatch path, on temp copies of the KiCad 10 fixtures.+ use super::*;+ use crate::util::{norm, State};+ use std::path::{Path, PathBuf};++ fn fixtures() -> PathBuf {+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures")+ }++ fn temp_copy(tag: &str, rel: &str) -> PathBuf {+ let d = std::env::temp_dir().join(format!("kicad-bridge-std-{tag}-{}-{}", std::process::id(), kicad_core::schematic::new_uuid()));+ std::fs::create_dir_all(&d).unwrap();+ let src = fixtures().join(rel);+ let p = d.join(src.file_name().unwrap());+ std::fs::copy(&src, &p).unwrap();+ p+ }++ fn call(verb: &str, args: Value) -> Value {+ let caller = crate::Caller { thread: "std-shapes-test".into(), container: String::new(), reason: String::new() };+ crate::verbs::dispatch(&mut State::new(), verb, &args, &caller)+ }++ fn ok(verb: &str, args: Value) -> Value {+ let v = call(verb, args);+ assert_eq!(v["success"], true, "{verb}: {v}");+ v+ }++ #[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();+ let args = json!({"sheet": norm(&p), "pairs": [{"from": "R1.2", "to": "C1.1"}, {"from": "R2.2", "to": "C1.2", "first": "v"}]});+ let mut dry = args.clone();+ dry["dryRun"] = json!(true);+ let v = ok("sch_connect_pins", dry);+ assert_eq!(v["batchedAs"], "kicad_sch_batch", "{v}");+ assert_eq!(std::fs::read_to_string(&p).unwrap(), before, "a dry run writes nothing");+ let v = ok("kicad_sch_connect_pins", args);+ assert_eq!(v["results"].as_array().unwrap().len(), 2, "{v}");+ assert!(v["routes"].as_array().map(|r| r.len() >= 2).unwrap_or(false), "routes merged from both edits: {v}");+ assert_ne!(std::fs::read_to_string(&p).unwrap(), before);+ }++ #[test]+ fn texts_and_items_round_trip_in_the_standard_frame() {+ let p = temp_copy("frame", "sch-edit/edit10.kicad_sch");+ ok("sch_text", json!({"sheet": norm(&p), "frame": "standard", "texts": [{"text": "up here", "at": [50.8, -30.48], "sizeMm": 2.54}]}));+ // KiCad's own frame has the text at y = +30.48+ let k = ok("sch_items", json!({"sheet": norm(&p), "kinds": ["texts"]}));+ let t = k["texts"].as_array().unwrap().iter().find(|t| t["text"] == "up here").cloned().unwrap_or_else(|| panic!("{k}"));+ assert_eq!(t["at"][1].as_f64(), Some(30.48), "{t}");+ assert!(t.get("id").is_some(), "id beside uuid: {t}");+ // the standard frame gives back what went in+ let s = ok("sch_items", json!({"sheet": norm(&p), "kinds": ["texts"], "frame": "standard"}));+ assert_eq!(s["frame"], "standard");+ let t = s["texts"].as_array().unwrap().iter().find(|t| t["text"] == "up here").cloned().unwrap();+ assert_eq!(t["at"][1].as_f64(), Some(-30.48), "{t}");+ let pins = ok("sch_pins", json!({"sheet": norm(&p), "references": ["R1"], "frame": "standard"}));+ assert!(pins["pins"].as_array().map(|a| !a.is_empty() && a.iter().all(|x| x["ref"] == "R1")).unwrap_or(false), "{pins}");+ }++ #[test]+ fn junctions_add_and_remove_in_one_call() {+ let p = temp_copy("junction", "sch-edit/edit10.kicad_sch");+ ok("sch_junction", json!({"sheet": norm(&p), "points": [[110.49, 60.96], [120.65, 60.96]]}));+ let items = ok("sch_items", json!({"sheet": norm(&p), "kinds": ["junctions"]}));+ let id = items["junctions"].as_array().unwrap().iter().find(|j| j["at"] == json!([110.49, 60.96])).and_then(|j| j["id"].as_str()).map(String::from).unwrap_or_else(|| panic!("{items}"));+ let v = ok("sch_junction", json!({"sheet": norm(&p), "points": [[130.81, 60.96]], "remove": [id, [120.65, 60.96]]}));+ assert_eq!(v["batchedAs"], "kicad_sch_batch");+ let after = ok("sch_items", json!({"sheet": norm(&p), "kinds": ["junctions"]}));+ let at: Vec<Value> = after["junctions"].as_array().unwrap().iter().map(|j| j["at"].clone()).collect();+ assert!(at.contains(&json!([130.81, 60.96])), "{at:?}");+ assert!(!at.contains(&json!([110.49, 60.96])) && !at.contains(&json!([120.65, 60.96])), "{at:?}");+ }++ #[test]+ fn edit_parts_gives_each_part_its_own_edit() {+ let p = temp_copy("parts", "konnect-route/route-fixture.kicad_pcb");+ let v = ok("edit_parts", json!({"filePath": norm(&p), "source": "file", "edits": [{"ref": "R1", "value": "4k7"}, {"ref": "C1", "value": "10u", "dnp": true}]}));+ let text = std::fs::read_to_string(&p).unwrap();+ assert!(text.contains("\"4k7\"") && text.contains("\"10u\""), "{v}");+ assert_eq!(v["edited"].as_array().map(|e| e.len()), Some(2), "{v}");+ let e = call("edit_parts", json!({"filePath": norm(&p), "source": "file", "edits": [{"ref": "R1", "value": "1k"}, {"ref": "r1", "dnp": true}]}));+ assert_eq!(e["success"], false, "a part edited twice is refused: {e}");+ }++ #[test]+ fn create_part_project_never_overwrites_unasked() {+ let s = to_native("kicad_create_part_project", "kicad_make_part_project", &json!({"part": "R_0603"})).unwrap().unwrap();+ assert_eq!(s.args["partName"], "R_0603");+ assert_eq!(s.args["_refuseExisting"], true);+ let s = to_native("kicad_create_part_project", "kicad_make_part_project", &json!({"part": "R_0603", "overwrite": true})).unwrap().unwrap();+ assert!(s.args.get("_refuseExisting").is_none());+ let s = to_native("kicad_make_part_project", "kicad_make_part_project", &json!({"partName": "R_0603"})).unwrap().unwrap();+ assert!(s.args.get("_refuseExisting").is_none(), "KiCad's own name keeps regenerating in place");+ }++ #[test]+ fn board_shapes_in_kicads_terms() {+ let s = to_native("kicad_import_svg", "kicad_import_svg", &json!({"filePath": "b.kicad_pcb", "at": [120, -95], "widthMm": 10, "frame": "standard"})).unwrap().unwrap();+ assert_eq!((s.args["x"].as_f64(), s.args["y"].as_f64(), s.args["width"].as_f64()), (Some(120.0), Some(95.0), Some(10.0)), "{}", s.args);+ assert!(s.args.get("frame").is_none() && s.args.get("at").is_none());+ let s = to_native("kicad_copy_routing", "kicad_copy_routing", &json!({"box": [10, -25, 30, -10], "offset": [25, 5], "frame": "standard"})).unwrap().unwrap();+ assert_eq!(s.args["source"], json!({"minX": 10.0, "minY": 10.0, "maxX": 30.0, "maxY": 25.0}), "{}", s.args);+ assert_eq!(s.args["offset"], json!([25, -5.0]));+ let s = to_native("kicad_align_parts", "kicad_align_footprints", &json!({"refs": ["C1", "C2"], "mode": "top", "to": -40, "frame": "standard"})).unwrap().unwrap();+ assert_eq!(s.args["to"].as_f64(), Some(40.0));+ let s = to_native("kicad_edit_footprint", "kicad_edit_footprint", &json!({"libraryPath": "C:/lib/Mine.pretty", "name": "SOT-23", "pads": [{"number": "1", "set": {"sizeMm": [1, 0.8]}}]})).unwrap().unwrap();+ assert_eq!(s.command, "kicad_fp_edit", "a library edit under the standard's name goes to fp_edit");+ assert_eq!(s.args["pads"][0]["set"]["size"], json!([1, 0.8]));+ let s = to_native("kicad_edit_footprint", "kicad_edit_footprint", &json!({"refs": ["R7"], "value": "4k7"})).unwrap().unwrap();+ assert_eq!(s.command, "kicad_edit_footprint", "KiCad's own board edit is unchanged");+ let s = to_native("kicad_export_ipc2581", "kicad_export_ipc2581", &json!({"bomFields": {"mpn": "MPN", "distributor": "LCSC"}})).unwrap().unwrap();+ assert_eq!((s.args["bomColMfgPn"].as_str(), s.args["bomColDist"].as_str()), (Some("MPN"), Some("LCSC")));+ let s = to_native("kicad_sch_sheet_pins", "kicad_sch_sheet_pins", &json!({"sheet": "C:/p/main.kicad_sch", "sheetSymbol": "Power", "kind": "input"})).unwrap().unwrap();+ assert_eq!((s.args["filePath"].as_str(), s.args["sheet"].as_str(), s.args["shape"].as_str()), (Some("C:/p/main.kicad_sch"), Some("Power"), Some("input")));+ let e = to_native("kicad_sch_items", "kicad_sch_items", &json!({"frame": "upside"})).unwrap();+ assert!(e.is_err());+ assert!(to_native("kicad_route_net", "kicad_route_net", &json!({})).is_none(), "verbs outside the 47 are left alone");+ }+}+@@ -1,606 +1,620 @@⋯ 11 unchanged lines ⋯ use kicad_platform::native; use crate::Caller; -pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Caller) -> Value {+pub fn dispatch(state: &mut State, command_in: &str, args: &Value, caller: &Caller) -> Value { // ab forwards the bare name (it strips the `kicad_` prefix it routed on); the CLI and // hand callers send the full one. Accept both, like the Python bridge. let mut full: String = if command_in.starts_with("kicad_") { command_in.to_string() } else { format!("kicad_{command_in}") }; if full == "kicad_export_gerbers" { full = "kicad_export_gerber".into(); // alias kept from the Python bridge }+ let called = full.clone(); // the EDA Bridge Standard's name for a verb this bridge has under its own (catalog::ALIASES) if let Some(t) = catalog::alias_target(&full) { full = t.to_string(); }- let command = full.as_str();+ // the standard's argument shapes for the verbs this bridge proposed to it (std_shapes.rs)+ match crate::std_shapes::to_native(&called, &full, args) {+ None => run(state, &full, args, caller),+ Some(Err(refusal)) => refusal,+ Some(Ok(shaped)) => {+ let mut out = run(state, &shaped.command, &shaped.args, caller);+ crate::std_shapes::from_native(&shaped, &mut out);+ out+ }+ }+}++fn run(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value { let Some(verb) = catalog::find(command) else { let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect(); return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));⋯ 574 unchanged lines ⋯ _ => catalog.into(), } }+@@ -1,487 +1,501 @@⋯ 386 unchanged lines ⋯ if quantities.is_empty() { return fail("bad_arg", "quantities must be positive integers", "e.g. [5, 10, 100]"); }+ if let Some(f) = args.get("fab").and_then(Value::as_str) {+ if !f.eq_ignore_ascii_case("jlcpcb") {+ return fail("not_supported", format!("No price list for {f}: the estimate knows JLCPCB-style rates only"), "Leave fab out (or pass jlcpcb); for another fab, send it the manufacturing files for a quote.");+ }+ } let assembly = args.get("assembly").and_then(Value::as_bool).unwrap_or(numbers["totalParts"].as_u64().unwrap_or(0) > 0); let table = fab::cost_table(&numbers, &quantities, assembly, args.get("basicParts").and_then(Value::as_u64)); let mut warnings = Vec::new();⋯ 5 unchanged lines ⋯ "output": format!("{} x {} mm, {} layer(s), {} SMD + {} THT parts ({} unique)", numbers["widthMm"], numbers["heightMm"], numbers["layers"], numbers["smdParts"], numbers["thtParts"], numbers["uniqueParts"]), "path": norm(&board), "board": numbers,+ "prices": table["rows"].as_array().map(|rows| rows.iter().map(|r| json!({+ "quantity": r["quantity"], "board": r["pcbUsd"], "assembly": r.get("assemblyUsd").cloned().unwrap_or(Value::Null),+ "total": r["totalUsdExcludingParts"], "currency": "USD"})).collect::<Vec<_>>()).unwrap_or_default(),+ "assumptions": std::iter::once(json!("Built-in JLCPCB-style rates, never checked against the fab's price page: not a current price."))+ .chain(table["assumptions"].as_array().cloned().unwrap_or_default()).collect::<Vec<_>>(),+ // the EDA Bridge Standard asks every outside number to name its source and the date it was read+ "source": {"name": "JLCPCB-style rates built into kicad-bridge (approximate, not a live price list)", "url": "https://jlcpcb.com/quote",+ "read": Value::Null, "readNote": "The rates were written into the bridge by hand; the date they were last checked against jlcpcb.com is not recorded, so treat them as dated."}, "estimate": table, "warnings": warnings,- "_hint": "This is a rough estimate with the assumptions listed, not a quote. For a real one: kicad_export_fab_package {fab:\"jlcpcb\"}, then adom-instapcb-quote on the zip (it prices the BOM too). adom-parts-search prices single parts.",+ "_hint": "This is a rough estimate from built-in rates, never checked against the fab's price page, with the assumptions listed: not a quote and not a current price. For a real one: kicad_export_fab_package {fab:\"jlcpcb\"}, then adom-instapcb-quote on the zip (it prices the BOM too). adom-parts-search prices single parts.", }) } ⋯ 78 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -1,1505 +1,1530 @@⋯ 949 unchanged lines ⋯ } /// `kicad_edit_footprint` (file form): value, reference, fields, attributes and lock.-pub fn edit_footprints(text: &str, args: &Value) -> RResult<Change> {- let root = parse_root(text)?;+/// The edit each footprint gets: `edits: [{ref, ...}]` gives every part its own (the EDA Bridge+/// Standard's edit_parts); otherwise `ref`/`refs` share the call's own arguments.+pub fn edits_by_ref(args: &Value) -> RResult<Vec<(String, Value)>> {+ if let Some(edits) = args.get("edits") {+ let list = edits.as_array().filter(|a| !a.is_empty()).ok_or_else(|| err("invalid_args", "edits is a list of {ref, ...the fields to change}"))?;+ let mut out: Vec<(String, Value)> = Vec::new();+ for (i, e) in list.iter().enumerate() {+ let r = e.get("ref").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).ok_or_else(|| err("missing_ref", format!("edits[{i}] has no ref")))?;+ if out.iter().any(|(x, _)| x.eq_ignore_ascii_case(r)) {+ return Err(err("invalid_refs", format!("{r} is edited twice; merge its edits into one entry")));+ }+ out.push((r.to_string(), e.clone()));+ }+ if out.len() > 256 {+ return Err(err("invalid_refs", "At most 256 refs per call"));+ }+ return Ok(out);+ } let refs = refs_arg(args)?;- let fps = find_refs(&root, &refs)?;- if fps.len() > 1 && args.get("newReference").or_else(|| args.get("reference")).is_some() {+ if refs.len() > 1 && args.get("newReference").or_else(|| args.get("reference")).is_some() { return Err(err("invalid_args", "newReference renames one footprint; pass a single ref")); }+ Ok(refs.into_iter().map(|r| (r, args.clone())).collect())+}++pub fn edit_footprints(text: &str, args: &Value) -> RResult<Change> {+ let root = parse_root(text)?;+ let edits = edits_by_ref(args)?;+ let refs: Vec<String> = edits.iter().map(|(r, _)| r.clone()).collect();+ let fps = find_refs(&root, &refs)?; let taken = existing_refs(&root); let mut rows = Vec::new(); let mut report = Vec::new(); for f in &fps {- let (b, changed) = edit_one(f.src(text), args, &taken)?;+ let own = edits.iter().find(|(r, _)| r.eq_ignore_ascii_case(&fp_ref(f))).map(|(_, a)| a).unwrap_or(args);+ let (b, changed) = edit_one(f.src(text), own, &taken)?; let nf = sxedit::parse(&b).map_err(|e| err("edit_failed", e))?; report.push(json!({"ref": fp_ref(f), "changed": changed, "before": footprint_info(f), "after": footprint_info(&nf)})); rows.push((*f, b)); } let mut c = replace_blocks(text, &root, &rows, format!("Adom edit {}", refs.join(", ")), false, json!({"edited": report}))?;- c.copper = args.get("attributes").is_some() || fp_text::ATTR_FLAGS.iter().any(|(k, _)| args.get(*k).is_some());+ c.copper = edits.iter().any(|(_, a)| a.get("attributes").is_some() || fp_text::ATTR_FLAGS.iter().any(|(k, _)| a.get(*k).is_some())); Ok(c) } ⋯ 529 unchanged lines ⋯ assert_eq!(mounting_hole_lib_id(3.0, false), None); } }+@@ -1,2757 +1,2763 @@⋯ 1773 unchanged lines ⋯ "" => documents_dir(&user_dir).join(&proj), d => PathBuf::from(d), };+ // called as the standard's create_part_project: never overwrite without overwrite:true+ if args.get("_refuseExisting") == Some(&json!(true)) && out_dir.join(format!("{proj}.kicad_pro")).exists() {+ return json!({"success": false, "errorCode": "project_exists", "error": format!("A project named {proj} already exists in {}", norm_path(&out_dir)),+ "_hint": "Pass overwrite:true to regenerate it in place, or another projectName."});+ } if let Err(e) = std::fs::create_dir_all(&out_dir) { return json!({"success": false, "error": format!("Failed to write project files: cannot create {}: {e}", norm_path(&out_dir))}); }⋯ 975 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&root); } }+@@ -1,719 +1,731 @@⋯ 415 unchanged lines ⋯ /// footprint proto (UpdateItems, the footprint keeps its UUID); field edits go through the /// delete-and-create path. pub fn edit_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {- let proto_only = args.as_object().map(|o| o.keys().all(|k| PROTO_KEYS.contains(&k.as_str()) || !is_change_key(k))).unwrap_or(false)- && args.get("attributes").and_then(Value::as_object).map(|a| !a.contains_key("type")).unwrap_or(true);+ let edits = board_edit::edits_by_ref(args)?;+ let proto_only = edits.iter().all(|(_, a)| {+ a.as_object().map(|o| o.keys().all(|k| PROTO_KEYS.contains(&k.as_str()) || !is_change_key(k))).unwrap_or(false)+ && a.get("attributes").and_then(Value::as_object).map(|x| !x.contains_key("type")).unwrap_or(true)+ }); if !proto_only { return commit_change(ctx, args, |text| board_edit::edit_footprints(text, args)); }⋯ 17 unchanged lines ⋯ return Err(RoutingError::new("footprint_not_found", "The editor no longer holds every footprint; read the board again").with("mutated", json!(false))); } for it in items.iter_mut() {- edit_footprint_proto(it, args)?;+ // each footprint takes its own edit (edits: [{ref, ...}]), else the call's arguments+ let own = match it {+ EditablePcbItem::Footprint(fp) => {+ let r = fp.proto().reference_field.as_ref().and_then(|f| f.text.as_ref()).and_then(|t| t.text.as_ref()).map(|t| t.text.clone()).unwrap_or_default();+ edits.iter().find(|(e, _)| e.eq_ignore_ascii_case(&r)).map(|(_, a)| a).unwrap_or(args)+ }+ _ => args,+ };+ edit_footprint_proto(it, own)?; } let n_items = items.len(); transaction(&s, &change.label, || {⋯ 269 unchanged lines ⋯ assert_eq!(edit_footprint_proto(&mut item, &json!({"newReference": "R9"})).unwrap_err().code, "ipc_error"); } }+@@ -1,1039 +1,1043 @@⋯ 142 unchanged lines ⋯ - **The standard has the job**: name the verb after it (`kicad_net_classes`, not `kicad_netclasses`), or keep your name and add `(standard name, your verb)` to `catalog::ALIASES`, so both names are served. - **It covers part of the job**: keep your name, list the verb in `kicad_only.txt`, and record it as partial in the- standard's `tools/equivalents.json`.-- **The standard has no such job**: list the verb in `rust/crates/kicad-bridge/src/kicad_only.txt`.+ standard's `tools/equivalents/kicad.json`.+- **The standard has no such job**: list the verb in `rust/crates/kicad-bridge/src/kicad_only.txt`. If other EDA+ tools could do the job, propose it to the standard (an issue titled `kicad-lane: propose ...` on its page; once+ the standard-lane thread agrees, the entry goes in `tools/proposed/kicad-lane.json`). When it lands, take the+ verb out of `kicad_only.txt` and give it the standard's arguments in `std_shapes.rs`. `catalog.rs`'s test `every_verb_has_a_standard_decision` fails until every verb is one of the three, and `bridge_json_declares_exactly_the_catalog` fails until `bridge.json` lists it. Each new verb also needs an entry in⋯ 886 unchanged lines ⋯ - **A headless VM hands new windows the foreground by itself.** With no console user, every background launch measures at z=0; that is the environment, not a focus steal (see the test skill's victim rule).+@@ -1,283 +1,285 @@⋯ 278 unchanged lines ⋯ page's pinned issue #3) or open an issue titled `kicad-lane: <what>`. Never run the standard's `generate.py` and push its outputs; the release script prints the coverage it expects instead. A verb that does a standard job under another name gets an alias in `catalog::ALIASES` first (or an entry in the kicad section of the standard's-`tools/equivalents.json`), or the coverage table does not count it. A job the standard lacks is proposed on an issue+`tools/equivalents/kicad.json`), or the coverage table does not count it. A job the standard lacks is proposed on an issue there, then added to `tools/proposed.json` once standard-lane agrees.++@@ -1,1364 +1,1453 @@⋯ 1292 unchanged lines ⋯ r = F("import_svg", {"svg": svg, "x": round(cx, 2), "y": round(cy, 2), "width": 3, "layer": "Dwgs.User"}); rec("import_svg", r, ok(r)) r = F("delete_graphics", {"layer": "Dwgs.User"}); rec("delete_graphics", r, ok(r)) + def std_phase():+ """Phase 9: the EDA Bridge Standard's shapes for the 47 verbs KiCad proposed to it (standard 0.3.0):+ the standard names, sheet/project paths, id, lists as one write, frame:"standard" (y up) read back+ against KiCad's own frame. Recorded as std_<verb>; the verbs themselves are covered by phase 8."""+ if a.skip_gui or (only and not any(o.startswith("std") for o in only)):+ return+ WHY = "daily verb test: EDA Bridge Standard shapes"+ S = lambda verb, args, t=200: ab(a.target, f"kicad_{verb}", dict(args, reason=WHY), timeout=t)+ def rec(name, r, good, why=None):+ record("std_" + name, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))+ clean = lambda r: bool(r.get("errorCode")) and r.get("mutated") is not True+ tag = datetime.now().strftime("%m%d%H%M%S")+ base, OUT = w["out"].rsplit("/", 1)[0], w["out"]+ P0 = base + "/std-" + tag+ pd = P0 + "/kstd"+ pro, sch = pd + "/kstd.kicad_pro", pd + "/kstd.kicad_sch"++ print("[9.0] describe marks the standard shapes")+ d = ab(a.target, "kicad_describe", {}, timeout=90)+ marked = {v["name"] for v in (d.get("verbs") or []) if isinstance(v, dict) and v.get("standardShape")}+ rec("describe", {"marked": len(marked)}, "kicad_sch_text" in marked and "kicad_edit_parts" in marked and len(marked) >= 47, f"{len(marked)} verbs marked")++ print("[9.1] a sheet in the standard's shapes")+ r = S("project_create", {"parentDir": P0, "name": "kstd"}); rec("project_create", r, ok(r))+ r = S("project_info", {"project": pro}); rec("project_info", r, ok(r))+ r = S("sch_page", {"sheet": sch, "paper": "A4", "orientation": "landscape", "title": "kstd"}); rec("sch_page", r, ok(r))+ for ref, x in (("R1", 50.8), ("R2", 76.2)):+ ab(a.target, "kicad_sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": ref, "at": [x, 50.8]}, timeout=150)+ k = S("sch_pins", {"sheet": sch, "references": ["R1"]})+ u = S("sch_pins", {"sheet": sch, "references": ["R1"], "frame": "standard"})+ def pin_at(rr, num):+ return next((p.get("at") for p in (rr.get("pins") or []) if str(p.get("number")) == num), None)+ ka, ua = pin_at(k, "2"), pin_at(u, "2")+ rec("sch_pins_frame", {"kicad": ka, "standard": ua}, ka and ua and abs(ua[0] - ka[0]) < 1e-6 and abs(ua[1] + ka[1]) < 1e-6 and u.get("frame") == "standard",+ f"R1.2 at {ka} in KiCad's frame but {ua} in the standard's (y should be negated)")+ r = S("sch_connect_pins", {"sheet": sch, "pairs": [{"from": "R1.2", "to": "R2.2"}, {"from": "R1.1", "to": "R2.1", "first": "v"}]})+ rec("sch_connect_pins", r, ok(r) and r.get("batchedAs") == "kicad_sch_batch" and len(r.get("routes") or []) >= 2)+ r = S("sch_trace_point", {"sheet": sch, "at": ua, "toleranceMm": 0.1, "frame": "standard"}) if ua else {"error": "no pin"}+ rec("sch_trace_point", r, ok(r) and "R1.2" in json.dumps(r) and "R2.2" in json.dumps(r), "the y-up point did not find R1.2 joined to R2.2")+ r = S("sch_text", {"sheet": sch, "frame": "standard", "texts": [{"text": "std up", "at": [30.48, -30.48], "sizeMm": 2}, {"text": "std two", "at": [30.48, -35.56]}]})+ rec("sch_text", r, ok(r) and r.get("batchedAs") == "kicad_sch_batch")+ r = S("sch_items", {"sheet": sch, "kinds": ["texts"], "frame": "standard"})+ t = next((x for x in (r.get("texts") or []) if x.get("text") == "std up"), {})+ rec("sch_items", r, ok(r) and t.get("id") and t.get("at") == [30.48, -30.48], f"text read back as {t}")+ r = S("sch_junction", {"sheet": sch, "points": [[30.48, 60.96]]})+ jid = next((j.get("id") for j in (S("sch_items", {"sheet": sch, "kinds": ["junctions"]}).get("junctions") or []) if j.get("at") == [30.48, 60.96]), None)+ r = S("sch_junction", {"sheet": sch, "points": [[35.56, 60.96]], "remove": [jid] if jid else []}) if jid else {"error": "the added junction has no id"}+ rec("sch_junction", r, ok(r) and r.get("batchedAs") == "kicad_sch_batch")+ ab(a.target, "kicad_sch_connect_to_net", {"filePath": sch, "pin": "R1.1", "net": "VIN"}, timeout=120)+ r = S("sch_edit_label", {"sheet": sch, "frame": "standard", "edits": [{"net": "VIN", "by": [0, 2.54]}]}); rec("sch_edit_label", r, ok(r))+ r = S("sch_create", {"sheet": pd + "/std-extra.kicad_sch", "orientation": "portrait", "title": "extra"}); rec("sch_create", r, ok(r))+ r = S("sch_hierarchy", {"project": pro}); rec("sch_hierarchy", r, ok(r))+ r = S("render_schematic", {"sheet": sch, "outputDir": OUT + "/std-render-sch"}, 220); rec("render_schematic", r, ok(r))+ r = S("project_snapshot", {"project": pro, "label": "std", "pdfs": False}, 220); rec("project_snapshot", r, ok(r))+ r = S("project_rename", {"project": pro, "newName": "kstd2", "dryRun": True}); rec("project_rename", r, ok(r) and r.get("mutated") is not True)++ print("[9.2] board, library and outputs in the standard's shapes")+ B, F = w["board"], w["offline"]+ st = ab(a.target, "kicad_routing_state", {"filePath": F, "model": "standard"}, timeout=150)+ refs = [p.get("ref") for p in (st.get("components") or st.get("parts") or []) if str(p.get("ref", "")).startswith(("C", "R"))][:2]+ if len(refs) == 2:+ r = S("edit_parts", {"filePath": F, "source": "file", "edits": [{"ref": refs[0], "value": "2u2"}, {"ref": refs[1], "value": "4k7", "dnp": True}]})+ rec("edit_parts", r, ok(r) and len(r.get("edited") or []) == 2)+ r = S("align_parts", {"filePath": F, "refs": refs, "mode": "top", "dryRun": True, "allowOverlap": True, "frame": "standard"})+ rec("align_parts", r, ok(r) and r.get("inputFrame") == "standard")+ r = S("remove_parts", {"filePath": F, "refs": refs[:1], "dryRun": True, "source": "file"}); rec("remove_parts", r, ok(r) and r.get("mutated") is not True)+ else:+ rec("edit_parts", st, False, "no two C/R parts on the offline board")+ svg = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10'/></svg>"+ r = S("import_svg", {"filePath": F, "svg": svg, "at": [100, -100], "widthMm": 3, "layer": "Dwgs.User", "frame": "standard", "dryRun": True, "source": "file"})+ rec("import_svg", r, ok(r) and r.get("inputFrame") == "standard")+ r = S("remove_graphics", {"filePath": F, "layer": "Dwgs.User", "dryRun": True, "source": "file"}); rec("remove_graphics", r, ok(r) and r.get("mutated") is not True)+ r = S("check_clearance", {"filePath": B, "mode": "pads", "thresholdMm": 0.2}); rec("check_clearance", r, ok(r))+ r = S("cross_probe", {"filePath": B, "ref": "U1"}); rec("cross_probe", r, ok(r) or clean(r))+ r = S("estimate_cost", {"filePath": B, "quantities": [5, 50]})+ rec("estimate_cost", r, ok(r) and (r.get("source") or {}).get("name") and "read" in (r.get("source") or {}) and len(r.get("prices") or []) == 2)+ r = S("export_ipc2581", {"filePath": B, "outputDir": OUT + "/std-ipc2581", "bomFields": {"mpn": "MPN"}}, 300); rec("export_ipc2581", r, ok(r))+ r = S("export_dxf", {"filePath": B, "outputDir": OUT + "/std-dxf", "layers": ["Edge.Cuts"], "contours": True}, 300); rec("export_dxf", r, ok(r))+ r = S("render_board_3d", {"filePath": B, "side": "top", "outputDir": OUT + "/std-render3d"}, 600); rec("render_board_3d", r, ok(r) and r.get("image"))+ r = S("read_footprint", {"libId": "Resistor_SMD:R_0603_1608Metric"}); rec("read_footprint", r, ok(r))+ pretty = f"{OUT}/KSTD{tag}.pretty"+ S("create_footprint", {"libraryPath": pretty, "name": "KSTD_FP", "pads": [{"number": 1, "size": [1, 1], "at": [-1, 0]}, {"number": 2, "size": [1, 1], "at": [1, 0]}]})+ r = S("edit_footprint", {"libraryPath": pretty, "name": "KSTD_FP", "pads": [{"number": "1", "set": {"sizeMm": [1.2, 1]}}]})+ rec("edit_footprint_library", r, ok(r), "the standard's edit_footprint with library arguments did not reach kicad_fp_edit")+ r = S("export_library_part", {"part": "__no_such_part__"}); rec("export_library_part", r, r.get("success") is False)+ r = S("create_part_project", {"part": "__no_such_part__"}); rec("create_part_project", r, r.get("success") is False)+ live_phase() k161_phase()+ std_phase() # phase 7: clean up EVERYTHING the suite created on the box (John 2026-08-14) if not only and not a.skip_gui:⋯ 61 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+@@ -1,58 +1,82 @@⋯ 54 unchanged lines ⋯ `export_ipc2581`, `export_ipc356`, `export_gencad`, `export_dxf`, `validate_for_fab` (the standard's `fab_check`), `estimate_cost`. +## The EDA Bridge Standard's shapes (from 1.0.63)++These verbs are in the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard) (0.3.0), so an AI+that knows the standard can call them under the standard's names and arguments; KiCad's own names and arguments+keep working. `kicad_describe` marks each one with `standardShape`.++- **Names**: `kicad_render_board_3d` (render_board), `kicad_remove_parts` (delete_footprint), `kicad_edit_parts`+ (edit_footprint on the board), `kicad_align_parts`, `kicad_remove_graphics`, `kicad_read_footprint` (fp_info),+ `kicad_export_library_part` (export_part), `kicad_create_part_project` (make_part_project, which then refuses+ to overwrite an existing project unless `overwrite:true`). `kicad_edit_footprint` with library arguments+ (`libraryPath` + `name`, no `ref`) edits the LIBRARY footprint (fp_edit), as the standard means it.+- **Arguments**: `sheet` and `project` paths, `id`/`ids` for uuids, mm-named numbers (`toleranceMm`, `gapMm`,+ `thresholdMm`, `widthMm`...), `bomFields` on IPC-2581, `at` and `box` on import_svg and copy_routing.+- **Lists are one write**: `sch_connect_pins {pairs: [{from, to}]}`, `sch_bus_entry {entries}`,+ `sch_edit_label {edits}`, `sch_text {texts}` and `sch_junction {points, remove: [id or [x, y]]}` run as one+ `kicad_sch_batch` (`batchedAs` says so). `kicad_edit_parts {edits: [{ref, value, dnp...}]}` gives each part its+ own edit in one undo step.+- **y up**: `frame:"standard"` takes coordinates with KiCad's y negated. A sheet verb's reply comes back y up+ too (`frame:"standard"` in it); a board verb's reply stays in KiCad's frame (`inputFrame`), and+ `kicad_routing_state {model:"standard"}` reads the board back y up.+- `estimate_cost` names its price `source` (built-in JLCPCB-style rates, with no recorded check date) and lists+ `prices` per quantity.+ Every verb here runs in the verb battery on a real Windows box before each release.+@@ -1,291 +1,307 @@⋯ 271 unchanged lines ⋯ return ref = f"reference/kicad-{ver}.json" (fresh / ref).write_text(json.dumps({"bridge": "kicad", "version": ver, "verbs": verbs}, indent=1) + "\n")- r = subprocess.run(["adom-wiki", "repo", "push", "adom/eda-bridge-standard", "-m", f"kicad-lane: KiCad bridge {ver} reference", "--files", ref],+ files = [ref]+ # An exact equivalent the release now answers by the standard's own name (an alias in catalog::ALIASES pointing+ # at the same verb) is dropped in the same push, so no regeneration ever sees neither credit it. A partial one+ # stays: the equivalents entry wins over the name match and keeps the credit honest.+ eqf = fresh / "tools/equivalents/kicad.json"+ if eqf.exists():+ aliases = dict(re.findall(r'\("kicad_(\w+)", "kicad_(\w+)"\)', (RUST / "crates/kicad-bridge/src/catalog.rs").read_text()))+ eq = json.loads(eqf.read_text())+ named = [k for k, (v, m) in eq.items() if m == "exact" and aliases.get(k) == v.strip() and f"kicad_{k}" in verbs]+ if named:+ for k in named:+ eq.pop(k)+ eqf.write_text(json.dumps(eq, indent=1) + "\n")+ files.append("tools/equivalents/kicad.json")+ print(f"EDA Bridge Standard: dropping equivalents now answered by name: {', '.join(named)}")+ r = subprocess.run(["adom-wiki", "repo", "push", "adom/eda-bridge-standard", "-m", f"kicad-lane: KiCad bridge {ver} reference", "--files"] + files, cwd=fresh, capture_output=True, text=True) ok = '"ok": true' in r.stdout or "PUSH_UNCHANGED" in r.stdout + r.stderr if not ok:⋯ 3 unchanged lines ⋯ subprocess.run(["adom-wiki", "repo", "clone", "adom/eda-bridge-standard", "--dir", str(check)], capture_output=True, text=True) same = (check / ref).exists() and (check / ref).read_text() == (fresh / ref).read_text() g = subprocess.run([sys.executable, "tools/generate.py"], cwd=check, capture_output=True, text=True)- print(f"EDA Bridge Standard: pushed {ref} ({'read back' if same else 'NOT found on a fresh clone'}); "+ print(f"EDA Bridge Standard: pushed {', '.join(files)} ({'read back' if same else 'NOT found on a fresh clone'}); " f"expected after regeneration: {g.stdout.strip() or g.stderr[-200:]}. Now ping standard-lane to regenerate: " f"a SendMessage to the session named in adom/eda-bridge-standard issue #3, or a 'kicad-lane:' issue there.") if __name__ == "__main__": main()+
Comments
Log in to comment.
No comments yet.