master
John Lauer 0.9.350: kicad_model_check and model diagnostics on the 3D show verbs (#88) f84a2c6 2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
"""show.py — kicad_show_* verbs: one clean, self-documenting verb per KiCad
surface, so a caller (ab, a web demo, an AI) NEVER hand-assembles a workflow.

Mirrors the Fusion bridge's fusion_show_* family (#24 follow-up, John 2026-08-14):
the bridge is the expert on which window shows what, so it owns a named verb for
each surface instead of making callers guess file types / editor combos.

The six surfaces a hardware person thinks in, plus library browse:

  kicad_show_symbol      -> Symbol Editor at a symbol (or a library)   [design/lib]
  kicad_show_footprint   -> Footprint Editor at a footprint (or a library)
  kicad_show_3d_chip     -> 3D Viewer of the FOOTPRINT (single part in 3D)
  kicad_show_schematic   -> Schematic Editor on a .kicad_sch
  kicad_show_2d_board    -> PCB Editor (2D) on a .kicad_pcb
  kicad_show_3d_board    -> 3D Viewer of the BOARD (whole assembly in 3D)
  kicad_show_library     -> Symbol OR Footprint Editor filtered to a library

Every one is a thin, arg-normalizing wrapper over the existing open_* handlers —
same background-first behavior, same verified loads — but with an obvious name
and an obvious argument. All accept the KiCad analogue of Fusion's deviceset:
symbolName/footprintName (+ optional libraryName), or filePath for the docs.
"""
from handlers.launch import handle_launch
from handlers.open_files import handle_open_board, handle_open_schematic
from handlers.open_symbol_editor import handle_open_symbol_editor
import sys
import time
from handlers.open_footprint_editor import (handle_open_footprint_editor, handle_open_3d_viewer,
    _find_pcb_editor, _find_footprint_editor)


def handle_show_project(kicad_info: dict, args: dict) -> dict:
    """Show the whole ELECTRONICS PROJECT — the KiCad Project Manager, optionally
    on a specific .kicad_pro. This is the top of the tree: from here the schematic,
    board, and every editor hang off one design. Args: filePath/project (.kicad_pro).
    With no path it just brings up (or confirms) the Project Manager."""
    a = dict(args or {})
    proj = a.get("filePath") or a.get("project")
    r = handle_launch(kicad_info, {"project": proj} if proj else {})
    if isinstance(r, dict):
        r.setdefault("surface", "project")
    return r


def _open_editor_showing(kind: str, name: str, lib: str):
    """Is an editor of `kind` ALREADY open on exactly this part?

    Wiki issue #35 (Colby Knox): kicad_show_symbol cost ~56s on EVERY call, even
    when the Symbol Editor was already open on that exact symbol, because the
    verb always ran the cold path - and KiCad re-indexes every symbol library
    when that editor opens, which is where the time goes. The desired end state
    was already on screen; we just were not looking."""
    try:
        from handlers.kicad_ui import _find_all_kicad_windows
        want_editor = kind.lower()
        for w in _find_all_kicad_windows() or []:
            title = (w.get("title") or "")
            low = title.lower()
            if want_editor not in low:
                continue
            if name and name.lower() in low and (not lib or lib.lower() in low):
                return w
    except Exception:
        pass
    return None


def handle_show_symbol(kicad_info: dict, args: dict) -> dict:
    """Show a schematic SYMBOL in the Symbol Editor. Args: symbolName (+ libraryName),
    or libraryName alone to browse a library."""
    a = args or {}
    # rescan:true means "I do not trust this editor's library index" (the remedy
    # for errorCode library_cache_stale), so the warm-reuse shortcut must not
    # swallow it - reuse is exactly what the caller is asking us to stop doing.
    already = None if a.get("rescan") else _open_editor_showing(
        "symbol editor", a.get("symbolName") or "",
        a.get("libraryName") or a.get("library") or "")
    if already:
        # #49 ask 2: the reuse fast path returned no resolution fields, so the
        # most common success on a warm editor was also the least inspectable.
        try:
            from handlers.open_symbol_editor import _resolved_fields
            _res = _resolved_fields(already.get("title") or "",
                                    a.get("libraryName") or a.get("library") or "")
        except Exception:
            _res = {}
        _ev = _capture_evidence(already, "symbol-editor-reused")
        _sh = _build_shot(already, "symbol-editor-reused",
                          a.get("captureMaxWidth", _SHOT_DEFAULT_MAX)) if a.get("capture") else None
        return {"success": True, "surface": "symbol", "reused": True,
                **({"shot": _sh} if _sh else {}),
                "window": already.get("title"),
                **({"evidence": _ev} if _ev else {}),
                "output": f"Symbol Editor is already showing {already.get('title')} - "
                          f"reused it (no relaunch, no library rescan).",
                "_hint": "Returned the existing editor instead of running the cold path. "
                         "KiCad re-indexes every symbol library when the Symbol Editor "
                         "opens, so reuse is the difference between about a second and "
                         "about a minute (wiki issue #35)."}
    r = handle_open_symbol_editor(kicad_info, a)
    r = _verify_window(r, _find_symbol_editor, "Symbol Editor", args=a)
    r.setdefault("surface", "symbol")
    return r


def _find_by_title(*needles):
    """The window whose title contains ALL needles, or None.

    0.9.195: this used to return a bare bool. The window DICT is what lets
    _verify_window capture the thing it just verified, so evidence comes from
    the same lookup that decided the verb succeeded - not a second, possibly
    different, window found later."""
    try:
        from handlers.kicad_ui import _find_all_kicad_windows
        for w in _find_all_kicad_windows() or []:
            t = (w.get("title") or "").lower()
            if all(n in t for n in needles):
                return w
    except Exception:
        pass
    return None


def _window_title(hwnd: int) -> str:
    try:
        from handlers.kicad_ui import _get_window_title
        return _get_window_title(hwnd) or ""
    except Exception:
        return ""


def _capture_evidence(win, label: str) -> dict | None:
    """Screenshot the window a show_* verb just opened, in the BACKGROUND.

    John's contract to the team (2026-08-19): "you should always get a
    screenshot of the kicad or fusion window after the item opens in it so you
    can show that in a thumbnail". Before this, the verbs only TOLD the caller
    to go screenshot something - which is how a caller ends up screenshotting
    the project manager and captioning it as a footprint (0.9.172). Capturing
    here, from the verified hwnd, means the evidence and the success verdict
    come from the same window and cannot disagree.

    PrintWindow captures an occluded window, so this never foregrounds."""
    try:
        # The finders are not uniform: show.py's own return a window dict, while
        # _find_footprint_editor / _find_pcb_editor return a bare hwnd. Accept
        # both, or the footprint and board surfaces - the ones most likely to be
        # mistaken for the project window - would be the only ones with no proof.
        if isinstance(win, dict):
            hwnd, title = win.get("hwnd"), win.get("title")
        else:
            hwnd, title = win, _window_title(win)
        if not hwnd:
            return None
        from handlers.kicad_ui import _screenshot_hwnd
        # WAIT FOR THE CANVAS TO PAINT before calling this evidence.
        #
        # 0.9.202. The blank thumbnails were not a GPU problem and not a capture
        # problem - they were a TIMING problem, and mine. A show_* verb confirms
        # the window title and we photographed it immediately, while KiCad was
        # still loading libraries (the status bar literally reads "Loading
        # Footprint Li..."). The GL canvas had not painted yet, so the shot
        # showed correct chrome around an empty rectangle. Proof: the same part,
        # same box, same zoom (Z 30.61) that captured blank photographs fully
        # drawn once KiCad settles.
        #
        # So poll rather than assume. Each attempt is a real capture, and the
        # canvas check tells us whether it is worth keeping.
        shot = None
        for attempt in range(8):
            shot = _screenshot_hwnd(hwnd, label=label)
            if not shot or not shot.get("success"):
                return None
            c = shot.get("canvas") or {}
            if not c.get("checked") or c.get("rendered"):
                break
            time.sleep(1.0)
        if not shot or not shot.get("success"):
            return None
        canvas = shot.get("canvas") or {}
        ev = {
            "window": title,
            "hwnd": hwnd,
            "screenshotPath": shot.get("fullPath") or shot.get("savedTo"),
            "sizeKB": shot.get("sizeKB"),
            "capturedInBackground": True,
            "_hint": ("Proof this surface is really on screen, captured from the same "
                      "window that satisfied the verb. ab auto-pulls fullPath into the "
                      "caller's container, so it can be read or shown as a thumbnail "
                      "without a separate pull_file."),
        }
        # A thumbnail whose CANVAS is empty proves the editor is on the part, not
        # that the part is drawn - and it looks like a success to anyone glancing
        # at it. Say so on the evidence itself rather than letting a blank picture
        # speak for the verb.
        if canvas.get("checked"):
            ev["canvasRendered"] = bool(canvas.get("rendered"))
            ev["canvasWaitedSec"] = attempt
            if not canvas.get("rendered"):
                ev["_canvasHint"] = (
                    "The window is correct but its drawing canvas is still BLANK "
                    f"({canvas.get('distinctColors')} distinct colour(s)) after waiting "
                    f"{attempt}s for it to paint. Usually this just means KiCad is still "
                    "loading libraries - check the status bar in the image. If it never "
                    "paints, the canvas is an OpenGL surface and yields no picture on a "
                    "host with no usable GPU or no active console session; "
                    "kicad_enable_software_opengl is the last-resort fix there. "
                    "Either way, treat this thumbnail as proof of the WINDOW, not of "
                    "the part."
                )
        return ev
    except Exception:
        return None



_SHOT_DEFAULT_MAX = 640
_SHOT_MIN_MAX, _SHOT_MAX_MAX = 120, 1600


def _build_shot(win, label: str, max_width: int) -> dict | None:
    """The ab gallery-item `shot` contract (wiki #45, settled in adom-bridge#91).

    Why this exists next to `evidence` rather than inside it: the wiki Quick
    Actions caller is a BROWSER. It has no container for ab to auto-pull
    `fullPath` into, and the /command response carries no image bytes, so a page
    cannot render the thumbnail beside "opened in KiCad". Colby's split is the
    right one and is kept here deliberately:

        evidence -> render PROOF   (canvasRendered, hwnd, window)
        shot     -> the FRAME      (bytes a browser can paint)

    Opt-in via capture:true, so no existing caller pays for bytes it cannot use.
    """
    try:
        import base64
        if isinstance(win, dict):
            hwnd, title = win.get("hwnd"), win.get("title")
        else:
            hwnd, title = win, _window_title(win)
        if not hwnd:
            return None
        try:
            mw = int(max_width)
        except Exception:
            mw = _SHOT_DEFAULT_MAX
        mw = max(_SHOT_MIN_MAX, min(_SHOT_MAX_MAX, mw))

        from handlers.kicad_ui import _screenshot_hwnd
        # Wiki #52 (John): 4 of 30 symbol shots came back 100%-uniform white -
        # the editor answers "ready" before its GL canvas first paints, and the
        # blanks cluster on FAST parts precisely because less time passes
        # before the capture. The evidence path has polled for paint since
        # 0.9.202; this path - the one web control actually consumes - captured
        # once and shipped whatever it got. Own the settle here: retry while
        # the canvas region is unrendered, up to 10s, and REPORT the verdict on
        # the shot either way so a blank can never pose as proof.
        shot = None
        waited = 0
        for waited in range(11):
            shot = _screenshot_hwnd(hwnd, label=f"shot-{label}", max_dim=mw)
            c = (shot or {}).get("canvas") or {}
            if not shot or not shot.get("success"):
                return None
            if not c.get("checked") or c.get("rendered"):
                break
            time.sleep(1.0)
        path = (shot or {}).get("fullPath") or (shot or {}).get("savedTo")
        if not path:
            return None
        raw = open(path, "rb").read()
        b64 = base64.b64encode(raw).decode()
        # The MIME belongs to whoever encodes the bytes. Colby drops
        # thumbnailDataUrl straight into <img src> and does NOT rebuild it from
        # `image`, so a mislabelled prefix is our bug, not his. Derive it from
        # what we actually wrote rather than assuming PNG.
        ext = str(path).lower().rsplit(".", 1)[-1]
        mime = {"png": "image/png", "bmp": "image/bmp", "webp": "image/webp"}.get(ext, "image/png")
        _c = (shot or {}).get("canvas") or {}
        out = {
            "title": title,
            "image": b64,
            "thumbnailDataUrl": f"data:{mime};base64,{b64}",
            "maxWidth": mw,
            "path": str(path),
            "_hint": ("thumbnailDataUrl drops straight into an <img src>. `image` is the same "
                      "bytes raw for container callers, and `path` is the on-box file ab "
                      "auto-pulls. Request it with capture:true; captureMaxWidth defaults to "
                      f"{_SHOT_DEFAULT_MAX} and clamps to {_SHOT_MIN_MAX}-{_SHOT_MAX_MAX}."),
        }
        if _c.get("checked"):
            out["canvasRendered"] = bool(_c.get("rendered"))
            out["canvasWaitedSec"] = waited
            if not _c.get("rendered"):
                out["_canvasHint"] = ("The canvas region is still UNRENDERED after 10s of "
                                      "polling - treat this frame as proof of the window, "
                                      "not the part, and recapture rather than render it.")
        return out
    except Exception:
        return None


def _attach_shot(result: dict, win, label: str, args: dict) -> dict:
    """Attach `shot` when the caller asked for it. Never replaces `evidence`."""
    try:
        if not isinstance(result, dict) or not (args or {}).get("capture"):
            return result
        sh = _build_shot(win, label, (args or {}).get("captureMaxWidth", _SHOT_DEFAULT_MAX))
        if sh:
            result["shot"] = sh
        else:
            result["shotUnavailable"] = "capture was requested but the frame could not be grabbed"
    except Exception:
        pass
    return result

def _find_symbol_editor():
    return _find_by_title("symbol editor")


def _find_3d_viewer():
    return _find_by_title("3d viewer")


def _verify_window(result: dict, finder, label: str, waits: int = 12,
                   args: dict = None) -> dict:
    """A show_* verb has ONE job: put THAT surface on screen. If its window is
    not there, the verb FAILS - even when every internal step reported success.

    0.9.172 (John, 2026-08-19: "i had asked for a footprint to be shown, and the
    evidence you provided was just of the project window"). The caller screenshots
    whatever the bridge says succeeded, so a false success does not just mislead,
    it manufactures wrong evidence."""
    if not isinstance(result, dict) or not result.get("success"):
        return result
    for _ in range(waits):
        win = finder()
        if win:
            # Evidence is part of the contract, not a nicety - but a failed
            # capture must never turn a real success into a failure, so it is
            # reported as absent rather than raised.
            ev = _capture_evidence(win, label)
            if ev:
                result["evidence"] = ev
            else:
                result["evidenceUnavailable"] = "window verified but the capture failed"
            return _attach_shot(result, win, label, args or {})
        time.sleep(0.5)
    return {**result, "success": False,
            "error": f"{label} reported success but its window never appeared",
            "errorCode": "surface_window_missing",
            "_hint": (f"The bridge could not put the {label} on screen, so there is nothing "
                      "truthful to screenshot. Retry after kicad_launch, or check the Plugin "
                      "LED: the background path needs the reverse-bridge plugin bound.")}


def _plugin_alive() -> bool:
    try:
        from handlers.bridge_client import _files_for_exe, _resolve_plugin
        for exe in ("pcbnew", "kicad"):
            meta, err = _resolve_plugin(exe)
            if meta and not err:
                return True
    except Exception:
        pass
    return False


def ensure_plugin_ready(kicad_info: dict, waits: int = 14) -> bool:
    """A click is a GOAL, not an audit (the Adom automation doctrine).

    The background footprint path needs the reverse-bridge plugin, and on KiCad
    10.0.5 the plugin only loads when KiCad SCANS its plugin directory - which
    happens when a board editor comes up, not when the project manager alone is
    running. So rather than telling the user "no plugin, open a board yourself"
    (0.9.172 did exactly that, correctly but uselessly), open one ourselves, in
    the background, and wait for the bind."""
    if _plugin_alive():
        return True
    from pathlib import Path as _P
    import os as _os
    board = None
    docs = _P(_os.environ.get("USERPROFILE") or _P.home()) / "Documents"
    for cand in sorted(docs.glob("adom-*/*.kicad_pcb")) + sorted(docs.glob("*/*.kicad_pcb")):
        board = str(cand)
        break
    if not board:
        # WIKI ISSUE #34 (Colby Knox), Bug B: on a box with no board anywhere,
        # the cold path had nothing to open and simply failed - "a user with no
        # board open cannot open the Footprint Editor at all". Generate a
        # throwaway board instead of giving up: it exists only to make KiCad
        # scan its plugin directory, and it costs a few KB in TEMP.
        try:
            import tempfile as _tf
            scratch = _P(_tf.gettempdir()) / "adom-plugin-bootstrap"
            scratch.mkdir(parents=True, exist_ok=True)
            board = str(scratch / "adom-plugin-bootstrap.kicad_pcb")
            if not _os.path.exists(board):
                _P(board).write_text(
                    '(kicad_pcb (version 20240108) (generator "adom-bridge")\n'
                    '  (general (thickness 1.6))\n'
                    '  (paper "A4")\n'
                    '  (layers (0 "F.Cu" signal) (31 "B.Cu" signal)\n'
                    '    (44 "Edge.Cuts" user))\n'
                    ')\n', encoding="utf-8")
            print(f"[show] no board on this machine - generated {board} purely to "
                  f"trigger KiCad's plugin scan (issue #34)", file=sys.stderr, flush=True)
        except Exception:
            return False
    if not board:
        return False
    print(f"[show] no plugin bound - opening {board} in the background to bind it",
          file=sys.stderr, flush=True)
    try:
        handle_open_board(kicad_info, {"filePath": board,
                                       "reason": "bind the reverse-bridge plugin"})
    except Exception:
        return False
    for _ in range(waits):
        time.sleep(3)
        if _plugin_alive():
            return True
    return False


def handle_show_footprint(kicad_info: dict, args: dict) -> dict:
    """Show a FOOTPRINT in the Footprint Editor. Args: footprintName (+ library),
    or library alone to browse."""
    a2 = args or {}
    already_fp = _open_editor_showing("footprint editor", a2.get("footprintName") or "",
                                      a2.get("library") or a2.get("libraryName") or "")
    if already_fp:
        _ev = _capture_evidence(already_fp, "footprint-editor-reused")
        _sh = _build_shot(already_fp, "footprint-editor-reused",
                          a2.get("captureMaxWidth", _SHOT_DEFAULT_MAX)) if a2.get("capture") else None
        return {"success": True, "surface": "footprint", "reused": True,
                **({"shot": _sh} if _sh else {}),
                "window": already_fp.get("title"),
                **({"evidence": _ev} if _ev else {}),
                "output": f"Footprint Editor is already showing {already_fp.get('title')} - "
                          f"reused it (no relaunch, no library rescan).",
                "_hint": "Returned the existing editor instead of running the cold path "
                         "(wiki issue #35)."}
    ensure_plugin_ready(kicad_info)
    r = handle_open_footprint_editor(kicad_info, a2)
    r = _verify_window(r, _find_footprint_editor, "Footprint Editor", args=a2)
    r.setdefault("surface", "footprint")
    return r


def handle_show_3d_chip(kicad_info: dict, args: dict) -> dict:
    """Show a single part in 3D — the FOOTPRINT's 3D view (Fusion's Create3DPackage
    analogue). Loads the footprint first if footprintName is given, then its 3D viewer."""
    a = dict(args or {})
    if a.get("footprintName") or a.get("footprint") or a.get("library"):
        ensure_plugin_ready(kicad_info)
        loaded = handle_open_footprint_editor(kicad_info, a)
        if not loaded.get("success") and not loaded.get("editorOpened"):
            loaded["surface"] = "3d_chip"
            return loaded
        for _ in range(20):
            if _find_footprint_editor():
                break
            time.sleep(0.5)
    r = handle_open_3d_viewer(kicad_info, {**a, "editor": "fp"})
    r = _verify_window(r, _find_3d_viewer, "3D Viewer", args=a)
    r.setdefault("surface", "3d_chip")

    # Wiki issue #42 item 4 (John): "the 3D Viewer window is titled just
    # '3D Viewer', with no part identity anywhere, so nothing downstream can
    # confirm which part is on screen - we open it and simply cannot check."
    #
    # KiCad owns that title and we are not going to rewrite it, but the 3D chip
    # view is rendered FROM the Footprint Editor, and that window's title does
    # name the part. So echo the part this viewer was opened from, and say where
    # the claim comes from rather than asserting it out of thin air.
    want = (a.get("footprintName") or a.get("footprint") or "").strip()
    if isinstance(r, dict) and r.get("success"):
        src_title = ""
        try:
            h = _find_footprint_editor()
            if h:
                src_title = _window_title(h)
        except Exception:
            pass
        from handlers.open_footprint_editor import _parse_fp_title
        lib, fp = _parse_fp_title(src_title)
        r["shows"] = {
            "requested": want or None,
            "footprint": fp or None,
            "library": lib or None,
            "sourceWindow": src_title or None,
            "verified": bool(want and fp and fp.lower() == want.lower()),
            "_hint": ("The 3D Viewer's own title carries no part identity, so this is read "
                      "from the Footprint Editor the view was rendered from. verified:true "
                      "means that editor is on the part you asked for; false means it is "
                      "not, and the 3D view is therefore of something else."),
        }
        # #88: say whether the part even HAS a resolvable model before anyone
        # reads pads-only pixels as a rendered chip. File diagnostics only; the
        # frame remains the render proof.
        if lib and fp:
            try:
                from handlers.model_check import check_footprint
                mc = check_footprint(kicad_info, lib, fp)
                if mc.get("success"):
                    r["models"] = {"ok": mc["ok"], "count": len(mc["models"]), "warnings": mc["warnings"],
                                   "renderVerified": False, "note": mc["note"]}
                    if mc["warnings"]:
                        r["_modelHint"] = ("The 3D view may show pads only: " + "; ".join(
                            f"{w['code']}: {w['hint']}" for w in mc["warnings"][:3]) +
                            " (kicad_model_check has the full list).")
            except Exception:  # pylint: disable=broad-except
                pass
        if want and not r["shows"]["verified"]:
            r["success"] = False
            r["errorCode"] = "wrong_part_in_3d_view"
            r["error"] = (f"3D Viewer opened, but it was rendered from '{fp or 'unknown'}' "
                          f"rather than the requested '{want}'.")
    return r


def handle_show_schematic(kicad_info: dict, args: dict) -> dict:
    """Show a SCHEMATIC (2D) in the Schematic Editor. Args: filePath (.kicad_sch)."""
    r = handle_open_schematic(kicad_info, args or {})
    if isinstance(r, dict):
        r.setdefault("surface", "schematic")
    return r


def handle_show_2d_board(kicad_info: dict, args: dict) -> dict:
    """Show the 2D BOARD LAYOUT in the PCB Editor. Args: filePath (.kicad_pcb)."""
    r = handle_open_board(kicad_info, args or {})
    if isinstance(r, dict):
        r.setdefault("surface", "2d_board")
    return r


def handle_show_3d_board(kicad_info: dict, args: dict) -> dict:
    """Show the whole board in 3D — the BOARD's 3D viewer. Opens the board first if
    filePath is given, then the PCB 3D viewer."""
    a = dict(args or {})
    if a.get("filePath"):
        opened = handle_open_board(kicad_info, {"filePath": a["filePath"],
                                                "reason": a.get("reason", "show 3D board")})
        if isinstance(opened, dict) and not opened.get("success"):
            opened["surface"] = "3d_board"
            return opened
        # open_board launches pcbnew ASYNC — wait for its window before the 3D
        # viewer (which needs a live PCB editor) so we don't race "not open".
        for _ in range(40):
            if _find_pcb_editor():
                break
            time.sleep(0.75)
    r = handle_open_3d_viewer(kicad_info, {**a, "editor": "pcb"})
    r.setdefault("surface", "3d_board")
    # #88: board-wide model coverage from the file (read-only), so a viewer full
    # of bare pads comes with the reason instead of a screenshot to interpret.
    try:
        if a.get("filePath") and isinstance(r, dict) and r.get("success"):
            from handlers.model_check import check_board
            mc = check_board(kicad_info, a["filePath"], limit=20)
            if mc.get("success"):
                r["models"] = {"ok": mc["ok"], "counts": mc["counts"], "renderVerified": False,
                               "footprintsWithWarnings": mc["footprintsWithWarnings"]}
                if not mc["ok"]:
                    c = mc["counts"]
                    r["_modelHint"] = (f"{c['missingModel']} footprint(s) have no model and {c['unresolvedModel']} reference a file "
                                       f"KiCad cannot find; those render as pads only. kicad_model_check {{boardPath}} lists them.")
    except Exception:  # pylint: disable=broad-except
        pass
    return r


def handle_show_library(kicad_info: dict, args: dict) -> dict:
    """Browse a LIBRARY (symbol or footprint). Args: libraryName (+ kind:'symbol'|'footprint',
    default 'symbol'). No part is loaded — the tree is filtered to the library."""
    a = dict(args or {})
    lib = a.get("libraryName") or a.get("library")
    if not lib:
        return {"success": False, "error": "libraryName is required",
                "_hint": "kicad_show_library {\"libraryName\":\"Device\"} — add kind:\"footprint\" for the footprint side."}
    kind = (a.get("kind") or "symbol").lower()
    # #49 follow-up: symbolName/footprintName were DROPPED here, and the
    # library-browse response then said "No symbol was requested" - which was
    # untrue when the caller had passed one. Silently discarding an argument and
    # then denying it was given is worse than ignoring it.
    want_sym = a.get("symbolName") or ""
    want_fp = a.get("footprintName") or ""
    if kind in ("footprint", "fp"):
        r = handle_open_footprint_editor(kicad_info, {
            "library": lib, "timeout": a.get("timeout"),
            **({"footprintName": want_fp} if want_fp else {})})
    else:
        r = handle_open_symbol_editor(kicad_info, {
            "libraryName": lib, "timeout": a.get("timeout"),
            **({"symbolName": want_sym} if want_sym else {})})
    if isinstance(r, dict):
        r.setdefault("surface", "library")
    return r