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.
241 lines
8.1 KiB
Python
241 lines
8.1 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,
|
||
)
|
||
|
||
|
||
@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,
|
||
)
|