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/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"]