fix(task-22): live named views read and restore the display (VIEWCTR/VIEWSIZE, ZOOM Window), UCS sysvars come from the document, and a frozen current layer is thawed before restore makes it current

Four review findings, all measured by the reviewer on AutoCAD 2026:

1. ucs_list and the ucs_restore('world') CMDACTIVE guard read
   app.GetVariable, but GetVariable is an AcadDocument member — the tool
   was dead live and the guard's swallowed read forced cmd_active to 0,
   sending `_.UCS _W` into any open prompt. Both read the document now,
   and a failed CMDACTIVE read refuses instead of proceeding. The fake
   app in tests/test_views_ucs.py has no GetVariable any more, so the
   old code cannot pass.

2. view_named_save defaulted from doc.ActiveViewport.Center/Height/Width,
   which does not track the display (it answered the initial view after
   `_.ZOOM _C 5,6 20`). The current view is VIEWCTR / VIEWSIZE at the
   SCREENSIZE aspect — what `-VIEW _S` records (width 40.267 for height
   20). The fake viewport is deliberately stale so a read of it fails.

3. view_named_restore wrote Center/Height/Width into the AcadViewport;
   AutoCAD anchors the lower-left corner and widens to the display
   aspect, landing a 10x20 view at (5, 6) on VIEWCTR (20.134, 6). It now
   zooms the saved rectangle (app.ZoomWindow), which centres and fits
   like `-VIEW _R` (VIEWCTR = saved centre, VIEWSIZE = max(h, w/aspect)),
   reports `applied: "zoom_window"` and reads VIEWCTR / VIEWSIZE back.
   Headless the same reconciliation moved the centre whenever the
   *Active VPORT's aspect was rewritten from w/h (even the 1.34 default
   opened at x = 11.734): the aspect the file carries is left alone —
   it is the display's, written by AutoCAD at save — and the window is
   fitted into it; the payload adds `vport_height` / `aspect_ratio`.

4. layer_state_restore set ActiveLayer before the loop and outside the
   try; AutoCAD refuses to make a frozen layer current, so the spec's
   own save → freeze → restore path raised an opaque COM error with
   nothing applied. When `frozen` is being restored the state's current
   layer is thawed first; the write is wrapped and a refusal lands in
   `warnings`. The fake document and layer now model both refusals.

Live AutoCAD was not running in this round (GetActiveObject failed, no
acad process), so the fix is measured against the reviewer's numbers,
not re-measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 16:48:19 +03:00
co-authored by Claude Opus 5
parent bbc9aeeb63
commit c7dd72410a
6 changed files with 318 additions and 62 deletions
+72 -27
View File
@@ -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 = <frozen>`` 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="
+4 -1
View File
@@ -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
+15 -3
View File
@@ -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",
}
+17 -8
View File
@@ -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)
+77 -7
View File
@@ -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 = <frozen>``, 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")
+133 -16
View File
@@ -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"):