Introduces an engineering/ package that lets MCP clients produce production- quality technical drawings without LLM-improvised geometry. Adds 7 new MCP tools, an 8-step pre-completion validator, and an automatic linetype + layer bootstrap on drawing_new so CENTER/HIDDEN/PHANTOM and standard mechanical layers (GEOMETRY, DIM, HATCH, TEXT, TITLEBLOCK, ...) are always available. - engineering/gear.py: pure-Python involute math, 40+ points/flank, helical + spur front views, deterministic section A-A (no helix lines in section). - engineering/keyway.py: DIN 6885 keyway table + parametric keyed bore / side-section drawers. - engineering/titleblock.py: ISO 7200 A3 (420x297 mm) title block; title text is written verbatim to the title cell. - engineering/validator.py: DrawingValidator with 8 checks (real DIMENSION entities, no hidden orphans, linetypes loaded, bore + keyway in both views when expected, title consistency e.g. forbid 'HELICAL SPUR GEAR' when helix_angle truthy, saved-to-disk + file-on-disk, screenshot exported, dwg path returned). - backends/ezdxf_backend.py: AutoCAD-only linetype fallback (HIDDEN, BORDER families) so ezdxf doc gets correct patterns when linetype_load is called for names ezdxf.tools.standards does not ship. - backends/base.py: concrete helpers set_layer_active / ensure_linetypes. - server.py: Section 12 with 7 engineering tools, drawing_new(bootstrap= True), and an ENGINEERING WORKFLOW block in the MCP instructions string. - 30 new tests covering gear math, DIN6885 lookup, ISO A3 layout, title verbatim, and every validator check. Tool count 89 -> 96.
432 lines
14 KiB
Python
432 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 root through base circle out to addendum tip."""
|
|
if n_points < 30:
|
|
n_points = 30
|
|
points: list[tuple[float, float]] = []
|
|
if root_r < base_r:
|
|
points.append((root_r, 0.0))
|
|
ratio = outer_r / base_r if base_r > 0 else 1.0
|
|
if ratio <= 1.0:
|
|
points.append((base_r, 0.0))
|
|
return points
|
|
t_max = math.sqrt(ratio * ratio - 1.0)
|
|
for i in range(n_points):
|
|
t = t_max * 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_tooth_profile(
|
|
module: float,
|
|
teeth: int,
|
|
pressure_angle: float = 20.0,
|
|
n_points_per_flank: int = 40,
|
|
) -> list[tuple[float, float]]:
|
|
"""Single tooth profile: right flank, tip arc, mirrored left flank."""
|
|
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
|
|
|
|
# Tooth half-thickness angle on the pitch circle.
|
|
s = math.pi * module / 2.0
|
|
theta_pitch = s / pitch_r / 2.0
|
|
|
|
# Find involute parameter at pitch circle so we can shift the flank to be
|
|
# symmetric about the tooth centerline (y=0 axis after rotation).
|
|
if outer_r > base_r:
|
|
t_pitch = math.sqrt((pitch_r / base_r) ** 2 - 1.0) if pitch_r > base_r else 0.0
|
|
else:
|
|
t_pitch = 0.0
|
|
inv_pitch = math.tan(pa) - pa # involute function at pressure angle
|
|
# The flank generated by `generate_involute_flank` starts at theta=0 on the
|
|
# base circle. The involute point at the pitch radius makes angle inv_pitch
|
|
# (approx). We rotate the flank by -inv_pitch + theta_pitch so the pitch
|
|
# crossing sits at +theta_pitch above the tooth-center axis.
|
|
rot = -inv_pitch + theta_pitch
|
|
|
|
right_flank = generate_involute_flank(base_r, root_r, outer_r, n_points_per_flank)
|
|
right_flank = _rotate_points(right_flank, rot)
|
|
|
|
# Left flank by mirroring across the X axis (tooth centerline).
|
|
left_flank = [(x, -y) for x, y in right_flank]
|
|
|
|
# Tip arc connects the outermost right-flank point to the outermost left-flank point.
|
|
tip_right = right_flank[-1]
|
|
tip_left = left_flank[-1]
|
|
a_right = math.atan2(tip_right[1], tip_right[0])
|
|
a_left = math.atan2(tip_left[1], tip_left[0])
|
|
if a_left > a_right:
|
|
a_left -= 2 * math.pi
|
|
tip_arc = _arc_points(0.0, 0.0, outer_r, a_right, a_left, n=5)
|
|
|
|
profile: list[tuple[float, float]] = []
|
|
profile.extend(right_flank)
|
|
profile.extend(tip_arc[1:-1])
|
|
profile.extend(reversed(left_flank))
|
|
return profile
|
|
|
|
|
|
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_circ = await backend.entity_create_circle(cx, cy, base_r, layer="CONSTRUCTION")
|
|
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
|
|
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]
|
|
|
|
if keyway_w is not None or keyway_h is not None:
|
|
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,
|
|
)
|
|
|
|
return {
|
|
"top": top.handle,
|
|
"bottom": bottom.handle,
|
|
"left": left.handle,
|
|
"right": right.handle,
|
|
"bore": bore_handles,
|
|
"keyway": keyway_dict,
|
|
"hatch": hatch_handle,
|
|
}
|