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

Introduces an engineering/ package that lets MCP clients produce production-
quality technical drawings without LLM-improvised geometry. Adds 7 new MCP
tools, an 8-step pre-completion validator, and an automatic linetype + layer
bootstrap on drawing_new so CENTER/HIDDEN/PHANTOM and standard mechanical
layers (GEOMETRY, DIM, HATCH, TEXT, TITLEBLOCK, ...) are always available.

- engineering/gear.py: pure-Python involute math, 40+ points/flank, helical
  + spur front views, deterministic section A-A (no helix lines in section).
- engineering/keyway.py: DIN 6885 keyway table + parametric keyed bore /
  side-section drawers.
- engineering/titleblock.py: ISO 7200 A3 (420x297 mm) title block; title
  text is written verbatim to the title cell.
- engineering/validator.py: DrawingValidator with 8 checks (real DIMENSION
  entities, no hidden orphans, linetypes loaded, bore + keyway in both
  views when expected, title consistency e.g. forbid 'HELICAL SPUR GEAR'
  when helix_angle truthy, saved-to-disk + file-on-disk, screenshot
  exported, dwg path returned).
- backends/ezdxf_backend.py: AutoCAD-only linetype fallback (HIDDEN,
  BORDER families) so ezdxf doc gets correct patterns when linetype_load
  is called for names ezdxf.tools.standards does not ship.
- backends/base.py: concrete helpers set_layer_active / ensure_linetypes.
- server.py: Section 12 with 7 engineering tools, drawing_new(bootstrap=
  True), and an ENGINEERING WORKFLOW block in the MCP instructions string.
- 30 new tests covering gear math, DIN6885 lookup, ISO A3 layout, title
  verbatim, and every validator check. Tool count 89 -> 96.
