"""ezdxf backend – headless DXF file operations. Works without AutoCAD installed. Ideal for file generation and analysis. All operations are synchronous but wrapped in asyncio.to_thread for non-blocking use in the FastMCP async context. DXF only: ezdxf has no DWG writer, so every save path that would put DXF bytes behind a ``.dwg`` name refuses instead (capability ``dwg``). Real DWG I/O needs the live COM backend. """ from __future__ import annotations import asyncio import io import logging import math import os import tempfile import uuid from dataclasses import dataclass, field from importlib.util import find_spec from pathlib import Path from typing import Any import config from engineering import measure from . import ocs from .base import ( AutoCADBackend, BlockInfo, CapabilityMap, DrawingInfo, EntityInfo, FeatureCapability, LayerInfo, UnsupportedCapabilityError, _unsupported, normalize_lineweight, shoelace_area, ) from .contracts.settings import ( SUMMARY_FIELDS, custom_key_fold, parse_scale, scale_value, validate_drawing_properties, ) from .quarantine import ( AbandonedCall, DocumentQuarantineError, QuarantineRecord, quarantine_refusal, real_call_name, ) __all__ = [ "DocumentQuarantineError", "EzdxfBackend", "UnsupportedCapabilityError", "_unsupported", ] #: How many abandoned calls ``system_status`` keeps. The interesting one is #: always the most recent; older entries exist so a support transcript shows a #: document that has been abandoned on repeatedly rather than once. _MAX_ABANDONED_RECORDS = 10 #: System variables that AutoCAD does not keep in the HEADER section. Grid and #: snap belong to the active VPORT table entry (DXF codes 76 / 15 / 75 / 14); #: measured 2026-09-16: `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError`. _VPORT_SYSVARS: dict[str, tuple[str, str]] = { "GRIDMODE": ("grid_on", "flag"), "SNAPMODE": ("snap_on", "flag"), "GRIDUNIT": ("grid_spacing", "point"), "SNAPUNIT": ("snap_spacing", "point"), } #: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved #: in" column). A file has nowhere to keep them, so the headless engine refuses #: a write with `capability: registry_sysvar` instead of storing a value that #: would vanish, and reads report `None` rather than the value ezdxf's document #: template happens to hold in memory; `engineering.standards.sysvars` pins this #: set to its catalogue. OSMODE / ATTREQ / ATTDIA are the trap: ezdxf accepts #: `doc.header["$OSMODE"]` (an R12 header variable, `maxdxf="AC1009"` in #: `HEADER_VAR_MAP`) but never exports it for R2000+ - measured 2026-09-17: a #: write of 4134 to a fresh AC1024 document read back `None` after save + open. _UNSAVED_SYSVARS: frozenset[str] = frozenset( { "AUTOSNAP", "POLARANG", "POLARMODE", "OSMODE", "ATTREQ", "ATTDIA", "SAVETIME", "FILEDIA", "XREFCTL", "PROXYSHOW", "CMDECHO", "HIGHLIGHT", "HPNAME", "HPSCALE", "HPANG", "DWGNAME", "DWGPREFIX", } ) #: Header variables the DXF file stores in degrees (group code 50) while the #: sysvar boundary speaks radians - the unit ActiveX `GetVariable` and AutoLISP #: `getvar` use, so both engines report the same number. Measured on AutoCAD #: 2026 (2026-09-17): `SETVAR ANGBASE 90` reads back 1.5707963267948966 over #: COM and the DXF AutoCAD saves carries `$ANGBASE = 90.0`. _HEADER_DEGREE_SYSVARS: frozenset[str] = frozenset({"ANGBASE"}) 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 from ezdxf.audit import AuditError from ezdxf.enums import TextEntityAlignment # noqa: F401 from ezdxf.math import BSpline, Vec2, Vec3 # noqa: F401 _EZDXF_OK = True except ImportError: AuditError = None _EZDXF_OK = False log.warning("ezdxf not installed. ezdxf backend unavailable.") try: from ezdxf import bbox as ezdxf_bbox _BBOX_OK = True except ImportError: _BBOX_OK = False # --------------------------------------------------------------------------- # Capability refusals # --------------------------------------------------------------------------- # Re-exported, not redefined: server.py and the batch classifier import the # refusal type from here, and an isinstance check against a second class object # with the same name would quietly stop matching. # ezdxf has no DWG writer. Writing DXF bytes to a .dwg path (the pre-1.5.0 # behaviour of drawing_save) produces a file AutoCAD refuses or misreads, so the # save paths refuse instead and name both escape hatches. _DWG_WRITE_REFUSAL = ( "{operation}: the headless ezdxf backend cannot write DWG. ezdxf has no DWG " "writer, and writing DXF bytes to '{path}' would produce a file AutoCAD " "refuses or misreads. Save with a .dxf extension instead, or switch to the " "live COM backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for " "real DWG output." ) # A DWT is a DWG container with a template flag. ezdxf cannot write it, and a # DXF under a .dwt name is exactly the mislabelled file the DWG refusal exists # to prevent. The DXF route works headlessly end to end: drawing_new(template=) # opens a .dxf template. _DWT_WRITE_REFUSAL = ( "drawing_template_save: the headless ezdxf backend cannot write DWT — a .dwt is a DWG " "container, and ezdxf has no DWG writer. Save the template as .dxf instead " "(drawing_new(template='{stem}.dxf') opens it headlessly), or switch to the live COM " "backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for a real .dwt." ) def _is_dwg_path(path: str) -> bool: """True when `path` names a DWG file. The extension is authoritative (N2).""" return Path(path).suffix.lower() == ".dwg" def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError: return UnsupportedCapabilityError( "dwg", _DWG_WRITE_REFUSAL.format(operation=operation, path=path) ) def _audit_entry(entry) -> dict: """Flatten an ezdxf ``ErrorEntry`` into something a client can read. ``ErrorEntry`` defines no ``__str__``, so the pre-1.5.0 ``[str(e) for e in auditor.errors]`` handed users ``''`` — a count of problems with no way to learn what they were. ``code`` is the numeric ``AuditError`` value and ``name`` its symbolic form (100/UNDEFINED_LINETYPE, 104/ INVALID_BLOCK_REFERENCE_CYCLE, ...). """ entity = getattr(entry, "entity", None) handle = None if entity is not None and getattr(entity, "is_alive", True): try: handle = entity.dxf.get("handle", None) except Exception: # a trashed entity has no usable namespace handle = None code = int(entry.code) try: name = AuditError(code).name except ValueError: # A code ezdxf added after this release. str(code) would put "999" in a # field called `name`, which is not a name. name = "UNKNOWN" return { "code": code, "name": name, "message": entry.message, "handle": handle, } # --------------------------------------------------------------------------- # Helper utilities # --------------------------------------------------------------------------- _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} # AutoCAD-shipped linetypes that ezdxf.tools.standards does not include. # Pattern format: [total_length, dash_1, gap_1, dash_2, gap_2, ...] (gaps negative). # Values match acadiso.lin so drawings render the same in AutoCAD viewers. _AUTOCAD_FALLBACK_LINETYPES: dict[str, tuple[str, list[float]]] = { "HIDDEN": ("Hidden __ __ __ __ __ __ __ __ __", [7.5, 5.0, -2.5]), "HIDDEN2": ("Hidden (.5x) _ _ _ _ _ _ _ _ _", [3.75, 2.5, -1.25]), "HIDDENX2": ("Hidden (2x) ____ ____ ____ ____", [15.0, 10.0, -5.0]), "BORDER": ("Border __ __ . __ __ . __ __ . __", [22.5, 7.5, -2.5, 7.5, -2.5, 0.0, -2.5]), "BORDER2": ("Border (.5x) __.__.__.__.__.__", [11.25, 3.75, -1.25, 3.75, -1.25, 0.0, -1.25]), "BORDERX2": ("Border (2x) ____ __ ____ __", [45.0, 15.0, -5.0, 15.0, -5.0, 0.0, -5.0]), } def _add_linetype_from_fallback(doc, name: str) -> bool: """Add `name` from _AUTOCAD_FALLBACK_LINETYPES if known. Returns True on success.""" fallback = _AUTOCAD_FALLBACK_LINETYPES.get(name.upper()) if fallback is None: return False description, pattern = fallback doc.linetypes.add(name.upper(), pattern=pattern, description=description) return True def _ensure_linetype_loaded(doc, name: str) -> None: """Add `name` to doc.linetypes if not already present. Lookup order: ezdxf.tools.standards → AutoCAD fallback table → warn. Lets callers write `linetype="CENTER"` (or HIDDEN, BORDER) without separately loading it. """ if not name or name.lower() in _BUILTIN_LINETYPES: return if name in doc.linetypes: return try: from ezdxf.tools import standards as ezdxf_standards for lt_name, description, pattern in ezdxf_standards.linetypes(): if lt_name.lower() == name.lower(): doc.linetypes.add(lt_name, pattern=pattern, description=description) return except ImportError: pass if _add_linetype_from_fallback(doc, name): return log.warning( "ezdxf backend: linetype %r is not in ezdxf's standard set or AutoCAD " "fallback table; name will be assigned but renders as Continuous until " "properly defined.", name, ) #: Types whose area lives in ACIS solid data. ezdxf stores SAT/SAB opaquely and #: cannot evaluate or generate it; live AutoCAD exposes .Area on all of these. #: This is an ENGINE boundary, so it refuses with a capability. _ACIS_AREA_TYPES = frozenset({"REGION", "3DSOLID", "BODY", "SURFACE", "PLANESURFACE"}) _MEASURABLE_TYPES = ( "LWPOLYLINE, POLYLINE (2D), CIRCLE, ELLIPSE, SPLINE, HATCH, SOLID, TRACE, 3DFACE" ) def _measure_dxf_entity(ent, tolerance: float) -> dict: """Area and perimeter of one entity, with its accuracy stated. The two failure modes are deliberately different exception types. A LINE bounds no area on any engine, so that is a plain ``RuntimeError`` — a capability tag would tell the user to switch backends for something no backend can do. An ACIS type is a real headless-engine boundary and refuses with ``UnsupportedCapabilityError``, naming the live backend. """ ent_type = ent.dxftype() handle = ent.dxf.get("handle", "?") result = { "handle": str(handle), "type": ent_type, "closed": True, "assumed_closed": False, "method": "analytic", "exact": True, "flatten_tolerance": None, "backend": "ezdxf", "self_intersecting": None, "perimeter_exact": True, "loop_count": 1, } if ent_type in _ACIS_AREA_TYPES: raise UnsupportedCapabilityError( "measure_area_acis", f"entity_measure: {handle} is a {ent_type}. Its area lives in ACIS " "solid data, which the headless ezdxf backend cannot evaluate — ezdxf " "stores SAT/SAB opaquely and cannot generate it. Switch to the live " "COM backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD).", ) if ent_type == "CIRCLE": radius = float(ent.dxf.radius) result["area"] = math.pi * radius * radius result["perimeter"] = 2.0 * math.pi * radius return _round_measure(result) if ent_type == "ELLIPSE": major = Vec2(ent.dxf.major_axis).magnitude span = abs(float(ent.dxf.end_param) - float(ent.dxf.start_param)) if abs(span - 2.0 * math.pi) < 1e-9: result["area"] = measure.ellipse_area(major, float(ent.dxf.ratio)) result["perimeter"] = measure.ellipse_perimeter_ramanujan(major, float(ent.dxf.ratio)) result["method"] = "analytic_ellipse" # Exact area, approximate perimeter — the two are reported separately # so neither claim borrows credibility from the other. result["perimeter_exact"] = False return _round_measure(result) return _round_measure(_flatten_measure(ent, tolerance, result)) if ent_type == "SPLINE": return _round_measure(_flatten_measure(ent, tolerance, result)) if ent_type == "HATCH": return _measure_hatch(ent, tolerance, result) vertices = None if ent_type == "LWPOLYLINE": vertices = [(float(x), float(y), float(b)) for x, y, b in ent.get_points(format="xyb")] result["closed"] = bool(ent.closed) elif ent_type == "POLYLINE": mode = ent.get_mode() if mode != "AcDb2dPolyline": raise RuntimeError( f"entity_measure: handle {handle} is a {mode}, which does not bound " f"a planar area. Measurable types: {_MEASURABLE_TYPES}." ) vertices = [ ( float(v.dxf.location.x), float(v.dxf.location.y), float(v.dxf.get("bulge", 0.0) or 0.0), ) for v in ent.vertices ] result["closed"] = bool(ent.is_closed) elif ent_type in ("SOLID", "TRACE", "3DFACE"): corners = [ent.dxf.get(name) for name in ("vtx0", "vtx1", "vtx3", "vtx2")] vertices = [(float(c.x), float(c.y), 0.0) for c in corners if c is not None] if vertices is None: raise RuntimeError( f"entity_measure: handle {handle} is {ent_type}, which does not bound an " f"area. Measurable types: {_MEASURABLE_TYPES}." ) result["assumed_closed"] = not result["closed"] result["area"], result["perimeter"] = measure.polygon_area_perimeter(vertices, result["closed"]) result["method"] = "analytic_bulge" if any(abs(v[2]) > 1e-12 for v in vertices) else "analytic" result["self_intersecting"] = measure.is_self_intersecting([(v[0], v[1]) for v in vertices]) return _round_measure(result) def _flatten_measure(ent, tolerance: float, result: dict) -> dict: """Fall back to a dense polyline approximation, and say that is what it is.""" from ezdxf import path as _path pth = _path.make_path(ent) points = [(float(v.x), float(v.y)) for v in pth.flattening(distance=tolerance)] if len(points) < 3: raise RuntimeError( f"entity_measure: handle {result['handle']} produced no measurable outline." ) result["closed"] = bool(pth.is_closed) result["assumed_closed"] = not result["closed"] result["area"], result["perimeter"] = measure.polygon_area_perimeter( [(x, y, 0.0) for x, y in points], result["closed"] ) result["method"] = "flattened" result["exact"] = False result["perimeter_exact"] = False result["flatten_tolerance"] = tolerance result["self_intersecting"] = measure.is_self_intersecting(points) return result #: ISO-conformant dimension style for a new headless document, in mm. #: #: ezdxf's bare `Standard` dimstyle leaves every one of these unset, and the #: renderer then falls back to its own defaults rather than to the DXF schema #: defaults -- 1.0 mm text with a *comma* decimal marker. ISO 3098 puts the #: minimum lettering height at 2.5 mm and ISO 129 wants a point, so every #: headless drawing this server made was non-conformant on both counts while #: the document header claimed `$DIMTXT 2.5`. #: #: `ezdxf.new(setup=True)` is not the fix: measured, it renders a 100 mm #: dimension as "10000" at 0.25 mm text, because its metric styles carry a #: length factor. Setting the attributes explicitly is what reaches the #: renderer, because only a *stored* value does. _ISO_DIMSTYLE = { "dimtxt": 2.5, # ISO 3098 minimum lettering height "dimasz": 2.5, # arrowhead, matched to the text "dimdsep": ord("."), # ISO 129 decimal marker; ezdxf defaults to a comma "dimdec": 2, "dimexe": 1.25, # extension line beyond the dimension line "dimexo": 0.625, # offset from the measured feature "dimgap": 0.625, # gap around the text } def _normalise_dimstyle_rounding(doc) -> None: """Discard a stored ``dimrnd`` of 0.0, which AutoCAD means as *no* rounding. ezdxf applies ``xround(value, dimrnd)`` whenever the attribute is present at all, and ``xround(33.333, 0.0)`` is ``33``. Saving a drawing writes the attribute out, so every reopened file — including one this server saved itself — silently rounded every dimension to a whole unit *before* DIMDEC got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6. Every style in the table, not `Standard` alone: ezdxf's exporter writes group 45 = 0.0 for *each* DIMSTYLE entry, so a user style (the ISO-25 / ANSI presets `dimstyle_create` makes) came back poisoned from every DXF round trip — save/open, `transaction_rollback`, `drawing_undo` — and a 33.333 mm feature dimensioned with it rendered "33" while `dimstyle_list` still reported DIMDEC 2. Measured before this ran on the whole table. Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm rounding keeps it. """ try: styles = list(doc.dimstyles) except Exception: return for style in styles: try: if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0: style.dxf.discard("dimrnd") except (TypeError, ValueError): continue def _apply_iso_dimstyle(doc) -> None: """Store ISO defaults on the document's `Standard` dimension style.""" try: style = doc.dimstyles.get("Standard") except Exception: # a template without a Standard style is the caller's own return for attribute, value in _ISO_DIMSTYLE.items(): style.dxf.set(attribute, value) doc.header["$DIMTXT"] = _ISO_DIMSTYLE["dimtxt"] doc.header["$DIMASZ"] = _ISO_DIMSTYLE["dimasz"] doc.header["$DIMDSEP"] = _ISO_DIMSTYLE["dimdsep"] doc.header["$DIMDEC"] = _ISO_DIMSTYLE["dimdec"] #: AutoCAD's HPISLANDDETECTION, by the code DXF stores in group 75. _HATCH_STYLE_NAMES = {0: "normal", 1: "outer", 2: "ignore"} def _nesting_depths(loops: list[list[tuple[float, float]]]) -> list[int]: """How many other loops each loop sits inside. Read off containment rather than off the boundary-path flags, because the flags do not carry it: DXF marks a path external or outermost, never "two levels down". Depth is precisely where AutoCAD's three island styles stop agreeing, so guessing it would make `normal` and `outer` return the same number and quietly be wrong for one of them. Hatch boundary loops do not cross, so any vertex of the inner loop settles the question — the first one that is not sitting exactly on the other loop's edge decides. """ from ezdxf import edgesmith from ezdxf.math import Vec2 rings = [[Vec2(x, y) for x, y in points] for points in loops] depths = [] for index, points in enumerate(loops): depth = 0 for other, ring in enumerate(rings): if other == index: continue for x, y in points: verdict = edgesmith.is_point_in_polygon_2d(Vec2(x, y), ring) if verdict != 0: depth += verdict > 0 break depths.append(depth) return depths def _measure_hatch(ent, tolerance: float, result: dict) -> dict: """The area a hatch actually fills: outer loops minus the islands in them. Counting the islands as filled is a 25% error on the test fixture here and an unbounded one on a real section view, which is mostly holes. Which loops count is not a house rule — it is `hatch_style`, reported back so the number can be read against the drawing's own island setting. On a curved edge `flatten_tolerance` is not the accuracy knob it looks like. ezdxf hands boundary paths over as cubic Beziers, so a circular edge already carries the ~0.027% Bezier-vs-circle error before anything is flattened, and tightening the tolerance converges on that wrong number rather than on the true one. `exact` goes False for exactly this reason. """ from ezdxf import path as _path outlines: list[list[tuple[float, float]]] = [] perimeter = 0.0 curved = False open_path = False for boundary in ent.paths: pth = _path.from_hatch_boundary_path(boundary) curved = curved or pth.has_curves open_path = open_path or not pth.is_closed points = [(float(v.x), float(v.y)) for v in pth.flattening(distance=tolerance)] if len(points) < 3: continue outlines.append(points) perimeter += measure.polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[1] if not outlines: raise RuntimeError( f"entity_measure: handle {result['handle']} is a HATCH whose boundary paths " "enclose nothing. A hatch that bounds no loop fills no area." ) style_code = int(ent.dxf.get("hatch_style", 0) or 0) style = _HATCH_STYLE_NAMES.get(style_code, "normal") depths = _nesting_depths(outlines) area = 0.0 for points, depth in zip(outlines, depths, strict=True): if style == "ignore" and depth > 0: continue # HPISLAND=2 fills straight over every island if style == "outer" and depth > 1: continue # only the outermost ring and the holes directly in it signed = -1.0 if depth % 2 else 1.0 area += signed * abs( measure.polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[0] ) verdicts = [measure.is_self_intersecting(points) for points in outlines] result["area"] = abs(area) result["perimeter"] = perimeter result["method"] = "hatch_loops" result["hatch_style"] = style result["loop_count"] = len(outlines) result["closed"] = True result["assumed_closed"] = open_path result["exact"] = not curved result["perimeter_exact"] = not curved result["flatten_tolerance"] = tolerance if curved else None result["self_intersecting"] = ( True if any(v is True for v in verdicts) else (False if any(v is False for v in verdicts) else None) ) return _round_measure(result) #: The dimension style every headless dimension is rendered from. #: #: `msp.add_*_dim` defaults to `dimstyle="EZDXF"` (`"EZ_RADIUS"` for the radial #: ones) and ezdxf silently substitutes `Standard` when that entry is missing — #: which it is in every document this server creates, so the five call sites got #: `Standard` by accident rather than by name. Naming it keeps three things #: pointed at one style: `_apply_iso_dimstyle`, which writes it; the `iso128` #: critique, which grades it; and the header fold below, which compares against #: it. A file authored by `ezdxf.new(setup=True)` *does* carry an `EZDXF` entry, #: and its metric styles carry a length factor — measured, that renders a 100 mm #: dimension as "10000" — so on an opened file the substitution would have #: pointed the dimension at a style nobody here configured and left both the #: fold and the ISO defaults inert. _DIMSTYLE_NAME = "Standard" #: DIM* header variables folded onto the dimension being created, as #: ``dimstyle attribute -> (header variable, renderer fallback, coercion)``. #: #: The third column is what ezdxf's *renderer* resolves the attribute to when #: the dimstyle stores nothing — `DimStyleOverride.get(attr, default)` with the #: defaults spelled out in `render/dim_base.py` and `get_decimal_separator`. #: Deliberately NOT the DXF schema default, which differs for three of the six: #: dimtxt 1.0 vs 2.5, dimasz 0.25 vs 2.5, dimdsep 0 (a comma) vs 44. Keying the #: "header already agrees, nothing to fold" test on the schema default is the #: invisible way to get this wrong — it switches the fold off for exactly the #: opened-file case it exists for, and switches it *on* for a fresh drawing, #: where folding dimdsep would put ezdxf's comma back onto a sheet ISO 129 wants #: a point on. #: #: This is an allowlist, not "every DIM* variable". $DIMADEC and $DIMAZIN sit at #: 0 in a fresh header while the renderer falls back to 2 and 2, so completing #: the table would silently reformat every angular dimension this server has #: ever drawn (measured: 239.04° becomes 239°). $DIMGAP is the other one that #: looks safe and is not: `build_dim_override` sets dimgap=-1.0 for #: `tol_mode="basic"` to draw the theoretically-exact box, and a fold would be #: competing with it for the same key. _HEADER_DIMVARS: dict[str, tuple[str, Any, Any]] = { "dimscale": ("$DIMSCALE", 1.0, float), "dimtxt": ("$DIMTXT", 1.0, float), "dimasz": ("$DIMASZ", 0.25, float), "dimdec": ("$DIMDEC", 2, int), "dimdsep": ("$DIMDSEP", 0, int), # a character code; 0 renders as a comma "dimzin": ("$DIMZIN", 8, int), } def _coerce_dimvar(value, coerce): """The value as the override dict needs it, or None if it is junk. ezdxf validates the *header* (a string in `$DIMDSEP` raises DXFValueError) but not the override dict, so a float 46.0 or a stray string reaching `dimdsep` renders garbage instead of raising. Coercing here is what keeps an opened file's odd header out of the drawing. """ try: return coerce(value) except (TypeError, ValueError): return None def _header_dimvar(doc, header_var: str, coerce): """The header's value for a DIM* variable, or None if it has none usable.""" try: raw = doc.header.get(header_var, None) except Exception: # an opened document may not have a readable header return None return None if raw is None else _coerce_dimvar(raw, coerce) def _style_dimvar(doc, attribute: str, fallback, coerce): """What the renderer would use with no header and no override in play.""" try: style = doc.dimstyles.get(_current_dimstyle_name(doc)) if style.dxf.hasattr(attribute): stored = _coerce_dimvar(style.dxf.get(attribute), coerce) if stored is not None: return stored except Exception: # a document without a Standard style is the caller's own pass return fallback def effective_dimvar(doc, attribute: str): """The value the next dimension will actually be rendered with. Header first, then the stored dimstyle attribute, then ezdxf's renderer fallback — the order `_with_header_dimvars` produces below, and the order a live seat uses (the header DIMVARS *are* the current settings; the dimstyle table entry is only the base underneath them). `drawing_critique` has to resolve the same value, or it reports a defect the sheet does not have and clears one it does. """ header_var, fallback, coerce = _HEADER_DIMVARS[attribute] from_header = _header_dimvar(doc, header_var, coerce) if from_header is not None: return from_header return _style_dimvar(doc, attribute, fallback, coerce) def _with_header_dimvars(doc, override: dict | None) -> dict | None: """Carry the DIM* header variables onto the dimension being created. `system_set_variable("DIMTXT", 5.0)` writes the header variable, which is what AutoCAD consults when it builds a dimension — so the call worked on the live backend. ezdxf renders from the dimstyle table entry plus the per-dimension override and never reads the header, so headlessly the same call reported success and changed nothing. Measured on v1.5.1: after DIMTXT=5.0, DIMASZ=6.0, DIMDEC=1, DIMDSEP=44 and DIMZIN=0 the header read all five back, and the next dimension was byte-identical to the one before them — still 2.5 mm text, 2.5 arrows, "33.33". Folding them into the override makes one call mean one thing on both engines. v1.5.1 folded `$DIMSCALE` alone, which is why this is a table rather than a fifth copy of the same six lines. A variable whose header value already matches what the renderer would use is skipped, so an untouched `drawing_new` drawing gains no override at all — header and style agree on all six there. `setdefault` is what keeps the fold below `build_dim_override`: an explicit tolerance/limit/basic key, or any value a caller passed, always outranks the header. """ merged = override for attribute, (header_var, fallback, coerce) in _HEADER_DIMVARS.items(): from_header = _header_dimvar(doc, header_var, coerce) if from_header is None: continue if attribute == "dimscale" and not from_header: # DIMSCALE 0 means "scale to the paper-space viewport". There is no # viewport to scale to here, and folding a literal 0 would render # the dimension to nothing; 1.0 is what the renderer already uses. continue stored = _style_dimvar(doc, attribute, fallback, coerce) if isinstance(from_header, float): if abs(from_header - float(stored)) < 1e-9: continue elif from_header == stored: continue if merged is override: # only allocate once something is actually folded merged = dict(override or {}) merged.setdefault(attribute, from_header) return merged # ── styles (track E): module helpers ───────────────────────────────────────── # # `$DIMSTYLE` names the style new dimensions are created with, exactly as on # a live seat. Two measured ezdxf facts shape everything below: # # * `ezdxf.new()` writes `$DIMSTYLE "ISO-25"` into a fresh header with no such # table entry (only `Standard` exists). `_current_dimstyle_name` therefore # falls back to `Standard` when the header names nothing in the table, and # `dimstyle_create` pins the header so creating a style *called* ISO-25 does # not make it current by accident. # * `_with_header_dimvars` folds every `$DIM*` that differs from the current # style's stored value onto the next dimension. Making a style current # without loading its values into the header leaves the previous style's # header winning: measured, ISO-25 (comma) after `drawing_new` (point) # rendered "33.33". `_sync_header_to_dimstyle` is what a live seat's # `-DIMSTYLE Restore` does, and it makes the fold empty. def _current_dimstyle_name(doc) -> str: """The dimension style new dimensions are created with. `$DIMSTYLE` when the table holds it (in the table's spelling); otherwise `Standard` — ezdxf's own substitution for a missing style, made explicit. """ try: name = doc.header.get("$DIMSTYLE", _DIMSTYLE_NAME) except Exception: return _DIMSTYLE_NAME if isinstance(name, str) and name and doc.dimstyles.has_entry(name): return doc.dimstyles.get(name).dxf.name return _DIMSTYLE_NAME def _current_textstyle_name(doc) -> str: """The text style new TEXT/MTEXT carry: `$TEXTSTYLE`, else `Standard`.""" try: name = doc.header.get("$TEXTSTYLE", "Standard") except Exception: return "Standard" if isinstance(name, str) and name and doc.styles.has_entry(name): return doc.styles.get(name).dxf.name return "Standard" def _dimstyle_schema_default(var: str): """What AutoCAD reads for a DIMSTYLE entry that omits the code.""" from ezdxf.entities.dimstyle import acdb_dimstyle attrib = acdb_dimstyle.attribs.get(var.lower()) return None if attrib is None else attrib.default _ARROWHEAD_VARS = ("DIMBLK", "DIMBLK1", "DIMBLK2") def _dimstyle_value(style, var: str): """A style's value for ``var`` in the tool vocabulary (DIMDSEP as the character, arrowheads in the canonical ``_OBLIQUE`` spelling).""" from engineering.standards.dimstyles import reported_arrowhead attr = var.lower() raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None if raw is None: raw = _dimstyle_schema_default(var) if var == "DIMDSEP": code = 44 if raw is None else int(raw) return "," if code == 0 else chr(code) if var == "DIMTXSTY": return str(raw) if raw else "Standard" if var in _ARROWHEAD_VARS: # Measured: ezdxf holds the writer's ``OBLIQUE`` in-session and the # block name ``_OBLIQUE`` after a reload. Raw, one style would be # spelled two ways and re-setting a value to itself would report a # change (spec §8.3); canonicalised, both reads agree. return reported_arrowhead(raw) return raw def _read_dimstyle_values(style, variables=None) -> dict[str, Any]: from engineering.standards.dimstyles import PRESET_VARIABLES return {var: _dimstyle_value(style, var) for var in (variables or PRESET_VARIABLES)} def _require_arrowhead_blocks(doc, values: dict[str, Any]) -> None: """Refuse a user arrowhead block the drawing does not define — before any write. A built-in (``ARROWHEAD_BLOCKS``) and closed filled (``""``) need no block: ezdxf creates the built-in's block on first use. A user block name passes the data layer (it cannot see the drawing) and, stored, ezdxf raises ``DXFUndefinedBlockError`` at the next dimension render and ``DXFValueError: Block "MYARROW" does not exist`` at ``doc.write`` — after the create tool already said ``ok``. ``doc.blocks`` membership is case-insensitive, and a reload reports the block table's own spelling (measured: ``myarrow`` stored, ``MYARROW`` after reopen), so the name is rewritten in ``values`` to that spelling here — the same "reported in the table's own spelling" rule the style names follow, and what keeps a re-set after reopen a non-change. """ from engineering.standards.dimstyles import ARROWHEAD_BLOCKS for var in _ARROWHEAD_VARS: name = values.get(var) if not name or name in ARROWHEAD_BLOCKS: continue if name not in doc.blocks: raise ValueError( f"{var}: block {name!r} does not exist in this drawing; define it first " "with block_define, or name one of AutoCAD's built-in arrowheads" ) values[var] = doc.blocks.get(name).name def _write_dimstyle_values(style, values: dict[str, Any]) -> None: """Store typed values (from ``validate_overrides``) on a DIMSTYLE entry. DIMRND 0 is AutoCAD's "no rounding"; ezdxf applies ``xround(x, 0.0)`` to a *stored* 0.0 and rounds every dimension to a whole unit, so it is discarded rather than stored (the `_normalise_dimstyle_rounding` lesson). Arrowheads arrive in the canonical ``_OBLIQUE`` spelling (what ActiveX takes verbatim); ezdxf's writer wants ``OBLIQUE`` and raises at ``doc.write`` on the underscore form, so they go through ``ezdxf_arrowhead``. The caller has run ``_require_arrowhead_blocks``. """ from engineering.standards.dimstyles import ezdxf_arrowhead for var, value in values.items(): attr = var.lower() if var == "DIMRND" and float(value) == 0.0: style.dxf.discard("dimrnd") continue if var == "DIMDSEP": value = ord(value) elif var in _ARROWHEAD_VARS: value = ezdxf_arrowhead(value) style.dxf.set(attr, value) def _sync_header_to_dimstyle(doc, style) -> None: """Load the style into ``$DIM*`` — every whitelist variable, a live seat's Restore. ``-DIMSTYLE Restore`` replaces *all* the DIM* system variables with the style's settings; a DIMSTYLE entry that omits a code means its schema default. Writing only the attributes the style *stores* left every other ``$DIM*`` at the previous style's value — measured, ISO-25 with DIMZIN 0 / DIMSCALE 2.0 made current and then ``dimstyle_set_current("Standard")`` rendered the next dimension at 5.0 mm as "33.30": `_with_header_dimvars` folded the stale header onto it, and the saved file carried the same values next to ``$DIMSTYLE Standard``, which AutoCAD reads as overrides. The same gap kept ``$DIMRND 0.5`` in the header after `dimstyle_modify` discarded the style's own attribute. Measured on ezdxf 1.4.4: a fresh header equals the schema default for every whitelist variable a fresh `Standard` does not store, so restoring `Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value` reports. The arrowheads cross a vocabulary boundary here. The DIMSTYLE entry holds ezdxf's writer spelling (``OBLIQUE``, see `_write_dimstyle_values`); the ``$DIMBLK`` / ``$DIMBLK1`` / ``$DIMBLK2`` header variables are DXF-facing and spell a built-in with its underscore (``_OBLIQUE``, what a live seat stores and what `system_get_variable("DIMBLK")` returns on COM). Copied raw, the saved file carried ``$DIMBLK "OBLIQUE"`` next to blocks named ``_OBLIQUE`` — a current-override naming a block that does not exist — and the two engines answered the same read two ways. `reported_arrowhead` is the same read-side rule `_dimstyle_value` applies. """ from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST, reported_arrowhead for var in sorted(DIM_VARIABLE_WHITELIST): attr = var.lower() value = style.dxf.get(attr) if style.dxf.hasattr(attr) else None if value is None: value = _dimstyle_schema_default(var) if value is None and var == "DIMTXSTY": value = "Standard" if var in _ARROWHEAD_VARS: value = reported_arrowhead(value) key = f"${var}" if value is None: try: del doc.header[key] except KeyError: pass continue doc.header[key] = value def _font_available(font_file: str) -> bool: """Whether ezdxf's font manager can find ``font_file`` on this machine.""" try: from ezdxf.fonts import fonts return bool(fonts.font_manager.has_font(font_file)) except Exception: return False def _ensure_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]: """``(table spelling, created)`` for a text style that must exist. A bundled preset (ISOCP / ISOCPEUR / ARIAL / ROMANS) is created on demand; any other missing name is refused by ``refusal_key`` before a write. """ from engineering.standards.textstyles import TEXT_PRESETS if doc.styles.has_entry(name): return doc.styles.get(name).dxf.name, False preset = TEXT_PRESETS.get(name.upper()) if preset is None: raise ValueError( f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a " f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create" ) font_file, width, oblique = preset doc.styles.new( name.upper(), dxfattribs={"font": font_file, "width": width, "oblique": oblique, "height": 0.0}, ) return name.upper(), True def _dimensions_using(doc, style_name: str) -> list[str]: """Handles of every DIMENSION, in any layout, drawn with ``style_name``.""" wanted = style_name.lower() handles: list[str] = [] for layout in doc.layouts: for entity in layout.query("DIMENSION"): if str(entity.dxf.get("dimstyle", "")).lower() == wanted: handles.append(entity.dxf.handle) return handles def _style_table_references(doc) -> tuple[set[str], set[str]]: """``(block names, text-style names)`` the style tables and header reference. Lower-cased, for `drawing_purge`. A DIMSTYLE names its text style and up to four arrowhead blocks (stored in ezdxf's spelling, ``OBLIQUE``, while the block is ``_OBLIQUE`` — both spellings are counted); an MLEADERSTYLE references its text style and arrowhead by handle; the header names the current text style and dimension text style. ``Standard`` is never purged. """ blocks: set[str] = set() styles: set[str] = {"standard"} def _add_block(name) -> None: if isinstance(name, str) and name: lowered = name.lower() blocks.add(lowered) blocks.add(lowered if lowered.startswith("_") else f"_{lowered}") def _add_style(name) -> None: if isinstance(name, str) and name: styles.add(name.lower()) def _add_by_handle(handle, into) -> None: entity = doc.entitydb.get(handle) if handle else None if entity is not None: into(entity.dxf.get("name")) try: for var in ("$TEXTSTYLE", "$DIMTXSTY"): _add_style(doc.header.get(var, None)) for var in ("$DIMBLK", "$DIMBLK1", "$DIMBLK2", "$DIMLDRBLK"): _add_block(doc.header.get(var, None)) except Exception: # an opened document may not have a readable header pass for style in doc.dimstyles: _add_style(style.dxf.get("dimtxsty")) for attr in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"): _add_block(style.dxf.get(attr)) for _name, style in doc.mleader_styles: _add_by_handle(style.dxf.get("text_style_handle"), _add_style) _add_by_handle(style.dxf.get("arrow_head_handle"), _add_block) for entity in doc.entitydb.values(): if entity.dxftype() != "MULTILEADER": continue context = getattr(entity, "context", None) _add_by_handle(getattr(context, "text_style_handle", None), _add_style) for arrow in getattr(entity, "arrow_heads", None) or (): _add_by_handle(getattr(arrow, "handle", None), _add_block) return blocks, styles def _round_measure(result: dict) -> dict: """Six decimal places, matching analysis_measure_distance.""" result["area"] = round(float(result["area"]), 6) result["perimeter"] = round(float(result["perimeter"]), 6) return result #: How many handle labels a grounded screenshot will draw before it stops and #: says so. Past this the labels bury the geometry they exist to explain. DEFAULT_MAX_HANDLE_LABELS = 40 def _label_anchor(ent) -> tuple[float, float] | None: """Where an entity's handle label goes — the centre of its own extents. Uses the renderer's own bbox rather than the entity's stored coordinates, so the label lands where the entity is *drawn* even in a rotated frame (T0.4). """ try: from ezdxf import bbox as _bbox box = _bbox.extents([ent]) if not box.has_data: return None return ( (float(box.extmin.x) + float(box.extmax.x)) / 2.0, (float(box.extmin.y) + float(box.extmax.y)) / 2.0, ) except Exception as exc: log.debug("no label anchor for %s: %s", ent.dxftype(), exc) return None _IDENTITY_EXTRUSION = (0.0, 0.0, 1.0) #: The DXF types whose stored geometry is in the entity's own frame. LINE, MTEXT, #: ELLIPSE, SPLINE and POINT carry an `extrusion` attribute too, and are already #: correct — ezdxf overrides their `ocs()` to a pass-through — so any fix keyed on #: "has extrusion" would break them. _OCS_ENTITY_TYPES = frozenset( { "CIRCLE", "ARC", "LWPOLYLINE", "TEXT", "INSERT", "DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR", "DIMRADIUS", "DIMDIAMETER", "DIMORDINATE", } ) def _entity_info_dxf(ent) -> EntityInfo: """Convert an ezdxf entity to EntityInfo. Coordinates out are always WCS.""" handle = ent.dxf.get("handle", "?") ent_type = ent.dxftype() layer = ent.dxf.get("layer", "0") color = ent.dxf.get("color", 256) linetype = ent.dxf.get("linetype", "ByLayer") visible = ent.dxf.get("invisible", False) is False props: dict = {} # T0.4 — everything leaving this function is WCS. CIRCLE/ARC/LWPOLYLINE/TEXT/ # INSERT and a dimension's text position are stored in the entity's own frame, # so a raw read is not where the entity is; `bounding_box` below already # reports WCS, which is how the contradiction was found. `flat` distinguishes # the two kinds of non-identity frame: a mirrored entity (extrusion -Z) still # lies in the WCS XY plane and every field survives translation, while a # tilted one projects to something xy cannot describe. extrusion = _IDENTITY_EXTRUSION try: if ent.dxf.is_supported("extrusion"): extrusion = tuple(ent.dxf.get("extrusion", _IDENTITY_EXTRUSION)) except Exception as exc: log.debug("reading extrusion for %s: %s", ent_type, exc) needs_ocs = ent_type in _OCS_ENTITY_TYPES and not ocs.is_wcs_frame(extrusion) flat = ocs.is_flat_frame(extrusion) def _v2(v): """Convert Vec3/tuple to [x, y] list.""" return [float(v.x), float(v.y)] if hasattr(v, "x") else [float(v[0]), float(v[1])] def _pt(v): """A stored point as WCS [x, y] — the only spelling that leaves here.""" x, y, z = ( (float(v.x), float(v.y), float(getattr(v, "z", 0.0))) if hasattr(v, "x") else (float(v[0]), float(v[1]), float(v[2]) if len(v) > 2 else 0.0) ) return ocs.to_wcs_2d(extrusion, x, y, z) if needs_ocs else [x, y] try: if ent_type == "LINE": props["start"] = _v2(ent.dxf.start) props["end"] = _v2(ent.dxf.end) props["length"] = ent.dxf.start.distance(ent.dxf.end) elif ent_type == "CIRCLE": props["center"] = _pt(ent.dxf.center) # A tilted circle projects to an ellipse (measured: radius 5.0 has a # 5.0 x 3.99 WCS-XY footprint), so `radius` has no meaning in the # frame this dict is expressed in. Omitting it is silence; reporting # it would be a fresh silent wrong number. if flat: props["radius"] = ent.dxf.radius elif ent_type == "ARC": props["center"] = _pt(ent.dxf.center) if flat: props["radius"] = ent.dxf.radius props["start_angle"], props["end_angle"] = ( ocs.wcs_arc_angles( extrusion, ent.dxf.center, ent.dxf.radius, ent.dxf.start_angle, ent.dxf.end_angle, ) if needs_ocs else (ent.dxf.start_angle, ent.dxf.end_angle) ) # Arc length (N3) so entity_select_smart length_range works on ARCs. # Computed from the ORIGINAL sweep: length is frame-invariant, and the # WCS pair above is swapped for a left-handed frame. _sweep = (ent.dxf.end_angle - ent.dxf.start_angle) % 360.0 props["length"] = ent.dxf.radius * math.radians(_sweep) elif ent_type == "LWPOLYLINE": # LWPOLYLINE vertices carry no z of their own; the whole polyline # sits at dxf.elevation within its frame. _elev = float(ent.dxf.get("elevation", 0.0)) props["points"] = [ ( ocs.to_wcs_2d(extrusion, pt[0], pt[1], _elev) if needs_ocs else [float(pt[0]), float(pt[1])] ) for pt in ent.get_points() ] props["closed"] = ent.closed # `ent.length()` used to be here. LWPolyline has no such method, so # the AttributeError went into the debug log below and the key simply # never appeared — and a caller shoelacing the xy points above to # recover it lost 28.2% of the area on bulged geometry. Both come # from the same exact, bulge-aware maths as entity_measure. _verts = [(float(x), float(y), float(b)) for x, y, b in ent.get_points(format="xyb")] _area, _length = measure.polygon_area_perimeter(_verts, bool(ent.closed)) props["length"] = round(_length, 6) # The bulge of the segment leaving each vertex (DXF convention), so # a reader that walks `points` can test against the arc the drawing # holds instead of the chord it does not. Parallel to `points`, and # in the same frame as them: a mirrored polyline's frame is a # reflection, which reverses every arc's sense, so the stored sign # next to translated points would describe the arc's mirror image. props["bulges"] = [ ocs.wcs_bulge(extrusion, b) if needs_ocs else b for _x, _y, b in _verts ] if ent.closed: props["area"] = round(_area, 6) elif ent_type == "TEXT": props["text"] = ent.dxf.text props["insertion"] = _pt(ent.dxf.insert) props["height"] = ent.dxf.height # `rotation` stays in the entity's own frame. A mirrored TEXT is # mirror-imaged, and no single scalar angle expresses that — measured # 29.999 reported against a true WCS 150.0 — so normalising it would # replace one wrong number with another. Named in the # `ocs_normalized` capability reason instead. props["rotation"] = ent.dxf.get("rotation", 0.0) elif ent_type == "MTEXT": 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"] = _pt(ent.dxf.insert) props["x_scale"] = ent.dxf.xscale if ent.dxf.hasattr("xscale") else 1.0 props["y_scale"] = ent.dxf.yscale if ent.dxf.hasattr("yscale") else 1.0 props["rotation_deg"] = ent.dxf.get("rotation", 0.0) # MIRROR3D and foreign DXFs store a mirror as extrusion -Z (the # stored x unnegated); `insertion` above is already WCS, but the # block's content is drawn on the mirror image about it, so a # reader that places catalogue ports needs to know. props["mirrored"] = float(extrusion[2]) < 0 # The drawn symbol alone. ``bounding_box`` below takes the ATTRIBs # with it, so a TAG above a valve pushes the box past the body and # a line ending on the body's real edge reads as *inside* it. # `virtual_entities` already applies the INSERT transform, so this # is WCS; the key is omitted when the block draws nothing. try: from ezdxf import bbox as _bbox _gb = _bbox.extents( e for e in ent.virtual_entities() if e.dxftype() not in ("ATTRIB", "ATTDEF") ) if _gb.has_data: props["geometry_bbox"] = { "min": [float(_gb.extmin.x), float(_gb.extmin.y)], "max": [float(_gb.extmax.x), float(_gb.extmax.y)], } except Exception as exc: log.debug("geometry bbox failed for INSERT %s: %s", ent.dxf.name, exc) elif ent_type == "ELLIPSE": props["center"] = _v2(ent.dxf.center) props["major_axis"] = _v2(ent.dxf.major_axis) props["ratio"] = ent.dxf.ratio elif ent_type == "SPLINE": if ent.dxf.hasattr("fit_points"): props["fit_point_count"] = len(list(ent.fit_points)) props["degree"] = ent.dxf.degree 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: # defpoint is DXF group code 10 and WCS already — ezdxf's # Dimension.transform partitions exactly here, sending defpoint # through the raw matrix and only text_midpoint through the OCS. # Translating it would corrupt a correct field. 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"] = _pt(tm) # group code 11 — OCS except Exception: pass except Exception as exc: log.debug("extracting entity properties for %s: %s", ent_type, exc) if needs_ocs and not flat: # The frame cannot be flattened into xy without losing information, so # say which plane the entity is in rather than leaving the omissions # above unexplained. props["plane_normal"] = ocs.plane_normal(extrusion) # 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, layer=layer, color=color, linetype=linetype, visible=visible, properties=props, ) def _layer_info_dxf(layer_obj, current_name: str) -> LayerInfo: lw = layer_obj.dxf.get("lineweight", -3) return LayerInfo( name=layer_obj.dxf.name, color=abs(layer_obj.dxf.get("color", 7)), linetype=layer_obj.dxf.get("linetype", "Continuous"), lineweight=float(lw), is_on=not layer_obj.is_off(), is_frozen=layer_obj.is_frozen(), is_locked=layer_obj.is_locked(), is_current=(layer_obj.dxf.name == current_name), ) # --------------------------------------------------------------------------- # ── environment (track E): document registry ──────────────────────────────── # --------------------------------------------------------------------------- @dataclass class _DocState: """Everything the backend keeps per open document. Before v1.6 these were twelve attributes on the backend itself, which is why it could hold exactly one drawing. The registry holds one of these per open document; the old attribute names survive as descriptors over the *active* entry so no existing method had to change. """ key: str doc: Any = None path: str | None = None dirty: bool = False current_layer: str = "0" current_space: str = "Model" undo_stack: list[Path] = field(default_factory=list) redo_stack: list[Path] = field(default_factory=list) transaction_stack: list[Path] = field(default_factory=list) plan_spec: Any = None preflight_result: Any = None # `drawing_apply_iso_layers` records the standard here so `_role_layer` # files dims / scaffolding on that standard's layer; it is per drawing, # not per backend (an iso13567 sheet next to a mech one must not push # the mech drawing's dimensions onto M-DIMEN-T-N). active_layer_set: str | None = None gdt_datums_defined: set = field(default_factory=set) gdt_datums_referenced: set = field(default_factory=set) activated_seq: int = 0 @property def label(self) -> str: return Path(self.path).name if self.path else self.key def discard_snapshots(self) -> None: for stack in (self.undo_stack, self.redo_stack, self.transaction_stack): while stack: try: stack.pop().unlink() except OSError: pass class _ActiveField: """A backend attribute that lives on the active document's ``_DocState``. A data descriptor, so it also overrides the plain class attributes the ``PremiumContract`` mixin declares (``_plan_spec = None``) — the mixin's ``self._plan_spec = plan`` lands on the active document. """ def __init__(self, attr: str): self.attr = attr def __get__(self, instance, owner=None): if instance is None: return self return getattr(instance._state, self.attr) def __set__(self, instance, value): setattr(instance._state, self.attr, value) # --------------------------------------------------------------------------- # EzdxfBackend # --------------------------------------------------------------------------- class EzdxfBackend(AutoCADBackend): """File-based ezdxf backend – no live AutoCAD needed.""" # Old per-backend attributes, now routed to the active document (v1.6). # `_doc` and `_doc_path` are explicit properties below (binding and # re-keying rules); the rest are plain views. _dirty = _ActiveField("dirty") _current_layer = _ActiveField("current_layer") _current_space = _ActiveField("current_space") _undo_stack = _ActiveField("undo_stack") _redo_stack = _ActiveField("redo_stack") _transaction_stack = _ActiveField("transaction_stack") _plan_spec = _ActiveField("plan_spec") _preflight_result = _ActiveField("preflight_result") _active_layer_set = _ActiveField("active_layer_set") _gdt_datums_defined = _ActiveField("gdt_datums_defined") _gdt_datums_referenced = _ActiveField("gdt_datums_referenced") def __init__(self): if not _EZDXF_OK: raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf") # v1.6: one entry per open document; the old attributes above are # views over the active one. `_void` is what they read and write when # nothing is open, so `_reset_document_state()` on disconnect and # `system_status()` on an empty backend keep working. self._docs: dict[str, _DocState] = {} self._active_key: str | None = None self._void = _DocState(key="") self._untitled_counter = 0 self._activation_counter = 0 self._connected = False self._lock = asyncio.Lock() # R32: the document a timed-out call was abandoned on is untrusted until # it is replaced. See backends/quarantine.py for what that buys. self._quarantine: QuarantineRecord | None = None self._abandoned_calls: list[QuarantineRecord] = [] @property def _state(self) -> _DocState: if self._active_key is None: return self._void return self._docs[self._active_key] @property def _doc(self): return self._state.doc @_doc.setter def _doc(self, value): # `_restore_snapshot` and `transaction_rollback` rebind the active # document; nothing may bind a Drawing while none is active — that is # `_register_document`'s job, and it is what keeps the registry honest. if self._active_key is None: if value is None: return raise RuntimeError("EzdxfBackend: bind a Drawing through _register_document") self._state.doc = value @property def _doc_path(self) -> str | None: return self._state.path @_doc_path.setter def _doc_path(self, value: str | None): # `drawing_save` / `drawing_save_as` assign here. A document keyed # `untitled-N` that is saved to a file becomes keyed by that file, so # `document_list` shows one identity per document and `document_close` # can be told the file name. The registry holds one entry per file: # `_refuse_path_held_elsewhere` turns a save onto a file that another # entry holds away *before* the bytes are written (AutoCAD refuses # SAVEAS onto an open drawing for the same reason), and this setter # raises rather than let two entries share a path, because the # resolver could then only guess which one a caller means. if self._active_key is None or value is None: self._state.path = value return new_key = os.path.abspath(value) holder = self._path_holder(new_key, exclude=self._active_key) if holder is not None: raise RuntimeError( f"EzdxfBackend: registry entry {holder!r} already holds {new_key!r}; " "a file is held by one open document" ) state = self._state state.path = value # Re-key only when the *file* changes: another name for the file the # entry already holds (a case variant, a hard link, a junction) keeps # the key a caller may be holding. if not self._same_file(new_key, self._active_key): del self._docs[self._active_key] state.key = new_key self._docs[new_key] = state self._active_key = new_key @staticmethod def _file_identity(path: str) -> tuple: """What two names of one file share - the *file*, not its spelling. Every registry comparison goes through this. A path that exists is identified by ``os.stat`` ``(st_dev, st_ino)`` - the same identity ``os.path.samefile`` uses - so a case variant on a case-insensitive volume (NTFS, default APFS), a hard link, an NTFS junction or a symlink all name the one file, and the registry keeps one entry for it. ``normcase(abspath(path))`` was the earlier rule and folded only case and separators, and only on Windows: two names for one file registered twice, both rows said ``saved``, and a save from either overwrote the other's bytes. It stays as the fallback for a path that is not on disk yet (a save target) or that the OS cannot stat, and for a filesystem that reports no inode. Keys themselves stay spelled as the caller spelled them - the identity is for comparing, not showing. """ try: absolute = os.path.abspath(path) except (OSError, ValueError): # a path this OS cannot normalise return ("spelling", os.path.normcase(path)) try: st = os.stat(absolute) except (OSError, ValueError): return ("spelling", os.path.normcase(absolute)) if st.st_ino == 0 and st.st_dev == 0: # A filesystem without stable file identity (some network shares): # fall back to the spelling rather than call every file one file. return ("spelling", os.path.normcase(absolute)) return ("file", st.st_dev, st.st_ino) @classmethod def _same_file(cls, a: str, b: str) -> bool: return cls._file_identity(a) == cls._file_identity(b) def _path_holder(self, path: str, *, exclude: str | None = None) -> str | None: """Key of the entry (other than ``exclude``) that holds ``path``, or None. Compared by ``_file_identity``: the entry's path and its key are both names of the file it holds, so either matching ``path`` - by inode when both are on disk, by ``normcase`` spelling otherwise - means the file is held. An untitled entry holds no file and never matches. """ wanted = self._file_identity(path) for key, state in self._docs.items(): if key == exclude or not state.path: continue if any(self._file_identity(c) == wanted for c in (key, state.path)): return key return None def _refuse_path_held_elsewhere(self, op: str, path: str) -> None: """Refuse ``op`` onto a file that another open document holds. Runs before ``doc.saveas`` so a refusal leaves the disk, the registry and the active document exactly as they were. """ holder = self._path_holder(path, exclude=self._active_key) if holder is None: return label = self._docs[holder].label raise ValueError( f"{op}: {label!r} is already open as document {holder!r}; a file is held by " "one open document at a time - document_close it first, or save to another path" ) @property def name(self) -> str: return "ezdxf" @property def is_connected(self) -> bool: return self._connected def capabilities(self) -> CapabilityMap: render_available = find_spec("matplotlib") is not None render_capability = ( FeatureCapability(True, "rendered") if render_available else FeatureCapability(False, reason="optional_dependency_missing:matplotlib") ) return CapabilityMap( backend="ezdxf", features={ "drawing_2d": FeatureCapability(True, "native"), "dxf": FeatureCapability(True, "native"), "dwg": FeatureCapability(False, reason="no_dwg_writer_requires_live_autocad"), "pdf": render_capability, "png": render_capability, "transactions": FeatureCapability(True, "snapshot"), "audit_detail": FeatureCapability(True, "native"), "handle_overlay": render_capability, "revcloud": FeatureCapability(True, "native", reason="ezdxf_revcloud_helper"), "wipeout": FeatureCapability(True, "native"), "mtext_background_color": FeatureCapability(True, "native"), "boundary_trace": FeatureCapability( True, "computed", reason="straight_edges_split_at_intersections;curved_edges_not_split", ), "hatch_edge_paths": FeatureCapability(True, "native"), "chspace": FeatureCapability( True, "computed", reason=( "top_view_untwisted_viewports_only;dimensions_refused_unless_frozen;" "acis_proxy_table_refused;viewport_clipping_reported_not_applied" ), ), "undo_history": ( FeatureCapability(True, "snapshot") if config.settings.ezdxf_undo_depth > 0 else FeatureCapability(False, reason="set_EZDXF_UNDO_DEPTH_to_enable") ), "measure_area_acis": FeatureCapability( False, reason="acis_evaluation_requires_live_autocad" ), "explode_opaque_members": FeatureCapability( False, reason=( "ezdxf_cannot_transform:OLE2FRAME,VIEWPORT," "ACAD_PROXY_ENTITY_without_proxy_graphic;refused_before_writing" ), ), "ocs_normalized": FeatureCapability( True, "wcs", reason=( "reported_xy_is_wcs;z_component_not_reported;" "text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported" ), ), "ocs_tilted_plane": FeatureCapability( False, reason="2d_xy_cannot_address_a_tilted_plane" ), "table": FeatureCapability(True, "composite"), "mleader": FeatureCapability(True, "composite"), "preflight": FeatureCapability(True, "shared"), "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), "dwt_write": FeatureCapability( False, reason="dwt_is_a_dwg_container_requires_live_autocad" ), # Measured, not assumed: a sheet holding nothing but a viewport # renders the model geometry that viewport looks at, at its # scale and position, clipped to its window. v1.4 declared this # COM-only and sent callers to the live backend for something # the headless one already did. "viewport_render": ( FeatureCapability(True, "rendered", reason="viewport_borders_not_drawn") if render_available else FeatureCapability(False, reason="optional_dependency_missing:matplotlib") ), "solid_3d": FeatureCapability( False, reason="acis_generation_requires_live_autocad" ), "lisp": FeatureCapability(False, reason="live_com_only"), "registry_sysvar": FeatureCapability( False, reason="registry_saved_variables_have_no_home_in_a_file" ), "dwgprops": FeatureCapability( False, reason="summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer", ), "documents": FeatureCapability(True, "registry"), # Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads # and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim. "layer_states": FeatureCapability( True, "xrecord", reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager", ), "named_views": FeatureCapability( True, "vport_active", reason="restore_sets_the_view_the_file_opens_on;no_live_display", ), "ucs": FeatureCapability( True, "stored_not_interpreted", reason="tool_coordinates_stay_wcs" ), # Track E keys. `@capability` registers its key at import, and # tests/test_capability_contract.py checks both maps from that # moment on — so every key the contract module uses is declared # here in the task that creates the module, not when the COM # implementation lands (Task 23). "live_application": FeatureCapability( False, reason="headless_engine_has_no_application_to_launch_or_attach" ), "preferences": FeatureCapability( False, reason="preferences_live_in_the_running_application_not_in_a_file" ), "interactive_prompt": FeatureCapability( False, reason="no_operator_to_prompt_headlessly" ), }, ) async def connect(self) -> None: self._connected = True log.info("ezdxf backend ready") async def disconnect(self) -> None: for state in self._docs.values(): state.discard_snapshots() self._docs.clear() self._active_key = None self._void = _DocState(key="") self._reset_document_state() # The documents go away with the backend, so nothing is left to protect. # The record stays in _abandoned_calls for anyone reading the transcript. self._clear_quarantine("disconnect") self._connected = False def _cleanup_undo_stack(self): self._state.discard_snapshots() # ── document registry (v1.6) ────────────────────────────────────────────── def _register_document(self, doc, path: str | None) -> tuple[str, bool]: """Add ``doc`` to the registry and make it active. Returns ``(key, reloaded)``. A path that is already open is *reloaded*: its old entry (and its snapshots) is dropped and the fresh read takes its place — the pre-registry behaviour of ``drawing_open``, kept so a save-then-reopen verification still reads the disk. "Already open" is decided by ``_path_holder`` (file identity), not by the key string: the same file under another name (a case variant, a hard link, a junction) used to register a second entry, after which a save from either silently overwrote the other's bytes. """ if path is None: self._untitled_counter += 1 key = f"untitled-{self._untitled_counter}" reloaded = False else: key = os.path.abspath(path) previous_key = self._path_holder(key) reloaded = previous_key is not None if previous_key is not None: self._docs.pop(previous_key).discard_snapshots() if self._active_key == previous_key: self._active_key = None self._docs[key] = _DocState(key=key, doc=doc, path=path) self._activate_key(key) return key, reloaded def _activate_key(self, key: str) -> None: self._activation_counter += 1 self._docs[key].activated_seq = self._activation_counter self._active_key = key def _most_recent_key(self) -> str | None: if not self._docs: return None return max(self._docs.values(), key=lambda state: state.activated_seq).key def _resolve_document_key(self, name_or_path: str | None) -> str: """Registry key for ``None`` (the active document), a key, a path or a basename.""" if name_or_path is None: if self._active_key is None: raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.") return self._active_key if not isinstance(name_or_path, str): raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}") wanted = name_or_path.strip() if not wanted: raise ValueError("name_or_path must not be empty") if wanted in self._docs: # An exact key hit is still refused when a second entry holds the # same file: the registry is meant to keep one entry per file (the # save paths enforce it), and if that is ever broken a lookup must # say so rather than hand back whichever entry the key names. self._refuse_shared_path(wanted, self._docs[wanted]) return wanted # Paths compare by file identity (the inode when the file is on disk), # as the live engine's ``_com_find_document`` resolves before comparing: # a lowercase drive letter, a case variant, a hard link or a junction # names the same document. as_path = self._file_identity(wanted) by_path = [ key for key, state in self._docs.items() if state.path and self._file_identity(state.path) == as_path ] if len(by_path) == 1: self._refuse_shared_path(wanted, self._docs[by_path[0]]) return by_path[0] if len(by_path) > 1: raise ValueError( f"document {wanted!r} is ambiguous: entries {by_path} share the file " f"{as_path!r}; the registry must hold one entry per file" ) by_name = [ key for key, state in self._docs.items() if state.label.lower() == wanted.lower() ] if len(by_name) == 1: self._refuse_shared_path(wanted, self._docs[by_name[0]]) return by_name[0] if len(by_name) > 1: # Same basename in two directories is a real ambiguity the full # path settles; two entries on one file is the invariant breach, # and "pass the full path" would send the caller to the exact-key # shortcut that this resolver refuses for the same reason. for key in by_name: self._refuse_shared_path(wanted, self._docs[key]) raise ValueError(f"document {wanted!r} is ambiguous: {by_name}; pass the full path") raise ValueError(f"no open document named {wanted!r}; open documents: {list(self._docs)}") def _refuse_shared_path(self, wanted: str, state: _DocState) -> None: """Raise if any other entry holds the file ``state`` holds.""" if not state.path: return abs_path = os.path.abspath(state.path) other = self._path_holder(state.path, exclude=state.key) if other is not None: raise ValueError( f"document {wanted!r} is ambiguous: entries {sorted([state.key, other])} " f"share the file {abs_path!r}; the registry must hold one entry per file" ) def _evict_quarantined_document(self) -> None: """Drop the quarantined entry before a fresh document is registered. Pre-registry, ``drawing_new`` *replaced* the only document, and the quarantine relies on that: the runaway keeps the object it captured and its writes land on an orphan nobody reads. With a registry the quarantined entry would otherwise survive, and ``document_activate`` could hand back a drawing another thread is still mutating. """ if self._quarantine is None or self._active_key is None: return state = self._docs.pop(self._active_key) state.discard_snapshots() self._active_key = self._most_recent_key() def _document_row(self, key: str, state: _DocState) -> dict: return { "name": state.label, "path": state.path, "key": key, "active": key == self._active_key, "saved": not state.dirty, "entity_count": len(state.doc.modelspace()), } def _close_document_sync(self, key: str, *, save: bool, discard: bool) -> dict: """Close one registry entry. Runs on the worker thread under the lock.""" state = self._docs[key] closing_active = key == self._active_key quarantined = closing_active and self._quarantine is not None label = state.label saved = False if state.dirty: if save: if quarantined: raise ValueError( f"document_close: {label!r} was quarantined after a call was " "abandoned mid-write and cannot be saved; pass discard=True to drop " "its changes" ) if not state.path: raise ValueError( f"document_close: {label!r} has never been saved, so save=True has " "nowhere to write; drawing_save_as(path) first, or pass discard=True" ) # ``drawing_close`` reaches here without the resolver, so the # one-entry-per-file check is made at the write itself: a # save onto a file another entry holds would leave that # entry claiming ``saved: True`` over someone else's bytes. self._refuse_shared_path(label, state) state.doc.saveas(state.path) state.dirty = False saved = True elif not discard: where = f" to {state.path}" if state.path else " (after drawing_save_as)" raise ValueError( f"document_close: {label!r} has unsaved changes; pass save=True to write " f"them{where}, or discard=True to drop them" ) discarded = state.dirty state.discard_snapshots() del self._docs[key] result = {"ok": True, "closed": key, "saved": saved, "discarded_changes": discarded} if closing_active: released = self._clear_quarantine("document_close") self._active_key = self._most_recent_key() if released is not None: result["quarantine_cleared"] = released.to_dict() result["active"] = self._active_key result["open_documents"] = len(self._docs) return result def _require_doc(self): if self._doc is None: raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.") return self._doc def _require_block(self, name): """The block definition ``name`` names, or a refusal before any write. ``add_blockref`` never checks the name and ``add_auto_attribs`` skips autofill when ``Insert.block()`` resolves to None, so a typo'd name used to yield a dangling INSERT that dropped its attribute values and that ``doc.audit()`` later deletes. A layout block (``*Model_Space``, ``*Paper_Space``) is in ``doc.blocks`` too, but inserting one is the reference cycle AutoCAD's INSERT refuses, so it is refused here by name. Shared by ``block_insert`` and ``entity_create_block_ref`` so the two tools that place a block reference agree on what a typo does. """ doc = self._require_doc() if not isinstance(name, str) or not name.strip(): raise TypeError("block name must be a non-empty string") if name not in doc.blocks: raise ValueError(f"block {name!r} is not defined") blk = doc.blocks.get(name) if blk.block_record.is_any_layout: raise ValueError(f"block {name!r} is a layout block and cannot be inserted") return blk def _msp(self): """The space new geometry goes into — model space, or the current layout. This returned ``doc.modelspace()`` unconditionally, so ``layout_set_current("A3-Sheet")`` reported success and then every entity created afterwards still landed in model space. Following the current layout is also what makes a title block on a sheet possible at all: the title block draws through the same ``entity_create_*`` calls. """ doc = self._require_doc() if self._current_space == "Model": return doc.modelspace() try: return doc.layouts.get(self._current_space) except Exception as exc: # A layout deleted out from under us must not strand every later # write; fall back to model space and say why. log.warning( "current layout %r is gone (%s); falling back to model space", self._current_space, exc, ) self._current_space = "Model" return doc.modelspace() @staticmethod def _busy_error(timeout: float) -> RuntimeError: return RuntimeError( "ezdxf backend busy: another call has held the document lock for more " f"than {timeout:g}s. Retry once it finishes, or raise EZDXF_CALL_TIMEOUT " "(seconds; 0 disables the timeout entirely)." ) @staticmethod def _timeout_error(call: str, timeout: float) -> RuntimeError: return RuntimeError( f"ezdxf call '{call}' timed out after {timeout:g}s. asyncio.to_thread " "cannot be cancelled, so the worker thread was abandoned and may still be " "writing to the drawing. Every later call on this document is refused - " "reads included - until drawing_open(path), drawing_new() or " "drawing_close() rebinds it, or until the abandoned call is seen to finish " "cleanly, which lifts the refusal by itself. system_status() stays " "answerable throughout. Raise EZDXF_CALL_TIMEOUT (seconds; 0 disables the " "timeout entirely) if this operation is legitimately slow." ) # ── document quarantine (R32) ───────────────────────────────────────────── def _reap_quarantine(self) -> None: """The free exit: an abandoned call that finished cleanly is no corruption. The common real trigger for the deadline is a legitimately slow ``drawing_audit`` or a big ``drawing_export_pdf`` against the 120s default, not a hang. If the ``_sync`` body ran to completion the document holds exactly the state that call intended, and forcing a reopen for that would be its own defect. This inference is sound *only* because every other caller was refused in the meantime, so "the call finished" really does mean "the document is what that call intended". Add one mutating exemption to the gate and the lift becomes a lie. The lift is not silent: the caller never received the return value, so a late-completing ``entity_create_*`` leaves an entity whose handle nobody holds. That fact survives in the record. """ record = self._quarantine if record is None or not record.watcher.finished: return if record.watcher.error is not None: if record.outcome == "running": error = record.watcher.error record.outcome = "failed_after_deadline" record.detail = f"{type(error).__name__}: {error}" log.error( "abandoned ezdxf call %r raised %s after its deadline; the document " "stays quarantined - it was left mid-write", record.call, record.detail, ) return record.outcome = "completed_after_deadline" record.capture_lost_result() self._quarantine = None log.warning( "abandoned ezdxf call %r completed %.1fs after its deadline; lifting the " "document quarantine. Its return value never reached the caller (%s), so " "anything it created is in the drawing with no handle reported", record.call, record.age(), record.result_repr, ) def _clear_quarantine(self, released_by: str, new_path: str | None = None): """Release the quarantine because ``self._doc`` was just rebound. Only the three calls that construct a *fresh* ``Drawing`` may do this: the runaway keeps the object it captured, so its remaining writes land on an orphan nobody reads. That holds for a ``_sync`` which captured doc/msp once at entry — the shape of nearly every closure in this file. One that re-reads ``self._msp()`` inside its own loop would follow the rebind into the fresh document, and ``_async`` cannot prevent that, which is why a still-live runaway is said out loud rather than implied away. """ record = self._quarantine if record is None: return None self._quarantine = None record.outcome = "released" record.released_by = released_by if record.thread_alive(): record.detail = "the abandoned worker was still running at recovery time" log.warning( "%s cleared the document quarantine while the abandoned %r worker is " "STILL RUNNING; its writes go to the replaced Drawing unless its _sync " "re-reads self._msp() inside its own loop", released_by, record.call, ) if new_path and record.document_path: same = self._same_file(new_path, record.document_path) if same: log.warning( "%s is reopening %s, the same path the abandoned %r call was working " "against; if that call is inside doc.saveas the file is being written " "right now and this read may be of a truncated document", released_by, new_path, record.call, ) return record def _quarantine_document(self, func, watcher, timeout: float) -> str: """Record an abandoned call and refuse the document until it is replaced. ``func`` is the caller's closure (it carries the qualname the message needs); ``watcher`` is the wrapper the worker thread is actually running, and the only thing that can report whether it ever finished. """ call = real_call_name(func) record = QuarantineRecord( call, timeout, watcher, document_path=self._doc_path, document_id=id(self._doc), ) self._quarantine = record self._abandoned_calls.append(record) del self._abandoned_calls[:-_MAX_ABANDONED_RECORDS] return call async def _async(self, func, *args, quarantine_exit: bool = False, **kwargs): """Run a blocking ezdxf call in a worker thread, serialised and timed out. The ezdxf ``Drawing`` is not thread-safe, so every backend method funnels through one lock. Without a timeout a single hung call held that lock forever and every later tool call on the server deadlocked behind it. ``EZDXF_CALL_TIMEOUT`` (seconds, ``0`` disables) bounds the *whole* wait — queueing behind an in-flight call plus the call itself — mirroring ``ComBackend._run``, where the executor queue wait sits inside the same ``wait_for``. Lock ownership on timeout: ``asyncio.to_thread`` cannot be cancelled, so the worker thread outlives the timeout and may still be mutating the document. The timed-out call therefore deliberately **never releases its lock**; the backend swaps in a fresh ``asyncio.Lock`` and leaves the abandoned call holding the orphaned one. The runaway thread can then never hand the lock to new work mid-write, and the next tool call proceeds immediately instead of deadlocking — the same abandon-and-rebuild shape the COM backend uses for its stuck STA executor. Callers already queued on the orphaned lock get the explicit "backend busy" error rather than silently interleaving with the runaway thread. Timing out while merely *waiting* for the lock does not abandon it: that lock is held by a live call which will release it normally. Outside cancellation (client disconnect) also releases normally, as it did before the timeout existed. R32 — swapping the lock is only half the answer, and on its own it is the more dangerous half: it removes the mutual exclusion the lock existed for, so the *next* call runs against the same ``Drawing`` the runaway is still mutating. Measured, that returned a fresh circle handle, an entity count for a modelspace 50 entities further on, and a valid DXF of a state that never existed. So the abandonment quarantines the document, and every later call is refused **before** ``lock.acquire()`` — after the acquire the refusal would queue behind an unrelated in-flight call and the caller would wait the full timeout to be told the document is untrusted. Reads are refused too: a read of a container another thread is appending to does not tear loudly, it runs unbounded — ``drawing_info`` blew its own 2.0s deadline at 2.03s and abandoned a *second* worker. ``quarantine_exit=True`` is for the three calls that rebind ``self._doc`` to a freshly constructed object. They still take the lock, because two concurrent recoveries would otherwise both rebind it and one fresh document would be silently discarded with its caller's work in it. """ # The gate, before any await: a refusal must not queue. if self._quarantine is not None: self._reap_quarantine() if self._quarantine is not None and not quarantine_exit: raise quarantine_refusal(self._quarantine) timeout = config.settings.ezdxf_call_timeout if timeout <= 0: # No deadline means no abandon branch, so nothing here can quarantine. async with self._lock: return await asyncio.to_thread(func, *args, **kwargs) loop = asyncio.get_running_loop() deadline = loop.time() + timeout lock = self._lock # One `asyncio.timeout_at` for both phases rather than two # `asyncio.wait_for`s. `wait_for` wraps its awaitable in a Task, so the # old shape cost two extra Tasks and two timer handles on *every* call # — 4,000 of each to draw 2,000 lines, which is where the throughput # this release lost was going. `acquired` keeps the two failure modes # distinguishable, which is the whole reason they were separate. acquired = False call: asyncio.Future | None = None # The only place an abandoned thread's outcome is observable is inside # the thread: `call` is CANCELLED by the deadline, so its done-callback # reports the await ending and never the worker. See AbandonedCall. watched = AbandonedCall(func) try: async with asyncio.timeout_at(deadline): await lock.acquire() acquired = True call = asyncio.ensure_future(asyncio.to_thread(watched, *args, **kwargs)) result = await call except TimeoutError as exc: if not acquired: # Timed out queueing behind someone else's live call. That lock # is held by a call that will release it normally; do not # abandon it. raise self._busy_error(timeout) from exc if call is None or not call.cancelled(): # The call itself raised TimeoutError (an OSError subclass, # and the same type the deadline raises) rather than overrunning # the deadline. That is an ordinary failure: release the lock # and let it through unchanged instead of blaming the deadline. lock.release() raise if self._lock is lock: self._lock = asyncio.Lock() # Same handler, same branch as the lock swap: the two other exits # above touched nothing, and quarantining on either would refuse the # whole document for a failure that never reached it. name = self._quarantine_document(func, watched, timeout) log.error( "ezdxf call %r exceeded EZDXF_CALL_TIMEOUT=%gs; abandoning the worker " "thread and the lock it holds, and quarantining the document it may " "still be writing to", name, timeout, ) raise self._timeout_error(name, timeout) from exc except BaseException: # Only ours to release if we ever took it — cancellation can land # while still queueing, and releasing someone else's lock there # would hand the document to two callers at once. if acquired: lock.release() raise lock.release() return result # ── drawing management ──────────────────────────────────────────────────── async def drawing_info(self) -> DrawingInfo: def _sync(): doc = self._require_doc() msp = self._msp() entities = list(msp) layers = list(doc.layers) if _BBOX_OK: try: bb = ezdxf_bbox.extents(msp) emin = (bb.extmin.x, bb.extmin.y) if bb else (0.0, 0.0) emax = (bb.extmax.x, bb.extmax.y) if bb else (0.0, 0.0) except Exception as exc: log.debug("computing drawing extents via bbox: %s", exc) emin, emax = (0.0, 0.0), (0.0, 0.0) else: emin, emax = (0.0, 0.0), (0.0, 0.0) unit_map = {0: "Unitless", 1: "Inches", 2: "Feet", 4: "mm", 5: "cm", 6: "m"} try: ins_units = doc.header.get("$INSUNITS", 0) unit_str = unit_map.get(ins_units, "Unknown") except Exception as exc: log.debug("reading $INSUNITS from header: %s", exc) unit_str = "Unknown" return DrawingInfo( name=Path(self._doc_path).name if self._doc_path else "untitled.dxf", full_path=self._doc_path or "", saved=not self._dirty, entity_count=len(entities), layer_count=len(layers), block_count=len(list(doc.blocks)), extents_min=emin, extents_max=emax, units=unit_str, version=doc.dxfversion, backend="ezdxf", ) return await self._async(_sync) async def drawing_new(self, template: str | None = None) -> dict: def _sync(): if template and Path(template).exists(): doc = ezdxf.readfile(template) _normalise_dimstyle_rounding(doc) else: doc = ezdxf.new(dxfversion="R2010") _apply_iso_dimstyle(doc) # R32: registering a fresh Drawing is the release — the runaway keeps # the object it captured. The quarantined entry is evicted first so # it can never be re-activated. Clear before _reset_history_baseline, # which snapshots the new doc. self._evict_quarantined_document() released = self._clear_quarantine("drawing_new") key, _ = self._register_document(doc, None) self._reset_document_state() self._reset_history_baseline() result = { "ok": True, "name": "untitled.dxf", "document": key, "open_documents": len(self._docs), } if released is not None: result["quarantine_cleared"] = released.to_dict() return result return await self._async(_sync, quarantine_exit=True) async def drawing_open(self, path: str) -> dict: def _sync(): max_bytes = config.settings.max_dxf_bytes if max_bytes > 0: try: size = os.path.getsize(path) except OSError as exc: raise RuntimeError(f"Cannot stat DXF file: {exc}") from exc if size > max_bytes: raise RuntimeError( f"DXF file exceeds MAX_DXF_BYTES limit " f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override." ) doc = ezdxf.readfile(path) _normalise_dimstyle_rounding(doc) # R32: only reached if the read succeeded. Reopening the very path a # runaway doc.saveas is writing either fails loudly here (acceptable) # or hands back a truncated document (not) — _clear_quarantine # compares the two paths and says so. self._evict_quarantined_document() released = self._clear_quarantine("drawing_open", path) key, reloaded = self._register_document(doc, path) try: self._current_layer = doc.header.get("$CLAYER", "0") except Exception as exc: log.debug("reading $CLAYER from header: %s", exc) self._current_layer = "0" self._reset_document_state() self._reset_history_baseline() result = { "ok": True, "name": Path(path).name, "path": path, "document": key, "open_documents": len(self._docs), } if reloaded: result["reloaded"] = True if released is not None: result["quarantine_cleared"] = released.to_dict() return result return await self._async(_sync, quarantine_exit=True) async def drawing_save(self, path: str | None = None) -> dict: save_path = path or self._doc_path # `doc.saveas` derives the format from the extension and ezdxf can only # write DXF, so a .dwg destination here would silently mislabel the file. if save_path and _is_dwg_path(save_path): raise _refuse_dwg("drawing_save", save_path) def _sync(): doc = self._require_doc() if not save_path: raise RuntimeError("No path specified and no current file path.") self._refuse_path_held_elsewhere("drawing_save", save_path) doc.saveas(save_path) self._doc_path = save_path self._dirty = False return {"ok": True, "path": save_path} return await self._async(_sync) async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict: # The extension is authoritative (N2): a .dwg name is a DWG request even # when the caller passes fmt="dxf", because the bytes must match the name. if fmt.lower() == "dwg" or _is_dwg_path(path): raise _refuse_dwg("drawing_save_as", path) def _sync(): doc = self._require_doc() self._refuse_path_held_elsewhere("drawing_save_as", path) doc.saveas(path) self._doc_path = path self._dirty = False return {"ok": True, "path": path, "format": "dxf"} return await self._async(_sync) async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: """Export to PDF via ezdxf's matplotlib backend. With ``layout`` set to a paper-space layout name, the layout's own entities *and* the model content each viewport looks at are rendered, at the viewport's scale and position and clipped to its window. This docstring used to say model projection was COM-only. It was measured and it is not: a sheet holding nothing but a viewport renders the geometry that viewport shows, and geometry outside the window is correctly left out. What is genuinely missing is the viewport *border*, which the headless renderer does not draw. ``layout=None`` and ``layout="Model"`` both mean model space, as the tool advertises and as the COM engine plots. This used to route them through ``_msp()``, which follows the *current* tab, so with a sheet current the "Model" PDF was that sheet's paper-space content — a wrong page with no refusal. Measured: circle in model space, 390 x 270 rectangle on an A3 sheet, sheet current — the Model row's /MediaBox was 176.1 x 121.9 mm (the rectangle's aspect) instead of the circle's 121.9 x 121.9. The target is now ``doc.modelspace()`` whichever tab is current. """ def _sync(): doc = self._require_doc() resolved = self._find_layout(layout) if layout else None if layout and layout.strip().lower() != "model": if resolved is None: return {"ok": False, "error": f"Layout not found: {layout}"} target = doc.layouts.get(resolved) else: target = doc.modelspace() try: from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend # Track E: for a sheet, the figure *is* the paper. Sized from # the layout's page setup and left unadjusted, so the PDF's # /MediaBox is the paper size — the evidence page_setup_apply # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6 # pt whatever the sheet was. paper = None if resolved is not None and resolved != "Model": paper = self._paper_frame(target) fig = _new_agg_figure(figsize=paper["figsize"] if paper else None) ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) out = MatplotlibBackend(ax, adjust_figure=paper is None) Frontend(ctx, out).draw_layout(target, finalize=True) if paper is not None: ax.set_xlim(*paper["xlim"]) ax.set_ylim(*paper["ylim"]) ax.set_aspect("equal", adjustable="box") fig.savefig(path, dpi=150) result = {"ok": True, "path": path} if paper is not None: result["paper_mm"] = paper["size_mm"] result["scale"] = paper["scale"] result["effective_scale"] = paper["effective_scale"] result["scale_applied"] = paper["scale_applied"] result["plot_area"] = paper["plot_area"] result["plot_area_applied"] = paper["plot_area_applied"] if "plot_area_note" in paper: result["plot_area_note"] = paper["plot_area_note"] if not paper["rotation_applied"]: result["rotation_applied"] = False if resolved is not None and resolved != "Model": result["layout"] = resolved result["note"] = ( "Paper-space entities are rendered, and model content is projected " "through each viewport at its scale and clipped to its window. " "Viewport borders themselves are not drawn." ) return result except ImportError: raise RuntimeError( "PDF export requires matplotlib: pip install matplotlib" ) from None return await self._async(_sync) # ── layouts / paper space ──────────────────────────────────────────────── @staticmethod def _layout_name(raw: str) -> str: """Names arrive from tool input, so blanks have to die at the door. ``doc.layouts.delete("")`` deletes the first paper-space layout and ``doc.layouts.get("")`` returns the active one — neither refuses, so an empty string is a live footgun rather than a no-op. """ return str(raw or "").strip() def _find_layout(self, raw: str) -> str | None: """Resolve a layout name the way the engine does, or ``None``. ezdxf matches layout names case-insensitively in ``get``, ``new``, ``rename``, ``delete`` and ``set_active_layout``, but ``names()`` returns the *stored* spelling — so every guard written as ``name in doc.layouts.names()`` disagrees with the code it guards, in both directions: a duplicate walks past the check and dies on a raw ``DXFValueError`` from inside ezdxf, and an existing layout typed in another case is reported as missing. Returns the stored spelling so callers compare and store one canonical form. """ name = self._layout_name(raw) if not name: return None lowered = name.lower() return next( (n for n in self._require_doc().layouts.names() if n.lower() == lowered), None, ) def _free_layout_name(self, seed: str) -> str: """A layout name nothing currently holds, derived from ``seed``.""" for suffix in range(1, 1000): candidate = f"{seed}~{suffix}" if self._find_layout(candidate) is None: return candidate raise RuntimeError("cannot find a free temporary layout name") def _resync_space(self) -> None: """Re-assert ``_current_space`` over ``$TILEMODE`` and the active layout. Called after a lifecycle change so the three ways a caller can ask "where am I?" cannot drift apart. """ doc = self._require_doc() if self._current_space == "Model": doc.header["$TILEMODE"] = 1 else: doc.layouts.set_active_layout(self._current_space) doc.header["$TILEMODE"] = 0 async def layout_list(self) -> dict: def _sync(): doc = self._require_doc() names = list(doc.layouts.names_in_taborder()) # DXF stores the current tab as $TILEMODE (1 = Model) plus the # active paper-space layout binding (*Paper_Space block record). if int(doc.header.get("$TILEMODE", 1)): current = "Model" else: current = doc.active_layout().name return {"ok": True, "layouts": names, "current": current} return await self._async(_sync) async def layout_create(self, name: str) -> dict: def _sync(): doc = self._require_doc() wanted = self._layout_name(name) if not wanted: return {"ok": False, "error": "Layout name must not be blank"} existing = self._find_layout(wanted) if existing is not None: return {"ok": False, "error": f"Layout already exists: {existing}"} doc.layouts.new(wanted) self._mark_dirty() return {"ok": True, "layout": wanted} return await self._async(_sync) async def layout_set_current(self, name: str) -> dict: def _sync(): doc = self._require_doc() resolved = self._find_layout(name) if resolved is None: return {"ok": False, "error": f"Layout not found: {name}"} if resolved == "Model": doc.header["$TILEMODE"] = 1 else: # Rebind *Paper_Space so AutoCAD treats it as the active # paper-space layout; TILEMODE=0 selects the paper tab. doc.layouts.set_active_layout(resolved) doc.header["$TILEMODE"] = 0 # The whole point: subsequent entity_create_* calls write here. # Stored in the canonical spelling so _msp and _resync_space # compare against one form. self._current_space = resolved # $TILEMODE and the active-layout binding are persisted state: # the file on disk differs from the document after this, so the # close refusal and the ``saved`` row must know about it. self._mark_dirty() return {"ok": True, "current": resolved} return await self._async(_sync) async def layout_delete(self, name: str) -> dict: def _sync(): doc = self._require_doc() if not self._layout_name(name): return {"ok": False, "error": "Layout name must not be blank"} target = self._find_layout(name) if target is None: return {"ok": False, "error": f"Layout not found: {name}"} if target == "Model": return {"ok": False, "error": "Model space cannot be deleted"} paper = [n for n in doc.layouts.names() if n != "Model"] if len(paper) <= 1: return { "ok": False, "error": ( f"Cannot delete {target}: a drawing must keep at least one " "paper-space layout" ), } destroyed = len(list(doc.layouts.get(target))) doc.layouts.delete(target) # Destroyed entities stay in entitydb, where a plain `is None` check # walks straight past them and the next attribute access explodes. doc.entitydb.purge() if self._current_space == target: self._current_space = "Model" self._resync_space() self._mark_dirty() return { "ok": True, "deleted": target, "entities_destroyed": destroyed, "current": self._current_space, } return await self._async(_sync) async def layout_rename(self, old_name: str, new_name: str) -> dict: def _sync(): from ezdxf.lldxf.validator import is_valid_table_name doc = self._require_doc() new = self._layout_name(new_name) if not self._layout_name(old_name) or not new: return {"ok": False, "error": "Layout names must not be blank"} old = self._find_layout(old_name) if old is None: return {"ok": False, "error": f"Layout not found: {old_name}"} if "Model" in (old, new) or new.lower() == "model": return {"ok": False, "error": "Model space cannot be renamed or shadowed"} # Re-spelling a layout's own name is a rename onto itself, not a # collision — only a *different* layout blocks the new name. clash = self._find_layout(new) if clash is not None and clash != old: return {"ok": False, "error": f"Layout already exists: {clash}"} # `rename()` skips the name check `new()` performs, so `Bad/Name` # lands in the table and the file still audits clean. if not is_valid_table_name(new): return {"ok": False, "error": f"Invalid layout name: {new!r}"} if clash == old and new != old: # Pure re-spelling. ezdxf's own `rename` tests `new_name in # self` case-insensitively, which matches the layout against # itself, so a direct call refuses something AutoCAD allows. # Two renames through a name nothing else holds get there. staging = self._free_layout_name(new) doc.layouts.rename(old, staging) doc.layouts.rename(staging, new) else: doc.layouts.rename(old, new) if self._current_space == old: self._current_space = new self._resync_space() self._mark_dirty() return {"ok": True, "old": old, "new": new, "current": self._current_space} return await self._async(_sync) #: Attributes that identify a layout rather than describe it. Copying these #: onto a clone would point it back at the source's block record. _LAYOUT_IDENTITY_ATTRIBS = frozenset( {"handle", "owner", "name", "taborder", "block_record_handle", "viewport_handle"} ) @staticmethod def _remap_boundaries(layout, handle_map: dict[str, str]) -> tuple[int, int]: """Re-point cloned hatch boundaries at the cloned entities. A copied associative hatch keeps the *source* layout's handles in ``source_boundary_objects``. Nothing complains — both the live and the reloaded document audit clean — right up until the source layout is deleted and the reference dangles. """ remapped = dropped = 0 for entity in layout: if entity.dxftype() != "HATCH": continue for path in entity.paths: refs = list(getattr(path, "source_boundary_objects", []) or []) if not refs: continue kept = [] for handle in refs: mapped = handle_map.get(handle) if mapped is None: dropped += 1 else: kept.append(mapped) remapped += 1 path.source_boundary_objects = kept return remapped, dropped async def layout_copy(self, source: str, new_name: str) -> dict: def _sync(): from ezdxf.entities.copy import CopyNotSupported from ezdxf.lldxf.validator import is_valid_table_name doc = self._require_doc() dst_name = self._layout_name(new_name) if not self._layout_name(source) or not dst_name: return {"ok": False, "error": "Layout names must not be blank"} src_name = self._find_layout(source) if src_name is None: return {"ok": False, "error": f"Layout not found: {source}"} if src_name == "Model": return { "ok": False, "error": "Model space cannot be copied to a sheet; use viewport_create", } if dst_name.lower() == "model": return {"ok": False, "error": "Model space cannot be overwritten"} clash = self._find_layout(dst_name) if clash is not None: return {"ok": False, "error": f"Layout already exists: {clash}"} if not is_valid_table_name(dst_name): return {"ok": False, "error": f"Invalid layout name: {dst_name!r}"} src = doc.layouts.get(src_name) # A deleted layout leaves a gap in the tab order and `new()` reuses # it, so two tabs end up sharing an order and surviving reload. orders = [ int(doc.layouts.get(n).dxf.get("taborder", 0) or 0) for n in doc.layouts.names() if n != "Model" ] dst = doc.layouts.new(dst_name, dxfattribs={"taborder": max(orders, default=0) + 1}) # Set after `new()`, not through it: `new()` resets the paper limits # *after* applying dxfattribs, so a page setup passed in looks # copied and is not — the sheet then simply plots wrong. for key, value in src.dxf.all_existing_dxf_attribs().items(): if key not in self._LAYOUT_IDENTITY_ATTRIBS: dst.dxf.set(key, value) handle_map: dict[str, str] = {} skipped: list[str] = [] for entity in list(src): try: clone = doc.entitydb.duplicate_entity(entity) except (CopyNotSupported, NotImplementedError): skipped.append(entity.dxftype()) continue dst.add_entity(clone) handle_map[entity.dxf.handle] = clone.dxf.handle remapped, dropped = self._remap_boundaries(dst, handle_map) # The copy brought the source's main viewport with it; the new tab # has to point at *that* one, not at the source's handle. main = dst.main_viewport() if main is not None: dst.set_current_viewport_handle(main.dxf.handle) else: dst.dxf.discard("viewport_handle") self._mark_dirty() return { "ok": True, "source": src_name, "layout": dst_name, "entities_copied": len(handle_map), "skipped": skipped, "associativity_remapped": remapped, "associativity_dropped": dropped, } return await self._async(_sync) async def viewport_create( self, layout: str, center_x: float, center_y: float, width: float, height: float, view_center_x: float, view_center_y: float, scale: float = 1.0, ) -> dict: def _sync(): doc = self._require_doc() if scale <= 0: return {"ok": False, "error": "scale must be > 0 (paper:model, e.g. 0.5 for 1:2)"} if layout == "Model" or layout not in doc.layouts.names(): return {"ok": False, "error": f"Paper-space layout not found: {layout}"} target = doc.layouts.get(layout) view_height = height / scale viewport = target.add_viewport( center=(center_x, center_y), size=(width, height), view_center_point=(view_center_x, view_center_y), view_height=view_height, ) self._mark_dirty() return { "ok": True, "handle": viewport.dxf.handle, "layout": layout, "scale": scale, "view_height": view_height, } return await self._async(_sync) # --------------------------------------------------------------------------- # ── environment (track E) ─────────────────────────────────────────────────── # --------------------------------------------------------------------------- async def document_list(self) -> list[dict]: def _sync(): return [self._document_row(key, state) for key, state in self._docs.items()] return await self._async(_sync) async def document_activate(self, name_or_path: str) -> dict: if not isinstance(name_or_path, str): raise TypeError( f"document_activate: name_or_path must be a string, got {type(name_or_path).__name__}" ) def _sync(): key = self._resolve_document_key(name_or_path) previous = self._active_key self._activate_key(key) state = self._docs[key] return { "ok": True, "active": key, "previous": previous, "name": state.label, "path": state.path, } # Not a quarantine exit: a quarantined backend refuses to switch, because # the runaway thread holds the one worker and the one orphaned lock. return await self._async(_sync) async def document_close( self, name_or_path: str | None = None, save: bool = False, discard: bool = False ) -> dict: if save and discard: raise ValueError("document_close: save and discard are mutually exclusive") # Resolve once outside the lock to decide whether this is the active # document (then it is a quarantine exit, like drawing_close); the # closure resolves again under the lock before touching the registry. target = self._resolve_document_key(name_or_path) closing_active = target == self._active_key def _sync(): key = self._resolve_document_key(name_or_path) return self._close_document_sync(key, save=save, discard=discard) return await self._async(_sync, quarantine_exit=closing_active) # ── layer states ────────────────────────────────────────────────────────── @staticmethod def _layer_state_entry(states, name: str): """Case-insensitive lookup → ``(stored_key, xrecord)`` or ``(None, None)``.""" if states is None: return None, None if name in states: return name, states.get(name) for key in states.keys(): if key.lower() == name.lower(): return key, states.get(key) return None, None @staticmethod def _layer_state_chunks(xrecord) -> list[str]: return [str(tag.value) for tag in xrecord.tags if tag.code == 1000] @staticmethod def _layer_states(doc): from engineering.environment.layer_states import DICT_NAME return doc.rootdict.get(DICT_NAME) if DICT_NAME in doc.rootdict else None @staticmethod def _layer_state_names(states) -> list[str]: return list(states.keys()) if states is not None else [] def _layer_state_snapshot(self, doc, description): from engineering.environment.layer_states import snapshot_from_layers layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] plot = {lyr.dxf.name: bool(lyr.dxf.get("plot", 1)) for lyr in doc.layers} return snapshot_from_layers(layers, self._current_layer, plot=plot, description=description) async def layer_state_save(self, name, description=None) -> dict: """Snapshot the layer table into ``ACADMCP_LAYERSTATES/``. Portable and file-resident; not an AutoCAD Layer States Manager entry. """ from engineering.environment.layer_states import ( DICT_NAME, encode_state, validate_state_name, ) clean = validate_state_name(name) if description is not None and not isinstance(description, str): raise TypeError("layer_state_save: description must be a string") def _sync(): doc = self._require_doc() state = self._layer_state_snapshot(doc, description) chunks = encode_state(state) states = doc.rootdict.get_required_dict(DICT_NAME) existing_key, _ = self._layer_state_entry(states, clean) replaced = existing_key is not None if replaced: states.remove(existing_key) xrecord = states.add_xrecord(clean) xrecord.extend([(1000, chunk) for chunk in chunks]) self._mark_dirty() return { "ok": True, "name": clean, "layer_count": len(state["layers"]), "replaced": replaced, "chunks": len(chunks), "backend": "ezdxf", } return await self._async(_sync) async def layer_state_restore(self, name, properties=None) -> dict: from engineering.environment.layer_states import ( decode_state, diff_snapshot, validate_properties, validate_state_name, ) clean = validate_state_name(name) props = validate_properties(properties) def _sync(): doc = self._require_doc() states = self._layer_states(doc) key, xrecord = self._layer_state_entry(states, clean) if xrecord is None: raise ValueError( f"layer_state_restore: no layer state named {clean!r}; " f"saved states: {self._layer_state_names(states)}" ) state = decode_state(self._layer_state_chunks(xrecord)) layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] missing, new = diff_snapshot(state, layers) applied = 0 for layer_name, snap in state["layers"].items(): if layer_name not in doc.layers: continue # missing: reported, never created lyr = doc.layers.get(layer_name) # Colour first: `Layer.color` keeps the on/off sign, and the # on/off call below must see the final colour. if "color" in props: lyr.color = int(snap["color"]) if "linetype" in props: _ensure_linetype_loaded(doc, snap["linetype"]) lyr.dxf.linetype = snap["linetype"] if "lineweight" in props: lyr.dxf.lineweight = int(snap["lineweight"]) if "plot" in props: lyr.dxf.plot = 1 if snap["plot"] else 0 if "on" in props: if snap["on"]: lyr.on() else: lyr.off() if "frozen" in props: if snap["frozen"]: lyr.freeze() else: lyr.thaw() if "locked" in props: if snap["locked"]: lyr.lock() else: lyr.unlock() applied += 1 current = None if "current" in props and state["current_layer"] in doc.layers: current = state["current_layer"] self._current_layer = current doc.header["$CLAYER"] = current self._mark_dirty() return { "ok": True, "name": key, "applied": { "layers": applied, "properties": list(props), "current_layer": current, }, "missing_layers": missing, "new_layers": new, "backend": "ezdxf", } return await self._async(_sync) async def layer_state_list(self) -> list[dict]: from engineering.environment.layer_states import decode_state def _sync(): doc = self._require_doc() states = self._layer_states(doc) rows = [] for key in self._layer_state_names(states): state = decode_state(self._layer_state_chunks(states.get(key))) rows.append( { "name": key, "description": state.get("description"), "layer_count": len(state["layers"]), } ) return rows return await self._async(_sync) async def layer_state_delete(self, name) -> dict: from engineering.environment.layer_states import validate_state_name clean = validate_state_name(name) def _sync(): doc = self._require_doc() states = self._layer_states(doc) key, xrecord = self._layer_state_entry(states, clean) if xrecord is None: raise ValueError( f"layer_state_delete: no layer state named {clean!r}; " f"saved states: {self._layer_state_names(states)}" ) states.remove(key) self._mark_dirty() return {"ok": True, "deleted": key, "backend": "ezdxf"} return await self._async(_sync) # ── named views ─────────────────────────────────────────────────────────── # `_active_vport` (the `*Active` VPORT entry) is defined once, in the # `# ── settings (track E, group C) ──` block at the end of the class. @staticmethod def _view_entry(doc, name: str): if name in doc.views: # case-insensitive in ezdxf's table return doc.views.get(name) return None async def view_named_save(self, name, center=None, height=None, width=None) -> dict: from engineering.environment.names import validate_name from engineering.environment.views import resolve_view_args clean = validate_name(name, what="view name") args = resolve_view_args(center, height, width) def _sync(): doc = self._require_doc() vport = self._active_vport(doc) aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34 if args["center"] is None or args["height"] is None: # Headless there is no display to read a "current view" from; # the drawing's extents are the honest default. bb = ezdxf_bbox.extents(doc.modelspace()) if _BBOX_OK else None if bb is None or not bb.has_data: raise ValueError( f"view_named_save: {clean!r} — an empty drawing has no extents; " "pass center and height" ) ext_center = ( (bb.extmin.x + bb.extmax.x) / 2.0, (bb.extmin.y + bb.extmax.y) / 2.0, ) # The window must hold the extents at the viewport's aspect: # whichever side binds decides the height. ext_height = max(bb.extmax.y - bb.extmin.y, (bb.extmax.x - bb.extmin.x) / aspect) if ext_height <= 0: ext_height = 1.0 else: ext_center, ext_height = args["center"], args["height"] cx, cy = args["center"] or ext_center h = args["height"] or ext_height w = args["width"] or h * aspect existing = self._view_entry(doc, clean) replaced = existing is not None stored = existing.dxf.name if replaced else clean if replaced: doc.views.remove(stored) # The tool stores a plan window. ezdxf's VIEW default is # direction=(1, 1, 1) — an isometric view — so the orientation must # be written explicitly or AutoCAD restores the entry oblique and # reads ``center`` in that DCS. Measured: the VPORT default is plan, # which is why a save→restore round trip alone never shows it. doc.views.add( stored, dxfattribs={ "center": (cx, cy, 0.0), "height": h, "width": w, "direction": (0.0, 0.0, 1.0), "target": (0.0, 0.0, 0.0), "view_twist": 0.0, }, ) self._mark_dirty() return { "ok": True, "name": stored, "center": [float(cx), float(cy)], "height": float(h), "width": float(w), "replaced": replaced, "backend": "ezdxf", } return await self._async(_sync) async def view_named_restore(self, name) -> dict: from engineering.environment.names import validate_name clean = validate_name(name, what="view name") def _sync(): doc = self._require_doc() view = self._view_entry(doc, clean) if view is None: raise ValueError( f"view_named_restore: no named view {clean!r}; " f"saved views: {[v.dxf.name for v in doc.views]}" ) cx, cy = float(view.dxf.center.x), float(view.dxf.center.y) h, w = float(view.dxf.height), float(view.dxf.width) vport = self._active_vport(doc) # The *Active VPORT's aspect ratio is the DISPLAY's, written by # AutoCAD at its last save, and AutoCAD reconciles the stored # window against the real display by its lower-left corner: a # stored aspect that differs from the display's shifts the centre # by (display − stored) · height / 2. Measured (AutoCAD 2026, # display aspect 2.013): a 10x20 view at (5, 6) written with # aspect 0.5 opened at VIEWCTR (20.134, 6). So the aspect is left # as the file carries it and the window is FITTED into it — # height = max(h, w / aspect), the same rule `-VIEW _R` applies — # which is exactly what AutoCAD's own restore-then-save writes. aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34 fit_height = max(h, w / aspect) vport.dxf.center = (cx, cy) vport.dxf.height = fit_height # The orientation travels with the window: a foreign drawing's # VIEW may be oblique or twisted, and flattening it to a plan view # of the same center would be a silent lie. The attribute read # mirrors what the file carries: ezdxf forces these tags on export, # so an unset direction is (1, 1, 1) on disk as well as here. vport.dxf.direction = tuple(view.dxf.direction) vport.dxf.target = tuple(view.dxf.target) vport.dxf.view_twist = float(view.dxf.view_twist) self._mark_dirty() return { "ok": True, "name": view.dxf.name, "center": [cx, cy], "height": h, "width": w, # The plan's literal. Measured: ezdxf's header refuses $VIEWCTR, # so the store is the *Active VPORT — what AutoCAD reads as the # initial view when it opens the file. No live display moves. "applied": "header_only", "store": "vport_active", "vport_height": fit_height, "aspect_ratio": aspect, "backend": "ezdxf", } return await self._async(_sync) async def view_named_list(self) -> list[dict]: def _sync(): doc = self._require_doc() return [ { "name": view.dxf.name, "center": [float(view.dxf.center.x), float(view.dxf.center.y)], "height": float(view.dxf.height), "width": float(view.dxf.width), } for view in doc.views ] return await self._async(_sync) # ── UCS ─────────────────────────────────────────────────────────────────── @staticmethod def _ucs_entry(doc, name: str): for ucs in doc.ucs: if ucs.dxf.name.lower() == name.lower(): return ucs return None @staticmethod def _set_ucs_header(doc, name: str, origin, x_axis, y_axis) -> None: doc.header["$UCSNAME"] = name doc.header["$UCSORG"] = tuple(origin) doc.header["$UCSXDIR"] = tuple(x_axis) doc.header["$UCSYDIR"] = tuple(y_axis) @staticmethod def _ucs_row(name, origin, x_axis, y_axis, current: bool) -> dict: return { "name": name, "origin": [float(c) for c in origin], "x_axis": [float(c) for c in x_axis], "y_axis": [float(c) for c in y_axis], "current": current, } async def ucs_list(self) -> list[dict]: from engineering.environment.ucs import ( WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS, is_world_axes, ) def _sync(): doc = self._require_doc() header = doc.header current = str(header.get("$UCSNAME", "") or "") origin = tuple(header.get("$UCSORG", WORLD_ORIGIN)) x_axis = tuple(header.get("$UCSXDIR", WORLD_X_AXIS)) y_axis = tuple(header.get("$UCSYDIR", WORLD_Y_AXIS)) rows = [self._ucs_row(WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS, False)] named_current = False for ucs in doc.ucs: is_current = current != "" and ucs.dxf.name.lower() == current.lower() named_current = named_current or is_current rows.append( self._ucs_row( ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis, is_current ) ) if named_current: return rows # No saved entry is current. An empty $UCSNAME is NOT "world": an # unnamed UCS (UCS Origin / 3P without saving) has an empty name # too, and AutoCAD writes exactly that. The frame decides, the way # WORLDUCS does live. if is_world_axes(origin, x_axis, y_axis): rows[0]["current"] = True else: rows.append(self._ucs_row(None, origin, x_axis, y_axis, True)) return rows return await self._async(_sync) async def ucs_set(self, name, origin, x_axis, y_axis) -> dict: from engineering.environment.names import validate_name from engineering.environment.ucs import WORLD, resolve_ucs_axes clean = validate_name(name, what="UCS name") if clean.lower() == WORLD: raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS") axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any write def _sync(): doc = self._require_doc() existing = self._ucs_entry(doc, clean) replaced = existing is not None stored = existing.dxf.name if replaced else clean if replaced: doc.ucs.remove(stored) doc.ucs.add( stored, dxfattribs={ "origin": axes["origin"], "xaxis": axes["x_axis"], "yaxis": axes["y_axis"], }, ) self._set_ucs_header(doc, stored, axes["origin"], axes["x_axis"], axes["y_axis"]) self._mark_dirty() return { "ok": True, "name": stored, "origin": list(axes["origin"]), "x_axis": list(axes["x_axis"]), "y_axis": list(axes["y_axis"]), "replaced": replaced, "current": True, "backend": "ezdxf", } return await self._async(_sync) async def ucs_restore(self, name) -> dict: from engineering.environment.names import validate_name from engineering.environment.ucs import WORLD clean = validate_name(name, what="UCS name") def _sync(): doc = self._require_doc() if clean.lower() == WORLD: self._set_ucs_header(doc, "", (0, 0, 0), (1, 0, 0), (0, 1, 0)) self._mark_dirty() return {"ok": True, "name": WORLD, "current": True, "backend": "ezdxf"} ucs = self._ucs_entry(doc, clean) if ucs is None: raise ValueError( f"ucs_restore: no UCS named {clean!r}; " f"saved: {[u.dxf.name for u in doc.ucs]} (or 'world')" ) self._set_ucs_header(doc, ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis) self._mark_dirty() return {"ok": True, "name": ucs.dxf.name, "current": True, "backend": "ezdxf"} return await self._async(_sync) # ── viewports ──────────────────────────────────────────────────────────── #: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM). _VP_LOCK_ZOOM = 0x4000 #: R12 has no slot for view_height or flags, so both are dropped on export. _R12_NOTE = "R12 stores neither viewport scale nor display lock; both are reported as null" def _paper_layouts(self) -> list[str]: return [n for n in self._require_doc().layouts.names_in_taborder() if n != "Model"] def _resolve_viewport(self, handle: str): """``(viewport, None)`` or ``(None, refusal)``. Liveness is checked before the type, because a deleted entity stays in ``entitydb`` and still answers ``dxftype() == "VIEWPORT"`` — so a type-check-first resolver hands back a corpse. """ doc = self._require_doc() key = str(handle or "").strip().upper() entity = doc.entitydb.get(key) if entity is None or not entity.is_alive: return None, {"ok": False, "error": f"Viewport handle not found: {handle}"} if entity.dxftype() != "VIEWPORT": return None, { "ok": False, "error": f"Handle {key} is a {entity.dxftype()}, not a VIEWPORT", } return entity, None @staticmethod def _is_main_viewport(viewport) -> bool: """Ask the layout, rather than testing ``id == 1``. ezdxf resolves the main viewport by ``status == 1`` first and falls back to ``id == 1``, and the two can disagree. """ try: layout = viewport.get_layout() except Exception: return False main = layout.main_viewport() if layout is not None else None return main is not None and main.dxf.handle == viewport.dxf.handle def _is_r12(self) -> bool: return self._require_doc().dxfversion == "AC1009" def _viewport_row(self, viewport, layout_name: str, r12: bool) -> dict: center = viewport.dxf.center view_center = viewport.dxf.get("view_center_point", None) view_height = viewport.dxf.get("view_height", None) flags = viewport.dxf.get("flags", None) return { "handle": viewport.dxf.handle, "layout": layout_name, "center": [float(center.x), float(center.y)], "width": float(viewport.dxf.width), "height": float(viewport.dxf.height), "view_center": ( [float(view_center.x), float(view_center.y)] if view_center is not None else None ), "view_height": float(view_height) if view_height is not None else None, # get_scale() divides by a view_height the file does not have and # returns a number anyway; refuse to pass that on as a scale. "scale": (None if (r12 or not view_height) else float(viewport.get_scale())), # On R2000+ an absent flags field means "no flags set", i.e. # unlocked. On R12 the field cannot exist at all, so the honest # answer is "this file cannot say". "locked": (None if r12 else bool(int(flags or 0) & self._VP_LOCK_ZOOM)), "status": viewport.dxf.get("status", None), "id": viewport.dxf.get("id", None), "is_main": self._is_main_viewport(viewport), } async def viewport_list(self, layout: str | None = None) -> dict: def _sync(): doc = self._require_doc() if layout is None: names = self._paper_layouts() else: if not self._layout_name(layout): return {"ok": False, "error": "Layout name must not be blank"} resolved = self._find_layout(layout) if resolved is None: return {"ok": False, "error": f"Layout not found: {layout}"} if resolved == "Model": return {"ok": False, "error": "Model space has no paper-space viewports"} names = [resolved] r12 = self._is_r12() rows = [ self._viewport_row(vp, name, r12) for name in names for vp in doc.layouts.get(name).viewports() ] result = {"ok": True, "viewports": rows, "count": len(rows)} if r12: result["note"] = self._R12_NOTE return result return await self._async(_sync) async def viewport_set_scale(self, handle: str, scale: float) -> dict: def _sync(): if float(scale) <= 0: return {"ok": False, "error": "scale must be > 0 (paper:model, e.g. 0.5 for 1:2)"} viewport, refusal = self._resolve_viewport(handle) if refusal: return refusal if self._is_r12(): return { "ok": False, "error": ( "R12 drops view_height on export, so the scale would be lost on save. " "Save as R2000 or newer first." ), } if self._is_main_viewport(viewport): return { "ok": False, "error": ( "This is the layout's main viewport: its view height is the tab's own " "pan/zoom state, not a drafting scale. Create a viewport with " "viewport_create and scale that." ), } height = float(viewport.dxf.height) if height <= 0: return {"ok": False, "error": "Viewport has zero height; its scale is undefined"} viewport.dxf.view_height = height / float(scale) self._mark_dirty() return { "ok": True, "handle": viewport.dxf.handle, "scale": float(viewport.get_scale()), "view_height": float(viewport.dxf.view_height), "note": "geometric scale only; annotative text and dimensions do not resize", } return await self._async(_sync) async def viewport_lock(self, handle: str, locked: bool = True) -> dict: def _sync(): viewport, refusal = self._resolve_viewport(handle) if refusal: return refusal if self._is_r12(): return { "ok": False, "error": ( "R12 has no viewport flags field, so the lock would be lost on save. " "Save as R2000 or newer first." ), } # Read-modify-write: `flags` also carries the UCS-icon and grid # bits, and a bare assignment drops them and still saves cleanly. flags = int(viewport.dxf.get("flags", 0) or 0) viewport.dxf.flags = ( flags | self._VP_LOCK_ZOOM if locked else flags & ~self._VP_LOCK_ZOOM ) self._mark_dirty() return { "ok": True, "handle": viewport.dxf.handle, "locked": bool(viewport.dxf.flags & self._VP_LOCK_ZOOM), } return await self._async(_sync) async def viewport_delete(self, handle: str, force: bool = False) -> dict: def _sync(): viewport, refusal = self._resolve_viewport(handle) if refusal: return refusal was_main = self._is_main_viewport(viewport) if was_main and not force: return { "ok": False, "error": ( "This is the layout's main viewport; deleting it leaves the tab without " "its own view state. Pass force=true if that is what you want." ), } layout = viewport.get_layout() layout_name = layout.name if layout is not None else None layout.delete_entity(viewport) repaired = False if was_main and layout is not None: survivor = next(iter(layout.viewports()), None) if survivor is not None: layout.set_current_viewport_handle(survivor.dxf.handle) else: # A pointer at a destroyed entity audits clean here and # breaks in AutoCAD; drop it instead. layout.dxf.discard("viewport_handle") repaired = True self._mark_dirty() return { "ok": True, "handle": str(handle).strip().upper(), "layout": layout_name, "was_main": was_main, "viewport_handle_repaired": repaired, } return await self._async(_sync) # ── change space (CHSPACE) ─────────────────────────────────────────────── #: Types whose ``transform()`` is accepted and does nothing: the matrix is #: parked in a pending transformation only an ACIS kernel could apply. _CHSPACE_ACIS_TYPES = frozenset({"3DSOLID", "BODY", "REGION", "SURFACE", "EXTRUDEDSURFACE"}) #: Types whose ``transform()`` raises. ``hasattr(e, "transform")`` is True #: for all of them, so a capability probe does not filter them out. _CHSPACE_UNTRANSFORMABLE = frozenset( {"ACAD_TABLE", "ACAD_PROXY_ENTITY", "OLE2FRAME", "OLEFRAME"} ) async def entity_change_space( self, handles: list[str], viewport_handle: str, direction: str = "to_paper", freeze_dimensions: bool = False, ) -> dict: def _sync(): from ezdxf.math import Vec3 doc = self._require_doc() if direction not in ("to_paper", "to_model"): return { "ok": False, "error": f"direction must be to_paper or to_model: {direction!r}", } if not handles: return {"ok": False, "error": "No entity handles given"} viewport, refusal = self._resolve_viewport(viewport_handle) if refusal: return refusal # ezdxf hands back a top-view matrix for a non-top view without # complaining, and rotates about the paper origin for a twisted # one. Both produce plausible-looking, wrong geometry. if not viewport.is_top_view: return { "ok": False, "error": ( "Viewport is not a plan (top) view; CHSPACE through it would place " "geometry using a projection this engine cannot reproduce." ), } if float(viewport.dxf.get("view_twist_angle", 0.0) or 0.0): return { "ok": False, "error": ( "Viewport has a view twist angle; this engine rotates about the paper " "origin rather than the view centre, so the result would be wrong." ), } sheet = viewport.get_layout() if sheet is None: return {"ok": False, "error": "Viewport is not on a paper-space layout"} model = doc.modelspace() to_paper = direction == "to_paper" source, target = (model, sheet) if to_paper else (sheet, model) to_paper_matrix = viewport.get_transformation_matrix() if to_paper: matrix = to_paper_matrix else: # Matrix44.inverse() mutates in place and returns None. matrix = to_paper_matrix.copy() matrix.inverse() scale = float(viewport.get_scale()) vp_center = viewport.dxf.center half_w, half_h = float(viewport.dxf.width) / 2, float(viewport.dxf.height) / 2 paper_limits = sheet.get_paper_limits() moved: list[dict] = [] refused: list[dict] = [] for raw in handles: key = str(raw or "").strip().upper() entity = doc.entitydb.get(key) if entity is None or not entity.is_alive: refused.append({"handle": raw, "reason": f"Entity handle not found: {raw}"}) continue dxftype = entity.dxftype() if dxftype == "VIEWPORT": refused.append( {"handle": key, "reason": "VIEWPORT entities do not change space"} ) continue if dxftype in self._CHSPACE_ACIS_TYPES: refused.append( { "handle": key, "reason": ( f"{dxftype}: this engine accepts the transform and moves no " "geometry (no ACIS kernel), so the move would be a lie" ), } ) continue if dxftype in self._CHSPACE_UNTRANSFORMABLE: refused.append( {"handle": key, "reason": f"{dxftype} cannot be transformed by this engine"} ) continue # Membership is checked before anything is written, because # `move_to_layout` raises from deep inside ezdxf *after* the # transform has been applied — leaving the entity rescaled by a # viewport it never moved through. owner = entity.dxf.owner if owner == target.block_record.dxf.handle: refused.append( {"handle": key, "reason": "Entity is already in the target space"} ) continue if owner != source.block_record.dxf.handle: refused.append( { "handle": key, "reason": ( f"Entity is in {self._space_name(owner)}, not in the space this " f"move starts from ({'Model' if to_paper else sheet.name})" ), } ) continue frozen_text = None if dxftype == "DIMENSION": if not freeze_dimensions: refused.append( { "handle": key, "reason": ( "DIMENSION: scaling changes the measurement while the baked " "dimension text keeps the old value. Pass " "freeze_dimensions=true to bake the current measurement in " "first." ), } ) continue frozen_text = self._format_measurement(entity.get_measurement()) entity.dxf.text = frozen_text self._transform(entity, matrix) source.move_to_layout(entity, target) # Where the entity now sits, expressed in paper coordinates. # Clipping is reported, not enforced: AutoCAD lets you move # geometry the viewport does not show, and so does this — but # the caller has to be told the drawing left the sheet. anchor = _label_anchor(entity) inside = on_sheet = None if anchor is not None: point = Vec3(anchor) if not to_paper: point = to_paper_matrix.transform(point) inside = ( abs(point.x - vp_center.x) <= half_w and abs(point.y - vp_center.y) <= half_h ) on_sheet = ( paper_limits[0].x <= point.x <= paper_limits[1].x and paper_limits[0].y <= point.y <= paper_limits[1].y ) row = { "handle": key, "type": dxftype, "from": "Model" if to_paper else sheet.name, "to": sheet.name if to_paper else "Model", "inside_viewport": inside, "on_sheet": on_sheet, } if frozen_text is not None: row["frozen_text"] = frozen_text moved.append(row) if moved: self._mark_dirty() return { "ok": True, "moved": moved, "refused": refused, "scale": scale, "viewport": viewport.dxf.handle, "layout": sheet.name, } return await self._async(_sync) def _space_name(self, block_record_handle: str) -> str: """Name the layout tab an owning block record belongs to.""" doc = self._require_doc() if block_record_handle == doc.modelspace().block_record.dxf.handle: return "Model" for name in doc.layouts.names(): if name == "Model": continue if doc.layouts.get(name).block_record.dxf.handle == block_record_handle: return name return "a block definition" @staticmethod def _format_measurement(value: float) -> str: """Render a dimension measurement the way a dimension text reads.""" text = f"{float(value):.4f}".rstrip("0").rstrip(".") return text or "0" # ── page setup (track E) ───────────────────────────────────────────────── def _paper_layout(self, raw: str, operation: str): """Resolve a paper-space layout by name, refusing Model and unknowns.""" doc = self._require_doc() resolved = self._find_layout(raw) if resolved is None: available = [n for n in doc.layouts.names_in_taborder() if n != "Model"] raise ValueError( f"{operation}: layout {raw!r} not found (paper-space layouts: " f"{', '.join(available) or 'none'})" ) if resolved == "Model": raise ValueError( f"{operation}: Model space has no sheet; plot it with " "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" ) return doc.layouts.get(resolved) @staticmethod def _stored_sheet(dxf) -> tuple[list[float], list[float]]: """``(size_mm, margins_mm)`` of the sheet *as plotted*, from the media as stored: ``papers.turned_sheet`` over PLOTSETTINGS 40-47 and ``plot_rotation``. DXF stores every paper size and margin in millimetres, whatever ``plot_paper_units`` says (ezdxf's acdb_plot_settings, verified). ``margins_mm`` is [top, bottom, left, right]. """ from engineering.standards.papers import turned_sheet return turned_sheet( dxf.paper_width, dxf.paper_height, (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin), dxf.plot_rotation, ) @classmethod def _page_setup_row(cls, layout) -> dict: from engineering.standards.papers import ( PLOT_TYPE_NAMES, center_applies, dxf_scale_label, paper_from_size, paper_units_name, scale_label, ) dxf = layout.dxf_layout.dxf flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) # The sheet as plotted: size *and* margins turned by plot_rotation # (the same mapping _paper_frame plots by). Swapping only the size # reported a 297 x 210 sheet with its portrait margins. (width, height), margins = cls._stored_sheet(dxf) media = str(dxf.paper_size) numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) if flags & layout.USE_STANDARD_SCALE: scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator) else: scale = scale_label(numerator, denominator) plot_type = int(dxf.plot_type) return { "layout": layout.name, "paper": paper_from_size(width, height) or media, "canonical_media_name": media, "size_mm": [round(width, 2), round(height, 2)], "orientation": "landscape" if width >= height else "portrait", "plot_style": str(dxf.current_style_sheet), "scale": scale, "plot_area": PLOT_TYPE_NAMES.get(plot_type, str(dxf.plot_type)), "device": str(dxf.plot_configuration_file), "margins_mm": [round(v, 3) for v in margins], # What one paper-space unit means at the plot scale (DXF group 72). "paper_units": paper_units_name(dxf.plot_paper_units), # A layout plot has no centring (AutoCAD greys it out; ActiveX # refuses CenterPlot=True under acLayout): None, not a bit nobody honours. "center": bool(flags & layout.PLOT_CENTERED) if center_applies(plot_type) else None, } @staticmethod def _plot_scale(layout) -> tuple[str, float | None, float | None]: """``(label, numerator, denominator)`` of the layout's plot scale; the terms are ``None`` for *scaled to fit* (code 75 = 0). Reads the same bits ``_page_setup_row`` reports, so the label here is the label ``page_setup_list`` shows: a standard code under ``USE_STANDARD_SCALE`` resolves through the authored ``DXF_STD_SCALES`` (the ObjectARX enum AutoCAD writes — ezdxf's ``STD_SCALES`` is one off above 18 and would render an AutoCAD-authored 1:10 sheet at 1:16), anything else through the stored numerator / denominator. """ from engineering.standards.papers import DXF_STD_SCALES, dxf_scale_label, scale_label dxf = layout.dxf_layout.dxf flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) if flags & layout.USE_STANDARD_SCALE: code = int(dxf.standard_scale_type) if code == 0: return "fit", None, None if code in DXF_STD_SCALES: numerator, denominator = (float(v) for v in DXF_STD_SCALES[code]) return dxf_scale_label(code, numerator, denominator), numerator, denominator return scale_label(numerator, denominator), numerator, denominator @staticmethod def _plot_extents(layout): """Paper-space extents AutoCAD's *extents* plot area means, or ``None``. ``ezdxf.bbox`` counts the main paper-space viewport (id 1, the whole sheet) as an entity; AutoCAD does not, so it is left out here. """ from ezdxf import bbox entities = [ e for e in layout if not (e.dxftype() == "VIEWPORT" and int(e.dxf.get("id", 0)) == 1) ] if not entities: return None box = bbox.extents(entities, fast=True) return box if box.has_data else None @classmethod def _paper_frame(cls, layout) -> dict: """The sheet as a matplotlib figure: size in inches plus the axes window. Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper and the content lands on it at the layout's plot scale. **Scale.** The axes window spans the sheet in paper-space units, and that is ``sheet_mm / unit x denominator / numerator`` — at 1:2 an A3 sheet holds 840 x 594 units, not 420 x 297. It used to be the sheet at 1:1 whatever ``page_setup_apply`` had written (measured: a 1:2 sheet exported pixel-identical to the same sheet at 1:1, ``ok: True``, and the MediaBox read as correct, so nothing downstream could see it). ``fit`` on a *layout* plot is 1:1: AutoCAD greys "Fit to paper" out under that plot area (reported as ``effective_scale``). **Plot area.** ``layout`` (5) is the sheet from the plot origin. ``extents`` (1) is the paper-space extents (the main viewport excluded, as AutoCAD excludes it) placed at the plot origin, or centred on the printable area under ``PLOT_CENTERED``; with ``fit`` the extents are scaled to the printable area. The plot areas the headless engine cannot know (display, view) and the ones it does not read (limits, window) fall back to the sheet window and say so in ``plot_area_applied``. ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media convention: the media stays 210 x 297 and the sheet is turned, so paper-space X runs along the 297 mm side. The figure *and* the axes window both take the turned size — sizing only the figure left the window 210 wide inside a 297 mm page, and ``set_aspect("equal")`` then shrank the axes box to 0.707 and cut everything past x = 210 minus the margin (measured: a 277 mm frame lost its right 27%, with ``ok: True`` and a MediaBox that read as correct). The margins turn with the sheet, mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them — ``papers.turned_sheet``, the one mapping ``page_setup_list`` reports by, so the row and the plot never disagree. What the headless renderer still does not do is rotate the *content* on the media (reported as ``rotation_applied``, not hidden). """ from engineering.standards.papers import PLOT_TYPE_NAMES, scale_label dxf = layout.dxf_layout.dxf rotation = int(dxf.plot_rotation) unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 (sheet_w, sheet_h), (top, bottom, left, right) = cls._stored_sheet(dxf) offset_x = float(dxf.plot_origin_x_offset) offset_y = float(dxf.plot_origin_y_offset) printable_w = max(sheet_w - left - right, 1e-9) printable_h = max(sheet_h - top - bottom, 1e-9) label, numerator, denominator = cls._plot_scale(layout) plot_type = int(dxf.plot_type) plot_area = PLOT_TYPE_NAMES.get(plot_type, str(plot_type)) flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) centered = bool(flags & layout.PLOT_CENTERED) box = cls._plot_extents(layout) if plot_type == 1 else None plot_area_applied = plot_type == 5 or box is not None note = None if plot_type == 1 and box is None: note = "extents: the layout holds no entities, so the sheet window was plotted" elif plot_type not in (1, 5): note = ( f"plot area {plot_area!r} is not rendered headlessly; the sheet window was plotted" ) # Paper-space units per millimetre of sheet. scale_applied = True effective = label if ( numerator is not None and denominator is not None and (numerator <= 0 or denominator <= 0) ): scale_applied, effective, units_per_mm = False, "1:1", 1.0 / unit elif numerator is None: # scaled to fit if box is not None: ratios = [ printable_w / box.size.x if box.size.x > 1e-12 else None, printable_h / box.size.y if box.size.y > 1e-12 else None, ] usable = [r for r in ratios if r is not None] mm_per_unit = min(usable) if usable else 1.0 units_per_mm = 1.0 / mm_per_unit # ``unit`` is millimetres per paper unit (25.4 under inches), # so paper units per drawing unit *divides* by it. Multiplying # reported a 1:1.06 inch fit as ``607.06:1`` (off by 25.4^2) # while the render itself was right, ``units_per_mm`` being # unit-free; the mm sheet hid it because there ``unit`` is 1. paper_per_unit = mm_per_unit / unit effective = ( scale_label(paper_per_unit, 1.0) if paper_per_unit >= 1.0 else scale_label(1.0, 1.0 / paper_per_unit) ) else: # Fit under a layout plot is not a choice AutoCAD offers: the # Page Setup dialog greys it out and the sheet plots at 1:1. units_per_mm, effective = 1.0 / unit, "1:1" else: units_per_mm = denominator / numerator / unit # Lower-left corner of the *sheet* in paper-space units. if box is None: x0 = -(left + offset_x) * units_per_mm y0 = -(bottom + offset_y) * units_per_mm elif centered: x0 = box.center.x - (left + printable_w / 2.0) * units_per_mm y0 = box.center.y - (bottom + printable_h / 2.0) * units_per_mm else: x0 = box.extmin.x - (left + offset_x) * units_per_mm y0 = box.extmin.y - (bottom + offset_y) * units_per_mm frame = { "size_mm": [round(sheet_w, 2), round(sheet_h, 2)], "figsize": (sheet_w / 25.4, sheet_h / 25.4), "xlim": (x0, x0 + sheet_w * units_per_mm), "ylim": (y0, y0 + sheet_h * units_per_mm), "rotation_applied": rotation == 0, "scale": label, "effective_scale": effective, "scale_applied": scale_applied, "plot_area": plot_area, "plot_area_applied": plot_area_applied, } if note: frame["plot_area_note"] = note return frame async def page_setup_list(self, layout: str | None = None) -> list[dict]: def _sync(): doc = self._require_doc() if layout is not None and self._layout_name(layout): return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))] return [ self._page_setup_row(doc.layouts.get(name)) for name in doc.layouts.names_in_taborder() if name != "Model" ] return await self._async(_sync) async def page_setup_apply(self, layout: str, setup: dict) -> dict: from engineering.standards.papers import ( PAPER_UNITS, page_setup_warnings, require_page_setup, ) resolved = require_page_setup(setup) def _sync(): target = self._paper_layout(layout, "page_setup_apply") before = self._page_setup_row(target) dxf = target.dxf_layout.dxf # Measured: a fresh layout has no stored plot_layout_flags, and # set_flag_state starts from 0 — toggling one bit would drop # plot-styles (32), lineweights (128) and viewports-first (512). if not dxf.hasattr("plot_layout_flags"): dxf.plot_layout_flags = dxf.get_default("plot_layout_flags") width, height = (float(v) for v in resolved["size_mm"]) # Orientation rides on width/height, as AutoCAD names media, so # plot_rotation goes to 0. The stored margins only meant what # they meant *through* the old turn: with none asked for, the # sheet keeps the margins it had (the turned ones are written as # the unturned ones), instead of silently swapping to the media's # portrait margins with nothing in ``changed``. margins = resolved["margins_mm"] if margins is None: margins = self._stored_sheet(dxf)[1] # turned, un-rounded dxf.page_setup_name = "" dxf.plot_configuration_file = resolved["device"] dxf.paper_size = resolved["canonical_media_name"] dxf.paper_width = width dxf.paper_height = height if resolved["paper_units"] is not None: dxf.plot_paper_units = PAPER_UNITS[resolved["paper_units"]] dxf.plot_rotation = 0 top, bottom, left, right = (float(v) for v in margins) dxf.top_margin, dxf.bottom_margin = top, bottom dxf.left_margin, dxf.right_margin = left, right dxf.plot_origin_x_offset = 0.0 dxf.plot_origin_y_offset = 0.0 dxf.current_style_sheet = resolved["plot_style"] target.use_plot_styles(True) target.set_plot_type(int(resolved["plot_type"])) # The scale is stored three ways and AutoCAD reads a different # one per route (measured, AutoCAD 2026 — papers.py docstring): # under USE_STANDARD_SCALE its plot engine scales by group 147 # alone (a sheet with code 21 and 147 = 1.0 plots 1:10 geometry # at 1:1 while page_setup_list says 1:10); under the custom # route it scales by 142/143. All three are written, on every # route, from the one resolved ratio, so no reader can disagree # with the plot. ``fit`` has ratio 1/1 and 147 = 1.0, as AutoCAD # writes it. code = resolved["dxf_standard_scale_type"] numerator, denominator = (float(v) for v in resolved["scale_ratio"]) if code is not None: dxf.standard_scale_type = int(code) target.use_standard_scale(True) else: dxf.standard_scale_type = 16 target.use_standard_scale(False) dxf.scale_numerator, dxf.scale_denominator = numerator, denominator dxf.unit_factor = numerator / denominator # resolve_page_setup hands over None for a layout plot: the bit is # cleared, never set — the state AutoCAD itself stores for acLayout, # and the mirror of the live engine never writing CenterPlot there. target.plot_centered(bool(resolved["center"])) # Limits follow the paper; viewports are deliberately left alone # (Paperspace.page_setup() would delete them). target.reset_paper_limits() self._mark_dirty() after = self._page_setup_row(target) changed = { key: [before[key], after[key]] for key in after if key != "layout" and before.get(key) != after[key] } return { "ok": True, "layout": target.name, "applied": dict(resolved), "changed": changed, "warnings": page_setup_warnings(changed), "plot_style_known": bool(resolved["plot_style_known"]), "viewports_kept": True, "backend": "ezdxf", } return await self._async(_sync) async def plot_style_list(self) -> list[dict]: from engineering.standards.papers import CTB_CATALOG return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] async def drawing_template_save(self, path, name=None, description=None) -> dict: """Save the current drawing as a template — DXF only, headlessly. ``.dwt`` is refused with capability ``dwt_write`` before anything is written; ``.dxf`` goes through ``drawing_save_as`` (the document is rebound to the new path, as AutoCAD's SAVEAS does). A description has no home in a DXF header — it is a DWG/DWT summary property — so it is reported ``description_written: False`` rather than dropped silently. """ suffix = Path(path).suffix.lower() if suffix == ".dwt": raise UnsupportedCapabilityError( "dwt_write", _DWT_WRITE_REFUSAL.format(stem=Path(path).stem) ) if suffix != ".dxf": raise ValueError( f"drawing_template_save: path must end in .dxf (headless) or .dwt (live " f"AutoCAD), got {path!r}" ) saved = await self.drawing_save_as(path, "dxf") result = { "ok": True, "path": saved["path"], "format": "dxf", "backend": "ezdxf", "name": name or Path(path).stem, "description_written": False, } if description is not None: result["description_note"] = ( "DXF has no template description field; it is a DWG/DWT summary property " "(capability 'dwgprops')" ) return result # ── 3D solids (unsupported headlessly — honest capability boundary) ───── @staticmethod def _solid_unsupported(operation: str) -> UnsupportedCapabilityError: """Raise, don't return: a refusal returned as a value is invisible. The message is unchanged, so the payload a client sees is identical; what changes is that ``is_error`` stops being quietly ``False`` on a call that did nothing, and that the one refusal middleware can see this at all. """ return UnsupportedCapabilityError( "solid_3d", f"{operation}: 3D ACIS solids cannot be generated headlessly; " "use the live COM backend with ENABLE_3D=true", ) async def solid_box( self, cx: float, cy: float, cz: float, length: float, width: float, height: float ) -> dict: raise self._solid_unsupported("solid_box") async def solid_cylinder( self, cx: float, cy: float, cz: float, radius: float, height: float ) -> dict: raise self._solid_unsupported("solid_cylinder") async def solid_extrude( self, profile_handle: str, height: float, taper_angle: float = 0.0 ) -> dict: raise self._solid_unsupported("solid_extrude") async def solid_revolve( self, profile_handle: str, axis_x1: float, axis_y1: float, axis_x2: float, axis_y2: float, angle: float = 360.0, ) -> dict: raise self._solid_unsupported("solid_revolve") async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: raise self._solid_unsupported("solid_boolean") async def drawing_purge(self) -> dict: """Delete every unreferenced block, layer, linetype and text style. A reference is not only an entity's own attribute. The style tables reference each other — a DIMSTYLE names its text style (DIMTXSTY) and its arrowhead blocks, an MLEADERSTYLE holds a text-style handle — and the header names the current text style (``$TEXTSTYLE``, ``$DIMTXSTY``). Measured before those were counted: ``dimstyle_create("ISO-25", …, set_current=True)`` then ``drawing_purge()`` removed ISOCP, the next dimension silently rendered in ``Standard`` while `dimstyle_list` still said ISOCP, and ``drawing_save_as`` raised ``DXFTableEntryError`` after leaving a truncated file on disk. Names are compared case-insensitively, AutoCAD's rule for every symbol table. """ def _sync(): doc = self._require_doc() purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0} used_blocks, used_styles = _style_table_references(doc) for ent in doc.entitydb.values(): if ent.dxftype() == "INSERT": used_blocks.add(str(ent.dxf.name).lower()) for blk in list(doc.blocks): name = blk.name if name.startswith("*") or name.lower() in used_blocks: continue try: doc.blocks.delete_block(name, safe=True) purged["blocks"] += 1 except Exception: pass used_layers: set[str] = {"0", "Defpoints"} for ent in doc.entitydb.values(): if hasattr(ent.dxf, "layer"): used_layers.add(ent.dxf.layer) for lyr in list(doc.layers): name = lyr.dxf.name if name in used_layers: continue try: doc.layers.remove(name) purged["layers"] += 1 except Exception: pass used_linetypes: set[str] = {"BYLAYER", "BYBLOCK", "Continuous"} for lyr in doc.layers: used_linetypes.add(lyr.dxf.linetype) for ent in doc.entitydb.values(): if hasattr(ent.dxf, "linetype"): used_linetypes.add(ent.dxf.linetype) for lt in list(doc.linetypes): name = lt.dxf.name if name in used_linetypes: continue try: doc.linetypes.remove(name) purged["linetypes"] += 1 except Exception: pass for ent in doc.entitydb.values(): if hasattr(ent.dxf, "style"): used_styles.add(str(ent.dxf.style).lower()) for st in list(doc.styles): name = st.dxf.name if name.lower() in used_styles: continue try: doc.styles.remove(name) purged["text_styles"] += 1 except Exception: pass self._mark_dirty() return {"ok": True, "purged": purged} return await self._async(_sync) async def drawing_audit(self) -> dict: """Audit and repair, then report both halves of what happened. ``Drawing.audit()`` is not a read: it fixes every fixable problem in place, so each entry in ``auditor.fixes`` is a mutation that has already been applied. Reporting only ``errors`` therefore said "0 problems" about a document the call had just rewritten. Marking dirty is the load-bearing half — ``drawing_close`` saves only ``if save and self._dirty``, so without it the repairs are discarded on close and the report becomes a promise the file never keeps. """ def _sync(): doc = self._require_doc() auditor = doc.audit() fixes = [_audit_entry(e) for e in auditor.fixes] errors = [_audit_entry(e) for e in auditor.errors] if fixes: self._mark_dirty() return { "ok": True, "repaired": bool(fixes), "fixes": fixes, "fix_count": len(fixes), "errors": errors, "error_count": len(errors), } return await self._async(_sync) async def drawing_close(self, save: bool = True) -> dict: """The active-document case of ``document_close``, with the 1.4 contract kept. ``save=True`` saves when the document has a path and is not quarantined. An untitled or quarantined document is closed *without* saving and the result says so (``saved: False``, ``discarded_changes``, ``warning``) — the pre-registry behaviour two shipped tests pin. ``document_close`` is the strict tool: it refuses instead of warning. """ def _sync(): if self._active_key is None: return { "ok": True, "closed": None, "saved": False, "active": None, "open_documents": 0, } key = self._active_key state = self._docs[key] # R32: closing is a way out of a quarantine, but saving on the way is # not. That save is the exact defect measured — a valid DXF holding # 15200 of the 60000 entities the runaway went on to write, a # considered document of a state that never existed. Say what was # dropped instead of writing it. quarantined = self._quarantine is not None dirty = state.dirty can_save = save and dirty and bool(state.path) and not quarantined result = self._close_document_sync(key, save=can_save, discard=not can_save) if save and dirty and not can_save: result["warning"] = ( "the document was quarantined after a call was abandoned mid-write, " "so unsaved changes were discarded rather than serialised out of a " "drawing another thread may still have been mutating" if quarantined else f"{key} had never been saved, so save=True had nowhere to write; " "its changes were discarded. drawing_save_as(path) before closing, or " "document_close(discard=True) to say so explicitly." ) return result return await self._async(_sync, quarantine_exit=True) async def drawing_undo(self) -> dict: self._require_undo_history("drawing_undo") # 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(): # Two entries minimum: the top is the current state, and undo means # restoring the one below it. if len(self._undo_stack) < 2: return { "ok": False, "error": "Nothing to undo — this is the oldest state in the history.", "undo_depth": max(0, len(self._undo_stack) - 1), } self._redo_stack.append(self._undo_stack.pop()) self._restore_snapshot(self._undo_stack[-1]) return { "ok": True, "message": "Stepped back one operation.", "undo_depth": len(self._undo_stack) - 1, "redo_depth": len(self._redo_stack), } return await self._async(_sync) async def drawing_redo(self) -> dict: self._require_undo_history("drawing_redo") def _sync(): if not self._redo_stack: return { "ok": False, "error": "Nothing to redo — nothing has been undone, or a " "later edit discarded the branch.", "redo_depth": 0, } snapshot = self._redo_stack.pop() self._undo_stack.append(snapshot) self._restore_snapshot(snapshot) return { "ok": True, "message": "Reapplied the operation you undid.", "undo_depth": len(self._undo_stack) - 1, "redo_depth": len(self._redo_stack), } return await self._async(_sync) # ── internal: apply common attrs ────────────────────────────────────────── def _apply_attrs( self, entity, layer: str | None, color: int | None, linetype: str | None = None ): if layer is not None: entity.dxf.layer = layer # Ensure layer exists doc = self._require_doc() if layer not in doc.layers: doc.layers.add(layer) 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): self._dirty = True self._push_undo_snapshot() # ── undo / redo ─────────────────────────────────────────────────────────── # # ezdxf keeps no journal, so a history step is a whole DXF snapshot. The # stack holds *post*-mutation states — S0 from drawing_new, then one per # mutation — because _mark_dirty is the single hook every mutating method # already calls, and it runs after the change. Undo therefore restores the # entry below the top rather than the top itself. def _snapshot_doc(self) -> Path | None: """Write the current document to a temp DXF, or None if there is none.""" if self._doc is None: return None fd, tmp_path = tempfile.mkstemp(suffix=".dxf", prefix="acad_mcp_undo_") os.close(fd) try: self._doc.saveas(tmp_path) except Exception: try: os.unlink(tmp_path) except OSError: pass raise return Path(tmp_path) @staticmethod def _discard(paths: list[Path]) -> None: while paths: try: paths.pop().unlink() except OSError: pass def _push_undo_snapshot(self) -> None: depth = config.settings.ezdxf_undo_depth if depth <= 0 or self._doc is None: return snapshot = self._snapshot_doc() if snapshot is None: return self._undo_stack.append(snapshot) # A mutation after an undo abandons the branch the user stepped off. # Keeping it would let redo restore a state that never existed: geometry # they had removed reappearing beside geometry they drew afterwards. self._discard(self._redo_stack) # depth+1 because the bottom entry is the state *before* the first # undoable step, and restoring it is what the first undo does. while len(self._undo_stack) > depth + 1: try: self._undo_stack.pop(0).unlink() except OSError: pass def _restore_snapshot(self, path: Path) -> None: self._doc = ezdxf.readfile(str(path)) _normalise_dimstyle_rounding(self._doc) self._current_layer = str(self._doc.header.get("$CLAYER", "0")) self._dirty = True def _reset_history_baseline(self) -> None: """Start the history at the document as opened or created. Without this the stack's oldest entry is the state *after* the first edit, so that edit could never be undone — the history would silently be one step shorter than the user asked for. """ self._cleanup_undo_stack() self._push_undo_snapshot() def _require_undo_history(self, operation: str) -> None: if config.settings.ezdxf_undo_depth > 0: return raise UnsupportedCapabilityError( "undo_history", f"{operation}: the headless backend keeps no history by default. ezdxf " "has no journal, so every step is a full DXF snapshot — measured at " "3 ms for 10 entities but 130 ms and 658 KB at 5000, paid on every " "mutating call. Set EZDXF_UNDO_DEPTH to the number of steps you want " "(e.g. 10) to switch it on, or use transaction_begin/rollback for a " "single explicit checkpoint. The live COM backend uses AutoCAD's own " "undo and needs no setting.", ) # ── entity creation ─────────────────────────────────────────────────────── async def entity_create_line( self, x1, y1, x2, y2, z1=0.0, z2=0.0, layer=None, color=None, linetype=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_line( (float(x1), float(y1), float(z1)), (float(x2), float(y2), float(z2)), ) self._apply_attrs(ent, layer, color, linetype) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_circle( self, cx, cy, radius, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_circle((float(cx), float(cy)), float(radius)) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_arc( self, cx, cy, radius, start_angle, end_angle, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_arc( (float(cx), float(cy)), float(radius), float(start_angle), float(end_angle), ) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_polyline( self, points, closed=False, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() pts_2d = [(float(p[0]), float(p[1])) for p in points] ent = msp.add_lwpolyline(pts_2d, close=closed) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_text( self, text, x, y, height=2.5, rotation=0.0, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_text( text, dxfattribs={ "style": _current_textstyle_name(self._require_doc()), "height": float(height), "rotation": float(rotation), "insert": (float(x), float(y)), }, ) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_mtext( self, text, x, y, width=100.0, height=2.5, rotation=0.0, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_mtext( text, dxfattribs={ "style": _current_textstyle_name(self._require_doc()), "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) return await self._async(_sync) async def entity_create_table( self, x, y, rows, headers=None, column_widths=None, row_height=7.0, text_height=2.5, title=None, layer="TEXT", ) -> EntityInfo: from engineering.annotation import prepare_table_layout layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title) def _sync(): msp = self._msp() children = [] x0, y0 = float(x), float(y) x_positions = [x0] for width in layout.column_widths: x_positions.append(x_positions[-1] + width) for row_index in range(layout.row_count + 1): yy = y0 - row_index * layout.row_height line = msp.add_line((x0, yy), (x0 + layout.width, yy)) self._apply_attrs(line, layer, None) children.append(line) for xx in x_positions: line = msp.add_line((xx, y0), (xx, y0 - layout.height)) self._apply_attrs(line, layer, None) children.append(line) padding = min(1.0, layout.row_height * 0.15) for row_index, row in enumerate(layout.cells): for column_index, value in enumerate(row): if not value: continue text_entity = msp.add_mtext( value, dxfattribs={ "char_height": layout.text_height, "width": max(1.0, layout.column_widths[column_index] - 2 * padding), }, ) text_entity.dxf.insert = ( x_positions[column_index] + padding, y0 - row_index * layout.row_height - padding, 0.0, ) self._apply_attrs(text_entity, layer, None) children.append(text_entity) self._mark_dirty() child_handles = [str(entity.dxf.handle) for entity in children] group_id = f"table:{uuid.uuid4().hex}" return EntityInfo( handle=child_handles[0], type="TABLE", layer=layer, color=256, linetype="ByLayer", visible=True, properties={ "representation": "composite", "logical_group_id": group_id, "child_handles": child_handles, "rows": layout.row_count, "columns": layout.column_count, "bounds": { "min": [x0, y0 - layout.height], "max": [x0 + layout.width, y0], }, }, ) return await self._async(_sync) async def leader_create_mleader( self, points, text, text_height=2.5, landing_gap=1.0, arrow_size=2.5, layer="DIM", ) -> EntityInfo: from engineering.annotation import validate_mleader normalized = validate_mleader(points, text) if text_height <= 0 or landing_gap < 0 or arrow_size <= 0: raise RuntimeError( "leader_create_mleader: text_height/arrow_size must be positive and landing_gap non-negative" ) def _sync(): msp = self._msp() path = msp.add_lwpolyline(normalized) self._apply_attrs(path, layer, None) (x0, y0), (x1, y1) = normalized[0], normalized[1] dx, dy = x1 - x0, y1 - y0 length = math.hypot(dx, dy) if length <= 1e-12: raise RuntimeError("leader_create_mleader: first two points must be distinct") ux, uy = dx / length, dy / length px, py = -uy, ux size = float(arrow_size) arrow = msp.add_lwpolyline( [ (x0, y0), (x0 + ux * size + px * size * 0.35, y0 + uy * size + py * size * 0.35), (x0 + ux * size - px * size * 0.35, y0 + uy * size - py * size * 0.35), ], close=True, ) self._apply_attrs(arrow, layer, None) tx, ty = normalized[-1] label = msp.add_mtext( str(text), dxfattribs={"char_height": float(text_height), "width": 100.0}, ) label.dxf.insert = (tx + float(landing_gap), ty, 0.0) self._apply_attrs(label, layer, None) self._mark_dirty() children = [path, arrow, label] child_handles = [str(entity.dxf.handle) for entity in children] return EntityInfo( handle=child_handles[0], type="MLEADER", layer=layer, color=256, linetype="ByLayer", visible=True, properties={ "representation": "composite", "logical_group_id": f"mleader:{uuid.uuid4().hex}", "child_handles": child_handles, "points": [list(point) for point in normalized], "text": str(text), "bounds": { "min": [ min(point[0] for point in normalized), min(point[1] for point in normalized), ], "max": [ max(point[0] for point in normalized), max(point[1] for point in normalized), ], }, }, ) return await self._async(_sync) async def entity_create_hatch( self, pattern, boundary_points, scale=1.0, angle=0.0, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() hatch = msp.add_hatch() hatch.set_pattern_fill(pattern, scale=float(scale), angle=float(angle)) pts = [(float(p[0]), float(p[1])) for p in boundary_points] hatch.paths.add_polyline_path(pts, is_closed=True) self._apply_attrs(hatch, layer, color) self._mark_dirty() return _entity_info_dxf(hatch) return await self._async(_sync) async def entity_create_spline( self, fit_points, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() pts = [(float(p[0]), float(p[1]), 0.0) for p in fit_points] ent = msp.add_spline(fit_points=pts) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_ellipse( self, cx, cy, major_x, major_y, ratio=0.5, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_ellipse( center=(float(cx), float(cy), 0.0), major_axis=(float(major_x), float(major_y), 0.0), ratio=float(ratio), ) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_point( self, x, y, layer=None, color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_point((float(x), float(y))) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def entity_create_block_ref( self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() self._require_block(name) ent = msp.add_blockref( name, (float(x), float(y)), dxfattribs={ "xscale": float(scale_x), "yscale": float(scale_y), "rotation": float(rotation), }, ) if layer: ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) # ── hatch depth (M8 / F4) ──────────────────────────────────────────────── #: Island style names, in DXF ``hatch_style`` order. _HATCH_STYLES = ("normal", "outer", "ignore") def _resolve_hatch(self, handle: str): entity = self._get_entity(handle) if entity.dxftype() != "HATCH": return None, { "ok": False, "error": f"Handle {handle} is a {entity.dxftype()}, not a HATCH", } return entity, None async def hatch_set_gradient( self, handle, color1, color2, rotation: float = 0.0, centered: float = 0.0, one_color: bool = False, tint: float = 0.0, name: str = "LINEAR", ) -> dict: def _sync(): hatch, refusal = self._resolve_hatch(handle) if refusal: return refusal try: rgb1 = tuple(int(c) for c in color1) rgb2 = tuple(int(c) for c in color2) except (TypeError, ValueError): return {"ok": False, "error": "color1 and color2 must be [r, g, b] triples"} if len(rgb1) != 3 or len(rgb2) != 3: return {"ok": False, "error": "color1 and color2 must be [r, g, b] triples"} hatch.set_gradient( color1=rgb1, color2=rgb2, rotation=float(rotation), centered=float(centered), one_color=1 if one_color else 0, tint=float(tint), name=str(name), ) self._mark_dirty() gradient = hatch.gradient return { "ok": True, "handle": hatch.dxf.handle, "gradient": { "color1": list(gradient.color1), "color2": list(gradient.color2), "rotation": float(gradient.rotation), "centered": float(gradient.centered), "one_color": bool(gradient.one_color), "tint": float(gradient.tint), "name": gradient.name, }, } return await self._async(_sync) async def hatch_edit( self, handle, pattern: str = "", scale: float | None = None, angle: float | None = None, color: int | None = None, style: str = "", ) -> dict: def _sync(): hatch, refusal = self._resolve_hatch(handle) if refusal: return refusal if scale is not None and float(scale) <= 0: return {"ok": False, "error": "scale must be > 0"} wanted_style = str(style).strip().lower() if wanted_style and wanted_style not in self._HATCH_STYLES: return { "ok": False, "error": ( f"Unknown style {style!r}; valid styles are {', '.join(self._HATCH_STYLES)}" ), } # Every write is conditional on the parameter being given: a hatch # edit that resets the attributes the caller did not mention is # silent data loss. `changed` then reports what actually *moved*, # not what was passed — re-setting a value to what it already was # is a no-op and saying otherwise would make the field useless for # deciding whether anything happened. def _snapshot(): return { "pattern": hatch.dxf.pattern_name, "scale": float(hatch.dxf.pattern_scale), "angle": float(hatch.dxf.pattern_angle), "color": int(hatch.dxf.color), "style": int(hatch.dxf.hatch_style), } before = _snapshot() if pattern: hatch.set_pattern_fill( str(pattern), scale=float(scale) if scale is not None else before["scale"], angle=float(angle) if angle is not None else before["angle"], color=int(color) if color is not None else before["color"], ) else: if scale is not None: hatch.set_pattern_scale(float(scale)) if angle is not None: hatch.set_pattern_angle(float(angle)) if color is not None: hatch.dxf.color = int(color) if wanted_style: hatch.dxf.hatch_style = self._HATCH_STYLES.index(wanted_style) after = _snapshot() changed = [key for key in before if before[key] != after[key]] if changed: self._mark_dirty() return {"ok": True, "handle": hatch.dxf.handle, "changed": changed} return await self._async(_sync) async def hatch_add_boundary(self, handle, edges) -> dict: def _sync(): hatch, refusal = self._resolve_hatch(handle) if refusal: return refusal if not edges: return {"ok": False, "error": "edges must not be empty"} def _need(edge, *keys): missing = [k for k in keys if k not in edge] return missing[0] if missing else None prepared: list[tuple[str, dict]] = [] for index, edge in enumerate(edges): if not isinstance(edge, dict): return {"ok": False, "error": f"edge {index} is not an object"} kind = str(edge.get("type", "")).strip().lower() if kind == "line": missing = _need(edge, "start", "end") elif kind == "arc": missing = _need(edge, "center", "radius", "start_angle", "end_angle") elif kind == "ellipse": missing = _need(edge, "center", "major_axis", "ratio") else: return { "ok": False, "error": ( f"edge {index}: unknown type {edge.get('type')!r}; valid types are " "line, arc, ellipse" ), } if missing: return {"ok": False, "error": f"edge {index} ({kind}) is missing {missing!r}"} prepared.append((kind, edge)) # Validated in full before a single edge is written: a half-built # boundary path is worse than a refusal. path = hatch.paths.add_edge_path() for kind, edge in prepared: if kind == "line": path.add_line(tuple(edge["start"][:2]), tuple(edge["end"][:2])) elif kind == "arc": path.add_arc( center=tuple(edge["center"][:2]), radius=float(edge["radius"]), start_angle=float(edge["start_angle"]), end_angle=float(edge["end_angle"]), ccw=bool(edge.get("ccw", True)), ) else: path.add_ellipse( center=tuple(edge["center"][:2]), major_axis=tuple(edge["major_axis"][:2]), ratio=float(edge["ratio"]), start_angle=float(edge.get("start_angle", 0.0)), end_angle=float(edge.get("end_angle", 360.0)), ccw=bool(edge.get("ccw", True)), ) self._mark_dirty() return { "ok": True, "handle": hatch.dxf.handle, "path_count": len(hatch.paths), "edge_types": [kind for kind, _ in prepared], } return await self._async(_sync) # ── annotation objects (M8 / F15) ──────────────────────────────────────── @staticmethod def _plane_points(points) -> list[tuple[float, float]] | None: """Coerce tool input to 2D vertices, or ``None`` if it is not shaped like points.""" try: return [(float(p[0]), float(p[1])) for p in points] except (TypeError, ValueError, IndexError): return None async def entity_create_wipeout(self, points, layer=None) -> dict: def _sync(): vertices = self._plane_points(points) if vertices is None: return {"ok": False, "error": "points must be a list of [x, y] pairs"} if len(vertices) < 3: return { "ok": False, "error": ( f"A wipeout needs at least 3 points to enclose an area; got {len(vertices)}" ), } msp = self._msp() entity = msp.add_wipeout(vertices) if layer: entity.dxf.layer = layer self._mark_dirty() return { "ok": True, "handle": entity.dxf.handle, "points": [[x, y] for x, y in vertices], "layer": entity.dxf.layer, } return await self._async(_sync) async def entity_create_revcloud( self, points, segment_length, layer=None, closed: bool = True ) -> dict: def _sync(): from ezdxf.math import Vec2 from ezdxf.revcloud import add_entity as add_revcloud vertices = self._plane_points(points) if vertices is None: return {"ok": False, "error": "points must be a list of [x, y] pairs"} if len(vertices) < 2: return {"ok": False, "error": "A revision cloud needs at least 2 points"} length = float(segment_length) if length <= 0: return {"ok": False, "error": "segment_length must be > 0"} # The arcs are the cloud. ezdxf emits a straight segment when the # requested length exceeds the edge, so an oversized segment_length # produces a plain polyline that still reports success. path = [Vec2(v) for v in vertices] edges = list(zip(path, path[1:] + ([path[0]] if closed else []), strict=False)) shortest = min((a - b).magnitude for a, b in edges) if edges else 0.0 if length > shortest: return { "ok": False, "error": ( f"segment_length {length:g} exceeds the shortest edge ({shortest:.4g}); " "the result would carry no arcs and would not be a revision cloud" ), } # ezdxf's own helper, not a hand-rolled polyline: it stamps the # REVCLOUD_PROPS xdata and picks the bulge sign from the winding # direction, which is what makes the result recognisable as a # revision cloud rather than a wobbly polyline. msp = self._msp() entity = add_revcloud(msp, vertices, length) if layer: entity.dxf.layer = layer self._mark_dirty() return { "ok": True, "handle": entity.dxf.handle, "segments": len(entity), "segment_length": length, "layer": entity.dxf.layer, } return await self._async(_sync) # ── dimensions ─────────────────────────────────────────────────────────── async def dimension_linear( self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None, tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) override = _with_header_dimvars(self._require_doc(), override) msp = self._msp() dim = msp.add_linear_dim( base=(float(dim_x), float(dim_y)), p1=(float(x1), float(y1)), p2=(float(x2), float(y2)), angle=float(rotation), text=text if text is not None else "<>", dimstyle=_current_dimstyle_name(self._require_doc()), override=override or None, ) dim.render() ent = dim.dimension if layer: ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def dimension_aligned( self, x1, y1, x2, y2, dim_x, dim_y, layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() dim = msp.add_aligned_dim( p1=(float(x1), float(y1)), p2=(float(x2), float(y2)), distance=math.sqrt( (float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2 ), dimstyle=_current_dimstyle_name(self._require_doc()), override=_with_header_dimvars(self._require_doc(), None), ) dim.render() ent = dim.dimension if layer: ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def dimension_angular( self, vx, vy, x1, y1, x2, y2, tx, ty, layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() vxf, vyf = float(vx), float(vy) dim = msp.add_angular_dim_2l( base=(float(tx), float(ty)), line1=((vxf, vyf), (float(x1), float(y1))), line2=((vxf, vyf), (float(x2), float(y2))), location=(float(tx), float(ty)), dimstyle=_current_dimstyle_name(self._require_doc()), override=_with_header_dimvars(self._require_doc(), None), ) dim.render() ent = dim.dimension if layer: ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def dimension_radius( self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None, tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) override = _with_header_dimvars(self._require_doc(), override) msp = self._msp() cxf, cyf = float(cx), float(cy) radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2) angle_rad = math.atan2(chord_y - cyf, chord_x - cxf) leader = float(leader_length) # Same correction as dimension_diameter: the size comes from # `radius`, and `location` only places the text. dim = msp.add_radius_dim( center=(cxf, cyf), radius=radius, location=( cxf + (radius + leader) * math.cos(angle_rad), cyf + (radius + leader) * math.sin(angle_rad), ), text=text if text is not None else "<>", dimstyle=_current_dimstyle_name(self._require_doc()), override=override or None, ) dim.render() ent = dim.dimension if layer: ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) async def dimension_diameter( self, x1, y1, x2, y2, leader_length=10.0, layer=None, tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) override = _with_header_dimvars(self._require_doc(), override) msp = self._msp() x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2) cx = (x1f + x2f) / 2 cy = (y1f + y2f) / 2 radius = math.sqrt((x2f - x1f) ** 2 + (y2f - y1f) ** 2) / 2 angle_rad = math.atan2(y2f - y1f, x2f - x1f) leader = float(leader_length) # The measured size comes from `radius`; `location` only places the # text. This used to pass centre + (radius + leader) as `mpoint`, # which ezdxf measures to — so every diameter came out # 2 x leader_length too large. 60 on a true 40, at default # settings, with nothing on the drawing to show for it. dim = msp.add_diameter_dim( center=(cx, cy), radius=radius, location=( cx + (radius + leader) * math.cos(angle_rad), cy + (radius + leader) * math.sin(angle_rad), ), text=text if text is not None else "<>", dimstyle=_current_dimstyle_name(self._require_doc()), override=override or None, ) dim.render() ent = dim.dimension if layer: ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) # ── entity modification ─────────────────────────────────────────────────── def _get_entity(self, handle: str): doc = self._require_doc() ent = doc.entitydb.get(handle) # `entitydb.get` is documented not to filter destroyed entities, so a # handle from a deleted layout used to pass this guard and then die on # the first attribute access with an AttributeError. if ent is None or not ent.is_alive: raise RuntimeError(f"Entity with handle '{handle}' not found.") return ent async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: def _sync(): ent = self._get_entity(handle) self._translate(ent, float(dx), float(dy), float(dz)) self._mark_dirty() return {"ok": True, "handle": handle} return await self._async(_sync) async def entity_copy(self, handle, dx, dy, dz=0.0) -> EntityInfo: def _sync(): ent = self._get_entity(handle) copy = ent.copy() self._msp().add_entity(copy) self._translate(copy, float(dx), float(dy), float(dz)) self._mark_dirty() return _entity_info_dxf(copy) return await self._async(_sync) async def entity_rotate(self, handle, base_x, base_y, angle_deg) -> dict: def _sync(): ent = self._get_entity(handle) from ezdxf.math import Matrix44 m = Matrix44.z_rotate(math.radians(float(angle_deg))) # Translate to origin, rotate, translate back bx, by = float(base_x), float(base_y) self._transform( ent, Matrix44.translate(-bx, -by, 0) @ m @ Matrix44.translate(bx, by, 0) ) self._mark_dirty() return {"ok": True, "handle": handle} return await self._async(_sync) async def entity_scale(self, handle, base_x, base_y, factor) -> dict: def _sync(): ent = self._get_entity(handle) from ezdxf.math import Matrix44 s = float(factor) bx, by = float(base_x), float(base_y) self._transform( ent, Matrix44.translate(-bx, -by, 0) @ Matrix44.scale(s, s, s) @ Matrix44.translate(bx, by, 0), ) self._mark_dirty() return {"ok": True, "handle": handle} return await self._async(_sync) async def entity_mirror( self, handle, x1, y1, x2, y2, delete_original=False, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) copy = ent.copy() self._msp().add_entity(copy) from ezdxf.math import Matrix44 # Mirror across the line defined by (x1,y1)-(x2,y2) dx = float(x2) - float(x1) dy = float(y2) - float(y1) length = math.sqrt(dx * dx + dy * dy) if length == 0: raise ValueError("Mirror line has zero length") cos2 = (dx * dx - dy * dy) / (length * length) sin2 = 2 * dx * dy / (length * length) m = Matrix44( ( cos2, sin2, 0, 0, sin2, -cos2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ) ) tx, ty = float(x1), float(y1) self._transform( copy, Matrix44.translate(-tx, -ty, 0) @ m @ Matrix44.translate(tx, ty, 0) ) if delete_original: self._msp().delete_entity(ent) self._mark_dirty() return _entity_info_dxf(copy) return await self._async(_sync) async def entity_offset( self, handle, distance, side_x=None, side_y=None, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) ent_type = ent.dxftype() d = float(distance) if ent_type == "LINE": # Offset a line: move parallel; side_x/side_y selects which side start = Vec2(ent.dxf.start.x, ent.dxf.start.y) end = Vec2(ent.dxf.end.x, ent.dxf.end.y) direction = (end - start).normalize() left_normal = Vec2(-direction.y, direction.x) # Determine sign: positive = left side (default); flip if side point is on right sign = 1.0 if side_x is not None and side_y is not None: mid = (start + end) * 0.5 side_vec = Vec2(float(side_x), float(side_y)) - mid if left_normal.dot(side_vec) < 0: sign = -1.0 normal = left_normal * (d * sign) new_start = start + normal new_end = end + normal msp = self._msp() new_ent = msp.add_line( (new_start.x, new_start.y), (new_end.x, new_end.y), dxfattribs={"layer": ent.dxf.layer}, ) self._mark_dirty() return _entity_info_dxf(new_ent) elif ent_type == "CIRCLE": # WCS centre, because side_x/side_y are WCS — the caller got them # from entity_get or point_from_snap. Comparing a WCS side point # against an OCS centre put the new circle 60 mm away *and* got # the inside/outside test backwards, so it grew when asked to # shrink. The new circle is created with identity extrusion, so # writing WCS into it is correct and the two end up concentric. _extrusion = tuple(ent.dxf.get("extrusion", _IDENTITY_EXTRUSION)) _c = ent.dxf.center cx, cy = ocs.to_wcs_2d(_extrusion, _c.x, _c.y, _c.z) # side_x/side_y selects inside (shrink) or outside (grow); default = outside sign = 1.0 if side_x is not None and side_y is not None: dist_to_center = math.sqrt( (float(side_x) - cx) ** 2 + (float(side_y) - cy) ** 2 ) if dist_to_center < ent.dxf.radius: sign = -1.0 new_r = ent.dxf.radius + d * sign if new_r <= 0: raise ValueError("Offset distance too large for circle") msp = self._msp() new_ent = msp.add_circle( (cx, cy), new_r, dxfattribs={"layer": ent.dxf.layer}, ) self._mark_dirty() return _entity_info_dxf(new_ent) else: raise RuntimeError(f"Offset not supported for {ent_type}") return await self._async(_sync) async def entity_delete(self, handle) -> dict: def _sync(): ent = self._get_entity(handle) self._msp().delete_entity(ent) self._mark_dirty() return {"ok": True, "deleted_handle": handle} return await self._async(_sync) async def entity_array_rectangular( self, handle, rows, cols, row_spacing, col_spacing, ) -> list[EntityInfo]: def _sync(): ent = self._get_entity(handle) msp = self._msp() results = [] for r in range(int(rows)): for c in range(int(cols)): if r == 0 and c == 0: continue # skip original copy = ent.copy() msp.add_entity(copy) self._translate(copy, c * float(col_spacing), r * float(row_spacing), 0) results.append(_entity_info_dxf(copy)) self._mark_dirty() return results return await self._async(_sync) async def entity_array_polar( self, handle, count, fill_angle, center_x, center_y, ) -> list[EntityInfo]: def _sync(): ent = self._get_entity(handle) msp = self._msp() from ezdxf.math import Matrix44 results = [] cx, cy = float(center_x), float(center_y) fa = float(fill_angle) n = int(count) # 360° full circle: n copies evenly spaced, no duplicate at 0° divisor = n if abs(fa % 360.0) < 1e-6 else max(n - 1, 1) step = math.radians(fa) / divisor for i in range(1, n): copy = ent.copy() msp.add_entity(copy) angle = step * i m = ( Matrix44.translate(-cx, -cy, 0) @ Matrix44.z_rotate(angle) @ Matrix44.translate(cx, cy, 0) ) self._transform(copy, m) results.append(_entity_info_dxf(copy)) self._mark_dirty() return results return await self._async(_sync) # ── entity query / properties ───────────────────────────────────────────── async def entity_get(self, handle) -> EntityInfo: def _sync(): return _entity_info_dxf(self._get_entity(handle)) return await self._async(_sync) async def entity_set_properties( self, handle, layer=None, color=None, linetype=None, lineweight=None, visible=None, ) -> dict: def _sync(): ent = self._get_entity(handle) if layer is not None: ent.dxf.layer = layer if color is not None: ent.dxf.color = int(color) if linetype is not None: _ensure_linetype_loaded(self._require_doc(), linetype) ent.dxf.linetype = linetype if lineweight is not None: ent.dxf.lineweight = normalize_lineweight(lineweight) if visible is not None: ent.dxf.invisible = not bool(visible) self._mark_dirty() return {"ok": True, "handle": handle} return await self._async(_sync) async def entity_edit_text( self, handle, text=None, height=None, rotation=None, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) et = ent.dxftype() if et == "TEXT": if text is not None: ent.dxf.text = str(text) if height is not None: ent.dxf.height = float(height) if rotation is not None: ent.dxf.rotation = float(rotation) elif et == "MTEXT": if text is not None: ent.text = str(text) if height is not None: ent.dxf.char_height = float(height) if rotation is not None: ent.dxf.rotation = float(rotation) else: raise RuntimeError( f"entity_edit_text: handle {handle} is {et}, expected TEXT or MTEXT." ) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) # ── text annotation (M8 / F15) ─────────────────────────────────────────── #: Entity types whose text this server can both read and write. DIMENSION is #: deliberately absent: its ``text`` field holds the ``<>`` override #: placeholder rather than the measurement. _TEXT_BEARING_TYPES = ("TEXT", "MTEXT", "ATTRIB", "ATTDEF") _DIMENSION_NOTE = ( "DIMENSION text is not searched: its text field holds the '<>' override " "placeholder rather than the measured value, so editing it would either do " "nothing or detach the dimension from what it measures. Use " "dimension_* text_override for that." ) async def text_set_background( self, handle, enabled: bool = True, color: int | None = None, scale: float = 1.5 ) -> dict: def _sync(): entity = self._get_entity(handle) if entity.dxftype() != "MTEXT": return { "ok": False, "error": ( f"Handle {handle} is {entity.dxftype()}; only MTEXT carries a " "background fill. Setting one on TEXT would report success and " "change nothing." ), } if not enabled: entity.dxf.discard("bg_fill") entity.dxf.discard("bg_fill_color") entity.dxf.discard("box_fill_scale") self._mark_dirty() return {"ok": True, "handle": entity.dxf.handle, "enabled": False} factor = float(scale) if factor < 1.0: return { "ok": False, "error": ( f"scale must be >= 1.0 (got {factor:g}); a box smaller than its " "text is a stripe through the text, not a mask" ), } entity.set_bg_color(int(color) if color is not None else None, scale=factor) self._mark_dirty() return { "ok": True, "handle": entity.dxf.handle, "enabled": True, "color": color, "scale": factor, } return await self._async(_sync) async def text_find_replace( self, find: str, replace: str, layer: str | None = None, match_case: bool = True, dry_run: bool = False, ) -> dict: def _sync(): import re needle = str(find) if not needle: return { "ok": False, "error": ( "find must not be empty: an empty pattern matches between every " "character and would shred the text" ), } doc = self._require_doc() pattern = re.compile(re.escape(needle), 0 if match_case else re.IGNORECASE) def _candidates(): # Block *definitions* carry ATTDEF and any text drawn inside the # block; a search that only walks layouts silently misses them. yield from doc.modelspace() for layout_name in doc.layouts.names(): if layout_name != "Model": yield from doc.layouts.get(layout_name) for block in doc.blocks: yield from block for entity in doc.modelspace().query("INSERT"): yield from entity.attribs changed: list[dict] = [] seen: set[str] = set() for entity in _candidates(): dxftype = entity.dxftype() if dxftype not in self._TEXT_BEARING_TYPES: continue key = entity.dxf.get("handle", None) if key in seen: continue seen.add(key) if layer and entity.dxf.get("layer", "0") != layer: continue # An ATTRIB/ATTDEF is read and written through the attribute # helpers: a multi-line one keeps its content in an embedded # MTEXT and ``dxf.text`` is only the first line, so matching # and writing ``dxf.text`` alone reported ``replaced: 1`` while # ``block_get_attributes`` and ``block_explode`` still carried # the old value. One value, every surface -- the same rule # ``block_set_attributes`` follows. if dxftype == "MTEXT": current = entity.text elif dxftype in ("ATTRIB", "ATTDEF"): current = self._attrib_value(entity) else: current = entity.dxf.get("text", "") if not current or not pattern.search(current): continue updated = pattern.sub(replace, current) if not dry_run: if dxftype == "MTEXT": entity.text = updated elif dxftype in ("ATTRIB", "ATTDEF"): self._write_attrib_value(entity, updated) else: entity.dxf.text = updated changed.append( { "handle": key, "type": dxftype, "before": current, "after": updated, } ) if changed and not dry_run: self._mark_dirty() return { "ok": True, "replaced": len(changed), "entities": changed, # Reported, not implied: "no matches" and "never looked there" # are different answers, and the caller can only tell them apart # if the scope is on the response. "searched_types": list(self._TEXT_BEARING_TYPES), "note": self._DIMENSION_NOTE, "dry_run": bool(dry_run), } return await self._async(_sync) async def entity_edit_geometry( self, handle, cx=None, cy=None, radius=None, x1=None, y1=None, x2=None, y2=None, start_angle=None, end_angle=None, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) et = ent.dxftype() # cx/cy arrive in WCS, because that is what entity_get reports. For a # mirrored entity the stored attribute is not WCS, so the value has to # be mapped back into the entity's own frame before it is written. # Read and write used to be *equally* wrong, which made a round trip an # accidental no-op; normalising only the read would have turned that # no-op into a real 60 mm move. new_cx, new_cy = cx, cy if et in ("CIRCLE", "ARC"): extrusion = tuple(ent.dxf.get("extrusion", _IDENTITY_EXTRUSION)) if not ocs.is_wcs_frame(extrusion): if not ocs.is_flat_frame(extrusion): raise UnsupportedCapabilityError( "ocs_tilted_plane", f"entity_edit_geometry: entity {handle} lies in a plane " f"tilted out of WCS XY (normal=" f"{tuple(round(float(v), 4) for v in extrusion)}). A 2D " "(cx, cy) does not determine a point in that plane, so " "writing one would displace the entity out of its own " "plane by an amount this server cannot infer. Use " "entity_move, which transforms the whole entity and " "preserves its plane, or edit it in AutoCAD.", ) centre = ent.dxf.center current = ocs.to_wcs_2d(extrusion, centre.x, centre.y, centre.z) target_x = float(cx) if cx is not None else current[0] target_y = float(cy) if cy is not None else current[1] new_cx, new_cy = ocs.from_wcs(extrusion, target_x, target_y, centre.z)[:2] if et == "CIRCLE": c = ent.dxf.center ent.dxf.center = ( float(new_cx) if new_cx is not None else c[0], float(new_cy) if new_cy is not None else c[1], c[2] if len(c) > 2 else 0.0, ) if radius is not None: ent.dxf.radius = float(radius) elif et == "LINE": s, e = ent.dxf.start, ent.dxf.end ent.dxf.start = ( float(x1) if x1 is not None else s[0], float(y1) if y1 is not None else s[1], s[2] if len(s) > 2 else 0.0, ) ent.dxf.end = ( float(x2) if x2 is not None else e[0], float(y2) if y2 is not None else e[1], e[2] if len(e) > 2 else 0.0, ) elif et == "ARC": c = ent.dxf.center ent.dxf.center = ( float(new_cx) if new_cx is not None else c[0], float(new_cy) if new_cy is not None else c[1], c[2] if len(c) > 2 else 0.0, ) if radius is not None: ent.dxf.radius = float(radius) if start_angle is not None: ent.dxf.start_angle = float(start_angle) if end_angle is not None: ent.dxf.end_angle = float(end_angle) else: raise RuntimeError( f"entity_edit_geometry: {et} not supported (use CIRCLE, LINE, or ARC)." ) self._mark_dirty() return _entity_info_dxf(ent) return await self._async(_sync) # ── selection filters (M8 / F1) ────────────────────────────────────────── _SELECTION_MODES = ("window", "crossing") def _select_shape(self, shape, mode: str, entity_type: str, layer: str) -> dict: """Run a selection shape over the current space and shape the response.""" from ezdxf import select picker = select.bbox_inside if mode == "window" else select.bbox_overlap wanted_type = str(entity_type or "").strip().upper() wanted_layer = str(layer or "").strip() handles = [] for entity in picker(shape, self._msp()): if wanted_type and entity.dxftype() != wanted_type: continue if wanted_layer and entity.dxf.get("layer", "0") != wanted_layer: continue handles.append(entity.dxf.handle) return {"ok": True, "handles": handles, "count": len(handles), "mode": mode} def _selection_mode(self, mode: str) -> tuple[str | None, dict | None]: normalised = str(mode or "").strip().lower() if normalised not in self._SELECTION_MODES: return None, { "ok": False, "error": ( f"Unknown mode {mode!r}; valid modes are {', '.join(self._SELECTION_MODES)}" ), } return normalised, None async def selection_window( self, x1, y1, x2, y2, mode: str = "window", entity_type: str = "", layer: str = "" ) -> dict: def _sync(): from ezdxf import select resolved, refusal = self._selection_mode(mode) if refusal: return refusal lo_x, hi_x = sorted((float(x1), float(x2))) lo_y, hi_y = sorted((float(y1), float(y2))) if lo_x == hi_x or lo_y == hi_y: # An empty answer here would read as "nothing is there" rather # than "you asked for a box that cannot contain anything". return { "ok": False, "error": ( "The selection box has zero area; give two opposite corners that " "differ in both x and y" ), } return self._select_shape( select.Window((lo_x, lo_y), (hi_x, hi_y)), resolved, entity_type, layer ) return await self._async(_sync) async def selection_polygon( self, points, mode: str = "window", entity_type: str = "", layer: str = "" ) -> dict: def _sync(): from ezdxf import select resolved, refusal = self._selection_mode(mode) if refusal: return refusal vertices = self._plane_points(points) if vertices is None: return {"ok": False, "error": "points must be a list of [x, y] pairs"} if len(vertices) < 3: return { "ok": False, "error": f"A selection polygon needs at least 3 points; got {len(vertices)}", } return self._select_shape(select.Polygon(vertices), resolved, entity_type, layer) return await self._async(_sync) async def selection_filter( self, entity_type: str = "", layer: str = "", color: int | None = None, linetype: str = "", min_area: float | None = None, ) -> dict: def _sync(): if min_area is not None and float(min_area) < 0: return {"ok": False, "error": "min_area cannot be negative; no area is negative"} wanted_type = str(entity_type or "").strip().upper() wanted_layer = str(layer or "").strip() wanted_linetype = str(linetype or "").strip() filtered_by = [ name for name, active in ( ("entity_type", bool(wanted_type)), ("layer", bool(wanted_layer)), ("color", color is not None), ("linetype", bool(wanted_linetype)), ("min_area", min_area is not None), ) if active ] def _area(entity) -> float | None: """None means "this shape has no area", not "its area is zero". Answered by the same measurement `entity_measure` uses, so the two cannot disagree about the same entity. They did: this used to read ``entity.dxf.get("area", 0.0)`` for SOLID, TRACE and HATCH, none of which carry an ``area`` DXF attribute, so every one of them measured 0.0 and any positive threshold silently excluded every hatch in the drawing while ``filtered_by`` reported that the filter had run. The two refusals stay distinguishable. A type that bounds no area on any engine raises RuntimeError and is *excluded*; a type this engine cannot evaluate (ACIS) refuses with a capability and is also excluded, because "I could not measure it" must never read as "it is big enough". """ try: return float(_measure_dxf_entity(entity, 0.001)["area"]) except (UnsupportedCapabilityError, RuntimeError, ValueError, TypeError): return None handles = [] for entity in self._msp(): if wanted_type and entity.dxftype() != wanted_type: continue if wanted_layer and entity.dxf.get("layer", "0") != wanted_layer: continue if color is not None and int(entity.dxf.get("color", 256)) != int(color): continue if wanted_linetype and entity.dxf.get("linetype", "ByLayer") != wanted_linetype: continue if min_area is not None: area = _area(entity) if area is None or area < float(min_area): continue handles.append(entity.dxf.handle) return { "ok": True, "handles": handles, "count": len(handles), "filtered_by": filtered_by, } return await self._async(_sync) async def entity_list( self, type_filter=None, layer_filter=None, limit=200, offset=0, ) -> list[EntityInfo]: def _sync(): msp = self._msp() results = [] skipped = 0 for ent in msp: ent_type = ent.dxftype() ent_layer = ent.dxf.get("layer", "0") if type_filter and type_filter.upper() != ent_type.upper(): continue if layer_filter and layer_filter.lower() != ent_layer.lower(): continue if skipped < offset: skipped += 1 continue results.append(_entity_info_dxf(ent)) if len(results) >= limit: break return results return await self._async(_sync) async def entity_count(self, type_filter=None, layer_filter=None) -> int: # Deliberately a mirror of entity_list's filter arms rather than a call # into it: the whole value of the count is that it never constructs an # EntityInfo (bounding-box extents alone are the expensive part). The # comparisons are kept character-for-character identical to the ones # above so `total` can never be measured against a different set than # the page it describes. def _sync(): msp = self._msp() count = 0 for ent in msp: if type_filter and type_filter.upper() != ent.dxftype().upper(): continue if layer_filter and layer_filter.lower() != ent.dxf.get("layer", "0").lower(): continue count += 1 return count return await self._async(_sync) # ── layer management ────────────────────────────────────────────────────── async def layer_list(self) -> list[LayerInfo]: def _sync(): doc = self._require_doc() return [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] return await self._async(_sync) async def layer_create( self, name, color=7, linetype="Continuous", lineweight=-3, ) -> LayerInfo: def _sync(): doc = self._require_doc() _ensure_linetype_loaded(doc, linetype) lyr = doc.layers.add( name, color=int(color), linetype=linetype, lineweight=normalize_lineweight(lineweight), ) self._mark_dirty() return _layer_info_dxf(lyr, self._current_layer) return await self._async(_sync) async def layer_delete(self, name) -> dict: def _sync(): doc = self._require_doc() if name in doc.layers: doc.layers.remove(name) self._mark_dirty() return {"ok": True, "deleted": name} return await self._async(_sync) async def layer_set_current(self, name) -> dict: def _sync(): doc = self._require_doc() if name not in doc.layers: doc.layers.add(name) self._current_layer = name try: doc.header["$CLAYER"] = name except Exception as exc: log.debug("writing $CLAYER to header: %s", exc) self._mark_dirty() return {"ok": True, "current_layer": name} return await self._async(_sync) async def layer_modify( self, name, color=None, linetype=None, lineweight=None, ) -> LayerInfo: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr is None: raise RuntimeError(f"Layer '{name}' not found.") if color is not None: lyr.dxf.color = int(color) if linetype is not None: _ensure_linetype_loaded(doc, linetype) lyr.dxf.linetype = linetype if lineweight is not None: lyr.dxf.lineweight = normalize_lineweight(lineweight) self._mark_dirty() return _layer_info_dxf(lyr, self._current_layer) return await self._async(_sync) async def layer_freeze(self, name) -> dict: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr: lyr.freeze() self._mark_dirty() return {"ok": True, "layer": name, "frozen": True} return await self._async(_sync) async def layer_thaw(self, name) -> dict: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr: lyr.thaw() self._mark_dirty() return {"ok": True, "layer": name, "frozen": False} return await self._async(_sync) async def layer_lock(self, name) -> dict: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr: lyr.lock() self._mark_dirty() return {"ok": True, "layer": name, "locked": True} return await self._async(_sync) async def layer_unlock(self, name) -> dict: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr: lyr.unlock() self._mark_dirty() return {"ok": True, "layer": name, "locked": False} return await self._async(_sync) async def layer_hide(self, name) -> dict: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr: lyr.off() self._mark_dirty() return {"ok": True, "layer": name, "visible": False} return await self._async(_sync) async def layer_show(self, name) -> dict: def _sync(): doc = self._require_doc() lyr = doc.layers.get(name) if lyr: lyr.on() self._mark_dirty() return {"ok": True, "layer": name, "visible": True} return await self._async(_sync) # ── linetype management ─────────────────────────────────────────────────── async def linetype_list(self) -> list[str]: def _sync(): doc = self._require_doc() return [lt.dxf.name for lt in doc.linetypes] return await self._async(_sync) async def linetype_load(self, name, file=None) -> dict: # Loads a single linetype. Lookup order: # 1. ezdxf.tools.standards (ISO: CENTER, DASHED, DASHDOT, PHANTOM, ...) # 2. AutoCAD fallback table (HIDDEN, BORDER and *2/X2 variants) # The `file` parameter is accepted for API parity with COM but ignored # — ezdxf does not ship a .lin parser. from ezdxf.tools import standards as ezdxf_standards del file def _sync(): doc = self._require_doc() existing = {lt.dxf.name.lower() for lt in doc.linetypes} if name.lower() in existing: return {"ok": True, "name": name, "already_loaded": True} for lt_name, description, pattern in ezdxf_standards.linetypes(): if lt_name.lower() == name.lower(): doc.linetypes.add(lt_name, pattern=pattern, description=description) self._mark_dirty() return {"ok": True, "name": lt_name, "source": "ezdxf.tools.standards"} if _add_linetype_from_fallback(doc, name): self._mark_dirty() return {"ok": True, "name": name.upper(), "source": "autocad_fallback"} raise RuntimeError( f"Linetype '{name}' is not in ezdxf's standard set or the " "AutoCAD fallback table. Available standards: CENTER*, DASHED*, " "DASHDOT*, PHANTOM*, DOT*, DIVIDE*. Available fallbacks: " "HIDDEN*, BORDER*. Custom .lin files require the COM backend." ) return await self._async(_sync) # ── block operations ────────────────────────────────────────────────────── async def block_list(self) -> list[BlockInfo]: def _sync(): doc = self._require_doc() blocks = [] for blk in doc.blocks: if blk.name.startswith("*"): continue attr_count = sum(1 for e in blk if e.dxftype() == "ATTDEF") 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) async def block_insert( self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, attributes=None, layer=None, ) -> EntityInfo: from backends.block_specs import validate_attribute_values values = validate_attribute_values(attributes) def _sync(): msp = self._msp() attribs: dict = { "xscale": float(scale_x), "yscale": float(scale_y), "rotation": float(rotation), } if layer: attribs["layer"] = layer self._require_block(name) ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) if values: # `add_auto_blockref` wraps the INSERT in an anonymous *U block, # so the returned handle names `*U1` and carries no ATTRIBs. # `add_auto_attribs` attaches ATTRIBs to this INSERT for every # ATTDEF the definition has; values for tags it lacks are ignored. ref.add_auto_attribs(values) # A multi-line ATTDEF becomes a multi-line ATTRIB through # `embed_mtext`, which mirrors only the first line of the value # into `dxf.text`; the whole value goes there (the convention # `_write_attrib_value` keeps) so an R2010 save keeps every line. for attrib in ref.attribs: if attrib.has_embedded_mtext_entity: attrib.dxf.text = self._attrib_value(attrib) self._mark_dirty() return _entity_info_dxf(ref) return await self._async(_sync) @staticmethod def _owner_layout_for_explode(ent, handle: str): """The layout that owns ``ent`` — model space or a paper-space layout. Raises before any write when the owner is a block definition (a nested reference) or cannot be resolved at all; guessing the current space instead is how geometry ends up duplicated into the wrong tab. """ try: layout = ent.get_layout() except Exception as exc: raise RuntimeError( f"Entity {handle} has no resolvable owner layout ({exc}); refusing to " "explode into a guessed space" ) from exc if layout is None or not layout.is_any_layout: owner = getattr(layout, "name", None) or ent.dxf.get("owner", "?") raise RuntimeError( f"Entity {handle} is nested inside block definition {owner!r}; explode " "the outer reference instead (exploding here would redefine the block " "under every other reference)" ) return layout #: ATTRIB DXF attributes carried onto the TEXT that replaces it on explode. #: An ATTRIB is an OCS entity: ``insert``/``align_point`` are numbers in the #: plane of its own ``extrusion``, so the frame travels with them. Without #: it the TEXT took the OCS numbers with the default +Z frame, and a #: mirrored reference (ezdxf reflects the ATTRIB to extrusion ``(0, 0, -1)`` #: while the INSERT keeps +Z with ``yscale=-1``) put the tag 50 units away #: on the other side of the mirror axis — audit clean, ``plane_normal`` #: None, nothing said. Same numbers in the same frame is the same place. _ATTRIB_TO_TEXT = ( "layer", "color", "style", "height", "rotation", "width", "oblique", "halign", "valign", "text_generation_flag", "insert", "align_point", "extrusion", "thickness", ) @classmethod def _text_attribs_of(cls, attrib) -> dict: """The ``_ATTRIB_TO_TEXT`` members ``attrib`` (ATTRIB or ATTDEF) carries.""" return {key: attrib.dxf.get(key) for key in cls._ATTRIB_TO_TEXT if attrib.dxf.hasattr(key)} @staticmethod def _anchored_mtext(attrib): """The embedded MTEXT of a multi-line ``attrib`` (ATTRIB or ATTDEF) as a virtual entity placed where the attribute itself is. A multi-line attribute keeps its placement on two surfaces: the ATTRIB's own ``insert``/``align_point`` (OCS, the frame of its ``extrusion``) and the embedded MTEXT's ``insert`` (WCS). ezdxf keeps them in step through ``transform`` (``BaseAttrib`` overrides it) but not through ``translate``: ``Insert.translate`` calls ``Text.translate`` on every ATTRIB, which moves the ATTRIB's two points and nothing else (ezdxf 1.4.4 ``attrib.py`` has no ``translate``). So after a move the virtual MTEXT still said where the attribute *was*, ``block_explode`` burst the note 50 units from its symbol with a clean audit, and a value write (``set_mtext`` re-places the ATTRIB from the MTEXT) pulled the attribute back to the old spot while the INSERT stayed put. The ATTRIB's placement is the attribute's placement -- it is what ``entity_get`` reports, what a single-line attribute has, and what every ezdxf write path maintains -- so the virtual entity is anchored to it: the inverse of ezdxf's own ``_update_location_from_mtext`` (``get_placement()`` picks ``insert`` for a LEFT-aligned text and ``align_point`` otherwise, the same rule the renderer uses). """ mtext = attrib.virtual_mtext_entity() mtext.dxf.insert = attrib.ocs().to_wcs(attrib.get_placement()[1]) return mtext @classmethod def _settle_attrib_mtext(cls, attrib) -> None: """Put the embedded MTEXT of ``attrib`` where the ATTRIB now is; a no-op for a single-line attribute. Runs after every ``translate`` of an INSERT (``entity_move``, ``entity_copy``, ``entity_array_rectangular``). The round trip is the one ezdxf's ``BaseAttrib.transform`` uses; ``set_mtext`` also mirrors only the first line into ``dxf.text``, so the value (the whole ``Line1\\PLine2`` -- see ``_write_attrib_value``) is put back. """ if not attrib.has_embedded_mtext_entity: return text = attrib.dxf.text attrib.set_mtext(cls._anchored_mtext(attrib), graphic_properties=False) attrib.dxf.text = text @classmethod def _translate(cls, ent, dx: float, dy: float, dz: float) -> None: """``ent.translate(dx, dy, dz)`` that carries a multi-line ATTRIB's embedded MTEXT along (see ``_anchored_mtext``).""" ent.translate(dx, dy, dz) if ent.dxftype() == "INSERT": for attrib in ent.attribs: cls._settle_attrib_mtext(attrib) @staticmethod def _transform(ent, m) -> None: """``ent.transform(m)`` that keeps group 1 of a multi-line ATTRIB whole. ezdxf's ``BaseAttrib.transform`` rebuilds the ATTRIB through ``set_mtext``, which mirrors only the *first line* into ``dxf.text``: a rotate, scale or mirror after ``block_set_attributes`` turned ``A\\PB`` back into ``A`` on the one surface an R2010 save keeps, and the reloaded drawing had lost every line but the first. The content does not change under a transform, so the value is put back. """ if ent.dxftype() != "INSERT": ent.transform(m) return texts = [(a, a.dxf.text) for a in ent.attribs if a.has_embedded_mtext_entity] ent.transform(m) for attrib, text in texts: attrib.dxf.text = text @classmethod def _burst_attrib(cls, layout, attrib, matrix=None): """Add the entity that stands in for ``attrib`` (ATTRIB or ATTDEF) to ``layout`` and return it: a TEXT carrying the value, or -- for a multi-line attribute -- an MTEXT carrying every line. A multi-line ATTRIB keeps its content in an embedded MTEXT; its ``dxf.text`` is only the first line (ezdxf) or ``Line1\\PLine2...`` (AutoCAD), so a TEXT built from it drops the other lines or renders literal ``\\P`` -- with a clean audit. BURST emits an MTEXT there. ``_anchored_mtext`` carries the ATTRIB's graphic properties and its placement (the embedded MTEXT's own ``insert`` is stale after a ``translate``, see there). ``matrix``, when given, is the reference's matrix, for an ATTDEF still in block coordinates. """ if attrib.has_embedded_mtext_entity: entity = cls._anchored_mtext(attrib) if matrix is not None: entity.transform(matrix) layout.add_entity(entity) else: entity = layout.add_text(attrib.dxf.text, dxfattribs=cls._text_attribs_of(attrib)) if matrix is not None: entity.transform(matrix) if attrib.is_invisible: entity.dxf.invisible = 1 return entity @staticmethod def _refuse_unexplodable_reference(ent, handle: str) -> None: """Raise before any write for the two INSERT kinds ``virtual_entities()`` cannot explode -- both used to be destroyed with ``ok: True``. An *xref* INSERT yields no virtual entities (the geometry is in the other file), so the reference was deleted and nothing put in its place; AutoCAD refuses to explode an xref (bind it first). A *MINSERT* (``mcount`` > 1) yields only its first cell -- ``multi_insert()`` resolves the grid -- so a 2x3 grid exploded into one cell and five vanished; AutoCAD refuses to explode a MINSERT. """ name = ent.dxf.get("name", "?") block = ent.block() record = getattr(block, "block_record", None) if record is not None and record.is_xref: # covers XREF_OVERLAY too raise RuntimeError( f"Entity {handle} is an external reference {name!r}; an xref cannot " "be exploded (bind it into the drawing first)" ) if ent.mcount > 1: rows = ent.dxf.get("row_count", 1) cols = ent.dxf.get("column_count", 1) raise RuntimeError( f"Entity {handle} is a MINSERT ({rows}x{cols} grid of {name!r}); a " "multi-insert cannot be exploded" ) @staticmethod def _explodable_members(ent, handle: str) -> list: """The reference's members as virtual entities, or a typed refusal before any write when ezdxf would drop one. ``virtual_entities()`` skips every member it cannot copy (OLE2FRAME) or transform (VIEWPORT) with a debug-level log only, and an ACAD_PROXY_ENTITY contributes its proxy graphic -- nothing at all when it carries none -- so a title block's OLE logo or a vertical product's proxy geometry used to vanish with ``ok: True`` and a clean audit. The list is materialized here so the skips are known before the layout is touched; AutoCAD explodes all of these natively. """ skipped: list[str] = [] def _collect(entity, reason): entity_handle = entity.dxf.get("handle") or "?" skipped.append(f"{entity.dxftype()} {entity_handle} ({reason})") members = list(ent.virtual_entities(skipped_entity_callback=_collect)) block = ent.block() for member in block if block is not None else (): if member.dxftype() != "ACAD_PROXY_ENTITY": continue if not any(True for _ in member.virtual_entities()): skipped.append(f"ACAD_PROXY_ENTITY {member.dxf.handle} (no proxy graphic)") if skipped: name = ent.dxf.get("name", "?") raise UnsupportedCapabilityError( "explode_opaque_members", f"block_explode: entity {handle} references block {name!r} whose " f"members {', '.join(skipped)} cannot be exploded by the headless " "ezdxf backend (it cannot transform them; exploding would delete " "them). Nothing was written. Switch to the live COM backend " "(AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD), which hands " "the explode to AutoCAD.", ) return members async def block_explode(self, handle) -> dict: """Explode an INSERT into its members; ATTRIB values survive as TEXT. ``virtual_entities()`` yields the definition's geometry only — no ATTDEF, no ATTRIB — so an exploded tagged symbol used to lose its tag text silently. This is the BURST rule (Express Tools): every attached ATTRIB becomes a TEXT at the same WCS placement, height, rotation, style and layer, carrying the *value*; an invisible ATTRIB becomes an invisible TEXT rather than appearing. The new handles are reported as ``attribute_texts``, separate from the geometry's ``inserted_handles``. A *constant* attribute (ATTDEF flag 2) carries its value in the definition and, in an AutoCAD-authored file, has no ATTRIB on the reference — ``virtual_entities()`` skips ATTDEF, so its visible text used to vanish with a clean audit. BURST converts it to TEXT, so every constant ATTDEF whose tag has no attached ATTRIB is copied as a TEXT and transformed by the reference's matrix — the same path ``add_auto_attribs`` takes, so it lands where the ATTRIB the headless ``block_insert`` attaches would have (that ATTRIB, when present, is converted above and the definition is not converted twice). A *multi-line* attribute (ATTRIB or constant ATTDEF with an embedded MTEXT) becomes an MTEXT carrying every line, as BURST does -- its ``dxf.text`` holds only the first line (ezdxf) or the lines joined with literal ``\\P`` (AutoCAD), so a TEXT silently lost content. See ``_burst_attrib``. The members and the TEXTs go into the INSERT's *owner* layout, not the current one: ``_get_entity`` resolves a handle in any layout, so a title block on a sheet exploded while Model was current used to have its geometry copied into model space and then die on ``delete_entity`` with the reference still alive on the sheet. Refused before anything is written: a reference nested inside a block definition (exploding it there would redefine the block under every other reference), an xref, and a MINSERT -- see ``_refuse_unexplodable_reference`` for the last two, which ``virtual_entities()`` cannot represent and which used to be deleted with ``ok: True`` -- and a reference whose definition holds a member ezdxf cannot copy or transform (OLE2FRAME, VIEWPORT, a proxy entity without proxy graphics), refused by member with the capability key ``explode_opaque_members`` -- see ``_explodable_members``; those used to be dropped from the result with ``ok: True`` while the reference was deleted. """ def _sync(): ent = self._get_entity(handle) if ent.dxftype() != "INSERT": raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") self._refuse_unexplodable_reference(ent, handle) msp = self._owner_layout_for_explode(ent, handle) members = self._explodable_members(ent, handle) inserted = [] for sub in members: sub_copy = sub.copy() msp.add_entity(sub_copy) inserted.append(sub_copy.dxf.handle) attribute_texts = [] attached_tags = set() for attrib in ent.attribs: attached_tags.add(attrib.dxf.tag) attribute_texts.append(self._burst_attrib(msp, attrib).dxf.handle) block = ent.block() attdefs = block.attdefs() if block is not None else () matrix = ent.matrix44() for attdef in attdefs: if not attdef.is_const or attdef.dxf.get("tag") in attached_tags: continue if not attdef.dxf.hasattr("insert"): continue # a structure error; nowhere to place it attribute_texts.append(self._burst_attrib(msp, attdef, matrix).dxf.handle) msp.delete_entity(ent) self._mark_dirty() return { "ok": True, "exploded_handle": handle, "inserted_handles": inserted, "attribute_texts": attribute_texts, "backend": "ezdxf", } return await self._async(_sync) @staticmethod def _attrib_value(attrib) -> str: """The value an ATTRIB carries, as AutoCAD's ``TextString`` reports it. A multi-line attribute keeps its content in an embedded MTEXT and its ``dxf.text`` is only the first line when ezdxf's ``embed_mtext`` / ``set_mtext`` authored it (AutoCAD, and ``_write_attrib_value``, write ``Line1\\PLine2``), so the embedded content is the value; a single-line attribute's value is ``dxf.text``. """ if attrib.has_embedded_mtext_entity: return attrib.virtual_mtext_entity().text return attrib.dxf.text @classmethod def _write_attrib_value(cls, attrib, value: str) -> None: """Write ``value`` into ``attrib`` on every surface it has. Setting only ``dxf.text`` on a multi-line ATTRIB left the embedded MTEXT at its old content: ``block_get_attributes`` reported the new value, ``block_explode`` burst the old one, and on an R2010 document (no embedded MTEXT exported) a save/reload carried the new value the exploded drawing never showed. The embedded MTEXT is rebuilt from its virtual entity with the new content -- the round trip ezdxf's ``BaseAttrib.transform`` itself uses -- so the MTEXT and the placement stay one attribute. The virtual entity is anchored to the ATTRIB's own placement first (``_anchored_mtext``): ``set_mtext`` re-places the ATTRIB from the MTEXT, and after an ``entity_move`` the embedded MTEXT still sat where the attribute *was*, so a value edit teleported the attribute 50 units away from its symbol. ``set_mtext`` then mirrors only the *first line* into ``dxf.text`` (ezdxf's own choice), and below R2018 ezdxf exports no embedded MTEXT, so the default (R2010) document saved ``NEW ONE`` where the reader had promised ``NEW ONE\\PNEW TWO``. ``dxf.text`` is therefore written last with the whole value: that is AutoCAD's own convention (``TextString`` of a live multi-line attribute reads ``'LINE ONE\\PLINE TWO'``), the reader prefers the embedded MTEXT when there is one, and the file carries every line on every DXF version. """ if attrib.has_embedded_mtext_entity: mtext = cls._anchored_mtext(attrib) mtext.text = value attrib.set_mtext(mtext, graphic_properties=False) attrib.dxf.text = value async def block_get_attributes(self, handle) -> dict: def _sync(): ent = self._get_entity(handle) if ent.dxftype() != "INSERT": raise RuntimeError(f"Entity {handle} is not a block reference") result = {} for attrib in ent.attribs: result[attrib.dxf.tag] = self._attrib_value(attrib) return result return await self._async(_sync) async def block_set_attributes(self, handle, attributes) -> dict: from backends.block_specs import validate_attribute_values values = validate_attribute_values(attributes) def _sync(): ent = self._get_entity(handle) if ent.dxftype() != "INSERT": raise RuntimeError(f"Entity {handle} is not a block reference") updated = [] for attrib in ent.attribs: tag = attrib.dxf.tag if tag in values: self._write_attrib_value(attrib, values[tag]) updated.append(tag) self._mark_dirty() return {"ok": True, "updated_tags": updated} return await self._async(_sync) # ── extended entity data (XDATA) ───────────────────────────────────────── async def entity_get_xdata(self, handle, app_name=None) -> dict: from backends.xdata_specs import decode_values, validate_app_name wanted = validate_app_name(app_name) if app_name else None def _sync(): ent = self._get_entity(handle) out: dict[str, list] = {} if ent.xdata is not None: apps = [wanted] if wanted else list(ent.xdata.data.keys()) for app in apps: if not ent.has_xdata(app): continue tags = ent.get_xdata(app) out[app] = decode_values((tag.code, tag.value) for tag in tags) return {"handle": handle, "xdata": out, "backend": "ezdxf"} return await self._async(_sync) async def entity_set_xdata(self, handle, app_name, values) -> dict: from backends.xdata_specs import ( app_size, check_entity_budget, encode_values, validate_app_name, ) # Validate and type every value before touching the document: a bad # values[i] raises here and nothing is written. app = validate_app_name(app_name) tags = encode_values(values) def _sync(): doc = self._require_doc() ent = self._get_entity(handle) if not tags: ent.discard_xdata(app) self._mark_dirty() return { "ok": True, "handle": handle, "app_name": app, "value_count": 0, "bytes": 0, "removed": True, "backend": "ezdxf", } # The 16 KB limit is per entity across every application (ACAD's # own DSTYLE overrides included), so the gate sees what is already # there. Raises before the APPID is registered or anything is set. existing = {} if ent.xdata is not None: existing = { other: [(tag.code, tag.value) for tag in other_tags] for other, other_tags in ent.xdata.data.items() } check_entity_budget(app, tags, existing) if app not in doc.appids: doc.appids.add(app) ent.set_xdata(app, tags) self._mark_dirty() return { "ok": True, "handle": handle, "app_name": app, "value_count": len(tags), "bytes": app_size(app, tags), "removed": False, "backend": "ezdxf", } return await self._async(_sync) async def block_create_from_entities( self, name, handles, base_x=0.0, base_y=0.0, ) -> dict: def _sync(): doc = self._require_doc() # Resolve first. Creating the definition and then discovering every # handle was a typo left an empty block behind, and reporting # entity_count without naming what was dropped told the caller three # of their five entities made it and nothing about the other two. entities, skipped = [], [] for handle in handles: try: entities.append(self._get_entity(handle)) except Exception as exc: log.debug("resolving %s for block %s: %s", handle, name, exc) skipped.append(str(handle)) if not entities: raise RuntimeError( f"block_create_from_entities: none of the handles resolved " f"({', '.join(skipped) or 'no handles given'}), so there is nothing " f"to put in block {name!r}. No definition was created." ) blk = doc.blocks.new(name=name) blk.block.dxf.base_point = (float(base_x), float(base_y), 0.0) for ent in entities: blk.add_entity(ent.copy()) self._mark_dirty() return { "ok": True, "name": name, "entity_count": len(entities), "skipped": skipped, "backend": "ezdxf", } return await self._async(_sync) async def block_define( self, name, entities, attdefs=None, base_x=0.0, base_y=0.0, overwrite=False, create_layers=False, ) -> dict: """A block definition with ATTDEFs from typed specs. Validation runs before any write, so a malformed entry leaves no definition behind: specs, the base point (``base_x``/``base_y`` must be finite numbers — ``float("nan")`` used to be written into the BLOCK record) and the layer table (a primitive's ``layer`` must exist, or ``create_layers`` must be true — a dangling layer reference passes ``doc.audit()`` silently). ``overwrite`` replaces the *contents* of an existing definition rather than deleting it: existing INSERTs keep pointing at the name and show the new geometry (deleting a referenced block is refused by both engines; replacing contents works on both). """ from ezdxf.enums import TextEntityAlignment from backends.block_specs import ( referenced_layers, solid_vertices, validate_attdef_specs, validate_base_point, validate_entity_specs, ) from security import sanitize_symbol_name # The anonymous-name rule comes first: ``*`` is also a forbidden DXF # symbol character, so ``sanitize_symbol_name`` would refuse ``*U9`` # with the generic message before this one could name the real reason. if (name or "").strip().startswith("*"): raise ValueError("block_define: anonymous block names (*...) are refused") clean_name = sanitize_symbol_name(name, kind="block") ents = validate_entity_specs(entities) atts = validate_attdef_specs(attdefs or []) base = (*validate_base_point(base_x, base_y), 0.0) wanted_layers = referenced_layers(ents) align_map = { "left": TextEntityAlignment.LEFT, "center": TextEntityAlignment.CENTER, "right": TextEntityAlignment.RIGHT, "middle_center": TextEntityAlignment.MIDDLE_CENTER, } def _sync(): doc = self._require_doc() existing = doc.blocks.get(clean_name) if clean_name in doc.blocks else None if existing is not None and not overwrite: raise ValueError( f"block_define: block {clean_name!r} already exists; " "pass overwrite=true to replace its contents" ) replaced = existing is not None # ``in doc.layers`` is case-insensitive, like the table itself. missing_layers = [layer for layer in wanted_layers if layer not in doc.layers] if missing_layers and not create_layers: raise ValueError( "block_define: layer(s) " + ", ".join(repr(layer) for layer in missing_layers) + " do not exist in the drawing; create them with layer_create " "or pass create_layers=true" ) for layer in missing_layers: doc.layers.add(layer) if existing is None: blk = doc.blocks.new(name=clean_name, base_point=base) else: blk = existing blk.delete_all_entities() blk.block.dxf.base_point = base for spec in ents: attribs = {"layer": spec["layer"], "color": 0, "linetype": "ByBlock"} kind = spec["type"] if kind == "line": blk.add_line( (spec["x1"], spec["y1"]), (spec["x2"], spec["y2"]), dxfattribs=attribs ) elif kind == "circle": blk.add_circle((spec["cx"], spec["cy"]), spec["r"], dxfattribs=attribs) elif kind == "arc": blk.add_arc( (spec["cx"], spec["cy"]), spec["r"], spec["start_deg"], spec["end_deg"], dxfattribs=attribs, ) elif kind == "polyline": bulges = spec["bulges"] or [0.0] * len(spec["points"]) blk.add_lwpolyline( [(x, y, b) for (x, y), b in zip(spec["points"], bulges, strict=True)], format="xyb", close=spec["closed"], dxfattribs=attribs, ) elif kind == "text": txt = blk.add_text( spec["text"], dxfattribs={ **attribs, "height": spec["height"], "rotation": spec["rotation_deg"], }, ) txt.set_placement((spec["x"], spec["y"]), align=align_map[spec["align"]]) elif kind == "solid": blk.add_solid(solid_vertices(spec["points"]), dxfattribs=attribs) for att in atts: attdef = blk.add_attdef( att["tag"], insert=(att["x"], att["y"]), text=att["default"], dxfattribs={ "layer": "0", "color": 0, "height": att["height"], "rotation": att["rotation_deg"], "prompt": att["prompt"], }, ) attdef.set_placement((att["x"], att["y"]), align=align_map[att["align"]]) if att["invisible"]: attdef.is_invisible = True self._mark_dirty() return { "ok": True, "name": clean_name, "entity_count": len(ents), "attdef_count": len(atts), "replaced": replaced, "layers_created": missing_layers, "backend": "ezdxf", } return await self._async(_sync) # ── analysis / query ────────────────────────────────────────────────────── async def analysis_stats(self) -> dict: def _sync(): msp = self._msp() type_counts: dict[str, int] = {} layer_counts: dict[str, int] = {} for ent in msp: t = ent.dxftype() lyr = ent.dxf.get("layer", "0") type_counts[t] = type_counts.get(t, 0) + 1 layer_counts[lyr] = layer_counts.get(lyr, 0) + 1 total = sum(type_counts.values()) return { "total_entities": total, "by_type": dict(sorted(type_counts.items(), key=lambda x: -x[1])), "by_layer": dict(sorted(layer_counts.items(), key=lambda x: -x[1])), } return await self._async(_sync) async def analysis_entities_in_region( self, x1, y1, x2, y2, ) -> list[EntityInfo]: def _sync(): msp = self._msp() results = [] mn_x, mx_x = min(float(x1), float(x2)), max(float(x1), float(x2)) mn_y, mx_y = min(float(y1), float(y2)), max(float(y1), float(y2)) if _BBOX_OK: for ent in msp: try: bb = ezdxf_bbox.extents([ent]) if ( bb and bb.extmin.x >= mn_x and bb.extmax.x <= mx_x and bb.extmin.y >= mn_y and bb.extmax.y <= mx_y ): results.append(_entity_info_dxf(ent)) except Exception as exc: log.debug("bbox check for entity in region: %s", exc) continue else: # Fallback: check insertion points for ent in msp: try: ins = None if hasattr(ent.dxf, "insert"): ins = ent.dxf.insert elif hasattr(ent.dxf, "start"): ins = ent.dxf.start if ins and mn_x <= ins[0] <= mx_x and mn_y <= ins[1] <= mx_y: results.append(_entity_info_dxf(ent)) except Exception as exc: log.debug("insertion point check for entity in region: %s", exc) continue return results return await self._async(_sync) async def analysis_measure_distance(self, x1, y1, x2, y2) -> float: return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) async def analysis_measure_area(self, points) -> float: return shoelace_area(points) async def entity_measure(self, handle, flatten_tolerance: float = 0.001) -> dict: def _sync(): return _measure_dxf_entity(self._get_entity(handle), float(flatten_tolerance)) return await self._async(_sync) # ── boundary tracing (M8 / F3) ─────────────────────────────────────────── #: How many seed-adjacent edges to try as loop starts. Bounded because the #: work is per candidate; unbounded it would degrade on a large drawing #: without finding a better answer than the nearest edges already give. _BOUNDARY_SEED_CANDIDATES = 64 #: Above this many edges the O(n^2) intersection split is skipped. Reported #: on the response rather than silently degrading the answer. _BOUNDARY_SPLIT_LIMIT = 400 @classmethod def _split_edges_at_intersections(cls, edges, gap_tol: float): """Split straight edges where they cross, so crossings become junctions. ``edgeminer`` connects edges by shared *endpoints*. A construction line drawn across a square touches it at two points that are not vertices of anything, so in the edge graph the line is not connected to the square at all and the halves it makes are invisible. AutoCAD's BOUNDARY computes the intersections; this does the same for straight edges. Curved edges are left whole: splitting an arc mid-span needs the arc's own parameters, and a wrong split would move geometry rather than just miss a loop. ``split`` on the response says whether this ran. """ from ezdxf.math import Vec2, intersection_line_line_2d straight = [] curved = [] for edge in edges: payload = getattr(edge, "payload", None) dxftype = payload.dxftype() if payload is not None else "" (straight if dxftype in ("LINE", "") else curved).append(edge) cuts: dict[int, list] = {index: [] for index in range(len(straight))} for i, a in enumerate(straight): for j in range(i + 1, len(straight)): b = straight[j] point = intersection_line_line_2d( (Vec2(a.start), Vec2(a.end)), (Vec2(b.start), Vec2(b.end)), virtual=False ) if point is None: continue for index, edge in ((i, a), (j, b)): if (point - Vec2(edge.start)).magnitude > gap_tol and ( point - Vec2(edge.end) ).magnitude > gap_tol: cuts[index].append(point) from ezdxf import edgeminer as em result = list(curved) for index, edge in enumerate(straight): points = cuts[index] if not points: result.append(edge) continue start, end = Vec2(edge.start), Vec2(edge.end) ordered = sorted(set(points), key=lambda p: (p - start).magnitude) previous = start for point in [*ordered, end]: if (point - previous).magnitude > gap_tol: result.append(em.make_edge(previous, point, payload=edge.payload)) previous = point return result @staticmethod def _edge_bulge(edge) -> float: """The bulge that reproduces a curved edge as a polyline segment. Without this the traced boundary of two semicircles is a straight line between two points — the shape is gone and only the area report would have noticed. """ from ezdxf import edgesmith from ezdxf.math import Vec2 payload = getattr(edge, "payload", None) if payload is None or payload.dxftype() not in ("ARC", "CIRCLE"): return 0.0 try: span = edgesmith.arc_angle_span_deg( float(payload.dxf.start_angle), float(payload.dxf.end_angle) ) bulge = edgesmith.bulge_from_arc_angle(math.radians(span)) except Exception: return 0.0 # The chain may walk the arc backwards; the bulge follows the walk. center = Vec2(payload.dxf.center) start_is_arc_start = ( Vec2(edge.start) - ( center + Vec2.from_deg_angle(float(payload.dxf.start_angle), float(payload.dxf.radius)) ) ).magnitude <= 1e-6 return bulge if start_is_arc_start else -bulge @classmethod def _chain_to_polyline(cls, space, chain, tolerance: float): """Turn a chain of edges into a genuinely closed LWPOLYLINE. ``edgesmith.lwpolyline_from_chain`` emits n+1 points with the last duplicating the first and ``closed`` left False — a polyline that looks shut on screen and behaves open to everything that asks: hatch association, area queries, ``entity_offset``. The duplicate is dropped and the flag set here instead, and each curved edge keeps its bulge. """ from ezdxf.math import Vec2 points = [] for edge in chain: start = Vec2(edge.start) points.append((start.x, start.y, 0.0, 0.0, cls._edge_bulge(edge))) last = Vec2(chain[-1].end) if (last - Vec2(chain[0].start)).magnitude > max(tolerance, 1e-9): points.append((last.x, last.y, 0.0, 0.0, 0.0)) polyline = space.add_lwpolyline(points, format="xyseb", close=True) polyline.closed = True return polyline, points @staticmethod def _loop_area(chain) -> float: """Unsigned area enclosed by a chain of edges, arcs included. Not ``edgesmith.loop_area``: it works off the edge *endpoints*, so two semicircular arcs closing into a circle measure 0.0 against a true 314.159 — the curvature is exactly the part it drops. Flattening the chain through its own path keeps the curvature at a small, bounded cost (314.12 against 314.159 at a 0.01 sag). This is the **ranking** area: `boundary_trace` uses it to pick the smallest loop enclosing the seed, where a 0.01% error cannot change the winner. What the caller is told comes from `_polyline_area` instead — see there for why the two must not be the same number. """ from ezdxf import edgesmith from engineering.measure import polygon_area_perimeter points = [[v.x, v.y, 0.0] for v in edgesmith.path2d_from_chain(chain).flattening(0.01)] if len(points) < 3: return 0.0 return abs(polygon_area_perimeter(points, closed=True)[0]) @staticmethod def _polyline_area(vertices) -> float: """Unsigned area of the polyline these tools actually stored. The reported area used to come from `_loop_area`, a copy of the shape flattened at a fixed 0.01 sag, while `entity_measure` read the stored bulges analytically. Same figure, two tools, 139.2177 against 139.2699, and neither payload said which one was the approximation. The vertices handed to `add_lwpolyline` carry exact bulges, so the exact answer was always there for the taking. """ from engineering.measure import polygon_area_perimeter # Two vertices is not a degenerate case here: a loop of two arcs is a # circle, and it stores as exactly that. points = [(x, y, bulge) for x, y, _s, _e, bulge in vertices] if len(points) < 2: return 0.0 # Six decimals, the same as `entity_measure` and # `analysis_measure_distance`. Agreeing to full float precision and # then printing different tails is still two answers. return round(abs(polygon_area_perimeter(points, closed=True)[0]), 6) async def boundary_trace(self, x, y, layer=None, tolerance: float = 1e-9) -> dict: def _sync(): from ezdxf import edgeminer as em from ezdxf import edgesmith from ezdxf.math import Vec2 space = self._msp() candidates = [ e for e in space if (not layer or e.dxf.get("layer", "0") == layer) and e.dxftype() != "POINT" ] edges = list(edgesmith.edges_from_entities_2d(candidates)) if not edges: return {"ok": False, "error": "No linear geometry to trace a boundary from"} gap_tol = max(float(tolerance), 1e-9) split = len(edges) <= self._BOUNDARY_SPLIT_LIMIT if split: edges = self._split_edges_at_intersections(edges, gap_tol) seed = Vec2((float(x), float(y))) deposit = em.Deposit(edges, gap_tol=gap_tol) # Walk loops outward from the edges nearest the seed. Enumerating # every loop first would be O(n!); one loop per candidate edge is # linear in the edges actually tried. # Every candidate is scored and the SMALLEST enclosing loop wins: # with nested regions the outer one also contains the seed, and # stopping at the first hit would hand back the wrong boundary. # Bounded to the nearest edges so a large drawing stays linear. best = None nearest = sorted(edges, key=lambda e: (Vec2(e.start) - seed).magnitude) for edge in nearest[: self._BOUNDARY_SEED_CANDIDATES]: for clockwise in (True, False): try: chain = em.find_loop_by_edge(deposit, edge, clockwise=clockwise) except Exception: continue if not chain: continue ring = [Vec2(link.start) for link in chain] if len(ring) < 3 or edgesmith.is_point_in_polygon_2d(seed, ring) < 0: continue area = self._loop_area(chain) if area > 0 and (best is None or area < best[1]): best = (chain, area) if best is None: # "These do not form a loop" is not actionable on its own. If # an endpoint is dangling, the refusal names where, because # that is the coordinate the caller has to go and fix. gap_start, gap_end = self._dangling_endpoint(edges, max(float(tolerance), 1e-9)) if (gap_start - gap_end).magnitude > max(float(tolerance), 1e-9): return { "ok": False, "error": ( f"No closed loop encloses ({float(x):g}, {float(y):g}): there is a " f"gap at ({gap_start.x:g}, {gap_start.y:g}) -> " f"({gap_end.x:g}, {gap_end.y:g})" ), } return { "ok": False, "error": ( f"No closed loop encloses ({float(x):g}, {float(y):g}); the seed point " "is outside every closed region" ), } chain, _ = best polyline, vertices = self._chain_to_polyline(space, chain, float(tolerance)) if layer: polyline.dxf.layer = layer sources = sorted( { handle for handle in ( getattr(getattr(link.payload, "dxf", None), "handle", None) for link in chain ) if handle } ) self._mark_dirty() return { "ok": True, "handle": polyline.dxf.handle, "vertices": len(vertices), "area": self._polyline_area(vertices), "closed": True, "source_handles": sources, } return await self._async(_sync) async def boundary_from_entities(self, handles, tolerance: float = 1e-9) -> dict: def _sync(): from ezdxf import edgeminer as em from ezdxf import edgesmith if not handles or len(handles) < 2: return {"ok": False, "error": "At least 2 entity handles are needed to form a loop"} doc = self._require_doc() entities = [] for raw in handles: key = str(raw or "").strip().upper() entity = doc.entitydb.get(key) if entity is None or not entity.is_alive: return {"ok": False, "error": f"Entity handle not found: {raw}"} if not edgesmith.is_pure_2d_entity(entity): return { "ok": False, "error": ( f"Handle {key} is a {entity.dxftype()}; a boundary can only be " "chained from 2D linear entities" ), } entities.append(entity) edges = list(edgesmith.edges_from_entities_2d(entities)) gap_tol = max(float(tolerance), 1e-9) # The handles arrive in drawing order, not chain order — putting # them in order is the tool's job, so the loop is searched for # rather than assumed. try: chain = em.find_loop(em.Deposit(edges, gap_tol=gap_tol), timeout=5.0) except Exception: chain = [] if not chain or len(chain) != len(edges): start, end = self._dangling_endpoint(edges, gap_tol) return { "ok": False, "error": ( f"These entities do not close: there is a gap at ({start.x:g}, " f"{start.y:g}) -> ({end.x:g}, {end.y:g})" ), } polyline, vertices = self._chain_to_polyline(self._msp(), chain, gap_tol) self._mark_dirty() return { "ok": True, "handle": polyline.dxf.handle, "vertices": len(vertices), "area": self._polyline_area(vertices), "closed": True, } return await self._async(_sync) @staticmethod def _dangling_endpoint(edges, gap_tol: float): """The first endpoint with no partner, so the refusal can name the gap.""" from ezdxf.math import Vec2 endpoints = [] for edge in edges: endpoints.extend([Vec2(edge.start), Vec2(edge.end)]) for point in endpoints: matches = sum(1 for other in endpoints if (point - other).magnitude <= gap_tol) if matches < 2: nearest = min( (p for p in endpoints if (p - point).magnitude > gap_tol), key=lambda p: (p - point).magnitude, default=point, ) return point, nearest return endpoints[0], endpoints[-1] async def analysis_list_properties(self, handle: str) -> dict: def _sync(): entity = self._get_entity(handle) dxftype = entity.dxftype() extrusion = entity.dxf.get("extrusion", (0.0, 0.0, 1.0)) needs_ocs = dxftype in _OCS_ENTITY_TYPES and not ocs.is_wcs_frame(extrusion) def _value(key, raw): # Vec3 and friends are not JSON-serialisable, and a raw OCS # coordinate would contradict every other coordinate this # server reports. `extrusion` itself stays untranslated — it # is the frame, not a point in it. if hasattr(raw, "x") and hasattr(raw, "y"): if key == "extrusion": return [float(raw.x), float(raw.y), float(getattr(raw, "z", 0.0))] if needs_ocs: return ocs.to_wcs_2d(extrusion, raw.x, raw.y, getattr(raw, "z", 0.0)) return [float(raw.x), float(raw.y), float(getattr(raw, "z", 0.0))] if isinstance(raw, (list, tuple)): return [_value(key, item) for item in raw] if isinstance(raw, (int, float, str, bool)) or raw is None: return raw return str(raw) dump = {key: _value(key, raw) for key, raw in entity.dxfattribs().items()} if dxftype == "LWPOLYLINE": # Every vertex reports its bulge, including the straight ones: # a points list that drops zero bulges cannot be read back # positionally. dump["points"] = [ [*ocs.to_wcs_2d(extrusion, p[0], p[1]), p[4]] if needs_ocs else [float(p[0]), float(p[1]), float(p[4])] for p in entity.get_points() ] if dxftype == "MTEXT": dump.setdefault("text", entity.text) info = _entity_info_dxf(entity) return { "ok": True, "handle": info.handle, "type": dxftype, "layer": info.layer, "properties": info.properties, "dxf_attributes": dump, } return await self._async(_sync) async def analysis_bounding_box(self) -> dict: def _sync(): msp = self._msp() if _BBOX_OK: try: bb = ezdxf_bbox.extents(msp) if bb: return { "min": [bb.extmin.x, bb.extmin.y], "max": [bb.extmax.x, bb.extmax.y], "width": bb.extmax.x - bb.extmin.x, "height": bb.extmax.y - bb.extmin.y, } except Exception as e: return {"error": str(e)} return {"error": "ezdxf bbox not available"} return await self._async(_sync) async def analysis_select_by_layer(self, layer_name) -> list[EntityInfo]: def _sync(): msp = self._msp() return [ _entity_info_dxf(e) for e in msp if e.dxf.get("layer", "0").lower() == layer_name.lower() ] return await self._async(_sync) async def analysis_select_by_type(self, entity_type) -> list[EntityInfo]: def _sync(): msp = self._msp() et = entity_type.upper() return [_entity_info_dxf(e) for e in msp if et == e.dxftype().upper()] return await self._async(_sync) async def selection_get(self) -> dict: # COM-only: headless ezdxf has no viewport / pickfirst grip selection. # Return a not-supported result but keep the same shape (empty handles) # so callers that iterate the selection do not KeyError. return { "ok": False, "error": "selection_get not supported in ezdxf backend (no live AutoCAD viewport)", "count": 0, "handles": [], "entities": [], "pickfirst": None, } # ── view / screenshot ────────────────────────────────────────────────────── async def view_zoom_extents(self) -> dict: # 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: # 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, overlay_handles: bool = False) -> bytes | None: """Render drawing to PNG using matplotlib.""" result = await self.view_screenshot_grounded( overlay_handles=overlay_handles, max_labels=DEFAULT_MAX_HANDLE_LABELS ) return result["png"] async def view_screenshot_grounded( self, overlay_handles: bool = True, max_labels: int = DEFAULT_MAX_HANDLE_LABELS, ) -> dict: """Render to PNG, optionally labelling each entity with its handle. Handle grounding: a screenshot shows what the drawing looks like and nothing the caller can act on, because every modify tool takes a handle. Drawing the handle at the entity's own bounding-box centre makes "the circle at the top-left" and "handle 2F" the same statement, and returns the label positions so the mapping is machine-readable too. Labels are ink on the drawing, so a crowded sheet is capped and the image says so — labelling a subset silently would let the picture read as the whole drawing. """ def _sync(): try: doc = self._require_doc() space = self._msp() from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend fig = _new_agg_figure(figsize=(16, 9), dpi=100) ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) out = MatplotlibBackend(ax) Frontend(ctx, out).draw_layout(space, finalize=True) labels: dict[str, list[float]] = {} entities = list(space) total = len(entities) truncated = False if overlay_handles and total: limit = max(0, int(max_labels)) truncated = total > limit for ent in entities[:limit]: anchor = _label_anchor(ent) if anchor is None: continue handle = str(ent.dxf.get("handle", "?")) labels[handle] = [anchor[0], anchor[1]] ax.annotate( handle, anchor, color="#d62728", fontsize=7, ha="center", va="center", bbox={ "boxstyle": "round,pad=0.15", "facecolor": "white", "edgecolor": "#d62728", "linewidth": 0.4, "alpha": 0.85, }, annotation_clip=False, ) if truncated: ax.set_title( f"handles shown for {len(labels)} of {total} entities", color="#d62728", fontsize=9, ) buf = io.BytesIO() fig.savefig(buf, format="png", bbox_inches="tight", dpi=100) buf.seek(0) return { "png": buf.read(), "labels": labels, "labelled": len(labels), "total": total, "truncated": truncated, } except ImportError: log.warning("matplotlib not installed – screenshot unavailable") return { "png": None, "labels": {}, "labelled": 0, "total": 0, "truncated": False, } return await self._async(_sync) # ── transactions ────────────────────────────────────────────────────────── async def transaction_begin(self) -> dict: def _sync(): doc = self._require_doc() fd, tmp_path = tempfile.mkstemp(suffix=".dxf", prefix="acad_mcp_undo_") os.close(fd) try: doc.saveas(tmp_path) except Exception: try: os.unlink(tmp_path) except OSError: pass raise self._transaction_stack.append(Path(tmp_path)) max_stack = config.settings.max_undo_stack while len(self._transaction_stack) > max_stack: old = self._transaction_stack.pop(0) try: old.unlink() except OSError: pass return {"ok": True, "message": "Transaction begun (DXF snapshot saved for rollback)"} return await self._async(_sync) async def transaction_commit(self) -> dict: # 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._transaction_stack: return {"ok": False, "error": "No active transaction"} p = self._transaction_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: # 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._transaction_stack: return {"ok": False, "error": "No active transaction to rollback"} p = self._transaction_stack.pop() try: self._doc = ezdxf.readfile(str(p)) _normalise_dimstyle_rounding(self._doc) self._current_layer = str(self._doc.header.get("$CLAYER", "0")) self._dirty = True finally: try: p.unlink() except OSError: pass return {"ok": True, "message": "Transaction rolled back to snapshot"} return await self._async(_sync) # ── system ──────────────────────────────────────────────────────────────── async def system_status(self) -> dict: # R32: deliberately NOT routed through _async. It reads self._doc # directly, which is what keeps it answerable while the document is # quarantined; putting it behind the lock would make the diagnostic # channel the first thing the quarantine closes, leaving the user with a # refusal and no way to ask what happened. self._reap_quarantine() has_doc = self._doc is not None return { "backend": "ezdxf", "connected": True, "has_document": has_doc, "document_path": self._doc_path, "open_documents": len(self._docs), "active_document": self._active_key, "unsaved_changes": self._dirty, "transaction_depth": len(self._transaction_stack), "quarantine": self._quarantine.to_dict() if self._quarantine else None, "abandoned_calls": [record.to_dict() for record in self._abandoned_calls], "capabilities": [ "file_read_write", "dxf_export", "pdf_export", "entity_creation", "layer_management", "blocks", "dimensions", "analysis", "screenshot_matplotlib", ], } async def system_get_variable(self, name) -> Any: def _sync(): doc = self._require_doc() key = str(name).upper() if key in _VPORT_SYSVARS: attr, shape = _VPORT_SYSVARS[key] raw = getattr(self._active_vport(doc).dxf, attr) if shape == "point": return (float(raw[0]), float(raw[1])) return int(raw) if key == "CANNOSCALE": return self._dictionary_variable(doc, "CANNOSCALE", "1:1") if key == "CANNOSCALEVALUE": return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1")) if key in _UNSAVED_SYSVARS: return None # a file holds no value; the template's memory is not one raw = doc.header.get(f"${key}", None) if key in _HEADER_DEGREE_SYSVARS and raw is not None: return math.radians(float(raw)) return raw return await self._async(_sync) async def system_set_variable(self, name, value) -> dict: from ezdxf.lldxf.const import DXFKeyError def _sync(): doc = self._require_doc() key = str(name).upper() if key in _UNSAVED_SYSVARS: raise UnsupportedCapabilityError( "registry_sysvar", f"system_set_variable: {key} is saved in the AutoCAD registry (or not " "saved at all), never in the drawing, so the headless ezdxf backend has " "nowhere to keep it. Set it on the live COM backend.", ) if key in _VPORT_SYSVARS: attr, shape = _VPORT_SYSVARS[key] vport = self._active_vport(doc) if shape == "point": x, y = float(value[0]), float(value[1]) if x <= 0 or y <= 0: raise ValueError(f"{key}: spacing must be greater than 0, got {value!r}") vport.dxf.set(attr, (x, y)) else: vport.dxf.set(attr, 1 if int(value) else 0) elif key == "CANNOSCALE": self._set_annotation_scale(doc, value) elif key == "CANNOSCALEVALUE": raise ValueError( "CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead." ) else: stored = value if key in _HEADER_DEGREE_SYSVARS: stored = math.degrees(float(value)) try: doc.header[f"${key}"] = stored except DXFKeyError as exc: # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py) # while saying why the write is refused rather than dropped. raise ValueError( f"system_set_variable: {exc} ezdxf has no header slot for it, so the " "value would be dropped at save; system_variable_describe(name) says " "where the variable lives." ) from exc if key in ("LIMMIN", "LIMMAX"): # Measured: `Drawing.update_limits()` copies the model-space # LAYOUT's limits into the header on every write(), so a # header-only value is overwritten at save. The layout is # the source of truth; keep both in step. doc.modelspace().dxf_layout.dxf.set( key.lower(), (float(value[0]), float(value[1])) ) self._mark_dirty() return {"ok": True, "variable": name, "value": value} return await self._async(_sync) async def system_run_command(self, command) -> dict: return { "ok": False, "error": "system_run_command not supported in ezdxf backend (no live AutoCAD)", } async def system_run_lisp(self, expression) -> dict: return { "ok": False, "error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)", } # ── styles (track E) ───────────────────────────────────────────────────── async def dimstyle_list(self) -> list[dict]: def _sync(): doc = self._require_doc() current = _current_dimstyle_name(doc) rows = [ { "name": style.dxf.name, "current": style.dxf.name == current, "values": _read_dimstyle_values(style), "values_available": True, } for style in doc.dimstyles ] rows.sort(key=lambda row: row["name"].lower()) return rows return await self._async(_sync) async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict: from engineering.standards.dimstyles import validate_overrides from security import sanitize_symbol_name clean = sanitize_symbol_name(name, kind="dimstyle") typed = validate_overrides(values) if not typed: raise ValueError("dimstyle_create: values is empty; resolve a preset first") def _sync(): doc = self._require_doc() if doc.dimstyles.has_entry(clean): raise ValueError( f"dimstyle_create: dimension style {clean!r} already exists; " "use dimstyle_modify to change it" ) previous = _current_dimstyle_name(doc) _require_arrowhead_blocks(doc, typed) txsty, textstyle_created = _ensure_textstyle( doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY" ) style = doc.dimstyles.new(clean) _write_dimstyle_values(style, {**typed, "DIMTXSTY": txsty}) if set_current: doc.header["$DIMSTYLE"] = clean _sync_header_to_dimstyle(doc, style) else: # Measured: ezdxf.new() writes $DIMSTYLE "ISO-25" into a fresh # header with no such table entry, so creating a style of that # name would silently make it current. Pin the header to the # style that was effectively current before this call. doc.header["$DIMSTYLE"] = previous self._mark_dirty() return { "ok": True, "name": clean, "values": _read_dimstyle_values(style), "written": sorted(typed), "current": bool(set_current), "textstyle_created": textstyle_created, } return await self._async(_sync) async def dimstyle_modify(self, name: str, values: dict) -> dict: from engineering.standards.dimstyles import validate_overrides typed = validate_overrides(values) if not typed: raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable") def _sync(): doc = self._require_doc() if not doc.dimstyles.has_entry(name): raise ValueError( f"dimstyle_modify: dimension style {name!r} does not exist; " "dimstyle_list names the styles this drawing holds" ) style = doc.dimstyles.get(name) if "DIMTXSTY" in typed and not doc.styles.has_entry(typed["DIMTXSTY"]): raise ValueError( f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; " "create it first with textstyle_create" ) _require_arrowhead_blocks(doc, typed) before = _read_dimstyle_values(style, tuple(typed)) _write_dimstyle_values(style, typed) after = _read_dimstyle_values(style, tuple(typed)) changed = {var: [before[var], after[var]] for var in typed if before[var] != after[var]} if changed and style.dxf.name == _current_dimstyle_name(doc): _sync_header_to_dimstyle(doc, style) using = _dimensions_using(doc, style.dxf.name) if changed: self._mark_dirty() return { "ok": True, "name": style.dxf.name, "changed": changed, "dimensions_using_style": using, # A headless DIMENSION carries its rendered block; it shows the # old style until redrawn. Stated, not silent. "rerender_required": bool(changed and using), } return await self._async(_sync) async def dimstyle_set_current(self, name: str) -> dict: def _sync(): doc = self._require_doc() if not doc.dimstyles.has_entry(name): raise ValueError( f"dimstyle_set_current: dimension style {name!r} does not exist; " "dimstyle_list names the styles this drawing holds" ) style = doc.dimstyles.get(name) previous = _current_dimstyle_name(doc) doc.header["$DIMSTYLE"] = style.dxf.name _sync_header_to_dimstyle(doc, style) changed = style.dxf.name != previous if changed: self._mark_dirty() return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed} return await self._async(_sync) async def textstyle_list(self) -> list[dict]: def _sync(): doc = self._require_doc() current = _current_textstyle_name(doc) rows = [ { "name": style.dxf.name, "font": str(style.dxf.get("font", "") or ""), "height": float(style.dxf.get("height", 0.0) or 0.0), "width_factor": float(style.dxf.get("width", 1.0) or 1.0), "oblique_deg": float(style.dxf.get("oblique", 0.0) or 0.0), "current": style.dxf.name == current, } for style in doc.styles ] rows.sort(key=lambda row: row["name"].lower()) return rows return await self._async(_sync) async def textstyle_create( self, name: str, font: str, height: float = 0.0, width_factor: float = 1.0, oblique_deg: float = 0.0, set_current: bool = False, ) -> dict: from engineering.standards.textstyles import validate_textstyle spec = validate_textstyle(name, font, height, width_factor, oblique_deg) def _sync(): doc = self._require_doc() if doc.styles.has_entry(spec["name"]): raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists") doc.styles.new( spec["name"], dxfattribs={ "font": spec["font_file"], "width": spec["width_factor"], "oblique": spec["oblique_deg"], "height": spec["height"], }, ) if set_current: doc.header["$TEXTSTYLE"] = spec["name"] self._mark_dirty() return { "ok": True, "name": spec["name"], "font": spec["font_file"], # A DXF stores the name only; a font nobody can find is written # and *said* to be unresolved, never refused. "font_resolved": spec["known"] or _font_available(spec["font_file"]), "current": bool(set_current), } return await self._async(_sync) async def textstyle_set_current(self, name: str) -> dict: def _sync(): doc = self._require_doc() if not doc.styles.has_entry(name): raise ValueError( f"textstyle_set_current: text style {name!r} does not exist; " "textstyle_list names the styles this drawing holds" ) style = doc.styles.get(name) previous = _current_textstyle_name(doc) doc.header["$TEXTSTYLE"] = style.dxf.name changed = style.dxf.name != previous if changed: self._mark_dirty() return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed} return await self._async(_sync) async def mleaderstyle_list(self) -> list[dict]: def _sync(): doc = self._require_doc() rows = [] for name, style in doc.mleader_styles: handle = style.dxf.get("text_style_handle") text_style = doc.entitydb.get(handle) if handle else None rows.append( { "name": name, # schema defaults (measured: 4 / 2.0 / 4) for an unset field "arrow_size": float(style.dxf.get("arrow_head_size", 4.0)), "landing_gap": float(style.dxf.get("landing_gap_size", 2.0)), "text_style": text_style.dxf.name if text_style is not None else "Standard", "text_height": float(style.dxf.get("char_height", 4.0)), "values_available": True, } ) rows.sort(key=lambda row: row["name"].lower()) return rows return await self._async(_sync) async def mleaderstyle_create(self, name: str, values: dict) -> dict: from engineering.standards.mleaderstyles import validate_mleaderstyle from security import sanitize_symbol_name clean = sanitize_symbol_name(name, kind="mleaderstyle") typed = validate_mleaderstyle(values) def _sync(): doc = self._require_doc() if doc.mleader_styles.has_entry(clean): raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists") text_style_name, textstyle_created = _ensure_textstyle( doc, typed["text_style"], refusal_key="text_style" ) style = doc.mleader_styles.new(clean) style.dxf.arrow_head_size = typed["arrow_size"] style.dxf.landing_gap_size = typed["landing_gap"] style.dxf.char_height = typed["text_height"] style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle self._mark_dirty() return { "ok": True, "name": clean, "values": {**typed, "text_style": text_style_name}, "textstyle_created": textstyle_created, } return await self._async(_sync) # ── corner ops ────────────────────────────────────────────────────────── @staticmethod def _line_endpoints(ent) -> tuple[tuple[float, float], tuple[float, float]]: return ( (float(ent.dxf.start.x), float(ent.dxf.start.y)), (float(ent.dxf.end.x), float(ent.dxf.end.y)), ) @staticmethod def _dist2(a: tuple[float, float], b: tuple[float, float]) -> float: return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo: def _sync(): from ezdxf.math import Vec2, intersection_line_line_2d target = self._get_entity(target_handle) cutter = self._get_entity(cutter_handle) if target.dxftype() != "LINE" or cutter.dxftype() != "LINE": raise RuntimeError( f"entity_trim V1 supports LINE+LINE only " f"(got {target.dxftype()}+{cutter.dxftype()}). V2 will add LINE+ARC." ) if target_handle == cutter_handle: raise RuntimeError("entity_trim: target and cutter cannot be the same entity.") t_start, t_end = self._line_endpoints(target) c_start, c_end = self._line_endpoints(cutter) # virtual=True so cutter can extend in the target's direction # (AutoCAD's default trim behaviour = "implied extend"). ip = intersection_line_line_2d( (Vec2(*t_start), Vec2(*t_end)), (Vec2(*c_start), Vec2(*c_end)), virtual=True, ) if ip is None: raise RuntimeError( "entity_trim: target and cutter are parallel or do not intersect." ) ix, iy = float(ip.x), float(ip.y) keep = (float(keep_x), float(keep_y)) d_start = self._dist2(t_start, keep) d_end = self._dist2(t_end, keep) if d_start <= d_end: target.dxf.end = (ix, iy, 0.0) else: target.dxf.start = (ix, iy, 0.0) self._mark_dirty() return _entity_info_dxf(target) return await self._async(_sync) async def entity_extend( self, target_handle, boundary_handle, end_x=None, end_y=None, ) -> EntityInfo: def _sync(): from ezdxf.math import Vec2, intersection_line_line_2d target = self._get_entity(target_handle) boundary = self._get_entity(boundary_handle) if target.dxftype() != "LINE" or boundary.dxftype() != "LINE": raise RuntimeError( f"entity_extend V1 supports LINE+LINE only " f"(got {target.dxftype()}+{boundary.dxftype()}). V2 will add LINE+ARC." ) if target_handle == boundary_handle: raise RuntimeError("entity_extend: target and boundary cannot be the same entity.") t_start, t_end = self._line_endpoints(target) b_start, b_end = self._line_endpoints(boundary) ip = intersection_line_line_2d( (Vec2(*t_start), Vec2(*t_end)), (Vec2(*b_start), Vec2(*b_end)), virtual=True, # treat lines as infinite for extend ) if ip is None: raise RuntimeError( "entity_extend: target and boundary are parallel; no extension possible." ) ix, iy = float(ip.x), float(ip.y) if end_x is None or end_y is None: # auto: extend the endpoint nearest the boundary midpoint b_mid = ((b_start[0] + b_end[0]) / 2, (b_start[1] + b_end[1]) / 2) ref = b_mid else: ref = (float(end_x), float(end_y)) d_start = self._dist2(t_start, ref) d_end = self._dist2(t_end, ref) if d_start <= d_end: target.dxf.start = (ix, iy, 0.0) else: target.dxf.end = (ix, iy, 0.0) self._mark_dirty() return _entity_info_dxf(target) return await self._async(_sync) def _fillet_chamfer_setup(self, handle1: str, handle2: str, op: str): """Shared geometry setup for fillet/chamfer. Returns (line1, line2, P, d1, d2, A_far, B_far, theta) where d1/d2 are unit vectors from intersection P toward the far endpoint of each line.""" from ezdxf.math import Vec2, intersection_line_line_2d line1 = self._get_entity(handle1) line2 = self._get_entity(handle2) if line1.dxftype() != "LINE" or line2.dxftype() != "LINE": raise RuntimeError( f"entity_{op} V1 supports LINE+LINE only " f"(got {line1.dxftype()}+{line2.dxftype()}). V2 will add LINE+ARC." ) if handle1 == handle2: raise RuntimeError(f"entity_{op}: handle1 and handle2 cannot be the same.") a_start, a_end = self._line_endpoints(line1) b_start, b_end = self._line_endpoints(line2) P = intersection_line_line_2d( (Vec2(*a_start), Vec2(*a_end)), (Vec2(*b_start), Vec2(*b_end)), virtual=True, ) if P is None: raise RuntimeError(f"entity_{op}: lines are parallel; no intersection.") Pv = (float(P.x), float(P.y)) # Pick the far endpoint of each line (relative to P). A_far = a_start if self._dist2(a_start, Pv) > self._dist2(a_end, Pv) else a_end B_far = b_start if self._dist2(b_start, Pv) > self._dist2(b_end, Pv) else b_end # Unit vectors from P toward the far endpoints. ax, ay = A_far[0] - Pv[0], A_far[1] - Pv[1] bx, by = B_far[0] - Pv[0], B_far[1] - Pv[1] la = math.hypot(ax, ay) lb = math.hypot(bx, by) if la < 1e-12 or lb < 1e-12: raise RuntimeError(f"entity_{op}: degenerate line (zero length).") d1 = (ax / la, ay / la) d2 = (bx / lb, by / lb) cos_t = max(-1.0, min(1.0, d1[0] * d2[0] + d1[1] * d2[1])) theta = math.acos(cos_t) if theta < 1e-9 or abs(math.pi - theta) < 1e-9: raise RuntimeError( f"entity_{op}: lines are collinear or anti-parallel; cannot resolve corner." ) return line1, line2, Pv, d1, d2, A_far, B_far, theta async def entity_fillet(self, handle1, handle2, radius, trim=True) -> EntityInfo: def _sync(): line1, line2, P, d1, d2, A_far, B_far, theta = self._fillet_chamfer_setup( handle1, handle2, "fillet" ) r = float(radius) if r < 0: raise RuntimeError("entity_fillet: radius must be >= 0.") half = theta / 2.0 s = r / math.tan(half) if r > 0 else 0.0 # tangent distance from P T1 = (P[0] + s * d1[0], P[1] + s * d1[1]) T2 = (P[0] + s * d2[0], P[1] + s * d2[1]) msp = self._msp() arc_handle = None if r > 0: # Bisector unit vector (interior of the corner). bx, by = d1[0] + d2[0], d1[1] + d2[1] bl = math.hypot(bx, by) if bl < 1e-12: raise RuntimeError("entity_fillet: bisector is degenerate.") bux, buy = bx / bl, by / bl C = (P[0] + (r / math.sin(half)) * bux, P[1] + (r / math.sin(half)) * buy) # Arc start/end angles in degrees, CCW. Pick the orientation # that yields the short arc (interior fillet) using the cross # product sign of (T1-C) × (T2-C). a1 = math.degrees(math.atan2(T1[1] - C[1], T1[0] - C[0])) a2 = math.degrees(math.atan2(T2[1] - C[1], T2[0] - C[0])) cross = (T1[0] - C[0]) * (T2[1] - C[1]) - (T1[1] - C[1]) * (T2[0] - C[0]) if cross >= 0: start_a, end_a = a1, a2 else: start_a, end_a = a2, a1 arc = msp.add_arc( center=(C[0], C[1]), radius=r, start_angle=start_a, end_angle=end_a, dxfattribs={"layer": line1.dxf.layer}, ) arc_handle = arc.dxf.handle if trim: # Shorten line1 so its near-end becomes T1; same for line2 → T2. a_start, a_end = self._line_endpoints(line1) if self._dist2(a_start, P) <= self._dist2(a_end, P): line1.dxf.start = (T1[0], T1[1], 0.0) else: line1.dxf.end = (T1[0], T1[1], 0.0) b_start, b_end = self._line_endpoints(line2) if self._dist2(b_start, P) <= self._dist2(b_end, P): line2.dxf.start = (T2[0], T2[1], 0.0) else: line2.dxf.end = (T2[0], T2[1], 0.0) self._mark_dirty() if arc_handle is not None: return _entity_info_dxf(self._get_entity(arc_handle)) # Zero-radius fillet: no arc; return line1 (the modified first line). return _entity_info_dxf(line1) return await self._async(_sync) async def entity_chamfer( self, handle1, handle2, dist1, dist2=None, trim=True, ) -> EntityInfo: def _sync(): line1, line2, P, d1, d2, A_far, B_far, theta = self._fillet_chamfer_setup( handle1, handle2, "chamfer" ) d1v = float(dist1) d2v = float(dist1 if dist2 is None else dist2) if d1v <= 0 or d2v <= 0: raise RuntimeError("entity_chamfer: distances must be > 0.") T1 = (P[0] + d1v * d1[0], P[1] + d1v * d1[1]) T2 = (P[0] + d2v * d2[0], P[1] + d2v * d2[1]) msp = self._msp() chamfer_line = msp.add_line( (T1[0], T1[1]), (T2[0], T2[1]), dxfattribs={"layer": line1.dxf.layer}, ) if trim: a_start, a_end = self._line_endpoints(line1) if self._dist2(a_start, P) <= self._dist2(a_end, P): line1.dxf.start = (T1[0], T1[1], 0.0) else: line1.dxf.end = (T1[0], T1[1], 0.0) b_start, b_end = self._line_endpoints(line2) if self._dist2(b_start, P) <= self._dist2(b_end, P): line2.dxf.start = (T2[0], T2[1], 0.0) else: line2.dxf.end = (T2[0], T2[1], 0.0) self._mark_dirty() return _entity_info_dxf(chamfer_line) return await self._async(_sync) # ── premium meta-tools live on AutoCADBackend (base.py) and are shared by # both backends. Only the XLINE primitive is backend-specific. ──────────── async def _create_xline(self, x, y, dx, dy, layer) -> EntityInfo: def _sync(): self._require_doc() msp = self._msp() xline = msp.add_xline( (float(x), float(y), 0.0), (float(dx), float(dy), 0.0), dxfattribs={"layer": layer}, ) self._mark_dirty() return _entity_info_dxf(xline) return await self._async(_sync) # ── settings (track E, group C) ─────────────────────────────────────────── # # Where the variables the `drawing_settings` facade reaches actually live # in a DXF file when they are not header variables. @staticmethod def _active_vport(doc): """The `*Active` VPORT entry — grid and snap state live on it. Owned here (group C). Group V's named views use the same entry and carry a worktree-only copy that the merge agent deletes; this definition is the one that ships. """ entries = doc.viewports.get("*Active") if not entries: entries = [doc.viewports.new("*Active")] return entries[0] @staticmethod def _variable_dictionary(doc, create: bool): """`AcDbVariableDictionary` under the root dictionary (CANNOSCALE lives there).""" vardict = doc.rootdict.get("AcDbVariableDictionary") if vardict is None and create: vardict = doc.rootdict.add_new_dict("AcDbVariableDictionary") return vardict def _dictionary_variable(self, doc, key: str, default: str) -> str: vardict = self._variable_dictionary(doc, create=False) if vardict is None: return default entry = vardict.get(key) return default if entry is None else str(entry.dxf.value) @staticmethod def _scale_entry_name(entry) -> str | None: """The name a SCALE object carries in its AcDbScale group 300. The dictionary *key* is not the name: AutoCAD saves ACAD_SCALELIST keyed `A0`, `A1`, … and only group 300 says `1:50`. ezdxf loads SCALE as `DXFTagStorage`, so the tag is read from the stored subclass. """ xtags = getattr(entry, "xtags", None) if xtags is None: return None for subclass in xtags.subclasses: if subclass and subclass[0].code == 100 and subclass[0].value == "AcDbScale": for tag in subclass: if tag.code == 300: return str(tag.value) return None # AutoCAD's default metric ACAD_SCALELIST, in its own order (measured: # AutoCAD 2026, MEASUREMENT 1, `Documents.Add()` saved as DXF — 17 SCALE # objects keyed A0 … A9, B0 … B6). An imperial seat (MEASUREMENT 0) adds the # architectural fractions on top; those are not seeded — every ratio here # is valid in either unit system, and AutoCAD's SCALELISTEDIT can add more. _DEFAULT_SCALE_LIST: tuple[str, ...] = ( "1:1", "1:2", "1:4", "1:5", "1:8", "1:10", "1:16", "1:20", "1:30", "1:40", "1:50", "1:100", "2:1", "4:1", "8:1", "10:1", "100:1", ) @staticmethod def _scale_list_key(index: int) -> str: """The dictionary key AutoCAD gives the ``index``-th SCALE: A0 … A9, B0 …""" return f"{chr(ord('A') + index // 10)}{index % 10}" @classmethod def _add_scale_object(cls, doc, scales, name: str, paper: float, drawing: float) -> None: """Author one SCALE object and file it under the next free AutoCAD-style key. ezdxf has no SCALE entity class, so the object is authored as raw tags and loaded through the factory (a `DXFTagStorage` that exports verbatim). Measured: it round-trips, ezdxf registers the CLASS, and `doc.audit()` is clean after reopen. """ from ezdxf.entities import factory from ezdxf.lldxf.extendedtags import ExtendedTags existing_keys = set(scales.keys()) index = 0 while cls._scale_list_key(index) in existing_keys: index += 1 handle = doc.entitydb.next_handle() unit = 1 if paper == drawing else 0 text = ( f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n" f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{unit}\n" ) entry = factory.load(ExtendedTags.from_text(text), doc) doc.entitydb.add(entry) doc.objects.add_object(entry) scales.add(cls._scale_list_key(index), entry) @classmethod def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool: """Add `name` to ACAD_SCALELIST unless present; True when added. Presence is decided by the SCALE objects' group-300 names, never by the dictionary key — an AutoCAD-authored drawing keys the list `A0`, `A1`, … and only group 300 says `1:50`; matching on the key appended a duplicate `1:50` to every one of them. An ezdxf-authored drawing has **no** ACAD_SCALELIST entries at all. AutoCAD repopulates its default list only when the dictionary is empty, so appending the one requested scale to an empty list shipped a file whose scale list *was* that one entry — measured (AutoCAD 2026): `SetVariable("CANNOSCALE", "1:1")` then fails with `Error setting system variable` and the annotation scale cannot be changed in the UI. An empty list is therefore seeded with AutoCAD's own default list first, so the saved file offers what a fresh AutoCAD drawing offers. AutoCAD only honours a CANNOSCALE that names an entry of this list. """ scales = doc.rootdict.get("ACAD_SCALELIST") if scales is None: scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") if len(scales) == 0: for default in cls._DEFAULT_SCALE_LIST: _, default_paper, default_drawing = parse_scale(default) cls._add_scale_object(doc, scales, default, default_paper, default_drawing) for _key, entry in scales.items(): if cls._scale_entry_name(entry) == name: return False cls._add_scale_object(doc, scales, name, paper, drawing) return True def _set_annotation_scale(self, doc, value: Any) -> str: name, paper, drawing = parse_scale(value) # refused before any write if self._is_r12(): # Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf # exports neither AcDbVariableDictionary/DICTIONARYVAR nor the # SCALE objects. The in-memory write "succeeded" and reloaded as # 1:1 -- the header-only-write-that-vanishes class again. Refused # here, before either dictionary is created, so a refused call # leaves the root dictionary as it found it. raise ValueError( "CANNOSCALE: R12 has no OBJECTS section, so the annotation scale " "(AcDbVariableDictionary and ACAD_SCALELIST) would be lost on save. " "Save as R2000 or newer first." ) self._ensure_scale_entry(doc, name, paper, drawing) vardict = self._variable_dictionary(doc, create=True) entry = vardict.get("CANNOSCALE") if entry is None: vardict.add_dict_var("CANNOSCALE", name) else: entry.dxf.value = name return name async def drawing_properties_get(self) -> dict: def _sync(): doc = self._require_doc() return { "summary": {field: None for field in SUMMARY_FIELDS}, "summary_available": False, "custom": {tag: value for tag, value in doc.header.custom_vars}, "backend": "ezdxf", } return await self._async(_sync) async def drawing_properties_set( self, summary: dict | None = None, custom: dict | None = None ) -> dict: written, to_write, to_delete = validate_drawing_properties(summary, custom) if written: # The five summary fields live in the DWG SummaryInfo stream, which # a DXF has no slot for; refuse before the custom keys are touched. raise UnsupportedCapabilityError( "dwgprops", "drawing_properties_set: the summary fields (title, subject, author, keywords, " "comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot " f"write (asked for {sorted(written)}). Custom properties work on both engines; " "set the summary on the live COM backend.", ) def _sync(): doc = self._require_doc() if (to_write or to_delete) and doc.dxfversion < "AC1018": # Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only # after $LASTSAVEDBY, a header variable that does not exist # before R2004 (its own CustomVars docstring says so). On an # R12 or R2000 document the in-memory write "succeeded", # drawing_properties_get echoed it back, and drawing_save_as # (version kept) wrote nothing -- the header-only write that # vanishes on save, the class f3ff703 removed for CANNOSCALE. # Refused here, before custom_vars is touched. raise ValueError( f"custom properties: this document is DXF {doc.dxfversion} " f"({doc.acad_release}), and $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY " "header pairs are only written for R2004 (AC1018) or newer, so " f"{sorted(to_write) + sorted(to_delete)} would be lost on save. " "Save as R2004 or newer first." ) custom_vars = doc.header.custom_vars # AutoCAD compares custom keys with a simple per-character case # compare (measured on 2026: SetCustomByKey("PROJECT") updates an # existing "Project" and keeps that spelling, a second AddCustomInfo # is 'Duplicate key' -- but 'Straße' and 'STRASSE' are two keys, # which str.casefold() would merge), and ezdxf's CustomVars are # plain case-sensitive tuples, so the match is done here with # custom_key_fold: an existing tag is updated in place under its # stored spelling, never duplicated under a second one. props = custom_vars.properties for key, value in to_write.items(): folded = custom_key_fold(key) hits = [i for i, (tag, _v) in enumerate(props) if custom_key_fold(tag) == folded] if hits: props[hits[0]] = (props[hits[0]][0], value) else: custom_vars.append(key, value) deleted = [] for key in to_delete: folded = custom_key_fold(key) kept = [(tag, v) for tag, v in props if custom_key_fold(tag) != folded] if len(kept) != len(props): props[:] = kept deleted.append(key) if to_write or deleted: self._mark_dirty() return { "ok": True, "summary_written": [], "custom_written": sorted(to_write), "custom_deleted": deleted, "backend": "ezdxf", } return await self._async(_sync)