diff --git a/backends/com_backend.py b/backends/com_backend.py index 391f00c..006e43d 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1534,9 +1534,22 @@ class ComBackend(AutoCADBackend): warnings: list[str] = [] # Current first: AutoCAD refuses to freeze the active layer, so the # active one must already be the state's before the loop freezes. + # And it refuses to make a *frozen* layer current (measured, AutoCAD + # 2026: ``doc.ActiveLayer = `` raises 'Error setting active + # layer'), so when the state thaws its own current layer that thaw + # comes before the ActiveLayer write instead of after it in the + # loop. Only a requested property is touched: without "frozen" the + # refusal stands and lands in ``warnings`` like every other one. if "current" in props and state["current_layer"] not in missing: - doc.ActiveLayer = doc.Layers.Item(state["current_layer"]) - current = state["current_layer"] + target_name = state["current_layer"] + target = doc.Layers.Item(target_name) + try: + if "frozen" in props and not bool(state["layers"][target_name]["frozen"]): + target.Freeze = False + doc.ActiveLayer = target + current = target_name + except Exception as exc: # e.g. the state's current layer is frozen + warnings.append(f"{target_name}: cannot be made current: {exc}") applied = 0 for layer_name, snap in state["layers"].items(): if layer_name in missing: @@ -1633,6 +1646,13 @@ class ComBackend(AutoCADBackend): except Exception: return None + @staticmethod + def _com_display_aspect(doc) -> float: + """Width / height of the current viewport's display, from SCREENSIZE.""" + size = doc.GetVariable("SCREENSIZE") + sx, sy = float(size[0]), float(size[1]) + return sx / sy if sx > 0 and sy > 0 else 1.0 + @staticmethod def _com_view_row(view) -> dict: center = view.Center @@ -1652,12 +1672,20 @@ class ComBackend(AutoCADBackend): def _sync(): doc = _acad_doc() - vport = doc.ActiveViewport - vp_center = vport.Center - vp_height = float(vport.Height) or 1.0 - aspect = float(vport.Width) / vp_height if vp_height else 1.5 - cx, cy = args["center"] or (float(vp_center[0]), float(vp_center[1])) - h = args["height"] or vp_height + # "The current view" is VIEWCTR / VIEWSIZE at the display's aspect + # (SCREENSIZE), which is what AutoCAD's own `-VIEW _S` records. + # NOT ``doc.ActiveViewport.Center/Height/Width``: that object does + # not track the display — measured (AutoCAD 2026) it still + # answered the document's initial view after `_.ZOOM _C 5,6 20`, + # after ``app.ZoomCenter`` and after its own values were written. + if args["center"] is None or args["height"] is None or args["width"] is None: + view_center = doc.GetVariable("VIEWCTR") + view_height = float(doc.GetVariable("VIEWSIZE")) or 1.0 + aspect = self._com_display_aspect(doc) + else: + view_center, view_height, aspect = None, 1.0, 1.0 + cx, cy = args["center"] or (float(view_center[0]), float(view_center[1])) + h = args["height"] or view_height w = args["width"] or h * aspect view = self._com_view(doc, clean) replaced = view is not None @@ -1695,14 +1723,26 @@ class ComBackend(AutoCADBackend): f"view_named_restore: no named view {clean!r}; saved views: {names}" ) row = self._com_view_row(view) - vport = doc.ActiveViewport - vport.Center = _av(row["center"]) - vport.Height = row["height"] - vport.Width = row["width"] - # ActiveX applies viewport changes only when the object is assigned back. - doc.ActiveViewport = vport - _regen() - return {"ok": True, **row, "applied": "active_viewport", "backend": "com"} + # NOT ``vport.Center/Height/Width`` + ``doc.ActiveViewport = vport``: + # AutoCAD reconciles a width that does not match the display aspect + # by anchoring the viewport's lower-left corner and widening, so the + # view lands off-centre (measured, AutoCAD 2026, display aspect + # 2.013: a 10x20 view at (5, 6) restored to VIEWCTR (20.134, 6)). + # `-VIEW _R` centres the saved window and fits it — VIEWCTR = the + # saved centre, VIEWSIZE = max(height, width / aspect) — and so + # does ZOOM Window on the same rectangle, without a SendCommand. + (cx, cy), h, w = row["center"], row["height"], row["width"] + _acad_app().ZoomWindow( + _apoint(cx - w / 2.0, cy - h / 2.0), _apoint(cx + w / 2.0, cy + h / 2.0) + ) + result = {"ok": True, **row, "applied": "zoom_window", "backend": "com"} + try: # the read-back is what a reviewer measures the tool against + ctr = doc.GetVariable("VIEWCTR") + result["viewctr"] = [float(ctr[0]), float(ctr[1])] + result["viewsize"] = float(doc.GetVariable("VIEWSIZE")) + except Exception as exc: + log.debug("VIEWCTR/VIEWSIZE read-back failed: %s", exc) + return result return await self._run(_sync) @@ -1736,9 +1776,10 @@ class ComBackend(AutoCADBackend): from engineering.environment.ucs import WORLD, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS def _sync(): - app = _acad_app() + # GetVariable is an AcadDocument member; AcadApplication has none + # (measured: ``hasattr(app, "GetVariable") is False``). doc = _acad_doc() - current = str(app.GetVariable("UCSNAME") or "") + current = str(doc.GetVariable("UCSNAME") or "") rows = [ { "name": WORLD, @@ -1757,15 +1798,15 @@ class ComBackend(AutoCADBackend): # (UCS Origin / 3P without saving) as well as for WCS — verified # live (AutoCAD 2026: after `_.UCS _O 10,10,0`, UCSNAME="" and # WORLDUCS=0). WORLDUCS is AutoCAD's own answer to "is this WCS". - if int(app.GetVariable("WORLDUCS")) == 1: + if int(doc.GetVariable("WORLDUCS")) == 1: rows[0]["current"] = True else: rows.append( { "name": None, - "origin": [float(c) for c in app.GetVariable("UCSORG")], - "x_axis": [float(c) for c in app.GetVariable("UCSXDIR")], - "y_axis": [float(c) for c in app.GetVariable("UCSYDIR")], + "origin": [float(c) for c in doc.GetVariable("UCSORG")], + "x_axis": [float(c) for c in doc.GetVariable("UCSXDIR")], + "y_axis": [float(c) for c in doc.GetVariable("UCSYDIR")], "current": True, } ) @@ -1820,16 +1861,20 @@ class ComBackend(AutoCADBackend): clean = validate_name(name, what="UCS name") def _sync(): - app = _acad_app() doc = _acad_doc() if clean.lower() == WORLD: # No ActiveX member selects WCS; the command does. Same guard as - # system_run_command: never send into an active prompt. + # system_run_command: never send into an active prompt. The + # read is a document member, and a failed read REFUSES: a + # swallowed error here once forced cmd_active to 0 and sent + # `_.UCS _W` into whatever prompt was open. try: - cmd_active = int(app.GetVariable("CMDACTIVE")) + cmd_active = int(doc.GetVariable("CMDACTIVE")) except Exception as exc: - log.debug("CMDACTIVE read failed, proceeding: %s", exc) - cmd_active = 0 + raise RuntimeError( + "ucs_restore: cannot verify AutoCAD is idle (CMDACTIVE read " + f"failed: {exc}); nothing was sent" + ) from exc if cmd_active: raise RuntimeError( "AutoCAD has an active command or prompt (CMDACTIVE=" diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py index dd0f5bc..9ffe0bd 100644 --- a/backends/contracts/environment.py +++ b/backends/contracts/environment.py @@ -76,7 +76,10 @@ class EnvironmentContract(ABC): @abstractmethod async def view_named_restore(self, name: str) -> dict: - """ezdxf: sets the ``*Active`` VPORT, ``"applied": "header_only"``.""" + """COM: ZOOM Window on the saved rectangle (``"applied": "zoom_window"`` + plus the read-back ``viewctr`` / ``viewsize``); ezdxf: fits the window + into the ``*Active`` VPORT without touching its aspect ratio, + ``"applied": "header_only"`` plus ``vport_height`` / ``aspect_ratio``.""" ... @abstractmethod diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 63fb8de..b9db27d 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2650,10 +2650,20 @@ class EzdxfBackend(AutoCADBackend): cx, cy = float(view.dxf.center.x), float(view.dxf.center.y) h, w = float(view.dxf.height), float(view.dxf.width) vport = self._active_vport(doc) + # The *Active VPORT's aspect ratio is the DISPLAY's, written by + # AutoCAD at its last save, and AutoCAD reconciles the stored + # window against the real display by its lower-left corner: a + # stored aspect that differs from the display's shifts the centre + # by (display − stored) · height / 2. Measured (AutoCAD 2026, + # display aspect 2.013): a 10x20 view at (5, 6) written with + # aspect 0.5 opened at VIEWCTR (20.134, 6). So the aspect is left + # as the file carries it and the window is FITTED into it — + # height = max(h, w / aspect), the same rule `-VIEW _R` applies — + # which is exactly what AutoCAD's own restore-then-save writes. + aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34 + fit_height = max(h, w / aspect) vport.dxf.center = (cx, cy) - vport.dxf.height = h - if h > 0: - vport.dxf.aspect_ratio = w / h + vport.dxf.height = fit_height # The orientation travels with the window: a foreign drawing's # VIEW may be oblique or twisted, and flattening it to a plan view # of the same center would be a silent lie. The attribute read @@ -2674,6 +2684,8 @@ class EzdxfBackend(AutoCADBackend): # initial view when it opens the file. No live display moves. "applied": "header_only", "store": "vport_active", + "vport_height": fit_height, + "aspect_ratio": aspect, "backend": "ezdxf", } diff --git a/server.py b/server.py index 0c34a30..49b87a7 100644 --- a/server.py +++ b/server.py @@ -6951,8 +6951,11 @@ async def layer_state_restore( `applied` says how many layers and which properties moved. Refusals: an unknown state name (lists the saved ones), a property outside the eight (names the index). Live, a layer AutoCAD refuses to change (freezing the - active layer) lands in `warnings` instead of failing the call. Portable - server states, not Layer States Manager entries. Pack: settings · lean: no. + active layer, making a frozen layer current) lands in `warnings` instead + of failing the call; the state's current layer is thawed before it is + made current when `frozen` is being restored, so save → freeze → restore + round-trips. Portable server states, not Layer States Manager entries. + Pack: settings · lean: no. """ await ctx.info(f"Restoring layer state {name!r}") return await _backend(ctx).layer_state_restore(name, properties) @@ -7022,12 +7025,18 @@ async def view_named_restore( name: Annotated[str, "A name from view_named_list"], ctx: Context = None, ) -> dict: - """Live: sets the active viewport to the saved centre/height/width - (`applied: "active_viewport"`). Headless there is no display: the saved - window is written to the `*Active` VPORT — the view AutoCAD opens the file - on — and the result says `applied: "header_only"`, the same reported-no-op - rule as `view_zoom_extents`. Refusal: an unknown name (lists the saved - views). Pack: settings · lean: no.""" + """Live: zooms the active viewport to the saved window the way `-VIEW _R` + does — VIEWCTR becomes the saved centre and VIEWSIZE the saved height + (or width / display aspect when the window is wider than the display); + `applied: "zoom_window"` plus the read-back `viewctr` / `viewsize`. + Headless there is no display: the saved window is fitted into the + `*Active` VPORT — the view AutoCAD opens the file on — whose aspect ratio + is left as the file carries it (AutoCAD reconciles a changed aspect by the + viewport's lower-left corner, which shifts the centre); the result says + `applied: "header_only"` with the written `vport_height` and the kept + `aspect_ratio`, the same reported-no-op rule as `view_zoom_extents`. + Refusal: an unknown name (lists the saved views). Pack: settings · lean: + no.""" return await _backend(ctx).view_named_restore(name) diff --git a/tests/test_layer_states.py b/tests/test_layer_states.py index 2ba41f0..ffb4b0a 100644 --- a/tests/test_layer_states.py +++ b/tests/test_layer_states.py @@ -10,7 +10,6 @@ the COM tests prove the ActiveX call shapes. from __future__ import annotations import json -import types import pytest @@ -338,15 +337,49 @@ class _FakeDictionaries: class _FakeLayer: + """Models the two refusals measured live (AutoCAD 2026): the active layer + cannot be frozen, and a frozen layer cannot be made active.""" + def __init__(self, name, **props): self.Name = name self.Color = props.get("color", 7) self.Linetype = props.get("linetype", "Continuous") self.LineWeight = props.get("lineweight", -3) self.LayerOn = props.get("on", True) - self.Freeze = props.get("frozen", False) + self._frozen = props.get("frozen", False) self.Lock = props.get("locked", False) self.Plottable = props.get("plot", True) + self.document = None + + @property + def Freeze(self): + return self._frozen + + @Freeze.setter + def Freeze(self, value): + if value and self.document is not None and self.document.ActiveLayer is self: + raise RuntimeError("AutoCAD COM error: cannot freeze the current layer") + self._frozen = bool(value) + + +class _FakeDocument: + def __init__(self, layers, current): + self.Dictionaries = _FakeDictionaries() + self.Layers = _FakeLayers(layers) + self._active = current + for lyr in layers: + lyr.document = self + + @property + def ActiveLayer(self): + return self._active + + @ActiveLayer.setter + def ActiveLayer(self, layer): + if layer.Freeze: + # Measured: com_error 'Error setting active layer' (-2145320874). + raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Error setting active layer") + self._active = layer class _FakeLayers: @@ -376,11 +409,7 @@ def com_backend(monkeypatch): _FakeLayer("GEOMETRY", lineweight=50), _FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False), ] - document = types.SimpleNamespace( - Dictionaries=_FakeDictionaries(), - Layers=_FakeLayers(layers), - ActiveLayer=layers[1], - ) + document = _FakeDocument(layers, current=layers[1]) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None) monkeypatch.setattr(module, "_regen", lambda: None) @@ -429,6 +458,47 @@ async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_bac assert result["missing_layers"] == [] and result["new_layers"] == [] +async def test_com_restore_thaws_the_states_current_layer_before_making_it_current(com_backend): + """The spec's own acceptance path, measured live: save with GEOMETRY + current and HIDDEN frozen → thaw HIDDEN, make 0 current, freeze GEOMETRY + → restore. AutoCAD refuses ``ActiveLayer = ``, so the write + before the loop raised an opaque COM error and nothing was applied.""" + backend, document = com_backend + await backend.layer_state_save("PLOT") + geometry = document.Layers.Item("GEOMETRY") + hidden = document.Layers.Item("HIDDEN") + hidden.Freeze = False + document.ActiveLayer = document.Layers.Item("0") + geometry.Freeze = True + result = await backend.layer_state_restore("PLOT") + assert "warnings" not in result, result + assert document.ActiveLayer is geometry and geometry.Freeze is False + assert hidden.Freeze is True + assert result["applied"] == { + "layers": 3, + "properties": list(PROPERTIES), + "current_layer": "GEOMETRY", + } + + +async def test_com_restore_reports_a_refused_current_layer_instead_of_failing(com_backend): + """Without ``frozen`` in the requested properties the thaw is not ours to + make: AutoCAD's refusal lands in ``warnings`` and the rest still applies.""" + backend, document = com_backend + await backend.layer_state_save("PLOT") + geometry = document.Layers.Item("GEOMETRY") + hidden = document.Layers.Item("HIDDEN") + hidden.Color = 3 + document.ActiveLayer = document.Layers.Item("0") + geometry.Freeze = True + result = await backend.layer_state_restore("PLOT", properties=["color", "current"]) + assert result["applied"]["current_layer"] is None + assert result["applied"]["layers"] == 3 and hidden.Color == 8 + assert geometry.Freeze is True and document.ActiveLayer.Name == "0" + (warning,) = result["warnings"] + assert warning.startswith("GEOMETRY: cannot be made current: ") + + async def test_com_list_and_delete(com_backend): backend, document = com_backend await backend.layer_state_save("A", description="one") diff --git a/tests/test_views_ucs.py b/tests/test_views_ucs.py index 7d36b7f..4026499 100644 --- a/tests/test_views_ucs.py +++ b/tests/test_views_ucs.py @@ -102,6 +102,8 @@ async def test_view_replace_restore_and_list(backend): "width": pytest.approx(26.8), "applied": "header_only", "store": "vport_active", + "vport_height": 20.0, + "aspect_ratio": pytest.approx(1.34), "backend": "ezdxf", } (vport,) = backend._doc.viewports.get("*Active") @@ -116,6 +118,28 @@ async def test_view_replace_restore_and_list(backend): assert "BAD" not in backend._doc.views +async def test_view_restore_keeps_the_vport_aspect_and_fits_the_window(backend): + """Measured (AutoCAD 2026, display aspect 2.013): the *Active VPORT's + aspect ratio is reconciled against the real display by the viewport's + lower-left corner — a 10x20 view at (5, 6) written with aspect 0.5 + opened at VIEWCTR (20.134, 6), and the default 1.34 at (11.734, 6). The + aspect is the display's (AutoCAD writes it at save), so restore leaves it + alone and fits the window into it, like `-VIEW _R` does.""" + (vport,) = backend._doc.viewports.get("*Active") or [backend._doc.viewports.new("*Active")] + vport.dxf.aspect_ratio = 2.0125 # what AutoCAD wrote for a 2262 x 1124 display + await backend.view_named_save("TALL", center=[5, 6], height=20, width=10) + await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100) + tall = await backend.view_named_restore("TALL") + assert vport.dxf.aspect_ratio == pytest.approx(2.0125), "never rewritten from w / h" + assert tuple(vport.dxf.center)[:2] == (5.0, 6.0) + assert vport.dxf.height == 20.0 and tall["vport_height"] == 20.0 + assert tall["aspect_ratio"] == pytest.approx(2.0125) + wide = await backend.view_named_restore("WIDE") + assert vport.dxf.aspect_ratio == pytest.approx(2.0125) + assert vport.dxf.height == pytest.approx(100.0 / 2.0125) + assert wide["vport_height"] == pytest.approx(100.0 / 2.0125) and wide["height"] == 20.0 + + async def test_view_is_written_as_a_plan_view_on_disk(backend, tmp_path): """ezdxf's VIEW default is ``direction=(1, 1, 1)`` — an isometric view. The tool stores the plan window of ``center``/``height``, so the entry must @@ -361,15 +385,31 @@ class _FakeUcsCollection: return u +# Measured (AutoCAD 2026): `-VIEW _S` of VIEWSIZE 20 recorded width 40.267 +# and the corner-anchored restores landed at x = 10.134 / 20.134 — all three +# say the display aspect was 2.0134, i.e. a 2263 x 1124 SCREENSIZE. +_SCREEN = (2263.0, 1124.0) +_DISPLAY_ASPECT = _SCREEN[0] / _SCREEN[1] # 2.0134 + + class _FakeDocument: - def __init__(self): + """``GetVariable`` is an AcadDocument member (``hasattr(app, "GetVariable")`` + is False live). ``ActiveViewport`` is deliberately STALE — measured, the + AcadViewport object keeps the document's initial view whatever the + display shows — so any code that reads it saves the wrong window.""" + + def __init__(self, variables): self.Views = _FakeViews() self.UserCoordinateSystems = _FakeUcsCollection() - self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0) + self.variables = variables + self._viewport = types.SimpleNamespace(Center=(298.98, 148.5), Height=297.0, Width=597.97) self.viewport_sets = 0 self.ActiveUCS = None self.commands: list[str] = [] + def GetVariable(self, name): + return self.variables[name] + @property def ActiveViewport(self): return self._viewport @@ -383,12 +423,29 @@ class _FakeDocument: self.commands.append(macro) +class _FakeApp: + """No ``GetVariable``. ``ZoomWindow`` models ZOOM Window / `-VIEW _R`: + the window is centred and fitted to the display aspect (measured live: + `-VIEW _R` of a 10x20 view at (5, 6) gives VIEWCTR (5, 6), VIEWSIZE 20).""" + + def __init__(self, document): + self.document = document + self.zoom_windows: list[tuple[tuple, tuple]] = [] + + def ZoomWindow(self, lower_left, upper_right): + self.zoom_windows.append((tuple(lower_left), tuple(upper_right))) + w = upper_right[0] - lower_left[0] + h = upper_right[1] - lower_left[1] + cx, cy = lower_left[0] + w / 2.0, lower_left[1] + h / 2.0 + self.document.variables["VIEWCTR"] = (cx, cy, 0.0) + self.document.variables["VIEWSIZE"] = max(h, w / _DISPLAY_ASPECT) + + @pytest.fixture def com_backend(monkeypatch): pytest.importorskip("win32com.client", reason="pywin32 not installed") from backends import com_backend as module - document = _FakeDocument() variables = { "CMDACTIVE": 0, "UCSNAME": "", @@ -396,8 +453,13 @@ def com_backend(monkeypatch): "UCSORG": (0.0, 0.0, 0.0), "UCSXDIR": (1.0, 0.0, 0.0), "UCSYDIR": (0.0, 1.0, 0.0), + "VIEWCTR": (5.0, 6.0, 0.0), + "VIEWSIZE": 20.0, + "SCREENSIZE": _SCREEN, } - app = types.SimpleNamespace(GetVariable=lambda name: variables[name]) + document = _FakeDocument(variables) + app = _FakeApp(document) + assert not hasattr(app, "GetVariable"), "AcadApplication has no GetVariable member" monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_regen", lambda: None) @@ -412,28 +474,59 @@ def com_backend(monkeypatch): return backend, document, variables -async def test_com_view_save_defaults_to_the_active_viewport(com_backend): +async def test_com_view_save_defaults_to_viewctr_viewsize_not_the_viewport_object(com_backend): + """Measured (AutoCAD 2026): after ``_.ZOOM _C 5,6 20`` VIEWCTR is (5, 6) + and VIEWSIZE 20 while ``doc.ActiveViewport`` still answers the initial + view (298.98, 148.5) x 297 — and AutoCAD's own `-VIEW _S` records + centre (5, 6), height 20, width 20 x the display aspect (40.267).""" backend, document, _ = com_backend result = await backend.view_named_save("HOME") assert document.Views.calls == [("Add", ("HOME",))] (view,) = document.Views.views - assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0 - assert result["center"] == [30.0, 40.0] and result["replaced"] is False + assert view.Center == (5.0, 6.0) and view.Height == 20.0 + assert view.Width == pytest.approx(20.0 * _DISPLAY_ASPECT) + assert result["center"] == [5.0, 6.0] and result["replaced"] is False + assert result["width"] == pytest.approx(40.267, abs=1e-3), "what `-VIEW _S` recorded live" again = await backend.view_named_save("home", center=[1, 2], height=10) assert again["replaced"] is True and len(document.Views.views) == 1 - assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0 + assert view.Center == (1.0, 2.0) and view.Height == 10.0 + assert view.Width == pytest.approx(10.0 * _DISPLAY_ASPECT), ( + "width defaults to the display aspect" + ) + assert document.viewport_sets == 0 -async def test_com_view_restore_sets_the_active_viewport(com_backend): - backend, document, _ = com_backend +async def test_com_view_restore_zooms_the_window_like_view_r(com_backend): + """Measured (AutoCAD 2026, display aspect 2.013): writing Center/Height/ + Width into the AcadViewport lands a 30x20 view at (5, 6) on VIEWCTR + (10.134, 6) and a 10x20 one on (20.134, 6) — AutoCAD anchors the + lower-left corner and widens. `-VIEW _R` gives VIEWCTR (5, 6), VIEWSIZE + 20 for both, and so does ZOOM Window on the saved rectangle.""" + backend, document, variables = com_backend + from backends import com_backend as module + + app = module._acad_app() # the fixture's _FakeApp await backend.view_named_save("HOME", center=[5, 6], height=20, width=30) - result = await backend.view_named_restore("HOME") - assert result["applied"] == "active_viewport" - assert document.ActiveViewport.Center == (5.0, 6.0) - assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0 - assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment" + await backend.view_named_save("TALL", center=[5, 6], height=20, width=10) + await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100) + for name, expect_size in (("HOME", 20.0), ("TALL", 20.0), ("WIDE", 100.0 / _DISPLAY_ASPECT)): + result = await backend.view_named_restore(name) + assert result["applied"] == "zoom_window" and result["center"] == [5.0, 6.0] + assert result["viewctr"] == [5.0, 6.0], name + assert result["viewsize"] == pytest.approx(expect_size), name + assert variables["VIEWCTR"][:2] == (5.0, 6.0) + assert app.zoom_windows == [ + ((-10.0, -4.0, 0.0), (20.0, 16.0, 0.0)), + ((0.0, -4.0, 0.0), (10.0, 16.0, 0.0)), + ((-45.0, -4.0, 0.0), (55.0, 16.0, 0.0)), + ] + # The stale AcadViewport object was neither written nor re-assigned. + assert document.viewport_sets == 0 + assert document.ActiveViewport.Center == (298.98, 148.5) assert await backend.view_named_list() == [ - {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0} + {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0}, + {"name": "TALL", "center": [5.0, 6.0], "height": 20.0, "width": 10.0}, + {"name": "WIDE", "center": [5.0, 6.0], "height": 20.0, "width": 100.0}, ] @@ -494,6 +587,30 @@ async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend assert len(document.commands) == 1 +async def test_com_ucs_restore_world_refuses_when_cmdactive_cannot_be_read(com_backend): + """The guard once swallowed a failed read as "idle" — with the read on the + wrong object (``app.GetVariable``) it failed on every call, so `_.UCS _W` + went into whatever prompt was open. A read failure now refuses.""" + backend, document, variables = com_backend + del variables["CMDACTIVE"] + with pytest.raises(RuntimeError, match="cannot verify AutoCAD is idle"): + await backend.ucs_restore("world") + assert document.commands == [] + + +async def test_com_ucs_reads_sysvars_from_the_document_never_the_application(com_backend): + """Live, ``AutoCAD.Application.GetVariable`` raises AttributeError: the + member is on AcadDocument. The fixture's app has none, so every UCS path + here would blow up on the old code.""" + backend, document, variables = com_backend + from backends import com_backend as module + + assert not hasattr(module._acad_app(), "GetVariable") + rows = await backend.ucs_list() + assert [(r["name"], r["current"]) for r in rows] == [("world", True)] + assert (await backend.ucs_restore("world"))["current"] is True + + async def test_com_ucs_refuses_before_activex(com_backend): backend, document, _ = com_backend with pytest.raises(ValueError, match="measured"):