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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user