Source code for bosl2.screws

# 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/screws.py
#    Pure-Python port of the core of BOSL2's screws.scad, built on top of
#    :class:`~bosl2.threading.Threading`. The :class:`Screws` class turns a metric screw name (``"M6"``,
#    ``"M8x1"``) into ready-to-print geometry: :meth:`Screws.screw` (a threaded/plain shaft plus a
#    socket / hex / button / pan / flat / setscrew head with an optional hex or slot drive recess),
#    :meth:`Screws.nut` (a hex/square nut with a matching threaded hole), and :meth:`Screws.screw_hole`
#    (a clearance/counterbore/countersink hole cutter). :meth:`Screws.screw_info` returns the resolved
#    dimensions as a plain dict.
#
#    The dimension tables (ISO coarse/fine thread pitches, and ISO head sizes for socket cap ISO 4762,
#    hex ISO 4017, button ISO 7380, pan ISO 14583, countersunk ISO 10642/7046, setscrew, and hex/square
#    nuts ISO 4032/4035/4034) are transcribed verbatim from screws.scad and checked in
#    tests/test_screws.py. The threads themselves come from the watertight-polyhedron thread generator
#    in threading.py. Phillips/Torx (and hex/Robertson) drive-recess masks are ported separately in
#    screw_drive.py (the ScrewDrive class), though not yet wired into screw()'s drive= argument.
#    Not ported (a follow-up): UTS/imperial specs, the named-anchor system, shoulder screws, and
#    per-tolerance thread-class diameters.
#
# FileSummary: Metric screws, nuts and screw holes built on the threading port.
# FileGroup: BOSL2

from __future__ import annotations

import math
from dataclasses import dataclass

from bosl2.shapes3d import Bosl2Solid, cuboid, cyl, regular_prism

__all__ = [
    "Screws",
    "ThreadPitches",
    "HexHead",
    "SocketHead",
    "ButtonHead",
    "PanHead",
    "FlatHead",
    "NutSpec",
]


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

# Thread-class name -> the ThreadPitches attribute holding that pitch.
_THREAD_ALIAS = {
    "coarse": "coarse",
    "fine": "fine",
    "medium": "fine",
    "extra fine": "extra_fine",
    "extrafine": "extra_fine",
    "extra-fine": "extra_fine",
    "super fine": "super_fine",
    "superfine": "super_fine",
    "super-fine": "super_fine",
}


@dataclass(frozen=True)
class ThreadPitches:
    """
    ISO metric thread pitches (mm) for one nominal diameter; ``None`` where a class is
    undefined.
    """

    coarse: float
    fine: float | None = None
    extra_fine: float | None = None
    super_fine: float | None = None

    def pitch(self, thread: str = "coarse") -> float:
        """The pitch for a thread class (``"coarse"``/``"fine"``/``"extra-fine"``/``"super-fine"``),
        falling back to coarse if the requested class is undefined for this size."""
        return getattr(self, _THREAD_ALIAS.get(str(thread).lower(), "coarse")) or self.coarse


@dataclass(frozen=True)
class HexHead:
    """Hex cap head (ISO 4017)."""

    width: float  # across-flats
    height: float


@dataclass(frozen=True)
class SocketHead:
    """
    Socket cap head (ISO 4762). Head height == nominal diameter; hex-drive depth == diameter/2.
    """

    head_d: float
    hex_drive: float  # hex drive across-flats


@dataclass(frozen=True)
class ButtonHead:
    """Button head (ISO 7380)."""

    head_d: float
    height: float
    hex_drive: float
    hex_depth: float


@dataclass(frozen=True)
class PanHead:
    """Pan head (ISO 14583)."""

    head_d: float
    height: float


@dataclass(frozen=True)
class FlatHead:
    """Countersunk / flat head (ISO 10642 / ISO 7046), 90-degree included angle."""

    sharp_d: float  # theoretical sharp diameter
    actual_d: float  # actual (truncated) diameter


@dataclass(frozen=True)
class NutSpec:
    """Hex / square nut (ISO 4032 / 4035 / 4034); ``None`` where a thickness class is undefined."""

    width: float  # across-flats
    normal: float
    thin: float | None
    thick: float | None


