gitcad 0.7.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitcad/bench/__init__.py +0 -0
- gitcad/bench/corpus.py +260 -0
- gitcad/bench/scorecard.py +176 -0
- gitcad/bridge.py +72 -0
- gitcad/convert.py +30 -0
- gitcad/explore.py +99 -0
- gitcad/fasteners.py +112 -0
- gitcad/init.py +134 -0
- gitcad/lots.py +136 -0
- gitcad/mcp/__init__.py +15 -0
- gitcad/mcp/server.py +1065 -0
- gitcad/merge3.py +289 -0
- gitcad/pcba.py +114 -0
- gitcad/release.py +223 -0
- gitcad/render.py +177 -0
- gitcad/requirements.py +204 -0
- gitcad/review.py +230 -0
- gitcad/viewer/__init__.py +19 -0
- gitcad/viewer/boardsvg.py +80 -0
- gitcad/viewer/page.py +577 -0
- gitcad/viewer/server.py +433 -0
- gitcad-0.7.7.dist-info/METADATA +70 -0
- gitcad-0.7.7.dist-info/RECORD +25 -0
- gitcad-0.7.7.dist-info/WHEEL +4 -0
- gitcad-0.7.7.dist-info/entry_points.txt +11 -0
gitcad/render.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""gitcad-render — PNG/SVG artifacts from any design file.
|
|
2
|
+
|
|
3
|
+
The real-world deliverable set includes IMAGES (board renders, schematic
|
|
4
|
+
sheets, assembly views for the styling review). SVG is native everywhere
|
|
5
|
+
here; PNG rasterization borrows a local Chrome/Edge in headless mode —
|
|
6
|
+
found automatically, and its absence is a loud, actionable error, never
|
|
7
|
+
a silent downgrade.
|
|
8
|
+
|
|
9
|
+
- schematic -> sheet-fidelity SVG (imported) or auto-layout SVG
|
|
10
|
+
- board -> board SVG
|
|
11
|
+
- model / assembly / pcba -> 3D via the viewer + headless browser
|
|
12
|
+
(#x= deep link renders exploded states)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from gitcad.errors import GitcadError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_browser() -> str | None:
|
|
26
|
+
for name in ("chrome", "google-chrome", "chromium", "chromium-browser",
|
|
27
|
+
"msedge"):
|
|
28
|
+
exe = shutil.which(name)
|
|
29
|
+
if exe:
|
|
30
|
+
return exe
|
|
31
|
+
for cand in (
|
|
32
|
+
Path("C:/Program Files/Google/Chrome/Application/chrome.exe"),
|
|
33
|
+
Path("C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"),
|
|
34
|
+
Path("C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"),
|
|
35
|
+
Path("C:/Program Files/Microsoft/Edge/Application/msedge.exe"),
|
|
36
|
+
):
|
|
37
|
+
if cand.is_file():
|
|
38
|
+
return str(cand)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _svg_for(path: Path) -> str:
|
|
43
|
+
if path.suffix == ".kicad_sch":
|
|
44
|
+
# imported sheets render EXACTLY as drawn (fidelity renderer)
|
|
45
|
+
from gitcad.ecad.schsvg import sheet_to_svg
|
|
46
|
+
from gitcad.importers.kicad_sch import import_kicad_sch
|
|
47
|
+
|
|
48
|
+
sch, _report = import_kicad_sch(str(path))
|
|
49
|
+
return sheet_to_svg(sch)
|
|
50
|
+
text = path.read_text(encoding="utf-8")
|
|
51
|
+
from gitcad.viewer.server import detect_kind
|
|
52
|
+
|
|
53
|
+
kind = detect_kind(text)
|
|
54
|
+
if kind == "schematic":
|
|
55
|
+
from gitcad.ecad import Schematic, schematic_to_svg
|
|
56
|
+
|
|
57
|
+
return schematic_to_svg(Schematic.loads(text))
|
|
58
|
+
if kind == "board":
|
|
59
|
+
from gitcad.ecad import Board
|
|
60
|
+
from gitcad.viewer.boardsvg import board_to_svg
|
|
61
|
+
|
|
62
|
+
return board_to_svg(Board.loads(text))
|
|
63
|
+
if kind == "pcba":
|
|
64
|
+
from gitcad.ecad import Board
|
|
65
|
+
from gitcad.pcba import pcba_sources
|
|
66
|
+
from gitcad.viewer.boardsvg import board_to_svg
|
|
67
|
+
|
|
68
|
+
src = pcba_sources(text, str(path.parent))
|
|
69
|
+
return board_to_svg(Board.loads(src["board"].read_text(encoding="utf-8")))
|
|
70
|
+
raise GitcadError(f"no direct SVG projection for kind {kind!r} — "
|
|
71
|
+
"use PNG output (3D render via the viewer)")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _png_from_svg(svg: str, out: Path, browser: str, width: int, height: int) -> None:
|
|
75
|
+
import tempfile
|
|
76
|
+
|
|
77
|
+
with tempfile.TemporaryDirectory() as td:
|
|
78
|
+
f = Path(td) / "r.svg"
|
|
79
|
+
f.write_text(svg, encoding="utf-8")
|
|
80
|
+
_shoot(browser, f.as_uri(), out, width, height)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _shoot(browser: str, url: str, out: Path, width: int, height: int) -> None:
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
[browser, "--headless", "--disable-gpu",
|
|
86
|
+
f"--screenshot={out}", f"--window-size={width},{height}",
|
|
87
|
+
"--virtual-time-budget=10000", url],
|
|
88
|
+
capture_output=True, timeout=120)
|
|
89
|
+
if not out.is_file():
|
|
90
|
+
raise GitcadError(
|
|
91
|
+
f"browser rasterization produced no file "
|
|
92
|
+
f"(exit {proc.returncode}): {proc.stderr.decode(errors='replace')[:200]}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render(file: str, out: str, *, width: int = 1400, height: int = 900,
|
|
96
|
+
explode: float = 0.0, three: bool = False) -> str:
|
|
97
|
+
"""Render a design file to ``out`` (.svg or .png). ``three`` forces a 3D
|
|
98
|
+
render for boards (the '3d iso' deliverable — board extruded through the
|
|
99
|
+
bridge). Returns the path."""
|
|
100
|
+
src = Path(file)
|
|
101
|
+
dst = Path(out)
|
|
102
|
+
if src.suffix == ".kicad_sch":
|
|
103
|
+
kind = "schematic"
|
|
104
|
+
text = None
|
|
105
|
+
else:
|
|
106
|
+
text = src.read_text(encoding="utf-8")
|
|
107
|
+
from gitcad.viewer.server import detect_kind
|
|
108
|
+
|
|
109
|
+
kind = detect_kind(text)
|
|
110
|
+
|
|
111
|
+
if dst.suffix.lower() == ".svg":
|
|
112
|
+
dst.write_text(_svg_for(src), encoding="utf-8")
|
|
113
|
+
return str(dst)
|
|
114
|
+
if dst.suffix.lower() != ".png":
|
|
115
|
+
raise GitcadError(f"unknown output format {dst.suffix!r} (want .svg or .png)")
|
|
116
|
+
|
|
117
|
+
browser = find_browser()
|
|
118
|
+
if browser is None:
|
|
119
|
+
raise GitcadError(
|
|
120
|
+
"PNG rendering needs a local Chrome/Edge (headless screenshot) — "
|
|
121
|
+
"none found; install one or render .svg instead")
|
|
122
|
+
|
|
123
|
+
if kind == "board" and three:
|
|
124
|
+
# the 3D board deliverable: extrude through the bridge, serve, shoot
|
|
125
|
+
import tempfile
|
|
126
|
+
|
|
127
|
+
from gitcad.bridge import board_to_model
|
|
128
|
+
from gitcad.ecad import Board
|
|
129
|
+
|
|
130
|
+
doc = board_to_model(Board.loads(text))
|
|
131
|
+
with tempfile.TemporaryDirectory() as td:
|
|
132
|
+
model = Path(td) / "board3d.model"
|
|
133
|
+
model.write_text(doc.dumps(), encoding="utf-8")
|
|
134
|
+
return _serve_and_shoot(model, dst, browser, width, height, explode)
|
|
135
|
+
|
|
136
|
+
if kind in ("schematic", "board"):
|
|
137
|
+
_png_from_svg(_svg_for(src), dst, browser, width, height)
|
|
138
|
+
return str(dst)
|
|
139
|
+
|
|
140
|
+
# 3D kinds: serve the viewer briefly, screenshot it
|
|
141
|
+
return _serve_and_shoot(src, dst, browser, width, height, explode)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _serve_and_shoot(src: Path, dst: Path, browser: str,
|
|
145
|
+
width: int, height: int, explode: float) -> str:
|
|
146
|
+
import threading
|
|
147
|
+
|
|
148
|
+
from gitcad.viewer.server import serve
|
|
149
|
+
|
|
150
|
+
httpd = serve(str(src), port=0)
|
|
151
|
+
port = httpd.server_address[1]
|
|
152
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
153
|
+
try:
|
|
154
|
+
time.sleep(0.3)
|
|
155
|
+
frag = f"#x={explode}" if explode else ""
|
|
156
|
+
_shoot(browser, f"http://127.0.0.1:{port}/{frag}", dst, width, height)
|
|
157
|
+
finally:
|
|
158
|
+
httpd.shutdown()
|
|
159
|
+
return str(dst)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None: # pragma: no cover - CLI entrypoint
|
|
163
|
+
import argparse
|
|
164
|
+
|
|
165
|
+
ap = argparse.ArgumentParser(
|
|
166
|
+
description="gitcad render — PNG/SVG images from any design file")
|
|
167
|
+
ap.add_argument("file")
|
|
168
|
+
ap.add_argument("-o", "--out", required=True, help="output .png or .svg")
|
|
169
|
+
ap.add_argument("--width", type=int, default=1400)
|
|
170
|
+
ap.add_argument("--height", type=int, default=900)
|
|
171
|
+
ap.add_argument("--explode", type=float, default=0.0,
|
|
172
|
+
help="exploded-view amount 0..1 (3D kinds)")
|
|
173
|
+
ap.add_argument("--three", action="store_true",
|
|
174
|
+
help="render a board in 3D (extruded) instead of top view")
|
|
175
|
+
args = ap.parse_args()
|
|
176
|
+
print(render(args.file, args.out, width=args.width, height=args.height,
|
|
177
|
+
explode=args.explode, three=args.three))
|
gitcad/requirements.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Requirements as code — the traceability matrix that actually executes.
|
|
2
|
+
|
|
3
|
+
A requirements document is canonical text binding named requirements to
|
|
4
|
+
machine checks over the design: mass and volume limits, bbox envelopes,
|
|
5
|
+
ERC/envelope/DRC cleanliness, rail utilization. ``verify`` runs every one
|
|
6
|
+
and reports measured-vs-limit per requirement — pass/fail history rides in
|
|
7
|
+
git next to the design that satisfies it, and a requirement nobody wired
|
|
8
|
+
to a check is visibly ``unchecked``, never silently green.
|
|
9
|
+
|
|
10
|
+
Aerospace/medical teams pay fortunes for traceability spreadsheets that
|
|
11
|
+
are lies by the second week. This is ~200 lines because the checks
|
|
12
|
+
already exist; requirements are just names bound to them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from gitcad.canonical import canonical_json
|
|
21
|
+
from gitcad.errors import GitcadError
|
|
22
|
+
|
|
23
|
+
SCHEMA = "gitcad/requirements@1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_requirements(text: str) -> list[dict]:
|
|
27
|
+
doc = json.loads(text)
|
|
28
|
+
if doc.get("schema") != SCHEMA:
|
|
29
|
+
raise GitcadError(f"unsupported requirements schema {doc.get('schema')!r}")
|
|
30
|
+
reqs = doc.get("requirements", [])
|
|
31
|
+
seen = set()
|
|
32
|
+
for r in reqs:
|
|
33
|
+
if "id" not in r or "text" not in r:
|
|
34
|
+
raise GitcadError("every requirement needs id and text")
|
|
35
|
+
if r["id"] in seen:
|
|
36
|
+
raise GitcadError(f"duplicate requirement id {r['id']!r}")
|
|
37
|
+
seen.add(r["id"])
|
|
38
|
+
return reqs
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_target(root: Path, target: str):
|
|
42
|
+
"""'model:file.json' / 'schematic:file.json' / 'board:file.json'."""
|
|
43
|
+
kind, _, rel = target.partition(":")
|
|
44
|
+
path = root / rel
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise GitcadError(f"target file not found: {rel}")
|
|
47
|
+
text = path.read_text(encoding="utf-8")
|
|
48
|
+
if kind == "model":
|
|
49
|
+
from gitcad.document import Document
|
|
50
|
+
|
|
51
|
+
return Document.loads(text)
|
|
52
|
+
if kind == "schematic":
|
|
53
|
+
from gitcad.ecad import Schematic
|
|
54
|
+
|
|
55
|
+
return Schematic.loads(text)
|
|
56
|
+
if kind == "board":
|
|
57
|
+
from gitcad.ecad import Board
|
|
58
|
+
|
|
59
|
+
return Board.loads(text)
|
|
60
|
+
raise GitcadError(f"unknown target kind {kind!r} (want model|schematic|board)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_check(check: dict, root: Path) -> dict:
|
|
64
|
+
"""One requirement's check. Returns {ok, measured, limit, detail?}."""
|
|
65
|
+
kind = check.get("kind")
|
|
66
|
+
if kind in ("mass_max_g", "volume_max_mm3", "bbox_max_mm"):
|
|
67
|
+
from gitcad.kernel import get_kernel
|
|
68
|
+
|
|
69
|
+
kernel = get_kernel()
|
|
70
|
+
doc = _load_target(root, check["target"])
|
|
71
|
+
shape = doc.build(kernel).final(doc)
|
|
72
|
+
if kind == "bbox_max_mm":
|
|
73
|
+
lo, hi = kernel.bbox(shape)
|
|
74
|
+
dims = [round(hi[i] - lo[i], 6) for i in range(3)]
|
|
75
|
+
limit = list(check["limit"])
|
|
76
|
+
return {"ok": all(d <= lim + 1e-9 for d, lim in zip(dims, limit)),
|
|
77
|
+
"measured": dims, "limit": limit,
|
|
78
|
+
"geometry_verified": not kernel.name.startswith("null")}
|
|
79
|
+
vol = kernel.measure(shape).get("volume")
|
|
80
|
+
if vol is None:
|
|
81
|
+
return {"ok": False, "measured": None, "limit": check["limit"],
|
|
82
|
+
"detail": "volume unavailable on this kernel"}
|
|
83
|
+
if kind == "volume_max_mm3":
|
|
84
|
+
return {"ok": vol <= check["limit"] + 1e-9,
|
|
85
|
+
"measured": round(vol, 3), "limit": check["limit"],
|
|
86
|
+
"geometry_verified": not kernel.name.startswith("null")}
|
|
87
|
+
mass = vol * check.get("density_g_cm3", 1.0) / 1000.0
|
|
88
|
+
return {"ok": mass <= check["limit"] + 1e-9,
|
|
89
|
+
"measured": round(mass, 3), "limit": check["limit"],
|
|
90
|
+
"geometry_verified": not kernel.name.startswith("null")}
|
|
91
|
+
|
|
92
|
+
if kind == "interference_clear":
|
|
93
|
+
# the cross-domain fit check: every instance of an assembly —
|
|
94
|
+
# mech models AND board-backed PCBAs (populated envelopes) —
|
|
95
|
+
# pairwise boolean-intersected within a clash budget
|
|
96
|
+
from gitcad.kernel import get_kernel
|
|
97
|
+
from gitcad.part.interference import check_interference
|
|
98
|
+
from gitcad.viewer.server import resolve_assembly_shapes
|
|
99
|
+
|
|
100
|
+
kernel = get_kernel()
|
|
101
|
+
target = check["target"]
|
|
102
|
+
kind2, _, rel = target.partition(":")
|
|
103
|
+
if kind2 != "assembly":
|
|
104
|
+
raise GitcadError("interference_clear target must be 'assembly:<file>'")
|
|
105
|
+
path = root / rel
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
raise GitcadError(f"target file not found: {rel}")
|
|
108
|
+
resolved = resolve_assembly_shapes(path, kernel)
|
|
109
|
+
instances = {n: (s, t, r) for n, (s, t, r, _p) in resolved.items()}
|
|
110
|
+
tol = float(check.get("tol_mm3", 0.0))
|
|
111
|
+
rep = check_interference(kernel, instances, tol_mm3=tol or None)
|
|
112
|
+
worst = max(rep.checks["overlaps_mm3"].values(), default=0.0)
|
|
113
|
+
return {"ok": rep.ok, "measured": worst, "limit": tol,
|
|
114
|
+
"detail": rep.checks["overlaps_mm3"] or "no overlaps",
|
|
115
|
+
"geometry_verified": not kernel.name.startswith("null")}
|
|
116
|
+
|
|
117
|
+
if kind == "erc_clean":
|
|
118
|
+
sch = _load_target(root, check["target"])
|
|
119
|
+
r = sch.erc()
|
|
120
|
+
return {"ok": r.ok, "measured": len(r.violations), "limit": 0,
|
|
121
|
+
"detail": r.violations[:10]}
|
|
122
|
+
if kind == "envelope_clean":
|
|
123
|
+
from gitcad.ecad import check_envelopes
|
|
124
|
+
|
|
125
|
+
r = check_envelopes(_load_target(root, check["target"]))
|
|
126
|
+
return {"ok": r.ok, "measured": len(r.violations), "limit": 0,
|
|
127
|
+
"detail": r.violations[:10],
|
|
128
|
+
"coverage_pins": r.checks["pins_with_specs"]}
|
|
129
|
+
if kind == "rail_utilization_max":
|
|
130
|
+
from gitcad.ecad import power_budget
|
|
131
|
+
|
|
132
|
+
budget = power_budget(_load_target(root, check["target"]))
|
|
133
|
+
rail = budget.get(check["net"])
|
|
134
|
+
if rail is None or rail.get("utilization") is None:
|
|
135
|
+
return {"ok": False, "measured": None, "limit": check["limit"],
|
|
136
|
+
"detail": f"rail {check['net']!r} has no spec'd source/loads"}
|
|
137
|
+
return {"ok": rail["utilization"] <= check["limit"] + 1e-9,
|
|
138
|
+
"measured": rail["utilization"], "limit": check["limit"]}
|
|
139
|
+
if kind == "drc_clean":
|
|
140
|
+
from gitcad.ecad.drc import run_drc
|
|
141
|
+
|
|
142
|
+
r = run_drc(_load_target(root, check["target"]))
|
|
143
|
+
return {"ok": r.ok, "measured": len(r.violations), "limit": 0,
|
|
144
|
+
"detail": r.violations[:10]}
|
|
145
|
+
raise GitcadError(f"unknown check kind {kind!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def verify(requirements_text: str, root: str) -> dict:
|
|
149
|
+
rootp = Path(root)
|
|
150
|
+
results = []
|
|
151
|
+
ok = True
|
|
152
|
+
for req in load_requirements(requirements_text):
|
|
153
|
+
entry = {"id": req["id"], "text": req["text"]}
|
|
154
|
+
check = req.get("check")
|
|
155
|
+
if not check:
|
|
156
|
+
# a requirement without a check is VISIBLE debt, not silent green
|
|
157
|
+
entry.update({"status": "unchecked"})
|
|
158
|
+
ok = False
|
|
159
|
+
else:
|
|
160
|
+
try:
|
|
161
|
+
r = _run_check(check, rootp)
|
|
162
|
+
entry.update({"status": "pass" if r.pop("ok") else "fail", **r})
|
|
163
|
+
if entry["status"] == "fail":
|
|
164
|
+
ok = False
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
entry.update({"status": "error",
|
|
167
|
+
"detail": f"{type(exc).__name__}: {exc}"})
|
|
168
|
+
ok = False
|
|
169
|
+
results.append(entry)
|
|
170
|
+
counts = {s: sum(1 for r in results if r["status"] == s)
|
|
171
|
+
for s in ("pass", "fail", "error", "unchecked")}
|
|
172
|
+
return {"ok": ok, "requirements": results, "summary": counts}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def to_markdown(report: dict) -> str:
|
|
176
|
+
s = report["summary"]
|
|
177
|
+
lines = [f"## requirements — {'ALL PASS' if report['ok'] else 'NOT MET'}",
|
|
178
|
+
f"{s['pass']} pass · {s['fail']} fail · {s['error']} error · "
|
|
179
|
+
f"{s['unchecked']} unchecked", "",
|
|
180
|
+
"| id | requirement | status | measured | limit |",
|
|
181
|
+
"|----|-------------|--------|----------|-------|"]
|
|
182
|
+
for r in report["requirements"]:
|
|
183
|
+
lines.append(f"| {r['id']} | {r['text']} | **{r['status']}** | "
|
|
184
|
+
f"{r.get('measured', '—')} | {r.get('limit', '—')} |")
|
|
185
|
+
return "\n".join(lines) + "\n"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def new_requirements_doc(requirements: list[dict]) -> str:
|
|
189
|
+
return canonical_json({"schema": SCHEMA, "requirements": requirements},
|
|
190
|
+
indent=2) + "\n"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> None: # pragma: no cover - CLI entrypoint
|
|
194
|
+
import argparse
|
|
195
|
+
import sys
|
|
196
|
+
|
|
197
|
+
ap = argparse.ArgumentParser(
|
|
198
|
+
description="gitcad requirements — the traceability matrix that executes")
|
|
199
|
+
ap.add_argument("file", help="requirements.json")
|
|
200
|
+
ap.add_argument("--root", default=".", help="design tree targets resolve against")
|
|
201
|
+
args = ap.parse_args()
|
|
202
|
+
report = verify(Path(args.file).read_text(encoding="utf-8"), args.root)
|
|
203
|
+
print(to_markdown(report))
|
|
204
|
+
sys.exit(0 if report["ok"] else 1)
|
gitcad/review.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""PR review packaging — what a gitcad pull request shows a reviewer.
|
|
2
|
+
|
|
3
|
+
``review_range`` compares every changed design document between two git
|
|
4
|
+
refs and produces, per file: the semantic diff (feature/component-level,
|
|
5
|
+
volume delta), the CHECK DELTA (violations introduced or fixed — ERC,
|
|
6
|
+
envelopes, board validation, DRC), and before/after renders. The rollup's
|
|
7
|
+
``gate_ok`` is the merge gate: any NEW violation fails it. A regression
|
|
8
|
+
that was already red on base doesn't block (it isn't this PR's fault) but
|
|
9
|
+
still shows.
|
|
10
|
+
|
|
11
|
+
Binary CAD formats can't do any of this; canonical text + headless checks
|
|
12
|
+
make it a ~300-line module. Emit markdown for a PR comment via
|
|
13
|
+
``to_markdown``, or a self-contained HTML page via ``to_html``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from gitcad.release import _kind, semantic_diff
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _git_show(repo: Path, ref: str, relpath: str) -> str | None:
|
|
25
|
+
proc = subprocess.run(["git", "-C", str(repo), "show", f"{ref}:{relpath}"],
|
|
26
|
+
capture_output=True, text=True)
|
|
27
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Design-document extensions: the json-free names are canonical (directory
|
|
31
|
+
# legibility); .json variants stay accepted forever — kind detection is by
|
|
32
|
+
# CONTENT (the schema field), extensions only scope the scan.
|
|
33
|
+
DESIGN_EXTENSIONS = (".json", ".gitcad", ".model", ".sch", ".board", ".part",
|
|
34
|
+
".pcba", ".reqs")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _changed_files(repo: Path, base: str, head: str) -> list[str]:
|
|
38
|
+
proc = subprocess.run(
|
|
39
|
+
["git", "-C", str(repo), "diff", "--name-only", f"{base}...{head}"],
|
|
40
|
+
capture_output=True, text=True, check=True)
|
|
41
|
+
return [ln.strip().replace("\\", "/") for ln in proc.stdout.splitlines()
|
|
42
|
+
if ln.strip().endswith(DESIGN_EXTENSIONS)
|
|
43
|
+
and not ln.strip().endswith(".kicad_sch")]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _checks(kind: str, text: str) -> list[str]:
|
|
47
|
+
"""The check suite for one document kind — violation strings."""
|
|
48
|
+
try:
|
|
49
|
+
if kind == "schematic":
|
|
50
|
+
from gitcad.ecad import Schematic, check_envelopes
|
|
51
|
+
|
|
52
|
+
sch = Schematic.loads(text)
|
|
53
|
+
return sch.erc().violations + check_envelopes(sch).violations
|
|
54
|
+
if kind == "board":
|
|
55
|
+
from gitcad.ecad import Board
|
|
56
|
+
from gitcad.ecad.drc import run_drc
|
|
57
|
+
|
|
58
|
+
board = Board.loads(text)
|
|
59
|
+
return board.validate().violations + run_drc(board).violations
|
|
60
|
+
if kind == "document":
|
|
61
|
+
from gitcad.document import Document
|
|
62
|
+
from gitcad.kernel import get_kernel
|
|
63
|
+
|
|
64
|
+
doc = Document.loads(text)
|
|
65
|
+
kernel = get_kernel()
|
|
66
|
+
result = doc.build(kernel)
|
|
67
|
+
report = kernel.validate(result.final(doc)) if len(doc) else None
|
|
68
|
+
return list(report.violations) if report and not report.ok else []
|
|
69
|
+
return [] # part manifests: interface-semver rides in the diff
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
return [f"check-error:{type(exc).__name__}:{exc}"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render(kind: str, text: str) -> str | None:
|
|
75
|
+
"""A reviewable SVG for one revision of a document, or None."""
|
|
76
|
+
try:
|
|
77
|
+
if kind == "schematic":
|
|
78
|
+
from gitcad.ecad import Schematic, schematic_to_svg
|
|
79
|
+
|
|
80
|
+
return schematic_to_svg(Schematic.loads(text))
|
|
81
|
+
if kind == "board":
|
|
82
|
+
from gitcad.ecad import Board
|
|
83
|
+
from gitcad.viewer.boardsvg import board_to_svg
|
|
84
|
+
|
|
85
|
+
return board_to_svg(Board.loads(text))
|
|
86
|
+
if kind == "document":
|
|
87
|
+
from gitcad.document import Document
|
|
88
|
+
from gitcad.drawing.sheet import make_drawing
|
|
89
|
+
from gitcad.kernel import get_kernel
|
|
90
|
+
|
|
91
|
+
kernel = get_kernel()
|
|
92
|
+
if kernel.name.startswith("null"):
|
|
93
|
+
return None # no fake geometry renders
|
|
94
|
+
doc = Document.loads(text)
|
|
95
|
+
return make_drawing(doc.build(kernel).final(doc), kernel,
|
|
96
|
+
title="review", sheet="A4").to_svg()
|
|
97
|
+
except Exception:
|
|
98
|
+
return None
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def review_range(repo: str, base: str, head: str = "HEAD") -> dict:
|
|
103
|
+
root = Path(repo)
|
|
104
|
+
files = []
|
|
105
|
+
gate_ok = True
|
|
106
|
+
for rel in _changed_files(root, base, head):
|
|
107
|
+
old = _git_show(root, base, rel)
|
|
108
|
+
new = _git_show(root, head, rel)
|
|
109
|
+
if new is None and old is None:
|
|
110
|
+
continue
|
|
111
|
+
kind = _kind(new if new is not None else old)
|
|
112
|
+
if kind not in ("document", "board", "schematic", "part"):
|
|
113
|
+
continue
|
|
114
|
+
entry: dict = {"file": rel, "kind": kind,
|
|
115
|
+
"status": ("added" if old is None else
|
|
116
|
+
"removed" if new is None else "modified")}
|
|
117
|
+
if old is not None and new is not None:
|
|
118
|
+
entry["diff"] = semantic_diff(old, new)
|
|
119
|
+
old_v = _checks(kind, old) if old is not None else []
|
|
120
|
+
new_v = _checks(kind, new) if new is not None else []
|
|
121
|
+
# sibling waivers file at HEAD applies to the gate (reviewable text —
|
|
122
|
+
# a PR that adds a waiver shows exactly what it silences and why)
|
|
123
|
+
wtext = _git_show(root, head, rel + ".waivers")
|
|
124
|
+
if wtext is not None:
|
|
125
|
+
from gitcad.waivers import load_waivers, waive
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
ws = load_waivers(wtext)
|
|
129
|
+
new_v, waived, unused = waive(new_v, ws)
|
|
130
|
+
entry["waived"] = waived
|
|
131
|
+
new_v += [f"waiver-unused:{m}" for m in unused]
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
new_v.append(f"waiver-error:{type(exc).__name__}:{exc}")
|
|
134
|
+
entry["violations_introduced"] = sorted(set(new_v) - set(old_v))
|
|
135
|
+
entry["violations_fixed"] = sorted(set(old_v) - set(new_v))
|
|
136
|
+
entry["violations_preexisting"] = sorted(set(new_v) & set(old_v))
|
|
137
|
+
if entry["violations_introduced"]:
|
|
138
|
+
gate_ok = False
|
|
139
|
+
entry["render_old"] = _render(kind, old) if old is not None else None
|
|
140
|
+
entry["render_new"] = _render(kind, new) if new is not None else None
|
|
141
|
+
files.append(entry)
|
|
142
|
+
return {"base": base, "head": head, "files": files, "gate_ok": gate_ok,
|
|
143
|
+
"summary": {
|
|
144
|
+
"changed": len(files),
|
|
145
|
+
"introduced": sum(len(f["violations_introduced"]) for f in files),
|
|
146
|
+
"fixed": sum(len(f["violations_fixed"]) for f in files)}}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_markdown(report: dict) -> str:
|
|
150
|
+
s = report["summary"]
|
|
151
|
+
gate = "PASS" if report["gate_ok"] else "FAIL"
|
|
152
|
+
lines = [f"## gitcad review — {report['base']}...{report['head']}",
|
|
153
|
+
f"**gate: {gate}** · {s['changed']} design file(s) changed · "
|
|
154
|
+
f"{s['introduced']} violation(s) introduced · {s['fixed']} fixed", ""]
|
|
155
|
+
for f in report["files"]:
|
|
156
|
+
lines.append(f"### `{f['file']}` ({f['kind']}, {f['status']})")
|
|
157
|
+
d = f.get("diff", {})
|
|
158
|
+
for key in ("features_added", "features_removed", "features_changed",
|
|
159
|
+
"components_added", "components_removed"):
|
|
160
|
+
if d.get(key):
|
|
161
|
+
items = [x["id"] if isinstance(x, dict) else x for x in d[key]]
|
|
162
|
+
lines.append(f"- {key.replace('_', ' ')}: {', '.join(map(str, items))}")
|
|
163
|
+
if "volume_mm3" in d and "delta" in d.get("volume_mm3", {}):
|
|
164
|
+
v = d["volume_mm3"]
|
|
165
|
+
lines.append(f"- volume: {v['old']} → {v['new']} mm³ (Δ {v['delta']:+})")
|
|
166
|
+
if d.get("required_bump"):
|
|
167
|
+
lines.append(f"- interface-semver: **{d['required_bump']}** bump required "
|
|
168
|
+
f"({'; '.join(d.get('reasons', []))})")
|
|
169
|
+
for label, key in (("**introduced**", "violations_introduced"),
|
|
170
|
+
("fixed", "violations_fixed")):
|
|
171
|
+
for v in f[key]:
|
|
172
|
+
lines.append(f"- {label}: `{v}`")
|
|
173
|
+
if f["violations_preexisting"]:
|
|
174
|
+
lines.append(f"- pre-existing (not this PR): {len(f['violations_preexisting'])}")
|
|
175
|
+
lines.append("")
|
|
176
|
+
return "\n".join(lines)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def to_html(report: dict) -> str:
|
|
180
|
+
cards = []
|
|
181
|
+
for f in report["files"]:
|
|
182
|
+
panes = ""
|
|
183
|
+
if f["render_old"] or f["render_new"]:
|
|
184
|
+
for title, svg in (("base", f["render_old"]), ("head", f["render_new"])):
|
|
185
|
+
inner = svg if svg else '<p class="none">(no render)</p>'
|
|
186
|
+
panes += f'<div class="pane"><h4>{title}</h4>{inner}</div>'
|
|
187
|
+
panes = f'<div class="sxs">{panes}</div>'
|
|
188
|
+
vio = "".join(f'<li class="bad">{v}</li>' for v in f["violations_introduced"])
|
|
189
|
+
vio += "".join(f'<li class="good">fixed: {v}</li>' for v in f["violations_fixed"])
|
|
190
|
+
cards.append(f'<section><h3>{f["file"]} <small>({f["kind"]}, {f["status"]})'
|
|
191
|
+
f'</small></h3><ul>{vio}</ul>{panes}</section>')
|
|
192
|
+
gate = "PASS" if report["gate_ok"] else "FAIL"
|
|
193
|
+
color = "#3fb950" if report["gate_ok"] else "#f85149"
|
|
194
|
+
return (
|
|
195
|
+
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
|
196
|
+
"<title>gitcad review</title><style>"
|
|
197
|
+
"body{font:14px ui-monospace,Consolas,monospace;margin:2rem;background:#0d1117;color:#c9d1d9}"
|
|
198
|
+
"section{margin-bottom:2rem;border:1px solid #21262d;border-radius:6px;padding:1rem}"
|
|
199
|
+
".sxs{display:flex;gap:12px;flex-wrap:wrap}"
|
|
200
|
+
".pane{flex:1;min-width:320px;background:#fff;border-radius:4px;padding:8px}"
|
|
201
|
+
".pane h4{color:#57606a;margin:0 0 6px}.pane svg{max-width:100%;height:auto}"
|
|
202
|
+
".bad{color:#f85149}.good{color:#3fb950}.none{color:#8b949e}"
|
|
203
|
+
"small{color:#8b949e}</style></head><body>"
|
|
204
|
+
f"<h1>gitcad review <span style='color:{color}'>{gate}</span></h1>"
|
|
205
|
+
f"<p>{report['base']}...{report['head']} · "
|
|
206
|
+
f"{report['summary']['changed']} file(s) · "
|
|
207
|
+
f"{report['summary']['introduced']} introduced · "
|
|
208
|
+
f"{report['summary']['fixed']} fixed</p>"
|
|
209
|
+
+ "".join(cards) + "</body></html>")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main() -> None: # pragma: no cover - CLI entrypoint
|
|
213
|
+
import argparse
|
|
214
|
+
import sys
|
|
215
|
+
|
|
216
|
+
ap = argparse.ArgumentParser(
|
|
217
|
+
description="gitcad review — semantic + check + visual diff between git refs")
|
|
218
|
+
ap.add_argument("--repo", default=".")
|
|
219
|
+
ap.add_argument("--base", required=True)
|
|
220
|
+
ap.add_argument("--head", default="HEAD")
|
|
221
|
+
ap.add_argument("--md", help="write a Markdown report (PR comment) here")
|
|
222
|
+
ap.add_argument("--html", help="write a self-contained HTML report here")
|
|
223
|
+
args = ap.parse_args()
|
|
224
|
+
report = review_range(args.repo, args.base, args.head)
|
|
225
|
+
if args.md:
|
|
226
|
+
Path(args.md).write_text(to_markdown(report), encoding="utf-8")
|
|
227
|
+
if args.html:
|
|
228
|
+
Path(args.html).write_text(to_html(report), encoding="utf-8")
|
|
229
|
+
print(to_markdown(report))
|
|
230
|
+
sys.exit(0 if report["gate_ok"] else 1)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The web viewer — a local, dependency-free window into models and boards.
|
|
2
|
+
|
|
3
|
+
``gitcad-view model.gitcad.json`` serves a single-page viewer at localhost:
|
|
4
|
+
3D models render in a ~200-line WebGL2 client (no three.js, no CDN — the
|
|
5
|
+
page is self-contained, matching the project's zero-dependency ethos); boards
|
|
6
|
+
render as server-generated SVG. The page polls for file changes and reloads
|
|
7
|
+
live, which makes it simultaneously:
|
|
8
|
+
|
|
9
|
+
- the human's window while an agent works, and
|
|
10
|
+
- the agent's eyes (drive the file, screenshot the viewer).
|
|
11
|
+
|
|
12
|
+
Server: Python stdlib only. The Renderer seam gets a real backend the day we
|
|
13
|
+
need offscreen PNGs; the tessellation source is already kernel-side.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from gitcad.viewer.boardsvg import board_to_svg
|
|
17
|
+
from gitcad.viewer.server import main, mesh_payload
|
|
18
|
+
|
|
19
|
+
__all__ = ["board_to_svg", "mesh_payload", "main"]
|