Files
autocad-mcp-pro/engineering/gear.py
T
Umutcan EdizsalanandCursor 9ec6c4a989 style: apply ruff format across the repository
ruff format was never run repo-wide; CI now enforces 'ruff format --check', so normalize every module once. No behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:14:51 +03:00

426 lines
14 KiB
Python

"""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,
}