Files
autocad-mcp-pro/backends/ezdxf_backend.py
T
grigory 1cc9043d70 port: наш оверлей 1.5.1 → апстрим 1.6.0 (ветка ours-1.6)
Перенос нашей дельты (10 изменённых + 2 новых файла) с v1.5.1 на тег v1.6.0
трёхсторонним слиянием с ветвью ours-1.5 (v1.5.1 + /opt/data/autocad-mcp-build/patch).
Файлы 1.6.0 не перезаписывались копированием: 30 конфликтов разрешены по смыслу.

Что перенесено (наше, чего в 1.6.0 не было):
- инструменты: titleblock_apply_gost_form3, file_put, file_get,
  drawing_open_base64, drawing_save_base64, entity_create_attdef,
  block_get_attdefs, textstyle_ensure, text_apply_style, text_style_report,
  dimstyle_ensure, layout_set_paper (+ таблица алиасов и регистрация в server.py);
- параметр `layout` (адресация в пространство листа) — 36 инструментов, включая
  геометрию (line/polyline/circle/arc/...), block_insert/block_create_from_entities,
  dimension_* и аннотацию; в COM-бэкенде этому служит _activate_layout();
- `inline_font` (ГОСТ-шрифт ISOCPEUR прямо в содержимое MTEXT/таблиц) — 5 инструментов;
- ГОСТ-стиль по умолчанию (_ensure_default_text_style, DEFAULT_TEXT_STYLE=GOST_A)
  при drawing_new/drawing_open;
- ATTDEF → ATTRIB: block_insert создаёт именованную вставку (не анонимный *U-блок),
  block_set_attributes достраивает отсутствующие ATTRIB из определений блока;
- отчётность block_insert/block_set_attributes: filled / unknown_tags / created_tags
  и штамп стиля на ATTRIB (mark_style, attr_style);
- PDF: page_size («layout» — страница равна формату листа 1:1, «extents» — по
  содержимому), настоящий текстовый слой (pdf.fonttype=42 + отрисовка текста
  поверх геометрии, включая текст через видовые экраны) и предупреждения о
  нечитаемо мелких надписях;
- engineering/gost_titleblock.py (основная надпись по ГОСТ Р 21.1101-2013, форма 3)
  и _fonts/isocpeur.ttf.

Что взято у 1.6.0 (наши дубли заменены, чтобы не спорить с апстримом):
- drawing_export_pdf: рамка листа считается его _paper_frame() (масштаб, plot area,
  поворот, поля) вместо нашей _layout_paper_mm(); наш текстовый слой и page_size
  наложены сверху;
- размерные стили: _current_dimstyle_name() вместо нашего _current_dimstyle()
  (наш хелпер оставлен — им пользуется отчёт о стилях);
- таблицы (ezdxf и COM): объединённая строка-заголовок, «span», масштабируемый
  отступ ячеек и SetCellTextHeight() из 1.6.0 + наш inline_font/style;
- block_insert: структура вставки и обработка многострочных ATTRIB из 1.6.0
  + наша отчётность и штамп стиля;
- экспорт DWG/xref/image_attach/профили, группы arch/pid/mech/sheet/understand —
  сохранены целиком.

Три структурные правки после слияния (git сшил код механически, найдены pyflakes
и аудитом дерева):
- com_backend: наша подпись drawing_export_pdf попала в тело xref_attach и
  дублировала метод; вставка убрана, паритетные text_layer/page_size перенесены
  в настоящую COM-функцию;
- _space_name в ezdxf-бэкенде был занят дважды (нашин и 1.6.0): наш переименован
  в _reply_space_name, иначе он затенял хелпер апстрима;
- entity_create_text: ключ "style" в dxfattribs стоял дважды (наш оставлен).

Прод :30015 не затронут.
2026-10-05 18:12:40 +03:00

