feat(refs): xrefs, raster underlays, data extract and real DWG output
SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.
The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:
IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
RotationAngle)
IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
XRefDatabase (get)
IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
ac2013_dwg=60 ac2018_dwg=64 (=acNative)
The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.
Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.
Three capability keys, declared in both maps:
dwg_write -- measured absent here (odafc.is_installed() is False), so the
ezdxf entry is a runtime re-evaluation like viewport_render's
matplotlib check rather than a hardcoded False: installing the
ODA File Converter starts the call working with no edit to the
map. The refusal names the install route and drawing_export_dxf.
xref_live -- reload and bind need a live seat's xref manager and are refused
headlessly; list, detach and path are headless and are not
behind the key.
xlsx_write -- openpyxl is optional and absent here; CSV is always available,
so the refusal never blocks the deliverable and says so.
Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
de8dc8f813
commit
3ef316d520
@@ -33,6 +33,7 @@ from backends.contracts import (
|
||||
LinetypeContract,
|
||||
PageSetupContract,
|
||||
PremiumContract,
|
||||
RefsContract,
|
||||
SettingsContract,
|
||||
SolidContract,
|
||||
StylesContract,
|
||||
@@ -190,6 +191,7 @@ class AutoCADBackend(
|
||||
SettingsContract,
|
||||
StylesContract,
|
||||
EnvironmentContract,
|
||||
RefsContract,
|
||||
):
|
||||
"""All backends implement this interface.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -157,6 +158,28 @@ def _apoint(x: float, y: float, z: float = 0.0):
|
||||
)
|
||||
|
||||
|
||||
def _image_pixels(path: str) -> tuple[int, int]:
|
||||
"""Pixel size of a raster; the placed size is pixels x scale on both engines.
|
||||
|
||||
Copied from the ezdxf backend rather than imported: the two backends must
|
||||
not depend on each other.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc: # pragma: no cover - Pillow ships with [pdf]
|
||||
raise ValueError(
|
||||
"image_attach needs Pillow to read the image's pixel size; "
|
||||
'install it with: pip install -e ".[pdf]"'
|
||||
) from exc
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
return int(image.width), int(image.height)
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"image_attach: {path} is not an image this server can read ({exc})"
|
||||
) from exc
|
||||
|
||||
|
||||
def _av(values: list[float]):
|
||||
"""Create a VARIANT double-array from a flat list."""
|
||||
return win32com.client.VARIANT(
|
||||
@@ -1536,6 +1559,16 @@ class ComBackend(AutoCADBackend):
|
||||
"interactive_prompt": FeatureCapability(
|
||||
True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
|
||||
),
|
||||
# Track G keys.
|
||||
"dwg_write": FeatureCapability(True, "native", reason="document_saveas"),
|
||||
"xref_live": FeatureCapability(
|
||||
True, "native", reason="block_reload_bind_detach_on_the_live_seat"
|
||||
),
|
||||
"xlsx_write": (
|
||||
FeatureCapability(True, "openpyxl")
|
||||
if find_spec("openpyxl") is not None
|
||||
else FeatureCapability(False, reason="optional_dependency_missing:openpyxl")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1805,6 +1838,176 @@ class ComBackend(AutoCADBackend):
|
||||
async def drawing_export_dxf(self, path: str) -> dict:
|
||||
return await self.drawing_save_as(path, "dxf")
|
||||
|
||||
# ── external references, rasters and DWG (v1.6 track G) ──────────────────
|
||||
#
|
||||
# Measured on AutoCAD 2026 (AutoCAD.Application 25.1s), from the live
|
||||
# seat's type library:
|
||||
# ModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
|
||||
# Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
|
||||
# ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
|
||||
# RotationAngle)
|
||||
# Block: IsXRef, Path, Name, Reload(), Unload(), Bind(bPrefixName),
|
||||
# Detach(), XRefDatabase
|
||||
# Document.SaveAs(FullFileName, SaveAsType, vSecurityParams)
|
||||
# ActiveX angles are radians; the tool boundary is degrees, so every
|
||||
# rotation crosses here and nowhere else.
|
||||
|
||||
#: AcSaveAsType, read off the same type library.
|
||||
_DWG_SAVE_AS = {
|
||||
"R2000": 12,
|
||||
"R2004": 24,
|
||||
"R2007": 36,
|
||||
"R2010": 48,
|
||||
"R2013": 60,
|
||||
"R2018": 64,
|
||||
}
|
||||
|
||||
async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict:
|
||||
from backends.contracts.refs import XREF_KINDS
|
||||
|
||||
if kind not in XREF_KINDS:
|
||||
raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}")
|
||||
source = Path(path)
|
||||
if not source.is_file():
|
||||
raise ValueError(f"xref_attach: {path} does not exist")
|
||||
x, y = float(at[0]), float(at[1])
|
||||
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"xref_attach: {label} must be finite, got {value!r}")
|
||||
if float(scale) <= 0:
|
||||
raise ValueError(f"xref_attach: scale must be positive, got {scale!r}")
|
||||
name = source.stem.upper()
|
||||
factor = float(scale)
|
||||
radians = math.radians(float(rotation))
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
reference = doc.ModelSpace.AttachExternalReference(
|
||||
str(source),
|
||||
name,
|
||||
_apoint(x, y),
|
||||
factor,
|
||||
factor,
|
||||
factor,
|
||||
radians,
|
||||
kind == "overlay",
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"path": str(source),
|
||||
"kind": kind,
|
||||
"handle": reference.Handle,
|
||||
"at": [x, y],
|
||||
"scale": factor,
|
||||
"rotation": float(rotation),
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def xref_manage(self, name, action, new_path=None) -> dict:
|
||||
from backends.contracts.refs import XREF_ACTIONS
|
||||
|
||||
if action not in XREF_ACTIONS:
|
||||
raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}")
|
||||
if action == "path" and not (new_path or "").strip():
|
||||
raise ValueError("xref_manage(action='path') needs new_path")
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
|
||||
def _xref_blocks():
|
||||
found = []
|
||||
for index in range(doc.Blocks.Count):
|
||||
block = doc.Blocks.Item(index)
|
||||
try:
|
||||
if block.IsXRef:
|
||||
found.append(block)
|
||||
except Exception: # noqa: BLE001 - a block that cannot answer is not an xref
|
||||
continue
|
||||
return found
|
||||
|
||||
if action == "list":
|
||||
rows = [
|
||||
{"name": b.Name, "path": str(b.Path or ""), "kind": "attach", "inserts": None}
|
||||
for b in _xref_blocks()
|
||||
]
|
||||
rows.sort(key=lambda row: row["name"])
|
||||
return {"ok": True, "xrefs": rows, "backend": self.name}
|
||||
|
||||
key = str(name).strip()
|
||||
block = next((b for b in _xref_blocks() if b.Name == key), None)
|
||||
if block is None:
|
||||
raise ValueError(
|
||||
f"xref_manage: {key!r} is not an external reference in this drawing"
|
||||
)
|
||||
if action == "reload":
|
||||
block.Reload()
|
||||
return {"ok": True, "name": key, "action": action, "backend": self.name}
|
||||
if action == "bind":
|
||||
block.Bind(False)
|
||||
return {"ok": True, "name": key, "action": action, "backend": self.name}
|
||||
if action == "detach":
|
||||
block.Detach()
|
||||
return {"ok": True, "name": key, "inserts_removed": None, "backend": self.name}
|
||||
block.Path = str(new_path)
|
||||
return {"ok": True, "name": key, "path": str(new_path), "backend": self.name}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict:
|
||||
source = Path(path)
|
||||
if not source.is_file():
|
||||
raise ValueError(f"image_attach: {path} does not exist")
|
||||
x, y = float(at[0]), float(at[1])
|
||||
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"image_attach: {label} must be finite, got {value!r}")
|
||||
if float(scale) <= 0:
|
||||
raise ValueError(f"image_attach: scale must be positive, got {scale!r}")
|
||||
px, py = _image_pixels(str(source))
|
||||
factor = float(scale)
|
||||
radians = math.radians(float(rotation))
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
raster = doc.ModelSpace.AddRaster(str(source), _apoint(x, y), factor, radians)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(source),
|
||||
"handle": raster.Handle,
|
||||
"at": [x, y],
|
||||
"scale": factor,
|
||||
"rotation": float(rotation),
|
||||
"pixels": [px, py],
|
||||
"size_mm": [px * factor, py * factor],
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def drawing_export_dwg(self, path, version="R2018") -> dict:
|
||||
from backends.contracts.refs import DWG_VERSIONS
|
||||
|
||||
if version not in DWG_VERSIONS:
|
||||
raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}")
|
||||
save_as_type = self._DWG_SAVE_AS[version]
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
doc.SaveAs(path, save_as_type)
|
||||
target = Path(path)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": path,
|
||||
"version": version,
|
||||
"bytes": target.stat().st_size if target.exists() else None,
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
|
||||
"""Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from backends.contracts.layouts import LayoutContract
|
||||
from backends.contracts.linetypes import LinetypeContract
|
||||
from backends.contracts.pagesetup import PageSetupContract
|
||||
from backends.contracts.premium import PremiumContract
|
||||
from backends.contracts.refs import RefsContract
|
||||
from backends.contracts.settings import SettingsContract
|
||||
from backends.contracts.solids import SolidContract
|
||||
from backends.contracts.styles import StylesContract
|
||||
@@ -48,6 +49,7 @@ __all__ = [
|
||||
"LinetypeContract",
|
||||
"PageSetupContract",
|
||||
"PremiumContract",
|
||||
"RefsContract",
|
||||
"SettingsContract",
|
||||
"SolidContract",
|
||||
"StylesContract",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""External references, raster images and DWG output (v1.6 tracks B+G, group G).
|
||||
|
||||
These are the only four operations of the sheet track that genuinely differ per
|
||||
engine, so they are the only four that become contract members. Everything else
|
||||
in `engineering/sheet/` composes the generic members and is written once.
|
||||
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
#: An xref is attached (its own xrefs come with it) or overlaid (they do not).
|
||||
XREF_KINDS = ("attach", "overlay")
|
||||
|
||||
#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live
|
||||
#: seat and are refused with the `xref_live` capability on the ezdxf engine.
|
||||
XREF_ACTIONS = ("list", "reload", "bind", "detach", "path")
|
||||
|
||||
#: DWG formats both engines name the same way. COM maps them to AcSaveAsType
|
||||
#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through
|
||||
#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more.
|
||||
DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
|
||||
|
||||
|
||||
class RefsContract(ABC):
|
||||
@abstractmethod
|
||||
async def xref_attach(
|
||||
self,
|
||||
path: str,
|
||||
at,
|
||||
scale: float,
|
||||
rotation: float,
|
||||
kind: str,
|
||||
) -> dict:
|
||||
"""Attach an external reference and insert it once.
|
||||
|
||||
The block name is the referenced file's stem, which is what both
|
||||
engines do. Returns ``{ok, name, path, kind, handle, at, scale,
|
||||
rotation, backend}``.
|
||||
|
||||
Refuses, before anything is written: a `kind` outside
|
||||
:data:`XREF_KINDS`, a file that does not exist, a non-finite
|
||||
coordinate/scale/rotation, a non-positive scale, and a block name the
|
||||
drawing already holds (re-attaching would either silently replace the
|
||||
definition or raise mid-write).
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict:
|
||||
"""One of :data:`XREF_ACTIONS` on one external reference.
|
||||
|
||||
``list`` ignores `name` and returns
|
||||
``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``path``
|
||||
rewrites the saved path to `new_path` and returns the new one.
|
||||
``detach`` removes every insert of the xref and its definition, and
|
||||
reports ``inserts_removed``. ``reload`` and ``bind`` need a live seat's
|
||||
xref manager: the headless engine raises
|
||||
``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending,
|
||||
which is why the key exists; ``list``/``detach``/``path`` are *not*
|
||||
behind it.
|
||||
|
||||
Refuses an action outside the vocabulary, an unknown xref name, and a
|
||||
``path`` action with no `new_path`.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict:
|
||||
"""Attach a raster image as an underlay.
|
||||
|
||||
`scale` is drawing units per pixel, so the placed size is the image's
|
||||
pixel size times `scale` and the aspect ratio is the file's. Returns
|
||||
``{ok, path, handle, at, scale, rotation, pixels: [w, h],
|
||||
size_mm: [w, h], backend}``.
|
||||
|
||||
Refuses a file that does not exist, a file no image reader can open
|
||||
(naming it), a non-positive scale and a non-finite number -- all before
|
||||
anything is written.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_export_dwg(self, path: str, version: str) -> dict:
|
||||
"""Write the current drawing as a real DWG.
|
||||
|
||||
Returns ``{ok, path, version, bytes, backend}``. Refuses a `version`
|
||||
outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA
|
||||
File Converter and otherwise raises
|
||||
``UnsupportedCapabilityError("dwg_write", ...)`` naming the install
|
||||
route; on a live seat it is ``Document.SaveAs`` with the matching
|
||||
``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the
|
||||
session bound to the file it just wrote.
|
||||
"""
|
||||
...
|
||||
@@ -202,6 +202,39 @@ def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError:
|
||||
)
|
||||
|
||||
|
||||
def _odafc_available() -> bool:
|
||||
"""Is the ODA File Converter on this machine right now?
|
||||
|
||||
Re-evaluated per call, the way the matplotlib check behind
|
||||
``viewport_render`` is: installing the converter must change the answer
|
||||
without editing the map.
|
||||
"""
|
||||
try:
|
||||
from ezdxf.addons import odafc
|
||||
|
||||
return bool(odafc.is_installed())
|
||||
except Exception: # noqa: BLE001 - an import failure is simply "not installed"
|
||||
return False
|
||||
|
||||
|
||||
def _image_pixels(path: str) -> tuple[int, int]:
|
||||
"""Pixel size of a raster, so the placed image keeps the file's aspect."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc: # pragma: no cover - Pillow ships with [pdf]
|
||||
raise ValueError(
|
||||
"image_attach needs Pillow to read the image's pixel size; "
|
||||
'install it with: pip install -e ".[pdf]"'
|
||||
) from exc
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
return int(image.width), int(image.height)
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"image_attach: {path} is not an image this server can read ({exc})"
|
||||
) from exc
|
||||
|
||||
|
||||
def _audit_entry(entry) -> dict:
|
||||
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
|
||||
|
||||
@@ -1676,6 +1709,30 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"interactive_prompt": FeatureCapability(
|
||||
False, reason="no_operator_to_prompt_headlessly"
|
||||
),
|
||||
# Track G keys. `dwg_write` is a runtime re-evaluation, not a
|
||||
# hardcoded False: installing the converter must change the
|
||||
# answer without editing this map, exactly as `viewport_render`
|
||||
# follows matplotlib.
|
||||
"dwg_write": (
|
||||
FeatureCapability(True, "odafc", reason="oda_file_converter_installed")
|
||||
if _odafc_available()
|
||||
else FeatureCapability(
|
||||
False,
|
||||
reason=(
|
||||
"requires_oda_file_converter:"
|
||||
"https://www.opendesign.com/guestfiles/oda_file_converter"
|
||||
),
|
||||
)
|
||||
),
|
||||
"xref_live": FeatureCapability(
|
||||
False,
|
||||
reason="reload_and_bind_need_a_live_seat;list_detach_path_are_headless",
|
||||
),
|
||||
"xlsx_write": (
|
||||
FeatureCapability(True, "openpyxl")
|
||||
if find_spec("openpyxl") is not None
|
||||
else FeatureCapability(False, reason="optional_dependency_missing:openpyxl")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2322,6 +2379,199 @@ class EzdxfBackend(AutoCADBackend):
|
||||
async def drawing_export_dxf(self, path: str) -> dict:
|
||||
return await self.drawing_save_as(path, "dxf")
|
||||
|
||||
# ── external references, rasters and DWG (v1.6 track G) ──────────────────
|
||||
|
||||
async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict:
|
||||
from backends.contracts.refs import XREF_KINDS
|
||||
|
||||
if kind not in XREF_KINDS:
|
||||
raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}")
|
||||
source = Path(path)
|
||||
if not source.is_file():
|
||||
raise ValueError(f"xref_attach: {path} does not exist")
|
||||
x, y = float(at[0]), float(at[1])
|
||||
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"xref_attach: {label} must be finite, got {value!r}")
|
||||
if float(scale) <= 0:
|
||||
raise ValueError(f"xref_attach: scale must be positive, got {scale!r}")
|
||||
name = source.stem.upper()
|
||||
|
||||
def _sync():
|
||||
from ezdxf import xref
|
||||
|
||||
doc = self._require_doc()
|
||||
if name in doc.blocks:
|
||||
raise ValueError(
|
||||
f"xref_attach: the drawing already holds a block named {name!r}; "
|
||||
"detach it first with xref_manage(action='detach')"
|
||||
)
|
||||
insert = xref.attach(
|
||||
doc,
|
||||
block_name=name,
|
||||
filename=str(source),
|
||||
insert=(x, y),
|
||||
scale=float(scale),
|
||||
rotation=float(rotation),
|
||||
overlay=(kind == "overlay"),
|
||||
)
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"path": str(source),
|
||||
"kind": kind,
|
||||
"handle": insert.dxf.handle,
|
||||
"at": [x, y],
|
||||
"scale": float(scale),
|
||||
"rotation": float(rotation),
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def xref_manage(self, name, action, new_path=None) -> dict:
|
||||
from backends.contracts.refs import XREF_ACTIONS
|
||||
|
||||
if action not in XREF_ACTIONS:
|
||||
raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}")
|
||||
if action in ("reload", "bind"):
|
||||
raise UnsupportedCapabilityError(
|
||||
"xref_live",
|
||||
f"xref_manage(action={action!r}): the {self.name} backend has no live xref "
|
||||
"manager. list, detach and path work headlessly; reload and bind need the "
|
||||
"com backend (a running AutoCAD seat).",
|
||||
)
|
||||
if action == "path" and not (new_path or "").strip():
|
||||
raise ValueError("xref_manage(action='path') needs new_path")
|
||||
|
||||
def _sync():
|
||||
from ezdxf.lldxf import const
|
||||
|
||||
doc = self._require_doc()
|
||||
# Measured on ezdxf 1.4.4: `xref.attach` leaves an attachment's
|
||||
# block flags at 20 and an overlay's at 24, so the question "is
|
||||
# this an xref" is the *pair* of bits. Masking BLK_XREF alone
|
||||
# misses every overlay.
|
||||
xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY
|
||||
|
||||
def _rows():
|
||||
rows = []
|
||||
for block in doc.blocks:
|
||||
flags = int(block.block.dxf.flags)
|
||||
if not flags & xref_mask:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": block.name,
|
||||
"path": str(block.block.dxf.xref_path or ""),
|
||||
"kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach",
|
||||
"inserts": sum(
|
||||
1
|
||||
for entity in doc.modelspace()
|
||||
if entity.dxftype() == "INSERT" and entity.dxf.name == block.name
|
||||
),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: row["name"])
|
||||
return rows
|
||||
|
||||
if action == "list":
|
||||
return {"ok": True, "xrefs": _rows(), "backend": self.name}
|
||||
|
||||
key = str(name).strip()
|
||||
row = next((r for r in _rows() if r["name"] == key), None)
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"xref_manage: {key!r} is not an external reference in this drawing "
|
||||
f"(have: {', '.join(r['name'] for r in _rows()) or 'none'})"
|
||||
)
|
||||
if action == "path":
|
||||
doc.blocks.get(key).block.dxf.xref_path = str(new_path)
|
||||
self._mark_dirty()
|
||||
return {"ok": True, "name": key, "path": str(new_path), "backend": self.name}
|
||||
|
||||
victims = [
|
||||
entity
|
||||
for entity in doc.modelspace()
|
||||
if entity.dxftype() == "INSERT" and entity.dxf.name == key
|
||||
]
|
||||
for entity in victims:
|
||||
doc.modelspace().delete_entity(entity)
|
||||
doc.blocks.delete_block(key, safe=False)
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": key,
|
||||
"inserts_removed": len(victims),
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict:
|
||||
source = Path(path)
|
||||
if not source.is_file():
|
||||
raise ValueError(f"image_attach: {path} does not exist")
|
||||
x, y = float(at[0]), float(at[1])
|
||||
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"image_attach: {label} must be finite, got {value!r}")
|
||||
if float(scale) <= 0:
|
||||
raise ValueError(f"image_attach: scale must be positive, got {scale!r}")
|
||||
px, py = _image_pixels(str(source))
|
||||
width, height = px * float(scale), py * float(scale)
|
||||
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
image_def = doc.add_image_def(str(source), size_in_pixel=(px, py))
|
||||
image = self._msp().add_image(image_def, (x, y), (width, height), float(rotation))
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(source),
|
||||
"handle": image.dxf.handle,
|
||||
"at": [x, y],
|
||||
"scale": float(scale),
|
||||
"rotation": float(rotation),
|
||||
"pixels": [px, py],
|
||||
"size_mm": [width, height],
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def drawing_export_dwg(self, path, version="R2018") -> dict:
|
||||
from backends.contracts.refs import DWG_VERSIONS
|
||||
|
||||
if version not in DWG_VERSIONS:
|
||||
raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}")
|
||||
if not _odafc_available():
|
||||
raise UnsupportedCapabilityError(
|
||||
"dwg_write",
|
||||
f"drawing_export_dwg: the {self.name} backend writes DWG only through the "
|
||||
"ODA File Converter, which is not installed here. Install it from "
|
||||
"https://www.opendesign.com/guestfiles/oda_file_converter and this call "
|
||||
"starts working, or export from the com backend (a running AutoCAD seat). "
|
||||
"drawing_export_dxf writes a DXF that AutoCAD opens unchanged.",
|
||||
)
|
||||
|
||||
def _sync():
|
||||
from ezdxf.addons import odafc
|
||||
|
||||
doc = self._require_doc()
|
||||
self._refuse_path_held_elsewhere("drawing_export_dwg", path)
|
||||
odafc.export_dwg(doc, path, version=version, replace=True)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": path,
|
||||
"version": version,
|
||||
"bytes": Path(path).stat().st_size,
|
||||
"backend": self.name,
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
|
||||
"""Export to PDF via ezdxf's matplotlib backend.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user