# 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/joiners.py
# Pure-Python port of the core joiners from BOSL2's joiners.scad -- shapes for connecting two
# separately-printed parts. :class:`Dovetail` is the flagship: a (optionally tapered)
# dovetail joint you attach as a male tenon or difference out as a female socket. A functional
# :class:`SnapPin` and its :class:`SnapPinSocket` give a press-and-click pin.
#
# The snap pin is a clean functional build (a slotted, barbed shaft); BOSL2's named-size table and
# the hirth/rabbit-clip couplings are not ported.
#
# FileSummary: Dovetail joints and snap-pin connectors.
# DocCategory: Parts library
# FileGroup: BOSL2
"""Dovetail joints and snap-pin connectors."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from pybosl2.parts._buildable import Buildable
from pybosl2.parts.enums import Gender
from pybosl2.solid import cuboid, cyl, prismoid, sphere
if TYPE_CHECKING:
from pybosl2._backend import Solid
__all__ = ["Dovetail", "SnapPin", "SnapPinSocket"]
[docs]
class Dovetail(Buildable):
"""A dovetail joint that slides along Y and flares upward in X.
The male form is a tenon you attach to a part; the female form is the same
shape enlarged by *slop* for you to difference out as the mating socket.
*slope* is the flare (rise/run per side; ``angle`` sets it as ``1/tan(angle)``).
Give *taper* (degrees) or *back_width* to taper it along its length.
Examples:
A male dovetail beside its female socket:
.. pythonscad-example::
from pybosl2.parts.enums import Gender
from pybosl2.parts.joiners import Dovetail
(Dovetail(Gender.MALE, width=15, height=8, slide=30).shape
| Dovetail(Gender.FEMALE, width=15, height=8, slide=30).shape.right(24)).show()
"""
def __init__(
self,
gender: Gender = Gender.MALE,
width: float = 15,
height: float = 8,
slide: float = 30,
angle: float | None = None,
slope: float = 6,
taper: float = 0,
back_width: float | None = None,
slop: float = 0.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a dovetail joint tenon (male) or socket (female).
Args:
gender: Whether the joint is a male tenon or female socket. Defaults to MALE.
width: Base width of the dovetail in mm. Defaults to 15.
height: Joint height in mm. Defaults to 8.
slide: Slide length along the joint direction in mm. Defaults to 30.
angle: Flare angle in degrees; sets slope as ``1/tan(angle)``. Overrides slope if given.
slope: Flare as rise/run per side (higher value = less flare). Defaults to 6.
taper: Taper angle in degrees along the slide length. Defaults to 0.
back_width: Width at the far end of a tapered dovetail. Overrides taper if given.
slop: Clearance gap for the female socket in mm. Only applied when gender is FEMALE. Defaults to 0.0.
fn: Number of fragments for cylinder resolution. Passed to the geometry primitives.
fa: Minimum fragment angle. Passed to the geometry primitives.
fs: Minimum fragment size. Passed to the geometry primitives.
Returns:
None
"""
self._gender: Gender = gender
self._width: float = width
self._height: float = height
self._slide: float = slide
# The spec above is all a caller needs to *measure* this part; the geometry
# below is deferred to `shape` (SPEC C-14, PLAN O-2).
self._args = (
gender,
width,
height,
slide,
angle,
slope,
taper,
back_width,
slop,
fn,
fa,
fs,
)
self._solid: "Solid | None" = None
def _build(self) -> "Solid":
"""Build the geometry. Called once, on the first access to `shape`."""
(
gender,
width,
height,
slide,
angle,
slope,
taper,
back_width,
slop,
fn,
fa,
fs,
) = self._args
if angle is not None:
slope = 1 / math.tan(math.radians(angle))
hslop = slop if gender == Gender.FEMALE else 0.0
w = width + 2 * hslop
h = height + hslop
flare = 2 * h / slope
if taper or back_width is not None:
if back_width is None:
back_width = width - 2 * slide * math.tan(math.radians(taper))
wb = back_width + 2 * hslop
front = prismoid([w, 0.02], [w + flare, 0.02], height=h, fn=fn, fa=fa, fs=fs).back(slide / 2)
back = prismoid([wb, 0.02], [wb + flare, 0.02], height=h, fn=fn, fa=fa, fs=fs).forward(slide / 2)
body = front.hull(back)
else:
body = prismoid([w, slide], [w + flare, slide], height=h, fn=fn, fa=fa, fs=fs)
return body.with_nominal_size([w + flare, slide, h])
@property
def gender(self) -> Gender:
"""Male or female."""
return self._gender
@property
def width(self) -> float:
"""Base width in mm."""
return self._width
@property
def height(self) -> float:
"""Joint height in mm."""
return self._height
@property
def slide(self) -> float:
"""Slide length in mm."""
return self._slide
@property
def shape(self) -> "Solid":
"""Return the dovetail geometry."""
if self._solid is None:
self._solid = self._build()
return self._solid
[docs]
class SnapPin(Buildable):
"""A press-and-click snap pin: a slotted shaft with a barbed head.
Push it head-first through a hole; the slot lets the barb compress and
spring back to lock. *nub_depth* is the barb overhang, *snap* its height,
and *slot* the width of the flex gap.
Examples:
A snap pin:
.. pythonscad-example::
from pybosl2.parts.joiners import SnapPin
SnapPin().show()
"""
def __init__(
self,
diameter: float = 5,
length: float = 12,
nub_depth: float = 0.6,
snap: float = 2.2,
clearance: float = 0.2,
slot: float = 1.2,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a snap pin.
Args:
diameter: Shaft diameter in mm. Defaults to 5.
length: Shaft length in mm. Defaults to 12.
nub_depth: Barb overhang depth in mm. Defaults to 0.6.
snap: Barb height in mm. Defaults to 2.2.
clearance: Clearance gap in mm (currently not used in the shape computation). Defaults to 0.2.
slot: Width of the flex gap slot in mm. Defaults to 1.2.
fn: Number of fragments for cylinder resolution. Passed to the geometry primitives.
fa: Minimum fragment angle. Passed to the geometry primitives.
fs: Minimum fragment size. Passed to the geometry primitives.
Returns:
None
"""
self._diameter: float = diameter
self._length: float = length
# The spec above is all a caller needs to *measure* this part; the geometry
# below is deferred to `shape` (SPEC C-14, PLAN O-2).
self._args = (
diameter,
length,
nub_depth,
snap,
clearance,
slot,
fn,
fa,
fs,
)
self._solid: "Solid | None" = None
def _build(self) -> "Solid":
"""Build the geometry. Called once, on the first access to `shape`."""
(
diameter,
length,
nub_depth,
snap,
clearance,
slot,
fn,
fa,
fs,
) = self._args
_ = clearance
shaft = cyl(height=length, diameter=diameter, fn=fn, fa=fa, fs=fs)
barb = cyl(
height=snap,
diameter1=diameter + 2 * nub_depth,
diameter2=diameter,
fn=fn,
fa=fa,
fs=fs,
).up(length / 2 - snap / 2)
tip = sphere(diameter=diameter, fn=fn, fa=fa, fs=fs).up(length / 2)
pin = shaft | barb | tip
pin = pin - cuboid([diameter + 2 * nub_depth + 1, slot, length + snap], fn=fn, fa=fa, fs=fs)
# Nominal anchor box: the pin's nominal envelope -- shaft diameter plus the barbs, and the
# length plus a nominal tip. The moulded barb and rounded tip do not fill it exactly, so
# bounds() differs slightly; anchoring follows the stated size a socket is cut for.
return pin.with_nominal_size([diameter + 2 * nub_depth, diameter, length + diameter / 2])
@property
def diameter(self) -> float:
"""Shaft diameter in mm."""
return self._diameter
@property
def length(self) -> float:
"""Shaft length in mm."""
return self._length
@property
def shape(self) -> "Solid":
"""Return the snap pin geometry."""
if self._solid is None:
self._solid = self._build()
return self._solid
[docs]
class SnapPinSocket(Buildable):
"""The mating socket mask for a :class:`SnapPin` — difference it out of a part.
A clearance bore with a relief groove that the pin's barb clicks into.
Examples:
A snap pin socket:
.. pythonscad-example::
from pybosl2.parts.joiners import SnapPinSocket
SnapPinSocket().show()
"""
def __init__(
self,
diameter: float = 5,
length: float = 12,
nub_depth: float = 0.6,
snap: float = 2.2,
clearance: float = 0.2,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a snap pin socket mask.
Args:
diameter: Mating shaft diameter in mm. Defaults to 5.
length: Mating shaft length in mm. Defaults to 12.
nub_depth: Barb overhang depth in mm. Defaults to 0.6.
snap: Barb height in mm. Defaults to 2.2.
clearance: Additional clearance gap added to all diameters. Defaults to 0.2.
fn: Number of fragments for cylinder resolution. Passed to the geometry primitives.
fa: Minimum fragment angle. Passed to the geometry primitives.
fs: Minimum fragment size. Passed to the geometry primitives.
Returns:
None
"""
self._diameter: float = diameter
self._length: float = length
# The spec above is all a caller needs to *measure* this part; the geometry
# below is deferred to `shape` (SPEC C-14, PLAN O-2).
self._args = (
diameter,
length,
nub_depth,
snap,
clearance,
fn,
fa,
fs,
)
self._solid: "Solid | None" = None
def _build(self) -> "Solid":
"""Build the geometry. Called once, on the first access to `shape`."""
(
diameter,
length,
nub_depth,
snap,
clearance,
fn,
fa,
fs,
) = self._args
bore = cyl(height=length + 1, diameter=diameter + 2 * clearance, fn=fn, fa=fa, fs=fs)
relief = cyl(
height=snap + clearance,
diameter=diameter + 2 * nub_depth + 2 * clearance,
fn=fn,
fa=fa,
fs=fs,
).up(length / 2 - snap / 2)
# Nominal anchor box: the matching pin's envelope plus the clearance, so a pin and its
# socket anchor alike (see SnapPin). The relief cut makes the real solid a little different.
return (bore | relief).with_nominal_size(
[diameter + 2 * nub_depth + 2 * clearance, diameter + 2 * clearance, length]
)
@property
def diameter(self) -> float:
"""Shaft diameter in mm."""
return self._diameter
@property
def length(self) -> float:
"""Shaft length in mm."""
return self._length
@property
def shape(self) -> "Solid":
"""Return the socket geometry."""
if self._solid is None:
self._solid = self._build()
return self._solid