10254 lines
434 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
def _layout_paper_mm(layout) -> tuple[float, float] | None:
"""The sheet a paper-space layout was set up for, in millimetres.
``Layout.dxf.paper_width`` / ``paper_height`` are the *page* fields a plotter
reads, not the drawn content: an A3 layout carries 420 x 297 no matter how
much geometry happens to sit on it. They are what ``drawing_export_pdf`` has
to size its page from — autoscaling to the content bounding box turned every
sheet into a ~168 x 122 mm thumbnail of itself.
Model space has no sheet (nothing to plot 1:1 onto), so ``None`` is returned
and the extents behaviour stays in force. ``$MEASUREMENT`` says whether the
stored numbers are millimetres (1, metric) or inches (0).
"""
try:
dxf = layout.dxf
width = float(dxf.get("paper_width", 0.0) or 0.0)
height = float(dxf.get("paper_height", 0.0) or 0.0)
except Exception as exc: # a malformed LAYOUT record must not kill the export
log.debug("PDF page: layout paper fields unreadable (%s)", exc)
return None
if width <= 0 or height <= 0:
return None
try:
metric = int(layout.doc.header.get("$MEASUREMENT", 1) or 1)
except Exception:
metric = 1
if metric == 0:
return width * 25.4, height * 25.4
return width, height
#: DejaVu Sans cap-height / em ratio. DXF text height is a cap height, matplotlib
#: font size is an em size, so a 5 mm DXF text is drawn at 5 / 0.72 * scale pt.
_PDF_TEXT_CAP_RATIO = 0.72
#: Text smaller than this (points) is not worth a text object in the PDF.
_PDF_MIN_FONT_PT = 0.4
#: Smallest legible cap height on a sheet, mm (ГОСТ 2.304 / ISO 3098 for
#: drawing lettering). ``drawing_export_pdf`` lists every label that plots below
#: it in ``warnings``: an illegible sheet used to leave the plotter silently.
_PDF_MIN_READABLE_MM = 1.8
#: Where the bundled ISOCPEUR face may live. ISOCPEUR is the GOST 2.304
#: typeface the drawings are lettered in; AutoCAD ships it, a bare container
#: does not, so the build copies the TTF next to the package. When nothing is
#: found the text layer falls back to matplotlib's DejaVu Sans, which still
#: carries Cyrillic and a usable ToUnicode map — a missing font degrades the
#: look of the PDF, it must never fail the export.
_PDF_FONT_CANDIDATES = (
Path(__file__).resolve().parent.parent / "_fonts" / "isocpeur.ttf",
Path("/opt/fonts/isocpeur.ttf"),
Path("/usr/share/fonts/truetype/isocpeur/isocpeur.ttf"),
)
_pdf_font_cache: dict[str, str | None] = {}
def _pdf_font() -> tuple[str, str | None]:
"""``(matplotlib family, source path)`` for the editable text layer.
Resolved once per process: ``addfont`` mutates matplotlib's global font
manager, and doing that on every export would grow its registry without
bound inside a long-lived MCP server.
"""
if _pdf_font_cache:
return _pdf_font_cache["family"], _pdf_font_cache["path"]
family: str = "DejaVu Sans"
path: str | None = None
try:
from matplotlib import font_manager
from matplotlib.font_manager import FontProperties
for candidate in _PDF_FONT_CANDIDATES:
if not candidate.is_file():
continue
font_manager.fontManager.addfont(str(candidate))
name = str(FontProperties(fname=str(candidate)).get_name() or "").strip()
if name:
family, path = name, str(candidate)
break
except Exception as exc: # noqa: BLE001 - a bad font must not break the plot
log.debug("PDF text layer: ISOCPEUR unavailable (%s)", exc)
_pdf_font_cache["family"], _pdf_font_cache["path"] = family, path
return family, path
#: The text style a drawing is lettered in. ISOCPEUR is the ГОСТ 2.304 face
#: (``isocpeur.ttf``, shipped with AutoCAD); the style holding it is called
#: GOST_A by the title-block module, and one name for both keeps the stamp and
#: the model lettering identical instead of half the sheet in ``Standard``.
DEFAULT_TEXT_STYLE = "GOST_A"
DEFAULT_TEXT_STYLE_FONT = "isocpeur.ttf"
#: Entity types whose ``style`` attribute selects a lettering style.
_TEXT_STYLE_TYPES = ("TEXT", "MTEXT", "ATTRIB", "ATTDEF")
def _ensure_default_text_style(doc, name: str = DEFAULT_TEXT_STYLE,
font: str = DEFAULT_TEXT_STYLE_FONT) -> dict:
"""Create the ГОСТ text style and make it the drawing's ``$TEXTSTYLE``.
Every ``entity_create_*`` that writes text takes its style from
``$TEXTSTYLE`` when the caller names none, so a drawing that starts without
that style letters its model text in ``Standard`` — not the face a ГОСТ sheet
is drawn in, and on some DXF versions not a Unicode font either. Called on
create and on open, so the style is current before any text lands.
"""
try:
created = False
if name in doc.styles:
style = doc.styles.get(name)
if font and not str(style.dxf.get("font", "") or "").strip():
style.dxf.font = font
else:
style = doc.styles.add(name, font=font or DEFAULT_TEXT_STYLE_FONT)
created = True
doc.header["$TEXTSTYLE"] = name
return {
"name": name,
"font": str(style.dxf.get("font", "") or ""),
"created": created,
"default": doc.header.get("$TEXTSTYLE", ""),
}
except Exception as exc: # a style table that refuses must not block a load
log.debug("default text style unavailable (%s)", exc)
return {"name": name, "error": str(exc)}
def _pdf_text_halign(entity) -> str:
return {0: "left", 1: "center", 2: "right", 3: "left", 4: "center", 5: "center"}.get(
int(entity.dxf.get("halign", 0) or 0), "left"
)
def _pdf_text_valign(entity) -> str:
return {
0: "baseline",
1: "bottom",
2: "center",
3: "top",
}.get(int(entity.dxf.get("valign", 0) or 0), "baseline")
def _pdf_text_anchor(entity) -> tuple[float, float]:
"""Insertion point, honouring the alignment point of aligned TEXT.
A TEXT entity with halign/valign set ignores ``insert`` and is positioned by
``align_point`` instead — reading only ``insert`` would put every centred or
right-aligned label in the wrong place.
"""
halign = int(entity.dxf.get("halign", 0) or 0)
valign = int(entity.dxf.get("valign", 0) or 0)
point = entity.dxf.get("align_point", None) if (halign or valign) else None
if point is None:
point = entity.dxf.get("insert", (0.0, 0.0, 0.0))
return float(point[0]), float(point[1])
def _iter_pdf_text_entities(layout) -> list:
"""Every TEXT/MTEXT/ATTRIB that should reach the PDF, in WCS.
Block references are expanded with ``virtual_entities()`` so their nested
TEXT entities arrive transformed by the insert's position, rotation and
scale instead of in the block's local frame. Their ATTRIBs are *not* part of
the virtual expansion — a DXF ATTRIB is a real entity owned by the reference
(``INSERT.attribs``), holding the value the attribute definition was filled
with — so they are collected separately. Without them the block marks
(``Т1``/``S1``/``V1``) never reached the plot even though the geometry did.
"""
found = []
try:
entities = list(layout)
except Exception:
return found
for ent in entities:
dxftype = ent.dxftype()
if dxftype in ("TEXT", "MTEXT", "ATTRIB"):
found.append(ent)
elif dxftype == "INSERT":
try:
found.extend(
sub for sub in ent.virtual_entities() if sub.dxftype() in ("TEXT", "MTEXT")
)
except Exception as exc: # a broken reference must not kill the plot
log.debug("PDF text layer: skipping INSERT %s (%s)", ent.dxf.get("handle", "?"), exc)
try:
found.extend(ent.attribs)
except Exception as exc:
log.debug("PDF text layer: INSERT %s attributes unreadable (%s)",
ent.dxf.get("handle", "?"), exc)
elif dxftype == "DIMENSION":
# Текст размера лежит не в самой сущности, а в её блоке геометрии
# (*D1): обход смотрел только INSERT, поэтому геометрия размера
# печаталась, а само число на листе пропадало. Блок у размера
# свой собственный, поэтому двойного учёта не возникает.
try:
block = ent.get_geometry_block()
except Exception as exc:
log.debug("PDF text layer: DIMENSION %s geometry unreadable (%s)",
ent.dxf.get("handle", "?"), exc)
block = None
if block is not None:
for sub in block:
if sub.dxftype() in ("TEXT", "MTEXT"):
found.append(sub)
return found
def _pdf_text_extents(entities) -> tuple[float, float, float, float] | None:
"""Bounding box of the text entities, in the same space they live in.
``TextPolicy.IGNORE`` keeps TEXT out of the geometry pass, so the extents the
Frontend derives exclude it. On a drawing whose only vertical size comes from
its labels (a plan whose geometry is one horizontal line, a detail sheet)
that leaves a degenerate plot box: the page is scaled to the geometry alone
and the labels — placed in data coordinates — land outside the visible area
and are clipped away on save. The caller unions these extents into the axes
limits before drawing the text, so the strings survive into the PDF.
"""
if not entities:
return None
boxes = []
if _BBOX_OK:
try:
box = ezdxf_bbox.extents(entities, fast=True)
if box.has_data:
boxes.append((float(box.extmin.x), float(box.extmin.y),
float(box.extmax.x), float(box.extmax.y)))
except Exception as exc: # a pathological entity must not kill the export
log.debug("PDF text layer: ezdxf bbox failed (%s)", exc)
if not boxes:
# Fallback estimate: anchor plus the font height, width from the glyph count.
xs: list[float] = []
ys: list[float] = []
for ent in entities:
try:
if ent.dxftype() == "MTEXT":
point = ent.dxf.get("insert", (0.0, 0.0, 0.0))
height = float(ent.dxf.get("char_height", 2.5) or 2.5)
text = ent.plain_text()
else:
point = _pdf_text_anchor(ent)
height = float(ent.dxf.get("height", 2.5) or 2.5)
text = str(ent.dxf.get("text", ""))
except Exception:
continue
x, y = float(point[0]), float(point[1])
xs.extend((x, x + max(len(text), 1) * height * 0.7))
ys.extend((y, y + height * 1.2))
if not xs:
return None
boxes.append((min(xs), min(ys), max(xs), max(ys)))
return (
min(b[0] for b in boxes),
min(b[1] for b in boxes),
max(b[2] for b in boxes),
max(b[3] for b in boxes),
)
def _pdf_text_item(entity) -> dict | None:
"""One drawable label: text, anchor, height in drawing units, alignment.
``None`` for anything that must not reach the plot — an empty string, an
invisible ATTRIB, an entity whose DXF fields do not read.
"""
try:
if entity.dxftype() == "MTEXT":
text = entity.plain_text()
height = float(entity.dxf.get("char_height", 2.5) or 2.5)
point = entity.dxf.get("insert", (0.0, 0.0, 0.0))
x, y = float(point[0]), float(point[1])
rotation = float(entity.dxf.get("rotation", 0.0) or 0.0)
ha, va = "left", "top"
else:
# An ATTRIB flagged invisible is data the sheet deliberately hides
# (the ATTDEF "Invisible" mode); plotting it would put values on the
# drawing that the file itself says are not displayed.
if entity.dxftype() == "ATTRIB" and int(entity.dxf.get("invisible", 0) or 0):
return None
text = str(entity.dxf.get("text", ""))
height = float(entity.dxf.get("height", 2.5) or 2.5)
x, y = _pdf_text_anchor(entity)
rotation = float(entity.dxf.get("rotation", 0.0) or 0.0)
ha, va = _pdf_text_halign(entity), _pdf_text_valign(entity)
except Exception as exc:
log.debug("PDF text layer: unreadable entity (%s)", exc)
return None
text = (text or "").strip()
if not text or height <= 0:
return None
return {
"text": text,
"x": x,
"y": y,
"height": height,
"rotation": rotation,
"ha": ha,
"va": va,
"source": entity.dxftype(),
"clip": None,
"via": "layout",
}
def _viewport_text_items(layout) -> tuple[list[dict], list[dict]]:
"""Model-space labels as a paper-space layout's viewports show them.
The geometry pass already projects model content through each viewport, but
the text layer is assembled from entities and a viewport holds none: the
model's TEXT/MTEXT/ATTRIB were missing from every plotted sheet while the
circles and lines around them were drawn. Each label is mapped with
``paper = centre + (model - view_center) * scale`` and clipped to the viewport
window; its height is scaled the same way, so the lettering keeps the
proportion AutoCAD gives it instead of plotting at model size over the sheet.
"""
try:
modelspace = layout.doc.modelspace()
except Exception as exc:
log.debug("PDF text layer: model space unavailable (%s)", exc)
return [], []
model_items = [item for item in map(_pdf_text_item, _iter_pdf_text_entities(modelspace)) if item]
if not model_items:
return [], []
items: list[dict] = []
info: list[dict] = []
for viewport in layout.viewports():
try:
width = float(viewport.dxf.get("width", 0.0) or 0.0)
height = float(viewport.dxf.get("height", 0.0) or 0.0)
view_height = float(viewport.dxf.get("view_height", 0.0) or 0.0)
center = viewport.dxf.get("center", (0.0, 0.0, 0.0))
view_center = viewport.dxf.get("view_center_point", (0.0, 0.0, 0.0))
except Exception as exc:
log.debug("PDF text layer: viewport unreadable (%s)", exc)
continue
if width <= 0 or height <= 0 or view_height <= 0:
# The layout's main viewport carries the tab's own pan/zoom state
# rather than a drafting scale, so it has no view height to use.
continue
scale = height / view_height
cx, cy = float(center[0]), float(center[1])
vx, vy = float(view_center[0]), float(view_center[1])
x0, y0 = cx - width / 2.0, cy - height / 2.0
clip = (x0, y0, x0 + width, y0 + height)
for item in model_items:
moved = dict(item)
moved["x"] = cx + (item["x"] - vx) * scale
moved["y"] = cy + (item["y"] - vy) * scale
moved["height"] = item["height"] * scale
moved["clip"] = clip
moved["via"] = "viewport"
items.append(moved)
info.append(
{
"handle": str(viewport.dxf.get("handle", "")),
"center": [cx, cy],
"size": [width, height],
"scale": round(scale, 8),
"view_height": view_height,
"clip": list(clip),
}
)
return items, info
def _layout_text_items(layout, *, include_viewports: bool) -> tuple[list[dict], list[dict]]:
"""Every label to plot for ``layout``: its own, plus what viewports show."""
items = [item for item in map(_pdf_text_item, _iter_pdf_text_entities(layout)) if item]
info: list[dict] = []
if include_viewports:
projected, info = _viewport_text_items(layout)
items.extend(projected)
return items, info
def _item_box(item) -> tuple[float, float, float, float]:
"""Rough data-space box of a label, for the "does it reach the window" test."""
width = max(len(item["text"]), 1) * item["height"] * 0.9
height = item["height"] * 1.35
x, y = item["x"], item["y"]
if item["ha"] == "center":
x -= width / 2.0
elif item["ha"] == "right":
x -= width
if item["va"] == "top":
y -= height
elif item["va"] == "center":
y -= height / 2.0
return x, y, x + width, y + height
def _draw_pdf_text_items(ax, fig, items, *, family: str = "DejaVu Sans") -> dict:
"""Place real text on the matplotlib axes so the PDF keeps a text layer.
ezdxf >= 1.1 converts TEXT/MTEXT to filled glyph paths before the backend
sees them, so ``drawing_export_pdf`` had no text in the output at all: text
extraction returned an empty string and the labels were not selectable. The
geometry pass runs with ``TextPolicy.IGNORE`` and the strings are drawn here,
in data coordinates, at a font size derived from the axes scale.
A label carrying ``clip`` belongs to a viewport: it is cut off at the window
edge the way AutoCAD plots it, instead of spilling across the sheet. Every
label that ends up below ``_PDF_MIN_READABLE_MM`` on the sheet is named in
``warnings``, so an illegible sheet is reported instead of plotted silently.
"""
from matplotlib.patches import Rectangle
stats: dict = {
"entities": len(items),
"drawn": 0,
"skipped": 0,
"clipped": 0,
"font": family,
"fonttype": 42,
"min_height_mm": _PDF_MIN_READABLE_MM,
"warnings": [],
}
try:
y0, y1 = ax.get_ylim()
fig_w_in, fig_h_in = fig.get_size_inches()
except Exception as exc:
stats["error"] = f"axes limits unavailable: {exc}"
return stats
y_span = abs(y1 - y0)
if y_span <= 0 or not fig_h_in:
stats["error"] = "degenerate axes limits"
return stats
pt_per_unit = (float(fig_h_in) * 72.0) / y_span
stats["points_per_unit"] = round(pt_per_unit, 6)
for item in items:
fontsize = item["height"] * pt_per_unit / _PDF_TEXT_CAP_RATIO
if fontsize < _PDF_MIN_FONT_PT:
stats["skipped"] += 1
continue
clip = item.get("clip")
if clip is not None:
bx0, by0, bx1, by1 = _item_box(item)
if bx1 < clip[0] or bx0 > clip[2] or by1 < clip[1] or by0 > clip[3]:
stats["clipped"] += 1
continue
try:
artist = ax.text(
item["x"],
item["y"],
item["text"],
fontsize=fontsize,
rotation=item["rotation"],
rotation_mode="anchor",
ha=item["ha"],
va=item["va"],
color="black",
family=family,
clip_on=clip is not None,
)
if clip is not None:
artist.set_clip_path(
Rectangle(
(clip[0], clip[1]),
clip[2] - clip[0],
clip[3] - clip[1],
transform=ax.transData,
)
)
stats["drawn"] += 1
except Exception as exc:
log.debug("PDF text layer: drawing failed (%s)", exc)
stats["skipped"] += 1
continue
if item["height"] < _PDF_MIN_READABLE_MM:
stats["warnings"].append(
{
"text": item["text"][:60],
"height_mm": round(item["height"], 3),
"source": item["source"],
"via": item["via"],
"at": [round(item["x"], 2), round(item["y"], 2)],
}
)
return stats
def _draw_pdf_text_layer(ax, layout, fig, *, include_viewports: bool = False) -> dict:
"""Draw everything textual a layout shows: its own labels and viewport content."""
family, _font_path = _pdf_font()
items, viewport_info = _layout_text_items(layout, include_viewports=include_viewports)
stats = _draw_pdf_text_items(ax, fig, items, family=family)
if viewport_info:
stats["viewports"] = viewport_info
return stats
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 _odafc_available() -> bool:
"""Is the ODA File Converter on this machine right now?
Re-evaluated per call, the way the matplotlib check behind
``viewport_render`` is: installing the converter must change the answer
without editing the map.
"""
try:
from ezdxf.addons import odafc
return bool(odafc.is_installed())
except Exception: # noqa: BLE001 - an import failure is simply "not installed"
return False
def _image_pixels(path: str) -> tuple[int, int]:
"""Pixel size of a raster, so the placed image keeps the file's aspect."""
try:
from PIL import Image
except ImportError as exc: # pragma: no cover - Pillow ships with [pdf]
raise ValueError(
"image_attach needs Pillow to read the image's pixel size; "
'install it with: pip install -e ".[pdf]"'
) from exc
try:
with Image.open(path) as image:
return int(image.width), int(image.height)
except Exception as exc:
raise ValueError(
f"image_attach: {path} is not an image this server can read ({exc})"
) from exc
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
``'<ezdxf.audit.ErrorEntry object at 0x...>'`` — 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 _arial_standard_style(doc) -> None:
"""Standard in Arial, as AutoCAD 2026's acadiso.dwt ships it.
ezdxf's own default is txt.shx, which has no superscript two: an
architectural area label 'm²' read 'm?' when AutoCAD opened the DXF, and
rendered as a box headlessly. A new live drawing already starts in Arial,
so the two engines now begin from the same Standard.
"""
doc.styles.get("Standard").dxf.font = "arial.ttf"
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 _current_dimstyle(doc) -> str:
"""Размерный стиль, которым будет начерчен следующий размер.
Размеры жёстко брали ``Standard``, поэтому «сделать СПДС текущим» меняло
только заголовок ``$DIMSTYLE``, а следующий размер всё равно выходил
Standard. Берём из заголовка, если он называет существующий стиль; иначе —
прежний ``Standard``, то есть на нетронутом чертеже поведение не меняется.
Стили, которые создаёт сам ezdxf (``drawing_new`` идёт через
``ezdxf.new(setup=True)`` и оставляет в заголовке ``$DIMSTYLE=EZDXF``),
пропускаем: сервер их не настраивал, а метрические ``EZ_*``-стили несут
коэффициент длины — прочитанный вслепую заголовок начертил бы 100 мм как
«10000».
"""
try:
name = str(doc.header.get("$DIMSTYLE", "") or "").strip()
if name and not name.upper().startswith("EZ") and name in doc.dimstyles:
return name
except Exception: # a header that will not read must not block a dimension
pass
return _DIMSTYLE_NAME
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"
),
# Track G keys. `dwg_write` is a runtime re-evaluation, not a
# hardcoded False: installing the converter must change the
# answer without editing this map, exactly as `viewport_render`
# follows matplotlib.
"dwg_write": (
FeatureCapability(True, "odafc", reason="oda_file_converter_installed")
if _odafc_available()
else FeatureCapability(
False,
reason=(
"requires_oda_file_converter:"
"https://www.opendesign.com/guestfiles/oda_file_converter"
),
)
),
"xref_live": FeatureCapability(
False,
reason="reload_and_bind_need_a_live_seat;list_detach_path_are_headless",
),
"xlsx_write": (
FeatureCapability(True, "openpyxl")
if find_spec("openpyxl") is not None
else FeatureCapability(False, reason="optional_dependency_missing:openpyxl")
),
},
)
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()
def _space(self, layout=None):
"""The space one create call writes into: current space, or a named sheet.
``layout`` is a per-call target, not a mode switch: without it the call
lands where it always did (``_msp`` — model space, or the current layout
after ``layout_set_current``). With it the entities go into that paper
layout's block, which is what makes annotation live in millimetres of
paper and stop depending on the viewport scale. The named layout must
exist — silently falling back to model space would put a sheet's labels
somewhere nobody looks.
"""
name = self._layout_name(layout) if layout is not None else ""
if not name:
return self._msp()
if name.lower() == "model":
return self._require_doc().modelspace()
resolved = self._find_layout(name)
if resolved is None:
raise RuntimeError(
f"layout {name!r} does not exist: create it with layout_create first"
)
return self._require_doc().layouts.get(resolved)
def _inline_lettering_style(self, style=None) -> str:
"""The style that gives a single-line TEXT the inline font's guarantee.
``inline_font`` on MTEXT writes ``{\\fISOCPEUR|b0|i0|c204|p34;\\Q0;…}``
and MTEXT decodes it. A single-line TEXT (DTEXT) does not parse control
codes at all — the group would be plotted literally, braces and all — so
for TEXT the same promise ("this label is lettered in ISOCPEUR whatever
happens to the style table") is kept by putting the entity into the ГОСТ
style that owns ``isocpeur.ttf``. Pavel's sample does exactly this: every
MTEXT carries the inline group, every TEXT simply sits in the ОСНОВНОЙ
(isocpeur.ttf) style.
"""
if style:
return str(style)
try:
if DEFAULT_TEXT_STYLE in self._require_doc().styles:
return DEFAULT_TEXT_STYLE
except Exception as exc: # a style table that will not read must not block
log.debug("inline lettering style unavailable (%s)", exc)
return self._default_text_style()
@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)
_arial_standard_style(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),
}
result = {"ok": True, "name": "untitled.dxf"}
# The ГОСТ lettering style is put in place before the first entity:
# every text tool reads $TEXTSTYLE when the caller names no style, so
# an empty header is what used to letter the whole model in Standard.
result["text_style"] = _ensure_default_text_style(self._doc)
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
result = {"ok": True, "name": Path(path).name, "path": path}
# Opening a file written before the ГОСТ style existed would leave
# every later tool lettering new text in Standard; the style is put
# in place (and made current) on the way in.
result["text_style"] = _ensure_default_text_style(self._doc)
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_open_base64(self, content_base64: str, name: str = "from_base64.dxf") -> dict:
"""Open a drawing handed over inline as base64 (N4, 01.10.2026).
The container's ``/opt/data/dxf`` is NOT the agent's ``/opt/data/dxf``
(separate bind mount), so a file the agent writes is invisible to
``drawing_open`` and a file this server saves is invisible to the agent.
Every exchange had to detour through a throwaway container. This opens
the drawing from the bytes themselves — no shared folder, no HTTP bridge.
"""
import base64 as _b64
import io as _io
def _sync():
raw = str(content_base64 or "").strip()
if not raw:
raise RuntimeError("drawing_open_base64: content_base64 is empty")
if raw.startswith("data:"):
raw = raw.split(",", 1)[-1]
try:
data = _b64.b64decode(raw, validate=False)
except Exception as exc:
raise RuntimeError(f"drawing_open_base64: not valid base64 ({exc})") from exc
max_bytes = config.settings.max_dxf_bytes
if max_bytes > 0 and len(data) > max_bytes:
raise RuntimeError(
f"DXF payload exceeds MAX_DXF_BYTES limit ({len(data)} > {max_bytes})."
)
text = data.decode("utf-8", errors="replace")
self._doc = ezdxf.read(_io.StringIO(text))
_normalise_dimstyle_rounding(self._doc)
self._clear_quarantine("drawing_open_base64")
self._doc_path = None
self._dirty = False
try:
self._current_layer = self._doc.header.get("$CLAYER", "0")
except Exception:
self._current_layer = "0"
self._reset_document_state()
self._current_space = "Model"
self._reset_history_baseline()
return {
"ok": True,
"name": str(name or "from_base64.dxf"),
"bytes": len(data),
"source": "base64",
"text_style": _ensure_default_text_style(self._doc),
}
return await self._async(_sync, quarantine_exit=True)
async def drawing_save_base64(self) -> dict:
"""Return the current drawing inline as base64 DXF (N4, 01.10.2026)."""
import base64 as _b64
import io as _io
def _sync():
doc = self._require_doc()
stream = _io.StringIO()
doc.write(stream, fmt="asc")
data = stream.getvalue().encode("utf-8")
return {
"ok": True,
"name": Path(self._doc_path).name if self._doc_path else "untitled.dxf",
"bytes": len(data),
"content_base64": _b64.b64encode(data).decode("ascii"),
}
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")
# ── external references, rasters and DWG (v1.6 track G) ──────────────────
async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict:
from backends.contracts.refs import XREF_KINDS
if kind not in XREF_KINDS:
raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}")
source = Path(path)
if not source.is_file():
raise ValueError(f"xref_attach: {path} does not exist")
x, y = float(at[0]), float(at[1])
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
if not math.isfinite(float(value)):
raise ValueError(f"xref_attach: {label} must be finite, got {value!r}")
if float(scale) <= 0:
raise ValueError(f"xref_attach: scale must be positive, got {scale!r}")
name = source.stem.upper()
def _sync():
from ezdxf import xref
doc = self._require_doc()
if name in doc.blocks:
raise ValueError(
f"xref_attach: the drawing already holds a block named {name!r}; "
"detach it first with xref_manage(action='detach')"
)
insert = xref.attach(
doc,
block_name=name,
filename=str(source),
insert=(x, y),
scale=float(scale),
rotation=float(rotation),
overlay=(kind == "overlay"),
)
self._mark_dirty()
return {
"ok": True,
"name": name,
"path": str(source),
"kind": kind,
"handle": insert.dxf.handle,
"at": [x, y],
"scale": float(scale),
"rotation": float(rotation),
"backend": self.name,
}
return await self._async(_sync)
async def xref_manage(self, name, action, new_path=None) -> dict:
from backends.contracts.refs import XREF_ACTIONS
if action not in XREF_ACTIONS:
raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}")
if action in ("reload", "bind"):
raise UnsupportedCapabilityError(
"xref_live",
f"xref_manage(action={action!r}): the {self.name} backend has no live xref "
"manager. list, detach and path work headlessly; reload and bind need the "
"com backend (a running AutoCAD seat).",
)
if action == "path" and not (new_path or "").strip():
raise ValueError("xref_manage(action='path') needs new_path")
def _sync():
from ezdxf.lldxf import const
doc = self._require_doc()
# Measured on ezdxf 1.4.4: `xref.attach` leaves an attachment's
# block flags at 20 and an overlay's at 24, so the question "is
# this an xref" is the *pair* of bits. Masking BLK_XREF alone
# misses every overlay.
xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY
def _inserts_of(key: str):
"""Every INSERT of `key`, paired with the layout that owns it.
Model space is *not* the drawing. `doc.blocks` is the one
iterable that reaches every entity space -- it yields the
block layouts behind model space and each paper-space tab as
well as the real block definitions -- and it is exactly what
ezdxf's own `delete_block(safe=True)` searches through
`doc.query("INSERT[name==...]i")`. Counting or deleting over
`doc.modelspace()` alone under-reports an xref shown on a
sheet and leaves that INSERT behind with no BLOCK definition.
The name comparison is case-insensitive for the same reason:
the `i` on that query string.
"""
wanted = key.lower()
found = []
for layout in doc.blocks:
for entity in layout:
if entity.dxftype() != "INSERT":
continue
if str(entity.dxf.name).lower() == wanted:
found.append((layout, entity))
return found
def _rows():
rows = []
for block in doc.blocks:
flags = int(block.block.dxf.flags)
if not flags & xref_mask:
continue
rows.append(
{
"name": block.name,
"path": str(block.block.dxf.xref_path or ""),
"kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach",
"inserts": len(_inserts_of(block.name)),
}
)
rows.sort(key=lambda row: row["name"])
return rows
if action == "list":
return {"ok": True, "xrefs": _rows(), "backend": self.name}
key = str(name).strip()
row = next((r for r in _rows() if r["name"] == key), None)
if row is None:
raise ValueError(
f"xref_manage: {key!r} is not an external reference in this drawing "
f"(have: {', '.join(r['name'] for r in _rows()) or 'none'})"
)
if action == "path":
doc.blocks.get(key).block.dxf.xref_path = str(new_path)
self._mark_dirty()
return {"ok": True, "name": key, "path": str(new_path), "backend": self.name}
victims = _inserts_of(key)
for layout, entity in victims:
layout.delete_entity(entity)
# safe=True, as the plan pins it: the in-use check is the only
# thing standing between a missed insert and a saved drawing whose
# INSERT has no BLOCK behind it (ezdxf's recover reports that as
# `FIX 103 UNDEFINED_BLOCK` and throws the geometry away). If the
# scan above ever misses one, this raises instead of corrupting.
doc.blocks.delete_block(key, safe=True)
self._mark_dirty()
return {
"ok": True,
"name": key,
"inserts_removed": len(victims),
"backend": self.name,
}
return await self._async(_sync)
async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict:
source = Path(path)
if not source.is_file():
raise ValueError(f"image_attach: {path} does not exist")
x, y = float(at[0]), float(at[1])
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
if not math.isfinite(float(value)):
raise ValueError(f"image_attach: {label} must be finite, got {value!r}")
if float(scale) <= 0:
raise ValueError(f"image_attach: scale must be positive, got {scale!r}")
px, py = _image_pixels(str(source))
width, height = px * float(scale), py * float(scale)
def _sync():
doc = self._require_doc()
image_def = doc.add_image_def(str(source), size_in_pixel=(px, py))
image = self._msp().add_image(image_def, (x, y), (width, height), float(rotation))
self._mark_dirty()
return {
"ok": True,
"path": str(source),
"handle": image.dxf.handle,
"at": [x, y],
"scale": float(scale),
"rotation": float(rotation),
"pixels": [px, py],
"size_mm": [width, height],
"backend": self.name,
}
return await self._async(_sync)
async def drawing_export_dwg(self, path, version="R2018") -> dict:
from backends.contracts.refs import DWG_VERSIONS
if version not in DWG_VERSIONS:
raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}")
if not _odafc_available():
raise UnsupportedCapabilityError(
"dwg_write",
f"drawing_export_dwg: the {self.name} backend writes DWG only through the "
"ODA File Converter, which is not installed here. Install it from "
"https://www.opendesign.com/guestfiles/oda_file_converter and this call "
"starts working, or export from the com backend (a running AutoCAD seat). "
"drawing_export_dxf writes a DXF that AutoCAD opens unchanged.",
)
def _sync():
from ezdxf.addons import odafc
doc = self._require_doc()
self._refuse_path_held_elsewhere("drawing_export_dwg", path)
odafc.export_dwg(doc, path, version=version, replace=True)
return {
"ok": True,
"path": path,
"version": version,
"bytes": Path(path).stat().st_size,
"backend": self.name,
}
return await self._async(_sync)
async def drawing_export_pdf(
self,
path: str,
layout: str | None = None,
text_layer: bool = True,
page_size: str = "layout",
) -> 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.
``page_size`` decides what the PDF page *is*:
* ``"layout"`` (default) — a plot of the sheet: the page is the
layout's own ``paper_width`` x ``paper_height`` and the axes span that
same sheet in drawing units, so the plot is 1:1 the way AutoCAD plots
a layout. A 185 mm ГОСТ stamp stays 185 mm on paper and the frame
lands at its margins (20/5/5/5 mm on A3). Model space has no sheet, so
this falls back to ``"extents"``.
* ``"extents"`` — the old behaviour: the page is shrunk to the bounding
box of everything drawn (a ~168 x 122 mm thumbnail of an A3 sheet),
which is fine for a draft screenshot and wrong for a drawing.
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()
mode = str(page_size or "layout").strip().lower()
if mode not in ("layout", "extents"):
return {
"ok": False,
"error": (
f"Unknown page_size {page_size!r}: use 'layout' (sheet, 1:1) "
"or 'extents' (page around the drawn content)"
),
}
try:
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.config import Configuration, TextPolicy
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 mode == "layout" and resolved is not None and resolved != "Model":
paper = self._paper_frame(target)
import matplotlib
# N5 (01.10.2026) — matplotlib's PDF backend defaults to Type 3
# fonts, whose glyphs carry no usable ToUnicode map: text
# extraction from such a PDF returns an empty string and the
# labels are neither searchable nor selectable. Type 42 embeds
# a real TrueType subset with the encoding table.
matplotlib.rcParams["pdf.fonttype"] = 42
matplotlib.rcParams["pdf.use14corefonts"] = False
# The figure is created at the sheet's size in inches *before*
# anything is drawn, and the figure is what sets the PDF page:
# 420 mm of sheet is 16.54 in of figure. ``adjust_figure`` and
# ``finalize`` — which re-fit both the data limits and the figure
# box to the content — are off in this mode.
fig = _new_agg_figure(
figsize=paper["figsize"] if paper else None, dpi=150
)
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
# ezdxf >= 1.1 renders TEXT/MTEXT as filled glyph paths (curves),
# so nothing textual reaches the PDF at all. With
# ``text_layer=True`` the pipeline skips text and it is placed as
# real matplotlib text afterwards (see ``_draw_pdf_text_layer``).
config_obj = Configuration(
text_policy=TextPolicy.IGNORE if text_layer else TextPolicy.FILLING
)
out = MatplotlibBackend(ax, adjust_figure=paper is None)
Frontend(ctx, out, config=config_obj).draw_layout(
target, finalize=paper is None
)
if paper is not None:
ax.set_xlim(*paper["xlim"])
ax.set_ylim(*paper["ylim"])
ax.set_aspect("equal", adjustable="box")
text_stats = None
if text_layer:
if paper is None:
# Text is invisible to the geometry pass, so it does not
# contribute to the plot box the Frontend just fitted. Union
# the label extents into the axes limits first — otherwise a
# drawing sized by its geometry alone clips every label away
# (see _pdf_text_extents).
ext = _pdf_text_extents(_iter_pdf_text_entities(target))
if ext is not None:
dx0, dx1 = ax.get_xlim()
dy0, dy1 = ax.get_ylim()
ax.set_xlim(min(dx0, ext[0]), max(dx1, ext[2]))
ax.set_ylim(min(dy0, ext[1]), max(dy1, ext[3]))
text_stats = _draw_pdf_text_layer(ax, target, fig)
else:
# A sheet plots its own entities *and* whatever its
# viewports look at. The geometry pass draws the second
# half from the viewport transform, so the text layer has
# to project it the same way — otherwise the sheet comes
# out with the circles and lines but none of the labels
# (defect found on 01.10.2026).
text_stats = _draw_pdf_text_layer(ax, target, fig, include_viewports=True)
fig.savefig(path, dpi=150)
result = {"ok": True, "path": path, "page_size": mode}
if paper is not None:
result["paper_mm"] = paper["size_mm"]
result["page_mm"] = [round(v, 2) for v in 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 text_stats is not None:
result["text_layer"] = text_stats
narrow = text_stats.get("warnings") or []
if narrow:
limit = text_stats.get("min_height_mm", _PDF_MIN_READABLE_MM)
result["warnings"] = [
f"надпись {item['text']!r} выходит на листе {item['height_mm']} мм "
f"(норма ≥ {limit} мм, источник {item['source']}, {item['via']})"
for item in narrow
]
result["warnings_detail"] = narrow
if resolved is not None and resolved != "Model":
result["layout"] = resolved
result["note"] = (
"Paper-space entities are rendered, and model content — geometry and "
"text alike (TEXT/MTEXT/ATTRIB, block marks included) — is projected "
"through each viewport at its scale and clipped to its window. "
"Viewport borders themselves are not drawn."
)
if paper is None and mode == "layout":
result["note_page_size"] = (
"page_size='layout' fell back to the drawing extents: model space "
"has no sheet to plot 1:1 onto."
)
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_paper(self, name: str, width: float, height: float) -> dict:
"""Set the sheet a layout is set up for, in millimetres.
``Layout.dxf.paper_width`` / ``paper_height`` are the page fields a plot
reads, and ``drawing_export_pdf(page_size="layout")`` sizes its PDF from
them. A drawing made by ``drawing_new`` starts on A3 whatever the frame
drawn on it says, so a GOST-A4 sheet would be exported onto an A3 page —
the frame would sit in a corner of a bigger sheet. The values are stored
in the document's own units ($MEASUREMENT: 1 = mm, 0 = inch).
"""
def _sync():
doc = self._require_doc()
resolved = self._find_layout(name)
if resolved is None:
return {"ok": False, "error": f"Layout not found: {name}"}
try:
width_mm = float(width)
height_mm = float(height)
except (TypeError, ValueError):
return {"ok": False, "error": "Paper size must be two numbers"}
if width_mm <= 0 or height_mm <= 0:
return {"ok": False, "error": "Paper size must be positive"}
layout = doc.layouts.get(resolved)
try:
metric = int(doc.header.get("$MEASUREMENT", 1) or 1)
except Exception:
metric = 1
if metric == 0:
width_mm, height_mm = width_mm / 25.4, height_mm / 25.4
layout.dxf.paper_width = width_mm
layout.dxf.paper_height = height_mm
return {
"ok": True,
"layout": resolved,
"paper_mm": [round(width_mm, 2), round(height_mm, 2)],
}
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/<name>``.
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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)
def _default_text_style(self) -> str:
"""The document's current text style ($TEXTSTYLE), when it exists.
``msp.add_text`` hardcodes ``style="Standard"``, which for Cyrillic
content is exactly the style that turns into question marks in AutoCAD.
Honouring ``$TEXTSTYLE`` is what AutoCAD itself does for new text, and
it is how ``textstyle_ensure(set_default=True)`` reaches every label a
title block draws.
When ``$TEXTSTYLE`` names nothing usable the ГОСТ style is used if the
drawing carries it — a file built before the style was made current
would otherwise silently letter its model text in ``Standard``, which is
the defect the ISOCPEUR requirement was raised about.
"""
doc = None
try:
doc = self._require_doc()
except Exception:
return "Standard"
name = ""
try:
name = str(doc.header.get("$TEXTSTYLE", "") or "").strip()
except Exception:
name = ""
try:
if name and name in doc.styles:
return name
if DEFAULT_TEXT_STYLE in doc.styles:
return DEFAULT_TEXT_STYLE
except Exception:
pass
return "Standard"
async def entity_create_text(
self,
text,
x,
y,
height=2.5,
rotation=0.0,
layer=None,
color=None,
style=None,
layout=None,
inline_font=True,
) -> EntityInfo:
def _sync():
space = self._space(layout)
resolved_style = (
self._inline_lettering_style(style)
if inline_font
else (style or self._default_text_style())
)
ent = space.add_text(
text,
dxfattribs={
"style": resolved_style,
"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,
style=None,
layout=None,
inline_font=True,
) -> EntityInfo:
from engineering.annotation import wrap_inline_font
def _sync():
space = self._space(layout)
ent = space.add_mtext(
wrap_inline_font(text, inline_font),
dxfattribs={
"char_height": float(height),
"width": float(width),
"style": style or self._default_text_style(),
},
)
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",
style=None,
layout=None,
inline_font=True,
) -> EntityInfo:
from engineering.annotation import prepare_table_layout, wrap_inline_font
table_layout = prepare_table_layout(
rows, headers, column_widths, row_height, text_height, title
)
def _sync():
space = self._space(layout)
cell_style = style or self._default_text_style()
children = []
x0, y0 = float(x), float(y)
x_positions = [x0]
for width in table_layout.column_widths:
x_positions.append(x_positions[-1] + width)
for row_index in range(table_layout.row_count + 1):
yy = y0 - row_index * table_layout.row_height
line = space.add_line((x0, yy), (x0 + table_layout.width, yy))
self._apply_attrs(line, layer, None)
children.append(line)
last = len(x_positions) - 1
for index, xx in enumerate(x_positions):
# a title row is merged, as AutoCAD's TABLE draws it: only the
# outer edges cross it
inner = 0 < index < last
top = (
y0 - table_layout.row_height
if table_layout.title_row and inner
else y0
)
line = space.add_line((xx, top), (xx, y0 - table_layout.height))
self._apply_attrs(line, layer, None)
children.append(line)
# the cell margin scales with the lettering: 1.0 at the default 2.5
# text, 50 on a 1:50 schedule (a flat 1.0 there is 0.02 mm on the
# sheet and the text sat on the cell line)
padding = min(0.4 * table_layout.text_height, 0.15 * table_layout.row_height)
for row_index, row in enumerate(table_layout.cells):
for column_index, value in enumerate(row):
if not value:
continue
span = (
table_layout.width
if table_layout.title_row and row_index == 0
else table_layout.column_widths[column_index]
)
text_entity = space.add_mtext(
wrap_inline_font(value, inline_font),
dxfattribs={
"char_height": table_layout.text_height,
"width": max(1.0, span - 2 * padding),
"style": cell_style,
},
)
text_entity.dxf.insert = (
x_positions[column_index] + padding,
y0 - row_index * table_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": table_layout.row_count,
"columns": table_layout.column_count,
"space": self._reply_space_name(layout),
"bounds": {
"min": [x0, y0 - table_layout.height],
"max": [x0 + table_layout.width, y0],
},
},
)
return await self._async(_sync)
def _reply_space_name(self, layout=None) -> str:
"""Where the entities of a create call ended up, for the tool reply."""
name = self._layout_name(layout) if layout is not None else ""
if not name:
return self._current_space
if name.lower() == "model":
return "Model"
return self._find_layout(name) or name
async def leader_create_mleader(
self,
points,
text,
text_height=2.5,
landing_gap=1.0,
arrow_size=2.5,
layer="DIM",
layout=None,
inline_font=True,
) -> EntityInfo:
from engineering.annotation import validate_mleader, wrap_inline_font
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._space(layout)
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(
wrap_inline_font(text, inline_font),
dxfattribs={
"char_height": float(text_height),
"width": 100.0,
"style": self._default_text_style(),
},
)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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, layout=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._space(layout)
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, layout=None
) -> 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._space(layout)
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,
layout=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._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=None,
) -> EntityInfo:
def _sync():
msp = self._space(layout)
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,
layout=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._space(layout)
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,
layout=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._space(layout)
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
# The text stands `leader_length` beyond the FIRST chord point, the
# ActiveX rule: AddDimDiametric(ChordPoint, FarChordPoint,
# LeaderLength) measured on AutoCAD 2026 put a 50 mm diameter drawn
# from (x, 170) to (x, 220) with its text at (x, 160). This engine
# used to put it beyond the second point, so the same call annotated
# opposite sides of a part on the two engines - and the mechanical
# drawer's front-view diameters landed on their own chain dimensions
# headlessly while clearing them live.
angle_rad = math.atan2(y1f - y2f, x1f - x2f)
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,
style=None,
layout=None,
) -> EntityInfo:
from backends.block_specs import validate_attribute_values
values = validate_attribute_values(attributes)
def _sync():
doc = self._require_doc()
msp = self._space(layout)
# N1 (01.10.2026) — was ``add_auto_blockref``, which wraps the INSERT
# and its ATTRIBs in an anonymous block ``*U<n>``: the drawing then
# carries no reference to the named definition at all (::
# ``block_find_references`` found nothing, ``block_get_attributes``
# answered ``{}`` and every purge/audit pass saw an unnamed block).
# The reference is created directly on the named definition and the
# ATTRIBs are attached with ``Insert.add_auto_attribs``.
if name not in doc.blocks:
raise RuntimeError(
f"block_insert: block definition {name!r} does not exist in this "
f"drawing (have: {', '.join(b.name for b in doc.blocks if not b.name.startswith('*')) or 'none'}). "
"Create it first (drawing_open with a template, or block_create_from_entities)."
)
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)
tags = [attdef.dxf.tag for attdef in doc.blocks[name].attdefs()]
filled: list[str] = []
unknown: list[str] = []
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
# and are reported below instead of pretending they landed.
ref.add_auto_attribs(values)
filled = [tag for tag in tags if tag in values]
unknown = sorted(set(values) - set(tags))
# 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)
# An ATTRIB inherits its ATTDEF's style, and a definition drawn
# before the ГОСТ style existed carries ``Standard`` — which is not
# the face a sheet is lettered in. The reference is stamped with the
# requested style (or the drawing's current one) so a block mark
# (Т1/S1/V1) comes out in the same type as everything around it.
mark_style = str(style).strip() if style else self._default_text_style()
restyled = 0
for attrib in ref.attribs:
if mark_style and attrib.dxf.get("style", None) != mark_style:
attrib.dxf.style = mark_style
restyled += 1
self._mark_dirty()
info = _entity_info_dxf(ref)
info.properties["block_name"] = name
info.properties["attribs_filled"] = filled
info.properties["attribs_unknown_tags"] = unknown
if restyled or ref.attribs:
info.properties["attribs_style"] = mark_style
info.properties["attribs_restyled"] = restyled
return info
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, style=None) -> dict:
from backends.block_specs import validate_attribute_values
values = validate_attribute_values(attributes)
def _sync():
doc = self._require_doc()
ent = self._get_entity(handle)
if ent.dxftype() != "INSERT":
raise RuntimeError(f"Entity {handle} is not a block reference")
existing = {attrib.dxf.tag: attrib for attrib in ent.attribs}
block_name = ent.dxf.name
updated: list[str] = []
created: list[str] = []
unknown: list[str] = []
attr_style = str(style).strip() if style else self._default_text_style()
# N2 (01.10.2026) — this used to walk ``ent.attribs`` only, so a
# reference inserted without attribute values (or created before the
# fix to block_insert) had nothing to update and the call answered
# ``updated_tags: []`` while the drawing stayed empty. Missing ATTRIBs
# are now materialised from the block definition's ATTDEFs.
for tag, value in values.items():
attrib = existing.get(tag)
if attrib is not None:
self._write_attrib_value(attrib, value)
updated.append(tag)
continue
attrib = self._add_missing_attrib(doc, ent, block_name, tag, value, attr_style)
if attrib is None:
unknown.append(tag)
else:
created.append(tag)
# A value written into an ATTRIB whose style is ``Standard`` still
# plots in the wrong face: the update carries the style too.
restyled = 0
if attr_style:
for attrib in ent.attribs:
if attrib.dxf.get("style", None) != attr_style:
attrib.dxf.style = attr_style
restyled += 1
if updated or created or restyled:
self._mark_dirty()
return {
"ok": True,
"handle": ent.dxf.get("handle", handle),
"block_name": block_name,
"updated_tags": updated,
"created_tags": created,
"unknown_tags": unknown,
"style": attr_style,
"restyled_tags": restyled,
"attributes": {attrib.dxf.tag: attrib.dxf.text for attrib in ent.attribs},
}
return await self._async(_sync)
@staticmethod
def _add_missing_attrib(doc, insert, block_name, tag, value, style=None):
"""Materialise one ATTRIB on ``insert`` from the block's ATTDEF ``tag``.
Returns the new ATTRIB, or ``None`` when the block defines no such tag —
the caller reports that instead of inventing an attribute AutoCAD would
not associate with the definition.
"""
if block_name not in doc.blocks:
return None
attdef = next(
(a for a in doc.blocks[block_name].attdefs() if a.dxf.tag == tag), None
)
if attdef is None:
return None
dxfattribs = attdef.dxfattribs(drop={"prompt", "handle"})
location = dxfattribs.pop("insert", (0.0, 0.0))
dxfattribs.pop("tag", None)
if style:
dxfattribs["style"] = style
return insert.add_attrib(tag, str(value), location, dxfattribs)
async def block_get_attdefs(self, name) -> list[dict]:
"""Attribute definitions (ATTDEF) carried by a block definition."""
def _sync():
doc = self._require_doc()
if name not in doc.blocks:
raise RuntimeError(f"Block definition {name!r} does not exist")
out = []
for attdef in doc.blocks[name].attdefs():
point = attdef.dxf.get("insert", (0.0, 0.0, 0.0))
out.append(
{
"tag": attdef.dxf.tag,
"prompt": attdef.dxf.get("prompt", ""),
"default": attdef.dxf.get("text", ""),
"height": float(attdef.dxf.get("height", 0.0)),
"x": float(point[0]),
"y": float(point[1]),
"layer": attdef.dxf.get("layer", "0"),
}
)
return out
return await self._async(_sync)
async def entity_create_attdef(
self,
block_name,
tag,
x,
y,
height=2.5,
prompt="",
default="",
layer=None,
rotation=0.0,
style=None,
invisible=False,
) -> EntityInfo:
"""Create an ATTDEF inside a block definition (N3, 01.10.2026).
Every other ``entity_create_*`` writes into the current space, so an
attribute definition — which by definition lives *inside* a block
definition — had no tool at all, and a symbol library with attributes
could only be assembled locally in ezdxf, outside this server.
"""
def _sync():
doc = self._require_doc()
if block_name not in doc.blocks:
raise RuntimeError(
f"entity_create_attdef: block definition {block_name!r} does not exist. "
"Create it first with block_create_from_entities."
)
blk = doc.blocks[block_name]
tag_value = str(tag).strip()
if not tag_value:
raise RuntimeError("entity_create_attdef: tag must not be empty")
if any(a.dxf.tag == tag_value for a in blk.attdefs()):
raise RuntimeError(
f"entity_create_attdef: block {block_name!r} already defines attribute {tag_value!r}"
)
dxfattribs: dict = {
"height": float(height),
"rotation": float(rotation),
"prompt": str(prompt or tag_value),
"text": str(default),
"style": style or self._default_text_style(),
}
if layer:
dxfattribs["layer"] = layer
if invisible:
dxfattribs["invisible"] = 1
attdef = blk.add_attdef(tag_value, (float(x), float(y)), dxfattribs=dxfattribs)
self._mark_dirty()
info = _entity_info_dxf(attdef)
info.properties["tag"] = tag_value
info.properties["block_name"] = block_name
return info
return await self._async(_sync)
async def textstyle_ensure(
self,
name="GOST_A",
font="isocpeur.ttf",
bigfont="",
set_default=False,
height=0.0,
width_factor=1.0,
) -> dict:
"""Create the named text style if missing and optionally make it default.
Cyrillic needs a style that points at a Unicode-capable font: the
``Standard`` style references ``txt``/``Arial`` depending on the DXF
version, and a GOST sheet stamped with it can come out as question marks
in AutoCAD. ``ISOCPEUR`` ships with AutoCAD, hence the default font here.
"""
def _sync():
doc = self._require_doc()
style_name = str(name or "").strip()
if not style_name:
raise RuntimeError("textstyle_ensure: name must not be empty")
created = False
if style_name in doc.styles:
style = doc.styles.get(style_name)
if font:
style.dxf.font = font
if bigfont is not None:
style.dxf.bigfont = bigfont or ""
else:
style = doc.styles.add(style_name, font=font or "isocpeur.ttf")
style.dxf.bigfont = bigfont or ""
created = True
style.dxf.height = float(height)
style.dxf.width = float(width_factor)
if set_default:
doc.header["$TEXTSTYLE"] = style_name
self._mark_dirty()
return {
"ok": True,
"name": style_name,
"font": style.dxf.font,
"bigfont": style.dxf.get("bigfont", ""),
"created": created,
"default": doc.header.get("$TEXTSTYLE", "Standard"),
"styles": [s.dxf.name for s in doc.styles],
}
return await self._async(_sync)
async def dimstyle_ensure(
self,
name="СПДС",
text_style=None,
base=None,
set_current=True,
text_height=2.5,
arrow_size=2.5,
) -> dict:
"""Create the named dimension style and (optionally) make it current.
The dimension text style is the ГОСТ ISOCPEUR face (``GOST_A`` unless the
caller names another one, and created if the drawing lacks it): a
dimension lettered in a foreign face stands out on the sheet. Making the
style current is what the header ``$DIMSTYLE`` records, and it is now what
the dimension tools read — before this, "make СПДС current" changed the
header while every later dimension still came out ``Standard``.
``base`` copies an existing style (its arrows, gaps, units) instead of
starting from the schema defaults, and defaults to ``Standard``. Text and
arrow sizes are then set explicitly (ГОСТ 2.304: 2.5 mm lettering for
sizes at 1:1): an entry left entirely empty makes the renderer fall back
to 1.0 mm text, which is below the 1.8 mm the plotter check calls
illegible.
"""
def _sync():
doc = self._require_doc()
style_name = str(name or "").strip()
if not style_name:
raise RuntimeError("dimstyle_ensure: name must not be empty")
text_style_name = self._inline_lettering_style(text_style)
if text_style_name not in doc.styles:
doc.styles.add(text_style_name, font=DEFAULT_TEXT_STYLE_FONT)
created = False
if style_name in doc.dimstyles:
style = doc.dimstyles.get(style_name)
else:
source = str(base or "").strip() or _DIMSTYLE_NAME
if source in doc.dimstyles:
style = doc.dimstyles.duplicate_entry(source, style_name)
else:
style = doc.dimstyles.add(style_name)
created = True
style.dxf.dimtxsty = text_style_name
try:
if float(text_height) > 0:
style.dxf.dimtxt = float(text_height)
if float(arrow_size) > 0:
style.dxf.dimasz = float(arrow_size)
except (TypeError, ValueError):
pass
if set_current:
doc.header["$DIMSTYLE"] = style_name
self._mark_dirty()
return {
"ok": True,
"name": style_name,
"created": created,
"text_style": text_style_name,
"dimtxsty": str(style.dxf.get("dimtxsty", "")),
"dimtxt": style.dxf.get("dimtxt", None),
"dimasz": style.dxf.get("dimasz", None),
"set_current": bool(set_current),
"current": _current_dimstyle(doc),
"dimstyles": [entry.dxf.name for entry in doc.dimstyles],
}
return await self._async(_sync)
def _iter_text_entities(self, *, include_block_definitions: bool = True):
"""``(where, entity)`` for every TEXT/MTEXT/ATTRIB/ATTDEF in the file.
A DXF keeps text in three places that a single walk misses: the current
space, the ATTRIB entities owned by each block reference (they are not
part of the layout's entity list) and the ATTDEF/TEXT inside block
*definitions*. Handles are deduplicated, because a layout and its block
record expose the same entities.
"""
doc = self._require_doc()
seen: set[str] = set()
out: list[tuple[str, Any]] = []
def _add(where: str, ent) -> None:
key = str(ent.dxf.get("handle", "") or id(ent))
if key in seen:
return
seen.add(key)
out.append((where, ent))
spaces: list[tuple[str, Any]] = [("Model", doc.modelspace())]
for name in doc.layouts.names_in_taborder():
if name != "Model":
spaces.append((name, doc.layouts.get(name)))
for label, space in spaces:
try:
entities = list(space)
except Exception as exc:
log.debug("text walk: space %s unreadable (%s)", label, exc)
continue
for ent in entities:
kind = ent.dxftype()
if kind in ("TEXT", "MTEXT", "ATTDEF"):
_add(label, ent)
elif kind == "INSERT":
try:
for attrib in list(ent.attribs):
_add(label, attrib)
except Exception as exc:
log.debug("text walk: attributes of %s unreadable (%s)", label, exc)
if include_block_definitions:
for block in doc.blocks:
try:
entities = list(block)
except Exception as exc:
log.debug("text walk: block %s unreadable (%s)", block.name, exc)
continue
for ent in entities:
if ent.dxftype() in ("ATTDEF", "TEXT", "MTEXT"):
_add(f"block:{block.name}", ent)
return out
async def text_apply_style(
self,
style=DEFAULT_TEXT_STYLE,
font=DEFAULT_TEXT_STYLE_FONT,
include_block_definitions=True,
set_default=True,
inline_font=False,
) -> dict:
"""Reassign one text style to every TEXT/MTEXT/ATTRIB/ATTDEF in the file.
Tools that create text take a per-call style, but a drawing assembled
before the style existed — or by a tool that never asked — is lettered
half in ``Standard`` and half in the ГОСТ face. This is the sweep that
makes the whole file one type, block definitions included: an ATTDEF that
stays in ``Standard`` hands that style to every ATTRIB materialised from
it later.
The style is created when the drawing does not have it yet, and (by
default) made the drawing's ``$TEXTSTYLE``, so text created afterwards
lands in it without being told.
``inline_font=True`` additionally writes the ISOCPEUR group into every
MTEXT that does not carry it yet — the repair half of ``inline_font`` on
``entity_create_mtext``, for a drawing that was built before the font had
to be baked in. Off by default: this rewrites existing content, and a
sweep nobody asked for must not do that.
"""
from engineering.annotation import wrap_inline_font
def _sync():
doc = self._require_doc()
name = str(style or "").strip()
if not name:
raise RuntimeError("text_apply_style: style name must not be empty")
created = False
if name not in doc.styles:
doc.styles.add(name, font=font or DEFAULT_TEXT_STYLE_FONT)
created = True
counts = {"TEXT": 0, "MTEXT": 0, "ATTRIB": 0, "ATTDEF": 0}
changed = 0
failed = 0
inline_changed = 0
for where, ent in self._iter_text_entities(
include_block_definitions=include_block_definitions
):
kind = ent.dxftype()
counts[kind] = counts.get(kind, 0) + 1
try:
if str(ent.dxf.get("style", "") or "") != name:
ent.dxf.style = name
changed += 1
if inline_font and kind == "MTEXT":
current = str(ent.dxf.get("text", "") or "")
wrapped = wrap_inline_font(current, True)
if wrapped != current:
ent.text = wrapped
inline_changed += 1
except Exception as exc:
failed += 1
log.debug("text_apply_style: %s %s (%s)", where, kind, exc)
if set_default:
doc.header["$TEXTSTYLE"] = name
self._mark_dirty()
try:
applied_font = str(doc.styles.get(name).dxf.get("font", "") or "")
except Exception:
applied_font = ""
return {
"ok": True,
"style": name,
"font": applied_font,
"created": created,
"counts": counts,
"total": sum(counts.values()),
"changed": changed,
"failed": failed,
"inline_font": bool(inline_font),
"inline_changed": inline_changed,
"include_block_definitions": bool(include_block_definitions),
"default": doc.header.get("$TEXTSTYLE", ""),
}
return await self._async(_sync)
async def text_style_report(self, style=None) -> dict:
"""Which text style each text entity carries, and who disagrees.
The audit half of ``text_apply_style``: counts per style, the entities
that are not in the expected style (handle, type, current style, text)
and the font each style in the file points at. Reads the whole file —
spaces, block references and block definitions.
"""
def _sync():
doc = self._require_doc()
expected = str(style or "").strip() or self._default_text_style()
by_style: dict[str, int] = {}
by_kind: dict[str, int] = {}
offenders: list[dict] = []
total = 0
for where, ent in self._iter_text_entities(include_block_definitions=True):
kind = ent.dxftype()
current = str(ent.dxf.get("style", "Standard") or "Standard")
total += 1
by_style[current] = by_style.get(current, 0) + 1
by_kind[kind] = by_kind.get(kind, 0) + 1
if current != expected:
offenders.append(
{
"handle": str(ent.dxf.get("handle", "")),
"type": kind,
"style": current,
"where": where,
"text": str(ent.dxf.get("text", ""))[:40],
}
)
styles = []
for entry in doc.styles:
try:
styles.append({"name": entry.dxf.name, "font": entry.dxf.get("font", "")})
except Exception:
continue
correct = by_style.get(expected, 0)
return {
"ok": True,
"expected": expected,
"total": total,
"correct": correct,
"wrong": total - correct,
"by_style": by_style,
"by_type": by_kind,
"offenders": offenders[:80],
"styles": styles,
"default": doc.header.get("$TEXTSTYLE", ""),
}
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,
layout=None,
) -> dict:
def _sync():
doc = self._require_doc()
# N2 (01.10.2026) — a block definition is document-global in DXF: it
# carries no owner sheet, so a definition can never be said to live
# "in paper space" the way an entity can. ``layout`` is therefore not
# a destination for the definition but a constraint on its source:
# with it given, only handles that actually sit in that sheet are
# taken, and a handle from model space (or another sheet) is named in
# the refusal instead of being copied silently. Without it the
# drawing-wide lookup of the old behaviour stands.
space = None
owner_handle = ""
space_label = ""
if layout is not None:
space = self._space(layout)
space_label = self._layout_name(layout) or "Model"
try:
owner_handle = str(space.block_record.dxf.handle)
except Exception as exc: # a space with no block record is a bad document
raise RuntimeError(
f"block_create_from_entities: cannot read the block record of "
f"space {space_label!r} ({exc})"
) from exc
# 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, foreign = [], [], []
for handle in handles:
try:
entity = self._get_entity(handle)
except Exception as exc:
log.debug("resolving %s for block %s: %s", handle, name, exc)
skipped.append(str(handle))
continue
if owner_handle and str(entity.dxf.get("owner", "")) != owner_handle:
foreign.append(str(handle))
continue
entities.append(entity)
if foreign:
raise RuntimeError(
f"block_create_from_entities: handle(s) {', '.join(foreign)} are not in "
f"space {space_label!r}; a definition is built from one sheet's entities "
f"at a time. Drop layout= to collect drawing-wide, or pass the handles "
f"of that sheet. No definition was created."
)
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,
"space": space_label or "(drawing-wide)",
"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)