@forgent3d/cad-runtime 0.1.0

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.
@@ -0,0 +1,989 @@
1
+ """aicad_select — robust selection & operation helpers for build123d.
2
+
3
+ Ships with the Forgent3D bundled runtime. Generated `part.py` files can import
4
+ any helper from this module without further setup:
5
+
6
+ from aicad_select import top_edges, safe_fillet, safe_add
7
+
8
+ Design notes
9
+ ------------
10
+ - All helpers operate on finalized geometry (the `Part` you get after a
11
+ `with BuildPart() as bp: ...` block, i.e. `bp.part`). They do not need to be
12
+ called inside an active builder context.
13
+ - Returns are build123d-native types (`ShapeList[Edge]`, `Face`, `Part`) so
14
+ results compose with `+`, `-`, and the standard `fillet`, `chamfer`,
15
+ `sweep`, `loft` ops.
16
+ - Tolerances default to 1e-3 mm. Override `tol` for tiny features.
17
+ - These helpers raise `SelectionError` (subclass of ValueError) with a
18
+ human-readable hint when the selection is ambiguous or empty.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from dataclasses import dataclass
25
+ from typing import Iterable, Sequence
26
+
27
+ from build123d import (
28
+ Axis,
29
+ Compound,
30
+ Edge,
31
+ Face,
32
+ GeomType,
33
+ Part,
34
+ Plane,
35
+ ShapeList,
36
+ Vector,
37
+ Wire,
38
+ chamfer,
39
+ fillet,
40
+ loft,
41
+ sweep,
42
+ )
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Errors #
47
+ # --------------------------------------------------------------------------- #
48
+
49
+
50
+ class SelectionError(ValueError):
51
+ """Raised when a selector returns nothing or an ambiguous result.
52
+
53
+ The message always names the selector, the part bbox, and a one-line hint
54
+ so the agent can self-correct without re-reading this module.
55
+ """
56
+
57
+
58
+ def _require_nonempty(label: str, items: ShapeList, hint: str, part=None) -> ShapeList:
59
+ if len(items) == 0:
60
+ msg = f"{label} returned 0 results."
61
+ if part is not None:
62
+ bbox = _as_part(part).bounding_box()
63
+ msg += f" Part BBox: X[{bbox.min.X:.2f}, {bbox.max.X:.2f}] Y[{bbox.min.Y:.2f}, {bbox.max.Y:.2f}] Z[{bbox.min.Z:.2f}, {bbox.max.Z:.2f}]."
64
+ msg += f" Hint: {hint}"
65
+ raise SelectionError(msg)
66
+ return items
67
+
68
+
69
+ def _as_part(obj) -> Part | Compound:
70
+ if hasattr(obj, "part") and not isinstance(obj, (Part, Compound)):
71
+ return obj.part # BuildPart context
72
+ return obj
73
+
74
+
75
+ # --------------------------------------------------------------------------- #
76
+ # Axis helpers #
77
+ # --------------------------------------------------------------------------- #
78
+
79
+
80
+ def _axis_index(axis) -> str:
81
+ """Map an Axis (Axis.X / Axis.Y / Axis.Z, or any unit-direction-bearing object)
82
+ to the attribute name 'X' / 'Y' / 'Z' used for point access and position filters.
83
+ Raises SelectionError when the axis is not aligned with a principal direction.
84
+ """
85
+ if axis is Axis.X:
86
+ return "X"
87
+ if axis is Axis.Y:
88
+ return "Y"
89
+ if axis is Axis.Z:
90
+ return "Z"
91
+ direction = getattr(axis, "direction", None)
92
+ if direction is None and isinstance(axis, (tuple, list)) and len(axis) == 3:
93
+ direction = Vector(*axis)
94
+ if direction is None:
95
+ raise SelectionError(f"_axis_index(): cannot interpret axis {axis!r}")
96
+ d = direction.normalized() if hasattr(direction, "normalized") else Vector(direction).normalized()
97
+ best = max((("X", abs(d.X)), ("Y", abs(d.Y)), ("Z", abs(d.Z))), key=lambda kv: kv[1])
98
+ if best[1] < 0.999:
99
+ raise SelectionError(
100
+ f"_axis_index(): axis is not aligned with a principal direction "
101
+ f"(X/Y/Z components: {d.X:.3f}, {d.Y:.3f}, {d.Z:.3f}). "
102
+ "Pass Axis.X, Axis.Y, or Axis.Z."
103
+ )
104
+ return best[0]
105
+
106
+
107
+ def _axis_label(axis) -> str:
108
+ """Short, human-readable label for an axis in error messages.
109
+ Returns 'X' / 'Y' / 'Z' for principal axes, else a compact direction triple.
110
+ """
111
+ if axis is Axis.X:
112
+ return "X"
113
+ if axis is Axis.Y:
114
+ return "Y"
115
+ if axis is Axis.Z:
116
+ return "Z"
117
+ try:
118
+ d = _axis_direction(axis)
119
+ return f"({d.X:.3f}, {d.Y:.3f}, {d.Z:.3f})"
120
+ except Exception:
121
+ return repr(axis)
122
+
123
+
124
+ def _axis_direction(axis) -> Vector:
125
+ """Unit Vector along the given axis (Axis or 3-tuple)."""
126
+ if hasattr(axis, "direction"):
127
+ d = axis.direction
128
+ return d.normalized() if hasattr(d, "normalized") else Vector(d).normalized()
129
+ if isinstance(axis, (tuple, list)) and len(axis) == 3:
130
+ return Vector(*axis).normalized()
131
+ raise SelectionError(f"_axis_direction(): cannot interpret axis {axis!r}")
132
+
133
+
134
+ def _normalize_direction(value, label: str) -> str:
135
+ """Coerce a direction argument to the canonical 'max' or 'min'.
136
+
137
+ Accepts: 'max' / 'min' / 'top' / 'bottom' / '+' / '-' (strings),
138
+ a positive or negative number (1 / -1 / 1.0 / -1.0).
139
+ """
140
+ if isinstance(value, str):
141
+ v = value.strip().lower()
142
+ if v in ("max", "top", "+", "high", "highest", "up"):
143
+ return "max"
144
+ if v in ("min", "bottom", "-", "low", "lowest", "down"):
145
+ return "min"
146
+ raise SelectionError(
147
+ f"{label}: direction must be 'max'/'min' (or 'top'/'bottom', or a signed number); got {value!r}"
148
+ )
149
+ if isinstance(value, bool):
150
+ return "max" if value else "min"
151
+ if isinstance(value, (int, float)):
152
+ if value > 0:
153
+ return "max"
154
+ if value < 0:
155
+ return "min"
156
+ raise SelectionError(f"{label}: direction must be non-zero; got 0")
157
+ raise SelectionError(
158
+ f"{label}: direction must be 'max'/'min' or a signed number; got {value!r} ({type(value).__name__})"
159
+ )
160
+
161
+
162
+ # --------------------------------------------------------------------------- #
163
+ # Edge selection #
164
+ # --------------------------------------------------------------------------- #
165
+
166
+
167
+ def edges_at(part, axis: Axis, value: float, tol: float = 1e-3) -> ShapeList[Edge]:
168
+ """Edges whose entire span lies at `value` along `axis` (± tol).
169
+
170
+ Generalization of `edges_at_z` to any principal axis. `axis` is `Axis.X`,
171
+ `Axis.Y`, or `Axis.Z`. This is position-based: it does NOT select edges
172
+ whose *direction* is parallel to `axis` — use `edges_parallel_to(part, axis)`
173
+ for that.
174
+ """
175
+ p = _as_part(part)
176
+ _axis_index(axis) # validate
177
+ result = p.edges().filter_by_position(axis, value - tol, value + tol)
178
+ al = _axis_label(axis)
179
+ return _require_nonempty(
180
+ f"edges_at({al}, {value})",
181
+ result,
182
+ f"no edges within ±{tol} of {value} along {al}; check the face position",
183
+ part=p,
184
+ )
185
+
186
+
187
+ def extreme_edges(
188
+ part,
189
+ axis: Axis,
190
+ direction="max",
191
+ tol: float = 1e-3,
192
+ ) -> ShapeList[Edge]:
193
+ """Edges whose centers sit at the extreme position along `axis`.
194
+
195
+ `direction` selects which extreme; pass any of `'max'` / `'min'` / `'top'` /
196
+ `'bottom'`, or a signed number (positive → max, negative → min). The call
197
+ `extreme_edges(part, Axis.Z, 1)` therefore means "the topmost edges".
198
+
199
+ Generalization of `top_edges` / `bottom_edges`. Use this for "the rim on the
200
+ +X side of the part" or "every edge along the bottom of a bracket".
201
+ """
202
+ d = _normalize_direction(direction, "extreme_edges")
203
+ p = _as_part(part)
204
+ idx = _axis_index(axis)
205
+ al = _axis_label(axis)
206
+ edges = p.edges()
207
+ if len(edges) == 0:
208
+ raise SelectionError(f"extreme_edges({al}, {d}): part has no edges")
209
+ centers = [getattr(e.center(), idx) for e in edges]
210
+ target = max(centers) if d == "max" else min(centers)
211
+ return _require_nonempty(
212
+ f"extreme_edges({al}, {d})",
213
+ edges.filter_by_position(axis, target - tol, target + tol),
214
+ f"no edges grouped at the {d} of {al}; widen tol",
215
+ part=p,
216
+ )
217
+
218
+
219
+ def edges_at_z(part, z: float, tol: float = 1e-3) -> ShapeList[Edge]:
220
+ """Edges whose entire span lies at height `z` (± tol). Alias for `edges_at(part, Axis.Z, z)`."""
221
+ return edges_at(part, Axis.Z, z, tol=tol)
222
+
223
+
224
+ def top_edges(part, tol: float = 1e-3) -> ShapeList[Edge]:
225
+ """All edges sitting on the topmost horizontal face of `part`. Alias for `extreme_edges(..., Axis.Z, 'max')`."""
226
+ return extreme_edges(part, Axis.Z, direction="max", tol=tol)
227
+
228
+
229
+ def bottom_edges(part, tol: float = 1e-3) -> ShapeList[Edge]:
230
+ """All edges sitting on the bottom-most horizontal face of `part`. Alias for `extreme_edges(..., Axis.Z, 'min')`."""
231
+ return extreme_edges(part, Axis.Z, direction="min", tol=tol)
232
+
233
+
234
+ def vertical_edges(part) -> ShapeList[Edge]:
235
+ """Edges whose direction is parallel to the Z axis."""
236
+ p = _as_part(part)
237
+ result = p.edges().filter_by(Axis.Z)
238
+ return _require_nonempty(
239
+ "vertical_edges()", result, "no edges parallel to Z; the body may be flat or curved", part=p
240
+ )
241
+
242
+
243
+ def edges_parallel_to(part, axis: Axis) -> ShapeList[Edge]:
244
+ """Edges whose direction is parallel to `axis` (Axis.X / Y / Z / custom)."""
245
+ p = _as_part(part)
246
+ result = p.edges().filter_by(axis)
247
+ return _require_nonempty(
248
+ f"edges_parallel_to({axis})", result, "no straight edges aligned with that axis", part=p
249
+ )
250
+
251
+
252
+ def edges_on_face(part, face: Face) -> ShapeList[Edge]:
253
+ """Edges shared with `face` (its boundary)."""
254
+ p = _as_part(part)
255
+ boundary = set(e for e in face.edges())
256
+ result = ShapeList(e for e in p.edges() if e in boundary)
257
+ return _require_nonempty(
258
+ "edges_on_face()", result, "face is not part of the body or has no boundary edges", part=p
259
+ )
260
+
261
+
262
+ def outer_edges_at_z(
263
+ part,
264
+ z: float,
265
+ *,
266
+ tol: float = 1e-3,
267
+ length_ratio: float = 0.5,
268
+ ) -> ShapeList[Edge]:
269
+ """Perimeter edges at height `z`, filtered to drop small feature edges.
270
+
271
+ Use this for fillet/chamfer on the dominant rim of a part that has many
272
+ small repeated features (knurls, fins, ribs, teeth, grips). Keeps edges
273
+ whose length is at least `length_ratio` x the longest edge at this height,
274
+ so OCCT does not pay O(features) cost on tiny edges.
275
+
276
+ Example:
277
+ finished = safe_fillet(part, outer_edges_at_z(part, z=height), radius=r)
278
+ """
279
+ if not (0 < length_ratio <= 1):
280
+ raise SelectionError(
281
+ f"outer_edges_at_z(length_ratio={length_ratio}): must be in (0, 1]."
282
+ )
283
+ edges = edges_at_z(part, z, tol=tol)
284
+ longest = max(e.length for e in edges)
285
+ threshold = longest * length_ratio
286
+ result = ShapeList(e for e in edges if e.length >= threshold)
287
+ return _require_nonempty(
288
+ f"outer_edges_at_z(z={z})",
289
+ result,
290
+ f"no edges at z={z} meet length_ratio={length_ratio}; lower it or use edges_at_z()",
291
+ part=_as_part(part),
292
+ )
293
+
294
+
295
+ def circular_edges(part, radius: float | None = None, tol: float = 1e-3) -> ShapeList[Edge]:
296
+ """Edges whose geometry is a circle. Optionally filter by radius (± tol)."""
297
+ p = _as_part(part)
298
+ all_circ = p.edges().filter_by(GeomType.CIRCLE)
299
+ if not all_circ:
300
+ raise SelectionError("circular_edges() found 0 circular edges in the part.")
301
+
302
+ if radius is not None:
303
+ result = ShapeList(e for e in all_circ if abs(e.radius - radius) <= tol)
304
+ if not result:
305
+ actual_radii = sorted(list(set(round(e.radius, 3) for e in all_circ)))
306
+ raise SelectionError(
307
+ f"circular_edges(radius={radius}) found 0 matches. "
308
+ f"Actual circular radii in part: {actual_radii}. "
309
+ "Hint: Use one of the actual radii or omit the radius filter."
310
+ )
311
+ return result
312
+ return all_circ
313
+
314
+
315
+ # --------------------------------------------------------------------------- #
316
+ # Face selection #
317
+ # --------------------------------------------------------------------------- #
318
+
319
+
320
+ def face_at(part, axis: Axis, value: float, tol: float = 1e-3) -> Face:
321
+ """The single planar face perpendicular to `axis` whose center sits at `value` along `axis` (± tol).
322
+
323
+ Generalization of `face_at_z` to any principal axis. Raises if 0 or >1 faces match.
324
+ """
325
+ p = _as_part(part)
326
+ idx = _axis_index(axis)
327
+ al = _axis_label(axis)
328
+ axis_dir = _axis_direction(axis)
329
+ candidates = [
330
+ f for f in p.faces().filter_by(GeomType.PLANE)
331
+ if abs(getattr(f.center(), idx) - value) <= tol
332
+ and abs(abs(f.normal_at().normalized().dot(axis_dir)) - 1.0) < 1e-2
333
+ ]
334
+ if not candidates:
335
+ bbox = p.bounding_box()
336
+ lo, hi = getattr(bbox.min, idx), getattr(bbox.max, idx)
337
+ raise SelectionError(
338
+ f"face_at({al}, {value}) found 0 faces. "
339
+ f"Part {idx}-bounds: [{lo:.2f}, {hi:.2f}]. "
340
+ "Hint: check your math for the position."
341
+ )
342
+ if len(candidates) > 1:
343
+ raise SelectionError(
344
+ f"face_at({al}, {value}): {len(candidates)} faces match; widen tol or use face_facing()"
345
+ )
346
+ return candidates[0]
347
+
348
+
349
+ def extreme_face(part, axis: Axis, direction="max") -> Face:
350
+ """The planar face perpendicular to `axis` with the extreme center along `axis`.
351
+
352
+ `direction` selects which extreme; accepts `'max'` / `'min'` / `'top'` /
353
+ `'bottom'`, or a signed number (positive → max, negative → min).
354
+
355
+ Generalization of `top_face` / `bottom_face`.
356
+ """
357
+ d = _normalize_direction(direction, "extreme_face")
358
+ p = _as_part(part)
359
+ idx = _axis_index(axis)
360
+ al = _axis_label(axis)
361
+ axis_dir = _axis_direction(axis)
362
+ faces = [
363
+ f for f in p.faces().filter_by(GeomType.PLANE)
364
+ if abs(abs(f.normal_at().normalized().dot(axis_dir)) - 1.0) < 1e-2
365
+ ]
366
+ if not faces:
367
+ raise SelectionError(
368
+ f"extreme_face({al}, {d}): no planar face is perpendicular to {al}. "
369
+ "Hint: is the part rotated relative to that axis?"
370
+ )
371
+ keyed = [(f, getattr(f.center(), idx)) for f in faces]
372
+ chosen = max(keyed, key=lambda kv: kv[1]) if d == "max" else min(keyed, key=lambda kv: kv[1])
373
+ return chosen[0]
374
+
375
+
376
+ def top_face(part) -> Face:
377
+ """The single horizontal face with the highest Z. Alias for `extreme_face(part, Axis.Z, 'max')`."""
378
+ return extreme_face(part, Axis.Z, direction="max")
379
+
380
+
381
+ def bottom_face(part) -> Face:
382
+ """The single horizontal face with the lowest Z. Alias for `extreme_face(part, Axis.Z, 'min')`."""
383
+ return extreme_face(part, Axis.Z, direction="min")
384
+
385
+
386
+ def face_at_z(part, z: float, tol: float = 1e-3) -> Face:
387
+ """The horizontal face whose center is at height `z` (± tol). Alias for `face_at(part, Axis.Z, z)`."""
388
+ return face_at(part, Axis.Z, z, tol=tol)
389
+
390
+
391
+ def face_facing(part, direction: Vector | tuple[float, float, float]) -> ShapeList[Face]:
392
+ """Planar faces whose outward normal is (roughly) parallel to `direction`."""
393
+ p = _as_part(part)
394
+ d = Vector(*direction) if isinstance(direction, tuple) else direction
395
+ d = d.normalized()
396
+ result = ShapeList(
397
+ f
398
+ for f in p.faces().filter_by(GeomType.PLANE)
399
+ if f.normal_at().normalized().dot(d) > 0.99
400
+ )
401
+ return _require_nonempty(
402
+ "face_facing()", result, "no planar face has a normal aligned with that direction", part=p
403
+ )
404
+
405
+
406
+ # --------------------------------------------------------------------------- #
407
+ # Feature edges (dihedral) #
408
+ # --------------------------------------------------------------------------- #
409
+
410
+
411
+ def _edge_fingerprint(e: Edge, ndigits: int = 4) -> tuple:
412
+ """Stable identity for an Edge across faces, based on rounded geometry."""
413
+ mid = e.position_at(0.5)
414
+ start = e.position_at(0.0)
415
+ end = e.position_at(1.0)
416
+ # Sort endpoints so direction does not affect identity.
417
+ a = (round(start.X, ndigits), round(start.Y, ndigits), round(start.Z, ndigits))
418
+ b = (round(end.X, ndigits), round(end.Y, ndigits), round(end.Z, ndigits))
419
+ if b < a:
420
+ a, b = b, a
421
+ return (
422
+ a,
423
+ b,
424
+ (round(mid.X, ndigits), round(mid.Y, ndigits), round(mid.Z, ndigits)),
425
+ round(float(e.length), ndigits),
426
+ )
427
+
428
+
429
+ def _face_normal_at_edge(face: Face, edge: Edge):
430
+ """Outward face normal sampled near the edge midpoint.
431
+ Falls back to the face's center normal if the projection fails.
432
+ """
433
+ try:
434
+ return face.normal_at(edge.position_at(0.5)).normalized()
435
+ except Exception:
436
+ try:
437
+ return face.normal_at().normalized()
438
+ except Exception:
439
+ return None
440
+
441
+
442
+ def feature_edges(part, *, min_angle: float = 30.0) -> ShapeList[Edge]:
443
+ """Edges where the two adjacent face normals differ by at least `min_angle` degrees.
444
+
445
+ These are the "sharp" or "feature" edges of a body — the visible corners
446
+ that a human would pick to chamfer/fillet. Smooth/tangent seams (e.g. the
447
+ longitudinal seam on a cylinder side) have parallel adjacent normals and
448
+ are excluded. Open-boundary edges (only one adjacent face) are also
449
+ excluded.
450
+
451
+ Example:
452
+ chamfered = safe_chamfer(part, feature_edges(part, min_angle=45), distance=0.5)
453
+ """
454
+ if not (0 < min_angle <= 180):
455
+ raise SelectionError(
456
+ f"feature_edges(min_angle={min_angle}): must be in (0, 180]."
457
+ )
458
+ p = _as_part(part)
459
+ edge_index: dict = {}
460
+ edge_to_faces: dict = {}
461
+ for f in p.faces():
462
+ for e in f.edges():
463
+ key = _edge_fingerprint(e)
464
+ edge_index.setdefault(key, e)
465
+ edge_to_faces.setdefault(key, []).append(f)
466
+ threshold = math.cos(math.radians(min_angle))
467
+ result_edges = []
468
+ for key, faces in edge_to_faces.items():
469
+ if len(faces) < 2:
470
+ continue
471
+ edge = edge_index[key]
472
+ n1 = _face_normal_at_edge(faces[0], edge)
473
+ n2 = _face_normal_at_edge(faces[1], edge)
474
+ if n1 is None or n2 is None:
475
+ continue
476
+ cos_a = max(-1.0, min(1.0, n1.dot(n2)))
477
+ if cos_a <= threshold:
478
+ result_edges.append(edge)
479
+ return _require_nonempty(
480
+ f"feature_edges(min_angle={min_angle})",
481
+ ShapeList(result_edges),
482
+ "no sharp edges meet that angle threshold; lower min_angle or check the part is not all smooth surfaces",
483
+ part=p,
484
+ )
485
+
486
+
487
+ # --------------------------------------------------------------------------- #
488
+ # Holes (cylindrical features) #
489
+ # --------------------------------------------------------------------------- #
490
+
491
+
492
+ def _circle_axis_from_edge(e: Edge):
493
+ """Unit Vector of a circular edge's plane normal, derived from three sampled points.
494
+ Returns None if the edge is non-circular or degenerate.
495
+ """
496
+ try:
497
+ p0 = e.position_at(0.0)
498
+ p1 = e.position_at(0.25)
499
+ p2 = e.position_at(0.5)
500
+ except Exception:
501
+ return None
502
+ v1 = Vector(p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z)
503
+ v2 = Vector(p2.X - p0.X, p2.Y - p0.Y, p2.Z - p0.Z)
504
+ try:
505
+ n = v1.cross(v2)
506
+ length = math.sqrt(n.X * n.X + n.Y * n.Y + n.Z * n.Z)
507
+ if length < 1e-9:
508
+ return None
509
+ return Vector(n.X / length, n.Y / length, n.Z / length)
510
+ except Exception:
511
+ return None
512
+
513
+
514
+ def _cylinder_face_axis_and_radius(face: Face):
515
+ """Return (axis_unit_vector, radius) for a cylindrical face, derived from a
516
+ bounding circular edge. Returns (None, None) when not derivable.
517
+ """
518
+ for e in face.edges():
519
+ gt = getattr(e, "geom_type", None)
520
+ gt_val = gt() if callable(gt) else gt
521
+ if gt_val != GeomType.CIRCLE:
522
+ continue
523
+ axis = _circle_axis_from_edge(e)
524
+ try:
525
+ radius = float(e.radius)
526
+ except Exception:
527
+ continue
528
+ if axis is not None:
529
+ return axis, radius
530
+ return None, None
531
+
532
+
533
+ def holes(
534
+ part,
535
+ radius: float | None = None,
536
+ axis: Axis = Axis.Z,
537
+ tol: float = 1e-3,
538
+ ) -> ShapeList[Face]:
539
+ """Cylindrical faces whose axis is parallel to `axis`, optionally filtered by `radius` (± tol).
540
+
541
+ Returns the side walls of through-holes, blind holes, bores, and counterbores.
542
+ Each face exposes `.center()`, `.normal_at()`, and adjacent circular edges
543
+ via `.edges()` for downstream picking. Use the bounding circular edges
544
+ (via `circular_edges` or `edges_on_face`) when you need to chamfer the rim.
545
+
546
+ Example:
547
+ rim_edges = []
548
+ for hole in holes(part, radius=2.5, axis=Axis.Z):
549
+ rim_edges.extend(e for e in hole.edges() if e.geom_type == GeomType.CIRCLE)
550
+ chamfered = safe_chamfer(part, rim_edges, distance=0.3)
551
+ """
552
+ p = _as_part(part)
553
+ al = _axis_label(axis)
554
+ axis_dir = _axis_direction(axis)
555
+ all_cylinders = p.faces().filter_by(GeomType.CYLINDER)
556
+ if not all_cylinders:
557
+ raise SelectionError(
558
+ f"holes(axis={al}): part has no cylindrical faces. "
559
+ "Hint: maybe the holes are conical or modeled as polygonal cuts."
560
+ )
561
+ matching = []
562
+ radii_seen = []
563
+ for f in all_cylinders:
564
+ face_axis, face_radius = _cylinder_face_axis_and_radius(f)
565
+ if face_axis is None or face_radius is None:
566
+ continue
567
+ if abs(abs(face_axis.dot(axis_dir)) - 1.0) > 1e-2:
568
+ continue
569
+ radii_seen.append(round(face_radius, 4))
570
+ if radius is None or abs(face_radius - radius) <= tol:
571
+ matching.append(f)
572
+ if not matching:
573
+ if radius is not None and radii_seen:
574
+ unique_radii = sorted(set(radii_seen))
575
+ raise SelectionError(
576
+ f"holes(radius={radius}, axis={al}) found 0 matches. "
577
+ f"Cylindrical face radii on this axis: {unique_radii}. "
578
+ "Hint: use one of the actual radii or omit `radius`."
579
+ )
580
+ raise SelectionError(
581
+ f"holes(axis={al}) found 0 cylindrical faces parallel to that axis. "
582
+ "Hint: check the hole axis or pass a different `axis`."
583
+ )
584
+ return ShapeList(matching)
585
+
586
+
587
+ # --------------------------------------------------------------------------- #
588
+ # Safe fillet / chamfer #
589
+ # --------------------------------------------------------------------------- #
590
+
591
+
592
+ def safe_fillet(part, edges, radius: float, *, factor: float = 0.9, label: str | None = None) -> Part:
593
+ """Apply a fillet capped to `min(radius, max_fillet(edges) * factor)`.
594
+
595
+ Use this instead of `fillet(edges, r)` when `r` may be larger than the
596
+ geometry can support. Returns a new `Part`.
597
+ """
598
+ p = _as_part(part)
599
+ edges = _ensure_shapelist(edges)
600
+ _require_nonempty("safe_fillet(edges=…)", edges, "empty edge selection")
601
+
602
+ # Fast path: try the requested radius directly. If it works, we avoid
603
+ # the expensive max_fillet binary search (which can take 20 iterations).
604
+ try:
605
+ return fillet(edges, radius=radius)
606
+ except Exception:
607
+ pass # Radius too large or topology issue; fall back to safe probe
608
+
609
+ try:
610
+ upper = p.max_fillet(list(edges), tolerance=0.1, max_iterations=20) * factor
611
+ except Exception as exc: # build123d raises generic Exception for degenerate edges
612
+ context = _safe_op_context("safe_fillet", p, edges, radius, factor, label)
613
+ raise SelectionError(
614
+ f"safe_fillet(): max_fillet failed ({exc}). Common causes:\n"
615
+ " 1. Selection includes BOTH inner and outer rim of a hollow wall — the "
616
+ "two fillets collide. Pick only one rim, or use a radius < wall_thickness/2.\n"
617
+ " 2. Selection includes a seam edge from a prior boolean (e.g. handle/body "
618
+ "join). Fillet the bodies BEFORE fusing them.\n"
619
+ " 3. A previously-added sub-part is only tangent to the body. Re-add it "
620
+ "with safe_add(..., min_overlap >= 1.0).\n"
621
+ f" Debug: {context}"
622
+ ) from exc
623
+ r = min(radius, upper)
624
+ if r <= 0:
625
+ context = _safe_op_context("safe_fillet", p, edges, radius, factor, label)
626
+ raise SelectionError(f"safe_fillet(): computed radius {r} <= 0; edge set is too thin. {context}")
627
+ return fillet(edges, radius=r)
628
+
629
+
630
+ def safe_chamfer(part, edges, distance: float, *, factor: float = 0.9, label: str | None = None) -> Part:
631
+ """Apply a chamfer capped to `min(distance, max_fillet(edges) * factor)`.
632
+
633
+ `max_fillet` is reused as a topology-safety probe — chamfers share the
634
+ same degeneracy modes as fillets at the BREP level.
635
+ """
636
+ p = _as_part(part)
637
+ edges = _ensure_shapelist(edges)
638
+ _require_nonempty("safe_chamfer(edges=…)", edges, "empty edge selection")
639
+
640
+ # Fast path: try the requested distance directly
641
+ try:
642
+ return chamfer(edges, length=distance)
643
+ except Exception:
644
+ pass # Distance too large or topology issue; fall back to safe probe
645
+
646
+ try:
647
+ upper = p.max_fillet(list(edges), tolerance=0.1, max_iterations=20) * factor
648
+ except Exception as exc:
649
+ context = _safe_op_context("safe_chamfer", p, edges, distance, factor, label)
650
+ raise SelectionError(
651
+ f"safe_chamfer(): max_fillet probe failed ({exc}). "
652
+ "See safe_fillet() for common topological causes. "
653
+ f"Debug: {context}"
654
+ ) from exc
655
+ d = min(distance, upper)
656
+ if d <= 0:
657
+ context = _safe_op_context("safe_chamfer", p, edges, distance, factor, label)
658
+ raise SelectionError(f"safe_chamfer(): computed distance {d} <= 0. {context}")
659
+ return chamfer(edges, length=d)
660
+
661
+
662
+ # --------------------------------------------------------------------------- #
663
+ # Safe booleans #
664
+ # --------------------------------------------------------------------------- #
665
+
666
+
667
+ @dataclass(frozen=True)
668
+ class _Overlap:
669
+ dx: float
670
+ dy: float
671
+ dz: float
672
+
673
+ @property
674
+ def min_axis(self) -> float:
675
+ return min(self.dx, self.dy, self.dz)
676
+
677
+ @property
678
+ def min_axis_name(self) -> str:
679
+ values = {"X": self.dx, "Y": self.dy, "Z": self.dz}
680
+ return min(values, key=values.get)
681
+
682
+
683
+ def _bbox_overlap(a, b) -> _Overlap:
684
+ ba, bb = a.bounding_box(), b.bounding_box()
685
+ return _Overlap(
686
+ dx=min(ba.max.X, bb.max.X) - max(ba.min.X, bb.min.X),
687
+ dy=min(ba.max.Y, bb.max.Y) - max(ba.min.Y, bb.min.Y),
688
+ dz=min(ba.max.Z, bb.max.Z) - max(ba.min.Z, bb.min.Z),
689
+ )
690
+
691
+
692
+ def safe_add(main, sub, *, min_overlap: float = 1.0) -> Part:
693
+ """Add `sub` to `main`, enforcing >= `min_overlap` mm of interpenetration.
694
+
695
+ Tangent-only or barely-touching sub-parts (handles, lugs, bosses) create
696
+ degenerate BREP at the seam and cause later fillets to fail with cryptic
697
+ errors. This helper checks bbox overlap on all three axes before fusing
698
+ and raises a clear message if the sub-part needs to be moved inward.
699
+ """
700
+ main_p = _as_part(main)
701
+ sub_p = _as_part(sub)
702
+ overlap = _bbox_overlap(main_p, sub_p)
703
+ if overlap.min_axis < min_overlap:
704
+ axis = overlap.min_axis_name
705
+ raise SelectionError(
706
+ f"safe_add(): bbox overlap is only {overlap.min_axis:.3f} mm "
707
+ f"(dx={overlap.dx:.3f}, dy={overlap.dy:.3f}, dz={overlap.dz:.3f}); "
708
+ f"limiting_axis={axis}; move `sub` inward along {axis} by >= "
709
+ f"{min_overlap - overlap.min_axis:.3f} mm or lower min_overlap if a tangent join is intentional. "
710
+ f"Debug: {_bbox_summary(main_p, 'main_bbox')}; {_bbox_summary(sub_p, 'sub_bbox')}"
711
+ )
712
+ return main_p + sub_p
713
+
714
+
715
+ def safe_cut(main, tool, *, min_through: float = 0.1) -> Part:
716
+ """Subtract `tool` from `main`, enforcing the cutter pokes through by
717
+ `min_through` mm on all bbox axes that intersect.
718
+
719
+ Catches the common case where a Hole or cut feature exactly meets the
720
+ far face — the result has a zero-thickness sliver that fillets and
721
+ rebuilds can't recover from.
722
+ """
723
+ a, b = _as_part(main), _as_part(tool)
724
+ overlap = _bbox_overlap(a, b)
725
+ if overlap.min_axis < min_through:
726
+ raise SelectionError(
727
+ f"safe_cut(): cutter only penetrates {overlap.min_axis:.3f} mm; "
728
+ f"extend it by >= {min_through - overlap.min_axis:.3f} mm to avoid a "
729
+ "zero-thickness sliver."
730
+ )
731
+ return a - b
732
+
733
+
734
+ # --------------------------------------------------------------------------- #
735
+ # Sweep / loft helpers #
736
+ # --------------------------------------------------------------------------- #
737
+
738
+
739
+ def sweep_path(edges_or_wires) -> Wire:
740
+ """Stitch `edges_or_wires` into a single continuous Wire suitable for sweep().
741
+
742
+ Raises if the input cannot be ordered head-to-tail within 1e-4 mm.
743
+ """
744
+ items = list(edges_or_wires)
745
+ if not items:
746
+ raise SelectionError("sweep_path(): empty input")
747
+ edges: list[Edge] = []
748
+ for item in items:
749
+ edges.extend(item.edges() if hasattr(item, "edges") else [item])
750
+ try:
751
+ return Wire(edges)
752
+ except Exception as exc:
753
+ raise SelectionError(
754
+ f"sweep_path(): edges are not connected head-to-tail ({exc}). "
755
+ "Order them along the path and ensure endpoints coincide."
756
+ ) from exc
757
+
758
+
759
+ def swept(profile, path) -> Part:
760
+ """Sweep `profile` along `path`. Accepts a ShapeList, Wire, or Edge for path."""
761
+ wire = path if isinstance(path, Wire) else sweep_path(path)
762
+ return sweep(sections=profile, path=wire)
763
+
764
+
765
+ def lofted(profiles: Sequence, *, ruled: bool = False) -> Part:
766
+ """Loft through `profiles` in order. Validates that profiles are coplanar-free
767
+ (each on its own plane) and that adjacent profiles have compatible vertex
768
+ counts — the two most common causes of twisting / non-manifold lofts.
769
+ """
770
+ if len(profiles) < 2:
771
+ raise SelectionError("lofted(): need at least 2 profiles")
772
+ vcounts = [len(p.vertices()) for p in profiles]
773
+ if len(set(vcounts)) > 1:
774
+ raise SelectionError(
775
+ f"lofted(): profiles have differing vertex counts {vcounts}; "
776
+ "loft will twist or fail. Insert intermediate profiles or use ruled=True."
777
+ )
778
+ return loft(sections=list(profiles), ruled=ruled)
779
+
780
+
781
+ # --------------------------------------------------------------------------- #
782
+ # Internals #
783
+ # --------------------------------------------------------------------------- #
784
+
785
+
786
+ def _ensure_shapelist(x) -> ShapeList:
787
+ if isinstance(x, ShapeList):
788
+ return x
789
+ if isinstance(x, Iterable):
790
+ return ShapeList(x)
791
+ return ShapeList([x])
792
+
793
+
794
+ def _bbox_summary(obj, label: str = "bbox") -> str:
795
+ try:
796
+ bb = _as_part(obj).bounding_box()
797
+ return (
798
+ f"{label}=X[{bb.min.X:.2f},{bb.max.X:.2f}] "
799
+ f"Y[{bb.min.Y:.2f},{bb.max.Y:.2f}] "
800
+ f"Z[{bb.min.Z:.2f},{bb.max.Z:.2f}]"
801
+ )
802
+ except Exception as exc:
803
+ return f"{label}=unavailable({exc})"
804
+
805
+
806
+ def _edge_context(edges) -> str:
807
+ items = list(edges)
808
+ if not items:
809
+ return "edge_count=0"
810
+ lengths = []
811
+ centers = []
812
+ for edge in items[:20]:
813
+ try:
814
+ lengths.append(float(edge.length))
815
+ except Exception:
816
+ pass
817
+ try:
818
+ c = edge.center()
819
+ centers.append((float(c.X), float(c.Y), float(c.Z)))
820
+ except Exception:
821
+ pass
822
+ pieces = [f"edge_count={len(items)}"]
823
+ if lengths:
824
+ pieces.append(f"edge_length_range=[{min(lengths):.3f},{max(lengths):.3f}]")
825
+ if centers:
826
+ xs, ys, zs = zip(*centers)
827
+ pieces.append(
828
+ "edge_center_bbox="
829
+ f"X[{min(xs):.2f},{max(xs):.2f}] "
830
+ f"Y[{min(ys):.2f},{max(ys):.2f}] "
831
+ f"Z[{min(zs):.2f},{max(zs):.2f}]"
832
+ )
833
+ if len(items) > 20:
834
+ pieces.append("edge_sample=first_20")
835
+ return ", ".join(pieces)
836
+
837
+
838
+ def _safe_op_context(op_name: str, part, edges, amount: float, factor: float, label: str | None = None) -> str:
839
+ label_text = f" label={label!r};" if label else ""
840
+ return (
841
+ f"{op_name} context:{label_text} requested={amount:.3f}, factor={factor:.3f}, "
842
+ f"{_bbox_summary(part, 'part_bbox')}, {_edge_context(edges)}"
843
+ )
844
+
845
+
846
+ # --------------------------------------------------------------------------- #
847
+ # High-Level Macros #
848
+ # --------------------------------------------------------------------------- #
849
+
850
+
851
+ def make_revolved_shell(profile_curves, thickness: float, axis: Axis = Axis.Z) -> Part:
852
+ """Generate a revolved shell from an open profile curve.
853
+
854
+ Automatically closes the curve to the axis, revolves it, and hollows it.
855
+ If the curve does not touch the axis at the top/bottom, the resulting flat
856
+ face at the higher end is automatically opened during hollowing.
857
+ """
858
+ from build123d import BuildLine, BuildSketch, BuildPart, Line, make_face, revolve, Plane, Vector
859
+
860
+ wire = sweep_path(profile_curves)
861
+ p0 = wire.position_at(0)
862
+ p1 = wire.position_at(1)
863
+
864
+ idx = _axis_index(axis)
865
+
866
+ def project_to_axis(p):
867
+ if idx == "X": return Vector(p.X, 0, 0)
868
+ elif idx == "Y": return Vector(0, p.Y, 0)
869
+ else: return Vector(0, 0, p.Z)
870
+
871
+ proj0 = project_to_axis(p0)
872
+ proj1 = project_to_axis(p1)
873
+
874
+ with BuildLine() as bl:
875
+ from build123d import add
876
+ add(wire)
877
+ if (p1 - proj1).length > 1e-5:
878
+ Line(p1, proj1)
879
+ if (proj1 - proj0).length > 1e-5:
880
+ Line(proj1, proj0)
881
+ if (proj0 - p0).length > 1e-5:
882
+ Line(proj0, p0)
883
+
884
+ # Try to infer the drawing plane based on vertices
885
+ vs = bl.wire().vertices()
886
+ is_y_zero = all(abs(getattr(v, "Y", 0)) < 1e-4 for v in vs)
887
+ is_x_zero = all(abs(getattr(v, "X", 0)) < 1e-4 for v in vs)
888
+ plane = Plane.XZ if is_y_zero else (Plane.YZ if is_x_zero else Plane.XY)
889
+
890
+ with BuildSketch(plane) as sk:
891
+ make_face(bl.wire())
892
+
893
+ with BuildPart() as bp:
894
+ from build123d import add
895
+ add(sk.sketch)
896
+ revolve(axis=axis)
897
+
898
+ base_part = bp.part
899
+ if abs(thickness) < 1e-5:
900
+ return base_part
901
+
902
+ # Attempt to open the top face if one was created
903
+ openings = []
904
+ v0_val = getattr(p0, idx)
905
+ v1_val = getattr(p1, idx)
906
+ max_val = max(v0_val, v1_val)
907
+
908
+ point_at_max = p0 if v0_val > v1_val else p1
909
+ proj_at_max = proj0 if v0_val > v1_val else proj1
910
+
911
+ if (point_at_max - proj_at_max).length > 1e-5:
912
+ try:
913
+ top_face = face_at(base_part, axis, max_val, tol=1e-3)
914
+ openings.append(top_face)
915
+ except Exception:
916
+ pass
917
+
918
+ try:
919
+ from build123d import offset
920
+ return offset(base_part, amount=-abs(thickness), openings=openings)
921
+ except Exception as e:
922
+ raise SelectionError(f"make_revolved_shell(): hollowing failed ({e}). Try adjusting curves to avoid self-intersection or reducing thickness.")
923
+
924
+
925
+ def make_tube_along_path(path_points, radius: float) -> Part:
926
+ """Generate a smooth solid tube along a 3D path of points."""
927
+ if len(path_points) < 2:
928
+ raise SelectionError("make_tube_along_path: at least 2 points required")
929
+
930
+ from build123d import BuildLine, Spline, BuildSketch, Circle, sweep, BuildPart, Plane, Line, Vector
931
+ with BuildPart() as bp:
932
+ with BuildLine() as bl:
933
+ pts = [_axis_direction(p) if not isinstance(p, Vector) else p for p in path_points] if False else path_points # Ensure Vector, but build123d accepts tuples
934
+ if len(pts) == 2:
935
+ Line(pts[0], pts[1])
936
+ else:
937
+ Spline(*pts)
938
+ path = bl.wire()
939
+
940
+ start_point = path.position_at(0)
941
+ tangent = path.tangent_at(0)
942
+ plane = Plane(origin=start_point, z_dir=tangent)
943
+ with BuildSketch(plane) as sk:
944
+ Circle(radius=radius)
945
+ sweep(path=path)
946
+ return bp.part
947
+
948
+
949
+ __all__ = [
950
+ "SelectionError",
951
+ # Edges — general (preferred)
952
+ "edges_at",
953
+ "extreme_edges",
954
+ # Edges — Z-axis aliases (back-compat)
955
+ "edges_at_z",
956
+ "top_edges",
957
+ "bottom_edges",
958
+ # Edges — other
959
+ "vertical_edges",
960
+ "edges_parallel_to",
961
+ "edges_on_face",
962
+ "outer_edges_at_z",
963
+ "circular_edges",
964
+ # Faces — general (preferred)
965
+ "face_at",
966
+ "extreme_face",
967
+ # Faces — Z-axis aliases (back-compat)
968
+ "top_face",
969
+ "bottom_face",
970
+ "face_at_z",
971
+ # Faces — other
972
+ "face_facing",
973
+ # Feature edges
974
+ "feature_edges",
975
+ # Holes
976
+ "holes",
977
+ # Safe ops
978
+ "safe_fillet",
979
+ "safe_chamfer",
980
+ "safe_add",
981
+ "safe_cut",
982
+ # Sweep / loft
983
+ "sweep_path",
984
+ "swept",
985
+ "lofted",
986
+ # Macros
987
+ "make_revolved_shell",
988
+ "make_tube_along_path",
989
+ ]