app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
0.9.350: kicad_model_check and model diagnostics on the 3D show verbs (#88)
9a40be6
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
"""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