@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,586 @@
|
|
|
1
|
+
"""aicad_attach — verifiable part-to-body connections for build123d.
|
|
2
|
+
|
|
3
|
+
Ships with the Forgent3D bundled runtime. Use :func:`attach` as the default
|
|
4
|
+
substitute for ``safe_add(host, guest.moved(Location(...)))``. It dispatches on
|
|
5
|
+
the geometry of ``where``:
|
|
6
|
+
|
|
7
|
+
from aicad_attach import attach
|
|
8
|
+
from aicad_select import top_face, holes, face_facing
|
|
9
|
+
|
|
10
|
+
out = attach(body, bracket, top_face(body), inset=1.0) # planar face → pad
|
|
11
|
+
out = attach(body, sleeve, holes(body, radius=6)[0]) # cylinder → coaxial
|
|
12
|
+
out = attach(body, grip, ((40, 0, 20), (1, 0, 0))) # (point, normal) → stem
|
|
13
|
+
|
|
14
|
+
Each call builds a :class:`Connection`, runs ``verify_attach`` (probe-friendly),
|
|
15
|
+
then ``safe_add``. Use ``.report.summary()`` or ``preview_attach`` before fusing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Literal, Mapping, Sequence
|
|
23
|
+
|
|
24
|
+
from build123d import Compound, Edge, Face, GeomType, Location, Part, Plane, Vector
|
|
25
|
+
|
|
26
|
+
from aicad_select import safe_add
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# Errors #
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AttachError(ValueError):
|
|
35
|
+
"""Raised when a connection cannot be satisfied before or after placement."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# Frames & connections #
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _vec3(value: Sequence[float] | Vector, *, label: str = "point") -> Vector:
|
|
44
|
+
if isinstance(value, Vector):
|
|
45
|
+
return value
|
|
46
|
+
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
|
47
|
+
return Vector(float(value[0]), float(value[1]), float(value[2]))
|
|
48
|
+
if isinstance(value, Mapping):
|
|
49
|
+
for keys in (("x", "y", "z"), ("X", "Y", "Z")):
|
|
50
|
+
if all(k in value for k in keys):
|
|
51
|
+
return Vector(float(value[keys[0]]), float(value[keys[1]]), float(value[keys[2]]))
|
|
52
|
+
for key in ("point", "position", "origin", "center"):
|
|
53
|
+
if key in value:
|
|
54
|
+
return _vec3(value[key], label=label)
|
|
55
|
+
raise AttachError(f"{label}: expected [x,y,z] or Vector, got {value!r}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unit(v: Vector, *, label: str) -> Vector:
|
|
59
|
+
length = v.length
|
|
60
|
+
if length < 1e-9:
|
|
61
|
+
raise AttachError(f"{label}: zero-length direction {v!r}")
|
|
62
|
+
return v / length
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class MountFrame:
|
|
67
|
+
"""A mount point: origin on a body and outward-facing normal (+Z of the mating plane)."""
|
|
68
|
+
|
|
69
|
+
origin: Vector
|
|
70
|
+
normal: Vector
|
|
71
|
+
x_hint: Vector | None = None
|
|
72
|
+
|
|
73
|
+
def plane(self, *, flip_normal: bool = False) -> Plane:
|
|
74
|
+
z_dir = -self.normal if flip_normal else self.normal
|
|
75
|
+
if self.x_hint is not None:
|
|
76
|
+
return Plane(origin=self.origin, z_dir=z_dir, x_dir=self.x_hint)
|
|
77
|
+
return Plane(origin=self.origin, z_dir=z_dir)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Connection:
|
|
82
|
+
"""Declarative host↔guest mount intent."""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
host: MountFrame
|
|
86
|
+
guest: MountFrame
|
|
87
|
+
min_overlap: float = 1.0
|
|
88
|
+
inset: float = 0.0
|
|
89
|
+
mate: str = "flush" # "flush" | "coincident" (guest normal // host normal)
|
|
90
|
+
kind: str = "custom"
|
|
91
|
+
|
|
92
|
+
def to_metadata(self) -> dict[str, Any]:
|
|
93
|
+
def _frame_dict(frame: MountFrame) -> dict[str, Any]:
|
|
94
|
+
out: dict[str, Any] = {
|
|
95
|
+
"origin": [frame.origin.X, frame.origin.Y, frame.origin.Z],
|
|
96
|
+
"normal": [frame.normal.X, frame.normal.Y, frame.normal.Z],
|
|
97
|
+
}
|
|
98
|
+
if frame.x_hint is not None:
|
|
99
|
+
out["x_hint"] = [frame.x_hint.X, frame.x_hint.Y, frame.x_hint.Z]
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"schema": "aicad.connection.v1",
|
|
104
|
+
"name": self.name,
|
|
105
|
+
"kind": self.kind,
|
|
106
|
+
"mate": self.mate,
|
|
107
|
+
"min_overlap": self.min_overlap,
|
|
108
|
+
"inset": self.inset,
|
|
109
|
+
"host": _frame_dict(self.host),
|
|
110
|
+
"guest": _frame_dict(self.guest),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class AttachReport:
|
|
116
|
+
"""Outcome of :func:`verify_attach` — safe to log / return from ``probe``."""
|
|
117
|
+
|
|
118
|
+
ok: bool
|
|
119
|
+
name: str
|
|
120
|
+
anchor_gap_mm: float
|
|
121
|
+
normal_angle_deg: float
|
|
122
|
+
overlap_mm: float
|
|
123
|
+
overlap_axes: tuple[float, float, float]
|
|
124
|
+
messages: list[str] = field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
def summary(self) -> str:
|
|
127
|
+
status = "OK" if self.ok else "FAIL"
|
|
128
|
+
lines = [
|
|
129
|
+
f"[{status}] connection '{self.name}'",
|
|
130
|
+
f" anchor_gap={self.anchor_gap_mm:.4f} mm",
|
|
131
|
+
f" normal_angle={self.normal_angle_deg:.3f} deg",
|
|
132
|
+
f" bbox_overlap=({self.overlap_axes[0]:.3f}, {self.overlap_axes[1]:.3f}, {self.overlap_axes[2]:.3f}) min={self.overlap_mm:.3f} mm",
|
|
133
|
+
]
|
|
134
|
+
lines.extend(f" - {msg}" for msg in self.messages)
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class AttachResult:
|
|
140
|
+
part: Part | Compound
|
|
141
|
+
connection: Connection
|
|
142
|
+
placement: Location
|
|
143
|
+
report: AttachReport
|
|
144
|
+
placed_guest: Part | Compound
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------- #
|
|
148
|
+
# Construction helpers #
|
|
149
|
+
# --------------------------------------------------------------------------- #
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def mount_frame(
|
|
153
|
+
origin: Sequence[float] | Vector,
|
|
154
|
+
normal: Sequence[float] | Vector = (0, 0, 1),
|
|
155
|
+
*,
|
|
156
|
+
x_hint: Sequence[float] | Vector | None = None,
|
|
157
|
+
) -> MountFrame:
|
|
158
|
+
"""Define a mount frame from an origin and outward normal (mm, part-local)."""
|
|
159
|
+
o = _vec3(origin, label="mount_frame.origin")
|
|
160
|
+
n = _unit(_vec3(normal, label="mount_frame.normal"), label="mount_frame.normal")
|
|
161
|
+
hint = None if x_hint is None else _vec3(x_hint, label="mount_frame.x_hint")
|
|
162
|
+
return MountFrame(origin=o, normal=n, x_hint=hint)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def frame_from_metadata(
|
|
166
|
+
metadata: Mapping[str, Any],
|
|
167
|
+
anchor_key: str,
|
|
168
|
+
*,
|
|
169
|
+
normal_key: str | None = None,
|
|
170
|
+
x_hint_key: str | None = None,
|
|
171
|
+
default_normal: Sequence[float] = (0, 0, 1),
|
|
172
|
+
) -> MountFrame:
|
|
173
|
+
"""Build a :class:`MountFrame` from ``metadata['anchors'][...]`` entries."""
|
|
174
|
+
anchors = metadata.get("anchors") or metadata.get("points") or {}
|
|
175
|
+
if anchor_key not in anchors:
|
|
176
|
+
raise AttachError(f"frame_from_metadata(): anchor '{anchor_key}' not in metadata")
|
|
177
|
+
origin = anchors[anchor_key]
|
|
178
|
+
normal = default_normal
|
|
179
|
+
if normal_key and normal_key in anchors:
|
|
180
|
+
normal = anchors[normal_key]
|
|
181
|
+
x_hint = anchors[x_hint_key] if x_hint_key and x_hint_key in anchors else None
|
|
182
|
+
return mount_frame(origin, normal, x_hint=x_hint)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def define_connection(
|
|
186
|
+
name: str,
|
|
187
|
+
*,
|
|
188
|
+
host: MountFrame,
|
|
189
|
+
guest: MountFrame,
|
|
190
|
+
min_overlap: float = 1.0,
|
|
191
|
+
inset: float = 0.0,
|
|
192
|
+
mate: str = "flush",
|
|
193
|
+
kind: str = "custom",
|
|
194
|
+
) -> Connection:
|
|
195
|
+
"""Describe how ``guest`` should mate to ``host`` before any boolean fuse."""
|
|
196
|
+
mate_norm = str(mate).strip().lower()
|
|
197
|
+
if mate_norm not in ("flush", "coincident"):
|
|
198
|
+
raise AttachError(f"define_connection(): unknown mate {mate!r}; use 'flush' or 'coincident'")
|
|
199
|
+
if min_overlap < 0:
|
|
200
|
+
raise AttachError("define_connection(): min_overlap must be >= 0")
|
|
201
|
+
if inset < 0:
|
|
202
|
+
raise AttachError("define_connection(): inset must be >= 0")
|
|
203
|
+
return Connection(
|
|
204
|
+
name=name,
|
|
205
|
+
host=host,
|
|
206
|
+
guest=guest,
|
|
207
|
+
min_overlap=min_overlap,
|
|
208
|
+
inset=inset,
|
|
209
|
+
mate=mate_norm,
|
|
210
|
+
kind=kind,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
# Geometry → frames #
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _circle_axis_from_edge(e: Edge) -> Vector | None:
|
|
220
|
+
try:
|
|
221
|
+
p0 = e.position_at(0.0)
|
|
222
|
+
p1 = e.position_at(0.25)
|
|
223
|
+
p2 = e.position_at(0.5)
|
|
224
|
+
except Exception:
|
|
225
|
+
return None
|
|
226
|
+
v1 = Vector(p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z)
|
|
227
|
+
v2 = Vector(p2.X - p0.X, p2.Y - p0.Y, p2.Z - p0.Z)
|
|
228
|
+
n = v1.cross(v2)
|
|
229
|
+
length = math.sqrt(n.X * n.X + n.Y * n.Y + n.Z * n.Z)
|
|
230
|
+
if length < 1e-9:
|
|
231
|
+
return None
|
|
232
|
+
return Vector(n.X / length, n.Y / length, n.Z / length)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cylinder_axis_radius(face: Face) -> tuple[Vector, float]:
|
|
236
|
+
for e in face.edges():
|
|
237
|
+
gt = getattr(e, "geom_type", None)
|
|
238
|
+
gt_val = gt() if callable(gt) else gt
|
|
239
|
+
if gt_val != GeomType.CIRCLE:
|
|
240
|
+
continue
|
|
241
|
+
axis = _circle_axis_from_edge(e)
|
|
242
|
+
try:
|
|
243
|
+
radius = float(e.radius)
|
|
244
|
+
except Exception:
|
|
245
|
+
continue
|
|
246
|
+
if axis is not None:
|
|
247
|
+
return axis, radius
|
|
248
|
+
raise AttachError("_cylinder_axis_radius(): face has no circular edge with a usable axis")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def frame_from_face(face: Face) -> MountFrame:
|
|
252
|
+
"""Mount frame on a planar host face (origin at face center, normal outward)."""
|
|
253
|
+
normal = face.normal_at().normalized()
|
|
254
|
+
return mount_frame(face.center(), normal)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def frame_from_cylinder(face: Face, *, direction: Literal["into", "out"] = "into") -> MountFrame:
|
|
258
|
+
"""Mount frame on a cylindrical socket (hole wall or boss side).
|
|
259
|
+
|
|
260
|
+
``direction='into'`` points along the bore axis (for plugs / liners).
|
|
261
|
+
``direction='out'`` reverses the axis (for sleeves over an external boss).
|
|
262
|
+
"""
|
|
263
|
+
axis, _radius = _cylinder_axis_radius(face)
|
|
264
|
+
if direction == "out":
|
|
265
|
+
axis = -axis
|
|
266
|
+
return mount_frame(face.center(), axis)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# --------------------------------------------------------------------------- #
|
|
270
|
+
# Placement & verification #
|
|
271
|
+
# --------------------------------------------------------------------------- #
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _as_part(obj) -> Part | Compound:
|
|
275
|
+
if hasattr(obj, "part") and not isinstance(obj, (Part, Compound)):
|
|
276
|
+
return obj.part
|
|
277
|
+
return obj
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _bbox_overlap(a, b) -> tuple[float, float, float]:
|
|
281
|
+
ba, bb = a.bounding_box(), b.bounding_box()
|
|
282
|
+
return (
|
|
283
|
+
min(ba.max.X, bb.max.X) - max(ba.min.X, bb.min.X),
|
|
284
|
+
min(ba.max.Y, bb.max.Y) - max(ba.min.Y, bb.min.Y),
|
|
285
|
+
min(ba.max.Z, bb.max.Z) - max(ba.min.Z, bb.min.Z),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def placement(conn: Connection) -> Location:
|
|
290
|
+
"""Return the ``Location`` that aligns ``guest`` to ``host`` for ``conn``.
|
|
291
|
+
|
|
292
|
+
The inset translation is in *world* coordinates (along ``conn.host.normal``),
|
|
293
|
+
so it must be left-multiplied — ``loc * Location(t)`` would treat ``t`` as
|
|
294
|
+
a guest-local offset and produce a diagonal anchor gap.
|
|
295
|
+
"""
|
|
296
|
+
guest_flip = conn.mate != "coincident"
|
|
297
|
+
host_plane = conn.host.plane(flip_normal=False)
|
|
298
|
+
guest_plane = conn.guest.plane(flip_normal=guest_flip)
|
|
299
|
+
loc = host_plane.location * guest_plane.location.inverse()
|
|
300
|
+
if conn.inset > 0:
|
|
301
|
+
n = _unit(conn.host.normal, label="placement.inset")
|
|
302
|
+
loc = Location((-n.X * conn.inset, -n.Y * conn.inset, -n.Z * conn.inset)) * loc
|
|
303
|
+
return loc
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _transform_point(loc: Location, point: Vector) -> Vector:
|
|
307
|
+
"""Apply a Location to a point, returning the world-space position.
|
|
308
|
+
|
|
309
|
+
Uses ``Plane(loc).from_local_coords`` since ``Location * Vector`` is not a
|
|
310
|
+
supported operation in build123d (it tries to call ``Vector.moved``).
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
plane = Plane(loc)
|
|
314
|
+
out = plane.from_local_coords((float(point.X), float(point.Y), float(point.Z)))
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
raise AttachError(f"_transform_point(): Plane(loc).from_local_coords failed: {exc}") from exc
|
|
317
|
+
if isinstance(out, Vector):
|
|
318
|
+
return out
|
|
319
|
+
if hasattr(out, "X"):
|
|
320
|
+
return Vector(out.X, out.Y, out.Z)
|
|
321
|
+
raise AttachError("_transform_point(): unexpected return type from Plane.from_local_coords")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _transform_direction(loc: Location, direction: Vector) -> Vector:
|
|
325
|
+
"""Apply a Location's rotation to a direction (translation cancels out)."""
|
|
326
|
+
base = _transform_point(loc, Vector(0, 0, 0))
|
|
327
|
+
tip = _transform_point(loc, direction)
|
|
328
|
+
return _unit(tip - base, label="transformed direction")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def verify_attach(
|
|
332
|
+
host,
|
|
333
|
+
guest,
|
|
334
|
+
conn: Connection,
|
|
335
|
+
*,
|
|
336
|
+
loc: Location | None = None,
|
|
337
|
+
anchor_tol: float = 0.05,
|
|
338
|
+
normal_tol_deg: float = 0.5,
|
|
339
|
+
) -> AttachReport:
|
|
340
|
+
"""Check anchor coincidence, normal alignment, and bbox overlap without fusing."""
|
|
341
|
+
host_p = _as_part(host)
|
|
342
|
+
guest_p = _as_part(guest)
|
|
343
|
+
loc = loc if loc is not None else placement(conn)
|
|
344
|
+
placed = guest_p.moved(loc)
|
|
345
|
+
|
|
346
|
+
guest_origin_world = _transform_point(loc, conn.guest.origin)
|
|
347
|
+
expected_origin = conn.host.origin - _unit(conn.host.normal, label="host.normal") * conn.inset
|
|
348
|
+
anchor_gap = (guest_origin_world - expected_origin).length
|
|
349
|
+
|
|
350
|
+
guest_normal_world = _transform_direction(loc, conn.guest.normal)
|
|
351
|
+
target_normal = conn.host.normal if conn.mate == "coincident" else -conn.host.normal
|
|
352
|
+
target_normal = _unit(target_normal, label="verify.target_normal")
|
|
353
|
+
guest_n = _unit(guest_normal_world, label="verify.guest_normal")
|
|
354
|
+
dot = max(-1.0, min(1.0, target_normal.dot(guest_n)))
|
|
355
|
+
normal_angle = math.degrees(math.acos(dot))
|
|
356
|
+
|
|
357
|
+
ox, oy, oz = _bbox_overlap(host_p, placed)
|
|
358
|
+
overlap_min = min(ox, oy, oz)
|
|
359
|
+
|
|
360
|
+
messages: list[str] = []
|
|
361
|
+
ok = True
|
|
362
|
+
if anchor_gap > anchor_tol:
|
|
363
|
+
ok = False
|
|
364
|
+
messages.append(
|
|
365
|
+
f"anchor gap {anchor_gap:.4f} mm > {anchor_tol} mm; adjust guest frame or host mount"
|
|
366
|
+
)
|
|
367
|
+
if normal_angle > normal_tol_deg:
|
|
368
|
+
ok = False
|
|
369
|
+
messages.append(
|
|
370
|
+
f"normal misalignment {normal_angle:.3f}° > {normal_tol_deg}°; check mate mode or x_hint"
|
|
371
|
+
)
|
|
372
|
+
if overlap_min < conn.min_overlap:
|
|
373
|
+
ok = False
|
|
374
|
+
messages.append(
|
|
375
|
+
f"bbox overlap {overlap_min:.3f} mm < min_overlap {conn.min_overlap} mm; "
|
|
376
|
+
"increase inset or move guest frame inward"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return AttachReport(
|
|
380
|
+
ok=ok,
|
|
381
|
+
name=conn.name,
|
|
382
|
+
anchor_gap_mm=anchor_gap,
|
|
383
|
+
normal_angle_deg=normal_angle,
|
|
384
|
+
overlap_mm=overlap_min,
|
|
385
|
+
overlap_axes=(ox, oy, oz),
|
|
386
|
+
messages=messages,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def preview_attach(host, guest, conn: Connection) -> Compound:
|
|
391
|
+
"""Return host + positioned guest without boolean fuse (for visual probe)."""
|
|
392
|
+
loc = placement(conn)
|
|
393
|
+
return Compound([_as_part(host), _as_part(guest).moved(loc)])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def attach_part(
|
|
397
|
+
host,
|
|
398
|
+
guest,
|
|
399
|
+
conn: Connection,
|
|
400
|
+
*,
|
|
401
|
+
fuse: bool = True,
|
|
402
|
+
verify: bool = True,
|
|
403
|
+
) -> AttachResult:
|
|
404
|
+
"""Place ``guest`` on ``host``, verify, then optionally ``safe_add`` fuse."""
|
|
405
|
+
loc = placement(conn)
|
|
406
|
+
report = verify_attach(host, guest, conn, loc=loc)
|
|
407
|
+
placed = _as_part(guest).moved(loc)
|
|
408
|
+
if verify and not report.ok:
|
|
409
|
+
raise AttachError(report.summary())
|
|
410
|
+
if fuse:
|
|
411
|
+
part = safe_add(host, placed, min_overlap=conn.min_overlap)
|
|
412
|
+
else:
|
|
413
|
+
part = Compound([_as_part(host), placed])
|
|
414
|
+
return AttachResult(
|
|
415
|
+
part=part,
|
|
416
|
+
connection=conn,
|
|
417
|
+
placement=loc,
|
|
418
|
+
report=report,
|
|
419
|
+
placed_guest=placed,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# --------------------------------------------------------------------------- #
|
|
424
|
+
# General dispatcher #
|
|
425
|
+
# --------------------------------------------------------------------------- #
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _face_geom_type(face: Face):
|
|
429
|
+
gt = getattr(face, "geom_type", None)
|
|
430
|
+
return gt() if callable(gt) else gt
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _resolve_where(where, *, socket: Literal["bore", "boss"] = "bore"):
|
|
434
|
+
"""Map a ``where`` argument to (kind, host_frame, default_guest_normal, default_inset, mate)."""
|
|
435
|
+
if isinstance(where, MountFrame):
|
|
436
|
+
return "custom", where, (0, 0, -1), 1.0, "flush"
|
|
437
|
+
if isinstance(where, Face):
|
|
438
|
+
gt = _face_geom_type(where)
|
|
439
|
+
if gt == GeomType.PLANE:
|
|
440
|
+
return "pad", frame_from_face(where), (0, 0, -1), 1.0, "flush"
|
|
441
|
+
if gt == GeomType.CYLINDER:
|
|
442
|
+
direction = "into" if socket == "bore" else "out"
|
|
443
|
+
return (
|
|
444
|
+
"tube",
|
|
445
|
+
frame_from_cylinder(where, direction=direction),
|
|
446
|
+
(0, 0, 1),
|
|
447
|
+
2.0,
|
|
448
|
+
"coincident",
|
|
449
|
+
)
|
|
450
|
+
raise AttachError(
|
|
451
|
+
f"attach(): unsupported face geom_type {gt!r}; pass a planar or cylindrical face, "
|
|
452
|
+
"a (point, normal) tuple, a MountFrame, or call attach_part() with a custom Connection"
|
|
453
|
+
)
|
|
454
|
+
if isinstance(where, (tuple, list)) and len(where) == 2:
|
|
455
|
+
point, normal = where
|
|
456
|
+
return "handle", mount_frame(point, normal), (0, 0, 1), 2.0, "flush"
|
|
457
|
+
raise AttachError(
|
|
458
|
+
f"attach(): could not interpret where={where!r}; pass a Face (planar or cylindrical), "
|
|
459
|
+
"a (point, normal) tuple, or a MountFrame"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def attach(
|
|
464
|
+
host,
|
|
465
|
+
guest,
|
|
466
|
+
where,
|
|
467
|
+
*,
|
|
468
|
+
guest_origin: Sequence[float] | Vector = (0, 0, 0),
|
|
469
|
+
guest_normal: Sequence[float] | Vector | None = None,
|
|
470
|
+
guest_x_hint: Sequence[float] | Vector | None = None,
|
|
471
|
+
inset: float | None = None,
|
|
472
|
+
min_overlap: float = 1.0,
|
|
473
|
+
socket: Literal["bore", "boss"] = "bore",
|
|
474
|
+
name: str | None = None,
|
|
475
|
+
fuse: bool = True,
|
|
476
|
+
verify: bool = True,
|
|
477
|
+
) -> AttachResult:
|
|
478
|
+
"""General sub-part mount: place ``guest`` on ``host`` at ``where``, verify, fuse.
|
|
479
|
+
|
|
480
|
+
The default for any time you would otherwise write
|
|
481
|
+
``safe_add(host, guest.moved(Location(...)))``. ``where`` selects the host
|
|
482
|
+
mount geometry and the placement strategy:
|
|
483
|
+
|
|
484
|
+
- planar :class:`Face` (``top_face``, ``face_at``, ``face_facing`` …) →
|
|
485
|
+
flush flat mount (pad / foot / bracket / cover). Default ``inset=1.0``.
|
|
486
|
+
- cylindrical :class:`Face` (``holes(host)[0]``) → coaxial mount
|
|
487
|
+
(tube / bushing / liner / sleeve). Default ``inset=2.0``. Use
|
|
488
|
+
``socket='boss'`` for an external boss instead of a bore.
|
|
489
|
+
- ``(point, normal)`` tuple → stem / lug mount at an arbitrary point with
|
|
490
|
+
the stem buried into the wall. Default ``inset=2.0``.
|
|
491
|
+
- :class:`MountFrame` (from ``mount_frame`` / ``frame_from_metadata``) →
|
|
492
|
+
direct frame mount for custom anchors. Default ``inset=1.0``.
|
|
493
|
+
|
|
494
|
+
Guest defaults assume the mating feature is modeled at the guest's local
|
|
495
|
+
origin: flat mounts expect the mating face on XY at ``z=0`` (normal
|
|
496
|
+
``(0,0,-1)`` pointing toward host); coaxial and stem mounts expect the
|
|
497
|
+
centerline along ``+Z``. Override via ``guest_origin``, ``guest_normal``,
|
|
498
|
+
``guest_x_hint`` when the guest is modeled differently.
|
|
499
|
+
|
|
500
|
+
Verifies anchor coincidence, normal alignment, and bbox overlap before
|
|
501
|
+
fusing; raises :class:`AttachError` with a hint on failure. To probe a
|
|
502
|
+
placement without raising or fusing, call with ``fuse=False, verify=False``
|
|
503
|
+
and inspect ``result.report.summary()`` and ``result.part`` (a
|
|
504
|
+
:class:`Compound` of host + positioned guest). ``result.connection`` exposes
|
|
505
|
+
the underlying :class:`Connection` for use with :func:`preview_attach` /
|
|
506
|
+
:func:`verify_attach` if more detailed inspection is needed.
|
|
507
|
+
"""
|
|
508
|
+
kind, host_frame, default_normal, default_inset, mate = _resolve_where(
|
|
509
|
+
where, socket=socket
|
|
510
|
+
)
|
|
511
|
+
guest_n = guest_normal if guest_normal is not None else default_normal
|
|
512
|
+
inset_val = inset if inset is not None else default_inset
|
|
513
|
+
guest_frame = mount_frame(guest_origin, guest_n, x_hint=guest_x_hint)
|
|
514
|
+
conn = define_connection(
|
|
515
|
+
name or kind,
|
|
516
|
+
host=host_frame,
|
|
517
|
+
guest=guest_frame,
|
|
518
|
+
min_overlap=min_overlap,
|
|
519
|
+
inset=inset_val,
|
|
520
|
+
mate=mate,
|
|
521
|
+
kind=kind,
|
|
522
|
+
)
|
|
523
|
+
return attach_part(host, guest, conn, fuse=fuse, verify=verify)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def attach_tube_to_surface(host, tube_part, start_point, direction, min_penetration: float = 5.0, bore_radius: float = 0.0) -> Part:
|
|
527
|
+
"""
|
|
528
|
+
Attach a tube (e.g. handle, spout) to a curved surface.
|
|
529
|
+
|
|
530
|
+
Automatically extrudes the base of the tube backward by `min_penetration` to ensure
|
|
531
|
+
deep intersection (avoiding Z-fighting/coincident face issues), then fuses it to the host.
|
|
532
|
+
If `bore_radius` > 0, it will also drill a passage through the host wall to open the port.
|
|
533
|
+
"""
|
|
534
|
+
from build123d import extrude, Cylinder, Plane, Vector
|
|
535
|
+
from aicad_select import safe_add, safe_cut
|
|
536
|
+
|
|
537
|
+
host_p = _as_part(host)
|
|
538
|
+
tube_p = _as_part(tube_part)
|
|
539
|
+
|
|
540
|
+
sp = _vec3(start_point, label="attach_tube_to_surface.start_point")
|
|
541
|
+
d = _unit(_vec3(direction, label="direction"), label="attach_tube_to_surface.direction")
|
|
542
|
+
|
|
543
|
+
tube_faces = tube_p.faces()
|
|
544
|
+
# Find the planar face closest to the start point
|
|
545
|
+
planar_faces = [f for f in tube_faces if getattr(f, "geom_type", lambda: None)() == "PLANE" or getattr(f, "geom_type", lambda: None) == GeomType.PLANE]
|
|
546
|
+
if not planar_faces:
|
|
547
|
+
planar_faces = tube_faces
|
|
548
|
+
base_face = min(planar_faces, key=lambda f: (f.center() - sp).length)
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
# Extrude backwards along the negative direction
|
|
552
|
+
extension = extrude(base_face, amount=min_penetration, dir=-d)
|
|
553
|
+
extended_tube = safe_add(tube_p, extension, min_overlap=0.0)
|
|
554
|
+
except Exception:
|
|
555
|
+
# Fallback if extrude fails
|
|
556
|
+
extended_tube = tube_p
|
|
557
|
+
|
|
558
|
+
result = safe_add(host_p, extended_tube, min_overlap=0.1)
|
|
559
|
+
|
|
560
|
+
if bore_radius > 0:
|
|
561
|
+
drill_plane = Plane(origin=sp, z_dir=-d)
|
|
562
|
+
drill = Cylinder(radius=bore_radius, height=min_penetration * 3)
|
|
563
|
+
drill = drill.moved(drill_plane.location)
|
|
564
|
+
result = safe_cut(result, drill, min_through=0.1)
|
|
565
|
+
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
__all__ = [
|
|
570
|
+
"AttachError",
|
|
571
|
+
"AttachReport",
|
|
572
|
+
"AttachResult",
|
|
573
|
+
"Connection",
|
|
574
|
+
"MountFrame",
|
|
575
|
+
"attach",
|
|
576
|
+
"attach_part",
|
|
577
|
+
"attach_tube_to_surface",
|
|
578
|
+
"define_connection",
|
|
579
|
+
"frame_from_cylinder",
|
|
580
|
+
"frame_from_face",
|
|
581
|
+
"frame_from_metadata",
|
|
582
|
+
"mount_frame",
|
|
583
|
+
"placement",
|
|
584
|
+
"preview_attach",
|
|
585
|
+
"verify_attach",
|
|
586
|
+
]
|