Audit-driven fixes across the dual backend, the premium workflow, and the engineering layer. 318 tests, ruff-clean. Quality gate (the moat): - drawing_finalize now runs the premium ISO-128 critique in addition to the 8-step validator; leftover construction, non-ISO lineweights, untrimmed corners, duplicates and dim overlap block the gate (strict_critique fails on warnings too). - dim_overlap critique no longer a no-op (dim reference point + bbox fallback); dim/construction layers resolve from the active layer set (iso13567 too). Stability / correctness: - view_screenshot & drawing_export_pdf render headless via Agg (no more worker-thread SIGSEGV). - gear tooth profile no longer self-overlaps at high tooth counts; section view no longer duplicates bore lines; validator keyway heuristic fixed. - ezdxf aligned/angular dim API, full-circle polar array duplicate already covered by Sprint 1; this bundle adds save-format-from-extension, entity_offset side selection, ARC length, properties parity (bounding_box / rotation / char_height), mtext rotation. COM robustness (mocked-COM tested): - CoUninitialize on teardown; transaction commit/rollback try/finally + UNDO via the CMDACTIVE guard; system_run_lisp guard + USERS1 value capture; set_variable type coercion; HWND via Application.HWND; BlockInfo.description. Security: - HTTP remote-bind guard fires on every launch path (incl. `fastmcp run`); StaticTokenVerifier wired into FastMCP; AutoLISP allowlist bypass-vector tests. Hygiene: linetype-on-demand in _apply_attrs, honest view_zoom returns, snap-on-arc sweep handling, point_tangent acos clamp, dynamic tool_groups / tool count, word-boundary SPUR/HELICAL, undo-stack locking. Deferred: R22 (undo/transaction stack isolation) lands with the I1 refiner (1.2), which depends on it.
404 lines
14 KiB
Python
404 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,
|
|
}
|