app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
0.9.350: kicad_model_check and model diagnostics on the 3D show verbs (#88)
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