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 typing import TYPE_CHECKING

from pybosl2._helpers import union
from pybosl2.constants import BOTTOM
from pybosl2.exceptions import Bosl2ValueError
from pybosl2.parts._buildable import Buildable
from pybosl2.solid import cuboid, cyl, prismoid, sphere

if TYPE_CHECKING:
    from pybosl2._backend import Solid

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


[docs] class LivingHingeMask(Buildable): """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. """ self._length: float = length self._thick: float = thick # 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 = ( length, thick, layerheight, foldangle, hingegap, slop, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( length, thick, layerheight, foldangle, hingegap, slop, ) = self._args hg = (layerheight if hingegap is None else hingegap) + 2 * slop top = hg + 2 * thick / math.tan(math.radians(foldangle / 2)) return prismoid([length, hg], [length, top], height=thick, anchor=BOTTOM).up(layerheight * 2) @property def length(self) -> float: """Hinge length in mm.""" return self._length @property def thick(self) -> float: """Plate thickness in mm.""" return self._thick @property def shape(self) -> "Solid": """Return the hinge mask geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class KnuckleHinge(Buildable): """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. """ if not (segs >= 2): raise Bosl2ValueError("knuckle_hinge(): segs must be >= 2.") self._length: float = length self._arm: float = arm self._inner: bool = inner # 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 = ( length, segs, knuckle_diam, pin_diam, arm, thick, gap, inner, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( length, segs, knuckle_diam, pin_diam, arm, thick, gap, inner, fn, fa, fs, ) = self._args seglen = (length - (segs - 1) * gap) / segs mine = 1 if inner else 0 def knuckle_x(index: int) -> float: return -length / 2 + seglen / 2 + index * (seglen + gap) parts: list["Solid"] = [] for i in range(segs): if (i % 2) != mine: continue parts.append( cyl(height=seglen, diameter=knuckle_diam, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]).right(knuckle_x(i)) ) 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]) # A leaf may only occupy the space around the pin WHERE ITS OWN KNUCKLES ARE. # # The plate used to span the whole length and reach the axis, so both leaves filled # the pin's neighbourhood everywhere: mated leaves shared a solid running the full # length of the hinge, and no rotation was possible. Clearing only the other leaf's # knuckles is not enough either -- the leftover plate roots still sweep through each # other as soon as the hinge folds. # # So cut the pin's whole neighbourhood (radius + `gap`, the same clearance `gap` # already gives axially between neighbouring knuckles) out of the plate, and keep it # only across this leaf's own knuckles, which is what joins plate to knuckle. Every # x along the hinge then belongs to exactly one leaf, at any fold angle. This is the # same invariant the SDF `_sdf.joiners.knuckle_hinge` gets by extruding arm and # knuckle together once per segment. clearance = cyl(height=length + 2, diameter=knuckle_diam + 2 * gap, fn=fn, fa=fa, fs=fs).rotate([0, 90, 0]) keep = [ cuboid([seglen, knuckle_diam + 2 * gap + 2, knuckle_diam + 2 * gap + 2]).right(knuckle_x(i)) for i in range(segs) if (i % 2) == mine ] if keep: clearance = clearance - union(keep) leaf = leaf - clearance return leaf.with_nominal_size([length, plate_w + knuckle_diam / 2, knuckle_diam]) @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 @property def shape(self) -> "Solid": """Return the knuckle hinge leaf geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class KnuckleHingePair(Buildable): """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. """ self._length: float = length self._fold: float = fold # 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 = ( length, segs, knuckle_diam, pin_diam, arm, thick, gap, fold, pin, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( length, segs, knuckle_diam, pin_diam, arm, thick, gap, fold, pin, fn, fa, fs, ) = self._args 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]) return hinge.with_nominal_size([length, 2 * arm + knuckle_diam, knuckle_diam]) @property def length(self) -> float: """Hinge length in mm.""" return self._length @property def fold(self) -> float: """Fold angle in degrees.""" return self._fold @property def shape(self) -> "Solid": """Return the hinge pair geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class SnapLock(Buildable): """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. """ self._thick: float = thick # 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 = ( thick, snaplen, snapdiam, layerheight, foldangle, hingegap, slop, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( thick, snaplen, snapdiam, layerheight, foldangle, hingegap, slop, fn, fa, fs, ) = self._args 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) # Nominal anchor box: the plate the snap is mounted on, so a lock and its socket anchor to # the same frame. The snap head stands above it, making bounds() taller. return (post | ridge).back(snap_x).with_nominal_size([snaplen, snapdiam, 2 * thick]) @property def thick(self) -> float: """Plate thickness in mm.""" return self._thick @property def shape(self) -> "Solid": """Return the snap-lock tab geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class SnapSocket(Buildable): """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. """ self._thick: float = thick # 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 = ( thick, snaplen, snapdiam, layerheight, foldangle, hingegap, slop, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( thick, snaplen, snapdiam, layerheight, foldangle, hingegap, slop, fn, fa, fs, ) = self._args 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) ) # Nominal anchor box: the plate, as SnapLock uses, so the two halves anchor to the same # frame. The socket's ridge stands above the plate, so bounds() is taller. return ((post | ridge) - divot).forward(snap_x).with_nominal_size([snaplen, snapdiam, 2 * thick]) @property def thick(self) -> float: """Plate thickness in mm.""" return self._thick @property def shape(self) -> "Solid": """Return the snap socket geometry.""" if self._solid is None: self._solid = self._build() return self._solid