# 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._native import native
from pybosl2.parts.enums import Gender
from pybosl2.shapes3d import Bosl2Solid, cuboid, cyl, prismoid, sphere
if TYPE_CHECKING: # real stub-typed imports for the checker (identical to pre-lazy)
from pythonscad import hull as _ohull
else:
_ohull = native("hull")
__all__ = ["Dovetail", "SnapPin", "SnapPinSocket"]
[docs]
class Dovetail:
"""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
"""
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 = Bosl2Solid(_ohull(front.shape, back.shape))
else:
body = prismoid([w, slide], [w + flare, slide], height=h, fn=fn, fa=fa, fs=fs)
self._solid: Bosl2Solid = Bosl2Solid(body.shape, size=[w + flare, slide, h])
self._gender: Gender = gender
self._width: float = width
self._height: float = height
self._slide: float = slide
@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
[docs]
def shape(self) -> Bosl2Solid:
"""Return the dovetail geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the dovetail in the viewer."""
self._solid.show()
[docs]
class SnapPin:
"""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
"""
_ = 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)
self._solid: Bosl2Solid = Bosl2Solid(
pin.shape,
size=[diameter + 2 * nub_depth, diameter, length + diameter / 2],
)
self._diameter: float = diameter
self._length: float = 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
[docs]
def shape(self) -> Bosl2Solid:
"""Return the snap pin geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the snap pin in the viewer."""
self._solid.show()
[docs]
class SnapPinSocket:
"""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
"""
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)
self._solid: Bosl2Solid = Bosl2Solid(
(bore | relief).shape,
size=[diameter + 2 * nub_depth + 2 * clearance, diameter + 2 * clearance, length],
)
self._diameter: float = diameter
self._length: float = 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
[docs]
def shape(self) -> Bosl2Solid:
"""Return the socket geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the socket in the viewer."""
self._solid.show()