Source code for pybosl2.parts.gears

# 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

# mypy: allow-untyped-defs

# LibFile: pybosl2/parts/gears.py
#    Pure-Python port of the core of BOSL2's (current) gears.scad. Gears are sized by circular pitch
#    (``circ_pitch``), metric ``mod``, or ``diam_pitch``; the default 20-degree pressure angle and
#    ``profile_shift=None`` (which corrects undercut on low-tooth-count gears) match BOSL2. The
#    :class:`SpurGear2d` / :class:`SpurGear` teeth are generated the way BOSL2 does it:
#    the involute working flank plus the trochoid that a meshing rack would carve, so low-tooth gears
#    get a real undercut. :class:`HerringboneGear`, the linear :class:`Rack`, the
#    internal :class:`RingGear`, the :class:`BevelGear` and the :class:`Worm` /
#    :class:`WormGear` pair are ported too, along with the dimension helpers and
#    :func:`gear_dist` (meshing-distance) / :func:`auto_profile_shift`.
#
#    Bevel/worm sweep a simpler symmetric involute tooth (no undercut modelling) -- fine for those
#    swept 3-D forms.
#
#    Note: the helical *sign* sets the twist handedness of a 3-D gear directly here; BOSL2 reaches the
#    same geometry via an internal helical inversion, so a given ``helical`` value may produce the
#    opposite hand from BOSL2. A helical gear still meshes its opposite-hand mate either way.
#
# FileSummary: Gears: spur (with undercut), helical, herringbone, rack, ring, bevel, worm.
# DocCategory: Parts library
# FileGroup: BOSL2

"""Gears: spur (with undercut), helical, herringbone, rack, ring, bevel, worm."""

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

import numpy as np

from pybosl2._backend import csg_part
from pybosl2._helpers import frag_count as _frag_count
from pybosl2._native import native
from pybosl2.caps import CapType
from pybosl2.constants import INCH
from pybosl2.enums import VNFStyle
from pybosl2.exceptions import Bosl2ValueError
from pybosl2.math import lerp as _math_lerp
from pybosl2.parts._buildable import Buildable
from pybosl2.path2d import Path2D
from pybosl2.path3d import Path3D
from pybosl2.shapes3d import cylinder
from pybosl2.solid import cyl
from pybosl2.solid import cylinder as facade_cylinder
from pybosl2.vectors import v_theta as _v_theta
from pybosl2.vnf import VNF

if TYPE_CHECKING:  # real stub-typed imports for the checker (identical to pre-lazy)
    from pythonscad import polygon as _opolygon

    from pybosl2._backend import Solid
    from pybosl2.regions import Region
else:
    _opolygon = native("polygon")

__all__ = [
    "BevelGear",
    "GearSpec",
    "GearToothProfile",
    "HerringboneGear",
    "Rack",
    "Rack2d",
    "RingGear",
    "SpurGear",
    "SpurGear2d",
    "Worm",
    "WormGear",
]

PI = math.pi


# ---------------------------------------------------------------------------
# Section: pitch / module resolution and the derived radii (BOSL2 gears.scad)
# ---------------------------------------------------------------------------


def _circular_pitch(
    circ_pitch: float | None = None,
    mod: float | None = None,
    pitch: float | None = None,
    diam_pitch: float | None = None,
) -> float:
    """Resolve the circular pitch from any of the accepted pitch inputs (BOSL2 circular_pitch()).

    When none is given, defaults to a circular pitch of 5 (like BOSL2's ``mod``-ish default gear).
    """
    if pitch is not None:
        return pitch
    if circ_pitch is not None:
        return circ_pitch
    if diam_pitch is not None:
        return PI / diam_pitch * INCH
    if mod is not None:
        return mod * PI
    return 5.0


def _module_value(circ_pitch: float) -> float:
    return circ_pitch / PI


def _pitch_radius(circ_pitch: float, teeth: int, helical: float = 0) -> float:
    return circ_pitch * teeth / PI / 2 / math.cos(math.radians(helical))


def _adendum(circ_pitch: float, profile_shift: float = 0, shorten: float = 0) -> float:
    return _module_value(circ_pitch) * (1 + profile_shift - shorten)


def _dedendum(circ_pitch: float, clearance: float | None = None, profile_shift: float = 0) -> float:
    mod = _module_value(circ_pitch)
    clear = 0.25 * mod if clearance is None else clearance
    return mod * (1 - profile_shift) + clear


def _base_radius(circ_pitch: float, teeth: int, pressure_angle: float = 20, helical: float = 0) -> float:
    trans_pa = math.degrees(math.atan(math.tan(math.radians(pressure_angle)) / math.cos(math.radians(helical))))
    return _pitch_radius(circ_pitch, teeth, helical) * math.cos(math.radians(trans_pa))


def _root_radius_basic(
    circ_pitch: float,
    teeth: int,
    clearance: float | None = None,
    internal: bool = False,
    helical: float = 0,
    profile_shift: float = 0,
) -> float:
    pr = _pitch_radius(circ_pitch, teeth, helical)
    return pr - (_adendum(circ_pitch, -profile_shift) if internal else _dedendum(circ_pitch, clearance, profile_shift))


def _outer_radius_basic(
    circ_pitch: float,
    teeth: int,
    clearance: float | None = None,
    internal: bool = False,
    helical: float = 0,
    profile_shift: float = 0,
    shorten: float = 0,
) -> float:
    pr = _pitch_radius(circ_pitch, teeth, helical)
    return pr + (
        _dedendum(circ_pitch, clearance, -profile_shift) if internal else _adendum(circ_pitch, profile_shift, shorten)
    )


def _auto_profile_shift(
    teeth: int,
    pressure_angle: float = 20,
    helical: float = 0,
    profile_shift: float | None = None,
) -> float:
    """Minimum profile shift to avoid undercut, or the given value (BOSL2 auto_profile_shift())."""
    if isinstance(profile_shift, (int, float)):
        return float(profile_shift)
    if teeth == 0:
        return 0.0
    pa = math.atan(math.tan(math.radians(pressure_angle)) / math.cos(math.radians(helical)))
    min_teeth = 2 / math.sin(pa) ** 2
    if teeth > math.floor(min_teeth):
        return 0.0
    return (1 - teeth / min_teeth) / math.cos(math.radians(helical))


# ---------------------------------------------------------------------------
# Section: 2-D geometry helpers for the tooth generator
# ---------------------------------------------------------------------------


def _involute(base_r: float, a_deg: float) -> list[float]:
    b = a_deg * PI / 180
    ar = math.radians(a_deg)
    return [
        base_r * (math.cos(ar) + b * math.sin(ar)),
        base_r * (math.sin(ar) - b * math.cos(ar)),
    ]


def _xy_to_polar(xy: list[float]) -> list[float]:
    return [math.hypot(xy[0], xy[1]), math.degrees(math.atan2(xy[1], xy[0]))]


def _p2xy(r: float, angle: float) -> list[float]:
    a = math.radians(angle)
    return [r * math.cos(a), r * math.sin(a)]


def _lookup(x: float, table: list[list[float]]) -> float:
    xs = [t[0] for t in table]
    ys = [t[1] for t in table]
    if xs[0] > xs[-1]:
        xs, ys = xs[::-1], ys[::-1]
    return float(np.interp(x, xs, ys))


def _zrot_pts(pts: list[list[float]], angle: float) -> list[list[float]]:
    a = math.radians(angle)
    c, s = math.cos(a), math.sin(a)
    return [[x * c - y * s, x * s + y * c] for x, y in pts]


def _line_isect(l1: list[list[float]], l2: list[list[float]]) -> list[float]:
    (x1, y1), (x2, y2) = l1[0], l1[1]
    (x3, y3), (x4, y4) = l2[0], l2[1]
    den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
    if abs(den) < 1e-12:
        return [float(l1[1][0]), float(l1[1][1])]
    px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / den
    py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / den
    return [px, py]


