Files
autocad-mcp-pro/engineering/validator.py
T
U-C4N 88a9005a6a 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.
2026-04-28 12:24:08 +03:00

241 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)