Source code for pybosl2.parts.hooks

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

# LibFile: pybosl2/parts/hooks.py
#    Pure-Python port of BOSL2's hooks.scad: hooks and hook-like parts. At the moment BOSL2 supplies
#    a single part, :class:`RingHook` -- a rectangular mounting base that flares up and joins
#    tangentially to a Y-axis cylinder (the "ring"), with an optional round, D-shaped or custom
#    through-hole.
#
# FileSummary: Hooks and hook-like parts (the ring hook).
# DocCategory: Parts library
# FileGroup: BOSL2

"""Hooks and hook-like parts (the ring hook)."""

from __future__ import annotations

import math
from enum import StrEnum

import numpy as np

from pybosl2._native import native
from pybosl2.shapes3d import Bosl2Solid, cuboid, cyl, prismoid

_opolygon = native("polygon")

__all__ = ["RingHook", "HoleType"]


[docs] class HoleType(StrEnum): """Through-hole shape for :class:`RingHook`.""" CIRCLE = "circle" D = "D"
def _circle_point_tangents(r: float, center: list[float], pt: list[float]) -> list[list[float]]: """Return the two tangent points on a circle (centre *center*, radius *r*) from external point *pt* (BOSL2. circle_point_tangents()). Points are 2-vectors ``[x, height]``. """ center_arr = np.asarray(center, dtype=float) pt_arr = np.asarray(pt, dtype=float) diameter = float(np.linalg.norm(pt_arr - center_arr)) if diameter <= r: raise ValueError("point must be outside the circle for a tangent to exist") u = (pt_arr - center_arr) / diameter angle = math.acos(r / diameter) out: list[list[float]] = [] for s in (1, -1): c, si = math.cos(s * angle), math.sin(s * angle) rot = np.array([c * u[0] - si * u[1], si * u[0] + c * u[1]]) out.append((center_arr + r * rot).tolist()) return out def _radius(r: float | None, d: float | None) -> float | None: if r is not None: return float(r) if d is not None: return float(d) / 2 return None
[docs] class RingHook: """A ring hook: a rectangular base that flares tangentially into a Y-axis cylinder with a hole. *base_size* is the ``[x, y]`` of the mounting base, which sits on ``z = 0``; *hole_z* the height of the cylinder centre above it; *outer_radius* / *outer_diameter* the cylinder's outer radius / diameter. Give exactly two of *outer_radius/outer_diameter*, *inner_radius/inner_diameter* and *wall* to set the wall around the through-hole. *hole* is :attr:`HoleType.CIRCLE`, :attr:`HoleType.D` (semicircular, flat side down) or a list of ``[x, z]`` points for a custom hole. *rounding* rounds the base's vertical edges; *hole_rounding* eases the hole mouth. Examples: A ring connector: .. pythonscad-example:: from pybosl2.parts.hooks import RingHook RingHook([50, 10], 25, outer_radius=25, inner_radius=20).show() """ def __init__( self, base_size: list[float], hole_z: float, outer_radius: float | None = None, inner_radius: float | None = None, outer_diameter: float | None = None, inner_diameter: float | None = None, wall: float | None = None, hole: HoleType | list[list[float]] = HoleType.CIRCLE, rounding: float = 0, hole_rounding: float = 0, fillet: float = 0, outside_segments: int | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a ring hook. Args: base_size: The ``[x, y]`` dimensions of the mounting base rectangle. hole_z: Height of the cylinder centre above the base. outer_radius: Outer radius of the cylinder. inner_radius: Inner (bore) radius of the cylinder. outer_diameter: Outer diameter of the cylinder (alternative to outer_radius). inner_diameter: Inner diameter of the cylinder (alternative to inner_radius). wall: Wall thickness around the through-hole. hole: Through-hole shape -- :attr:`HoleType.CIRCLE`, :attr:`HoleType.D`, or a custom 2-D path. rounding: Radius for rounding the base's vertical edges. hole_rounding: Radius for easing the hole mouth. fillet: Fillet radius at the base-to-cylinder junction. outside_segments: Number of segments on the outer cylinder. fn: Number of facets for $fn-based resolution. fa: Minimum facet angle. fs: Minimum facet size. Returns: None. Raises: NotImplementedError: If *fillet* is non-zero (not yet ported). ValueError: If the geometry constraints are violated. """ if fillet: raise NotImplementedError("ring_hook(): the base fillet is not yet ported; use fillet=0.") bx, w = float(base_size[0]), float(base_size[1]) custom = isinstance(hole, list) or_t = _radius(outer_radius, outer_diameter) ir_t = _radius(inner_radius, inner_diameter) if custom: if ir_t is not None or wall is not None: raise ValueError( "ring_hook(): cannot give inner_radius/inner_diameter or wall with a custom hole path." ) if or_t is None: raise ValueError("ring_hook(): a custom hole needs or/outer_diameter.") ri, ro = 0.0, or_t else: defined = sum(v is not None for v in (or_t, ir_t, wall)) if defined != 2: raise ValueError( "ring_hook(): define exactly two of or/outer_diameter, inner_radius/inner_diameter and wall." ) ri = ir_t if ir_t is not None else float(or_t) - float(wall) # type: ignore[arg-type] ro = or_t if or_t is not None else float(ri) + float(wall) # type: ignore[arg-type] if ri > ro: raise ValueError("ring_hook(): hole doesn't fit, or wall is negative.") if isinstance(hole, HoleType) and hole not in (HoleType.CIRCLE, HoleType.D): raise ValueError(f"ring_hook(): hole must be CIRCLE, D or a 2-D path, got {hole!r}") if hole == HoleType.CIRCLE and ri > 0 and ri + hole_rounding >= hole_z: raise ValueError(f"ring_hook(): inner_radius + hole_rounding must be less than hole_z ({hole_z}).") if math.hypot(bx / 2, hole_z) <= ro: raise ValueError("ring_hook(): base corners must be outside the cylinder (need a tangent).") tangents = _circle_point_tangents(ro, [0, hole_z], [bx / 2, 0]) tx, tz = max(tangents, key=lambda t: t[1]) base = prismoid( [bx, w], [2 * tx, w], height=tz, rounding=rounding if rounding else 0, fn=fn, fa=fa, fs=fs, ) ring = ( cyl( height=w, radius=ro, fn=outside_segments if outside_segments else fn, fa=fa, fs=fs, ) .rotate([90, 0, 0]) .up(hole_z) ) body = base | ring if ri > 0 or custom: body = body - _hole_cutter(hole, ri, w, hole_z, hole_rounding, fn, fa, fs) self._solid: Bosl2Solid = Bosl2Solid(body.shape, size=[bx, w, hole_z + ro]) self._base_size: list[float] = base_size self._hole_z: float = hole_z self._outer_radius: float = ro self._inner_radius: float = ri @property def base_size(self) -> list[float]: """Mounting base ``[x, y]``.""" return self._base_size @property def hole_z(self) -> float: """Cylinder centre height.""" return self._hole_z @property def outer_radius(self) -> float: """Cylinder outer radius.""" return self._outer_radius @property def inner_radius(self) -> float: """Cylinder inner (bore) radius.""" return self._inner_radius
[docs] def shape(self) -> Bosl2Solid: """Return the ring hook geometry.""" return self._solid
[docs] def show(self) -> None: """Display the ring hook in the viewer.""" self._solid.show()
def _hole_cutter( hole: HoleType | list[list[float]], ri: float, w: float, hole_z: float, hole_rounding: float, fn: int | None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """Return the solid to subtract for the through-hole, laid along Y and centred at z=hole_z.""" length_ = w + 2 if isinstance(hole, list): pts = [[float(p[0]), float(p[1])] for p in hole] cut = _opolygon(pts).linear_extrude(height=length_, center=True) return Bosl2Solid(cut).rotate([90, 0, 0]).up(hole_z) rnd = hole_rounding if hole_rounding else None bore = cyl(height=length_, radius=ri, rounding=rnd, fn=fn, fa=fa, fs=fs).rotate([90, 0, 0]).up(hole_z) if hole == HoleType.D: upper = cuboid([2 * ri + 2, length_ + 2, 2 * ri], fn=fn, fa=fa, fs=fs).up(hole_z + ri) bore = bore & upper return bore