This commit is contained in:
U-C4N
2026-04-28 12:24:08 +03:00
parent 59f3bc5fd5
commit 88a9005a6a
16 changed files with 2320 additions and 10 deletions
+36
View File
@@ -358,6 +358,15 @@ class AutoCADBackend(ABC):
@abstractmethod
async def layer_show(self, name: str) -> dict: ...
# ── linetype management ──────────────────────────────────────────────────
@abstractmethod
async def linetype_list(self) -> list[str]: ...
@abstractmethod
async def linetype_load(
self, name: str, file: str | None = None,
) -> dict: ...
# ── block operations ─────────────────────────────────────────────────────
@abstractmethod
async def block_list(self) -> list[BlockInfo]: ...
@@ -449,6 +458,33 @@ class AutoCADBackend(ABC):
@abstractmethod
async def system_run_lisp(self, expression: str) -> dict: ...
# ── concrete helpers (use existing primitives) ──────────────────────────
async def set_layer_active(self, name: str) -> None:
"""Convenience: alias for layer_set_current. Concrete; backends need not override."""
await self.layer_set_current(name)
async def ensure_linetypes(
self, names: list[str], file: str | None = None,
) -> dict[str, str]:
"""Idempotently load each linetype in `names`. Returns {name: status}.
Status is one of: 'already_loaded', 'loaded', 'failed: <msg>'.
"""
try:
current = {ln.lower() for ln in await self.linetype_list()}
except Exception:
current = set()
results: dict[str, str] = {}
for n in names:
if n.lower() in current:
results[n] = "already_loaded"
continue
try:
await self.linetype_load(n, file=file)
results[n] = "loaded"
except Exception as exc:
results[n] = f"failed: {exc}"
return results
# ---------------------------------------------------------------------------
# Helpers
+133 -3
View File
@@ -127,6 +127,44 @@ def _msp():
return _acad_doc().ModelSpace
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
def _ensure_linetype_loaded(name: str) -> None:
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
Called by attribute setters so users can write `linetype="CENTER"` without
having to remember to load it first. Must run on the COM thread.
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower()
for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return
app = _acad_app()
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc)
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
try:
old_filedia = int(app.GetVariable("FILEDIA"))
except Exception as exc:
log.debug("FILEDIA read failed, assuming 1: %s", exc)
old_filedia = 1
try:
app.SetVariable("FILEDIA", 0)
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
except Exception as exc:
log.debug("FILEDIA restore failed: %s", exc)
def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: str | None):
"""Apply common entity attributes after creation."""
if layer is not None:
@@ -134,6 +172,7 @@ def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype:
if color is not None:
entity.Color = int(color)
if linetype is not None:
_ensure_linetype_loaded(linetype)
entity.Linetype = linetype
@@ -374,11 +413,23 @@ class ComBackend(AutoCADBackend):
return await asyncio.wait_for(future, timeout=timeout)
return await future
except asyncio.TimeoutError as e:
log.error("COM call timed out after %.1fs", timeout)
log.error("COM call timed out after %.1fs; rebuilding executor", timeout)
# The worker thread is still blocked inside SendCommand and cannot be
# cancelled. Abandon it (shutdown(wait=False)) and start a fresh
# single-thread executor so the next call doesn't queue behind the
# stuck one and time out too. Drop the cached COM app for the same
# reason — the new thread must re-Dispatch in its own apartment.
stuck = self._executor
self._executor = ThreadPoolExecutor(max_workers=1, initializer=_com_init)
if stuck is not None:
stuck.shutdown(wait=False)
_COM_STATE.pop("app", None)
self._connected = False
raise RuntimeError(
f"AutoCAD did not respond within {timeout:.0f}s. The application "
"may be showing a modal dialog or hung. Increase COM_CALL_TIMEOUT "
"if a long operation is expected."
"may be showing a modal dialog or have an active command prompt "
"(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long "
"operation is expected."
) from e
except pywintypes.com_error as e:
hr = e.args[0] if e.args else 0
@@ -904,6 +955,7 @@ class ComBackend(AutoCADBackend):
if color is not None:
ent.Color = int(color)
if linetype is not None:
_ensure_linetype_loaded(linetype)
ent.Linetype = linetype
if lineweight is not None:
ent.LineWeight = int(lineweight)
@@ -966,6 +1018,7 @@ class ComBackend(AutoCADBackend):
doc = _acad_doc()
lyr = doc.Layers.Add(name)
lyr.Color = int(color)
_ensure_linetype_loaded(linetype)
try:
lyr.Linetype = linetype
except Exception as exc:
@@ -999,6 +1052,7 @@ class ComBackend(AutoCADBackend):
if color is not None:
lyr.Color = int(color)
if linetype is not None:
_ensure_linetype_loaded(linetype)
lyr.Linetype = linetype
if lineweight is not None:
lyr.LineWeight = int(lineweight)
@@ -1053,6 +1107,67 @@ class ComBackend(AutoCADBackend):
return {"ok": True, "layer": name, "visible": True}
return await self._run(_sync)
# ── linetype management ───────────────────────────────────────────────────
async def linetype_list(self) -> list[str]:
def _sync():
doc = _acad_doc()
return [doc.Linetypes.Item(i).Name for i in range(doc.Linetypes.Count)]
return await self._run(_sync)
async def linetype_load(self, name, file=None) -> dict:
# Loads a single linetype from a .lin file. Two things would otherwise
# bite the user: (1) AutoCAD pops a "Select Linetype File" dialog when
# FILEDIA=1 and the file path can't be auto-resolved — that modal
# dialog deadlocks SendCommand; (2) ISO/metric drawings need acadiso.lin,
# imperial drawings need acad.lin. We pick automatically from MEASUREMENT
# if the caller didn't specify a file.
def _sync():
app = _acad_app()
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower()
for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
if file is None:
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc)
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
else:
lin_file = file
try:
old_filedia = int(app.GetVariable("FILEDIA"))
except Exception as exc:
log.debug("FILEDIA read failed, assuming 1: %s", exc)
old_filedia = 1
try:
app.SetVariable("FILEDIA", 0)
# Double newline: first ends the file name, second exits
# -LINETYPE's [?/Create/Load/Set] option menu.
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
except Exception as exc:
log.debug("FILEDIA restore failed: %s", exc)
after = {doc.Linetypes.Item(i).Name.lower()
for i in range(doc.Linetypes.Count)}
if name.lower() not in after:
raise RuntimeError(
f"Failed to load linetype '{name}' from '{lin_file}'. "
"Check the linetype name spelling and that the .lin file is "
"on AutoCAD's support path."
)
return {"ok": True, "name": name, "file": lin_file}
return await self._run(_sync)
# ── block operations ──────────────────────────────────────────────────────
async def block_list(self) -> list[BlockInfo]:
@@ -1347,8 +1462,23 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def system_run_command(self, command) -> dict:
# Commands ending in option menus (e.g. -LINETYPE returning to its
# [?/Create/Load/Set] prompt) need a trailing blank line or _X to exit;
# otherwise SendCommand returns but AutoCAD stays at a prompt and the
# next COM call deadlocks. Example: "_-LINETYPE _LOAD CENTER acad.lin\n\n"
def _sync():
app = _acad_app()
doc = _acad_doc()
try:
cmd_active = int(app.GetVariable("CMDACTIVE"))
except Exception as exc:
log.debug("CMDACTIVE read failed, proceeding anyway: %s", exc)
cmd_active = 0
if cmd_active:
raise RuntimeError(
"AutoCAD has an active command or prompt (CMDACTIVE="
f"{cmd_active}). Press ESC in AutoCAD to cancel, then retry."
)
cmd = command if command.endswith("\n") else command + "\n"
doc.SendCommand(cmd)
return {"ok": True, "command": command}
+95
View File
@@ -55,6 +55,59 @@ _ACI_NAMES = {
5: "Blue", 6: "Magenta", 7: "White", 256: "ByLayer", 0: "ByBlock",
}
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
# AutoCAD-shipped linetypes that ezdxf.tools.standards does not include.
# Pattern format: [total_length, dash_1, gap_1, dash_2, gap_2, ...] (gaps negative).
# Values match acadiso.lin so drawings render the same in AutoCAD viewers.
_AUTOCAD_FALLBACK_LINETYPES: dict[str, tuple[str, list[float]]] = {
"HIDDEN": ("Hidden __ __ __ __ __ __ __ __ __", [7.5, 5.0, -2.5]),
"HIDDEN2": ("Hidden (.5x) _ _ _ _ _ _ _ _ _", [3.75, 2.5, -1.25]),
"HIDDENX2": ("Hidden (2x) ____ ____ ____ ____", [15.0, 10.0, -5.0]),
"BORDER": ("Border __ __ . __ __ . __ __ . __", [22.5, 7.5, -2.5, 7.5, -2.5, 0.0, -2.5]),
"BORDER2": ("Border (.5x) __.__.__.__.__.__", [11.25, 3.75, -1.25, 3.75, -1.25, 0.0, -1.25]),
"BORDERX2": ("Border (2x) ____ __ ____ __", [45.0, 15.0, -5.0, 15.0, -5.0, 0.0, -5.0]),
}
def _add_linetype_from_fallback(doc, name: str) -> bool:
"""Add `name` from _AUTOCAD_FALLBACK_LINETYPES if known. Returns True on success."""
fallback = _AUTOCAD_FALLBACK_LINETYPES.get(name.upper())
if fallback is None:
return False
description, pattern = fallback
doc.linetypes.add(name.upper(), pattern=pattern, description=description)
return True
def _ensure_linetype_loaded(doc, name: str) -> None:
"""Add `name` to doc.linetypes if not already present.
Lookup order: ezdxf.tools.standards → AutoCAD fallback table → warn.
Lets callers write `linetype="CENTER"` (or HIDDEN, BORDER) without
separately loading it.
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
if name in doc.linetypes:
return
try:
from ezdxf.tools import standards as ezdxf_standards
for lt_name, description, pattern in ezdxf_standards.linetypes():
if lt_name.lower() == name.lower():
doc.linetypes.add(lt_name, pattern=pattern, description=description)
return
except ImportError:
pass
if _add_linetype_from_fallback(doc, name):
return
log.warning(
"ezdxf backend: linetype %r is not in ezdxf's standard set or AutoCAD "
"fallback table; name will be assigned but renders as Continuous until "
"properly defined.",
name,
)
def _entity_info_dxf(ent) -> EntityInfo:
"""Convert an ezdxf entity to EntityInfo."""
@@ -924,6 +977,7 @@ class EzdxfBackend(AutoCADBackend):
if color is not None:
ent.dxf.color = int(color)
if linetype is not None:
_ensure_linetype_loaded(self._require_doc(), linetype)
ent.dxf.linetype = linetype
if lineweight is not None:
ent.dxf.lineweight = int(lineweight)
@@ -972,6 +1026,7 @@ class EzdxfBackend(AutoCADBackend):
) -> LayerInfo:
def _sync():
doc = self._require_doc()
_ensure_linetype_loaded(doc, linetype)
lyr = doc.layers.add(
name,
color=int(color),
@@ -1016,6 +1071,7 @@ class EzdxfBackend(AutoCADBackend):
if color is not None:
lyr.dxf.color = int(color)
if linetype is not None:
_ensure_linetype_loaded(doc, linetype)
lyr.dxf.linetype = linetype
if lineweight is not None:
lyr.dxf.lineweight = int(lineweight)
@@ -1083,6 +1139,45 @@ class EzdxfBackend(AutoCADBackend):
return {"ok": True, "layer": name, "visible": True}
return await self._async(_sync)
# ── linetype management ───────────────────────────────────────────────────
async def linetype_list(self) -> list[str]:
def _sync():
doc = self._require_doc()
return [lt.dxf.name for lt in doc.linetypes]
return await self._async(_sync)
async def linetype_load(self, name, file=None) -> dict:
# Loads a single linetype. Lookup order:
# 1. ezdxf.tools.standards (ISO: CENTER, DASHED, DASHDOT, PHANTOM, ...)
# 2. AutoCAD fallback table (HIDDEN, BORDER and *2/X2 variants)
# The `file` parameter is accepted for API parity with COM but ignored
# — ezdxf does not ship a .lin parser.
from ezdxf.tools import standards as ezdxf_standards
del file
def _sync():
doc = self._require_doc()
existing = {lt.dxf.name.lower() for lt in doc.linetypes}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
for lt_name, description, pattern in ezdxf_standards.linetypes():
if lt_name.lower() == name.lower():
doc.linetypes.add(lt_name, pattern=pattern, description=description)
self._mark_dirty()
return {"ok": True, "name": lt_name,
"source": "ezdxf.tools.standards"}
if _add_linetype_from_fallback(doc, name):
self._mark_dirty()
return {"ok": True, "name": name.upper(),
"source": "autocad_fallback"}
raise RuntimeError(
f"Linetype '{name}' is not in ezdxf's standard set or the "
"AutoCAD fallback table. Available standards: CENTER*, DASHED*, "
"DASHDOT*, PHANTOM*, DOT*, DIVIDE*. Available fallbacks: "
"HIDDEN*, BORDER*. Custom .lin files require the COM backend."
)
return await self._async(_sync)
# ── block operations ──────────────────────────────────────────────────────
async def block_list(self) -> list[BlockInfo]: