← All Pull Requests

Refresh exact linked PCB editor and 3D viewer through native menus #16

Merged opened by John Lauer 2026-09-18
Merges astra/linked-view-refresh → master

AI Flow needs both native views refreshed after board changes. Adds kicad_refresh_board_views {pcbHwnd}: validate the exact PCB editor, resolve only its uniquely bound already-open viewer, discover both exact View > Refresh menu actions, then invoke them without focus or mouse. Ambiguous/stale bindings refuse. A partial invocation reports which window received its request. Success explicitly says renderVerified:false; subsequent screenshots remain required.

Based on live 1.0.25 commit 6e873f2b7afa57d5d5552909b2eb0cd544edacba. Focused patch rebased after PR #15 publication. Production cargo check and workspace tests pass (26 bridge + 233 core). Exact menu matching excludes Refresh Plugins and Refresh All. Only verbs_windows.rs and the new acceptance document change; all predecessor modules preserved.

Native Windows acceptance remains pending: two independent editor/viewer pairs, visible silk updates in both surfaces, stale/ambiguous refusal, preserved camera/foreground. This requests refresh, does not claim synchronous geometry rebuilding or save the board. Please verify through the owning development bridge. Partial #104 follow-up.

Diff Skip to comments (1)

docs/linked-view-refresh.mdadded+7
@@ -0,0 +1,7 @@+# Refresh the linked board views++After native edits, call `kicad_refresh_board_views {pcbHwnd}` with the current exact PCB editor HWND. The viewer must already be open and uniquely associated with that editor. The bridge uses its editor/viewer binding logic and refuses missing or ambiguous identities instead of picking the first viewer.++It discovers both exact `View > Refresh` menu actions before invoking either, then requests both redraws using window-targeted native menu calls. It does not save, open windows, move the cursor, change camera pose or take focus. This is a rendering request, not rendering proof: the response says `refreshRequested:true`, `renderVerified:false`. Inspect the subsequent `kicad_state` screenshots for both surfaces. A failure after one menu invocation lists `refreshRequestedFor`; do not assume neither ran.++Native acceptance: two boards with separate viewers; refresh each exact pair without touching the other; reject stale PCB handles and ambiguous viewer bindings; verify visible changes appear in both 2D and 3D after a native silk edit; preserve camera and foreground; no substitute windows. Source tests cover exact menu labels and retain the existing viewer-binding tests, but do not establish those Windows results.
rust/crates/kicad-bridge/src/verbs_windows.rs+112
@@ -47,6 +47,15 @@ use kicad_platform::{native, unsupported, Placement, WindowInfo}; // ── Catalog ───────────────────────────────────────────────────────────────────  pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_refresh_board_views",+        summary: "Refresh the exact PCB editor and its already-open linked 3D viewer without focus or mouse.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,+        input: "{pcbHwnd}", example: "kicad_refresh_board_views {pcbHwnd:12345}",+        hint: "Requires an existing uniquely bound viewer. Discovers the exact View > Refresh menu on both windows before invoking either. Returns refreshRequested, not rendering proof; inspect kicad_state screenshots after redraw.",+        related: &["kicad_state", "kicad_open_3d_viewer", "kicad_silk_text_batch"],+        pitfalls: &["Does not open windows, substitute another editor/viewer, save the board, or claim that the 3D model has finished rebuilding."],+    },     Verb {         name: "kicad_launch",         summary: "Cold-start KiCad (project manager) when nothing is running.",@@ -304,6 +313,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_open_footprint_editor" => open_footprint_editor(state, args),         "kicad_open_3d_viewer" => open_3d_viewer(args),         "kicad_3d_view" => view_3d(args),+        "kicad_refresh_board_views" => refresh_board_views(args),         "kicad_menu_items" => menu_items(args),         "kicad_open_editors" => open_editors(),         "kicad_close" => close_kicad(state, args),@@ -2023,6 +2033,92 @@ fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<O /// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.  /// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+fn is_view_refresh(path: &[String]) -> bool {+    let clean = |s: &str| {+        s.replace('&', "")+            .split('\t')+            .next()+            .unwrap_or("")+            .trim()+            .to_lowercase()+    };+    path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"+}++pub(crate) fn refresh_board_views(args: &Value) -> Value {+    let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {+        return fail(+            "source_editor_required",+            "Pass the exact pcbHwnd.",+            "Read kicad_open_editors first.",+        );+    };+    invalidate();+    let windows = kicad_windows();+    let Some(source) = windows+        .iter()+        .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)+    else {+        return fail(+            "source_editor_missing",+            "Requested PCB editor is not open.",+            "No substitute was used.",+        );+    };+    let viewers = windows_of_kind(WindowKind::Viewer3d);+    let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();+    let count = windows+        .iter()+        .filter(|w| {+            w.pid == source.pid+                && matches!(+                    w.kind(),+                    WindowKind::PcbEditor | WindowKind::FootprintEditor+                )+        })+        .count();+    let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {+        m.get(&frame)+            .filter(|(pid, _)| *pid == source.pid)+            .map(|(_, h)| *h)+    });+    let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {+        return fail(+            "viewer_binding_ambiguous",+            "No uniquely linked open 3D viewer.",+            "Open the viewer from this exact PCB editor first; nothing was refreshed.",+        );+    };+    let mut commands = Vec::new();+    for hwnd in [frame, viewer.hwnd] {+        let items = match native().menu_tree(hwnd) {+            Ok(items) => items,+            Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),+        };+        let matches: Vec<_> = items+            .iter()+            .filter(|m| m.id != 0 && is_view_refresh(&m.path))+            .collect();+        if matches.len() != 1 {+            return fail(+                "refresh_action_missing",+                "Exact View > Refresh menu not uniquely available.",+                "No fallback menu or keyboard action was invoked.",+            );+        }+        commands.push((hwnd, matches[0].id));+    }+    let mut invoked = Vec::new();+    for (hwnd, id) in commands {+        if let Err(e) = native().menu_invoke(hwnd, id) {+            return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});+        }+        invoked.push(hwnd);+    }+    invalidate();+    json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})+}+ pub(crate) fn menu_items(args: &Value) -> Value {     let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {         Some(h) => h,@@ -3171,3 +3267,19 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value {         "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."}) } ++#[cfg(test)]+mod linked_refresh_tests {+    #[test]+    fn exact_view_refresh_excludes_plugins_and_partial_labels() {+        let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();+        assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));+        for a in [+            vec!["Tools", "Refresh Plugins"],+            vec!["View", "Refresh All"],+            vec!["Refresh"],+        ] {+            assert!(!super::is_view_refresh(&path(&a)));+        }+    }+}

