Source code for bosl2.screw_drive

# 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: bosl2/screw_drive.py
#    Pure-Python port of BOSL2's screw_drive.scad: masks for the driver recesses cut into a screw
#    head -- Phillips, hex (Allen), Torx and Robertson/square. The :class:`ScrewDrive` class groups
#    them as static methods that return :class:`~bosl2.shapes3d.Bosl2Solid` masks (subtract one from
#    a head to make the recess), alongside the dimensional helpers BOSL2 provides:
#    :meth:`ScrewDrive.torx_info`/:meth:`~ScrewDrive.torx_diam`/:meth:`~ScrewDrive.torx_depth` and
#    :meth:`ScrewDrive.phillips_depth`/:meth:`~ScrewDrive.phillips_diam`.
#
#    The dimension tables (Phillips ISO 4757 shaft/cutout sizes, the Torx OD/ID/depth/rounding table
#    from ISO 14583, and the Robertson square-drive inch table) are transcribed verbatim from
#    screw_drive.scad and checked in tests/test_screw_drive.py. Geometry is built with the same
#    primitives BOSL2 uses -- rotate_extrude/linear_extrude of a 2-D profile, hulls of circles, the
#    zrot_copies ring placement, cyl() and prismoid() -- via this package's native-op wrappers.
#
# FileSummary: Phillips, hex, Torx and Robertson driver-recess masks.
# FileGroup: BOSL2

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING

from bosl2._helpers import union
from bosl2._native import native
from bosl2.constants import BOTTOM, INCH
from bosl2.distributors import zrot_copies
from bosl2.shapes2d import _frag_count, circle, hexagon
from bosl2.shapes2d import hull as _hull2d
from bosl2.shapes3d import Bosl2Solid, _quantup, cyl, prismoid

if TYPE_CHECKING:  # real stub-typed imports for the checker (identical to pre-lazy)
    from pythonscad import polygon as _opolygon
    from pythonscad import rotate_extrude as _orotate_extrude
else:
    _opolygon = native("polygon")
    _orotate_extrude = native("rotate_extrude")

__all__ = ["ScrewDrive", "PhillipsSpec", "TorxSpec", "RobertsonSpec"]


def _adj_ang_to_opp(adj: float, angle: float) -> float:
    """
    The opposite side of a right triangle given the adjacent side and angle (BOSL2
    adj_ang_to_opp).
    """
    return adj * math.tan(math.radians(angle))


def _union(shapes):
    """Boolean union of a non-empty iterable of shapes."""
    return union(shapes)


# ---------------------------------------------------------------------------
# Section: dimension tables (transcribed from screw_drive.scad)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class PhillipsSpec:
    """
    Phillips recess geometry for one bit size (ISO 4757). See :func:`ScrewDrive.phillips_mask`.
    """

    shaft: float  # shaft/outer diameter
    b: float  # cutout wing spacing radius
    e: float  # cutout wing base width
    g: float  # tip cone diameter
    alpha: float  # cutout near-face angle
    beta: float  # cutout tilt angle


@dataclass(frozen=True)
class TorxSpec:
    """Torx driver dimensions for one size (ISO 14583). See :func:`ScrewDrive.torx_info`."""

    outer_diameter: float  # outer diameter
    inner_diameter: float  # inner diameter
    depth: float  # drive-hole depth
    tip_rounding: float  # external tip rounding radius
    inner_rounding: float  # inner rounding radius

    def as_tuple(self) -> tuple[float, float, float, float, float]:
        """``(outer_diameter, inner_diameter, depth, tip_rounding, inner_rounding)`` -- the raw BOSL2 ``torx_info``
        list."""
        return (self.outer_diameter, self.inner_diameter, self.depth, self.tip_rounding, self.inner_rounding)


@dataclass(frozen=True)
class RobertsonSpec:
    """Robertson/square-drive dimensions for one size, in inches (min/max per the spec).

    ``m`` (across flats), ``t`` (depth) and ``f`` (flat-to-taper transition) return the (min+max)/2
    nominal, as BOSL2 uses.
    """

    m_min: float
    m_max: float
    t_min: float
    t_max: float
    f_min: float
    f_max: float

    @property
    def m(self) -> float:
        return (self.m_min + self.m_max) / 2

    @property
    def t(self) -> float:
        return (self.t_min + self.t_max) / 2

    @property
    def f(self) -> float:
        return (self.f_min + self.f_max) / 2


