Source code for pybosl2.parts.hinges

# 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/hinges.py
#    Pure-Python port of the hinges in BOSL2's hinges.scad. The classes provide
#    :class:`LivingHingeMask` (a wedge cut into a flat plate so it folds -- a print-in-place
#    "living" hinge), a functional interlocking :class:`KnuckleHinge` leaf (and
#    :class:`KnuckleHingePair`, the two mating leaves around one pin), and simple
#    :class:`SnapLock` / :class:`SnapSocket` connectors.
#
#    The knuckle hinge is a clean functional build (interlocking knuckles + a pin bore); BOSL2's
#    elaborate screw-pin / teardrop / clip / tag refinements are not reproduced.
#
# FileSummary: Living (folding) hinges, knuckle hinges, and snap connectors.
# DocCategory: Parts library
# FileGroup: BOSL2

"""Living (folding) hinges, knuckle hinges, and snap connectors."""

from __future__ import annotations

import math

from pybosl2._helpers import union
from pybosl2.constants import BOTTOM
from pybosl2.shapes3d import Bosl2Solid, cuboid, cyl, prismoid, sphere

__all__ = [
    "KnuckleHinge",
    "KnuckleHingePair",
    "LivingHingeMask",
    "SnapLock",
    "SnapSocket",
]


[docs] class LivingHingeMask: """A wedge mask to difference out of a plate to make a print-in-place living hinge. Centre it on the bottom of a plate of thickness *thick*; it leaves ``2*layerheight`` of material as the flexible hinge, and a V-groove wide enough to fold *foldangle* degrees. Examples: A living hinge cut into a 100x40 plate: .. pythonscad-example:: from pybosl2.parts.hinges import LivingHingeMask from pybosl2.solid import cuboid (cuboid([100, 40, 3]) - LivingHingeMask(length=100, thick=3, foldangle=60).shape().down(1.5)).show() """ def __init__( self, length: float, thick: float, layerheight: float = 0.2, foldangle: float = 90, hingegap: float | None = None, slop: float = 0.0, ) -> None: """Create a living hinge mask for a plate of the given *thick*ness and *length*. Args: length: Length of the hinge in mm. thick: Thickness of the plate in mm. layerheight: Layer height in mm. Defaults to 0.2. foldangle: Maximum fold angle in degrees. Defaults to 90. hingegap: Gap at the hinge point. Defaults to layerheight. slop: Extra clearance. Returns: None. """ hg = (layerheight if hingegap is None else hingegap) + 2 * slop top = hg + 2 * thick / math.tan(math.radians(foldangle / 2)) self._solid: Bosl2Solid = prismoid([length, hg], [length, top], height=thick, anchor=BOTTOM).up(layerheight * 2) self._length: float = length self._thick: float = thick @property def length(self) -> float: """Hinge length in mm.""" return self._length @property def thick(self) -> float: """Plate thickness in mm.""" return self._thick
[docs] def shape(self) -> Bosl2Solid: """Return the hinge mask geometry.""" return self._solid
[docs] def show(self) -> None: """Display the hinge mask in the viewer.""" self._solid.show()
[docs] class KnuckleHinge: """One leaf of an interlocking knuckle hinge with a pin bore. The hinge pin lies along X at the origin; the flat leaf extends in +Y (outer leaf) or -Y (inner leaf). *segs* is the total knuckle count across both leaves — the outer leaf takes the ``ceil(segs/2)`` even knuckles, the inner leaf the ``floor(segs/2)`` odd ones. Pair with :class:`KnuckleHingePair`. Examples: An outer knuckle hinge leaf: .. pythonscad-example:: from pybosl2.parts.hinges import KnuckleHinge KnuckleHinge().show() """ def __init__( self, length: float = 40, segs: int = 5, knuckle_diam: float = 6, pin_diam: float = 2, arm: float = 20, thick: float = 3, gap: float = 0.4, inner: bool = False, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a single knuckle hinge leaf. Args: length: Total hinge length in mm. Defaults to 40. segs: Total knuckle count across both leaves. Defaults to 5. knuckle_diam: Outer diameter of each knuckle. Defaults to 6. pin_diam: Diameter of the pin bore. Defaults to 2. arm: Length of the flat leaf arm. Defaults to 20. thick: Thickness of the flat leaf. Defaults to 3. gap: Gap between knuckles. Defaults to 0.4. inner: If True, build the inner leaf; outer otherwise. Defaults to False. fn: Number of fragments for rounded geometry. fa: Fragment angle for rounded geometry. fs: Fragment size for rounded geometry. Returns: None. """ assert segs >= 2, "knuckle_hinge(): segs must be >= 2." seglen = (length - (segs - 1) * gap) / segs parts: list[Bosl2Solid] = [] for i in range(segs): if (i % 2) != (1 if inner else 0): continue x = -length / 2 + seglen / 2 + i * (seglen + gap) parts.append(cyl(height=seglen, diameter=knuckle_diam, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]).right(x)) ydir = -1 if inner else 1 plate_w = arm + knuckle_diam / 2 parts.append(cuboid([length, plate_w, thick], fn=fn, fa=fa, fs=fs).back(ydir * plate_w / 2)) leaf = union(parts) leaf = leaf - cyl(height=length + 1, diameter=pin_diam, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]) self._solid: Bosl2Solid = Bosl2Solid(leaf.shape, size=[length, plate_w + knuckle_diam / 2, knuckle_diam]) self._length: float = length self._arm: float = arm self._inner: bool = inner @property def length(self) -> float: """Hinge length in mm.""" return self._length @property def arm(self) -> float: """Leaf arm length in mm.""" return self._arm @property def inner(self) -> bool: """True for the inner leaf, False for outer.""" return self._inner
[docs] def shape(self) -> Bosl2Solid: """Return the knuckle hinge leaf geometry.""" return self._solid
[docs] def show(self) -> None: """Display the knuckle hinge leaf in the viewer.""" self._solid.show()
[docs] class KnuckleHingePair: """Both leaves of a knuckle hinge, meshed around one pin. Set *fold* to rotate the inner leaf about the pin axis. With *pin*, a pin cylinder is included. Examples: A knuckle hinge folded 90 degrees: .. pythonscad-example:: from pybosl2.parts.hinges import KnuckleHingePair KnuckleHingePair(fold=90).show() """ def __init__( self, length: float = 40, segs: int = 5, knuckle_diam: float = 6, pin_diam: float = 2, arm: float = 20, thick: float = 3, gap: float = 0.4, fold: float = 0, pin: bool = True, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a pair of meshing knuckle hinge leaves. Args: length: Total hinge length in mm. Defaults to 40. segs: Total knuckle count across both leaves. Defaults to 5. knuckle_diam: Outer diameter of each knuckle. Defaults to 6. pin_diam: Diameter of the pin bore. Defaults to 2. arm: Length of the flat leaf arm. Defaults to 20. thick: Thickness of the flat leaf. Defaults to 3. gap: Gap between knuckles. Defaults to 0.4. fold: Angle to rotate the inner leaf. Defaults to 0. pin: If True, include a pin cylinder. Defaults to True. fn: Number of fragments for rounded geometry. fa: Fragment angle for rounded geometry. fs: Fragment size for rounded geometry. Returns: None. """ outer = KnuckleHinge( length, segs, knuckle_diam, pin_diam, arm, thick, gap, inner=False, fn=fn, fa=fa, fs=fs, ).shape() inner = KnuckleHinge( length, segs, knuckle_diam, pin_diam, arm, thick, gap, inner=True, fn=fn, fa=fa, fs=fs, ).shape() if fold: inner = inner.rotate([fold, 0, 0]) hinge = outer | inner if pin: hinge = hinge | cyl(height=length - gap, diameter=pin_diam - 0.1, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]) self._solid: Bosl2Solid = Bosl2Solid( hinge.shape, size=[length, 2 * arm + knuckle_diam, knuckle_diam], ) self._length: float = length self._fold: float = fold @property def length(self) -> float: """Hinge length in mm.""" return self._length @property def fold(self) -> float: """Fold angle in degrees.""" return self._fold
[docs] def shape(self) -> Bosl2Solid: """Return the hinge pair geometry.""" return self._solid
[docs] def show(self) -> None: """Display the hinge pair in the viewer.""" self._solid.show()
[docs] class SnapLock: """A snap-lock tab (a ridge on a post) that clicks into a :class:`SnapSocket`. Examples: A snap-lock tab: .. pythonscad-example:: from pybosl2.parts.hinges import SnapLock SnapLock().show() """ def __init__( self, thick: float = 3, snaplen: float = 5, snapdiam: float = 5, layerheight: float = 0.2, foldangle: float = 90, hingegap: float | None = None, slop: float = 0.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a snap-lock tab. Args: thick: Plate thickness in mm. Defaults to 3. snaplen: Snap tab length in mm. Defaults to 5. snapdiam: Snap ridge diameter in mm. Defaults to 5. layerheight: Layer height in mm. Defaults to 0.2. foldangle: Fold angle for the living hinge section. Defaults to 90. hingegap: Gap at the hinge point. Defaults to layerheight. slop: Extra clearance. fn: Number of fragments for rounded geometry. fa: Fragment angle for rounded geometry. fs: Fragment size for rounded geometry. Returns: None. """ hg = (layerheight if hingegap is None else hingegap) + 2 * slop snap_x = (snapdiam / 2 + (thick - 2 * layerheight)) / math.tan(math.radians(foldangle / 2)) + hg / 2 post = cuboid([snaplen, snapdiam, snapdiam / 2 + thick], fn=fn, fa=fa, fs=fs).up((snapdiam / 2 + thick) / 2) ridge = cyl(height=snaplen, diameter=snapdiam, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]).up(snapdiam / 2 + thick) self._solid: Bosl2Solid = Bosl2Solid((post | ridge).back(snap_x).shape, size=[snaplen, snapdiam, 2 * thick]) self._thick: float = thick @property def thick(self) -> float: """Plate thickness in mm.""" return self._thick
[docs] def shape(self) -> Bosl2Solid: """Return the snap-lock tab geometry.""" return self._solid
[docs] def show(self) -> None: """Display the snap-lock tab in the viewer.""" self._solid.show()
[docs] class SnapSocket: """The receiving socket for a :class:`SnapLock` tab. Examples: A snap socket: .. pythonscad-example:: from pybosl2.parts.hinges import SnapSocket SnapSocket().show() """ def __init__( self, thick: float = 3, snaplen: float = 5, snapdiam: float = 5, layerheight: float = 0.2, foldangle: float = 90, hingegap: float | None = None, slop: float = 0.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a snap socket. Args: thick: Plate thickness in mm. Defaults to 3. snaplen: Snap tab length in mm. Defaults to 5. snapdiam: Snap ridge diameter in mm. Defaults to 5. layerheight: Layer height in mm. Defaults to 0.2. foldangle: Fold angle for the living hinge section. Defaults to 90. hingegap: Gap at the hinge point. Defaults to layerheight. slop: Extra clearance. fn: Number of fragments for rounded geometry. fa: Fragment angle for rounded geometry. fs: Fragment size for rounded geometry. Returns: None. """ hg = (layerheight if hingegap is None else hingegap) + 2 * slop snap_x = (snapdiam / 2 + (thick - 2 * layerheight)) / math.tan(math.radians(foldangle / 2)) + hg / 2 post = cuboid([snaplen, snapdiam, snapdiam / 2 + thick], fn=fn, fa=fa, fs=fs).up((snapdiam / 2 + thick) / 2) ridge = cyl(height=snaplen, diameter=snapdiam, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]).up(snapdiam / 2 + thick) divot = ( sphere(diameter=snapdiam * 0.8, fn=fn, fa=fa, fs=fs) .scale([0.333, 1, 1]) .left((snaplen + snapdiam / 12) / 2) .up(snapdiam / 2 + thick) ) self._solid: Bosl2Solid = Bosl2Solid( ((post | ridge) - divot).forward(snap_x).shape, size=[snaplen, snapdiam, 2 * thick], ) self._thick: float = thick @property def thick(self) -> float: """Plate thickness in mm.""" return self._thick
[docs] def shape(self) -> Bosl2Solid: """Return the snap socket geometry.""" return self._solid
[docs] def show(self) -> None: """Display the snap socket in the viewer.""" self._solid.show()