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:
+44
-13
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user