Audit-driven fixes across the dual backend, the premium workflow, and the engineering layer. 318 tests, ruff-clean. Quality gate (the moat): - drawing_finalize now runs the premium ISO-128 critique in addition to the 8-step validator; leftover construction, non-ISO lineweights, untrimmed corners, duplicates and dim overlap block the gate (strict_critique fails on warnings too). - dim_overlap critique no longer a no-op (dim reference point + bbox fallback); dim/construction layers resolve from the active layer set (iso13567 too). Stability / correctness: - view_screenshot & drawing_export_pdf render headless via Agg (no more worker-thread SIGSEGV). - gear tooth profile no longer self-overlaps at high tooth counts; section view no longer duplicates bore lines; validator keyway heuristic fixed. - ezdxf aligned/angular dim API, full-circle polar array duplicate already covered by Sprint 1; this bundle adds save-format-from-extension, entity_offset side selection, ARC length, properties parity (bounding_box / rotation / char_height), mtext rotation. COM robustness (mocked-COM tested): - CoUninitialize on teardown; transaction commit/rollback try/finally + UNDO via the CMDACTIVE guard; system_run_lisp guard + USERS1 value capture; set_variable type coercion; HWND via Application.HWND; BlockInfo.description. Security: - HTTP remote-bind guard fires on every launch path (incl. `fastmcp run`); StaticTokenVerifier wired into FastMCP; AutoLISP allowlist bypass-vector tests. Hygiene: linetype-on-demand in _apply_attrs, honest view_zoom returns, snap-on-arc sweep handling, point_tangent acos clamp, dynamic tool_groups / tool count, word-boundary SPUR/HELICAL, undo-stack locking. Deferred: R22 (undo/transaction stack isolation) lands with the I1 refiner (1.2), which depends on it.
265 lines
9.3 KiB
Python
265 lines
9.3 KiB
Python
"""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,
|
||
)
|
||
|
||
# R27 — match SPUR / HELICAL as whole words (not substrings of e.g.
|
||
# "SPURIOUS") when checking title/helix consistency.
|
||
_SPUR_RE = re.compile(r"\bSPUR\b", re.IGNORECASE)
|
||
_HELICAL_RE = re.compile(r"\bHELICAL\b", re.IGNORECASE)
|
||
|
||
# Layers that hold title-block text (scoped scan target for step 5).
|
||
_TITLE_LAYERS = {"TITLE", "TITLEBLOCK"}
|
||
|
||
|
||
@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",
|
||
))
|
||
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
|
||
# on GEOMETRY — requiring `closed` here made this a permanent
|
||
# false-negative. Match a small (3-6 point) polyline, open or closed.
|
||
keyway_polys = [
|
||
e for e in ents
|
||
if e.type in ("LWPOLYLINE", "POLYLINE")
|
||
and e.layer == "GEOMETRY"
|
||
and 3 <= len(e.properties.get("points", [])) <= 6
|
||
]
|
||
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
|
||
findings.append(ValidationFinding(
|
||
"warning",
|
||
"keyway_section_unverified",
|
||
"Could not confirm keyway via heuristic "
|
||
"(small 3-6 point polyline on GEOMETRY)",
|
||
))
|
||
|
||
# 5 — title consistency
|
||
helix = expected.get("helix_angle")
|
||
if helix:
|
||
# R27 — scope the scan to title-block text where possible (TITLE /
|
||
# TITLEBLOCK layer); fall back to all TEXT-layer text if no title
|
||
# layer carries any text.
|
||
title_block_texts = [
|
||
e.properties.get("text", "") or ""
|
||
for e in ents
|
||
if e.type in ("TEXT", "MTEXT") and e.layer in _TITLE_LAYERS
|
||
]
|
||
if title_block_texts:
|
||
title_texts = title_block_texts
|
||
else:
|
||
title_texts = [
|
||
e.properties.get("text", "") or ""
|
||
for e in ents
|
||
if e.type in ("TEXT", "MTEXT") and e.layer == "TEXT"
|
||
]
|
||
joined = " | ".join(title_texts)
|
||
# R27 — whole-word matching so "SPURIOUS" no longer trips SPUR.
|
||
has_spur = bool(_SPUR_RE.search(joined))
|
||
has_helical = bool(_HELICAL_RE.search(joined))
|
||
if has_spur and has_helical:
|
||
findings.append(ValidationFinding(
|
||
"error",
|
||
"title_helical_spur",
|
||
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
|
||
"'HELICAL GEAR' or 'SPUR GEAR'",
|
||
))
|
||
elif has_spur:
|
||
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,
|
||
)
|