_PH_GAMMA = 92.0
_PH_BOT_ANGLE = 28.0
_PH_SIDE_ANGLE = 26.5

# Phillips number "#0".."#4" -> its recess geometry (ISO 4757).
_PHILLIPS = {
    0: PhillipsSpec(shaft=3, b=0.61, e=0.31, g=0.81, alpha=136, beta=7.00),
    1: PhillipsSpec(shaft=4.5, b=0.97, e=0.435, g=1.27, alpha=138, beta=7.00),
    2: PhillipsSpec(shaft=6, b=1.47, e=0.815, g=2.29, alpha=140, beta=5.75),
    3: PhillipsSpec(shaft=8, b=2.41, e=2.005, g=3.81, alpha=146, beta=5.75),
    4: PhillipsSpec(shaft=10, b=3.48, e=2.415, g=5.08, alpha=153, beta=7.00),
}

# Torx size -> dimensions. Depth is from metric socket-head screws, ISO 14583
# (some depths interpolated -- see BOSL2).
_TORX = {
    1: TorxSpec(0.90, 0.65, 0.40, 0.059, 0.201),
    2: TorxSpec(1.00, 0.73, 0.44, 0.069, 0.224),
    3: TorxSpec(1.20, 0.87, 0.53, 0.081, 0.266),
    4: TorxSpec(1.35, 0.98, 0.59, 0.090, 0.308),
    5: TorxSpec(1.48, 1.08, 0.65, 0.109, 0.330),
    6: TorxSpec(1.75, 1.27, 0.775, 0.132, 0.383),
    7: TorxSpec(2.08, 1.50, 0.886, 0.161, 0.446),
    8: TorxSpec(2.40, 1.75, 1.0, 0.190, 0.510),
    9: TorxSpec(2.58, 1.87, 1.078, 0.207, 0.554),
    10: TorxSpec(2.80, 2.05, 1.142, 0.229, 0.598),
    15: TorxSpec(3.35, 2.40, 1.2, 0.267, 0.716),
    20: TorxSpec(3.95, 2.85, 1.4, 0.305, 0.859),
    25: TorxSpec(4.50, 3.25, 1.61, 0.375, 0.920),
    27: TorxSpec(5.07, 3.65, 1.84, 0.390, 1.108),
    30: TorxSpec(5.60, 4.05, 2.22, 0.451, 1.194),
    40: TorxSpec(6.75, 4.85, 2.63, 0.546, 1.428),
    45: TorxSpec(7.93, 5.64, 3.115, 0.574, 1.796),
    50: TorxSpec(8.95, 6.45, 3.82, 0.775, 1.816),
    55: TorxSpec(11.35, 8.05, 5.015, 0.867, 2.667),
    60: TorxSpec(13.45, 9.60, 5.805, 1.067, 2.883),
    70: TorxSpec(15.70, 11.20, 6.815, 1.194, 3.477),
    80: TorxSpec(17.75, 12.80, 7.75, 1.526, 3.627),
    90: TorxSpec(20.20, 14.40, 8.945, 1.530, 4.468),
    100: TorxSpec(22.40, 16.00, 10.79, 1.720, 4.925),
}

# Robertson/square size 0..4 -> dimensions, in inches.
_ROBERTSON = {
    0: RobertsonSpec(0.0696, 0.0710, 0.063, 0.073, 0.032, 0.038),
    1: RobertsonSpec(0.0900, 0.0910, 0.105, 0.113, 0.057, 0.065),
    2: RobertsonSpec(0.1110, 0.1126, 0.119, 0.140, 0.065, 0.075),
    3: RobertsonSpec(0.1315, 0.1330, 0.155, 0.165, 0.085, 0.095),
    4: RobertsonSpec(0.1895, 0.1910, 0.191, 0.201, 0.090, 0.100),
}


def _phillips_num(size) -> int:
    """Parse a Phillips size (int 0..4 or a string like ``"#2"``) into its integer number."""
    if isinstance(size, str):
        count = int(size.lstrip("#"))
    else:
        count = int(size)
    if count < 0 or count > 4:
        raise ValueError(f"phillips size must be #0..#4, got {size!r}")
    return count


