# 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/threading.py
# Pure-Python port of the core of BOSL2's threading.scad: screw threads built by sweeping a 2-D
# thread profile helically (via the toolkit's :func:`~bosl2.skin.spiral_sweep`) and unioning a
# core cylinder. The :class:`Threading` class exposes the thread generators as methods:
# ``threaded_rod`` (ISO/UTS), ``trapezoidal_threaded_rod``, ``acme_threaded_rod``,
# ``square_threaded_rod``, ``buttress_threaded_rod``, the matching ``*_nut`` builders,
# ``generic_threaded_rod`` / ``generic_threaded_nut``, and ``thread_helix``.
#
# The thread *profiles* are ported verbatim from BOSL2 (checked in tests/test_threading.py) and
# the resulting geometry is verified against a real-app BOSL2 render (matching major/minor
# diameter, length, thread pitch, and watertightness). This is a clean, geometrically-correct
# port; the elaborate BOSL2 refinements -- blunt-start / lead-in tapers, teardrop threads, and
# the bevel machinery -- are NOT ported (a follow-up), so ends are cut flush.
#
# FileSummary: Screw threading: threaded rods and nuts (ISO/trapezoidal/acme/square/buttress).
# FileGroup: BOSL2
from __future__ import annotations
import math
from dataclasses import dataclass
from bosl2.shapes3d import Bosl2Solid, cuboid, cyl, regular_prism
__all__ = ["Threading", "ThreadProfile"]
# ---------------------------------------------------------------------------
# Section: thread profiles (in pitch units: x in [-1/2, 1/2], y the depth fraction)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ThreadProfile:
"""A 2-D thread cross-section in pitch units: x along the axis in [-1/2, 1/2], y the (negative)
depth fraction. ``name`` labels the standard it came from; ``points`` is the profile polygon.
Behaves like the plain list of ``[x, y]`` points it wraps -- it iterates, indexes, has a length
and converts to a numpy array of shape ``(n, 2)`` -- so it drops straight into the thread
builders (and anywhere a raw point list is accepted), while also carrying its name and
:attr:`depth`.
"""
name: str
points: tuple[tuple[float, float], ...]
@property
def depth(self) -> float:
"""Peak-to-valley depth as a fraction of the pitch."""
ys = [p[1] for p in self.points]
return max(ys) - min(ys)
def depth_abs(self, pitch: float) -> float:
"""Absolute peak-to-valley depth (mm) at the given *pitch*."""
return self.depth * pitch
def as_points(self) -> list[list[float]]:
"""The profile as a plain list of ``[x, y]`` float pairs."""
return [[float(x), float(y)] for x, y in self.points]
def __iter__(self):
return (list(p) for p in self.points)
def __len__(self) -> int:
return len(self.points)
def __getitem__(self, i):
return list(self.points[i])
def _iso_profile() -> ThreadProfile:
depth = math.cos(math.radians(30)) * 5 / 8
clockwise = 1 / 8
return ThreadProfile(
"ISO",
(
(-depth / math.sqrt(3) - clockwise / 2, -depth),
(-clockwise / 2, 0),
(clockwise / 2, 0),
(depth / math.sqrt(3) + clockwise / 2, -depth),
),
)
def _trapezoidal_profile(pitch, thread_angle: float = 30, thread_depth=None) -> ThreadProfile:
depth = thread_depth if thread_depth is not None else pitch / 2
pa_delta = 0.5 * depth * math.tan(math.radians(thread_angle / 2)) / pitch
assert pa_delta <= 0.25, "trapezoidal thread geometry is impossible (angle/depth too large)."
rr1 = -depth / pitch
z1, z2 = 0.25 - pa_delta, 0.25 + pa_delta
return ThreadProfile(f"trapezoidal-{thread_angle:g}deg", ((-z2, rr1), (-z1, 0), (z1, 0), (z2, rr1)))
def _buttress_profile() -> ThreadProfile:
return ThreadProfile(
"buttress",
(
(-1 / 2, -0.77),
(-7 / 16, -0.75),
(5 / 16, 0),
(7 / 16, 0),
(7 / 16, -0.75),
(1 / 2, -0.77),
),
)
# ---------------------------------------------------------------------------
# Section: geometry
# ---------------------------------------------------------------------------
def _quantup(x, n):
return int(math.ceil(x / n) * n)
def _thread_grid(profile, pitch, r, length, starts, left_handed, sides):
"""One angular sector (360/starts) of the thread surface as a column grid for vnf_vertex_array.
Each column is a vertical stack of vertices for one angle: bottom axis point, the thread profile
repeated up every turn, and the top axis point. Sweeping the columns around builds the whole
rod (core + helical thread) as one closed, manifold polyhedron -- no CSG union of the thread
with a coaxial core (which Manifold cannot do cleanly)."""
prof = [[float(x), float(y)] for x, y in profile]
start_steps = sides // starts
direction = -1 if left_handed else 1
len1, len2 = -length / 2 - pitch, length / 2 + pitch
turns1 = int(math.floor(len1 / pitch)) - 1
turns2 = int(math.ceil(len2 / pitch)) + 1
grid = []
for step in range(start_steps + 1):
angle = math.radians(360 * step / sides * direction)
dz = step / start_steps
ca, sa = math.cos(angle), math.sin(angle)
col = [[0.0, 0.0, len1]]
for turn in range(turns1, turns2 + 1):
for px, py in prof:
z = max(len1, min(len2, (px + turn + dz) * pitch))
rad = r + py * pitch
col.append([rad * ca, rad * sa, z])
col.append([0.0, 0.0, len2])
grid.append(col)
return grid
def _rot_z(pts, deg):
a = math.radians(deg)
c, s = math.cos(a), math.sin(a)
return [[x * c - y * s, x * s + y * c, z] for x, y, z in pts]
def _rod_solid(d, length, pitch, profile, starts=1, left_handed=False, fn=None, fa=None, fs=None):
"""The external threaded-rod solid, built as a direct manifold polyhedron, trimmed to length.
Each of the *starts* thread starts is one angular sector's vertex-array surface; the sectors are
merged at the VNF level (not by CSG union, which Manifold cannot do on coaxial helical solids)
into one polyhedron, then trimmed to length with an intersection."""
from bosl2.shapes2d import _frag_count
from bosl2.vnf import VNF
radius = d / 2
sides = _quantup(_frag_count(radius, fn, fa, fs), starts)
verts, faces = [], []
for k in range(starts):
grid = _thread_grid(profile, pitch, radius, length, starts, left_handed, sides)
vnf = VNF.vertex_array(grid, col_wrap=False, style="convex")
rv = _rot_z(list(vnf.vertices), k * 360 / starts) if starts > 1 else list(vnf.vertices)
off = len(verts)
verts += [list(v) for v in rv]
faces += [[i + off for i in f] for f in vnf.faces]
thread = Bosl2Solid(VNF(verts, faces).polyhedron())
return thread & cyl(height=length, radius=radius + 1, fn=fn, fa=fa, fs=fs)
def _profile_depth_abs(profile, pitch):
if isinstance(profile, ThreadProfile):
return profile.depth_abs(pitch)
ys = [float(p[1]) for p in profile]
return (max(ys) - min(ys)) * pitch
def _nut_solid(
nutwidth,
idia,
h,
pitch,
profile,
shape="hex",
starts=1,
left_handed=False,
slop=0.0,
fn=None,
fa=None,
fs=None,
):
"""A nut: a hex/square body with a threaded hole cut by a matching thread 'tap'."""
if shape == "hex":
body = regular_prism(6, height=h, inner_diameter=nutwidth)
elif shape == "square":
body = cuboid([nutwidth, nutwidth, h])
else:
raise AssertionError('nut shape must be "hex" or "square".')
if pitch == 0:
return body - cyl(height=h + 2, radius=idia / 2 + slop, fn=fn, fa=fa, fs=fs)
depth_abs = _profile_depth_abs(profile, pitch)
tap = _rod_solid(
idia + 2 * depth_abs + 2 * slop,
h + 2 * pitch,
pitch,
profile,
starts=starts,
left_handed=left_handed,
fn=fn,
fa=fa,
fs=fs,
)
return body - tap
# ---------------------------------------------------------------------------
# Section: Threading class
# ---------------------------------------------------------------------------
[docs]
class Threading:
"""Screw-thread generators (BOSL2 threading.scad). Every method returns a
:class:`~bosl2.shapes3d.Bosl2Solid`; call them on the class, e.g. ``Threading.threaded_rod(...)``.
A *rod* is a threaded cylinder; a *nut* is a hex/square block with a matching threaded hole
(cut by a thread 'tap', with *slop* clearance). *pitch* is the axial distance between threads,
*starts* the number of thread starts, and *left_handed* flips the helix.
"""
# -- generic ---------------------------------------------------------------------------
[docs]
@staticmethod
def generic_threaded_rod(
d: float,
length: float,
pitch: float,
profile,
starts: int = 1,
left_handed: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A threaded rod from an explicit 2-D thread *profile* (x in [-1/2, 1/2], y the depth
fraction, both in pitch units) -- the core every other rod builds on (BOSL2 generic_threaded_rod())."""
assert pitch > 0 and length > 0 and d > 0, "generic_threaded_rod(): d, length and pitch must be positive."
return _rod_solid(d, length, pitch, profile, starts, left_handed, fn, fa, fs)
[docs]
@staticmethod
def generic_threaded_nut(
nutwidth: float,
inner_diameter: float,
h: float,
pitch: float,
profile,
shape="hex",
starts: int = 1,
left_handed: bool = False,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A nut from an explicit thread *profile* (BOSL2 generic_threaded_nut())."""
return _nut_solid(
nutwidth,
inner_diameter,
h,
pitch,
profile,
shape,
starts,
left_handed,
slop,
fn,
fa,
fs,
)
# -- ISO / UTS -------------------------------------------------------------------------
[docs]
@staticmethod
def threaded_rod(
d: float,
length: float,
pitch: float,
starts: int = 1,
left_handed: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""
An ISO (metric) / UTS (imperial) 60-degree triangular threaded rod (BOSL2
threaded_rod()).
"""
return _rod_solid(d, length, pitch, _iso_profile(), starts, left_handed, fn, fa, fs)
[docs]
@staticmethod
def threaded_nut(
nutwidth: float,
inner_diameter: float,
h: float,
pitch: float,
shape="hex",
starts: int = 1,
left_handed: bool = False,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A hex/square nut for an ISO/UTS threaded rod (BOSL2 threaded_nut())."""
return _nut_solid(
nutwidth,
inner_diameter,
h,
pitch,
_iso_profile(),
shape,
starts,
left_handed,
slop,
fn,
fa,
fs,
)
# -- trapezoidal / metric trapezoidal --------------------------------------------------
[docs]
@staticmethod
def trapezoidal_threaded_rod(
d: float,
length: float,
pitch: float,
thread_angle: float = 30,
thread_depth: float | None = None,
starts: int = 1,
left_handed: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""
A symmetric trapezoidal threaded rod (metric trapezoidal by default) (BOSL2
trapezoidal_threaded_rod()).
"""
prof = _trapezoidal_profile(pitch, thread_angle, thread_depth)
return _rod_solid(d, length, pitch, prof, starts, left_handed, fn, fa, fs)
[docs]
@staticmethod
def trapezoidal_threaded_nut(
nutwidth: float,
inner_diameter: float,
h: float,
pitch: float,
thread_angle: float = 30,
thread_depth: float | None = None,
shape="hex",
starts: int = 1,
left_handed: bool = False,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A nut for a trapezoidal threaded rod (BOSL2 trapezoidal_threaded_nut())."""
prof = _trapezoidal_profile(pitch, thread_angle, thread_depth)
return _nut_solid(
nutwidth,
inner_diameter,
h,
pitch,
prof,
shape,
starts,
left_handed,
slop,
fn,
fa,
fs,
)
# -- ACME ------------------------------------------------------------------------------
[docs]
@staticmethod
def acme_threaded_rod(
d: float,
length: float,
pitch: float,
thread_depth: float | None = None,
starts: int = 1,
left_handed: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A 29-degree ACME threaded rod (BOSL2 acme_threaded_rod())."""
prof = _trapezoidal_profile(pitch, 29, thread_depth if thread_depth is not None else pitch / 2)
return _rod_solid(d, length, pitch, prof, starts, left_handed, fn, fa, fs)
[docs]
@staticmethod
def acme_threaded_nut(
nutwidth: float,
inner_diameter: float,
h: float,
pitch: float,
thread_depth: float | None = None,
shape="hex",
starts: int = 1,
left_handed: bool = False,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A nut for an ACME threaded rod (BOSL2 acme_threaded_nut())."""
prof = _trapezoidal_profile(pitch, 29, thread_depth if thread_depth is not None else pitch / 2)
return _nut_solid(
nutwidth,
inner_diameter,
h,
pitch,
prof,
shape,
starts,
left_handed,
slop,
fn,
fa,
fs,
)
# -- square ----------------------------------------------------------------------------
[docs]
@staticmethod
def square_threaded_rod(
d: float,
length: float,
pitch: float,
starts: int = 1,
left_handed: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A square-profile threaded rod (BOSL2 square_threaded_rod())."""
prof = _trapezoidal_profile(pitch, 0.1)
return _rod_solid(d, length, pitch, prof, starts, left_handed, fn, fa, fs)
[docs]
@staticmethod
def square_threaded_nut(
nutwidth: float,
inner_diameter: float,
h: float,
pitch: float,
shape="hex",
starts: int = 1,
left_handed: bool = False,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A nut for a square threaded rod (BOSL2 square_threaded_nut())."""
prof = _trapezoidal_profile(pitch, 0.1)
return _nut_solid(
nutwidth,
inner_diameter,
h,
pitch,
prof,
shape,
starts,
left_handed,
slop,
fn,
fa,
fs,
)
# -- buttress --------------------------------------------------------------------------
[docs]
@staticmethod
def buttress_threaded_rod(
d: float,
length: float,
pitch: float,
starts: int = 1,
left_handed: bool = False,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""An asymmetric buttress threaded rod (BOSL2 buttress_threaded_rod())."""
return _rod_solid(d, length, pitch, _buttress_profile(), starts, left_handed, fn, fa, fs)
[docs]
@staticmethod
def buttress_threaded_nut(
nutwidth: float,
inner_diameter: float,
h: float,
pitch: float,
shape="hex",
starts: int = 1,
left_handed: bool = False,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A nut for a buttress threaded rod (BOSL2 buttress_threaded_nut())."""
return _nut_solid(
nutwidth,
inner_diameter,
h,
pitch,
_buttress_profile(),
shape,
starts,
left_handed,
slop,
fn,
fa,
fs,
)
# -- single thread helix ---------------------------------------------------------------
[docs]
@staticmethod
def thread_helix(
d: float,
pitch: float,
thread_depth: float | None = None,
flank_angle: float = 15,
turns: float = 1,
starts: int = 1,
left_handed: bool = False,
profile=None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Solid:
"""A single helical thread ridge (no core), for adding threads onto your own cylinder
(BOSL2 thread_helix()). The thread crest is at diameter *d*; give *thread_depth* and
*flank_angle*, or an explicit *profile*."""
from bosl2.skin import spiral_sweep
assert pitch > 0 and d > 0, "thread_helix(): d and pitch must be positive."
if profile is None:
depth = thread_depth if thread_depth is not None else pitch / 2
profile = _trapezoidal_profile(pitch, 2 * flank_angle, depth)
prof = [[float(x), float(y)] for x, y in profile]
ys = [p[1] for p in prof]
pmax = max(ys)
radius = d / 2
section = [[(py - pmax) * pitch, px * pitch] for px, py in prof]
lead = starts * pitch
height = turns * lead
thread: Bosl2Solid | None = None
for k in range(starts):
sec = [[x, y + k * pitch] for x, y in section]
piece = Bosl2Solid(
spiral_sweep(
sec,
height=height,
radius=radius,
turns=turns * (-1 if left_handed else 1),
center=True,
).polyhedron()
)
if starts > 1:
piece = piece.rotate([0, 0, k * 360 / starts])
thread = piece if thread is None else (thread | piece)
assert thread is not None
return thread