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