@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,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
+ ]