← 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)

John Lauer ·95750bc572 ·1mo ago ·parent 96b31b3
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