diff --git a/README.md b/README.md index 2e3fd90..2cadf2d 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **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 | -210 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +215 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **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. diff --git a/backends/base.py b/backends/base.py index 951cb02..99a24e0 100644 --- a/backends/base.py +++ b/backends/base.py @@ -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. diff --git a/backends/com_backend.py b/backends/com_backend.py index 667bf8f..0218371 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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. diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index 49835f5..6665641 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -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", diff --git a/backends/contracts/refs.py b/backends/contracts/refs.py new file mode 100644 index 0000000..f05faec --- /dev/null +++ b/backends/contracts/refs.py @@ -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. + """ + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index e020fa8..582dac3 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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. diff --git a/discovery/aliases.py b/discovery/aliases.py index f3c6d72..8936242 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -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"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index e25b609..75f7e0e 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -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": [ diff --git a/engineering/sheet/__init__.py b/engineering/sheet/__init__.py index 76f1438..e6a99c3 100644 --- a/engineering/sheet/__init__.py +++ b/engineering/sheet/__init__.py @@ -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", ] diff --git a/engineering/sheet/extract.py b/engineering/sheet/extract.py new file mode 100644 index 0000000..be25a81 --- /dev/null +++ b/engineering/sheet/extract.py @@ -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, + } diff --git a/server.py b/server.py index 4f52b46..b1a4849 100644 --- a/server.py +++ b/server.py @@ -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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_refs_contract.py b/tests/test_refs_contract.py new file mode 100644 index 0000000..33512d2 --- /dev/null +++ b/tests/test_refs_contract.py @@ -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] diff --git a/tests/test_sheet_extract.py b/tests/test_sheet_extract.py new file mode 100644 index 0000000..c2ed4a7 --- /dev/null +++ b/tests/test_sheet_extract.py @@ -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() diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index b1cff77..508c513 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -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