← Commit history

0.9.350: kicad_model_check and model diagnostics on the 3D show verbs (#88)

John Lauer ·9a40be67ea ·1mo ago ·parent f84a2c6
1 file changed +292
handlers/model_check.pyadded+292
@@ -0,0 +1,292 @@+"""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