app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
0.9.339: Tour motion: zoom depth per beat (one step for a single part, two plus one for a sheet or board)
b87c1d7
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
"""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 _menu_ids(hwnd: int) -> dict:
"""The 3D viewer's View-menu command ids, resolved from the live menu bar
(labels are stable across KiCad 8/9/10; ids are not). Empty when the walk
fails (then the caller falls back to mouse drags)."""
try:
from handlers import win_menu
except Exception: # pylint: disable=broad-except
return {}
ids = {}
for key, words in {"fit": ("zoom", "fit"), "zoom_in": ("zoom", "in"), "zoom_out": ("zoom", "out"),
"rotx_cw": ("rotate x", "clockwise"), "rotx_ccw": ("rotate x", "counter"),
"rotz_cw": ("rotate z", "clockwise"), "rotz_ccw": ("rotate z", "counter")}.items():
try:
cid, _label = win_menu.find_menu_command(hwnd, *words)
if cid:
ids[key] = int(cid)
except Exception: # pylint: disable=broad-except
pass
# "zoom in" also matches "Zoom In" only if "counter" did not swallow "clockwise" ids: guard the pairs
if ids.get("rotx_cw") and ids.get("rotx_cw") == ids.get("rotx_ccw"):
ids.pop("rotx_ccw", None)
if ids.get("rotz_cw") and ids.get("rotz_cw") == ids.get("rotz_ccw"):
ids.pop("rotz_ccw", None)
return ids
def choreograph_3d_menu(hwnd: int, abort=None, log=None, measure=None) -> dict | None:
"""Deterministic 3D motion through KiCad's own View menu (WM_COMMAND, 10
degrees per rotate, no mouse, no foreground needed): mouse drags over-rotated
the chip viewer and did nothing in the board viewer on 2026-09-03 because
drag sensitivity and the window under the cursor are not ours to control.
Returns None when the menu could not be resolved (caller falls back)."""
from handlers import win_menu
ids = _menu_ids(hwnd)
if not all(k in ids for k in ("rotx_cw", "rotz_cw", "zoom_in", "zoom_out")):
return None
rep = {"kind": "3d", "method": "menu", "gestures": 0, "aborted": None, "ids": ids}
plan = ([("extents", "fit", 1, 0.6)] if "fit" in ids else []) + [
("tilt", "rotx_cw", 3, 0.28), # ~30 degrees toward isometric
("turn", "rotz_cw", 4, 0.28), # ~40 degrees of yaw
("zoom-in", "zoom_in", 1, 0.5), # one measured step, never a close-up
("orbit", "rotz_cw", 6, 0.32), # a slow 60 degree orbit
("zoom-out", "zoom_out", 1, 0.5),
("orbit-back", "rotz_ccw" if "rotz_ccw" in ids else "rotz_cw", 4, 0.32),
] # NO trailing Zoom to Fit: in the 3D viewer it also RESETS the orientation, so the
# beat ended top-down on John's laptop (2026-09-03). Zoom in/out net to zero, so the
# board is still framed at the opening fit, now isometric.
for name, key, reps, dt in plan:
for _ in range(reps):
if abort and abort():
rep["aborted"] = f"stop requested during {name}"
return rep
if not win_menu.invoke_menu_command(hwnd, ids[key]):
rep["aborted"] = f"menu command refused during {name}"
return rep
rep["gestures"] += 1
time.sleep(dt)
if log:
log(f"motion 3d(menu): {name}")
time.sleep(0.4)
# Framing check (John, 2026-09-05): after every step look at the canvas.
# Board clipped on two opposite edges -> one zoom-out; board tiny -> one
# zoom-in. Recorded so the run says what it corrected.
if measure and name not in ("extents",):
try:
m = measure() or {}
t = set(m.get("touches") or [])
if ({"left", "right"} <= t or {"top", "bottom"} <= t) and "zoom_out" in ids:
win_menu.invoke_menu_command(hwnd, ids["zoom_out"]); time.sleep(0.5)
rep.setdefault("framed", []).append(f"{name}: clipped {sorted(t)} -> zoom out")
elif (m.get("coverage") or 0) < 0.10 and m.get("bbox") and "zoom_in" in ids:
win_menu.invoke_menu_command(hwnd, ids["zoom_in"]); time.sleep(0.5)
rep.setdefault("framed", []).append(f"{name}: coverage {m.get('coverage')} -> zoom in")
rep["lastCoverage"] = m.get("coverage")
except Exception: # pylint: disable=broad-except
pass
return rep
def choreograph(hwnd: int, kind: str, abort=None, log=None, check=None, measure=None, focus=None, zoom=(2, 1)) -> 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
# GetForegroundWindow can lag AD's z-order by a beat right after a raise
# (John's laptop, 2026-09-03: the symbol beat aborted at its FIRST
# gesture while AD had already measured the window on top). Give the
# OS up to 1.5 s to agree before calling it a user switch.
for _ in range(6):
if is_front(hwnd):
return True
time.sleep(0.25)
rep["aborted"] = f"window not in front before {name} (user switched away)"; return False
if kind == "3d":
try:
time.sleep(1.2) # let the STEP bodies load before the first turn
menu_rep = choreograph_3d_menu(hwnd, abort=abort, log=log, measure=measure)
if menu_rep is not None:
return menu_rep
except Exception as e: # pylint: disable=broad-except
rep["menuError"] = f"{type(e).__name__}: {e}"
try:
cx, cy, w, h = _canvas_center(hwnd)
dx, dy = int(w * 0.10), int(h * 0.08)
if kind == "3d":
# Measured 2026-09-03 on a 3840 px wide viewer: four drags totalling
# ~0.37 of the width turned the board nearly edge-on (KiCad's trackball
# is roughly 0.1 degree per pixel). Isometric wants ~30 degrees of
# tilt and ~40 of yaw, then gentle orbits that keep the board readable.
tilt = int(h * 0.12)
yaw = int(w * 0.03)
plan = [
("fit", lambda: key(VK_HOME)),
("isometric", lambda: drag("left", cx, cy, cx - int(w * 0.035), cy - tilt, steps=28, dt=0.035)),
("orbit-1", lambda: drag("left", cx, cy, cx + yaw, cy, steps=30, dt=0.04)),
("zoom-in", lambda: wheel(2, cx, cy)),
("orbit-2", lambda: drag("left", cx, cy, cx + yaw, cy, steps=30, dt=0.04)),
("zoom-out", lambda: wheel(-2, cx, cy)),
("orbit-3", lambda: drag("left", cx, cy, cx - yaw, cy, steps=30, dt=0.04)),
]
else:
# John, 2026-09-03: "your zooming is too close ... zoom extents, then be
# smart about how close". Extents first, two wheel clicks in (about
# 1.7x), a short pan, back out, and END on extents so the frame the
# user is left with is the whole thing.
# John, 2026-09-05: "be smart about your zooming/panning cuz you still
# don't end up showing important stuff". Aim at the featured part: after
# extents, measure where the drawing sits on the canvas and map the
# part's fraction (from the design files) onto it; wheel-zoom AT that
# point (KiCad zooms about the cursor), pan it to the centre, hold,
# zoom back out, extents.
aim = {"x": cx, "y": cy}
def _aim():
key(VK_HOME); time.sleep(0.7)
if measure and focus:
try:
m = measure() or {}
bb = m.get("bbox")
if bb:
aim["x"] = int(bb[0] + float(focus[0]) * (bb[2] - bb[0]))
aim["y"] = int(bb[1] + float(focus[1]) * (bb[3] - bb[1]))
rep["aim"] = {"x": aim["x"], "y": aim["y"], "bbox": bb, "coverage": m.get("coverage")}
except Exception: # pylint: disable=broad-except
pass
# zoom depth per beat (John, 2026-09-05: the footprint take opened on a
# giant REFERENCE label). A single part fills the canvas at extents, so
# it gets one step; a whole sheet or board gets two plus one after centring.
z1, z2 = (int(zoom[0]), int(zoom[1])) if zoom else (2, 1)
plan = [
("extents", _aim),
("zoom-in", lambda: wheel(z1, aim["x"], aim["y"])),
("centre", lambda: drag("middle", aim["x"], aim["y"], cx, cy, steps=18)),
] + ([("zoom-in-2", lambda: wheel(z2, cx, cy))] if z2 else []) + [
("pan-look", lambda: drag("middle", cx, cy, cx - dx, cy - dy, steps=14)),
("pan-back", lambda: drag("middle", cx - dx, cy - dy, cx, cy, steps=14)),
("zoom-out", lambda: wheel(-(z1 + z2), cx, cy)),
("extents-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)
# John, 2026-09-05: "don't be dumb about your zooming or panning such
# that content goes off the screen ... analyze your motion". After
# every 2D gesture that can empty the view, look at the canvas; a
# blank one means we panned off the content: recenter on extents.
if check and kind != "3d" and name.startswith(("pan", "zoom-in", "centre")):
try:
if not check():
key(VK_HOME); time.sleep(0.45)
rep.setdefault("recentered", []).append(name)
except Exception: # pylint: disable=broad-except
pass
except Exception as e: # pylint: disable=broad-except
rep["error"] = f"{type(e).__name__}: {e}"
return rep