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

import numpy as np

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.path2d import Path2D
from pybosl2.shapes2d import Bosl2Shape2D
from pybosl2.shapes3d import Bosl2Solid, cylinder
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
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 _v_theta(v: list[float]) -> float:
    return math.degrees(math.atan2(v[1], v[0]))


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:
    p0, p1, p2 = (np.asarray(p, float) for p in three)
    v0, v1 = p0 - p1, p2 - p1
    c = np.clip(np.dot(v0, v1) / (np.linalg.norm(v0) * np.linalg.norm(v1)), -1, 1)
    return math.degrees(math.acos(c))


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]]:
    out: list[list[float]] = []
    for p in pts:
        if not out or abs(p[0] - out[-1][0]) > eps or abs(p[1] - out[-1][1]) > eps:
            out.append([float(p[0]), float(p[1])])
    return out


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 a + (b - a) * 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) assert a + diameter < height, "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) 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]) shape = _opolygon(_dedup(perim)) _or = _outer_radius_basic(center, teeth, None, False, helical, ps, shorten) result = Bosl2Shape2D(shape, size=[2 * _or, 2 * _or]) if shaft_diam > 0 and not hide: from pybosl2.shapes2d import circle as _circle2d result = result - _circle2d(diameter=shaft_diam) self._shape: Bosl2Shape2D = result self._teeth: int = teeth self._mod: float | None = mod @property def teeth(self) -> int: """Number of teeth.""" return self._teeth
[docs] def shape(self) -> Bosl2Shape2D: """Return the 2-D gear outline.""" return self._shape
[docs] def show(self) -> None: """Display the gear in the viewer.""" self._shape.show()
[docs] class SpurGear: """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 """ 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, ).scale([1, 1, -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, ) result = Bosl2Solid(solid.shape, size=[2 * _or, 2 * _or, thickness]) if gear_spin: result = result.rotate([0, 0, gear_spin]) self._solid: Bosl2Solid = result self._teeth: int = teeth @property def teeth(self) -> int: """Number of teeth.""" return self._teeth
[docs] def shape(self) -> Bosl2Solid: """Return the spur gear geometry.""" return self._solid
[docs] def show(self) -> None: """Display the spur gear in the viewer.""" self._solid.show()
[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: """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 """ 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 = cylinder(height=thickness, diameter=2 * _or, center=True, fn=fn, fa=fa, fs=fs) self._solid: Bosl2Solid = Bosl2Solid((body - cavity).shape, size=[2 * _or, 2 * _or, thickness]) self._teeth: int = teeth @property def teeth(self) -> int: """Number of teeth.""" return self._teeth
[docs] def shape(self) -> Bosl2Solid: """Return the ring gear geometry.""" return self._solid
[docs] def show(self) -> None: """Display the ring gear in the viewer.""" self._solid.show()
[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: Bosl2Shape2D = Bosl2Shape2D(_opolygon(path), size=[teeth * center, 2 * abs(a - height)])
[docs] def shape(self) -> Bosl2Shape2D: """Return the 2-D rack outline.""" return self._shape
[docs] def show(self) -> None: """Display the rack in the viewer.""" self._shape.show()
[docs] class Rack: """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 """ 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 = _opolygon(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 self._solid: Bosl2Solid = Bosl2Solid(shape, size=[sheared_length, thickness, z_extent]) self._teeth: int = teeth @property def teeth(self) -> int: """Number of teeth.""" return self._teeth
[docs] def shape(self) -> Bosl2Solid: """Return the rack geometry.""" return self._solid
[docs] def show(self) -> None: """Display the rack in the viewer.""" self._solid.show()
[docs] class BevelGear: """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 """ _ = 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(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) solid = Bosl2Solid(vnf.polyhedron(), 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) self._solid: Bosl2Solid = solid self._teeth: int = teeth @property def teeth(self) -> int: """Number of teeth.""" return self._teeth
[docs] def shape(self) -> Bosl2Solid: """Return the bevel gear geometry.""" return self._solid
[docs] def show(self) -> None: """Display the bevel gear in the viewer.""" self._solid.show()
[docs] class Worm: """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 """ 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(rprofiles, caps=CapType.BUTT, col_wrap=True, style=VNFStyle.MIN_EDGE) if left_handed: vnf = _vnf_xflip(vnf) self._solid: Bosl2Solid = Bosl2Solid(vnf.polyhedron(), size=[diameter, diameter, length])
[docs] def shape(self) -> Bosl2Solid: """Return the worm geometry.""" return self._solid
[docs] def show(self) -> None: """Display the worm in the viewer.""" self._solid.show()
[docs] class WormGear: """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 """ assert 10 <= worm_arc <= 60, "worm_gear(): worm_arc must be between 10 and 60 degrees." 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(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) solid = Bosl2Solid(vnf.polyhedron(), 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) self._solid: Bosl2Solid = solid self._teeth: int = teeth @property def teeth(self) -> int: """Number of teeth.""" return self._teeth
[docs] def shape(self) -> Bosl2Solid: """Return the worm gear geometry.""" return self._solid
[docs] def show(self) -> None: """Display the worm gear in the viewer.""" self._solid.show()