Add deterministic engineering CAD layer (gear, keyway, titleblock, validator)

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.
This commit is contained in:
U-C4N
2026-04-28 12:24:08 +03:00
parent 59f3bc5fd5
commit 88a9005a6a
16 changed files with 2320 additions and 10 deletions
+5 -2
View File
@@ -69,18 +69,19 @@ The `mcp` FastMCP instance is configured with:
- **Middleware stack**: `ErrorHandlingMiddleware` → `AuditMiddleware` (custom timing/audit log) → `TimingMiddleware` → `LoggingMiddleware`
- **`_backend(ctx)`** helper: retrieves the backend from lifespan context, raises `ToolError` if not ready
Tools are organized into 11 sections:
Tools are organized into 12 sections:
1. Drawing Management (11 tools): `drawing_*`
2. Entity Creation (13 tools): `entity_create_*`
3. Dimensions (5 tools): `dimension_*`
4. Entity Modification (10 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`
5. Entity Query (3 tools): `entity_get`, `entity_list`, `entity_delete_many`
6. Layer Management (12 tools): `layer_*`
6. Layer Management (12 tools + 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
7. Block Operations (7 tools): `block_*`
8. Analysis & Query (8 tools): `analysis_*`
9. View & Screenshot (5 tools — includes `view_zoom_and_screenshot`): `view_*`
10. Transactions (3 tools): `transaction_begin/commit/rollback`
11. System (6 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about`
12. Engineering / Deterministic CAD (7 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
### Adding a New Tool
@@ -98,3 +99,5 @@ Tools are organized into 11 sections:
- **COM backend only**: `system_run_command`, `system_run_lisp`, and `view_zoom_extents/window` (view ops are no-ops in ezdxf).
- **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib.
- **`_dc(obj)`**: converts dataclasses to dicts recursively for JSON serialization.
- **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out.
- **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator.
+36
View File
@@ -358,6 +358,15 @@ class AutoCADBackend(ABC):
@abstractmethod
async def layer_show(self, name: str) -> dict: ...
# ── linetype management ──────────────────────────────────────────────────
@abstractmethod
async def linetype_list(self) -> list[str]: ...
@abstractmethod
async def linetype_load(
self, name: str, file: str | None = None,
) -> dict: ...
# ── block operations ─────────────────────────────────────────────────────
@abstractmethod
async def block_list(self) -> list[BlockInfo]: ...
@@ -449,6 +458,33 @@ class AutoCADBackend(ABC):
@abstractmethod
async def system_run_lisp(self, expression: str) -> dict: ...
# ── concrete helpers (use existing primitives) ──────────────────────────
async def set_layer_active(self, name: str) -> None:
"""Convenience: alias for layer_set_current. Concrete; backends need not override."""
await self.layer_set_current(name)
async def ensure_linetypes(
self, names: list[str], file: str | None = None,
) -> dict[str, str]:
"""Idempotently load each linetype in `names`. Returns {name: status}.
Status is one of: 'already_loaded', 'loaded', 'failed: <msg>'.
"""
try:
current = {ln.lower() for ln in await self.linetype_list()}
except Exception:
current = set()
results: dict[str, str] = {}
for n in names:
if n.lower() in current:
results[n] = "already_loaded"
continue
try:
await self.linetype_load(n, file=file)
results[n] = "loaded"
except Exception as exc:
results[n] = f"failed: {exc}"
return results
# ---------------------------------------------------------------------------
# Helpers
+133 -3
View File
@@ -127,6 +127,44 @@ def _msp():
return _acad_doc().ModelSpace
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
def _ensure_linetype_loaded(name: str) -> None:
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
Called by attribute setters so users can write `linetype="CENTER"` without
having to remember to load it first. Must run on the COM thread.
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower()
for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return
app = _acad_app()
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc)
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
try:
old_filedia = int(app.GetVariable("FILEDIA"))
except Exception as exc:
log.debug("FILEDIA read failed, assuming 1: %s", exc)
old_filedia = 1
try:
app.SetVariable("FILEDIA", 0)
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
except Exception as exc:
log.debug("FILEDIA restore failed: %s", exc)
def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: str | None):
"""Apply common entity attributes after creation."""
if layer is not None:
@@ -134,6 +172,7 @@ def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype:
if color is not None:
entity.Color = int(color)
if linetype is not None:
_ensure_linetype_loaded(linetype)
entity.Linetype = linetype
@@ -374,11 +413,23 @@ class ComBackend(AutoCADBackend):
return await asyncio.wait_for(future, timeout=timeout)
return await future
except asyncio.TimeoutError as e:
log.error("COM call timed out after %.1fs", timeout)
log.error("COM call timed out after %.1fs; rebuilding executor", timeout)
# The worker thread is still blocked inside SendCommand and cannot be
# cancelled. Abandon it (shutdown(wait=False)) and start a fresh
# single-thread executor so the next call doesn't queue behind the
# stuck one and time out too. Drop the cached COM app for the same
# reason — the new thread must re-Dispatch in its own apartment.
stuck = self._executor
self._executor = ThreadPoolExecutor(max_workers=1, initializer=_com_init)
if stuck is not None:
stuck.shutdown(wait=False)
_COM_STATE.pop("app", None)
self._connected = False
raise RuntimeError(
f"AutoCAD did not respond within {timeout:.0f}s. The application "
"may be showing a modal dialog or hung. Increase COM_CALL_TIMEOUT "
"if a long operation is expected."
"may be showing a modal dialog or have an active command prompt "
"(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long "
"operation is expected."
) from e
except pywintypes.com_error as e:
hr = e.args[0] if e.args else 0
@@ -904,6 +955,7 @@ class ComBackend(AutoCADBackend):
if color is not None:
ent.Color = int(color)
if linetype is not None:
_ensure_linetype_loaded(linetype)
ent.Linetype = linetype
if lineweight is not None:
ent.LineWeight = int(lineweight)
@@ -966,6 +1018,7 @@ class ComBackend(AutoCADBackend):
doc = _acad_doc()
lyr = doc.Layers.Add(name)
lyr.Color = int(color)
_ensure_linetype_loaded(linetype)
try:
lyr.Linetype = linetype
except Exception as exc:
@@ -999,6 +1052,7 @@ class ComBackend(AutoCADBackend):
if color is not None:
lyr.Color = int(color)
if linetype is not None:
_ensure_linetype_loaded(linetype)
lyr.Linetype = linetype
if lineweight is not None:
lyr.LineWeight = int(lineweight)
@@ -1053,6 +1107,67 @@ class ComBackend(AutoCADBackend):
return {"ok": True, "layer": name, "visible": True}
return await self._run(_sync)
# ── linetype management ───────────────────────────────────────────────────
async def linetype_list(self) -> list[str]:
def _sync():
doc = _acad_doc()
return [doc.Linetypes.Item(i).Name for i in range(doc.Linetypes.Count)]
return await self._run(_sync)
async def linetype_load(self, name, file=None) -> dict:
# Loads a single linetype from a .lin file. Two things would otherwise
# bite the user: (1) AutoCAD pops a "Select Linetype File" dialog when
# FILEDIA=1 and the file path can't be auto-resolved — that modal
# dialog deadlocks SendCommand; (2) ISO/metric drawings need acadiso.lin,
# imperial drawings need acad.lin. We pick automatically from MEASUREMENT
# if the caller didn't specify a file.
def _sync():
app = _acad_app()
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower()
for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
if file is None:
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc)
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
else:
lin_file = file
try:
old_filedia = int(app.GetVariable("FILEDIA"))
except Exception as exc:
log.debug("FILEDIA read failed, assuming 1: %s", exc)
old_filedia = 1
try:
app.SetVariable("FILEDIA", 0)
# Double newline: first ends the file name, second exits
# -LINETYPE's [?/Create/Load/Set] option menu.
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
except Exception as exc:
log.debug("FILEDIA restore failed: %s", exc)
after = {doc.Linetypes.Item(i).Name.lower()
for i in range(doc.Linetypes.Count)}
if name.lower() not in after:
raise RuntimeError(
f"Failed to load linetype '{name}' from '{lin_file}'. "
"Check the linetype name spelling and that the .lin file is "
"on AutoCAD's support path."
)
return {"ok": True, "name": name, "file": lin_file}
return await self._run(_sync)
# ── block operations ──────────────────────────────────────────────────────
async def block_list(self) -> list[BlockInfo]:
@@ -1347,8 +1462,23 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def system_run_command(self, command) -> dict:
# Commands ending in option menus (e.g. -LINETYPE returning to its
# [?/Create/Load/Set] prompt) need a trailing blank line or _X to exit;
# otherwise SendCommand returns but AutoCAD stays at a prompt and the
# next COM call deadlocks. Example: "_-LINETYPE _LOAD CENTER acad.lin\n\n"
def _sync():
app = _acad_app()
doc = _acad_doc()
try:
cmd_active = int(app.GetVariable("CMDACTIVE"))
except Exception as exc:
log.debug("CMDACTIVE read failed, proceeding anyway: %s", exc)
cmd_active = 0
if cmd_active:
raise RuntimeError(
"AutoCAD has an active command or prompt (CMDACTIVE="
f"{cmd_active}). Press ESC in AutoCAD to cancel, then retry."
)
cmd = command if command.endswith("\n") else command + "\n"
doc.SendCommand(cmd)
return {"ok": True, "command": command}
+95
View File
@@ -55,6 +55,59 @@ _ACI_NAMES = {
5: "Blue", 6: "Magenta", 7: "White", 256: "ByLayer", 0: "ByBlock",
}
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
# AutoCAD-shipped linetypes that ezdxf.tools.standards does not include.
# Pattern format: [total_length, dash_1, gap_1, dash_2, gap_2, ...] (gaps negative).
# Values match acadiso.lin so drawings render the same in AutoCAD viewers.
_AUTOCAD_FALLBACK_LINETYPES: dict[str, tuple[str, list[float]]] = {
"HIDDEN": ("Hidden __ __ __ __ __ __ __ __ __", [7.5, 5.0, -2.5]),
"HIDDEN2": ("Hidden (.5x) _ _ _ _ _ _ _ _ _", [3.75, 2.5, -1.25]),
"HIDDENX2": ("Hidden (2x) ____ ____ ____ ____", [15.0, 10.0, -5.0]),
"BORDER": ("Border __ __ . __ __ . __ __ . __", [22.5, 7.5, -2.5, 7.5, -2.5, 0.0, -2.5]),
"BORDER2": ("Border (.5x) __.__.__.__.__.__", [11.25, 3.75, -1.25, 3.75, -1.25, 0.0, -1.25]),
"BORDERX2": ("Border (2x) ____ __ ____ __", [45.0, 15.0, -5.0, 15.0, -5.0, 0.0, -5.0]),
}
def _add_linetype_from_fallback(doc, name: str) -> bool:
"""Add `name` from _AUTOCAD_FALLBACK_LINETYPES if known. Returns True on success."""
fallback = _AUTOCAD_FALLBACK_LINETYPES.get(name.upper())
if fallback is None:
return False
description, pattern = fallback
doc.linetypes.add(name.upper(), pattern=pattern, description=description)
return True
def _ensure_linetype_loaded(doc, name: str) -> None:
"""Add `name` to doc.linetypes if not already present.
Lookup order: ezdxf.tools.standards → AutoCAD fallback table → warn.
Lets callers write `linetype="CENTER"` (or HIDDEN, BORDER) without
separately loading it.
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
if name in doc.linetypes:
return
try:
from ezdxf.tools import standards as ezdxf_standards
for lt_name, description, pattern in ezdxf_standards.linetypes():
if lt_name.lower() == name.lower():
doc.linetypes.add(lt_name, pattern=pattern, description=description)
return
except ImportError:
pass
if _add_linetype_from_fallback(doc, name):
return
log.warning(
"ezdxf backend: linetype %r is not in ezdxf's standard set or AutoCAD "
"fallback table; name will be assigned but renders as Continuous until "
"properly defined.",
name,
)
def _entity_info_dxf(ent) -> EntityInfo:
"""Convert an ezdxf entity to EntityInfo."""
@@ -924,6 +977,7 @@ class EzdxfBackend(AutoCADBackend):
if color is not None:
ent.dxf.color = int(color)
if linetype is not None:
_ensure_linetype_loaded(self._require_doc(), linetype)
ent.dxf.linetype = linetype
if lineweight is not None:
ent.dxf.lineweight = int(lineweight)
@@ -972,6 +1026,7 @@ class EzdxfBackend(AutoCADBackend):
) -> LayerInfo:
def _sync():
doc = self._require_doc()
_ensure_linetype_loaded(doc, linetype)
lyr = doc.layers.add(
name,
color=int(color),
@@ -1016,6 +1071,7 @@ class EzdxfBackend(AutoCADBackend):
if color is not None:
lyr.dxf.color = int(color)
if linetype is not None:
_ensure_linetype_loaded(doc, linetype)
lyr.dxf.linetype = linetype
if lineweight is not None:
lyr.dxf.lineweight = int(lineweight)
@@ -1083,6 +1139,45 @@ class EzdxfBackend(AutoCADBackend):
return {"ok": True, "layer": name, "visible": True}
return await self._async(_sync)
# ── linetype management ───────────────────────────────────────────────────
async def linetype_list(self) -> list[str]:
def _sync():
doc = self._require_doc()
return [lt.dxf.name for lt in doc.linetypes]
return await self._async(_sync)
async def linetype_load(self, name, file=None) -> dict:
# Loads a single linetype. Lookup order:
# 1. ezdxf.tools.standards (ISO: CENTER, DASHED, DASHDOT, PHANTOM, ...)
# 2. AutoCAD fallback table (HIDDEN, BORDER and *2/X2 variants)
# The `file` parameter is accepted for API parity with COM but ignored
# — ezdxf does not ship a .lin parser.
from ezdxf.tools import standards as ezdxf_standards
del file
def _sync():
doc = self._require_doc()
existing = {lt.dxf.name.lower() for lt in doc.linetypes}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
for lt_name, description, pattern in ezdxf_standards.linetypes():
if lt_name.lower() == name.lower():
doc.linetypes.add(lt_name, pattern=pattern, description=description)
self._mark_dirty()
return {"ok": True, "name": lt_name,
"source": "ezdxf.tools.standards"}
if _add_linetype_from_fallback(doc, name):
self._mark_dirty()
return {"ok": True, "name": name.upper(),
"source": "autocad_fallback"}
raise RuntimeError(
f"Linetype '{name}' is not in ezdxf's standard set or the "
"AutoCAD fallback table. Available standards: CENTER*, DASHED*, "
"DASHDOT*, PHANTOM*, DOT*, DIVIDE*. Available fallbacks: "
"HIDDEN*, BORDER*. Custom .lin files require the COM backend."
)
return await self._async(_sync)
# ── block operations ──────────────────────────────────────────────────────
async def block_list(self) -> list[BlockInfo]:
+65
View File
@@ -0,0 +1,65 @@
"""Deterministic engineering CAD layer for AutoCAD MCP Pro."""
from .gear import (
draw_gear_section_aa,
draw_helical_gear_front_view,
draw_spur_gear_front_view,
generate_full_gear_outline,
generate_involute_flank,
generate_tooth_profile,
involute_xy,
)
from .keyway import (
DIN6885_TABLE,
draw_keyed_bore,
draw_keyway_section,
keyway_dimensions,
)
from .layers import (
ENGINEERING_LAYERS,
STANDARD_LINETYPES,
ensure_engineering_layers,
ensure_standard_linetypes,
)
from .section import apply_section_hatch, cutting_plane_line
# Agent B contributes titleblock + validator. Imports are guarded so this
# package remains usable while B is still in flight; once those modules land
# the symbols flow through transparently.
try:
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
except ImportError:
TitleBlockMetadata = None # type: ignore[assignment]
apply_iso_a3_titleblock = None # type: ignore[assignment]
try:
from .validator import DrawingValidator, ValidationFinding, ValidationResult
except ImportError:
DrawingValidator = None # type: ignore[assignment]
ValidationFinding = None # type: ignore[assignment]
ValidationResult = None # type: ignore[assignment]
__all__ = [
"draw_helical_gear_front_view",
"draw_spur_gear_front_view",
"draw_gear_section_aa",
"generate_full_gear_outline",
"generate_involute_flank",
"generate_tooth_profile",
"involute_xy",
"draw_keyed_bore",
"draw_keyway_section",
"keyway_dimensions",
"DIN6885_TABLE",
"cutting_plane_line",
"apply_section_hatch",
"ENGINEERING_LAYERS",
"STANDARD_LINETYPES",
"ensure_engineering_layers",
"ensure_standard_linetypes",
"apply_iso_a3_titleblock",
"TitleBlockMetadata",
"DrawingValidator",
"ValidationResult",
"ValidationFinding",
]
+431
View File
@@ -0,0 +1,431 @@
"""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,
}
+173
View File
@@ -0,0 +1,173 @@
"""DIN 6885 keyway geometry — bore + keyway cutter for shafts and hubs."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
log = logging.getLogger(__name__)
DIN6885_TABLE: list[tuple[float, float, float, float, float]] = [
# (bore_min, bore_max, key_width, key_height, depth_in_shaft_t1)
(6, 8, 2, 2, 1.2),
(8, 10, 3, 3, 1.8),
(10, 12, 4, 4, 2.5),
(12, 17, 5, 5, 3.0),
(17, 22, 6, 6, 3.5),
(22, 30, 8, 7, 4.0),
(30, 38, 10, 8, 5.0),
(38, 44, 12, 8, 5.0),
(44, 50, 14, 9, 5.5),
(50, 58, 16, 10, 6.0),
(58, 65, 18, 11, 7.0),
(65, 75, 20, 12, 7.5),
(75, 85, 22, 14, 9.0),
(85, 95, 25, 14, 9.0),
(95, 110, 28, 16, 10.0),
(110, 130, 32, 18, 11.0),
]
def keyway_dimensions(bore_diameter: float) -> dict:
"""Look up DIN 6885 key width/height/depth for a given bore diameter (mm)."""
if bore_diameter < DIN6885_TABLE[0][0]:
raise ValueError(
f"bore_diameter {bore_diameter} mm is below DIN 6885 minimum "
f"({DIN6885_TABLE[0][0]} mm)."
)
if bore_diameter > DIN6885_TABLE[-1][1]:
raise ValueError(
f"bore_diameter {bore_diameter} mm exceeds DIN 6885 maximum "
f"({DIN6885_TABLE[-1][1]} mm)."
)
for b_min, b_max, width, height, t1 in DIN6885_TABLE:
if b_min < bore_diameter <= b_max or (bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]):
return {
"width": float(width),
"height": float(height),
"depth_shaft": float(t1),
"depth_hub": float(height) - float(t1),
}
raise ValueError(f"No DIN 6885 row matched bore_diameter={bore_diameter}.")
async def draw_keyed_bore(
backend: "AutoCADBackend",
*,
center: tuple[float, float],
bore_diameter: float,
keyway_width: float | None = None,
keyway_depth: float | None = None,
layer: str = "GEOMETRY",
center_layer: str = "CENTER",
) -> dict:
"""Draw bore + upward-opening keyway notch + horizontal/vertical centerlines."""
cx, cy = float(center[0]), float(center[1])
r = float(bore_diameter) / 2.0
if keyway_width is None or keyway_depth is None:
dims = keyway_dimensions(bore_diameter)
if keyway_width is None:
keyway_width = dims["width"]
if keyway_depth is None:
keyway_depth = dims["depth_hub"]
bore = await backend.entity_create_circle(cx, cy, r, layer=layer)
half_w = float(keyway_width) / 2.0
depth = float(keyway_depth)
bore_top_y = cy + r
notch_top_y = bore_top_y + depth
left_x = cx - half_w
right_x = cx + half_w
points = [
[left_x, bore_top_y],
[left_x, notch_top_y],
[right_x, notch_top_y],
[right_x, bore_top_y],
]
keyway_poly = await backend.entity_create_polyline(points, closed=False, layer=layer)
overshoot = max(r * 0.2, 5.0)
horiz = await backend.entity_create_line(
cx - r - overshoot, cy, cx + r + overshoot, cy, layer=center_layer,
)
vert = await backend.entity_create_line(
cx, cy - r - overshoot, cx, cy + r + depth + overshoot, layer=center_layer,
)
return {
"bore": bore.handle,
"keyway_polyline": keyway_poly.handle,
"centerline_h": horiz.handle,
"centerline_v": vert.handle,
}
async def draw_keyway_section(
backend: "AutoCADBackend",
*,
center: tuple[float, float],
bore_diameter: float,
face_width: float,
keyway_width: float | None = None,
keyway_depth: float | None = None,
layer: str = "GEOMETRY",
) -> dict:
"""Side cross-section: bore as two horizontal lines with rectangular keyway notch on top."""
cx, cy = float(center[0]), float(center[1])
r = float(bore_diameter) / 2.0
half_face = float(face_width) / 2.0
if keyway_width is None or keyway_depth is None:
dims = keyway_dimensions(bore_diameter)
if keyway_width is None:
keyway_width = dims["width"]
if keyway_depth is None:
keyway_depth = dims["depth_hub"]
half_kw = float(keyway_width) / 2.0
depth = float(keyway_depth)
top_y = cy + r
bot_y = cy - r
notch_top_y = top_y + depth
x_left = cx - half_face
x_right = cx + half_face
notch_left = cx - half_kw
notch_right = cx + half_kw
bore_top = await backend.entity_create_line(
x_left, top_y, x_right, top_y, layer=layer,
)
bore_bottom = await backend.entity_create_line(
x_left, bot_y, x_right, bot_y, layer=layer,
)
notch_bottom = await backend.entity_create_line(
notch_left, top_y, notch_right, top_y, layer=layer,
)
notch_left_wall = await backend.entity_create_line(
notch_left, top_y, notch_left, notch_top_y, layer=layer,
)
notch_top = await backend.entity_create_line(
notch_left, notch_top_y, notch_right, notch_top_y, layer=layer,
)
notch_right_wall = await backend.entity_create_line(
notch_right, notch_top_y, notch_right, top_y, layer=layer,
)
return {
"bore_top": bore_top.handle,
"bore_bottom": bore_bottom.handle,
"keyway": [
notch_bottom.handle,
notch_left_wall.handle,
notch_top.handle,
notch_right_wall.handle,
],
}
+74
View File
@@ -0,0 +1,74 @@
"""Standard engineering layer + linetype scaffold for production drawings."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
log = logging.getLogger(__name__)
ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
# (name, color, linetype, lineweight, description)
("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
async def ensure_standard_linetypes(backend: "AutoCADBackend") -> dict[str, str]:
"""Idempotently load STANDARD_LINETYPES via backend.linetype_load."""
try:
existing = {ln.lower() for ln in await backend.linetype_list()}
except Exception as exc:
log.warning("linetype_list failed; assuming none loaded: %s", exc)
existing = set()
results: dict[str, str] = {}
for name in STANDARD_LINETYPES:
if name.lower() in existing:
results[name] = "already_loaded"
continue
try:
await backend.linetype_load(name)
results[name] = "loaded"
except Exception as exc:
results[name] = f"failed: {exc}"
return results
async def ensure_engineering_layers(backend: "AutoCADBackend") -> dict[str, str]:
"""Idempotently create every layer in ENGINEERING_LAYERS via backend.layer_create."""
await ensure_standard_linetypes(backend)
try:
existing = {lyr.name.lower() for lyr in await backend.layer_list()}
except Exception as exc:
log.warning("layer_list failed; assuming empty: %s", exc)
existing = set()
results: dict[str, str] = {}
for name, color, linetype, lineweight, _desc in ENGINEERING_LAYERS:
if name.lower() in existing:
results[name] = "exists"
continue
try:
await backend.layer_create(
name=name, color=color, linetype=linetype, lineweight=lineweight,
)
results[name] = "created"
except Exception as exc:
results[name] = f"failed: {exc}"
return results
+82
View File
@@ -0,0 +1,82 @@
"""Generic cutting-plane and section-hatch helpers."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
async def cutting_plane_line(
backend: "AutoCADBackend",
*,
p1: tuple[float, float],
p2: tuple[float, float],
label: str = "A",
layer: str = "PHANTOM",
) -> dict:
"""Draw a section line, arrowheads at each end, and 'X-X' labels."""
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
dx, dy = x2 - x1, y2 - y1
length = math.hypot(dx, dy) or 1.0
ux, uy = dx / length, dy / length
nx, ny = -uy, ux
arrow_len = max(length * 0.05, 4.0)
arrow_w = arrow_len * 0.4
arrows: list[str] = []
for tip_x, tip_y, dir_x, dir_y in (
(x1, y1, -ux, -uy),
(x2, y2, ux, uy),
):
bx = tip_x + dir_x * arrow_len
by = tip_y + dir_y * arrow_len
wing1_x = bx + nx * arrow_w
wing1_y = by + ny * arrow_w
wing2_x = bx - nx * arrow_w
wing2_y = by - ny * arrow_w
a1 = await backend.entity_create_line(tip_x, tip_y, wing1_x, wing1_y, layer=layer)
a2 = await backend.entity_create_line(tip_x, tip_y, wing2_x, wing2_y, layer=layer)
arrows.extend([a1.handle, a2.handle])
text = f"{label}-{label}"
text_h = max(arrow_len, 3.5)
offset = arrow_len * 1.5
labels: list[str] = []
for tip_x, tip_y, dir_x, dir_y in (
(x1, y1, -ux, -uy),
(x2, y2, ux, uy),
):
tx = tip_x + dir_x * offset + nx * offset * 0.5
ty = tip_y + dir_y * offset + ny * offset * 0.5
t = await backend.entity_create_text(text, tx, ty, height=text_h, layer="TEXT")
labels.append(t.handle)
return {"line": line.handle, "arrows": arrows, "labels": labels}
async def apply_section_hatch(
backend: "AutoCADBackend",
*,
boundary_points: list[list[float]],
pattern: str = "ANSI31",
angle: float = 45.0,
scale: float = 1.0,
layer: str = "HATCH",
) -> str:
"""Wrapper around backend.entity_create_hatch returning the new handle."""
info = await backend.entity_create_hatch(
pattern=pattern,
boundary_points=boundary_points,
scale=scale,
angle=angle,
layer=layer,
)
return info.handle
+254
View File
@@ -0,0 +1,254 @@
"""ISO 7200 / A3 (420 x 297 mm) title block generator.
Deterministic — the title text is written verbatim from metadata.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
# A3 paper, ISO 7200 layout constants (mm)
SHEET_W = 420.0
SHEET_H = 297.0
LEFT_MARGIN = 20.0
RIGHT_MARGIN = 10.0
TOP_MARGIN = 10.0
BOTTOM_MARGIN = 10.0
TB_WIDTH = 180.0
TB_HEIGHT = 60.0
ROW1_H = 20.0 # title
ROW2_H = 15.0 # drawing_no | revision | sheet
ROW3_H = 15.0 # part_no | material | scale
ROW4_H = 10.0 # drawn_by | checked_by | date | company
LABEL_HEIGHT = 2.0
VALUE_HEIGHT = 3.5
TITLE_HEIGHT = 5.0
LAYER_BORDER = "TITLEBLOCK"
LAYER_TEXT = "TEXT"
@dataclass
class TitleBlockMetadata:
"""ISO 7200 title block fields (all strings — no LLM transformation)."""
title: str
drawing_no: str
part_no: str = ""
material: str = ""
scale: str = "1:1"
units: str = "mm"
drawn_by: str = ""
checked_by: str = ""
date: str = ""
sheet: str = "1/1"
revision: str = "A"
company: str = "Anka-Makine"
async def apply_iso_a3_titleblock(
backend: "AutoCADBackend",
*,
metadata: TitleBlockMetadata,
origin: tuple[float, float] = (0.0, 0.0),
) -> dict:
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet)."""
ox, oy = float(origin[0]), float(origin[1])
# ---- outer + inner borders -------------------------------------------------
outer_pts = [
[ox, oy],
[ox + SHEET_W, oy],
[ox + SHEET_W, oy + SHEET_H],
[ox, oy + SHEET_H],
]
outer = await backend.entity_create_polyline(
points=outer_pts, closed=True, layer=LAYER_BORDER,
)
inner_x0 = ox + LEFT_MARGIN
inner_y0 = oy + BOTTOM_MARGIN
inner_x1 = ox + SHEET_W - RIGHT_MARGIN
inner_y1 = oy + SHEET_H - TOP_MARGIN
inner_pts = [
[inner_x0, inner_y0],
[inner_x1, inner_y0],
[inner_x1, inner_y1],
[inner_x0, inner_y1],
]
inner = await backend.entity_create_polyline(
points=inner_pts, closed=True, layer=LAYER_BORDER,
)
# ---- title block (lower-right corner of inner border) ----------------------
tb_x0 = inner_x1 - TB_WIDTH
tb_y0 = inner_y0
tb_x1 = inner_x1
tb_y1 = inner_y0 + TB_HEIGHT
# Title block frame (closed polyline)
tb_frame_pts = [
[tb_x0, tb_y0],
[tb_x1, tb_y0],
[tb_x1, tb_y1],
[tb_x0, tb_y1],
]
tb_frame = await backend.entity_create_polyline(
points=tb_frame_pts, closed=True, layer=LAYER_BORDER,
)
# Row baselines (from bottom up): row4_top, row3_top, row2_top
row4_top = tb_y0 + ROW4_H
row3_top = row4_top + ROW3_H
row2_top = row3_top + ROW2_H # == tb_y1 - ROW1_H
titleblock_lines: list[str] = [tb_frame.handle]
async def hline(y: float) -> str:
ent = await backend.entity_create_line(
tb_x0, y, tb_x1, y, layer=LAYER_BORDER,
)
return ent.handle
async def vline(x: float, y0: float, y1: float) -> str:
ent = await backend.entity_create_line(
x, y0, x, y1, layer=LAYER_BORDER,
)
return ent.handle
titleblock_lines.append(await hline(row4_top))
titleblock_lines.append(await hline(row3_top))
titleblock_lines.append(await hline(row2_top))
# Row 2 splits: left half = drawing_no, then quarter rev, quarter sheet
r2_split1 = tb_x0 + TB_WIDTH * 0.5
r2_split2 = tb_x0 + TB_WIDTH * 0.75
titleblock_lines.append(await vline(r2_split1, row3_top, row2_top))
titleblock_lines.append(await vline(r2_split2, row3_top, row2_top))
# Row 3 splits: thirds — part_no | material | scale
r3_split1 = tb_x0 + TB_WIDTH / 3.0
r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0
titleblock_lines.append(await vline(r3_split1, row4_top, row3_top))
titleblock_lines.append(await vline(r3_split2, row4_top, row3_top))
# Row 4 splits: quarters — drawn_by | checked_by | date | company
r4_split1 = tb_x0 + TB_WIDTH * 0.25
r4_split2 = tb_x0 + TB_WIDTH * 0.50
r4_split3 = tb_x0 + TB_WIDTH * 0.75
titleblock_lines.append(await vline(r4_split1, tb_y0, row4_top))
titleblock_lines.append(await vline(r4_split2, tb_y0, row4_top))
titleblock_lines.append(await vline(r4_split3, tb_y0, row4_top))
# ---- text content ----------------------------------------------------------
label_pad_x = 1.5
label_pad_y = 1.5
value_pad_x = 3.0
value_pad_y = 5.0
async def label(text: str, x: float, y: float) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=LABEL_HEIGHT, layer=LAYER_TEXT,
)
return ent.handle
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=height, layer=LAYER_TEXT,
)
return ent.handle
# Row 1 — TITLE (verbatim)
title_x = tb_x0 + TB_WIDTH * 0.5 - (len(metadata.title) * TITLE_HEIGHT * 0.3)
title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0
title_text_handle = await value(metadata.title, title_x, title_y, height=TITLE_HEIGHT)
value_texts: dict[str, str] = {}
# Row 2: drawing_no (left half) | revision (3rd qtr) | sheet (4th qtr)
await label("DWG NO", tb_x0 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
value_texts["drawing_no"] = await value(
metadata.drawing_no,
tb_x0 + value_pad_x,
row3_top + value_pad_y,
)
await label("REV", r2_split1 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
value_texts["revision"] = await value(
metadata.revision,
r2_split1 + value_pad_x,
row3_top + value_pad_y,
)
await label("SHEET", r2_split2 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
value_texts["sheet"] = await value(
metadata.sheet,
r2_split2 + value_pad_x,
row3_top + value_pad_y,
)
# Row 3: part_no | material | scale
await label("PART NO", tb_x0 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
value_texts["part_no"] = await value(
metadata.part_no,
tb_x0 + value_pad_x,
row4_top + value_pad_y,
)
await label("MATERIAL", r3_split1 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
value_texts["material"] = await value(
metadata.material,
r3_split1 + value_pad_x,
row4_top + value_pad_y,
)
await label("SCALE", r3_split2 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
value_texts["scale"] = await value(
metadata.scale,
r3_split2 + value_pad_x,
row4_top + value_pad_y,
)
# Row 4: drawn_by | checked_by | date | company
await label("DRAWN", tb_x0 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["drawn_by"] = await value(
metadata.drawn_by,
tb_x0 + value_pad_x,
tb_y0 + 1.5,
)
await label("CHECKED", r4_split1 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["checked_by"] = await value(
metadata.checked_by,
r4_split1 + value_pad_x,
tb_y0 + 1.5,
)
await label("DATE", r4_split2 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["date"] = await value(
metadata.date,
r4_split2 + value_pad_x,
tb_y0 + 1.5,
)
await label("COMPANY", r4_split3 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["company"] = await value(
metadata.company,
r4_split3 + value_pad_x,
tb_y0 + 1.5,
)
return {
"outer_border": outer.handle,
"inner_border": inner.handle,
"titleblock_lines": titleblock_lines,
"title_text": title_text_handle,
"value_texts": value_texts,
"metadata": asdict(metadata),
"bbox": {
"min": [ox, oy],
"max": [ox + SHEET_W, oy + SHEET_H],
},
}
+240
View File
@@ -0,0 +1,240 @@
"""8-step pre-completion validator for engineering drawings."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
REAL_DIMENSION_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
}
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
DIMENSION_LIKE_TEXT = re.compile(
r"(Ø|⌀|∅|%%c|%%C|R\d|±|%%p|\d+\s*[×x]\s*\d+|\d+\.\d+\s*mm)",
re.IGNORECASE,
)
@dataclass
class ValidationFinding:
"""One issue surfaced by the validator."""
severity: str # "error" | "warning" | "info"
code: str
message: str
detail: dict = field(default_factory=dict)
@dataclass
class ValidationResult:
"""Aggregate of validator findings."""
ok: bool
findings: list[ValidationFinding]
summary: dict
def to_dict(self) -> dict:
return {
"ok": self.ok,
"summary": self.summary,
"findings": [
{
"severity": f.severity,
"code": f.code,
"message": f.message,
"detail": f.detail,
}
for f in self.findings
],
}
class DrawingValidator:
"""8-step pre-completion validator."""
async def run(
self,
backend: "AutoCADBackend",
*,
expected: dict | None = None,
) -> ValidationResult:
"""Run all 8 checks; return aggregate ValidationResult."""
findings: list[ValidationFinding] = []
expected = expected or {}
ents: list = []
# 1 — fake dimensions
try:
ents = await backend.entity_list(limit=10000)
text_likes = [
e for e in ents
if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search(
getattr(e, "properties", {}).get("text", "") or ""
)
]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes:
findings.append(ValidationFinding(
"warning",
"fake_dimension_text",
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
"(Ø, ±, R, etc.) — use dimension_* tools instead",
{
"sample_handles": [t.handle for t in text_likes[:5]],
"real_dim_count": len(real_dims),
},
))
except Exception as exc:
findings.append(ValidationFinding(
"warning", "dim_check_failed", str(exc),
))
# 2 — hidden orphans
try:
hidden = [e for e in ents if not e.visible]
if hidden:
findings.append(ValidationFinding(
"error",
"hidden_orphans",
f"{len(hidden)} entities have visible=False — hide-fallback "
"is forbidden, use entity_delete",
{"sample_handles": [h.handle for h in hidden[:5]]},
))
except Exception:
pass
# 3 — linetypes
try:
lts = {x.lower() for x in await backend.linetype_list()}
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
if missing:
findings.append(ValidationFinding(
"error",
"linetypes_missing",
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
{"missing": missing},
))
except Exception as exc:
findings.append(ValidationFinding(
"warning", "linetype_check_failed", str(exc),
))
# 4 — gear views (heuristic, only if expected demands)
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
bore_circles = [
e for e in ents
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
]
if expected.get("must_have_bore") and len(bore_circles) < 1:
findings.append(ValidationFinding(
"error",
"bore_missing",
"Expected bore but no circle on GEOMETRY layer found",
))
keyway_polys = [
e for e in ents
if e.type in ("LWPOLYLINE", "POLYLINE")
and e.layer == "GEOMETRY"
and len(e.properties.get("points", [])) <= 6
and e.properties.get("closed")
]
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
findings.append(ValidationFinding(
"warning",
"keyway_section_unverified",
"Could not confirm keyway in section view via heuristic "
"(small closed polyline on GEOMETRY)",
))
# 5 — title consistency
helix = expected.get("helix_angle")
if helix:
title_texts = [
(e.properties.get("text", "") or "").upper()
for e in ents
if e.type in ("TEXT", "MTEXT") and e.layer == "TEXT"
]
joined = " | ".join(title_texts)
if "HELICAL SPUR" in joined:
findings.append(ValidationFinding(
"error",
"title_helical_spur",
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
"'HELICAL GEAR' or 'SPUR GEAR'",
))
elif "SPUR" in joined and "HELICAL" not in joined:
findings.append(ValidationFinding(
"error",
"title_says_spur_for_helical",
f"Title contains SPUR but part is helical (helix_angle={helix})",
{"sample": joined[:200]},
))
# 6 — saved to disk
try:
info = await backend.drawing_info()
saved_attr = getattr(info, "saved", None)
full_path = getattr(info, "full_path", "") or ""
if not saved_attr:
findings.append(ValidationFinding(
"error",
"not_saved",
"Drawing has unsaved changes — call drawing_save before finalize",
{"full_path": full_path},
))
elif full_path and not Path(full_path).exists():
findings.append(ValidationFinding(
"error",
"file_missing",
f"Drawing reports saved=True but file not on disk: {full_path}",
))
except Exception as exc:
findings.append(ValidationFinding(
"warning", "save_check_failed", str(exc),
))
# 7 — screenshot
try:
shot = await backend.view_screenshot()
if not shot:
findings.append(ValidationFinding(
"info",
"screenshot_unavailable",
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
))
except Exception as exc:
findings.append(ValidationFinding(
"info", "screenshot_failed", str(exc),
))
# 8 — dwg path returned
try:
info = await backend.drawing_info()
if not getattr(info, "full_path", ""):
findings.append(ValidationFinding(
"warning",
"no_path",
"drawing_info.full_path is empty",
))
except Exception:
pass
summary = {
sev: sum(1 for f in findings if f.severity == sev)
for sev in ("error", "warning", "info")
}
return ValidationResult(
ok=summary["error"] == 0,
findings=findings,
summary=summary,
)
+296 -5
View File
@@ -167,6 +167,22 @@ WORKFLOW:
5. view_screenshot → see current state
6. drawing_save → save your work
ENGINEERING WORKFLOW (parts requiring real production drawings):
1. drawing_new → auto-bootstraps engineering layers + linetypes
2. titleblock_apply_iso_a3 → standard ISO 7200 border + title (NEVER hand-draw the title)
3. gear_draw_helical_front_view / gear_draw_spur_front_view
→ parametric gear front view (NEVER hand-draw teeth or invent the title)
4. gear_draw_section_aa → parametric cross-section
5. dimension_* → real DIMENSION entities (NEVER use text+leader fakes)
6. drawing_finalize → 8-step validator + save + screenshot;
cannot complete without this; raises if invalid.
CRITICAL: For engineering drawings, NEVER manually draw gear teeth, keyways,
or section hatches with primitive line/circle calls. Use the gear_*, keyway_*,
and titleblock_* tools — they are deterministic and produce production-quality
output. The title text is whatever you pass to titleblock_apply_iso_a3 — do
not invent variants like "HELICAL SPUR GEAR".
HANDLES: Every entity has a unique handle (hex string). Use it for
entity_get, entity_move, entity_delete, entity_set_properties, etc.
@@ -243,14 +259,39 @@ async def drawing_info(ctx: Context) -> dict:
)
async def drawing_new(
template: Annotated[str | None, "Optional path to .dwt template file"] = None,
bootstrap: Annotated[bool, Field(
description="Auto-load CENTER/HIDDEN/PHANTOM linetypes and create standard "
"engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, "
"HATCH, TEXT, TITLEBLOCK). Disable for vanilla DXF.",
)] = True,
ctx: Context = None,
) -> dict:
"""Create a new empty drawing, optionally from a template (.dwt)."""
"""Create a new empty drawing, optionally from a template (.dwt).
When ``bootstrap=True`` (default), the drawing is also seeded with the
standard engineering linetypes (CENTER/HIDDEN/PHANTOM) and layers
(GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK).
"""
if template is not None:
validated_template = validate_path(template, allow_write=False)
template = str(validated_template)
await ctx.info(f"Creating new drawing (template={template})")
return await _backend(ctx).drawing_new(template)
await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})")
backend = _backend(ctx)
raw = await backend.drawing_new(template)
result = dict(raw) if isinstance(raw, dict) else {"result": raw}
if bootstrap:
try:
from engineering import (
ensure_engineering_layers,
ensure_standard_linetypes,
)
lt_status = await ensure_standard_linetypes(backend)
layer_status = await ensure_engineering_layers(backend)
result["bootstrap"] = {"linetypes": lt_status, "layers": layer_status}
except Exception as exc:
await ctx.warning(f"Engineering bootstrap skipped: {exc}")
result["bootstrap"] = {"error": str(exc)}
return result
@mcp.tool(
@@ -1111,6 +1152,39 @@ async def layer_isolate(
return {"ok": True, "isolated": name, "hidden_count": len(hidden), "hidden_layers": hidden}
@mcp.tool(
annotations={"title": "List Loaded Linetypes", "readOnlyHint": True},
tags={"layer", "linetype"},
)
async def linetype_list(ctx: Context = None) -> list[str]:
"""Return the names of all linetypes currently loaded in the active drawing."""
return await _backend(ctx).linetype_list()
@mcp.tool(
annotations={"title": "Load Linetype", "readOnlyHint": False},
tags={"layer", "linetype"},
)
async def linetype_load(
name: Annotated[str, "Linetype name to load (e.g. 'CENTER', 'DASHED', 'HIDDEN')"],
file: Annotated[
str | None,
"Optional .lin file. Defaults to acadiso.lin (metric) or acad.lin "
"(imperial), chosen from MEASUREMENT. Ignored by ezdxf backend."
] = None,
ctx: Context = None,
) -> dict:
"""Load a single linetype safely.
Use this instead of `system_run_command('_-LINETYPE _LOAD ...')` — that
raw form can deadlock on the FILEDIA file-picker dialog and on the
-LINETYPE option-menu prompt. This tool sets FILEDIA=0 around the call,
picks the right .lin file from MEASUREMENT, and verifies the linetype
actually loaded.
"""
return await _backend(ctx).linetype_load(name, file)
# ---------------------------------------------------------------------------
# ── SECTION 7: Block Operations (7 tools) ───────────────────────────────────
# ---------------------------------------------------------------------------
@@ -1874,7 +1948,12 @@ async def system_run_command(
) -> dict:
"""Execute an AutoCAD command string directly (COM backend only).
Append \\n for Enter. Example: '_LINE 0,0 100,0 \\n'
Append \\n for Enter. Example: '_LINE 0,0 100,0 \\n'.
IMPORTANT: commands that finish at an option menu (e.g. -LINETYPE, -LAYER,
-STYLE return to '[?/Create/Load/Set]:' after their action) need an EXTRA
blank line or '_X\\n' to exit, otherwise AutoCAD stays at a prompt and the
next COM call will deadlock. Example: '_-LINETYPE _LOAD CENTER acad.lin\\n\\n'.
"""
sanitize_command(command)
await ctx.warning(f"Running command: {command}")
@@ -1932,7 +2011,8 @@ async def system_about(ctx: Context = None) -> dict:
"templates": ["template_apply_layers", "template_list"],
"layers": ["layer_list", "layer_create", "layer_delete", "layer_set_current",
"layer_modify", "layer_freeze", "layer_thaw", "layer_lock",
"layer_unlock", "layer_hide", "layer_show", "layer_isolate"],
"layer_unlock", "layer_hide", "layer_show", "layer_isolate",
"linetype_list", "linetype_load"],
"blocks": ["block_list", "block_insert", "block_explode",
"block_get_attributes", "block_set_attributes",
"block_create_from_entities", "block_find_references"],
@@ -1952,6 +2032,217 @@ async def system_about(ctx: Context = None) -> dict:
}
# ---------------------------------------------------------------------------
# ── SECTION 12: Engineering Tools (deterministic CAD generators) ────────────
# ---------------------------------------------------------------------------
@mcp.tool(
annotations={"title": "Gear: Helical Front View", "destructiveHint": False},
tags={"engineering", "gear"},
)
async def gear_draw_helical_front_view(
module: Annotated[float, Field(gt=0, description="Module (mm). Pitch radius = module*teeth/2.")],
teeth: Annotated[int, Field(ge=6, description="Number of teeth.")],
helix_angle: Annotated[float, Field(ge=0, lt=45, description="Helix angle in degrees.")],
pressure_angle: Annotated[float, Field(default=20.0, gt=0, lt=45,
description="Pressure angle (deg). Standard: 20.")] = 20.0,
hand: Annotated[str, Field(default="RH",
description="Helix hand: 'RH' (right) or 'LH' (left).")] = "RH",
center_x: Annotated[float, Field(default=0.0)] = 0.0,
center_y: Annotated[float, Field(default=0.0)] = 0.0,
bore_diameter: Annotated[float | None, Field(default=None, gt=0,
description="Optional bore diameter (mm). Adds a centered hole.")] = None,
keyway_width: Annotated[float | None, Field(default=None, gt=0,
description="Keyway width (b). Auto from DIN 6885 if bore set and this is None.")] = None,
keyway_depth: Annotated[float | None, Field(default=None, gt=0,
description="Keyway depth into hub (t2).")] = None,
ctx: Context = None,
) -> dict:
"""Deterministic helical gear front view: full involute outline (40 pts/flank),
pitch/base/outer/root circles, helix symbol, optional bore + keyway.
Returns a handle bundle plus 'metadata' for downstream gear_draw_section_aa.
"""
from engineering import draw_helical_gear_front_view
backend = _backend(ctx)
await ctx.info(f"Drawing helical gear: m={module}, z={teeth}, beta={helix_angle} deg, hand={hand}")
return await draw_helical_gear_front_view(
backend, module=module, teeth=teeth, helix_angle=helix_angle,
pressure_angle=pressure_angle, hand=hand, center=(center_x, center_y),
bore_diameter=bore_diameter, keyway_width=keyway_width, keyway_depth=keyway_depth,
)
@mcp.tool(
annotations={"title": "Gear: Spur Front View", "destructiveHint": False},
tags={"engineering", "gear"},
)
async def gear_draw_spur_front_view(
module: Annotated[float, Field(gt=0)],
teeth: Annotated[int, Field(ge=6)],
pressure_angle: Annotated[float, Field(default=20.0, gt=0, lt=45)] = 20.0,
center_x: Annotated[float, Field(default=0.0)] = 0.0,
center_y: Annotated[float, Field(default=0.0)] = 0.0,
bore_diameter: Annotated[float | None, Field(default=None, gt=0)] = None,
keyway_width: Annotated[float | None, Field(default=None, gt=0)] = None,
keyway_depth: Annotated[float | None, Field(default=None, gt=0)] = None,
ctx: Context = None,
) -> dict:
"""Deterministic spur gear front view (no helix symbol)."""
from engineering import draw_spur_gear_front_view
backend = _backend(ctx)
await ctx.info(f"Drawing spur gear: m={module}, z={teeth}")
return await draw_spur_gear_front_view(
backend, module=module, teeth=teeth, pressure_angle=pressure_angle,
center=(center_x, center_y), bore_diameter=bore_diameter,
keyway_width=keyway_width, keyway_depth=keyway_depth,
)
@mcp.tool(
annotations={"title": "Gear: Section A-A View", "destructiveHint": False},
tags={"engineering", "gear"},
)
async def gear_draw_section_aa(
gear_metadata: Annotated[dict, Field(description="The 'metadata' dict returned by gear_draw_helical_front_view or gear_draw_spur_front_view.")],
x_offset: Annotated[float, Field(description="X position to place the section view.")],
face_width: Annotated[float, Field(gt=0, description="Gear face width (mm).")],
ctx: Context = None,
) -> dict:
"""Deterministic side cross-section of a gear created by gear_draw_*_front_view.
Includes top/bottom/left/right boundaries, bore lines, keyway notch, ANSI31 hatch.
"""
from engineering import draw_gear_section_aa
backend = _backend(ctx)
await ctx.info(f"Drawing section A-A at x={x_offset}, face_width={face_width}")
return await draw_gear_section_aa(
backend, gear_metadata=gear_metadata, x_offset=x_offset, face_width=face_width,
)
@mcp.tool(
annotations={"title": "Keyway: Keyed Bore (front view)", "destructiveHint": False},
tags={"engineering", "keyway"},
)
async def keyway_draw_keyed_bore(
center_x: Annotated[float, Field()],
center_y: Annotated[float, Field()],
bore_diameter: Annotated[float, Field(gt=0)],
keyway_width: Annotated[float | None, Field(default=None, gt=0)] = None,
keyway_depth: Annotated[float | None, Field(default=None, gt=0)] = None,
layer: Annotated[str, Field(default="GEOMETRY")] = "GEOMETRY",
ctx: Context = None,
) -> dict:
"""Bore + DIN 6885 keyway in front view. Auto-sizes keyway from bore if width/depth omitted."""
from engineering import draw_keyed_bore
backend = _backend(ctx)
return await draw_keyed_bore(
backend, center=(center_x, center_y), bore_diameter=bore_diameter,
keyway_width=keyway_width, keyway_depth=keyway_depth, layer=layer,
)
@mcp.tool(
annotations={"title": "Keyway: Side Section", "destructiveHint": False},
tags={"engineering", "keyway"},
)
async def keyway_draw_section(
center_x: Annotated[float, Field()],
center_y: Annotated[float, Field()],
bore_diameter: Annotated[float, Field(gt=0)],
face_width: Annotated[float, Field(gt=0)],
keyway_width: Annotated[float | None, Field(default=None, gt=0)] = None,
keyway_depth: Annotated[float | None, Field(default=None, gt=0)] = None,
ctx: Context = None,
) -> dict:
"""Side cross-section view of a keyed bore."""
from engineering import draw_keyway_section
backend = _backend(ctx)
return await draw_keyway_section(
backend, center=(center_x, center_y), bore_diameter=bore_diameter,
face_width=face_width, keyway_width=keyway_width, keyway_depth=keyway_depth,
)
@mcp.tool(
annotations={"title": "TitleBlock: ISO A3", "destructiveHint": False},
tags={"engineering", "titleblock"},
)
async def titleblock_apply_iso_a3(
title: Annotated[str, Field(description="Drawing title (verbatim, no LLM transformation).")],
drawing_no: Annotated[str, Field(description="Drawing number (e.g. 'AM-2026-001').")],
part_no: Annotated[str, Field(default="")] = "",
material: Annotated[str, Field(default="")] = "",
scale: Annotated[str, Field(default="1:1")] = "1:1",
units: Annotated[str, Field(default="mm")] = "mm",
drawn_by: Annotated[str, Field(default="")] = "",
checked_by: Annotated[str, Field(default="")] = "",
date: Annotated[str, Field(default="")] = "",
sheet: Annotated[str, Field(default="1/1")] = "1/1",
revision: Annotated[str, Field(default="A")] = "A",
company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine",
origin_x: Annotated[float, Field(default=0.0)] = 0.0,
origin_y: Annotated[float, Field(default=0.0)] = 0.0,
ctx: Context = None,
) -> dict:
"""ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim."""
from engineering import apply_iso_a3_titleblock, TitleBlockMetadata
backend = _backend(ctx)
metadata = TitleBlockMetadata(
title=title, drawing_no=drawing_no, part_no=part_no, material=material,
scale=scale, units=units, drawn_by=drawn_by, checked_by=checked_by,
date=date, sheet=sheet, revision=revision, company=company,
)
return await apply_iso_a3_titleblock(backend, metadata=metadata, origin=(origin_x, origin_y))
@mcp.tool(
annotations={"title": "Drawing: Finalize (validate + save + screenshot)",
"destructiveHint": True},
tags={"engineering", "drawing", "validation"},
)
async def drawing_finalize(
save_path: Annotated[str | None, Field(default=None,
description="If given, save drawing here before validation. Pass full path including extension.")] = None,
screenshot_path: Annotated[str | None, Field(default=None,
description="If given, write PNG screenshot to this path.")] = None,
expected: Annotated[dict | None, Field(default=None,
description="Optional contract: {'part_type', 'helix_angle', 'must_have_bore', 'must_have_keyway'}")] = None,
ctx: Context = None,
) -> dict:
"""8-step pre-completion validator: real dimensions, no hidden orphans, linetypes loaded,
bore+keyway in both views (if expected), title consistency, saved to disk, screenshot exported,
DWG path returned. Raises ToolError if any 'error' severity finding is present.
"""
from engineering import DrawingValidator
backend = _backend(ctx)
if save_path:
validated = validate_path(save_path, allow_write=True)
await backend.drawing_save_as(str(validated))
if screenshot_path:
validated_shot = validate_path(screenshot_path, allow_write=True)
try:
shot = await backend.view_screenshot()
if shot:
from pathlib import Path as _P
_P(str(validated_shot)).write_bytes(shot)
except Exception as exc:
await ctx.warning(f"Screenshot export failed: {exc}")
result = await DrawingValidator().run(backend, expected=expected or {})
payload = result.to_dict()
info = await backend.drawing_info()
payload["dwg_path"] = getattr(info, "full_path", "")
if not result.ok:
raise ToolError(
f"drawing_finalize: validation failed with {result.summary['error']} error(s). "
f"First: {result.findings[0].code}: {result.findings[0].message}"
)
return payload
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+128
View File
@@ -0,0 +1,128 @@
"""Tests for engineering.gear — pure-math + drawing orchestration."""
from __future__ import annotations
import math
import pytest
from engineering.gear import (
draw_gear_section_aa,
draw_helical_gear_front_view,
generate_full_gear_outline,
generate_involute_flank,
involute_xy,
)
from engineering.layers import (
ensure_engineering_layers,
ensure_standard_linetypes,
)
# ---------------------------------------------------------------------------
# Pure math (sync)
# ---------------------------------------------------------------------------
def test_involute_xy_at_zero_returns_base_point():
x, y = involute_xy(10.0, 0.0)
assert math.isclose(x, 10.0, abs_tol=1e-9)
assert math.isclose(y, 0.0, abs_tol=1e-9)
def test_generate_involute_flank_min_30_points():
flank = generate_involute_flank(base_r=20.0, root_r=18.0, outer_r=25.0, n_points=5)
assert len(flank) >= 30
def test_generate_involute_flank_radius_progression():
base_r, root_r, outer_r = 20.0, 18.0, 25.0
flank = generate_involute_flank(base_r=base_r, root_r=root_r, outer_r=outer_r)
first_r = math.hypot(*flank[0])
last_r = math.hypot(*flank[-1])
assert math.isclose(first_r, root_r, abs_tol=1e-6)
assert math.isclose(last_r, outer_r, rel_tol=1e-3)
def test_generate_full_gear_outline_closed():
pts = generate_full_gear_outline(module=3.0, teeth=24)
assert pts[0] == pts[-1]
def test_generate_full_gear_outline_point_count():
teeth = 24
pts = generate_full_gear_outline(module=3.0, teeth=teeth)
# At minimum: teeth * (40 right + 40 left) = 1920 unique flank points,
# plus tip and root arc samples. Closing duplicate adds 1.
assert len(pts) >= teeth * 40
# ---------------------------------------------------------------------------
# Drawing orchestration (async, ezdxf backend)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_helical_gear_front_view_returns_metadata(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, hand="RH",
center=(0.0, 0.0),
)
expected_keys = {
"outline", "pitch_circle", "base_circle", "outer_circle", "root_circle",
"helix_symbol", "helix_label", "bore", "keyway", "metadata",
}
assert expected_keys.issubset(res.keys())
md = res["metadata"]
for k in (
"module", "teeth", "pressure_angle", "helix_angle", "hand", "center",
"bore_diameter", "keyway_width", "keyway_depth",
"pitch_radius", "outer_radius", "base_radius", "root_radius",
):
assert k in md, f"metadata missing key {k!r}"
@pytest.mark.asyncio
async def test_helical_gear_with_bore_and_keyway_handles_present(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
)
assert res["bore"] is not None
assert res["keyway"] is not None
assert "bore" in res["keyway"]
assert "keyway_polyline" in res["keyway"]
@pytest.mark.asyncio
async def test_helical_gear_metadata_radii_correct(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, pressure_angle=20.0,
)
md = res["metadata"]
assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6)
assert math.isclose(md["outer_radius"], 39.0, abs_tol=1e-6)
assert math.isclose(md["base_radius"], 36.0 * math.cos(math.radians(20.0)), abs_tol=1e-3)
assert math.isclose(md["root_radius"], 32.25, abs_tol=1e-6)
@pytest.mark.asyncio
async def test_section_aa_does_not_draw_helix_lines(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
front = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
)
section = await draw_gear_section_aa(
backend, gear_metadata=front["metadata"],
x_offset=200.0, face_width=40.0,
)
assert "helix" not in section
assert "helix_symbol" not in section
+66
View File
@@ -0,0 +1,66 @@
"""Tests for engineering.keyway — DIN 6885 lookup + drawing helpers."""
from __future__ import annotations
import pytest
from engineering.keyway import (
draw_keyed_bore,
draw_keyway_section,
keyway_dimensions,
)
from engineering.layers import (
ensure_engineering_layers,
ensure_standard_linetypes,
)
def test_din6885_lookup_22_30_range():
dims = keyway_dimensions(25.0)
assert dims["width"] == 8.0
assert dims["height"] == 7.0
assert dims["depth_shaft"] == 4.0
def test_din6885_lookup_38_44_range():
dims = keyway_dimensions(40.0)
assert dims["width"] == 12.0
def test_keyway_dimensions_below_min_raises_or_clamps():
with pytest.raises(ValueError):
keyway_dimensions(3.0)
@pytest.mark.asyncio
async def test_draw_keyed_bore_returns_expected_handles(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_keyed_bore(
backend, center=(0.0, 0.0), bore_diameter=25.0,
keyway_width=8.0, keyway_depth=4.0,
)
for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"):
assert k in res
assert res[k]
@pytest.mark.asyncio
async def test_draw_keyway_section_geometry(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_keyway_section(
backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0,
keyway_width=8.0, keyway_depth=4.0,
)
assert "bore_top" in res
assert "bore_bottom" in res
assert isinstance(res["keyway"], list)
assert len(res["keyway"]) == 4
# Verify the notch top sits above the bore top by reading entities back.
bore_top = await backend.entity_get(res["bore_top"])
bore_top_y = bore_top.properties["start"][1]
notch_top_handle = res["keyway"][2] # notch_top in our return order
notch_top_ent = await backend.entity_get(notch_top_handle)
notch_top_y = notch_top_ent.properties["start"][1]
assert notch_top_y > bore_top_y
+104
View File
@@ -0,0 +1,104 @@
"""Tests for engineering.titleblock — ISO 7200 A3 layout."""
import pytest
from engineering.titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
pytestmark = pytest.mark.asyncio
def _meta(**overrides) -> TitleBlockMetadata:
base = dict(
title="HELICAL GEAR",
drawing_no="AM-2026-001",
part_no="GR-24T-M3",
material="C45",
scale="1:1",
units="mm",
drawn_by="Umutcan",
checked_by="QA",
date="2026-04-28",
sheet="1/1",
revision="A",
company="Anka-Makine",
)
base.update(overrides)
return TitleBlockMetadata(**base)
async def test_titleblock_outer_border_dimensions(backend):
"""Outer border bbox must equal A3 sheet (420 x 297 mm)."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
bbox = result["bbox"]
width = bbox["max"][0] - bbox["min"][0]
height = bbox["max"][1] - bbox["min"][1]
assert width == pytest.approx(420.0)
assert height == pytest.approx(297.0)
async def test_title_text_exact_match(backend):
"""Title text in the drawing must equal metadata.title verbatim."""
result = await apply_iso_a3_titleblock(
backend, metadata=_meta(title="HELICAL GEAR"),
)
title_ent = await backend.entity_get(result["title_text"])
assert title_ent.type == "TEXT"
assert title_ent.properties["text"] == "HELICAL GEAR"
async def test_titleblock_creates_text_for_each_metadata_field(backend):
"""value_texts must contain a handle for every metadata field."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
expected_keys = {
"drawing_no", "revision", "sheet",
"part_no", "material", "scale",
"drawn_by", "checked_by", "date", "company",
}
assert set(result["value_texts"].keys()) == expected_keys
# All handles must resolve to TEXT entities
for key, handle in result["value_texts"].items():
ent = await backend.entity_get(handle)
assert ent.type == "TEXT", f"{key} expected TEXT, got {ent.type}"
async def test_titleblock_origin_offset(backend):
"""Passing origin=(100,50) must shift the bbox by that amount."""
result = await apply_iso_a3_titleblock(
backend, metadata=_meta(), origin=(100.0, 50.0),
)
bbox = result["bbox"]
assert bbox["min"][0] == pytest.approx(100.0)
assert bbox["min"][1] == pytest.approx(50.0)
assert bbox["max"][0] == pytest.approx(520.0)
assert bbox["max"][1] == pytest.approx(347.0)
async def test_titleblock_borders_on_titleblock_layer(backend):
"""Outer + inner borders + grid lines must be on TITLEBLOCK layer."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
outer = await backend.entity_get(result["outer_border"])
inner = await backend.entity_get(result["inner_border"])
assert outer.layer == "TITLEBLOCK"
assert inner.layer == "TITLEBLOCK"
for handle in result["titleblock_lines"]:
ent = await backend.entity_get(handle)
assert ent.layer == "TITLEBLOCK"
async def test_titleblock_text_on_text_layer(backend):
"""Title and all value texts must be on TEXT layer."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
title = await backend.entity_get(result["title_text"])
assert title.layer == "TEXT"
for handle in result["value_texts"].values():
ent = await backend.entity_get(handle)
assert ent.layer == "TEXT"
async def test_titleblock_metadata_in_result(backend):
"""Returned metadata dict must round-trip the input metadata."""
md = _meta(title="SPUR GEAR", drawing_no="AM-2026-002")
result = await apply_iso_a3_titleblock(backend, metadata=md)
assert result["metadata"]["title"] == "SPUR GEAR"
assert result["metadata"]["drawing_no"] == "AM-2026-002"
assert result["metadata"]["company"] == "Anka-Makine"
+138
View File
@@ -0,0 +1,138 @@
"""Tests for engineering.validator — 8-step pre-completion validator."""
import os
import pytest
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from engineering.validator import DrawingValidator
pytestmark = pytest.mark.asyncio
def _codes(result) -> set[str]:
return {f.code for f in result.findings}
async def _ensure_hidden_linetype(backend) -> None:
"""ezdxf doesn't ship a HIDDEN pattern; add a synthetic one so the validator passes."""
doc = backend._require_doc()
if "HIDDEN" not in doc.linetypes:
doc.linetypes.add(
"HIDDEN",
pattern=[6.35, 5.0, -1.27],
description="Hidden _ _ _ _ _ _",
)
async def _setup_clean(backend, tmp_path):
"""Bootstrap layers/linetypes, draw a bore + a real DIMENSION, save to disk."""
await ensure_standard_linetypes(backend)
await _ensure_hidden_linetype(backend)
await ensure_engineering_layers(backend)
# Bore on GEOMETRY layer
await backend.entity_create_circle(0, 0, 12.5, layer="GEOMETRY")
# Real dimension entity
await backend.dimension_linear(0, 0, 25, 0, dim_x=12.5, dim_y=20, layer="DIM")
save_path = str(tmp_path / "clean.dxf")
await backend.drawing_save_as(save_path)
return save_path
async def test_validator_passes_clean_drawing(backend, tmp_path):
await _setup_clean(backend, tmp_path)
result = await DrawingValidator().run(backend)
# Should have zero errors; warnings/info okay (e.g. screenshot info)
assert result.ok is True, f"unexpected findings: {[f.code for f in result.findings]}"
assert result.summary["error"] == 0
async def test_validator_catches_unsaved_drawing(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
# Do not save
result = await DrawingValidator().run(backend)
assert "not_saved" in _codes(result)
assert result.ok is False
async def test_validator_catches_missing_file(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
save_path = str(tmp_path / "vanish.dxf")
await backend.drawing_save_as(save_path)
# Delete the file out from under the backend
os.remove(save_path)
result = await DrawingValidator().run(backend)
assert "file_missing" in _codes(result)
assert result.ok is False
async def test_validator_catches_helical_spur_title(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 20})
codes = _codes(result)
assert "title_helical_spur" in codes or "title_says_spur_for_helical" in codes
assert result.ok is False
async def test_validator_catches_fake_dimension_text(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_text(
text="Ø78", x=10, y=10, height=3.5, layer="DIM",
)
await backend.drawing_save_as(str(tmp_path / "fake.dxf"))
result = await DrawingValidator().run(backend)
assert "fake_dimension_text" in _codes(result)
async def test_validator_catches_hidden_orphans(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
info = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
await backend.entity_set_properties(info.handle, visible=False)
await backend.drawing_save_as(str(tmp_path / "hidden.dxf"))
result = await DrawingValidator().run(backend)
assert "hidden_orphans" in _codes(result)
assert result.ok is False
async def test_validator_catches_missing_linetypes(backend, tmp_path):
"""Fresh drawing without ensure_standard_linetypes lacks CENTER/HIDDEN/PHANTOM."""
# Skip the bootstrap step entirely
await backend.entity_create_line(0, 0, 100, 0)
await backend.drawing_save_as(str(tmp_path / "bare.dxf"))
result = await DrawingValidator().run(backend)
codes = _codes(result)
assert "linetypes_missing" in codes
# And the missing detail names them
missing_finding = next(f for f in result.findings if f.code == "linetypes_missing")
missing_set = set(missing_finding.detail.get("missing", []))
assert {"center", "hidden", "phantom"}.issubset(missing_set)
async def test_validator_bore_missing_when_expected(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
# No circle drawn at all
await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
result = await DrawingValidator().run(
backend, expected={"must_have_bore": True},
)
assert "bore_missing" in _codes(result)
async def test_validation_result_to_dict_serializes(backend):
result = await DrawingValidator().run(backend)
d = result.to_dict()
assert "ok" in d
assert "summary" in d
assert "findings" in d
assert isinstance(d["findings"], list)