fix: correctness, cross-backend parity & enforced quality gate (audit Sprints 2-4)

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.
This commit is contained in:
Umutcan Edizsalan
2026-06-19 08:36:58 +03:00
parent 00e14423df
commit 32a68be919
18 changed files with 2016 additions and 166 deletions
+44 -13
View File
@@ -94,22 +94,29 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
"""CONSTRUCTION layer must be empty before drawing_finalize."""
"""No construction/scratch geometry may remain before drawing_finalize.
Matches any layer whose name contains 'CONST' (CONSTRUCTION, M-CONST-E-N, …)
so the check is independent of the active layer set — a leftover iso13567
scaffold on M-CONST-E-N is caught just like a mech one on CONSTRUCTION.
"""
try:
ents = await backend.entity_list(layer_filter="CONSTRUCTION", limit=5000)
ents = await backend.entity_list(limit=5000)
except Exception:
return []
if not ents:
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
if not leftover:
return []
return [Issue(
severity="error",
focus="construction_left",
message=(
f"{len(ents)} entity/entities still on CONSTRUCTION layer. "
f"{len(leftover)} entity/entities still on a construction layer. "
"Call construction_clear() before drawing_finalize."
),
handles=[e.handle for e in ents[:50]],
detail={"count": len(ents)},
handles=[e.handle for e in leftover[:50]],
detail={"count": len(leftover),
"layers": sorted({e.layer for e in leftover if e.layer})},
)]
@@ -185,22 +192,46 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
return issues
def _dim_ref_point(e) -> tuple[float, float] | None:
"""Best-effort reference point for a dimension entity, tolerant of the
per-backend property shape: explicit text/def points first, then the
bounding-box centre (COM seeds `bounding_box` for every entity)."""
for key in ("text_position", "defpoint", "insertion_point", "insertion"):
ip = e.properties.get(key)
if ip and len(ip) >= 2:
return (float(ip[0]), float(ip[1]))
bb = e.properties.get("bounding_box")
try:
if isinstance(bb, dict):
mn, mx = bb.get("min"), bb.get("max")
return ((mn[0] + mx[0]) / 2.0, (mn[1] + mx[1]) / 2.0)
if bb and len(bb) == 2 and hasattr(bb[0], "__len__"):
return ((bb[0][0] + bb[1][0]) / 2.0, (bb[0][1] + bb[1][1]) / 2.0)
if bb and len(bb) >= 4:
return ((bb[0] + bb[2]) / 2.0, (bb[1] + bb[3]) / 2.0)
except (TypeError, IndexError, KeyError):
return None
return None
async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
"""Two dimensions whose insertion points are within `tol` likely overlap."""
"""Two dimensions whose reference points are within `tol` likely overlap.
Filters by entity TYPE (not layer) so dims land-anywhere — including the
iso13567 M-DIMEN-T-N layer — are still considered.
"""
tol = 5.0 # mm
try:
ents = await backend.entity_list(layer_filter="DIM", limit=5000)
ents = await backend.entity_list(limit=5000)
except Exception:
return []
dim_points: list[tuple[str, tuple[float, float]]] = []
for e in ents:
if e.type not in _DIM_TYPES and "DIM" not in e.type:
continue
ip = (e.properties.get("text_position")
or e.properties.get("defpoint")
or e.properties.get("insertion_point"))
if ip and len(ip) >= 2:
dim_points.append((e.handle, (float(ip[0]), float(ip[1]))))
ip = _dim_ref_point(e)
if ip is not None:
dim_points.append((e.handle, ip))
issues: list[Issue] = []
seen: set[frozenset[str]] = set()
for i, (h1, p1) in enumerate(dim_points):
+39 -15
View File
@@ -33,19 +33,36 @@ def generate_involute_flank(
outer_r: float,
n_points: int = 40,
) -> list[tuple[float, float]]:
"""One tooth flank from root through base circle out to addendum tip."""
"""One tooth flank from the root circle out to the addendum tip.
The first returned point ALWAYS sits exactly on the root circle (radius
`root_r`) so the caller's root arc connects seamlessly:
* root_r < base_r — the involute is undefined below the base circle, so the
flank runs radially from the root circle up to (base_r, 0) and then follows
the involute outward (a straight radial fillet approximation).
* root_r >= base_r — (high tooth counts, z>=~42 @20deg) the active profile
starts where the involute crosses the root circle; sampling begins at that
parameter so the flank never dips inside the root circle (which previously
produced a self-overlapping outline).
"""
if n_points < 30:
n_points = 30
points: list[tuple[float, float]] = []
if root_r < base_r:
points.append((root_r, 0.0))
ratio = outer_r / base_r if base_r > 0 else 1.0
if ratio <= 1.0:
points.append((base_r, 0.0))
return points
# Degenerate (outer circle at/below the base circle) — unreachable for
# real gears (outer_r = pitch_r + module > base_r). Anchor at the root.
return [(max(base_r, root_r), 0.0)]
t_max = math.sqrt(ratio * ratio - 1.0)
if root_r > base_r:
# Involute radius r(t) = base_r * sqrt(1 + t^2); solve r(t)=root_r.
t_min = math.sqrt((root_r / base_r) ** 2 - 1.0)
else:
t_min = 0.0
points.append((root_r, 0.0)) # radial fillet up to the base circle
for i in range(n_points):
t = t_max * i / (n_points - 1)
t = t_min + (t_max - t_min) * i / (n_points - 1)
points.append(involute_xy(base_r, t))
return points
@@ -208,7 +225,10 @@ async def draw_helical_gear_front_view(
outer_circ = await backend.entity_create_circle(cx, cy, outer_r, layer="GEOMETRY")
pitch_circ = await backend.entity_create_circle(cx, cy, pitch_r, layer="CENTER")
base_circ = await backend.entity_create_circle(cx, cy, base_r, layer="CONSTRUCTION")
# Base circle is a persistent involute reference on PHANTOM — NOT CONSTRUCTION,
# which the mandated construction_clear() before finalize would delete, leaving
# the returned base_circle handle dangling.
base_circ = await backend.entity_create_circle(cx, cy, base_r, layer="PHANTOM")
root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY")
pts = generate_full_gear_outline(
@@ -350,15 +370,10 @@ async def draw_gear_section_aa(
keyway_dict: dict | None = None
if bore_d is not None:
bore_r = float(bore_d) / 2.0
bore_top = await backend.entity_create_line(
x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY",
)
bore_bot = await backend.entity_create_line(
x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY",
)
bore_handles = [bore_top.handle, bore_bot.handle]
if keyway_w is not None or keyway_h is not None:
# draw_keyway_section draws BOTH bore lines (same span/y) AND the
# notch — drawing the bore lines here too would duplicate them and
# trip the duplicate_entities critique. Let it own the bore lines.
keyway_dict = await draw_keyway_section(
backend,
center=(section_cx, cy),
@@ -367,6 +382,15 @@ async def draw_gear_section_aa(
keyway_width=keyway_w,
keyway_depth=keyway_h,
)
bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]]
else:
bore_top = await backend.entity_create_line(
x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY",
)
bore_bot = await backend.entity_create_line(
x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY",
)
bore_handles = [bore_top.handle, bore_bot.handle]
return {
"top": top.handle,
+17
View File
@@ -68,6 +68,23 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
"iso13567": ISO13567_LAYERS,
}
# Role → layer name, per layer set. Lets dimension_auto / construction ops
# resolve the correct layer for the active standard instead of hardcoding
# "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses
# M-DIMEN-T-N / M-CONST-E-N).
LAYER_ROLES: dict[str, dict[str, str]] = {
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
}
def resolve_role_layer(layer_set_id: str | None, role: str) -> str:
"""Resolve the layer name for `role` ('dim' | 'construction') in the active
layer set, falling back to the mech/default name when unknown."""
roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"])
return roles.get(role, LAYER_ROLES["mech"][role])
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
+33 -9
View File
@@ -22,6 +22,14 @@ DIMENSION_LIKE_TEXT = re.compile(
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:
@@ -141,38 +149,54 @@ class DrawingValidator:
"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 len(e.properties.get("points", [])) <= 6
and e.properties.get("closed")
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 in section view via heuristic "
"(small closed polyline on GEOMETRY)",
"Could not confirm keyway via heuristic "
"(small 3-6 point polyline on GEOMETRY)",
))
# 5 — title consistency
helix = expected.get("helix_angle")
if helix:
title_texts = [
(e.properties.get("text", "") or "").upper()
# 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 == "TEXT"
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)
if "HELICAL SPUR" in joined:
# 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 "SPUR" in joined and "HELICAL" not in joined:
elif has_spur:
findings.append(ValidationFinding(
"error",
"title_says_spur_for_helical",