Files
cdsl-cad/backend/engine/cdsl_engine/parametric_gears.py
T

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))