Files
autocad-mcp-pro/engineering/validator.py
T
Umutcan Edizsalan 0c7f64ceb6 fix(correctness-bundle): I16 correctness fixes + dead code removal + ruff clean
N1: COM hatch non-associative (AddHatch associativity=False)
N2: ezdxf refuses DWG saves with clear error; server.py derives fmt from extension
R19: dimension_aligned dist→distance; dimension_angular correct add_angular_dim_2l args (base/line1/line2/location)
R9: polar array 360° no-duplicate (divisor=n when fill_angle%360<ε)
R13: entity_offset side_x/side_y honored in ezdxf via cross-product (LINE) and distance-to-center (CIRCLE)
R18: drawing_new bootstrap failure surfaces status="degraded" instead of swallowing silently
Dead code: engineering/section.py, gear.generate_tooth_profile, CommandResult, _ACI_NAMES, set_layer_active, ensure_linetypes, ComBackend._batch_mode instance attr, engineering/__init__ build-seam comment
Ruff: 38 auto-fixed + 5 manual → codebase is now ruff-clean; CI lint is now blocking

184 tests pass.
2026-06-18 11:34:30 +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,
)