From dcc8f07bf4993b2e59c9dd210ae34b2de7372eec Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:47:39 +0300 Subject: [PATCH] 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 == []