Skills › Scientific Agent Skills › lab-hardware-cad › _common.py
_common.py
683 lines · skills/lab-hardware-cad/scripts/_common.py at 5f2bc03
1"""Shared helpers for the lab-hardware-cad scripts.
3Import of build123d is deferred so that standard-library-only commands
4(``check.py standards``) work in an environment without the CAD kernel.
5"""
7from __future__ import annotations
9import hashlib
10import importlib.util
11import json
12import math
13import sys
14from pathlib import Path
15from typing import Any
17# Model files are imported from the user's working directory; leaving compiled
18# bytecode there puts a __pycache__ next to the deliverables.
19sys.dont_write_bytecode = True
21SKILL_ROOT = Path(__file__).resolve().parent.parent
22STANDARDS_PATH = SKILL_ROOT / "assets" / "standards.json"
24MESH_FORMATS = {".stl"}
25BREP_FORMATS = {".step", ".stp"}
28class LabCadError(RuntimeError):
29 """A user-facing error: printed without a traceback."""
32def finite_number(value: Any, label: str) -> float:
33 """Reject non-finite values instead of letting them bypass a numerical check."""
34 if isinstance(value, bool):
35 raise LabCadError(f"{label} must be a finite number, not a boolean")
36 try:
37 result = float(value)
38 except (ValueError, TypeError, OverflowError) as exc:
39 raise LabCadError(f"{label} must be a finite number") from exc
40 if not math.isfinite(result):
41 raise LabCadError(f"{label} must be a finite number")
42 return result
45def eprint(message: str) -> None:
46 """Progress and diagnostics go to stderr so stdout stays machine-readable."""
47 print(message, file=sys.stderr)
50def emit(payload: Any, as_json: bool, text: str | None = None) -> None:
51 """Write a result to stdout as JSON or as human-readable text."""
52 if as_json:
53 print(json.dumps(payload, indent=2, sort_keys=True, default=str))
54 else:
55 print(text if text is not None else payload)
58def load_standards() -> dict:
59 """Load the bundled standards database. Standard library only."""
60 if not STANDARDS_PATH.exists():
61 raise LabCadError(f"standards database missing at {STANDARDS_PATH}")
62 with STANDARDS_PATH.open(encoding="utf-8") as handle:
63 return json.load(handle)
66def get_standard(standard_id: str) -> dict:
67 data = load_standards()
68 standards = data.get("standards", {})
69 if standard_id not in standards:
70 known = ", ".join(sorted(standards))
71 raise LabCadError(f"unknown standard {standard_id!r}. Available: {known}")
72 return standards[standard_id]
75def require_build123d():
76 """Import build123d, or fail with an actionable message."""
77 try:
78 import build123d # noqa: PLC0415
79 except ImportError as exc: # pragma: no cover - environment dependent
80 raise LabCadError(
81 "build123d is not installed in this interpreter.\n"
82 " uv venv --python 3.12 .venv-labcad\n"
83 ' uv pip install --python .venv-labcad/bin/python "build123d==0.13.0" "matplotlib>=3.8"'
84 ) from exc
85 return build123d
88def sha256_of(path: Path) -> str:
89 digest = hashlib.sha256()
90 with path.open("rb") as handle:
91 for chunk in iter(lambda: handle.read(65536), b""):
92 digest.update(chunk)
93 return digest.hexdigest()
96def _coerce(value: str) -> Any:
97 """Parse a --param value into the narrowest sensible Python type."""
98 lowered = value.strip().lower()
99 if lowered in {"true", "false"}:
100 return lowered == "true"
101 for caster in (int, float):
102 try:
103 return caster(value)
104 except ValueError:
105 continue
106 return value
109def parse_params(pairs: list[str] | None) -> dict[str, Any]:
110 """Turn ``["bore_d_mm=6.1", "wall_t_mm=3"]`` into a dict."""
111 params: dict[str, Any] = {}
112 for pair in pairs or []:
113 if "=" not in pair:
114 raise LabCadError(f"--param expects key=value, got {pair!r}")
115 key, _, raw = pair.partition("=")
116 params[key.strip()] = _coerce(raw)
117 return params
120def model_parameters(module) -> dict[str, Any]:
121 """Collect a model module's public scalar parameters for the manifest."""
122 return {
123 name: value
124 for name, value in vars(module).items()
125 if not name.startswith("_") and isinstance(value, (int, float, str, bool))
126 }
129def import_model(model_path: Path, overrides: dict[str, Any] | None = None):
130 """Import a ``*_model.py`` file and apply ``--param`` overrides.
132 Returns the module without calling ``build()``, so declared interfaces can be
133 read without paying for the geometry.
134 """
135 model_path = model_path.resolve()
136 if not model_path.exists():
137 raise LabCadError(f"model file not found: {model_path}")
139 spec = importlib.util.spec_from_file_location(model_path.stem, model_path)
140 if spec is None or spec.loader is None:
141 raise LabCadError(f"cannot import {model_path}")
142 module = importlib.util.module_from_spec(spec)
143 # Let the model resolve sibling imports.
144 sys.path.insert(0, str(model_path.parent))
145 try:
146 spec.loader.exec_module(module)
147 finally:
148 sys.path.pop(0)
150 for key, value in (overrides or {}).items():
151 if not hasattr(module, key):
152 known = ", ".join(sorted(model_parameters(module)))
153 raise LabCadError(
154 f"model has no parameter {key!r}. Available: {known}"
155 )
156 setattr(module, key, value)
157 return module
160def run_model(model_path: Path, overrides: dict[str, Any] | None = None):
161 """Import a ``*_model.py`` file, apply overrides, and call ``build()``.
163 Returns ``(part, resolved_parameters)``.
164 """
165 module = import_model(model_path, overrides)
167 builder = getattr(module, "build", None)
168 if builder is None or not callable(builder):
169 raise LabCadError(
170 f"{Path(model_path).name} must define a callable build() that returns a Part"
171 )
173 part = builder()
174 if part is None:
175 raise LabCadError(f"{Path(model_path).name}: build() returned None")
176 return part, model_parameters(module)
179def model_interfaces(module) -> list[dict]:
180 """Collect the interface checks a model declares about itself.
182 A model exposes either a module-level ``INTERFACES`` list or an
183 ``interfaces()`` callable returning one. Each entry names the standard and
184 dimension the feature must satisfy and the value the model computed:
186 INTERFACES = [
187 {"feature": "plate pocket length",
188 "standard": "slas-microplate-footprint",
189 "dimension": "footprint_length",
190 "value": pocket_l_mm,
191 "intent": "envelope",
192 "clearance": 0.80},
193 ]
195 This exists because most lab-hardware interfaces are internal features -- a
196 pocket, a bore, a slot -- whose size is nowhere in the part's outer bounding
197 box. Declaring them lets ``check.py interfaces`` verify the number the model
198 actually built instead of one retyped by hand.
199 """
200 declared = getattr(module, "interfaces", None)
201 if callable(declared):
202 declared = declared()
203 elif declared is None:
204 declared = getattr(module, "INTERFACES", None)
205 if declared is None:
206 return []
207 return normalise_interfaces(declared)
210def normalise_interfaces(declared: Any) -> list[dict]:
211 """Validate and fill in defaults for declared interface entries."""
212 if isinstance(declared, dict):
213 declared = [declared]
214 if not isinstance(declared, (list, tuple)):
215 raise LabCadError("INTERFACES must be a list of dicts")
217 entries: list[dict] = []
218 for index, raw in enumerate(declared):
219 if not isinstance(raw, dict):
220 raise LabCadError(f"INTERFACES[{index}] must be a dict, got {type(raw).__name__}")
221 missing = [key for key in ("standard", "dimension", "value") if key not in raw]
222 if missing:
223 raise LabCadError(
224 f"INTERFACES[{index}] is missing {', '.join(missing)}. Every entry needs "
225 "standard, dimension, and value."
226 )
227 value = finite_number(raw["value"], f"INTERFACES[{index}] value")
228 clearance = finite_number(raw.get("clearance", 0.0), f"INTERFACES[{index}] clearance")
229 if clearance < 0:
230 raise LabCadError(f"INTERFACES[{index}] clearance must be >= 0")
231 intent = str(raw.get("intent", "match"))
232 if intent not in {"match", "envelope"}:
233 raise LabCadError(
234 f"INTERFACES[{index}] intent must be 'match' or 'envelope', got {intent!r}"
235 )
236 entries.append({
237 "feature": str(raw.get("feature", raw["dimension"])),
238 "standard": str(raw["standard"]),
239 "dimension": str(raw["dimension"]),
240 "value": value,
241 "intent": intent,
242 "clearance": clearance,
243 })
244 return entries
247def model_checks(module) -> list[dict]:
248 """Collect the geometry checks a model declares about itself.
250 A model exposes a ``checks()`` callable (or a ``CHECKS`` list) of go/no-go
251 gauge assertions evaluated against the BUILT solid -- unlike ``interfaces()``,
252 which only compares declared numbers against the standards database. Each
253 entry asserts one of:
255 clear - a region must contain no material (a screw shaft, a beam
256 corridor, a gauge part dropping into a pocket)
257 material - a region must contain material (a ridge, a ledge, a boss)
258 bbox_* - a bounding-box measure must sit inside [min, max]
260 See ``normalise_checks`` for the entry schema.
261 """
262 declared = getattr(module, "checks", None)
263 if callable(declared):
264 declared = declared()
265 elif declared is None:
266 declared = getattr(module, "CHECKS", None)
267 if declared is None:
268 return []
269 return normalise_checks(declared)
272_MEASURE_NAMES = ("bbox_x", "bbox_y", "bbox_z", "bbox_min", "bbox_mid", "bbox_max")
275def _normalise_region(raw: dict, index: int) -> dict:
276 """Validate one region spec: {"cylinder": dia, ...} or {"box": (dx,dy,dz), ...}."""
277 if sum(key in raw for key in ("box", "cylinder")) != 1:
278 raise LabCadError(f"CHECKS[{index}]: specify exactly one box or cylinder")
279 if "at" in raw and (not isinstance(raw["at"], (list, tuple)) or not raw["at"]):
280 raise LabCadError(f"CHECKS[{index}]: 'at' must contain at least one position")
281 if "cylinder" in raw:
282 dia = finite_number(raw["cylinder"], f"CHECKS[{index}] cylinder diameter")
283 if dia <= 0:
284 raise LabCadError(f"CHECKS[{index}]: cylinder diameter must be > 0")
285 axis = str(raw.get("axis", "z")).lower()
286 if axis not in ("x", "y", "z"):
287 raise LabCadError(f"CHECKS[{index}]: axis must be 'x', 'y', or 'z', got {axis!r}")
288 at = raw.get("at", [(0.0, 0.0)])
289 positions = []
290 for pos in at:
291 if not isinstance(pos, (list, tuple)) or len(pos) != 2:
292 raise LabCadError(f"CHECKS[{index}]: cylinder position needs exactly 2 coordinates")
293 try:
294 a, b = (finite_number(pos[0], "position"), finite_number(pos[1], "position"))
295 except (TypeError, ValueError, IndexError) as exc:
296 raise LabCadError(
297 f"CHECKS[{index}]: cylinder 'at' entries are 2D positions in the "
298 "plane perpendicular to the axis (axis z: (x, y); axis x: (y, z); "
299 f"axis y: (x, z)), got {pos!r}"
300 ) from exc
301 positions.append([a, b])
302 span = raw.get("span")
303 if span is not None:
304 if not isinstance(span, (list, tuple)) or len(span) != 2:
305 raise LabCadError(f"CHECKS[{index}]: span needs exactly 2 coordinates")
306 try:
307 span = [finite_number(span[0], "span"), finite_number(span[1], "span")]
308 except (TypeError, ValueError, IndexError) as exc:
309 raise LabCadError(f"CHECKS[{index}]: span must be (start, end) along the axis") from exc
310 if span[0] == span[1]:
311 raise LabCadError(f"CHECKS[{index}]: span must have nonzero length")
312 return {"shape": "cylinder", "dia": dia, "axis": axis, "at": positions, "span": span}
313 if "box" in raw:
314 size = raw["box"]
315 if not isinstance(size, (list, tuple)) or len(size) != 3:
316 raise LabCadError(f"CHECKS[{index}]: box needs exactly 3 dimensions")
317 try:
318 size = [finite_number(v, "box dimension") for v in size]
319 except (TypeError, ValueError, IndexError) as exc:
320 raise LabCadError(f"CHECKS[{index}]: box must be (dx, dy, dz)") from exc
321 if min(size) <= 0:
322 raise LabCadError(f"CHECKS[{index}]: box dimensions must be > 0")
323 at = raw.get("at", [(0.0, 0.0, 0.0)])
324 positions = []
325 for pos in at:
326 if not isinstance(pos, (list, tuple)) or len(pos) != 3:
327 raise LabCadError(f"CHECKS[{index}]: box position needs exactly 3 coordinates")
328 try:
329 positions.append([finite_number(v, "position") for v in pos])
330 except (TypeError, ValueError, IndexError) as exc:
331 raise LabCadError(
332 f"CHECKS[{index}]: box 'at' entries are 3D centres (x, y, z), got {pos!r}"
333 ) from exc
334 return {"shape": "box", "size": size, "at": positions}
335 raise LabCadError(
336 f"CHECKS[{index}]: a region needs 'cylinder': diameter or 'box': (dx, dy, dz)"
337 )
340def normalise_checks(declared: Any) -> list[dict]:
341 """Validate and fill in defaults for declared geometry-check entries.
343 Raw entry forms::
345 {"feature": "M6 screws pass", "clear": {"cylinder": 6.6, "axis": "z",
346 "at": [(37.5, 37.5), (-37.5, 37.5), (37.5, -37.5), (-37.5, -37.5)]}}
347 {"feature": "plate at MMC drops in", "clear": {"box": (128.26, 85.98, 6.0),
348 "at": [(0.0, 0.0, 7.0)]}}
349 {"feature": "ridge stands proud", "material": {"box": (40.0, 0.8, 0.28),
350 "at": [(0.0, 0.0, 4.15)]}, "min_mm3": 5.0}
351 {"feature": "clears the turret", "bbox_z": {"max": 15.0}}
353 A cylinder with no ``span`` runs through the whole part. ``tol_mm3`` (clear,
354 default 0.01) and ``min_mm3`` (material, default 0.01) tune the pass volume.
355 """
356 if isinstance(declared, dict):
357 declared = [declared]
358 if not isinstance(declared, (list, tuple)):
359 raise LabCadError("CHECKS must be a list of dicts")
361 entries: list[dict] = []
362 for index, raw in enumerate(declared):
363 if not isinstance(raw, dict):
364 raise LabCadError(f"CHECKS[{index}] must be a dict, got {type(raw).__name__}")
365 kinds = [k for k in ("clear", "material", *_MEASURE_NAMES) if k in raw]
366 if len(kinds) != 1:
367 raise LabCadError(
368 f"CHECKS[{index}] needs exactly one of 'clear', 'material', or a bbox "
369 f"measure ({', '.join(_MEASURE_NAMES)}), got {kinds or 'none'}"
370 )
371 kind = kinds[0]
372 entry: dict = {"feature": str(raw.get("feature", kind))}
373 if kind in ("clear", "material"):
374 region = raw[kind]
375 if not isinstance(region, dict):
376 raise LabCadError(f"CHECKS[{index}]: {kind!r} must be a region dict")
377 entry["kind"] = kind
378 entry["region"] = _normalise_region(region, index)
379 entry["tol_mm3"] = finite_number(raw.get("tol_mm3", 0.01), "tol_mm3")
380 entry["min_mm3"] = finite_number(raw.get("min_mm3", 0.01), "min_mm3")
381 if entry["tol_mm3"] < 0 or entry["min_mm3"] <= 0:
382 raise LabCadError("tol_mm3 must be >= 0 and min_mm3 must be > 0")
383 else:
384 bounds = raw[kind]
385 if not isinstance(bounds, dict) or not (
386 "min" in bounds or "max" in bounds
387 ):
388 raise LabCadError(
389 f"CHECKS[{index}]: {kind!r} needs a dict with 'min' and/or 'max' in mm"
390 )
391 entry["kind"] = "measure"
392 entry["measure"] = kind
393 entry["min"] = None if bounds.get("min") is None else finite_number(bounds["min"], "min")
394 entry["max"] = None if bounds.get("max") is None else finite_number(bounds["max"], "max")
395 if entry["min"] is None and entry["max"] is None:
396 raise LabCadError("a bounding-box check needs a finite bound")
397 if entry["min"] is not None and entry["max"] is not None and entry["min"] > entry["max"]:
398 raise LabCadError("bounding-box min must be <= max")
399 entries.append(entry)
400 return entries
403def intersection_volume(shape_a, shape_b) -> float:
404 """Volume of the boolean intersection, tolerant of the kernel's return types.
406 Touching or disjoint solids yield ``None``, an empty ``Compound``, or a
407 ``ShapeList`` with no ``.volume`` depending on the path taken; all of those
408 count as zero.
409 """
410 try:
411 result = shape_a & shape_b
412 except Exception: # noqa: BLE001 - kernel raises assorted OCCT errors
413 try:
414 result = shape_a.intersect(shape_b)
415 except Exception as exc: # noqa: BLE001
416 raise LabCadError(f"boolean intersection failed: {exc}") from exc
417 if result is None:
418 return 0.0
419 volume = getattr(result, "volume", None)
420 if volume is not None:
421 return float(volume)
422 return float(sum(float(getattr(item, "volume", 0.0) or 0.0) for item in result))
425def _region_solids(build123d, region: dict, part_bbox) -> list:
426 """Materialise a region spec into one solid per 'at' position."""
427 solids = []
428 if region["shape"] == "box":
429 dx, dy, dz = region["size"]
430 for x, y, z in region["at"]:
431 solids.append(build123d.Pos(x, y, z) * build123d.Box(dx, dy, dz))
432 return solids
434 dia = region["dia"]
435 axis = region["axis"]
436 span = region["span"]
437 if span is None:
438 lo = {"x": part_bbox.min.X, "y": part_bbox.min.Y, "z": part_bbox.min.Z}[axis] - 2.0
439 hi = {"x": part_bbox.max.X, "y": part_bbox.max.Y, "z": part_bbox.max.Z}[axis] + 2.0
440 else:
441 lo, hi = sorted(span)
442 length = hi - lo
443 mid = (hi + lo) / 2.0
444 for a, b in region["at"]:
445 cyl = build123d.Cylinder(dia / 2.0, length)
446 if axis == "z":
447 solid = build123d.Pos(a, b, mid) * cyl
448 elif axis == "x":
449 solid = build123d.Pos(mid, a, b) * build123d.Rot(0, 90, 0) * cyl
450 else: # y; 'at' is (x, z)
451 solid = build123d.Pos(a, mid, b) * build123d.Rot(90, 0, 0) * cyl
452 solids.append(solid)
453 return solids
456def evaluate_checks(part, declared: list[dict]) -> list[dict]:
457 """Evaluate normalised geometry checks against a built solid."""
458 build123d = require_build123d()
459 facts = shape_facts(part)
460 if not facts["is_valid"] or not facts["solid_count"] or float(part.volume) <= 0:
461 raise LabCadError("geometry gauges require a valid positive-volume solid; use STEP, not an STL face")
462 bbox = part.bounding_box()
463 results = []
464 for entry in declared:
465 result = dict(entry)
466 if entry["kind"] == "measure":
467 actual = measure({"bounding_box_mm": {
468 "x": float(bbox.size.X), "y": float(bbox.size.Y), "z": float(bbox.size.Z)
469 }}, entry["measure"])
470 ok = True
471 if entry["min"] is not None and actual < entry["min"] - 1e-9:
472 ok = False
473 if entry["max"] is not None and actual > entry["max"] + 1e-9:
474 ok = False
475 result.update({"actual_mm": round(actual, 8), "pass": ok})
476 else:
477 volumes = [
478 intersection_volume(part, solid)
479 for solid in _region_solids(build123d, entry["region"], bbox)
480 ]
481 total = round(sum(volumes), 4)
482 if entry["kind"] == "clear":
483 ok = all(v <= entry["tol_mm3"] for v in volumes)
484 else:
485 ok = all(v >= entry["min_mm3"] for v in volumes)
486 result.update({"volumes_mm3": [round(v, 8) for v in volumes],
487 "total_mm3": total, "pass": ok})
488 results.append(result)
489 return results
492def format_check_result(item: dict) -> list[str]:
493 """Human-readable lines for one evaluated geometry-check result."""
494 mark = "PASS" if item["pass"] else "FAIL"
495 if item["kind"] == "measure":
496 bounds = []
497 if item.get("min") is not None:
498 bounds.append(f">= {item['min']:.3f}")
499 if item.get("max") is not None:
500 bounds.append(f"<= {item['max']:.3f}")
501 return [f" [{mark}] {item['feature']:<38} {item['measure']} "
502 f"{item['actual_mm']:.3f} mm expected {' and '.join(bounds)}"]
503 region = item["region"]
504 if region["shape"] == "cylinder":
505 where = f"cyl d{region['dia']:g} axis {region['axis']} x{len(region['at'])}"
506 else:
507 dx, dy, dz = region["size"]
508 where = f"box {dx:g}x{dy:g}x{dz:g} x{len(region['at'])}"
509 if item["kind"] == "clear":
510 detail = f"intruding {item['total_mm3']:.3f} mm^3 (tol {item['tol_mm3']:g}/position)"
511 else:
512 detail = (f"material {item['total_mm3']:.3f} mm^3 "
513 f"(min {item['min_mm3']:g}/position)")
514 lines = [f" [{mark}] {item['feature']:<38} {item['kind']} {where} {detail}"]
515 if not item["pass"]:
516 lines.append(f" per position (mm^3): {item['volumes_mm3']}")
517 return lines
520def cylinder_census(part) -> list[dict]:
521 """Every cylindrical face in the part: radius, axis, extent, sweep.
523 This is the instrument for reconciling what a render appears to show with
524 what the solid actually contains: a bore is a ~360 degree sweep, an edge
525 fillet ~90, and a counterbore is two coaxial full sweeps stacked along the
526 axis with different radii.
527 """
528 build123d = require_build123d()
529 from OCP.BRepAdaptor import BRepAdaptor_Surface # noqa: PLC0415
530 import math # noqa: PLC0415
532 rows = []
533 for face in part.faces():
534 if face.geom_type != build123d.GeomType.CYLINDER:
535 continue
536 surface = BRepAdaptor_Surface(face.wrapped)
537 cyl = surface.Cylinder()
538 ax = cyl.Axis()
539 loc, direction = ax.Location(), ax.Direction()
540 d = (direction.X(), direction.Y(), direction.Z())
541 point = (loc.X(), loc.Y(), loc.Z())
543 axis_name = None
544 for name, vec in (("x", (1, 0, 0)), ("y", (0, 1, 0)), ("z", (0, 0, 1))):
545 if abs(abs(d[0] * vec[0] + d[1] * vec[1] + d[2] * vec[2]) - 1.0) < 1e-6:
546 axis_name = name
547 # Canonicalise to the +axis direction so spans read in real coordinates
548 # instead of sign-flipping for bores cut top-down.
549 if axis_name is not None:
550 d = {"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[axis_name]
552 # Cylindrical U is angle (radians), V is axial distance. Projecting a
553 # world-axis bounding box exaggerates the span of an oblique cylinder.
554 # UV bounds describe the trimmed face's enclosing parameter rectangle;
555 # an irregularly trimmed face need not fill that rectangle.
556 points = [surface.Value(surface.FirstUParameter(), v)
557 for v in (surface.FirstVParameter(), surface.LastVParameter())]
558 proj = [p.X() * d[0] + p.Y() * d[1] + p.Z() * d[2] for p in points]
559 extent = max(proj) - min(proj)
560 radius = float(cyl.Radius())
561 sweep = math.degrees(surface.LastUParameter() - surface.FirstUParameter())
562 # In-plane position, ordered like probe positions: axis z -> (x, y),
563 # axis x -> (y, z), axis y -> (x, z). The axis point's own component
564 # along the axis is arbitrary, so it is not reported for aligned axes.
565 if axis_name == "z":
566 at = [round(point[0], 4), round(point[1], 4)]
567 elif axis_name == "x":
568 at = [round(point[1], 4), round(point[2], 4)]
569 elif axis_name == "y":
570 at = [round(point[0], 4), round(point[2], 4)]
571 else:
572 at = [round(c, 4) for c in point]
573 rows.append({
574 "radius_mm": round(radius, 4),
575 "diameter_mm": round(2 * radius, 4),
576 "axis": axis_name or [round(c, 4) for c in d],
577 "at_mm": at,
578 "extent_mm": round(extent, 4),
579 "span_min_mm": round(min(proj), 4),
580 "span_max_mm": round(max(proj), 4),
581 "sweep_deg": round(sweep, 1),
582 "span_basis": "cylindrical UV bounds; irregular trims may not fill the span",
583 "full": sweep >= 355.0,
584 })
585 rows.sort(key=lambda r: (str(r["axis"]), r["at_mm"], r["radius_mm"]))
586 return rows
589def load_shape(path: Path):
590 """Load a STEP or STL file, or build a model, into a build123d shape."""
591 path = Path(path)
592 if not path.exists():
593 raise LabCadError(f"file not found: {path}")
595 suffix = path.suffix.lower()
596 if suffix == ".py":
597 part, _ = run_model(path)
598 return part
600 build123d = require_build123d()
601 if suffix in BREP_FORMATS:
602 return build123d.import_step(str(path))
603 if suffix in MESH_FORMATS:
604 eprint(
605 f"warning: {path.name} imports as a triangulated Face, not a BREP solid. "
606 "and STEP is the authoritative format. Prefer inspecting the STEP."
607 )
608 return build123d.import_stl(str(path))
609 raise LabCadError(
610 f"unsupported input {suffix!r}. Expected .step, .stp, .stl, or a *_model.py file."
611 )
614def _is_valid(shape) -> bool:
615 """``Shape.is_valid`` is a property in build123d 0.13.0; older builds expose a method."""
616 value = shape.is_valid
617 return bool(value() if callable(value) else value)
620def shape_facts(shape) -> dict:
621 """Deterministic geometric facts about a shape, in millimetres."""
622 build123d = require_build123d()
623 bbox = shape.bounding_box()
625 try:
626 centre = shape.center(build123d.CenterOf.MASS)
627 centre_of = "mass"
628 except (ValueError, NotImplementedError):
629 centre = bbox.center()
630 centre_of = "bounding_box"
632 try:
633 solids = len(shape.solids())
634 except (AttributeError, TypeError):
635 solids = None
637 return {
638 "is_valid": _is_valid(shape),
639 "bounding_box_mm": {
640 "x": round(bbox.size.X, 4),
641 "y": round(bbox.size.Y, 4),
642 "z": round(bbox.size.Z, 4),
643 "min": [round(bbox.min.X, 4), round(bbox.min.Y, 4), round(bbox.min.Z, 4)],
644 "max": [round(bbox.max.X, 4), round(bbox.max.Y, 4), round(bbox.max.Z, 4)],
645 },
646 "volume_mm3": round(float(shape.volume), 4),
647 "area_mm2": round(float(shape.area), 4),
648 "center_mm": [round(centre.X, 4), round(centre.Y, 4), round(centre.Z, 4)],
649 "center_of": centre_of,
650 "solid_count": solids,
651 }
654def measure(facts: dict, name: str, swap_xy: bool = False) -> float:
655 """Resolve a fit-check measure name against a facts dict."""
656 box = facts["bounding_box_mm"]
657 x, y = (box["y"], box["x"]) if swap_xy else (box["x"], box["y"])
658 extents = sorted((x, y, box["z"]))
659 table = {
660 "bbox_x": x,
661 "bbox_y": y,
662 "bbox_z": box["z"],
663 "bbox_min": extents[0],
664 "bbox_mid": extents[1],
665 "bbox_max": extents[2],
666 }
667 if name not in table:
668 raise LabCadError(
669 f"unknown measure {name!r}. Expected one of: {', '.join(sorted(table))}"
670 )
671 return table[name]
674def main_guard(func) -> None:
675 """Run a CLI entry point, converting LabCadError into a clean exit."""
676 try:
677 sys.exit(func())
678 except LabCadError as exc:
679 eprint(f"error: {exc}")
680 sys.exit(2)
681 except KeyboardInterrupt: # pragma: no cover
682 eprint("interrupted")
683 sys.exit(130)