@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.
- package/README.md +24 -0
- package/dist/index.cjs +52 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/python/aicad-script.py +621 -0
- package/python/export_runner.py +316 -0
- package/python/skill-helpers/aicad_attach.py +586 -0
- package/python/skill-helpers/aicad_select.py +989 -0
|
@@ -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
|
+
]
|