Files
autocad-mcp-pro/engineering/understand/snapshot.py
T
Umut CANandClaude Opus 5.5 efa1ea8182 fix(understand): a truncated DXF is refused by the snapshot, not left hanging
ezdxf's tag reader raises StopIteration on a file that ends early, for example
after an interrupted save or a partial copy. read_snapshot caught only OSError
and DXFError, so the StopIteration escaped. asyncio.to_thread cannot hand
StopIteration back to an awaiting caller, so every track H tool reading such
a file would have hung instead of refusing it.

The review of drawing_diff found this in its own reader. The snapshot now
does the same: every parse or walk failure except MemoryError becomes the
existing "is not a readable DXF file" refusal. A new test checks this both
directly and through a worker thread with a 10 s timeout. It failed with
StopIteration before this change and passes after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:22:23 +03:00

426 lines
17 KiB
Python

"""One bulk read of a drawing into frozen records - the input of every track H reader.
Reading a 35,000-entity P&ID one COM call at a time takes minutes; reading its
DXF takes about a second. So every analysis in ``engineering/understand/`` runs
on a :class:`Snapshot`: the drawing read once, into small frozen
:class:`EntityRecord` s, and never touched again.
Where the snapshot comes from:
* **A DXF file** - :func:`read_snapshot`, ``ezdxf.readfile`` after the size
check (``MAX_DXF_BYTES``, 512 MB by default; the refusal names the size and
the variable).
* **The headless engine's current document** - :func:`records_from_doc` on the
in-memory ``ezdxf`` document, inside the backend's own lock.
* **The live engine's current document** - ``Document.Export(<tmp>/snap, "DXF",
<selection set of all>)``, then :func:`read_snapshot` on the file, which is
deleted afterwards. Measured on AutoCAD 2026 (``25.1s (LMS Tech)``) on
2026-09-24, in a new scratch document closed afterwards:
- the extension argument must be ``"DXF"``; ``".dxf"`` fails with *Invalid
argument*; the file is written as ``snap.DXF`` (upper-case extension);
- the document keeps its name (``Export`` is not ``SaveAs``), and an unsaved
document exports;
- **model space, every paper-space layout and every block definition reach
the snapshot** - a LINE in Layout1 was read back in Layout1, and an unused
block definition was in the BLOCKS section;
- **the selection set does not narrow a DXF export**: an empty selection set
wrote the same model space, paper space and blocks as the set of all. The
set of all is passed anyway, because the ActiveX signature requires one.
- xrefs were not measured, so nothing is claimed for the live engine;
headless, ezdxf reads the host file only. Either way an xref's own
entities are not read in 1.6 - the host drawing is - and
``drawing_understand``'s docstring says so.
A DWG path on the live engine is opened read-only, exported the same way and
closed without saving.
Records hold what the readers need and nothing else: WCS points (OCS is
translated here with ``backends/ocs.py``, so a mirrored ARC or LWPOLYLINE is
where it looks), plain text (``labels.plain``: AutoCAD codes and MTEXT format
runs decoded), block names and attributes, a WCS box. Block definitions are not
exploded: an INSERT is one record with its block name, attributes and box.
"""
from __future__ import annotations
import asyncio
import math
import os
import shutil
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
import ezdxf
from ezdxf import bbox as ezbbox
import config
from backends.capability import UnsupportedCapabilityError
from backends.ocs import to_wcs_2d, wcs_arc_angles, wcs_bulge
from engineering.measure import polygon_area_perimeter
from engineering.understand.labels import plain
__all__ = [
"EntityRecord",
"Pt",
"Snapshot",
"length_of",
"read_snapshot",
"records_from_doc",
"take_snapshot",
]
Pt = tuple[float, float]
#: ezdxf's placeholder for an extents header nobody computed.
_UNSET_EXTENT = 1e19
#: The live export's base name inside its temporary folder.
_SNAP_BASE = "snap"
@dataclass(frozen=True)
class EntityRecord:
"""One entity of a layout, in WCS, as the readers need it."""
handle: str
type: str
layer: str
space: str
points: tuple[Pt, ...] = ()
bulges: tuple[float, ...] = ()
closed: bool = False
radius: float | None = None
angles: tuple[float, float] | None = None
text: str | None = None
height: float | None = None
block: str | None = None
attribs: tuple[tuple[str, str], ...] = ()
bbox: tuple[float, float, float, float] | None = None
rotation: float = 0.0
scale: tuple[float, float] = (1.0, 1.0)
@dataclass(frozen=True)
class Snapshot:
"""A whole drawing, read once."""
source: str
insunits: int
extmin: Pt | None
extmax: Pt | None
layers: dict[str, dict]
layouts: tuple[str, ...]
records: tuple[EntityRecord, ...]
def _xy(point) -> Pt:
return (float(point[0]), float(point[1]))
def _box(entity) -> tuple[float, float, float, float] | None:
"""The entity's WCS box. An INSERT's box is its block geometry only: the
ATTRIB texts are left out, because a TAG lettered beside a tank would push
the tank's box past its body (the rule `engineering/pid/graph.py` keeps)."""
try:
if entity.dxftype() == "INSERT":
box = ezbbox.extents(entity.virtual_entities(), fast=True)
else:
box = ezbbox.extents([entity], fast=True)
except Exception: # a proxy or a malformed entity has no box; the record stays
return None
if not box.has_data:
return None
return (
float(box.extmin.x),
float(box.extmin.y),
float(box.extmax.x),
float(box.extmax.y),
)
def _header_point(doc, name: str) -> Pt | None:
try:
value = doc.header.get(name)
except Exception:
return None
if value is None or abs(float(value[0])) >= _UNSET_EXTENT:
return None
return _xy(value)
def _extrusion(entity):
"""The entity's extrusion, or +Z for a type that has none (MULTILEADER, ...)."""
if entity.dxf.is_supported("extrusion"):
return entity.dxf.get("extrusion", (0.0, 0.0, 1.0))
return (0.0, 0.0, 1.0)
def _polyline(entity) -> tuple[tuple[Pt, ...], tuple[float, ...], bool]:
extrusion = _extrusion(entity)
if entity.dxftype() == "LWPOLYLINE":
elevation = float(entity.dxf.get("elevation", 0.0))
rows = [(float(x), float(y), float(b)) for x, y, b in entity.get_points("xyb")]
points = tuple(tuple(to_wcs_2d(extrusion, x, y, elevation)) for x, y, _ in rows)
bulges = tuple(wcs_bulge(extrusion, b) for _, _, b in rows)
return points, bulges, bool(entity.closed)
vertices = list(entity.vertices)
if entity.is_2d_polyline:
points = tuple(
tuple(to_wcs_2d(extrusion, v.dxf.location.x, v.dxf.location.y, v.dxf.location.z))
for v in vertices
)
bulges = tuple(wcs_bulge(extrusion, v.dxf.get("bulge", 0.0)) for v in vertices)
else: # a 3D polyline is WCS already; its z is dropped
points = tuple(_xy(v.dxf.location) for v in vertices)
bulges = tuple(0.0 for _ in vertices)
return points, bulges, bool(entity.is_closed)
def _record(entity, space: str) -> EntityRecord | None:
kind = entity.dxftype()
dxf = entity.dxf
base = {
"handle": str(dxf.handle),
"type": kind,
"layer": str(dxf.get("layer", "0")),
"space": space,
"bbox": _box(entity),
}
extrusion = _extrusion(entity)
if kind == "LINE":
return EntityRecord(**base, points=(_xy(dxf.start), _xy(dxf.end)))
if kind in ("LWPOLYLINE", "POLYLINE"):
if kind == "POLYLINE" and (entity.is_poly_face_mesh or entity.is_polygon_mesh):
return EntityRecord(**base)
points, bulges, closed = _polyline(entity)
return EntityRecord(**base, points=points, bulges=bulges, closed=closed)
if kind in ("ARC", "CIRCLE"):
centre = tuple(to_wcs_2d(extrusion, dxf.center.x, dxf.center.y, dxf.center.z))
angles = None
if kind == "ARC":
angles = wcs_arc_angles(
extrusion, dxf.center, dxf.radius, dxf.start_angle, dxf.end_angle
)
return EntityRecord(**base, points=(centre,), radius=float(dxf.radius), angles=angles)
if kind == "TEXT":
insert = dxf.insert
return EntityRecord(
**base,
points=(tuple(to_wcs_2d(extrusion, insert.x, insert.y, insert.z)),),
text=plain(dxf.get("text", "")),
height=float(dxf.get("height", 0.0)),
rotation=float(dxf.get("rotation", 0.0)),
)
if kind == "MTEXT":
return EntityRecord(
**base,
points=(_xy(dxf.insert),),
text=plain(entity.text),
height=float(dxf.get("char_height", 0.0)),
rotation=float(entity.get_rotation()),
)
if kind == "INSERT":
insert = dxf.insert
attribs = tuple((str(a.dxf.tag), plain(a.dxf.get("text", ""))) for a in entity.attribs)
return EntityRecord(
**base,
points=(tuple(to_wcs_2d(extrusion, insert.x, insert.y, insert.z)),),
block=str(dxf.name),
attribs=attribs,
rotation=float(dxf.get("rotation", 0.0)),
scale=(float(dxf.get("xscale", 1.0)), float(dxf.get("yscale", 1.0))),
)
if kind == "MULTILEADER":
context = entity.context
points: list[Pt] = []
for leader in context.leaders:
for line in leader.lines:
points.extend(_xy(v) for v in line.vertices)
points.append(_xy(leader.last_leader_point))
text = None
height = None
if context.mtext is not None:
text = plain(context.mtext.default_content)
points.append(_xy(context.mtext.insert))
height = float(context.char_height)
return EntityRecord(**base, points=tuple(points), text=text, height=height)
if kind == "DIMENSION":
override = str(dxf.get("text", "") or "")
text = plain(override) if override not in ("", "<>") else None
middle = dxf.get("text_midpoint", dxf.defpoint)
points = (_xy(dxf.defpoint), tuple(to_wcs_2d(extrusion, middle.x, middle.y, middle.z)))
return EntityRecord(**base, points=points, text=text)
if kind == "LEADER":
return EntityRecord(**base, points=tuple(_xy(v) for v in entity.vertices))
if kind == "POINT":
return EntityRecord(**base, points=(_xy(dxf.location),))
if kind == "SPLINE":
return EntityRecord(**base, points=tuple(_xy(p) for p in entity.control_points))
if kind == "ELLIPSE":
return EntityRecord(**base, points=(_xy(dxf.center),))
return EntityRecord(**base)
def records_from_doc(doc, *, source: str) -> Snapshot:
"""Every entity of every layout of an ``ezdxf`` document, as records.
Model space first, then each paper layout in tab order. ``insunits`` is
``$INSUNITS`` (0 when absent); ``extmin`` / ``extmax`` are the declared
``$EXTMIN`` / ``$EXTMAX``, None when the header holds ezdxf's "never
computed" placeholder. Nothing in ``doc`` is modified.
"""
records: list[EntityRecord] = []
layouts = tuple(doc.layouts.names_in_taborder())
for name in layouts:
for entity in doc.layouts.get(name):
record = _record(entity, name)
if record is not None:
records.append(record)
layers = {
str(layer.dxf.name): {
"color": int(layer.dxf.get("color", 7)),
"linetype": str(layer.dxf.get("linetype", "Continuous")),
}
for layer in doc.layers
}
return Snapshot(
source=source,
insunits=int(doc.header.get("$INSUNITS", 0) or 0),
extmin=_header_point(doc, "$EXTMIN"),
extmax=_header_point(doc, "$EXTMAX"),
layers=layers,
layouts=layouts,
records=tuple(records),
)
def _read(path: str, *, source: str) -> Snapshot:
# Every parse or walk failure is one refusal. A truncated file makes ezdxf's
# tag reader raise StopIteration, which asyncio.to_thread cannot hand back to
# an awaiting tool - uncaught, the tool would never answer.
try:
return records_from_doc(ezdxf.readfile(path), source=source)
except MemoryError:
raise
except Exception as exc: # noqa: BLE001 - see above
reason = str(exc) or type(exc).__name__
raise ValueError(f"{path} is not a readable DXF file: {reason}") from exc
def read_snapshot(path: str) -> Snapshot:
"""The snapshot of a DXF file on disk, after the size check.
Refuses a missing file (``FileNotFoundError``) and a file larger than
``MAX_DXF_BYTES`` (``ValueError`` naming the size, the limit and the
variable; 0 disables the check) before reading a byte of it.
"""
target = Path(path)
if not target.is_file():
raise FileNotFoundError(f"{path}: no such file")
limit = int(config.settings.max_dxf_bytes)
size = target.stat().st_size
if limit > 0 and size > limit:
raise ValueError(
f"{path} is {size:,} bytes ({size / 2**20:.1f} MB), over the MAX_DXF_BYTES "
f"limit of {limit:,} bytes ({limit / 2**20:.0f} MB). Raise MAX_DXF_BYTES "
"(0 disables the check) to read it."
)
return _read(str(target), source=str(target))
def _com_export(backend, path: str | None) -> tuple[str, str, str]:
"""(snapshot file, its temporary folder, source) - runs on the COM thread."""
from backends import com_backend as com
app = com._acad_app()
opened = None
if path is None:
doc = com._acad_doc()
else:
opened = app.Documents.Open(str(path), True) # read-only
doc = opened
folder = tempfile.mkdtemp(prefix="acadmcp_snap_")
try:
name = str(backend._wait_out_rejected_call(lambda: doc.Name))
selection = doc.SelectionSets.Add(f"_SNAP_{uuid.uuid4().hex[:8]}")
try:
selection.Select(com._AC_SELECTION_SET_ALL)
# "DXF", never ".dxf": the dotted spelling is refused with
# "Invalid argument" (measured, AutoCAD 2026).
doc.Export(os.path.join(folder, _SNAP_BASE), "DXF", selection)
finally:
try:
selection.Delete()
except Exception: # a selection set left behind is harmless; the export is not
pass
except Exception:
shutil.rmtree(folder, ignore_errors=True)
raise
finally:
if opened is not None:
opened.Close(False)
written = sorted(Path(folder).glob(f"{_SNAP_BASE}.*"))
if not written:
shutil.rmtree(folder, ignore_errors=True)
raise RuntimeError(f"AutoCAD reported the export of {name} done but wrote no file")
return str(written[0]), folder, f"live:{name}"
async def take_snapshot(backend, path: str | None = None) -> Snapshot:
"""The snapshot of ``path``, or of the backend's current document.
``path`` given: a ``.dxf`` is read directly (on either engine); a ``.dwg``
needs the live engine, which opens it read-only, exports it and closes it -
headlessly it is refused with the ``dwg`` capability. ``path`` None: the
headless engine reads its in-memory document under its own lock; the live
engine exports the active document to a temporary DXF (see the module
docstring for what was measured) and deletes it afterwards. The snapshot of
a live document is not size-checked: it is already in AutoCAD's memory.
"""
if path is not None:
suffix = Path(path).suffix.lower()
if suffix == ".dxf":
return await asyncio.to_thread(read_snapshot, str(path))
if suffix != ".dwg":
raise ValueError(f"{path}: a snapshot reads a .dxf or a .dwg, not {suffix or 'this'}")
if not Path(path).is_file():
raise FileNotFoundError(f"{path}: no such file")
if backend.name != "com":
raise UnsupportedCapabilityError(
"dwg",
f"{path} is a DWG, which the headless engine cannot read. Export it to "
"DXF (DXFOUT or SAVEAS in AutoCAD) and pass the .dxf, or run the server "
"on the live engine (AUTOCAD_MCP_BACKEND=com).",
)
if backend.name == "com":
written, folder, source = await backend._run(_com_export, backend, path)
if path is not None:
await backend._ensure_document_state()
try:
return await asyncio.to_thread(_read, written, source=source)
finally:
shutil.rmtree(folder, ignore_errors=True)
source = backend._doc_path or "memory:untitled"
return await backend._async(lambda: records_from_doc(backend._doc, source=source))
def length_of(rec: EntityRecord) -> float:
"""Drawn length in drawing units: LINE, LWPOLYLINE / POLYLINE with their
bulges (``engineering.measure``, the same arithmetic ``analysis_measure_entity``
reports), ARC, CIRCLE. Every other type is 0.0.
"""
if rec.type == "LINE" and len(rec.points) == 2:
return math.dist(rec.points[0], rec.points[1])
if rec.type in ("LWPOLYLINE", "POLYLINE") and len(rec.points) >= 2:
bulges = rec.bulges or tuple(0.0 for _ in rec.points)
vertices = [(x, y, b) for (x, y), b in zip(rec.points, bulges, strict=False)]
return polygon_area_perimeter(vertices, rec.closed)[1]
if rec.type == "ARC" and rec.radius is not None and rec.angles is not None:
sweep = (rec.angles[1] - rec.angles[0]) % 360.0
return rec.radius * math.radians(sweep)
if rec.type == "CIRCLE" and rec.radius is not None:
return 2.0 * math.pi * rec.radius
return 0.0