def _vector_angle(three: list[list[float]]) -> float:
    from pybosl2.geometry import vector_angle3

    return vector_angle3(three[0], three[1], three[2])


def _arc_corner(n: int, r: float, corner: list[list[float]]) -> list[list[float]]:
    """n-point arc of radius r rounding the corner ``[p0, p1, p2]`` (BOSL2 arc(corner=))."""
    p0, p1, p2 = (np.asarray(p, float) for p in corner)
    u0 = (p0 - p1) / np.linalg.norm(p0 - p1)
    u1 = (p2 - p1) / np.linalg.norm(p2 - p1)
    half = math.acos(np.clip(np.dot(u0, u1), -1, 1)) / 2
    if half <= 1e-9:
        return [p1.tolist()]
    center = p1 + (u0 + u1) / np.linalg.norm(u0 + u1) * (r / math.sin(half))
    t0, t1 = p1 + u0 * (r / math.tan(half)), p1 + u1 * (r / math.tan(half))
    a0 = math.atan2(t0[1] - center[1], t0[0] - center[0])
    a1 = math.atan2(t1[1] - center[1], t1[0] - center[0])
    da = (a1 - a0 + math.pi) % (2 * math.pi) - math.pi
    return [
        [
            center[0] + r * math.cos(a0 + da * i / n),
            center[1] + r * math.sin(a0 + da * i / n),
        ]
        for i in range(n + 1)
    ]


def _dedup(pts: list[list[float]], eps: float = 1e-9) -> list[list[float]]:
    from pybosl2.path2d import Path2D

    return [list(p) for p in Path2D._deduplicate(pts, closed=False, eps=eps)]


def _norm2(v: list[float]) -> float:
    return math.hypot(v[0], v[1])


def _strip_left(path: list[list[float]], undercut_max: float) -> list[list[float]]:
    """Remove the inward 'jaggies' the undercut can leave (BOSL2 strip_left)."""
    out = []
    i = 0
    sides = len(path)
    while i < sides:
        p = path[i]
        if _norm2(p) >= undercut_max:
            out += [list(q) for q in path[i:]]
            break
        out.append(list(p))
        angs = [
            _v_theta([path[j][0] - p[0], path[j][1] - p[1]])
            for j in range(i + 1, sides)
            if _norm2(path[j]) < undercut_max
        ]
        if not angs:
            i += 1
        else:
            i += int(np.argmin(angs)) + 1
    return out


# ---------------------------------------------------------------------------
# Section: the involute gear tooth (BOSL2 _gear_tooth_profile), with undercut
# ---------------------------------------------------------------------------


def _gear_tooth_profile(
    circ_pitch: float,
    teeth: int,
    pressure_angle: float = 20,
    clearance: float | None = None,
    backlash: float = 0.0,
    helical: float = 0,
    internal: bool = False,
    profile_shift: float = 0.0,
    shorten: float = 0,
    center: bool = False,
    steps: int = 16,
) -> list[list[float]]:
    pa = pressure_angle
    mod = _module_value(circ_pitch)
    clear = 0.25 * mod if clearance is None else clearance
    arad = _outer_radius_basic(circ_pitch, teeth, None, internal, helical, profile_shift, shorten)
    prad = _pitch_radius(circ_pitch, teeth, helical)
    brad = _base_radius(circ_pitch, teeth, pa, helical)
    rrad = _root_radius_basic(circ_pitch, teeth, clear, internal, helical, profile_shift)
    _srad = max(rrad, brad)
    tthick = circ_pitch / PI / math.cos(math.radians(helical)) * (
        PI / 2 + 2 * profile_shift * math.tan(math.radians(pa))
    ) + (backlash if internal else -backlash)
    tang = tthick / prad / 2 * 180 / PI

    involute_lup: list[list[float]] = []
    i = 0.0
    end = arad / PI / brad * 360
    while i <= end:
        pol = _xy_to_polar(_involute(brad, i))
        if pol[0] <= arad * 1.1:
            involute_lup.append([pol[0], 90 - pol[1]])
        i += 5
    involute_rlup = [[y, x] for x, y in involute_lup]

    b_ang = _lookup(brad, involute_lup)
    p_ang = _lookup(prad, involute_lup)
    soff = tang + (b_ang - p_ang)
    ma_rad = min(arad, _lookup(90 - soff + 0.05 * 360 / teeth / 2, involute_rlup))
    ma_ang = _lookup(ma_rad, involute_lup)
    cap_steps = max(1, math.ceil((ma_ang + soff - 90) / 5))
    cap_step = (ma_ang + soff - 90) / cap_steps
    ax = circ_pitch / 4 - (circ_pitch / PI) * math.tan(math.radians(pa))

    undercut = []
    a = math.degrees(math.atan2(ax, rrad))
    while a >= -90:
        bx = -a / 360 * 2 * PI * prad
        pol = _xy_to_polar([bx + ax, prad - circ_pitch / PI + profile_shift * circ_pitch / PI])
        if pol[0] < arad * 1.05:
            undercut.append([pol[0], pol[1] - a + 180 / teeth])
        a -= 1
    if undercut:
        uc_min = int(np.argmin([u[0] for u in undercut]))
        undercut_lup = undercut[uc_min:]
    else:
        undercut_lup = [[rrad, 0.0]]

    us = [k / steps / 2 for k in range(steps * 2 + 1)]

    def flank_angle(r: float) -> tuple[float, float, bool]:
        a1 = _lookup(r, involute_lup) + soff
        if internal or r < undercut_lup[0][0]:
            return a1, a1, False
        a2 = _lookup(r, undercut_lup)
        return min(a1, a2), a2, a1 > a2

    undercut_max = 0.0
    for u in us:
        radius = _lerp(rrad, ma_rad, u)
        aa, _a2, use_uc = flank_angle(radius)
        if aa < 90 + 180 / teeth and use_uc:
            undercut_max = max(undercut_max, radius)

    tooth_half_raw = []
    for u in us:
        radius = _lerp(rrad, ma_rad, u)
        aa, _a2, _uc = flank_angle(radius)
        if (internal or radius > rrad + clear) and (not internal or radius < ma_rad - clear) and aa < 90 + 180 / teeth:
            tooth_half_raw.append(_p2xy(radius, aa))
    if not internal:
        for k in range(cap_steps):
            tooth_half_raw.append(_p2xy(ma_rad, ma_ang + soff - k * (cap_step - 1)))

    if len(tooth_half_raw) < 2:
        tooth_half_raw += [_p2xy(ma_rad, 90)]

    rcircum = 2 * PI * (ma_rad if internal else rrad)
    rpart = (180 / teeth - tang) / 360
    if internal:
        line1 = tooth_half_raw[-2:]
        line2 = [[0, ma_rad], [-1, ma_rad]]
    else:
        line1 = tooth_half_raw[0:2]
        line2 = _zrot_pts([[0, rrad], [1, rrad]], 180 / teeth)
    isect_pt = _line_isect(line1, line2)
    rcorner = [tooth_half_raw[-1], isect_pt, line2[0]] if internal else [line2[0], isect_pt, line1[0]]
    maxr = _norm2([rcorner[0][0] - rcorner[1][0], rcorner[0][1] - rcorner[1][1]]) * math.tan(
        math.radians(_vector_angle(rcorner) / 2)
    )
    round_r = min(maxr, clear, rcircum * rpart)

    rounded: list[list[float]] = []
    if not internal:
        rounded += _arc_corner(8, round_r, rcorner) if round_r > 0 else [isect_pt]
    rounded += tooth_half_raw
    if internal:
        rounded += _arc_corner(8, round_r, rcorner) if round_r > 0 else [isect_pt]
    rounded = _dedup(rounded)

    tooth_half = _strip_left(rounded, undercut_max) if undercut_max else rounded

    invalid = [
        i2
        for i2 in range(len(tooth_half))
        if math.degrees(math.atan2(tooth_half[i2][1], tooth_half[i2][0])) > 90 + 180 / teeth
    ]
    if invalid:
        ind = invalid[-1]
        ipt = _line_isect([[0, 0], _p2xy(1, 90 + 180 / teeth)], tooth_half[ind : ind + 2])
        clipped = [ipt] + [list(q) for q in tooth_half[ind + 1 :]]
    else:
        clipped = tooth_half

    full = _dedup([list(q) for q in clipped] + [[-x, y] for x, y in reversed(clipped)])
    merged = Path2D(full).merge_collinear(closed=False)
    if center:
        merged = [[x, y - prad] for x, y in merged]  # type: ignore[assignment]
    return [[float(x), float(y)] for x, y in merged]