[docs] class ScrewDrive: """Driver-recess masks and their dimensional helpers (BOSL2 screw_drive.scad). Every ``*_mask`` method returns a :class:`~bosl2.shapes3d.Bosl2Solid` positioned with its recess opening at the top and its bottom on the XY plane (BOSL2's ``anchor=BOTTOM``); subtract it from a screw head to cut the recess. Pass ``center=True`` to center the mask vertically instead. """ # ---- Phillips --------------------------------------------------------
[docs] @staticmethod def phillips_mask( size="#2", center: bool = False, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A Phillips driver-recess mask for the given Phillips *size* (BOSL2 phillips_mask()). Args: size: bit size as ``"#0"``..``"#4"`` or an integer ``0``..``4``. center: center the mask vertically (default: bottom on the XY plane). fn/fa/fs: facet controls for the revolved body (default: BOSL2's fixed 36 facets). Examples: A #2 Phillips recess cut into a tapered head: .. pythonscad-example:: from bosl2.screw_drive import ScrewDrive (s3.cyl(diameter1=2, diameter2=8, height=4).down(2) - ScrewDrive.phillips_mask(size="#2")).show() """ if fn is None and fa is None and fs is None: fn = 36 # BOSL2 revolves the phillips body at a fixed $fn=36 spec = _PHILLIPS[_phillips_num(size)] shaft, b, e, g = spec.shaft, spec.b, spec.e, spec.g alpha, beta, gamma = spec.alpha, spec.beta, _PH_GAMMA h1 = _adj_ang_to_opp(g / 2, _PH_BOT_ANGLE) # height of the small conical tip h2 = _adj_ang_to_opp((shaft - g) / 2, 90 - _PH_SIDE_ANGLE) # height of the larger cone length = h1 + h2 h3 = _adj_ang_to_opp(b / 2, _PH_BOT_ANGLE) # height where the cutout starts p0 = [0.0, 0.0] p1 = [_adj_ang_to_opp(e / 2, 90 - alpha / 2), -e / 2] p2 = [ p1[0] + _adj_ang_to_opp((shaft - e) / 2, 90 - gamma / 2), p1[1] - (shaft - e) / 2, ] cut_path = [p0, p1, p2, [p2[0], -p2[1]], [p1[0], -p1[1]]] # One cutout wing: extruded profile, dropped 1mm, tilted by beta, raised to h3. wing = _opolygon(cut_path).linear_extrude(height=length + 2) wing = wing.translate([0, 0, -1]).rotate([0, beta, 0]).translate([0, 0, h3]) cutter = _union(wing.multmatrix(m.tolist()) for m in zrot_copies(sides=4, radius=b / 2)) cutter = cutter.rotate([0, 0, 45]) body = _orotate_extrude( _opolygon([[0, 0], [g / 2, h1], [shaft / 2, length], [0, length]]), fn=fn, fa=fa, fs=fs, ) mask = Bosl2Solid(body - cutter, size=[shaft, shaft, length]) return mask.down(length / 2) if center else mask
[docs] @staticmethod def phillips_depth(size, diameter: float): """ Recess depth needed to reach diameter *diameter* for a Phillips *size*, or ``None`` (BOSL2 phillips_depth()). """ spec = _PHILLIPS[_phillips_num(size)] shaft, g = spec.shaft, spec.g h1 = _adj_ang_to_opp(g / 2, _PH_BOT_ANGLE) if diameter >= shaft or diameter < g: return None return (diameter - g) / 2 / math.tan(math.radians(_PH_SIDE_ANGLE)) + h1
[docs] @staticmethod def phillips_diam(size, depth: float): """ Recess diameter at the top when cut to *depth* for a Phillips *size*, or ``None`` (BOSL2 phillips_diam()). """ spec = _PHILLIPS[_phillips_num(size)] shaft, g = spec.shaft, spec.g h1 = _adj_ang_to_opp(g / 2, _PH_BOT_ANGLE) h2 = _adj_ang_to_opp((shaft - g) / 2, 90 - _PH_SIDE_ANGLE) if depth < h1 or depth >= h1 + h2: return None return 2 * math.tan(math.radians(_PH_SIDE_ANGLE)) * (depth - h1) + g
# ---- Hex (Allen) -----------------------------------------------------
[docs] @staticmethod def hex_drive_mask(size: float, length: float, slop: float = 0.0, center: bool = False) -> Bosl2Solid: """A hex (Allen) driver-recess mask, *size* across flats, *length* tall (BOSL2 hex_drive_mask()). The recess is slightly oversized per the ISO standard; *slop* enlarges it by a further ``2 * slop``. """ realsize = 1.0072 * size + 0.0341 + 2 * slop # empirical fit to the ISO standard solid = hexagon(inner_diameter=realsize).linear_extrude(height=length, center=center) return Bosl2Solid(solid.shape, size=[realsize, realsize, length])
# ---- Torx ------------------------------------------------------------
[docs] @staticmethod def torx_info(size: int) -> TorxSpec: """ The :class:`TorxSpec` (outer_diameter/inner_diameter/depth/tip_rounding/inner_rounding) for a Torx *size* (BOSL2 torx_info()). """ try: return _TORX[int(size)] except (KeyError, ValueError): raise ValueError(f"Unsupported Torx size: {size!r}")
[docs] @staticmethod def torx_diam(size: int) -> float: """Outer diameter of a Torx *size* profile (BOSL2 torx_diam()).""" return ScrewDrive.torx_info(size).outer_diameter
[docs] @staticmethod def torx_depth(size: int) -> float: """Typical drive-hole depth for a Torx *size* (BOSL2 torx_depth()).""" return ScrewDrive.torx_info(size).depth
[docs] @staticmethod def torx_mask2d(size: int) -> Bosl2Solid: """The 2-D profile of a Torx *size* driver (BOSL2 torx_mask2d()).""" return Bosl2Solid(ScrewDrive._torx_profile(size))
@staticmethod def _torx_profile(size: int): spec = ScrewDrive.torx_info(size) outer_diameter, id_, tip, rounding = ( spec.outer_diameter, spec.inner_diameter, spec.tip_rounding, spec.inner_rounding, ) base = outer_diameter - 2 * tip fn = int(_quantup(_frag_count(outer_diameter / 2), 12)) # Six outward lobes: two rotated copies of a hull of three tip circles, plus the base circle. tip_circles = [ circle(radius=tip, fn=fn // 2).translate([base / 2, 0]).multmatrix(m.tolist()) for m in zrot_copies(sides=3) ] tri = _hull2d(tip_circles) lobes = _union(tri.multmatrix(m.tolist()) for m in zrot_copies(sides=2)) solid = circle(diameter=base, fn=fn) | lobes # Six inner rounding cutouts. cut = _union( circle(radius=rounding, fn=fn) .translate([id_ / 2 + rounding, 0]) .rotate([0, 0, 180 / 6]) .multmatrix(m.tolist()) for m in zrot_copies(sides=6) ) return solid - cut
[docs] @staticmethod def torx_mask(size: int, length: float = 5.0, center: bool = False) -> Bosl2Solid: """A Torx driver-recess mask: the 2-D profile extruded *length* tall (BOSL2 torx_mask()). Examples: A T30 Torx tip: .. pythonscad-example:: from bosl2.screw_drive import ScrewDrive ScrewDrive.torx_mask(size=30, length=10).show() """ outer_diameter = ScrewDrive.torx_diam(size) solid = ScrewDrive._torx_profile(size).linear_extrude(height=length, center=center) return Bosl2Solid(solid.shape, size=[outer_diameter, outer_diameter, length])
# ---- Robertson / square ---------------------------------------------
[docs] @staticmethod def robertson_mask(size: int, extra: float = 1.0, angle: float = 2.5, slop: float = 0.0) -> Bosl2Solid: """A Robertson/square driver-recess mask for square-drive *size* ``0``..``4`` (BOSL2 robertson_mask()). Args: size: square-drive size, an integer ``0``..``4``. extra: extra length of drive mask beyond the nominal depth. angle: taper angle of each face (default 2.5, from BOSL2's print tests). slop: enlarge the recess by ``2 * slop``. """ if not (isinstance(size, int) and 0 <= size <= 4): raise ValueError(f"robertson size must be an int 0..4, got {size!r}") spec = _ROBERTSON[size] M = spec.m * INCH # across flats T = spec.t * INCH # depth F = spec.f * INCH # flat-to-taper transition height = T + extra m_slop = M + 2 * slop m_top = m_slop + 2 * _adj_ang_to_opp(F + extra, angle) m_bot = m_slop - 2 * _adj_ang_to_opp(T - F, angle) tapered = prismoid([m_bot, m_bot], [m_top, m_top], height=height, anchor=BOTTOM) cone = cyl( diameter1=0, diameter2=m_slop / (T - F) * math.sqrt(2) * height, height=height, anchor=BOTTOM, ) return (tapered & cone).down(T)