Comments

John Lauer · 1mo ago

Merged on the wiki (56522ef), shipped as KiCad Bridge 1.0.26 (insiders), accepted on ConfRoomROG (KiCad 10.0.5) against docs/linked-view-refresh.md. The verb does exactly what it claims; the interesting result is what KiCad does without it.

Binding and refusal

check result
two pairs open (esc-g431-astra-models pid 25412, esc-g431-astra pid 33580) {pcbHwnd:791978} refreshed viewer 1116134 (pid 25412); {pcbHwnd:529716} refreshed viewer 660442 (pid 33580); each pair its own, refreshRequested:true, renderVerified:false
a third editor and viewer added that pair resolved to its own viewer too
stale pcbHwnd:123456 source_editor_missing, nothing refreshed, no substitute
camera and foreground the viewer's pixels before and after a refresh differ only in a status-bar tick (0.03%); nothing was brought forward

Visible change, measured on both surfaces. With one disposable board open, I added a 56 mm F.SilkS line along the top edge through kicad_silk_graphics_batch and screenshotted the PCB editor and the 3D viewer before the edit, after the edit, and after kicad_refresh_board_views:

surface before -> after edit, no refresh after edit -> after refresh
2D editor 0.15% of pixels changed (the line) 0.00%
3D viewer 0.20% of pixels changed (the line) 0.00%

On this KiCad both views already redraw on their own after an IPC edit, so the refresh is a no-op here. That is not a defect in the verb: it discovers and fires the exact menu items, binds correctly and stays out of the user's way. It means AI Flow should treat it as belt and braces rather than as the step that makes an edit visible, and the reply's renderVerified:false remains the right claim. Worth a sentence in the doc so nobody waits on it.

Closing.

Log in to comment.