app
Adom Desktop - 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
"""Handler for open_footprint_editor and open_3d_viewer commands.
Windows: clicks the launcher icon in the KiCad project manager (the launcher
buttons in KiCad 9 use dynamic WM_COMMAND IDs we can't rely on), then optionally
loads a footprint via PowerShell SendKeys against the search box.
macOS: launches the Footprint Editor app directly via `open -a`. Footprint-name
navigation requires Accessibility-framework permission and is not implemented.
"""
import ctypes
import ctypes.wintypes
import subprocess
import sys
import time
from pathlib import Path
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
# Win32 constants — cross-platform safe (just integers).
WM_CLOSE = 0x0010
SW_RESTORE = 9
GW_OWNER = 4
KEYEVENTF_KEYUP = 0x0002
VK_MENU = 0x12
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
class RECT(ctypes.Structure):
_fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),
("right", ctypes.c_long), ("bottom", ctypes.c_long)]
if IS_WINDOWS:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
else:
user32 = None
kernel32 = None
# Keep callback references alive to prevent GC
_callbacks = []
# Reuse the same navigate_symbol.ps1 script (works for both editors)
_SCRIPT_DIR = Path(__file__).resolve().parent.parent
_NAVIGATE_SCRIPT = _SCRIPT_DIR / "navigate_symbol.ps1"
def _find_windows_by_title(substring: str) -> list:
"""Find all visible windows whose title contains the given substring."""
results = []
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
if user32.IsWindowVisible(hwnd):
length = user32.GetWindowTextLengthW(hwnd)
if length > 0:
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
if substring in buf.value:
owner = user32.GetWindow(hwnd, GW_OWNER)
results.append((hwnd, buf.value, owner))
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumWindows(cb, 0)
_callbacks.remove(cb)
return results
def _find_kicad_manager() -> int | None:
"""Find the KiCad project manager window handle."""
windows = _find_windows_by_title("KiCad")
for hwnd, title, owner in windows:
lower = title.lower()
if ("editor" not in lower
and "viewer" not in lower
and "calculator" not in lower
and owner == 0):
return hwnd
return None
def _find_footprint_editor() -> int | None:
"""Find an open Footprint Editor window."""
windows = _find_windows_by_title("Footprint Editor")
for hwnd, title, _owner in windows:
if "footprint editor" in title.lower():
return hwnd
return None
def _find_child_by_text(parent_hwnd: int, text: str) -> int | None:
"""Find a child window with exact text match."""
result = [None]
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
length = user32.GetWindowTextLengthW(hwnd)
if length > 0:
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
if buf.value == text:
result[0] = hwnd
return False
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumChildWindows(parent_hwnd, cb, 0)
_callbacks.remove(cb)
return result[0]
def _find_icon_panel_near(parent_hwnd: int, near_y: int) -> tuple | None:
"""Find a small (~51x51) wxWindowNR 'panel' child near the given y coordinate."""
results = []
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
cls = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls, 256)
if cls.value == "wxWindowNR":
txt_len = user32.GetWindowTextLengthW(hwnd)
txt = ""
if txt_len > 0:
buf = ctypes.create_unicode_buffer(txt_len + 1)
user32.GetWindowTextW(hwnd, buf, txt_len + 1)
txt = buf.value
if txt == "panel":
rect = RECT()
user32.GetWindowRect(hwnd, ctypes.byref(rect))
w = rect.right - rect.left
h = rect.bottom - rect.top
if 40 <= w <= 60 and 40 <= h <= 60:
if abs(rect.top - near_y) < 15:
results.append((hwnd, rect))
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumChildWindows(parent_hwnd, cb, 0)
_callbacks.remove(cb)
return results[0] if results else None
def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_text: str) -> int | None:
"""Find a child window by class name and parent window text."""
result = [None]
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
cls_buf = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls_buf, 256)
if cls_buf.value == child_class:
parent = user32.GetParent(hwnd)
p_buf = ctypes.create_unicode_buffer(256)
user32.GetWindowTextW(parent, p_buf, 256)
if p_buf.value == parent_text:
result[0] = hwnd
return False
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumChildWindows(parent_hwnd, cb, 0)
_callbacks.remove(cb)
return result[0]
SW_SHOWNOACTIVATE = 4
def _force_foreground(hwnd: int):
"""DELIBERATELY does NOT foreground. The bridge must never steal the user's
focus or flash a window in front of their work (John, 2026-07 — 'do your stuff
in the background'). We only make the window VISIBLE without activating it
(SW_SHOWNOACTIVATE); the plugin WM_COMMAND path drives it fine without focus,
and the dispatcher restores the user's window afterward anyway. Name kept so
the ~10 call sites don't churn."""
try:
user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)
except Exception:
pass
def _click_at(x: int, y: int):
"""Simulate a mouse click at screen coordinates."""
user32.SetCursorPos(x, y)
time.sleep(0.15)
user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, ctypes.POINTER(ctypes.c_ulong)())
time.sleep(0.05)
user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, ctypes.POINTER(ctypes.c_ulong)())
def _open_footprint_editor_via_click(kicad_hwnd: int) -> bool:
"""Open the Footprint Editor by clicking its launcher icon in the project manager.
Returns True if the Footprint Editor window appeared.
"""
label_hwnd = _find_child_by_text(kicad_hwnd, "Footprint Editor")
if not label_hwnd:
return False
label_rect = RECT()
user32.GetWindowRect(label_hwnd, ctypes.byref(label_rect))
# Find the icon panel (small ~51x51 wxWindowNR "panel" near the label's y position)
icon_result = _find_icon_panel_near(kicad_hwnd, label_rect.top - 6)
if icon_result:
_, icon_rect = icon_result
click_x = (icon_rect.left + icon_rect.right) // 2
click_y = (icon_rect.top + icon_rect.bottom) // 2
else:
# Fallback: click just left of the label
click_x = label_rect.left - 30
click_y = (label_rect.top + label_rect.bottom) // 2
# Bring KiCad to foreground and click
_force_foreground(kicad_hwnd)
time.sleep(0.5)
_click_at(click_x, click_y)
# Wait for the Footprint Editor to appear
for _ in range(12):
time.sleep(0.5)
if _find_footprint_editor():
return True
return False
def _send_ctrl_shift_e(hwnd: int):
"""Send Ctrl+Shift+E to load the selected footprint in the Footprint Editor."""
VK_CONTROL = 0x11
VK_SHIFT = 0x10
VK_E = 0x45
_force_foreground(hwnd)
time.sleep(0.3)
user32.keybd_event(VK_CONTROL, 0, 0, 0)
user32.keybd_event(VK_SHIFT, 0, 0, 0)
user32.keybd_event(VK_E, 0, 0, 0)
user32.keybd_event(VK_E, 0, KEYEVENTF_KEYUP, 0)
user32.keybd_event(VK_SHIFT, 0, KEYEVENTF_KEYUP, 0)
user32.keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0)
def _navigate_to_footprint(fp_hwnd: int, footprint_name: str, load_after: bool = True) -> dict:
"""Call the PowerShell script to navigate to a specific footprint.
Uses the same navigate_symbol.ps1 script as the Symbol Editor handler.
If load_after is True, sends Ctrl+Shift+E to load the footprint after
searching.
"""
if not _NAVIGATE_SCRIPT.exists():
return {"status": "error", "error": f"Script not found: {_NAVIGATE_SCRIPT}"}
search_edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl")
if not search_edit:
return {"status": "error", "error": "Search edit control not found in Footprint Editor"}
try:
subprocess.run(
[
"powershell", "-ExecutionPolicy", "Bypass",
"-File", str(_NAVIGATE_SCRIPT),
"-SymEditorHwnd", str(fp_hwnd),
"-SearchEditHwnd", str(search_edit),
"-SymbolName", footprint_name,
],
capture_output=True, text=True, timeout=20,
)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
time.sleep(0.5)
windows = _find_windows_by_title("Footprint Editor")
for _, title, _ in windows:
if footprint_name in title:
return {"status": "ok", "title": title}
if "footprint editor" in title.lower() and "[no footprint loaded]" not in title.lower():
return {"status": "partial", "title": title}
# Footprint found in search but not loaded — try Ctrl+Shift+E
if load_after:
_send_ctrl_shift_e(fp_hwnd)
time.sleep(1.5)
windows = _find_windows_by_title("Footprint Editor")
for _, title, _ in windows:
if footprint_name in title:
return {"status": "ok", "title": title}
if "footprint editor" in title.lower() and "[no footprint loaded]" not in title.lower():
return {"status": "partial", "title": title}
return {"status": "noload", "title": "[no footprint loaded]"}
def _mac_open_footprint_editor(kicad_info: dict, args: dict) -> dict:
"""macOS: launch the Footprint Editor app directly. Footprint navigation
is not implemented (requires Accessibility permission)."""
parent = Path(kicad_info.get("base_dir", "")).parent
fp_app = parent / "Footprint Editor.app"
if not fp_app.exists():
return {
"success": False,
"error": f"Footprint Editor.app not found at {fp_app}",
"_hint": "Reinstall KiCad — the Footprint Editor app bundle is missing.",
}
try:
subprocess.Popen(
["open", "-a", str(fp_app)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception as e:
return {"success": False, "error": f"Failed to launch Footprint Editor: {e}"}
fp_name = args.get("footprintName", "") or args.get("footprint", "")
lib_name = args.get("libraryName", "") or args.get("library", "")
if fp_name or lib_name:
return {
"success": True,
"output": "Footprint Editor opened. Navigation to a specific footprint is not implemented on macOS.",
"_hint": "Ask the user to type the footprint name in the editor's search box.",
}
return {"success": True, "output": "Footprint Editor opened."}
def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:
"""Open the KiCad Footprint Editor and optionally load a specific footprint.
Args:
kicad_info: KiCad detection info dict.
args: Optional dict with:
- footprintName: Name of footprint to load (e.g. "ADOM_R0805").
- libraryName: Name of the library containing the footprint.
"""
if not kicad_info.get("installed"):
return {
"success": False,
"error": "KiCad not installed",
"_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
}
if IS_MACOS:
return _mac_open_footprint_editor(kicad_info, args or {})
if not IS_WINDOWS:
return {"success": False, "error": f"open_footprint_editor not implemented on {sys.platform!r}"}
footprint_name = args.get("footprintName", "") or args.get("footprint", "")
library_name = args.get("libraryName", "") or args.get("library", "")
# Check if the footprint is already loaded
if footprint_name:
fp_hwnd = _find_footprint_editor()
if fp_hwnd:
windows = _find_windows_by_title("Footprint Editor")
for hwnd, title, _ in windows:
if footprint_name in title:
_force_foreground(hwnd)
return {
"success": True,
"output": f"Footprint '{footprint_name}' is already loaded in the Footprint Editor.",
}
# --- Phase 1: Ensure Footprint Editor is open ---
fp_hwnd = _find_footprint_editor()
pathways_tried: list[str] = []
# Tier 0: open Footprint Editor from a RUNNING pcbnew (menu id 20572 —
# "Footprint Editor", discovered via get_menu_ids on KiCad 10). The Footprint
# Editor is hosted inside pcbnew, so when a board is open this is the reliable
# path and it does NOT need the kicad.exe project manager (which may not be
# running, and whose first-run "KiCad Setup" wizard otherwise blocks the icon
# click). Mirrors how open_symbol_editor drives a live eeschema. (ADOMBASELINE 2026-07.)
PCBNEW_FOOTPRINT_EDITOR_MENU_ID = 20572 # noqa: N806
if not fp_hwnd:
try:
from handlers.bridge_client import try_wm_command_via_bridge
tier0 = try_wm_command_via_bridge(
exe_name="pcbnew", menu_id=PCBNEW_FOOTPRINT_EDITOR_MENU_ID, frame_index=0,
)
pathways_tried.append("pcbnew" if tier0.get("success") else f"pcbnew-failed:{tier0.get('errorCode','')}")
if tier0.get("success"):
for _ in range(12):
time.sleep(0.5)
fp_hwnd = _find_footprint_editor()
if fp_hwnd:
break
if fp_hwnd:
_force_foreground(fp_hwnd)
if not footprint_name and not library_name:
return {
"success": True,
"output": "Footprint Editor opened successfully via a running pcbnew.",
"pathway": "pcbnew",
"pathwaysTried": pathways_tried,
"bridgePid": tier0.get("pid"),
}
# else fall through to navigate_footprint for footprint_name/library_name
except Exception as e: # pylint: disable=broad-except
pathways_tried.append(f"pcbnew-import-error:{type(e).__name__}")
# Phase 3 Tier 1: try the reverse bridge first if the kicad.exe
# project manager has the plugin loaded. WM_COMMAND 20013 from the
# Project Manager's Tools menu opens Footprint Editor. This sidesteps
# the flaky icon-click fallback documented at the top of this file
# (KiCad 9 launcher icons use dynamic WM_COMMAND IDs we can't rely on).
KICAD_FOOTPRINT_EDITOR_CMD_ID = 20013 # noqa: N806
if not fp_hwnd:
try:
from handlers.bridge_client import try_wm_command_via_bridge
tier1 = try_wm_command_via_bridge(
exe_name="kicad", menu_id=KICAD_FOOTPRINT_EDITOR_CMD_ID, frame_index=0,
)
pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")
if tier1.get("success"):
for _ in range(12):
time.sleep(0.5)
fp_hwnd = _find_footprint_editor()
if fp_hwnd:
break
if fp_hwnd:
_force_foreground(fp_hwnd)
if not footprint_name and not library_name:
return {
"success": True,
"output": "Footprint Editor opened successfully via bridge.",
"pathway": "plugin",
"pathwaysTried": pathways_tried,
"bridgePid": tier1.get("pid"),
}
# Fall through to navigate_footprint for footprint_name/library_name
except Exception as e: # pylint: disable=broad-except
pathways_tried.append(f"plugin-import-error:{type(e).__name__}")
if not fp_hwnd:
kicad_hwnd = _find_kicad_manager()
if not kicad_hwnd:
kicad_exe = kicad_info.get("kicad_exe")
if not kicad_exe or not Path(kicad_exe).exists():
return {
"success": False,
"error": "KiCad executable not found",
"_hint": "Report to the user and ask them to reinstall KiCad — the installation appears corrupted.",
}
try:
_si = None
try:
from handlers import win_focus
_si = win_focus.background_startupinfo() # open the manager behind the user
except Exception:
_si = None
subprocess.Popen(
[kicad_exe],
startupinfo=_si,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
return {
"success": False,
"error": f"Failed to launch KiCad: {e}",
"_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.",
}
for _ in range(12):
time.sleep(0.5)
kicad_hwnd = _find_kicad_manager()
if kicad_hwnd:
break
if not kicad_hwnd:
return {
"success": False,
"error": "KiCad launched but project manager window not found after 10s",
"_hint": "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry.",
}
time.sleep(2.0)
# Tier 1.5 — UIA background Invoke (the RELIABLE background fallback). KiCad
# exposes an invokable UIA tree, so we can trigger the "Footprint Editor"
# control PROGRAMMATICALLY: no foreground, no cursor move. Try a running
# pcbnew first (its toolbar/Tools has the control), then the project manager.
try:
from handlers import uia
uia_targets = [h for (h, _t, _o) in _find_windows_by_title("PCB Editor")]
if kicad_hwnd:
uia_targets.append(kicad_hwnd)
for tgt in uia_targets:
r = uia.uia_invoke(tgt, contains="Footprint Editor")
if r.get("ok"):
pathways_tried.append("uia")
for _ in range(12):
time.sleep(0.5)
fp_hwnd = _find_footprint_editor()
if fp_hwnd:
_force_foreground(fp_hwnd) # SW_SHOWNOACTIVATE — visible, not focused
if not footprint_name and not library_name:
return {
"success": True,
"output": "Footprint Editor opened via background UIA Invoke.",
"pathway": "uia",
"pathwaysTried": pathways_tried,
}
break
if fp_hwnd:
break
else:
pathways_tried.append(f"uia-{r.get('reason','miss')}")
except Exception as e: # pylint: disable=broad-except
pathways_tried.append(f"uia-error:{type(e).__name__}")
# Tier 2: legacy launcher-icon click — DISABLED BY DEFAULT. It MOVES the
# user's mouse cursor (SetCursorPos) and needs the manager foregrounded, so
# it hijacks the desktop mid-work. The bridge must never do that (John,
# 2026-07). We only run it if the caller EXPLICITLY opts in with
# {"allowCursorClick": true}; otherwise we fail gracefully and point at the
# background plugin path (which needs the reverse-bridge plugin loaded).
opened = False
if args.get("allowCursorClick"):
for attempt in range(2):
if _open_footprint_editor_via_click(kicad_hwnd):
opened = True
break
_force_foreground(kicad_hwnd)
time.sleep(1.0)
pathways_tried.append("icon_click" if opened else "icon_click_failed")
else:
pathways_tried.append("icon_click_skipped_background_mode")
if not opened:
return {
"success": False,
"error": "Could not open the Footprint Editor via the background (plugin) path.",
"pathwaysTried": pathways_tried,
"_hint": "The background WM_COMMAND path needs the reverse-bridge plugin loaded in a running pcbnew/kicad — open a board first (kicad_open_board) so pcbnew loads the plugin, then retry; the pcbnew tier (menu 20572) opens the Footprint Editor with NO focus theft. The mouse-moving launcher-click fallback is disabled by default; pass {\"allowCursorClick\":true} only if you accept it will move the cursor + foreground KiCad.",
}
fp_hwnd = _find_footprint_editor()
if not fp_hwnd:
return {
"success": False,
"error": "Footprint Editor launcher clicked but window did not appear.",
"_hint": "Call screenshot_all to check for a modal dialog, dismiss with send_key escape, then retry.",
}
time.sleep(1.5)
# --- Phase 2: Navigate to specific footprint (if requested) ---
if not footprint_name and not library_name:
_force_foreground(fp_hwnd)
return {"success": True, "output": "Footprint Editor opened successfully."}
search_text = footprint_name or library_name
nav_result = _navigate_to_footprint(fp_hwnd, search_text)
if nav_result["status"] == "ok":
return {
"success": True,
"output": f"Footprint '{search_text}' loaded in the Footprint Editor: {nav_result['title']}",
}
elif nav_result["status"] == "partial":
return {
"success": True,
"output": f"Footprint Editor loaded: {nav_result['title']}",
}
elif nav_result["status"] == "noload":
time.sleep(0.5)
nav_result2 = _navigate_to_footprint(fp_hwnd, search_text)
if nav_result2["status"] == "ok":
return {
"success": True,
"output": f"Footprint '{search_text}' loaded in the Footprint Editor: {nav_result2['title']}",
}
elif nav_result2["status"] == "partial":
return {
"success": True,
"output": f"Footprint Editor loaded: {nav_result2['title']}",
}
else:
return {
"success": True,
"output": f"Footprint Editor opened and searched for '{search_text}'. "
f"Use Ctrl+Shift+E to load the footprint if it didn't auto-load.",
}
else:
_force_foreground(fp_hwnd)
return {
"success": True,
"output": f"Footprint Editor opened but navigation failed: {nav_result.get('error', 'unknown')}. "
f"Search for '{search_text}' manually.",
}
def _find_pcb_editor() -> int | None:
"""Find an open PCB Editor (pcbnew) window."""
windows = _find_windows_by_title("PCB Editor")
for hwnd, title, _owner in windows:
if "pcb editor" in title.lower():
return hwnd
return None
def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict:
"""Open the 3D Viewer from the Footprint Editor or the PCB Editor.
Args:
editor: "auto" (default), "fp", or "pcb". Picks which editor to open
the 3D viewer from. v0.7.1+ — added to fix the bug where the FP
editor was always tried first and blocked the PCB fallback when
FP's WM_COMMAND silently failed (KiCad 10's command IDs differ
from KiCad 9's, so the FP path can fail without opening a
viewer AND without raising an error).
Windows: sends WM_COMMAND 20500 to Footprint Editor (KiCad 9; may differ
on 10), or Alt+3 keystroke / WM_COMMAND 20563 to PCB Editor.
macOS: not implemented — invoking a menu item in another app requires
Accessibility-framework permission.
"""
if IS_MACOS:
return {
"success": False,
"error": "open_3d_viewer is not implemented on macOS",
"_hint": ("On macOS, ask the user to open the 3D Viewer from the editor's "
"View menu (or press Alt+3 in PCB Editor). UI scripting requires "
"Accessibility permission."),
}
if not IS_WINDOWS:
return {"success": False, "error": f"open_3d_viewer not implemented on {sys.platform!r}"}
# Editor selection. "auto" means: try FP if open AND PCB isn't (since FP
# 3D viewer is per-footprint, PCB 3D viewer is per-board — caller usually
# has a strong preference even on "auto" once one editor dominates the
# screen). When BOTH are open, prefer PCB — that matches the common tour
# flow (FP gets the chip in step 4; PCB gets the board in step 7).
editor_choice = (args.get("editor") or "auto").lower()
if editor_choice not in {"auto", "fp", "footprint", "pcb", "board"}:
return {
"success": False,
"error": f"Unknown editor '{editor_choice}'. Use 'auto', 'fp', or 'pcb'.",
}
fp_hwnd = _find_footprint_editor()
pcb_hwnd = _find_pcb_editor()
if editor_choice in {"fp", "footprint"}:
if not fp_hwnd:
return {
"success": False,
"error": "Footprint Editor is not open.",
"_hint": "Call open_footprint_editor first or pass editor='pcb'.",
}
pcb_hwnd = None # skip pcb fallback when caller explicitly wants fp
elif editor_choice in {"pcb", "board"}:
if not pcb_hwnd:
return {
"success": False,
"error": "PCB Editor is not open.",
"_hint": "Call open_board first or pass editor='fp'.",
}
fp_hwnd = None # skip fp attempt when caller explicitly wants pcb
else:
# auto: prefer PCB when both are open (tour-like flow)
if pcb_hwnd and fp_hwnd:
fp_hwnd = None
# Try Footprint Editor (only when chosen / sole open editor)
if fp_hwnd:
WM_COMMAND = 0x0111
fp_pathways: list[str] = []
# 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"
# menu id to it (wm_command honors frame_index). No foreground, no keystroke.
# (Replaces the old Alt+3 path, which needed focus we deliberately don't take
# — and referenced VK_MENU as a bare global, raising UnboundLocalError because
# the pcbnew branch below assigns VK_MENU, making it function-local.)
try:
from handlers.bridge_client import (resolve_frame_index_via_bridge,
resolve_menu_id_via_bridge,
try_wm_command_via_bridge)
fp_fi = resolve_frame_index_via_bridge("pcbnew", "Footprint Editor")
if fp_fi is not None:
mid = resolve_menu_id_via_bridge("pcbnew", "3D Viewer", frame_index=fp_fi) or 20568
fp_pathways.append(f"plugin(frame={fp_fi},menuid={mid})")
r = try_wm_command_via_bridge(exe_name="pcbnew", menu_id=mid, frame_index=fp_fi)
if r.get("success"):
time.sleep(3.0)
windows = _find_windows_by_title("3D Viewer")
if windows:
return {
"success": True,
"output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",
"pathway": "plugin",
"pathwaysTried": fp_pathways,
}
else:
fp_pathways.append("plugin-no-fp-frame")
except Exception as e: # pylint: disable=broad-except
fp_pathways.append(f"plugin-error:{type(e).__name__}")
# Opt-in disruptive fallback: Alt+3 keystroke (needs the FP window foregrounded,
# so it steals focus) — only when the caller explicitly allows it.
if args.get("allowFocusSteal"):
VK_MENU_LOCAL = 0x12
VK_3 = 0x33
user32.SetForegroundWindow(fp_hwnd)
time.sleep(0.2)
try:
user32.keybd_event(VK_MENU_LOCAL, 0, 0, 0)
user32.keybd_event(VK_3, 0, 0, 0)
user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)
finally:
user32.keybd_event(VK_MENU_LOCAL, 0, KEYEVENTF_KEYUP, 0)
fp_pathways.append("alt3_focussteal")
time.sleep(2.5)
windows = _find_windows_by_title("3D Viewer")
if windows:
return {
"success": True,
"output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",
"pathway": "fp_alt3",
"pathwaysTried": fp_pathways,
}
# FP path failed (plugin frame absent AND no focus-steal fallback).
# If caller asked for 'auto' AND a PCB editor is also open, fall through
# to PCB attempt rather than dead-ending here.
if editor_choice == "auto" and pcb_hwnd is None:
# We nulled it above on auto+both; redo the lookup to recover.
pcb_hwnd = _find_pcb_editor()
if not pcb_hwnd:
return {
"success": False,
"error": "3D Viewer did not open from Footprint Editor.",
"_hint": "Call open_footprint_editor with a footprintName first, OR "
"pass editor='pcb' to target the board's 3D viewer instead.",
}
# else: fall through to PCB Editor attempt below
# Try PCB Editor (pcbnew) — Phase 3 prefers tier-1 bridge wm_command
# over the legacy Alt+3 keystroke. The bridge posts WM_COMMAND 20563
# (3D Viewer menu id from PCB Editor's View menu) which doesn't
# require foregrounding the window or stealing keyboard focus.
pcb_hwnd = _find_pcb_editor()
if pcb_hwnd:
# 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.
PCB_3D_VIEWER_CMD = 20563 # noqa: N806 legacy fallback (PCB Editor → View → 3D Viewer)
pathways_tried: list[str] = []
# Tier 1: bridge wm_command via pcbnew plugin
try:
from handlers.bridge_client import try_wm_command_via_bridge, resolve_menu_id_via_bridge
_live_id = resolve_menu_id_via_bridge(exe_name="pcbnew", contains="3D Viewer", frame_index=0)
_cmd_id = _live_id if _live_id else PCB_3D_VIEWER_CMD
pathways_tried.append(f"menuid={_cmd_id}{'(live)' if _live_id else '(fallback)'}")
tier1 = try_wm_command_via_bridge(
exe_name="pcbnew", menu_id=_cmd_id, frame_index=0,
)
pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")
if tier1.get("success"):
time.sleep(3.0)
windows = _find_windows_by_title("3D Viewer")
if windows:
return {
"success": True,
"output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",
"pathway": "plugin",
"pathwaysTried": pathways_tried,
"bridgePid": tier1.get("pid"),
}
except Exception as e: # pylint: disable=broad-except
pathways_tried.append(f"plugin-import-error:{type(e).__name__}")
# Tier 1.5 — UIA background Invoke of the "3D Viewer" control on pcbnew.
# Programmatic (no foreground, no cursor), replaces the Alt+3 keystroke.
try:
from handlers import uia
r = uia.uia_invoke(pcb_hwnd, contains="3D Viewer")
if r.get("ok"):
pathways_tried.append("uia")
time.sleep(3.0)
windows = _find_windows_by_title("3D Viewer")
if windows:
return {
"success": True,
"output": f"3D Viewer opened via background UIA Invoke: {windows[0][1]}",
"pathway": "uia",
"pathwaysTried": pathways_tried,
}
else:
pathways_tried.append(f"uia-{r.get('reason','miss')}")
except Exception as e: # pylint: disable=broad-except
pathways_tried.append(f"uia-error:{type(e).__name__}")
# Tier 2 / legacy: Alt+3 keystroke — needs pcbnew FOREGROUNDED so the global
# keystroke lands there (a global Alt+3 to the user's own window could fire
# their app's Alt+3). That steals focus, so it's OPT-IN only
# ({"allowFocusSteal": true}); by default we fail to the background path.
if args.get("allowFocusSteal"):
user32.SetForegroundWindow(pcb_hwnd)
time.sleep(0.3)
VK_MENU = 0x12 # Alt key
VK_3 = 0x33 # '3' key
KEYEVENTF_KEYUP = 0x0002
user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down
user32.keybd_event(VK_3, 0, 0, 0) # 3 down
user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0) # 3 up
user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up
pathways_tried.append("alt_keystroke")
time.sleep(3.0)
windows = _find_windows_by_title("3D Viewer")
if windows:
return {
"success": True,
"output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",
"pathway": "alt_keystroke",
"pathwaysTried": pathways_tried,
}
else:
pathways_tried.append("alt_keystroke_skipped_background_mode")
return {
"success": False,
"error": "3D Viewer did not open via the background (plugin) path.",
"pathwaysTried": pathways_tried,
"_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry.",
}
return {
"success": False,
"error": "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_pcb.",
"_hint": "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.",
}