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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user