← 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)

John Lauer ·7c3f3754a3 ·1mo ago ·parent 317238d
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)?;