diff --git a/README.md b/README.md index 5993c66..621f20e 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:** 185 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:** 204 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> 204 is the **registered** count; a default install advertises 199 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -105,6 +105,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | 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), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` | | Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly) | +| 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/base.py b/backends/base.py index b024f48..951cb02 100644 --- a/backends/base.py +++ b/backends/base.py @@ -25,6 +25,7 @@ from backends.contracts import ( EntityCreationContract, EntityModificationContract, EntityQueryContract, + EnvironmentContract, GdtContract, IdentityContract, LayerContract, @@ -188,6 +189,7 @@ class AutoCADBackend( GdtContract, SettingsContract, StylesContract, + EnvironmentContract, ): """All backends implement this interface. diff --git a/backends/com_backend.py b/backends/com_backend.py index 3030e34..0e60561 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -111,6 +111,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( @@ -135,6 +140,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: @@ -1421,6 +1434,23 @@ class ComBackend(AutoCADBackend): "lisp": FeatureCapability(True, "sanitized"), "registry_sysvar": FeatureCapability(True, "native"), "dwgprops": FeatureCapability(True, "native"), + "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( + True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out" + ), }, ) @@ -1958,6 +1988,840 @@ 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 + + # ── 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. + # 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: + 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: + 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_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 + 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() + # "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 + 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) + 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) + # 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) + + 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, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS + + def _sync(): + # GetVariable is an AcadDocument member; AcadApplication has none + # (measured: ``hasattr(app, "GetVariable") is False``). + doc = _acad_doc() + current = str(doc.GetVariable("UCSNAME") or "") + rows = [ + { + "name": WORLD, + "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(doc.GetVariable("WORLDUCS")) == 1: + rows[0]["current"] = True + else: + rows.append( + { + "name": None, + "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, + } + ) + 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(): + 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. 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(doc.GetVariable("CMDACTIVE")) + except Exception as exc: + 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=" + 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) + + # ── 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"} + # 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(world[0]), float(world[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). @@ -3188,14 +4052,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 1fc45cb..49835f5 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 @@ -39,6 +40,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..1340340 --- /dev/null +++ b/backends/contracts/environment.py @@ -0,0 +1,178 @@ +"""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 (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: + """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 + 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". + + 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 + 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) ─────────────── + + @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": [...]}``; ``mode="single"`` adds ``"picked": [x, y]`` in WCS.""" + ... + + @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) ─── + + @abstractmethod + async def drawing_properties_get(self) -> dict: + """→ ``{"summary": {"title","subject","author","keywords","comments"} + (None each headless), "summary_available": bool, "custom": {k: v}}``.""" + ... + + @abstractmethod + 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/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 59ae718..e020fa8 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 @@ -1313,6 +1314,74 @@ 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 + # `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 + + @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 # --------------------------------------------------------------------------- @@ -1321,17 +1390,33 @@ 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") + _active_layer_set = _ActiveField("active_layer_set") + _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 @@ -1339,6 +1424,129 @@ 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. 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) + 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 + # 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 + self._docs[new_key] = state + self._active_key = new_key + + @staticmethod + def _file_identity(path: str) -> tuple: + """What two names of one file share - the *file*, not its spelling. + + 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: + absolute = os.path.abspath(path) + except (OSError, ValueError): # a path this OS cannot normalise + 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: + 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``: 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 or not state.path: + continue + if any(self._file_identity(c) == wanted for c in (key, state.path)): + 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(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" @@ -1438,6 +1646,36 @@ class EzdxfBackend(AutoCADBackend): False, reason="summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer", ), + "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 + # 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" + ), }, ) @@ -1446,21 +1684,195 @@ 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. "Already open" is + decided by ``_path_holder`` (file identity), not by the key string: + 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 + key = f"untitled-{self._untitled_counter}" + reloaded = False + else: + key = os.path.abspath(path) + 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 + + 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: + # 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 + # 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 + for key, state in self._docs.items() + 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]]) + 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(state.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. + + 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" + ) + # ``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 + 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: @@ -1609,10 +2021,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 " @@ -1805,22 +2214,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 @@ -1840,24 +2253,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 @@ -1875,6 +2295,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 @@ -1890,6 +2311,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 @@ -2060,6 +2482,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) @@ -2081,6 +2504,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) @@ -2298,6 +2725,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, @@ -2308,6 +2736,524 @@ 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) + + # ── 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 ─────────────────────────────────────────────────────────── + # `_active_vport` (the `*Active` VPORT entry) is defined once, in the + # `# ── settings (track E, group C) ──` block at the end of the class. + + @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) + # 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, + "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) + # 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 = 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 + # 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, + "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", + "vport_height": fit_height, + "aspect_ratio": aspect, + "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, + WORLD_ORIGIN, + WORLD_X_AXIS, + WORLD_Y_AXIS, + is_world_axes, + ) + + def _sync(): + doc = self._require_doc() + 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, 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) + + 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). @@ -3277,29 +4223,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 @@ -6728,6 +7689,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 559b6a3..f139c03 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -84,20 +84,30 @@ class ToolAliases: # (batch_plot); a drafter typing it could mean either. # SAVEAS -- the same command saves a copy (drawing_save_as) or a template # (drawing_template_save, its "Drawing Template" file type). +# 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. +# 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( { "ARRAY", "BLOCK", + "CLOSE", "DIMSTYLE", "DWGPROPS", "ERASE", "INSERT", "LAYER", + "LAYERSTATE", "LAYOUT", "MEASUREGEOM", "MLEADERSTYLE", + "OPTIONS", "PAGESETUP", "PLINE", "PLOT", @@ -105,6 +115,8 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "SAVEAS", "SETVAR", "STYLE", + "UCS", + "VIEW", "ZOOM", } ) @@ -1672,6 +1684,181 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "which plot styles are installed", ), ), + # ── 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", + ), + ), + "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", + ), + ), + "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"), + ), + "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", + ), + ), + "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"), + ), + "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 58a28b1..5706bcc 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": 185, + "tools": 204, "resources": 8, "prompts": 5, "groups": 21 @@ -14,22 +14,22 @@ "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 13, + "drawing": 16, "engineering": 10, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, - "layers": 14, + "layers": 18, "layouts": 16, "pid": 9, "premium": 12, "solids": 5, "styles": 10, - "system": 8, + "system": 14, "templates": 4, "transactions": 3, "validation": 1, - "view": 4 + "view": 10 }, "tools": [ { @@ -522,6 +522,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", @@ -1714,6 +1754,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", @@ -2262,6 +2356,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", @@ -2482,6 +2631,73 @@ "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": "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", @@ -2496,6 +2712,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..d16b22c --- /dev/null +++ b/engineering/environment/__init__.py @@ -0,0 +1,23 @@ +"""Environment helpers (v1.6 track E): portable layer states, view and UCS +argument resolution, the preference whitelist. 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 .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/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/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/engineering/environment/ucs.py b/engineering/environment/ucs.py new file mode 100644 index 0000000..3e95e7b --- /dev/null +++ b/engineering/environment/ucs.py @@ -0,0 +1,82 @@ +"""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 + +#: 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: + 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 d803cf1..181576b 100644 --- a/server.py +++ b/server.py @@ -1340,9 +1340,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) @@ -7498,12 +7501,452 @@ async def drawing_template_save( # --------------------------------------------------------------------------- -# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── +# ── SECTION 20: Environment (22 tools) ────────────────────────────────────── # --------------------------------------------------------------------------- -# Group C (settings) opens this section with `system_variable_describe` and, -# in Task 16, the two `drawing_properties_*` tools; group V's merge extends the -# header count to the full environment surface (documents, layer states, -# named views, UCS, launch, preferences, interactive prompts). + + +@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) + + +@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, 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) + + +@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: 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) + + +@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. `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": active["name"] if active else None, + "current_unnamed": bool(active and active["name"] is 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) + + +@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 `[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: + "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) @cad_tool( diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..16a9e08 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,817 @@ +"""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 tempfile +import types + +import pytest +from fastmcp import Client + +import config +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_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)) + 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_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" + + +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( + 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. + + ``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_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. + + 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)) + 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 _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)) + 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" + + +@_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 + + +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 ────────────────────────────── + + +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() + backend._active_layer_set = "iso13567" + 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" + 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") + + +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_environment_live.py b/tests/test_environment_live.py new file mode 100644 index 0000000..53ff63e --- /dev/null +++ b/tests/test_environment_live.py @@ -0,0 +1,537 @@ +"""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: + """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") + + 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, 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,))) + + +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",)), + ("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") + 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_layer_states.py b/tests/test_layer_states.py new file mode 100644 index 0000000..ffb4b0a --- /dev/null +++ b/tests/test_layer_states.py @@ -0,0 +1,514 @@ +"""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 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: + """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._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: + 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 = _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) + 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_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") + 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 26b1980..e6c5243 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -535,8 +535,17 @@ async def test_tool_group_sizes_are_unchanged(): `query`/`export`, so they file with the sheets they set up. `templates` moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19 (tagged `template`, which ranks above `drawing`), then 3 -> 4 when - `drawing_template_save` joined. Every other number - here has been unchanged since the snapshot was taken. + `drawing_template_save` joined. `drawing` + moved 13 -> 16 when v1.6's `document_list` / `document_activate` / + `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. `system` + moved 8 -> 14 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 == { @@ -545,21 +554,21 @@ async def test_tool_group_sizes_are_unchanged(): "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 13, + "drawing": 16, "engineering": 10, "entity_creation": 18, "entity_modification": 16, "entity_query": 9, - "layers": 14, + "layers": 18, "layouts": 16, "pid": 9, "premium": 12, "solids": 5, "styles": 10, - "system": 8, + "system": 14, "templates": 4, "transactions": 3, "validation": 1, - "view": 4, + "view": 10, } - assert sum(sizes.values()) == 185 + assert sum(sizes.values()) == 204 diff --git a/tests/test_views_ucs.py b/tests/test_views_ucs.py new file mode 100644 index 0000000..4026499 --- /dev/null +++ b/tests/test_views_ucs.py @@ -0,0 +1,618 @@ +"""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 fastmcp import Client + +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 + + +# ── 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]) + + +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 ─────────────────────────────────────────────────── + + +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", + "vport_height": 20.0, + "aspect_ratio": pytest.approx(1.34), + "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 + + +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 + 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 ─────────────────────────────────────────────────────────── + + +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 + + +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 ────────────────────────────── + + +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 + + +# 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: + """``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.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 + + @ActiveViewport.setter + def ActiveViewport(self, value): + self._viewport = value + self.viewport_sets += 1 + + def SendCommand(self, macro): + 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 + + 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), + "VIEWCTR": (5.0, 6.0, 0.0), + "VIEWSIZE": 20.0, + "SCREENSIZE": _SCREEN, + } + 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) + 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_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 == (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 + 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_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) + 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": "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}, + ] + + +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_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") + 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_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"): + await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [1, 1, 0]) + assert document.UserCoordinateSystems.calls == []