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
0.9.297: Tour: motion on every beat for the recording (fit, wheel zoom in/out, middle-drag pans in the 2D editors; Home, an isometric left-drag and three orbits with zoom in the 3D viewers), sent only while the beat's window is confirmed in front and aborted at the next gesture on STOP or when the user switches away. The board-in-3D beat now closes the chip's 3D viewer first and picks the viewer owned by the PCB Editor (the last beat was showing the chip's viewer by title match). STOP from the remote's X now clears the caption and ends the tour within the beat, audio already off. Per-beat motion reports ride on the result.
1 file changed
+172
handlers/demo_motion.pyadded+172@@ -0,0 +1,172 @@+"""Motion for the KiCad tour (John, 2026-09-03: "for each step you need to add+motion like zooming in/out, panning, and on 3D you must go isometric and+rotate around the chip or board, cuz we're going to record a video").++ab exposes mouse move, click and keys, but no drag or wheel, so the bridge+sends the motion itself with SendInput (it runs on the desktop). Two rules:++1. Input only goes to OUR window while it is the foreground window. Every+ gesture re-checks GetForegroundWindow; the moment the user switches away,+ the choreography stops. Nothing ever lands in a window the user chose.+2. `abort()` is polled between gestures so a STOP from the remote ends the+ motion within one gesture.++Gestures use KiCad's own mouse grammar, identical in every editor:+ wheel zoom at the cursor+ middle-button drag pan (2D editors)+ left-button drag orbit (3D viewer)+ Home zoom to fit / reset view+"""++from __future__ import annotations++import ctypes+import ctypes.wintypes+import sys+import time++IS_WINDOWS = sys.platform == "win32"+if IS_WINDOWS:+ user32 = ctypes.windll.user32+else: # pragma: no cover+ user32 = None++MOUSEEVENTF_MOVE = 0x0001+MOUSEEVENTF_ABSOLUTE = 0x8000+MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004+MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP = 0x0020, 0x0040+MOUSEEVENTF_WHEEL = 0x0800+KEYEVENTF_KEYUP = 0x0002+VK_HOME = 0x24+WHEEL_DELTA = 120+++def _inputs():+ from handlers.kicad_ui import INPUT, INPUT_MOUSE, INPUT_KEYBOARD, _send_input+ return INPUT, INPUT_MOUSE, INPUT_KEYBOARD, _send_input+++def _rect(hwnd: int):+ r = ctypes.wintypes.RECT()+ user32.GetWindowRect(int(hwnd), ctypes.byref(r))+ return r.left, r.top, r.right, r.bottom+++def is_front(hwnd: int) -> bool:+ try:+ return int(user32.GetForegroundWindow()) == int(hwnd)+ except Exception: # pylint: disable=broad-except+ return False+++def _abs(x: int, y: int):+ """Screen pixel -> SendInput absolute (0..65535 over the virtual screen)."""+ SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 76, 77, 78, 79+ vx = user32.GetSystemMetrics(SM_XVIRTUALSCREEN); vy = user32.GetSystemMetrics(SM_YVIRTUALSCREEN)+ vw = user32.GetSystemMetrics(SM_CXVIRTUALSCREEN) or 1; vh = user32.GetSystemMetrics(SM_CYVIRTUALSCREEN) or 1+ return int((x - vx) * 65535 / vw), int((y - vy) * 65535 / vh)+++def _mouse(flags: int, x: int | None = None, y: int | None = None, data: int = 0) -> None:+ INPUT, INPUT_MOUSE, _, send = _inputs()+ inp = INPUT(); inp.type = INPUT_MOUSE+ mi = inp.union.mi+ if x is not None and y is not None:+ ax, ay = _abs(x, y)+ mi.dx, mi.dy = ax, ay+ flags |= MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE+ # SendInput ABSOLUTE maps to the PRIMARY monitor unless VIRTUALDESK is set+ flags |= 0x4000 # MOUSEEVENTF_VIRTUALDESK+ mi.mouseData = ctypes.c_ulong(data & 0xFFFFFFFF).value+ mi.dwFlags = flags+ send(inp)+++def move(x: int, y: int) -> None:+ _mouse(0, x, y)+++def wheel(clicks: int, x: int, y: int) -> None:+ """clicks > 0 zooms in (wheel up), < 0 out."""+ move(x, y); time.sleep(0.05)+ for _ in range(abs(clicks)):+ _mouse(MOUSEEVENTF_WHEEL, data=(WHEEL_DELTA if clicks > 0 else -WHEEL_DELTA))+ time.sleep(0.16)+++def drag(button: str, x0: int, y0: int, x1: int, y1: int, steps: int = 16, dt: float = 0.03) -> None:+ down, up = ((MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP) if button == "middle"+ else (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP))+ move(x0, y0); time.sleep(0.06)+ _mouse(down); time.sleep(0.05)+ for i in range(1, steps + 1):+ move(int(x0 + (x1 - x0) * i / steps), int(y0 + (y1 - y0) * i / steps))+ time.sleep(dt)+ _mouse(up); time.sleep(0.05)+++def key(vk: int) -> None:+ INPUT, _, INPUT_KEYBOARD, send = _inputs()+ d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk+ u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk; u.union.ki.dwFlags = KEYEVENTF_KEYUP+ send(d, u)+++def _canvas_center(hwnd: int):+ """The drawing canvas sits right of the editors' left panels: aim at 62%+ across and 55% down the window, which is canvas in every KiCad editor+ and in the 3D viewer (which has no side panel)."""+ l, t, r, b = _rect(hwnd)+ return int(l + (r - l) * 0.62), int(t + (b - t) * 0.55), (r - l), (b - t)+++def choreograph(hwnd: int, kind: str, abort=None, log=None) -> dict:+ """Run the beat's motion on `hwnd`. kind: '2d' | '3d'. Returns a report;+ never raises. Stops the instant the window is not in front or abort()."""+ rep = {"kind": kind, "gestures": 0, "aborted": None}+ if not IS_WINDOWS or not hwnd:+ rep["aborted"] = "not windows / no hwnd"+ return rep++ def gate(name: str) -> bool:+ if abort and abort():+ rep["aborted"] = f"stop requested before {name}"; return False+ if not is_front(hwnd):+ rep["aborted"] = f"window not in front before {name} (user switched away)"; return False+ return True++ try:+ cx, cy, w, h = _canvas_center(hwnd)+ dx, dy = int(w * 0.10), int(h * 0.08)+ if kind == "3d":+ plan = [+ ("fit", lambda: key(VK_HOME)),+ ("isometric", lambda: drag("left", cx, cy, cx - int(w * 0.09), cy - int(h * 0.11), steps=18)),+ ("orbit-1", lambda: drag("left", cx, cy, cx + int(w * 0.10), cy, steps=22)),+ ("zoom-in", lambda: wheel(3, cx, cy)),+ ("orbit-2", lambda: drag("left", cx, cy, cx + int(w * 0.10), cy + int(h * 0.02), steps=22)),+ ("zoom-out", lambda: wheel(-2, cx, cy)),+ ("orbit-3", lambda: drag("left", cx, cy, cx + int(w * 0.08), cy, steps=22)),+ ]+ else:+ plan = [+ ("fit", lambda: key(VK_HOME)),+ ("zoom-in", lambda: wheel(3, cx, cy)),+ ("pan-right", lambda: drag("middle", cx, cy, cx - dx, cy, steps=16)),+ ("pan-down", lambda: drag("middle", cx, cy, cx, cy - dy, steps=16)),+ ("zoom-in-2", lambda: wheel(2, cx, cy)),+ ("pan-back", lambda: drag("middle", cx, cy, cx + dx, cy + dy, steps=16)),+ ("zoom-out", lambda: wheel(-4, cx, cy)),+ ("fit-2", lambda: key(VK_HOME)),+ ]+ for name, fn in plan:+ if not gate(name):+ break+ fn()+ rep["gestures"] += 1+ if log:+ log(f"motion {kind}: {name}")+ time.sleep(0.45)+ except Exception as e: # pylint: disable=broad-except+ rep["error"] = f"{type(e).__name__}: {e}"+ return rep