# 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/screws.py
# Pure-Python port of the core of BOSL2's screws.scad, built on top of
# :class:`Screw` turns a metric screw name (``"M6"``,
# ``"M8x1"``) into ready-to-print geometry: :class:`Screw` (a threaded/plain shaft plus a
# socket / hex / button / pan / flat / setscrew head with an optional hex or slot drive recess),
# :class:`Nut` (a hex/square nut with a matching threaded hole), and :class:`ScrewHole`
# (a clearance/counterbore/countersink hole cutter). :class:`ScrewSpec` returns the resolved
# dimensions.
#
# 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.
"""Metric screws, nuts and screw holes built on the threading port."""
# DocCategory: Parts library
# FileGroup: BOSL2
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from pybosl2.parts.enums import NutShape, ScrewDriveType, ScrewHeadType, ThreadPitchClass
from pybosl2.shapes3d import Bosl2Solid, cuboid, cyl, regular_prism
__all__ = [
"Nut",
"Screw",
"ScrewHole",
"ScrewSpec",
"ThreadPitches",
]
# ---------------------------------------------------------------------------
# Section: metric dimension tables (transcribed from screws.scad)
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class ThreadPitches:
"""ISO metric thread pitches (mm) for one nominal diameter.
Positional args are ``coarse / fine / extra_fine / super_fine``; ``None``
marks a pitch class that is undefined for this size.
"""
coarse: float
fine: float | None = None
extra_fine: float | None = None
super_fine: float | None = None
[docs]
def pitch(self, thread: ThreadPitchClass = ThreadPitchClass.COARSE) -> float:
"""Return the pitch for a thread class, falling back to coarse if it's undefined for this size."""
if thread == ThreadPitchClass.NONE:
return self.coarse
lookup: dict[ThreadPitchClass, float | None] = {
ThreadPitchClass.COARSE: self.coarse,
ThreadPitchClass.FINE: self.fine,
ThreadPitchClass.EXTRA_FINE: self.extra_fine,
ThreadPitchClass.SUPER_FINE: self.super_fine,
}
return lookup.get(thread) 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}
[docs]
class ScrewSpec:
"""Resolved dimensions for a metric screw.
Construct directly (replaces the old ``_parse_spec`` helper):
``ScrewSpec("M6")``, ``ScrewSpec("M8x1", head=ScrewHeadType.HEX)``, etc.
Construct directly (replaces the old ``_parse_spec`` helper):
``ScrewSpec("M6")``, ``ScrewSpec("M8x1", head=ScrewHeadType.HEX)``, etc.
Attributes are set by the constructor and may be read freely.
"""
system: str
diameter: float
pitch: float
head: ScrewHeadType
head_size: float | None
head_height: float
head_angle: float | None
head_size_sharp: float | None
drive: ScrewDriveType
drive_size: float | None
drive_depth: float | None
def __init__(
self,
spec: str | dict[str, float] | float,
head: ScrewHeadType = ScrewHeadType.NONE,
thread: ThreadPitchClass = ThreadPitchClass.COARSE,
drive: ScrewDriveType = ScrewDriveType.NONE,
pitch: float | None = None,
) -> None:
"""Resolve a screw specification to a fully-populated :class:`ScrewSpec`.
*spec* may be ``"M6"``, ``"M8x1"`` (explicit pitch), a bare number (treated as the
metric nominal diameter), or a mapping already carrying ``diameter``/``pitch``. When
*head* is anything other than ``ScrewHeadType.NONE`` the appropriate head dimensions
and optional drive-recess dimensions are looked up from the ISO tables.
Args:
spec: Screw specification -- ``"M6"``, ``"M8x1"``, a bare float diameter, or a dict.
head: Desired head style as a :class:`ScrewHeadType`.
thread: Thread pitch class as a :class:`ThreadPitchClass`.
drive: Drive recess type as a :class:`ScrewDriveType`.
pitch: Explicit thread pitch override.
Returns:
None.
Raises:
ValueError: If the specification cannot be resolved or the head type is unknown.
"""
self.system = "ISO"
if isinstance(spec, dict):
d = float(spec["diameter"])
p = float(spec.get("pitch", 0)) if spec.get("pitch") is not None else _lookup_pitch(d, thread)
elif isinstance(spec, (int, float)):
d = float(spec)
p = float(pitch) if pitch is not None else _lookup_pitch(d, thread)
else:
s = str(spec).strip().upper()
if s.startswith("M"):
s = s[1:]
if "X" in s:
dpart, ppart = s.split("X", 1)
d, p = float(dpart), float(ppart)
else:
d = float(s)
p = float(pitch) if pitch is not None else _lookup_pitch(d, thread)
self.diameter = d
self.pitch = p
self.head = head
self.drive = drive
self.head_size = None
self.head_height = 0.0
self.head_angle = None
self.head_size_sharp = None
self.drive_size = None
self.drive_depth = None
if head in (None, ScrewHeadType.NONE):
self.head = ScrewHeadType.NONE
if drive == ScrewDriveType.HEX:
self.drive_size = _closest(_SETSCREW, d)
self.drive_depth = d / 2
elif head == ScrewHeadType.HEX:
spec_h: _HexHead = _closest(_HEX_HEAD, d)
self.head_size, self.head_height = spec_h.width, spec_h.height
elif head in (ScrewHeadType.SOCKET, ScrewHeadType.SOCKET_RIBBED):
spec_s: _SocketHead = _closest(_SOCKET_HEAD, d)
self.head_size, self.head_height = spec_s.head_d, d
if drive == ScrewDriveType.HEX:
self.drive_size, self.drive_depth = spec_s.hex_drive, d / 2
elif head == ScrewHeadType.BUTTON:
spec_b: _ButtonHead = _closest(_BUTTON_HEAD, d)
self.head_size, self.head_height = spec_b.head_d, spec_b.height
if drive == ScrewDriveType.HEX:
self.drive_size, self.drive_depth = spec_b.hex_drive, spec_b.hex_depth
elif head in (ScrewHeadType.PAN, ScrewHeadType.ROUND):
spec_p: _PanHead = _closest(_PAN_HEAD, d)
self.head_size, self.head_height = spec_p.head_d, spec_p.height
elif head == ScrewHeadType.FLAT:
spec_f: _FlatHead = _closest(_FLAT_HEAD, d)
self.head_size = spec_f.actual_d
self.head_size_sharp = spec_f.sharp_d
self.head_angle = 90.0
self.head_height = (spec_f.actual_d - d) / 2
else:
raise ValueError(f'Unknown head type "{head}"')
def _lookup_pitch(diam: float, thread: ThreadPitchClass) -> float:
if diam not in _ISO_THREAD:
raise ValueError(f"Unknown metric screw size M{diam:g}")
return float(_ISO_THREAD[diam].pitch(thread))
def _make_head(info: ScrewSpec, fn: int | None, fa: float | None, fs: float | None) -> Bosl2Solid | None:
"""Build the screw head from resolved dimensions."""
head = info.head
if head in (None, ScrewHeadType.NONE):
return None
hh = info.head_height
hs = info.head_size
assert hs is not None, f"head_size not set for head type {head}"
if head == ScrewHeadType.HEX:
return regular_prism(6, height=hh, inner_diameter=hs, fn=fn, fa=fa, fs=fs).up(hh / 2)
if head in (ScrewHeadType.SOCKET, ScrewHeadType.SOCKET_RIBBED):
return cyl(diameter=hs, height=hh, chamfer2=hs / 20, fn=fn, fa=fa, fs=fs).up(hh / 2)
if head == ScrewHeadType.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 (ScrewHeadType.PAN, ScrewHeadType.ROUND):
return cyl(diameter=hs, height=hh, rounding2=0.2 * hs, fn=fn, fa=fa, fs=fs).up(hh / 2)
if head == ScrewHeadType.FLAT:
return cyl(diameter1=info.diameter, diameter2=hs, height=hh, fn=fn, fa=fa, fs=fs).up(hh / 2)
return None
def _make_recess(
info: ScrewSpec, head_top: float, fn: int | None, fa: float | None, fs: float | None
) -> Bosl2Solid | None:
"""Build the drive recess from resolved dimensions."""
drive = info.drive
size = info.drive_size
depth = info.drive_depth
if drive in (None, ScrewDriveType.NONE) or not size or not depth:
return None
eps = 0.02
if drive == ScrewDriveType.HEX:
rec = regular_prism(6, height=depth + eps, inner_diameter=size, fn=fn, fa=fa, fs=fs)
elif drive == ScrewDriveType.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], fn=fn, fa=fa, fs=fs)
else:
return None
return rec.up(head_top - (depth + eps) / 2 + eps / 2)
[docs]
class Screw:
"""A metric screw: threaded (or plain) shaft plus a head with an optional drive recess.
Examples:
An M6×20 socket-head cap screw:
.. pythonscad-example::
from pybosl2.parts.enums import ScrewHeadType, ScrewDriveType
from pybosl2.parts.screws import Screw
Screw("M6", length=20, head=ScrewHeadType.SOCKET, drive=ScrewDriveType.HEX).show()
"""
def __init__(
self,
spec: str | dict[str, float] | float,
length: float,
head: ScrewHeadType = ScrewHeadType.SOCKET,
drive: ScrewDriveType = ScrewDriveType.NONE,
thread: ThreadPitchClass = ThreadPitchClass.COARSE,
thread_len: float | None = None,
pitch: float | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a screw from *spec* (``"M6"`` / ``"M8x1"``) and dimensions.
Args:
spec: Screw specification -- ``"M6"``, ``"M8x1"``, a bare float diameter, or a dict.
length: Shaft length below the head in mm.
head: Desired head style as a :class:`ScrewHeadType`.
drive: Drive recess type as a :class:`ScrewDriveType`.
thread: Thread pitch class as a :class:`ThreadPitchClass`.
thread_len: Length of the threaded portion; None means the full length.
pitch: Explicit thread pitch override.
fn: Number of facets for $fn-based resolution.
fa: Minimum facet angle.
fs: Minimum facet size.
Returns:
None.
"""
self._spec: ScrewSpec = ScrewSpec(
spec,
head=head,
thread=ThreadPitchClass.COARSE if isinstance(thread, bool) else thread,
drive=drive,
pitch=pitch,
)
self._length: float = length
self._thread: ThreadPitchClass = thread if isinstance(thread, ThreadPitchClass) else ThreadPitchClass.COARSE
self._thread_len: float | None = thread_len
self._fn: int | None = fn
self._fa: float | None = fa
self._fs: float | None = fs
self._solid: Bosl2Solid | None = None
@property
def spec(self) -> ScrewSpec:
"""The resolved :class:`ScrewSpec`."""
return self._spec
@property
def diameter(self) -> float:
"""Nominal screw diameter in mm."""
return self._spec.diameter
@property
def pitch(self) -> float:
"""Thread pitch in mm."""
return self._spec.pitch
@property
def head(self) -> ScrewHeadType:
"""Head style."""
return self._spec.head
@property
def head_size(self) -> float | None:
"""Head diameter / across-flats in mm."""
return self._spec.head_size
@property
def head_height(self) -> float:
"""Head height in mm (0 for headless)."""
return self._spec.head_height
@property
def drive(self) -> ScrewDriveType:
"""Drive recess type."""
return self._spec.drive
@property
def length(self) -> float:
"""Shaft length below the head in mm."""
return self._length
[docs]
def shape(self) -> Bosl2Solid:
"""Build and return the screw geometry (result is cached)."""
if self._solid is not None:
return self._solid
d = self._spec.diameter
if self._thread != ThreadPitchClass.NONE:
from pybosl2.parts.threading import iso_threaded_rod
tp = ScrewSpec(self._spec.diameter, thread=self._thread, pitch=self._spec.pitch).pitch
tl = self._length if (self._thread_len is None or self._thread_len >= self._length) else self._thread_len
shank_len = self._length - tl
shaft = iso_threaded_rod(d, tl, tp, fn=self._fn, fa=self._fa, fs=self._fs).shape().down(shank_len + tl / 2)
if shank_len > 1e-9:
shank = cyl(diameter=d, height=shank_len, fn=self._fn, fa=self._fa, fs=self._fs).down(shank_len / 2)
shaft = shaft | shank
else:
shaft = cyl(diameter=d, height=self._length, fn=self._fn, fa=self._fa, fs=self._fs).down(self._length / 2)
result = shaft
head_top = self._spec.head_height
headobj = _make_head(self._spec, self._fn, self._fa, self._fs)
if headobj is not None:
result = result | headobj
recess = _make_recess(self._spec, head_top, self._fn, self._fa, self._fs)
if recess is not None:
result = result - recess
self._solid = result
return result
[docs]
def show(self) -> None:
"""Display the screw in the viewer."""
self.shape().show()
[docs]
class Nut:
"""A hex or square nut with a threaded hole.
Examples:
An M8 hex nut of normal thickness:
.. pythonscad-example::
from pybosl2.parts.screws import Nut
Nut("M8").show()
"""
def __init__(
self,
spec: str | dict[str, float] | float,
thickness: float | str = "normal",
shape: NutShape = NutShape.HEX,
thread: ThreadPitchClass = ThreadPitchClass.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,
) -> None:
"""Create a nut from *spec* (``"M8"``) and dimensions.
Args:
spec: Screw specification -- ``"M6"``, ``"M8x1"``, a bare float diameter, or a dict.
thickness: Nut thickness in mm, or ``"normal"``, ``"thin"``, or ``"thick"``.
shape: Nut shape as a :class:`NutShape` (``HEX`` or ``SQUARE``).
thread: Thread pitch class as a :class:`ThreadPitchClass`.
nutwidth: Across-flats width override.
slop: Additional clearance for the threaded hole.
pitch: Explicit thread pitch override.
fn: Number of facets for $fn-based resolution.
fa: Minimum facet angle.
fs: Minimum facet size.
Returns:
None.
"""
self._spec: ScrewSpec = ScrewSpec(spec, thread=thread, pitch=pitch)
self._thickness: float | str = thickness
self._shape: NutShape = shape
self._nutwidth: float | None = nutwidth
self._slop: float = slop
self._fn: int | None = fn
self._fa: float | None = fa
self._fs: float | None = fs
self._solid: Bosl2Solid | None = None
@property
def spec(self) -> ScrewSpec:
"""The resolved :class:`ScrewSpec`."""
return self._spec
@property
def diameter(self) -> float:
"""Nominal diameter in mm."""
return self._spec.diameter
@property
def pitch(self) -> float:
"""Thread pitch in mm."""
return self._spec.pitch
@property
def shape_nut(self) -> NutShape:
"""Nut outer shape."""
return self._shape
[docs]
def shape(self) -> Bosl2Solid:
"""Build and return the nut geometry (result is cached)."""
if self._solid is not None:
return self._solid
from pybosl2.parts.threading import iso_threaded_nut
width, th = _nut_dims(self._spec.diameter, self._thickness, self._nutwidth)
self._solid = iso_threaded_nut(
width,
self._spec.diameter,
th,
self._spec.pitch,
shape=self._shape,
slop=self._slop,
fn=self._fn,
fa=self._fa,
fs=self._fs,
).shape()
return self._solid
[docs]
def show(self) -> None:
"""Display the nut in the viewer."""
self.shape().show()
[docs]
class ScrewHole:
"""A hole cutter for a screw: clearance shaft plus optional countersink/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.
Examples:
Drill a clearance hole for an M6 bolt through a 10 mm plate:
.. pythonscad-example::
from pybosl2.parts.enums import ScrewHeadType
from pybosl2.parts.screws import ScrewHole
from pybosl2.solid import cuboid
(cuboid([20, 20, 10])
- ScrewHole("M6", length=10, head=ScrewHeadType.SOCKET, fit="normal").shape()).show()
"""
def __init__(
self,
spec: str | dict[str, float] | float,
length: float,
head: ScrewHeadType = ScrewHeadType.NONE,
counterbore: float = 0.0,
fit: str = "normal",
thread: ThreadPitchClass = ThreadPitchClass.NONE,
pitch: float | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a hole cutter from *spec* (``"M6"``) and dimensions.
Args:
spec: Screw specification -- ``"M6"``, ``"M8x1"``, a bare float diameter, or a dict.
length: Hole depth in mm.
head: Desired head style as a :class:`ScrewHeadType` (for countersink/counterbore).
counterbore: Depth of the counterbore in mm.
fit: Clearance fit class (``"close"``, ``"normal"``, or ``"loose"``).
thread: Thread pitch class for a threaded (tapped) hole; ``NONE`` for a clearance hole.
pitch: Explicit thread pitch override.
fn: Number of facets for $fn-based resolution.
fa: Minimum facet angle.
fs: Minimum facet size.
Returns:
None.
"""
self._spec_str: str | dict[str, float] | float = spec
self._length: float = length
self._head: ScrewHeadType = head
self._counterbore: float = counterbore
self._fit: str = fit
self._thread: ThreadPitchClass = thread
self._pitch: float | None = pitch
self._fn: int | None = fn
self._fa: float | None = fa
self._fs: float | None = fs
self._solid: Bosl2Solid | None = None
@property
def fit(self) -> str:
"""Clearance fit class (``"close"`` / ``"normal"`` / ``"loose"``)."""
return self._fit
@property
def length(self) -> float:
"""Hole depth in mm."""
return self._length
[docs]
def shape(self) -> Bosl2Solid:
"""Build and return the hole cutter geometry (result is cached)."""
if self._solid is not None:
return self._solid
use_thread = self._thread != ThreadPitchClass.NONE
sp = ScrewSpec(
self._spec_str,
thread=ThreadPitchClass.COARSE if not use_thread else self._thread,
pitch=self._pitch,
)
d, p = sp.diameter, sp.pitch
if use_thread:
from pybosl2.parts.threading import iso_threaded_rod
cutter = (
iso_threaded_rod(d + 0.0, self._length, p, fn=self._fn, fa=self._fa, fs=self._fs)
.shape()
.down(self._length / 2)
)
else:
gap = _CLEARANCE.get(str(self._fit).lower(), 0.5)
cutter = cyl(diameter=d + 2 * gap, height=self._length, fn=self._fn, fa=self._fa, fs=self._fs).down(
self._length / 2
)
if self._head == ScrewHeadType.FLAT:
info = ScrewSpec(self._spec_str, head=ScrewHeadType.FLAT, pitch=self._pitch)
hs = info.head_size
assert hs is not None
csk_h = (hs - d) / 2
csink = cyl(
diameter1=d,
diameter2=hs,
height=csk_h + 0.02,
fn=self._fn,
fa=self._fa,
fs=self._fs,
).up((csk_h + 0.02) / 2 - 0.01)
cutter = cutter | csink
elif self._counterbore and self._counterbore > 0:
info = ScrewSpec(
self._spec_str,
head=self._head if self._head not in (None, ScrewHeadType.NONE) else ScrewHeadType.SOCKET,
pitch=self._pitch,
)
raw_hd = info.head_size if self._head == ScrewHeadType.HEX else (info.head_size or 2 * d)
assert raw_hd is not None
hd: float = raw_hd
if self._head == ScrewHeadType.HEX:
hd = 2 * hd / math.sqrt(3)
cb = cyl(
diameter=hd,
height=self._counterbore + 0.02,
fn=self._fn,
fa=self._fa,
fs=self._fs,
).up((self._counterbore + 0.02) / 2 - 0.01)
cutter = cutter | cb
self._solid = cutter
return cutter
[docs]
def show(self) -> None:
"""Display the hole cutter in the viewer."""
self.shape().show()
# ---------------------------------------------------------------------------
# Section: table helpers
# ---------------------------------------------------------------------------
def _closest(table: dict[Any, Any], diam: float) -> Any:
"""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: float, thickness: float | str | None, nutwidth: float | None) -> tuple[float, float]:
"""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