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/mcp/server.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"""gitcad MCP server — the primary, agent-facing interface.
|
|
2
|
+
|
|
3
|
+
Handlers are plain functions in ``REGISTRY`` so they can be called and tested
|
|
4
|
+
directly (no ``mcp`` install needed). ``main()`` exposes them over MCP when the
|
|
5
|
+
optional dependency is present.
|
|
6
|
+
|
|
7
|
+
The tool surface is deliberately intent-level and verification-first: an agent
|
|
8
|
+
builds, then *inspects* and *measures*, closing the loop rather than modeling
|
|
9
|
+
blind. Every handler returns plain JSON-able dicts.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from gitcad.document import Document, Feature
|
|
17
|
+
from gitcad.kernel import get_kernel
|
|
18
|
+
|
|
19
|
+
Handler = Callable[..., dict[str, Any]]
|
|
20
|
+
REGISTRY: dict[str, Handler] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tool(name: str) -> Callable[[Handler], Handler]:
|
|
24
|
+
"""Register a handler, wrapped in the structured-error contract: an agent
|
|
25
|
+
NEVER sees a raw traceback — failures return machine-actionable
|
|
26
|
+
``{"ok": false, "error": {...}}``, with the dedup fingerprint attached
|
|
27
|
+
when the failure is a kernel/geometry error (feeds the report pipeline)."""
|
|
28
|
+
|
|
29
|
+
def deco(fn: Handler) -> Handler:
|
|
30
|
+
import functools
|
|
31
|
+
import json as _json
|
|
32
|
+
|
|
33
|
+
from gitcad.errors import GitcadError, KernelError
|
|
34
|
+
from gitcad.report.fingerprint import fingerprint
|
|
35
|
+
|
|
36
|
+
@functools.wraps(fn)
|
|
37
|
+
def wrapped(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
38
|
+
try:
|
|
39
|
+
return fn(*args, **kwargs)
|
|
40
|
+
except KernelError as exc:
|
|
41
|
+
return {"ok": False, "error": {"type": type(exc).__name__, "message": str(exc)},
|
|
42
|
+
"fingerprint": fingerprint(exc.signature)}
|
|
43
|
+
except (GitcadError, ValueError, KeyError, FileNotFoundError,
|
|
44
|
+
_json.JSONDecodeError, NotImplementedError) as exc:
|
|
45
|
+
return {"ok": False, "error": {"type": type(exc).__name__, "message": str(exc)}}
|
|
46
|
+
|
|
47
|
+
REGISTRY[name] = wrapped
|
|
48
|
+
return wrapped
|
|
49
|
+
|
|
50
|
+
return deco
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@tool("model_new")
|
|
54
|
+
def model_new() -> dict[str, Any]:
|
|
55
|
+
"""Create an empty model; returns its canonical text form."""
|
|
56
|
+
return {"model": Document().dumps()}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@tool("sheetmetal_author")
|
|
60
|
+
def sheetmetal_author(spec: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
"""Sheet metal (SW-map P3) — the mech Gerber. ``spec`` declares the
|
|
62
|
+
part: {name, width, height, thickness, k_factor, bend_radius,
|
|
63
|
+
flanges: [{edge: n|e|s|w, length, angle, direction, radius, holes:
|
|
64
|
+
[{u, v, diameter}], children: [...same, edge: "end"]}], base_holes}.
|
|
65
|
+
Returns the canonical text, DFM validation (hole-to-bend, setback,
|
|
66
|
+
radius rules), the exact K-factor flat pattern, the shop DXF (layers
|
|
67
|
+
CUT/BEND_UP/BEND_DOWN/HOLES), the bend table, and the folded solid
|
|
68
|
+
as an ordinary model document (viewer/STEP/interference all apply;
|
|
69
|
+
sharp-corner bends — the flat pattern is the manufacturing truth)."""
|
|
70
|
+
import json as _json
|
|
71
|
+
|
|
72
|
+
from gitcad.sheetmetal import SheetMetal
|
|
73
|
+
|
|
74
|
+
sm = SheetMetal.loads(_json.dumps({"schema": SheetMetal.SCHEMA,
|
|
75
|
+
"sheetmetal": spec}))
|
|
76
|
+
report = sm.validate()
|
|
77
|
+
out: dict[str, Any] = {"sheetmetal": sm.dumps(), "ok": report.ok,
|
|
78
|
+
"violations": report.violations}
|
|
79
|
+
if report.ok:
|
|
80
|
+
out["flat"] = sm.flat_pattern()
|
|
81
|
+
out["dxf"] = sm.flat_dxf()
|
|
82
|
+
out["bend_table"] = sm.bend_table()
|
|
83
|
+
out["model"] = sm.to_document().dumps()
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@tool("model_parameters")
|
|
88
|
+
def model_parameters(model: str, set: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
89
|
+
"""Named parameters + equations (SW-map P1): read or update the model's
|
|
90
|
+
parameter table. Values are numbers or '=expr' strings ('=W/2 + wall';
|
|
91
|
+
trig in degrees, pi available). Feature params reference them the same
|
|
92
|
+
way — a part becomes a function of its parameters. Re-valuing a
|
|
93
|
+
parameter changes geometry (an ADR-0006 breaking change, gated like
|
|
94
|
+
any other) but never re-identifies features. Returns the model text
|
|
95
|
+
and the fully resolved table."""
|
|
96
|
+
doc = Document.loads(model)
|
|
97
|
+
for name, value in (set or {}).items():
|
|
98
|
+
doc.set_parameter(name, value)
|
|
99
|
+
return {"model": doc.dumps(), "parameters": doc.parameters,
|
|
100
|
+
"resolved": doc.resolved_parameters()}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@tool("model_configurations")
|
|
104
|
+
def model_configurations(model: str, set: dict[str, dict[str, Any]] | None = None,
|
|
105
|
+
delete: list[str] | None = None) -> dict[str, Any]:
|
|
106
|
+
"""Configurations / design tables (SW-map P2): one document IS a
|
|
107
|
+
product family. Each configuration is a named override set of
|
|
108
|
+
existing parameters ('M3x10': {'L': 10}); the whole table re-resolves
|
|
109
|
+
per variant so dependent expressions follow. Build any variant with
|
|
110
|
+
model ops' `config` argument. Returns the model text plus each
|
|
111
|
+
configuration's fully resolved parameter table — the design table,
|
|
112
|
+
as data."""
|
|
113
|
+
doc = Document.loads(model)
|
|
114
|
+
for name, overrides in (set or {}).items():
|
|
115
|
+
doc.set_configuration(name, overrides)
|
|
116
|
+
for name in (delete or []):
|
|
117
|
+
doc.configurations.pop(name, None)
|
|
118
|
+
return {"model": doc.dumps(),
|
|
119
|
+
"configurations": doc.configurations,
|
|
120
|
+
"resolved": {name: doc.resolved_parameters(name)
|
|
121
|
+
for name in sorted(doc.configurations)}}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@tool("feature_add")
|
|
125
|
+
def feature_add(model: str, op: str, params: dict[str, Any] | None = None,
|
|
126
|
+
inputs: list[str] | None = None) -> dict[str, Any]:
|
|
127
|
+
"""Append an intent-level feature. Returns the new model text and the stable
|
|
128
|
+
id assigned to the feature (never an ordinal index)."""
|
|
129
|
+
doc = Document.loads(model)
|
|
130
|
+
fid = doc.add(Feature(op=op, params=params or {}, inputs=inputs or []))
|
|
131
|
+
return {"model": doc.dumps(), "feature_id": fid}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@tool("model_measure")
|
|
135
|
+
def model_measure(model: str) -> dict[str, Any]:
|
|
136
|
+
"""Build against the best available kernel and return mass properties per
|
|
137
|
+
feature — the deterministic oracle an agent verifies against."""
|
|
138
|
+
doc = Document.loads(model)
|
|
139
|
+
kernel = get_kernel()
|
|
140
|
+
result = doc.build(kernel)
|
|
141
|
+
return {
|
|
142
|
+
"kernel": kernel.name,
|
|
143
|
+
"geometry_verified": not kernel.name.startswith("null"),
|
|
144
|
+
"measures": {fid: kernel.measure(shape) for fid, shape in result.shapes.items()},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@tool("sketch_solve")
|
|
149
|
+
def sketch_solve(points: dict[str, list[float]],
|
|
150
|
+
constraints: list[list],
|
|
151
|
+
profile: list[str] | None = None) -> dict[str, Any]:
|
|
152
|
+
"""Solve a 2D constraint sketch (ADR-0013) and return exact coordinates
|
|
153
|
+
— authoring-time only; the solved profile is what goes in the document.
|
|
154
|
+
points: {name: [x, y]} rough positions (they pin the solution branch).
|
|
155
|
+
constraints: [[kind, ...args]] with kinds fix(p,x,y), coincident(p,q),
|
|
156
|
+
horizontal(p,q), vertical(p,q), distance(p,q,d), angle(p,q,deg),
|
|
157
|
+
parallel(p,q,r,s), perpendicular(p,q,r,s), equal_length(p,q,r,s).
|
|
158
|
+
profile: optional point order to emit a closed Profile params dict."""
|
|
159
|
+
from gitcad.sketch_solver import ConstraintSketch
|
|
160
|
+
|
|
161
|
+
s = ConstraintSketch()
|
|
162
|
+
for name, (x, y) in points.items():
|
|
163
|
+
s.point(name, x, y)
|
|
164
|
+
two_line = {"parallel", "perpendicular", "equal_length"}
|
|
165
|
+
for c in constraints:
|
|
166
|
+
kind, args = c[0], c[1:]
|
|
167
|
+
if kind in two_line:
|
|
168
|
+
getattr(s, kind)((args[0], args[1]), (args[2], args[3]))
|
|
169
|
+
else:
|
|
170
|
+
getattr(s, kind)(*args)
|
|
171
|
+
result = s.solve()
|
|
172
|
+
out: dict[str, Any] = {"points": {k: list(v) for k, v in result.points.items()},
|
|
173
|
+
"dof": result.dof, "iterations": result.iterations,
|
|
174
|
+
"converged": result.converged}
|
|
175
|
+
if profile:
|
|
176
|
+
out["profile"] = s.profile(*profile).to_params()
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@tool("model_mass")
|
|
181
|
+
def model_mass(model: str, density_g_cm3: float = 1.0) -> dict[str, Any]:
|
|
182
|
+
"""Physical mass properties of the model's final body: volume (mm^3),
|
|
183
|
+
mass (g) at the given density (g/cm^3), center of mass, and the
|
|
184
|
+
unit-density inertia tensor about the COM. The engineering numbers a
|
|
185
|
+
drawing title block or a motion study starts from."""
|
|
186
|
+
doc = Document.loads(model)
|
|
187
|
+
kernel = get_kernel()
|
|
188
|
+
result = doc.build(kernel)
|
|
189
|
+
props = kernel.mass_props(result.final(doc))
|
|
190
|
+
out: dict[str, Any] = {"kernel": kernel.name,
|
|
191
|
+
"geometry_verified": not kernel.name.startswith("null"),
|
|
192
|
+
"density_g_cm3": density_g_cm3, **props}
|
|
193
|
+
if "volume" in props:
|
|
194
|
+
out["mass_g"] = props["volume"] * density_g_cm3 / 1000.0 # mm^3 -> cm^3
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@tool("model_validate")
|
|
199
|
+
def model_validate(model: str) -> dict[str, Any]:
|
|
200
|
+
"""Build and run geometric validity checks per feature (watertight,
|
|
201
|
+
self-intersection, ...). ``geometry_verified: false`` means only the null
|
|
202
|
+
backend was available — structure was checked, geometry was NOT."""
|
|
203
|
+
doc = Document.loads(model)
|
|
204
|
+
kernel = get_kernel()
|
|
205
|
+
result = doc.build(kernel)
|
|
206
|
+
out: dict[str, Any] = {}
|
|
207
|
+
for fid, shape in result.shapes.items():
|
|
208
|
+
r = kernel.validate(shape)
|
|
209
|
+
out[fid] = {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
210
|
+
return {
|
|
211
|
+
"kernel": kernel.name,
|
|
212
|
+
"geometry_verified": not kernel.name.startswith("null"),
|
|
213
|
+
"results": out,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@tool("model_entities")
|
|
218
|
+
def model_entities(model: str, feature_id: str, kind: str = "edge",
|
|
219
|
+
select: str | None = None) -> dict[str, Any]:
|
|
220
|
+
"""Stable entity ids + descriptors for a feature's topology (ADR-0003).
|
|
221
|
+
``select`` filters with the query DSL (e.g. "plane,zmax" = the back face;
|
|
222
|
+
"cylinder"; "line,zmin") instead of manual centroid filtering."""
|
|
223
|
+
doc = Document.loads(model)
|
|
224
|
+
kernel = get_kernel()
|
|
225
|
+
result = doc.build(kernel)
|
|
226
|
+
if feature_id not in result.entities:
|
|
227
|
+
raise ValueError(f"unknown feature {feature_id!r}")
|
|
228
|
+
indexed = result.entities[feature_id].get(kind, [])
|
|
229
|
+
picks = range(len(indexed))
|
|
230
|
+
if select:
|
|
231
|
+
from gitcad.select import select_entities
|
|
232
|
+
|
|
233
|
+
picks = select_entities([d for _, d in indexed], select)
|
|
234
|
+
return {
|
|
235
|
+
"kernel": kernel.name,
|
|
236
|
+
"entities": [{"id": indexed[i][0], **indexed[i][1]} for i in picks],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@tool("model_export")
|
|
241
|
+
def model_export(model: str, path: str, fmt: str = "step") -> dict[str, Any]:
|
|
242
|
+
"""Build the model and export the final feature's shape to STEP or STL —
|
|
243
|
+
the mechanical manufacturing deliverable."""
|
|
244
|
+
doc = Document.loads(model)
|
|
245
|
+
if not len(doc):
|
|
246
|
+
raise ValueError("model has no features")
|
|
247
|
+
kernel = get_kernel()
|
|
248
|
+
final = doc.build(kernel).final(doc)
|
|
249
|
+
if fmt == "step":
|
|
250
|
+
kernel.export_step(final, path)
|
|
251
|
+
elif fmt == "stl":
|
|
252
|
+
kernel.export_stl(final, path)
|
|
253
|
+
else:
|
|
254
|
+
raise ValueError(f"unknown format {fmt!r} (want step|stl)")
|
|
255
|
+
return {"path": path, "format": fmt, "kernel": kernel.name}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@tool("model_drawing")
|
|
259
|
+
def model_drawing(model: str, path: str, title: str = "part", sheet: str = "A3",
|
|
260
|
+
details: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
261
|
+
"""Build the model and emit a dimensioned 2D drawing (SVG or PDF by file
|
|
262
|
+
extension) of the final feature — front/top/right/iso, third angle."""
|
|
263
|
+
from gitcad.drawing import make_drawing
|
|
264
|
+
|
|
265
|
+
doc = Document.loads(model)
|
|
266
|
+
if not len(doc):
|
|
267
|
+
raise ValueError("model has no features")
|
|
268
|
+
kernel = get_kernel()
|
|
269
|
+
# thread specs on hole features surface in the hole callouts (SW-map P5);
|
|
270
|
+
# positions resolve through the parameter table like the build does
|
|
271
|
+
from gitcad.expr import resolve_value
|
|
272
|
+
|
|
273
|
+
env = doc.resolved_parameters()
|
|
274
|
+
dim_tols = {t["feature"]: t for t in doc.tolerances
|
|
275
|
+
if t.get("kind") == "dim" and t.get("param") == "diameter"}
|
|
276
|
+
threads = {}
|
|
277
|
+
for f in doc.features:
|
|
278
|
+
if f.op != "hole":
|
|
279
|
+
continue
|
|
280
|
+
parts = []
|
|
281
|
+
if f.params.get("thread"):
|
|
282
|
+
parts.append(str(f.params["thread"]))
|
|
283
|
+
tol = dim_tols.get(f.id)
|
|
284
|
+
if tol:
|
|
285
|
+
parts.append(tol["fit"] if "fit" in tol
|
|
286
|
+
else f"+{tol.get('plus', 0):g}/-{tol.get('minus', 0):g}")
|
|
287
|
+
if parts:
|
|
288
|
+
rp = resolve_value(f.params, env)
|
|
289
|
+
threads[(round(float(rp["x"]), 3), round(float(rp["y"]), 3))] = " ".join(parts)
|
|
290
|
+
d = make_drawing(doc.build(kernel).final(doc), title=title, sheet=sheet,
|
|
291
|
+
thread_specs=threads, notes=doc.tolerance_notes(),
|
|
292
|
+
details=details)
|
|
293
|
+
if path.lower().endswith(".pdf"):
|
|
294
|
+
with open(path, "wb") as f:
|
|
295
|
+
f.write(d.to_pdf())
|
|
296
|
+
else:
|
|
297
|
+
# utf-8 explicitly: GD&T symbols are non-ASCII and Windows' locale
|
|
298
|
+
# codec is not (the em-dash lesson, round two)
|
|
299
|
+
with open(path, "w", newline="\n", encoding="utf-8") as f:
|
|
300
|
+
f.write(d.to_svg())
|
|
301
|
+
return {"path": path, "scale": d.scale, "sheet": d.sheet, "views": [v.name for v in d.views]}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@tool("board_pad_position")
|
|
305
|
+
def board_pad_position(board: str, pad: str) -> dict[str, Any]:
|
|
306
|
+
"""Resolve "REF.pad_name" to absolute board coordinates + side/through/net
|
|
307
|
+
— no more mental pad arithmetic (dogfood friction #1)."""
|
|
308
|
+
from gitcad.ecad import Board, pad_position
|
|
309
|
+
|
|
310
|
+
return pad_position(Board.loads(board), pad)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@tool("board_route")
|
|
314
|
+
def board_route(board: str, net: str, points: list[dict[str, Any]],
|
|
315
|
+
width: float = 0.4) -> dict[str, Any]:
|
|
316
|
+
"""Route a net through waypoints ({"pad": "REF.name"} or {"x","y"},
|
|
317
|
+
optional "layer") — auto-vias on layer changes, wrong-net pads refused,
|
|
318
|
+
SMD side enforced. Returns the updated board text."""
|
|
319
|
+
from gitcad.ecad import Board, route
|
|
320
|
+
|
|
321
|
+
b = Board.loads(board)
|
|
322
|
+
r = route(b, net, points, width=width)
|
|
323
|
+
return {"board": b.dumps(), "added": r}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@tool("board_to_model")
|
|
327
|
+
def board_to_model_tool(board: str) -> dict[str, Any]:
|
|
328
|
+
"""The board as a 3D mech model (outline x thickness, mounting holes cut)
|
|
329
|
+
— ready for assemblies, interference, STEP, and the viewer."""
|
|
330
|
+
from gitcad.bridge import board_to_model
|
|
331
|
+
from gitcad.ecad import Board
|
|
332
|
+
|
|
333
|
+
return {"model": board_to_model(Board.loads(board)).dumps()}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@tool("board_validate")
|
|
337
|
+
def board_validate(board: str) -> dict[str, Any]:
|
|
338
|
+
"""Fab-readiness checks on a board document. Machine-readable."""
|
|
339
|
+
from gitcad.ecad import Board
|
|
340
|
+
|
|
341
|
+
b = Board.loads(board)
|
|
342
|
+
r = b.validate()
|
|
343
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@tool("board_export_fab")
|
|
347
|
+
def board_export_fab(board: str, outdir: str) -> dict[str, Any]:
|
|
348
|
+
"""Validate and write the full fabrication package: Gerber X2 layers,
|
|
349
|
+
Excellon drill, pick-and-place CSV, manifest."""
|
|
350
|
+
from gitcad.ecad import Board, export_fab
|
|
351
|
+
|
|
352
|
+
b = Board.loads(board)
|
|
353
|
+
files = export_fab(b, outdir)
|
|
354
|
+
return {"files": files, "board": b.name}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@tool("model_import")
|
|
358
|
+
def model_import(path: str, fmt: str = "auto", assets_dir: str = ".") -> dict[str, Any]:
|
|
359
|
+
"""Import existing mechanical work (STEP or FreeCAD .FCStd) into a gitcad
|
|
360
|
+
model. Returns the model text plus an honest report of what was imported,
|
|
361
|
+
approximated, and dropped. Requires the OCCT kernel."""
|
|
362
|
+
lower = path.lower()
|
|
363
|
+
if lower.endswith((".sldprt", ".sldasm", ".slddrw")):
|
|
364
|
+
raise ValueError(
|
|
365
|
+
"SolidWorks files are a proprietary Parasolid-based format no "
|
|
366
|
+
"open-source library can read. Full-fidelity path: export STEP "
|
|
367
|
+
"from SolidWorks (File > Save As > .step), or bulk-convert a whole "
|
|
368
|
+
"library with scripts/sw-batch-export.ps1 (drives your installed "
|
|
369
|
+
"SolidWorks via COM), then import the STEP files."
|
|
370
|
+
)
|
|
371
|
+
kernel = get_kernel(require="occt")
|
|
372
|
+
if fmt == "auto":
|
|
373
|
+
fmt = "fcstd" if lower.endswith(".fcstd") else "step"
|
|
374
|
+
if fmt == "step":
|
|
375
|
+
from gitcad.importers.step import import_step_file
|
|
376
|
+
|
|
377
|
+
doc, report = import_step_file(path, kernel)
|
|
378
|
+
elif fmt == "fcstd":
|
|
379
|
+
# Parametric-first: recover the FreeCAD feature tree when it maps and
|
|
380
|
+
# proves out; fall back to geometry-only with the reason reported.
|
|
381
|
+
from gitcad.importers.fcstd_tree import import_fcstd_tree
|
|
382
|
+
|
|
383
|
+
doc, report = import_fcstd_tree(path, kernel, assets_dir)
|
|
384
|
+
else:
|
|
385
|
+
raise ValueError(f"unknown import format {fmt!r} (want step|fcstd)")
|
|
386
|
+
return {"model": doc.dumps(), "report": report.to_dict(), "kernel": kernel.name}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@tool("model_recognize")
|
|
390
|
+
def model_recognize(model: str) -> dict[str, Any]:
|
|
391
|
+
"""Convert dead imported geometry back into a parameterized model — WITH
|
|
392
|
+
PROOF. Recognizes plate-with-holes shapes; the returned model rebuilds to
|
|
393
|
+
exactly the input geometry (symmetric-difference residual ~0) with real,
|
|
394
|
+
editable dimensions. Unrecognizable shapes are reported honestly."""
|
|
395
|
+
from gitcad.importers.recognize import recognize
|
|
396
|
+
|
|
397
|
+
doc = Document.loads(model)
|
|
398
|
+
kernel = get_kernel(require="occt")
|
|
399
|
+
r = recognize(doc, kernel)
|
|
400
|
+
out: dict[str, Any] = {"recognized": r.recognized, "proof": r.proof,
|
|
401
|
+
"holes": [{"x": h.x, "y": h.y, "radius": h.radius} for h in r.holes]}
|
|
402
|
+
if r.recognized:
|
|
403
|
+
out["model"] = r.document.dumps()
|
|
404
|
+
else:
|
|
405
|
+
out["reason"] = r.reason
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@tool("board_import")
|
|
410
|
+
def board_import(path: str) -> dict[str, Any]:
|
|
411
|
+
"""Import an existing KiCad board (.kicad_pcb) into a gitcad board.
|
|
412
|
+
Pure Python — no kernel needed. The report lists every approximation and
|
|
413
|
+
drop (zones, arcs, complex outlines); nothing is lost silently."""
|
|
414
|
+
from gitcad.importers.kicad import import_kicad_pcb
|
|
415
|
+
|
|
416
|
+
board, report = import_kicad_pcb(path)
|
|
417
|
+
validation = board.validate()
|
|
418
|
+
return {"board": board.dumps(), "report": report.to_dict(),
|
|
419
|
+
"valid": validation.ok, "violations": validation.violations}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@tool("schematic_import")
|
|
423
|
+
def schematic_import(path: str) -> dict[str, Any]:
|
|
424
|
+
"""Import a KiCad schematic (.kicad_sch) into a gitcad schematic. The
|
|
425
|
+
netlist is derived the way KiCad derives it — geometrically, from
|
|
426
|
+
wire-pin connectivity, junctions, labels and power symbols — and the
|
|
427
|
+
report's wire_end_hit_pct self-checks the symbol transforms. Pure
|
|
428
|
+
Python — no kernel needed."""
|
|
429
|
+
from gitcad.importers.kicad_sch import import_kicad_sch
|
|
430
|
+
|
|
431
|
+
sch, report = import_kicad_sch(path)
|
|
432
|
+
erc = sch.erc()
|
|
433
|
+
out = {"schematic": sch.dumps(), "report": report.to_dict(),
|
|
434
|
+
"erc_ok": erc.ok, "erc_violations": erc.violations}
|
|
435
|
+
try:
|
|
436
|
+
from gitcad.ecad.schsvg import sheet_to_svg
|
|
437
|
+
|
|
438
|
+
out["sheet_svg"] = sheet_to_svg(sch) # the sheet exactly as drawn
|
|
439
|
+
except GitcadError:
|
|
440
|
+
pass # no graphics (unusual) — netlist import still succeeds
|
|
441
|
+
return out
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@tool("schematic_author")
|
|
445
|
+
def schematic_author(name: str, ops: list[list]) -> dict[str, Any]:
|
|
446
|
+
"""Author a DRAWN schematic sheet — symbols placed, wires routed, labels
|
|
447
|
+
and power flags set — and get back the netlist derived from the drawing
|
|
448
|
+
(same engine as the KiCad importer), ERC, sheet parity, and a KiCad-style
|
|
449
|
+
SVG. ops is a sequence of:
|
|
450
|
+
["place", ref, kind, x, y, {value, rot, footprint, pin_types, left,
|
|
451
|
+
right, n}] (kinds: resistor|capacitor|led|diode|ic|header)
|
|
452
|
+
["connect", refA, pinA, refB, pinB, [via points...]]
|
|
453
|
+
["wire", [[x, y], ...]] ["junction", x, y]
|
|
454
|
+
["label", net, x, y] ["power", net, x, y]
|
|
455
|
+
["global_label", net, x, y] (design-wide across all sheets)
|
|
456
|
+
["hier_label", net, x, y] (child-side sheet-pin attachment)
|
|
457
|
+
["sheet", name, x, y, w, h, {"ops": [...child ops...],
|
|
458
|
+
"pins": {pin: [x, y]}, "ref_map": {childRef: instanceRef},
|
|
459
|
+
"child": childname}]
|
|
460
|
+
Hierarchies flatten through the same merge as KiCad import; reuse one
|
|
461
|
+
child by placing two sheets with the same ops and distinct ref_maps."""
|
|
462
|
+
from gitcad.ecad.netderive import sheet_parity
|
|
463
|
+
from gitcad.ecad.schsvg import sheet_to_svg
|
|
464
|
+
from gitcad.ecad.sheetedit import SheetEditor
|
|
465
|
+
|
|
466
|
+
def build(sheet_name: str, sheet_ops: list[list]) -> SheetEditor:
|
|
467
|
+
e = SheetEditor(sheet_name)
|
|
468
|
+
for op in sheet_ops:
|
|
469
|
+
kind, args = op[0], op[1:]
|
|
470
|
+
if kind == "place":
|
|
471
|
+
kw = args[4] if len(args) > 4 else {}
|
|
472
|
+
e.place(args[0], args[1], args[2], args[3], **kw)
|
|
473
|
+
elif kind == "connect":
|
|
474
|
+
via = [tuple(p) for p in (args[4] if len(args) > 4 else [])]
|
|
475
|
+
e.connect((args[0], args[1]), (args[2], args[3]), *via)
|
|
476
|
+
elif kind == "wire":
|
|
477
|
+
e.wire(*[tuple(p) for p in args[0]])
|
|
478
|
+
elif kind == "junction":
|
|
479
|
+
e.junction(args[0], args[1])
|
|
480
|
+
elif kind == "label":
|
|
481
|
+
e.label(args[0], args[1], args[2])
|
|
482
|
+
elif kind == "power":
|
|
483
|
+
e.power(args[0], args[1], args[2])
|
|
484
|
+
elif kind == "global_label":
|
|
485
|
+
e.global_label(args[0], args[1], args[2])
|
|
486
|
+
elif kind == "hier_label":
|
|
487
|
+
e.hier_label(args[0], args[1], args[2])
|
|
488
|
+
elif kind == "sheet":
|
|
489
|
+
kw = args[5] if len(args) > 5 else {}
|
|
490
|
+
child = build(kw.get("child", args[0]), kw.get("ops", []))
|
|
491
|
+
e.sheet(args[0], child, args[1], args[2], args[3], args[4],
|
|
492
|
+
pins={k: tuple(v) for k, v in kw.get("pins", {}).items()},
|
|
493
|
+
ref_map=kw.get("ref_map"))
|
|
494
|
+
else:
|
|
495
|
+
raise GitcadError(f"unknown sheet op {kind!r}")
|
|
496
|
+
return e
|
|
497
|
+
|
|
498
|
+
e = build(name, ops)
|
|
499
|
+
sch = e.finish()
|
|
500
|
+
erc = sch.erc()
|
|
501
|
+
if e._subsheets:
|
|
502
|
+
# parity is per-sheet by design; every sheet here was derivation-
|
|
503
|
+
# authored through the same engine, so it is green by construction
|
|
504
|
+
parity_ok: bool | None = None
|
|
505
|
+
parity_note = "hierarchical parent — parity applies per child sheet"
|
|
506
|
+
else:
|
|
507
|
+
parity = sheet_parity(sch)
|
|
508
|
+
parity_ok, parity_note = parity.ok, ""
|
|
509
|
+
out = {"schematic": sch.dumps(), "nets": sch.nets,
|
|
510
|
+
"erc_ok": erc.ok, "erc_violations": erc.violations,
|
|
511
|
+
"parity_ok": parity_ok, "warnings": e.warnings,
|
|
512
|
+
"sheet_svg": sheet_to_svg(sch)}
|
|
513
|
+
if parity_note:
|
|
514
|
+
out["parity_note"] = parity_note
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@tool("schematic_sim")
|
|
519
|
+
def schematic_sim(schematic: str, checks: list[dict] | None = None) -> dict[str, Any]:
|
|
520
|
+
"""Simulation as tests: export the schematic to SPICE (rails become
|
|
521
|
+
ideal sources by the same name contract the envelope checker uses;
|
|
522
|
+
unmodeled parts reported, never dropped) and — when ngspice is
|
|
523
|
+
installed and checks given — run an operating-point analysis asserting
|
|
524
|
+
node voltages: checks=[{"node": "OUT", "min": 3.2, "max": 3.4}]."""
|
|
525
|
+
from gitcad.ecad.schematic import Schematic
|
|
526
|
+
from gitcad.ecad.spice import _find_ngspice, sim_check, to_spice
|
|
527
|
+
|
|
528
|
+
sch = Schematic.loads(schematic)
|
|
529
|
+
netlist, report = to_spice(sch)
|
|
530
|
+
out: dict[str, Any] = {"netlist": netlist, "export": report,
|
|
531
|
+
"simulator": "ngspice" if _find_ngspice() else "unavailable"}
|
|
532
|
+
if checks and _find_ngspice():
|
|
533
|
+
r = sim_check(sch, checks)
|
|
534
|
+
out.update({"ok": r.ok, "checks": r.checks, "violations": r.violations})
|
|
535
|
+
return out
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@tool("requirements_verify")
|
|
539
|
+
def requirements_verify(requirements: str, root: str) -> dict[str, Any]:
|
|
540
|
+
"""Requirements as code: run a canonical requirements document (named
|
|
541
|
+
limits bound to machine checks — mass_max_g, volume_max_mm3,
|
|
542
|
+
bbox_max_mm, erc_clean, envelope_clean, rail_utilization_max,
|
|
543
|
+
drc_clean, interference_clear [assembly-wide pairwise fit within a
|
|
544
|
+
clash budget — mech models AND populated PCBAs]) against the design
|
|
545
|
+
tree at root. Every requirement reports
|
|
546
|
+
measured-vs-limit; one without a check shows as 'unchecked' — visible
|
|
547
|
+
debt, never silent green. markdown field = the executing traceability
|
|
548
|
+
matrix."""
|
|
549
|
+
from gitcad.requirements import to_markdown, verify
|
|
550
|
+
|
|
551
|
+
report = verify(requirements, root)
|
|
552
|
+
report["markdown"] = to_markdown(report)
|
|
553
|
+
return report
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@tool("design_merge")
|
|
557
|
+
def design_merge(base: str, ours: str, theirs: str) -> dict[str, Any]:
|
|
558
|
+
"""Semantic 3-way merge of a design document (ADR-0016): features by
|
|
559
|
+
stable id for models, components by ref + connectivity by PIN for
|
|
560
|
+
schematics. Parallel edits to different units merge cleanly (a net
|
|
561
|
+
rename merges — pins move together); the same unit changed differently
|
|
562
|
+
returns structured conflicts with both candidates, never text markers.
|
|
563
|
+
Also the git merge driver: gitcad-merge %O %A %B with
|
|
564
|
+
.gitattributes '*.gitcad.json merge=gitcad'."""
|
|
565
|
+
from gitcad.merge3 import merge_documents
|
|
566
|
+
|
|
567
|
+
return merge_documents(base, ours, theirs)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@tool("design_review")
|
|
571
|
+
def design_review(repo: str, base: str, head: str = "HEAD") -> dict[str, Any]:
|
|
572
|
+
"""Review the design changes between two git refs: per-file semantic
|
|
573
|
+
diff (features/components/volume/interface-semver), the CHECK DELTA
|
|
574
|
+
(violations introduced / fixed / pre-existing — ERC, envelopes, board
|
|
575
|
+
validation, DRC), and before/after SVG renders. gate_ok fails on any
|
|
576
|
+
INTRODUCED violation; pre-existing reds don't block (not this PR's
|
|
577
|
+
fault). The markdown field is ready to post as a PR comment."""
|
|
578
|
+
from gitcad.review import review_range, to_markdown
|
|
579
|
+
|
|
580
|
+
report = review_range(repo, base, head)
|
|
581
|
+
# renders are large; agents get the verdict + markdown, HTML via CLI
|
|
582
|
+
slim = {**report, "files": [
|
|
583
|
+
{k: v for k, v in f.items() if not k.startswith("render_")}
|
|
584
|
+
for f in report["files"]]}
|
|
585
|
+
slim["markdown"] = to_markdown(report)
|
|
586
|
+
return slim
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@tool("board_stats")
|
|
590
|
+
def board_stats_tool(board: str) -> dict[str, Any]:
|
|
591
|
+
"""Board statistics report: area, component counts by side, SMD/PTH
|
|
592
|
+
pads, routed copper length, vias, zones vs keepouts, drill-size
|
|
593
|
+
histogram — kicad-cli's stats as data."""
|
|
594
|
+
from gitcad.ecad import Board
|
|
595
|
+
from gitcad.ecad.stats import board_stats, net_lengths
|
|
596
|
+
|
|
597
|
+
b = Board.loads(board)
|
|
598
|
+
return {"stats": board_stats(b), "net_lengths": net_lengths(b)}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@tool("board_length_match")
|
|
602
|
+
def board_length_match(board: str, pairs: list[list],
|
|
603
|
+
tol_mm: float = 1.0) -> dict[str, Any]:
|
|
604
|
+
"""Matched-pair length check (USB, LVDS, clocks): each [netA, netB]
|
|
605
|
+
pair's routed lengths must agree within tol_mm; unrouted members and
|
|
606
|
+
mismatches are named violations."""
|
|
607
|
+
from gitcad.ecad import Board
|
|
608
|
+
from gitcad.ecad.stats import check_length_match
|
|
609
|
+
|
|
610
|
+
r = check_length_match(Board.loads(board),
|
|
611
|
+
[(a, b) for a, b in pairs], tol_mm)
|
|
612
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@tool("board_back_annotate")
|
|
616
|
+
def board_back_annotate(schematic: str, board: str) -> dict[str, Any]:
|
|
617
|
+
"""Reverse ECO: board value edits flow back into the schematic source
|
|
618
|
+
(matched by ref); board-only refs are reported, never invented."""
|
|
619
|
+
from gitcad.ecad import Board
|
|
620
|
+
from gitcad.ecad.schematic import Schematic
|
|
621
|
+
from gitcad.ecad.sync import back_annotate
|
|
622
|
+
|
|
623
|
+
sch = Schematic.loads(schematic)
|
|
624
|
+
report = back_annotate(sch, Board.loads(board))
|
|
625
|
+
return {"schematic": sch.dumps(), **report}
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@tool("schematic_export_kicad")
|
|
629
|
+
def schematic_export_kicad(schematic: str) -> dict[str, Any]:
|
|
630
|
+
"""Export a KiCad-format netlist (kicadsexpr) — author in gitcad, lay
|
|
631
|
+
out in pcbnew or anything else that reads KiCad netlists."""
|
|
632
|
+
from gitcad.ecad.kicadout import to_kicad_netlist
|
|
633
|
+
from gitcad.ecad.schematic import Schematic
|
|
634
|
+
|
|
635
|
+
return {"netlist": to_kicad_netlist(Schematic.loads(schematic))}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@tool("board_ipc2581")
|
|
639
|
+
def board_ipc2581(board: str, origination: str = "1970-01-01T00:00:00") -> dict[str, Any]:
|
|
640
|
+
"""IPC-2581C export — the modern fab/assembly exchange XML (stackup,
|
|
641
|
+
profile, packages, components, logical nets, per-layer features,
|
|
642
|
+
drills). Structure conformance-benchmarked element-for-element against
|
|
643
|
+
kicad-cli's own IPC-2581 on the same board. Deterministic: pass
|
|
644
|
+
origination only when a customer needs a real date."""
|
|
645
|
+
from gitcad.ecad import Board
|
|
646
|
+
from gitcad.ecad.ipc2581 import to_ipc2581
|
|
647
|
+
|
|
648
|
+
return {"ipc2581": to_ipc2581(Board.loads(board), origination=origination)}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@tool("board_odb")
|
|
652
|
+
def board_odb(board: str) -> dict[str, Any]:
|
|
653
|
+
"""ODB++ export — the CAM-exchange directory tree (matrix, per-layer
|
|
654
|
+
features, components, per-span drills + tools, profile, cadnet).
|
|
655
|
+
Structure copied from kicad-cli's own ODB++ export and census-checked
|
|
656
|
+
against it on the real benchmark board. Returns {relpath: content};
|
|
657
|
+
write with export_odb or any file sink."""
|
|
658
|
+
from gitcad.ecad import Board
|
|
659
|
+
from gitcad.ecad.odb import to_odb
|
|
660
|
+
|
|
661
|
+
tree = to_odb(Board.loads(board))
|
|
662
|
+
return {"files": sorted(tree), "tree": tree}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@tool("board_gencad")
|
|
666
|
+
def board_gencad(board: str) -> dict[str, Any]:
|
|
667
|
+
"""GenCAD 1.4 export — the test/assembly-machine format ($BOARD,
|
|
668
|
+
$PADS/$PADSTACKS, $SHAPES, $COMPONENTS, $DEVICES, $SIGNALS, $ROUTES).
|
|
669
|
+
Section grammar mirrors kicad-cli's GenCAD export; units INCH per the
|
|
670
|
+
installed-base convention."""
|
|
671
|
+
from gitcad.ecad import Board
|
|
672
|
+
from gitcad.ecad.gencad import to_gencad
|
|
673
|
+
|
|
674
|
+
return {"gencad": to_gencad(Board.loads(board))}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@tool("board_teardrops")
|
|
678
|
+
def board_teardrops(board: str, length_ratio: float = 1.0,
|
|
679
|
+
width_ratio: float = 0.9) -> dict[str, Any]:
|
|
680
|
+
"""Generate teardrop copper (same-net wedges) where thin tracks meet
|
|
681
|
+
wider via/through-pad barrels — drill-breakout insurance. Idempotent;
|
|
682
|
+
the result is ordinary zones gated by DRC + connectivity like any
|
|
683
|
+
copper edit. Returns the updated board text."""
|
|
684
|
+
from gitcad.ecad import Board
|
|
685
|
+
from gitcad.ecad.drc import run_drc
|
|
686
|
+
from gitcad.ecad.teardrop import generate_teardrops
|
|
687
|
+
|
|
688
|
+
b = Board.loads(board)
|
|
689
|
+
added = generate_teardrops(b, length_ratio=length_ratio,
|
|
690
|
+
width_ratio=width_ratio)
|
|
691
|
+
drc = run_drc(b)
|
|
692
|
+
return {"added": added, "board": b.dumps(),
|
|
693
|
+
"drc_ok": drc.ok, "drc_violations": drc.violations}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@tool("board_autoroute")
|
|
697
|
+
def board_autoroute(board: str, net: str, grid: float = 0.25,
|
|
698
|
+
width: float = 0.25, clearance: float = 0.2,
|
|
699
|
+
layers: list[str] | None = None) -> dict[str, Any]:
|
|
700
|
+
"""Autorouting assist v1: deterministic grid maze router (Lee) for one
|
|
701
|
+
net — clearance-aware obstacle grid, through-vias on layer change,
|
|
702
|
+
honest refusal when no path exists. The routed copper is ordinary
|
|
703
|
+
tracks/vias; DRC + connectivity gate it like hand routing."""
|
|
704
|
+
from gitcad.ecad import Board
|
|
705
|
+
from gitcad.ecad.autoroute import autoroute
|
|
706
|
+
from gitcad.ecad.connectivity import check_connectivity
|
|
707
|
+
from gitcad.ecad.drc import run_drc
|
|
708
|
+
|
|
709
|
+
b = Board.loads(board)
|
|
710
|
+
stats = autoroute(b, net, grid=grid, width=width, clearance=clearance,
|
|
711
|
+
layers=tuple(layers) if layers else None)
|
|
712
|
+
drc = run_drc(b)
|
|
713
|
+
conn = check_connectivity(b)
|
|
714
|
+
return {"routed": stats, "board": b.dumps(),
|
|
715
|
+
"drc_ok": drc.ok, "drc_violations": drc.violations,
|
|
716
|
+
"connectivity_ok": conn.ok,
|
|
717
|
+
"connectivity_violations": conn.violations}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
@tool("schematic_pdf")
|
|
721
|
+
def schematic_pdf(schematic: str, out: str) -> dict[str, Any]:
|
|
722
|
+
"""Plot a drawn schematic (imported or sheet-authored graphics) to a
|
|
723
|
+
vector PDF at ``out`` — the print/archive projection. The SVG remains
|
|
724
|
+
the review-loop rendering; this is the same drawing on paper."""
|
|
725
|
+
import base64
|
|
726
|
+
from pathlib import Path as _P
|
|
727
|
+
|
|
728
|
+
from gitcad.ecad import Schematic
|
|
729
|
+
from gitcad.ecad.schpdf import sheet_to_pdf
|
|
730
|
+
|
|
731
|
+
sch = Schematic.loads(schematic)
|
|
732
|
+
pdf = sheet_to_pdf(sch)
|
|
733
|
+
_P(out).write_bytes(pdf)
|
|
734
|
+
return {"path": out, "bytes": len(pdf),
|
|
735
|
+
"sha256_prefix": __import__("hashlib").sha256(pdf).hexdigest()[:16]}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@tool("board_import_altium")
|
|
739
|
+
def board_import_altium(path: str) -> dict[str, Any]:
|
|
740
|
+
"""Import an Altium .PcbDoc saved in ASCII form (components/pads/
|
|
741
|
+
tracks/vias/nets, drops reported). Binary OLE PcbDocs are refused
|
|
742
|
+
with the working migration path (KiCad's Altium importer -> .kicad_pcb
|
|
743
|
+
-> board_import)."""
|
|
744
|
+
from gitcad.importers.altium import import_altium_pcb
|
|
745
|
+
|
|
746
|
+
b, report = import_altium_pcb(path)
|
|
747
|
+
return {"board": b.dumps(), "report": report.to_dict()}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@tool("board_ipcd356")
|
|
751
|
+
def board_ipcd356(board: str) -> dict[str, Any]:
|
|
752
|
+
"""IPC-D-356 electrical test netlist (flying probe / bed-of-nails):
|
|
753
|
+
every netted pad and via as fixed-column records, metric units.
|
|
754
|
+
Structure follows the published layout; not yet conformance-run on a
|
|
755
|
+
physical tester."""
|
|
756
|
+
from gitcad.ecad import Board
|
|
757
|
+
from gitcad.ecad.ipcd356 import to_ipcd356
|
|
758
|
+
|
|
759
|
+
return {"ipcd356": to_ipcd356(Board.loads(board))}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@tool("schematic_import_eagle")
|
|
763
|
+
def schematic_import_eagle(path: str) -> dict[str, Any]:
|
|
764
|
+
"""Import an Eagle .sch (XML): parts + explicit netlist, honest report
|
|
765
|
+
(netlist-only — Eagle pin names stand in for pad numbers until device
|
|
766
|
+
mappings are resolved)."""
|
|
767
|
+
from gitcad.importers.eagle import import_eagle_sch
|
|
768
|
+
|
|
769
|
+
sch, report = import_eagle_sch(path)
|
|
770
|
+
erc = sch.erc()
|
|
771
|
+
return {"schematic": sch.dumps(), "report": report.to_dict(),
|
|
772
|
+
"erc_ok": erc.ok, "erc_violations": erc.violations}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@tool("schematic_annotate")
|
|
776
|
+
def schematic_annotate_tool(schematic: str) -> dict[str, Any]:
|
|
777
|
+
"""Deterministic reference numbering (KiCad-map P4): placeholder refs
|
|
778
|
+
(R?, U?) get the lowest free number per prefix in reading order
|
|
779
|
+
(top-to-bottom, left-to-right); existing numbers never move; nets
|
|
780
|
+
referencing placeholders refuse (ambiguous — annotate before
|
|
781
|
+
connecting). Returns the annotated schematic + the rename map."""
|
|
782
|
+
from gitcad.ecad.annotate import annotate
|
|
783
|
+
from gitcad.ecad.schematic import Schematic
|
|
784
|
+
|
|
785
|
+
sch = Schematic.loads(schematic)
|
|
786
|
+
renames = annotate(sch)
|
|
787
|
+
return {"schematic": sch.dumps(), "renames": renames}
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@tool("footprint_generate")
|
|
791
|
+
def footprint_generate(kind: str, params: dict | None = None) -> dict[str, Any]:
|
|
792
|
+
"""Parametric footprint generators (KiCad-map P5) — wizards, agent-first:
|
|
793
|
+
chip(size='0603'), soic(n=8, pitch=1.27), qfn(n=16, pitch=0.5, ep=2.1),
|
|
794
|
+
header(n=6, rows=2). Returns the footprint (pads + courtyard) ready for
|
|
795
|
+
a Board component or footprint_to_part registry publishing."""
|
|
796
|
+
from dataclasses import asdict
|
|
797
|
+
|
|
798
|
+
from gitcad.ecad.fpgen import generate
|
|
799
|
+
|
|
800
|
+
fp = generate(kind, **(params or {}))
|
|
801
|
+
return {"footprint": asdict(fp)}
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
@tool("pcba_verify")
|
|
805
|
+
def pcba_verify_tool(part: str, root: str) -> dict[str, Any]:
|
|
806
|
+
"""Enter a PCBA's electrical workflow as one gate: ERC + electrical
|
|
807
|
+
envelopes per referenced schematic, board validation + DRC + copper
|
|
808
|
+
connectivity, and schematic<->board parity — the Fusion-360 duality:
|
|
809
|
+
a .pcba is mechanical from the outside (envelope, mounting ports, 3D
|
|
810
|
+
body) and this suite is what 'inside' means."""
|
|
811
|
+
from gitcad.pcba import pcba_verify
|
|
812
|
+
|
|
813
|
+
return pcba_verify(part, root)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@tool("schematic_envelope")
|
|
817
|
+
def schematic_envelope(schematic: str) -> dict[str, Any]:
|
|
818
|
+
"""The hardware type system, electrical v1 (ADR-0015): net voltages
|
|
819
|
+
derived from rail names + net_specs, checked against each pin's
|
|
820
|
+
v_abs_max/v_op_min, and rail current draw vs. source capacity —
|
|
821
|
+
overvoltage caught at design time, not bring-up. Coverage is reported
|
|
822
|
+
(pins_with_specs) so green never masquerades as verified. Includes the
|
|
823
|
+
per-rail power budget."""
|
|
824
|
+
from gitcad.ecad.envelope import check_envelopes, power_budget
|
|
825
|
+
from gitcad.ecad.schematic import Schematic
|
|
826
|
+
|
|
827
|
+
sch = Schematic.loads(schematic)
|
|
828
|
+
r = check_envelopes(sch)
|
|
829
|
+
out: dict[str, Any] = {"ok": r.ok, "checks": r.checks,
|
|
830
|
+
"violations": r.violations}
|
|
831
|
+
try:
|
|
832
|
+
out["power_budget"] = power_budget(sch)
|
|
833
|
+
except GitcadError:
|
|
834
|
+
out["power_budget"] = {} # zero spec coverage — already visible above
|
|
835
|
+
return out
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@tool("schematic_system_erc")
|
|
839
|
+
def schematic_system_erc(schematics: list[str]) -> dict[str, Any]:
|
|
840
|
+
"""Merge multiple board schematics (canonical gitcad text) into one
|
|
841
|
+
system schematic — nets union by NAME (the cross-connector contract) —
|
|
842
|
+
and run ERC on the whole circuit. This is how multi-board designs are
|
|
843
|
+
checked: per-sheet ERC flags interface signals as single-pin; system
|
|
844
|
+
ERC sees the real nets."""
|
|
845
|
+
from gitcad.ecad.schematic import Schematic, merge_schematics
|
|
846
|
+
|
|
847
|
+
sheets = [Schematic.loads(s) for s in schematics]
|
|
848
|
+
sys_sch = merge_schematics("system", sheets)
|
|
849
|
+
erc = sys_sch.erc()
|
|
850
|
+
return {"schematic": sys_sch.dumps(), "components": len(sys_sch.components),
|
|
851
|
+
"nets": len(sys_sch.nets), "erc_ok": erc.ok,
|
|
852
|
+
"erc_violations": erc.violations}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@tool("board_annotate")
|
|
856
|
+
def board_annotate(board: str, schematic: str,
|
|
857
|
+
overwrite_conflicts: bool = False) -> dict[str, Any]:
|
|
858
|
+
"""Forward annotation (the ECO write path): push the schematic's netlist
|
|
859
|
+
onto board pads, matched by ref + pin number. Mismatches are reported,
|
|
860
|
+
never guessed; existing conflicting nets are kept unless
|
|
861
|
+
overwrite_conflicts. Returns the annotated board text + sync report +
|
|
862
|
+
board_parity result. For multi-board systems pass the merged system
|
|
863
|
+
schematic — refs_missing_on_board then just means 'lives on another
|
|
864
|
+
board'."""
|
|
865
|
+
from gitcad.ecad.board import Board
|
|
866
|
+
from gitcad.ecad.schematic import Schematic, board_parity
|
|
867
|
+
from gitcad.ecad.sync import annotate_board
|
|
868
|
+
|
|
869
|
+
b = Board.loads(board)
|
|
870
|
+
sch = Schematic.loads(schematic)
|
|
871
|
+
report = annotate_board(b, sch, overwrite_conflicts=overwrite_conflicts)
|
|
872
|
+
parity = board_parity(sch, b)
|
|
873
|
+
return {"board": b.dumps(), "report": report.to_dict(),
|
|
874
|
+
"parity_ok": parity.ok, "parity_violations": parity.violations}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@tool("board_drc")
|
|
878
|
+
def board_drc(board: str, rulepack: str | None = None) -> dict[str, Any]:
|
|
879
|
+
"""Design-rule check: clearance, track width, annular ring, drill sizes,
|
|
880
|
+
hole spacing, edge clearance — against a rule pack (default: conservative
|
|
881
|
+
2-layer prototype profile). Rule packs are canonical text and shareable."""
|
|
882
|
+
from gitcad.ecad import Board, RulePack, run_drc
|
|
883
|
+
|
|
884
|
+
b = Board.loads(board)
|
|
885
|
+
pack = RulePack.loads(rulepack) if rulepack else None
|
|
886
|
+
r = run_drc(b, pack)
|
|
887
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
@tool("board_connectivity")
|
|
891
|
+
def board_connectivity(board: str) -> dict[str, Any]:
|
|
892
|
+
"""Copper connectivity check: every net's pads must be joined by actual
|
|
893
|
+
touching copper (tracks/vias), and no copper component may bridge two
|
|
894
|
+
nets. Geometric — catches mislabeled tracks as the shorts they are."""
|
|
895
|
+
from gitcad.ecad import Board, check_connectivity
|
|
896
|
+
|
|
897
|
+
r = check_connectivity(Board.loads(board))
|
|
898
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@tool("schematic_render")
|
|
902
|
+
def schematic_render(schematic: str, path: str) -> dict[str, Any]:
|
|
903
|
+
"""Render the schematic DIAGRAM (SVG) — symbols, net lanes, junctions:
|
|
904
|
+
the human review surface before layout. Auto-layout; manual placement
|
|
905
|
+
honored via component attrs["at"]."""
|
|
906
|
+
from gitcad.ecad import Schematic, schematic_to_svg
|
|
907
|
+
|
|
908
|
+
svg = schematic_to_svg(Schematic.loads(schematic))
|
|
909
|
+
with open(path, "w", newline="\n") as f:
|
|
910
|
+
f.write(svg)
|
|
911
|
+
return {"path": path, "bytes": len(svg)}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
@tool("schematic_erc")
|
|
915
|
+
def schematic_erc(schematic: str) -> dict[str, Any]:
|
|
916
|
+
"""Electrical rule check on a schematic document: pin-type conflicts,
|
|
917
|
+
undriven inputs, unpowered power pins, unconnected pins, degenerate nets.
|
|
918
|
+
'The schematic compiles' as a machine-decidable statement."""
|
|
919
|
+
from gitcad.ecad import Schematic
|
|
920
|
+
|
|
921
|
+
s = Schematic.loads(schematic)
|
|
922
|
+
r = s.erc()
|
|
923
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@tool("schematic_board_parity")
|
|
927
|
+
def schematic_board_parity(schematic: str, board: str) -> dict[str, Any]:
|
|
928
|
+
"""Schematic <-> board consistency (the ECO check): missing components,
|
|
929
|
+
missing/extra connections, net mismatches — in both directions."""
|
|
930
|
+
from gitcad.ecad import Board, Schematic, board_parity
|
|
931
|
+
|
|
932
|
+
r = board_parity(Schematic.loads(schematic), Board.loads(board))
|
|
933
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@tool("project_release")
|
|
937
|
+
def project_release(sources: list[str], outdir: str, version: str) -> dict[str, Any]:
|
|
938
|
+
"""Project-Releaser-as-code: run EVERY check (validate/ERC/parity/DRC/fab)
|
|
939
|
+
across the given model/board/schematic documents; only on all-green write
|
|
940
|
+
the full artifact set + sha256 manifest. Red checks = no release."""
|
|
941
|
+
from gitcad.release import release
|
|
942
|
+
|
|
943
|
+
r = release(sources, outdir, version)
|
|
944
|
+
return {"ok": r.ok, "version": r.version, "checks": r.checks,
|
|
945
|
+
"failures": r.failures, "artifacts": r.artifacts,
|
|
946
|
+
"manifest": r.manifest_path}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
@tool("semantic_diff")
|
|
950
|
+
def semantic_diff_tool(old: str, new: str) -> dict[str, Any]:
|
|
951
|
+
"""Meaning-level diff between two revisions of the same document text:
|
|
952
|
+
features added/removed/changed by stable id, volume delta, board deltas,
|
|
953
|
+
or interface-semver classification for parts. The PR review surface."""
|
|
954
|
+
from gitcad.release import semantic_diff
|
|
955
|
+
|
|
956
|
+
return semantic_diff(old, new)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@tool("assembly_interference")
|
|
960
|
+
def assembly_interference(assembly_body: dict[str, Any], models: dict[str, str]) -> dict[str, Any]:
|
|
961
|
+
"""EXACT interference check: build each instanced part's real geometry
|
|
962
|
+
(``models``: part id -> model text), place per the assembly transforms,
|
|
963
|
+
boolean-intersect every AABB-overlapping pair. Nonzero common volume =
|
|
964
|
+
collision, measured in mm3. Requires the OCCT kernel."""
|
|
965
|
+
from gitcad.part.interference import check_interference
|
|
966
|
+
|
|
967
|
+
kernel = get_kernel(require="occt")
|
|
968
|
+
instances: dict[str, Any] = {}
|
|
969
|
+
for name, inst in assembly_body["instances"].items():
|
|
970
|
+
text = models.get(inst["part"])
|
|
971
|
+
if text is None:
|
|
972
|
+
raise ValueError(f"no model text supplied for part {inst['part']!r}")
|
|
973
|
+
doc = Document.loads(text)
|
|
974
|
+
shape = doc.build(kernel).final(doc)
|
|
975
|
+
instances[name] = (shape, tuple(inst.get("translate", (0, 0, 0))),
|
|
976
|
+
inst.get("rotate_z_deg", 0.0))
|
|
977
|
+
r = check_interference(kernel, instances)
|
|
978
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@tool("part_check_release")
|
|
982
|
+
def part_check_release(old_part: str, new_part: str) -> dict[str, Any]:
|
|
983
|
+
"""Interface-semver release gate (ADR-0009): given old and new part.json
|
|
984
|
+
texts, classify the interface change and verify the version bump suffices.
|
|
985
|
+
The check that stops shipping a breaking change as a patch."""
|
|
986
|
+
from gitcad.part import PartManifest, check_release, classify_change
|
|
987
|
+
|
|
988
|
+
old, new = PartManifest.loads(old_part), PartManifest.loads(new_part)
|
|
989
|
+
required, reasons = classify_change(old.interface, new.interface)
|
|
990
|
+
violations = check_release(old.version, new.version, old.interface, new.interface)
|
|
991
|
+
return {"ok": not violations, "required_bump": required,
|
|
992
|
+
"reasons": reasons, "violations": violations}
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@tool("assembly_validate")
|
|
996
|
+
def assembly_validate(assembly_body: dict[str, Any], parts: list[str]) -> dict[str, Any]:
|
|
997
|
+
"""Validate an assembly body (instances + mates) against its parts'
|
|
998
|
+
interfaces: port-type compatibility and positional coincidence — the
|
|
999
|
+
cross-domain co-design check (ADR-0008)."""
|
|
1000
|
+
from gitcad.part import Assembly, PartManifest
|
|
1001
|
+
|
|
1002
|
+
by_id = {m.id: m for m in (PartManifest.loads(p) for p in parts)}
|
|
1003
|
+
asm = Assembly(assembly_body.get("name", "assembly"))
|
|
1004
|
+
for name, inst in assembly_body["instances"].items():
|
|
1005
|
+
part = by_id.get(inst["part"])
|
|
1006
|
+
if part is None:
|
|
1007
|
+
raise ValueError(f"instance {name!r} references unknown part {inst['part']!r}")
|
|
1008
|
+
asm.add(name, part, translate=tuple(inst.get("translate", (0, 0, 0))),
|
|
1009
|
+
rotate_z_deg=inst.get("rotate_z_deg", 0.0))
|
|
1010
|
+
for m in assembly_body.get("mates", []):
|
|
1011
|
+
asm.mate(m["a"], m["b"])
|
|
1012
|
+
r = asm.validate()
|
|
1013
|
+
return {"ok": r.ok, "checks": r.checks, "violations": r.violations}
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@tool("assembly_fasteners")
|
|
1017
|
+
def assembly_fasteners(assembly_body: dict[str, Any], parts: list[str],
|
|
1018
|
+
default_length: float = 8.0) -> dict[str, Any]:
|
|
1019
|
+
"""Toolbox, agent-first (SW-map P6): populate every unmated mech.bolt
|
|
1020
|
+
port with a correctly sized ISO 4762 bolt instance + mate, then run
|
|
1021
|
+
the standard assembly validation as the proof. Sizing comes from the
|
|
1022
|
+
port's spec (thread "M3", optional length); specless ports are
|
|
1023
|
+
reported, never guessed. Returns the additions, skips, the updated
|
|
1024
|
+
assembly body, and the post-populate validation."""
|
|
1025
|
+
from gitcad.fasteners import generate_fasteners
|
|
1026
|
+
from gitcad.part import Assembly, PartManifest
|
|
1027
|
+
|
|
1028
|
+
by_id = {m.id: m for m in (PartManifest.loads(p) for p in parts)}
|
|
1029
|
+
asm = Assembly(assembly_body.get("name", "assembly"))
|
|
1030
|
+
for name, inst in assembly_body["instances"].items():
|
|
1031
|
+
part = by_id.get(inst["part"])
|
|
1032
|
+
if part is None:
|
|
1033
|
+
raise ValueError(f"instance {name!r} references unknown part {inst['part']!r}")
|
|
1034
|
+
asm.add(name, part, translate=tuple(inst.get("translate", (0, 0, 0))),
|
|
1035
|
+
rotate_z_deg=inst.get("rotate_z_deg", 0.0))
|
|
1036
|
+
for m in assembly_body.get("mates", []):
|
|
1037
|
+
asm.mate(m["a"], m["b"])
|
|
1038
|
+
result = generate_fasteners(asm, default_length=default_length)
|
|
1039
|
+
r = asm.validate()
|
|
1040
|
+
body = {"name": asm.name,
|
|
1041
|
+
"instances": {n: {"part": i.part.id, "translate": list(i.translate),
|
|
1042
|
+
"rotate_z_deg": i.rotate_z_deg}
|
|
1043
|
+
for n, i in asm.instances.items()},
|
|
1044
|
+
"mates": [{"a": m.a, "b": m.b} for m in asm.mates]}
|
|
1045
|
+
return {**result, "assembly": body,
|
|
1046
|
+
"bolt_sizes": sorted({f"{a['thread']}x{a['length']:g}"
|
|
1047
|
+
for a in result["added"]}),
|
|
1048
|
+
"ok": r.ok, "violations": r.violations}
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def main() -> None: # pragma: no cover - process entrypoint
|
|
1052
|
+
"""Serve the registry over MCP (requires the optional ``mcp`` dependency)."""
|
|
1053
|
+
try:
|
|
1054
|
+
from mcp.server.fastmcp import FastMCP
|
|
1055
|
+
except Exception as exc: # pragma: no cover
|
|
1056
|
+
raise SystemExit(
|
|
1057
|
+
"The MCP server needs the optional dependency. Install with:\n"
|
|
1058
|
+
" pip install 'gitcad[mcp]'\n"
|
|
1059
|
+
f"(import error: {exc!r})"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
server = FastMCP("gitcad")
|
|
1063
|
+
for name, fn in REGISTRY.items():
|
|
1064
|
+
server.add_tool(fn, name=name)
|
|
1065
|
+
server.run()
|