# nominal diameter -> its thread pitches.
_ISO_THREAD = {
    1: ThreadPitches(0.25, 0.2),
    1.2: ThreadPitches(0.25, 0.2),
    1.4: ThreadPitches(0.3, 0.2),
    1.6: ThreadPitches(0.35, 0.2),
    1.8: ThreadPitches(0.35, 0.2),
    2: ThreadPitches(0.4, 0.25),
    2.2: ThreadPitches(0.45, 0.25),
    2.5: ThreadPitches(0.45, 0.35),
    3: ThreadPitches(0.5, 0.35),
    3.5: ThreadPitches(0.6, 0.35),
    4: ThreadPitches(0.7, 0.5),
    5: ThreadPitches(0.8, 0.5),
    6: ThreadPitches(1, 0.75),
    7: ThreadPitches(1, 0.75),
    8: ThreadPitches(1.25, 1, 0.75),
    9: ThreadPitches(1.25, 1, 0.75),
    10: ThreadPitches(1.5, 1.25, 1, 0.75),
    11: ThreadPitches(1.5, 1, 0.75),
    12: ThreadPitches(1.75, 1.5, 1.25, 1),
    14: ThreadPitches(2, 1.5, 1.25, 1),
    16: ThreadPitches(2, 1.5, 1),
    18: ThreadPitches(2.5, 2, 1.5, 1),
    20: ThreadPitches(2.5, 2, 1.5, 1),
    22: ThreadPitches(2.5, 2, 1.5, 1),
    24: ThreadPitches(3, 2, 1.5, 1),
    27: ThreadPitches(3, 2, 1.5, 1),
    30: ThreadPitches(3.5, 3, 2, 1.5),
    33: ThreadPitches(3.5, 3, 2, 1.5),
    36: ThreadPitches(4, 3, 2, 1.5),
    39: ThreadPitches(4, 3, 2, 1.5),
    42: ThreadPitches(4.5, 4, 3, 2),
    48: ThreadPitches(5, 4, 3, 2),
}

_HEX_HEAD = {
    5: HexHead(8, 3.5),
    6: HexHead(10, 4),
    8: HexHead(13, 5.3),
    10: HexHead(17, 6.4),
    12: HexHead(19, 7.5),
    14: HexHead(22, 8.8),
    16: HexHead(24, 10),
    18: HexHead(27, 11.5),
    20: HexHead(30, 12.5),
    24: HexHead(36, 15),
    30: HexHead(46, 18.7),
}

_SOCKET_HEAD = {
    1.6: SocketHead(3, 1.5),
    2: SocketHead(3.8, 1.5),
    2.5: SocketHead(4.5, 2),
    2.6: SocketHead(5, 2),
    3: SocketHead(5.5, 2.5),
    3.5: SocketHead(6.2, 2.5),
    4: SocketHead(7, 3),
    5: SocketHead(8.5, 4),
    6: SocketHead(10, 5),
    7: SocketHead(12, 6),
    8: SocketHead(13, 6),
    10: SocketHead(16, 8),
    12: SocketHead(18, 10),
    14: SocketHead(21, 12),
    16: SocketHead(24, 14),
    18: SocketHead(27, 14),
    20: SocketHead(30, 17),
    22: SocketHead(33, 17),
    24: SocketHead(36, 19),
    27: SocketHead(40, 19),
    30: SocketHead(45, 22),
    33: SocketHead(50, 24),
    36: SocketHead(54, 27),
    42: SocketHead(63, 32),
    48: SocketHead(72, 36),
}

_BUTTON_HEAD = {
    1.6: ButtonHead(2.9, 0.8, 0.9, 0.55),
    2: ButtonHead(3.5, 1.3, 1.3, 0.69),
    2.5: ButtonHead(4.6, 1.5, 1.5, 0.87),
    3: ButtonHead(5.7, 1.65, 2, 1.04),
    3.5: ButtonHead(5.7, 1.65, 2, 1.21),
    4: ButtonHead(7.6, 2.2, 2.5, 1.30),
    5: ButtonHead(9.5, 2.75, 3, 1.56),
    6: ButtonHead(10.5, 3.3, 4, 2.08),
    8: ButtonHead(14, 4.4, 5, 2.60),
    10: ButtonHead(17.5, 5.5, 6, 3.12),
    12: ButtonHead(21, 6.6, 8, 4.16),
    16: ButtonHead(28, 8.8, 10, 5.2),
}

_PAN_HEAD = {
    1.6: PanHead(3.2, 1.3),
    2: PanHead(4, 1.6),
    2.5: PanHead(5, 2),
    3: PanHead(5.6, 2.4),
    3.5: PanHead(7, 3.1),
    4: PanHead(8, 3.1),
    5: PanHead(9.5, 3.8),
    6: PanHead(12, 4.6),
    8: PanHead(16, 6),
    10: PanHead(20, 7.5),
}

