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.
File without changes
gitcad/bench/corpus.py ADDED
@@ -0,0 +1,260 @@
1
+ """The kernel benchmark corpus — deterministic documents per operator
2
+ class (ADR-0018 W0).
3
+
4
+ Each entry builds a Document tagged with the operator classes it
5
+ exercises. This corpus is simultaneously: the differential-test input
6
+ (ref vs occt), the benchmark workload (timed per backend), and the
7
+ future acceptance suite for kernel cutover. Torture entries encode the
8
+ configurations that break kernels — tangencies, coincidences, slivers
9
+ — where beating OCCT must become measurable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Callable
15
+
16
+ from gitcad.document import Document, Feature
17
+
18
+
19
+ def plate_with_holes() -> Document:
20
+ d = Document()
21
+ base = d.add(Feature(op="box", params={"dx": 60, "dy": 40, "dz": 4}))
22
+ prev = base
23
+ for i in range(4):
24
+ prev = d.add(Feature(op="hole", params={
25
+ "x": 10 + 13 * i, "y": 20, "top_z": 4, "depth": 4,
26
+ "diameter": 5}, inputs=[prev]))
27
+ return d
28
+
29
+
30
+ def quadric_boss() -> Document:
31
+ d = Document()
32
+ base = d.add(Feature(op="cylinder", params={"radius": 20, "height": 8}))
33
+ boss = d.add(Feature(op="cone", params={"r1": 10, "r2": 6, "height": 12}))
34
+ bm = d.add(Feature(op="move", params={"translate": [0, 0, 8]}, inputs=[boss]))
35
+ u = d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[base, bm]))
36
+ s = d.add(Feature(op="sphere", params={"radius": 5}))
37
+ sm = d.add(Feature(op="move", params={"translate": [0, 0, 22]}, inputs=[s]))
38
+ d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[u, sm]))
39
+ return d
40
+
41
+
42
+ def revolve_profile() -> Document:
43
+ d = Document()
44
+ profile = {"start": [5, 0], "segments": [
45
+ {"kind": "line", "to": [15, 0]}, {"kind": "line", "to": [15, 4]},
46
+ {"kind": "line", "to": [8, 8]}, {"kind": "line", "to": [8, 20]},
47
+ {"kind": "line", "to": [5, 20]}, {"kind": "line", "to": [5, 0]}]}
48
+ d.add(Feature(op="revolve", params={"profile": profile, "angle_deg": 360.0}))
49
+ return d
50
+
51
+
52
+ def extrude_L() -> Document:
53
+ d = Document()
54
+ profile = {"start": [0, 0], "segments": [
55
+ {"kind": "line", "to": [30, 0]}, {"kind": "line", "to": [30, 8]},
56
+ {"kind": "line", "to": [8, 8]}, {"kind": "line", "to": [8, 25]},
57
+ {"kind": "line", "to": [0, 25]}, {"kind": "line", "to": [0, 0]}]}
58
+ d.add(Feature(op="extrude", params={"profile": profile, "height": 12}))
59
+ return d
60
+
61
+
62
+ def filleted_block() -> Document:
63
+ d = Document()
64
+ b = d.add(Feature(op="box", params={"dx": 30, "dy": 20, "dz": 10}))
65
+ d.add(Feature(op="fillet", params={"radius": 2.5}, inputs=[b]))
66
+ return d
67
+
68
+
69
+ def chamfered_block() -> Document:
70
+ d = Document()
71
+ b = d.add(Feature(op="box", params={"dx": 30, "dy": 20, "dz": 10}))
72
+ d.add(Feature(op="chamfer", params={"distance": 2.0}, inputs=[b]))
73
+ return d
74
+
75
+
76
+ def shelled_box() -> Document:
77
+ d = Document()
78
+ b = d.add(Feature(op="box", params={"dx": 40, "dy": 30, "dz": 20}))
79
+ d.add(Feature(op="shell", params={"faces": [], "thickness": 2.0},
80
+ inputs=[b]))
81
+ return d
82
+
83
+
84
+ def shelled_prism() -> Document:
85
+ # K4.1: closed hollow of a non-box prism — a right trapezoid with a
86
+ # 3-4-5 hypotenuse (Pythagorean edges → the exact inward inset).
87
+ # ref = exactly 541/6; OCCT's thick-solid offset agrees in float.
88
+ d = Document()
89
+ p = d.add(Feature(op="extrude", params={"profile": {
90
+ "start": [0, 0], "segments": [
91
+ {"kind": "line", "to": [8, 0]}, {"kind": "line", "to": [8, 3]},
92
+ {"kind": "line", "to": [4, 6]}, {"kind": "line", "to": [0, 6]},
93
+ {"kind": "line", "to": [0, 0]}]}, "height": 5}))
94
+ d.add(Feature(op="shell", params={"faces": [], "thickness": 0.5},
95
+ inputs=[p]))
96
+ return d
97
+
98
+
99
+ def drafted_block() -> Document:
100
+ d = Document()
101
+ b = d.add(Feature(op="box", params={"dx": 30, "dy": 30, "dz": 15}))
102
+ d.add(Feature(op="draft", params={"faces": [], "angle_deg": 3.0},
103
+ inputs=[b]))
104
+ return d
105
+
106
+
107
+ def spring() -> Document:
108
+ d = Document()
109
+ d.add(Feature(op="spring", params={"radius": 8, "pitch": 4, "turns": 6,
110
+ "wire_diameter": 1.5}))
111
+ return d
112
+
113
+
114
+ def swept_channel() -> Document:
115
+ d = Document()
116
+ profile = {"start": [-2, -2], "segments": [
117
+ {"kind": "line", "to": [2, -2]}, {"kind": "line", "to": [2, 2]},
118
+ {"kind": "line", "to": [-2, 2]}, {"kind": "line", "to": [-2, -2]}]}
119
+ d.add(Feature(op="sweep", params={"profile": profile,
120
+ "path": [[0, 0, 0], [0, 0, 20],
121
+ [15, 0, 35], [40, 0, 35]]}))
122
+ return d
123
+
124
+
125
+ def loft_transition() -> Document:
126
+ d = Document()
127
+ sq = {"start": [-10, -10], "segments": [
128
+ {"kind": "line", "to": [10, -10]}, {"kind": "line", "to": [10, 10]},
129
+ {"kind": "line", "to": [-10, 10]}, {"kind": "line", "to": [-10, -10]}]}
130
+ sm = {"start": [-4, -4], "segments": [
131
+ {"kind": "line", "to": [4, -4]}, {"kind": "line", "to": [4, 4]},
132
+ {"kind": "line", "to": [-4, 4]}, {"kind": "line", "to": [-4, -4]}]}
133
+ d.add(Feature(op="loft", params={"sections": [
134
+ {"profile": sq, "z": 0.0}, {"profile": sm, "z": 25.0}]}))
135
+ return d
136
+
137
+
138
+ def sheetmetal_folded() -> Document:
139
+ from gitcad.sheetmetal import Flange, SheetMetal
140
+
141
+ sm = SheetMetal(name="bench-bracket", width=40, height=50, thickness=2,
142
+ k_factor=0.44, bend_radius=2,
143
+ flanges=[Flange(edge="n", length=20),
144
+ Flange(edge="e", length=12, direction="down")])
145
+ return sm.to_document()
146
+
147
+
148
+ # -- torture: the configurations that break kernels ---------------------------
149
+
150
+ def torture_tangent_cylinders() -> Document:
151
+ d = Document()
152
+ a = d.add(Feature(op="cylinder", params={"radius": 10, "height": 10}))
153
+ b = d.add(Feature(op="cylinder", params={"radius": 10, "height": 10}))
154
+ bm = d.add(Feature(op="move", params={"translate": [20, 0, 0]}, inputs=[b]))
155
+ d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[a, bm]))
156
+ return d
157
+
158
+
159
+ def torture_coincident_faces() -> Document:
160
+ d = Document()
161
+ a = d.add(Feature(op="box", params={"dx": 20, "dy": 20, "dz": 10}))
162
+ b = d.add(Feature(op="box", params={"dx": 20, "dy": 20, "dz": 10}))
163
+ bm = d.add(Feature(op="move", params={"translate": [20, 0, 0]}, inputs=[b]))
164
+ d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[a, bm]))
165
+ return d
166
+
167
+
168
+ def torture_sliver_cut() -> Document:
169
+ d = Document()
170
+ a = d.add(Feature(op="box", params={"dx": 30, "dy": 30, "dz": 10}))
171
+ b = d.add(Feature(op="box", params={"dx": 30, "dy": 30, "dz": 10}))
172
+ bm = d.add(Feature(op="move", params={"translate": [29.999, 0, 0]},
173
+ inputs=[b]))
174
+ d.add(Feature(op="boolean", params={"kind": "cut"}, inputs=[a, bm]))
175
+ return d
176
+
177
+
178
+ def torture_tangent_sphere_plane() -> Document:
179
+ d = Document()
180
+ a = d.add(Feature(op="box", params={"dx": 30, "dy": 30, "dz": 10}))
181
+ s = d.add(Feature(op="sphere", params={"radius": 5}))
182
+ sm = d.add(Feature(op="move", params={"translate": [15, 15, 15]},
183
+ inputs=[s]))
184
+ d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[a, sm]))
185
+ return d
186
+
187
+
188
+ def quadric_sphere_overlap() -> Document:
189
+ """Two GENUINELY OVERLAPPING spheres unioned — a non-coaxial quadric
190
+ boolean OCCT builds and ref does EXACTLY in ℚ[π] (cap/lens): r=5
191
+ spheres 6 apart -> union = 896/3 π. The K2.2 exact case (parallel-
192
+ cylinder overlap stays transcendental / refused)."""
193
+ d = Document()
194
+ a = d.add(Feature(op="sphere", params={"radius": 5}))
195
+ b = d.add(Feature(op="sphere", params={"radius": 5}))
196
+ bm = d.add(Feature(op="move", params={"translate": [6, 0, 0]}, inputs=[b]))
197
+ d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[a, bm]))
198
+ return d
199
+
200
+
201
+ def sweep_rightangle() -> Document:
202
+ """A square profile swept along a 90-degree-cornered axis-aligned path
203
+ — all segment lengths RATIONAL, so ref builds it in plain Q (no surd
204
+ field needed). Exact volume = area * (10+10+10) = 16*30 = 480."""
205
+ d = Document()
206
+ profile = {"start": [-2, -2], "segments": [
207
+ {"kind": "line", "to": [2, -2]}, {"kind": "line", "to": [2, 2]},
208
+ {"kind": "line", "to": [-2, 2]}, {"kind": "line", "to": [-2, -2]}]}
209
+ d.add(Feature(op="sweep", params={"profile": profile,
210
+ "path": [[0, 0, 0], [0, 0, 10],
211
+ [10, 0, 10], [10, 0, 20]]}))
212
+ return d
213
+
214
+
215
+ def torture_menger_1() -> Document:
216
+ """The K1 acid test: Menger sponge level 1 — a 27mm cube minus three
217
+ orthogonal 9mm square rods through the center. Five chained booleans
218
+ with coincident internal faces everywhere; exact volume is KNOWN:
219
+ 20/27 of the cube = 14580 mm^3. A kernel that smears tolerances gets
220
+ this wrong; an exact kernel gets it to the last bit."""
221
+ d = Document()
222
+ cube = d.add(Feature(op="box", params={"dx": 27, "dy": 27, "dz": 27}))
223
+ rz = d.add(Feature(op="box", params={"dx": 9, "dy": 9, "dz": 27}))
224
+ rzm = d.add(Feature(op="move", params={"translate": [9, 9, 0]}, inputs=[rz]))
225
+ rx = d.add(Feature(op="box", params={"dx": 27, "dy": 9, "dz": 9}))
226
+ rxm = d.add(Feature(op="move", params={"translate": [0, 9, 9]}, inputs=[rx]))
227
+ ry = d.add(Feature(op="box", params={"dx": 9, "dy": 27, "dz": 9}))
228
+ rym = d.add(Feature(op="move", params={"translate": [9, 0, 9]}, inputs=[ry]))
229
+ u1 = d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[rzm, rxm]))
230
+ u2 = d.add(Feature(op="boolean", params={"kind": "union"}, inputs=[u1, rym]))
231
+ d.add(Feature(op="boolean", params={"kind": "cut"}, inputs=[cube, u2]))
232
+ return d
233
+
234
+
235
+ CORPUS: list[tuple[str, tuple[str, ...], Callable[[], Document]]] = [
236
+ ("plate_with_holes", ("planar", "boolean", "hole"), plate_with_holes),
237
+ ("quadric_boss", ("quadric", "boolean"), quadric_boss),
238
+ ("revolve_profile", ("quadric", "revolve"), revolve_profile),
239
+ ("extrude_L", ("planar", "extrude"), extrude_L),
240
+ ("filleted_block", ("blend",), filleted_block),
241
+ ("chamfered_block", ("planar", "blend"), chamfered_block),
242
+ ("shelled_box", ("offset",), shelled_box),
243
+ ("shelled_prism", ("offset",), shelled_prism),
244
+ ("drafted_block", ("draft",), drafted_block),
245
+ ("spring", ("sweep", "freeform"), spring),
246
+ ("swept_channel", ("sweep",), swept_channel),
247
+ ("loft_transition", ("loft", "freeform"), loft_transition),
248
+ ("sheetmetal_folded", ("planar", "boolean"), sheetmetal_folded),
249
+ ("torture_tangent_cylinders", ("torture", "quadric", "boolean"),
250
+ torture_tangent_cylinders),
251
+ ("torture_coincident_faces", ("torture", "planar", "boolean"),
252
+ torture_coincident_faces),
253
+ ("torture_sliver_cut", ("torture", "planar", "boolean"),
254
+ torture_sliver_cut),
255
+ ("torture_tangent_sphere_plane", ("torture", "quadric", "boolean"),
256
+ torture_tangent_sphere_plane),
257
+ ("torture_menger_1", ("torture", "planar", "boolean"), torture_menger_1),
258
+ ("sweep_rightangle", ("sweep",), sweep_rightangle),
259
+ ("quadric_sphere_overlap", ("quadric", "boolean"), quadric_sphere_overlap),
260
+ ]
@@ -0,0 +1,176 @@
1
+ """Kernel scorecard — the benchmark that makes improvement provable
2
+ (ADR-0018 W0; owner requirement: "show we are actually improving").
3
+
4
+ Runs the corpus on a named backend and emits a snapshot: per-model
5
+ build result, wall time, volume/bbox/topology metrics, validation.
6
+ With two backends, emits deltas. Snapshots are dated JSON committed
7
+ under bench/; TREND.md regenerates from them so every improvement
8
+ claim has a number in git history behind it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from gitcad.bench.corpus import CORPUS
19
+
20
+ _REL_TOL = 1e-9
21
+
22
+
23
+ def _get_kernel(name: str):
24
+ if name == "occt":
25
+ from gitcad.kernel.occt import OcctKernel
26
+ return OcctKernel()
27
+ if name == "null":
28
+ from gitcad.kernel.null import NullKernel
29
+ return NullKernel()
30
+ if name == "ref":
31
+ from gitcad.kernel.ref import RefKernel
32
+ return RefKernel()
33
+ if name == "forge":
34
+ from gitcad.kernel.rustref import RustKernel
35
+ return RustKernel()
36
+ if name == "auto":
37
+ from gitcad.kernel.auto import AutoKernel
38
+ return AutoKernel()
39
+ raise ValueError(f"unknown backend {name!r} (occt|null|ref|forge|auto)")
40
+
41
+
42
+ def run_backend(backend: str) -> dict[str, Any]:
43
+ """Build every corpus model on one backend; never raises — failures
44
+ are data (that is the point of a robustness benchmark)."""
45
+ kernel = _get_kernel(backend)
46
+ models: dict[str, Any] = {}
47
+ for name, classes, build in CORPUS:
48
+ entry: dict[str, Any] = {"classes": list(classes)}
49
+ t0 = time.perf_counter()
50
+ try:
51
+ doc = build()
52
+ result = doc.build(kernel)
53
+ shape = result.final(doc)
54
+ entry["ok"] = True
55
+ entry["seconds"] = round(time.perf_counter() - t0, 4)
56
+ try:
57
+ mp = kernel.mass_props(shape)
58
+ entry["volume"] = mp.get("volume")
59
+ except Exception as exc: # metric, not fatal
60
+ entry["volume_error"] = f"{type(exc).__name__}: {exc}"
61
+ try:
62
+ (x0, y0, z0), (x1, y1, z1) = kernel.bbox(shape)
63
+ entry["bbox"] = [round(v, 6) for v in
64
+ (x0, y0, z0, x1, y1, z1)]
65
+ except Exception:
66
+ pass
67
+ try:
68
+ entry["faces"] = len(kernel.entities(shape, "face"))
69
+ entry["edges"] = len(kernel.entities(shape, "edge"))
70
+ except Exception:
71
+ pass
72
+ try:
73
+ v = kernel.validate(shape)
74
+ entry["valid"] = bool(v.ok)
75
+ except Exception:
76
+ pass
77
+ except Exception as exc:
78
+ entry["ok"] = False
79
+ entry["seconds"] = round(time.perf_counter() - t0, 4)
80
+ entry["error"] = f"{type(exc).__name__}: {exc}"
81
+ models[name] = entry
82
+
83
+ by_class: dict[str, dict[str, int]] = {}
84
+ for name, entry in models.items():
85
+ for cls in entry["classes"]:
86
+ c = by_class.setdefault(cls, {"total": 0, "ok": 0, "valid": 0})
87
+ c["total"] += 1
88
+ c["ok"] += 1 if entry.get("ok") else 0
89
+ c["valid"] += 1 if entry.get("valid") else 0
90
+ return {"backend": backend, "models": models, "by_class": by_class,
91
+ "capability_pct": round(
92
+ 100 * sum(1 for m in models.values() if m.get("ok"))
93
+ / len(models), 1)}
94
+
95
+
96
+ def compare(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
97
+ """Deltas of run b against oracle run a (volume rel-delta, topology
98
+ count diffs, disagreement list)."""
99
+ out: dict[str, Any] = {"oracle": a["backend"], "subject": b["backend"],
100
+ "models": {}, "disagreements": []}
101
+ for name, ea in a["models"].items():
102
+ eb = b["models"].get(name, {})
103
+ row: dict[str, Any] = {"oracle_ok": ea.get("ok"),
104
+ "subject_ok": eb.get("ok")}
105
+ va, vb = ea.get("volume"), eb.get("volume")
106
+ if isinstance(va, (int, float)) and isinstance(vb, (int, float)) and va:
107
+ row["volume_rel_delta"] = abs(vb - va) / abs(va)
108
+ if row["volume_rel_delta"] > 1e-6:
109
+ out["disagreements"].append(
110
+ f"{name}: volume {va:.6g} vs {vb:.6g}")
111
+ if ea.get("ok") != eb.get("ok"):
112
+ out["disagreements"].append(
113
+ f"{name}: ok {ea.get('ok')} vs {eb.get('ok')}")
114
+ for k in ("faces", "edges"):
115
+ if k in ea and k in eb and ea[k] != eb[k]:
116
+ row[f"{k}_diff"] = (ea[k], eb[k])
117
+ out["models"][name] = row
118
+ return out
119
+
120
+
121
+ def snapshot(backends: list[str], outdir: str = "bench", *,
122
+ stamp: str) -> dict[str, Any]:
123
+ """Run + persist one dated benchmark snapshot; regenerate TREND.md."""
124
+ runs = {b: run_backend(b) for b in backends}
125
+ payload: dict[str, Any] = {"stamp": stamp, "runs": runs}
126
+ if len(backends) == 2:
127
+ payload["compare"] = compare(runs[backends[0]], runs[backends[1]])
128
+ out = Path(outdir)
129
+ out.mkdir(parents=True, exist_ok=True)
130
+ path = out / f"{stamp}-{'-vs-'.join(backends)}.json"
131
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n",
132
+ newline="\n")
133
+ _render_trend(out)
134
+ return payload
135
+
136
+
137
+ def _render_trend(outdir: Path) -> None:
138
+ rows: list[str] = ["# Kernel benchmark trend", "",
139
+ "Regenerated from bench/*.json — every improvement "
140
+ "claim has a snapshot behind it.", "",
141
+ "| snapshot | backend | capability % | torture ok | "
142
+ "models ok | total s |",
143
+ "|---|---|---|---|---|---|"]
144
+ for p in sorted(outdir.glob("*.json")):
145
+ data = json.loads(p.read_text())
146
+ for backend, run in sorted(data.get("runs", {}).items()):
147
+ models = run["models"]
148
+ torture = [m for m in models.values() if "torture" in m["classes"]]
149
+ rows.append(
150
+ f"| {data['stamp']} | {backend} | {run['capability_pct']} | "
151
+ f"{sum(1 for m in torture if m.get('ok'))}/{len(torture)} | "
152
+ f"{sum(1 for m in models.values() if m.get('ok'))}/{len(models)} | "
153
+ f"{round(sum(m.get('seconds', 0) for m in models.values()), 2)} |")
154
+ (outdir / "TREND.md").write_text("\n".join(rows) + "\n", newline="\n")
155
+
156
+
157
+ def main() -> None: # pragma: no cover - CLI entry
158
+ import argparse
159
+ from datetime import date
160
+
161
+ ap = argparse.ArgumentParser(description="kernel benchmark scorecard")
162
+ ap.add_argument("backends", nargs="+", help="occt | null | ref")
163
+ ap.add_argument("--outdir", default="bench")
164
+ ap.add_argument("--stamp", default=str(date.today()))
165
+ args = ap.parse_args()
166
+ payload = snapshot(args.backends, args.outdir, stamp=args.stamp)
167
+ for b, run in payload["runs"].items():
168
+ print(f"{b}: capability {run['capability_pct']}% "
169
+ f"({sum(1 for m in run['models'].values() if m.get('ok'))}"
170
+ f"/{len(run['models'])} models)")
171
+ for d in payload.get("compare", {}).get("disagreements", []):
172
+ print("DISAGREE:", d)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
gitcad/bridge.py ADDED
@@ -0,0 +1,72 @@
1
+ """Cross-domain bridges (metapackage — the one place mech and ecad meet).
2
+
3
+ :func:`board_to_model` — the dogfood's #2 friction finding: interference
4
+ checking needed the board as real 3D geometry, previously hand-extruded.
5
+ Now: outline (polygon, any shape) x thickness, mounting holes cut through —
6
+ a mech Document derived from the ecad source, ready for assemblies,
7
+ interference, STEP export, or the viewer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from gitcad.document import Document, Feature
13
+ from gitcad.ecad.board import Board
14
+ from gitcad.sketch import Profile
15
+
16
+
17
+ def component_envelope(comp, *, default_height: float = 2.0
18
+ ) -> tuple[float, float, float] | None:
19
+ """(w, h, height) of a component's IDF-style envelope, rotation applied:
20
+ courtyard when declared, pads' extent + margin otherwise, None when the
21
+ footprint has neither."""
22
+ cy = comp.footprint.courtyard
23
+ if cy is not None:
24
+ cw, ch = cy
25
+ else:
26
+ pads = comp.footprint.pads
27
+ if not pads:
28
+ return None
29
+ cw = max(abs(p.x) + p.w / 2 for p in pads) * 2 + 0.4
30
+ ch = max(abs(p.y) + p.h / 2 for p in pads) * 2 + 0.4
31
+ if round(comp.rot) % 180 == 90:
32
+ cw, ch = ch, cw
33
+ return (cw, ch, comp.footprint.height or default_height)
34
+
35
+
36
+ def board_to_model(board: Board, *, components: bool = True,
37
+ default_height: float = 2.0) -> Document:
38
+ """The board as a 3D body: extruded outline with mounting holes cut,
39
+ plus IDF-style component envelopes — each part's courtyard extruded to
40
+ its footprint height (``default_height`` when unknown). That makes the
41
+ populated board a REAL mechanical envelope: enclosure interference
42
+ checks see the tall electrolytic, not a bare slab."""
43
+ pts = list(board.outline)
44
+ prof = Profile(tuple(pts[0]))
45
+ for x, y in pts[1:]:
46
+ prof.line_to(x, y)
47
+ prof.close()
48
+
49
+ doc = Document()
50
+ fid = doc.add(Feature(op="extrude", params={"profile": prof.to_params(),
51
+ "height": board.thickness}))
52
+ for mh in board.mounting_holes:
53
+ fid = doc.add(Feature(op="hole", params={
54
+ "x": mh.x, "y": mh.y, "top_z": board.thickness,
55
+ "diameter": mh.drill, "depth": board.thickness}, inputs=[fid]))
56
+ if components:
57
+ for comp in board.components:
58
+ env = component_envelope(comp, default_height=default_height)
59
+ if env is None:
60
+ continue
61
+ cw, ch, h = env
62
+ # top: sits on the board surface; bottom: hangs below z=0
63
+ base = board.thickness if comp.side == "top" else -h
64
+ body = Profile((comp.x - cw / 2, comp.y - ch / 2)) \
65
+ .line_to(comp.x + cw / 2, comp.y - ch / 2) \
66
+ .line_to(comp.x + cw / 2, comp.y + ch / 2) \
67
+ .line_to(comp.x - cw / 2, comp.y + ch / 2).close()
68
+ fid = doc.add(Feature(op="extrude", params={
69
+ "profile": body.to_params(), "height": h,
70
+ "plane": {"offset": base},
71
+ "mode": "add"}, inputs=[fid]))
72
+ return doc
gitcad/convert.py ADDED
@@ -0,0 +1,30 @@
1
+ """gitcad-convert — one-command onboarding from other CAD.
2
+
3
+ Currently: multi-body FreeCAD .FCStd -> a full gitcad project (per-body
4
+ .model + .part, content-addressed assets/, and a .gitcad product root
5
+ instancing everything at as-modeled positions). From there the whole
6
+ toolchain applies: viewer + explode, interference with clash budgets,
7
+ review gates, release.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def main() -> None: # pragma: no cover - CLI entrypoint
14
+ import argparse
15
+
16
+ from gitcad.importers.fcstd import fcstd_to_project
17
+ from gitcad.kernel import get_kernel
18
+
19
+ ap = argparse.ArgumentParser(
20
+ description="gitcad convert — foreign CAD file to a gitcad project")
21
+ ap.add_argument("file", help="an .FCStd file (more formats to come)")
22
+ ap.add_argument("-o", "--out", required=True, help="project directory")
23
+ ap.add_argument("--name", help="project name (default: file stem)")
24
+ args = ap.parse_args()
25
+ result = fcstd_to_project(args.file, args.out,
26
+ get_kernel(require="occt"), name=args.name)
27
+ print(f"converted {result['project']}: {len(result['bodies'])} bodies")
28
+ for rel in result["written"]:
29
+ print(f" {rel}")
30
+ print(f"open it: gitcad-view {args.out}/{result['root']}")
gitcad/explore.py ADDED
@@ -0,0 +1,99 @@
1
+ """gitcad-explore — the branch scoreboard (ADR-0016's exploration half).
2
+
3
+ Spawn N design variants as branches, then judge them all by the same
4
+ gates: the review check-delta against base (violations introduced/fixed)
5
+ and, when the branch carries ``requirements.reqs``, the full executable
6
+ requirements suite run in a throwaway worktree of that branch. Output is
7
+ a table — the design-space comparison incumbent tools cannot make
8
+ because their files cannot branch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from gitcad.review import review_range
18
+
19
+
20
+ def _git(repo: str, *args: str) -> subprocess.CompletedProcess:
21
+ return subprocess.run(["git", "-C", repo, *args],
22
+ capture_output=True, text=True)
23
+
24
+
25
+ def _branches(repo: str, base: str) -> list[str]:
26
+ proc = _git(repo, "branch", "--format=%(refname:short)")
27
+ return [b.strip() for b in proc.stdout.splitlines()
28
+ if b.strip() and b.strip() != base]
29
+
30
+
31
+ def _requirements_in(repo: str, branch: str) -> str | None:
32
+ proc = _git(repo, "show", f"{branch}:requirements.reqs")
33
+ return proc.stdout if proc.returncode == 0 else None
34
+
35
+
36
+ def score_branches(repo: str, base: str,
37
+ branches: list[str] | None = None) -> dict:
38
+ from gitcad.requirements import verify
39
+
40
+ rows = []
41
+ for branch in (branches or _branches(repo, base)):
42
+ row: dict = {"branch": branch}
43
+ review = review_range(repo, base, branch)
44
+ row["gate"] = "PASS" if review["gate_ok"] else "FAIL"
45
+ row["introduced"] = review["summary"]["introduced"]
46
+ row["fixed"] = review["summary"]["fixed"]
47
+ row["files_changed"] = review["summary"]["changed"]
48
+
49
+ reqs_text = _requirements_in(repo, branch)
50
+ if reqs_text is not None:
51
+ with tempfile.TemporaryDirectory() as td:
52
+ wt = str(Path(td) / "wt")
53
+ added = _git(repo, "worktree", "add", "--detach", wt, branch)
54
+ if added.returncode == 0:
55
+ try:
56
+ report = verify(reqs_text, wt)
57
+ row["requirements"] = (
58
+ f"{report['summary']['pass']}/"
59
+ f"{len(report['requirements'])} pass")
60
+ row["requirements_ok"] = report["ok"]
61
+ finally:
62
+ _git(repo, "worktree", "remove", "--force", wt)
63
+ else:
64
+ row["requirements"] = "worktree failed"
65
+ row["requirements_ok"] = False
66
+ else:
67
+ row["requirements"] = "—"
68
+ row["requirements_ok"] = None
69
+ rows.append(row)
70
+
71
+ # rank: gate pass first, then requirements ok, then most fixed, fewest introduced
72
+ rows.sort(key=lambda r: (r["gate"] != "PASS",
73
+ r["requirements_ok"] is False,
74
+ -r["fixed"], r["introduced"], r["branch"]))
75
+ return {"base": base, "rows": rows}
76
+
77
+
78
+ def to_markdown(scoreboard: dict) -> str:
79
+ lines = [f"## branch scoreboard vs `{scoreboard['base']}`", "",
80
+ "| branch | gate | introduced | fixed | files | requirements |",
81
+ "|--------|------|-----------:|------:|------:|--------------|"]
82
+ for r in scoreboard["rows"]:
83
+ lines.append(f"| {r['branch']} | **{r['gate']}** | {r['introduced']} "
84
+ f"| {r['fixed']} | {r['files_changed']} | {r['requirements']} |")
85
+ return "\n".join(lines) + "\n"
86
+
87
+
88
+ def main() -> None: # pragma: no cover - CLI entrypoint
89
+ import argparse
90
+
91
+ ap = argparse.ArgumentParser(
92
+ description="gitcad explore — judge N design branches by the same gates")
93
+ ap.add_argument("branches", nargs="*",
94
+ help="branches to score (default: all except base)")
95
+ ap.add_argument("--repo", default=".")
96
+ ap.add_argument("--base", required=True)
97
+ args = ap.parse_args()
98
+ print(to_markdown(score_branches(args.repo, args.base,
99
+ args.branches or None)))