def _lerp(a: float, b: float, v: float) -> float:
    return float(_math_lerp(a, b, v))


# ---------------------------------------------------------------------------
# Section: matrix / VNF helpers for the 3-D bevel and worm gears
# ---------------------------------------------------------------------------


def _polar(r: float, t_deg: float) -> list[float]:
    a = math.radians(t_deg)
    return [r * math.sin(a), r * math.cos(a)]


def _iang(radius1: float, radius2: float) -> float:
    return math.degrees(math.sqrt((radius2 / radius1) ** 2 - 1) - math.acos(radius1 / radius2))


def _q6(b: float, s: float, t: float, d: float) -> list[float]:
    return _polar(d, s * (_iang(b, d) + t))


def _q7(f: float, r: float, b: float, radius2: float, t: float, s: float) -> list[float]:
    return _q6(b, s, t, (1 - f) * max(b, r) + f * radius2)


def _rot2d(pts: list[list[float]], ang_deg: float) -> list[list[float]]:
    a = math.radians(ang_deg)
    c, s = math.cos(a), math.sin(a)
    return [[x * c - y * s, x * s + y * c] for x, y in pts]


def _polar_xy(r: float, angle: float) -> np.ndarray[tuple[int, ...], np.dtype[np.float64]]:
    a = math.radians(angle)
    return np.array([r * math.cos(a), r * math.sin(a)])


def _law_of_cosines(a: float, b: float, c: float) -> float:
    return math.degrees(math.acos(max(-1.0, min(1.0, (a * a + b * b - c * c) / (2 * a * b)))))


def _opp_ang_to_hyp(opp: float, angle: float) -> float:
    return opp / math.sin(math.radians(angle))


def _m_up(z: float) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    m = np.eye(4)
    m[2, 3] = z
    return m


def _m_back(y: float) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    m = np.eye(4)
    m[1, 3] = y
    return m


def _m_move(v: list[float]) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    m = np.eye(4)
    m[0, 3], m[1, 3], m[2, 3] = v[0], v[1], v[2]
    return m


def _m_zrot(deg: float) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    a = math.radians(deg)
    c, s = math.cos(a), math.sin(a)
    m = np.eye(4)
    m[0, 0] = c
    m[0, 1] = -s
    m[1, 0] = s
    m[1, 1] = c
    return m


def _m_xrot(deg: float) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    a = math.radians(deg)
    c, s = math.cos(a), math.sin(a)
    m = np.eye(4)
    m[1, 1] = c
    m[1, 2] = -s
    m[2, 1] = s
    m[2, 2] = c
    return m


def _m_scale(u: float) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    return np.diag([u, u, u, 1.0])


def _m_xflip() -> np.ndarray[tuple[int, int], np.dtype[np.float64]]:
    m = np.eye(4)
    m[0, 0] = -1
    return m


def _apply(
    m: np.ndarray[tuple[int, int], np.dtype[np.float64]],
    pts: list[list[float]],
) -> list[list[float]]:
    arr = np.c_[np.asarray(pts, dtype=float), np.ones(len(pts))]
    return (arr @ m.T)[:, :3].tolist()  # type: ignore[no-any-return]


def _vnf_join(vnfs: list[VNF]) -> VNF:
    verts: list[list[float]] = []
    faces: list[list[int]] = []
    for v in vnfs:
        off = len(verts)
        verts += [list(p) for p in v.vertices]
        faces += [[i + off for i in f] for f in v.faces]
    return VNF(verts, faces)


def _vnf_xflip(vnf: VNF) -> VNF:
    return VNF([[-x, y, z] for x, y, z in vnf.vertices], [f[::-1] for f in vnf.faces])


def _simple_tooth(
    circ_pitch: float,
    teeth: int,
    pressure_angle: float,
    clearance: float | None = None,
    backlash: float = 0.0,
    interior: bool = False,
    center: bool = False,
) -> list[list[float]]:
    """Return a simple symmetric involute tooth (the older BOSL2 profile) for the swept bevel/worm forms."""
    p = _pitch_radius(circ_pitch, teeth)
    c = _outer_radius_basic(circ_pitch, teeth, clearance, interior, 0, 0, 0)
    radius = _root_radius_basic(circ_pitch, teeth, clearance, interior, 0, 0)
    b = p * math.cos(math.radians(pressure_angle))
    t = circ_pitch / 2 - backlash / 2
    k = -_iang(b, p) - math.degrees(t / 2 / p)
    isteps = 5
    pts = [_polar(radius, -k if radius >= b else 180 / teeth)]
    pts += [_q7(i / isteps, radius, b, c, k, -1) for i in range(isteps + 1)]
    pts += [_q7(i / isteps, radius, b, c, k, 1) for i in range(isteps, -1, -1)]
    pts.append(_polar(radius, k if radius >= b else -180 / teeth))
    if center:
        pts = [[x, y - p] for x, y in pts]
    return pts


# ---------------------------------------------------------------------------
# Section: gear specification dataclass
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class GearSpec:
    """Resolved gear pitch and radius dimensions.

    Construct from any pitch specification: ``GearSpec(pitch=5, teeth=20)``,
    ``GearSpec(mod=2, teeth=30, helical=15)``, etc.
    """

    teeth: int
    circ_pitch: float
    pressure_angle: float = 20
    helical: float = 0
    clearance: float | None = None
    internal: bool = False
    profile_shift: float = 0.0
    shorten: float = 0

    def __init__(
        self,
        teeth: int,
        circ_pitch: float | None = None,
        mod: float | None = None,
        pitch: float | None = None,
        diam_pitch: float | None = None,
        pressure_angle: float = 20,
        clearance: float | None = None,
        internal: bool = False,
        helical: float = 0,
        profile_shift: float | None = None,
        shorten: float = 0,
    ) -> None:
        """Resolve pitch inputs and auto-correct profile shift for undercut.

        Args:
            teeth: Number of teeth on the gear.
            circ_pitch: Circular pitch in mm/tooth.
            mod: Metric module (mm/tooth).
            pitch: Circular pitch alias.
            diam_pitch: Diametral pitch (teeth per inch of pitch diameter).
            pressure_angle: Pressure angle in degrees.
            clearance: Clearance, or None for default (0.25 * module).
            internal: True for internal (ring) gears.
            helical: Helical angle in degrees.
            profile_shift: Explicit profile shift, or None for auto correction.
            shorten: Amount to shorten the teeth.

        Returns:
            None

        """
        object.__setattr__(self, "teeth", teeth)
        object.__setattr__(self, "pressure_angle", pressure_angle)
        object.__setattr__(self, "helical", helical)
        object.__setattr__(self, "clearance", clearance)
        object.__setattr__(self, "internal", internal)
        object.__setattr__(self, "shorten", shorten)
        cp = _circular_pitch(circ_pitch, mod, pitch, diam_pitch)
        object.__setattr__(self, "circ_pitch", cp)
        ps = _auto_profile_shift(teeth, pressure_angle, helical, profile_shift)
        object.__setattr__(self, "profile_shift", ps)

    @property
    def module(self) -> float:
        """Metric module (mm)."""
        return _module_value(self.circ_pitch)

    @property
    def pitch_radius(self) -> float:
        """Pitch-circle radius."""
        return _pitch_radius(self.circ_pitch, self.teeth, self.helical)

    @property
    def outer_radius(self) -> float:
        """Outer (tip) radius."""
        return _outer_radius_basic(
            self.circ_pitch,
            self.teeth,
            self.clearance,
            self.internal,
            self.helical,
            self.profile_shift,
            self.shorten,
        )

    @property
    def root_radius(self) -> float:
        """Root radius."""
        return _root_radius_basic(
            self.circ_pitch,
            self.teeth,
            self.clearance,
            self.internal,
            self.helical,
            self.profile_shift,
        )

    @property
    def base_radius(self) -> float:
        """Base-circle radius of the involute."""
        return _base_radius(self.circ_pitch, self.teeth, self.pressure_angle, self.helical)

    @property
    def diametral_pitch(self) -> float:
        """Diametral pitch (teeth per inch of pitch diameter)."""
        return PI / self.circ_pitch