_FLAT_HEAD = {
    1.6: FlatHead(3.6, 2.85),
    2: FlatHead(4.4, 3.65),
    2.5: FlatHead(5.5, 4.55),
    3: FlatHead(6.3, 5.35),
    3.5: FlatHead(8.2, 7.12),
    4: FlatHead(9.4, 8.22),
    5: FlatHead(10.4, 9.12),
    6: FlatHead(12.6, 11.085),
    8: FlatHead(17.3, 15.585),
    10: FlatHead(20, 18.04),
    12: FlatHead(24, 21.75),
    14: FlatHead(28, 25.25),
    16: FlatHead(32, 28.75),
    18: FlatHead(36, 32.2),
    20: FlatHead(40, 35.7),
}

# headless setscrew: diameter -> hex drive across-flats (depth == diameter/2)
_SETSCREW = {
    1.4: 0.7,
    1.6: 0.7,
    1.8: 0.7,
    2: 0.9,
    2.5: 1.3,
    3: 1.5,
    4: 2,
    5: 2.5,
    6: 3,
    8: 4,
    10: 5,
    12: 6,
    16: 8,
    20: 10,
}

_NUT = {
    1.6: NutSpec(3.2, 1.3, 1.0, None),
    2: NutSpec(4, 1.6, 1.2, None),
    2.5: NutSpec(5, 2, 1.6, None),
    3: NutSpec(5.5, 2.4, 1.8, None),
    4: NutSpec(7, 3.2, 2.2, None),
    5: NutSpec(8, 4.7, 2.7, 5.1),
    6: NutSpec(10, 5.2, 3.2, 5.7),
    8: NutSpec(13, 6.8, None, 7.5),
    10: NutSpec(16, 8.4, None, 9.3),
    12: NutSpec(18, 10.8, None, 12),
    16: NutSpec(24, 14.8, None, 16.4),
    20: NutSpec(30, 18, None, 20.3),
    24: NutSpec(36, 21.5, None, 23.9),
    30: NutSpec(46, 25.6, None, 28.6),
    36: NutSpec(55, 31, None, 34.7),
}

# ISO 965 clearance holes: fit name -> radial gap fraction expressed as an absolute add per size band.
# BOSL2 scales these by pitch; we approximate the common medium fit with a diameter-based add.
_CLEARANCE = {"close": 0.2, "normal": 0.5, "loose": 1.0}


def _parse_spec(spec, thread="coarse", pitch=None):
    """Resolve *spec* to ``(diameter, pitch)``.

    *spec* may be ``"M6"``, ``"M8x1"`` (explicit pitch), a bare number (treated as the metric
    nominal diameter), or a mapping already carrying ``diameter``/``pitch``.
    """
    if isinstance(spec, dict):
        diameter = float(spec["diameter"])
        sp = spec.get("pitch")
        return diameter, float(sp) if sp is not None else _lookup_pitch(diameter, thread)
    if isinstance(spec, (int, float)):
        diameter = float(spec)
        return diameter, float(pitch) if pitch is not None else _lookup_pitch(diameter, thread)
    s = str(spec).strip().upper()
    if s.startswith("M"):
        s = s[1:]
    if "X" in s:
        dpart, ppart = s.split("X", 1)
        return float(dpart), float(ppart)
    diameter = float(s)
    return diameter, float(pitch) if pitch is not None else _lookup_pitch(diameter, thread)


def _lookup_pitch(diam, thread):
    if diam not in _ISO_THREAD:
        raise ValueError(f"Unknown metric screw size M{diam:g}")
    return float(_ISO_THREAD[diam].pitch(thread))


