Skill Tracer
Skills › Scientific Agent Skills › lab-hardware-cad › check.py

check.py

668 lines · skills/lab-hardware-cad/scripts/check.py at 5f2bc03

1#!/usr/bin/env python3
2"""Deterministic checks on lab-hardware geometry.
3
4 python scripts/check.py standards --list
5 python scripts/check.py standards --show slas-microplate-footprint
6 python scripts/check.py facts out/carrier.step
7 python scripts/check.py interfaces out/carrier.manifest.json
8 python scripts/check.py geometry out/carrier.step --model carrier_model.py
9 python scripts/check.py probe out/carrier.step --cyl 6.6 --at 37.5,37.5 --at=-37.5,37.5
10 python scripts/check.py bores out/carrier.step
11 python scripts/check.py fit --standard slas-microplate-footprint \
12 --intent envelope --clearance 0.8 --value footprint_length=129.06
13 python scripts/check.py clearance out/carrier.step out/lid.step --min 0.3
15``standards`` and ``interfaces`` on a manifest run on the standard library alone. The
16other subcommands need build123d. Checking subcommands exit non-zero on failure so they
17can gate a build.
18"""
20from __future__ import annotations
22import argparse
23import json
24import sys
25from pathlib import Path
27sys.path.insert(0, str(Path(__file__).resolve().parent))
29from _common import ( # noqa: E402
30 LabCadError,
31 cylinder_census,
32 emit,
33 eprint,
34 evaluate_checks,
35 finite_number,
36 format_check_result,
37 get_standard,
38 import_model,
39 intersection_volume,
40 load_shape,
41 load_standards,
42 main_guard,
43 measure,
44 model_checks,
45 model_interfaces,
46 normalise_checks,
47 normalise_interfaces,
48 parse_params,
49 shape_facts,
53def cmd_standards(args) -> int:
54 data = load_standards()
55 standards = data["standards"]
57 if args.show:
58 entry = get_standard(args.show)
59 lines = [
60 f"{args.show}: {entry['title']}",
61 f" authority: {entry['authority']}",
62 f" document: {entry['document']}",
63 f" url: {entry.get('url', '-')}",
64 f" verified: {entry.get('verified')}",
65 " dimensions (mm unless explicitly labelled):",
66 ]
67 for name, dim in entry["dimensions"].items():
68 band = f"+{dim.get('tol_plus', 0)}/-{dim.get('tol_minus', 0)}"
69 lines.append(f" {name}: {dim['nominal']} {band} {dim.get('unit', 'mm')}")
70 if dim.get("note"):
71 lines.append(f" note: {dim['note']}")
72 if entry.get("design_note"):
73 lines.append(f" design note: {entry['design_note']}")
74 if not entry.get("verified", False):
75 lines.append(" WARNING: this entry is not verified against the primary document.")
76 emit(entry, args.as_json, "\n".join(lines))
77 return 0
79 listing = [
80 {
81 "id": key,
82 "title": value["title"],
83 "document": value["document"],
84 "verified": value.get("verified", False),
85 }
86 for key, value in sorted(standards.items())
87 ]
88 text = "\n".join(
89 f"{item['id']:<32} {'ok ' if item['verified'] else 'UNVERIFIED'} {item['title']}"
90 for item in listing
91 )
92 emit(listing, args.as_json, text)
93 return 0
96def cmd_facts(args) -> int:
97 shape = load_shape(args.target)
98 facts = shape_facts(shape)
99 box = facts["bounding_box_mm"]
100 text = "\n".join([
101 f"target: {args.target}",
102 f"is_valid: {facts['is_valid']}",
103 f"bbox (mm): {box['x']:.4f} x {box['y']:.4f} x {box['z']:.4f}",
104 f"bbox min: {box['min']}",
105 f"bbox max: {box['max']}",
106 f"volume: {facts['volume_mm3']:.4f} mm^3",
107 f"area: {facts['area_mm2']:.4f} mm^2",
108 f"centre ({facts['center_of']}): {facts['center_mm']}",
109 f"solids: {facts['solid_count']}",
110 ])
111 emit(facts, args.as_json, text)
112 return 0 if facts["is_valid"] else 1
115def _evaluate(
116 entry, dimension: str, actual: float, offset: float, measure_label: str, intent: str
117) -> dict:
118 """Compare one declared dimension against a standard.
120 Two intents, because they are different questions:
122 ``match`` - this part must itself conform to the standard. Symmetric band
123 around nominal, widened (never shifted) by ``offset``.
124 ``envelope`` - this feature must accept ANY conforming part (a pocket, bore,
125 or slot). One-sided minimum at maximum material condition plus
126 the clearance. Nominal sizing does not guarantee clearance for
127 all conforming parts.
129 ``offset`` must be non-negative: a negative clearance would let a declaration
130 move its own acceptance band and certify a nonconforming value.
131 """
132 if dimension not in entry["dimensions"]:
133 known = ", ".join(sorted(entry["dimensions"]))
134 raise LabCadError(f"unknown dimension {dimension!r}. Available: {known}")
135 actual = finite_number(actual, dimension)
136 offset = finite_number(offset, "clearance")
137 if offset < 0:
138 raise LabCadError(
139 f"{dimension}: clearance must be >= 0, got {offset}. A clearance widens the "
140 "acceptance band; it cannot shift it. If the feature is deliberately "
141 "undersized, say so in the report instead of encoding it as a negative "
142 "clearance."
143 )
144 dim = entry["dimensions"][dimension]
145 if dim.get("unit", "mm") != "mm":
146 raise LabCadError(f"{dimension} is in {dim['unit']}, not mm; inspect it with standards --show")
147 nominal = float(dim["nominal"])
148 tol_plus = float(dim.get("tol_plus", 0.0))
149 tol_minus = float(dim.get("tol_minus", 0.0))
151 if intent == "envelope":
152 low = nominal + tol_plus + offset
153 high = None
154 passed = actual >= low - 1e-9
155 headroom = round(actual - low, 4)
156 else:
157 low = nominal - tol_minus - offset
158 high = nominal + tol_plus + offset
159 passed = low - 1e-9 <= actual <= high + 1e-9
160 headroom = None
162 return {
163 "dimension": dimension,
164 "measure": measure_label,
165 "intent": intent,
166 "nominal_mm": nominal,
167 "max_material_mm": round(nominal + tol_plus, 4),
168 "expected_range_mm": [round(low, 4), None if high is None else round(high, 4)],
169 "actual_mm": round(actual, 4),
170 "headroom_mm": headroom,
171 "verified_source": dim.get("verified", entry.get("verified", False)),
172 "pass": passed,
173 }
176def cmd_fit(args) -> int:
177 entry = get_standard(args.standard)
178 offset = float(args.clearance)
179 results = []
181 if args.value:
182 # Value mode: check dimensions the model computed. Needed whenever the
183 # interface is an internal feature (a pocket, a bore, a slot), where the
184 # part's outer bounding box is not the dimension that has to match.
185 if args.target is not None:
186 eprint(
187 f"warning: --value was given, so {args.target} is not measured. Drop the "
188 "target, or drop --value to check the outer bounding box."
189 )
190 for pair in args.value:
191 if "=" not in pair:
192 raise LabCadError(f"--value expects dimension=number, got {pair!r}")
193 name, _, raw = pair.partition("=")
194 try:
195 actual = float(raw)
196 except ValueError as exc:
197 raise LabCadError(f"--value {pair!r}: {raw!r} is not a number") from exc
198 results.append(
199 _evaluate(entry, name.strip(), actual, offset, "declared", args.intent)
200 )
201 else:
202 checks = entry.get("fit_checks", [])
203 if not checks:
204 raise LabCadError(
205 f"{args.standard} defines no automatic bounding-box checks (it is a "
206 "reference dimension set). Use --value to check a computed dimension, "
207 "or `standards --show` and check the interface by hand."
208 )
209 if args.target is None:
210 raise LabCadError("fit needs either a target file or one or more --value arguments")
211 facts = shape_facts(load_shape(args.target))
212 for check in checks:
213 actual = measure(facts, check["measure"], swap_xy=args.swap_xy)
214 results.append(
215 _evaluate(
216 entry, check["dimension"], actual, offset, check["measure"], args.intent
217 )
218 )
220 passed = all(item["pass"] for item in results)
221 payload = {
222 "standard": args.standard,
223 "title": entry["title"],
224 "document": entry["document"],
225 "verified_source": all(item["verified_source"] for item in results),
226 "clearance_applied_mm": offset,
227 "mode": "declared" if args.value else "bounding_box",
228 "swap_xy": args.swap_xy,
229 "checks": results,
230 "pass": passed,
231 }
233 lines = [f"{args.standard} ({entry['document']}) intent={args.intent}"]
234 for item in results:
235 mark = "PASS" if item["pass"] else "FAIL"
236 low, high = item["expected_range_mm"]
237 if high is None:
238 expected = f">= {low:.3f} headroom {item['headroom_mm']:+.3f}"
239 else:
240 expected = f"{low:.3f}..{high:.3f}"
241 lines.append(
242 f" [{mark}] {item['dimension']:<22} {item['measure']:<9} "
243 f"actual {item['actual_mm']:>9.3f} expected {expected}"
244 )
245 if not payload["verified_source"]:
246 lines.append(" WARNING: standard entry is not verified against the primary document.")
247 if not passed and not args.value:
248 if not args.swap_xy:
249 lines.append(" hint: if the part is modelled rotated 90 degrees, rerun with --swap-xy")
250 lines.append(
251 " hint: bounding-box mode measures the OUTER envelope. If the interface is a "
252 "pocket, bore, or slot, pass the computed dimension with --value instead."
253 )
254 lines.append("Reminder: a passing bounding box is not a passing part. Run snapshot.py.")
255 emit(payload, args.as_json, "\n".join(lines))
256 return 0 if passed else 1
259def _declared_interfaces(target: Path) -> tuple[list[dict], str]:
260 """Read a model's declared interfaces from a manifest or from the model itself."""
261 suffix = target.suffix.lower()
262 if suffix == ".json":
263 if not target.exists():
264 raise LabCadError(f"file not found: {target}")
265 try:
266 payload = json.loads(target.read_text(encoding="utf-8"))
267 except json.JSONDecodeError as exc:
268 raise LabCadError(f"{target} is not valid JSON: {exc}") from exc
269 if not isinstance(payload, dict) or "interfaces" not in payload:
270 raise LabCadError(
271 "manifest must be a JSON object with an interfaces list; regenerate it "
272 "with gen.py after declaring interfaces() or INTERFACES"
273 )
274 declared = payload["interfaces"]
275 if not isinstance(declared, list):
276 raise LabCadError(
277 "manifest interfaces must be a list; use [] only for an explicitly "
278 "empty declaration"
279 )
280 return normalise_interfaces(declared), "manifest"
281 if suffix == ".py":
282 return model_interfaces(import_model(target)), "model"
283 raise LabCadError(
284 f"unsupported input {suffix!r}. Pass a *.manifest.json written by gen.py, or a "
285 "*_model.py."
286 )
289def cmd_interfaces(args) -> int:
290 """Check every interface a model declares about itself.
292 This verifies the DECLARED numbers against the standards database: it catches
293 a transcribed dimension, the wrong standard, and nominal-instead-of-MMC
294 sizing. It does not measure the built geometry -- ``facts`` and the snapshot
295 do that -- so a passing result here is necessary, not sufficient.
297 A model whose part mates with nothing in the bundled database correctly
298 declares no interfaces; that is a passing state, not an error. Every such
299 unchecked dimension must then be named in the report.
300 """
301 declared, source = _declared_interfaces(args.target)
303 if not declared:
304 payload = {"target": str(args.target), "source": source, "checks": [], "pass": True}
305 emit(payload, args.as_json, (
306 f"{args.target.name}: 0 declared interfaces - nothing in this part mates "
307 "with a bundled standard.\n"
308 "That is fine IF it is true. Do not invent a declaration to fill the gap; "
309 "instead name every interface dimension and its source (user spec, vendor "
310 "drawing, measurement) as UNCHECKED in the report."
311 ))
312 return 0
314 results = []
315 for entry in declared:
316 standard = get_standard(entry["standard"])
317 result = _evaluate(
318 standard,
319 entry["dimension"],
320 entry["value"],
321 entry["clearance"],
322 "declared",
323 entry["intent"],
324 )
325 result["feature"] = entry["feature"]
326 result["standard"] = entry["standard"]
327 result["document"] = standard["document"]
328 result["clearance_applied_mm"] = entry["clearance"]
329 results.append(result)
331 passed = all(item["pass"] for item in results)
332 payload = {
333 "target": str(args.target),
334 "source": source,
335 "checks": results,
336 "pass": passed,
337 }
339 lines = [f"{args.target.name}: {len(results)} declared interface(s) from the {source}"]
340 for item in results:
341 mark = "PASS" if item["pass"] else "FAIL"
342 low, high = item["expected_range_mm"]
343 if high is None:
344 expected = f">= {low:.3f} headroom {item['headroom_mm']:+.3f}"
345 else:
346 expected = f"{low:.3f}..{high:.3f}"
347 lines.append(
348 f" [{mark}] {item['feature']:<26} {item['actual_mm']:>9.3f} mm "
349 f"expected {expected}"
350 )
351 lines.append(
352 f" {item['standard']} {item['dimension']} "
353 f"({item['intent']}, clearance {item['clearance_applied_mm']} mm)"
354 )
355 if not item["verified_source"]:
356 lines.append(" WARNING: standard entry is not verified against the document.")
357 lines.append(
358 "Note: this checks the values the model DECLARED, not the built geometry. "
359 "A declaration computed from the same constants it is checked against will "
360 "pass with zero headroom by construction. Run check.py facts and snapshot.py "
361 "on the exported STEP to verify the geometry itself."
362 )
363 emit(payload, args.as_json, "\n".join(lines))
364 return 0 if passed else 1
367def cmd_geometry(args) -> int:
368 """Evaluate a model's declared geometry checks against the built solid.
370 Unlike ``interfaces``, which compares declared numbers against the standards
371 database, this measures the geometry itself: material really is absent from
372 every declared clear region, present in every material region, and the
373 bounding box sits inside its declared bounds.
374 """
375 target = args.target
376 if target.suffix.lower() == ".py":
377 module = import_model(target, parse_params(args.param))
378 declared = model_checks(module)
379 builder = getattr(module, "build", None)
380 if not callable(builder):
381 raise LabCadError("model must expose build()")
382 part = builder()
383 geometry_source = target.name
384 else:
385 if args.model is None:
386 raise LabCadError(
387 "checking a STEP needs the model that declares the checks: "
388 "check.py geometry out/part.step --model part_model.py"
389 )
390 declared = model_checks(import_model(args.model, parse_params(args.param)))
391 part = load_shape(target)
392 geometry_source = target.name
394 if not declared:
395 emit({"target": str(target), "checks": [], "pass": True}, args.as_json, (
396 f"{target.name}: no declared geometry checks.\n"
397 "Declare a checks() function for every geometric requirement in the "
398 "request - clearance holes, keep-out corridors, a gauge part that must "
399 "drop into a pocket, a feature that must stand proud, a size limit. "
400 "See references/build123d-patterns.md."
401 ))
402 return 0
404 results = evaluate_checks(part, declared)
405 passed = all(item["pass"] for item in results)
406 payload = {"target": str(target), "checks": results, "pass": passed}
408 lines = [f"{geometry_source}: {len(results)} geometry check(s), measured from the solid"]
409 for item in results:
410 lines.extend(format_check_result(item))
411 if not passed:
412 lines.append("Fix the model source and regenerate; never patch the STEP.")
413 emit(payload, args.as_json, "\n".join(lines))
414 return 0 if passed else 1
417def cmd_probe(args) -> int:
418 """One ad-hoc region probe against a solid, without editing the model."""
419 if (args.cyl is None) == (args.box is None):
420 raise LabCadError("pass exactly one of --cyl DIA or --box DX,DY,DZ")
422 region: dict = {}
423 if args.cyl is not None:
424 region["cylinder"] = args.cyl
425 region["axis"] = args.axis
426 if args.span:
427 region["span"] = _parse_floats(args.span, 2, "--span")
428 region["at"] = [_parse_floats(a, 2, "--at") for a in (args.at or ["0,0"])]
429 else:
430 region["box"] = _parse_floats(args.box, 3, "--box")
431 region["at"] = [_parse_floats(a, 3, "--at") for a in (args.at or ["0,0,0"])]
433 entry = {"feature": args.feature or f"probe ({args.expect})", args.expect: region}
434 if args.expect == "material" and args.min_mm3 is not None:
435 entry["min_mm3"] = args.min_mm3
436 if args.expect == "clear" and args.tol_mm3 is not None:
437 entry["tol_mm3"] = args.tol_mm3
439 part = load_shape(args.target)
440 results = evaluate_checks(part, normalise_checks([entry]))
441 payload = {"target": str(args.target), "checks": results, "pass": results[0]["pass"]}
442 emit(payload, args.as_json, "\n".join(
443 [f"{args.target.name}: probe"] + format_check_result(results[0])
444 ))
445 return 0 if results[0]["pass"] else 1
448def _parse_floats(raw: str, count: int, flag: str) -> list[float]:
449 parts = [p for p in raw.replace(" ", "").split(",") if p]
450 if len(parts) != count:
451 raise LabCadError(f"{flag} expects {count} comma-separated numbers, got {raw!r}")
452 try:
453 return [float(p) for p in parts]
454 except ValueError as exc:
455 raise LabCadError(f"{flag}: {raw!r} is not numeric") from exc
458def cmd_bores(args) -> int:
459 """List every cylindrical face: the census for reconciling render vs solid."""
460 part = load_shape(args.target)
461 rows = cylinder_census(part)
462 payload = {"target": str(args.target), "cylindrical_faces": rows}
464 if not rows:
465 emit(payload, args.as_json, f"{args.target.name}: no cylindrical faces.")
466 return 0
467 lines = [
468 f"{args.target.name}: {len(rows)} cylindrical face(s). Full ~360 degree sweeps "
469 "are bores/bosses; ~90 degree sweeps are edge fillets.",
470 ]
471 for r in rows:
472 axis = r["axis"] if isinstance(r["axis"], str) else str(r["axis"])
473 at = ", ".join(f"{v:g}" for v in r["at_mm"])
474 kind = "full" if r["full"] else f"{r['sweep_deg']:g} deg"
475 lines.append(
476 f" d {r['diameter_mm']:>8.3f} axis {axis:<12} at ({at})"
477 f" span {r['span_min_mm']:g}..{r['span_max_mm']:g} {kind}"
478 )
479 lines.append(
480 "Reconcile this against the model's intent before trusting a render: a missing "
481 "diameter or an unexpected span here is a real feature error, whatever the "
482 "picture appears to show."
483 )
484 emit(payload, args.as_json, "\n".join(lines))
485 return 0
488def _min_distance(shape_a, shape_b) -> float | None:
489 for method in ("distance_to", "distance"):
490 func = getattr(shape_a, method, None)
491 if callable(func):
492 try:
493 return float(func(shape_b))
494 except (TypeError, ValueError):
495 continue
496 func = getattr(shape_a, "distance_to_with_closest_points", None)
497 if callable(func):
498 try:
499 return float(func(shape_b)[0])
500 except (TypeError, ValueError, IndexError):
501 return None
502 return None
505def cmd_clearance(args) -> int:
506 args.min = finite_number(args.min, "minimum clearance")
507 if args.min < 0:
508 raise LabCadError("minimum clearance must be >= 0")
509 shape_a = load_shape(args.a)
510 shape_b = load_shape(args.b)
512 # Distance alone does not establish absence of volume interference. A failed
513 # boolean is inconclusive and must not become a successful fabrication gate.
514 overlap_volume = intersection_volume(shape_a, shape_b)
516 interferes = overlap_volume > 1e-6
517 gap = None if interferes else _min_distance(shape_a, shape_b)
519 payload = {
520 "a": str(args.a),
521 "b": str(args.b),
522 "interference": interferes,
523 "overlap_volume_mm3": round(overlap_volume, 6),
524 "min_distance_mm": None if gap is None else round(gap, 4),
525 "required_min_mm": args.min,
526 }
528 if interferes:
529 payload["pass"] = False
530 text = (
531 f"INTERFERENCE: the two solids overlap by {overlap_volume:.4f} mm^3.\n"
532 "Parts cannot be assembled as modelled."
533 )
534 elif gap is None:
535 payload["pass"] = None
536 text = (
537 "Could not compute a minimum distance with this build123d build, and the "
538 "solids do not overlap. Verify the fit visually with snapshot.py."
539 )
540 else:
541 payload["pass"] = gap >= args.min
542 mark = "PASS" if payload["pass"] else "FAIL"
543 text = (
544 f"[{mark}] minimum gap {gap:.4f} mm (required >= {args.min} mm)\n"
545 f" overlap volume {overlap_volume:.6f} mm^3"
546 )
548 emit(payload, args.as_json, text)
549 if payload["pass"] is None:
550 return 2
551 return 0 if payload["pass"] else 1
554def main() -> int:
555 parser = argparse.ArgumentParser(
556 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
557 )
558 parser.add_argument("--json", action="store_true", dest="as_json",
559 help="emit machine-readable JSON on stdout")
560 sub = parser.add_subparsers(dest="command", required=True)
562 p_std = sub.add_parser("standards", help="browse the bundled standards database")
563 group = p_std.add_mutually_exclusive_group()
564 group.add_argument("--list", action="store_true", help="list every standard (default)")
565 group.add_argument("--show", metavar="ID", help="show one standard in full")
566 p_std.set_defaults(func=cmd_standards)
568 p_facts = sub.add_parser("facts", help="validity, bounding box, volume, area, centre")
569 p_facts.add_argument("target", type=Path, help="STEP, STL, or *_model.py")
570 p_facts.set_defaults(func=cmd_facts)
572 p_int = sub.add_parser(
573 "interfaces",
574 help="check every interface a model declares about itself (the build gate)",
575 description="Check each entry of a model's INTERFACES list against its standard. "
576 "Use this rather than `fit` whenever the interface is an internal "
577 "feature -- a pocket, bore, or slot -- which is most of the time. "
578 "Reading a manifest needs no geometry kernel.",
579 )
580 p_int.add_argument("target", type=Path,
581 help="a *.manifest.json written by gen.py, or a *_model.py")
582 p_int.set_defaults(func=cmd_interfaces)
584 p_geo = sub.add_parser(
585 "geometry",
586 help="evaluate the model's declared geometry checks against the built solid",
587 description="Run every checks() entry -- clear regions, material regions, bbox "
588 "bounds -- as boolean gauges against the actual geometry. This is "
589 "the measured counterpart to `interfaces`, which only compares "
590 "declared numbers.",
591 )
592 p_geo.add_argument("target", type=Path, help="a *_model.py, or a STEP with --model")
593 p_geo.add_argument("--model", type=Path, default=None,
594 help="the *_model.py declaring checks(), when target is a STEP")
595 p_geo.add_argument("--param", action="append", metavar="KEY=VALUE",
596 help="repeat the overrides used by gen.py when evaluating model gauges")
597 p_geo.set_defaults(func=cmd_geometry)
599 p_probe = sub.add_parser(
600 "probe",
601 help="ad-hoc region gauge: is this cylinder/box clear of (or filled with) material?",
602 )
603 p_probe.add_argument("target", type=Path, help="STEP, STL, or *_model.py")
604 p_probe.add_argument("--cyl", type=float, metavar="DIA",
605 help="cylindrical gauge of this diameter in mm")
606 p_probe.add_argument("--box", metavar="DX,DY,DZ", help="box gauge, size in mm")
607 p_probe.add_argument("--axis", choices=("x", "y", "z"), default="z",
608 help="cylinder axis (default: z); runs through the part unless "
609 "--span is given")
610 p_probe.add_argument("--at", action="append", metavar="A,B[,C]",
611 help="position, repeatable. Cylinder: 2D in the plane "
612 "perpendicular to the axis (axis z: x,y; axis x: y,z; "
613 "axis y: x,z). Box: 3D centre x,y,z.")
614 p_probe.add_argument("--span", metavar="A,B",
615 help="cylinder extent along its axis (default: through the part)")
616 p_probe.add_argument("--expect", choices=("clear", "material"), default="clear",
617 help="'clear': no material in the region (default); "
618 "'material': the region must contain material")
619 p_probe.add_argument("--tol-mm3", type=float, default=None,
620 help="max intruding volume per position for 'clear' (default 0.01)")
621 p_probe.add_argument("--min-mm3", type=float, default=None,
622 help="min material volume per position for 'material' (default 0.01)")
623 p_probe.add_argument("--feature", help="label for the output")
624 p_probe.set_defaults(func=cmd_probe)
626 p_bores = sub.add_parser(
627 "bores",
628 help="census of every cylindrical face: diameter, axis, span, sweep",
629 description="The reconciliation instrument for step 6: compare what the render "
630 "appears to show against what the solid actually contains.",
631 )
632 p_bores.add_argument("target", type=Path, help="STEP, STL, or *_model.py")
633 p_bores.set_defaults(func=cmd_bores)
635 p_fit = sub.add_parser("fit", help="check one dimension against a standard by hand")
636 p_fit.add_argument("target", type=Path, nargs="?",
637 help="STEP, STL, or *_model.py; omit when using --value")
638 p_fit.add_argument("--standard", required=True, help="standard ID from `standards --list`")
639 p_fit.add_argument("--value", action="append", metavar="DIMENSION=MM",
640 help="check a dimension the model computed, e.g. "
641 "footprint_length=129.06. Use this when the interface is an "
642 "internal feature. Repeatable; needs no geometry kernel.")
643 p_fit.add_argument("--intent", choices=("match", "envelope"), default="match",
644 help="'match': this part must itself conform to the standard "
645 "(symmetric band). 'envelope': this feature must accept any "
646 "conforming part, so it is checked one-sided against maximum "
647 "material condition. Use 'envelope' for pockets, bores, and "
648 "slots. (default: match)")
649 p_fit.add_argument("--clearance", type=float, default=0.0,
650 help="total intended clearance in mm, e.g. 0.8 for a pocket with "
651 "0.4 mm clearance per side (default: 0)")
652 p_fit.add_argument("--swap-xy", action="store_true",
653 help="the part is modelled with x and y exchanged")
654 p_fit.set_defaults(func=cmd_fit)
656 p_clr = sub.add_parser("clearance", help="minimum distance between two solids")
657 p_clr.add_argument("a", type=Path)
658 p_clr.add_argument("b", type=Path)
659 p_clr.add_argument("--min", type=float, default=0.2,
660 help="required minimum gap in mm (default: 0.2)")
661 p_clr.set_defaults(func=cmd_clearance)
663 args = parser.parse_args()
664 return args.func(args)
667if __name__ == "__main__":
668 main_guard(main)