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/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()