[docs] @staticmethod def circular_pitch( circ_pitch: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> float: """Circular pitch (mm/tooth) from any pitch input. Args: circ_pitch: Circular pitch in mm/tooth. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: Resolved circular pitch in mm/tooth. """ return _circular_pitch(circ_pitch, mod, pitch, diam_pitch)
[docs] @staticmethod def pitch_value(mod: float) -> float: """Circular pitch from the metric module. Args: mod: Metric module (mm/tooth). Returns: Circular pitch in mm/tooth. """ return mod * PI
[docs] @staticmethod def module_value( circ_pitch: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> float: """Metric module from any pitch input. Args: circ_pitch: Circular pitch in mm/tooth. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: Metric module value. """ return _module_value(_circular_pitch(circ_pitch, mod, pitch, diam_pitch))
[docs] @staticmethod def diametral_pitch_func( circ_pitch: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> float: """Diametral pitch (teeth per inch of pitch diameter) from any pitch input. Args: circ_pitch: Circular pitch in mm/tooth. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: Diametral pitch value. """ return PI / _circular_pitch(circ_pitch, mod, pitch, diam_pitch)
[docs] @staticmethod def auto_profile_shift( teeth: int, pressure_angle: float = 20, helical: float = 0, profile_shift: float | None = None, ) -> float: """Minimum profile shift (modules) to avoid undercut. Args: teeth: Number of teeth on the gear. pressure_angle: Pressure angle in degrees. helical: Helical angle in degrees. profile_shift: Explicit profile shift override, or None for auto. Returns: Profile shift value (modules). """ return _auto_profile_shift(teeth, pressure_angle, helical, profile_shift)
[docs] @staticmethod def bevel_pitch_angle(teeth: int, mate_teeth: float, drive_angle: float = 90) -> float: """Pitch angle (deg) for a bevel gear meshing another. Args: teeth: Number of teeth on the gear. mate_teeth: Number of teeth on the mating gear. drive_angle: Shaft angle between gears in degrees. Returns: Pitch angle in degrees. """ return math.degrees( math.atan2(math.sin(math.radians(drive_angle)), (mate_teeth / teeth) + math.cos(math.radians(drive_angle))) )
[docs] @staticmethod def worm_gear_thickness( circ_pitch: float | None = None, teeth: int = 30, worm_diam: float = 30, worm_arc: float = 60, crowning: float = 1, clearance: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> float: """Thickness of a worm gear matched to a worm. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the worm gear. worm_diam: Diameter of the mating worm. worm_arc: Arc angle the worm gear wraps around the worm. crowning: Crowning amount. clearance: Clearance, or None for default. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: Worm gear thickness in mm. """ center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) radius = worm_diam / 2 + crowning pitch_thick = radius * math.sin(math.radians(worm_arc / 2)) * 2 pr = _pitch_radius(center, teeth) rr = pr - _dedendum(center, clearance) pitchoff = (pr - rr) * math.sin(math.radians(worm_arc / 2)) return pitch_thick + 2 * pitchoff
[docs] @staticmethod def gear_dist( teeth1: int, teeth2: int, helical: float = 0, profile_shift1: float | None = None, profile_shift2: float | None = None, internal1: bool = False, internal2: bool = False, backlash: float = 0, pressure_angle: float = 20, circ_pitch: float | None = None, mod: float | None = None, diam_pitch: float | None = None, ) -> float: """Center-to-center distance for two meshing gears. Args: teeth1: Number of teeth on the first gear. teeth2: Number of teeth on the second gear. helical: Helical angle in degrees. profile_shift1: Profile shift for the first gear, or None for auto. profile_shift2: Profile shift for the second gear, or None for auto. internal1: True if the first gear is an internal (ring) gear. internal2: True if the second gear is an internal (ring) gear. backlash: Backlash amount in mm. pressure_angle: Pressure angle in degrees. circ_pitch: Circular pitch in mm/tooth. mod: Metric module (mm/tooth). diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: Center-to-center meshing distance in mm. """ m_val = _module_value(_circular_pitch(circ_pitch, mod, None, diam_pitch)) ps1 = _auto_profile_shift(teeth1, pressure_angle, helical, profile_shift1) ps2 = _auto_profile_shift(teeth2, pressure_angle, helical, profile_shift2) t1 = -teeth1 if internal2 else teeth1 t2 = -teeth2 if internal1 else teeth2 if internal2: ps1 = -ps1 if internal1: ps2 = -ps2 if teeth1 == 0 or teeth2 == 0: return _pitch_radius(m_val * PI, t1 + t2, helical) + (ps1 + ps2) * m_val pa = math.radians(pressure_angle) pa_transv = math.atan(math.tan(pa) / math.cos(math.radians(helical))) def inv(a: float) -> float: return math.tan(a) - a target = inv(pa_transv) + 2 * (ps1 + ps2) / (t1 + t2) * math.tan(pa) lo, hi = 1e-4, math.radians(89) for _ in range(60): mid = (lo + hi) / 2 if inv(mid) < target: lo = mid else: hi = mid pa_eff = (lo + hi) / 2 diameter = m_val * (t1 + t2) * math.cos(pa_transv) / math.cos(pa_eff) / math.cos(math.radians(helical)) / 2 return diameter + (-1 if (internal1 or internal2) else 1) * backlash * math.cos( math.radians(helical) ) / math.tan(pa)
# --------------------------------------------------------------------------- # Section: geometry classes # --------------------------------------------------------------------------- def _rack2d_path( center: float, teeth: int, height: float, pressure_angle: float, backlash: float, clearance: float | None, ) -> list[list[float]]: a = _adendum(center) diameter = _dedendum(center, clearance) if not (a + diameter < height): raise Bosl2ValueError("rack(): height must exceed adendum + dedendum.") xa = a * math.sin(math.radians(pressure_angle)) xd = diameter * math.sin(math.radians(pressure_angle)) left = -(teeth - 1) / 2 * center - 0.5 * center right = (teeth - 1) / 2 * center + 0.5 * center path = [[left, a - height], [left, -diameter]] for i in range(teeth): off = (i - (teeth - 1) / 2) * center path += [ [off - 0.25 * center + backlash - xd, -diameter], [off - 0.25 * center + backlash + xa, a], [off + 0.25 * center - backlash - xa, a], [off + 0.25 * center - backlash + xd, -diameter], ] path += [[right, -diameter], [right, a - height]] return path
[docs] class GearToothProfile: """The 2-D path of one involute gear tooth, rack-carved with real undercut.""" def __init__( self, circ_pitch: float | None = None, teeth: int = 11, pressure_angle: float = 20, clearance: float | None = None, backlash: float = 0.0, helical: float = 0, internal: bool = False, profile_shift: float | None = None, shorten: float = 0, center: bool = False, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> None: """Compute the involute gear tooth profile. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. pressure_angle: Pressure angle in degrees. clearance: Clearance, or None for default (0.25 * module). backlash: Backlash amount in mm. helical: Helical angle in degrees. internal: True for internal (ring) gears. profile_shift: Explicit profile shift, or None for auto correction. shorten: Amount to shorten the teeth. center: If True, center the tooth vertically on the pitch circle. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: None """ circ_p: float = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) ps: float = _auto_profile_shift(teeth, pressure_angle, helical, profile_shift) self._path: list[list[float]] = _gear_tooth_profile( circ_p, teeth, pressure_angle, clearance, backlash, helical, internal, ps, shorten, center, )
[docs] def path(self) -> list[list[float]]: """Return the tooth profile as a 2-D point list. Returns: List of [x, y] points defining the tooth profile. """ return self._path
[docs] class SpurGear2d: """A 2-D involute spur gear outline. Examples: A 30-tooth metric gear: .. pythonscad-example:: from pybosl2.parts.gears import SpurGear2d SpurGear2d(mod=5, teeth=30).shape.linear_extrude(height=3).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 11, hide: int = 0, pressure_angle: float = 20, clearance: float | None = None, backlash: float = 0.0, internal: bool = False, profile_shift: float | None = None, helical: float = 0, shaft_diam: float = 0, shorten: float = 0, gear_spin: float = 0, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> None: """Create a 2-D spur gear. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. hide: Number of teeth to hide (for sector gears). pressure_angle: Pressure angle in degrees. clearance: Clearance, or None for default (0.25 * module). backlash: Backlash amount in mm. internal: True for internal (ring) gears. profile_shift: Explicit profile shift, or None for auto correction. helical: Helical angle in degrees. shaft_diam: Shaft bore diameter, or 0 for no bore. shorten: Amount to shorten the teeth. gear_spin: Rotation offset of the gear in degrees. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: None """ center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) ps: float = _auto_profile_shift(teeth, pressure_angle, helical, profile_shift) # A bore is a hole, and one path cannot describe an outline with a hole in it. It is cut # where the geometry is built instead -- see `bore`, and SpurGear, which subtracts it as a # cylinder for exactly the same result. self._shaft_diam: float = shaft_diam if not hide else 0.0 self._teeth: int = teeth self._mod: float | None = mod # The pitch and profile shift above are arithmetic on the arguments, so they still resolve # (and reject) at the call. Cutting the tooth profile and repeating it around the gear is # the expensive half, and is deferred to `shape` (SPEC C-14, PLAN O-2). self._args = ( center, ps, teeth, hide, pressure_angle, clearance, backlash, helical, internal, shorten, gear_spin, ) self._path: Path2D | None = None def _build(self) -> Path2D: """Build the perimeter path. Called once, on the first access to `shape`.""" (center, ps, teeth, hide, pressure_angle, clearance, backlash, helical, internal, shorten, gear_spin) = ( self._args ) tooth = _gear_tooth_profile(center, teeth, pressure_angle, clearance, backlash, helical, internal, ps, shorten) perim: list[list[float]] = [] for i in range(teeth - hide): perim += _zrot_pts(tooth, -i * 360 / teeth + gear_spin) if hide > 0: perim.append([0, 0]) # The perimeter as a path, not as 2-D geometry. A path is backend-neutral -- it is what # `Path2D.linear_extrude()` dispatches on -- while a `Bosl2Shape2D` is a CSG notion, and # that was what kept every gear CSG-only (TASKS T14). return Path2D(_dedup(perim), closed=True) @property def teeth(self) -> int: """Number of teeth.""" return self._teeth @property def shape(self) -> Path2D: """Return the gear's perimeter as a closed path. A path, not 2-D geometry: `Path2D.linear_extrude()` dispatches through the backend, so a gear built from this outline is not tied to CSG. It carries the teeth only -- a bore is a hole, which one path cannot describe; see :attr:`bore`. """ if self._path is None: self._path = self._build() return self._path @property def bore(self) -> float: """The shaft bore diameter this gear was asked for, or 0 for none. The bore is not part of :attr:`shape`, which is a single closed path. Cut it where the geometry is built -- :class:`SpurGear` subtracts it as a cylinder, which is the same solid the old 2-D difference produced. """ return self._shaft_diam
[docs] def region(self) -> "Region": """Return the gear as a :class:`~pybosl2.regions.Region`: the perimeter, less the bore. The form to use when the 2-D geometry itself is wanted, hole and all. It is CSG-only, as every region is -- an SDF prism has no way to express a hole. """ from pybosl2.regions import Region if self._shaft_diam <= 0: return Region([self.shape]) return Region.with_holes(self.shape, Path2D.circle2d(radius=self._shaft_diam / 2))
[docs] def show(self) -> Any: """Display the gear outline in the viewer, and return it. A path has no geometry to render on its own, so what is displayed is the region's geometry -- the outline with its bore, which is what the gear looks like in 2-D. Rendering is therefore CSG-only even though :attr:`shape` is not; 2-D geometry always is. Returns: The path, so the call can be chained or assigned (SPEC S-51). """ self.region().geometry().show() return self.shape
[docs] class SpurGear(Buildable): """A 3-D involute spur gear — helical and/or herringbone, with optional shaft bore. Examples: A helical gear with a shaft bore: .. pythonscad-example:: from pybosl2.parts.gears import SpurGear SpurGear(mod=5, teeth=18, thickness=25, helical=-29, shaft_diam=15).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 11, thickness: float = 6, shaft_diam: float = 0, hide: int = 0, pressure_angle: float = 20, clearance: float | None = None, backlash: float = 0.0, helical: float = 0, herringbone: bool = False, internal: bool = False, profile_shift: float | None = None, shorten: float = 0, slices: int | None = None, gear_spin: float = 0, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a 3-D spur gear. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. thickness: Gear thickness in mm. shaft_diam: Shaft bore diameter, or 0 for no bore. hide: Number of teeth to hide (for sector gears). pressure_angle: Pressure angle in degrees. clearance: Clearance, or None for default (0.25 * module). backlash: Backlash amount in mm. helical: Helical angle in degrees. herringbone: If True, create a herringbone (double-helical) gear. internal: True for internal (ring) gears. profile_shift: Explicit profile shift, or None for auto correction. shorten: Amount to shorten the teeth. slices: Number of slices for linear extrusion. gear_spin: Rotation offset of the gear in degrees. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). fn: Number of fragments (circle resolution). fa: Minimum fragment angle. fs: Minimum fragment size. Returns: None """ self._teeth: int = teeth # 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 = ( circ_pitch, teeth, thickness, shaft_diam, hide, pressure_angle, clearance, backlash, helical, herringbone, internal, profile_shift, shorten, slices, gear_spin, mod, pitch, diam_pitch, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( circ_pitch, teeth, thickness, shaft_diam, hide, pressure_angle, clearance, backlash, helical, herringbone, internal, profile_shift, shorten, slices, gear_spin, mod, pitch, diam_pitch, fn, fa, fs, ) = self._args spec = GearSpec( teeth=teeth, circ_pitch=circ_pitch, mod=mod, pitch=pitch, diam_pitch=diam_pitch, pressure_angle=pressure_angle, clearance=clearance, internal=internal, helical=helical, profile_shift=profile_shift, shorten=shorten, ) _or = _outer_radius_basic( spec.circ_pitch, spec.teeth, None, False, spec.helical, spec.profile_shift, spec.shorten, ) twist = math.degrees(thickness * math.tan(math.radians(spec.helical)) / spec.pitch_radius) shape2d = SpurGear2d( circ_pitch=spec.circ_pitch, teeth=spec.teeth, hide=hide, pressure_angle=spec.pressure_angle, clearance=spec.clearance, backlash=backlash, internal=spec.internal, profile_shift=spec.profile_shift, helical=spec.helical, shaft_diam=shaft_diam, shorten=spec.shorten, gear_spin=gear_spin, ).shape if herringbone: top = shape2d.linear_extrude( height=thickness / 2, twist=twist / 2, convexity=teeth, slices=slices, fn=fn, fa=fa, fs=fs, ) bot = shape2d.linear_extrude( height=thickness / 2, twist=twist / 2, convexity=teeth, slices=slices, fn=fn, fa=fa, fs=fs, ).mirror([0, 0, 1]) solid = top | bot else: solid = shape2d.linear_extrude( height=thickness, center=True, twist=twist, convexity=teeth, slices=slices, fn=fn, fa=fa, fs=fs, ) # The bore is cut here rather than in the 2-D outline: one path cannot describe a hole, # and a cylinder through the blank leaves the same solid (TASKS T14). if shaft_diam > 0 and not hide: solid = solid - cyl(diameter=shaft_diam, height=thickness + 1, fn=fn, fa=fa, fs=fs) result = solid.with_nominal_size([2 * _or, 2 * _or, thickness]) if gear_spin: result = result.rotate([0, 0, gear_spin]) return result @property def teeth(self) -> int: """Number of teeth.""" return self._teeth @property def shape(self) -> "Solid": """Return the spur gear geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class HerringboneGear(SpurGear): """A herringbone (double-helical) spur gear — :class:`SpurGear` with ``herringbone=True``. Examples: A herringbone gear with a shaft bore: .. pythonscad-example:: from pybosl2.parts.gears import HerringboneGear HerringboneGear(mod=5, teeth=18, thickness=25, helical=30, shaft_diam=15).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 11, thickness: float = 6, shaft_diam: float = 0, hide: int = 0, pressure_angle: float = 20, clearance: float | None = None, backlash: float = 0.0, helical: float = 0, internal: bool = False, profile_shift: float | None = None, shorten: float = 0, gear_spin: float = 0, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a herringbone gear. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. thickness: Gear thickness in mm. shaft_diam: Shaft bore diameter, or 0 for no bore. hide: Number of teeth to hide (for sector gears). pressure_angle: Pressure angle in degrees. clearance: Clearance, or None for default (0.25 * module). backlash: Backlash amount in mm. helical: Helical angle in degrees. internal: True for internal (ring) gears. profile_shift: Explicit profile shift, or None for auto correction. shorten: Amount to shorten the teeth. gear_spin: Rotation offset of the gear in degrees. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). fn: Number of fragments (circle resolution). fa: Minimum fragment angle. fs: Minimum fragment size. Returns: None """ super().__init__( circ_pitch=circ_pitch, teeth=teeth, thickness=thickness, shaft_diam=shaft_diam, hide=hide, pressure_angle=pressure_angle, clearance=clearance, backlash=backlash, helical=helical, herringbone=True, internal=internal, profile_shift=profile_shift, shorten=shorten, gear_spin=gear_spin, mod=mod, pitch=pitch, diam_pitch=diam_pitch, fn=fn, fa=fa, fs=fs, )
[docs] class RingGear(Buildable): """An internal (ring) gear: a disk with inward-facing teeth cut into its bore. Examples: .. pythonscad-example:: from pybosl2.parts.gears import RingGear RingGear(teeth=30, thickness=8, pressure_angle=14.5, helical=20).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 11, thickness: float = 6, backing: float = 3, pressure_angle: float = 20, clearance: float | None = None, backlash: float = 0.0, helical: float = 0, profile_shift: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create an internal ring gear. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. thickness: Gear thickness in mm. backing: Extra radial thickness behind the teeth. pressure_angle: Pressure angle in degrees. clearance: Clearance, or None for default (0.25 * module). backlash: Backlash amount in mm. helical: Helical angle in degrees. profile_shift: Explicit profile shift, or None for auto correction. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). fn: Number of fragments (circle resolution). fa: Minimum fragment angle. fs: Minimum fragment size. Returns: None """ self._teeth: int = teeth # 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 = ( circ_pitch, teeth, thickness, backing, pressure_angle, clearance, backlash, helical, profile_shift, mod, pitch, diam_pitch, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( circ_pitch, teeth, thickness, backing, pressure_angle, clearance, backlash, helical, profile_shift, mod, pitch, diam_pitch, fn, fa, fs, ) = self._args center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) ps: float = _auto_profile_shift(teeth, pressure_angle, helical, profile_shift) _or = _outer_radius_basic(center, teeth, clearance, True, helical, ps, 0) + backing cavity = SpurGear( circ_pitch=center, teeth=teeth, thickness=thickness + 1, pressure_angle=pressure_angle, clearance=clearance, backlash=backlash, helical=helical, internal=True, profile_shift=profile_shift, ).shape body = facade_cylinder(height=thickness, diameter=2 * _or, center=True, fn=fn, fa=fa, fs=fs) return (body - cavity).with_nominal_size([2 * _or, 2 * _or, thickness]) @property def teeth(self) -> int: """Number of teeth.""" return self._teeth @property def shape(self) -> "Solid": """Return the ring gear geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class Rack2d: """A 2-D involute rack outline — a straight bar of teeth. Examples: A 2-D rack extruded for STL export: .. pythonscad-example:: from pybosl2.parts.gears import Rack2d Rack2d(mod=2, teeth=20, height=10).shape.linear_extrude(height=5).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 20, height: float = 10, pressure_angle: float = 20, backlash: float = 0.0, clearance: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> None: """Create a 2-D rack. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the rack. height: Total height of the rack bar. pressure_angle: Pressure angle in degrees. backlash: Backlash amount in mm. clearance: Clearance, or None for default (0.25 * module). mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: None """ center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) a = _adendum(center) path = _rack2d_path(center, teeth, height, pressure_angle, backlash, clearance) self._shape: Path2D = Path2D(path, closed=True) self._nominal = [teeth * center, 2 * abs(a - height)] @property def shape(self) -> Path2D: """Return the rack's tooth profile as a closed path. A path, not 2-D geometry: `Path2D.linear_extrude()` dispatches through the backend, so a rack built from this outline is not tied to CSG (see :class:`SpurGear2d`). """ return self._shape
[docs] def show(self) -> Any: """Display the rack in the viewer, and return it. Returns: The shape, so the call can be chained or assigned. """ self._shape.polygon().show() return self._shape
[docs] class Rack(Buildable): """A 3-D rack: a linear toothed bar a gear rolls along. Examples: A rack to mesh with a spur gear: .. pythonscad-example:: from pybosl2.parts.gears import Rack Rack(mod=5, teeth=20, thickness=10, height=12).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 20, thickness: float = 5, height: float = 10, pressure_angle: float = 20, backlash: float = 0.0, clearance: float | None = None, helical: float = 0, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> None: """Create a 3-D rack. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the rack. thickness: Rack thickness in mm. height: Total height of the rack bar. pressure_angle: Pressure angle in degrees. backlash: Backlash amount in mm. clearance: Clearance, or None for default (0.25 * module). helical: Helical angle in degrees. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: None """ self._teeth: int = teeth # 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 = ( circ_pitch, teeth, thickness, height, pressure_angle, backlash, clearance, helical, mod, pitch, diam_pitch, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( circ_pitch, teeth, thickness, height, pressure_angle, backlash, clearance, helical, mod, pitch, diam_pitch, ) = self._args center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) a = _adendum(center) diameter = _dedendum(center, clearance) path = _rack2d_path(center, teeth, height, pressure_angle, backlash, clearance) shape = Path2D(path).linear_extrude(height=thickness, center=True, convexity=teeth * 2).rotate([90, 0, 0]) if helical: sxy = math.tan(math.radians(helical)) shape = shape.multmatrix([[1, sxy, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) sheared_length = teeth * center + thickness * sxy else: sheared_length = teeth * center z_extent = height + diameter - a # Nominal anchor box: the rack's nominal tooth height, which the rounded tooth tips sit # just inside. Anchoring follows the pitch line rather than the printed profile. return shape.with_nominal_size([sheared_length, thickness, z_extent]) @property def teeth(self) -> int: """Number of teeth.""" return self._teeth @property def shape(self) -> "Solid": """Return the rack geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class BevelGear(Buildable): """A (potentially spiral) involute bevel gear. Examples: A bevel gear with a shaft bore: .. pythonscad-example:: from pybosl2.parts.gears import BevelGear BevelGear(mod=5, teeth=30, face_width=10, pitch_angle=45, shaft_diam=15).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 20, face_width: float = 10, pitch_angle: float = 45, mate_teeth: int | None = None, shaft_diam: float = 0, hide: int = 0, pressure_angle: float = 20, clearance: float | None = None, backlash: float = 0.0, cutter_radius: float = 30, spiral_angle: float = 35, left_handed: bool = False, slices: int = 5, interior: bool = False, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a bevel gear. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. face_width: Width of the tooth face along the cone. pitch_angle: Pitch cone angle in degrees. mate_teeth: Number of teeth on the mating gear (overrides pitch_angle). shaft_diam: Shaft bore diameter, or 0 for no bore. hide: Number of teeth to hide (for sector gears). pressure_angle: Pressure angle in degrees. clearance: Clearance, or None for default (0.25 * module). backlash: Backlash amount in mm. cutter_radius: Radius of the cutter for spiral bevel gears. spiral_angle: Spiral angle in degrees. left_handed: True for left-handed spiral. slices: Number of slices along the face width. interior: True for interior bevel gear. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). fn: Number of fragments (circle resolution). fa: Minimum fragment angle. fs: Minimum fragment size. Returns: None """ self._teeth: int = teeth # 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 = ( circ_pitch, teeth, face_width, pitch_angle, mate_teeth, shaft_diam, hide, pressure_angle, clearance, backlash, cutter_radius, spiral_angle, left_handed, slices, interior, mod, pitch, diam_pitch, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( circ_pitch, teeth, face_width, pitch_angle, mate_teeth, shaft_diam, hide, pressure_angle, clearance, backlash, cutter_radius, spiral_angle, left_handed, slices, interior, mod, pitch, diam_pitch, fn, fa, fs, ) = self._args _ = hide center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) slices_ = 1 if cutter_radius == 0 else slices if mate_teeth is not None: pitch_angle = math.degrees(math.atan(teeth / mate_teeth)) pr = _pitch_radius(center, teeth) rr = _root_radius_basic(center, teeth, clearance, interior, 0, 0) pitchoff = (pr - rr) * math.sin(math.radians(pitch_angle)) ocone_rad = _opp_ang_to_hyp(pr, pitch_angle) icone_rad = ocone_rad - face_width cr = 1000 if cutter_radius == 0 else cutter_radius midpr = (icone_rad + ocone_rad) / 2 radcp = np.array([0.0, midpr]) + _polar_xy(cr, 180 + spiral_angle) ncp = float(np.linalg.norm(radcp)) ang_c1 = _law_of_cosines(cr, ncp, ocone_rad) ang_c2 = _law_of_cosines(cr, ncp, icone_rad) radcpang = math.degrees(math.atan2(radcp[1], radcp[0])) sang = radcpang - (180 - ang_c1) eang = radcpang - (180 - ang_c2) profile = _simple_tooth(center, teeth, pressure_angle, clearance, backlash, interior, center=True) prof3 = [[x, y, 0.0] for x, y in profile] sin_pa = math.sin(math.radians(pitch_angle)) verts1: list[list[list[float]]] = [] for v in np.linspace(0, 1, slices_ + 1): p = radcp + _polar_xy(cr, _lerp(sang, eang, v)) angle = math.degrees(math.atan2(p[1], p[0])) - 90 u = float(np.linalg.norm(p)) / ocone_rad m = ( _m_up((1 - u) * pr / math.tan(math.radians(pitch_angle))) @ _m_up(pitchoff) @ _m_zrot(angle / sin_pa) @ _m_back(u * pr) @ _m_xrot(pitch_angle) @ _m_scale(u) ) ring = [] for tooth in range(teeth): ring += _apply(_m_xflip() @ _m_zrot(360 * tooth / teeth) @ m, prof3) verts1.append(ring) botz, topz = verts1[0][0][2], verts1[-1][0][2] thickness = abs(topz - botz) cpz = (topz + botz) / 2 vertices = [row[::-1] for row in verts1] sides = VNF.vertex_array([Path3D(r) for r in vertices], col_wrap=True, reverse=True) top_verts, bot_verts = vertices[-1], vertices[0] gear_pts = len(top_verts) face_pts = gear_pts // teeth top_faces: list[list[int]] = [] for i in range(teeth): for j in range(face_pts // 2): top_faces.append([i * face_pts + j, (i + 1) * face_pts - j - 1, (i + 1) * face_pts - j - 2]) top_faces.append([i * face_pts + j, (i + 1) * face_pts - j - 2, i * face_pts + j + 1]) for i in range(teeth): top_faces.append([gear_pts, (i + 1) * face_pts - 1, i * face_pts]) top_faces.append([gear_pts, ((i + 1) % teeth) * face_pts, (i + 1) * face_pts - 1]) top_cap = VNF(top_verts + [[0, 0, top_verts[0][2]]], top_faces) bot_cap = VNF(bot_verts + [[0, 0, bot_verts[0][2]]], [f[::-1] for f in top_faces]) vnf = _vnf_join([top_cap, bot_cap, sides]) if not left_handed: vnf = _vnf_xflip(vnf) vnf = VNF([[x, y, z - cpz] for x, y, z in vnf.vertices], vnf.faces) # Nominal anchor box: the pitch circle and the nominal face width. A bevel gear's teeth # stand outside it and its cone runs past the face width, so this is deliberately # smaller than bounds() -- anchor to the gear's design circle, not to its tooth tips. solid = vnf.polyhedron().with_nominal_size([2 * pr, 2 * pr, thickness]) if shaft_diam and shaft_diam > 0: solid = solid - cylinder(height=2 * thickness + 1, diameter=shaft_diam, center=True, fn=fn, fa=fa, fs=fs) return solid @property def teeth(self) -> int: """Number of teeth.""" return self._teeth @property # Not "2-D geometry" -- the same copy-paste that mislabelled Worm. A bevel gear's teeth are # built with `VNF.vertex_array`, and a non-convex mesh has no distance-field form. Both wrong # reasons were invisible while the refusal fired generically at construction. @csg_part("builds its teeth as a VNF, and a non-convex mesh has no distance-field form") def shape(self) -> "Solid": """Return the bevel gear geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class Worm(Buildable): """A worm — a screw that meshes a worm gear. Examples: A worm with two starts: .. pythonscad-example:: from pybosl2.parts.gears import Worm Worm(mod=5, diameter=30, length=80, starts=2).show() """ def __init__( self, circ_pitch: float | None = None, diameter: float = 30, length: float = 100, starts: int = 1, left_handed: bool = False, pressure_angle: float = 20, backlash: float = 0.0, clearance: float | None = None, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, ) -> None: """Create a worm. Args: circ_pitch: Circular pitch in mm/tooth. diameter: Worm outer diameter in mm. length: Worm length in mm. starts: Number of thread starts. left_handed: True for left-handed worm. pressure_angle: Pressure angle in degrees. backlash: Backlash amount in mm. clearance: Clearance, or None for default (0.25 * module). mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). Returns: None """ # 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 = ( circ_pitch, diameter, length, starts, left_handed, pressure_angle, backlash, clearance, mod, pitch, diam_pitch, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( circ_pitch, diameter, length, starts, left_handed, pressure_angle, backlash, clearance, mod, pitch, diam_pitch, ) = self._args center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) rack = _rack2d_path(center, starts, diameter, pressure_angle, backlash, clearance)[1:-1] polars = [[360 * px / center / starts, py + diameter / 2] for px, py in rack] maxang = 360 / _frag_count(diameter / 2) refined: list[list[float]] = [] for i in range(len(polars) - 1): delta = polars[i + 1][0] - polars[i][0] steps = max(1, math.ceil(delta / maxang)) for j in range(steps): refined.append([polars[i][0] + j * delta / steps, _lerp(polars[i][1], polars[i + 1][1], j / steps)]) cross = [_polar_xy(r, a).tolist() for a, r in refined] revs = length / center / starts zsteps = max(1, math.ceil(revs * 360 / maxang)) zstep, astep = length / zsteps, revs * 360 / zsteps profiles = [] for i in range(zsteps + 1): m = _m_zrot(i * astep - 360 * revs / 2) @ _m_up(i * zstep - length / 2) profiles.append(_apply(m, [[x, y, 0.0] for x, y in cross])) rprofiles = [prof[::-1] for prof in profiles] vnf = VNF.vertex_array( [Path3D(r) for r in rprofiles], caps=CapType.BUTT, col_wrap=True, style=VNFStyle.MIN_EDGE ) if left_handed: vnf = _vnf_xflip(vnf) # Nominal anchor box: the worm's pitch diameter. The thread crests stand proud of it, so # bounds() reports a wider solid -- mating parts line up on the pitch cylinder. return vnf.polyhedron().with_nominal_size([diameter, diameter, length]) @property # Not "2-D geometry" -- that reason was copy-pasted from BevelGear. A worm is a swept helical # thread built with `VNF.vertex_array`, and a non-convex mesh has no distance-field form. The # wrong reason was invisible while the refusal fired at construction with a generic message. @csg_part("sweeps its helical thread as a VNF, and a non-convex mesh has no distance-field form") def shape(self) -> "Solid": """Return the worm geometry.""" if self._solid is None: self._solid = self._build() return self._solid
[docs] class WormGear(Buildable): """A worm gear, hobbed to mesh a matching :class:`Worm`. Examples: A worm gear with a shaft bore: .. pythonscad-example:: from pybosl2.parts.gears import WormGear WormGear(mod=5, teeth=36, worm_diam=30, shaft_diam=15).show() """ def __init__( self, circ_pitch: float | None = None, teeth: int = 36, worm_diam: float = 30, worm_starts: int = 1, worm_arc: float = 60, crowning: float = 1, left_handed: bool = False, pressure_angle: float = 20, backlash: float = 0.0, slices: int = 10, clearance: float | None = None, shaft_diam: float = 0, mod: float | None = None, pitch: float | None = None, diam_pitch: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> None: """Create a worm gear. Args: circ_pitch: Circular pitch in mm/tooth. teeth: Number of teeth on the gear. worm_diam: Diameter of the mating worm. worm_starts: Number of starts on the mating worm. worm_arc: Arc angle the worm gear wraps around the worm (10-60 degrees). crowning: Crowning amount. left_handed: True for left-handed worm gear. pressure_angle: Pressure angle in degrees. backlash: Backlash amount in mm. slices: Number of slices along the width. clearance: Clearance, or None for default (0.25 * module). shaft_diam: Shaft bore diameter, or 0 for no bore. mod: Metric module (mm/tooth). pitch: Circular pitch alias. diam_pitch: Diametral pitch (teeth per inch of pitch diameter). fn: Number of fragments (circle resolution). fa: Minimum fragment angle. fs: Minimum fragment size. Returns: None """ if not (10 <= worm_arc <= 60): raise Bosl2ValueError("worm_gear(): worm_arc must be between 10 and 60 degrees.") self._teeth: int = teeth # 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 = ( circ_pitch, teeth, worm_diam, worm_starts, worm_arc, crowning, left_handed, pressure_angle, backlash, slices, clearance, shaft_diam, mod, pitch, diam_pitch, fn, fa, fs, ) self._solid: "Solid | None" = None def _build(self) -> "Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( circ_pitch, teeth, worm_diam, worm_starts, worm_arc, crowning, left_handed, pressure_angle, backlash, slices, clearance, shaft_diam, mod, pitch, diam_pitch, fn, fa, fs, ) = self._args center = _circular_pitch(circ_pitch, mod, pitch, diam_pitch) p = _pitch_radius(center, teeth) circ = 2 * PI * p radius1 = p + worm_diam / 2 + crowning radius2 = worm_diam / 2 + crowning thickness = GearSpec.worm_gear_thickness( circ_pitch=center, teeth=teeth, worm_diam=worm_diam, worm_arc=worm_arc, crowning=crowning, clearance=clearance, ) helical = center * worm_starts * worm_arc / 360 * 360 / circ tooth = _simple_tooth(center, teeth, pressure_angle, clearance, backlash, False, center=True)[::-1] prof3 = [[x, y, 0.0] for x, y in tooth] profiles: list[list[list[float]]] = [] for sl in range(slices + 1): u = sl / slices - 0.5 zang = u * worm_arc cz = math.cos(math.radians(zang)) tp = [0.0, radius1 - radius2 * cz, radius2 * math.sin(math.radians(zang))] zang2 = u * helical ring = [] for i in range(teeth): ring += _apply(_m_zrot(zang2 - i * 360 / teeth) @ _m_move(tp) @ _m_xrot(-zang) @ _m_scale(cz), prof3) profiles.append(ring) top_verts, bot_verts = profiles[-1], profiles[0] face_pts = len(tooth) gear_pts = face_pts * teeth top_faces: list[list[int]] = [] for i in range(teeth): for j in range(face_pts // 2 - 1): top_faces.append([i * face_pts + j, (i + 1) * face_pts - j - 1, (i + 1) * face_pts - j - 2]) top_faces.append([i * face_pts + j, (i + 1) * face_pts - j - 2, i * face_pts + j + 1]) for i in range(teeth): top_faces.append([gear_pts, (i + 1) * face_pts - 1, i * face_pts]) top_faces.append([gear_pts, ((i + 1) % teeth) * face_pts, (i + 1) * face_pts - 1]) sides = VNF.vertex_array([Path3D(r) for r in profiles], col_wrap=True, style=VNFStyle.MIN_EDGE) top_cap = VNF(top_verts + [[0, 0, top_verts[0][2]]], [f[::-1] for f in top_faces]) bot_cap = VNF(bot_verts + [[0, 0, bot_verts[0][2]]], top_faces) vnf = _vnf_join([top_cap, bot_cap, sides]) if left_handed: vnf = _vnf_xflip(vnf) # Nominal anchor box: the pitch circle, which the teeth stand outside of (see BevelGear). solid = vnf.polyhedron().with_nominal_size([2 * p, 2 * p, thickness]) if shaft_diam and shaft_diam > 0: solid = solid - cylinder(height=worm_diam, diameter=shaft_diam, center=True, fn=fn, fa=fa, fs=fs) return solid @property def teeth(self) -> int: """Number of teeth.""" return self._teeth @property @csg_part("cuts its throated teeth into a VNF, and a non-convex mesh has no distance-field form") def shape(self) -> "Solid": """Return the worm gear geometry.""" if self._solid is None: self._solid = self._build() return self._solid