Files
autocad-mcp-pro/backends/ezdxf_backend.py
T
grigory 2e2a2b96a9 ours: наш оверлей к апстриму 1.5.1 (дельта для переноса в 1.6.0)
10 изменённых файлов + 2 новых (engineering/gost_titleblock.py, _fonts/isocpeur.ttf).
Ветка-донор для трёхстороннего переноса в ours-1.6.
2026-10-05 18:04:55 +03:00

6980 lines
286 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 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 .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
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."
)
def _is_dwg_path(path: str) -> bool:
"""True when `path` names a DWG file. The extension is authoritative (N2)."""
return Path(path).suffix.lower() == ".dwg"
def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError:
return UnsupportedCapabilityError(
"dwg", _DWG_WRITE_REFUSAL.format(operation=operation, path=path)
)
def _audit_entry(entry) -> dict:
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
``ErrorEntry`` defines no ``__str__``, so the pre-1.5.0
``[str(e) for e in auditor.errors]`` handed users
``'<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.
Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm
rounding keeps it.
"""
try:
style = doc.dimstyles.get("Standard")
except Exception:
return
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
style.dxf.discard("dimrnd")
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(_DIMSTYLE_NAME)
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
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)
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)
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),
)
# ---------------------------------------------------------------------------
# EzdxfBackend
# ---------------------------------------------------------------------------
class EzdxfBackend(AutoCADBackend):
"""File-based ezdxf backend – no live AutoCAD needed."""
def __init__(self):
if not _EZDXF_OK:
raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf")
self._doc: Any = None # ezdxf Drawing object
self._doc_path: str | None = None
self._dirty: bool = False
self._current_layer: str = "0"
self._undo_stack: list[Path] = [] # user-facing undo history
self._redo_stack: list[Path] = [] # states stepped off by an undo
self._current_space: str = "Model" # where entity_create_* writes
self._transaction_stack: list[Path] = [] # isolated transaction snapshots
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 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"
),
"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"),
# 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"),
},
)
async def connect(self) -> None:
self._connected = True
log.info("ezdxf backend ready")
async def disconnect(self) -> None:
self._cleanup_undo_stack()
self._reset_document_state()
# The document goes 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):
for stack in (self._undo_stack, self._redo_stack, self._transaction_stack):
while stack:
p = stack.pop()
try:
p.unlink()
except OSError:
pass
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 _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:
try:
same = os.path.abspath(new_path) == os.path.abspath(record.document_path)
except (OSError, ValueError): # a path this OS cannot normalise
same = 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():
self._doc = ezdxf.readfile(template)
_normalise_dimstyle_rounding(self._doc)
else:
self._doc = ezdxf.new(dxfversion="R2010")
_apply_iso_dimstyle(self._doc)
# R32: the rebind above is the release — the runaway keeps the
# Drawing it captured, so its remaining writes land on an orphan.
# Clear before _reset_history_baseline, which snapshots the new doc.
released = self._clear_quarantine("drawing_new")
self._doc_path = None
self._dirty = False
self._current_layer = "0"
self._reset_document_state()
self._current_space = "Model"
self._reset_history_baseline()
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."
)
self._doc = ezdxf.readfile(path)
_normalise_dimstyle_rounding(self._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.
released = self._clear_quarantine("drawing_open", path)
self._doc_path = path
self._dirty = False
try:
self._current_layer = self._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._current_space = "Model"
self._reset_history_baseline()
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.")
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()
doc.saveas(path)
self._doc_path = path
self._dirty = False
return {"ok": True, "path": path, "format": "dxf"}
return await self._async(_sync)
async def drawing_export_dxf(self, path: str) -> dict:
return await self.drawing_save_as(path, "dxf")
async def drawing_export_pdf(
self,
path: str,
layout: str | None = None,
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.
"""
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 = self._msp()
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)"
),
}
# Only a paper-space layout has a sheet; model space always plots
# its extents.
paper = _layout_paper_mm(target) if mode == "layout" else None
try:
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.config import Configuration, TextPolicy
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
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
# 420 mm of sheet is 16.54 in of figure: with the axes spanning
# the same 420 units, one drawing unit (mm) is one millimetre on
# the page. The figure is created at that size *before* anything
# is drawn, and ``adjust_figure``/``finalize`` — which re-fit
# both the data limits and the figure box to the content — are
# switched off in this mode.
fig_size = (paper[0] / 25.4, paper[1] / 25.4) if paper else None
fig = _new_agg_figure(figsize=fig_size, 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:
# Aspect "auto" here is deliberate: with equal data scales
# (the figure ratio is built from the paper size) it is the
# same picture as "equal", but it leaves the limits exactly
# as set. "equal"/"datalim" — what the ezdxf backend installs
# — is free to nudge the data limits while satisfying the box
# aspect, and a page that is 0.2 % off a sheet is a page that
# prints with a white stripe.
ax.set_aspect("auto")
ax.set_xlim(0.0, paper[0])
ax.set_ylim(0.0, paper[1])
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)
result = {"ok": True, "path": path, "page_size": mode}
if paper is not None:
result["page_mm"] = [round(paper[0], 2), round(paper[1], 2)]
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)
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
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,
)
return {
"ok": True,
"handle": viewport.dxf.handle,
"layout": layout,
"scale": scale,
"view_height": view_height,
}
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
entity.transform(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"
# ── 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:
def _sync():
doc = self._require_doc()
purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0}
used_blocks: set[str] = set()
for ent in doc.entitydb.values():
if ent.dxftype() == "INSERT":
used_blocks.add(ent.dxf.name)
for blk in list(doc.blocks):
name = blk.name
if name.startswith("*") or name 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
used_styles: set[str] = {"Standard"}
for ent in doc.entitydb.values():
if hasattr(ent.dxf, "style"):
used_styles.add(ent.dxf.style)
for st in list(doc.styles):
name = st.dxf.name
if name 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:
def _sync():
# 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
if save and self._dirty and self._doc_path and not quarantined:
self._doc.saveas(self._doc_path)
self._cleanup_undo_stack()
self._doc = None
released = self._clear_quarantine("drawing_close")
self._doc_path = None
self._dirty = False
self._reset_document_state()
result = {"ok": True}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
result["saved"] = False
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"
)
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={
"height": float(height),
"rotation": float(rotation),
"insert": (float(x), float(y)),
"style": resolved_style,
},
)
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)
for xx in x_positions:
line = space.add_line((xx, y0), (xx, y0 - table_layout.height))
self._apply_attrs(line, layer, None)
children.append(line)
padding = min(1.0, table_layout.row_height * 0.15)
for row_index, row in enumerate(table_layout.cells):
for column_index, value in enumerate(row):
if not value:
continue
text_entity = space.add_mtext(
wrap_inline_font(value, inline_font),
dxfattribs={
"char_height": table_layout.text_height,
"width": max(
1.0, table_layout.column_widths[column_index] - 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._space_name(layout),
"bounds": {
"min": [x0, y0 - table_layout.height],
"max": [x0 + table_layout.width, y0],
},
},
)
return await self._async(_sync)
def _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)
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(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(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(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(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
angle_rad = math.atan2(y2f - y1f, x2f - x1f)
leader = float(leader_length)
# The measured size comes from `radius`; `location` only places the
# text. This used to pass centre + (radius + leader) as `mpoint`,
# which ezdxf measures to — so every diameter came out
# 2 x leader_length too large. 60 on a true 40, at default
# settings, with nothing on the drawing to show for it.
dim = msp.add_diameter_dim(
center=(cx, cy),
radius=radius,
location=(
cx + (radius + leader) * math.cos(angle_rad),
cy + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_current_dimstyle(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)
ent.translate(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)
copy.translate(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)
ent.transform(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)
ent.transform(
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)
copy.transform(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)
copy.translate(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)
)
copy.transform(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
current = entity.text if dxftype == "MTEXT" else 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
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:
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
ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs)
filled: list[str] = []
unknown: list[str] = []
tags = [attdef.dxf.tag for attdef in doc.blocks[name].attdefs()]
if attributes:
values = {str(k): str(v) for k, v in dict(attributes).items()}
if tags:
# add_auto_attribs fills ATTRIBs from the block's ATTDEFs and
# silently ignores values whose tag has no ATTDEF; report the
# leftovers 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))
else:
unknown = sorted(values)
# 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)
async def block_explode(self, handle) -> dict:
def _sync():
ent = self._get_entity(handle)
if ent.dxftype() != "INSERT":
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
msp = self._msp()
# Decompose: add individual entities to modelspace
inserted = []
for sub in ent.virtual_entities():
sub_copy = sub.copy()
msp.add_entity(sub_copy)
inserted.append(sub_copy.dxf.handle)
msp.delete_entity(ent)
self._mark_dirty()
return {"ok": True, "inserted_handles": inserted}
return await self._async(_sync)
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] = attrib.dxf.text
return result
return await self._async(_sync)
async def block_set_attributes(self, handle, attributes, style=None) -> dict:
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")
values = {str(k): str(v) for k, v in dict(attributes or {}).items()}
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:
attrib.dxf.text = 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)
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)
# ── 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,
"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()
return doc.header.get(f"${name.upper()}", None)
return await self._async(_sync)
async def system_set_variable(self, name, value) -> dict:
def _sync():
doc = self._require_doc()
doc.header[f"${name.upper()}"] = value
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)",
}
# ── 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)