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:
+132
-25
@@ -201,7 +201,7 @@ class AutoCADBackend(ABC):
|
||||
@abstractmethod
|
||||
async def entity_create_mtext(
|
||||
self, text: str, x: float, y: float,
|
||||
width: float = 100.0, height: float = 2.5,
|
||||
width: float = 100.0, height: float = 2.5, rotation: float = 0.0,
|
||||
layer: str | None = None, color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@@ -545,7 +545,10 @@ class AutoCADBackend(ABC):
|
||||
notes: list[str] | None = None,
|
||||
) -> PlanSpec:
|
||||
"""Commit a drawing intent before any geometry is created.
|
||||
Returns a PlanSpec held on the backend for `drawing_critique`."""
|
||||
Returns a PlanSpec and stores it on the backend; read it back with
|
||||
`get_plan_spec()`. (N8: the previous claim that `drawing_critique`
|
||||
replays this plan was untrue — critique runs geometric checks, not a
|
||||
plan diff.)"""
|
||||
from engineering.plan_spec import PlanSpec
|
||||
plan = PlanSpec(
|
||||
intent=str(intent),
|
||||
@@ -562,11 +565,19 @@ class AutoCADBackend(ABC):
|
||||
async def drawing_critique(
|
||||
self, focus: list[CritiqueFocus] | None = None,
|
||||
) -> list[Issue]:
|
||||
"""Run premium-quality checks; return zero issues before
|
||||
`drawing_finalize`. `focus=None` runs all checks."""
|
||||
"""Run premium-quality geometric checks; return zero issues before
|
||||
`drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT
|
||||
replay the committed PlanSpec — it inspects the live drawing geometry.)"""
|
||||
from engineering.critique import run_critique
|
||||
return await run_critique(self, focus)
|
||||
|
||||
def get_plan_spec(self) -> dict | None:
|
||||
"""Return the PlanSpec committed by `drawing_plan` as a dict, or None
|
||||
if no plan has been committed. (N8: makes the stored `_plan_spec`
|
||||
actually readable instead of being write-only dead state.)"""
|
||||
plan = getattr(self, "_plan_spec", None)
|
||||
return plan.to_dict() if plan is not None else None
|
||||
|
||||
async def point_from_snap(
|
||||
self, handle: str, snap: SnapType,
|
||||
ref_x: float | None = None, ref_y: float | None = None,
|
||||
@@ -601,6 +612,19 @@ class AutoCADBackend(ABC):
|
||||
)
|
||||
return float(c[0]), float(c[1]), float(r)
|
||||
|
||||
def _arc_angles():
|
||||
# N7: use .get() + descriptive RuntimeError (matching _line_pts /
|
||||
# _circle_geom) instead of bracket indexing that raises an opaque
|
||||
# KeyError when start_angle/end_angle are missing.
|
||||
sa = p.get("start_angle")
|
||||
ea = p.get("end_angle")
|
||||
if sa is None or ea is None:
|
||||
raise RuntimeError(
|
||||
f"point_from_snap: entity {handle} ({et}) has no "
|
||||
"start_angle/end_angle."
|
||||
)
|
||||
return math.radians(float(sa)), math.radians(float(ea))
|
||||
|
||||
if snap == "end":
|
||||
if et == "LINE":
|
||||
s, e = _line_pts()
|
||||
@@ -611,8 +635,7 @@ class AutoCADBackend(ABC):
|
||||
return s if ds <= de else e
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa = math.radians(float(p["start_angle"]))
|
||||
ea = math.radians(float(p["end_angle"]))
|
||||
sa, ea = _arc_angles()
|
||||
sp = (cx + r * math.cos(sa), cy + r * math.sin(sa))
|
||||
ep = (cx + r * math.cos(ea), cy + r * math.sin(ea))
|
||||
if ref is None:
|
||||
@@ -628,8 +651,7 @@ class AutoCADBackend(ABC):
|
||||
return ((s[0] + e[0]) / 2.0, (s[1] + e[1]) / 2.0)
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa = math.radians(float(p["start_angle"]))
|
||||
ea = math.radians(float(p["end_angle"]))
|
||||
sa, ea = _arc_angles()
|
||||
if ea < sa:
|
||||
ea += 2 * math.pi
|
||||
ma = (sa + ea) / 2.0
|
||||
@@ -646,13 +668,49 @@ class AutoCADBackend(ABC):
|
||||
return (float(c[0]), float(c[1]))
|
||||
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
|
||||
|
||||
# NEW-snap-quad-arc / NEW-snap-near-arc: an ARC only covers part of the
|
||||
# full circle, so quad/near candidates derived from the circle may fall
|
||||
# outside the [start_angle, end_angle] sweep. Restrict (quad) / clamp
|
||||
# (near) the projected angle to the sweep, with 2π wraparound.
|
||||
def _arc_sweep():
|
||||
sa, ea = _arc_angles()
|
||||
if ea < sa:
|
||||
ea += 2 * math.pi
|
||||
return sa, ea
|
||||
|
||||
def _angle_in_sweep(theta: float, sa: float, ea: float) -> bool:
|
||||
# Normalise theta into [sa, sa+2π) and test against [sa, ea].
|
||||
t = sa + ((theta - sa) % (2 * math.pi))
|
||||
return t <= ea + 1e-9
|
||||
|
||||
def _arc_endpoints(cx: float, cy: float, r: float, sa: float, ea: float):
|
||||
return (
|
||||
(cx + r * math.cos(sa), cy + r * math.sin(sa)),
|
||||
(cx + r * math.cos(ea), cy + r * math.sin(ea)),
|
||||
)
|
||||
|
||||
if snap == "quad":
|
||||
if et in ("CIRCLE", "ARC"):
|
||||
if et == "CIRCLE":
|
||||
cx, cy, r = _circle_geom()
|
||||
pts = [(cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r)]
|
||||
if ref is None:
|
||||
return pts[0]
|
||||
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa, ea = _arc_sweep()
|
||||
quad_angles = (0.0, math.pi / 2.0, math.pi, 3 * math.pi / 2.0)
|
||||
pts = [
|
||||
(cx + r * math.cos(a), cy + r * math.sin(a))
|
||||
for a in quad_angles
|
||||
if _angle_in_sweep(a, sa, ea)
|
||||
]
|
||||
if not pts:
|
||||
# No quadrant lies on the sweep — fall back to arc endpoints.
|
||||
pts = list(_arc_endpoints(cx, cy, r, sa, ea))
|
||||
if ref is None:
|
||||
return pts[0]
|
||||
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
|
||||
raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}")
|
||||
|
||||
if snap == "perp":
|
||||
@@ -682,13 +740,29 @@ class AutoCADBackend(ABC):
|
||||
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
|
||||
t = max(0.0, min(1.0, t))
|
||||
return (s[0] + t * dx, s[1] + t * dy)
|
||||
if et in ("CIRCLE", "ARC"):
|
||||
if et == "CIRCLE":
|
||||
cx, cy, r = _circle_geom()
|
||||
vx, vy = ref[0] - cx, ref[1] - cy
|
||||
L = math.hypot(vx, vy)
|
||||
if L < 1e-18:
|
||||
return (cx + r, cy)
|
||||
return (cx + r * vx / L, cy + r * vy / L)
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa, ea = _arc_sweep()
|
||||
vx, vy = ref[0] - cx, ref[1] - cy
|
||||
L = math.hypot(vx, vy)
|
||||
if L < 1e-18:
|
||||
# Degenerate ref at center: return the start endpoint.
|
||||
return (cx + r * math.cos(sa), cy + r * math.sin(sa))
|
||||
theta = math.atan2(vy, vx)
|
||||
if _angle_in_sweep(theta, sa, ea):
|
||||
return (cx + r * math.cos(theta), cy + r * math.sin(theta))
|
||||
# Projected angle is off the sweep — fall back to the nearer endpoint.
|
||||
sp, ep = _arc_endpoints(cx, cy, r, sa, ea)
|
||||
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
|
||||
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
|
||||
return sp if ds <= de else ep
|
||||
raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}")
|
||||
|
||||
if snap == "int":
|
||||
@@ -831,7 +905,13 @@ class AutoCADBackend(ABC):
|
||||
# Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two
|
||||
# circle angles θ at which the tangent touches the perimeter.
|
||||
alpha = math.atan2(dy, dx) # direction from F to C
|
||||
acos_neg = math.acos(-r / d) # in (π/2, π) for external point
|
||||
# NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the
|
||||
# narrow band [r-1e-9, r) (past the internal-point guard but with
|
||||
# |-r/d| marginally > 1 from float error) degenerates gracefully to the
|
||||
# tangent-at-perimeter case instead of raising a raw ValueError
|
||||
# ("math domain error"). Genuine internal points (d < r) are already
|
||||
# rejected by the RuntimeError guard above.
|
||||
acos_neg = math.acos(max(-1.0, min(1.0, -r / d))) # (π/2, π] for external pt
|
||||
candidates: list[tuple[float, float]] = [
|
||||
(cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)),
|
||||
(cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)),
|
||||
@@ -864,12 +944,31 @@ class AutoCADBackend(ABC):
|
||||
except Exception:
|
||||
pass # already created / racy create — harmless
|
||||
|
||||
def _active_layer_set_id(self) -> str:
|
||||
"""Best-effort active layer set: an explicit `drawing_apply_iso_layers`
|
||||
wins, else the committed PlanSpec, else 'mech' (the default bootstrap)."""
|
||||
explicit = getattr(self, "_active_layer_set", None)
|
||||
if explicit:
|
||||
return explicit
|
||||
plan = getattr(self, "_plan_spec", None)
|
||||
if plan is not None:
|
||||
return getattr(plan, "layer_set_id", "mech")
|
||||
return "mech"
|
||||
|
||||
def _role_layer(self, role: str) -> str:
|
||||
"""Resolve the layer name for a role ('dim' | 'construction') in the
|
||||
active layer set, so dims/scaffolding land on the right layer for
|
||||
iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION)."""
|
||||
from engineering.layers import resolve_role_layer
|
||||
return resolve_role_layer(self._active_layer_set_id(), role)
|
||||
|
||||
async def construction_xline(
|
||||
self, x: float, y: float, angle_deg: float,
|
||||
layer: str = "CONSTRUCTION",
|
||||
layer: str | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Create an infinite construction line (XLINE) on a CONSTRUCTION
|
||||
layer (auto-created with color 250 = lightest if missing)."""
|
||||
"""Create an infinite construction line (XLINE) on the active layer set's
|
||||
construction layer (auto-created with color 250 = lightest if missing)."""
|
||||
layer = layer or self._role_layer("construction")
|
||||
await self._ensure_layer(layer, color=250)
|
||||
ang = math.radians(float(angle_deg))
|
||||
return await self._create_xline(
|
||||
@@ -877,10 +976,11 @@ class AutoCADBackend(ABC):
|
||||
)
|
||||
|
||||
async def construction_clear(
|
||||
self, layer: str = "CONSTRUCTION",
|
||||
self, layer: str | None = None,
|
||||
) -> dict:
|
||||
"""Delete every entity on the CONSTRUCTION layer.
|
||||
"""Delete every entity on the active layer set's construction layer.
|
||||
Must be called before `drawing_finalize`."""
|
||||
layer = layer or self._role_layer("construction")
|
||||
try:
|
||||
ents = await self.entity_list(layer_filter=layer, limit=5000)
|
||||
except Exception:
|
||||
@@ -901,17 +1001,20 @@ class AutoCADBackend(ABC):
|
||||
and lineweights. Idempotent."""
|
||||
from engineering.layers import apply_layer_set
|
||||
result = await apply_layer_set(self, standard)
|
||||
self._active_layer_set = standard
|
||||
return {"ok": True, "standard": standard, "layers": result}
|
||||
|
||||
async def dimension_auto(
|
||||
self, handles: list[str], style: DimStyle = "chain",
|
||||
offset: float = 10.0,
|
||||
offset: float = 10.0, layer: str | None = None,
|
||||
) -> list[EntityInfo]:
|
||||
"""Generate ISO 129 dimensions across `handles` in the chosen style
|
||||
(chain | baseline | ordinate). `offset` is the dimension-line
|
||||
distance from the reference geometry (mm)."""
|
||||
distance from the reference geometry (mm). `layer` defaults to the
|
||||
active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567)."""
|
||||
if not handles:
|
||||
return []
|
||||
dim_layer = layer or self._role_layer("dim")
|
||||
if style not in ("chain", "baseline", "ordinate"):
|
||||
raise RuntimeError(
|
||||
f"dimension_auto: unknown style '{style}'. "
|
||||
@@ -945,7 +1048,7 @@ class AutoCADBackend(ABC):
|
||||
nx, ny = -dy / L, dx / L
|
||||
dim = await self.dimension_linear(
|
||||
sx, sy, ex, ey, mx + nx * off, my + ny * off,
|
||||
rotation=math.degrees(math.atan2(dy, dx)), layer="DIM",
|
||||
rotation=math.degrees(math.atan2(dy, dx)), layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
|
||||
@@ -954,27 +1057,31 @@ class AutoCADBackend(ABC):
|
||||
dx, dy = ex0 - sx0, ey0 - sy0
|
||||
L0 = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / L0, dy / L0
|
||||
base_rot = math.degrees(math.atan2(dy, dx))
|
||||
for i, (_sx, _sy, ex, ey) in enumerate(segs):
|
||||
dim = await self.dimension_linear(
|
||||
sx0, sy0, ex, ey,
|
||||
sx0 - uy * (off + i * off), sy0 + ux * (off + i * off),
|
||||
layer="DIM",
|
||||
# Dimension line must run parallel to the (possibly rotated)
|
||||
# baseline, not horizontal.
|
||||
rotation=base_rot, layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
|
||||
else: # ordinate — X then Y distance of each segment's end from start
|
||||
for sx, sy, ex, ey in segs:
|
||||
else: # ordinate — stacked X/Y distances; each feature offset so dims don't overlap
|
||||
for i, (sx, sy, ex, ey) in enumerate(segs):
|
||||
rung = off + i * off # stagger per feature to avoid coincident dims
|
||||
if abs(ex - sx) > 1e-9:
|
||||
dim = await self.dimension_linear(
|
||||
sx, sy, ex, sy,
|
||||
sx + (ex - sx) / 2.0, max(sy, ey) + off, layer="DIM",
|
||||
sx + (ex - sx) / 2.0, max(sy, ey) + rung, layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
if abs(ey - sy) > 1e-9:
|
||||
dim = await self.dimension_linear(
|
||||
ex, sy, ex, ey,
|
||||
max(sx, ex) + off, (sy + ey) / 2.0,
|
||||
rotation=90.0, layer="DIM",
|
||||
max(sx, ex) + rung, (sy + ey) / 2.0,
|
||||
rotation=90.0, layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
|
||||
|
||||
+150
-20
@@ -72,6 +72,21 @@ def _com_init():
|
||||
pythoncom.CoInitialize()
|
||||
|
||||
|
||||
def _com_teardown():
|
||||
"""COM thread finalizer — drop the cached app and release the STA apartment.
|
||||
|
||||
Submitted to the executor (R5) so the CoInitialize done in _com_init is
|
||||
paired with a CoUninitialize when the worker is torn down or a stuck
|
||||
SendCommand eventually unblocks, instead of leaking the apartment forever.
|
||||
"""
|
||||
_COM_STATE.pop("app", None)
|
||||
if _COM_IMPORTS_OK:
|
||||
try:
|
||||
pythoncom.CoUninitialize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _apoint(x: float, y: float, z: float = 0.0):
|
||||
"""Create a VARIANT double-array point for AutoCAD COM."""
|
||||
return win32com.client.VARIANT(
|
||||
@@ -220,6 +235,9 @@ def _entity_info(entity) -> EntityInfo:
|
||||
props["radius"] = entity.Radius
|
||||
props["start_angle"] = rad2deg(entity.StartAngle)
|
||||
props["end_angle"] = rad2deg(entity.EndAngle)
|
||||
# Arc length (N3) — parity with ezdxf so length_range selects ARCs.
|
||||
_sweep = (entity.EndAngle - entity.StartAngle) % (2.0 * math.pi)
|
||||
props["length"] = entity.Radius * _sweep
|
||||
elif obj_name in ("AcDbLWPolyline", "AcDb2dPolyline"):
|
||||
coords = list(entity.Coordinates)
|
||||
pts = [[coords[i], coords[i+1]] for i in range(0, len(coords), 2)]
|
||||
@@ -231,10 +249,14 @@ def _entity_info(entity) -> EntityInfo:
|
||||
ins = entity.InsertionPoint
|
||||
props["insertion"] = [ins[0], ins[1]]
|
||||
props["height"] = entity.Height
|
||||
props["rotation"] = rad2deg(entity.Rotation) # N5 parity with ezdxf
|
||||
elif obj_name == "AcDbMText":
|
||||
props["text"] = entity.Contents
|
||||
ins = entity.InsertionPoint
|
||||
props["insertion"] = [ins[0], ins[1]]
|
||||
# N5 parity with ezdxf: MTEXT char_height + rotation.
|
||||
props["char_height"] = entity.Height
|
||||
props["rotation"] = rad2deg(entity.Rotation)
|
||||
elif obj_name == "AcDbBlockReference":
|
||||
ins = entity.InsertionPoint
|
||||
props["insertion"] = [ins[0], ins[1]]
|
||||
@@ -285,6 +307,13 @@ def _find_autocad_hwnd() -> int | None:
|
||||
"""Find the main AutoCAD window handle."""
|
||||
if not _WIN32_AVAILABLE:
|
||||
return None
|
||||
# R28: prefer the COM application's real main-frame handle. The EnumWindows
|
||||
# title-substring scan can match a palette / About dialog / Start tab, so it
|
||||
# is only a fallback for when the HWND read fails.
|
||||
try:
|
||||
return int(_acad_app().HWND)
|
||||
except Exception as exc:
|
||||
log.debug("Application.HWND read failed, falling back to EnumWindows: %s", exc)
|
||||
result = {"hwnd": None}
|
||||
|
||||
def _enum_cb(hwnd, _):
|
||||
@@ -385,6 +414,10 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._executor:
|
||||
try:
|
||||
self._executor.submit(_com_teardown) # release COM apartment (R5)
|
||||
except Exception:
|
||||
pass
|
||||
self._executor.shutdown(wait=False)
|
||||
self._executor = None
|
||||
self._connected = False
|
||||
@@ -422,6 +455,12 @@ class ComBackend(AutoCADBackend):
|
||||
stuck = self._executor
|
||||
self._executor = ThreadPoolExecutor(max_workers=1, initializer=_com_init)
|
||||
if stuck is not None:
|
||||
# When the stuck SendCommand eventually unblocks, the queued
|
||||
# teardown releases its COM apartment instead of leaking it (R5).
|
||||
try:
|
||||
stuck.submit(_com_teardown)
|
||||
except Exception:
|
||||
pass
|
||||
stuck.shutdown(wait=False)
|
||||
_COM_STATE.pop("app", None)
|
||||
self._connected = False
|
||||
@@ -637,12 +676,14 @@ class ComBackend(AutoCADBackend):
|
||||
return await self._run(_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():
|
||||
mspace = _msp()
|
||||
mt = mspace.AddMText(_apoint(x, y), float(width), text)
|
||||
mt.Height = float(height)
|
||||
if rotation:
|
||||
mt.Rotation = deg2rad(rotation) # NEW-mtext-1: honor caller rotation
|
||||
_apply_entity_attrs(mt, layer, color, None)
|
||||
_regen()
|
||||
return _entity_info(mt)
|
||||
@@ -863,14 +904,49 @@ class ComBackend(AutoCADBackend):
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
ent = doc.HandleToObject(handle)
|
||||
offset_ent = ent.Offset(float(distance))
|
||||
# Offset returns a variant array of entities
|
||||
try:
|
||||
first = offset_ent[0]
|
||||
except Exception as exc:
|
||||
log.debug("Offset result indexing failed, using raw result: %s", exc)
|
||||
first = offset_ent
|
||||
return _entity_info(first)
|
||||
|
||||
def _as_list(res):
|
||||
# Offset returns a variant array of entities (1 for line/arc/circle).
|
||||
try:
|
||||
return [res[i] for i in range(len(res))]
|
||||
except TypeError:
|
||||
return [res]
|
||||
|
||||
def _center(e):
|
||||
bb_min, bb_max = e.GetBoundingBox()
|
||||
return ((bb_min[0] + bb_max[0]) / 2.0, (bb_min[1] + bb_max[1]) / 2.0)
|
||||
|
||||
d = float(distance)
|
||||
# R13: honor side_x/side_y so positive distance means the same side as
|
||||
# ezdxf. Offset both ways, keep the copy nearest the side point, delete
|
||||
# the rest (also fixes the orphan-extras leak, NEW-com-offset-orphan).
|
||||
if side_x is not None and side_y is not None and abs(d) > 1e-12:
|
||||
sx, sy = float(side_x), float(side_y)
|
||||
pos = _as_list(ent.Offset(abs(d)))
|
||||
neg = _as_list(ent.Offset(-abs(d)))
|
||||
|
||||
def _sq_dist(e):
|
||||
cx, cy = _center(e)
|
||||
return (cx - sx) ** 2 + (cy - sy) ** 2
|
||||
|
||||
if _sq_dist(pos[0]) <= _sq_dist(neg[0]):
|
||||
keep, drop = pos, neg
|
||||
else:
|
||||
keep, drop = neg, pos
|
||||
for extra in drop + keep[1:]:
|
||||
try:
|
||||
extra.Delete()
|
||||
except Exception:
|
||||
pass
|
||||
return _entity_info(keep[0])
|
||||
|
||||
result = _as_list(ent.Offset(d))
|
||||
for extra in result[1:]:
|
||||
try:
|
||||
extra.Delete()
|
||||
except Exception:
|
||||
pass
|
||||
return _entity_info(result[0])
|
||||
return await self._run(_sync)
|
||||
|
||||
async def entity_delete(self, handle) -> dict:
|
||||
@@ -1178,12 +1254,18 @@ class ComBackend(AutoCADBackend):
|
||||
1 for j in range(blk.Count)
|
||||
if blk.Item(j).ObjectName == "AcDbAttributeDefinition"
|
||||
)
|
||||
# S3: populate description from the block definition's .Comments.
|
||||
try:
|
||||
description = blk.Comments or ""
|
||||
except Exception:
|
||||
description = ""
|
||||
blocks.append(BlockInfo(
|
||||
name=blk.Name,
|
||||
origin=(blk.Origin[0], blk.Origin[1]),
|
||||
attribute_count=attr_count,
|
||||
entity_count=blk.Count,
|
||||
is_xref=bool(blk.IsXRef),
|
||||
description=description,
|
||||
))
|
||||
return blocks
|
||||
return await self._run(_sync)
|
||||
@@ -1403,19 +1485,25 @@ class ComBackend(AutoCADBackend):
|
||||
doc = _acad_doc()
|
||||
doc.EndUndoMark()
|
||||
return {"ok": True, "message": "Transaction committed"}
|
||||
result = await self._run(_sync)
|
||||
self._transaction_active = False
|
||||
return result
|
||||
# R16: clear the flag even if _run raises, so a failed commit can't leave
|
||||
# _transaction_active permanently stale.
|
||||
try:
|
||||
return await self._run(_sync)
|
||||
finally:
|
||||
self._transaction_active = False
|
||||
|
||||
async def transaction_rollback(self) -> dict:
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
doc.EndUndoMark()
|
||||
doc.SendCommand("_UNDO B\n") # Back to last undo mark
|
||||
# R16: route UNDO B through the CMDACTIVE-aware sender instead of a raw
|
||||
# SendCommand that could deadlock if a command/prompt is active.
|
||||
self._safe_send_command(doc, "_UNDO B")
|
||||
return {"ok": True, "message": "Transaction rolled back"}
|
||||
result = await self._run(_sync)
|
||||
self._transaction_active = False
|
||||
return result
|
||||
try:
|
||||
return await self._run(_sync)
|
||||
finally:
|
||||
self._transaction_active = False
|
||||
|
||||
# ── system ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1453,8 +1541,22 @@ class ComBackend(AutoCADBackend):
|
||||
async def system_set_variable(self, name, value) -> dict:
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
app.SetVariable(name, value)
|
||||
return {"ok": True, "variable": name, "value": value}
|
||||
# Coerce to the sysvar's actual type (NEW-com-set-variable-coercion):
|
||||
# AutoCAD rejects e.g. a string "0" for the integer OSMODE. Probe the
|
||||
# current value's type and convert to match; pass through on failure.
|
||||
coerced = value
|
||||
try:
|
||||
current = app.GetVariable(name)
|
||||
if isinstance(current, bool):
|
||||
coerced = bool(int(value)) if isinstance(value, str) else bool(value)
|
||||
elif isinstance(current, int):
|
||||
coerced = int(float(value)) if isinstance(value, str) else int(value)
|
||||
elif isinstance(current, float):
|
||||
coerced = float(value)
|
||||
except Exception as exc:
|
||||
log.debug("set_variable type probe for %s failed: %s", name, exc)
|
||||
app.SetVariable(name, coerced)
|
||||
return {"ok": True, "variable": name, "value": coerced}
|
||||
return await self._run(_sync)
|
||||
|
||||
async def system_run_command(self, command) -> dict:
|
||||
@@ -1482,9 +1584,37 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
async def system_run_lisp(self, expression) -> dict:
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
doc = _acad_doc()
|
||||
result = doc.SendCommand(f"(progn {expression})\n")
|
||||
return {"ok": True, "expression": expression, "result": str(result) if result else "nil"}
|
||||
# R23: refuse to send while a command/prompt is active (raw SendCommand
|
||||
# of a prompting form would deadlock the single STA thread).
|
||||
try:
|
||||
if int(app.GetVariable("CMDACTIVE")):
|
||||
raise RuntimeError(
|
||||
"AutoCAD has an active command/prompt (CMDACTIVE). "
|
||||
"Press ESC in AutoCAD and retry."
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.debug("CMDACTIVE read failed, proceeding: %s", exc)
|
||||
# N6: SendCommand is void-returning, so the value can't be read from its
|
||||
# return. Stash it in USERS1 and read it back after the form completes
|
||||
# (CMDACTIVE-polled by _safe_send_command).
|
||||
wrapped = (
|
||||
'(vl-load-com)'
|
||||
f'(setvar "USERS1" (vl-princ-to-string (progn {expression})))'
|
||||
)
|
||||
self._safe_send_command(doc, wrapped)
|
||||
try:
|
||||
result = app.GetVariable("USERS1")
|
||||
except Exception:
|
||||
result = None
|
||||
return {
|
||||
"ok": True,
|
||||
"expression": expression,
|
||||
"result": str(result) if result not in (None, "") else "nil",
|
||||
}
|
||||
return await self._run(_sync)
|
||||
|
||||
# ── corner ops ──────────────────────────────────────────────────────────
|
||||
|
||||
+97
-23
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user