# 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/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. Each drive type is represented by its
# own class (e.g. :class:`PhillipsMask`, :class:`HexDriveMask`) with :meth:`shape` and :meth:`show`
# methods. Dimensional data is also available from the :class:`TorxSpec` and
# :class:`PhillipsSpec` dataclasses (``.diam`` / ``.depth`` and ``.depth(diameter)`` /
# ``.diam(depth)``).
#
# 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.
# DocCategory: Parts library
# FileGroup: BOSL2
"""Phillips, hex, Torx and Robertson driver-recess masks."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from pybosl2._helpers import frag_count as _frag_count
from pybosl2._helpers import quantup, union
from pybosl2._native import native
from pybosl2.constants import BOTTOM, INCH
from pybosl2.distributors import DistributableMatrix
from pybosl2.shapes2d import circle, hexagon
from pybosl2.shapes2d import hull as _hull2d
from pybosl2.shapes3d import cyl, prismoid
from pybosl2.shapes3d.base import Bosl2Solid
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__ = [
"HexDriveMask",
"PhillipsMask",
"RobertsonMask",
"TorxMask",
"TorxMask2d",
"PhillipsSpec",
"TorxSpec",
"RobertsonSpec",
"hex_mask",
]
def _adj_ang_to_opp(adj: float, angle: float) -> float:
"""Return 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: list[Any] | Any) -> Any:
"""Boolean union of a non-empty iterable of shapes."""
return union(list(shapes) if not isinstance(shapes, list) else shapes)
# ---------------------------------------------------------------------------
# Section: dimension tables (transcribed from screw_drive.scad)
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class PhillipsSpec:
"""Phillips recess geometry for one bit size (ISO 4757). See :class:`PhillipsMask`.
Construct with the size directly: ``PhillipsSpec(2)`` or ``PhillipsSpec("#2")``.
"""
shaft: float
b: float
e: float
g: float
alpha: float
beta: float
def __init__(self, size: str | int = "#2") -> None:
"""Look up the Phillips size from the ISO 4757 table.
Args:
size: Bit size as ``"#0"``..``"#4"`` or an integer ``0``..``4``.
Raises:
ValueError: If the size is outside the 0..4 range.
"""
count = _phillips_num(size)
spec = _PHILLIPS[count]
object.__setattr__(self, "shaft", spec["shaft"])
object.__setattr__(self, "b", spec["b"])
object.__setattr__(self, "e", spec["e"])
object.__setattr__(self, "g", spec["g"])
object.__setattr__(self, "alpha", spec["alpha"])
object.__setattr__(self, "beta", spec["beta"])
[docs]
def depth(self, diameter: float) -> float | None:
"""Recess depth needed to reach *diameter* for this Phillips size, or ``None``.
Args:
diameter: Target diameter in mm.
Returns:
Depth in mm, or None if *diameter* is outside the shaft/g range.
"""
h1 = _adj_ang_to_opp(self.g / 2, _PH_BOT_ANGLE)
if diameter >= self.shaft or diameter < self.g:
return None
return (diameter - self.g) / 2 / math.tan(math.radians(_PH_SIDE_ANGLE)) + h1
[docs]
def diam(self, depth: float) -> float | None:
"""Recess diameter at the top when cut to *depth* for this Phillips size, or ``None``.
Args:
depth: Depth in mm.
Returns:
Diameter in mm, or None if *depth* is outside the valid range.
"""
h1 = _adj_ang_to_opp(self.g / 2, _PH_BOT_ANGLE)
h2 = _adj_ang_to_opp((self.shaft - self.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) + self.g
[docs]
@dataclass(frozen=True)
class TorxSpec:
"""Torx driver dimensions for one size (ISO 14583).
Construct with the size directly: ``TorxSpec(30)``.
"""
outer_diameter: float
inner_diameter: float
depth: float
tip_rounding: float
inner_rounding: float
def __init__(self, size: int) -> None:
"""Look up the Torx size from the ISO 14583 table.
Args:
size: Torx size (1..100).
Raises:
ValueError: If the size is not in the table.
"""
try:
spec = _TORX[int(size)]
except (KeyError, ValueError):
raise ValueError(f"Unsupported Torx size: {size!r}") from None
object.__setattr__(self, "outer_diameter", spec[0])
object.__setattr__(self, "inner_diameter", spec[1])
object.__setattr__(self, "depth", spec[2])
object.__setattr__(self, "tip_rounding", spec[3])
object.__setattr__(self, "inner_rounding", spec[4])
@property
def diam(self) -> float:
"""Outer diameter in mm."""
return self.outer_diameter
[docs]
def as_tuple(self) -> tuple[float, float, float, float, float]:
"""``(outer_diameter, inner_diameter, depth, tip_rounding, inner_rounding)``."""
return (self.outer_diameter, self.inner_diameter, self.depth, self.tip_rounding, self.inner_rounding)
def _profile(self) -> Any:
"""Return the native 2-D CSG profile for this Torx size."""
outer_diameter = self.outer_diameter
id_ = self.inner_diameter
tip = self.tip_rounding
rounding = self.inner_rounding
base = outer_diameter - 2 * tip
fn_val = int(quantup(_frag_count(outer_diameter / 2), 12))
tip_circles = [
circle(radius=tip, fn=fn_val // 2).translate([base / 2, 0]).multmatrix(m.tolist())
for m in DistributableMatrix.zrot_copies(num_copies=3)
]
tri = _hull2d(tip_circles)
lobes = _union(tri.multmatrix(m.tolist()) for m in DistributableMatrix.zrot_copies(num_copies=2))
solid = circle(diameter=base, fn=fn_val) | lobes
cut = _union(
circle(radius=rounding, fn=fn_val)
.translate([id_ / 2 + rounding, 0])
.rotate([0, 0, 180 / 6])
.multmatrix(m.tolist())
for m in DistributableMatrix.zrot_copies(num_copies=6)
)
return solid - cut
@dataclass(frozen=True)
class RobertsonSpec:
"""Robertson/square-drive dimensions for one size, in inches.
Construct with the size directly: ``RobertsonSpec(2)``.
``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
def __init__(self, size: int) -> None:
"""Look up the Robertson size from the table.
Args:
size: Square-drive size, as ``0``..``4``.
Raises:
ValueError: If the size is outside the 0..4 range.
"""
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]
object.__setattr__(self, "m_min", spec[0])
object.__setattr__(self, "m_max", spec[1])
object.__setattr__(self, "t_min", spec[2])
object.__setattr__(self, "t_max", spec[3])
object.__setattr__(self, "f_min", spec[4])
object.__setattr__(self, "f_max", spec[5])
@property
def m(self) -> float:
"""Across flats in mm (nominal)."""
return (self.m_min + self.m_max) / 2
@property
def t(self) -> float:
"""Depth in mm (nominal)."""
return (self.t_min + self.t_max) / 2
@property
def f(self) -> float:
"""Flat-to-taper transition in mm (nominal)."""
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: dict[int, dict[str, float]] = {
0: {"shaft": 3, "b": 0.61, "e": 0.31, "g": 0.81, "alpha": 136, "beta": 7.00},
1: {"shaft": 4.5, "b": 0.97, "e": 0.435, "g": 1.27, "alpha": 138, "beta": 7.00},
2: {"shaft": 6, "b": 1.47, "e": 0.815, "g": 2.29, "alpha": 140, "beta": 5.75},
3: {"shaft": 8, "b": 2.41, "e": 2.005, "g": 3.81, "alpha": 146, "beta": 5.75},
4: {"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: dict[int, tuple[float, float, float, float, float]] = {
1: (0.90, 0.65, 0.40, 0.059, 0.201),
2: (1.00, 0.73, 0.44, 0.069, 0.224),
3: (1.20, 0.87, 0.53, 0.081, 0.266),
4: (1.35, 0.98, 0.59, 0.090, 0.308),
5: (1.48, 1.08, 0.65, 0.109, 0.330),
6: (1.75, 1.27, 0.775, 0.132, 0.383),
7: (2.08, 1.50, 0.886, 0.161, 0.446),
8: (2.40, 1.75, 1.0, 0.190, 0.510),
9: (2.58, 1.87, 1.078, 0.207, 0.554),
10: (2.80, 2.05, 1.142, 0.229, 0.598),
15: (3.35, 2.40, 1.2, 0.267, 0.716),
20: (3.95, 2.85, 1.4, 0.305, 0.859),
25: (4.50, 3.25, 1.61, 0.375, 0.920),
27: (5.07, 3.65, 1.84, 0.390, 1.108),
30: (5.60, 4.05, 2.22, 0.451, 1.194),
40: (6.75, 4.85, 2.63, 0.546, 1.428),
45: (7.93, 5.64, 3.115, 0.574, 1.796),
50: (8.95, 6.45, 3.82, 0.775, 1.816),
55: (11.35, 8.05, 5.015, 0.867, 2.667),
60: (13.45, 9.60, 5.805, 1.067, 2.883),
70: (15.70, 11.20, 6.815, 1.194, 3.477),
80: (17.75, 12.80, 7.75, 1.526, 3.627),
90: (20.20, 14.40, 8.945, 1.530, 4.468),
100: (22.40, 16.00, 10.79, 1.720, 4.925),
}
# Robertson/square size 0..4 -> dimensions, in inches.
_ROBERTSON: dict[int, tuple[float, float, float, float, float, float]] = {
0: (0.0696, 0.0710, 0.063, 0.073, 0.032, 0.038),
1: (0.0900, 0.0910, 0.105, 0.113, 0.057, 0.065),
2: (0.1110, 0.1126, 0.119, 0.140, 0.065, 0.075),
3: (0.1315, 0.1330, 0.155, 0.165, 0.085, 0.095),
4: (0.1895, 0.1910, 0.191, 0.201, 0.090, 0.100),
}
def _phillips_num(size: str | int) -> int:
"""Parse a Phillips size (int 0..4 or a string like ``"#2"``) into its integer number."""
count = int(size.lstrip("#")) if isinstance(size, str) else int(size)
if count < 0 or count > 4:
raise ValueError(f"phillips size must be #0..#4, got {size!r}")
return count
[docs]
class PhillipsMask:
"""Phillips driver-recess mask for a given bit size (BOSL2 phillips_mask()).
The mask is positioned with its opening at the top and its bottom on the XY
plane (BOSL2's ``anchor=BOTTOM``). Pass ``center=True`` to center the mask
vertically instead.
Examples:
A #2 Phillips recess cut into a tapered head:
.. pythonscad-example::
from pybosl2.parts.screw_drive import PhillipsMask
from pybosl2.solid import cyl
(cyl(diameter1=2, diameter2=8, height=4).down(2) - PhillipsMask(size="#2").shape()).show()
"""
def __init__(
self,
size: str | int = "#2",
center: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
l: float | None = None, # noqa: E741
) -> None:
"""Create a Phillips driver-recess 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: facet controls for the revolved body (default: BOSL2's fixed 36 facets).
fa: facet controls for the revolved body (default: BOSL2's fixed 36 facets).
fs: facet controls for the revolved body (default: BOSL2's fixed 36 facets).
l: overall length of the recess, overriding the computed length from the spec.
Returns:
None.
Raises:
ValueError: If *size* is not a valid Phillips bit size (#0..#4).
"""
self._size: str | int = size
self._center: bool = center
self._fn: int | None = fn
self._fa: float | None = fa
self._fs: float | None = fs
self._l: float | None = l
_fn = fn
if fn is None and fa is None and fs is None:
_fn = 36
spec = PhillipsSpec(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)
h2 = _adj_ang_to_opp((shaft - g) / 2, 90 - _PH_SIDE_ANGLE)
length = h1 + h2
h3 = _adj_ang_to_opp(b / 2, _PH_BOT_ANGLE)
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]]]
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 DistributableMatrix.zrot_copies(num_copies=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])
self._solid: Bosl2Solid = mask.down(length / 2) if center else mask
self._shaft: float = shaft
self._length: float = length
@property
def size(self) -> str | int:
"""Phillips bit size."""
return self._size
@property
def center(self) -> bool:
"""Whether the mask is centered vertically."""
return self._center
@property
def fn(self) -> int | None:
"""Facet count override."""
return self._fn
@property
def fa(self) -> float | None:
"""Minimum facet angle."""
return self._fa
@property
def fs(self) -> float | None:
"""Minimum facet size."""
return self._fs
@property
def l(self) -> float | None: # noqa: E743
"""Overall length of the recess."""
return self._l
@property
def shaft(self) -> float:
"""Shaft/outer diameter."""
return self._shaft
@property
def length(self) -> float:
"""Computed height of the recess."""
return self._length
[docs]
def shape(self) -> Bosl2Solid:
"""Return the Phillips driver-recess mask geometry.
Examples:
Generate an STL of a #2 Phillips recess:
.. pythonscad-example::
from pybosl2.parts.screw_drive import PhillipsMask
PhillipsMask(size="#2").shape().show()
"""
return self._solid
[docs]
def show(self) -> None:
"""Display the Phillips driver-recess mask in the viewer."""
self._solid.show()
[docs]
class HexDriveMask:
"""Hex (Allen) driver-recess mask (BOSL2 hex_drive_mask()).
The recess is slightly oversized per the ISO standard; *slop* enlarges it
by a further ``2 * slop``.
Examples:
A 2.5 mm hex drive mask, 5 mm deep:
.. pythonscad-example::
from pybosl2.parts.screw_drive import HexDriveMask
HexDriveMask(size=2.5, l=5).show()
"""
def __init__(self, size: float, l: float, slop: float = 0.0, center: bool = False) -> None: # noqa: E741
"""Create a hex (Allen) driver-recess mask.
Args:
size: across flats dimension of the hex key.
l: height of the recess.
slop: enlarge the recess by ``2 * slop``.
center: center the mask vertically (default: bottom on the XY plane).
Returns:
None.
"""
self._size: float = size
self._l: float = l
self._slop: float = slop
self._center: bool = center
realsize = 1.0072 * size + 0.0341 + 2 * slop
solid = hexagon(inner_diameter=realsize).linear_extrude(height=l, center=center)
self._solid: Bosl2Solid = Bosl2Solid(solid.shape, size=[realsize, realsize, l])
self._realsize: float = realsize
@property
def size(self) -> float:
"""Across flats dimension."""
return self._size
@property
def l(self) -> float: # noqa: E743
"""Height of the recess."""
return self._l
@property
def slop(self) -> float:
"""Slop enlargement."""
return self._slop
@property
def center(self) -> bool:
"""Whether the mask is centered vertically."""
return self._center
@property
def realsize(self) -> float:
"""Actual across-flats dimension after ISO oversizing."""
return self._realsize
[docs]
def shape(self) -> Bosl2Solid:
"""Return the hex drive mask geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the hex drive mask in the viewer."""
self._solid.show()
hex_mask = HexDriveMask #: Alias for :class:`HexDriveMask`.
[docs]
class TorxMask2d:
"""2-D profile of a Torx driver for a given size (BOSL2 torx_mask2d()).
This is a 2-D shape; to generate an STL, extrude it with
:meth:`~pybosl2.shapes2d.base.Bosl2Shape2D.linear_extrude` first.
Examples:
Generate an STL of a T30 Torx 2-D profile extruded 10 mm:
.. pythonscad-example::
from pybosl2.parts.screw_drive import TorxMask2d
TorxMask2d(size=30).shape().linear_extrude(height=10).show()
"""
def __init__(self, size: int) -> None:
"""Create a 2-D Torx profile.
Args:
size: Torx size number (e.g. 10, 20, 30).
Returns:
None.
"""
self._size: int = size
spec = TorxSpec(size)
self._solid: Bosl2Solid = Bosl2Solid(spec._profile())
@property
def size(self) -> int:
"""Torx size number."""
return self._size
[docs]
def shape(self) -> Bosl2Solid:
"""Return the 2-D Torx profile.
Examples:
Generate an STL of a T30 Torx 2-D profile extruded 10 mm:
.. pythonscad-example::
from pybosl2.parts.screw_drive import TorxMask2d
TorxMask2d(size=30).shape().linear_extrude(height=10).show()
"""
return self._solid
[docs]
def show(self) -> None:
"""Display the 2-D Torx profile in the viewer."""
self._solid.show()
[docs]
class TorxMask:
"""Torx driver-recess mask: the 2-D profile extruded *l* tall (BOSL2 torx_mask()).
Examples:
A T30 Torx tip:
.. pythonscad-example::
from pybosl2.parts.screw_drive import TorxMask
TorxMask(size=30, l=10).show()
"""
def __init__(self, size: int, l: float = 5.0, center: bool = False) -> None: # noqa: E741
"""Create a Torx driver-recess mask.
Args:
size: Torx size number (e.g. 10, 20, 30).
l: height of the recess.
center: center the mask vertically (default: bottom on the XY plane).
Returns:
None.
"""
self._size: int = size
self._l: float = l
self._center: bool = center
spec = TorxSpec(size)
outer_diameter = spec.diam
solid = spec._profile().linear_extrude(height=l, center=center)
self._solid: Bosl2Solid = Bosl2Solid(solid.shape, size=[outer_diameter, outer_diameter, l])
self._outer_diameter: float = outer_diameter
@property
def size(self) -> int:
"""Torx size number."""
return self._size
@property
def l(self) -> float: # noqa: E743
"""Height of the recess."""
return self._l
@property
def center(self) -> bool:
"""Whether the mask is centered vertically."""
return self._center
@property
def outer_diameter(self) -> float:
"""Outer diameter of the Torx profile."""
return self._outer_diameter
[docs]
def shape(self) -> Bosl2Solid:
"""Return the Torx driver-recess mask geometry.
Examples:
Generate an STL of a T30 Torx mask:
.. pythonscad-example::
from pybosl2.parts.screw_drive import TorxMask
TorxMask(size=30, l=10).shape().show()
"""
return self._solid
[docs]
def show(self) -> None:
"""Display the Torx driver-recess mask in the viewer."""
self._solid.show()
[docs]
class RobertsonMask:
"""Robertson/square driver-recess mask for square-drive sizes ``0``..``4`` (BOSL2 robertson_mask()).
Examples:
A #2 Robertson recess:
.. pythonscad-example::
from pybosl2.parts.screw_drive import RobertsonMask
RobertsonMask(size=2).show()
"""
def __init__(self, size: str | int, l: float | None = None, angle: float = 2.5, slop: float = 0.0) -> None: # noqa: E741
"""Create a Robertson/square driver-recess mask.
Args:
size: square-drive size, as ``"#2"`` / ``"2"`` or integer ``2``.
l: length of drive mask.
angle: taper angle of each face (default 2.5, from BOSL2's print tests).
slop: enlarge the recess by ``2 * slop``.
Returns:
None.
Raises:
ValueError: If *size* is not a valid Robertson size (0..4).
"""
if isinstance(size, str):
size = int(size.replace("#", ""))
spec = RobertsonSpec(size)
across_flats = spec.m * INCH
robertson_depth = spec.t * INCH
robertson_flat = spec.f * INCH
extra = l - robertson_depth if l is not None else 1.0
height = robertson_depth + extra
m_slop = across_flats + 2 * slop
m_top = m_slop + 2 * _adj_ang_to_opp(robertson_flat + extra, angle)
m_bot = m_slop - 2 * _adj_ang_to_opp(robertson_depth - robertson_flat, angle)
tapered = prismoid([m_bot, m_bot], [m_top, m_top], height=height, anchor=BOTTOM)
cone = cyl(
diameter1=0,
diameter2=m_slop / (robertson_depth - robertson_flat) * math.sqrt(2) * height,
height=height,
anchor=BOTTOM,
)
self._size: int = size
self._l: float | None = l
self._angle: float = angle
self._slop: float = slop
self._solid: Bosl2Solid = (tapered & cone).down(robertson_depth)
@property
def size(self) -> int:
"""Robertson size number (0..4)."""
return self._size
@property
def l(self) -> float | None: # noqa: E743
"""Length of drive mask."""
return self._l
@property
def angle(self) -> float:
"""Taper angle of each face."""
return self._angle
@property
def slop(self) -> float:
"""Slop enlargement."""
return self._slop
[docs]
def shape(self) -> Bosl2Solid:
"""Return the Robertson driver-recess mask geometry.
Examples:
Generate an STL of a #2 Robertson recess:
.. pythonscad-example::
from pybosl2.parts.screw_drive import RobertsonMask
RobertsonMask(size=2).shape().show()
"""
return self._solid
[docs]
def show(self) -> None:
"""Display the Robertson driver-recess mask in the viewer."""
self._solid.show()