[docs] class Screws: """Metric screws, nuts and screw holes (BOSL2 screws.scad), built on :class:`~bosl2.threading.Threading`. Every method is a class method returning a :class:`~bosl2.shapes3d.Bosl2Solid`, except :meth:`screw_info`, which returns a plain ``dict`` of resolved dimensions. Screws are built head-up: the shaft occupies ``z in [-length, 0]`` (tip at the bottom) and the head sits above ``z = 0``. """ # -- resolved dimensions ---------------------------------------------------------------
[docs] @staticmethod def screw_info(spec, head: str = "socket", thread: str = "coarse", drive: str = "none", pitch: float | None = None): """Resolve a screw specification to a dict of dimensions. Keys: ``system``, ``diameter``, ``pitch``, ``head``, ``head_size``, ``head_height``, ``head_angle`` (flat heads only), ``drive``, ``drive_size``, ``drive_depth``. """ d, p = _parse_spec(spec, thread, pitch) info = { "system": "ISO", "diameter": d, "pitch": p, "head": head, "drive": drive, "drive_size": None, "drive_depth": None, } if head in (None, "none"): info["head"] = "none" info["head_size"] = None info["head_height"] = 0.0 if drive == "hex": info["drive_size"] = _closest(_SETSCREW, d) info["drive_depth"] = d / 2 elif head == "hex": spec = _closest(_HEX_HEAD, d) info["head_size"], info["head_height"] = spec.width, spec.height elif head in ("socket", "socket ribbed"): spec = _closest(_SOCKET_HEAD, d) info["head_size"], info["head_height"] = spec.head_d, d if drive == "hex": info["drive_size"], info["drive_depth"] = spec.hex_drive, d / 2 elif head == "button": spec = _closest(_BUTTON_HEAD, d) info["head_size"], info["head_height"] = spec.head_d, spec.height if drive == "hex": info["drive_size"], info["drive_depth"] = spec.hex_drive, spec.hex_depth elif head in ("pan", "round"): spec = _closest(_PAN_HEAD, d) info["head_size"], info["head_height"] = spec.head_d, spec.height elif head == "flat": spec = _closest(_FLAT_HEAD, d) info["head_size"] = spec.actual_d info["head_size_sharp"] = spec.sharp_d info["head_angle"] = 90.0 info["head_height"] = (spec.actual_d - d) / 2 # 90-degree cone: radius drop == height else: raise ValueError(f'Unknown head type "{head}"') return info
# -- the screw -------------------------------------------------------------------------
[docs] @staticmethod def screw( spec, length: float, head: str = "socket", drive: str = "none", thread: str = "coarse", thread_len: float | None = None, pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A metric screw: a threaded (or plain) shaft plus a head, with an optional drive recess. *length* is the shaft length below the head (for a flat head, below the surface). Set ``thread="none"`` for a plain unthreaded shank, or ``thread_len`` for a partly-threaded shaft. """ info = Screws.screw_info( spec, head=head, drive=drive, thread="coarse" if thread in (True, False) else thread, pitch=pitch, ) d, _p = info["diameter"], info["pitch"] thread_kind = thread if isinstance(thread, str) else "coarse" # -- shaft: top face at z=0, tip at z=-length ----------------------------------- if thread: from bosl2.threading import Threading _, tp = _parse_spec(spec, thread_kind, pitch) tl = length if (thread_len is None or thread_len >= length) else thread_len shank_len = length - tl shaft = Threading.threaded_rod(d, tl, tp, fn=fn, fa=fa, fs=fs).down(shank_len + tl / 2) if shank_len > 1e-9: shank = cyl(diameter=d, height=shank_len, fn=fn, fa=fa, fs=fs).down(shank_len / 2) shaft = shaft | shank else: shaft = cyl(diameter=d, height=length, fn=fn, fa=fa, fs=fs).down(length / 2) result = shaft head_top = info["head_height"] # top face of the head; 0 for a headless setscrew (recess into shaft) headobj = Screws._make_head(info, fn, fa, fs) if headobj is not None: result = result | headobj recess = Screws._make_recess(info, head_top, fn, fa, fs) if recess is not None: result = result - recess return result
@staticmethod def _make_head(info, fn, fa, fs): head = info["head"] if head in (None, "none"): return None hh = info["head_height"] hs = info["head_size"] if head == "hex": return regular_prism(6, height=hh, inner_diameter=hs, fn=fn, fa=fa, fs=fs).up(hh / 2) if head in ("socket", "socket ribbed"): return cyl(diameter=hs, height=hh, chamfer2=hs / 20, fn=fn, fa=fa, fs=fs).up(hh / 2) if head == "button": rnd = min(hh * 0.9, hs / 2 * 0.9) return cyl(diameter=hs, height=hh, rounding2=rnd, fn=fn, fa=fa, fs=fs).up(hh / 2) if head in ("pan", "round"): return cyl(diameter=hs, height=hh, rounding2=0.2 * hs, fn=fn, fa=fa, fs=fs).up(hh / 2) if head == "flat": # 90-degree countersunk cone: shaft diameter at the bottom, head diameter at the surface. return cyl( diameter1=info["diameter"], diameter2=hs, height=hh, fn=fn, fa=fa, fs=fs, ).up(hh / 2) return None @staticmethod def _make_recess(info, head_top, fn, fa, fs): drive = info.get("drive") size = info.get("drive_size") depth = info.get("drive_depth") if drive in (None, "none") or not size or not depth: return None eps = 0.02 if drive == "hex": rec = regular_prism(6, height=depth + eps, inner_diameter=size, fn=fn, fa=fa, fs=fs) elif drive == "slot": width = size if size else max(0.6, info["diameter"] / 6) length = (info["head_size"] or info["diameter"]) + 2 rec = cuboid([length, width, depth + eps]) else: return None # place the recess so its open mouth is flush with the top of the head (or shaft top for setscrews) return rec.up(head_top - (depth + eps) / 2 + eps / 2) # -- the nut ---------------------------------------------------------------------------
[docs] @staticmethod def nut( spec, thickness: float | str = "normal", shape="hex", thread: str = "coarse", nutwidth: float | None = None, slop: float = 0.0, pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A hex or square nut with a threaded hole matching *spec* (BOSL2 nut()). *thickness* is ``"normal"``, ``"thin"``, ``"thick"`` or a number (mm). *nutwidth* overrides the standard across-flats width. *slop* adds radial clearance to the threaded hole. """ from bosl2.threading import Threading d, p = _parse_spec(spec, thread, pitch) width, th = _nut_dims(d, thickness, nutwidth) return Threading.threaded_nut(width, d, th, p, shape=shape, slop=slop, fn=fn, fa=fa, fs=fs)
# -- clearance / countersink / counterbore hole cutter ---------------------------------
[docs] @staticmethod def screw_hole( spec, length: float, head: str = "none", counterbore=0.0, fit: str = "normal", thread: str = "none", pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A hole cutter for a screw: clearance shaft, plus optional countersink (flat head) or counterbore. Returns a solid to *subtract* from your part. The clearance shaft occupies ``z in [-length, 0]`` with its mouth at ``z = 0``; countersinks/counterbores open upward from there. Set ``thread="coarse"`` for a tapped (threaded) hole instead of a clearance hole. """ use_thread = thread and str(thread).lower() not in ("none", "false", "no", "") d, p = _parse_spec(spec, "coarse" if thread in (True, False) else (thread if use_thread else "coarse"), pitch) if use_thread: from bosl2.threading import Threading # a tapped hole: cut with the rod's thread tap (major + a touch of clearance) cutter = Threading.threaded_rod(d + 0.0, length, p, fn=fn, fa=fa, fs=fs).down(length / 2) else: gap = _CLEARANCE.get(str(fit).lower(), 0.5) cutter = cyl(diameter=d + 2 * gap, height=length, fn=fn, fa=fa, fs=fs).down(length / 2) if head == "flat": info = Screws.screw_info(spec, head="flat", pitch=pitch) hs = info["head_size"] csk_h = (hs - d) / 2 csink = cyl( diameter1=d, diameter2=hs, height=csk_h + 0.02, fn=fn, fa=fa, fs=fs, ).up((csk_h + 0.02) / 2 - 0.01) cutter = cutter | csink elif counterbore and counterbore > 0: info = Screws.screw_info(spec, head=head if head not in (None, "none") else "socket", pitch=pitch) hd = info["head_size"] if head == "hex" else (info["head_size"] or 2 * d) if head == "hex": hd = 2 * hd / math.sqrt(3) # across-corners for a hex head pocket cb = cyl(diameter=hd, height=counterbore + 0.02, fn=fn, fa=fa, fs=fs).up((counterbore + 0.02) / 2 - 0.01) cutter = cutter | cb return cutter
# --------------------------------------------------------------------------- # Section: table helpers # --------------------------------------------------------------------------- def _closest(table, diam): """Look *diam* up in *table*, falling back to the nearest tabulated size.""" if diam in table: return table[diam] key = min(table, key=lambda k: abs(k - diam)) return table[key] def _nut_dims(diam, thickness, nutwidth): """ Resolve a nut's ``(across-flats width, thickness)`` for the given size and thickness class. """ spec = _closest(_NUT, diam) width = float(nutwidth) if nutwidth is not None else spec.width if thickness is None: return width, spec.normal if isinstance(thickness, (int, float)): return width, float(thickness) t = str(thickness).lower() if t == "thin" and spec.thin is not None: return width, spec.thin if t == "thick" and spec.thick is not None: return width, spec.thick return width, spec.normal