app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
1.0.8: 1.0.8: kicad_3d_view (zoom, rotate, view orientation, flip, tour: View-menu commands posted to the 3D Viewer in the background, no mouse), kicad_select_net and kicad_select_clear (select a net's pours, tracks, vias and pads through the IPC API so the editor highlights the whole net; Zoom to Selection frames it)
5 files changed
+142−4
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.7"+version = "1.0.8" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.7"+version = "1.0.8" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.7"+version = "1.0.8" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.7"+version = "1.0.8" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_routing.rs+25@@ -67,6 +67,25 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_add_zone", "kicad_routing_validate"], pitfalls: &["Coverage is against the outline's bounding box; a non-rectangular board reads low.", "Pads, tracks and vias outside zones are not counted."], },+ Verb {+ name: "kicad_select_net",+ summary: "Select every copper item of one net on the live board (pours, tracks, vias, pads) so the editor highlights the net as a whole; no cursor, no hotkey.",+ mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+ input: "{\"filePath\", \"net\": \"+VBAT\", \"add\"?: false}",+ example: "kicad_select_net {\"filePath\":\"C:/p/b.kicad_pcb\",\"net\":\"/DRV_SHA\"}",+ hint: "The way to walk a board net by net on camera: select the net, then kicad_send_key {hwnd, menu:\"Zoom to Selection\"} on the PCB Editor frames it; kicad_select_clear afterwards. Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.",+ related: &["kicad_select_clear", "kicad_send_key", "kicad_zone_state", "kicad_routing_state"],+ pitfalls: &["net names are exact (KiCad's spelling, with the leading slash for local labels)", "a net with no copper yet has nothing to select"],+ },+ Verb {+ name: "kicad_select_clear",+ summary: "Clear the live PCB editor's selection.",+ mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 30,+ input: "{\"filePath\"}", example: "kicad_select_clear {\"filePath\":\"C:/p/b.kicad_pcb\"}",+ hint: "After kicad_select_net, before recording the next net.",+ related: &["kicad_select_net"],+ pitfalls: &[],+ }, Verb { name: "kicad_remove_zone", summary: "Remove zones (pours or keepouts) by id live as one Undo step.",@@ -158,6 +177,8 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_add_zone" => live(state, args, Live::AddZone), "kicad_zone_state" => live(state, args, Live::ZoneState), "kicad_remove_zone" => live(state, args, Live::RemoveZone),+ "kicad_select_net" => live(state, args, Live::SelectNet),+ "kicad_select_clear" => live(state, args, Live::SelectClear), "kicad_board_pads" => board_pads(args), "kicad_add_track" => add_track(args), "kicad_add_via" => add_via(args),@@ -181,6 +202,8 @@ enum Live { AddZone, ZoneState, RemoveZone,+ SelectNet,+ SelectClear, } #[cfg(feature = "ipc")]@@ -199,6 +222,8 @@ fn live(state: &mut State, args: &Value, which: Live) -> Value { Live::AddZone => ipc::add_zone(&ctx, args), Live::ZoneState => ipc::zone_state(&ctx, args), Live::RemoveZone => ipc::remove_zone(&ctx, args),+ Live::SelectNet => ipc::select_net(&ctx, args),+ Live::SelectClear => ipc::select_clear(&ctx, args), }; match result { Ok(v) => v,
rust/crates/kicad-bridge/src/verbs_windows.rs+60@@ -96,6 +96,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_install_footprint", "kicad_close_footprint_editor", "kicad_screenshot_all"], pitfalls: &["the footprint must be in an installed fp-lib-table library (install_footprint first)", "allowCursorClick (the SendInput launcher click) is not in the native build: the background menu and UIA rungs are the only ones"], },+ Verb {+ name: "kicad_3d_view",+ summary: "Drive the 3D Viewer without the mouse: zoom, rotate, view orientation, flip, or a scripted tour; menu commands posted to the viewer in the background.",+ mechanism: Mechanism::Window, risk: "input", timeout_sec: 120,+ input: "{\"action\": \"zoom_in|zoom_out|fit|rotate_x_cw|rotate_x_ccw|rotate_y_cw|rotate_y_ccw|rotate_z_cw|rotate_z_ccw|view_top|view_bottom|view_front|view_back|view_left|view_right|flip|tour|actions\", \"count\"?: 3, \"pauseMs\"?: 700, \"hwnd\"?: 123, \"steps\"?: [[\"rotate_z_cw\", 8], [\"zoom_in\", 3]]}",+ example: "kicad_3d_view {\"action\":\"rotate_z_cw\",\"count\":8,\"pauseMs\":700}",+ hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. The viewer has no menu command for panning; zoom about the centre and rotate instead.",+ related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"],+ pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"],+ }, Verb { name: "kicad_open_3d_viewer", summary: "Open the 3D board viewer for a .kicad_pcb.",@@ -281,6 +291,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_open_symbol_editor" => open_symbol_editor(state, args), "kicad_open_footprint_editor" => open_footprint_editor(state, args), "kicad_open_3d_viewer" => open_3d_viewer(args),+ "kicad_3d_view" => view_3d(args), "kicad_open_editors" => open_editors(), "kicad_close" => close_kicad(state, args), "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),@@ -1855,6 +1866,55 @@ fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<O Ok(None) } ++/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.+pub(crate) fn view_3d(args: &Value) -> Value {+ const ACTIONS: &[(&str, &str)] = &[+ ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),+ ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),+ ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),+ ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),+ ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),+ ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),+ ];+ let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();+ if action == "actions" || action.is_empty() {+ return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});+ }+ invalidate();+ let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {+ Some(h) => h,+ None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),+ };+ let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);+ let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;+ let steps: Vec<(String, usize)> = if action == "tour" {+ match args.get("steps").and_then(Value::as_array) {+ Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),+ None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),+ }+ } else {+ vec![(action.clone(), count)]+ };+ let mut done: Vec<Value> = Vec::new();+ let started = Instant::now();+ for (act, n) in &steps {+ let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {+ return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");+ };+ for _ in 0..*n {+ match menu_invoke_by_text(hwnd, label) {+ Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }+ Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),+ Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),+ }+ std::thread::sleep(Duration::from_millis(pause));+ }+ }+ json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,+ "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})+}+ pub(crate) fn open_3d_viewer(args: &Value) -> Value { let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase(); if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {
rust/crates/kicad-core/src/ipc.rs+53@@ -555,6 +555,59 @@ pub fn build_items(plan: &Plan, net: &BoardNet) -> RResult<Vec<EditablePcbItem>> // The four operations // --------------------------------------------------------------------------- ++/// `kicad_select_net`: select every copper item of one net on the live board (tracks, arcs, vias,+/// zones and pads), so the editor shows the net's pours and traces together, highlighted, on+/// every layer; no cursor, no hotkey. Returns the ids selected per kind.+pub fn select_net(ctx: &Ctx, args: &Value) -> RResult<Value> {+ let s = connect(ctx, args)?;+ let net = args.get("net").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("missing_arg", "Pass net: the net name as KiCad writes it (e.g. \"+VBAT\", \"/DRV_SHA\")"))?;+ let items = s.items_of(&[PcbObjectTypeCode::new_trace(), PcbObjectTypeCode::new_arc(), PcbObjectTypeCode::new_via(), PcbObjectTypeCode::new_pad()])?;+ let mut ids: Vec<String> = Vec::new();+ let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default();+ let mut nets_seen: std::collections::BTreeSet<String> = Default::default();+ for it in &items {+ let (kind, item_net, id): (&str, Option<String>, Option<String>) = match it {+ PcbItem::Track(t) => ("tracks", t.net.as_ref().map(|n| n.name.clone()), t.id.clone()),+ PcbItem::Arc(a) => ("tracks", a.net.as_ref().map(|n| n.name.clone()), a.id.clone()),+ PcbItem::Via(v) => ("vias", v.net.as_ref().map(|n| n.name.clone()), v.id.clone()),+ PcbItem::Pad(p) => ("pads", p.net.as_ref().map(|n| n.name.clone()), p.id.clone()),+ _ => continue,+ };+ if let Some(n) = &item_net { if !n.is_empty() { nets_seen.insert(n.clone()); } }+ if item_net.as_deref() == Some(net) {+ if let Some(id) = id { ids.push(id); *kinds.entry(kind).or_insert(0) += 1; }+ }+ }+ // zones: the model has no net field; the raw proto does+ let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;+ for a in &raws {+ if let Ok(z) = zones::decode_zone(&a.value) {+ if !z.net.is_empty() { nets_seen.insert(z.net.clone()); }+ if z.net == net && !z.rule_area { ids.push(z.id.clone()); *kinds.entry("zones").or_insert(0) += 1; }+ }+ }+ if ids.is_empty() {+ return Err(RoutingError::new("unknown_net", format!("No copper item on the live board carries net '{net}'")).with("netsOnBoard", json!(nets_seen.into_iter().take(200).collect::<Vec<_>>())));+ }+ if args.get("add").and_then(Value::as_bool) != Some(true) {+ s.client.clear_selection().map_err(map_err)?;+ }+ let count = ids.len();+ s.client.add_to_selection(ids.clone()).map_err(map_err)?;+ Ok(json!({+ "success": true, "mutated": false, "source": "live-editor", "net": net, "selected": count, "byKind": kinds, "itemIds": ids,+ "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_send_key {hwnd, menu:\"Zoom to Selection\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",+ }))+}++/// `kicad_select_clear`: clear the live editor's selection.+pub fn select_clear(ctx: &Ctx, args: &Value) -> RResult<Value> {+ let s = connect(ctx, args)?;+ s.client.clear_selection().map_err(map_err)?;+ Ok(json!({"success": true, "mutated": false, "source": "live-editor", "_hint": "Selection cleared."}))+}+ /// `kicad_routing_state`: pads, copper, revision and KiCad-measured connectivity. pub fn routing_state(ctx: &Ctx, args: &Value) -> RResult<Value> { let s = connect(ctx, args)?;