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
+97 -23
View File
@@ -30,6 +30,25 @@ from .base import (
log = logging.getLogger(__name__)
def _new_agg_figure(*, figsize=None, dpi=100):
"""Create a matplotlib Figure bound to the headless Agg canvas.
Rendering runs inside an ``asyncio.to_thread`` worker. ``pyplot.figure``
selects a *GUI* backend from global state (Qt/Tk), and instantiating a GUI
canvas off the main thread can hard-crash the interpreter (SIGSEGV). Using
``Figure`` + ``FigureCanvasAgg`` directly bypasses pyplot's global backend
selection entirely and is thread-safe; ``savefig`` still dispatches to the
PDF backend by file extension when needed.
"""
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
fig = Figure(figsize=figsize, dpi=dpi)
FigureCanvasAgg(fig) # binds fig.canvas to Agg (no pyplot, no GUI)
return fig
try:
import ezdxf
from ezdxf import colors, units # noqa: F401
@@ -132,6 +151,9 @@ def _entity_info_dxf(ent) -> EntityInfo:
props["radius"] = ent.dxf.radius
props["start_angle"] = ent.dxf.start_angle
props["end_angle"] = ent.dxf.end_angle
# Arc length (N3) so entity_select_smart length_range works on ARCs.
_sweep = (ent.dxf.end_angle - ent.dxf.start_angle) % 360.0
props["length"] = ent.dxf.radius * math.radians(_sweep)
elif ent_type == "LWPOLYLINE":
props["points"] = [[float(pt[0]), float(pt[1])] for pt in ent.get_points()]
props["closed"] = ent.closed
@@ -145,6 +167,7 @@ def _entity_info_dxf(ent) -> EntityInfo:
props["text"] = ent.text
props["insertion"] = _v2(ent.dxf.insert)
props["char_height"] = ent.dxf.char_height
props["rotation"] = ent.dxf.get("rotation", 0.0)
elif ent_type == "INSERT":
props["block_name"] = ent.dxf.name
props["insertion"] = _v2(ent.dxf.insert)
@@ -159,12 +182,37 @@ def _entity_info_dxf(ent) -> EntityInfo:
if ent.dxf.hasattr("fit_points"):
props["fit_point_count"] = len(list(ent.fit_points))
props["degree"] = ent.dxf.degree
elif ent_type in ("DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER"):
elif ent_type in ("DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE"):
props["dim_type"] = ent_type
# Reference points for dim_overlap critique. ezdxf dimensions carry
# a definition point and (usually) the text midpoint.
try:
props["defpoint"] = _v2(ent.dxf.defpoint)
except Exception:
pass
try:
tm = ent.dxf.get("text_midpoint", None)
if tm is not None:
props["text_position"] = _v2(tm)
except Exception:
pass
except Exception as exc:
log.debug("extracting entity properties for %s: %s", ent_type, exc)
# bounding_box parity with the COM backend (same {min,max} shape) so clients
# reading properties["bounding_box"] work on both engines (N5).
try:
from ezdxf import bbox as _bbox
_bb = _bbox.extents([ent])
if _bb.has_data:
props["bounding_box"] = {
"min": [float(_bb.extmin.x), float(_bb.extmin.y)],
"max": [float(_bb.extmax.x), float(_bb.extmax.y)],
}
except Exception as exc:
log.debug("bbox extents failed for %s: %s", ent_type, exc)
return EntityInfo(
handle=str(handle),
type=ent_type,
@@ -361,17 +409,15 @@ class EzdxfBackend(AutoCADBackend):
doc = self._require_doc()
msp = self._msp()
try:
import matplotlib.pyplot as plt
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
fig = plt.figure()
fig = _new_agg_figure()
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
Frontend(ctx, out).draw_layout(msp, finalize=True)
fig.savefig(path, dpi=150)
plt.close(fig)
return {"ok": True, "path": path}
except ImportError:
raise RuntimeError(
@@ -469,9 +515,11 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync)
async def drawing_undo(self) -> dict:
if not self._undo_stack:
return {"ok": False, "error": "Nothing to undo (no undo history in ezdxf backend)"}
# NEW-undo-1: read+mutate _undo_stack under self._lock (via _async) so
# the empty-check and the pop are serialized with all other sync work.
def _sync():
if not self._undo_stack:
return {"ok": False, "error": "Nothing to undo (no undo history in ezdxf backend)"}
p = self._undo_stack.pop()
try:
self._doc = ezdxf.readfile(str(p))
@@ -499,6 +547,10 @@ class EzdxfBackend(AutoCADBackend):
if color is not None:
entity.dxf.color = int(color)
if linetype is not None:
# R24: load HIDDEN/CENTER/etc. on demand before assignment so the
# linetype actually renders instead of silently falling back to
# Continuous. Same loader used in entity_set_properties/layer_*.
_ensure_linetype_loaded(self._require_doc(), linetype)
entity.dxf.linetype = linetype
def _mark_dirty(self):
@@ -577,7 +629,7 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync)
async def entity_create_mtext(
self, text, x, y, width=100.0, height=2.5, layer=None, color=None,
self, text, x, y, width=100.0, height=2.5, rotation=0.0, layer=None, color=None,
) -> EntityInfo:
def _sync():
msp = self._msp()
@@ -586,6 +638,8 @@ class EzdxfBackend(AutoCADBackend):
dxfattribs={"char_height": float(height), "width": float(width)},
)
ent.dxf.insert = (float(x), float(y), 0.0)
if rotation:
ent.dxf.rotation = float(rotation) # NEW-mtext-1: honor caller rotation
self._apply_attrs(ent, layer, color)
self._mark_dirty()
return _entity_info_dxf(ent)
@@ -1212,12 +1266,15 @@ class EzdxfBackend(AutoCADBackend):
)
origin = blk.block.dxf.get("base_point", (0.0, 0.0, 0.0))
is_xref = bool(blk.block.dxf.get("xref_path", ""))
# S3: surface the block definition's description instead of always "".
description = blk.block.dxf.get("description", "")
blocks.append(BlockInfo(
name=blk.name,
origin=(origin[0], origin[1]),
attribute_count=attr_count,
entity_count=len(list(blk)),
is_xref=is_xref,
description=description,
))
return blocks
return await self._async(_sync)
@@ -1412,10 +1469,23 @@ class EzdxfBackend(AutoCADBackend):
# ── view / screenshot ──────────────────────────────────────────────────────
async def view_zoom_extents(self) -> dict:
return {"ok": True, "message": "Zoom extents not applicable for ezdxf backend (no display)"}
# R26/N9: ezdxf has no viewport, so framing is never actually applied.
# Return applied=False with a consistent shape so a client cannot mistake
# the no-op for real framing.
return {
"ok": True,
"applied": False,
"message": "Zoom extents not applicable for ezdxf backend (no display/viewport)",
}
async def view_zoom_window(self, x1, y1, x2, y2) -> dict:
return {"ok": True, "message": "Zoom window not applicable for ezdxf backend (no display)"}
# R26/N9: window region is ignored (no viewport). Consistent shape with
# view_zoom_extents; applied=False signals the framing was not honored.
return {
"ok": True,
"applied": False,
"message": "Zoom window not applicable for ezdxf backend (no display/viewport)",
}
async def view_screenshot(self) -> bytes | None:
"""Render drawing to PNG using matplotlib."""
@@ -1423,11 +1493,10 @@ class EzdxfBackend(AutoCADBackend):
try:
doc = self._require_doc()
msp = self._msp()
import matplotlib.pyplot as plt
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
fig = plt.figure(figsize=(16, 9), dpi=100)
fig = _new_agg_figure(figsize=(16, 9), dpi=100)
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
@@ -1435,7 +1504,6 @@ class EzdxfBackend(AutoCADBackend):
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=100)
plt.close(fig)
buf.seek(0)
return buf.read()
except ImportError:
@@ -1470,19 +1538,25 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync)
async def transaction_commit(self) -> dict:
if not self._undo_stack:
return {"ok": False, "error": "No active transaction"}
p = self._undo_stack.pop()
try:
p.unlink()
except OSError:
pass
return {"ok": True, "message": "Transaction committed (snapshot discarded)"}
# NEW-undo-1: guard the _undo_stack read+pop with self._lock (via _async)
# instead of touching it outside any lock.
def _sync():
if not self._undo_stack:
return {"ok": False, "error": "No active transaction"}
p = self._undo_stack.pop()
try:
p.unlink()
except OSError:
pass
return {"ok": True, "message": "Transaction committed (snapshot discarded)"}
return await self._async(_sync)
async def transaction_rollback(self) -> dict:
if not self._undo_stack:
return {"ok": False, "error": "No active transaction to rollback"}
# NEW-undo-1: read+mutate _undo_stack under self._lock (via _async) so the
# empty-check and the pop/readfile are serialized with other sync work.
def _sync():
if not self._undo_stack:
return {"ok": False, "error": "No active transaction to rollback"}
p = self._undo_stack.pop()
try:
self._doc = ezdxf.readfile(str(p))