app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
v0.9.99 (#26): plugin-free Win32 menu-bar tier for open_3d_viewer (GetMenu->resolve 3D Viewer->PostMessage WM_COMMAND, no plugin/focus)
6 files changed
+117−7
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.98+0.9.99
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.98",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.98/adom-bridge-kicad-v0.9.98.zip",- "sha256": "9fea18a42e0ebc49f44a94e66add004d4dbc1761cc4182da0a39918d49beae6a",- "size": 389942,+ "version": "0.9.99",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.99/adom-bridge-kicad-v0.9.99.zip",+ "sha256": "b6051a2f88bb12c775830d81e50d93abc00acc2f450d67a42125d8de756dfc4a",+ "size": 391591, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-15T02:22:06Z",+ "released_at": "2026-08-15T09:25:48Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.99.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.98",+ "version": "0.9.99", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",
handlers/open_footprint_editor.py+29@@ -873,6 +873,23 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict: if fp_hwnd: WM_COMMAND = 0x0111 fp_pathways: list[str] = []+ # Tier 0 (v0.9.99, #26): resolve "3D Viewer" from the wxFrame's native+ # Win32 MENU BAR and PostMessage its command id. Plugin-free, focus-free,+ # works from cold (no wait on the late-binding plugin, no opaque toolbar).+ try:+ from handlers import win_menu+ mr = win_menu.open_via_menu(fp_hwnd, "3d viewer")+ if mr.get("ok"):+ fp_pathways.append(f"win32menu(id={mr.get('cmdId')})")+ windows = _poll_3d_window(12)+ if windows:+ return {"success": True, "pathway": "win32menu",+ "output": f"3D Viewer opened from Footprint Editor (Win32 menu): {windows[0][1]}",+ "pathwaysTried": fp_pathways}+ else:+ fp_pathways.append(f"win32menu-{mr.get('reason')}")+ except Exception as e: # pylint: disable=broad-except+ fp_pathways.append(f"win32menu-error:{type(e).__name__}") # BACKGROUND path (preferred): in KiCad 10 the Footprint Editor is a frame # INSIDE the pcbnew process, so the reverse-bridge plugin can drive it. Find # that frame's index (list_frames) and post the live-resolved "3D Viewer"@@ -971,6 +988,18 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict: # require foregrounding the window or stealing keyboard focus. pcb_hwnd = _find_pcb_editor() if pcb_hwnd:+ # Tier 0 (v0.9.99, #26): native Win32 menu-bar resolve + PostMessage.+ try:+ from handlers import win_menu+ mr = win_menu.open_via_menu(pcb_hwnd, "3d viewer")+ if mr.get("ok"):+ windows = _poll_3d_window(12)+ if windows:+ return {"success": True, "pathway": "win32menu",+ "output": f"3D Viewer opened from PCB Editor (Win32 menu): {windows[0][1]}",+ "pathwaysTried": [f"win32menu(id={mr.get('cmdId')})"]}+ except Exception:+ pass # Menu ids DRIFT across KiCad builds (seen 20563 on one, 20568 on another), # so RESOLVE the live "3D Viewer" id from the running pcbnew instead of trusting # a constant. Fall back to the legacy id only if the lookup can't reach the plugin.
handlers/win_menu.pyadded+81@@ -0,0 +1,81 @@+"""win_menu.py — resolve a KiCad menu command id straight from the Win32 menu bar+and fire it with PostMessage(WM_COMMAND). Fully PLUGIN-FREE and FOCUS-FREE.++Issue #26: the 3D viewer opens via a menu command. The old paths needed either+the in-process plugin (binds minutes-late in editors) or an Alt+3 keystroke+(needs foreground). But wxFrame menus on Windows ARE native Win32 menus attached+with SetMenu — so we can walk the menu bar with GetMenu/GetSubMenu, find the item+by text, read its command id, and PostMessage it. No plugin, no focus, no cursor.+"""+import ctypes+import sys++IS_WINDOWS = sys.platform == "win32"++if IS_WINDOWS:+ user32 = ctypes.windll.user32+ MF_BYPOSITION = 0x0400+ WM_COMMAND = 0x0111+++def find_menu_command(hwnd: int, *contains: str, max_depth: int = 4):+ """Walk the window's Win32 menu bar; return the command id of the first leaf+ item whose text contains ALL of `contains` (case-insensitive), else None.+ Returns (cmd_id, menu_text) or (None, reason)."""+ if not IS_WINDOWS or not hwnd:+ return None, "no-win32"+ user32.GetMenu.restype = ctypes.c_void_p+ user32.GetSubMenu.restype = ctypes.c_void_p+ hmenu = user32.GetMenu(ctypes.c_void_p(hwnd))+ if not hmenu:+ return None, "no-menu" # wx custom/opaque menu, or wrong hwnd+ wants = [c.lower() for c in contains]++ def _text(menu, i):+ buf = ctypes.create_unicode_buffer(256)+ user32.GetMenuStringW(ctypes.c_void_p(menu), i, buf, 256, MF_BYPOSITION)+ return buf.value or ""++ def _walk(menu, depth):+ n = user32.GetMenuItemCount(ctypes.c_void_p(menu))+ for i in range(max(0, n)):+ txt = _text(menu, i).replace("&", "")+ sub = user32.GetSubMenu(ctypes.c_void_p(menu), i)+ if sub:+ if depth < max_depth:+ hit = _walk(sub, depth + 1)+ if hit is not None:+ return hit+ else:+ low = txt.lower()+ if all(w in low for w in wants):+ cid = user32.GetMenuItemID(ctypes.c_void_p(menu), i)+ if cid and cid != -1:+ return cid, txt+ return None++ hit = _walk(hmenu, 0)+ if hit is None:+ return None, "not-found"+ return hit+++def invoke_menu_command(hwnd: int, cmd_id: int) -> bool:+ """PostMessage WM_COMMAND — triggers the menu action with NO focus/cursor."""+ if not IS_WINDOWS or not hwnd or not cmd_id:+ return False+ try:+ user32.PostMessageW(ctypes.c_void_p(hwnd), WM_COMMAND, cmd_id, 0)+ return True+ except Exception:+ return False+++def open_via_menu(hwnd: int, *contains: str):+ """Convenience: resolve `contains` in the window's menu and fire it.+ Returns {ok, cmdId, menuText} or {ok:False, reason}."""+ cid, info = find_menu_command(hwnd, *contains)+ if cid is None:+ return {"ok": False, "reason": info}+ ok = invoke_menu_command(hwnd, cid)+ return {"ok": ok, "cmdId": cid, "menuText": info}