# 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/modular_hose.py
# Pure-Python port of BOSL2's modular_hose.scad: the ball-and-socket segments of a modular
# coolant/adjustable hose (the "Loc-Line" style). :class:`HoseSegment` revolves a
# ball end, a socket end, or a full segment for the 1/4", 1/2" or 3/4" sizes;
# :func:`modular_hose_radius` gives the bore radius. The ball/socket cross-section
# profiles are the same turtle paths BOSL2 uses.
#
# FileSummary: Modular (Loc-Line style) ball-and-socket hose segments.
# DocCategory: Parts library
# FileGroup: BOSL2
"""Modular (Loc-Line style) ball-and-socket hose segments."""
from __future__ import annotations
import math
from enum import StrEnum
from pybosl2._native import native
from pybosl2.shapes3d import Bosl2Solid
from pybosl2.turtle import Turtle2DState, TurtleCommand, turtle2d
from pybosl2.turtle import TurtleCommandType as TCT # noqa: N817
_opolygon = native("polygon")
_orotate_extrude = native("rotate_extrude")
__all__ = ["HoseSegment", "HoseType", "modular_hose_radius"]
class HoseType(StrEnum):
"""Modular hose segment type."""
BALL = "ball"
SMALL = "small"
SOCKET = "socket"
BIG = "big"
SEGMENT = "segment"
_SQRT2 = math.sqrt(2)
def _ts(x: float) -> Turtle2DState:
"""Full turtle state starting at (x, 0) heading +X."""
return Turtle2DState(path=[[float(x), 0.0]])
# Ball ("small") end cross-section, one per size (1/4", 1/2", 3/4"), from modular_hose.scad.
_SMALL_CMDS = [
(
[
TurtleCommand(TCT.LEFT, angle=90 - 38.5),
TurtleCommand(TCT.ARCSTEPS, size=12),
TurtleCommand(TCT.ARCLEFT, radius=6.38493, angle=62.15),
TurtleCommand(TCT.ARCSTEPS, size=4),
TurtleCommand(TCT.ARCLEFT, radius=0.5, angle=90 + 38.5 - 62.15),
TurtleCommand(TCT.MOVE, size=0.76),
TurtleCommand(TCT.LEFT, angle=67.5),
TurtleCommand(TCT.MOVE, size=0.47),
TurtleCommand(TCT.LEFT, angle=90 - 67.5),
TurtleCommand(TCT.MOVE, size=4.165),
TurtleCommand(TCT.RIGHT, angle=30),
TurtleCommand(TCT.MOVE, size=2.1),
],
4.864,
),
(
[
TurtleCommand(TCT.LEFT, angle=90 - 41),
TurtleCommand(TCT.ARCSTEPS, size=16),
TurtleCommand(TCT.ARCLEFT, radius=10.7407, angle=64.27),
TurtleCommand(TCT.ARCSTEPS, size=4),
TurtleCommand(TCT.ARCLEFT, radius=0.5, angle=90 + 41 - 64.27),
TurtleCommand(TCT.MOVE, size=0.95 - 0.4),
TurtleCommand(TCT.LEFT, angle=45),
TurtleCommand(TCT.MOVE, size=0.4 * _SQRT2),
TurtleCommand(TCT.LEFT, angle=45),
TurtleCommand(TCT.MOVE, size=7.643 - 0.4),
TurtleCommand(TCT.RIGHT, angle=30),
TurtleCommand(TCT.MOVE, size=4.06),
],
8.1,
),
(
[
TurtleCommand(TCT.LEFT, angle=90 - 30.4),
TurtleCommand(TCT.ARCSTEPS, size=16),
TurtleCommand(TCT.ARCLEFT, radius=13.99219, angle=53),
TurtleCommand(TCT.ARCSTEPS, size=4),
TurtleCommand(TCT.ARCLEFT, radius=0.47, angle=90 - 53 + 30.4),
TurtleCommand(TCT.MOVE, size=0.597),
TurtleCommand(TCT.LEFT),
TurtleCommand(
TCT.MOVE,
size=9.908 - 1.905 / math.tan(math.radians(25)) + 3.81 * math.cos(math.radians(30)),
),
TurtleCommand(TCT.RIGHT, angle=25),
TurtleCommand(TCT.MOVE, size=1.905 / math.sin(math.radians(25))),
],
11.989,
),
]
# Socket ("big") end cross-section, one per size.
_BIG_CMDS = [
(
[
TurtleCommand(TCT.LEFT, angle=90 - 22),
TurtleCommand(TCT.MOVE, size=6.5),
TurtleCommand(TCT.LEFT, angle=0.75),
TurtleCommand(TCT.ARCSTEPS, size=8),
TurtleCommand(TCT.ARCLEFT, radius=6.5, angle=37.3),
TurtleCommand(TCT.SETDIR, size=90),
TurtleCommand(TCT.MOVE, size=0.21),
TurtleCommand(TCT.RIGHT),
TurtleCommand(TCT.MOVE, size=1.24),
TurtleCommand(TCT.RIGHT, angle=45),
TurtleCommand(TCT.MOVE, size=0.7835),
TurtleCommand(TCT.RIGHT, angle=19),
TurtleCommand(TCT.MOVE, size=1.05),
TurtleCommand(TCT.SETDIR, size=-90),
TurtleCommand(TCT.MOVE, size=1),
TurtleCommand(TCT.RIGHT, angle=22),
TurtleCommand(TCT.MOVE, size=8.76),
],
3.268,
),
(
[
TurtleCommand(TCT.LEFT),
TurtleCommand(TCT.RIGHT, angle=22),
TurtleCommand(TCT.MOVE, size=9),
TurtleCommand(TCT.ARCSTEPS, size=8),
TurtleCommand(TCT.ARCLEFT, radius=11, angle=36.5),
TurtleCommand(TCT.SETDIR, size=90),
TurtleCommand(TCT.MOVE, size=2 - 1.366),
TurtleCommand(TCT.RIGHT),
TurtleCommand(TCT.MOVE, size=0.91),
TurtleCommand(TCT.ARCSTEPS, size=4),
TurtleCommand(TCT.ARCRIGHT, radius=1.25, angle=90),
TurtleCommand(TCT.MOVE, size=2.2),
TurtleCommand(TCT.ARCSTEPS, size=8),
TurtleCommand(TCT.ARCRIGHT, radius=13, angle=22.4),
TurtleCommand(TCT.MOVE, size=8.73),
],
6.42154,
),
(
[
TurtleCommand(TCT.LEFT, angle=90 - 22),
TurtleCommand(TCT.MOVE, size=7.633),
TurtleCommand(TCT.ARCSTEPS, size=16),
TurtleCommand(TCT.ARCLEFT, radius=13.77, angle=35.27),
TurtleCommand(TCT.SETDIR, size=90),
TurtleCommand(TCT.MOVE, size=1.09),
TurtleCommand(TCT.RIGHT),
TurtleCommand(TCT.MOVE, size=1.0177),
TurtleCommand(TCT.RIGHT, angle=45),
TurtleCommand(TCT.MOVE, size=1.009),
TurtleCommand(TCT.RIGHT, angle=77.8 - 45),
TurtleCommand(TCT.MOVE, size=0.3),
TurtleCommand(TCT.ARCRIGHT, radius=15.5, angle=34.2),
TurtleCommand(TCT.MOVE, size=6.47),
],
9.90237,
),
]
_WAIST = [1.7698, 1.8251, 3.95998]
_SIZES = {0.25: 0, 0.5: 1, 0.75: 2}
_SMALL = [[[float(x), float(y)] for x, y in turtle2d(cmds, state=_ts(x0)).points()] for cmds, x0 in _SMALL_CMDS]
_BIG = [[[float(x), float(y)] for x, y in turtle2d(cmds, state=_ts(x0)).points()] for cmds, x0 in _BIG_CMDS]
def _bounds(pts: list[list[float]]) -> tuple[tuple[float, float], tuple[float, float]]:
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return (min(xs), min(ys)), (max(xs), max(ys))
def _size_index(size: float) -> int:
try:
return _SIZES[size]
except KeyError:
raise ValueError('modular_hose(): size must be 0.25, 0.5 or 0.75 (1/4", 1/2", 3/4").') from None
[docs]
class HoseSegment:
"""A modular-hose ball end, socket end, or full segment.
*size* is 0.25, 0.5 or 0.75 (the 1/4", 1/2", 3/4" hose families). *type*
is a :class:`HoseType` enum value. *clearance* loosens the fit.
Examples:
A 1/2" hose segment:
.. pythonscad-example::
from pybosl2.parts.modular_hose import HoseSegment, HoseType
HoseSegment(0.5, HoseType.SEGMENT).show()
"""
def __init__(
self,
size: float,
type: HoseType = HoseType.SEGMENT, # noqa: A002
clearance: float | list[float] = 0,
waist_len: float | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> None:
"""Create a modular-hose segment, ball end or socket end.
Args:
size: Hose size (0.25, 0.5 or 0.75 inches).
type: Segment type as a :class:`HoseType` enum value.
clearance: Clearance for fit tuning; a single float or ``[ball_clearance, socket_clearance]``.
waist_len: Length of the waist section between ball and socket; auto-derived if None.
fn: Number of facets for $fn-based resolution.
fa: Minimum facet angle.
fs: Minimum facet size.
Returns:
None.
Raises:
ValueError: If *size* is not one of 0.25, 0.5, 0.75, or *type* is invalid.
"""
ind = _size_index(size)
cl = clearance if isinstance(clearance, (list, tuple)) else [clearance, clearance]
small, big = _SMALL[ind], _BIG[ind]
(_sx, smy), _ = _bounds(small)
(_bx, bmy), _ = _bounds(big)
smallend = [[x - cl[0], y - smy] for x, y in small]
bigend = [[x + cl[1], y - bmy] for x, y in big]
mid = _WAIST[ind] if waist_len is None else waist_len
assert mid >= 0, "waist_len must be nonnegative."
if type in (HoseType.SEGMENT,):
shape = [[x, y + mid] for x, y in smallend] + [[x, -y] for x, y in bigend]
elif type in (HoseType.SMALL, HoseType.BALL):
shape = [[x, y + mid] for x, y in smallend] + [
[smallend[-1][0], 0],
[smallend[0][0], 0],
]
elif type in (HoseType.BIG, HoseType.SOCKET):
shape = [[x, y + mid] for x, y in bigend] + [
[bigend[-1][0], 0],
[bigend[0][0], 0],
]
else:
raise ValueError("modular_hose(): type must be one of BALL/SMALL/SOCKET/BIG/SEGMENT.")
(_mnx, mny), (mxx, mxy) = _bounds(shape)
cy = (mny + mxy) / 2
poly = [[x, y - cy] for x, y in shape]
self._solid: Bosl2Solid = Bosl2Solid(
_orotate_extrude(_opolygon(poly), fn=fn, fa=fa, fs=fs),
size=[2 * mxx, 2 * mxx, mxy - mny],
)
self._size: float = size
self._type: HoseType = type
@property
def size(self) -> float:
"""Hose size (0.25, 0.5 or 0.75 inches)."""
return self._size
@property
def hose_type(self) -> HoseType:
"""Segment type."""
return self._type
[docs]
def shape(self) -> Bosl2Solid:
"""Return the hose segment geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the hose segment in the viewer."""
self._solid.show()
[docs]
def modular_hose_radius(size: float, outer: bool = False) -> float:
"""Return the inner (bore) or *outer* radius of a modular hose of *size*.
Args:
size: Hose size (0.25, 0.5 or 0.75 inches).
outer: If True, return the outer radius instead of the inner (bore) radius.
Returns:
The bore or outer radius in mm for the given hose size.
Raises:
ValueError: If *size* is not one of 0.25, 0.5, 0.75.
"""
big = _BIG[_size_index(size)]
return big[-1][0] if outer else big[0][0]