gitcad 0.7.7__tar.gz

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.
@@ -0,0 +1,38 @@
1
+ # Generated geometry is a build artifact, NOT source (ADR-0004). Never commit it.
2
+ *.brep
3
+ *.step
4
+ *.stp
5
+ *.stl
6
+ *.gltf
7
+ *.glb
8
+ *.iges
9
+ *.igs
10
+ # Drawings are generated from the model too.
11
+ /build-artifacts/
12
+ *.dxf
13
+ # PDFs generated from drawings (keep hand-authored docs elsewhere if needed).
14
+ /out/
15
+
16
+ # Python
17
+ __pycache__/
18
+ *.py[cod]
19
+ *.egg-info/
20
+ .eggs/
21
+ build/
22
+ dist/
23
+ .venv/
24
+ venv/
25
+
26
+ # Tooling
27
+ .pytest_cache/
28
+ .coverage
29
+ .coverage.*
30
+ htmlcov/
31
+ .mypy_cache/
32
+ .ruff_cache/
33
+
34
+ # Editors / OS
35
+ .vscode/
36
+ .idea/
37
+ .DS_Store
38
+ Thumbs.db
gitcad-0.7.7/PKG-INFO ADDED
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitcad
3
+ Version: 0.7.7
4
+ Summary: Agent-first, headless, git-native CAD (metapackage: core + mech + ecad + MCP + viewer + release)
5
+ Project-URL: Homepage, https://gitcad.xyz
6
+ Project-URL: Repository, https://github.com/gitcad-xyz/gitcad
7
+ Project-URL: Issues, https://github.com/gitcad-xyz/gitcad/issues
8
+ Author-email: Dan Willis <danielcwillis@gmail.com>
9
+ License-Expression: Apache-2.0
10
+ Keywords: agent,brep,cad,eda,git,headless,mcp,occt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Manufacturing
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: gitcad-core==0.7.7
22
+ Requires-Dist: gitcad-ecad==0.7.7
23
+ Requires-Dist: gitcad-mech==0.7.7
24
+ Requires-Dist: mcp>=1.0
25
+ Provides-Extra: occt
26
+ Requires-Dist: gitcad-mech[occt]==0.7.7; extra == 'occt'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # gitcad
30
+
31
+ **Agent-first, headless, git-native CAD — mechanical and electrical, one substrate.**
32
+
33
+ gitcad is a boundary-representation CAD system designed to be driven by agents and
34
+ version-controlled like source. Models are byte-canonical text (geometry is a
35
+ build artifact, never committed); identity is construction-lineage based, never
36
+ ordinal; every build produces a reviewable, diffable report.
37
+
38
+ This metapackage installs the whole system:
39
+
40
+ | package | domain |
41
+ |---|---|
42
+ | [`gitcad-core`](https://pypi.org/project/gitcad-core/) | canonical text, stable identity, the Part standard, report pipeline |
43
+ | [`gitcad-mech`](https://pypi.org/project/gitcad-mech/) | document model, **exact B-rep kernel**, sketches, drawings, importers |
44
+ | [`gitcad-ecad`](https://pypi.org/project/gitcad-ecad/) | schematic + ERC, board + DRC, fab outputs, KiCad import |
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install gitcad # full system: both domains, exact+native kernel, MCP server
50
+ pip install gitcad[occt] # + OCCT kernel oracle/fallback (cadquery-ocp)
51
+ ```
52
+
53
+ `pip install gitcad` is complete and agent-ready out of the box — mechanical and
54
+ electrical, the exact kernel with its native accelerator, and the `gitcad-mcp`
55
+ Model Context Protocol server. Only OCCT (a heavy optional oracle) is an extra.
56
+
57
+ The mechanical kernel is **exact**: topological decisions are made in rational
58
+ arithmetic (ℚ), never by a floating-point tolerance — see
59
+ [`forgekernel`](https://pypi.org/project/forgekernel/) and the
60
+ [benchmarks vs OCCT](https://github.com/gitcad-xyz/gitcad/blob/main/bench/RUST-vs-OCCT.md).
61
+ The compiled Rust build of the hot paths installs **by default** on mainstream
62
+ platforms (identical results, only faster); on an architecture with no wheel it is
63
+ skipped and the kernel runs pure-Python — same answers, nothing breaks.
64
+
65
+ ## Console tools
66
+
67
+ `gitcad-init`, `gitcad-render`, `gitcad-view`, `gitcad-review`, `gitcad-merge`,
68
+ `gitcad-verify`, `gitcad-convert`, `gitcad-explore`, `gitcad-mcp`, `gitcad-lot`.
69
+
70
+ Apache-2.0 · https://gitcad.xyz · https://github.com/gitcad-xyz/gitcad
gitcad-0.7.7/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # gitcad
2
+
3
+ **Agent-first, headless, git-native CAD — mechanical and electrical, one substrate.**
4
+
5
+ gitcad is a boundary-representation CAD system designed to be driven by agents and
6
+ version-controlled like source. Models are byte-canonical text (geometry is a
7
+ build artifact, never committed); identity is construction-lineage based, never
8
+ ordinal; every build produces a reviewable, diffable report.
9
+
10
+ This metapackage installs the whole system:
11
+
12
+ | package | domain |
13
+ |---|---|
14
+ | [`gitcad-core`](https://pypi.org/project/gitcad-core/) | canonical text, stable identity, the Part standard, report pipeline |
15
+ | [`gitcad-mech`](https://pypi.org/project/gitcad-mech/) | document model, **exact B-rep kernel**, sketches, drawings, importers |
16
+ | [`gitcad-ecad`](https://pypi.org/project/gitcad-ecad/) | schematic + ERC, board + DRC, fab outputs, KiCad import |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install gitcad # full system: both domains, exact+native kernel, MCP server
22
+ pip install gitcad[occt] # + OCCT kernel oracle/fallback (cadquery-ocp)
23
+ ```
24
+
25
+ `pip install gitcad` is complete and agent-ready out of the box — mechanical and
26
+ electrical, the exact kernel with its native accelerator, and the `gitcad-mcp`
27
+ Model Context Protocol server. Only OCCT (a heavy optional oracle) is an extra.
28
+
29
+ The mechanical kernel is **exact**: topological decisions are made in rational
30
+ arithmetic (ℚ), never by a floating-point tolerance — see
31
+ [`forgekernel`](https://pypi.org/project/forgekernel/) and the
32
+ [benchmarks vs OCCT](https://github.com/gitcad-xyz/gitcad/blob/main/bench/RUST-vs-OCCT.md).
33
+ The compiled Rust build of the hot paths installs **by default** on mainstream
34
+ platforms (identical results, only faster); on an architecture with no wheel it is
35
+ skipped and the kernel runs pure-Python — same answers, nothing breaks.
36
+
37
+ ## Console tools
38
+
39
+ `gitcad-init`, `gitcad-render`, `gitcad-view`, `gitcad-review`, `gitcad-merge`,
40
+ `gitcad-verify`, `gitcad-convert`, `gitcad-explore`, `gitcad-mcp`, `gitcad-lot`.
41
+
42
+ Apache-2.0 · https://gitcad.xyz · https://github.com/gitcad-xyz/gitcad
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gitcad"
7
+ version = "0.7.7"
8
+ description = "Agent-first, headless, git-native CAD (metapackage: core + mech + ecad + MCP + viewer + release)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{name = "Dan Willis", email = "danielcwillis@gmail.com"}]
13
+ keywords = ["cad", "brep", "occt", "mcp", "agent", "headless", "git", "eda"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Manufacturing",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Scientific/Engineering",
22
+ "Topic :: Multimedia :: Graphics :: 3D Modeling",
23
+ "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
24
+ ]
25
+ # Agent-first: the MCP server ships by default (gitcad-mcp entry point). The
26
+ # compiled kernel (forgekernel_rs) is likewise a default dep of gitcad-mech on
27
+ # mainstream architectures — no extra needed for either.
28
+ dependencies = [
29
+ "gitcad-core==0.7.7",
30
+ "gitcad-mech==0.7.7",
31
+ "gitcad-ecad==0.7.7",
32
+ "mcp>=1.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ # OCCT oracle/fallback kernel.
37
+ occt = ["gitcad-mech[occt]==0.7.7"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://gitcad.xyz"
41
+ Repository = "https://github.com/gitcad-xyz/gitcad"
42
+ Issues = "https://github.com/gitcad-xyz/gitcad/issues"
43
+
44
+ [project.scripts]
45
+ gitcad-mcp = "gitcad.mcp.server:main"
46
+ gitcad-view = "gitcad.viewer.server:main"
47
+ gitcad-review = "gitcad.review:main"
48
+ gitcad-merge = "gitcad.merge3:main"
49
+ gitcad-verify = "gitcad.requirements:main"
50
+ gitcad-lot = "gitcad.lots:main"
51
+ gitcad-init = "gitcad.init:main"
52
+ gitcad-render = "gitcad.render:main"
53
+ gitcad-convert = "gitcad.convert:main"
54
+ gitcad-explore = "gitcad.explore:main"
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["src/gitcad"]
File without changes
@@ -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()