10 изменённых файлов + 2 новых (engineering/gost_titleblock.py, _fonts/isocpeur.ttf). Ветка-донор для трёхстороннего переноса в ours-1.6.
6980 lines
286 KiB
Python
6980 lines
286 KiB
Python
"""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)
|