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
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
> **v1.5 release snapshot:** 215 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||||
> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
|
> 215 is the **registered** count; a default install advertises 210 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
## Why this exists
|
## 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 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 |
|
| 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) |
|
| 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 |
|
| 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from backends.contracts import (
|
|||||||
LinetypeContract,
|
LinetypeContract,
|
||||||
PageSetupContract,
|
PageSetupContract,
|
||||||
PremiumContract,
|
PremiumContract,
|
||||||
|
RefsContract,
|
||||||
SettingsContract,
|
SettingsContract,
|
||||||
SolidContract,
|
SolidContract,
|
||||||
StylesContract,
|
StylesContract,
|
||||||
@@ -190,6 +191,7 @@ class AutoCADBackend(
|
|||||||
SettingsContract,
|
SettingsContract,
|
||||||
StylesContract,
|
StylesContract,
|
||||||
EnvironmentContract,
|
EnvironmentContract,
|
||||||
|
RefsContract,
|
||||||
):
|
):
|
||||||
"""All backends implement this interface.
|
"""All backends implement this interface.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from importlib.util import find_spec
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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]):
|
def _av(values: list[float]):
|
||||||
"""Create a VARIANT double-array from a flat list."""
|
"""Create a VARIANT double-array from a flat list."""
|
||||||
return win32com.client.VARIANT(
|
return win32com.client.VARIANT(
|
||||||
@@ -1536,6 +1559,16 @@ class ComBackend(AutoCADBackend):
|
|||||||
"interactive_prompt": FeatureCapability(
|
"interactive_prompt": FeatureCapability(
|
||||||
True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
|
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:
|
async def drawing_export_dxf(self, path: str) -> dict:
|
||||||
return await self.drawing_save_as(path, "dxf")
|
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:
|
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.
|
"""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.linetypes import LinetypeContract
|
||||||
from backends.contracts.pagesetup import PageSetupContract
|
from backends.contracts.pagesetup import PageSetupContract
|
||||||
from backends.contracts.premium import PremiumContract
|
from backends.contracts.premium import PremiumContract
|
||||||
|
from backends.contracts.refs import RefsContract
|
||||||
from backends.contracts.settings import SettingsContract
|
from backends.contracts.settings import SettingsContract
|
||||||
from backends.contracts.solids import SolidContract
|
from backends.contracts.solids import SolidContract
|
||||||
from backends.contracts.styles import StylesContract
|
from backends.contracts.styles import StylesContract
|
||||||
@@ -48,6 +49,7 @@ __all__ = [
|
|||||||
"LinetypeContract",
|
"LinetypeContract",
|
||||||
"PageSetupContract",
|
"PageSetupContract",
|
||||||
"PremiumContract",
|
"PremiumContract",
|
||||||
|
"RefsContract",
|
||||||
"SettingsContract",
|
"SettingsContract",
|
||||||
"SolidContract",
|
"SolidContract",
|
||||||
"StylesContract",
|
"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:
|
def _audit_entry(entry) -> dict:
|
||||||
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
|
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
|
||||||
|
|
||||||
@@ -1676,6 +1709,30 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"interactive_prompt": FeatureCapability(
|
"interactive_prompt": FeatureCapability(
|
||||||
False, reason="no_operator_to_prompt_headlessly"
|
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:
|
async def drawing_export_dxf(self, path: str) -> dict:
|
||||||
return await self.drawing_save_as(path, "dxf")
|
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:
|
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
|
||||||
"""Export to PDF via ezdxf's matplotlib backend.
|
"""Export to PDF via ezdxf's matplotlib backend.
|
||||||
|
|
||||||
|
|||||||
@@ -1027,6 +1027,66 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"pozisyon numarasi",
|
"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 ──────────────────────────────────────────────────
|
# ── Layers & linetypes ──────────────────────────────────────────────────
|
||||||
"layer_create": ToolAliases(
|
"layer_create": ToolAliases(
|
||||||
acad=("LAYER", "LA"),
|
acad=("LAYER", "LA"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 210,
|
"tools": 215,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 23
|
"groups": 23
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 11,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 12,
|
||||||
"engineering": 16,
|
"engineering": 17,
|
||||||
"entity_creation": 18,
|
"entity_creation": 19,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"environment": 22,
|
"environment": 22,
|
||||||
@@ -414,6 +414,21 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "datum_feature",
|
||||||
"group": "engineering",
|
"group": "engineering",
|
||||||
@@ -697,6 +712,19 @@
|
|||||||
"destructiveHint": true
|
"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",
|
"name": "drawing_export_dxf",
|
||||||
"group": "drawing",
|
"group": "drawing",
|
||||||
@@ -1649,6 +1677,20 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "keyway_draw_keyed_bore",
|
||||||
"group": "engineering",
|
"group": "engineering",
|
||||||
@@ -2988,6 +3030,34 @@
|
|||||||
"title": "Set Viewport Scale",
|
"title": "Set Viewport Scale",
|
||||||
"destructiveHint": false
|
"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": [
|
"resources": [
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from engineering.sheet.bom import (
|
|||||||
rows_from_records,
|
rows_from_records,
|
||||||
table_prims,
|
table_prims,
|
||||||
)
|
)
|
||||||
|
from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available
|
||||||
from engineering.sheet.frames import (
|
from engineering.sheet.frames import (
|
||||||
CENTRING_OVERSHOOT,
|
CENTRING_OVERSHOOT,
|
||||||
EDGE_MARGIN,
|
EDGE_MARGIN,
|
||||||
@@ -50,6 +51,7 @@ __all__ = [
|
|||||||
"COLUMN_LABELS",
|
"COLUMN_LABELS",
|
||||||
"DEFAULT_COLUMNS",
|
"DEFAULT_COLUMNS",
|
||||||
"EDGE_MARGIN",
|
"EDGE_MARGIN",
|
||||||
|
"EXTRACT_FORMATS",
|
||||||
"FILING_MARGIN",
|
"FILING_MARGIN",
|
||||||
"LIST_WIDTH",
|
"LIST_WIDTH",
|
||||||
"REVISION_COLUMNS",
|
"REVISION_COLUMNS",
|
||||||
@@ -80,5 +82,7 @@ __all__ = [
|
|||||||
"rows_from_records",
|
"rows_from_records",
|
||||||
"sheet_size",
|
"sheet_size",
|
||||||
"table_prims",
|
"table_prims",
|
||||||
|
"write_rows",
|
||||||
|
"xlsx_available",
|
||||||
"zone_divisions",
|
"zone_divisions",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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():
|
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
|
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
|
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
|
It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and
|
||||||
`balloon_add` joined the same section; `bom_extract` also carries `query`,
|
`balloon_add` joined the same section; `bom_extract` also carries `query`,
|
||||||
but `engineering` outranks it in `_GROUP_TAG_PRIORITY`.
|
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
|
Every other number here has been unchanged
|
||||||
since the snapshot was taken.
|
since the snapshot was taken.
|
||||||
"""
|
"""
|
||||||
@@ -544,12 +553,12 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
assert sizes == {
|
assert sizes == {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 11,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 12,
|
||||||
"engineering": 16,
|
"engineering": 17,
|
||||||
"entity_creation": 18,
|
"entity_creation": 19,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"environment": 22,
|
"environment": 22,
|
||||||
@@ -566,4 +575,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 210
|
assert sum(sizes.values()) == 215
|
||||||
|
|||||||
Reference in New Issue
Block a user