"""Parametric involute spur and helical gear generators (pure-Python math).""" from __future__ import annotations import logging import math from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from backends.base import AutoCADBackend from .keyway import draw_keyed_bore, draw_keyway_section log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Pure math # --------------------------------------------------------------------------- def involute_xy(base_radius: float, t: float) -> tuple[float, float]: """Single involute point at parameter t (radians) on the base circle.""" return ( base_radius * (math.cos(t) + t * math.sin(t)), base_radius * (math.sin(t) - t * math.cos(t)), ) def generate_involute_flank( base_r: float, root_r: float, outer_r: float, n_points: int = 40, ) -> list[tuple[float, float]]: """One tooth flank from the root circle out to the addendum tip. The first returned point ALWAYS sits exactly on the root circle (radius `root_r`) so the caller's root arc connects seamlessly: * root_r < base_r — the involute is undefined below the base circle, so the flank runs radially from the root circle up to (base_r, 0) and then follows the involute outward (a straight radial fillet approximation). * root_r >= base_r — (high tooth counts, z>=~42 @20deg) the active profile starts where the involute crosses the root circle; sampling begins at that parameter so the flank never dips inside the root circle (which previously produced a self-overlapping outline). """ if n_points < 30: n_points = 30 points: list[tuple[float, float]] = [] ratio = outer_r / base_r if base_r > 0 else 1.0 if ratio <= 1.0: # Degenerate (outer circle at/below the base circle) — unreachable for # real gears (outer_r = pitch_r + module > base_r). Anchor at the root. return [(max(base_r, root_r), 0.0)] t_max = math.sqrt(ratio * ratio - 1.0) if root_r > base_r: # Involute radius r(t) = base_r * sqrt(1 + t^2); solve r(t)=root_r. t_min = math.sqrt((root_r / base_r) ** 2 - 1.0) else: t_min = 0.0 points.append((root_r, 0.0)) # radial fillet up to the base circle for i in range(n_points): t = t_min + (t_max - t_min) * i / (n_points - 1) points.append(involute_xy(base_r, t)) return points def _rotate_points( pts: list[tuple[float, float]], angle: float ) -> list[tuple[float, float]]: c, s = math.cos(angle), math.sin(angle) return [(c * x - s * y, s * x + c * y) for x, y in pts] def _arc_points( cx: float, cy: float, radius: float, start_angle: float, end_angle: float, n: int = 5, ) -> list[tuple[float, float]]: """Sample n points along an arc (start_angle → end_angle, radians, ccw).""" if n < 2: n = 2 pts: list[tuple[float, float]] = [] for i in range(n): a = start_angle + (end_angle - start_angle) * i / (n - 1) pts.append((cx + radius * math.cos(a), cy + radius * math.sin(a))) return pts def generate_full_gear_outline( module: float, teeth: int, pressure_angle: float = 20.0, helix_angle: float = 0.0, hand: str = "RH", center: tuple[float, float] = (0.0, 0.0), n_points_per_flank: int = 40, ) -> list[tuple[float, float]]: """Closed polyline of all teeth around the gear, optionally translated to `center`.""" del helix_angle, hand # helix is annotated by symbol, outline stays the same pa = math.radians(pressure_angle) pitch_r = module * teeth / 2.0 base_r = pitch_r * math.cos(pa) outer_r = pitch_r + module root_r = pitch_r - 1.25 * module s = math.pi * module / 2.0 theta_pitch = s / pitch_r / 2.0 if pitch_r > base_r: inv_pitch = math.tan(pa) - pa else: inv_pitch = 0.0 rot = -inv_pitch + theta_pitch base_right = generate_involute_flank(base_r, root_r, outer_r, n_points_per_flank) base_right = _rotate_points(base_right, rot) base_left = [(x, -y) for x, y in base_right] tip_right = base_right[-1] tip_left = base_left[-1] a_tip_right = math.atan2(tip_right[1], tip_right[0]) a_tip_left = math.atan2(tip_left[1], tip_left[0]) if a_tip_left > a_tip_right: a_tip_left -= 2 * math.pi tip_arc = _arc_points(0.0, 0.0, outer_r, a_tip_right, a_tip_left, n=5) root_right = base_right[0] root_left = base_left[0] a_root_right = math.atan2(root_right[1], root_right[0]) a_root_left = math.atan2(root_left[1], root_left[0]) pitch_step = 2.0 * math.pi / teeth cx, cy = float(center[0]), float(center[1]) outline: list[tuple[float, float]] = [] for k in range(teeth): angle = k * pitch_step # Right flank (root → tip) for x, y in _rotate_points(base_right, angle): outline.append((cx + x, cy + y)) # Tip arc (skip endpoints to avoid duplicates with flanks) for x, y in _rotate_points(tip_arc[1:-1], angle): outline.append((cx + x, cy + y)) # Left flank (tip → root, reversed) for x, y in _rotate_points(list(reversed(base_left)), angle): outline.append((cx + x, cy + y)) # Root arc to next tooth's right-flank root next_angle = (k + 1) * pitch_step start = a_root_left + angle end = a_root_right + next_angle if end < start: end += 2.0 * math.pi for x, y in _arc_points(0.0, 0.0, root_r, start, end, n=5)[1:-1]: outline.append((cx + x, cy + y)) if outline and outline[0] != outline[-1]: outline.append(outline[0]) return outline # --------------------------------------------------------------------------- # Drawing orchestration # --------------------------------------------------------------------------- async def _draw_helix_symbol( backend: AutoCADBackend, *, cx: float, cy: float, outer_r: float, helix_angle: float, hand: str, ) -> tuple[list[str], str]: """Three short diagonal lines + 'XX° hand' text near top-right of gear.""" base_x = cx + outer_r * 0.3 base_y = cy + outer_r * 0.6 seg_len = max(outer_r * 0.15, 4.0) spacing = max(outer_r * 0.06, 2.0) sign = -1.0 if hand.upper() == "LH" else 1.0 a = math.radians(helix_angle) * sign handles: list[str] = [] for i in range(3): x0 = base_x + i * spacing y0 = base_y x1 = x0 + seg_len * math.sin(a) y1 = y0 + seg_len * math.cos(a) line = await backend.entity_create_line(x0, y0, x1, y1, layer="GEOMETRY") handles.append(line.handle) label_text = f"{helix_angle:g}° {hand.upper()}" label = await backend.entity_create_text( label_text, base_x + 3 * spacing + seg_len, base_y + seg_len * 0.5, height=max(outer_r * 0.04, 2.5), layer="TEXT", ) return handles, label.handle async def draw_helical_gear_front_view( backend: AutoCADBackend, *, module: float, teeth: int, helix_angle: float, pressure_angle: float = 20.0, hand: str = "RH", center: tuple[float, float] = (0.0, 0.0), bore_diameter: float | None = None, keyway_width: float | None = None, keyway_depth: float | None = None, ) -> dict[str, Any]: """Draw front view of a helical gear: pitch/base/outer/root circles, full outline, helix symbol + label, and optional keyed bore.""" pa_rad = math.radians(pressure_angle) pitch_r = module * teeth / 2.0 base_r = pitch_r * math.cos(pa_rad) outer_r = pitch_r + module root_r = pitch_r - 1.25 * module cx, cy = float(center[0]), float(center[1]) outer_circ = await backend.entity_create_circle(cx, cy, outer_r, layer="GEOMETRY") pitch_circ = await backend.entity_create_circle(cx, cy, pitch_r, layer="CENTER") # Base circle is a persistent involute reference on PHANTOM — NOT CONSTRUCTION, # which the mandated construction_clear() before finalize would delete, leaving # the returned base_circle handle dangling. base_circ = await backend.entity_create_circle(cx, cy, base_r, layer="PHANTOM") root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY") pts = generate_full_gear_outline( module=module, teeth=teeth, pressure_angle=pressure_angle, helix_angle=helix_angle, hand=hand, center=(cx, cy), ) outline = await backend.entity_create_polyline( [[float(x), float(y)] for x, y in pts], closed=True, layer="GEOMETRY", ) helix_handles: list[str] | None = None helix_label: str | None = None if helix_angle and abs(helix_angle) > 1e-9: helix_handles, helix_label = await _draw_helix_symbol( backend, cx=cx, cy=cy, outer_r=outer_r, helix_angle=helix_angle, hand=hand, ) bore_handle: str | None = None keyway_dict: dict | None = None if bore_diameter is not None: if keyway_width is not None or keyway_depth is not None: keyed = await draw_keyed_bore( backend, center=(cx, cy), bore_diameter=bore_diameter, keyway_width=keyway_width, keyway_depth=keyway_depth, ) bore_handle = keyed["bore"] keyway_dict = keyed else: bore_only = await backend.entity_create_circle( cx, cy, bore_diameter / 2.0, layer="GEOMETRY", ) bore_handle = bore_only.handle metadata = { "module": float(module), "teeth": int(teeth), "pressure_angle": float(pressure_angle), "helix_angle": float(helix_angle), "hand": str(hand), "center": [cx, cy], "bore_diameter": float(bore_diameter) if bore_diameter is not None else None, "keyway_width": float(keyway_width) if keyway_width is not None else None, "keyway_depth": float(keyway_depth) if keyway_depth is not None else None, "pitch_radius": pitch_r, "outer_radius": outer_r, "base_radius": base_r, "root_radius": root_r, } return { "outline": outline.handle, "pitch_circle": pitch_circ.handle, "base_circle": base_circ.handle, "outer_circle": outer_circ.handle, "root_circle": root_circ.handle, "helix_symbol": helix_handles, "helix_label": helix_label, "bore": bore_handle, "keyway": keyway_dict, "metadata": metadata, } async def draw_spur_gear_front_view( backend: AutoCADBackend, *, module: float, teeth: int, pressure_angle: float = 20.0, center: tuple[float, float] = (0.0, 0.0), bore_diameter: float | None = None, keyway_width: float | None = None, keyway_depth: float | None = None, ) -> dict[str, Any]: """Front view of a spur gear (helix symbol omitted).""" return await draw_helical_gear_front_view( backend, module=module, teeth=teeth, helix_angle=0.0, pressure_angle=pressure_angle, hand="RH", center=center, bore_diameter=bore_diameter, keyway_width=keyway_width, keyway_depth=keyway_depth, ) async def draw_gear_section_aa( backend: AutoCADBackend, *, gear_metadata: dict, x_offset: float, face_width: float, ) -> dict[str, Any]: """Side cross-section view: outer rectangle, bore lines, optional keyway notch, ANSI31 hatch.""" outer_r = float(gear_metadata["outer_radius"]) cy = float(gear_metadata["center"][1]) bore_d = gear_metadata.get("bore_diameter") keyway_w = gear_metadata.get("keyway_width") keyway_h = gear_metadata.get("keyway_depth") x_left = float(x_offset) x_right = x_left + float(face_width) y_top = cy + outer_r y_bot = cy - outer_r section_cx = (x_left + x_right) / 2.0 top = await backend.entity_create_line(x_left, y_top, x_right, y_top, layer="GEOMETRY") bottom = await backend.entity_create_line(x_left, y_bot, x_right, y_bot, layer="GEOMETRY") left = await backend.entity_create_line(x_left, y_bot, x_left, y_top, layer="GEOMETRY") right = await backend.entity_create_line(x_right, y_bot, x_right, y_top, layer="GEOMETRY") hatch_handle = "" try: hatch_info = await backend.entity_create_hatch( pattern="ANSI31", boundary_points=[ [x_left, y_bot], [x_right, y_bot], [x_right, y_top], [x_left, y_top], ], scale=1.0, angle=45.0, layer="HATCH", ) hatch_handle = hatch_info.handle except Exception as exc: log.warning("section A-A hatch failed: %s", exc) bore_handles: list[str] = [] keyway_dict: dict | None = None if bore_d is not None: bore_r = float(bore_d) / 2.0 if keyway_w is not None or keyway_h is not None: # draw_keyway_section draws BOTH bore lines (same span/y) AND the # notch — drawing the bore lines here too would duplicate them and # trip the duplicate_entities critique. Let it own the bore lines. keyway_dict = await draw_keyway_section( backend, center=(section_cx, cy), bore_diameter=float(bore_d), face_width=float(face_width), keyway_width=keyway_w, keyway_depth=keyway_h, ) bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]] else: bore_top = await backend.entity_create_line( x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY", ) bore_bot = await backend.entity_create_line( x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY", ) bore_handles = [bore_top.handle, bore_bot.handle] return { "top": top.handle, "bottom": bottom.handle, "left": left.handle, "right": right.handle, "bore": bore_handles, "keyway": keyway_dict, "hatch": hatch_handle, }