From 174fb8010c0bd374ba2a4014c65b07704f3866c6 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:20:21 +0300 Subject: [PATCH 01/12] =?UTF-8?q?feat(environment):=20multi-document=20on?= =?UTF-8?q?=20both=20engines=20=E2=80=94=20ezdxf=20document=20registry,=20?= =?UTF-8?q?document=5Flist/activate/close,=20drawing=5Fclose=20folded=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnvironmentContract carries every member of the track E interface; the two drawing_properties_* members are @capability('dwgprops') in this worktree only and become @abstractmethod when group C's implementations merge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 5 +- backends/base.py | 2 + backends/com_backend.py | 140 +++++++++- backends/contracts/__init__.py | 2 + backends/contracts/environment.py | 120 ++++++++ backends/ezdxf_backend.py | 449 ++++++++++++++++++++++++++---- discovery/aliases.py | 39 +++ docs/tool-inventory.json | 44 ++- server.py | 95 ++++++- tests/test_documents.py | 361 ++++++++++++++++++++++++ tests/test_tool_registry.py | 8 +- 11 files changed, 1200 insertions(+), 65 deletions(-) create mode 100644 backends/contracts/environment.py create mode 100644 tests/test_documents.py diff --git a/README.md b/README.md index 0864612..7ba7a61 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests. -> 166 is the **registered** count; a default install advertises 161 over `tools/list`, +> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> 169 is the **registered** count; a default install advertises 164 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -104,6 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog | | Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools | | Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` | +| Environment | `document_list/activate/close` — several drawings open at once on both engines, the active one is what every tool targets | | Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box | | Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* | | Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle | diff --git a/backends/base.py b/backends/base.py index 0adbddc..800625a 100644 --- a/backends/base.py +++ b/backends/base.py @@ -25,6 +25,7 @@ from backends.contracts import ( EntityCreationContract, EntityModificationContract, EntityQueryContract, + EnvironmentContract, GdtContract, IdentityContract, LayerContract, @@ -184,6 +185,7 @@ class AutoCADBackend( PremiumContract, GdtContract, SettingsContract, + EnvironmentContract, ): """All backends implement this interface. diff --git a/backends/com_backend.py b/backends/com_backend.py index b0aa28d..2ab9467 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -814,6 +814,14 @@ class ComBackend(AutoCADBackend): "viewport_render": FeatureCapability(True, "native"), "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), + "documents": FeatureCapability(True, "native"), + "live_application": FeatureCapability(True, "native"), + "preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"), + "interactive_prompt": FeatureCapability( + True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out" + ), + # Byte-identical to group C's Task 16 entry; the merge agent keeps one. + "dwgprops": FeatureCapability(True, "native"), }, ) @@ -1276,6 +1284,125 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + # --------------------------------------------------------------------------- + # ── environment (track E) ─────────────────────────────────────────────────── + # --------------------------------------------------------------------------- + + @staticmethod + def _com_documents(app) -> list: + return [app.Documents.Item(i) for i in range(int(app.Documents.Count))] + + @staticmethod + def _com_document_row(doc, active) -> dict: + full_name = str(doc.FullName) + return { + "name": str(doc.Name), + "path": full_name or None, + "active": active is not None + and (str(doc.Name), full_name) == (str(active.Name), str(active.FullName)), + "saved": bool(doc.Saved), + "entity_count": int(doc.ModelSpace.Count), + } + + def _com_find_document(self, app, name_or_path: str): + """Match a key the way ``_resolve_document_key`` does: full path, then name.""" + if not isinstance(name_or_path, str): + raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}") + wanted = name_or_path.strip() + if not wanted: + raise ValueError("name_or_path must not be empty") + docs = self._com_documents(app) + try: + as_path = str(Path(wanted).resolve()).lower() + except (OSError, ValueError): + as_path = wanted.lower() + by_path = [ + d + for d in docs + if str(d.FullName) and str(Path(str(d.FullName)).resolve()).lower() == as_path + ] + by_name = [d for d in docs if str(d.Name).lower() == wanted.lower()] + hits = by_path or by_name + if len(hits) == 1: + return hits[0] + names = [str(d.Name) for d in docs] + if len(hits) > 1: + raise ValueError(f"document {wanted!r} is ambiguous: {names}; pass the full path") + raise ValueError(f"no open document named {wanted!r}; open documents: {names}") + + async def document_list(self) -> list[dict]: + def _sync(): + app = _acad_app() + active = app.ActiveDocument if int(app.Documents.Count) else None + return [self._com_document_row(doc, active) for doc in self._com_documents(app)] + + return await self._run(_sync) + + async def document_activate(self, name_or_path: str) -> dict: + def _sync(): + app = _acad_app() + previous = str(app.ActiveDocument.Name) if int(app.Documents.Count) else None + doc = self._com_find_document(app, name_or_path) + doc.Activate() + return { + "ok": True, + "active": str(doc.Name), + "previous": previous, + "name": str(doc.Name), + "path": str(doc.FullName) or None, + } + + result = await self._run(_sync) + await self._ensure_document_state() + return result + + async def document_close( + self, name_or_path: str | None = None, save: bool = False, discard: bool = False + ) -> dict: + if save and discard: + raise ValueError("document_close: save and discard are mutually exclusive") + + def _sync(): + app = _acad_app() + doc = ( + _acad_doc() if name_or_path is None else self._com_find_document(app, name_or_path) + ) + name = str(doc.Name) + path = str(doc.FullName) or None + dirty = not bool(doc.Saved) + if dirty: + if save: + if not path: + raise ValueError( + f"document_close: {name!r} has never been saved; Close(True) would " + "open AutoCAD's Save dialog and block the COM thread. " + "drawing_save_as(path) first, or pass discard=True" + ) + elif not discard: + where = f" to {path}" if path else " (after drawing_save_as)" + raise ValueError( + f"document_close: {name!r} has unsaved changes; pass save=True to " + f"write them{where}, or discard=True to drop them" + ) + # A clean or untitled document is never asked to save: Close(True) + # on a clean file is a no-op write, on an untitled one a dialog. + doc.Close(bool(save and dirty and path)) + count = int(app.Documents.Count) + return { + "ok": True, + "closed": name, + "path": path, + "saved": bool(save and dirty and path), + "discarded_changes": bool(dirty and not save), + "active": str(app.ActiveDocument.Name) if count else None, + "open_documents": count, + "backend": "com", + } + + result = await self._run(_sync) + await self._ensure_document_state() + return result + # ── selection filters (M8 / F1) ────────────────────────────────────────── # # VERIFIED against a live AutoCAD 2026 (2026-08-05). @@ -2119,14 +2246,13 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def drawing_close(self, save: bool = True) -> dict: - def _sync(): - doc = _acad_doc() - doc.Close(save) - return {"ok": True} + """The active-document case of ``document_close``. - result = await self._run(_sync) - await self._ensure_document_state() - return result + ``Close(True)`` on a drawing that has never been saved opens AutoCAD's + Save dialog and blocks the single STA thread until ``COM_CALL_TIMEOUT``; + that is refused here by name rather than waited out. + """ + return await self.document_close(None, save=save, discard=not save) async def drawing_undo(self) -> dict: def _sync(): diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index fb04be8..83b9c9c 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -16,6 +16,7 @@ from backends.contracts.drawing import DrawingContract from backends.contracts.entity_creation import EntityCreationContract from backends.contracts.entity_modification import EntityModificationContract from backends.contracts.entity_query import EntityQueryContract +from backends.contracts.environment import EnvironmentContract from backends.contracts.gdt import GdtContract from backends.contracts.identity import IdentityContract from backends.contracts.layers import LayerContract @@ -37,6 +38,7 @@ __all__ = [ "EntityCreationContract", "EntityModificationContract", "EntityQueryContract", + "EnvironmentContract", "GdtContract", "IdentityContract", "LayerContract", diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py new file mode 100644 index 0000000..00b294a --- /dev/null +++ b/backends/contracts/environment.py @@ -0,0 +1,120 @@ +"""Environment: documents, layer states, named views, UCS, application, +preferences, drawing properties and operator prompts (v1.6 track E). + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. + +Ownership: group V owns this module. The ``# ── properties ──`` block is +declared here and *implemented* by group C on both backends; the six +``@capability`` members are implemented on the COM backend only and refuse on +ezdxf with a key both capability maps declare. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from backends.capability import capability + + +class EnvironmentContract(ABC): + # ── documents ──────────────────────────────────────────────────────────── + + @abstractmethod + async def document_list(self) -> list[dict]: + """``[{"name", "path", "active": bool, "saved": bool, "entity_count"}]``.""" + ... + + @abstractmethod + async def document_activate(self, name_or_path: str) -> dict: + """Make one open document the target of every later call. + → ``{"ok", "active", "previous"}``.""" + ... + + @abstractmethod + async def document_close( + self, name_or_path: str | None = None, save: bool = False, discard: bool = False + ) -> dict: + """Close one document (default: the active one). + + Unsaved and neither ``save`` nor ``discard`` → ``ValueError`` naming the + document; ``save`` on a document that has never been saved → + ``ValueError`` (no path to write, and the live engine would open a + modal Save dialog). → ``{"ok", "closed", "saved": bool, "active": name|None}``. + """ + ... + + # ── layer states, named views, UCS: appended by Task 22 ────────────────── + + # ── application, preferences, operator prompts (COM only) ─────────────── + + @capability( # noqa: B027 — @capability supplies the body + "live_application", + reason="only a live CAD application can be launched or attached", + ) + async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict: + """→ ``{"launched": bool, "attached": bool, "version", "document"}``.""" + ... + + @capability( # noqa: B027 — @capability supplies the body + "preferences", + reason="AutoCAD preferences live in the running application, not in a file", + ) + async def preferences_get(self, keys: list[str] | None = None) -> dict: + """→ ``{"values": {key: value}, "read_only": [keys]}``.""" + ... + + @capability( # noqa: B027 — @capability supplies the body + "preferences", + reason="AutoCAD preferences live in the running application, not in a file", + ) + async def preferences_set(self, key: str, value) -> dict: + """→ ``{"ok", "key", "old", "new"}``.""" + ... + + @capability( # noqa: B027 — @capability supplies the body + "interactive_prompt", + reason="prompting the operator needs a live AutoCAD session", + ) + async def user_pick_point(self, prompt: str) -> dict: + """→ ``{"cancelled": bool, "x", "y"}`` | ``{"timed_out": True}``.""" + ... + + @capability( # noqa: B027 — @capability supplies the body + "interactive_prompt", + reason="prompting the operator needs a live AutoCAD session", + ) + async def user_select(self, prompt: str, mode: str = "single") -> dict: + """→ ``{"cancelled", "handles": [...]}``.""" + ... + + @capability( # noqa: B027 — @capability supplies the body + "interactive_prompt", + reason="prompting the operator needs a live AutoCAD session", + ) + async def system_prompt_message(self, text: str) -> dict: + """→ ``{"ok"}``.""" + ... + + # ── properties (declared here; implemented by group C on both engines) ─── + + # TEMPORARY (track-e/environment worktree only — the merge agent deletes this + # block and the two decorators below become @abstractmethod): until group C's + # implementations land, declaring these abstract would stop both backends from + # instantiating here. They refuse with a declared key rather than pretend. + + @capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027 + async def drawing_properties_get(self) -> dict: + """→ ``{"summary": {"title","subject","author","keywords","comments"} + (None each headless), "summary_available": bool, "custom": {k: v}}``.""" + ... + + @capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027 + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + """ezdxf: ``summary`` with any non-None field → raise + ``UnsupportedCapabilityError("dwgprops")``; ``custom`` → header + custom_vars (replace keys given; value None deletes). + → ``{"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}``.""" + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 465d1d8..ba0b77e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -18,6 +18,7 @@ import math import os import tempfile import uuid +from dataclasses import dataclass, field from importlib.util import find_spec from pathlib import Path from typing import Any @@ -950,6 +951,69 @@ def _layer_info_dxf(layer_obj, current_name: str) -> LayerInfo: ) +# --------------------------------------------------------------------------- +# ── environment (track E): document registry ──────────────────────────────── +# --------------------------------------------------------------------------- + + +@dataclass +class _DocState: + """Everything the backend keeps per open document. + + Before v1.6 these were twelve attributes on the backend itself, which is + why it could hold exactly one drawing. The registry holds one of these + per open document; the old attribute names survive as descriptors over + the *active* entry so no existing method had to change. + """ + + key: str + doc: Any = None + path: str | None = None + dirty: bool = False + current_layer: str = "0" + current_space: str = "Model" + undo_stack: list[Path] = field(default_factory=list) + redo_stack: list[Path] = field(default_factory=list) + transaction_stack: list[Path] = field(default_factory=list) + plan_spec: Any = None + preflight_result: Any = None + gdt_datums_defined: set = field(default_factory=set) + gdt_datums_referenced: set = field(default_factory=set) + activated_seq: int = 0 + + @property + def label(self) -> str: + return Path(self.path).name if self.path else self.key + + def discard_snapshots(self) -> None: + for stack in (self.undo_stack, self.redo_stack, self.transaction_stack): + while stack: + try: + stack.pop().unlink() + except OSError: + pass + + +class _ActiveField: + """A backend attribute that lives on the active document's ``_DocState``. + + A data descriptor, so it also overrides the plain class attributes the + ``PremiumContract`` mixin declares (``_plan_spec = None``) — the mixin's + ``self._plan_spec = plan`` lands on the active document. + """ + + def __init__(self, attr: str): + self.attr = attr + + def __get__(self, instance, owner=None): + if instance is None: + return self + return getattr(instance._state, self.attr) + + def __set__(self, instance, value): + setattr(instance._state, self.attr, value) + + # --------------------------------------------------------------------------- # EzdxfBackend # --------------------------------------------------------------------------- @@ -958,17 +1022,32 @@ def _layer_info_dxf(layer_obj, current_name: str) -> LayerInfo: class EzdxfBackend(AutoCADBackend): """File-based ezdxf backend – no live AutoCAD needed.""" + # Old per-backend attributes, now routed to the active document (v1.6). + # `_doc` and `_doc_path` are explicit properties below (binding and + # re-keying rules); the rest are plain views. + _dirty = _ActiveField("dirty") + _current_layer = _ActiveField("current_layer") + _current_space = _ActiveField("current_space") + _undo_stack = _ActiveField("undo_stack") + _redo_stack = _ActiveField("redo_stack") + _transaction_stack = _ActiveField("transaction_stack") + _plan_spec = _ActiveField("plan_spec") + _preflight_result = _ActiveField("preflight_result") + _gdt_datums_defined = _ActiveField("gdt_datums_defined") + _gdt_datums_referenced = _ActiveField("gdt_datums_referenced") + def __init__(self): if not _EZDXF_OK: raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf") - self._doc: Any = None # ezdxf Drawing object - self._doc_path: str | None = None - self._dirty: bool = False - self._current_layer: str = "0" - self._undo_stack: list[Path] = [] # user-facing undo history - self._redo_stack: list[Path] = [] # states stepped off by an undo - self._current_space: str = "Model" # where entity_create_* writes - self._transaction_stack: list[Path] = [] # isolated transaction snapshots + # v1.6: one entry per open document; the old attributes above are + # views over the active one. `_void` is what they read and write when + # nothing is open, so `_reset_document_state()` on disconnect and + # `system_status()` on an empty backend keep working. + self._docs: dict[str, _DocState] = {} + self._active_key: str | None = None + self._void = _DocState(key="") + self._untitled_counter = 0 + self._activation_counter = 0 self._connected = False self._lock = asyncio.Lock() # R32: the document a timed-out call was abandoned on is untrusted until @@ -976,6 +1055,50 @@ class EzdxfBackend(AutoCADBackend): self._quarantine: QuarantineRecord | None = None self._abandoned_calls: list[QuarantineRecord] = [] + @property + def _state(self) -> _DocState: + if self._active_key is None: + return self._void + return self._docs[self._active_key] + + @property + def _doc(self): + return self._state.doc + + @_doc.setter + def _doc(self, value): + # `_restore_snapshot` and `transaction_rollback` rebind the active + # document; nothing may bind a Drawing while none is active — that is + # `_register_document`'s job, and it is what keeps the registry honest. + if self._active_key is None: + if value is None: + return + raise RuntimeError("EzdxfBackend: bind a Drawing through _register_document") + self._state.doc = value + + @property + def _doc_path(self) -> str | None: + return self._state.path + + @_doc_path.setter + def _doc_path(self, value: str | None): + # `drawing_save` / `drawing_save_as` assign here. A document keyed + # `untitled-N` that is saved to a file becomes keyed by that file, so + # `document_list` shows one identity per document and `document_close` + # can be told the file name. Two entries may end up sharing a path + # (save_as onto a file that is open in another entry); the resolver + # then reports the ambiguity instead of guessing. + state = self._state + state.path = value + if self._active_key is None or value is None: + return + new_key = os.path.abspath(value) + if new_key != self._active_key and new_key not in self._docs: + del self._docs[self._active_key] + state.key = new_key + self._docs[new_key] = state + self._active_key = new_key + @property def name(self) -> str: return "ezdxf" @@ -1058,6 +1181,26 @@ class EzdxfBackend(AutoCADBackend): False, reason="acis_generation_requires_live_autocad" ), "lisp": FeatureCapability(False, reason="live_com_only"), + "documents": FeatureCapability(True, "registry"), + # Track E keys. `@capability` registers its key at import, and + # tests/test_capability_contract.py checks both maps from that + # moment on — so every key the contract module uses is declared + # here in the task that creates the module, not when the COM + # implementation lands (Task 23). + "live_application": FeatureCapability( + False, reason="headless_engine_has_no_application_to_launch_or_attach" + ), + "preferences": FeatureCapability( + False, reason="preferences_live_in_the_running_application_not_in_a_file" + ), + "interactive_prompt": FeatureCapability( + False, reason="no_operator_to_prompt_headlessly" + ), + # Byte-identical to group C's Task 16 entry; the merge agent keeps one. + "dwgprops": FeatureCapability( + False, + reason="summary_fields_need_live_autocad;custom_properties_supported", + ), }, ) @@ -1066,21 +1209,153 @@ class EzdxfBackend(AutoCADBackend): log.info("ezdxf backend ready") async def disconnect(self) -> None: - self._cleanup_undo_stack() + for state in self._docs.values(): + state.discard_snapshots() + self._docs.clear() + self._active_key = None + self._void = _DocState(key="") self._reset_document_state() - # The document goes away with the backend, so nothing is left to protect. + # The documents go away with the backend, so nothing is left to protect. # The record stays in _abandoned_calls for anyone reading the transcript. self._clear_quarantine("disconnect") self._connected = False def _cleanup_undo_stack(self): - for stack in (self._undo_stack, self._redo_stack, self._transaction_stack): - while stack: - p = stack.pop() - try: - p.unlink() - except OSError: - pass + self._state.discard_snapshots() + + # ── document registry (v1.6) ────────────────────────────────────────────── + + def _register_document(self, doc, path: str | None) -> tuple[str, bool]: + """Add ``doc`` to the registry and make it active. + + Returns ``(key, reloaded)``. A path that is already open is *reloaded*: + its old entry (and its snapshots) is dropped and the fresh read takes + its place — the pre-registry behaviour of ``drawing_open``, kept so a + save-then-reopen verification still reads the disk. + """ + if path is None: + self._untitled_counter += 1 + key = f"untitled-{self._untitled_counter}" + reloaded = False + else: + key = os.path.abspath(path) + previous = self._docs.pop(key, None) + reloaded = previous is not None + if previous is not None: + previous.discard_snapshots() + self._docs[key] = _DocState(key=key, doc=doc, path=path) + self._activate_key(key) + return key, reloaded + + def _activate_key(self, key: str) -> None: + self._activation_counter += 1 + self._docs[key].activated_seq = self._activation_counter + self._active_key = key + + def _most_recent_key(self) -> str | None: + if not self._docs: + return None + return max(self._docs.values(), key=lambda state: state.activated_seq).key + + def _resolve_document_key(self, name_or_path: str | None) -> str: + """Registry key for ``None`` (the active document), a key, a path or a basename.""" + if name_or_path is None: + if self._active_key is None: + raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.") + return self._active_key + if not isinstance(name_or_path, str): + raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}") + wanted = name_or_path.strip() + if not wanted: + raise ValueError("name_or_path must not be empty") + if wanted in self._docs: + return wanted + try: + as_path = os.path.abspath(wanted) + except (OSError, ValueError): + as_path = wanted + by_path = [ + key + for key, state in self._docs.items() + if state.path and os.path.abspath(state.path) == as_path + ] + if len(by_path) == 1: + return by_path[0] + by_name = [ + key for key, state in self._docs.items() if state.label.lower() == wanted.lower() + ] + if len(by_name) == 1: + return by_name[0] + if len(by_name) > 1: + raise ValueError(f"document {wanted!r} is ambiguous: {by_name}; pass the full path") + raise ValueError(f"no open document named {wanted!r}; open documents: {list(self._docs)}") + + def _evict_quarantined_document(self) -> None: + """Drop the quarantined entry before a fresh document is registered. + + Pre-registry, ``drawing_new`` *replaced* the only document, and the + quarantine relies on that: the runaway keeps the object it captured + and its writes land on an orphan nobody reads. With a registry the + quarantined entry would otherwise survive, and ``document_activate`` + could hand back a drawing another thread is still mutating. + """ + if self._quarantine is None or self._active_key is None: + return + state = self._docs.pop(self._active_key) + state.discard_snapshots() + self._active_key = self._most_recent_key() + + def _document_row(self, key: str, state: _DocState) -> dict: + return { + "name": state.label, + "path": state.path, + "key": key, + "active": key == self._active_key, + "saved": not state.dirty, + "entity_count": len(state.doc.modelspace()), + } + + def _close_document_sync(self, key: str, *, save: bool, discard: bool) -> dict: + """Close one registry entry. Runs on the worker thread under the lock.""" + state = self._docs[key] + closing_active = key == self._active_key + quarantined = closing_active and self._quarantine is not None + label = state.label + saved = False + if state.dirty: + if save: + if quarantined: + raise ValueError( + f"document_close: {label!r} was quarantined after a call was " + "abandoned mid-write and cannot be saved; pass discard=True to drop " + "its changes" + ) + if not state.path: + raise ValueError( + f"document_close: {label!r} has never been saved, so save=True has " + "nowhere to write; drawing_save_as(path) first, or pass discard=True" + ) + state.doc.saveas(state.path) + state.dirty = False + saved = True + elif not discard: + where = f" to {state.path}" if state.path else " (after drawing_save_as)" + raise ValueError( + f"document_close: {label!r} has unsaved changes; pass save=True to write " + f"them{where}, or discard=True to drop them" + ) + discarded = state.dirty + state.discard_snapshots() + del self._docs[key] + result = {"ok": True, "closed": key, "saved": saved, "discarded_changes": discarded} + if closing_active: + released = self._clear_quarantine("document_close") + self._active_key = self._most_recent_key() + if released is not None: + result["quarantine_cleared"] = released.to_dict() + result["active"] = self._active_key + result["open_documents"] = len(self._docs) + return result def _require_doc(self): if self._doc is None: @@ -1403,22 +1678,26 @@ class EzdxfBackend(AutoCADBackend): async def drawing_new(self, template: str | None = None) -> dict: def _sync(): if template and Path(template).exists(): - self._doc = ezdxf.readfile(template) - _normalise_dimstyle_rounding(self._doc) + doc = ezdxf.readfile(template) + _normalise_dimstyle_rounding(doc) else: - self._doc = ezdxf.new(dxfversion="R2010") - _apply_iso_dimstyle(self._doc) - # R32: the rebind above is the release — the runaway keeps the - # Drawing it captured, so its remaining writes land on an orphan. - # Clear before _reset_history_baseline, which snapshots the new doc. + doc = ezdxf.new(dxfversion="R2010") + _apply_iso_dimstyle(doc) + # R32: registering a fresh Drawing is the release — the runaway keeps + # the object it captured. The quarantined entry is evicted first so + # it can never be re-activated. Clear before _reset_history_baseline, + # which snapshots the new doc. + self._evict_quarantined_document() released = self._clear_quarantine("drawing_new") - self._doc_path = None - self._dirty = False - self._current_layer = "0" + key, _ = self._register_document(doc, None) self._reset_document_state() - self._current_space = "Model" self._reset_history_baseline() - result = {"ok": True, "name": "untitled.dxf"} + result = { + "ok": True, + "name": "untitled.dxf", + "document": key, + "open_documents": len(self._docs), + } if released is not None: result["quarantine_cleared"] = released.to_dict() return result @@ -1438,24 +1717,31 @@ class EzdxfBackend(AutoCADBackend): f"DXF file exceeds MAX_DXF_BYTES limit " f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override." ) - self._doc = ezdxf.readfile(path) - _normalise_dimstyle_rounding(self._doc) + doc = ezdxf.readfile(path) + _normalise_dimstyle_rounding(doc) # R32: only reached if the read succeeded. Reopening the very path a # runaway doc.saveas is writing either fails loudly here (acceptable) # or hands back a truncated document (not) — _clear_quarantine # compares the two paths and says so. + self._evict_quarantined_document() released = self._clear_quarantine("drawing_open", path) - self._doc_path = path - self._dirty = False + key, reloaded = self._register_document(doc, path) try: - self._current_layer = self._doc.header.get("$CLAYER", "0") + self._current_layer = doc.header.get("$CLAYER", "0") except Exception as exc: log.debug("reading $CLAYER from header: %s", exc) self._current_layer = "0" self._reset_document_state() - self._current_space = "Model" self._reset_history_baseline() - result = {"ok": True, "name": Path(path).name, "path": path} + result = { + "ok": True, + "name": Path(path).name, + "path": path, + "document": key, + "open_documents": len(self._docs), + } + if reloaded: + result["reloaded"] = True if released is not None: result["quarantine_cleared"] = released.to_dict() return result @@ -1873,6 +2159,56 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + # --------------------------------------------------------------------------- + # ── environment (track E) ─────────────────────────────────────────────────── + # --------------------------------------------------------------------------- + + async def document_list(self) -> list[dict]: + def _sync(): + return [self._document_row(key, state) for key, state in self._docs.items()] + + return await self._async(_sync) + + async def document_activate(self, name_or_path: str) -> dict: + if not isinstance(name_or_path, str): + raise TypeError( + f"document_activate: name_or_path must be a string, got {type(name_or_path).__name__}" + ) + + def _sync(): + key = self._resolve_document_key(name_or_path) + previous = self._active_key + self._activate_key(key) + state = self._docs[key] + return { + "ok": True, + "active": key, + "previous": previous, + "name": state.label, + "path": state.path, + } + + # Not a quarantine exit: a quarantined backend refuses to switch, because + # the runaway thread holds the one worker and the one orphaned lock. + return await self._async(_sync) + + async def document_close( + self, name_or_path: str | None = None, save: bool = False, discard: bool = False + ) -> dict: + if save and discard: + raise ValueError("document_close: save and discard are mutually exclusive") + # Resolve once outside the lock to decide whether this is the active + # document (then it is a quarantine exit, like drawing_close); the + # closure resolves again under the lock before touching the registry. + target = self._resolve_document_key(name_or_path) + closing_active = target == self._active_key + + def _sync(): + key = self._resolve_document_key(name_or_path) + return self._close_document_sync(key, save=save, discard=discard) + + return await self._async(_sync, quarantine_exit=closing_active) + # ── viewports ──────────────────────────────────────────────────────────── #: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM). @@ -2438,29 +2774,44 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def drawing_close(self, save: bool = True) -> dict: + """The active-document case of ``document_close``, with the 1.4 contract kept. + + ``save=True`` saves when the document has a path and is not quarantined. + An untitled or quarantined document is closed *without* saving and the + result says so (``saved: False``, ``discarded_changes``, ``warning``) — + the pre-registry behaviour two shipped tests pin. ``document_close`` is + the strict tool: it refuses instead of warning. + """ + def _sync(): + if self._active_key is None: + return { + "ok": True, + "closed": None, + "saved": False, + "active": None, + "open_documents": 0, + } + key = self._active_key + state = self._docs[key] # R32: closing is a way out of a quarantine, but saving on the way is # not. That save is the exact defect measured — a valid DXF holding # 15200 of the 60000 entities the runaway went on to write, a # considered document of a state that never existed. Say what was # dropped instead of writing it. quarantined = self._quarantine is not None - if save and self._dirty and self._doc_path and not quarantined: - self._doc.saveas(self._doc_path) - self._cleanup_undo_stack() - self._doc = None - released = self._clear_quarantine("drawing_close") - self._doc_path = None - self._dirty = False - self._reset_document_state() - result = {"ok": True} - if released is not None: - result["quarantine_cleared"] = released.to_dict() - result["saved"] = False + dirty = state.dirty + can_save = save and dirty and bool(state.path) and not quarantined + result = self._close_document_sync(key, save=can_save, discard=not can_save) + if save and dirty and not can_save: result["warning"] = ( "the document was quarantined after a call was abandoned mid-write, " "so unsaved changes were discarded rather than serialised out of a " "drawing another thread may still have been mutating" + if quarantined + else f"{key} had never been saved, so save=True had nowhere to write; " + "its changes were discarded. drawing_save_as(path) before closing, or " + "document_close(discard=True) to say so explicitly." ) return result @@ -5503,6 +5854,8 @@ class EzdxfBackend(AutoCADBackend): "connected": True, "has_document": has_doc, "document_path": self._doc_path, + "open_documents": len(self._docs), + "active_document": self._active_key, "unsaved_changes": self._dirty, "transaction_depth": len(self._transaction_stack), "quarantine": self._quarantine.to_dict() if self._quarantine else None, diff --git a/discovery/aliases.py b/discovery/aliases.py index e8b9af9..a089893 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -72,12 +72,15 @@ class ToolAliases: # PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run # between two ports (pid_line_draw), which is an LWPOLYLINE with a # class, a number and markers; a drafter typing it could mean either. +# CLOSE -- the active document (drawing_close) or one named document +# (document_close); a drafter typing it could mean either. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( { "ARRAY", "BLOCK", + "CLOSE", "ERASE", "INSERT", "LAYER", @@ -1427,6 +1430,42 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "loop number", ), ), + # ── Environment (track E, group V) ────────────────────────────────────── + "document_activate": ToolAliases( + acad=(), + synonyms=( + "switch drawing", + "switch to the other drawing", + "make this drawing active", + "go to the other open file", + "change the current document", + ), + ), + "document_close": ToolAliases( + acad=("CLOSE",), + synonyms=( + "close a drawing by name", + "close the other drawing", + "close without saving", + "discard changes and close", + "close all but this one", + ), + ), + # No "which ... are" / "which one is" phrasings here: the interrogative + # tokens are rare in the corpus, so two of them made this tool outrank + # `block_list` for the holdout "which blocks are defined". "drawings", + # "open", "active" and "documents" carry every document-list question + # to #1 on their own (measured 2026-09-16). + "document_list": ToolAliases( + acad=(), + synonyms=( + "open drawings", + "list documents", + "what files are open", + "the active drawing", + "currently open files", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 264451c..f934c03 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": 166, + "tools": 169, "resources": 8, "prompts": 5, "groups": 20 @@ -14,7 +14,7 @@ "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 11, + "drawing": 14, "engineering": 10, "entity_creation": 18, "entity_modification": 16, @@ -450,6 +450,46 @@ "readOnlyHint": false } }, + { + "name": "document_activate", + "group": "drawing", + "cost": "safe", + "summary": "Switch which open document every later tool call targets.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Activate Document", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "document_close", + "group": "drawing", + "cost": "destructive", + "summary": "Close one open document by name; never drops unsaved work unless told to.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Close Document", + "destructiveHint": true + } + }, + { + "name": "document_list", + "group": "drawing", + "cost": "read", + "summary": "List the open documents: which is active, which have unsaved changes.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "List Documents", + "readOnlyHint": true + } + }, { "name": "drawing_apply_iso_layers", "group": "premium", diff --git a/server.py b/server.py index 27a06b2..553b40d 100644 --- a/server.py +++ b/server.py @@ -1293,9 +1293,12 @@ async def drawing_close( save: Annotated[bool, "Save the drawing before closing"] = True, ctx: Context = None, ) -> dict: - """Close the current drawing. If save is True (default), the drawing is - saved to its current path before closing. After this call, you must call - drawing_new or drawing_open before any other tool.""" + """Close the active document. If save is True (default), the drawing is + saved to its current path before closing; an untitled document is closed + and `warning` says its changes were discarded (headless) or the call is + refused (live, where the alternative is a modal Save dialog). The + active-document case of `document_close`; after the last document is + closed, call drawing_new or drawing_open before any other tool.""" await ctx.info(f"Closing drawing (save={save})") return await _backend(ctx).drawing_close(save) @@ -6815,6 +6818,92 @@ async def pid_tag_parse( return parse_tag(tag, kind, equipment_prefixes) +# --------------------------------------------------------------------------- +# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="List the open documents: which is active, which have unsaved changes.", + cost="read", +) +@mcp.tool( + annotations={"title": "List Documents", "readOnlyHint": True}, + tags={"drawing"}, +) +async def document_list(ctx: Context = None) -> dict: + """Every open document with `name`, `path`, `active`, `saved` and + `entity_count`. + + Headless, the server keeps its own registry: `drawing_new` / `drawing_open` + add an entry (`untitled-N` or the file path) instead of replacing the only + one, and `document_activate` chooses which one every later tool targets. + Live, this is AutoCAD's `Documents` collection. No refusals: an empty + backend answers with an empty list. Pack: settings · lean: no. + """ + rows = await _backend(ctx).document_list() + return { + "documents": rows, + "count": len(rows), + "active": next((r["name"] for r in rows if r["active"]), None), + } + + +@cad_tool(summary="Switch which open document every later tool call targets.", cost="safe") +@mcp.tool( + annotations={"title": "Activate Document", "readOnlyHint": False, "destructiveHint": False}, + tags={"drawing"}, +) +async def document_activate( + name_or_path: Annotated[ + str, "A name from document_list (`gear.dxf`, `untitled-2`) or the full path" + ], + ctx: Context = None, +) -> dict: + """Make one open document the active one; reports `previous`. + + Matches the full path first, then the file name; an ambiguous name is + refused with the candidates, an unknown one with the list of open + documents. Headless, a document quarantined after an abandoned call cannot + be switched away from — `drawing_new`, `drawing_open` or closing it are + the ways out. Pack: settings · lean: no. + """ + await ctx.info(f"Activating document {name_or_path!r}") + return await _backend(ctx).document_activate(name_or_path) + + +@cad_tool( + summary="Close one open document by name; never drops unsaved work unless told to.", + cost="destructive", +) +@mcp.tool( + annotations={"title": "Close Document", "destructiveHint": True}, + tags={"drawing"}, +) +async def document_close( + name_or_path: Annotated[ + str | None, "A name from document_list or the full path; omit for the active document" + ] = None, + save: Annotated[bool, "Write unsaved changes to the document's own path first"] = False, + discard: Annotated[bool, "Close even with unsaved changes, dropping them"] = False, + ctx: Context = None, +) -> dict: + """Close a document and report `saved`, `discarded_changes` and the new `active`. + + Refusals, all before anything is closed: unsaved changes with neither + `save` nor `discard`; `save` on a document that has never been saved (no + path to write — live, `Close(True)` would open AutoCAD's Save dialog and + block the COM thread); `save` together with `discard`. Closing the last + document leaves the backend with none open, which every tool already + reports. `drawing_close` is the same operation for the active document + with its 1.4 contract kept. Pack: settings · lean: no. + """ + await ctx.info( + f"Closing document {name_or_path or '(active)'} (save={save}, discard={discard})" + ) + return await _backend(ctx).document_close(name_or_path, save, discard) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..a9abf45 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,361 @@ +"""Multi-document on both engines. + +The headless backend used to hold exactly one ``Drawing``: ``drawing_new`` +replaced it and silently discarded whatever was there. It now keeps a +registry keyed by path (or ``untitled-N``); every later tool call targets the +active entry, and ``drawing_close`` is the active-document case of +``document_close``. + +Two rules are pinned here. ``document_close`` never drops unsaved work +without being told to (``discard=True``), and it refuses ``save=True`` on a +document that has nowhere to write (an untitled one) instead of guessing a +path — on the live engine ``Close(True)`` on an untitled drawing opens +AutoCAD's Save dialog and blocks the COM thread until the timeout, so the +refusal is what keeps the server answerable. ``drawing_close`` keeps its 1.4 +contract (``save=True`` saves when it can; an untitled document is closed and +the result says the changes were discarded) because two shipped tests rely on +it; the strict path is the new tool. +""" + +from __future__ import annotations + +import os +import types + +import pytest +from fastmcp import Client + +import server +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_fixture_document_is_registered_and_active(backend): + rows = await backend.document_list() + assert rows == [ + { + "name": "untitled-1", + "path": None, + "key": "untitled-1", + "active": True, + "saved": True, + "entity_count": 0, + } + ] + + +async def test_two_documents_hold_separate_entities(backend): + first = (await backend.document_list())[0]["name"] + second = (await backend.drawing_new())["document"] + assert second == "untitled-2" + await backend.entity_create_circle(0, 0, 5) + + rows = {r["name"]: r for r in await backend.document_list()} + assert rows[second]["entity_count"] == 1 and rows[second]["active"] is True + assert rows[first]["entity_count"] == 0 and rows[first]["active"] is False + + switched = await backend.document_activate(first) + assert switched == { + "ok": True, + "active": first, + "previous": second, + "name": first, + "path": None, + } + assert (await backend.drawing_info()).entity_count == 0 + await backend.entity_create_line(0, 0, 1, 1) + await backend.document_activate(second) + assert (await backend.drawing_info()).entity_count == 1 + rows = {r["name"]: r for r in await backend.document_list()} + assert rows[first]["entity_count"] == 1 and rows[first]["saved"] is False + + +async def test_per_document_state_follows_the_active_entry(backend): + """Layer, current space, dirty flag and undo stack are per document.""" + await backend.layer_create("A-ONLY") + await backend.layer_set_current("A-ONLY") + await backend.drawing_new() + assert backend._current_layer == "0" + assert "A-ONLY" not in [lyr.name for lyr in await backend.layer_list()] + await backend.document_activate("untitled-1") + assert backend._current_layer == "A-ONLY" + assert backend._dirty is True + await backend.document_activate("untitled-2") + assert backend._dirty is False + + +async def test_opening_a_file_twice_reloads_it_instead_of_registering_twice(backend, tmp_path): + path = tmp_path / "part.dxf" + await backend.drawing_save(str(path)) + await backend.entity_create_circle(0, 0, 1) # unsaved edit + reopened = await backend.drawing_open(str(path)) + assert reopened["reloaded"] is True + assert reopened["open_documents"] == 1 + assert (await backend.drawing_info()).entity_count == 0, "reload reads the disk, not memory" + + +async def test_saving_rekeys_an_untitled_document_to_its_file(backend, tmp_path): + path = tmp_path / "gear.dxf" + await backend.drawing_save(str(path)) + rows = await backend.document_list() + assert rows[0]["key"] == os.path.abspath(str(path)) + assert rows[0]["name"] == "gear.dxf" and rows[0]["path"] == str(path) + assert backend._active_key == os.path.abspath(str(path)) + + +async def test_activate_by_basename_full_path_or_key(backend, tmp_path): + path = tmp_path / "gear.dxf" + await backend.drawing_save(str(path)) + key = os.path.abspath(str(path)) + await backend.drawing_new() + assert (await backend.document_activate("gear.dxf"))["active"] == key + await backend.document_activate("untitled-2") + assert (await backend.document_activate(str(path)))["name"] == "gear.dxf" + await backend.document_activate("untitled-2") + assert (await backend.document_activate(key))["previous"] == "untitled-2" + with pytest.raises(ValueError, match="no open document named 'nope'"): + await backend.document_activate("nope") + with pytest.raises(TypeError, match="name_or_path"): + await backend.document_activate(None) + + +async def test_close_unsaved_refuses_unless_discard(backend): + await backend.entity_create_line(0, 0, 1, 1) + with pytest.raises(ValueError, match="untitled-1.*unsaved changes"): + await backend.document_close("untitled-1") + assert len(await backend.document_list()) == 1, "a refusal closes nothing" + with pytest.raises(ValueError, match="never been saved"): + await backend.document_close("untitled-1", save=True) + with pytest.raises(ValueError, match="mutually exclusive"): + await backend.document_close("untitled-1", save=True, discard=True) + + result = await backend.document_close("untitled-1", discard=True) + assert result == { + "ok": True, + "closed": "untitled-1", + "saved": False, + "discarded_changes": True, + "active": None, + "open_documents": 0, + } + + +async def test_close_with_save_writes_the_file(backend, tmp_path): + path = tmp_path / "saved.dxf" + await backend.drawing_save(str(path)) + await backend.entity_create_circle(0, 0, 2) + before = path.read_bytes() + result = await backend.document_close(None, save=True) + assert result["saved"] is True and result["discarded_changes"] is False + assert path.read_bytes() != before + + +async def test_closing_the_last_document_leaves_the_no_document_state(backend): + await backend.document_close("untitled-1") + assert await backend.document_list() == [] + status = await backend.system_status() + assert status["has_document"] is False + assert status["open_documents"] == 0 and status["active_document"] is None + with pytest.raises(RuntimeError, match="No document open"): + await backend.drawing_info() + # ...and the backend recovers exactly as before. + assert (await backend.drawing_new())["document"] == "untitled-2" + + +async def test_closing_the_active_document_activates_the_most_recently_used(backend): + await backend.drawing_new() # untitled-2 + await backend.drawing_new() # untitled-3 + await backend.document_activate("untitled-1") + await backend.document_activate("untitled-3") + result = await backend.document_close("untitled-3") + assert result["active"] == "untitled-1", "most recently activated, not most recently created" + + +async def test_closing_a_background_document_keeps_the_active_one(backend): + await backend.drawing_new() # untitled-2, active + await backend.entity_create_circle(0, 0, 1) + result = await backend.document_close("untitled-1") + assert result["active"] == "untitled-2" + assert (await backend.drawing_info()).entity_count == 1 + + +async def test_drawing_close_keeps_its_legacy_contract(backend, tmp_path): + await backend.entity_create_line(0, 0, 1, 1) + closed = await backend.drawing_close(save=True) + assert closed["ok"] is True and closed["saved"] is False + assert closed["discarded_changes"] is True + assert "never been saved" in closed["warning"] + assert await backend.document_list() == [] + + await backend.drawing_new() + path = tmp_path / "keep.dxf" + await backend.drawing_save(str(path)) + await backend.entity_create_circle(0, 0, 3) + before = path.read_bytes() + closed = await backend.drawing_close(save=True) + assert closed["saved"] is True and "warning" not in closed + assert path.read_bytes() != before + + +async def test_disconnect_drops_every_document_and_its_snapshots(monkeypatch): + import config + + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 3) + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_new() + await backend.entity_create_line(0, 0, 1, 1) + await backend.drawing_new() + await backend.entity_create_line(0, 0, 2, 2) + snapshots = [p for state in backend._docs.values() for p in state.undo_stack] + assert len(snapshots) == 4 and all(p.exists() for p in snapshots) + await backend.disconnect() + assert backend._docs == {} and backend._active_key is None + assert not any(p.exists() for p in snapshots) + + +async def test_document_list_tool_over_the_wire(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {"bootstrap": False}) + await client.call_tool("drawing_new", {"bootstrap": False}) + payload = (await client.call_tool("document_list", {})).structured_content + assert payload["count"] == 2 and payload["active"] == "untitled-2" + closed = ( + await client.call_tool("document_close", {"name_or_path": "untitled-1"}) + ).structured_content + assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2" + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _FakeDoc: + def __init__(self, app, name, full_name, *, saved=True, count=0): + self._app = app + self.Name = name + self.FullName = full_name + self.Saved = saved + self.ModelSpace = types.SimpleNamespace(Count=count) + self.calls: list[tuple[str, tuple]] = [] + + def Activate(self): + self.calls.append(("Activate", ())) + self._app.ActiveDocument = self + + def Close(self, save_changes): + self.calls.append(("Close", (save_changes,))) + self._app.Documents._docs.remove(self) + docs = self._app.Documents._docs + self._app.ActiveDocument = docs[-1] if docs else None + + +class _FakeDocuments: + def __init__(self): + self._docs: list[_FakeDoc] = [] + + @property + def Count(self): + return len(self._docs) + + def Item(self, index): + return self._docs[index] + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + app = types.SimpleNamespace(Documents=_FakeDocuments(), ActiveDocument=None) + first = _FakeDoc(app, "First.dwg", "C:/work/First.dwg", saved=True, count=3) + second = _FakeDoc(app, "Second.dwg", "C:/work/Second.dwg", saved=False, count=1) + untitled = _FakeDoc(app, "Drawing1.dwg", "", saved=False, count=0) + app.Documents._docs.extend([first, second, untitled]) + app.ActiveDocument = first + monkeypatch.setattr(module, "_acad_app", lambda: app) + monkeypatch.setattr(module, "_acad_doc", lambda: app.ActiveDocument) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, app + + +async def test_com_document_list_reads_the_documents_collection(com_backend): + backend, app = com_backend + rows = await backend.document_list() + assert rows == [ + { + "name": "First.dwg", + "path": "C:/work/First.dwg", + "active": True, + "saved": True, + "entity_count": 3, + }, + { + "name": "Second.dwg", + "path": "C:/work/Second.dwg", + "active": False, + "saved": False, + "entity_count": 1, + }, + {"name": "Drawing1.dwg", "path": None, "active": False, "saved": False, "entity_count": 0}, + ] + + +async def test_com_activate_calls_activate_and_resets_document_scope(com_backend): + backend, app = com_backend + await backend._ensure_document_state() + backend._plan_spec = object() + result = await backend.document_activate("Second.dwg") + assert result == { + "ok": True, + "active": "Second.dwg", + "previous": "First.dwg", + "name": "Second.dwg", + "path": "C:/work/Second.dwg", + } + assert app.Documents._docs[1].calls == [("Activate", ())] + assert backend._plan_spec is None, "a document switch clears drawing-scoped state" + with pytest.raises(ValueError, match="no open document named 'Third.dwg'"): + await backend.document_activate("Third.dwg") + + +async def test_com_close_refuses_before_touching_activex(com_backend): + backend, app = com_backend + second = app.Documents._docs[1] + with pytest.raises(ValueError, match="Second.dwg.*unsaved changes"): + await backend.document_close("Second.dwg") + untitled = app.Documents._docs[2] + with pytest.raises(ValueError, match="never been saved"): + await backend.document_close("Drawing1.dwg", save=True) + assert second.calls == [] and untitled.calls == [] + + +async def test_com_close_passes_savechanges_through(com_backend): + backend, app = com_backend + first, second, untitled = app.Documents._docs + saved = await backend.document_close("Second.dwg", save=True) + assert second.calls == [("Close", (True,))] + assert saved["saved"] is True and saved["open_documents"] == 2 + dropped = await backend.document_close("Drawing1.dwg", discard=True) + assert untitled.calls == [("Close", (False,))] + assert dropped["discarded_changes"] is True and dropped["open_documents"] == 1 + clean = await backend.document_close(None) # the active, clean First.dwg + assert first.calls == [("Close", (False,))], "nothing to save, so nothing is asked to be saved" + assert clean["active"] is None + + +async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend): + backend, app = com_backend + app.ActiveDocument = app.Documents._docs[2] # the untitled one + with pytest.raises(ValueError, match="Save dialog"): + await backend.drawing_close(save=True) + assert app.Documents._docs[2].calls == [] diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 13ab419..29ee537 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -517,7 +517,9 @@ async def test_tool_group_sizes_are_unchanged(): when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool) joined; the `pid` tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under - `pid` rather than under their secondary `query` / `create` tags. Every + `pid` rather than under their secondary `query` / `create` tags. `drawing` + moved 11 -> 14 when v1.6's `document_list` / `document_activate` / + `document_close` (multi-document on both engines) opened SECTION 20. Every other number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} @@ -527,7 +529,7 @@ async def test_tool_group_sizes_are_unchanged(): "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 11, + "drawing": 14, "engineering": 10, "entity_creation": 18, "entity_modification": 16, @@ -543,4 +545,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 166 + assert sum(sizes.values()) == 169 From aeab950d2dfb03d4b2916809c1c0537b7184493e Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:36:44 +0300 Subject: [PATCH 02/12] =?UTF-8?q?fix(task-21):=20one=20registry=20entry=20?= =?UTF-8?q?per=20file=20=E2=80=94=20save/save=5Fas=20refuse=20a=20path=20a?= =?UTF-8?q?nother=20document=20holds;=20resolver=20refuses=20a=20shared=20?= =?UTF-8?q?path=20instead=20of=20guessing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drawing_save / drawing_save_as onto a file that another open entry holds used to succeed: the registry then carried two entries for one path, the resolver's exact-key shortcut picked the stale entry on document_close, and the file ended up holding the other document's bytes while both rows still reported saved: True. Both save paths now refuse before doc.saveas (as AutoCAD refuses SAVEAS onto an open drawing), the _doc_path setter raises rather than let two entries share a path, and _resolve_document_key refuses a shared file on every route — exact key, path and basename — naming the breach instead of advising "pass the full path" into the unchecked shortcut. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 86 ++++++++++++++++++++++++++++++++++++--- tests/test_documents.py | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index ba0b77e..d1496de 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1085,20 +1085,62 @@ class EzdxfBackend(AutoCADBackend): # `drawing_save` / `drawing_save_as` assign here. A document keyed # `untitled-N` that is saved to a file becomes keyed by that file, so # `document_list` shows one identity per document and `document_close` - # can be told the file name. Two entries may end up sharing a path - # (save_as onto a file that is open in another entry); the resolver - # then reports the ambiguity instead of guessing. - state = self._state - state.path = value + # can be told the file name. The registry holds one entry per file: + # `_refuse_path_held_elsewhere` turns a save onto a file that another + # entry holds away *before* the bytes are written (AutoCAD refuses + # SAVEAS onto an open drawing for the same reason), and this setter + # raises rather than let two entries share a path, because the + # resolver could then only guess which one a caller means. if self._active_key is None or value is None: + self._state.path = value return new_key = os.path.abspath(value) - if new_key != self._active_key and new_key not in self._docs: + holder = self._path_holder(new_key, exclude=self._active_key) + if holder is not None: + raise RuntimeError( + f"EzdxfBackend: registry entry {holder!r} already holds {new_key!r}; " + "a file is held by one open document" + ) + state = self._state + state.path = value + if new_key != self._active_key: del self._docs[self._active_key] state.key = new_key self._docs[new_key] = state self._active_key = new_key + def _path_holder(self, abs_path: str, *, exclude: str | None = None) -> str | None: + """Key of the entry (other than ``exclude``) that holds ``abs_path``, or None. + + Compared with ``os.path.normcase`` so that on Windows two spellings of + one file count as the same file; on POSIX that is plain equality. + """ + wanted = os.path.normcase(abs_path) + for key, state in self._docs.items(): + if key == exclude: + continue + candidates = [key] + if state.path: + candidates.append(os.path.abspath(state.path)) + if any(os.path.normcase(c) == wanted for c in candidates): + return key + return None + + def _refuse_path_held_elsewhere(self, op: str, path: str) -> None: + """Refuse ``op`` onto a file that another open document holds. + + Runs before ``doc.saveas`` so a refusal leaves the disk, the registry + and the active document exactly as they were. + """ + holder = self._path_holder(os.path.abspath(path), exclude=self._active_key) + if holder is None: + return + label = self._docs[holder].label + raise ValueError( + f"{op}: {label!r} is already open as document {holder!r}; a file is held by " + "one open document at a time - document_close it first, or save to another path" + ) + @property def name(self) -> str: return "ezdxf" @@ -1269,6 +1311,11 @@ class EzdxfBackend(AutoCADBackend): if not wanted: raise ValueError("name_or_path must not be empty") if wanted in self._docs: + # An exact key hit is still refused when a second entry holds the + # same file: the registry is meant to keep one entry per file (the + # save paths enforce it), and if that is ever broken a lookup must + # say so rather than hand back whichever entry the key names. + self._refuse_shared_path(wanted, self._docs[wanted]) return wanted try: as_path = os.path.abspath(wanted) @@ -1280,16 +1327,41 @@ class EzdxfBackend(AutoCADBackend): if state.path and os.path.abspath(state.path) == as_path ] if len(by_path) == 1: + self._refuse_shared_path(wanted, self._docs[by_path[0]]) return by_path[0] + if len(by_path) > 1: + raise ValueError( + f"document {wanted!r} is ambiguous: entries {by_path} share the file " + f"{as_path!r}; the registry must hold one entry per file" + ) by_name = [ key for key, state in self._docs.items() if state.label.lower() == wanted.lower() ] if len(by_name) == 1: + self._refuse_shared_path(wanted, self._docs[by_name[0]]) return by_name[0] if len(by_name) > 1: + # Same basename in two directories is a real ambiguity the full + # path settles; two entries on one file is the invariant breach, + # and "pass the full path" would send the caller to the exact-key + # shortcut that this resolver refuses for the same reason. + for key in by_name: + self._refuse_shared_path(wanted, self._docs[key]) raise ValueError(f"document {wanted!r} is ambiguous: {by_name}; pass the full path") raise ValueError(f"no open document named {wanted!r}; open documents: {list(self._docs)}") + def _refuse_shared_path(self, wanted: str, state: _DocState) -> None: + """Raise if any other entry holds the file ``state`` holds.""" + if not state.path: + return + abs_path = os.path.abspath(state.path) + other = self._path_holder(abs_path, exclude=state.key) + if other is not None: + raise ValueError( + f"document {wanted!r} is ambiguous: entries {sorted([state.key, other])} " + f"share the file {abs_path!r}; the registry must hold one entry per file" + ) + def _evict_quarantined_document(self) -> None: """Drop the quarantined entry before a fresh document is registered. @@ -1759,6 +1831,7 @@ class EzdxfBackend(AutoCADBackend): doc = self._require_doc() if not save_path: raise RuntimeError("No path specified and no current file path.") + self._refuse_path_held_elsewhere("drawing_save", save_path) doc.saveas(save_path) self._doc_path = save_path self._dirty = False @@ -1774,6 +1847,7 @@ class EzdxfBackend(AutoCADBackend): def _sync(): doc = self._require_doc() + self._refuse_path_held_elsewhere("drawing_save_as", path) doc.saveas(path) self._doc_path = path self._dirty = False diff --git a/tests/test_documents.py b/tests/test_documents.py index a9abf45..e0564c6 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -107,6 +107,78 @@ async def test_saving_rekeys_an_untitled_document_to_its_file(backend, tmp_path) assert backend._active_key == os.path.abspath(str(path)) +async def test_save_refuses_a_path_that_another_open_document_holds(backend, tmp_path): + """One entry per file: SAVEAS onto an open drawing is refused, as AutoCAD does. + + Without the refusal the registry held two entries for one path, the + resolver's exact-key shortcut picked the stale one on close, and the file + ended up holding the *other* document's bytes while ``saved`` stayed True. + """ + import ezdxf + + path = tmp_path / "gear.dxf" + await backend.drawing_save(str(path)) + key = os.path.abspath(str(path)) + await backend.entity_create_line(0, 0, 1, 1) # A: dirty, on disk empty + second = (await backend.drawing_new())["document"] + await backend.entity_create_circle(0, 0, 5) + before = path.read_bytes() + + for call in (backend.drawing_save_as, backend.drawing_save): + with pytest.raises(ValueError, match="gear.dxf.*already open.*document_close"): + await call(str(path)) + assert path.read_bytes() == before, "a refusal writes nothing" + + rows = {r["key"]: r for r in await backend.document_list()} + assert set(rows) == {key, second}, "a refusal re-keys nothing" + assert rows[second]["path"] is None and rows[second]["saved"] is False + assert rows[key]["saved"] is False + # The rejected document is untouched and can still be saved elsewhere. + other = tmp_path / "other.dxf" + await backend.drawing_save_as(str(other)) + assert [e.dxftype() for e in ezdxf.readfile(str(other)).modelspace()] == ["CIRCLE"] + # The by-name lookup is unambiguous and closes the entry that owns the file. + await backend.entity_create_line(2, 2, 3, 3) + closed = await backend.document_close("gear.dxf", save=True) + assert closed["closed"] == key and closed["saved"] is True + assert [e.dxftype() for e in ezdxf.readfile(str(path)).modelspace()] == ["LINE"] + + +async def test_save_to_the_document_own_path_is_not_a_collision(backend, tmp_path): + path = tmp_path / "self.dxf" + await backend.drawing_save(str(path)) + await backend.drawing_new() + await backend.document_activate("self.dxf") + await backend.entity_create_circle(0, 0, 1) + assert (await backend.drawing_save())["ok"] is True + await backend.entity_create_circle(0, 0, 2) + assert (await backend.drawing_save(str(path)))["ok"] is True + await backend.entity_create_circle(0, 0, 3) + # A spelling that differs from the key still names the same file. + spelled = str(tmp_path / "sub" / ".." / "self.dxf") + assert (await backend.drawing_save_as(spelled))["ok"] is True + assert len(await backend.document_list()) == 2 + assert backend._active_key == os.path.abspath(str(path)) + + +async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend, tmp_path): + """The save refusal keeps the registry one-entry-per-file; if that invariant + is ever broken anyway, every lookup of the pair refuses instead of guessing.""" + path = tmp_path / "dup.dxf" + await backend.drawing_save(str(path)) + key = os.path.abspath(str(path)) + second = (await backend.drawing_new())["document"] + with pytest.raises(RuntimeError, match="already holds"): + backend._doc_path = str(path) # the setter enforces the invariant + backend._docs[second].path = str(path) # corrupt the registry behind its back + for wanted in (key, second, "dup.dxf", str(path)): + with pytest.raises(ValueError, match="share the file"): + backend._resolve_document_key(wanted) + with pytest.raises(ValueError, match="share the file"): + await backend.document_close(key, save=True) + assert len(await backend.document_list()) == 2, "a refusal closes nothing" + + async def test_activate_by_basename_full_path_or_key(backend, tmp_path): path = tmp_path / "gear.dxf" await backend.drawing_save(str(path)) From 2805697d1682c42358a996cf2b957b5a1dfb9b94 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:51:16 +0300 Subject: [PATCH 03/12] =?UTF-8?q?fix(task-21):=20registry=20identity=20is?= =?UTF-8?q?=20normcase(abspath)=20everywhere=20=E2=80=94=20a=20case=20vari?= =?UTF-8?q?ant=20reloads=20instead=20of=20registering=20twice,=20resolves?= =?UTF-8?q?=20instead=20of=20being=20refused,=20and=20drawing=5Fclose(save?= =?UTF-8?q?)=20checks=20the=20file=20it=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aeab950 claimed one registry entry per file but only the two save paths compared with normcase. _register_document keyed by the plain os.path.abspath string, so on Windows drawing_open of an already-open file spelled with another case registered a second entry (no `reloaded`, open_documents: 2); from there every document_activate/close of either entry was refused as ambiguous, and drawing_close(save=True) — which bypasses the resolver — wrote the second entry's bytes to the shared file while the surviving row reported saved: True. The resolver's by_path route had the same plain-string comparison, so over the wire a lowercase drive letter was refused 'no open document named ...' while ComBackend._com_find_document lowercases and accepts it. One helper, _file_identity(path) = normcase(abspath(path)), now decides every registry comparison: _register_document consults _path_holder so another spelling of an open file reloads it; the _doc_path setter re-keys only when the file changes, so a case-variant save keeps the key a caller holds; _resolve_document_key's by_path compares identities; _close_document_sync refuses at the write when another entry holds the file; _clear_quarantine's same-path warning uses the same rule. Keys stay spelled as the caller spelled them — the identity is for comparing, not for showing. Five tests pin it (four skip on a case-sensitive filesystem, where two spellings of one file cannot exist), including the wire route through fastmcp.Client that the review measured. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 77 ++++++++++++++++++--------- tests/test_documents.py | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index d1496de..31fada4 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1103,26 +1103,48 @@ class EzdxfBackend(AutoCADBackend): ) state = self._state state.path = value - if new_key != self._active_key: + # Re-key only when the *file* changes: another spelling of the file + # the entry already holds (a case variant on Windows) keeps the key a + # caller may be holding. + if not self._same_file(new_key, self._active_key): del self._docs[self._active_key] state.key = new_key self._docs[new_key] = state self._active_key = new_key - def _path_holder(self, abs_path: str, *, exclude: str | None = None) -> str | None: - """Key of the entry (other than ``exclude``) that holds ``abs_path``, or None. + @staticmethod + def _file_identity(path: str) -> str: + """The string two spellings of one file share: ``normcase(abspath(path))``. - Compared with ``os.path.normcase`` so that on Windows two spellings of - one file count as the same file; on POSIX that is plain equality. + Every registry comparison goes through this. On Windows it folds case + and separators, so ``C:\\Work\\Gear.dxf`` and ``c:/work/gear.dxf`` are one + file; on POSIX ``normcase`` is the identity and only ``..`` / ``.`` / + duplicate separators are folded. Keys themselves stay spelled as the + caller spelled them - the identity is for comparing, not for showing. """ - wanted = os.path.normcase(abs_path) + try: + return os.path.normcase(os.path.abspath(path)) + except (OSError, ValueError): # a path this OS cannot normalise + return os.path.normcase(path) + + @classmethod + def _same_file(cls, a: str, b: str) -> bool: + return cls._file_identity(a) == cls._file_identity(b) + + def _path_holder(self, path: str, *, exclude: str | None = None) -> str | None: + """Key of the entry (other than ``exclude``) that holds ``path``, or None. + + Compared by ``_file_identity`` so that on Windows two spellings of one + file count as the same file; on POSIX that is plain equality. + """ + wanted = self._file_identity(path) for key, state in self._docs.items(): if key == exclude: continue candidates = [key] if state.path: - candidates.append(os.path.abspath(state.path)) - if any(os.path.normcase(c) == wanted for c in candidates): + candidates.append(state.path) + if any(self._file_identity(c) == wanted for c in candidates): return key return None @@ -1132,7 +1154,7 @@ class EzdxfBackend(AutoCADBackend): Runs before ``doc.saveas`` so a refusal leaves the disk, the registry and the active document exactly as they were. """ - holder = self._path_holder(os.path.abspath(path), exclude=self._active_key) + holder = self._path_holder(path, exclude=self._active_key) if holder is None: return label = self._docs[holder].label @@ -1273,7 +1295,10 @@ class EzdxfBackend(AutoCADBackend): Returns ``(key, reloaded)``. A path that is already open is *reloaded*: its old entry (and its snapshots) is dropped and the fresh read takes its place — the pre-registry behaviour of ``drawing_open``, kept so a - save-then-reopen verification still reads the disk. + save-then-reopen verification still reads the disk. "Already open" is + decided by ``_path_holder`` (file identity), not by the key string: + the same file spelled with another case used to register a second + entry, after which every lookup of either was refused as ambiguous. """ if path is None: self._untitled_counter += 1 @@ -1281,10 +1306,12 @@ class EzdxfBackend(AutoCADBackend): reloaded = False else: key = os.path.abspath(path) - previous = self._docs.pop(key, None) - reloaded = previous is not None - if previous is not None: - previous.discard_snapshots() + previous_key = self._path_holder(key) + reloaded = previous_key is not None + if previous_key is not None: + self._docs.pop(previous_key).discard_snapshots() + if self._active_key == previous_key: + self._active_key = None self._docs[key] = _DocState(key=key, doc=doc, path=path) self._activate_key(key) return key, reloaded @@ -1317,14 +1344,14 @@ class EzdxfBackend(AutoCADBackend): # say so rather than hand back whichever entry the key names. self._refuse_shared_path(wanted, self._docs[wanted]) return wanted - try: - as_path = os.path.abspath(wanted) - except (OSError, ValueError): - as_path = wanted + # Paths compare by file identity (``normcase`` on Windows), as the + # live engine's ``_com_find_document`` lowercases before comparing: a + # lowercase drive letter or a case variant names the same document. + as_path = self._file_identity(wanted) by_path = [ key for key, state in self._docs.items() - if state.path and os.path.abspath(state.path) == as_path + if state.path and self._file_identity(state.path) == as_path ] if len(by_path) == 1: self._refuse_shared_path(wanted, self._docs[by_path[0]]) @@ -1355,7 +1382,7 @@ class EzdxfBackend(AutoCADBackend): if not state.path: return abs_path = os.path.abspath(state.path) - other = self._path_holder(abs_path, exclude=state.key) + other = self._path_holder(state.path, exclude=state.key) if other is not None: raise ValueError( f"document {wanted!r} is ambiguous: entries {sorted([state.key, other])} " @@ -1407,6 +1434,11 @@ class EzdxfBackend(AutoCADBackend): f"document_close: {label!r} has never been saved, so save=True has " "nowhere to write; drawing_save_as(path) first, or pass discard=True" ) + # ``drawing_close`` reaches here without the resolver, so the + # one-entry-per-file check is made at the write itself: a + # save onto a file another entry holds would leave that + # entry claiming ``saved: True`` over someone else's bytes. + self._refuse_shared_path(label, state) state.doc.saveas(state.path) state.dirty = False saved = True @@ -1554,10 +1586,7 @@ class EzdxfBackend(AutoCADBackend): record.call, ) if new_path and record.document_path: - try: - same = os.path.abspath(new_path) == os.path.abspath(record.document_path) - except (OSError, ValueError): # a path this OS cannot normalise - same = new_path == record.document_path + same = self._same_file(new_path, record.document_path) if same: log.warning( "%s is reopening %s, the same path the abandoned %r call was working " diff --git a/tests/test_documents.py b/tests/test_documents.py index e0564c6..7b3b600 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -179,6 +179,91 @@ async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend, assert len(await backend.document_list()) == 2, "a refusal closes nothing" +# Two spellings of one file only exist where the filesystem folds case; +# ``os.path.normcase`` is the identity on POSIX, so these tests skip there. +_case_folding_fs = pytest.mark.skipif( + os.path.normcase("A") == "A", reason="filesystem is case-sensitive" +) + + +@_case_folding_fs +async def test_opening_a_case_variant_of_an_open_file_reloads_it(backend, tmp_path): + """Registration keys by file identity, not by spelling. + + ``drawing_open`` of an already-open file spelled with another case used to + register a *second* entry (key compared as plain strings), and from there + every lookup of either entry was refused as ambiguous. + """ + path = tmp_path / "Gear.dxf" + await backend.drawing_save(str(path)) + await backend.entity_create_circle(0, 0, 5) # unsaved edit + reopened = await backend.drawing_open(str(path).lower()) + assert reopened["reloaded"] is True + assert reopened["open_documents"] == 1 + rows = await backend.document_list() + assert len(rows) == 1 and rows[0]["entity_count"] == 0, "reload reads the disk" + assert (await backend.document_activate(str(path)))["ok"] is True + + +@_case_folding_fs +async def test_resolver_accepts_any_spelling_of_a_registered_path(backend, tmp_path): + """``document_activate`` / ``document_close`` compare paths as the OS does. + + The live engine lowercases before comparing (``_com_find_document``); a + lowercase drive letter or a case variant used to be refused headless as + 'no open document named ...' - a dual-engine parity break. + """ + path = tmp_path / "Gear.dxf" + await backend.drawing_save(str(path)) + key = os.path.abspath(str(path)) + await backend.drawing_new() + spelled = str(path) + for variant in ( + spelled.lower(), + spelled[0].lower() + spelled[1:], + spelled[0].upper() + spelled[1:], + spelled.upper(), + ): + await backend.document_activate("untitled-2") + assert (await backend.document_activate(variant))["active"] == key, variant + assert len(await backend.document_list()) == 2 + closed = await backend.document_close(spelled.lower()) + assert closed["closed"] == key and closed["open_documents"] == 1 + + +@_case_folding_fs +async def test_saving_to_a_case_variant_of_the_own_path_keeps_one_entry(backend, tmp_path): + path = tmp_path / "Self.dxf" + await backend.drawing_save(str(path)) + key = os.path.abspath(str(path)) + await backend.entity_create_circle(0, 0, 1) + assert (await backend.drawing_save_as(str(path).lower()))["ok"] is True + rows = await backend.document_list() + assert len(rows) == 1 and rows[0]["saved"] is True + assert backend._active_key == key, "the same file keeps its key" + assert (await backend.document_activate(key))["ok"] is True + + +async def test_drawing_close_with_save_refuses_a_shared_path(backend, tmp_path): + """``drawing_close`` bypasses the resolver, so the close itself must check. + + With two entries on one file, ``drawing_close(save=True)`` wrote the active + entry's bytes over the file the other entry holds and reported + ``saved: True`` - the surviving row then claimed the disk held its content. + """ + path = tmp_path / "dup.dxf" + await backend.drawing_save(str(path)) + await backend.entity_create_line(0, 0, 1, 1) # the owner is dirty + await backend.drawing_new() + await backend.entity_create_circle(0, 0, 5) + backend._docs[backend._active_key].path = str(path) # corrupt the registry behind its back + before = path.read_bytes() + with pytest.raises(ValueError, match="share the file"): + await backend.drawing_close(save=True) + assert path.read_bytes() == before, "a refusal writes nothing" + assert len(await backend.document_list()) == 2, "a refusal closes nothing" + + async def test_activate_by_basename_full_path_or_key(backend, tmp_path): path = tmp_path / "gear.dxf" await backend.drawing_save(str(path)) @@ -303,6 +388,30 @@ async def test_document_list_tool_over_the_wire(monkeypatch): assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2" +@_case_folding_fs +async def test_document_activate_accepts_a_lowercase_drive_over_the_wire(monkeypatch, tmp_path): + """The wire canonicalises ``drawing_open`` paths (``validate_path`` resolves + them to disk casing) but not ``document_activate`` / ``document_close`` + names, so a client spelling the drive letter in lowercase used to be + refused headless while the live engine accepted it.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + path = tmp_path / "Gear.dxf" + spelled = str(path) + lowered_drive = spelled[0].lower() + spelled[1:] + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {"bootstrap": False}) + await client.call_tool("drawing_save", {"path": spelled}) + await client.call_tool("drawing_new", {"bootstrap": False}) + activated = ( + await client.call_tool("document_activate", {"name_or_path": lowered_drive}) + ).structured_content + assert activated["name"] == "Gear.dxf" and activated["previous"] == "untitled-2" + closed = ( + await client.call_tool("document_close", {"name_or_path": spelled.lower()}) + ).structured_content + assert closed["closed"] == os.path.abspath(spelled) and closed["open_documents"] == 1 + + # ── live engine, against a fake ActiveX surface ────────────────────────────── From dcc8f07bf4993b2e59c9dd210ae34b2de7372eec Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:47:39 +0300 Subject: [PATCH 04/12] feat(environment): portable layer states, named views and UCS on both engines Layer states are ACADMCP_LAYERSTATES XRECORD chunks (<= 255 ASCII chars each), in the file and not in AutoCAD's Layer States Manager. Headless view restore writes the *Active VPORT (ezdxf's header has no $VIEWCTR; measured). UCS is stored and made current; tool coordinates stay WCS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 427 +++++++++++++++++++++++ backends/contracts/environment.py | 55 ++- backends/ezdxf_backend.py | 440 +++++++++++++++++++++++ discovery/aliases.py | 84 +++++ docs/tool-inventory.json | 142 +++++++- engineering/environment/__init__.py | 16 + engineering/environment/layer_states.py | 122 +++++++ engineering/environment/names.py | 17 + engineering/environment/ucs.py | 54 +++ engineering/environment/views.py | 32 ++ server.py | 203 ++++++++++- tests/test_layer_states.py | 444 ++++++++++++++++++++++++ tests/test_tool_registry.py | 11 +- tests/test_views_ucs.py | 338 ++++++++++++++++++ 15 files changed, 2379 insertions(+), 12 deletions(-) create mode 100644 engineering/environment/__init__.py create mode 100644 engineering/environment/layer_states.py create mode 100644 engineering/environment/names.py create mode 100644 engineering/environment/ucs.py create mode 100644 engineering/environment/views.py create mode 100644 tests/test_layer_states.py create mode 100644 tests/test_views_ucs.py diff --git a/README.md b/README.md index 7ba7a61..cf9052d 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests. -> 169 is the **registered** count; a default install advertises 164 over `tools/list`, +> **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> 179 is the **registered** count; a default install advertises 174 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog | | Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools | | Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` | -| Environment | `document_list/activate/close` — several drawings open at once on both engines, the active one is what every tool targets | +| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)* | | Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box | | Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* | | Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle | diff --git a/backends/com_backend.py b/backends/com_backend.py index 2ab9467..4474c2a 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -132,6 +132,14 @@ def _ai(values: list[int]): ) +def _avar(values): + """Create a VARIANT array of VARIANTs (what SetXRecordData takes for its values).""" + return win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, + list(values), + ) + + def _solid_3d_capability() -> FeatureCapability: """3D solids are opt-in (ENABLE_3D=true) even on the live COM backend.""" if config.settings.enable_3d: @@ -815,6 +823,17 @@ class ComBackend(AutoCADBackend): "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), "documents": FeatureCapability(True, "native"), + # Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads + # and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim. + "layer_states": FeatureCapability( + True, + "xrecord", + reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager", + ), + "named_views": FeatureCapability(True, "native"), + "ucs": FeatureCapability( + True, "native", reason="world_restore_via_ucs_command;tool_coordinates_stay_wcs" + ), "live_application": FeatureCapability(True, "native"), "preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"), "interactive_prompt": FeatureCapability( @@ -1403,6 +1422,414 @@ class ComBackend(AutoCADBackend): await self._ensure_document_state() return result + # ── layer states ────────────────────────────────────────────────────────── + + @staticmethod + def _com_layer_states(doc, *, create: bool): + from engineering.environment.layer_states import DICT_NAME + + try: + return doc.Dictionaries.Item(DICT_NAME) + except Exception as exc: # absent + log.debug("Dictionaries.Item(%s): %s", DICT_NAME, exc) + if not create: + return None + return doc.Dictionaries.Add(DICT_NAME) + + @staticmethod + def _com_dict_entries(states) -> list[tuple[str, Any]]: + if states is None: + return [] + out = [] + for index in range(int(states.Count)): + obj = states.Item(index) + out.append((str(states.GetName(obj)), obj)) + return out + + def _com_layer_state_entry(self, states, name: str): + for key, obj in self._com_dict_entries(states): + if key.lower() == name.lower(): + return key, obj + return None, None + + @staticmethod + def _com_xrecord_chunks(xrecord) -> list[str]: + # pywin32 returns the two [out] parameters as a tuple, exactly as + # GetBoundingBox() / GetXData() do elsewhere in this file. + codes, values = xrecord.GetXRecordData() + return [ + str(value) + for code, value in zip(list(codes or []), list(values or []), strict=False) + if int(code) == 1000 + ] + + @staticmethod + def _com_layer_snapshot(doc, description): + from engineering.environment.layer_states import snapshot_from_layers + + current = str(doc.ActiveLayer.Name) + layers, plot = [], {} + for index in range(int(doc.Layers.Count)): + lyr = doc.Layers.Item(index) + layers.append(_layer_info(lyr, current)) + plot[str(lyr.Name)] = bool(lyr.Plottable) + return snapshot_from_layers(layers, current, plot=plot, description=description) + + async def layer_state_save(self, name, description=None) -> dict: + from engineering.environment.layer_states import encode_state, validate_state_name + + clean = validate_state_name(name) + if description is not None and not isinstance(description, str): + raise TypeError("layer_state_save: description must be a string") + + def _sync(): + doc = _acad_doc() + state = self._com_layer_snapshot(doc, description) + chunks = encode_state(state) + states = self._com_layer_states(doc, create=True) + key, xrecord = self._com_layer_state_entry(states, clean) + replaced = xrecord is not None + if xrecord is None: + xrecord = states.AddXRecord(clean) + key = clean + xrecord.SetXRecordData(_ai([1000] * len(chunks)), _avar(chunks)) + return { + "ok": True, + "name": key, + "layer_count": len(state["layers"]), + "replaced": replaced, + "chunks": len(chunks), + "backend": "com", + } + + return await self._run(_sync) + + async def layer_state_restore(self, name, properties=None) -> dict: + from engineering.environment.layer_states import ( + decode_state, + diff_snapshot, + validate_properties, + validate_state_name, + ) + + clean = validate_state_name(name) + props = validate_properties(properties) + + def _sync(): + doc = _acad_doc() + states = self._com_layer_states(doc, create=False) + key, xrecord = self._com_layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_restore: no layer state named {clean!r}; " + f"saved states: {[k for k, _ in self._com_dict_entries(states)]}" + ) + state = decode_state(self._com_xrecord_chunks(xrecord)) + current_name = str(doc.ActiveLayer.Name) + layers = [ + _layer_info(doc.Layers.Item(i), current_name) for i in range(int(doc.Layers.Count)) + ] + missing, new = diff_snapshot(state, layers) + current = None + warnings: list[str] = [] + # Current first: AutoCAD refuses to freeze the active layer, so the + # active one must already be the state's before the loop freezes. + if "current" in props and state["current_layer"] not in missing: + doc.ActiveLayer = doc.Layers.Item(state["current_layer"]) + current = state["current_layer"] + applied = 0 + for layer_name, snap in state["layers"].items(): + if layer_name in missing: + continue + lyr = doc.Layers.Item(layer_name) + try: + if "color" in props: + lyr.Color = int(snap["color"]) + if "linetype" in props: + _ensure_linetype_loaded(snap["linetype"]) + lyr.Linetype = snap["linetype"] + if "lineweight" in props: + lyr.LineWeight = int(snap["lineweight"]) + if "plot" in props: + lyr.Plottable = bool(snap["plot"]) + if "on" in props: + lyr.LayerOn = bool(snap["on"]) + if "frozen" in props: + lyr.Freeze = bool(snap["frozen"]) + if "locked" in props: + lyr.Lock = bool(snap["locked"]) + except Exception as exc: # e.g. freezing the active layer + warnings.append(f"{layer_name}: {exc}") + continue + applied += 1 + _regen() + result = { + "ok": True, + "name": key, + "applied": { + "layers": applied, + "properties": list(props), + "current_layer": current, + }, + "missing_layers": missing, + "new_layers": new, + "backend": "com", + } + if warnings: + result["warnings"] = warnings + return result + + return await self._run(_sync) + + async def layer_state_list(self) -> list[dict]: + from engineering.environment.layer_states import decode_state + + def _sync(): + doc = _acad_doc() + states = self._com_layer_states(doc, create=False) + rows = [] + for key, xrecord in self._com_dict_entries(states): + state = decode_state(self._com_xrecord_chunks(xrecord)) + rows.append( + { + "name": key, + "description": state.get("description"), + "layer_count": len(state["layers"]), + } + ) + return rows + + return await self._run(_sync) + + async def layer_state_delete(self, name) -> dict: + from engineering.environment.layer_states import validate_state_name + + clean = validate_state_name(name) + + def _sync(): + doc = _acad_doc() + states = self._com_layer_states(doc, create=False) + key, xrecord = self._com_layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_delete: no layer state named {clean!r}; " + f"saved states: {[k for k, _ in self._com_dict_entries(states)]}" + ) + removed = states.Remove(key) + try: + removed.Delete() + except Exception as exc: # Remove already detached it + log.debug("XRecord.Delete after Remove: %s", exc) + return {"ok": True, "deleted": key, "backend": "com"} + + return await self._run(_sync) + + # ── named views ─────────────────────────────────────────────────────────── + + @staticmethod + def _com_view(doc, name: str): + try: + return doc.Views.Item(name) + except Exception: + return None + + @staticmethod + def _com_view_row(view) -> dict: + center = view.Center + return { + "name": str(view.Name), + "center": [float(center[0]), float(center[1])], + "height": float(view.Height), + "width": float(view.Width), + } + + async def view_named_save(self, name, center=None, height=None, width=None) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.views import resolve_view_args + + clean = validate_name(name, what="view name") + args = resolve_view_args(center, height, width) + + def _sync(): + doc = _acad_doc() + vport = doc.ActiveViewport + vp_center = vport.Center + vp_height = float(vport.Height) or 1.0 + aspect = float(vport.Width) / vp_height if vp_height else 1.5 + cx, cy = args["center"] or (float(vp_center[0]), float(vp_center[1])) + h = args["height"] or vp_height + w = args["width"] or h * aspect + view = self._com_view(doc, clean) + replaced = view is not None + if view is None: + view = doc.Views.Add(clean) + view.Center = _av([cx, cy]) + view.Height = float(h) + view.Width = float(w) + return { + "ok": True, + "name": str(view.Name), + "center": [float(cx), float(cy)], + "height": float(h), + "width": float(w), + "replaced": replaced, + "backend": "com", + } + + return await self._run(_sync) + + async def view_named_restore(self, name) -> dict: + from engineering.environment.names import validate_name + + clean = validate_name(name, what="view name") + + def _sync(): + doc = _acad_doc() + view = self._com_view(doc, clean) + if view is None: + names = [str(doc.Views.Item(i).Name) for i in range(int(doc.Views.Count))] + raise ValueError( + f"view_named_restore: no named view {clean!r}; saved views: {names}" + ) + row = self._com_view_row(view) + vport = doc.ActiveViewport + vport.Center = _av(row["center"]) + vport.Height = row["height"] + vport.Width = row["width"] + # ActiveX applies viewport changes only when the object is assigned back. + doc.ActiveViewport = vport + _regen() + return {"ok": True, **row, "applied": "active_viewport", "backend": "com"} + + return await self._run(_sync) + + async def view_named_list(self) -> list[dict]: + def _sync(): + doc = _acad_doc() + return [self._com_view_row(doc.Views.Item(i)) for i in range(int(doc.Views.Count))] + + return await self._run(_sync) + + # ── UCS ─────────────────────────────────────────────────────────────────── + + @staticmethod + def _com_ucs(doc, name: str): + try: + return doc.UserCoordinateSystems.Item(name) + except Exception: + return None + + @staticmethod + def _com_ucs_row(ucs, current: str) -> dict: + return { + "name": str(ucs.Name), + "origin": [float(c) for c in ucs.Origin], + "x_axis": [float(c) for c in ucs.XVector], + "y_axis": [float(c) for c in ucs.YVector], + "current": current != "" and str(ucs.Name).lower() == current.lower(), + } + + async def ucs_list(self) -> list[dict]: + from engineering.environment.ucs import WORLD + + def _sync(): + app = _acad_app() + doc = _acad_doc() + current = str(app.GetVariable("UCSNAME") or "") + rows = [ + { + "name": WORLD, + "origin": [0.0, 0.0, 0.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "current": current == "", + } + ] + collection = doc.UserCoordinateSystems + for index in range(int(collection.Count)): + rows.append(self._com_ucs_row(collection.Item(index), current)) + return rows + + return await self._run(_sync) + + async def ucs_set(self, name, origin, x_axis, y_axis) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD, resolve_ucs_axes + + clean = validate_name(name, what="UCS name") + if clean.lower() == WORLD: + raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS") + axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any ActiveX call + + def _sync(): + doc = _acad_doc() + o, x, y = axes["origin"], axes["x_axis"], axes["y_axis"] + ucs = self._com_ucs(doc, clean) + replaced = ucs is not None + if ucs is None: + # ActiveX takes POINTS on the axes, not direction vectors. + ucs = doc.UserCoordinateSystems.Add( + _apoint(*o), + _apoint(o[0] + x[0], o[1] + x[1], o[2] + x[2]), + _apoint(o[0] + y[0], o[1] + y[1], o[2] + y[2]), + clean, + ) + else: + ucs.Origin = _apoint(*o) + ucs.XVector = _apoint(*x) + ucs.YVector = _apoint(*y) + doc.ActiveUCS = ucs + return { + "ok": True, + "name": str(ucs.Name), + "origin": list(o), + "x_axis": list(x), + "y_axis": list(y), + "replaced": replaced, + "current": True, + "backend": "com", + } + + return await self._run(_sync) + + async def ucs_restore(self, name) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD + + clean = validate_name(name, what="UCS name") + + def _sync(): + app = _acad_app() + doc = _acad_doc() + if clean.lower() == WORLD: + # No ActiveX member selects WCS; the command does. Same guard as + # system_run_command: never send into an active prompt. + try: + cmd_active = int(app.GetVariable("CMDACTIVE")) + except Exception as exc: + log.debug("CMDACTIVE read failed, proceeding: %s", exc) + cmd_active = 0 + if cmd_active: + raise RuntimeError( + "AutoCAD has an active command or prompt (CMDACTIVE=" + f"{cmd_active}). Press ESC in AutoCAD to cancel, then retry." + ) + doc.SendCommand("_.UCS _W\n") + return {"ok": True, "name": WORLD, "current": True, "backend": "com"} + ucs = self._com_ucs(doc, clean) + if ucs is None: + names = [ + str(doc.UserCoordinateSystems.Item(i).Name) + for i in range(int(doc.UserCoordinateSystems.Count)) + ] + raise ValueError( + f"ucs_restore: no UCS named {clean!r}; saved: {names} (or 'world')" + ) + doc.ActiveUCS = ucs + return {"ok": True, "name": str(ucs.Name), "current": True, "backend": "com"} + + return await self._run(_sync) + # ── selection filters (M8 / F1) ────────────────────────────────────────── # # VERIFIED against a live AutoCAD 2026 (2026-08-05). diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py index 00b294a..24e8f70 100644 --- a/backends/contracts/environment.py +++ b/backends/contracts/environment.py @@ -44,7 +44,60 @@ class EnvironmentContract(ABC): """ ... - # ── layer states, named views, UCS: appended by Task 22 ────────────────── + # ── layer states (portable ACADMCP_LAYERSTATES XRECORDs, both engines) ── + + @abstractmethod + async def layer_state_save(self, name: str, description: str | None = None) -> dict: + """→ ``{"ok", "name", "layer_count", "replaced"}``.""" + ... + + @abstractmethod + async def layer_state_restore(self, name: str, properties: list[str] | None = None) -> dict: + """``properties`` ⊆ {"on","frozen","locked","color","linetype","lineweight","plot","current"}; + → ``{"ok", "name", "applied", "missing_layers", "new_layers"}``.""" + ... + + @abstractmethod + async def layer_state_list(self) -> list[dict]: + """→ ``[{"name", "description", "layer_count"}]``.""" + ... + + @abstractmethod + async def layer_state_delete(self, name: str) -> dict: ... + + # ── named views ───────────────────────────────────────────────────────── + + @abstractmethod + async def view_named_save( + self, name: str, center=None, height: float | None = None, width: float | None = None + ) -> dict: + """→ ``{"ok", "name", "center": [x, y], "height", "width", "replaced"}``.""" + ... + + @abstractmethod + async def view_named_restore(self, name: str) -> dict: + """ezdxf: sets the ``*Active`` VPORT, ``"applied": "header_only"``.""" + ... + + @abstractmethod + async def view_named_list(self) -> list[dict]: ... + + # ── UCS (stored and made current; tool coordinates stay WCS) ──────────── + + @abstractmethod + async def ucs_list(self) -> list[dict]: + """→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world".""" + ... + + @abstractmethod + async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict: + """non-orthogonal → ``ValueError`` with the measured angle.""" + ... + + @abstractmethod + async def ucs_restore(self, name: str) -> dict: + """``"world"`` resets.""" + ... # ── application, preferences, operator prompts (COM only) ─────────────── diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 31fada4..73b3339 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1246,6 +1246,21 @@ class EzdxfBackend(AutoCADBackend): ), "lisp": FeatureCapability(False, reason="live_com_only"), "documents": FeatureCapability(True, "registry"), + # Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads + # and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim. + "layer_states": FeatureCapability( + True, + "xrecord", + reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager", + ), + "named_views": FeatureCapability( + True, + "vport_active", + reason="restore_sets_the_view_the_file_opens_on;no_live_display", + ), + "ucs": FeatureCapability( + True, "stored_not_interpreted", reason="tool_coordinates_stay_wcs" + ), # Track E keys. `@capability` registers its key at import, and # tests/test_capability_contract.py checks both maps from that # moment on — so every key the contract module uses is declared @@ -2312,6 +2327,431 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync, quarantine_exit=closing_active) + # ── layer states ────────────────────────────────────────────────────────── + + @staticmethod + def _layer_state_entry(states, name: str): + """Case-insensitive lookup → ``(stored_key, xrecord)`` or ``(None, None)``.""" + if states is None: + return None, None + if name in states: + return name, states.get(name) + for key in states.keys(): + if key.lower() == name.lower(): + return key, states.get(key) + return None, None + + @staticmethod + def _layer_state_chunks(xrecord) -> list[str]: + return [str(tag.value) for tag in xrecord.tags if tag.code == 1000] + + @staticmethod + def _layer_states(doc): + from engineering.environment.layer_states import DICT_NAME + + return doc.rootdict.get(DICT_NAME) if DICT_NAME in doc.rootdict else None + + @staticmethod + def _layer_state_names(states) -> list[str]: + return list(states.keys()) if states is not None else [] + + def _layer_state_snapshot(self, doc, description): + from engineering.environment.layer_states import snapshot_from_layers + + layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] + plot = {lyr.dxf.name: bool(lyr.dxf.get("plot", 1)) for lyr in doc.layers} + return snapshot_from_layers(layers, self._current_layer, plot=plot, description=description) + + async def layer_state_save(self, name, description=None) -> dict: + """Snapshot the layer table into ``ACADMCP_LAYERSTATES/``. + + Portable and file-resident; not an AutoCAD Layer States Manager entry. + """ + from engineering.environment.layer_states import ( + DICT_NAME, + encode_state, + validate_state_name, + ) + + clean = validate_state_name(name) + if description is not None and not isinstance(description, str): + raise TypeError("layer_state_save: description must be a string") + + def _sync(): + doc = self._require_doc() + state = self._layer_state_snapshot(doc, description) + chunks = encode_state(state) + states = doc.rootdict.get_required_dict(DICT_NAME) + existing_key, _ = self._layer_state_entry(states, clean) + replaced = existing_key is not None + if replaced: + states.remove(existing_key) + xrecord = states.add_xrecord(clean) + xrecord.extend([(1000, chunk) for chunk in chunks]) + self._mark_dirty() + return { + "ok": True, + "name": clean, + "layer_count": len(state["layers"]), + "replaced": replaced, + "chunks": len(chunks), + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def layer_state_restore(self, name, properties=None) -> dict: + from engineering.environment.layer_states import ( + decode_state, + diff_snapshot, + validate_properties, + validate_state_name, + ) + + clean = validate_state_name(name) + props = validate_properties(properties) + + def _sync(): + doc = self._require_doc() + states = self._layer_states(doc) + key, xrecord = self._layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_restore: no layer state named {clean!r}; " + f"saved states: {self._layer_state_names(states)}" + ) + state = decode_state(self._layer_state_chunks(xrecord)) + layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] + missing, new = diff_snapshot(state, layers) + applied = 0 + for layer_name, snap in state["layers"].items(): + if layer_name not in doc.layers: + continue # missing: reported, never created + lyr = doc.layers.get(layer_name) + # Colour first: `Layer.color` keeps the on/off sign, and the + # on/off call below must see the final colour. + if "color" in props: + lyr.color = int(snap["color"]) + if "linetype" in props: + _ensure_linetype_loaded(doc, snap["linetype"]) + lyr.dxf.linetype = snap["linetype"] + if "lineweight" in props: + lyr.dxf.lineweight = int(snap["lineweight"]) + if "plot" in props: + lyr.dxf.plot = 1 if snap["plot"] else 0 + if "on" in props: + if snap["on"]: + lyr.on() + else: + lyr.off() + if "frozen" in props: + if snap["frozen"]: + lyr.freeze() + else: + lyr.thaw() + if "locked" in props: + if snap["locked"]: + lyr.lock() + else: + lyr.unlock() + applied += 1 + current = None + if "current" in props and state["current_layer"] in doc.layers: + current = state["current_layer"] + self._current_layer = current + doc.header["$CLAYER"] = current + self._mark_dirty() + return { + "ok": True, + "name": key, + "applied": { + "layers": applied, + "properties": list(props), + "current_layer": current, + }, + "missing_layers": missing, + "new_layers": new, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def layer_state_list(self) -> list[dict]: + from engineering.environment.layer_states import decode_state + + def _sync(): + doc = self._require_doc() + states = self._layer_states(doc) + rows = [] + for key in self._layer_state_names(states): + state = decode_state(self._layer_state_chunks(states.get(key))) + rows.append( + { + "name": key, + "description": state.get("description"), + "layer_count": len(state["layers"]), + } + ) + return rows + + return await self._async(_sync) + + async def layer_state_delete(self, name) -> dict: + from engineering.environment.layer_states import validate_state_name + + clean = validate_state_name(name) + + def _sync(): + doc = self._require_doc() + states = self._layer_states(doc) + key, xrecord = self._layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_delete: no layer state named {clean!r}; " + f"saved states: {self._layer_state_names(states)}" + ) + states.remove(key) + self._mark_dirty() + return {"ok": True, "deleted": key, "backend": "ezdxf"} + + return await self._async(_sync) + + # ── named views ─────────────────────────────────────────────────────────── + + # TEMPORARY (track-e/environment worktree only): group C's Task 14 defines + # the same `_active_vport` in its `# ── settings (track E, group C) ──` + # block, and C's is the one that ships. This copy exists so this worktree + # runs without C's code; the merge agent deletes THIS definition (two in + # one class are ruff F811, and the later one would silently win). + @staticmethod + def _active_vport(doc): + """The ``*Active`` VPORT entry — the view the file opens on. Created if absent.""" + entries = doc.viewports.get("*Active") + if entries: + return entries[0] + return doc.viewports.new("*Active") + + @staticmethod + def _view_entry(doc, name: str): + if name in doc.views: # case-insensitive in ezdxf's table + return doc.views.get(name) + return None + + async def view_named_save(self, name, center=None, height=None, width=None) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.views import resolve_view_args + + clean = validate_name(name, what="view name") + args = resolve_view_args(center, height, width) + + def _sync(): + doc = self._require_doc() + vport = self._active_vport(doc) + aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34 + if args["center"] is None or args["height"] is None: + # Headless there is no display to read a "current view" from; + # the drawing's extents are the honest default. + bb = ezdxf_bbox.extents(doc.modelspace()) if _BBOX_OK else None + if bb is None or not bb.has_data: + raise ValueError( + f"view_named_save: {clean!r} — an empty drawing has no extents; " + "pass center and height" + ) + ext_center = ( + (bb.extmin.x + bb.extmax.x) / 2.0, + (bb.extmin.y + bb.extmax.y) / 2.0, + ) + # The window must hold the extents at the viewport's aspect: + # whichever side binds decides the height. + ext_height = max(bb.extmax.y - bb.extmin.y, (bb.extmax.x - bb.extmin.x) / aspect) + if ext_height <= 0: + ext_height = 1.0 + else: + ext_center, ext_height = args["center"], args["height"] + cx, cy = args["center"] or ext_center + h = args["height"] or ext_height + w = args["width"] or h * aspect + existing = self._view_entry(doc, clean) + replaced = existing is not None + stored = existing.dxf.name if replaced else clean + if replaced: + doc.views.remove(stored) + doc.views.add(stored, dxfattribs={"center": (cx, cy, 0.0), "height": h, "width": w}) + self._mark_dirty() + return { + "ok": True, + "name": stored, + "center": [float(cx), float(cy)], + "height": float(h), + "width": float(w), + "replaced": replaced, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def view_named_restore(self, name) -> dict: + from engineering.environment.names import validate_name + + clean = validate_name(name, what="view name") + + def _sync(): + doc = self._require_doc() + view = self._view_entry(doc, clean) + if view is None: + raise ValueError( + f"view_named_restore: no named view {clean!r}; " + f"saved views: {[v.dxf.name for v in doc.views]}" + ) + cx, cy = float(view.dxf.center.x), float(view.dxf.center.y) + h, w = float(view.dxf.height), float(view.dxf.width) + vport = self._active_vport(doc) + vport.dxf.center = (cx, cy) + vport.dxf.height = h + if h > 0: + vport.dxf.aspect_ratio = w / h + self._mark_dirty() + return { + "ok": True, + "name": view.dxf.name, + "center": [cx, cy], + "height": h, + "width": w, + # The plan's literal. Measured: ezdxf's header refuses $VIEWCTR, + # so the store is the *Active VPORT — what AutoCAD reads as the + # initial view when it opens the file. No live display moves. + "applied": "header_only", + "store": "vport_active", + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def view_named_list(self) -> list[dict]: + def _sync(): + doc = self._require_doc() + return [ + { + "name": view.dxf.name, + "center": [float(view.dxf.center.x), float(view.dxf.center.y)], + "height": float(view.dxf.height), + "width": float(view.dxf.width), + } + for view in doc.views + ] + + return await self._async(_sync) + + # ── UCS ─────────────────────────────────────────────────────────────────── + + @staticmethod + def _ucs_entry(doc, name: str): + for ucs in doc.ucs: + if ucs.dxf.name.lower() == name.lower(): + return ucs + return None + + @staticmethod + def _set_ucs_header(doc, name: str, origin, x_axis, y_axis) -> None: + doc.header["$UCSNAME"] = name + doc.header["$UCSORG"] = tuple(origin) + doc.header["$UCSXDIR"] = tuple(x_axis) + doc.header["$UCSYDIR"] = tuple(y_axis) + + @staticmethod + def _ucs_row(name, origin, x_axis, y_axis, current: bool) -> dict: + return { + "name": name, + "origin": [float(c) for c in origin], + "x_axis": [float(c) for c in x_axis], + "y_axis": [float(c) for c in y_axis], + "current": current, + } + + async def ucs_list(self) -> list[dict]: + from engineering.environment.ucs import WORLD + + def _sync(): + doc = self._require_doc() + current = str(doc.header.get("$UCSNAME", "") or "") + rows = [self._ucs_row(WORLD, (0, 0, 0), (1, 0, 0), (0, 1, 0), current == "")] + for ucs in doc.ucs: + rows.append( + self._ucs_row( + ucs.dxf.name, + ucs.dxf.origin, + ucs.dxf.xaxis, + ucs.dxf.yaxis, + current != "" and ucs.dxf.name.lower() == current.lower(), + ) + ) + return rows + + return await self._async(_sync) + + async def ucs_set(self, name, origin, x_axis, y_axis) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD, resolve_ucs_axes + + clean = validate_name(name, what="UCS name") + if clean.lower() == WORLD: + raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS") + axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any write + + def _sync(): + doc = self._require_doc() + existing = self._ucs_entry(doc, clean) + replaced = existing is not None + stored = existing.dxf.name if replaced else clean + if replaced: + doc.ucs.remove(stored) + doc.ucs.add( + stored, + dxfattribs={ + "origin": axes["origin"], + "xaxis": axes["x_axis"], + "yaxis": axes["y_axis"], + }, + ) + self._set_ucs_header(doc, stored, axes["origin"], axes["x_axis"], axes["y_axis"]) + self._mark_dirty() + return { + "ok": True, + "name": stored, + "origin": list(axes["origin"]), + "x_axis": list(axes["x_axis"]), + "y_axis": list(axes["y_axis"]), + "replaced": replaced, + "current": True, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def ucs_restore(self, name) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD + + clean = validate_name(name, what="UCS name") + + def _sync(): + doc = self._require_doc() + if clean.lower() == WORLD: + self._set_ucs_header(doc, "", (0, 0, 0), (1, 0, 0), (0, 1, 0)) + self._mark_dirty() + return {"ok": True, "name": WORLD, "current": True, "backend": "ezdxf"} + ucs = self._ucs_entry(doc, clean) + if ucs is None: + raise ValueError( + f"ucs_restore: no UCS named {clean!r}; " + f"saved: {[u.dxf.name for u in doc.ucs]} (or 'world')" + ) + self._set_ucs_header(doc, ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis) + self._mark_dirty() + return {"ok": True, "name": ucs.dxf.name, "current": True, "backend": "ezdxf"} + + return await self._async(_sync) + # ── viewports ──────────────────────────────────────────────────────────── #: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM). diff --git a/discovery/aliases.py b/discovery/aliases.py index a089893..be2dcfa 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -74,6 +74,8 @@ class ToolAliases: # class, a number and markers; a drafter typing it could mean either. # CLOSE -- the active document (drawing_close) or one named document # (document_close); a drafter typing it could mean either. +# LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore, +# list, delete in one) that this server splits into discrete tools. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( @@ -84,11 +86,14 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "ERASE", "INSERT", "LAYER", + "LAYERSTATE", "LAYOUT", "MEASUREGEOM", "PLINE", "QSELECT", "SETVAR", + "UCS", + "VIEW", "ZOOM", } ) @@ -1466,6 +1471,85 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "currently open files", ), ), + "layer_state_delete": ToolAliases( + acad=("LAYERSTATE",), + synonyms=("delete a layer state", "remove a saved layer setup", "drop the layer snapshot"), + ), + # Same rule as `document_list` above: no "which ... are" phrasings on the + # three list tools here — "which layer setups are saved" alone pushed + # `block_list` to #4 for the holdout "which blocks are defined" (measured + # 2026-09-17); the nouns carry the questions without the interrogative. + "layer_state_list": ToolAliases( + acad=("LAYERSTATE",), + synonyms=("saved layer states", "saved layer setups", "list layer snapshots"), + ), + "layer_state_restore": ToolAliases( + acad=("LAYERSTATE",), + synonyms=( + "restore the layer state", + "put the layers back", + "go back to the plot layer setup", + "reapply saved layer settings", + "layer snapshot restore", + ), + ), + "layer_state_save": ToolAliases( + acad=("LAYERSTATE",), + synonyms=( + "save the layer state", + "remember the layer setup", + "snapshot the layers", + "layer configuration for plotting", + "save which layers are frozen", + ), + ), + "ucs_list": ToolAliases( + acad=("UCS", "UCSMAN"), + synonyms=("the current ucs", "list coordinate systems", "named ucs", "ucs manager"), + ), + "ucs_restore": ToolAliases( + acad=("UCS",), + synonyms=( + "back to world coordinates", + "reset the ucs", + "ucs world", + "switch to a saved ucs", + "make that coordinate system current", + ), + ), + "ucs_set": ToolAliases( + acad=("UCS",), + synonyms=( + "user coordinate system", + "define a ucs", + "new coordinate system at this origin", + "rotate the ucs", + "ucs origin", + ), + ), + "view_named_list": ToolAliases( + acad=("VIEW",), + synonyms=("saved views", "existing named views", "list the views"), + ), + "view_named_restore": ToolAliases( + acad=("VIEW",), + synonyms=( + "go to the saved view", + "restore a named view", + "jump to the detail view", + "recall the view", + ), + ), + "view_named_save": ToolAliases( + acad=("VIEW",), + synonyms=( + "save this view", + "named view", + "remember where i am looking", + "bookmark the view", + "save a detail view", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index f934c03..4f21c59 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": 169, + "tools": 179, "resources": 8, "prompts": 5, "groups": 20 @@ -19,7 +19,7 @@ "entity_creation": 18, "entity_modification": 16, "entity_query": 9, - "layers": 14, + "layers": 18, "layouts": 12, "pid": 9, "premium": 12, @@ -28,7 +28,7 @@ "templates": 2, "transactions": 3, "validation": 1, - "view": 4 + "view": 10 }, "tools": [ { @@ -1611,6 +1611,60 @@ "title": "Show Layer" } }, + { + "name": "layer_state_delete", + "group": "layers", + "cost": "destructive", + "summary": "Delete one saved layer state from the drawing.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Delete Layer State", + "destructiveHint": true + } + }, + { + "name": "layer_state_list", + "group": "layers", + "cost": "read", + "summary": "List the layer states saved in this drawing.", + "tags": [ + "layer" + ], + "annotations": { + "title": "List Layer States", + "readOnlyHint": true + } + }, + { + "name": "layer_state_restore", + "group": "layers", + "cost": "safe", + "summary": "Restore a saved layer state, all properties or a chosen subset.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Restore Layer State", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "layer_state_save", + "group": "layers", + "cost": "safe", + "summary": "Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, in the file.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Save Layer State", + "readOnlyHint": false, + "destructiveHint": false + } + }, { "name": "layer_thaw", "group": "layers", @@ -2251,6 +2305,47 @@ "destructiveHint": true } }, + { + "name": "ucs_list", + "group": "view", + "cost": "read", + "summary": "List the user coordinate systems, with the implicit world one.", + "tags": [ + "view" + ], + "annotations": { + "title": "List UCS", + "readOnlyHint": true + } + }, + { + "name": "ucs_restore", + "group": "view", + "cost": "safe", + "summary": "Make a saved UCS current, or `world` to reset to WCS.", + "tags": [ + "view" + ], + "annotations": { + "title": "Restore UCS", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "ucs_set", + "group": "view", + "cost": "safe", + "summary": "Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.", + "tags": [ + "view" + ], + "annotations": { + "title": "Set UCS", + "readOnlyHint": false, + "destructiveHint": false + } + }, { "name": "validation_check", "group": "validation", @@ -2265,6 +2360,47 @@ "readOnlyHint": true } }, + { + "name": "view_named_list", + "group": "view", + "cost": "read", + "summary": "List the named views saved in this drawing.", + "tags": [ + "view" + ], + "annotations": { + "title": "List Named Views", + "readOnlyHint": true + } + }, + { + "name": "view_named_restore", + "group": "view", + "cost": "safe", + "summary": "Restore a named view.", + "tags": [ + "view" + ], + "annotations": { + "title": "Restore Named View", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "view_named_save", + "group": "view", + "cost": "safe", + "summary": "Save a named view: a centre and height (and width) to come back to.", + "tags": [ + "view" + ], + "annotations": { + "title": "Save Named View", + "readOnlyHint": false, + "destructiveHint": false + } + }, { "name": "view_screenshot", "group": "view", diff --git a/engineering/environment/__init__.py b/engineering/environment/__init__.py new file mode 100644 index 0000000..7d42f1c --- /dev/null +++ b/engineering/environment/__init__.py @@ -0,0 +1,16 @@ +"""Environment helpers (v1.6 track E): portable layer states, view and UCS +argument resolution. Pure data and validation — no ezdxf, no COM.""" + +from .layer_states import ( # noqa: F401 + DICT_NAME, + PROPERTIES, + decode_state, + diff_snapshot, + encode_state, + snapshot_from_layers, + validate_properties, + validate_state_name, +) +from .names import validate_name # noqa: F401 +from .ucs import WORLD, resolve_ucs_axes # noqa: F401 +from .views import resolve_view_args # noqa: F401 diff --git a/engineering/environment/layer_states.py b/engineering/environment/layer_states.py new file mode 100644 index 0000000..e19411c --- /dev/null +++ b/engineering/environment/layer_states.py @@ -0,0 +1,122 @@ +"""Portable layer states: a layer-table snapshot <-> XRECORD string chunks. + +These are the server's own layer states, not AutoCAD's. AutoCAD keeps its +Layer States Manager entries under the layer table's extension dictionary in +a layout neither engine authors; this codec stores a JSON snapshot as +1000-code string chunks in an XRECORD under the drawing's named object +dictionary ``ACADMCP_LAYERSTATES``. They live in the file and travel with it, +and they do **not** appear in AutoCAD's Layer States Manager (LAYERSTATE). +That sentence is repeated in the tool docstrings, in ``system_capabilities`` +(feature ``layer_states``) and in the README. + +Chunks are ASCII (``ensure_ascii``), so a 255-character boundary can never +split a code point, and 255 is AutoCAD's ``SetXRecordData`` string limit — +ezdxf itself would accept longer tags (measured: a 300-character tag +round-trips through save/reopen unchanged). +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from .names import validate_name + +if TYPE_CHECKING: + from backends.base import LayerInfo + +DICT_NAME = "ACADMCP_LAYERSTATES" +PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current") +LAYER_PROPERTIES = PROPERTIES[:-1] +CHUNK_SIZE = 255 +FORMAT_VERSION = 1 + + +def validate_state_name(name) -> str: + return validate_name(name, what="layer state name") + + +def validate_properties(properties) -> tuple[str, ...]: + """The subset of ``PROPERTIES`` to apply; ``None`` means all of them.""" + if properties is None: + return PROPERTIES + if isinstance(properties, str) or not isinstance(properties, (list, tuple)): + raise TypeError("properties must be a list of property names") + out: list[str] = [] + for index, prop in enumerate(properties): + if prop not in PROPERTIES: + raise ValueError(f"properties[{index}]: {prop!r} is not one of {PROPERTIES}") + if prop not in out: + out.append(prop) + if not out: + raise ValueError(f"properties must name at least one of {PROPERTIES}") + return tuple(out) + + +def snapshot_from_layers( + layers: list[LayerInfo], + current_layer: str, + *, + plot: dict[str, bool] | None = None, + description: str | None = None, +) -> dict: + """The state dict for a layer table. + + ``plot`` is per-layer plottability (``LayerInfo`` has no such field; each + engine reads it and passes it in), defaulting to plottable. + """ + plot = plot or {} + return { + "version": FORMAT_VERSION, + "description": description, + "current_layer": str(current_layer), + "layers": { + layer.name: { + "on": bool(layer.is_on), + "frozen": bool(layer.is_frozen), + "locked": bool(layer.is_locked), + "color": int(layer.color), + "linetype": str(layer.linetype), + "lineweight": int(round(float(layer.lineweight))), + "plot": bool(plot.get(layer.name, True)), + } + for layer in layers + }, + } + + +def encode_state(state: dict) -> list[str]: + text = json.dumps(state, separators=(",", ":"), sort_keys=True, ensure_ascii=True) + return [text[i : i + CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE)] or [""] + + +def decode_state(chunks) -> dict: + text = "".join(str(chunk) for chunk in chunks) + try: + state = json.loads(text) + except ValueError as exc: + raise ValueError(f"layer state payload is not valid JSON: {exc}") from exc + if not isinstance(state, dict) or state.get("version") != FORMAT_VERSION: + found = state.get("version") if isinstance(state, dict) else type(state).__name__ + raise ValueError( + f"layer state payload is not a version {FORMAT_VERSION} state (got {found!r})" + ) + layers = state.get("layers") + if not isinstance(layers, dict): + raise ValueError("layer state payload has no 'layers' mapping") + for name, props in layers.items(): + if not isinstance(props, dict): + raise ValueError(f"layer state entry {name!r} is not a mapping") + missing = [prop for prop in LAYER_PROPERTIES if prop not in props] + if missing: + raise ValueError(f"layer state entry {name!r} lacks {missing}") + state.setdefault("description", None) + state.setdefault("current_layer", "0") + return state + + +def diff_snapshot(state: dict, layers: list[LayerInfo]) -> tuple[list[str], list[str]]: + """``(missing, new)``: in the state but not the drawing / in the drawing but not the state.""" + in_state = set(state["layers"]) + in_drawing = {layer.name for layer in layers} + return sorted(in_state - in_drawing), sorted(in_drawing - in_state) diff --git a/engineering/environment/names.py b/engineering/environment/names.py new file mode 100644 index 0000000..194f933 --- /dev/null +++ b/engineering/environment/names.py @@ -0,0 +1,17 @@ +"""One rule for the names this package stores in the drawing.""" + +from __future__ import annotations + + +def validate_name(name, *, what: str, max_len: int = 255) -> str: + """A non-empty, stripped string without control characters, else ``ValueError``.""" + if not isinstance(name, str): + raise ValueError(f"{what} must be a string, got {type(name).__name__}") + clean = name.strip() + if not clean: + raise ValueError(f"{what} must not be empty") + if len(clean) > max_len: + raise ValueError(f"{what} is longer than {max_len} characters") + if any(ord(ch) < 32 for ch in clean): + raise ValueError(f"{what} must not contain control characters") + return clean diff --git a/engineering/environment/ucs.py b/engineering/environment/ucs.py new file mode 100644 index 0000000..6314729 --- /dev/null +++ b/engineering/environment/ucs.py @@ -0,0 +1,54 @@ +"""UCS axes, validated and normalised before either engine writes. + +Tool coordinates stay WCS on both engines (the repository rule): a UCS is +stored and made current for the operator's benefit, and nothing here starts +interpreting inputs in it. +""" + +from __future__ import annotations + +import math + +WORLD = "world" +ORTHO_TOLERANCE_DEG = 1e-3 + + +def _vec3(value, where: str) -> tuple[float, float, float]: + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise ValueError(f"{where} must be an [x, y, z] triple") + out = [] + for axis, item in zip("xyz", value, strict=True): + if isinstance(item, bool) or not isinstance(item, (int, float)): + raise ValueError(f"{where}.{axis} must be a number, got {item!r}") + number = float(item) + if not math.isfinite(number): + raise ValueError(f"{where}.{axis} must be finite") + out.append(number) + return out[0], out[1], out[2] + + +def _unit(vector, where: str) -> tuple[float, float, float]: + length = math.sqrt(sum(c * c for c in vector)) + if length < 1e-12: + raise ValueError(f"{where} must not be a zero vector") + return vector[0] / length, vector[1] / length, vector[2] / length + + +def resolve_ucs_axes(origin, x_axis, y_axis) -> dict: + """Unit axes plus the measured angle; non-orthogonal axes are refused by that angle.""" + o = _vec3(origin, "origin") + x = _unit(_vec3(x_axis, "x_axis"), "x_axis") + y = _unit(_vec3(y_axis, "y_axis"), "y_axis") + dot = max(-1.0, min(1.0, sum(a * b for a, b in zip(x, y, strict=True)))) + angle = math.degrees(math.acos(dot)) + if abs(angle - 90.0) > ORTHO_TOLERANCE_DEG: + raise ValueError( + f"ucs_set: x_axis and y_axis must be perpendicular; measured {angle:.2f}° " + f"between them (tolerance {ORTHO_TOLERANCE_DEG}°)" + ) + z = ( + x[1] * y[2] - x[2] * y[1], + x[2] * y[0] - x[0] * y[2], + x[0] * y[1] - x[1] * y[0], + ) + return {"origin": o, "x_axis": x, "y_axis": y, "z_axis": z, "angle_deg": angle} diff --git a/engineering/environment/views.py b/engineering/environment/views.py new file mode 100644 index 0000000..4fec501 --- /dev/null +++ b/engineering/environment/views.py @@ -0,0 +1,32 @@ +"""Named-view arguments, validated before either engine writes.""" + +from __future__ import annotations + +import math + + +def _finite(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where} must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{where} must be finite") + return number + + +def resolve_view_args(center, height, width) -> dict: + """``{"center": (x, y) | None, "height": float | None, "width": float | None}``.""" + out: dict = {"center": None, "height": None, "width": None} + if center is not None: + if not isinstance(center, (list, tuple)) or len(center) != 2: + raise ValueError("center must be an [x, y] pair") + out["center"] = (_finite(center[0], "center.x"), _finite(center[1], "center.y")) + if height is not None: + out["height"] = _finite(height, "height") + if out["height"] <= 0: + raise ValueError(f"height must be > 0, got {out['height']}") + if width is not None: + out["width"] = _finite(width, "width") + if out["width"] <= 0: + raise ValueError(f"width must be > 0, got {out['width']}") + return out diff --git a/server.py b/server.py index 553b40d..cbdb451 100644 --- a/server.py +++ b/server.py @@ -6819,7 +6819,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── +# ── SECTION 20: Environment (13 tools) ────────────────────────────────────── # --------------------------------------------------------------------------- @@ -6904,6 +6904,207 @@ async def document_close( return await _backend(ctx).document_close(name_or_path, save, discard) +@cad_tool( + summary="Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, in the file.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Save Layer State", "readOnlyHint": False, "destructiveHint": False}, + tags={"layer"}, +) +async def layer_state_save( + name: Annotated[str, "State name, e.g. PLOT-SET or DESIGN"], + description: Annotated[str | None, "Free text stored with the state"] = None, + ctx: Context = None, +) -> dict: + """Snapshot every layer's on/frozen/locked/color/linetype/lineweight/plot + flags plus the current layer as a named state stored in the drawing. + + These are the server's own portable layer states: JSON chunks in an XRECORD + under the `ACADMCP_LAYERSTATES` dictionary. They live in the file and + travel with it, work on both engines, and do **not** appear in AutoCAD's + Layer States Manager (LAYERSTATE). Same name replaces (`replaced: true`). + Refusals: an empty or control-character name, a non-string description. + Pack: settings · lean: no. + """ + await ctx.info(f"Saving layer state {name!r}") + return await _backend(ctx).layer_state_save(name, description) + + +@cad_tool(summary="Restore a saved layer state, all properties or a chosen subset.", cost="safe") +@mcp.tool( + annotations={"title": "Restore Layer State", "readOnlyHint": False, "destructiveHint": False}, + tags={"layer"}, +) +async def layer_state_restore( + name: Annotated[str, "A name from layer_state_list"], + properties: Annotated[ + list[str] | None, + "Subset of on, frozen, locked, color, linetype, lineweight, plot, current; omit for all", + ] = None, + ctx: Context = None, +) -> dict: + """Apply a saved state to the layers that still exist. + + `missing_layers` (in the state, not in the drawing) are skipped, never + created; `new_layers` (in the drawing, not in the state) are untouched; + `applied` says how many layers and which properties moved. Refusals: an + unknown state name (lists the saved ones), a property outside the eight + (names the index). Live, a layer AutoCAD refuses to change (freezing the + active layer) lands in `warnings` instead of failing the call. Portable + server states, not Layer States Manager entries. Pack: settings · lean: no. + """ + await ctx.info(f"Restoring layer state {name!r}") + return await _backend(ctx).layer_state_restore(name, properties) + + +@cad_tool(summary="List the layer states saved in this drawing.", cost="read") +@mcp.tool( + annotations={"title": "List Layer States", "readOnlyHint": True}, + tags={"layer"}, +) +async def layer_state_list(ctx: Context = None) -> dict: + """Every state under `ACADMCP_LAYERSTATES` with its description and layer + count. These are the server's portable states, not AutoCAD Layer States + Manager entries. No refusals. Pack: settings · lean: no.""" + rows = await _backend(ctx).layer_state_list() + return {"states": rows, "count": len(rows)} + + +@cad_tool(summary="Delete one saved layer state from the drawing.", cost="destructive") +@mcp.tool( + annotations={"title": "Delete Layer State", "destructiveHint": True}, + tags={"layer"}, +) +async def layer_state_delete( + name: Annotated[str, "A name from layer_state_list"], + ctx: Context = None, +) -> dict: + """Remove the named state's XRECORD. Layers themselves are untouched. + Refusal: an unknown name (lists the saved ones). Pack: settings · lean: no.""" + await ctx.info(f"Deleting layer state {name!r}") + return await _backend(ctx).layer_state_delete(name) + + +@cad_tool( + summary="Save a named view: a centre and height (and width) to come back to.", cost="safe" +) +@mcp.tool( + annotations={"title": "Save Named View", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def view_named_save( + name: Annotated[str, "View name, e.g. DETAIL-A"], + center: Annotated[ + list[float] | None, + "[x, y] in WCS; default: the current view (live) or the drawing extents (headless)", + ] = None, + height: Annotated[float | None, "View height in drawing units; default as for center"] = None, + width: Annotated[ + float | None, "View width; default: height × the current viewport aspect" + ] = None, + ctx: Context = None, +) -> dict: + """Store a VIEW table entry (AutoCAD's VIEW command). Same name replaces + (`replaced: true`). Refusals: `height`/`width` ≤ 0 or non-finite, a + malformed `center`, and headless an empty drawing with no `center`/`height` + (there are no extents to default to). Pack: settings · lean: no.""" + await ctx.info(f"Saving named view {name!r}") + return await _backend(ctx).view_named_save(name, center, height, width) + + +@cad_tool(summary="Restore a named view.", cost="safe") +@mcp.tool( + annotations={"title": "Restore Named View", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def view_named_restore( + name: Annotated[str, "A name from view_named_list"], + ctx: Context = None, +) -> dict: + """Live: sets the active viewport to the saved centre/height/width + (`applied: "active_viewport"`). Headless there is no display: the saved + window is written to the `*Active` VPORT — the view AutoCAD opens the file + on — and the result says `applied: "header_only"`, the same reported-no-op + rule as `view_zoom_extents`. Refusal: an unknown name (lists the saved + views). Pack: settings · lean: no.""" + return await _backend(ctx).view_named_restore(name) + + +@cad_tool(summary="List the named views saved in this drawing.", cost="read") +@mcp.tool( + annotations={"title": "List Named Views", "readOnlyHint": True}, + tags={"view"}, +) +async def view_named_list(ctx: Context = None) -> dict: + """Every VIEW table entry with centre, height and width. No refusals. + Pack: settings · lean: no.""" + rows = await _backend(ctx).view_named_list() + return {"views": rows, "count": len(rows)} + + +@cad_tool(summary="List the user coordinate systems, with the implicit world one.", cost="read") +@mcp.tool( + annotations={"title": "List UCS", "readOnlyHint": True}, + tags={"view"}, +) +async def ucs_list(ctx: Context = None) -> dict: + """The `world` row first, then every UCS table entry with origin and unit + axes, `current` on the active one. Tool coordinates stay WCS on both + engines whatever is current. No refusals. Pack: settings · lean: no.""" + rows = await _backend(ctx).ucs_list() + return { + "ucs": rows, + "count": len(rows), + "current": next((r["name"] for r in rows if r["current"]), None), + } + + +@cad_tool( + summary="Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Set UCS", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def ucs_set( + name: Annotated[str, "UCS name"], + origin: Annotated[list[float], "[x, y, z] in WCS"], + x_axis: Annotated[list[float], "Direction of the new X axis (any length)"], + y_axis: Annotated[list[float], "Direction of the new Y axis; must be perpendicular to x_axis"], + ctx: Context = None, +) -> dict: + """Store a UCS table entry and make it current (AutoCAD's UCS command). + + Axes are normalised to unit vectors. **Every tool keeps taking and + returning WCS coordinates** — the repository rule; a UCS is for the + operator's own drafting, and no tool starts interpreting inputs in it. + Refusals, before any write: a name of `world` (reserved), a zero-length + axis, and non-perpendicular axes — the message carries the measured angle + (tolerance 0.001°). Pack: settings · lean: no. + """ + await ctx.info(f"Setting UCS {name!r}") + return await _backend(ctx).ucs_set(name, origin, x_axis, y_axis) + + +@cad_tool(summary="Make a saved UCS current, or `world` to reset to WCS.", cost="safe") +@mcp.tool( + annotations={"title": "Restore UCS", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def ucs_restore( + name: Annotated[str, "A name from ucs_list, or `world`"], + ctx: Context = None, +) -> dict: + """Live: `ActiveUCS` for a named entry; `world` runs `UCS World`, which is + refused while AutoCAD has an active command (CMDACTIVE — press ESC). + Headless: the `$UCSNAME/$UCSORG/$UCSXDIR/$UCSYDIR` header variables. + Refusal: an unknown name (lists the saved ones). Tool coordinates stay + WCS. Pack: settings · lean: no.""" + return await _backend(ctx).ucs_restore(name) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_layer_states.py b/tests/test_layer_states.py new file mode 100644 index 0000000..2ba41f0 --- /dev/null +++ b/tests/test_layer_states.py @@ -0,0 +1,444 @@ +"""Portable layer states: save → change → restore equals the snapshot. + +These are the server's own layer states — JSON chunks in an XRECORD under +``ACADMCP_LAYERSTATES`` in the drawing's named object dictionary. They live +in the file and travel with it; they do NOT appear in AutoCAD's Layer States +Manager. The codec is engine-neutral, so the ezdxf tests prove the format and +the COM tests prove the ActiveX call shapes. +""" + +from __future__ import annotations + +import json +import types + +import pytest + +from backends.base import LayerInfo +from engineering.environment.layer_states import ( + DICT_NAME, + PROPERTIES, + decode_state, + diff_snapshot, + encode_state, + snapshot_from_layers, + validate_properties, +) + +pytestmark = pytest.mark.asyncio + + +def _layer(name, **overrides) -> LayerInfo: + base = dict( + name=name, + color=7, + linetype="Continuous", + lineweight=-3, + is_on=True, + is_frozen=False, + is_locked=False, + is_current=False, + ) + base.update(overrides) + return LayerInfo(**base) + + +# ── codec ─────────────────────────────────────────────────────────────────── + + +def test_snapshot_records_every_property_and_the_current_layer(): + state = snapshot_from_layers( + [_layer("0"), _layer("HIDDEN", color=8, linetype="HIDDEN", lineweight=35, is_frozen=True)], + "HIDDEN", + plot={"HIDDEN": False}, + description="before plotting", + ) + assert state == { + "version": 1, + "description": "before plotting", + "current_layer": "HIDDEN", + "layers": { + "0": { + "on": True, + "frozen": False, + "locked": False, + "color": 7, + "linetype": "Continuous", + "lineweight": -3, + "plot": True, + }, + "HIDDEN": { + "on": True, + "frozen": True, + "locked": False, + "color": 8, + "linetype": "HIDDEN", + "lineweight": 35, + "plot": False, + }, + }, + } + + +def test_encode_chunks_are_at_most_255_ascii_characters_and_round_trip(): + layers = [_layer(f"LAYER-{i:03d}-{'x' * 40}") for i in range(60)] + state = snapshot_from_layers(layers, "0") + chunks = encode_state(state) + assert len(chunks) > 5 + assert all(len(c) <= 255 and c.isascii() for c in chunks) + assert decode_state(chunks) == state + + +def test_unicode_layer_names_survive_the_ascii_chunking(): + state = snapshot_from_layers([_layer("KÖRPER-Ø20")], "KÖRPER-Ø20") + chunks = encode_state(state) + assert all(c.isascii() for c in chunks), "escaped so a chunk boundary never splits a code point" + assert decode_state(chunks)["current_layer"] == "KÖRPER-Ø20" + + +@pytest.mark.parametrize( + "chunks, fragment", + [ + (["not json"], "not valid JSON"), + ([json.dumps({"version": 2, "layers": {}})], "version"), + ([json.dumps({"version": 1, "layers": {"A": {"on": True}}})], "lacks"), + ([json.dumps([1, 2])], "version"), + ], +) +def test_decode_refuses_anything_that_is_not_a_state(chunks, fragment): + with pytest.raises(ValueError, match=fragment): + decode_state(chunks) + + +def test_diff_reports_missing_and_new_layers_sorted(): + state = snapshot_from_layers([_layer("A"), _layer("B"), _layer("C")], "0") + missing, new = diff_snapshot(state, [_layer("C"), _layer("Z"), _layer("A"), _layer("Y")]) + assert missing == ["B"] and new == ["Y", "Z"] + + +def test_validate_properties_defaults_to_all_and_names_the_bad_one(): + assert validate_properties(None) == PROPERTIES + assert validate_properties(["color", "on", "color"]) == ("color", "on") + with pytest.raises(ValueError, match=r"properties\[1\]: 'colour'"): + validate_properties(["on", "colour"]) + with pytest.raises(TypeError, match="list"): + validate_properties("on") + with pytest.raises(ValueError, match="at least one"): + validate_properties([]) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def _seed(backend): + await backend.layer_create("GEOMETRY", color=7, linetype="Continuous", lineweight=50) + await backend.layer_create("HIDDEN", color=8, linetype="HIDDEN", lineweight=35) + await backend.layer_create("CENTER", color=1, linetype="CENTER", lineweight=25) + await backend.layer_set_current("GEOMETRY") + await backend.layer_freeze("CENTER") + await backend.layer_hide("HIDDEN") + + +async def _table(backend) -> dict: + return { + lyr.name: ( + lyr.color, + lyr.linetype, + lyr.lineweight, + lyr.is_on, + lyr.is_frozen, + lyr.is_locked, + lyr.is_current, + ) + for lyr in await backend.layer_list() + } + + +async def test_save_change_restore_equals_the_snapshot(backend): + await _seed(backend) + before = await _table(backend) + saved = await backend.layer_state_save("PLOT", description="plot set") + # measured: ezdxf.new() already has "0" and "Defpoints", so the seeded + # table is five layers and serialises to three <= 255-character chunks + assert saved == { + "ok": True, + "name": "PLOT", + "layer_count": 5, + "replaced": False, + "chunks": 3, + "backend": "ezdxf", + } + + await backend.layer_thaw("CENTER") + await backend.layer_show("HIDDEN") + await backend.layer_lock("GEOMETRY") + await backend.layer_modify("HIDDEN", color=3, linetype="Continuous", lineweight=13) + await backend.layer_set_current("0") + assert await _table(backend) != before + + restored = await backend.layer_state_restore("PLOT") + assert restored["applied"] == { + "layers": 5, + "properties": list(PROPERTIES), + "current_layer": "GEOMETRY", + } + assert restored["missing_layers"] == [] and restored["new_layers"] == [] + assert await _table(backend) == before + + +async def test_restore_applies_only_the_requested_properties(backend): + await _seed(backend) + await backend.layer_state_save("S") + await backend.layer_thaw("CENTER") + await backend.layer_modify("CENTER", color=5) + result = await backend.layer_state_restore("S", properties=["color"]) + assert result["applied"]["properties"] == ["color"] + center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER") + assert center.color == 1 and center.is_frozen is False, "frozen was not requested" + + +async def test_restore_reports_missing_and_new_layers_and_never_creates(backend): + await _seed(backend) + await backend.layer_state_save("S") + await backend.layer_delete("CENTER") + await backend.layer_create("NEW-ONE") + result = await backend.layer_state_restore("S") + assert result["missing_layers"] == ["CENTER"] and result["new_layers"] == ["NEW-ONE"] + assert "CENTER" not in [lyr.name for lyr in await backend.layer_list()] + + +async def test_plot_flag_and_current_layer_round_trip(backend): + await _seed(backend) + backend._doc.layers.get("HIDDEN").dxf.plot = 0 + await backend.layer_state_save("S") + backend._doc.layers.get("HIDDEN").dxf.plot = 1 + await backend.layer_set_current("0") + await backend.layer_state_restore("S", properties=["plot", "current"]) + assert backend._doc.layers.get("HIDDEN").dxf.plot == 0 + assert backend._current_layer == "GEOMETRY" + assert backend._doc.header["$CLAYER"] == "GEOMETRY" + + +async def test_state_survives_save_and_reopen(backend, tmp_path): + await _seed(backend) + await backend.layer_state_save("PLOT", description="d") + path = tmp_path / "states.dxf" + await backend.drawing_save(str(path)) + await backend.document_close(None) + await backend.drawing_open(str(path)) + assert await backend.layer_state_list() == [ + {"name": "PLOT", "description": "d", "layer_count": 5} + ] + await backend.layer_thaw("CENTER") + await backend.layer_state_restore("PLOT") + center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER") + assert center.is_frozen is True + + +async def test_xrecord_chunks_stay_under_256_characters(backend): + for i in range(80): + await backend.layer_create(f"LONG-LAYER-NAME-{i:03d}-{'x' * 30}") + saved = await backend.layer_state_save("BIG") + assert saved["chunks"] > 10 + xrecord = backend._doc.rootdict.get(DICT_NAME).get("BIG") + tags = [t for t in xrecord.tags if t.code == 1000] + assert len(tags) == saved["chunks"] + assert all(len(t.value) <= 255 for t in tags) + + +async def test_replace_delete_and_missing_names(backend): + await backend.layer_state_save("S") + again = await backend.layer_state_save("s") + assert again["replaced"] is True, "AutoCAD dictionaries are case-insensitive" + assert [row["name"] for row in await backend.layer_state_list()] == ["s"] + assert await backend.layer_state_delete("S") == {"ok": True, "deleted": "s", "backend": "ezdxf"} + assert await backend.layer_state_list() == [] + with pytest.raises(ValueError, match="no layer state named 'S'"): + await backend.layer_state_restore("S") + with pytest.raises(ValueError, match="no layer state named 'S'"): + await backend.layer_state_delete("S") + + +async def test_refusals_happen_before_any_write(backend): + with pytest.raises(ValueError, match="layer state name"): + await backend.layer_state_save(" ") + with pytest.raises(TypeError, match="description"): + await backend.layer_state_save("S", description=3) + assert DICT_NAME not in backend._doc.rootdict, "a refused save creates no dictionary" + await backend.layer_state_save("S") + with pytest.raises(ValueError, match=r"properties\[0\]: 'plottable'"): + await backend.layer_state_restore("S", properties=["plottable"]) + assert len(list(backend._doc.rootdict.get(DICT_NAME).keys())) == 1 + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _FakeXRecord: + def __init__(self, name): + self.name = name + self.types = None + self.values = None + self.deleted = False + + def SetXRecordData(self, types_, values): + self.types, self.values = list(types_), list(values) + + def GetXRecordData(self): + return self.types, self.values + + def Delete(self): + self.deleted = True + + +class _FakeDict: + def __init__(self, name): + self.Name = name + self.entries: list[_FakeXRecord] = [] + self.calls: list[tuple[str, tuple]] = [] + + @property + def Count(self): + return len(self.entries) + + def Item(self, index): + return self.entries[index] + + def GetName(self, obj): + return obj.name + + def AddXRecord(self, keyword): + self.calls.append(("AddXRecord", (keyword,))) + rec = _FakeXRecord(keyword) + self.entries.append(rec) + return rec + + def Remove(self, name): + self.calls.append(("Remove", (name,))) + rec = next(e for e in self.entries if e.name == name) + self.entries.remove(rec) + return rec + + +class _FakeDictionaries: + def __init__(self): + self.dicts: dict[str, _FakeDict] = {} + self.calls: list[tuple[str, tuple]] = [] + + def Item(self, name): + self.calls.append(("Item", (name,))) + if name not in self.dicts: + raise RuntimeError(f"no dictionary {name}") + return self.dicts[name] + + def Add(self, name): + self.calls.append(("Add", (name,))) + self.dicts[name] = _FakeDict(name) + return self.dicts[name] + + +class _FakeLayer: + def __init__(self, name, **props): + self.Name = name + self.Color = props.get("color", 7) + self.Linetype = props.get("linetype", "Continuous") + self.LineWeight = props.get("lineweight", -3) + self.LayerOn = props.get("on", True) + self.Freeze = props.get("frozen", False) + self.Lock = props.get("locked", False) + self.Plottable = props.get("plot", True) + + +class _FakeLayers: + def __init__(self, layers): + self._layers = layers + + @property + def Count(self): + return len(self._layers) + + def Item(self, key): + if isinstance(key, int): + return self._layers[key] + for lyr in self._layers: + if lyr.Name == key: + return lyr + raise RuntimeError(f"no layer {key}") + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + layers = [ + _FakeLayer("0"), + _FakeLayer("GEOMETRY", lineweight=50), + _FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False), + ] + document = types.SimpleNamespace( + Dictionaries=_FakeDictionaries(), + Layers=_FakeLayers(layers), + ActiveLayer=layers[1], + ) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None) + monkeypatch.setattr(module, "_regen", lambda: None) + monkeypatch.setattr(module, "_ai", lambda values: list(values)) + monkeypatch.setattr(module, "_avar", lambda values: list(values)) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_save_creates_the_dictionary_and_one_xrecord_of_1000_chunks(com_backend): + backend, document = com_backend + result = await backend.layer_state_save("PLOT", description="d") + assert result["ok"] and result["backend"] == "com" and result["replaced"] is False + assert document.Dictionaries.calls == [("Item", (DICT_NAME,)), ("Add", (DICT_NAME,))] + states = document.Dictionaries.dicts[DICT_NAME] + assert states.calls == [("AddXRecord", ("PLOT",))] + (rec,) = states.entries + assert rec.types == [1000] * len(rec.values) + assert all(len(v) <= 255 for v in rec.values) + state = decode_state(rec.values) + assert state["current_layer"] == "GEOMETRY" + assert state["layers"]["HIDDEN"]["plot"] is False + again = await backend.layer_state_save("plot") + assert again["replaced"] is True and again["name"] == "PLOT" + assert len(states.entries) == 1, "SetXRecordData on the existing record, no second AddXRecord" + + +async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_backend): + backend, document = com_backend + await backend.layer_state_save("S") + hidden = document.Layers.Item("HIDDEN") + hidden.Freeze = False + hidden.Color = 3 + hidden.Plottable = True + document.ActiveLayer = document.Layers.Item("0") + result = await backend.layer_state_restore("S") + assert result["applied"]["layers"] == 3 + assert result["applied"]["current_layer"] == "GEOMETRY" + assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False + assert document.ActiveLayer.Name == "GEOMETRY" + assert result["missing_layers"] == [] and result["new_layers"] == [] + + +async def test_com_list_and_delete(com_backend): + backend, document = com_backend + await backend.layer_state_save("A", description="one") + await backend.layer_state_save("B") + assert await backend.layer_state_list() == [ + {"name": "A", "description": "one", "layer_count": 3}, + {"name": "B", "description": None, "layer_count": 3}, + ] + assert (await backend.layer_state_delete("a"))["deleted"] == "A" + states = document.Dictionaries.dicts[DICT_NAME] + assert states.calls[-1] == ("Remove", ("A",)) + with pytest.raises(ValueError, match="no layer state named 'A'"): + await backend.layer_state_delete("A") diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 29ee537..5013664 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -519,7 +519,10 @@ async def test_tool_group_sizes_are_unchanged(): tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. `drawing` moved 11 -> 14 when v1.6's `document_list` / `document_activate` / - `document_close` (multi-document on both engines) opened SECTION 20. Every + `document_close` (multi-document on both engines) opened SECTION 20; + `layers` moved 14 -> 18 with the four `layer_state_*` tools and `view` + 4 -> 10 with `view_named_*` and `ucs_*` (UCS files under `view`, where + AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. Every other number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} @@ -534,7 +537,7 @@ async def test_tool_group_sizes_are_unchanged(): "entity_creation": 18, "entity_modification": 16, "entity_query": 9, - "layers": 14, + "layers": 18, "layouts": 12, "pid": 9, "premium": 12, @@ -543,6 +546,6 @@ async def test_tool_group_sizes_are_unchanged(): "templates": 2, "transactions": 3, "validation": 1, - "view": 4, + "view": 10, } - assert sum(sizes.values()) == 169 + assert sum(sizes.values()) == 179 diff --git a/tests/test_views_ucs.py b/tests/test_views_ucs.py new file mode 100644 index 0000000..3670595 --- /dev/null +++ b/tests/test_views_ucs.py @@ -0,0 +1,338 @@ +"""Named views and UCS on both engines. + +Headless, a named view is a VIEW table entry and "restore" writes the +``*Active`` VPORT (the view the file opens on) — ezdxf's header has no +``$VIEWCTR``; measured. A UCS is a UCS table entry plus the ``$UCS*`` header +variables. Tool coordinates stay WCS on both engines: a UCS is stored and +made current for the operator, and no tool interprets its inputs in it. +""" + +from __future__ import annotations + +import types + +import pytest + +from engineering.environment.ucs import resolve_ucs_axes +from engineering.environment.views import resolve_view_args + +pytestmark = pytest.mark.asyncio + + +# ── pure helpers ──────────────────────────────────────────────────────────── + + +def test_view_args_validate_before_any_write(): + assert resolve_view_args([10, 20], 50, None) == { + "center": (10.0, 20.0), + "height": 50.0, + "width": None, + } + assert resolve_view_args(None, None, None) == {"center": None, "height": None, "width": None} + with pytest.raises(ValueError, match="height must be > 0"): + resolve_view_args(None, 0, None) + with pytest.raises(ValueError, match="width must be > 0"): + resolve_view_args(None, 10, -1) + with pytest.raises(ValueError, match="center"): + resolve_view_args([1], None, None) + with pytest.raises(ValueError, match="finite"): + resolve_view_args([float("nan"), 0], None, None) + + +def test_ucs_axes_are_normalised_and_orthogonality_is_measured(): + axes = resolve_ucs_axes([10, 10, 0], [0, 2, 0], [-3, 0, 0]) + assert axes["origin"] == (10.0, 10.0, 0.0) + assert axes["x_axis"] == (0.0, 1.0, 0.0) and axes["y_axis"] == (-1.0, 0.0, 0.0) + assert axes["z_axis"] == (0.0, 0.0, 1.0) + assert axes["angle_deg"] == pytest.approx(90.0) + with pytest.raises(ValueError, match=r"measured 45\.00"): + resolve_ucs_axes([0, 0, 0], [1, 0, 0], [1, 1, 0]) + with pytest.raises(ValueError, match="x_axis.*zero"): + resolve_ucs_axes([0, 0, 0], [0, 0, 0], [0, 1, 0]) + with pytest.raises(ValueError, match="origin"): + resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0]) + + +# ── headless: named views ─────────────────────────────────────────────────── + + +async def test_view_defaults_to_the_drawing_extents(backend): + await backend.entity_create_line(0, 0, 100, 50) + result = await backend.view_named_save("OVERALL") + assert result["ok"] and result["replaced"] is False + assert result["center"] == [50.0, 25.0] + # extents 100 x 50 at the *Active VPORT's default aspect 1.34: the width + # is the binding side, so height = 100 / 1.34 + assert result["height"] == pytest.approx(100.0 / 1.34) + assert result["width"] == pytest.approx(100.0) + view = backend._doc.views.get("OVERALL") + assert tuple(view.dxf.center)[:2] == (50.0, 25.0) + + +async def test_view_on_an_empty_drawing_needs_an_explicit_window(backend): + with pytest.raises(ValueError, match="no extents"): + await backend.view_named_save("EMPTY") + assert "EMPTY" not in backend._doc.views + result = await backend.view_named_save("EMPTY", center=[0, 0], height=10) + assert result["center"] == [0.0, 0.0] and result["height"] == 10.0 + assert result["width"] == pytest.approx(13.4) + + +async def test_view_replace_restore_and_list(backend): + await backend.view_named_save("V", center=[1, 2], height=10, width=15) + again = await backend.view_named_save("v", center=[5, 5], height=20) + assert again["replaced"] is True and again["name"] == "V" + assert len(list(backend._doc.views)) == 1 + restored = await backend.view_named_restore("v") + assert restored == { + "ok": True, + "name": "V", + "center": [5.0, 5.0], + "height": 20.0, + "width": pytest.approx(26.8), + "applied": "header_only", + "store": "vport_active", + "backend": "ezdxf", + } + (vport,) = backend._doc.viewports.get("*Active") + assert tuple(vport.dxf.center)[:2] == (5.0, 5.0) and vport.dxf.height == 20.0 + assert await backend.view_named_list() == [ + {"name": "V", "center": [5.0, 5.0], "height": 20.0, "width": pytest.approx(26.8)} + ] + with pytest.raises(ValueError, match="no named view 'NOPE'"): + await backend.view_named_restore("NOPE") + with pytest.raises(ValueError, match="height must be > 0"): + await backend.view_named_save("BAD", center=[0, 0], height=0) + assert "BAD" not in backend._doc.views + + +# ── headless: UCS ─────────────────────────────────────────────────────────── + + +async def test_ucs_set_stores_the_entry_and_makes_it_current(backend): + result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0]) + assert result == { + "ok": True, + "name": "FRONT", + "origin": [10.0, 10.0, 0.0], + "x_axis": [0.0, 1.0, 0.0], + "y_axis": [-1.0, 0.0, 0.0], + "replaced": False, + "current": True, + "backend": "ezdxf", + } + header = backend._doc.header + assert header["$UCSNAME"] == "FRONT" and tuple(header["$UCSORG"]) == (10.0, 10.0, 0.0) + assert tuple(header["$UCSXDIR"]) == (0.0, 1.0, 0.0) + rows = await backend.ucs_list() + assert rows[0] == { + "name": "world", + "origin": [0.0, 0.0, 0.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "current": False, + } + assert rows[1]["name"] == "FRONT" and rows[1]["current"] is True + + +async def test_ucs_non_orthogonal_is_refused_before_any_write(backend): + with pytest.raises(ValueError, match="measured 60"): + await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [0.5, 0.8660254, 0]) + assert "SKEW" not in backend._doc.ucs and backend._doc.header["$UCSNAME"] == "" + + +async def test_ucs_restore_world_and_named(backend): + await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0]) + world = await backend.ucs_restore("world") + assert world["name"] == "world" and backend._doc.header["$UCSNAME"] == "" + assert tuple(backend._doc.header["$UCSXDIR"]) == (1.0, 0.0, 0.0) + assert (await backend.ucs_list())[0]["current"] is True + named = await backend.ucs_restore("front") + assert named["name"] == "FRONT" and backend._doc.header["$UCSNAME"] == "FRONT" + with pytest.raises(ValueError, match="no UCS named 'NOPE'"): + await backend.ucs_restore("NOPE") + with pytest.raises(ValueError, match="reserved"): + await backend.ucs_set("world", [0, 0, 0], [1, 0, 0], [0, 1, 0]) + + +async def test_ucs_replace_keeps_one_entry(backend): + await backend.ucs_set("A", [0, 0, 0], [1, 0, 0], [0, 1, 0]) + again = await backend.ucs_set("a", [1, 1, 0], [1, 0, 0], [0, 1, 0]) + assert again["replaced"] is True and again["name"] == "A" + assert len(list(backend._doc.ucs)) == 1 + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _FakeView: + def __init__(self, name): + self.Name = name + self.Center = (0.0, 0.0) + self.Height = 100.0 + self.Width = 150.0 + + +class _FakeViews: + def __init__(self): + self.views: list[_FakeView] = [] + self.calls: list[tuple[str, tuple]] = [] + + @property + def Count(self): + return len(self.views) + + def Item(self, key): + if isinstance(key, int): + return self.views[key] + for v in self.views: + if v.Name.lower() == key.lower(): + return v + raise RuntimeError(f"no view {key}") + + def Add(self, name): + self.calls.append(("Add", (name,))) + v = _FakeView(name) + self.views.append(v) + return v + + +class _FakeUcs: + def __init__(self, name, origin, xv, yv): + self.Name = name + self.Origin = origin + self.XVector = xv + self.YVector = yv + + +class _FakeUcsCollection: + def __init__(self): + self.items: list[_FakeUcs] = [] + self.calls: list[tuple[str, tuple]] = [] + + @property + def Count(self): + return len(self.items) + + def Item(self, key): + if isinstance(key, int): + return self.items[key] + for u in self.items: + if u.Name.lower() == key.lower(): + return u + raise RuntimeError(f"no ucs {key}") + + def Add(self, origin, x_point, y_point, name): + self.calls.append(("Add", (origin, x_point, y_point, name))) + # ActiveX stores unit vectors derived from the two points. + xv = tuple(p - o for p, o in zip(x_point, origin, strict=True)) + yv = tuple(p - o for p, o in zip(y_point, origin, strict=True)) + u = _FakeUcs(name, origin, xv, yv) + self.items.append(u) + return u + + +class _FakeDocument: + def __init__(self): + self.Views = _FakeViews() + self.UserCoordinateSystems = _FakeUcsCollection() + self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0) + self.viewport_sets = 0 + self.ActiveUCS = None + self.commands: list[str] = [] + + @property + def ActiveViewport(self): + return self._viewport + + @ActiveViewport.setter + def ActiveViewport(self, value): + self._viewport = value + self.viewport_sets += 1 + + def SendCommand(self, macro): + self.commands.append(macro) + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = _FakeDocument() + variables = {"CMDACTIVE": 0, "UCSNAME": ""} + app = types.SimpleNamespace(GetVariable=lambda name: variables[name]) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_acad_app", lambda: app) + monkeypatch.setattr(module, "_regen", lambda: None) + monkeypatch.setattr(module, "_apoint", lambda x, y, z=0.0: (float(x), float(y), float(z))) + monkeypatch.setattr(module, "_av", lambda values: tuple(float(v) for v in values)) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document, variables + + +async def test_com_view_save_defaults_to_the_active_viewport(com_backend): + backend, document, _ = com_backend + result = await backend.view_named_save("HOME") + assert document.Views.calls == [("Add", ("HOME",))] + (view,) = document.Views.views + assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0 + assert result["center"] == [30.0, 40.0] and result["replaced"] is False + again = await backend.view_named_save("home", center=[1, 2], height=10) + assert again["replaced"] is True and len(document.Views.views) == 1 + assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0 + + +async def test_com_view_restore_sets_the_active_viewport(com_backend): + backend, document, _ = com_backend + await backend.view_named_save("HOME", center=[5, 6], height=20, width=30) + result = await backend.view_named_restore("HOME") + assert result["applied"] == "active_viewport" + assert document.ActiveViewport.Center == (5.0, 6.0) + assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0 + assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment" + assert await backend.view_named_list() == [ + {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0} + ] + + +async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend): + backend, document, variables = com_backend + result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 5, 0], [-1, 0, 0]) + assert document.UserCoordinateSystems.calls == [ + ("Add", ((10.0, 10.0, 0.0), (10.0, 11.0, 0.0), (9.0, 10.0, 0.0), "FRONT")) + ] + assert document.ActiveUCS is document.UserCoordinateSystems.items[0] + assert result["x_axis"] == [0.0, 1.0, 0.0] and result["current"] is True + variables["UCSNAME"] = "FRONT" + rows = await backend.ucs_list() + assert rows[0]["name"] == "world" and rows[0]["current"] is False + assert rows[1] == { + "name": "FRONT", + "origin": [10.0, 10.0, 0.0], + "x_axis": [0.0, 1.0, 0.0], + "y_axis": [-1.0, 0.0, 0.0], + "current": True, + } + + +async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend): + backend, document, variables = com_backend + result = await backend.ucs_restore("world") + assert document.commands == ["_.UCS _W\n"] and result["name"] == "world" + variables["CMDACTIVE"] = 1 + with pytest.raises(RuntimeError, match="CMDACTIVE"): + await backend.ucs_restore("world") + assert len(document.commands) == 1 + + +async def test_com_ucs_refuses_before_activex(com_backend): + backend, document, _ = com_backend + with pytest.raises(ValueError, match="measured"): + await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [1, 1, 0]) + assert document.UserCoordinateSystems.calls == [] From 4bf686e9433d9ed2067b827aadc3590eb1bbb816 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 10:09:14 +0300 Subject: [PATCH 05/12] fix(task-22): headless named views are written as plan views and restore carries the orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ezdxf's VIEW default is direction=(1, 1, 1) — an isometric view — so every named view the headless engine wrote to disk was oblique while the tool claimed to store the plan window of center/height; AutoCAD restored it in that DCS. view_named_save now writes direction (0,0,1), target (0,0,0) and view_twist 0 explicitly. view_named_restore copies direction/target/twist from the VIEW into the *Active VPORT instead of flattening a foreign oblique view to plan, reading the attributes the way the file carries them (ezdxf forces those tags on export). Measured live on AutoCAD 2026: Views.Add already creates a plan view, so the COM path only gains a note. Tests: the VIEW entry and the reopened DXF assert 11/21/31 = 0/0/1 and the target; a hand-built oblique, twisted VIEW restores its orientation into the VPORT and a tool-saved view puts it back on plan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 3 +++ backends/ezdxf_backend.py | 25 ++++++++++++++++++- tests/test_views_ucs.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 4474c2a..1594725 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1662,6 +1662,9 @@ class ComBackend(AutoCADBackend): view = self._com_view(doc, clean) replaced = view is not None if view is None: + # Measured (AutoCAD 2026): Views.Add creates a plan view — + # Direction (0, 0, 1), Target (0, 0, 0) — so unlike ezdxf's + # VIEW default of (1, 1, 1) nothing has to be forced here. view = doc.Views.Add(clean) view.Center = _av([cx, cy]) view.Height = float(h) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 73b3339..287e3e9 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2576,7 +2576,22 @@ class EzdxfBackend(AutoCADBackend): stored = existing.dxf.name if replaced else clean if replaced: doc.views.remove(stored) - doc.views.add(stored, dxfattribs={"center": (cx, cy, 0.0), "height": h, "width": w}) + # The tool stores a plan window. ezdxf's VIEW default is + # direction=(1, 1, 1) — an isometric view — so the orientation must + # be written explicitly or AutoCAD restores the entry oblique and + # reads ``center`` in that DCS. Measured: the VPORT default is plan, + # which is why a save→restore round trip alone never shows it. + doc.views.add( + stored, + dxfattribs={ + "center": (cx, cy, 0.0), + "height": h, + "width": w, + "direction": (0.0, 0.0, 1.0), + "target": (0.0, 0.0, 0.0), + "view_twist": 0.0, + }, + ) self._mark_dirty() return { "ok": True, @@ -2610,6 +2625,14 @@ class EzdxfBackend(AutoCADBackend): vport.dxf.height = h if h > 0: vport.dxf.aspect_ratio = w / h + # The orientation travels with the window: a foreign drawing's + # VIEW may be oblique or twisted, and flattening it to a plan view + # of the same center would be a silent lie. The attribute read + # mirrors what the file carries: ezdxf forces these tags on export, + # so an unset direction is (1, 1, 1) on disk as well as here. + vport.dxf.direction = tuple(view.dxf.direction) + vport.dxf.target = tuple(view.dxf.target) + vport.dxf.view_twist = float(view.dxf.view_twist) self._mark_dirty() return { "ok": True, diff --git a/tests/test_views_ucs.py b/tests/test_views_ucs.py index 3670595..6943f85 100644 --- a/tests/test_views_ucs.py +++ b/tests/test_views_ucs.py @@ -106,6 +106,57 @@ async def test_view_replace_restore_and_list(backend): assert "BAD" not in backend._doc.views +async def test_view_is_written_as_a_plan_view_on_disk(backend, tmp_path): + """ezdxf's VIEW default is ``direction=(1, 1, 1)`` — an isometric view. The + tool stores the plan window of ``center``/``height``, so the entry must + say so explicitly (DXF 11/21/31, 12/22/32 and 50) or AutoCAD restores it + oblique and reads ``center`` in that DCS.""" + await backend.entity_create_line(0, 0, 100, 50) + await backend.view_named_save("OVERALL") + view = backend._doc.views.get("OVERALL") + assert tuple(view.dxf.direction) == (0.0, 0.0, 1.0) + assert tuple(view.dxf.target) == (0.0, 0.0, 0.0) + assert view.dxf.view_twist == 0.0 + + import ezdxf + + path = tmp_path / "views.dxf" + await backend.drawing_save_as(str(path)) + reopened = ezdxf.readfile(str(path)).views.get("OVERALL") + assert tuple(reopened.dxf.direction) == (0.0, 0.0, 1.0) + assert tuple(reopened.dxf.target) == (0.0, 0.0, 0.0) + assert tuple(reopened.dxf.center)[:2] == (50.0, 25.0) + + +async def test_view_restore_carries_the_orientation_into_the_vport(backend): + """A foreign drawing's VIEW may be oblique or twisted; restoring it must + not silently flatten it to a plan view of the same center.""" + backend._doc.views.add( + "ISO", + dxfattribs={ + "center": (5.0, 6.0, 0.0), + "height": 20.0, + "width": 30.0, + "direction": (1.0, 1.0, 1.0), + "target": (1.0, 2.0, 3.0), + "view_twist": 15.0, + }, + ) + result = await backend.view_named_restore("iso") + assert result["name"] == "ISO" and result["center"] == [5.0, 6.0] + (vport,) = backend._doc.viewports.get("*Active") + assert tuple(vport.dxf.direction) == (1.0, 1.0, 1.0) + assert tuple(vport.dxf.target) == (1.0, 2.0, 3.0) + assert vport.dxf.view_twist == 15.0 + # ...and restoring a view the tool saved puts the VPORT back on plan. + await backend.view_named_save("PLAN", center=[0, 0], height=10) + await backend.view_named_restore("PLAN") + (vport,) = backend._doc.viewports.get("*Active") + assert tuple(vport.dxf.direction) == (0.0, 0.0, 1.0) + assert tuple(vport.dxf.target) == (0.0, 0.0, 0.0) + assert vport.dxf.view_twist == 0.0 + + # ── headless: UCS ─────────────────────────────────────────────────────────── From 64cec37cc70d13ac8df12043ebd5e008f4b8c109 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 15:10:00 +0300 Subject: [PATCH 06/12] =?UTF-8?q?fix(task-22):=20ucs=5Flist=20decides=20"w?= =?UTF-8?q?orld"=20from=20the=20frame,=20not=20the=20empty=20name=20?= =?UTF-8?q?=E2=80=94=20an=20unnamed=20UCS=20is=20its=20own=20current=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unnamed UCS (UCS Origin / 3P without saving, the common kind) has an empty UCSNAME exactly like WCS does, so both engines answered `current: world` while the drawing was not in WCS — verified live on AutoCAD 2026 (`_.UCS _O 10,10,0` → UCSNAME="" WORLDUCS=0) and on the DXF it writes ($UCSNAME='' $UCSORG=(0,20,0)). A silent wrong value. - COM: a named match still wins; otherwise WORLDUCS (AutoCAD's own answer) decides, and WORLDUCS=0 appends a `name: None` row built from UCSORG/UCSXDIR/UCSYDIR. - ezdxf: the same rule against $UCSORG/$UCSXDIR/$UCSYDIR via the new `engineering.environment.ucs.is_world_axes`; a stale $UCSNAME falls back to the frame instead of leaving no row current. - Server `ucs_list` result: `current` is null for an unnamed UCS (never `world`), plus `current_unnamed: bool` so null is unambiguous. - Tests: helper, headless (unnamed row, stale name, and the exact AutoCAD DXF over the wire), and COM fake reading WORLDUCS + the frame variables. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 28 ++++++-- backends/contracts/environment.py | 9 ++- backends/ezdxf_backend.py | 35 ++++++--- engineering/environment/ucs.py | 28 ++++++++ server.py | 12 +++- tests/test_views_ucs.py | 116 +++++++++++++++++++++++++++++- 6 files changed, 209 insertions(+), 19 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 1594725..391f00c 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1733,7 +1733,7 @@ class ComBackend(AutoCADBackend): } async def ucs_list(self) -> list[dict]: - from engineering.environment.ucs import WORLD + from engineering.environment.ucs import WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS def _sync(): app = _acad_app() @@ -1742,15 +1742,33 @@ class ComBackend(AutoCADBackend): rows = [ { "name": WORLD, - "origin": [0.0, 0.0, 0.0], - "x_axis": [1.0, 0.0, 0.0], - "y_axis": [0.0, 1.0, 0.0], - "current": current == "", + "origin": list(WORLD_ORIGIN), + "x_axis": list(WORLD_X_AXIS), + "y_axis": list(WORLD_Y_AXIS), + "current": False, } ] collection = doc.UserCoordinateSystems for index in range(int(collection.Count)): rows.append(self._com_ucs_row(collection.Item(index), current)) + if any(row["current"] for row in rows[1:]): + return rows + # No saved entry is current. UCSNAME is empty for an unnamed UCS + # (UCS Origin / 3P without saving) as well as for WCS — verified + # live (AutoCAD 2026: after `_.UCS _O 10,10,0`, UCSNAME="" and + # WORLDUCS=0). WORLDUCS is AutoCAD's own answer to "is this WCS". + if int(app.GetVariable("WORLDUCS")) == 1: + rows[0]["current"] = True + else: + rows.append( + { + "name": None, + "origin": [float(c) for c in app.GetVariable("UCSORG")], + "x_axis": [float(c) for c in app.GetVariable("UCSXDIR")], + "y_axis": [float(c) for c in app.GetVariable("UCSYDIR")], + "current": True, + } + ) return rows return await self._run(_sync) diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py index 24e8f70..dd0f5bc 100644 --- a/backends/contracts/environment.py +++ b/backends/contracts/environment.py @@ -86,7 +86,14 @@ class EnvironmentContract(ABC): @abstractmethod async def ucs_list(self) -> list[dict]: - """→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world".""" + """→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world". + + Exactly one row is current. An *unnamed* current UCS (``UCS Origin`` / + ``3P`` without saving) is a trailing row with ``name: None`` carrying + its frame — "world" is current only when the frame is the WCS + (WORLDUCS live, the ``$UCS*`` header headless), never because the + name is empty. + """ ... @abstractmethod diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 287e3e9..92fa017 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2692,22 +2692,41 @@ class EzdxfBackend(AutoCADBackend): } async def ucs_list(self) -> list[dict]: - from engineering.environment.ucs import WORLD + from engineering.environment.ucs import ( + WORLD, + WORLD_ORIGIN, + WORLD_X_AXIS, + WORLD_Y_AXIS, + is_world_axes, + ) def _sync(): doc = self._require_doc() - current = str(doc.header.get("$UCSNAME", "") or "") - rows = [self._ucs_row(WORLD, (0, 0, 0), (1, 0, 0), (0, 1, 0), current == "")] + header = doc.header + current = str(header.get("$UCSNAME", "") or "") + origin = tuple(header.get("$UCSORG", WORLD_ORIGIN)) + x_axis = tuple(header.get("$UCSXDIR", WORLD_X_AXIS)) + y_axis = tuple(header.get("$UCSYDIR", WORLD_Y_AXIS)) + rows = [self._ucs_row(WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS, False)] + named_current = False for ucs in doc.ucs: + is_current = current != "" and ucs.dxf.name.lower() == current.lower() + named_current = named_current or is_current rows.append( self._ucs_row( - ucs.dxf.name, - ucs.dxf.origin, - ucs.dxf.xaxis, - ucs.dxf.yaxis, - current != "" and ucs.dxf.name.lower() == current.lower(), + ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis, is_current ) ) + if named_current: + return rows + # No saved entry is current. An empty $UCSNAME is NOT "world": an + # unnamed UCS (UCS Origin / 3P without saving) has an empty name + # too, and AutoCAD writes exactly that. The frame decides, the way + # WORLDUCS does live. + if is_world_axes(origin, x_axis, y_axis): + rows[0]["current"] = True + else: + rows.append(self._ucs_row(None, origin, x_axis, y_axis, True)) return rows return await self._async(_sync) diff --git a/engineering/environment/ucs.py b/engineering/environment/ucs.py index 6314729..3e95e7b 100644 --- a/engineering/environment/ucs.py +++ b/engineering/environment/ucs.py @@ -12,6 +12,34 @@ import math WORLD = "world" ORTHO_TOLERANCE_DEG = 1e-3 +#: The WCS frame — what ``$UCSORG`` / ``$UCSXDIR`` / ``$UCSYDIR`` (and the +#: UCSORG / UCSXDIR / UCSYDIR system variables) hold while no UCS is active. +WORLD_ORIGIN = (0.0, 0.0, 0.0) +WORLD_X_AXIS = (1.0, 0.0, 0.0) +WORLD_Y_AXIS = (0.0, 1.0, 0.0) +WORLD_TOLERANCE = 1e-9 + + +def is_world_axes(origin, x_axis, y_axis, *, tol: float = WORLD_TOLERANCE) -> bool: + """True when the frame *is* the WCS — the test ``$UCSNAME == ""`` cannot make. + + An unnamed UCS (``UCS Origin`` / ``UCS 3P`` without saving — the common + kind) also has an empty name, so "is the drawing in WCS" has to be + answered from the frame itself: AutoCAD's own answer is WORLDUCS, and + this is the same comparison for a header that has no such variable. + """ + for got, want in ( + (origin, WORLD_ORIGIN), + (x_axis, WORLD_X_AXIS), + (y_axis, WORLD_Y_AXIS), + ): + coords = tuple(float(c) for c in got) + if len(coords) != 3: + return False + if any(abs(a - b) > tol for a, b in zip(coords, want, strict=True)): + return False + return True + def _vec3(value, where: str) -> tuple[float, float, float]: if not isinstance(value, (list, tuple)) or len(value) != 3: diff --git a/server.py b/server.py index cbdb451..0c34a30 100644 --- a/server.py +++ b/server.py @@ -7050,13 +7050,19 @@ async def view_named_list(ctx: Context = None) -> dict: ) async def ucs_list(ctx: Context = None) -> dict: """The `world` row first, then every UCS table entry with origin and unit - axes, `current` on the active one. Tool coordinates stay WCS on both - engines whatever is current. No refusals. Pack: settings · lean: no.""" + axes, `current` on the active one. `world` is current only when the frame + IS the WCS (WORLDUCS live, the `$UCS*` header headless) — an unnamed UCS + (`UCS Origin` / `3P` without saving) has an empty name too and is + reported as a trailing row with `name: null` and its origin/axes, so + `current` is `null` then and never `world`. Tool coordinates stay WCS on + both engines whatever is current. No refusals. Pack: settings · lean: no.""" rows = await _backend(ctx).ucs_list() + active = next((r for r in rows if r["current"]), None) return { "ucs": rows, "count": len(rows), - "current": next((r["name"] for r in rows if r["current"]), None), + "current": active["name"] if active else None, + "current_unnamed": bool(active and active["name"] is None), } diff --git a/tests/test_views_ucs.py b/tests/test_views_ucs.py index 6943f85..7d36b7f 100644 --- a/tests/test_views_ucs.py +++ b/tests/test_views_ucs.py @@ -12,8 +12,10 @@ from __future__ import annotations import types import pytest +from fastmcp import Client -from engineering.environment.ucs import resolve_ucs_axes +import server +from engineering.environment.ucs import is_world_axes, resolve_ucs_axes from engineering.environment.views import resolve_view_args pytestmark = pytest.mark.asyncio @@ -53,6 +55,14 @@ def test_ucs_axes_are_normalised_and_orthogonality_is_measured(): resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0]) +def test_world_is_decided_by_the_frame_not_the_name(): + assert is_world_axes((0, 0, 0), (1, 0, 0), (0, 1, 0)) + assert is_world_axes([0.0, 0.0, 1e-12], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + assert not is_world_axes((0, 20, 0), (1, 0, 0), (0, 1, 0)) # UCS Origin + assert not is_world_axes((0, 0, 0), (0, 1, 0), (-1, 0, 0)) # rotated 90° + assert not is_world_axes((0, 0), (1, 0, 0), (0, 1, 0)) + + # ── headless: named views ─────────────────────────────────────────────────── @@ -213,6 +223,74 @@ async def test_ucs_replace_keeps_one_entry(backend): assert len(list(backend._doc.ucs)) == 1 +async def test_ucs_list_does_not_call_an_unnamed_ucs_world(backend): + """``UCS Origin`` / ``UCS 3P`` without saving — the common kind — leaves + ``$UCSNAME`` empty exactly like WCS does (AutoCAD 2026 writes + ``$UCSNAME=''``, ``$UCSORG=(0,20,0)`` after ``_.UCS _O``). The frame, not + the name, says whether the drawing is in WCS.""" + await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0]) + header = backend._doc.header + header["$UCSNAME"] = "" + header["$UCSORG"] = (50.0, 20.0, 0.0) + header["$UCSXDIR"] = (0.0, 1.0, 0.0) + header["$UCSYDIR"] = (-1.0, 0.0, 0.0) + rows = await backend.ucs_list() + assert [r["name"] for r in rows] == ["world", "FRONT", None] + assert [r["current"] for r in rows] == [False, False, True] + assert rows[2] == { + "name": None, + "origin": [50.0, 20.0, 0.0], + "x_axis": [0.0, 1.0, 0.0], + "y_axis": [-1.0, 0.0, 0.0], + "current": True, + } + # ...and the same empty name with the WCS frame really is world. + await backend.ucs_restore("world") + rows = await backend.ucs_list() + assert [r["name"] for r in rows] == ["world", "FRONT"] + assert rows[0]["current"] is True and rows[1]["current"] is False + + +async def test_ucs_list_a_stale_name_falls_back_to_the_frame(backend): + """``$UCSNAME`` naming an entry the table no longer holds is not a + current row; the frame decides between world and unnamed.""" + header = backend._doc.header + header["$UCSNAME"] = "GONE" + rows = await backend.ucs_list() + assert [(r["name"], r["current"]) for r in rows] == [("world", True)] + header["$UCSORG"] = (0.0, 20.0, 0.0) + rows = await backend.ucs_list() + assert [(r["name"], r["current"]) for r in rows] == [("world", False), (None, True)] + assert rows[1]["origin"] == [0.0, 20.0, 0.0] + + +async def test_ucs_list_tool_reports_an_unnamed_ucs_over_the_wire(monkeypatch, tmp_path): + """The exact file AutoCAD writes after ``_.UCS _O 0,20,0`` without saving: + ``$UCSNAME=''`` and ``$UCSORG=(0,20,0)``. The tool used to answer + ``current: "world"``.""" + import ezdxf + + doc = ezdxf.new("R2018") + doc.ucs.add( + "FRONT", dxfattribs={"origin": (10, 10, 0), "xaxis": (0, 1, 0), "yaxis": (-1, 0, 0)} + ) + doc.header["$UCSNAME"] = "" + doc.header["$UCSORG"] = (0.0, 20.0, 0.0) + path = tmp_path / "unnamed_ucs.dxf" + doc.saveas(str(path)) + + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as client: + await client.call_tool("drawing_open", {"path": str(path)}) + payload = (await client.call_tool("ucs_list", {})).structured_content + assert payload["current"] is None and payload["current_unnamed"] is True + assert payload["count"] == 3 + assert [r["name"] for r in payload["ucs"]] == ["world", "FRONT", None] + assert payload["ucs"][0]["current"] is False + assert payload["ucs"][2]["current"] is True + assert payload["ucs"][2]["origin"] == [0.0, 20.0, 0.0] + + # ── live engine, against a fake ActiveX surface ────────────────────────────── @@ -311,7 +389,14 @@ def com_backend(monkeypatch): from backends import com_backend as module document = _FakeDocument() - variables = {"CMDACTIVE": 0, "UCSNAME": ""} + variables = { + "CMDACTIVE": 0, + "UCSNAME": "", + "WORLDUCS": 1, + "UCSORG": (0.0, 0.0, 0.0), + "UCSXDIR": (1.0, 0.0, 0.0), + "UCSYDIR": (0.0, 1.0, 0.0), + } app = types.SimpleNamespace(GetVariable=lambda name: variables[name]) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) @@ -372,6 +457,33 @@ async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend): } +async def test_com_ucs_list_reads_worlducs_not_the_empty_name(com_backend): + """Verified live on AutoCAD 2026: after ``_.UCS _O 10,10,0`` (unnamed), + ``UCSNAME=""`` and ``WORLDUCS=0`` — the same empty name WCS has.""" + backend, document, variables = com_backend + await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0]) + variables.update( + { + "UCSNAME": "", + "WORLDUCS": 0, + "UCSORG": (0.0, 20.0, 0.0), + "UCSXDIR": (1.0, 0.0, 0.0), + "UCSYDIR": (0.0, 1.0, 0.0), + } + ) + rows = await backend.ucs_list() + assert [(r["name"], r["current"]) for r in rows] == [ + ("world", False), + ("FRONT", False), + (None, True), + ] + assert rows[2]["origin"] == [0.0, 20.0, 0.0] and rows[2]["x_axis"] == [1.0, 0.0, 0.0] + # WORLDUCS=1 with the same empty name is world, and no unnamed row appears. + variables.update({"WORLDUCS": 1, "UCSORG": (0.0, 0.0, 0.0)}) + rows = await backend.ucs_list() + assert [(r["name"], r["current"]) for r in rows] == [("world", True), ("FRONT", False)] + + async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend): backend, document, variables = com_backend result = await backend.ucs_restore("world") From b1c93cdd2a71ab8483b3110db092d7ef30f50abc Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 23:17:39 +0300 Subject: [PATCH 07/12] =?UTF-8?q?fix(task-21):=20the=20applied=20ISO=20lay?= =?UTF-8?q?er=20set=20is=20per=20document=20=E2=80=94=20it=20follows=20doc?= =?UTF-8?q?ument=5Factivate=20on=20ezdxf=20and=20is=20cleared=20on=20every?= =?UTF-8?q?=20document=20switch=20on=20both=20engines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drawing_apply_iso_layers` recorded its standard in `_active_layer_set` on the backend, and `_role_layer` reads it ahead of the PlanSpec to decide where `dimension_auto` / `construction_xline` file their output. It was neither a `_DocState` field nor cleared by `_reset_document_state`, so an iso13567 sheet next to a mech one pushed the mech drawing's dimensions onto `M-DIMEN-T-N` (created on demand), silently — and `drawing_critique` could not see it because the same stale set drove the critique. The attribute predates the registry (v1.5 `drawing_new` did not reset it either), but the per-document claim of Task 21 covered it and the measured attribute list missed it. - `_DocState.active_layer_set` + `_ActiveField("active_layer_set")` on `EzdxfBackend`: the set follows the active entry, bidirectionally. - `PremiumContract._reset_document_state` clears it (declared on the mixin like `_plan_spec`), which fixes COM's `_ensure_document_state` switch and `drawing_new` / `drawing_open` / `disconnect` on both engines. - tests: `test_active_layer_set_is_per_document` (headless registry, the reviewer's repro plus the reverse direction) and the COM activate test now also asserts the set is cleared and `_role_layer('dim')` is back on DIM. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/contracts/premium.py | 7 +++++++ backends/ezdxf_backend.py | 6 ++++++ tests/test_documents.py | 36 +++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/backends/contracts/premium.py b/backends/contracts/premium.py index a33679b..f1f090b 100644 --- a/backends/contracts/premium.py +++ b/backends/contracts/premium.py @@ -36,11 +36,18 @@ class PremiumContract(ABC): _plan_spec: PlanSpec | None = None _preflight_result: Any = None + # The standard `drawing_apply_iso_layers` last bootstrapped on *this* + # drawing (None until it runs) — `_active_layer_set_id` reads it ahead of + # the PlanSpec. Drawing-scoped like the plan: a live backend clears it + # when AutoCAD's active document changes, the headless registry keeps one + # per open document. + _active_layer_set: LayerSetId | None = None def _reset_document_state(self) -> None: """Clear metadata that is valid only for the current drawing.""" self._plan_spec = None self._preflight_result = None + self._active_layer_set = None self._gdt_datums_defined = set() self._gdt_datums_referenced = set() diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 92fa017..b119a58 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -977,6 +977,11 @@ class _DocState: transaction_stack: list[Path] = field(default_factory=list) plan_spec: Any = None preflight_result: Any = None + # `drawing_apply_iso_layers` records the standard here so `_role_layer` + # files dims / scaffolding on that standard's layer; it is per drawing, + # not per backend (an iso13567 sheet next to a mech one must not push + # the mech drawing's dimensions onto M-DIMEN-T-N). + active_layer_set: str | None = None gdt_datums_defined: set = field(default_factory=set) gdt_datums_referenced: set = field(default_factory=set) activated_seq: int = 0 @@ -1033,6 +1038,7 @@ class EzdxfBackend(AutoCADBackend): _transaction_stack = _ActiveField("transaction_stack") _plan_spec = _ActiveField("plan_spec") _preflight_result = _ActiveField("preflight_result") + _active_layer_set = _ActiveField("active_layer_set") _gdt_datums_defined = _ActiveField("gdt_datums_defined") _gdt_datums_referenced = _ActiveField("gdt_datums_referenced") diff --git a/tests/test_documents.py b/tests/test_documents.py index 7b3b600..4a159d3 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -88,6 +88,39 @@ async def test_per_document_state_follows_the_active_entry(backend): assert backend._dirty is False +async def test_active_layer_set_is_per_document(backend): + """The standard ``drawing_apply_iso_layers`` bootstrapped is per drawing. + + It drives ``_role_layer`` — where ``dimension_auto`` and + ``construction_xline`` file their output — and the critique that checks + them. Held on the backend it bled across documents: an iso13567 sheet + next to a mech one pushed the mech drawing's dimensions onto + ``M-DIMEN-T-N`` (created on demand), silently, and ``drawing_critique`` + could not see it because the same stale set drove the critique. + """ + await backend.drawing_apply_iso_layers("iso13567") + assert backend._role_layer("dim") == "M-DIMEN-T-N" + + second = (await backend.drawing_new())["document"] + assert backend._active_layer_set is None, "a fresh drawing has no explicit set" + assert backend._role_layer("dim") == "DIM" + line = await backend.entity_create_line(0, 0, 50, 0) + dims = await backend.dimension_auto([line.handle], style="chain") + assert dims[0].layer == "DIM" + assert "M-DIMEN-T-N" not in [lyr.name for lyr in await backend.layer_list()] + + # Bidirectional: switching back restores the first document's set … + await backend.document_activate("untitled-1") + assert backend._active_layer_set == "iso13567" + assert backend._role_layer("construction") == "M-CONST-E-N" + # … and a set applied on B while A is open does not reach A. + await backend.document_activate(second) + await backend.drawing_apply_iso_layers("pid") + assert backend._role_layer("dim") == "DIM" + await backend.document_activate("untitled-1") + assert backend._active_layer_set == "iso13567" + + async def test_opening_a_file_twice_reloads_it_instead_of_registering_twice(backend, tmp_path): path = tmp_path / "part.dxf" await backend.drawing_save(str(path)) @@ -495,6 +528,7 @@ async def test_com_activate_calls_activate_and_resets_document_scope(com_backend backend, app = com_backend await backend._ensure_document_state() backend._plan_spec = object() + backend._active_layer_set = "iso13567" result = await backend.document_activate("Second.dwg") assert result == { "ok": True, @@ -505,6 +539,8 @@ async def test_com_activate_calls_activate_and_resets_document_scope(com_backend } assert app.Documents._docs[1].calls == [("Activate", ())] assert backend._plan_spec is None, "a document switch clears drawing-scoped state" + assert backend._active_layer_set is None, "the layer set is drawing-scoped too" + assert backend._role_layer("dim") == "DIM" with pytest.raises(ValueError, match="no open document named 'Third.dwg'"): await backend.document_activate("Third.dwg") From 31daac022f3075eaee6aaf969c357bee1f34d0c6 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 23:37:21 +0300 Subject: [PATCH 08/12] =?UTF-8?q?fix(task-21):=20layout=5Fcreate,=20layout?= =?UTF-8?q?=5Fset=5Fcurrent=20and=20viewport=5Fcreate=20mark=20the=20headl?= =?UTF-8?q?ess=20document=20dirty=20=E2=80=94=20the=20close=20refusal=20an?= =?UTF-8?q?d=20the=20saved=20row=20now=20see=20them,=20and=20each=20gets?= =?UTF-8?q?=20an=20undo=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document_close's promise (never drop unsaved work unless told to) and the `saved` row of document_list are only as good as _DocState.dirty, and three headless mutators never set it: a new layout tab, the $TILEMODE / active layout write, and a paper-space VIEWPORT all changed the file yet closed without refusal, reported discarded_changes: False, and were absent on disk. COM refused the same sequence (Saved is AutoCAD's DBMOD), so the two engines disagreed. Each now calls _mark_dirty() after its write, which also pushes the undo snapshot every other mutator already has. The regression test runs the save -> mutate -> list/close/undo/redo -> close with save sequence for all three and checks the change reached the file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 6 +++ tests/test_documents.py | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index b119a58..739cc25 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2035,6 +2035,7 @@ class EzdxfBackend(AutoCADBackend): if existing is not None: return {"ok": False, "error": f"Layout already exists: {existing}"} doc.layouts.new(wanted) + self._mark_dirty() return {"ok": True, "layout": wanted} return await self._async(_sync) @@ -2056,6 +2057,10 @@ class EzdxfBackend(AutoCADBackend): # Stored in the canonical spelling so _msp and _resync_space # compare against one form. self._current_space = resolved + # $TILEMODE and the active-layout binding are persisted state: + # the file on disk differs from the document after this, so the + # close refusal and the ``saved`` row must know about it. + self._mark_dirty() return {"ok": True, "current": resolved} return await self._async(_sync) @@ -2273,6 +2278,7 @@ class EzdxfBackend(AutoCADBackend): view_center_point=(view_center_x, view_center_y), view_height=view_height, ) + self._mark_dirty() return { "ok": True, "handle": viewport.dxf.handle, diff --git a/tests/test_documents.py b/tests/test_documents.py index 4a159d3..9d07181 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -25,6 +25,7 @@ import types import pytest from fastmcp import Client +import config import server from backends.ezdxf_backend import EzdxfBackend @@ -334,6 +335,92 @@ async def test_close_unsaved_refuses_unless_discard(backend): } +async def _create_layout(backend): + assert (await backend.layout_create("A3-Sheet"))["ok"] is True + + +async def _set_current_layout(backend): + assert (await backend.layout_set_current("Layout1"))["ok"] is True + + +async def _create_viewport(backend): + result = await backend.viewport_create("Layout1", 150, 100, 200, 150, 0, 0, 1.0) + assert result["ok"] is True + + +def _layout_names(path): + import ezdxf + + return set(ezdxf.readfile(str(path)).layouts.names()) + + +def _layout1_has_viewport(path): + import ezdxf + + return any( + e.dxftype() == "VIEWPORT" and e.dxf.id != 1 + for e in ezdxf.readfile(str(path)).layouts.get("Layout1") + ) + + +def _tilemode(path): + import ezdxf + + return int(ezdxf.readfile(str(path)).header.get("$TILEMODE", 1)) + + +@pytest.mark.parametrize( + ("mutate", "changed_on_disk"), + [ + (_create_layout, lambda p: "A3-Sheet" in _layout_names(p)), + (_set_current_layout, lambda p: _tilemode(p) == 0), + (_create_viewport, _layout1_has_viewport), + ], + ids=["layout_create", "layout_set_current", "viewport_create"], +) +async def test_layout_and_viewport_mutators_mark_the_document_unsaved( + monkeypatch, tmp_path, mutate, changed_on_disk +): + """The close refusal is only as good as the dirty flag behind it. + + ``layout_create``, ``layout_set_current`` and ``viewport_create`` change + the file (a new layout tab, ``$TILEMODE`` plus the active layout, a + VIEWPORT) but used to leave ``_DocState.dirty`` untouched, so + ``document_list`` reported ``saved: True``, ``document_close`` closed + without refusing and reported ``discarded_changes: False`` while the work + was gone — and the live engine, whose ``Saved`` is AutoCAD's DBMOD, + refused the same sequence. Marking dirty also gives each an undo step, + which is why undo is switched on here before the document exists (the + S0 snapshot is seeded by ``drawing_new`` only when history is enabled). + """ + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_new() + path = tmp_path / "sheet.dxf" + await backend.drawing_save(str(path)) + assert (await backend.document_list())[0]["saved"] is True + assert not changed_on_disk(path) + + await mutate(backend) + + assert (await backend.document_list())[0]["saved"] is False + with pytest.raises(ValueError, match="unsaved changes"): + await backend.document_close(None) + assert len(await backend.document_list()) == 1, "a refusal closes nothing" + + # Every mutator pushes an undo snapshot; these must too. + assert (await backend.drawing_undo())["undo_depth"] == 0 + assert (await backend.drawing_redo())["ok"] is True + + result = await backend.document_close(None, save=True) + assert result["saved"] is True and result["discarded_changes"] is False + assert changed_on_disk(path) + finally: + await backend.disconnect() + + async def test_close_with_save_writes_the_file(backend, tmp_path): path = tmp_path / "saved.dxf" await backend.drawing_save(str(path)) From bbc9aeeb63de279aaf08645ef447a6f80140f3c3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Fri, 18 Sep 2026 10:08:12 +0300 Subject: [PATCH 09/12] =?UTF-8?q?fix(task-21):=20registry=20identity=20is?= =?UTF-8?q?=20the=20file=20(st=5Fdev/st=5Fino),=20not=20its=20spelling=20?= =?UTF-8?q?=E2=80=94=20a=20hard=20link,=20junction=20or=20case=20variant?= =?UTF-8?q?=20reloads=20instead=20of=20registering=20twice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_file_identity` keyed on normcase(abspath), which folds case and separators only, and only on Windows. Two names for one file — a hard link (which Path.resolve() cannot fold either), an NTFS junction reached in-process, or a case variant on macOS's case-insensitive APFS where normcase is the identity — registered two entries: both rows said `saved: True`, a save from either overwrote the other's bytes, and closing the 'clean' one dropped its work as `discarded_changes: False`. Identity now asks the disk: `os.stat` (st_dev, st_ino) when the path exists (the samefile identity), `normcase(abspath)` only for a path not on disk yet (a save target, or an owner whose file was removed) or a filesystem that reports no inode. `_register_document`, `_path_holder`, `_resolve_document_key`, the `_doc_path` setter and the quarantine's same-path warning all go through the one helper. An untitled entry holds no file and never matches a path. Tests: the `_case_folding_fs` guard probes the temp filesystem instead of `os.path.normcase`, so the case-variant tests run on macOS where they used to skip while the bug was live. New: hard link (backend and over the wire), junction (mklink /J; a directory symlink elsewhere), a case variant with normcase given its POSIX definition, and the spelling fallback for a save target that is not on disk. Four of the five fail on the previous backend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 65 ++++++++++------ tests/test_documents.py | 158 +++++++++++++++++++++++++++++++++++++- 2 files changed, 196 insertions(+), 27 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 739cc25..63fb8de 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1109,9 +1109,9 @@ class EzdxfBackend(AutoCADBackend): ) state = self._state state.path = value - # Re-key only when the *file* changes: another spelling of the file - # the entry already holds (a case variant on Windows) keeps the key a - # caller may be holding. + # Re-key only when the *file* changes: another name for the file the + # entry already holds (a case variant, a hard link, a junction) keeps + # the key a caller may be holding. if not self._same_file(new_key, self._active_key): del self._docs[self._active_key] state.key = new_key @@ -1119,19 +1119,35 @@ class EzdxfBackend(AutoCADBackend): self._active_key = new_key @staticmethod - def _file_identity(path: str) -> str: - """The string two spellings of one file share: ``normcase(abspath(path))``. + def _file_identity(path: str) -> tuple: + """What two names of one file share - the *file*, not its spelling. - Every registry comparison goes through this. On Windows it folds case - and separators, so ``C:\\Work\\Gear.dxf`` and ``c:/work/gear.dxf`` are one - file; on POSIX ``normcase`` is the identity and only ``..`` / ``.`` / - duplicate separators are folded. Keys themselves stay spelled as the - caller spelled them - the identity is for comparing, not for showing. + Every registry comparison goes through this. A path that exists is + identified by ``os.stat`` ``(st_dev, st_ino)`` - the same identity + ``os.path.samefile`` uses - so a case variant on a case-insensitive + volume (NTFS, default APFS), a hard link, an NTFS junction or a + symlink all name the one file, and the registry keeps one entry for + it. ``normcase(abspath(path))`` was the earlier rule and folded only + case and separators, and only on Windows: two names for one file + registered twice, both rows said ``saved``, and a save from either + overwrote the other's bytes. It stays as the fallback for a path that + is not on disk yet (a save target) or that the OS cannot stat, and for + a filesystem that reports no inode. Keys themselves stay spelled as + the caller spelled them - the identity is for comparing, not showing. """ try: - return os.path.normcase(os.path.abspath(path)) + absolute = os.path.abspath(path) except (OSError, ValueError): # a path this OS cannot normalise - return os.path.normcase(path) + return ("spelling", os.path.normcase(path)) + try: + st = os.stat(absolute) + except (OSError, ValueError): + return ("spelling", os.path.normcase(absolute)) + if st.st_ino == 0 and st.st_dev == 0: + # A filesystem without stable file identity (some network shares): + # fall back to the spelling rather than call every file one file. + return ("spelling", os.path.normcase(absolute)) + return ("file", st.st_dev, st.st_ino) @classmethod def _same_file(cls, a: str, b: str) -> bool: @@ -1140,17 +1156,16 @@ class EzdxfBackend(AutoCADBackend): def _path_holder(self, path: str, *, exclude: str | None = None) -> str | None: """Key of the entry (other than ``exclude``) that holds ``path``, or None. - Compared by ``_file_identity`` so that on Windows two spellings of one - file count as the same file; on POSIX that is plain equality. + Compared by ``_file_identity``: the entry's path and its key are both + names of the file it holds, so either matching ``path`` - by inode + when both are on disk, by ``normcase`` spelling otherwise - means the + file is held. An untitled entry holds no file and never matches. """ wanted = self._file_identity(path) for key, state in self._docs.items(): - if key == exclude: + if key == exclude or not state.path: continue - candidates = [key] - if state.path: - candidates.append(state.path) - if any(self._file_identity(c) == wanted for c in candidates): + if any(self._file_identity(c) == wanted for c in (key, state.path)): return key return None @@ -1318,8 +1333,9 @@ class EzdxfBackend(AutoCADBackend): its place — the pre-registry behaviour of ``drawing_open``, kept so a save-then-reopen verification still reads the disk. "Already open" is decided by ``_path_holder`` (file identity), not by the key string: - the same file spelled with another case used to register a second - entry, after which every lookup of either was refused as ambiguous. + the same file under another name (a case variant, a hard link, a + junction) used to register a second entry, after which a save from + either silently overwrote the other's bytes. """ if path is None: self._untitled_counter += 1 @@ -1365,9 +1381,10 @@ class EzdxfBackend(AutoCADBackend): # say so rather than hand back whichever entry the key names. self._refuse_shared_path(wanted, self._docs[wanted]) return wanted - # Paths compare by file identity (``normcase`` on Windows), as the - # live engine's ``_com_find_document`` lowercases before comparing: a - # lowercase drive letter or a case variant names the same document. + # Paths compare by file identity (the inode when the file is on disk), + # as the live engine's ``_com_find_document`` resolves before comparing: + # a lowercase drive letter, a case variant, a hard link or a junction + # names the same document. as_path = self._file_identity(wanted) by_path = [ key diff --git a/tests/test_documents.py b/tests/test_documents.py index 9d07181..16a9e08 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -20,6 +20,7 @@ it; the strict path is the new tool. from __future__ import annotations import os +import tempfile import types import pytest @@ -213,13 +214,56 @@ async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend, assert len(await backend.document_list()) == 2, "a refusal closes nothing" -# Two spellings of one file only exist where the filesystem folds case; -# ``os.path.normcase`` is the identity on POSIX, so these tests skip there. +def _filesystem_folds_case() -> bool: + """Whether the temp filesystem answers to a case variant of a file's name. + + Probed on disk rather than read off ``os.path.normcase``: the platform's + ``normcase`` is the identity on macOS while the default APFS volume folds + case, so a ``normcase`` guard skipped every case-variant test exactly + where the old spelling-based registry identity was wrong. + """ + with tempfile.TemporaryDirectory() as folder: + probe = os.path.join(folder, "Probe.dxf") + with open(probe, "w", encoding="utf-8"): + pass + return os.path.exists(os.path.join(folder, "probe.dxf")) + + +# Two spellings of one file only exist where the filesystem folds case. _case_folding_fs = pytest.mark.skipif( - os.path.normcase("A") == "A", reason="filesystem is case-sensitive" + not _filesystem_folds_case(), reason="filesystem is case-sensitive" ) +def _hard_link(target: str, link: str) -> None: + try: + os.link(target, link) + except (OSError, NotImplementedError, AttributeError) as exc: + pytest.skip(f"hard links unavailable here: {exc}") + + +def _junction(target_dir: str, link_dir: str) -> None: + """A second name for a directory: an NTFS junction, or a symlink elsewhere.""" + if os.name == "nt": + try: + import _winapi + + _winapi.CreateJunction(target_dir, link_dir) + return + except (ImportError, AttributeError, OSError) as exc: + pytest.skip(f"NTFS junctions unavailable here: {exc}") + try: + os.symlink(target_dir, link_dir, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"directory symlinks unavailable here: {exc}") + + +def _modelspace_types(path) -> list[str]: + import ezdxf + + return [e.dxftype() for e in ezdxf.readfile(str(path)).modelspace()] + + @_case_folding_fs async def test_opening_a_case_variant_of_an_open_file_reloads_it(backend, tmp_path): """Registration keys by file identity, not by spelling. @@ -278,6 +322,93 @@ async def test_saving_to_a_case_variant_of_the_own_path_keeps_one_entry(backend, assert (await backend.document_activate(key))["ok"] is True +async def test_a_hard_link_to_an_open_file_reloads_instead_of_registering_twice(backend, tmp_path): + """Registry identity is the file (``st_dev``/``st_ino``), not the spelling. + + ``normcase(abspath)`` folded case and separators only, so a hard link - + which ``Path.resolve()`` cannot fold either - registered a second entry: + both rows said ``saved: True``, a save from either overwrote the other's + bytes, and closing the 'clean' one dropped its work as ``discarded_changes: + False``. + """ + target = tmp_path / "gear.dxf" + link = tmp_path / "gear_link.dxf" + await backend.drawing_save(str(target)) + _hard_link(str(target), str(link)) + assert os.path.samefile(target, link) + await backend.entity_create_line(0, 0, 1, 1) # unsaved edit + + reopened = await backend.drawing_open(str(link)) + assert reopened["reloaded"] is True and reopened["open_documents"] == 1 + rows = await backend.document_list() + assert len(rows) == 1 and rows[0]["entity_count"] == 0, "the reload reads the disk" + key = rows[0]["key"] + + await backend.entity_create_circle(0, 0, 5) + await backend.drawing_save() + assert _modelspace_types(target) == ["CIRCLE"], "one entry, one file, one save" + assert (await backend.document_activate(str(target)))["active"] == key + assert (await backend.document_activate(str(link)))["active"] == key + + +async def test_a_junction_to_an_open_file_reloads_instead_of_registering_twice(backend, tmp_path): + """Same rule through a directory junction (``mklink /J``), which + ``os.path.abspath`` leaves untouched - the route in-process callers take.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + target = real_dir / "gear.dxf" + await backend.drawing_save(str(target)) + _junction(str(real_dir), str(tmp_path / "via_junction")) + aliased = tmp_path / "via_junction" / "gear.dxf" + assert os.path.samefile(target, aliased) + await backend.entity_create_line(0, 0, 1, 1) + + reopened = await backend.drawing_open(str(aliased)) + assert reopened["reloaded"] is True and reopened["open_documents"] == 1 + await backend.entity_create_circle(0, 0, 5) + await backend.drawing_save() + assert _modelspace_types(target) == ["CIRCLE"] + + await backend.drawing_new() + with pytest.raises(ValueError, match="already open as document"): + await backend.drawing_save_as(str(aliased)) + assert _modelspace_types(target) == ["CIRCLE"], "a refusal writes nothing" + closed = await backend.document_close(str(aliased)) + assert closed["discarded_changes"] is False and closed["open_documents"] == 1 + + +@_case_folding_fs +async def test_case_variant_identity_does_not_depend_on_normcase(backend, tmp_path, monkeypatch): + """macOS: ``os.path.normcase`` is the identity, the default APFS volume + folds case. Reproduced here by giving ``normcase`` its POSIX definition - + the registry must still see one file, because it asks the disk.""" + monkeypatch.setattr(os.path, "normcase", lambda s: s) + path = tmp_path / "Gear.dxf" + await backend.drawing_save(str(path)) + await backend.entity_create_line(0, 0, 1, 1) + + reopened = await backend.drawing_open(str(path).lower()) + assert reopened["reloaded"] is True and reopened["open_documents"] == 1 + await backend.entity_create_circle(0, 0, 5) + await backend.drawing_save() + assert _modelspace_types(path) == ["CIRCLE"] + assert len(await backend.document_list()) == 1 + + +async def test_save_target_that_does_not_exist_yet_is_compared_by_spelling(backend, tmp_path): + """A path with no file behind it cannot be stat'ed; it is still one file + with the spelling that another entry holds (an owner whose file was + removed from disk), and a save onto it is refused.""" + path = tmp_path / "gone.dxf" + await backend.drawing_save(str(path)) + path.unlink() + await backend.drawing_new() + with pytest.raises(ValueError, match="already open as document"): + await backend.drawing_save_as(str(path)) + assert not path.exists(), "a refusal writes nothing" + assert (await backend.drawing_save_as(str(tmp_path / "other.dxf")))["ok"] is True + + async def test_drawing_close_with_save_refuses_a_shared_path(backend, tmp_path): """``drawing_close`` bypasses the resolver, so the close itself must check. @@ -532,6 +663,27 @@ async def test_document_activate_accepts_a_lowercase_drive_over_the_wire(monkeyp assert closed["closed"] == os.path.abspath(spelled) and closed["open_documents"] == 1 +async def test_hard_link_over_the_wire_is_one_document(monkeypatch, tmp_path): + """Route (a) of the review finding: ``validate_path().resolve()`` does not + fold a hard link, so the wire hands the backend a second name for the + open file. One entry, one save, and the disk holds the last edit.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + target = tmp_path / "gear.dxf" + link = tmp_path / "gear_link.dxf" + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {"bootstrap": False}) + await client.call_tool("drawing_save", {"path": str(target)}) + _hard_link(str(target), str(link)) + await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 1, "y2": 1}) + opened = (await client.call_tool("drawing_open", {"path": str(link)})).structured_content + assert opened["reloaded"] is True and opened["open_documents"] == 1 + await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 5}) + await client.call_tool("drawing_save", {}) + rows = (await client.call_tool("document_list", {})).structured_content + assert rows["count"] == 1 and rows["documents"][0]["saved"] is True + assert _modelspace_types(target) == ["CIRCLE"] + + # ── live engine, against a fake ActiveX surface ────────────────────────────── From c7dd72410ac04fe2c15e60c60b4cd15b5602f846 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 16:48:19 +0300 Subject: [PATCH 10/12] fix(task-22): live named views read and restore the display (VIEWCTR/VIEWSIZE, ZOOM Window), UCS sysvars come from the document, and a frozen current layer is thawed before restore makes it current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all measured by the reviewer on AutoCAD 2026: 1. ucs_list and the ucs_restore('world') CMDACTIVE guard read app.GetVariable, but GetVariable is an AcadDocument member — the tool was dead live and the guard's swallowed read forced cmd_active to 0, sending `_.UCS _W` into any open prompt. Both read the document now, and a failed CMDACTIVE read refuses instead of proceeding. The fake app in tests/test_views_ucs.py has no GetVariable any more, so the old code cannot pass. 2. view_named_save defaulted from doc.ActiveViewport.Center/Height/Width, which does not track the display (it answered the initial view after `_.ZOOM _C 5,6 20`). The current view is VIEWCTR / VIEWSIZE at the SCREENSIZE aspect — what `-VIEW _S` records (width 40.267 for height 20). The fake viewport is deliberately stale so a read of it fails. 3. view_named_restore wrote Center/Height/Width into the AcadViewport; AutoCAD anchors the lower-left corner and widens to the display aspect, landing a 10x20 view at (5, 6) on VIEWCTR (20.134, 6). It now zooms the saved rectangle (app.ZoomWindow), which centres and fits like `-VIEW _R` (VIEWCTR = saved centre, VIEWSIZE = max(h, w/aspect)), reports `applied: "zoom_window"` and reads VIEWCTR / VIEWSIZE back. Headless the same reconciliation moved the centre whenever the *Active VPORT's aspect was rewritten from w/h (even the 1.34 default opened at x = 11.734): the aspect the file carries is left alone — it is the display's, written by AutoCAD at save — and the window is fitted into it; the payload adds `vport_height` / `aspect_ratio`. 4. layer_state_restore set ActiveLayer before the loop and outside the try; AutoCAD refuses to make a frozen layer current, so the spec's own save → freeze → restore path raised an opaque COM error with nothing applied. When `frozen` is being restored the state's current layer is thawed first; the write is wrapped and a refusal lands in `warnings`. The fake document and layer now model both refusals. Live AutoCAD was not running in this round (GetActiveObject failed, no acad process), so the fix is measured against the reviewer's numbers, not re-measured. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 99 ++++++++++++++------ backends/contracts/environment.py | 5 +- backends/ezdxf_backend.py | 18 +++- server.py | 25 +++-- tests/test_layer_states.py | 84 +++++++++++++++-- tests/test_views_ucs.py | 149 ++++++++++++++++++++++++++---- 6 files changed, 318 insertions(+), 62 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 391f00c..006e43d 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1534,9 +1534,22 @@ class ComBackend(AutoCADBackend): warnings: list[str] = [] # Current first: AutoCAD refuses to freeze the active layer, so the # active one must already be the state's before the loop freezes. + # And it refuses to make a *frozen* layer current (measured, AutoCAD + # 2026: ``doc.ActiveLayer = `` raises 'Error setting active + # layer'), so when the state thaws its own current layer that thaw + # comes before the ActiveLayer write instead of after it in the + # loop. Only a requested property is touched: without "frozen" the + # refusal stands and lands in ``warnings`` like every other one. if "current" in props and state["current_layer"] not in missing: - doc.ActiveLayer = doc.Layers.Item(state["current_layer"]) - current = state["current_layer"] + target_name = state["current_layer"] + target = doc.Layers.Item(target_name) + try: + if "frozen" in props and not bool(state["layers"][target_name]["frozen"]): + target.Freeze = False + doc.ActiveLayer = target + current = target_name + except Exception as exc: # e.g. the state's current layer is frozen + warnings.append(f"{target_name}: cannot be made current: {exc}") applied = 0 for layer_name, snap in state["layers"].items(): if layer_name in missing: @@ -1633,6 +1646,13 @@ class ComBackend(AutoCADBackend): except Exception: return None + @staticmethod + def _com_display_aspect(doc) -> float: + """Width / height of the current viewport's display, from SCREENSIZE.""" + size = doc.GetVariable("SCREENSIZE") + sx, sy = float(size[0]), float(size[1]) + return sx / sy if sx > 0 and sy > 0 else 1.0 + @staticmethod def _com_view_row(view) -> dict: center = view.Center @@ -1652,12 +1672,20 @@ class ComBackend(AutoCADBackend): def _sync(): doc = _acad_doc() - vport = doc.ActiveViewport - vp_center = vport.Center - vp_height = float(vport.Height) or 1.0 - aspect = float(vport.Width) / vp_height if vp_height else 1.5 - cx, cy = args["center"] or (float(vp_center[0]), float(vp_center[1])) - h = args["height"] or vp_height + # "The current view" is VIEWCTR / VIEWSIZE at the display's aspect + # (SCREENSIZE), which is what AutoCAD's own `-VIEW _S` records. + # NOT ``doc.ActiveViewport.Center/Height/Width``: that object does + # not track the display — measured (AutoCAD 2026) it still + # answered the document's initial view after `_.ZOOM _C 5,6 20`, + # after ``app.ZoomCenter`` and after its own values were written. + if args["center"] is None or args["height"] is None or args["width"] is None: + view_center = doc.GetVariable("VIEWCTR") + view_height = float(doc.GetVariable("VIEWSIZE")) or 1.0 + aspect = self._com_display_aspect(doc) + else: + view_center, view_height, aspect = None, 1.0, 1.0 + cx, cy = args["center"] or (float(view_center[0]), float(view_center[1])) + h = args["height"] or view_height w = args["width"] or h * aspect view = self._com_view(doc, clean) replaced = view is not None @@ -1695,14 +1723,26 @@ class ComBackend(AutoCADBackend): f"view_named_restore: no named view {clean!r}; saved views: {names}" ) row = self._com_view_row(view) - vport = doc.ActiveViewport - vport.Center = _av(row["center"]) - vport.Height = row["height"] - vport.Width = row["width"] - # ActiveX applies viewport changes only when the object is assigned back. - doc.ActiveViewport = vport - _regen() - return {"ok": True, **row, "applied": "active_viewport", "backend": "com"} + # NOT ``vport.Center/Height/Width`` + ``doc.ActiveViewport = vport``: + # AutoCAD reconciles a width that does not match the display aspect + # by anchoring the viewport's lower-left corner and widening, so the + # view lands off-centre (measured, AutoCAD 2026, display aspect + # 2.013: a 10x20 view at (5, 6) restored to VIEWCTR (20.134, 6)). + # `-VIEW _R` centres the saved window and fits it — VIEWCTR = the + # saved centre, VIEWSIZE = max(height, width / aspect) — and so + # does ZOOM Window on the same rectangle, without a SendCommand. + (cx, cy), h, w = row["center"], row["height"], row["width"] + _acad_app().ZoomWindow( + _apoint(cx - w / 2.0, cy - h / 2.0), _apoint(cx + w / 2.0, cy + h / 2.0) + ) + result = {"ok": True, **row, "applied": "zoom_window", "backend": "com"} + try: # the read-back is what a reviewer measures the tool against + ctr = doc.GetVariable("VIEWCTR") + result["viewctr"] = [float(ctr[0]), float(ctr[1])] + result["viewsize"] = float(doc.GetVariable("VIEWSIZE")) + except Exception as exc: + log.debug("VIEWCTR/VIEWSIZE read-back failed: %s", exc) + return result return await self._run(_sync) @@ -1736,9 +1776,10 @@ class ComBackend(AutoCADBackend): from engineering.environment.ucs import WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS def _sync(): - app = _acad_app() + # GetVariable is an AcadDocument member; AcadApplication has none + # (measured: ``hasattr(app, "GetVariable") is False``). doc = _acad_doc() - current = str(app.GetVariable("UCSNAME") or "") + current = str(doc.GetVariable("UCSNAME") or "") rows = [ { "name": WORLD, @@ -1757,15 +1798,15 @@ class ComBackend(AutoCADBackend): # (UCS Origin / 3P without saving) as well as for WCS — verified # live (AutoCAD 2026: after `_.UCS _O 10,10,0`, UCSNAME="" and # WORLDUCS=0). WORLDUCS is AutoCAD's own answer to "is this WCS". - if int(app.GetVariable("WORLDUCS")) == 1: + if int(doc.GetVariable("WORLDUCS")) == 1: rows[0]["current"] = True else: rows.append( { "name": None, - "origin": [float(c) for c in app.GetVariable("UCSORG")], - "x_axis": [float(c) for c in app.GetVariable("UCSXDIR")], - "y_axis": [float(c) for c in app.GetVariable("UCSYDIR")], + "origin": [float(c) for c in doc.GetVariable("UCSORG")], + "x_axis": [float(c) for c in doc.GetVariable("UCSXDIR")], + "y_axis": [float(c) for c in doc.GetVariable("UCSYDIR")], "current": True, } ) @@ -1820,16 +1861,20 @@ class ComBackend(AutoCADBackend): clean = validate_name(name, what="UCS name") def _sync(): - app = _acad_app() doc = _acad_doc() if clean.lower() == WORLD: # No ActiveX member selects WCS; the command does. Same guard as - # system_run_command: never send into an active prompt. + # system_run_command: never send into an active prompt. The + # read is a document member, and a failed read REFUSES: a + # swallowed error here once forced cmd_active to 0 and sent + # `_.UCS _W` into whatever prompt was open. try: - cmd_active = int(app.GetVariable("CMDACTIVE")) + cmd_active = int(doc.GetVariable("CMDACTIVE")) except Exception as exc: - log.debug("CMDACTIVE read failed, proceeding: %s", exc) - cmd_active = 0 + raise RuntimeError( + "ucs_restore: cannot verify AutoCAD is idle (CMDACTIVE read " + f"failed: {exc}); nothing was sent" + ) from exc if cmd_active: raise RuntimeError( "AutoCAD has an active command or prompt (CMDACTIVE=" diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py index dd0f5bc..9ffe0bd 100644 --- a/backends/contracts/environment.py +++ b/backends/contracts/environment.py @@ -76,7 +76,10 @@ class EnvironmentContract(ABC): @abstractmethod async def view_named_restore(self, name: str) -> dict: - """ezdxf: sets the ``*Active`` VPORT, ``"applied": "header_only"``.""" + """COM: ZOOM Window on the saved rectangle (``"applied": "zoom_window"`` + plus the read-back ``viewctr`` / ``viewsize``); ezdxf: fits the window + into the ``*Active`` VPORT without touching its aspect ratio, + ``"applied": "header_only"`` plus ``vport_height`` / ``aspect_ratio``.""" ... @abstractmethod diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 63fb8de..b9db27d 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2650,10 +2650,20 @@ class EzdxfBackend(AutoCADBackend): cx, cy = float(view.dxf.center.x), float(view.dxf.center.y) h, w = float(view.dxf.height), float(view.dxf.width) vport = self._active_vport(doc) + # The *Active VPORT's aspect ratio is the DISPLAY's, written by + # AutoCAD at its last save, and AutoCAD reconciles the stored + # window against the real display by its lower-left corner: a + # stored aspect that differs from the display's shifts the centre + # by (display − stored) · height / 2. Measured (AutoCAD 2026, + # display aspect 2.013): a 10x20 view at (5, 6) written with + # aspect 0.5 opened at VIEWCTR (20.134, 6). So the aspect is left + # as the file carries it and the window is FITTED into it — + # height = max(h, w / aspect), the same rule `-VIEW _R` applies — + # which is exactly what AutoCAD's own restore-then-save writes. + aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34 + fit_height = max(h, w / aspect) vport.dxf.center = (cx, cy) - vport.dxf.height = h - if h > 0: - vport.dxf.aspect_ratio = w / h + vport.dxf.height = fit_height # The orientation travels with the window: a foreign drawing's # VIEW may be oblique or twisted, and flattening it to a plan view # of the same center would be a silent lie. The attribute read @@ -2674,6 +2684,8 @@ class EzdxfBackend(AutoCADBackend): # initial view when it opens the file. No live display moves. "applied": "header_only", "store": "vport_active", + "vport_height": fit_height, + "aspect_ratio": aspect, "backend": "ezdxf", } diff --git a/server.py b/server.py index 0c34a30..49b87a7 100644 --- a/server.py +++ b/server.py @@ -6951,8 +6951,11 @@ async def layer_state_restore( `applied` says how many layers and which properties moved. Refusals: an unknown state name (lists the saved ones), a property outside the eight (names the index). Live, a layer AutoCAD refuses to change (freezing the - active layer) lands in `warnings` instead of failing the call. Portable - server states, not Layer States Manager entries. Pack: settings · lean: no. + active layer, making a frozen layer current) lands in `warnings` instead + of failing the call; the state's current layer is thawed before it is + made current when `frozen` is being restored, so save → freeze → restore + round-trips. Portable server states, not Layer States Manager entries. + Pack: settings · lean: no. """ await ctx.info(f"Restoring layer state {name!r}") return await _backend(ctx).layer_state_restore(name, properties) @@ -7022,12 +7025,18 @@ async def view_named_restore( name: Annotated[str, "A name from view_named_list"], ctx: Context = None, ) -> dict: - """Live: sets the active viewport to the saved centre/height/width - (`applied: "active_viewport"`). Headless there is no display: the saved - window is written to the `*Active` VPORT — the view AutoCAD opens the file - on — and the result says `applied: "header_only"`, the same reported-no-op - rule as `view_zoom_extents`. Refusal: an unknown name (lists the saved - views). Pack: settings · lean: no.""" + """Live: zooms the active viewport to the saved window the way `-VIEW _R` + does — VIEWCTR becomes the saved centre and VIEWSIZE the saved height + (or width / display aspect when the window is wider than the display); + `applied: "zoom_window"` plus the read-back `viewctr` / `viewsize`. + Headless there is no display: the saved window is fitted into the + `*Active` VPORT — the view AutoCAD opens the file on — whose aspect ratio + is left as the file carries it (AutoCAD reconciles a changed aspect by the + viewport's lower-left corner, which shifts the centre); the result says + `applied: "header_only"` with the written `vport_height` and the kept + `aspect_ratio`, the same reported-no-op rule as `view_zoom_extents`. + Refusal: an unknown name (lists the saved views). Pack: settings · lean: + no.""" return await _backend(ctx).view_named_restore(name) diff --git a/tests/test_layer_states.py b/tests/test_layer_states.py index 2ba41f0..ffb4b0a 100644 --- a/tests/test_layer_states.py +++ b/tests/test_layer_states.py @@ -10,7 +10,6 @@ the COM tests prove the ActiveX call shapes. from __future__ import annotations import json -import types import pytest @@ -338,15 +337,49 @@ class _FakeDictionaries: class _FakeLayer: + """Models the two refusals measured live (AutoCAD 2026): the active layer + cannot be frozen, and a frozen layer cannot be made active.""" + def __init__(self, name, **props): self.Name = name self.Color = props.get("color", 7) self.Linetype = props.get("linetype", "Continuous") self.LineWeight = props.get("lineweight", -3) self.LayerOn = props.get("on", True) - self.Freeze = props.get("frozen", False) + self._frozen = props.get("frozen", False) self.Lock = props.get("locked", False) self.Plottable = props.get("plot", True) + self.document = None + + @property + def Freeze(self): + return self._frozen + + @Freeze.setter + def Freeze(self, value): + if value and self.document is not None and self.document.ActiveLayer is self: + raise RuntimeError("AutoCAD COM error: cannot freeze the current layer") + self._frozen = bool(value) + + +class _FakeDocument: + def __init__(self, layers, current): + self.Dictionaries = _FakeDictionaries() + self.Layers = _FakeLayers(layers) + self._active = current + for lyr in layers: + lyr.document = self + + @property + def ActiveLayer(self): + return self._active + + @ActiveLayer.setter + def ActiveLayer(self, layer): + if layer.Freeze: + # Measured: com_error 'Error setting active layer' (-2145320874). + raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Error setting active layer") + self._active = layer class _FakeLayers: @@ -376,11 +409,7 @@ def com_backend(monkeypatch): _FakeLayer("GEOMETRY", lineweight=50), _FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False), ] - document = types.SimpleNamespace( - Dictionaries=_FakeDictionaries(), - Layers=_FakeLayers(layers), - ActiveLayer=layers[1], - ) + document = _FakeDocument(layers, current=layers[1]) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None) monkeypatch.setattr(module, "_regen", lambda: None) @@ -429,6 +458,47 @@ async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_bac assert result["missing_layers"] == [] and result["new_layers"] == [] +async def test_com_restore_thaws_the_states_current_layer_before_making_it_current(com_backend): + """The spec's own acceptance path, measured live: save with GEOMETRY + current and HIDDEN frozen → thaw HIDDEN, make 0 current, freeze GEOMETRY + → restore. AutoCAD refuses ``ActiveLayer = ``, so the write + before the loop raised an opaque COM error and nothing was applied.""" + backend, document = com_backend + await backend.layer_state_save("PLOT") + geometry = document.Layers.Item("GEOMETRY") + hidden = document.Layers.Item("HIDDEN") + hidden.Freeze = False + document.ActiveLayer = document.Layers.Item("0") + geometry.Freeze = True + result = await backend.layer_state_restore("PLOT") + assert "warnings" not in result, result + assert document.ActiveLayer is geometry and geometry.Freeze is False + assert hidden.Freeze is True + assert result["applied"] == { + "layers": 3, + "properties": list(PROPERTIES), + "current_layer": "GEOMETRY", + } + + +async def test_com_restore_reports_a_refused_current_layer_instead_of_failing(com_backend): + """Without ``frozen`` in the requested properties the thaw is not ours to + make: AutoCAD's refusal lands in ``warnings`` and the rest still applies.""" + backend, document = com_backend + await backend.layer_state_save("PLOT") + geometry = document.Layers.Item("GEOMETRY") + hidden = document.Layers.Item("HIDDEN") + hidden.Color = 3 + document.ActiveLayer = document.Layers.Item("0") + geometry.Freeze = True + result = await backend.layer_state_restore("PLOT", properties=["color", "current"]) + assert result["applied"]["current_layer"] is None + assert result["applied"]["layers"] == 3 and hidden.Color == 8 + assert geometry.Freeze is True and document.ActiveLayer.Name == "0" + (warning,) = result["warnings"] + assert warning.startswith("GEOMETRY: cannot be made current: ") + + async def test_com_list_and_delete(com_backend): backend, document = com_backend await backend.layer_state_save("A", description="one") diff --git a/tests/test_views_ucs.py b/tests/test_views_ucs.py index 7d36b7f..4026499 100644 --- a/tests/test_views_ucs.py +++ b/tests/test_views_ucs.py @@ -102,6 +102,8 @@ async def test_view_replace_restore_and_list(backend): "width": pytest.approx(26.8), "applied": "header_only", "store": "vport_active", + "vport_height": 20.0, + "aspect_ratio": pytest.approx(1.34), "backend": "ezdxf", } (vport,) = backend._doc.viewports.get("*Active") @@ -116,6 +118,28 @@ async def test_view_replace_restore_and_list(backend): assert "BAD" not in backend._doc.views +async def test_view_restore_keeps_the_vport_aspect_and_fits_the_window(backend): + """Measured (AutoCAD 2026, display aspect 2.013): the *Active VPORT's + aspect ratio is reconciled against the real display by the viewport's + lower-left corner — a 10x20 view at (5, 6) written with aspect 0.5 + opened at VIEWCTR (20.134, 6), and the default 1.34 at (11.734, 6). The + aspect is the display's (AutoCAD writes it at save), so restore leaves it + alone and fits the window into it, like `-VIEW _R` does.""" + (vport,) = backend._doc.viewports.get("*Active") or [backend._doc.viewports.new("*Active")] + vport.dxf.aspect_ratio = 2.0125 # what AutoCAD wrote for a 2262 x 1124 display + await backend.view_named_save("TALL", center=[5, 6], height=20, width=10) + await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100) + tall = await backend.view_named_restore("TALL") + assert vport.dxf.aspect_ratio == pytest.approx(2.0125), "never rewritten from w / h" + assert tuple(vport.dxf.center)[:2] == (5.0, 6.0) + assert vport.dxf.height == 20.0 and tall["vport_height"] == 20.0 + assert tall["aspect_ratio"] == pytest.approx(2.0125) + wide = await backend.view_named_restore("WIDE") + assert vport.dxf.aspect_ratio == pytest.approx(2.0125) + assert vport.dxf.height == pytest.approx(100.0 / 2.0125) + assert wide["vport_height"] == pytest.approx(100.0 / 2.0125) and wide["height"] == 20.0 + + async def test_view_is_written_as_a_plan_view_on_disk(backend, tmp_path): """ezdxf's VIEW default is ``direction=(1, 1, 1)`` — an isometric view. The tool stores the plan window of ``center``/``height``, so the entry must @@ -361,15 +385,31 @@ class _FakeUcsCollection: return u +# Measured (AutoCAD 2026): `-VIEW _S` of VIEWSIZE 20 recorded width 40.267 +# and the corner-anchored restores landed at x = 10.134 / 20.134 — all three +# say the display aspect was 2.0134, i.e. a 2263 x 1124 SCREENSIZE. +_SCREEN = (2263.0, 1124.0) +_DISPLAY_ASPECT = _SCREEN[0] / _SCREEN[1] # 2.0134 + + class _FakeDocument: - def __init__(self): + """``GetVariable`` is an AcadDocument member (``hasattr(app, "GetVariable")`` + is False live). ``ActiveViewport`` is deliberately STALE — measured, the + AcadViewport object keeps the document's initial view whatever the + display shows — so any code that reads it saves the wrong window.""" + + def __init__(self, variables): self.Views = _FakeViews() self.UserCoordinateSystems = _FakeUcsCollection() - self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0) + self.variables = variables + self._viewport = types.SimpleNamespace(Center=(298.98, 148.5), Height=297.0, Width=597.97) self.viewport_sets = 0 self.ActiveUCS = None self.commands: list[str] = [] + def GetVariable(self, name): + return self.variables[name] + @property def ActiveViewport(self): return self._viewport @@ -383,12 +423,29 @@ class _FakeDocument: self.commands.append(macro) +class _FakeApp: + """No ``GetVariable``. ``ZoomWindow`` models ZOOM Window / `-VIEW _R`: + the window is centred and fitted to the display aspect (measured live: + `-VIEW _R` of a 10x20 view at (5, 6) gives VIEWCTR (5, 6), VIEWSIZE 20).""" + + def __init__(self, document): + self.document = document + self.zoom_windows: list[tuple[tuple, tuple]] = [] + + def ZoomWindow(self, lower_left, upper_right): + self.zoom_windows.append((tuple(lower_left), tuple(upper_right))) + w = upper_right[0] - lower_left[0] + h = upper_right[1] - lower_left[1] + cx, cy = lower_left[0] + w / 2.0, lower_left[1] + h / 2.0 + self.document.variables["VIEWCTR"] = (cx, cy, 0.0) + self.document.variables["VIEWSIZE"] = max(h, w / _DISPLAY_ASPECT) + + @pytest.fixture def com_backend(monkeypatch): pytest.importorskip("win32com.client", reason="pywin32 not installed") from backends import com_backend as module - document = _FakeDocument() variables = { "CMDACTIVE": 0, "UCSNAME": "", @@ -396,8 +453,13 @@ def com_backend(monkeypatch): "UCSORG": (0.0, 0.0, 0.0), "UCSXDIR": (1.0, 0.0, 0.0), "UCSYDIR": (0.0, 1.0, 0.0), + "VIEWCTR": (5.0, 6.0, 0.0), + "VIEWSIZE": 20.0, + "SCREENSIZE": _SCREEN, } - app = types.SimpleNamespace(GetVariable=lambda name: variables[name]) + document = _FakeDocument(variables) + app = _FakeApp(document) + assert not hasattr(app, "GetVariable"), "AcadApplication has no GetVariable member" monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_regen", lambda: None) @@ -412,28 +474,59 @@ def com_backend(monkeypatch): return backend, document, variables -async def test_com_view_save_defaults_to_the_active_viewport(com_backend): +async def test_com_view_save_defaults_to_viewctr_viewsize_not_the_viewport_object(com_backend): + """Measured (AutoCAD 2026): after ``_.ZOOM _C 5,6 20`` VIEWCTR is (5, 6) + and VIEWSIZE 20 while ``doc.ActiveViewport`` still answers the initial + view (298.98, 148.5) x 297 — and AutoCAD's own `-VIEW _S` records + centre (5, 6), height 20, width 20 x the display aspect (40.267).""" backend, document, _ = com_backend result = await backend.view_named_save("HOME") assert document.Views.calls == [("Add", ("HOME",))] (view,) = document.Views.views - assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0 - assert result["center"] == [30.0, 40.0] and result["replaced"] is False + assert view.Center == (5.0, 6.0) and view.Height == 20.0 + assert view.Width == pytest.approx(20.0 * _DISPLAY_ASPECT) + assert result["center"] == [5.0, 6.0] and result["replaced"] is False + assert result["width"] == pytest.approx(40.267, abs=1e-3), "what `-VIEW _S` recorded live" again = await backend.view_named_save("home", center=[1, 2], height=10) assert again["replaced"] is True and len(document.Views.views) == 1 - assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0 + assert view.Center == (1.0, 2.0) and view.Height == 10.0 + assert view.Width == pytest.approx(10.0 * _DISPLAY_ASPECT), ( + "width defaults to the display aspect" + ) + assert document.viewport_sets == 0 -async def test_com_view_restore_sets_the_active_viewport(com_backend): - backend, document, _ = com_backend +async def test_com_view_restore_zooms_the_window_like_view_r(com_backend): + """Measured (AutoCAD 2026, display aspect 2.013): writing Center/Height/ + Width into the AcadViewport lands a 30x20 view at (5, 6) on VIEWCTR + (10.134, 6) and a 10x20 one on (20.134, 6) — AutoCAD anchors the + lower-left corner and widens. `-VIEW _R` gives VIEWCTR (5, 6), VIEWSIZE + 20 for both, and so does ZOOM Window on the saved rectangle.""" + backend, document, variables = com_backend + from backends import com_backend as module + + app = module._acad_app() # the fixture's _FakeApp await backend.view_named_save("HOME", center=[5, 6], height=20, width=30) - result = await backend.view_named_restore("HOME") - assert result["applied"] == "active_viewport" - assert document.ActiveViewport.Center == (5.0, 6.0) - assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0 - assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment" + await backend.view_named_save("TALL", center=[5, 6], height=20, width=10) + await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100) + for name, expect_size in (("HOME", 20.0), ("TALL", 20.0), ("WIDE", 100.0 / _DISPLAY_ASPECT)): + result = await backend.view_named_restore(name) + assert result["applied"] == "zoom_window" and result["center"] == [5.0, 6.0] + assert result["viewctr"] == [5.0, 6.0], name + assert result["viewsize"] == pytest.approx(expect_size), name + assert variables["VIEWCTR"][:2] == (5.0, 6.0) + assert app.zoom_windows == [ + ((-10.0, -4.0, 0.0), (20.0, 16.0, 0.0)), + ((0.0, -4.0, 0.0), (10.0, 16.0, 0.0)), + ((-45.0, -4.0, 0.0), (55.0, 16.0, 0.0)), + ] + # The stale AcadViewport object was neither written nor re-assigned. + assert document.viewport_sets == 0 + assert document.ActiveViewport.Center == (298.98, 148.5) assert await backend.view_named_list() == [ - {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0} + {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0}, + {"name": "TALL", "center": [5.0, 6.0], "height": 20.0, "width": 10.0}, + {"name": "WIDE", "center": [5.0, 6.0], "height": 20.0, "width": 100.0}, ] @@ -494,6 +587,30 @@ async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend assert len(document.commands) == 1 +async def test_com_ucs_restore_world_refuses_when_cmdactive_cannot_be_read(com_backend): + """The guard once swallowed a failed read as "idle" — with the read on the + wrong object (``app.GetVariable``) it failed on every call, so `_.UCS _W` + went into whatever prompt was open. A read failure now refuses.""" + backend, document, variables = com_backend + del variables["CMDACTIVE"] + with pytest.raises(RuntimeError, match="cannot verify AutoCAD is idle"): + await backend.ucs_restore("world") + assert document.commands == [] + + +async def test_com_ucs_reads_sysvars_from_the_document_never_the_application(com_backend): + """Live, ``AutoCAD.Application.GetVariable`` raises AttributeError: the + member is on AcadDocument. The fixture's app has none, so every UCS path + here would blow up on the old code.""" + backend, document, variables = com_backend + from backends import com_backend as module + + assert not hasattr(module._acad_app(), "GetVariable") + rows = await backend.ucs_list() + assert [(r["name"], r["current"]) for r in rows] == [("world", True)] + assert (await backend.ucs_restore("world"))["current"] is True + + async def test_com_ucs_refuses_before_activex(com_backend): backend, document, _ = com_backend with pytest.raises(ValueError, match="measured"): From b83a74e96980193bd85f8f869258bdba926b13e3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:34:09 +0300 Subject: [PATCH 11/12] feat(environment): system_launch, preference whitelist and operator prompts on COM; declared refusals headlessly user_pick_point / user_select answer {cancelled: true} on ESC (DISP_E_EXCEPTION) and {timed_out: true} past COM_CALL_TIMEOUT; preferences_set refuses read-only, unknown and out-of-range keys before any ActiveX call. The read-only key is Files.PrinterStyleSheetPath, not the spec's PrintStyleSheetPath: IAcadPreferencesFiles has no member of that name (measured live on AutoCAD 2026 / R25.1), and a misspelt key would make preferences_get() raise on every live call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 228 ++++++++++++ discovery/aliases.py | 64 ++++ docs/tool-inventory.json | 85 ++++- engineering/environment/__init__.py | 9 +- engineering/environment/preferences.py | 146 ++++++++ server.py | 147 +++++++- tests/test_environment_live.py | 467 +++++++++++++++++++++++++ tests/test_tool_registry.py | 12 +- 9 files changed, 1153 insertions(+), 11 deletions(-) create mode 100644 engineering/environment/preferences.py create mode 100644 tests/test_environment_live.py diff --git a/README.md b/README.md index cf9052d..bc9a650 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests. -> 179 is the **registered** count; a default install advertises 174 over `tools/list`, +> **v1.5 release snapshot:** 185 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> 185 is the **registered** count; a default install advertises 180 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog | | Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools | | Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` | -| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)* | +| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)*; live only: launch/attach, a preference whitelist (`OPTIONS`), and operator prompts — pick a point, select on screen, a command-line message — where ESC is `cancelled`, not an error | | Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box | | Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* | | Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle | diff --git a/backends/com_backend.py b/backends/com_backend.py index 006e43d..3a47762 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1896,6 +1896,234 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + # ── application, preferences, operator prompts ──────────────────────────── + + async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict: + """Attach to the running application, or start it; optionally open a file. + + Honours ``CAD_PROGID`` on both paths, like ``_acad_app``: ``Dispatch`` + launches the application, so falling back to AutoCAD would start the + very product the operator said they were not using. The path is + validated before any COM call. + """ + path = str(validate_path(open_path, allow_write=False)) if open_path else None + + def _sync(): + progid = config.settings.cad_progid + app = _COM_STATE.get("app") + launched = False + if app is None: + try: + app = win32com.client.GetActiveObject(progid) + except Exception as exc: + log.debug("GetActiveObject(%r) failed, dispatching: %s", progid, exc) + app = win32com.client.Dispatch(progid) + launched = True + _COM_STATE["app"] = app + try: + app.Visible = bool(visible) + except Exception as exc: # some hosts refuse to hide + log.debug("Visible=%s refused: %s", visible, exc) + document = None + if path: + document = str(app.Documents.Open(path).Name) + elif int(app.Documents.Count): + document = str(app.ActiveDocument.Name) + return { + "launched": launched, + "attached": not launched, + "version": str(app.Version), + "document": document, + "visible": bool(visible), + "progid": progid, + "backend": "com", + } + + result = await self._run(_sync) + await self._ensure_document_state() + return result + + async def preferences_get(self, keys: list[str] | None = None) -> dict: + from engineering.environment.preferences import ( + PREFERENCE_KEYS, + READ_ONLY_KEYS, + decode_value, + known_keys, + split_key, + ) + + if keys is None: + wanted = known_keys() + else: + if isinstance(keys, str) or not isinstance(keys, (list, tuple)): + raise TypeError("preferences_get: keys must be a list of preference names") + unknown = [k for k in keys if k not in PREFERENCE_KEYS and k not in READ_ONLY_KEYS] + if unknown: + raise ValueError( + f"preferences_get: unknown preference keys {unknown}; known: {known_keys()}" + ) + wanted = list(keys) + + def _sync(): + prefs = _acad_app().Preferences + values = {} + for key in wanted: + section, member = split_key(key) + values[key] = decode_value(key, getattr(getattr(prefs, section), member)) + return { + "values": values, + "read_only": [k for k in wanted if k in READ_ONLY_KEYS], + "backend": "com", + } + + return await self._run(_sync) + + async def preferences_set(self, key: str, value) -> dict: + from engineering.environment.preferences import ( + decode_value, + split_key, + validate_preference, + ) + + coerced = validate_preference(key, value) # read-only / unknown / range: refused here + section, member = split_key(key) + + def _sync(): + target = getattr(_acad_app().Preferences, section) + old = getattr(target, member) + setattr(target, member, coerced) + new = getattr(target, member) + return { + "ok": True, + "key": key, + "old": decode_value(key, old), + "new": decode_value(key, new), + "changed": old != new, + "backend": "com", + } + + return await self._run(_sync) + + @staticmethod + def _prompt_text(prompt, where: str, max_len: int = 255) -> str: + from engineering.environment.names import validate_name + + return validate_name(prompt, what=f"{where}: prompt", max_len=max_len) + + @staticmethod + def _com_cancel_reason(exc) -> str | None: + """The operator's cancel as ActiveX reports it, or None for any other COM error. + + ESC in GetPoint / GetEntity / SelectOnScreen raises DISP_E_EXCEPTION + (-2147352567) with AutoCAD's description ("User input is a keyword", + "Function cancelled"). Anything else is a real failure and propagates. + """ + args = getattr(exc, "args", ()) + if not args or args[0] != -2147352567: + return None + detail = args[2] if len(args) > 2 and isinstance(args[2], tuple) else () + description = detail[2] if len(detail) > 2 and detail[2] else "cancelled" + return str(description) + + async def _interactive(self, func): + """Run an operator prompt; a wall-clock timeout is an answer, not a crash. + + ``_run`` has already rebuilt the STA executor by the time its + "did not respond" error surfaces (the worker is still blocked inside + the prompt); the next call re-attaches lazily, exactly as after any + other timeout. ``COM_CALL_TIMEOUT`` is the budget the operator has. + """ + try: + return await self._run(func) + except RuntimeError as exc: + if str(exc).startswith("AutoCAD did not respond"): + return { + "timed_out": True, + "timeout_s": config.settings.com_call_timeout, + "error": str(exc), + } + raise + + async def user_pick_point(self, prompt: str) -> dict: + text = self._prompt_text(prompt, "user_pick_point") + + def _sync(): + doc = _acad_doc() + try: + point = doc.Utility.GetPoint(pythoncom.Missing, f"\n{text}") + except _COM_ERROR as exc: + reason = self._com_cancel_reason(exc) + if reason is None: + raise + return {"cancelled": True, "reason": reason, "backend": "com"} + return { + "cancelled": False, + "x": float(point[0]), + "y": float(point[1]), + "z": float(point[2]) if len(point) > 2 else 0.0, + "backend": "com", + } + + return await self._interactive(_sync) + + async def user_select(self, prompt: str, mode: str = "single") -> dict: + if mode not in ("single", "multiple"): + raise ValueError(f"user_select: mode must be 'single' or 'multiple', got {mode!r}") + text = self._prompt_text(prompt, "user_select") + + def _sync(): + doc = _acad_doc() + if mode == "single": + try: + obj, picked = doc.Utility.GetEntity(f"\n{text}") + except _COM_ERROR as exc: + reason = self._com_cancel_reason(exc) + if reason is None: + raise + return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"} + return { + "cancelled": False, + "mode": "single", + "handles": [str(obj.Handle)], + "picked": [float(picked[0]), float(picked[1])], + "backend": "com", + } + doc.Utility.Prompt(f"\n{text}\n") + ss = doc.SelectionSets.Add(f"_PICK_{uuid.uuid4().hex[:8]}") + try: + try: + ss.SelectOnScreen() + except _COM_ERROR as exc: + reason = self._com_cancel_reason(exc) + if reason is None: + raise + return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"} + handles = [str(ss.Item(i).Handle) for i in range(int(ss.Count))] + # Enter with nothing selected is an empty answer, not a cancel. + return { + "cancelled": False, + "mode": "multiple", + "handles": handles, + "count": len(handles), + "backend": "com", + } + finally: + try: + ss.Delete() + except Exception as exc: + log.debug("SelectionSet cleanup failed: %s", exc) + + return await self._interactive(_sync) + + async def system_prompt_message(self, text: str) -> dict: + clean = self._prompt_text(text, "system_prompt_message", max_len=2000) + + def _sync(): + _acad_doc().Utility.Prompt(f"\n{clean}\n") + return {"ok": True, "text": clean, "backend": "com"} + + return await self._run(_sync) + # ── selection filters (M8 / F1) ────────────────────────────────────────── # # VERIFIED against a live AutoCAD 2026 (2026-08-05). diff --git a/discovery/aliases.py b/discovery/aliases.py index be2dcfa..5973fa3 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -76,6 +76,9 @@ class ToolAliases: # (document_close); a drafter typing it could mean either. # LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore, # list, delete in one) that this server splits into discrete tools. +# OPTIONS -- one dialog both reads and writes a preference; the server +# splits read (system_preferences_get) and write (system_preferences_set), +# the SETVAR precedent. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( @@ -89,6 +92,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "LAYERSTATE", "LAYOUT", "MEASUREGEOM", + "OPTIONS", "PLINE", "QSELECT", "SETVAR", @@ -1503,6 +1507,46 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "save which layers are frozen", ), ), + "system_launch": ToolAliases( + acad=(), + synonyms=( + "start autocad", + "launch autocad", + "attach to the running autocad", + "is autocad running", + "open autocad with this file", + ), + ), + "system_preferences_get": ToolAliases( + acad=("OPTIONS",), + synonyms=( + "read a preference", + "autosave interval", + "support file search path", + "what is the pickbox size", + "options dialog value", + ), + ), + "system_preferences_set": ToolAliases( + acad=("OPTIONS",), + synonyms=( + "change a preference", + "set the autosave interval", + "cursor size", + "pickbox size", + "default plot style table", + ), + ), + "system_prompt_message": ToolAliases( + acad=(), + synonyms=( + "message on the command line", + "tell the operator", + "print to the command line", + "command line note", + "say something in autocad", + ), + ), "ucs_list": ToolAliases( acad=("UCS", "UCSMAN"), synonyms=("the current ucs", "list coordinate systems", "named ucs", "ucs manager"), @@ -1527,6 +1571,26 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "ucs origin", ), ), + "user_pick_point": ToolAliases( + acad=("ID",), + synonyms=( + "pick a point", + "click a point on screen", + "ask the operator for a point", + "where should this go", + "let me click", + ), + ), + "user_select": ToolAliases( + acad=("SELECT",), + synonyms=( + "let me select", + "pick objects on screen", + "ask the operator to select", + "select on screen", + "hand me the selection", + ), + ), "view_named_list": ToolAliases( acad=("VIEW",), synonyms=("saved views", "existing named views", "list the views"), diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 4f21c59..c286943 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": 179, + "tools": 185, "resources": 8, "prompts": 5, "groups": 20 @@ -24,7 +24,7 @@ "pid": 9, "premium": 12, "solids": 5, - "system": 7, + "system": 13, "templates": 2, "transactions": 3, "validation": 1, @@ -2141,6 +2141,61 @@ "readOnlyHint": true } }, + { + "name": "system_launch", + "group": "system", + "cost": "safe", + "summary": "Attach to the running AutoCAD or start it, optionally opening a file (live only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Launch / Attach AutoCAD", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "system_preferences_get", + "group": "system", + "cost": "read", + "summary": "Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Get Preferences", + "readOnlyHint": true + } + }, + { + "name": "system_preferences_set", + "group": "system", + "cost": "safe", + "summary": "Change one whitelisted AutoCAD preference; reports old and new (live only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Set Preference", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "system_prompt_message", + "group": "system", + "cost": "safe", + "summary": "Print a message on AutoCAD's command line (live only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Command-Line Message", + "readOnlyHint": false, + "destructiveHint": false + } + }, { "name": "system_run_command", "group": "system", @@ -2346,6 +2401,32 @@ "destructiveHint": false } }, + { + "name": "user_pick_point", + "group": "system", + "cost": "read", + "summary": "Ask the operator to pick a point on screen (live only; blocks until they do).", + "tags": [ + "system" + ], + "annotations": { + "title": "Ask Operator: Pick Point", + "readOnlyHint": true + } + }, + { + "name": "user_select", + "group": "system", + "cost": "read", + "summary": "Ask the operator to select one entity or a set on screen (live only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Ask Operator: Select", + "readOnlyHint": true + } + }, { "name": "validation_check", "group": "validation", diff --git a/engineering/environment/__init__.py b/engineering/environment/__init__.py index 7d42f1c..d16b22c 100644 --- a/engineering/environment/__init__.py +++ b/engineering/environment/__init__.py @@ -1,5 +1,6 @@ """Environment helpers (v1.6 track E): portable layer states, view and UCS -argument resolution. Pure data and validation — no ezdxf, no COM.""" +argument resolution, the preference whitelist. Pure data and validation — no +ezdxf, no COM.""" from .layer_states import ( # noqa: F401 DICT_NAME, @@ -12,5 +13,11 @@ from .layer_states import ( # noqa: F401 validate_state_name, ) from .names import validate_name # noqa: F401 +from .preferences import ( # noqa: F401 + PREFERENCE_KEYS, + READ_ONLY_KEYS, + describe_preferences, + validate_preference, +) from .ucs import WORLD, resolve_ucs_axes # noqa: F401 from .views import resolve_view_args # noqa: F401 diff --git a/engineering/environment/preferences.py b/engineering/environment/preferences.py new file mode 100644 index 0000000..7ad465b --- /dev/null +++ b/engineering/environment/preferences.py @@ -0,0 +1,146 @@ +"""The AutoCAD preferences a model may read and write, as data. + +``Preferences.*`` lives in the running application (registry-backed), not in +a drawing, so the headless engine refuses with ``capability: "preferences"``. +The whitelist is deliberately short: every key has a type and a range so a +write is refused *by name* before any ActiveX call, and the four ``Files.*`` +paths are read-only because rewriting a support path is how an installation +gets broken. ``OpenSave.SaveAsType`` speaks AutoCAD's ``AcSaveAsType`` enum +by name in both directions. +""" + +from __future__ import annotations + +#: AcSaveAsType: each release adds 12; dwg, dxf (+1), template (+2). +SAVE_AS_TYPES: dict[str, int] = { + "ac2000_dwg": 12, + "ac2000_dxf": 13, + "ac2000_template": 14, + "ac2004_dwg": 24, + "ac2004_dxf": 25, + "ac2004_template": 26, + "ac2007_dwg": 36, + "ac2007_dxf": 37, + "ac2007_template": 38, + "ac2010_dwg": 48, + "ac2010_dxf": 49, + "ac2010_template": 50, + "ac2013_dwg": 60, + "ac2013_dxf": 61, + "ac2013_template": 62, + "ac2018_dwg": 64, + "ac2018_dxf": 65, + "ac2018_template": 66, +} + +#: key -> (kind, spec). kind: "bool" | "int" (spec = (lo, hi)) | "str" | "enum" (spec = name -> value) +PREFERENCE_KEYS: dict[str, tuple[str, object]] = { + "OpenSave.SaveAsType": ("enum", SAVE_AS_TYPES), + "OpenSave.AutoSaveInterval": ("int", (0, 600)), + "OpenSave.CreateBackup": ("bool", None), + "OpenSave.IncrementalSavePercent": ("int", (0, 100)), + "Display.CursorSize": ("int", (1, 100)), + "Drafting.AutoSnapMarkerSize": ("int", (1, 20)), + "Drafting.AutoSnapTooltip": ("bool", None), + "Selection.PickBoxSize": ("int", (0, 50)), + "Output.DefaultPlotStyleTable": ("str", None), + "Output.DefaultOutputDevice": ("str", None), +} + +#: Every key is ``.`` verbatim, so ``split_key`` +#: reaches the ActiveX property without a translation table. The spec spelled +#: the third one ``PrintStyleSheetPath``; ``IAcadPreferencesFiles`` has no such +#: member (measured on AutoCAD 2026 / R25.1: ``PrinterStyleSheetPath``), and a +#: misspelling here would make ``preferences_get()`` raise on every live call. +READ_ONLY_KEYS: tuple[str, ...] = ( + "Files.SupportPath", + "Files.TemplateDwgPath", + "Files.PrinterStyleSheetPath", + "Files.PrinterConfigPath", +) + + +def split_key(key: str) -> tuple[str, str]: + """``"OpenSave.AutoSaveInterval"`` → ``("OpenSave", "AutoSaveInterval")``.""" + section, _, member = key.partition(".") + return section, member + + +def known_keys() -> list[str]: + return list(PREFERENCE_KEYS) + list(READ_ONLY_KEYS) + + +def validate_preference(key: str, value): + """The value to write, coerced to the key's type — or a refusal naming the key. + + Writes nothing; both engines call it before touching anything. + """ + if key in READ_ONLY_KEYS: + raise ValueError(f"preference {key!r} is read-only") + if key not in PREFERENCE_KEYS: + raise ValueError( + f"unknown preference {key!r}; writable keys: {list(PREFERENCE_KEYS)}; " + f"read-only keys: {list(READ_ONLY_KEYS)}" + ) + kind, spec = PREFERENCE_KEYS[key] + if kind == "bool": + if not isinstance(value, bool): + raise TypeError(f"preference {key!r} takes a bool, got {value!r}") + return value + if kind == "int": + whole = isinstance(value, int) and not isinstance(value, bool) + whole = whole or (isinstance(value, float) and value.is_integer()) + if not whole: + raise TypeError(f"preference {key!r} takes an int, got {value!r}") + number = int(value) + lo, hi = spec + if not lo <= number <= hi: + raise ValueError(f"preference {key!r} must be between {lo} and {hi}, got {number}") + return number + if kind == "str": + if not isinstance(value, str) or not value.strip(): + raise TypeError(f"preference {key!r} takes a non-empty string, got {value!r}") + return value.strip() + # enum + if isinstance(value, str): + name = value.strip().lower() + if name in spec: + return spec[name] + raise ValueError(f"preference {key!r} must be one of {sorted(spec)}, got {value!r}") + if isinstance(value, bool) or not isinstance(value, int) or value not in spec.values(): + raise ValueError( + f"preference {key!r} must be one of {sorted(spec)} (or their values), got {value!r}" + ) + return value + + +def decode_value(key: str, raw): + """What the application returned, in the whitelist's vocabulary (enum names, real bools).""" + kind, spec = PREFERENCE_KEYS.get(key, (None, None)) + if kind == "enum": + for name, code in spec.items(): + if code == raw: + return name + return raw + if kind == "bool": + return bool(raw) + if kind == "int": + return int(raw) + return raw + + +def describe_preferences() -> list[dict]: + rows = [] + for key, (kind, spec) in PREFERENCE_KEYS.items(): + rows.append( + { + "key": key, + "kind": kind, + "range": list(spec) if kind == "int" else None, + "enum": sorted(spec) if kind == "enum" else None, + "writable": True, + } + ) + for key in READ_ONLY_KEYS: + rows.append({"key": key, "kind": "str", "range": None, "enum": None, "writable": False}) + return rows diff --git a/server.py b/server.py index 49b87a7..3738f31 100644 --- a/server.py +++ b/server.py @@ -6819,7 +6819,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 20: Environment (13 tools) ────────────────────────────────────── +# ── SECTION 20: Environment (19 tools) ────────────────────────────────────── # --------------------------------------------------------------------------- @@ -7120,6 +7120,151 @@ async def ucs_restore( return await _backend(ctx).ucs_restore(name) +@cad_tool( + summary="Attach to the running AutoCAD or start it, optionally opening a file (live only).", + cost="safe", +) +@mcp.tool( + annotations={ + "title": "Launch / Attach AutoCAD", + "readOnlyHint": False, + "destructiveHint": False, + }, + tags={"system"}, +) +async def system_launch( + visible: Annotated[bool, "Show the application window"] = True, + open_path: Annotated[str | None, "A .dwg/.dxf to open after attaching"] = None, + ctx: Context = None, +) -> dict: + """Connect to the application named by `CAD_PROGID` (attach if running, + launch otherwise) and report `launched`, `attached`, `version` and the + active `document`. Refusals: headless, `capability: "live_application"` + (there is no application to launch); an `open_path` that fails path + validation. Pack: settings · lean: no. + """ + if open_path is not None: + open_path = str(validate_path(open_path, allow_write=False)) + await ctx.info(f"Launching or attaching to {config.settings.cad_progid}") + return await _backend(ctx).system_launch(visible, open_path) + + +@cad_tool( + summary="Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).", + cost="read", +) +@mcp.tool( + annotations={"title": "Get Preferences", "readOnlyHint": True}, + tags={"system"}, +) +async def system_preferences_get( + keys: Annotated[ + list[str] | None, + "Subset of OpenSave.SaveAsType, OpenSave.AutoSaveInterval, OpenSave.CreateBackup, " + "OpenSave.IncrementalSavePercent, Display.CursorSize, Drafting.AutoSnapMarkerSize, " + "Drafting.AutoSnapTooltip, Selection.PickBoxSize, Output.DefaultPlotStyleTable, " + "Output.DefaultOutputDevice, Files.SupportPath, Files.TemplateDwgPath, " + "Files.PrinterStyleSheetPath, Files.PrinterConfigPath; omit for all", + ] = None, + ctx: Context = None, +) -> dict: + """The whitelisted `Preferences.*` values, enum values as names + (`SaveAsType: "ac2018_dwg"`), with `read_only` naming the four `Files.*` + paths. Refusals: headless, `capability: "preferences"` (they live in the + running application, not in a file); a key outside the whitelist. + Pack: settings · lean: no. + """ + return await _backend(ctx).preferences_get(keys) + + +@cad_tool( + summary="Change one whitelisted AutoCAD preference; reports old and new (live only).", + cost="safe", +) +@mcp.tool( + annotations={"title": "Set Preference", "readOnlyHint": False, "destructiveHint": False}, + tags={"system"}, +) +async def system_preferences_set( + key: Annotated[str, "A writable key from system_preferences_get"], + value: Annotated[Any, "New value: bool, int within the key's range, string, or an enum name"], + ctx: Context = None, +) -> dict: + """Write one preference and report `old`, `new` and `changed`. + + Refusals, all before any write: a read-only key (`Files.*`), an unknown + key, a value of the wrong type, an int outside the authored range + (`AutoSaveInterval` 0–600 min, `CursorSize` 1–100, `PickBoxSize` 0–50, …), + an unknown `SaveAsType` name; headless, `capability: "preferences"`. + Pack: settings · lean: no. + """ + await ctx.info(f"Setting preference {key} = {value!r}") + return await _backend(ctx).preferences_set(key, value) + + +@cad_tool( + summary="Ask the operator to pick a point on screen (live only; blocks until they do).", + cost="read", +) +@mcp.tool( + annotations={"title": "Ask Operator: Pick Point", "readOnlyHint": True}, + tags={"system"}, +) +async def user_pick_point( + prompt: Annotated[str, "Shown on the command line, e.g. 'Pick the base point'"], + ctx: Context = None, +) -> dict: + """`Utility.GetPoint`: returns the WCS `x`, `y` (`z`) the operator clicks. + + ESC is an answer, not an error: `cancelled: true` with AutoCAD's reason. + Waiting longer than `COM_CALL_TIMEOUT` returns `timed_out: true` (the + prompt is abandoned on AutoCAD's side; press ESC there). Refusals: an + empty prompt; headless, `capability: "interactive_prompt"` (no operator). + Pack: settings · lean: no. + """ + await ctx.info(f"Asking the operator to pick a point: {prompt}") + return await _backend(ctx).user_pick_point(prompt) + + +@cad_tool( + summary="Ask the operator to select one entity or a set on screen (live only).", + cost="read", +) +@mcp.tool( + annotations={"title": "Ask Operator: Select", "readOnlyHint": True}, + tags={"system"}, +) +async def user_select( + prompt: Annotated[str, "Shown on the command line"], + mode: Annotated[str, "single (GetEntity) | multiple (SelectOnScreen)"] = "single", + ctx: Context = None, +) -> dict: + """Returns `handles` the operator picked — one with `mode="single"` (plus + the `picked` point), any number with `mode="multiple"` (Enter with nothing + selected is `count: 0`, not a cancel). ESC → `cancelled: true`; a wait + longer than `COM_CALL_TIMEOUT` → `timed_out: true`. Refusals: a mode + outside the two, an empty prompt; headless, `capability: + "interactive_prompt"`. Pack: settings · lean: no. + """ + await ctx.info(f"Asking the operator to select ({mode}): {prompt}") + return await _backend(ctx).user_select(prompt, mode) + + +@cad_tool(summary="Print a message on AutoCAD's command line (live only).", cost="safe") +@mcp.tool( + annotations={"title": "Command-Line Message", "readOnlyHint": False, "destructiveHint": False}, + tags={"system"}, +) +async def system_prompt_message( + text: Annotated[str, "The message; one line is best"], + ctx: Context = None, +) -> dict: + """`Utility.Prompt`: tell the operator something where they are looking. + Nothing in the drawing changes. Refusals: an empty message; headless, + `capability: "interactive_prompt"`. Pack: settings · lean: no.""" + return await _backend(ctx).system_prompt_message(text) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_environment_live.py b/tests/test_environment_live.py new file mode 100644 index 0000000..c8ddcd1 --- /dev/null +++ b/tests/test_environment_live.py @@ -0,0 +1,467 @@ +"""The live-only environment trio: launch, preferences, operator prompts. + +Headless there is no application to launch, no registry of preferences and +no operator to prompt, so ``EzdxfBackend`` inherits the ``@capability`` +defaults and refuses with a key ``system_capabilities`` declares. Live, the +COM paths are exercised against fake ActiveX objects asserting the call +shapes; ``scripts/smoke_settings_com.py`` runs them once for real. + +A cancelled pick is an answer (``cancelled: true``), not an error; a +wall-clock timeout is ``timed_out: true``. Preference writes are refused by +name before any ActiveX call: read-only keys, unknown keys, values outside +the authored range. +""" + +from __future__ import annotations + +import types + +import pytest +from fastmcp import Client + +import config +import server +from backends.base import UnsupportedCapabilityError +from backends.capability import is_capability_default +from backends.ezdxf_backend import EzdxfBackend +from engineering.environment.preferences import ( + PREFERENCE_KEYS, + READ_ONLY_KEYS, + SAVE_AS_TYPES, + decode_value, + describe_preferences, + split_key, + validate_preference, +) + +pytestmark = pytest.mark.asyncio + + +# ── the whitelist, pure ───────────────────────────────────────────────────── + + +def test_whitelist_is_the_spec_list(): + assert set(PREFERENCE_KEYS) == { + "OpenSave.SaveAsType", + "OpenSave.AutoSaveInterval", + "OpenSave.CreateBackup", + "OpenSave.IncrementalSavePercent", + "Display.CursorSize", + "Drafting.AutoSnapMarkerSize", + "Drafting.AutoSnapTooltip", + "Selection.PickBoxSize", + "Output.DefaultPlotStyleTable", + "Output.DefaultOutputDevice", + } + assert READ_ONLY_KEYS == ( + "Files.SupportPath", + "Files.TemplateDwgPath", + "Files.PrinterStyleSheetPath", + "Files.PrinterConfigPath", + ) + assert split_key("OpenSave.AutoSaveInterval") == ("OpenSave", "AutoSaveInterval") + assert SAVE_AS_TYPES["ac2018_dwg"] == 64 and SAVE_AS_TYPES["ac2000_dwg"] == 12 + + +def test_validate_preference_coerces_and_refuses_by_name(): + assert validate_preference("OpenSave.AutoSaveInterval", 10) == 10 + assert validate_preference("OpenSave.AutoSaveInterval", 10.0) == 10 + assert validate_preference("OpenSave.CreateBackup", False) is False + assert validate_preference("OpenSave.SaveAsType", "AC2018_DWG") == 64 + assert validate_preference("OpenSave.SaveAsType", 48) == 48 + assert ( + validate_preference("Output.DefaultPlotStyleTable", " monochrome.ctb ") == "monochrome.ctb" + ) + with pytest.raises(ValueError, match="'Files.SupportPath' is read-only"): + validate_preference("Files.SupportPath", "C:/x") + with pytest.raises(ValueError, match="unknown preference 'Display.Theme'"): + validate_preference("Display.Theme", 1) + with pytest.raises(ValueError, match="between 0 and 600, got 601"): + validate_preference("OpenSave.AutoSaveInterval", 601) + with pytest.raises(TypeError, match="takes an int"): + validate_preference("Selection.PickBoxSize", 2.5) + with pytest.raises(TypeError, match="takes a bool"): + validate_preference("OpenSave.CreateBackup", 1) + with pytest.raises(ValueError, match="must be one of"): + validate_preference("OpenSave.SaveAsType", "ac1999_dwg") + with pytest.raises(TypeError, match="takes a non-empty string"): + validate_preference("Output.DefaultOutputDevice", "") + + +def test_decode_value_maps_enum_values_back_to_names(): + assert decode_value("OpenSave.SaveAsType", 64) == "ac2018_dwg" + assert decode_value("OpenSave.SaveAsType", 999) == 999 + assert decode_value("OpenSave.CreateBackup", 1) is True + assert decode_value("Files.SupportPath", "C:/a;C:/b") == "C:/a;C:/b" + + +def test_describe_preferences_covers_every_key(): + rows = {row["key"]: row for row in describe_preferences()} + assert set(rows) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS) + assert rows["OpenSave.AutoSaveInterval"] == { + "key": "OpenSave.AutoSaveInterval", + "kind": "int", + "range": [0, 600], + "enum": None, + "writable": True, + } + assert rows["Files.SupportPath"]["writable"] is False + + +# ── headless: declared refusals ───────────────────────────────────────────── + + +@pytest.mark.parametrize( + "method, args, key", + [ + ("system_launch", (), "live_application"), + ("preferences_get", (), "preferences"), + ("preferences_set", ("Display.CursorSize", 5), "preferences"), + ("user_pick_point", ("Pick",), "interactive_prompt"), + ("user_select", ("Select",), "interactive_prompt"), + ("system_prompt_message", ("hi",), "interactive_prompt"), + ], +) +async def test_headless_refuses_with_the_declared_key(backend, method, args, key): + assert is_capability_default(EzdxfBackend, method) + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await getattr(backend, method)(*args) + assert excinfo.value.capability == key + assert "ezdxf" in str(excinfo.value) + feature = backend.capabilities().features[key] + assert feature.supported is False and feature.reason + + +async def test_refusal_reaches_the_client_with_the_key(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {"bootstrap": False}) + result = await client.call_tool( + "user_pick_point", {"prompt": "Pick the base point"}, raise_on_error=False + ) + assert result.is_error is True + payload = result.structured_content + assert payload["ok"] is False and payload["capability"] == "interactive_prompt" + caps = (await client.call_tool("system_capabilities", {})).structured_content + assert caps["features"]["interactive_prompt"]["supported"] is False + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +def _com_error(description: str): + pywintypes = pytest.importorskip("pywintypes") + return pywintypes.com_error( + -2147352567, "Exception occurred.", (0, None, description, None, 0, -2145320928), None + ) + + +class _FakeUtility: + def __init__(self): + self.calls: list[tuple[str, tuple]] = [] + self.point = (12.5, 7.25, 0.0) + self.raise_on_pick = None + self.entity = types.SimpleNamespace(Handle="1F3") + + def GetPoint(self, base, prompt): + self.calls.append(("GetPoint", (base, prompt))) + if self.raise_on_pick is not None: + raise self.raise_on_pick + return self.point + + def GetEntity(self, prompt): + self.calls.append(("GetEntity", (prompt,))) + if self.raise_on_pick is not None: + raise self.raise_on_pick + return self.entity, (3.0, 4.0, 0.0) + + def Prompt(self, text): + self.calls.append(("Prompt", (text,))) + + +class _FakeSelectionSet: + def __init__(self, name, items): + self.Name = name + self._items = items + self.calls: list[str] = [] + + @property + def Count(self): + return len(self._items) + + def Item(self, index): + return self._items[index] + + def SelectOnScreen(self): + self.calls.append("SelectOnScreen") + + def Delete(self): + self.calls.append("Delete") + + +class _FakeSelectionSets: + def __init__(self, items): + self.items = items + self.sets: list[_FakeSelectionSet] = [] + + def Add(self, name): + ss = _FakeSelectionSet(name, self.items) + self.sets.append(ss) + return ss + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + utility = _FakeUtility() + selection = [types.SimpleNamespace(Handle="A1"), types.SimpleNamespace(Handle="B2")] + document = types.SimpleNamespace( + Name="Part.dwg", + FullName="C:/work/Part.dwg", + Utility=utility, + SelectionSets=_FakeSelectionSets(selection), + ) + preferences = types.SimpleNamespace( + OpenSave=types.SimpleNamespace( + SaveAsType=64, AutoSaveInterval=10, CreateBackup=True, IncrementalSavePercent=50 + ), + Display=types.SimpleNamespace(CursorSize=5), + Drafting=types.SimpleNamespace(AutoSnapMarkerSize=5, AutoSnapTooltip=True), + Selection=types.SimpleNamespace(PickBoxSize=3), + Output=types.SimpleNamespace( + DefaultPlotStyleTable="monochrome.ctb", DefaultOutputDevice="DWG To PDF.pc3" + ), + Files=types.SimpleNamespace( + SupportPath="C:/support", + TemplateDwgPath="C:/t", + PrinterStyleSheetPath="C:/p", + PrinterConfigPath="C:/pc3", + ), + ) + app = types.SimpleNamespace( + Preferences=preferences, + Version="25.0", + Documents=types.SimpleNamespace(Count=1), + ActiveDocument=document, + ) + monkeypatch.setattr(module, "_acad_app", lambda: app) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, app, document + + +async def test_com_no_longer_inherits_the_defaults(): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends.com_backend import ComBackend + + for method in ( + "system_launch", + "preferences_get", + "preferences_set", + "user_pick_point", + "user_select", + "system_prompt_message", + ): + assert not is_capability_default(ComBackend, method), method + + +async def test_com_pick_point_returns_the_operator_point(com_backend): + backend, app, document = com_backend + import pythoncom + + result = await backend.user_pick_point("Pick the base point") + assert result == {"cancelled": False, "x": 12.5, "y": 7.25, "z": 0.0, "backend": "com"} + (call,) = document.Utility.calls + assert call[0] == "GetPoint" and call[1][0] is pythoncom.Missing + assert call[1][1] == "\nPick the base point" + + +async def test_com_pick_point_cancel_is_an_answer_not_an_error(com_backend): + backend, app, document = com_backend + document.Utility.raise_on_pick = _com_error("User input is a keyword") + result = await backend.user_pick_point("Pick") + assert result == {"cancelled": True, "reason": "User input is a keyword", "backend": "com"} + + +async def test_com_pick_point_other_com_errors_still_raise(com_backend): + backend, app, document = com_backend + pywintypes = pytest.importorskip("pywintypes") + document.Utility.raise_on_pick = pywintypes.com_error( + -2147418111, "Call was rejected", None, None + ) + with pytest.raises(pywintypes.com_error): + await backend.user_pick_point("Pick") + + +async def test_com_pick_point_timeout_reports_timed_out(com_backend, monkeypatch): + backend, app, document = com_backend + monkeypatch.setattr(config.settings, "com_call_timeout", 7.0) + + async def _timed_out(func, *args, **kwargs): + raise RuntimeError( + "AutoCAD did not respond within 7s. The application may be showing a modal dialog" + ) + + monkeypatch.setattr(backend, "_run", _timed_out) + result = await backend.user_pick_point("Pick") + assert result["timed_out"] is True and result["timeout_s"] == 7.0 + assert "did not respond" in result["error"] + result = await backend.user_select("Pick", "multiple") + assert result["timed_out"] is True + + +async def test_com_prompt_validation_happens_before_activex(com_backend): + backend, app, document = com_backend + with pytest.raises(ValueError, match="prompt must not be empty"): + await backend.user_pick_point(" ") + with pytest.raises(ValueError, match="mode must be 'single' or 'multiple'"): + await backend.user_select("Pick", "many") + assert document.Utility.calls == [] and document.SelectionSets.sets == [] + + +async def test_com_select_single_uses_getentity(com_backend): + backend, app, document = com_backend + result = await backend.user_select("Pick the flange") + assert result == { + "cancelled": False, + "mode": "single", + "handles": ["1F3"], + "picked": [3.0, 4.0], + "backend": "com", + } + assert document.Utility.calls == [("GetEntity", ("\nPick the flange",))] + document.Utility.raise_on_pick = _com_error("Function cancelled") + assert (await backend.user_select("Pick"))["cancelled"] is True + + +async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend): + backend, app, document = com_backend + result = await backend.user_select("Select the pipes", mode="multiple") + assert result == { + "cancelled": False, + "mode": "multiple", + "handles": ["A1", "B2"], + "count": 2, + "backend": "com", + } + assert document.Utility.calls == [("Prompt", ("\nSelect the pipes\n",))] + (ss,) = document.SelectionSets.sets + assert ss.Name.startswith("_PICK_") and ss.calls == ["SelectOnScreen", "Delete"] + + +async def test_com_prompt_message_writes_to_the_command_line(com_backend): + backend, app, document = com_backend + result = await backend.system_prompt_message("Drawing saved by the assistant") + assert result == {"ok": True, "text": "Drawing saved by the assistant", "backend": "com"} + assert document.Utility.calls == [("Prompt", ("\nDrawing saved by the assistant\n",))] + + +async def test_com_preferences_get_reads_the_whitelist(com_backend): + backend, app, document = com_backend + result = await backend.preferences_get() + assert result["read_only"] == list(READ_ONLY_KEYS) + assert result["values"]["OpenSave.SaveAsType"] == "ac2018_dwg" + assert result["values"]["OpenSave.AutoSaveInterval"] == 10 + assert result["values"]["Files.SupportPath"] == "C:/support" + assert set(result["values"]) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS) + subset = await backend.preferences_get(["Display.CursorSize"]) + assert subset == {"values": {"Display.CursorSize": 5}, "read_only": [], "backend": "com"} + with pytest.raises(ValueError, match="unknown preference keys \\['Display.Theme'\\]"): + await backend.preferences_get(["Display.Theme"]) + + +async def test_com_preferences_set_reports_old_and_new(com_backend): + backend, app, document = com_backend + result = await backend.preferences_set("OpenSave.AutoSaveInterval", 15) + assert result == { + "ok": True, + "key": "OpenSave.AutoSaveInterval", + "old": 10, + "new": 15, + "changed": True, + "backend": "com", + } + assert app.Preferences.OpenSave.AutoSaveInterval == 15 + same = await backend.preferences_set("OpenSave.AutoSaveInterval", 15) + assert same["changed"] is False + enum = await backend.preferences_set("OpenSave.SaveAsType", "ac2013_dwg") + assert enum["old"] == "ac2018_dwg" and enum["new"] == "ac2013_dwg" + assert app.Preferences.OpenSave.SaveAsType == 60 + + +async def test_com_preferences_set_refuses_before_any_write(com_backend): + backend, app, document = com_backend + with pytest.raises(ValueError, match="read-only"): + await backend.preferences_set("Files.SupportPath", "C:/evil") + with pytest.raises(ValueError, match="between 1 and 100"): + await backend.preferences_set("Display.CursorSize", 0) + with pytest.raises(ValueError, match="unknown preference"): + await backend.preferences_set("Display.Theme", 1) + assert app.Preferences.Files.SupportPath == "C:/support" + assert app.Preferences.Display.CursorSize == 5 + + +async def test_com_launch_attaches_first_and_dispatches_otherwise(com_backend, monkeypatch): + backend, app, document = com_backend + from backends import com_backend as module + + monkeypatch.setattr(module, "_COM_STATE", {}) + calls: list[str] = [] + + def _get_active(progid): + calls.append(f"GetActiveObject:{progid}") + raise RuntimeError("not running") + + def _dispatch(progid): + calls.append(f"Dispatch:{progid}") + app.Visible = False + return app + + monkeypatch.setattr(module.win32com.client, "GetActiveObject", _get_active) + monkeypatch.setattr(module.win32com.client, "Dispatch", _dispatch) + result = await backend.system_launch(visible=True) + assert result == { + "launched": True, + "attached": False, + "version": "25.0", + "document": "Part.dwg", + "visible": True, + "progid": config.settings.cad_progid, + "backend": "com", + } + assert calls == [ + f"GetActiveObject:{config.settings.cad_progid}", + f"Dispatch:{config.settings.cad_progid}", + ] + assert app.Visible is True and module._COM_STATE["app"] is app + + again = await backend.system_launch() + assert again["launched"] is False and again["attached"] is True + assert len(calls) == 2, "an app already cached is attached, not re-dispatched" + + +async def test_com_launch_opens_a_validated_path(com_backend, monkeypatch, tmp_path): + backend, app, document = com_backend + from backends import com_backend as module + + opened: list[str] = [] + app.Documents = types.SimpleNamespace( + Count=1, + Open=lambda path: opened.append(path) or types.SimpleNamespace(Name="gear.dwg"), + ) + monkeypatch.setattr(module, "_COM_STATE", {"app": app}) + target = tmp_path / "gear.dwg" + target.write_bytes(b"") + result = await backend.system_launch(open_path=str(target)) + assert result["document"] == "gear.dwg" and opened == [str(target.resolve())] + from fastmcp.exceptions import ToolError + + with pytest.raises(ToolError): + await backend.system_launch(open_path="../../etc/passwd") diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 5013664..305aaaf 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -522,8 +522,12 @@ async def test_tool_group_sizes_are_unchanged(): `document_close` (multi-document on both engines) opened SECTION 20; `layers` moved 14 -> 18 with the four `layer_state_*` tools and `view` 4 -> 10 with `view_named_*` and `ucs_*` (UCS files under `view`, where - AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. Every - other number here has been unchanged since the snapshot was taken. + AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. `system` + moved 7 -> 13 with v1.6's live-only environment tools (`system_launch`, + `system_preferences_get/set`, `user_pick_point`, `user_select`, + `system_prompt_message`), which refuse headlessly with declared + capability keys. Every other number here has been unchanged since the + snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -542,10 +546,10 @@ async def test_tool_group_sizes_are_unchanged(): "pid": 9, "premium": 12, "solids": 5, - "system": 7, + "system": 13, "templates": 2, "transactions": 3, "validation": 1, "view": 10, } - assert sum(sizes.values()) == 179 + assert sum(sizes.values()) == 185 From 2052915f34acbdeedb7ebdcda26185ed63c20111 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:34:26 +0300 Subject: [PATCH 12/12] =?UTF-8?q?fix(task-23):=20user=5Fselect=20reports?= =?UTF-8?q?=20the=20picked=20point=20in=20WCS=20=E2=80=94=20GetEntity=20an?= =?UTF-8?q?swers=20in=20the=20current=20UCS,=20so=20translate=20acUCS?= =?UTF-8?q?=E2=86=92acWorld=20before=20returning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on AutoCAD 2026: under a UCS at (100,50) rotated 90 deg, a pick on a circle at WCS (105,70) came back from IAcadUtility.GetEntity as (20,-5,0), while Utility.GetPoint already answered in WCS. A model doing ucs_set -> user_select -> entity_create_* at `picked` would place geometry at a silently wrong position. The point goes through Utility.TranslateCoordinates(acUCS, acWorld) packed as a VT_ARRAY|VT_R8 VARIANT (the plain tuple GetEntity hands back is refused as "Invalid argument Point"). The fake Utility now models the UCS frame and the VARIANT requirement; a new test pins the measured figures and checks GetPoint stays untranslated. Docstrings state WCS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 20 ++++++++- backends/contracts/environment.py | 2 +- server.py | 4 +- tests/test_environment_live.py | 74 ++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 3a47762..d0d5dc3 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -108,6 +108,11 @@ def _com_teardown(): pass +# AcCoordinateSystem, as Utility.TranslateCoordinates takes it (From / To). +_AC_WORLD = 0 +_AC_UCS = 1 + + def _apoint(x: float, y: float, z: float = 0.0): """Create a VARIANT double-array point for AutoCAD COM.""" return win32com.client.VARIANT( @@ -2081,11 +2086,24 @@ class ComBackend(AutoCADBackend): if reason is None: raise return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"} + # MEASURED (AutoCAD 2026): GetEntity's PickedPoint is in the + # *current UCS* — under a UCS at (100,50) rotated 90 deg, a pick + # on a circle at WCS (105,70) came back as (20,-5,0) — whereas + # Utility.GetPoint already answers in WCS. Every coordinate out + # of a tool is WCS, so translate acUCS → acWorld. The point must + # be a VT_ARRAY|VT_R8 VARIANT: the tuple GetEntity hands back is + # refused as "Invalid argument Point". + world = doc.Utility.TranslateCoordinates( + _apoint(picked[0], picked[1], picked[2] if len(picked) > 2 else 0.0), + _AC_UCS, + _AC_WORLD, + False, + ) return { "cancelled": False, "mode": "single", "handles": [str(obj.Handle)], - "picked": [float(picked[0]), float(picked[1])], + "picked": [float(world[0]), float(world[1])], "backend": "com", } doc.Utility.Prompt(f"\n{text}\n") diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py index 9ffe0bd..9427931 100644 --- a/backends/contracts/environment.py +++ b/backends/contracts/environment.py @@ -148,7 +148,7 @@ class EnvironmentContract(ABC): reason="prompting the operator needs a live AutoCAD session", ) async def user_select(self, prompt: str, mode: str = "single") -> dict: - """→ ``{"cancelled", "handles": [...]}``.""" + """→ ``{"cancelled", "handles": [...]}``; ``mode="single"`` adds ``"picked": [x, y]`` in WCS.""" ... @capability( # noqa: B027 — @capability supplies the body diff --git a/server.py b/server.py index 3738f31..765a98b 100644 --- a/server.py +++ b/server.py @@ -7240,7 +7240,9 @@ async def user_select( ctx: Context = None, ) -> dict: """Returns `handles` the operator picked — one with `mode="single"` (plus - the `picked` point), any number with `mode="multiple"` (Enter with nothing + the `picked` point `[x, y]` in **WCS** — AutoCAD reports it in the current + UCS and the tool translates, so a UCS made current by `ucs_set` never + shifts it), any number with `mode="multiple"` (Enter with nothing selected is `count: 0`, not a cancel). ESC → `cancelled: true`; a wait longer than `COM_CALL_TIMEOUT` → `timed_out: true`. Refusals: a mode outside the two, an empty prompt; headless, `capability: diff --git a/tests/test_environment_live.py b/tests/test_environment_live.py index c8ddcd1..53ff63e 100644 --- a/tests/test_environment_live.py +++ b/tests/test_environment_live.py @@ -157,9 +157,21 @@ def _com_error(description: str): class _FakeUtility: + """IAcadUtility as measured on AutoCAD 2026. + + ``GetPoint`` answers in WCS; ``GetEntity``'s PickedPoint is in the + *current UCS* (``ucs_origin`` / ``ucs_xdir`` / ``ucs_ydir``, world by + default); ``TranslateCoordinates`` only accepts a VT_ARRAY|VT_R8 VARIANT — + the plain tuple GetEntity returns is refused as "Invalid argument Point". + """ + def __init__(self): self.calls: list[tuple[str, tuple]] = [] self.point = (12.5, 7.25, 0.0) + self.picked_ucs = (3.0, 4.0, 0.0) + self.ucs_origin = (0.0, 0.0, 0.0) + self.ucs_xdir = (1.0, 0.0, 0.0) + self.ucs_ydir = (0.0, 1.0, 0.0) self.raise_on_pick = None self.entity = types.SimpleNamespace(Handle="1F3") @@ -173,7 +185,34 @@ class _FakeUtility: self.calls.append(("GetEntity", (prompt,))) if self.raise_on_pick is not None: raise self.raise_on_pick - return self.entity, (3.0, 4.0, 0.0) + return self.entity, self.picked_ucs + + def TranslateCoordinates(self, point, from_cs, to_cs, displacement): + import pythoncom + from win32com.client import VARIANT + + if not isinstance(point, VARIANT) or point.varianttype != ( + pythoncom.VT_ARRAY | pythoncom.VT_R8 + ): + raise _com_error("Invalid argument Point in TranslateCoordinates") + self.calls.append( + ("TranslateCoordinates", (tuple(point.value), from_cs, to_cs, displacement)) + ) + assert (from_cs, to_cs, displacement) == (1, 0, False) # acUCS → acWorld, a point + u, v, w = point.value + ox, oy, oz = self.ucs_origin + xx, xy, xz = self.ucs_xdir + yx, yy, yz = self.ucs_ydir + zx, zy, zz = ( + xy * yz - xz * yy, + xz * yx - xx * yz, + xx * yy - xy * yx, + ) + return ( + ox + u * xx + v * yx + w * zx, + oy + u * xy + v * yy + w * zy, + oz + u * xz + v * yz + w * zz, + ) def Prompt(self, text): self.calls.append(("Prompt", (text,))) @@ -336,11 +375,42 @@ async def test_com_select_single_uses_getentity(com_backend): "picked": [3.0, 4.0], "backend": "com", } - assert document.Utility.calls == [("GetEntity", ("\nPick the flange",))] + assert document.Utility.calls == [ + ("GetEntity", ("\nPick the flange",)), + ("TranslateCoordinates", ((3.0, 4.0, 0.0), 1, 0, False)), + ] document.Utility.raise_on_pick = _com_error("Function cancelled") assert (await backend.user_select("Pick"))["cancelled"] is True +async def test_com_select_single_reports_the_pick_in_wcs_under_a_ucs(com_backend): + """MEASURED (AutoCAD 2026): GetEntity's PickedPoint is in the current UCS. + + UCS origin (100,50,0) rotated 90 deg (X dir (0,1,0)); the operator picked + a circle at WCS (105,70): GetEntity reported (20,-5,0) and + TranslateCoordinates(acUCS → acWorld) gave (105,70,0). GetPoint already + answers in WCS, so ``user_pick_point`` and ``user_select`` must agree. + """ + backend, app, document = com_backend + utility = document.Utility + utility.ucs_origin = (100.0, 50.0, 0.0) + utility.ucs_xdir = (0.0, 1.0, 0.0) + utility.ucs_ydir = (-1.0, 0.0, 0.0) + utility.picked_ucs = (20.0, -5.0, 0.0) + result = await backend.user_select("Pick the circle") + assert result["picked"] == [105.0, 70.0] + assert result["handles"] == ["1F3"] and result["cancelled"] is False + # A translated UCS without rotation (origin (100,50)): (15,20) → (115,70). + utility.ucs_xdir, utility.ucs_ydir = (1.0, 0.0, 0.0), (0.0, 1.0, 0.0) + utility.picked_ucs = (15.0, 20.0, 0.0) + assert (await backend.user_select("Pick"))["picked"] == [115.0, 70.0] + # GetPoint is not translated — it already answers in WCS. + utility.point = (110.0, 70.0, 0.0) + point = await backend.user_pick_point("Pick") + assert (point["x"], point["y"]) == (110.0, 70.0) + assert not any(name == "TranslateCoordinates" for name, _ in utility.calls[-1:]) + + async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend): backend, app, document = com_backend result = await backend.user_select("Select the pipes", mode="multiple")