Skills › Scientific Agent Skills › lab-hardware-cad › gen.py
gen.py
263 lines · skills/lab-hardware-cad/scripts/gen.py at 5f2bc03
1#!/usr/bin/env python3
2"""Generate fabrication artifacts from a parametric build123d model.
4Runs a model file's ``build()``, exports STEP (authoritative) and STL (preview and
5printing), and writes a manifest recording the source hash, resolved parameters,
6library versions, and measured geometry.
8 python scripts/gen.py carrier_model.py --outdir out/
9 python scripts/gen.py carrier_model.py --outdir out/ --param wall_t_mm=4.0
10 python scripts/gen.py plate_model.py --outdir out/ --dxf # 2D laser profile
11"""
13from __future__ import annotations
15import argparse
16import json
17import platform
18import sys
19from pathlib import Path
21sys.path.insert(0, str(Path(__file__).resolve().parent))
23from _common import ( # noqa: E402
24 LabCadError,
25 emit,
26 eprint,
27 evaluate_checks,
28 format_check_result,
29 import_model,
30 main_guard,
31 model_checks,
32 model_interfaces,
33 model_parameters,
34 parse_params,
35 require_build123d,
36 sha256_of,
37 shape_facts,
38)
41def _build123d_version() -> str:
42 from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415
44 try:
45 return version("build123d")
46 except PackageNotFoundError: # pragma: no cover
47 return "unknown"
50def _export_dxf(part, path: Path, build123d, height: float | None) -> float:
51 """Slice the part on a horizontal plane and write the profile as DXF.
53 ``section()`` is a module-level operation in build123d 0.13.0, not a method on
54 the shape. The default cut height is the middle of the part rather than z = 0,
55 to avoid ambiguous sections coincident with outer faces. Returns the height actually used, for the manifest.
56 """
57 if height is None:
58 bbox = part.bounding_box()
59 height = (float(bbox.min.Z) + float(bbox.max.Z)) / 2.0
61 plane = build123d.Plane.XY.offset(height)
62 try:
63 profile = build123d.section(part, plane, mode=build123d.Mode.PRIVATE)
64 except Exception as exc: # noqa: BLE001 - the kernel raises assorted OCCT errors
65 raise LabCadError(f"DXF section at z={height:.3f} mm failed: {exc}") from exc
66 if not profile.faces():
67 raise LabCadError(
68 f"DXF section at z={height:.3f} mm is empty. Pass --dxf-z with a height "
69 "that actually cuts material."
70 )
72 # Translate the section back to z = 0: DXF is a 2D format, and handing it a
73 # profile at the section height makes the exporter warn about a non-planar
74 # shape even though the written entities would be flat anyway.
75 if abs(height) > 1e-9:
76 profile = profile.moved(build123d.Location((0.0, 0.0, -height)))
78 from build123d.exporters import ColorIndex # noqa: PLC0415 - not in the top-level namespace
80 exporter = build123d.ExportDXF(unit=build123d.Unit.MM)
81 # Cut geometry goes on a named layer: laser shops key power and speed to
82 # layer or colour, and geometry on layer 0 forces them to guess.
83 exporter.add_layer("CUT", color=ColorIndex.RED)
84 exporter.add_shape(profile, layer="CUT")
85 exporter.write(str(path))
86 return height
89def main() -> int:
90 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
91 parser.add_argument("model", type=Path, help="path to a *_model.py exposing build() -> Part")
92 parser.add_argument("--outdir", type=Path, default=Path("out"), help="output directory (default: out)")
93 parser.add_argument("--name", help="artifact basename (default: model filename without _model)")
94 parser.add_argument("--param", action="append", metavar="KEY=VALUE",
95 help="override a model parameter; repeatable")
96 parser.add_argument("--tolerance", type=float, default=1e-3,
97 help="STL linear deflection in mm (default: 0.001)")
98 parser.add_argument("--angular-tolerance", type=float, default=0.1,
99 help="STL angular deflection in radians (default: 0.1)")
100 parser.add_argument("--dxf", action="store_true",
101 help="also export a 2D DXF profile, sliced on a horizontal plane")
102 parser.add_argument("--dxf-z", type=float, default=None, metavar="MM",
103 help="height of the DXF section plane (default: the middle of the part)")
104 parser.add_argument("--no-stl", action="store_true", help="skip the STL export")
105 parser.add_argument("--json", action="store_true", dest="as_json", help="emit the manifest as JSON on stdout")
106 args = parser.parse_args()
108 if args.dxf_z is not None and not args.dxf:
109 raise LabCadError("--dxf-z sets the section height for --dxf; pass --dxf as well")
111 build123d = require_build123d()
112 overrides = parse_params(args.param)
114 eprint(f"building {args.model.name} ...")
115 module = import_model(args.model, overrides)
116 builder = getattr(module, "build", None)
117 if builder is None or not callable(builder):
118 raise LabCadError(f"{args.model.name} must define a callable build() that returns a Part")
119 try:
120 part = builder()
121 except LabCadError:
122 raise
123 except Exception as exc: # noqa: BLE001 - model code raises arbitrary errors
124 import traceback # noqa: PLC0415
126 frames = traceback.extract_tb(exc.__traceback__)
127 model_frames = [f for f in frames if f.filename == str(args.model.resolve())]
128 where = (
129 f" at {args.model.name}:{model_frames[-1].lineno} ({model_frames[-1].name})"
130 if model_frames else ""
131 )
132 raise LabCadError(
133 f"build() failed{where}: {type(exc).__name__}: {exc}"
134 ) from exc
135 if part is None:
136 raise LabCadError(f"{args.model.name}: build() returned None")
137 params = model_parameters(module)
138 # Read the declared interfaces after build(), so a model that computes them in
139 # build() and stores them on the module still reports the resolved numbers.
140 interfaces = model_interfaces(module)
141 if interfaces and overrides and not callable(getattr(module, "interfaces", None)):
142 eprint(
143 "WARNING: this model declares a static INTERFACES list, which was evaluated "
144 "at import - before --param was applied. Any interface derived from an "
145 "overridden parameter is now recorded WRONG. Convert INTERFACES into an "
146 "interfaces() function that computes from the current parameters."
147 )
149 stem = args.name or args.model.stem.removesuffix("_model")
150 outdir = args.outdir
151 outdir.mkdir(parents=True, exist_ok=True)
153 step_path = outdir / f"{stem}.step"
154 build123d.export_step(part, str(step_path), unit=build123d.Unit.MM)
155 eprint(f"wrote {step_path}")
156 artifacts = {"step": str(step_path)}
158 if not args.no_stl:
159 stl_path = outdir / f"{stem}.stl"
160 build123d.export_stl(
161 part, str(stl_path),
162 tolerance=args.tolerance,
163 angular_tolerance=args.angular_tolerance,
164 )
165 eprint(f"wrote {stl_path}")
166 artifacts["stl"] = str(stl_path)
168 dxf_z = None
169 dxf_error = None
170 if args.dxf:
171 dxf_path = outdir / f"{stem}.dxf"
172 try:
173 dxf_z = _export_dxf(part, dxf_path, build123d, args.dxf_z)
174 except LabCadError as exc:
175 # Not fatal: finish the manifest so the STEP already on disk keeps its
176 # provenance record, and fail at the end instead.
177 dxf_error = str(exc)
178 eprint(f"warning: DXF export failed: {exc}")
179 else:
180 eprint(f"wrote {dxf_path} (section at z = {dxf_z:.3f} mm)")
181 artifacts["dxf"] = str(dxf_path)
183 facts = shape_facts(part)
184 if not facts["is_valid"]:
185 eprint(
186 "WARNING: the generated solid fails OpenCascade validity checks. "
187 "Fix the model source before fabricating."
188 )
190 # Evaluate the model's declared geometry checks against the solid just built.
191 # These are measured, so a failure here is a real feature error, not a
192 # declaration mismatch.
193 declared_checks = model_checks(module)
194 check_results = []
195 checks_pass = True
196 if declared_checks:
197 check_results = evaluate_checks(part, declared_checks)
198 checks_pass = all(item["pass"] for item in check_results)
199 eprint(f"geometry checks: {len(check_results)}")
200 for item in check_results:
201 for line in format_check_result(item):
202 eprint(line)
203 if not checks_pass:
204 eprint(
205 "WARNING: geometry check(s) FAILED. The exported STEP does not meet "
206 "the model's own declared requirements; fix the source and rerun."
207 )
209 manifest = {
210 "artifact_name": stem,
211 "source": {
212 "path": str(args.model.resolve()),
213 "sha256": sha256_of(args.model),
214 },
215 "parameters": params,
216 "overrides": overrides,
217 "interfaces": interfaces,
218 "geometry_checks": {"declared": declared_checks, "results": check_results,
219 "pass": checks_pass},
220 "geometry": facts,
221 "artifacts": artifacts,
222 "dxf_section_z_mm": dxf_z,
223 "dxf_error": dxf_error,
224 "environment": {
225 "build123d": _build123d_version(),
226 "python": platform.python_version(),
227 "platform": platform.platform(),
228 },
229 "units": "mm",
230 }
232 manifest_path = outdir / f"{stem}.manifest.json"
233 manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
234 eprint(f"wrote {manifest_path}")
236 if not interfaces:
237 eprint(
238 "note: no declared interfaces. Correct if nothing here mates with a bundled "
239 "standard - do not invent one; name unchecked interface dimensions in the report."
240 )
242 box = facts["bounding_box_mm"]
243 checks_note = (
244 f"geometry checks: {sum(1 for c in check_results if c['pass'])}/{len(check_results)} pass"
245 if check_results else "geometry checks: none declared"
246 )
247 lines = [
248 f"{stem}: {box['x']:.2f} x {box['y']:.2f} x {box['z']:.2f} mm, "
249 f"volume {facts['volume_mm3']:.1f} mm^3, valid={facts['is_valid']}, "
250 f"declared interfaces: {len(interfaces)}, {checks_note}",
251 f"Next: python scripts/check.py facts {step_path}",
252 ]
253 if interfaces:
254 lines.append(f" python scripts/check.py interfaces {manifest_path}")
255 lines.append(
256 f" python scripts/snapshot.py {step_path} --out {outdir / (stem + '.png')}"
257 )
258 emit(manifest, args.as_json, "\n".join(lines))
259 return 0 if facts["is_valid"] and dxf_error is None and checks_pass else 1
262if __name__ == "__main__":
263 main_guard(main)