From 88a9005a6aacd019b8e982f48e3fd6df701dc69d Mon Sep 17 00:00:00 2001 From: U-C4N Date: Tue, 28 Apr 2026 12:24:08 +0300 Subject: [PATCH] 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. --- CLAUDE.md | 7 +- backends/base.py | 36 +++ backends/com_backend.py | 136 ++++++++- backends/ezdxf_backend.py | 95 ++++++ engineering/__init__.py | 65 ++++ engineering/gear.py | 431 +++++++++++++++++++++++++++ engineering/keyway.py | 173 +++++++++++ engineering/layers.py | 74 +++++ engineering/section.py | 82 +++++ engineering/titleblock.py | 254 ++++++++++++++++ engineering/validator.py | 240 +++++++++++++++ server.py | 301 ++++++++++++++++++- tests/test_engineering_gear.py | 128 ++++++++ tests/test_engineering_keyway.py | 66 ++++ tests/test_engineering_titleblock.py | 104 +++++++ tests/test_engineering_validator.py | 138 +++++++++ 16 files changed, 2320 insertions(+), 10 deletions(-) create mode 100644 engineering/__init__.py create mode 100644 engineering/gear.py create mode 100644 engineering/keyway.py create mode 100644 engineering/layers.py create mode 100644 engineering/section.py create mode 100644 engineering/titleblock.py create mode 100644 engineering/validator.py create mode 100644 tests/test_engineering_gear.py create mode 100644 tests/test_engineering_keyway.py create mode 100644 tests/test_engineering_titleblock.py create mode 100644 tests/test_engineering_validator.py diff --git a/CLAUDE.md b/CLAUDE.md index 9d17526..33f4e48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/backends/base.py b/backends/base.py index dc55f8e..36c0de2 100644 --- a/backends/base.py +++ b/backends/base.py @@ -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: '. + """ + 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 diff --git a/backends/com_backend.py b/backends/com_backend.py index 4fa2447..9ab3ff4 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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} diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 0ae7cfb..043db5c 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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]: diff --git a/engineering/__init__.py b/engineering/__init__.py new file mode 100644 index 0000000..8bc0fde --- /dev/null +++ b/engineering/__init__.py @@ -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", +] diff --git a/engineering/gear.py b/engineering/gear.py new file mode 100644 index 0000000..84c85e0 --- /dev/null +++ b/engineering/gear.py @@ -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, + } diff --git a/engineering/keyway.py b/engineering/keyway.py new file mode 100644 index 0000000..0d116ff --- /dev/null +++ b/engineering/keyway.py @@ -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, + ], + } diff --git a/engineering/layers.py b/engineering/layers.py new file mode 100644 index 0000000..465300a --- /dev/null +++ b/engineering/layers.py @@ -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 diff --git a/engineering/section.py b/engineering/section.py new file mode 100644 index 0000000..794793a --- /dev/null +++ b/engineering/section.py @@ -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 diff --git a/engineering/titleblock.py b/engineering/titleblock.py new file mode 100644 index 0000000..b0d87d1 --- /dev/null +++ b/engineering/titleblock.py @@ -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], + }, + } diff --git a/engineering/validator.py b/engineering/validator.py new file mode 100644 index 0000000..4137d88 --- /dev/null +++ b/engineering/validator.py @@ -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, + ) diff --git a/server.py b/server.py index 5bf24e4..c191637 100644 --- a/server.py +++ b/server.py @@ -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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_engineering_gear.py b/tests/test_engineering_gear.py new file mode 100644 index 0000000..f0b9a3d --- /dev/null +++ b/tests/test_engineering_gear.py @@ -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 diff --git a/tests/test_engineering_keyway.py b/tests/test_engineering_keyway.py new file mode 100644 index 0000000..02f6eca --- /dev/null +++ b/tests/test_engineering_keyway.py @@ -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 diff --git a/tests/test_engineering_titleblock.py b/tests/test_engineering_titleblock.py new file mode 100644 index 0000000..e8a2f58 --- /dev/null +++ b/tests/test_engineering_titleblock.py @@ -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" diff --git a/tests/test_engineering_validator.py b/tests/test_engineering_validator.py new file mode 100644 index 0000000..05a6cfe --- /dev/null +++ b/tests/test_engineering_validator.py @@ -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)