281 lines
12 KiB
Python
281 lines
12 KiB
Python
"""Parametric involute gear and rack generator (``gear_add`` / ``rack_add``).
|
|
|
|
Strategy
|
|
--------
|
|
A standard involute gear profile is sampled analytically in the end plane:
|
|
pitch radius ``r = m*z/2``, base radius ``r_b = r*cos(alpha)``, tip radius
|
|
``r + m`` and root radius ``r - 1.25*m``. Each tooth flank is the involute
|
|
of the base circle parameterised by the roll angle ``t``
|
|
|
|
x(t) = r_b*(cos t + t*sin t), y(t) = r_b*(sin t - t*cos t)
|
|
|
|
rotated by ``delta = psi - inv(alpha)`` (``psi = pi/(2z)`` is the half tooth
|
|
thickness angle on the pitch circle) so the flank passes through the pitch
|
|
point at the correct tooth thickness. The closed section polygon per tooth
|
|
period is: root arc (from the valley centre) -> left flank (root to tip) ->
|
|
tip arc (through the tooth centre) -> right flank (tip to root) -> root arc
|
|
(to the next valley centre). When ``r_f < r_b`` the involute starts on the
|
|
base circle and a radial foot joins it down to the root circle.
|
|
|
|
* Spur gear (``helix_angle_rad == 0``): the end face is extruded linearly.
|
|
* Helical gear: ``Solid.extrude_linear_with_rotation`` builds a true twisted
|
|
prism - the section rotates by ``width*tan(beta)/r_pitch`` while extruding
|
|
over ``width_mm`` - which is exactly the involute-helicoid tooth surface;
|
|
every ``z = const`` cross-section is the same rotated profile so the volume
|
|
is exactly ``section_area * width``.
|
|
* Herringbone (double helical): the upper half is the twisted prism above;
|
|
the lower half is its mirror image about the ``z = width/2`` plane, which
|
|
reverses the helix while keeping the mid-plane section phase-continuous.
|
|
The two halves meet on the shared mid-plane section; ``fuse`` plus
|
|
``clean`` merge them into one solid (the seam is a same-shape section, and
|
|
OCC handles it reliably, verified by exact volume ``2 * upper``).
|
|
|
|
The rack is the linear counterpart: pitch ``p = pi*m``, trapezoid teeth with
|
|
flanks inclined by ``pressure_angle_rad``, addendum ``m`` and dedendum
|
|
``1.25*m``. Both ends land in valley centres so the exact length is
|
|
``teeth_count * pi * m`` and the cross-section area has the closed form
|
|
``L*h_f + n*(b_root + b_tip)*h_a``.
|
|
|
|
Local frames: the gear axis is +Z with the ``z = 0`` end face centred on the
|
|
origin; the rack runs along +X, teeth point along +Z with thickness along
|
|
+Y. Placement onto ``spec.axis`` is the adapter's responsibility. The
|
|
module only reads ``runtime_types.GearSpec`` / ``RackSpec`` and returns OCC
|
|
``Solid`` objects.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from build123d import Edge, Face, Plane, Solid, Vector, Wire
|
|
|
|
from .runtime_types import GearSpec, RackSpec
|
|
|
|
#: Involute samples per tooth flank (chord error << 1e-3 mm at module 1).
|
|
_SAMPLES_PER_FLANK = 16
|
|
#: Maximum angular step (radians) when sampling tip/root arcs.
|
|
_ARC_STEP_RAD = math.radians(2.0)
|
|
#: Consecutive-point deduplication tolerance (mm).
|
|
_MERGE_TOL = 1e-9
|
|
|
|
|
|
def _rotate(point: tuple[float, float], angle: float) -> tuple[float, float]:
|
|
return (
|
|
point[0] * math.cos(angle) - point[1] * math.sin(angle),
|
|
point[0] * math.sin(angle) + point[1] * math.cos(angle),
|
|
)
|
|
|
|
|
|
def involute_geometry(spec: GearSpec) -> dict[str, float]:
|
|
"""Derive the analytic involute geometry of a gear spec.
|
|
|
|
Returns pitch/base/tip/root radii, the half tooth thickness angle on the
|
|
pitch circle (``psi``), the base-circle start offset (``delta``), the
|
|
flank roll-angle window ``[t_start, t_tip]`` and the polar half-angles of
|
|
the root/tip points (``a_root`` / ``a_tip``, measured from the tooth
|
|
centre line).
|
|
"""
|
|
r = spec.pitch_radius_mm
|
|
rb = spec.base_radius_mm
|
|
ra = spec.tip_radius_mm
|
|
rf = spec.root_radius_mm
|
|
alpha = spec.pressure_angle_rad
|
|
z = spec.teeth_count
|
|
if rb <= 0.0 or ra <= rb:
|
|
raise ValueError("gear tip radius must exceed the base radius")
|
|
psi = math.pi / (2.0 * z)
|
|
delta = psi - (math.tan(alpha) - alpha)
|
|
t_tip = math.sqrt((ra / rb) ** 2 - 1.0)
|
|
beta_tip = t_tip - math.atan(t_tip)
|
|
t_start = math.sqrt((rf / rb) ** 2 - 1.0) if rf >= rb else 0.0
|
|
beta_start = t_start - math.atan(t_start)
|
|
a_tip = delta + beta_tip
|
|
a_root = delta + beta_start
|
|
if not a_tip < math.pi / z:
|
|
raise ValueError("gear tooth tip arcs overlap; reduce module or add teeth")
|
|
if not a_root < math.pi / z:
|
|
raise ValueError("gear tooth root arcs overlap; gear geometry is degenerate")
|
|
return {
|
|
"r": r, "rb": rb, "ra": ra, "rf": rf, "psi": psi, "delta": delta,
|
|
"t_start": t_start, "t_tip": t_tip, "a_tip": a_tip, "a_root": a_root,
|
|
}
|
|
|
|
|
|
def right_flank_points(spec: GearSpec, samples: int = _SAMPLES_PER_FLANK) -> list[tuple[float, float]]:
|
|
"""Right tooth flank (root -> tip) with the tooth centred on angle 0.
|
|
|
|
Points lie exactly on the involute; when ``r_f < r_b`` the first point is
|
|
the radial foot on the root circle (same polar angle as the base-circle
|
|
start) so the section stays simply connected.
|
|
"""
|
|
geom = involute_geometry(spec)
|
|
rb, delta = geom["rb"], geom["delta"]
|
|
points: list[tuple[float, float]] = []
|
|
if spec.root_radius_mm < rb:
|
|
points.append((spec.root_radius_mm * math.cos(delta), spec.root_radius_mm * math.sin(delta)))
|
|
for index in range(samples):
|
|
t = geom["t_start"] + (geom["t_tip"] - geom["t_start"]) * index / (samples - 1)
|
|
x0 = rb * (math.cos(t) + t * math.sin(t))
|
|
y0 = rb * (math.sin(t) - t * math.cos(t))
|
|
points.append(_rotate((x0, y0), delta))
|
|
return points
|
|
|
|
|
|
def _arc_points(radius: float, start_angle: float, end_angle: float) -> list[tuple[float, float]]:
|
|
"""Sample a circular arc (inclusive of both ends) at ``_ARC_STEP_RAD``."""
|
|
step = max(1, math.ceil(abs(end_angle - start_angle) / _ARC_STEP_RAD))
|
|
return [
|
|
_rotate((radius, 0.0), start_angle + (end_angle - start_angle) * k / step)
|
|
for k in range(step + 1)
|
|
]
|
|
|
|
|
|
def spur_profile_polygon(spec: GearSpec, samples: int = _SAMPLES_PER_FLANK) -> list[tuple[float, float]]:
|
|
"""Closed end-plane section polygon of the full gear (counter-clockwise).
|
|
|
|
One period per tooth: valley centre -> root arc -> left flank (root to
|
|
tip) -> tip arc (through the tooth centre) -> right flank (tip to root)
|
|
-> root arc to the next valley centre. The polygon is a pure polyline so
|
|
its shoelace area equals the OCC face area exactly.
|
|
"""
|
|
geom = involute_geometry(spec)
|
|
ra, rf = geom["ra"], geom["rf"]
|
|
a_tip, a_root = geom["a_tip"], geom["a_root"]
|
|
period = 2.0 * math.pi / spec.teeth_count
|
|
flank = right_flank_points(spec, samples)
|
|
left_flank = [(x, -y) for (x, y) in flank]
|
|
points: list[tuple[float, float]] = []
|
|
for tooth in range(spec.teeth_count):
|
|
gamma = tooth * period
|
|
points.extend(_arc_points(rf, gamma - period / 2.0, gamma - a_root))
|
|
points.extend(_rotate(point, gamma) for point in left_flank)
|
|
points.extend(_arc_points(ra, gamma - a_tip, gamma + a_tip))
|
|
points.extend(_rotate(point, gamma) for point in reversed(flank))
|
|
points.extend(_arc_points(rf, gamma + a_root, gamma + period / 2.0))
|
|
merged = _merge_consecutive(points)
|
|
if polygon_area(merged) < 0.0:
|
|
merged.reverse()
|
|
return merged
|
|
|
|
|
|
def _merge_consecutive(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
|
"""Drop consecutive (and closing) duplicate points within ``_MERGE_TOL``."""
|
|
merged: list[tuple[float, float]] = []
|
|
for point in points:
|
|
if merged and math.dist(merged[-1], point) <= _MERGE_TOL:
|
|
continue
|
|
merged.append(point)
|
|
if len(merged) > 1 and math.dist(merged[0], merged[-1]) <= _MERGE_TOL:
|
|
merged.pop()
|
|
return merged
|
|
|
|
|
|
def polygon_area(points: list[tuple[float, float]]) -> float:
|
|
"""Signed shoelace area (positive = counter-clockwise)."""
|
|
total = 0.0
|
|
count = len(points)
|
|
for index in range(count):
|
|
x0, y0 = points[index]
|
|
x1, y1 = points[(index + 1) % count]
|
|
total += x0 * y1 - x1 * y0
|
|
return total / 2.0
|
|
|
|
|
|
def _section_wire(spec: GearSpec, theta_offset: float = 0.0, z: float = 0.0) -> Wire:
|
|
"""End-plane section wire rotated by ``theta_offset`` and lifted to ``z``."""
|
|
points = spur_profile_polygon(spec)
|
|
if theta_offset:
|
|
points = [_rotate(point, theta_offset) for point in points]
|
|
vertices = [Vector(x, y, z) for (x, y) in points]
|
|
return Wire([
|
|
Edge.make_line(vertices[index], vertices[(index + 1) % len(vertices)])
|
|
for index in range(len(vertices))
|
|
])
|
|
|
|
|
|
def helix_twist_angle_rad(spec: GearSpec) -> float:
|
|
"""Total section rotation over the full width for a helical gear."""
|
|
if spec.helix_angle_rad <= 0.0:
|
|
return 0.0
|
|
return math.tan(spec.helix_angle_rad) * spec.width_mm / spec.pitch_radius_mm
|
|
|
|
|
|
def build_gear_solid(spec: GearSpec) -> Solid:
|
|
"""Build the gear in the local frame (+Z = axis, ``z in [0, width_mm]``)."""
|
|
involute_geometry(spec) # validation
|
|
face = Face(_section_wire(spec))
|
|
width = spec.width_mm
|
|
if spec.helix_angle_rad <= 0.0:
|
|
return Solid.extrude(face, Vector(0.0, 0.0, width))
|
|
twist = helix_twist_angle_rad(spec)
|
|
if not spec.herringbone:
|
|
return Solid.extrude_linear_with_rotation(
|
|
face, (0.0, 0.0, 0.0), (0.0, 0.0, width), math.degrees(twist),
|
|
)
|
|
# Herringbone: the upper half twists 0 -> +A/2; its mirror image about
|
|
# the mid-width plane reverses the helix with a phase-continuous seam.
|
|
half = width / 2.0
|
|
upper = Solid.extrude_linear_with_rotation(
|
|
face, (0.0, 0.0, 0.0), (0.0, 0.0, half), math.degrees(twist / 2.0),
|
|
)
|
|
mirrored = upper.mirror(Plane(origin=(0.0, 0.0, half), z_dir=(0.0, 0.0, 1.0)))
|
|
merged = upper.fuse(mirrored).clean()
|
|
if isinstance(merged, Solid):
|
|
return merged
|
|
solids = merged.solids()
|
|
if len(solids) == 1:
|
|
return solids[0]
|
|
raise ValueError("herringbone gear fuse produced a non-single body")
|
|
|
|
|
|
def rack_profile_polygon(spec: RackSpec) -> list[tuple[float, float]]:
|
|
"""Closed rack cross-section polygon in the local ``(x, z)`` plane.
|
|
|
|
``x`` runs along the rack from 0 to ``teeth_count * pi * m`` (both ends
|
|
in valley centres); ``z`` runs from the root/backing plane (0) to the
|
|
crest plane (``2.25 * m``).
|
|
"""
|
|
m = spec.module_mm
|
|
alpha = spec.pressure_angle_rad
|
|
pitch = spec.pitch_mm
|
|
addendum = spec.addendum_mm
|
|
dedendum = spec.dedendum_mm
|
|
half_root = pitch / 4.0 + addendum * math.tan(alpha)
|
|
half_tip = pitch / 4.0 - addendum * math.tan(alpha)
|
|
if half_tip <= 0.0:
|
|
raise ValueError("rack pressure angle consumes the whole tooth crest")
|
|
length = spec.length_mm
|
|
points: list[tuple[float, float]] = [(0.0, 0.0), (length, 0.0), (length, dedendum)]
|
|
for tooth in reversed(range(spec.teeth_count)):
|
|
centre = (tooth + 0.5) * pitch
|
|
points.append((centre + half_root, dedendum))
|
|
points.append((centre + half_tip, dedendum + addendum))
|
|
points.append((centre - half_tip, dedendum + addendum))
|
|
points.append((centre - half_root, dedendum))
|
|
points.append((0.0, dedendum))
|
|
merged = _merge_consecutive(points)
|
|
if polygon_area(merged) < 0.0:
|
|
merged.reverse()
|
|
return merged
|
|
|
|
|
|
def rack_section_area_mm2(spec: RackSpec) -> float:
|
|
"""Closed-form rack cross-section area (shoelace-exact, all edges straight)."""
|
|
pitch = spec.pitch_mm
|
|
addendum = spec.addendum_mm
|
|
alpha = spec.pressure_angle_rad
|
|
half_root = pitch / 4.0 + addendum * math.tan(alpha)
|
|
half_tip = pitch / 4.0 - addendum * math.tan(alpha)
|
|
return spec.length_mm * spec.dedendum_mm + spec.teeth_count * (half_root + half_tip) * addendum
|
|
|
|
|
|
def build_rack_solid(spec: RackSpec) -> Solid:
|
|
"""Build the rack in the local frame (+X length, +Y thickness, +Z teeth)."""
|
|
points = rack_profile_polygon(spec)
|
|
vertices = [Vector(x, 0.0, z) for (x, z) in points]
|
|
wire = Wire([
|
|
Edge.make_line(vertices[index], vertices[(index + 1) % len(vertices)])
|
|
for index in range(len(vertices))
|
|
])
|
|
return Solid.extrude(Face(wire), Vector(0.0, spec.thickness_mm, 0.0))
|