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:
Umut CAN
2026-09-22 23:03:57 +03:00
co-authored by Claude Opus 5
parent de8dc8f813
commit 3ef316d520
14 changed files with 1449 additions and 16 deletions
+4 -4
View File
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
> **v1.5 release snapshot:** 215 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 215 is the **registered** count; a default install advertises 210 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
**A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **187 AutoCAD command names and 990 synonym phrases** covering all 210 tools. A test refuses to let a tool exist without one.
**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **193 AutoCAD command names and 1019 synonym phrases** covering all 215 tools. A test refuses to let a tool exist without one.
| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>210 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>215 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+2
View File
@@ -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.
+203
View File
@@ -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.
+2
View 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",
+98
View File
@@ -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.
"""
...
+250
View File
@@ -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.
+60
View File
@@ -1027,6 +1027,66 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"pozisyon numarasi",
),
),
"xref_attach": ToolAliases(
acad=("XATTACH",),
synonyms=(
"attach an xref",
"external reference",
"xref",
"reference another drawing",
"overlay a drawing",
"harici referans",
),
),
"xref_manage": ToolAliases(
acad=("XREF", "XBIND"),
synonyms=(
"list the xrefs",
"reload an xref",
"bind an xref",
"detach an xref",
"repath an xref",
"xref manager",
"broken xref path",
),
),
"image_attach": ToolAliases(
acad=("IMAGEATTACH",),
synonyms=(
"attach an image",
"raster underlay",
"insert a png",
"background image",
"scanned drawing",
"resim ekle",
),
),
"data_extract": ToolAliases(
acad=("DATAEXTRACTION", "EATTEXT"),
synonyms=(
# Not the bare phrase "parts list": that is the golden query for
# `entity_create_table`, and a file writer must not outrank the
# tool that draws the thing. This one is asked for by its
# destination -- a file -- which is what distinguishes it.
"export the bill of materials",
"extract attributes",
"bom to csv",
"bom to excel",
"data extraction",
"excele aktar",
),
),
"drawing_export_dwg": ToolAliases(
acad=("SAVEAS",),
synonyms=(
"export dwg",
"save as dwg",
"write a dwg",
"autocad 2018 format",
"downgrade to r2000",
"dwg olarak kaydet",
),
),
# ── Layers & linetypes ──────────────────────────────────────────────────
"layer_create": ToolAliases(
acad=("LAYER", "LA"),
+75 -5
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 210,
"tools": 215,
"resources": 8,
"prompts": 5,
"groups": 23
@@ -11,12 +11,12 @@
"groups": {
"analysis": 12,
"batch": 3,
"blocks": 9,
"blocks": 11,
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"engineering": 16,
"entity_creation": 18,
"drawing": 12,
"engineering": 17,
"entity_creation": 19,
"entity_modification": 16,
"entity_query": 9,
"environment": 22,
@@ -414,6 +414,21 @@
"destructiveHint": false
}
},
{
"name": "data_extract",
"group": "engineering",
"cost": "safe",
"summary": "Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.",
"tags": [
"engineering",
"query",
"sheet"
],
"annotations": {
"title": "Sheet: Extract Data",
"destructiveHint": false
}
},
{
"name": "datum_feature",
"group": "engineering",
@@ -697,6 +712,19 @@
"destructiveHint": true
}
},
{
"name": "drawing_export_dwg",
"group": "drawing",
"cost": "safe",
"summary": "Write the drawing as a real DWG at a chosen AutoCAD version.",
"tags": [
"drawing"
],
"annotations": {
"title": "Drawing: Export DWG",
"destructiveHint": false
}
},
{
"name": "drawing_export_dxf",
"group": "drawing",
@@ -1649,6 +1677,20 @@
"destructiveHint": false
}
},
{
"name": "image_attach",
"group": "entity_creation",
"cost": "mutate",
"summary": "Attach a raster image (PNG/JPG) as an underlay.",
"tags": [
"create",
"drawing"
],
"annotations": {
"title": "Sheet: Attach Image",
"destructiveHint": false
}
},
{
"name": "keyway_draw_keyed_bore",
"group": "engineering",
@@ -2988,6 +3030,34 @@
"title": "Set Viewport Scale",
"destructiveHint": false
}
},
{
"name": "xref_attach",
"group": "blocks",
"cost": "mutate",
"summary": "Attach a DWG/DXF as an external reference (attachment or overlay).",
"tags": [
"block",
"drawing"
],
"annotations": {
"title": "Sheet: Attach Xref",
"destructiveHint": false
}
},
{
"name": "xref_manage",
"group": "blocks",
"cost": "mutate",
"summary": "List, reload, bind, detach or repath an external reference.",
"tags": [
"block",
"drawing"
],
"annotations": {
"title": "Sheet: Manage Xrefs",
"destructiveHint": true
}
}
],
"resources": [
+4
View File
@@ -15,6 +15,7 @@ from engineering.sheet.bom import (
rows_from_records,
table_prims,
)
from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available
from engineering.sheet.frames import (
CENTRING_OVERSHOOT,
EDGE_MARGIN,
@@ -50,6 +51,7 @@ __all__ = [
"COLUMN_LABELS",
"DEFAULT_COLUMNS",
"EDGE_MARGIN",
"EXTRACT_FORMATS",
"FILING_MARGIN",
"LIST_WIDTH",
"REVISION_COLUMNS",
@@ -80,5 +82,7 @@ __all__ = [
"rows_from_records",
"sheet_size",
"table_prims",
"write_rows",
"xlsx_available",
"zone_divisions",
]
+81
View File
@@ -0,0 +1,81 @@
"""Parts-list rows to a file: CSV always, XLSX when openpyxl is installed.
CSV needs nothing but the standard library, so the deliverable is never blocked
-- which is exactly why `xlsx_write` can be a capability rather than a hard
dependency. The refusal names both the missing package and the format that
always works.
"""
from __future__ import annotations
import csv
from importlib.util import find_spec
from pathlib import Path
from backends.capability import UnsupportedCapabilityError
from engineering.sheet.bom import BOM_COLUMNS, COLUMN_LABELS, DEFAULT_COLUMNS
__all__ = ["EXTRACT_FORMATS", "write_rows", "xlsx_available"]
EXTRACT_FORMATS = ("csv", "xlsx")
def xlsx_available() -> bool:
"""Is openpyxl importable right now? Re-evaluated per call, so installing
the package changes the answer without a restart."""
return find_spec("openpyxl") is not None
def _prepare(rows, columns):
chosen = tuple(DEFAULT_COLUMNS if columns is None else columns)
unknown = [c for c in chosen if c not in BOM_COLUMNS]
if unknown:
raise ValueError(
f"unknown parts-list column(s) {', '.join(unknown)}; "
f"available: {', '.join(BOM_COLUMNS)}"
)
body = [[str(row.get(column, "")) for column in chosen] for row in rows]
if not body:
raise ValueError("data_extract: there are no parts-list rows to write")
return chosen, [COLUMN_LABELS[c] for c in chosen], body
def write_rows(rows, path: str, *, columns=None, fmt: str = "csv") -> dict:
"""Write the rows to `path`. Refuses before the file is created."""
if fmt not in EXTRACT_FORMATS:
raise ValueError(f"format must be one of {', '.join(EXTRACT_FORMATS)}; got {fmt!r}")
chosen, heading, body = _prepare(rows, columns)
if fmt == "xlsx" and not xlsx_available():
raise UnsupportedCapabilityError(
"xlsx_write",
"data_extract(format='xlsx') needs openpyxl, which is not installed. "
'Install it with: pip install openpyxl (or pip install -e ".[full]"), '
"or ask for format='csv', which always works and carries the same rows.",
)
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
if fmt == "csv":
with open(target, "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(heading)
writer.writerows(body)
else:
from openpyxl import Workbook
book = Workbook()
sheet = book.active
sheet.title = "Parts list"
sheet.append(heading)
for row in body:
sheet.append(row)
book.save(target)
return {
"ok": True,
"path": str(target),
"format": fmt,
"columns": list(chosen),
"rows": len(body),
"bytes": target.stat().st_size,
}
+176 -1
View File
@@ -8124,7 +8124,7 @@ async def drawing_properties_set(
# ---------------------------------------------------------------------------
# ── SECTION 24: Sheet & Delivery (6 tools) ──────────────────────────────────
# ── SECTION 24: Sheet & Delivery (11 tools) ─────────────────────────────────
# ---------------------------------------------------------------------------
@@ -8574,6 +8574,181 @@ async def balloon_add(
)
@cad_tool(
summary="Attach a DWG/DXF as an external reference (attachment or overlay).", cost="mutate"
)
@mcp.tool(
annotations={"title": "Sheet: Attach Xref", "destructiveHint": False},
tags={"drawing", "block"},
)
async def xref_attach(
path: Annotated[str, Field(description="Full path of the drawing to reference.")],
x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0,
y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0,
scale: Annotated[float, Field(default=1.0, gt=0, description="Uniform scale.")] = 1.0,
rotation: Annotated[
float, Field(default=0.0, description="Rotation in degrees, CCW from +X.")
] = 0.0,
kind: Annotated[
str,
Field(
default="attach",
description="'attach' (the reference's own xrefs come with it) or 'overlay' "
"(they do not, so a circular reference cannot form).",
),
] = "attach",
ctx: Context = None,
) -> dict:
"""Attach an external reference and insert it once. Both engines.
The block takes the referenced file's stem as its name. Refusals, all
before anything is written: a `kind` that is not 'attach' or 'overlay', a
file that does not exist, a non-positive or non-finite scale, and a block
name the drawing already holds — detach it first with
`xref_manage(action='detach')`.
"""
validated = validate_path(path)
await ctx.info(f"Xref {kind}: {validated}")
return await _backend(ctx).xref_attach(str(validated), (x, y), scale, rotation, kind)
@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate")
@mcp.tool(
annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True},
tags={"drawing", "block"},
)
async def xref_manage(
action: Annotated[
str,
Field(description="list | reload | bind | detach | path."),
],
name: Annotated[str, Field(default="", description="Xref name; ignored by 'list'.")] = "",
new_path: Annotated[
str, Field(default="", description="Required by 'path': the reference's new location.")
] = "",
ctx: Context = None,
) -> dict:
"""One operation on one external reference.
`list`, `detach` and `path` work on both engines. `reload` and `bind` need
a live AutoCAD seat's xref manager: headlessly they are refused with
`capability: "xref_live"` rather than pretended, and the refusal says which
engine does them. Other refusals: an action outside the five, an unknown
xref name, a 'path' action with no `new_path`. On the live engine a listed
row's `inserts` is null — ActiveX reports no per-xref insert count and this
server does not invent one.
"""
await ctx.info(f"Xref {action} {name or '(all)'}")
validated = str(validate_path(new_path)) if new_path else None
return await _backend(ctx).xref_manage(name, action, validated)
@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate")
@mcp.tool(
annotations={"title": "Sheet: Attach Image", "destructiveHint": False},
tags={"drawing", "create"},
)
async def image_attach(
path: Annotated[str, Field(description="Full path of the raster image.")],
x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0,
y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0,
scale: Annotated[
float,
Field(
default=1.0,
gt=0,
description="Drawing units per pixel: an 800x400 image at 0.1 is 80x40 mm.",
),
] = 1.0,
rotation: Annotated[float, Field(default=0.0, description="Rotation in degrees.")] = 0.0,
ctx: Context = None,
) -> dict:
"""Attach a raster underlay at the file's own aspect ratio. Both engines.
`scale` is drawing units per pixel, so the placed size is the image's pixel
size times `scale` and the payload reports both. Refusals, all before
anything is written: a file that does not exist, a file no image reader can
open (named), a non-positive or non-finite scale.
"""
validated = validate_path(path)
await ctx.info(f"Image underlay: {validated}")
return await _backend(ctx).image_attach(str(validated), (x, y), scale, rotation)
@cad_tool(
summary="Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.",
cost="safe",
)
@mcp.tool(
annotations={"title": "Sheet: Extract Data", "destructiveHint": False},
tags={"engineering", "sheet", "query"},
)
async def data_extract(
path: Annotated[str, Field(description="File to write, ending .csv or .xlsx.")],
fmt: Annotated[
str,
Field(default="csv", description="'csv' (always available) or 'xlsx' (needs openpyxl)."),
] = "csv",
layer: Annotated[
str, Field(default="", description="Only read INSERTs on this layer; empty reads all.")
] = "",
group_by: Annotated[
str,
Field(default="designation", description="Group identical parts by this field, or ''."),
] = "designation",
columns: Annotated[list[str] | None, Field(default=None)] = None,
ctx: Context = None,
) -> dict:
"""The same rows `bom_extract` returns, written to a file.
Refusals: a format that is not 'csv' or 'xlsx'; a drawing with nothing to
itemise (nothing is written); and `fmt='xlsx'` without openpyxl, which
carries `capability: "xlsx_write"` and names both the package to install
and the CSV route that always works.
"""
from engineering.sheet.bom import extract_records, rows_from_records
from engineering.sheet.extract import write_rows
backend = _backend(ctx)
validated = validate_path(path, allow_write=True)
records = await extract_records(backend, layer=layer or None)
rows = rows_from_records(records, columns=columns, group_by=group_by or None)
await ctx.info(f"Data extract: {len(rows)} rows -> {validated}")
return write_rows(rows, str(validated), columns=columns, fmt=fmt)
@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe")
@mcp.tool(
annotations={"title": "Drawing: Export DWG", "destructiveHint": False},
tags={"drawing"},
)
async def drawing_export_dwg(
path: Annotated[str, Field(description="Full path of the .dwg to write.")],
version: Annotated[
str,
Field(
default="R2018",
description="R2000 | R2004 | R2007 | R2010 | R2013 | R2018.",
),
] = "R2018",
ctx: Context = None,
) -> dict:
"""Write a real DWG — not a DXF under a .dwg name.
On a live AutoCAD seat this is `Document.SaveAs` with the matching
AcSaveAsType, which (as AutoCAD's own SaveAs does) leaves the session bound
to the file it just wrote. Headlessly it needs the ODA File Converter; when
that is absent the call is refused with `capability: "dwg_write"` and the
refusal names the install route and `drawing_export_dxf`, which writes a
DXF AutoCAD opens unchanged. Check `system_capabilities` first: the ezdxf
flag is re-evaluated at call time, so installing the converter changes it
without a restart. An unknown version is refused by name with the six.
"""
validated = validate_path(path, allow_write=True)
await ctx.info(f"DWG export {version}: {validated}")
return await _backend(ctx).drawing_export_dwg(str(validated), version)
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+390
View File
@@ -0,0 +1,390 @@
"""External references, raster images and DWG output on both engines.
The three capability keys are the point of this file: a refusal the feature map
never mentions is a lie by omission, so every key raised here is asserted to be
declared on both backends.
"""
from __future__ import annotations
from importlib.util import find_spec
from pathlib import Path
import pytest
from backends.base import AutoCADBackend, UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.contracts.refs import (
DWG_VERSIONS,
XREF_ACTIONS,
XREF_KINDS,
RefsContract,
)
from backends.ezdxf_backend import EzdxfBackend
TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write")
def _features(backend) -> dict:
return backend.capabilities().to_dict()["features"]
def test_the_contract_is_composed_into_both_backends():
assert issubclass(AutoCADBackend, RefsContract)
assert issubclass(EzdxfBackend, RefsContract)
assert issubclass(ComBackend, RefsContract)
for name in ("xref_attach", "xref_manage", "image_attach", "drawing_export_dwg"):
assert callable(getattr(AutoCADBackend, name, None)), name
def test_the_three_keys_are_declared_in_both_capability_maps():
ezdxf_features = _features(EzdxfBackend())
com_features = _features(ComBackend())
for key in TRACK_G_KEYS:
assert key in ezdxf_features, f"{key} undeclared on ezdxf"
assert key in com_features, f"{key} undeclared on com"
def test_com_declares_dwg_and_live_xrefs_and_ezdxf_does_not():
com_features = _features(ComBackend())
ezdxf_features = _features(EzdxfBackend())
assert com_features["dwg_write"]["supported"] is True
assert com_features["xref_live"]["supported"] is True
assert ezdxf_features["xref_live"]["supported"] is False
assert "live" in (ezdxf_features["xref_live"]["reason"] or "")
def test_ezdxf_dwg_write_is_re_evaluated_from_the_converter_not_hardcoded():
"""The same shape as viewport_render's matplotlib check: the flag follows
the machine, and the reason names the way to change it."""
from ezdxf.addons import odafc
feature = _features(EzdxfBackend())["dwg_write"]
assert feature["supported"] is bool(odafc.is_installed())
if not feature["supported"]:
assert "oda" in (feature["reason"] or "").lower()
def test_xlsx_write_follows_openpyxl_on_both_engines():
present = find_spec("openpyxl") is not None
for backend in (EzdxfBackend(), ComBackend()):
feature = _features(backend)["xlsx_write"]
assert feature["supported"] is present, backend.name
if not present:
assert "openpyxl" in (feature["reason"] or "")
def test_the_vocabularies_are_closed():
assert XREF_KINDS == ("attach", "overlay")
assert XREF_ACTIONS == ("list", "reload", "bind", "detach", "path")
assert DWG_VERSIONS == ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
# -- ezdxf -------------------------------------------------------------------
@pytest.fixture
def external(tmp_path: Path) -> str:
"""A real DXF on disk for the xref to point at."""
import ezdxf
doc = ezdxf.new("R2018")
doc.modelspace().add_circle((0, 0), 5)
target = tmp_path / "BASE.dxf"
doc.saveas(target)
return str(target)
@pytest.mark.asyncio
async def test_attach_creates_an_xref_block_and_an_insert(backend, external):
result = await backend.xref_attach(external, (10.0, 20.0), 2.0, 30.0, "attach")
assert result["ok"] is True
assert result["name"] == "BASE"
assert result["kind"] == "attach"
insert = await backend.entity_get(result["handle"])
assert insert.type == "INSERT"
assert insert.properties["block_name"] == "BASE"
listing = await backend.xref_manage("", "list", None)
assert [row["name"] for row in listing["xrefs"]] == ["BASE"]
assert listing["xrefs"][0]["kind"] == "attach"
assert Path(listing["xrefs"][0]["path"]).name == "BASE.dxf"
assert listing["xrefs"][0]["inserts"] == 1
@pytest.mark.asyncio
async def test_overlay_is_flagged_as_an_overlay(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "overlay")
listing = await backend.xref_manage("", "list", None)
assert listing["xrefs"][0]["kind"] == "overlay"
@pytest.mark.asyncio
async def test_attaching_the_same_name_twice_is_refused_before_writing(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
before = len(await backend.entity_list())
with pytest.raises(ValueError) as excinfo:
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
assert "BASE" in str(excinfo.value)
assert len(await backend.entity_list()) == before
@pytest.mark.asyncio
async def test_a_missing_file_and_a_bad_kind_are_refused(backend, tmp_path):
with pytest.raises(ValueError) as missing:
await backend.xref_attach(str(tmp_path / "nope.dxf"), (0.0, 0.0), 1.0, 0.0, "attach")
assert "nope.dxf" in str(missing.value)
with pytest.raises(ValueError) as kind:
await backend.xref_attach(str(tmp_path), (0.0, 0.0), 1.0, 0.0, "underlay")
assert "underlay" in str(kind.value)
@pytest.mark.asyncio
async def test_path_rewrites_the_saved_path_and_detach_removes_the_block(
backend, external, tmp_path
):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
moved = str(tmp_path / "moved" / "BASE.dxf")
changed = await backend.xref_manage("BASE", "path", moved)
assert changed["ok"] is True
assert changed["path"] == moved
assert (await backend.xref_manage("", "list", None))["xrefs"][0]["path"] == moved
detached = await backend.xref_manage("BASE", "detach", None)
assert detached["ok"] is True and detached["inserts_removed"] == 1
assert (await backend.xref_manage("", "list", None))["xrefs"] == []
@pytest.mark.asyncio
async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
for action in ("reload", "bind"):
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.xref_manage("BASE", action, None)
assert excinfo.value.capability == "xref_live"
assert "com" in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_an_unknown_action_and_an_unknown_name_are_refused(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
with pytest.raises(ValueError) as action:
await backend.xref_manage("BASE", "unload", None)
assert "unload" in str(action.value)
with pytest.raises(ValueError) as name:
await backend.xref_manage("NOPE", "detach", None)
assert "NOPE" in str(name.value)
@pytest.mark.asyncio
async def test_image_attach_places_the_raster_at_its_pixel_aspect(backend, tmp_path):
from PIL import Image
png = tmp_path / "logo.png"
Image.new("RGB", (800, 400), "white").save(png)
result = await backend.image_attach(str(png), (5.0, 5.0), 0.1, 0.0)
assert result["ok"] is True
assert result["pixels"] == [800, 400]
assert result["size_mm"] == [80.0, 40.0]
image = await backend.entity_get(result["handle"])
assert image.type == "IMAGE"
@pytest.mark.asyncio
async def test_a_non_image_file_is_refused_before_writing(backend, external):
before = len(await backend.entity_list())
with pytest.raises(ValueError) as excinfo:
await backend.image_attach(external, (0.0, 0.0), 1.0, 0.0)
assert "BASE.dxf" in str(excinfo.value)
assert len(await backend.entity_list()) == before
@pytest.mark.asyncio
async def test_headless_dwg_export_refuses_with_dwg_write_when_the_converter_is_absent(
backend, tmp_path
):
from ezdxf.addons import odafc
if odafc.is_installed():
pytest.skip("the ODA File Converter is installed on this machine")
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R2018")
assert excinfo.value.capability == "dwg_write"
assert "oda" in str(excinfo.value).lower()
assert not (tmp_path / "out.dwg").exists()
@pytest.mark.asyncio
async def test_an_unknown_dwg_version_is_refused_on_both_engines(backend, tmp_path):
with pytest.raises(ValueError) as excinfo:
await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R14")
message = str(excinfo.value)
assert "R14" in message
assert "R2018" in message
# -- COM, against fakes that model the measured ActiveX members --------------
def _com_backend():
b = ComBackend()
async def _run(func, *args, **kwargs): # bypass the single-thread executor
return func(*args, **kwargs)
b._run = _run
return b
def _point(variant):
"""The doubles a VARIANT point carries.
``_apoint`` hands ActiveX a ``win32com.client.VARIANT``, and that object is
*not* iterable -- measured here:
``tuple(VARIANT(VT_ARRAY | VT_R8, [1.0, 2.0, 0.0]))`` raises
``TypeError: 'VARIANT' object is not iterable``. The real ModelSpace reads
the array behind it, so the fakes read ``.value`` rather than pretending
the argument is a plain sequence.
"""
return tuple(getattr(variant, "value", variant))
class _Block:
"""Measured members of an AcadBlock that is an xref (AutoCAD 2026)."""
def __init__(self, name, path="", is_xref=True):
self.Name = name
self.Path = path
self.IsXRef = is_xref
self.calls = []
def Reload(self):
self.calls.append("Reload")
def Bind(self, bPrefixName):
self.calls.append(("Bind", bPrefixName))
def Detach(self):
self.calls.append("Detach")
class _Blocks:
def __init__(self, blocks):
self._blocks = list(blocks)
def Item(self, index):
if isinstance(index, int):
return self._blocks[index]
for block in self._blocks:
if block.Name == index:
return block
raise KeyError(index)
@property
def Count(self):
return len(self._blocks)
def __iter__(self):
return iter(self._blocks)
class _Ref:
def __init__(self, handle="7F"):
self.Handle = handle
class _ModelSpace:
def __init__(self):
self.attached = []
self.rasters = []
def AttachExternalReference(
self, PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay
):
self.attached.append(
(PathName, Name, _point(InsertionPoint), Xscale, Yscale, Zscale, Rotation, bOverlay)
)
return _Ref("A1")
def AddRaster(self, imageFileName, InsertionPoint, ScaleFactor, RotationAngle):
self.rasters.append((imageFileName, _point(InsertionPoint), ScaleFactor, RotationAngle))
return _Ref("B2")
class _ComDoc:
def __init__(self, blocks=()):
self.ModelSpace = _ModelSpace()
self.Blocks = _Blocks(blocks)
self.saved = []
def SaveAs(self, FullFileName, SaveAsType):
self.saved.append((FullFileName, SaveAsType))
@pytest.mark.asyncio
async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external):
import math
import backends.com_backend as cb
doc = _ComDoc()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
result = await _com_backend().xref_attach(external, (10.0, 20.0), 2.0, 90.0, "overlay")
assert result["handle"] == "A1"
path, name, insert, sx, sy, sz, rotation, overlay = doc.ModelSpace.attached[0]
assert name == "BASE" and overlay is True
assert (sx, sy, sz) == (2.0, 2.0, 2.0)
assert rotation == pytest.approx(math.pi / 2)
assert insert[:2] == (10.0, 20.0)
@pytest.mark.asyncio
async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch):
import backends.com_backend as cb
block = _Block("BASE", path="C:/refs/BASE.dwg")
doc = _ComDoc([block])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _com_backend()
assert (await b.xref_manage("BASE", "reload", None))["ok"] is True
assert (await b.xref_manage("BASE", "bind", None))["ok"] is True
assert (await b.xref_manage("BASE", "detach", None))["ok"] is True
assert block.calls == ["Reload", ("Bind", False), "Detach"]
listing = await b.xref_manage("", "list", None)
assert listing["xrefs"][0]["name"] == "BASE"
assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg"
@pytest.mark.asyncio
async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path):
import math
from PIL import Image
import backends.com_backend as cb
png = tmp_path / "logo.png"
Image.new("RGB", (400, 200), "white").save(png)
doc = _ComDoc()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
result = await _com_backend().image_attach(str(png), (1.0, 2.0), 0.5, 180.0)
assert result["handle"] == "B2"
name, insert, scale, rotation = doc.ModelSpace.rasters[0]
assert Path(name).name == "logo.png"
assert insert[:2] == (1.0, 2.0)
assert scale == 0.5
assert rotation == pytest.approx(math.pi)
@pytest.mark.asyncio
async def test_com_dwg_export_uses_the_measured_acsaveastype_values(monkeypatch, tmp_path):
import backends.com_backend as cb
doc = _ComDoc()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _com_backend()
await b.drawing_export_dwg(str(tmp_path / "a.dwg"), "R2018")
await b.drawing_export_dwg(str(tmp_path / "b.dwg"), "R2000")
assert [entry[1] for entry in doc.saved] == [64, 12]
+89
View File
@@ -0,0 +1,89 @@
"""Parts-list rows to CSV (always) and XLSX (with openpyxl)."""
from __future__ import annotations
import csv
from importlib.util import find_spec
import pytest
from backends.base import UnsupportedCapabilityError
from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available
def _rows():
return (
{
"item": 1,
"qty": 2,
"designation": "ISO 4014 - M12x60",
"standard": "ISO 4014",
"material": "8.8",
"handles": ("A1", "A2"),
},
{
"item": 2,
"qty": 1,
"designation": "SHAFT 40h6",
"standard": "",
"material": "C45",
"handles": ("B1",),
},
)
def test_the_formats_are_closed_and_csv_is_always_one_of_them():
assert EXTRACT_FORMATS == ("csv", "xlsx")
def test_csv_writes_the_heading_row_and_one_row_per_part(tmp_path):
target = tmp_path / "bom.csv"
result = write_rows(_rows(), str(target), fmt="csv")
assert result["ok"] is True and result["rows"] == 2 and result["format"] == "csv"
with open(target, newline="", encoding="utf-8") as handle:
written = list(csv.reader(handle))
assert written[0] == ["POS", "QTY", "DESIGNATION", "STANDARD", "MATERIAL"]
assert written[1] == ["1", "2", "ISO 4014 - M12x60", "ISO 4014", "8.8"]
assert written[2][2] == "SHAFT 40h6"
assert len(written) == 3
def test_a_chosen_column_set_is_honoured(tmp_path):
target = tmp_path / "short.csv"
write_rows(_rows(), str(target), columns=("item", "designation"), fmt="csv")
with open(target, newline="", encoding="utf-8") as handle:
written = list(csv.reader(handle))
assert written[0] == ["POS", "DESIGNATION"]
assert all(len(row) == 2 for row in written)
def test_an_unknown_format_is_refused_with_the_list(tmp_path):
with pytest.raises(ValueError) as excinfo:
write_rows(_rows(), str(tmp_path / "x.ods"), fmt="ods")
message = str(excinfo.value)
assert "ods" in message and "csv" in message and "xlsx" in message
def test_no_rows_is_refused_rather_than_writing_an_empty_file(tmp_path):
target = tmp_path / "empty.csv"
with pytest.raises(ValueError):
write_rows((), str(target), fmt="csv")
assert not target.exists()
def test_xlsx_available_agrees_with_the_installed_package():
assert xlsx_available() is (find_spec("openpyxl") is not None)
def test_xlsx_refuses_with_the_capability_key_and_names_the_extra(tmp_path):
if xlsx_available():
result = write_rows(_rows(), str(tmp_path / "bom.xlsx"), fmt="xlsx")
assert result["ok"] is True and (tmp_path / "bom.xlsx").exists()
return
target = tmp_path / "bom.xlsx"
with pytest.raises(UnsupportedCapabilityError) as excinfo:
write_rows(_rows(), str(target), fmt="xlsx")
assert excinfo.value.capability == "xlsx_write"
assert "openpyxl" in str(excinfo.value)
assert "csv" in str(excinfo.value), "a refusal that does not name the way out is half a refusal"
assert not target.exists()
+15 -6
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (210 tools, 23 groups).
"""Frozen snapshot of the surface (215 tools, 23 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -537,6 +537,15 @@ async def test_tool_group_sizes_are_unchanged():
It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and
`balloon_add` joined the same section; `bom_extract` also carries `query`,
but `engineering` outranks it in `_GROUP_TAG_PRIORITY`.
The section's last five tools file elsewhere on purpose: they are
whole-drawing and entity operations, not sheet furniture, and
`_GROUP_TAG_PRIORITY` files them where a caller would look.
`xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block`
outranks `drawing`, so they file under `blocks` (9 -> 11) beside
`block_insert` -- which is where an xref belongs: it is a block definition
that lives in another file. `image_attach` carries `create`
(entity_creation 18 -> 19), `data_extract` carries `engineering`
(16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12).
Every other number here has been unchanged
since the snapshot was taken.
"""
@@ -544,12 +553,12 @@ async def test_tool_group_sizes_are_unchanged():
assert sizes == {
"analysis": 12,
"batch": 3,
"blocks": 9,
"blocks": 11,
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
"engineering": 16,
"entity_creation": 18,
"drawing": 12,
"engineering": 17,
"entity_creation": 19,
"entity_modification": 16,
"entity_query": 9,
"environment": 22,
@@ -566,4 +575,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 210
assert sum(sizes.values()) == 215