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.340: land the shipped runtime source for handlers/demo.py (tour fixes 0.9.318-0.9.339 never reached master, issue #79)
1 file changed
+365−18
handlers/demo.py+365−18@@ -327,6 +327,81 @@ def _patch_models(pcb: str) -> str: return _re.sub(r'\n\s*\(model "(?:[A-Za-z]:/|\.\./)[^"]*"(?:(?!\n\t\t\(model)[\s\S])*?\n\t\t\)', "", pcb) +_MODEL_ALIASES = { # footprint id -> KiCad standard body when the name does not match a KiCad model+ "MOSFET_CDT:TDSON-8-1_BSC016N06NS": "Package_TO_SOT_SMD.3dshapes/TDSON-8-1",+}+++def _attach_standard_models(pcb: str, kicad_info: dict) -> tuple[str, dict]:+ """John, 2026-09-05: "why don't you have all the chips loaded in that final 3d+ board? there's no mosfets or microcontroller". The wiki board's author left+ many footprints (the LQFP-48 MCU, the six TDSON MOSFETs, the SOT-89 regulator,+ the pin headers) with NO (model ...) at all. For each footprint without a+ model, attach KiCad's own body when one exists on this box for the same+ Lib:Name (or an alias), so the 3D beat shows chips instead of bare pads.+ Only the tour's fetched copy is touched, never the wiki page."""+ import re as _re, os as _os+ base = (kicad_info or {}).get("base_dir") or ""+ root = _os.path.join(base, "share", "kicad", "3dmodels") if base else ""+ report = {"attached": 0, "skipped": 0, "missingModels": [], "root": root,+ "aliasProbe": {k: _os.path.exists(_os.path.join(root, *(v + ".step").split("/"))) for k, v in _MODEL_ALIASES.items()} if root else {}}+ if not root or not _os.path.isdir(root):+ report["why"] = f"no 3dmodels dir under {base!r}"+ return pcb, report+ out = []; pos = 0+ for m in _re.finditer(r'\(footprint "([^"]+)"', pcb):+ start = m.start()+ block = _sexpr_block(pcb, start)+ end = start + len(block)+ fp_id = m.group(1)+ # A body KiCad cannot load on THIS box is dead: the author's own disk+ # (C:/..., ../, a private ${VAR}) or a ${KICAD10_3DMODEL_DIR} file that is+ # not installed here. Drop it (balanced block) so a standard one can take+ # over. (arav-rog 2026-09-05: the MOSFETs carried a second, missing model.)+ if "MOSFET" in fp_id and len(report.setdefault("debug", [])) < 8:+ report["debug"].append({"id": fp_id, "modelsBefore": _re.findall(r'\(model "([^"]+)"', block)})+ pos_m = 0+ while True:+ dm = _re.search(r'\(model "([^"]+)"', block[pos_m:])+ if not dm:+ break+ at = pos_m + dm.start()+ path = dm.group(1)+ dead = bool(_re.match(r'(?:[A-Za-z]:[/\\]|\.\./|\$\{ADOM_)', path))+ if not dead and path.startswith("${KICAD10_3DMODEL_DIR}/"):+ dead = not _os.path.exists(_os.path.join(root, *path[len("${KICAD10_3DMODEL_DIR}/"):].split("/")))+ if dead:+ mb = _sexpr_block(block, at)+ cut0 = at+ while cut0 > 0 and block[cut0 - 1] in " \t\r\n":+ cut0 -= 1+ block = block[:cut0] + block[at + len(mb):]+ report["deadDropped"] = report.get("deadDropped", 0) + 1+ pos_m = cut0+ else:+ pos_m = at + len(dm.group(0))+ if "(model " not in block and ":" in fp_id:+ lib, name = fp_id.split(":", 1)+ cand = _MODEL_ALIASES.get(fp_id) or f"{lib}.3dshapes/{name}"+ rel = None+ for ext in (".step", ".wrl"):+ if _os.path.exists(_os.path.join(root, *(cand + ext).split("/"))):+ rel = cand + ext; break+ if "MOSFET" in fp_id:+ report["debug"].append({"id": fp_id, "cand": cand, "rel": rel})+ if rel:+ model = ('\n\t\t(model "${KICAD10_3DMODEL_DIR}/%s"\n\t\t\t(offset (xyz 0 0 0))\n\t\t\t(scale (xyz 1 1 1))\n\t\t\t(rotate (xyz 0 0 0))\n\t\t)' % rel)+ block = block[:-1].rstrip() + model + "\n\t)"+ report["attached"] += 1+ else:+ report["skipped"] += 1+ if len(report["missingModels"]) < 12 and fp_id not in report["missingModels"]:+ report["missingModels"].append(fp_id)+ out.append(pcb[pos:start]); out.append(block); pos = end+ out.append(pcb[pos:])+ return "".join(out), report++ def _extract_symbol(sch_text: str, lib_id: str) -> str: """One embedded library symbol (lib_symbols block) as a standalone .kicad_sym.""" i = sch_text.find("(lib_symbols")@@ -388,6 +463,47 @@ def _prepare(kicad_info: dict) -> dict: "project": files.get("project")}) sch_text = Path(files["schematic"]).read_text(encoding="utf-8", errors="replace") pcb_text = Path(files["board"]).read_text(encoding="utf-8", errors="replace")+ try:+ patched, rep = _attach_standard_models(pcb_text, kicad_info)+ if rep.get("attached") and patched != pcb_text:+ Path(files["board"]).write_text(patched, encoding="utf-8")+ pcb_text = patched+ # pcbnew keeps the OLD copy in memory; close the tour's board and its+ # viewers so _warm_kicad reopens the patched file (arav-rog 2026-09-05:+ # bodies were attached on disk and the 3D beat still showed bare pads).+ try:+ tt = _tour_titles({"boardName": DEMO_BOARD["name"]})+ for h in _viewers_3d():+ _close_hwnd(h)+ bh = _hwnd_for_title(tt["board"])+ if bh:+ _close_hwnd(bh)+ rep["boardReloaded"] = True+ except Exception as e: # pylint: disable=broad-except+ rep["reloadError"] = f"{type(e).__name__}: {e}"+ out["models3dAttached"] = rep+ print(f"[demo] 3D bodies: {rep}", file=_sys.stderr)+ # The open PCB editor holds whatever copy it loaded; if the file on disk+ # is newer than the running pcbnew (patched by this or an earlier tour),+ # close the tour's board and viewers so _warm_kicad reopens the fresh+ # file (arav-rog 2026-09-05: bodies attached on disk, viewer still bare).+ try:+ import os as _os+ started = _kicad_started_at()+ bf = files.get("board")+ if bf and started and _os.path.getmtime(bf) > started and not rep.get("boardReloaded"):+ tt = _tour_titles({"boardName": DEMO_BOARD["name"]})+ for h in _viewers_3d():+ _close_hwnd(h)+ bh = _hwnd_for_title(tt["board"])+ if bh:+ _close_hwnd(bh)+ rep["boardReloaded"] = "file newer than the running KiCad"+ except Exception as e: # pylint: disable=broad-except+ rep["reloadError"] = f"{type(e).__name__}: {e}"+ except Exception as e: # pylint: disable=broad-except+ out["warnings"].append(f"3D bodies: {e}")+ out["focus"] = _focus_fractions(sch_text, pcb_text) # where the MCU sits, for motion that shows it try: sym = _extract_symbol(sch_text, DEMO_BOARD["chipLibId"]) r = handle_install_symbol(kicad_info, {"fileName": f"{IC_SYM}.kicad_sym", "symbolName": IC_SYM,@@ -402,6 +518,8 @@ def _prepare(kicad_info: dict) -> dict: "fileContent": _b64(fp), "quietInstall": True}) (out["installed"] if r.get("success") else out["warnings"]).append( IC_FP if r.get("success") else f"{IC_FP}: {r.get('error')}")+ out["footprintWasNew"] = bool(r.get("success")) and not r.get("existedBefore")+ out["footprintPath"] = r.get("installedPath") except Exception as e: # pylint: disable=broad-except out["warnings"].append(f"{IC_FP}: {e}") lqfp = _model_path(kicad_info, "Package_QFP.3dshapes", "LQFP-48_7x7mm_P0.5mm")@@ -511,7 +629,25 @@ def _run_step(kicad_info: dict, step: str, prep: dict) -> dict: f"registered in your '{DEMO_LIB}' library, not a scratch file"], "raw": r} if step == "footprint":- r = handle_open_footprint_editor(kicad_info, {"libraryName": DEMO_LIB, "footprintName": IC_FP})+ # arav-rog 2026-09-05: KiCad was cold when the tour started, the+ # footprint beat ran before the project manager's plugin was alive and+ # every open tier failed. Wait for the plugin (bounded) and retry.+ # The Footprint Editor is hosted by pcbnew, and its background open needs+ # the reverse-bridge plugin alive INSIDE pcbnew (it arms ~5 s after the+ # PCB editor appears). Make sure the board is open, wait for the plugin,+ # then try twice.+ if not _hwnd_for_title(_tour_titles(prep)["board"]):+ _warm_kicad(kicad_info, prep)+ _wait_plugin_alive("pcbnew", 45)+ r = {}+ for _attempt in range(2):+ r = handle_open_footprint_editor(kicad_info, {"libraryName": DEMO_LIB, "footprintName": IC_FP})+ _fh = _hwnd_for_title(_tour_titles(prep)["footprint"])+ if r.get("success") and _fh:+ break+ if _EXIT["now"]:+ break+ __import__("time").sleep(min(45, (r.get("retryAfterMs") or 4000) / 1000.0) if r.get("errorCode") == "kicad_restarted_retry" else 4) _fh = _hwnd_for_title(_tour_titles(prep)["footprint"]) return {"ok": bool(r.get("success")) and bool(_fh), "window": _tour_titles(prep)["footprint"], "hwnd": _fh, "title": "2/6 · Its footprint",@@ -638,10 +774,10 @@ def _job_update(**kw): # KiCad remembers a frame's size when it closes, so every bound we touch is # saved first and restored at the end of the tour (or its stop, or a crash on # the next tour start via the leftovers file).-_LAYOUT_SLOTS = { # (x-anchor, y-anchor): 0 = top/left edge, 1 = bottom/right edge- "symbol": (0.0, 0.0), "footprint": (0.15, 0.0), "footprint3d": (1.0, 0.0),- "schematic": (0.0, 1.0), "board": (0.15, 1.0), "board3d": (1.0, 1.0),-}+# John, 2026-09-05: "make all of them be 95% covering the entire display and center it", then "go 98% sizing".+# (The earlier 80% corner slots are gone; every editor and viewer window lands here.)+_LAYOUT_FRACTION = 0.98+_LAYOUT_SLOTS = {k: (0.5, 0.5) for k in ("symbol", "footprint", "footprint3d", "schematic", "board", "board3d")} _SAVED_BOUNDS: dict = {} @@ -703,7 +839,7 @@ def _place_window(hwnd: int, step: str, title: str) -> dict: return {"placed": False, "why": "no monitor bounds or no hwnd"} ax, ay = _LAYOUT_SLOTS.get(step, (0.5, 0.5)) W, H = int(mon.get("width") or 0), int(mon.get("height") or 0)- w, h = int(W * 0.8), int(H * 0.8)+ w, h = int(W * _LAYOUT_FRACTION), int(H * _LAYOUT_FRACTION) x = int(mon.get("x", 0) + ax * (W - w)); y = int(mon.get("y", 0) + ay * (H - h)) _save_bounds(hwnd, title) try:@@ -788,9 +924,10 @@ def _verify_beat(hwnd: int | None, expected_title: str, tries: int = 2) -> dict: for attempt in range(tries): v["attempts"] = attempt + 1 rows = [r for r in (kicad_windows.find(all_windows=True, fresh=True) or []) if r.get("z") is not None]- top = sorted(rows, key=lambda r: r["z"])[0] if rows else {}+ fh = _front_hwnd(rows)+ top = next((r for r in rows if int(r.get("hwnd") or 0) == fh), {}) v["frontTitle"] = top.get("title")- v["front"] = int(top.get("hwnd") or 0) == int(hwnd)+ v["front"] = fh == int(hwnd) mine = next((r for r in rows if int(r.get("hwnd") or 0) == int(hwnd)), None) live_title = (mine or {}).get("title") or "" v["liveTitle"] = live_title@@ -861,6 +998,7 @@ def _foreground_once(window_title: str, hwnd: int | None = None) -> bool: win_focus.allow_activation(target) except Exception: # pylint: disable=broad-except pass+ _caption_note(f"Bringing the {window_title} to the front") raised = False try: r = ad_client.call("desktop_bring_to_front",@@ -878,15 +1016,159 @@ def _foreground_once(window_title: str, hwnd: int | None = None) -> bool: import time as _t for _ in range(4): # up to ~1 s for the z-order to settle _t.sleep(0.25)- rows = [x for x in (kicad_windows.find(all_windows=True, fresh=True) or []) if x.get("z") is not None]- if rows and int(sorted(rows, key=lambda x: x["z"])[0].get("hwnd") or 0) == target:+ rows = kicad_windows.find(all_windows=True, fresh=True) or []+ if _front_hwnd(rows) == target:+ _caption_note(f"Brought the {window_title} to the front") return True+ _caption_note(f"The {window_title} stayed behind another window; leaving your desktop alone", 5000) return False except Exception: # pylint: disable=broad-except return False ++def _focus_fractions(sch_text: str, pcb_text: str) -> dict:+ """Where the featured part (DEMO_BOARD chipRef) sits, as fractions of the+ drawn content: the sheet for the schematic, the Edge.Cuts box for the board.+ Motion aims its zoom there instead of the canvas centre (John, 2026-09-05:+ "you still don't end up showing important stuff"). Measured for esc-g431:+ schematic (0.20, 0.54) on A3, board (0.23, 0.66)."""+ import re as _re+ out = {}+ try:+ m = _re.search(r'\(symbol\s*\(lib_id "%s"\)\s*\(at ([-\d.]+) ([-\d.]+)' % _re.escape(DEMO_BOARD["chipLibId"]), sch_text)+ paper = (_re.search(r'\(paper "([^"]+)"', sch_text) or [None, "A4"])[1]+ sizes = {"A4": (297, 210), "A3": (420, 297), "A2": (594, 420), "A1": (841, 594), "A0": (1189, 841),+ "A": (279.4, 215.9), "B": (431.8, 279.4), "C": (558.8, 431.8), "D": (863.6, 558.8), "E": (1117.6, 863.6)}+ pw, ph = sizes.get(paper, (420, 297))+ if _re.search(r'\(paper "[^"]+" portrait\)', sch_text):+ pw, ph = ph, pw+ if m:+ out["schematic"] = (round(float(m.group(1)) / pw, 3), round(float(m.group(2)) / ph, 3))+ except Exception: # pylint: disable=broad-except+ pass+ try:+ i = pcb_text.find('(property "Reference" "%s"' % DEMO_BOARD["chipRef"])+ j = pcb_text.rfind("(footprint ", 0, i)+ m = _re.search(r'\(at ([-\d.]+) ([-\d.]+)', pcb_text[j:i]) if i > 0 and j >= 0 else None+ xs, ys = [], []+ for mm in _re.finditer(r'\((?:gr_line|gr_arc|gr_rect|gr_circle)[\s\S]*?\(layer "Edge\.Cuts"\)', pcb_text):+ for c in _re.finditer(r'\((?:start|end|mid) ([-\d.]+) ([-\d.]+)\)', mm.group(0)):+ xs.append(float(c.group(1))); ys.append(float(c.group(2)))+ if m and xs and ys and max(xs) > min(xs) and max(ys) > min(ys):+ fx = (float(m.group(1)) - min(xs)) / (max(xs) - min(xs)); fy = (float(m.group(2)) - min(ys)) / (max(ys) - min(ys))+ out["board"] = (round(min(max(fx, 0.0), 1.0), 3), round(min(max(fy, 0.0), 1.0), 3))+ except Exception: # pylint: disable=broad-except+ pass+ return out+++def _restart_for_new_footprint(kicad_info: dict, prep: dict) -> dict:+ """KiCad caches footprint libraries per PROCESS (#47). On a first-run box the+ tour's footprint is brand new, and a running KiCad cannot see it until it+ restarts; the opener did that restart MID-BEAT on arav-rog (2026-09-05),+ which closed every window the tour had opened and cost six minutes. Do it+ here, under the 'preparing' caption, before any window is on screen."""+ # KiCad caches footprint libraries per process: it needs a restart when the+ # file is NEW, or when it was (re)written after this KiCad started (arav-rog+ # 2026-09-05: earlier tours rewrote the file; the beat then failed with+ # "cache predates the install" on every run). Compare file mtime with the+ # process start time instead of trusting a flag.+ needs = bool(prep.get("footprintWasNew"))+ why = "footprint is new"+ try:+ import os as _os+ fp = prep.get("footprintPath")+ started = _kicad_started_at()+ if fp and started and _os.path.exists(fp) and _os.path.getmtime(fp) > started:+ needs, why = True, "footprint file is newer than the running KiCad"+ except Exception: # pylint: disable=broad-except+ pass+ if not needs:+ return {"restarted": False, "reason": "footprint already known to KiCad"}+ try:+ from handlers.kicad_ui import _find_all_kicad_windows+ if not (_find_all_kicad_windows() or []):+ return {"restarted": False, "reason": "KiCad not running; a fresh launch sees the new footprint"}+ from handlers.open_footprint_editor import _restart_kicad_for_cache+ _caption("KiCad tour 1/7: restarting KiCad so it sees the new footprint", duration_ms=40000)+ r = _restart_kicad_for_cache(kicad_info) or {}+ if r.get("restarted"):+ import time as _t+ t0 = _t.monotonic()+ while _t.monotonic() - t0 < 60 and not (_find_all_kicad_windows() or []):+ _t.sleep(1.0)+ return r+ except Exception as e: # pylint: disable=broad-except+ return {"restarted": False, "reason": f"{type(e).__name__}: {e}"}+++def _kicad_started_at() -> float | None:+ """Earliest process start time (epoch seconds) among the live KiCad windows,+ via GetProcessTimes. None off Windows or when nothing is running."""+ try:+ import ctypes, ctypes.wintypes as wt+ from handlers import kicad_windows+ pids = {int(r.get("pid") or 0) for r in (kicad_windows.find(fresh=True) or []) if r.get("pid")}+ if not pids:+ return None+ k32 = ctypes.windll.kernel32+ best = None+ for pid in pids:+ h = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION+ if not h:+ continue+ c, e, kt, ut = wt.FILETIME(), wt.FILETIME(), wt.FILETIME(), wt.FILETIME()+ ok = k32.GetProcessTimes(h, ctypes.byref(c), ctypes.byref(e), ctypes.byref(kt), ctypes.byref(ut))+ k32.CloseHandle(h)+ if ok:+ t = ((c.dwHighDateTime << 32) | c.dwLowDateTime) / 1e7 - 11644473600.0+ best = t if best is None else min(best, t)+ return best+ except Exception: # pylint: disable=broad-except+ return None+++def _wait_plugin_alive(exe: str, seconds: float) -> bool:+ """Bounded wait for the reverse-bridge plugin inside `exe` (kicad or pcbnew)+ to be resolvable and alive. Returns True when it is."""+ import time as _t+ t0 = _t.monotonic()+ while _t.monotonic() - t0 < seconds:+ try:+ from handlers.bridge_client import _resolve_plugin+ info, err = _resolve_plugin(exe)+ if info and not err:+ return True+ except Exception: # pylint: disable=broad-except+ pass+ if _EXIT["now"]:+ return False+ _t.sleep(1.5)+ return False+++_FRONT_IGNORE_TITLES = ("popuphost", "adom bridge recorder", "adom demo panel")+_FRONT_IGNORE_IMAGES = ("explorer.exe", "shellexperiencehost.exe", "searchhost.exe", "startmenuexperiencehost.exe",+ "textinputhost.exe", "applicationframehost.exe")+++def _front_hwnd(rows: list) -> int:+ """The hwnd a user would call 'in front': lowest z among windows that are+ not minimized, not Windows shell popups, not our own overlays."""+ cands = []+ for r in rows or []:+ if r.get("z") is None or r.get("minimized"):+ continue+ t = str(r.get("title") or "").lower(); im = str(r.get("image") or "").lower()+ if any(k in t for k in _FRONT_IGNORE_TITLES) or im in _FRONT_IGNORE_IMAGES:+ continue+ cands.append(r)+ top = sorted(cands, key=lambda r: r["z"])[0] if cands else {}+ return int(top.get("hwnd") or 0)++ def _hwnd_for_title(window_title: str) -> int | None: try: from handlers.kicad_ui import _find_all_kicad_windows@@ -1022,6 +1304,28 @@ _BEAT_CAPTIONS = { } _BEAT_CAPTION_MS = {"prepare": 35000, "schematic": 25000, "board": 25000, "board3d": 40000, "symbol": 30000, "footprint": 30000, "footprint3d": 35000}+# John, 2026-09-05: every beat is two parts. Part A loads the window and the+# document (its own short line, so the viewer is never stuck watching a load in+# silence); part B starts only when the window is verified on screen, with the+# motion and the line that explains what is being shown.+_BEAT_CAPTIONS_B = {+ "symbol": "Zooming in on the STM32 pins, then back out to the whole symbol",+ "footprint": "48 pads on 0.5 mm pitch: zooming in, then back out",+ "footprint3d": "The LQFP body on its pads, tilted to isometric and orbited",+ "schematic": "Zooming in on the microcontroller block, then back out to the sheet",+ "board": "Zooming in on the MCU and its decoupling, then back out",+ "board3d": "Isometric orbit around the MOSFETs and the MCU",+}+++def _wait_narration(max_s: float) -> None:+ """Let the current line finish (bounded), honouring exit and pending presses."""+ t0 = _time.monotonic()+ while _time.monotonic() - t0 < max_s:+ pr = _NARR.get("proc")+ if not pr or pr.poll() is not None or _EXIT["now"] or _pending_interrupt():+ return+ _time.sleep(0.2) # ── Narration: PRE-RENDERED mp3s, never on-the-fly TTS (John, 2026-08-24) ───@@ -1034,6 +1338,7 @@ _NARR: dict = {"proc": None, "muted": False} def _play_narration(step: str) -> None:+ _NARR["startedAt"] = _time.time() _NARR["step"] = step if _NARR.get("muted"): return@@ -1417,7 +1722,7 @@ def _caption(text: str, duration_ms: int = 60000) -> None: # MEDIUM size explicitly. r = ad_client.call("desktop_caption", {"id": _CAPTION_ID, "text": text, "duration": duration_ms,- "size": "medium", "position": "bottom",+ "size": "medium", "position": "bottom-left", # the demo panel owns bottom-right (John, 2026-09-05) "reason": "Narrate the KiCad tour the user started, one line per beat"}, timeout=6) or {} ok = bool(r.get("success")) and not r.get("error")@@ -1433,8 +1738,8 @@ def _caption_ack(text: str) -> None: indicating you got my signal').""" try: from handlers import ad_client- r = ad_client.call("desktop_caption", {"id": "kicad-demo-ack", "text": text, "duration": 3500,- "size": "medium", "position": "top",+ r = ad_client.call("desktop_caption", {"id": "kicad-demo-note", "text": text, "duration": 3500,+ "size": "small", "position": "top", "reason": "Acknowledge a press on the KiCad tour remote the instant it arrives"}, timeout=6) or {} with _JOB_LOCK: _JOB.setdefault("captions", []).append({"text": text[:60], "ack": True,@@ -1444,6 +1749,25 @@ def _caption_ack(text: str) -> None: pass +def _caption_note(text: str, duration_ms: int = 4000) -> None:+ """Small caption at the TOP for what the tour is doing to the desktop right+ now, above all "bringing the X window to the front" (John, 2026-09-05: "put+ ab captions (small) at the top indicating each time you're foregrounding a+ kicad window"). Shares the id with the press acks: same strip, replaced in+ place, never stacked."""+ try:+ from handlers import ad_client+ r = ad_client.call("desktop_caption", {"id": "kicad-demo-note", "text": text, "duration": duration_ms,+ "size": "small", "position": "top",+ "reason": "Tell the user which KiCad window the tour is bringing forward"}, timeout=6) or {}+ with _JOB_LOCK:+ _JOB.setdefault("captions", []).append({"text": text[:60], "note": True,+ "ok": bool(r.get("success")) and not r.get("error"),+ "error": (r.get("error") or None)})+ except Exception: # pylint: disable=broad-except+ pass++ def _pending_interrupt() -> bool: """A transport press that must cut the beat in flight (John, 2026-09-04: "i would expect your prev/next to be instant, not wait for next beat").@@ -1523,7 +1847,7 @@ def _run_demo_background(kicad_info: dict) -> None: _drain_stale_presses() _restore_bounds("leftovers") # a crashed earlier tour never leaves windows moved _close_stale_tour_windows()- _center_main_window()+ # John, 2026-09-05: leave KiCad's main window exactly as KiCad opens it; only the editor/viewer windows get the 80% slots. _panel_show(0, "playing") _caption(_BEAT_CAPTIONS["prepare"], duration_ms=_BEAT_CAPTION_MS["prepare"]) _play_narration("prepare")@@ -1531,6 +1855,7 @@ def _run_demo_background(kicad_info: dict) -> None: _job_update(step="prepare", stepLabel="Preparing the sample project", percent=int(100 * _BEAT_EST["prepare"] * 0.5 / total_est), elapsedSec=round(_t.monotonic() - t0))+ prep["cacheRestart"] = _restart_for_new_footprint(kicad_info, prep) prep["warm"] = _warm_kicad(kicad_info, prep) done_weight += _BEAT_EST["prepare"] shot = _beat_shot(kicad_info, "Preparing the sample project", "KiCad")@@ -1575,6 +1900,7 @@ def _run_demo_background(kicad_info: dict) -> None: # ("next" simply proceeds: we are already between beats, so moving # on IS skipping the wait; a next during a beat applies here.) s_ = STEPS[i]+ _beat_t0 = _time.time() # box epoch: lets a recording be synced to the beats afterwards _job_update(step=s_, stepLabel=f"Opening {s_.replace('_', ' ')}", beatIndex=i + 1, beatCount=len(STEPS), percent=min(95, int(100 * done_weight / total_est)),@@ -1582,8 +1908,11 @@ def _run_demo_background(kicad_info: dict) -> None: _panel_show(i + 1, "playing") if s_ in _BEAT_CAPTIONS: _caption(_BEAT_CAPTIONS[s_], duration_ms=_BEAT_CAPTION_MS.get(s_, 30000))- _play_narration(s_)+ _play_narration(f"{s_}-a" if (_AUDIO_DIR / f"{s_}-a.mp3").exists() else s_) # part A: loading r = _run_step(kicad_info, s_, prep)+ r["startedAt"] = _beat_t0+ r["partAAt"] = _NARR.get("startedAt")+ r["narrationAt"] = r["partAAt"] if not r.get("ok"): # 10.0.5 regression (found live 2026-08-16): a modal dialog at # editor-open stalled the first beats. STAGED calls get the@@ -1626,10 +1955,23 @@ def _run_demo_background(kicad_info: dict) -> None: else: # Motion for the recording (John): only on a verified window, and it # stops at the next gesture on STOP or a switch away.+ # Part B: the window is verified on screen; let part A finish,+ # then the explaining line and the motion together.+ _wait_narration(12.0)+ r["partBAt"] = _time.time()+ if s_ in _BEAT_CAPTIONS_B:+ _caption(_BEAT_CAPTIONS_B[s_], duration_ms=25000)+ if (_AUDIO_DIR / f"{s_}-b.mp3").exists():+ _play_narration(f"{s_}-b") try: from handlers import demo_motion kind = "3d" if s_ in ("footprint3d", "board3d") else "2d"- r["motion"] = demo_motion.choreograph(target, kind, abort=_pending_interrupt)+ _ku = __import__("handlers.kicad_ui", fromlist=["_canvas_probe", "_canvas_content"])+ r["motion"] = demo_motion.choreograph(target, kind, abort=_pending_interrupt,+ check=lambda: (_ku._canvas_probe(target) or {}).get("rendered", True),+ measure=lambda: _ku._canvas_content(target),+ focus=((prep.get("focus") or {}).get(s_)),+ zoom=((1, 0) if s_ in ("symbol", "footprint") else (2, 1))) except Exception as e: # pylint: disable=broad-except r["motion"] = {"error": f"{type(e).__name__}: {e}"} if _EXIT["now"]:@@ -1656,6 +1998,7 @@ def _run_demo_background(kicad_info: dict) -> None: _CTRL["queue"].insert(0, cmd); _CTRL["cmd"] = cmd break _t.sleep(0.5)+ r["partBEndAt"] = _time.time() if exited: break i += 1@@ -1669,7 +2012,8 @@ def _run_demo_background(kicad_info: dict) -> None: ok_ct = len([r for r in results if r.get("ok")]) _stop_narration() _panel_hide()- _job_update(windowsRestored=_restore_bounds("end"))+ _job_update(windowsRestored=_restore_bounds("end"),+ prepSummary={k: prep.get(k) for k in ("installed", "warnings", "footprintWasNew", "cacheRestart", "models3dAttached", "focus")}) try: from handlers import ad_client ad_client.call("notify_dismiss", {"id": _CTL_TOAST_ID}, timeout=4)@@ -1697,6 +2041,8 @@ def _run_demo_background(kicad_info: dict) -> None: "motion": r.get("motion"), "hwnd": r.get("hwnd"), "window": r.get("window"), "verified": r.get("verified"), "placed": r.get("placed"),+ "startedAt": r.get("startedAt"), "narrationAt": r.get("narrationAt"),+ "partAAt": r.get("partAAt"), "partBAt": r.get("partBAt"), "partBEndAt": r.get("partBEndAt"), "failReason": (None if r.get("ok") else { "handlerSuccess": bool((r.get("raw") or {}).get("success")), "error": str((r.get("raw") or {}).get("error") or "")[:200],@@ -1764,7 +2110,7 @@ def handle_demo(kicad_info: dict, args: dict) -> dict: out = {"success": True, **{k: snap.get(k) for k in ("done", "step", "stepLabel", "beatIndex", "beatCount", "percent", "elapsedSec", "estimatedSec", "error",- "panel", "muted", "muteSource", "lastPress", "presses", "paused", "stopping", "verified", "captions", "windowsRestored")}}+ "panel", "muted", "muteSource", "lastPress", "presses", "paused", "stopping", "verified", "captions", "windowsRestored", "prepSummary")}} # 0.9.199 (wiki issue #39, John): `active` used to be a BOOLEAN here and # an ARRAY in kicad_progress - the same key, two types, from one bridge. # A renderer written as `if (resp.active)` then treats a finished job as@@ -1909,6 +2255,7 @@ def handle_demo(kicad_info: dict, args: dict) -> dict: # Warm eeschema only when starting the tour (Symbol Editor is hosted inside it, # and a cold WM_COMMAND misses its 10 s budget). Later beats reuse the warm app. if run_all or steps[0] == STEPS[0]:+ prep["cacheRestart"] = _restart_for_new_footprint(kicad_info, prep) prep["warm"] = _warm_kicad(kicad_info, prep) import time