master
John Lauer 0.9.350: kicad_model_check and model diagnostics on the 3D show verbs (#88) 9a40be6 2mo ago
"""kicad_model_check: read-only 3D model diagnostics for a footprint or a board (#88).

A footprint editor that renders pads and no body is a silent failure: the 3D
viewer does not complain, and the bridge used to hand back success:true with a
screenshot the AI had to read. This verb says, per footprint, which (model ...)
references exist, where KiCad would look for each file, whether the file exists,
whether it looks like a real STEP/VRML with geometry, and what is still
unverified (the render itself). Nothing here touches a window.

KiCad's resolver (FILENAME_RESOLVER::ResolvePath): an absolute path is used as is;
${VAR} / $(VAR) expand from the environment and Configure Paths; anything else is
tried against the project directory (KIPRJMOD) and then each 3D search path
(KICAD<n>_3DMODEL_DIR, KICAD_3RD_PARTY, system.extra_3d_search_dirs). It never
looks next to the .kicad_mod file, which is the trap in #88.
"""
from __future__ import annotations

import json
import os
import re
from pathlib import Path

from parsers.sexpr import parse_file, find_nodes, find_node, node_value

_STEP_EXT = (".step", ".stp")
_VRML_EXT = (".wrl", ".wrz")
_NO_MODEL_EXPECTED = ("testpoint", "fiducial", "mountinghole", "solderjumper", "jumper",
                      "net-tie", "nettie", "logo", "symbol", "marking", "hole_")


def _kicad_common(kicad_info: dict) -> dict:
    try:
        import kicad_detect
        cfg = kicad_detect._win_find_config_dir(kicad_info.get("version") or "")
        if cfg and (Path(cfg) / "kicad_common.json").is_file():
            return json.loads((Path(cfg) / "kicad_common.json").read_text(encoding="utf-8"))
    except Exception:  # pylint: disable=broad-except
        pass
    return {}


def _search_dirs(kicad_info: dict, project_dir: str | None) -> list:
    """The directories KiCad tries for a relative model path, in its order."""
    dirs = []
    if project_dir:
        dirs.append(str(project_dir))
    ver = str(kicad_info.get("version") or "")
    major = ver.split(".")[0] if ver else ""
    common = _kicad_common(kicad_info)
    env = ((common.get("environment") or {}).get("vars") or {})
    base = kicad_info.get("base_dir")
    stock = env.get(f"KICAD{major}_3DMODEL_DIR") or (str(Path(base) / "share" / "kicad" / "3dmodels") if base else None)
    if stock:
        dirs.append(stock)
    third = env.get("KICAD_3RD_PARTY") or (str(Path(os.environ["APPDATA"]) / "kicad" / ver / "3rdparty")
                                          if os.environ.get("APPDATA") and ver else None)
    if third:
        dirs.append(third)
    for d in ((common.get("system") or {}).get("extra_3d_search_dirs") or []):
        if isinstance(d, str) and d:
            dirs.append(d)
    return [d.replace("\\", "/") for d in dirs]


def _expand(path: str, kicad_info: dict, project_dir: str | None):
    """Expand ${VAR} / $(VAR) the way KiCad does. Returns (expanded, unresolved_vars)."""
    from adom_library import _expand_kicad_uri
    p = path.replace("\\", "/").replace("$(", "${").replace(")", "}") if "$(" in path else path.replace("\\", "/")
    if "${KIPRJMOD}" in p and project_dir:
        p = p.replace("${KIPRJMOD}", str(project_dir).replace("\\", "/"))
    if "${" not in p:
        return p, []
    ver = str(kicad_info.get("version") or "")
    major = ver.split(".")[0] if ver else ""
    base = kicad_info.get("base_dir")
    subs = {}
    if base:
        share = Path(base) / "share" / "kicad"
        subs[f"KICAD{major}_3DMODEL_DIR"] = str(share / "3dmodels")
        subs[f"KICAD{major}_FOOTPRINT_DIR"] = str(share / "footprints")
        subs[f"KICAD{major}_SYMBOL_DIR"] = str(share / "symbols")
    if os.environ.get("APPDATA") and ver:
        subs["KICAD_3RD_PARTY"] = str(Path(os.environ["APPDATA"]) / "kicad" / ver / "3rdparty")
    subs.update(((_kicad_common(kicad_info).get("environment") or {}).get("vars") or {}))
    for k, v in subs.items():
        p = p.replace("${" + k + "}", str(v).replace("\\", "/"))
    unresolved = re.findall(r"\$\{([^}]+)\}", p)
    return p, unresolved


def _sniff(path: Path) -> dict:
    """Cheap format check: is this a STEP or VRML with something inside?"""
    out = {"bytes": None, "format": None, "geometryHints": 0}
    try:
        out["bytes"] = path.stat().st_size
        with open(path, "rb") as f:
            head = f.read(4096)
            f.seek(0)
            body = f.read(4 * 1024 * 1024)
    except Exception as exc:  # pylint: disable=broad-except
        out["readError"] = str(exc)[:120]
        return out
    ext = path.suffix.lower()
    if head.startswith(b"ISO-10303-21"):
        out["format"] = "step"
        out["geometryHints"] = len(re.findall(rb"MANIFOLD_SOLID_BREP|CLOSED_SHELL|ADVANCED_FACE", body))
        out["terminated"] = b"END-ISO-10303-21" in body[-4096:] or out["bytes"] > len(body)
    elif head.startswith(b"#VRML"):
        out["format"] = "vrml"
        out["geometryHints"] = len(re.findall(rb"IndexedFaceSet|Shape", body))
    elif head[:2] == b"\x1f\x8b" and ext == ".wrz":
        out["format"] = "vrml-gzip"
        out["geometryHints"] = None
    else:
        out["format"] = "unknown"
    return out


def _xyz(node, name):
    n = find_node(node, name)
    if not n:
        return None
    x = find_node(n, "xyz")
    if not x or len(x) < 4:
        return None
    try:
        return [float(x[1]), float(x[2]), float(x[3])]
    except Exception:  # pylint: disable=broad-except
        return None


def _model_rows(fp_node, kicad_info: dict, project_dir: str | None) -> list:
    rows = []
    for m in find_nodes(fp_node, "model"):
        raw = m[1] if len(m) > 1 and isinstance(m[1], str) else ""
        hidden = False
        for child in m[2:]:
            if child == "hide":
                hidden = True
            elif isinstance(child, list) and child and child[0] == "hide":
                hidden = str(child[1] if len(child) > 1 else "").lower() in ("yes", "true", "1")
        row = {"reference": raw, "hidden": hidden, "offset": _xyz(m, "offset"),
               "scale": _xyz(m, "scale"), "rotate": _xyz(m, "rotate"),
               "resolved": None, "exists": False, "warnings": []}
        expanded, unresolved = _expand(raw, kicad_info, project_dir)
        if unresolved:
            row["warnings"].append({"code": "unresolved_variable", "vars": unresolved,
                                    "hint": "KiCad cannot expand this variable on this box (Preferences > Configure Paths, or KIPRJMOD outside a project)."})
        candidates = []
        if unresolved:
            pass
        elif Path(expanded).is_absolute():
            candidates = [expanded]
        else:
            candidates = [str(Path(d) / expanded).replace("\\", "/") for d in _search_dirs(kicad_info, project_dir)]
            row["triedDirs"] = _search_dirs(kicad_info, project_dir)
        for c in candidates:
            if Path(c).is_file():
                row["resolved"] = c
                row["exists"] = True
                break
        if not row["exists"] and not unresolved:
            if candidates and not Path(expanded).is_absolute():
                row["warnings"].append({"code": "not_found_relative",
                                        "hint": "A bare relative model path is tried against the project directory and the 3D search paths, never next to the .kicad_mod. Use ${KICAD" + (str(kicad_info.get('version') or '').split('.')[0]) + "_3DMODEL_DIR}/<lib>.3dshapes/<file>.step, a Configure Paths variable, or an absolute path."})
            else:
                row["warnings"].append({"code": "file_missing", "hint": f"No file at {expanded}."})
        if row["exists"]:
            sn = _sniff(Path(row["resolved"]))
            row.update({k: v for k, v in sn.items()})
            if sn.get("readError"):
                row["warnings"].append({"code": "unreadable", "hint": sn["readError"]})
            elif sn.get("format") == "unknown":
                row["warnings"].append({"code": "not_step_or_vrml", "hint": "The file does not start like a STEP (ISO-10303-21) or VRML (#VRML) file; KiCad will draw nothing for it."})
            elif sn.get("geometryHints") == 0:
                row["warnings"].append({"code": "empty_geometry", "hint": "The file parses as STEP/VRML but declares no solids or faces."})
        sc = row.get("scale")
        if sc and (any(v == 0 for v in sc) or any(abs(v) > 100 for v in sc)):
            row["warnings"].append({"code": "implausible_scale", "hint": f"scale {sc}: a zero or >100 factor makes the body invisible or huge."})
        if hidden:
            row["warnings"].append({"code": "hidden", "hint": "This model is marked hidden in the footprint; the viewer will not draw it."})
        rows.append(row)
    return rows


def _no_model_expected(fp_id: str, attrs: list) -> bool:
    low = fp_id.lower()
    if any(k in low for k in _NO_MODEL_EXPECTED):
        return True
    return "board_only" in attrs or "exclude_from_bom" in attrs and "exclude_from_pos_files" in attrs


def _attrs(fp_node) -> list:
    a = find_node(fp_node, "attr")
    return [x for x in (a[1:] if a else []) if isinstance(x, str)]


def _library_dir(kicad_info: dict, library: str):
    from handlers.install_footprint import handle_list_footprints
    lst = handle_list_footprints(kicad_info, {"library": library})
    lib = (lst.get("libraries") or {}).get(library) or {}
    return lib.get("path")


def check_footprint(kicad_info: dict, library: str, footprint: str, project_dir: str | None = None) -> dict:
    pretty = _library_dir(kicad_info, library)
    if not pretty:
        return {"success": False, "errorCode": "library_not_found",
                "error": f"No library named '{library}' in the fp-lib-table or user footprints dir"}
    path = Path(pretty) / f"{footprint}.kicad_mod"
    if not path.is_file():
        return {"success": False, "errorCode": "footprint_not_found", "error": f"{path} does not exist"}
    tree = parse_file(str(path))
    fp_node = tree if tree and tree[0] == "footprint" else find_node(tree, "footprint")
    rows = _model_rows(fp_node, kicad_info, project_dir)
    attrs = _attrs(fp_node)
    warnings = [dict(w, model=r["reference"]) for r in rows for w in r["warnings"]]
    fp_id = f"{library}:{footprint}"
    if not rows:
        if _no_model_expected(fp_id, attrs):
            note = "no model reference, and none expected for this kind of footprint"
        else:
            warnings.append({"code": "no_model_reference", "model": None,
                             "hint": "The footprint carries no (model ...) clause, so the 3D viewer shows pads only. Attach one (kicad_install_footprint with model, or edit the footprint)."})
            note = "no model reference"
    else:
        note = f"{sum(1 for r in rows if r['exists'] and not r['warnings'])} of {len(rows)} model reference(s) resolve to a readable STEP/VRML with geometry"
    return {"success": True, "footprint": fp_id, "file": str(path).replace("\\", "/"), "attrs": attrs,
            "models": rows, "warnings": warnings, "ok": not warnings,
            "renderVerified": False, "note": note,
            "nextCheck": ("kicad_show_3d_chip {\"library\":\"%s\",\"footprintName\":\"%s\"} and read the frame it returns: "
                          "a body over the pads is the only proof KiCad drew the model." % (library, footprint))}


def check_board(kicad_info: dict, board_path: str, limit: int = 50) -> dict:
    p = Path(board_path)
    if not p.is_file():
        return {"success": False, "errorCode": "board_not_found", "error": f"{board_path} does not exist"}
    tree = parse_file(str(p))
    project_dir = str(p.parent)
    per = []
    counts = {"footprints": 0, "withModel": 0, "noModelExpected": 0, "missingModel": 0, "unresolvedModel": 0, "warnings": 0}
    for fp_node in find_nodes(tree, "footprint"):
        fp_id = fp_node[1] if len(fp_node) > 1 and isinstance(fp_node[1], str) else "?"
        ref = ""
        for prop in find_nodes(fp_node, "property"):
            if len(prop) > 2 and prop[1] == "Reference":
                ref = prop[2]
        rows = _model_rows(fp_node, kicad_info, project_dir)
        attrs = _attrs(fp_node)
        counts["footprints"] += 1
        w = [dict(x, model=r["reference"]) for r in rows for x in r["warnings"]]
        if rows:
            counts["withModel"] += 1
        elif _no_model_expected(fp_id, attrs):
            counts["noModelExpected"] += 1
        else:
            counts["missingModel"] += 1
            w.append({"code": "no_model_reference", "model": None, "hint": "no (model ...) clause"})
        if any(x["code"] in ("unresolved_variable", "not_found_relative", "file_missing") for x in w):
            counts["unresolvedModel"] += 1
        counts["warnings"] += len(w)
        if w and len(per) < limit:
            per.append({"reference": ref, "footprint": fp_id, "warnings": w,
                        "models": [{"reference": r["reference"], "resolved": r["resolved"], "exists": r["exists"]} for r in rows]})
    return {"success": True, "board": str(p).replace("\\", "/"), "projectDir": project_dir.replace("\\", "/"),
            "searchDirs": _search_dirs(kicad_info, project_dir), "counts": counts,
            "footprintsWithWarnings": per, "ok": counts["warnings"] == 0, "renderVerified": False,
            "nextCheck": "kicad_show_3d_board and read the frame: bodies on every populated footprint are the proof."}


def handle_model_check(kicad_info: dict, args: dict) -> dict:
    """kicad_model_check {library, footprintName} or {boardPath|filePath}."""
    a = args or {}
    board = a.get("boardPath") or a.get("filePath")
    try:
        if board and str(board).lower().endswith(".kicad_pcb"):
            out = check_board(kicad_info, str(board), int(a.get("limit") or 50))
        else:
            lib = (a.get("library") or a.get("libraryName") or "").strip()
            fp = (a.get("footprintName") or a.get("footprint") or "").strip()
            if not lib or not fp:
                return {"success": False, "errorCode": "missing_arg",
                        "error": "Pass library + footprintName, or boardPath (.kicad_pcb)"}
            out = check_footprint(kicad_info, lib, fp, a.get("projectDir"))
    except Exception as exc:  # pylint: disable=broad-except
        return {"success": False, "errorCode": "model_check_failed", "error": str(exc)[:200]}
    if out.get("success"):
        out["_hint"] = ("Read-only file diagnostics: resolution, existence, format and geometry per model reference. "
                        "renderVerified is always false here; the viewer frame from nextCheck is the render proof. "
                        "Warnings carry a code and a hint; ok:true means every reference resolved to a real STEP/VRML with geometry.")
    return out