"""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", "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_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 class _FakeDocument: def __init__(self): self.Views = _FakeViews() self.UserCoordinateSystems = _FakeUcsCollection() self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0) self.viewport_sets = 0 self.ActiveUCS = None self.commands: list[str] = [] @property def ActiveViewport(self): return self._viewport @ActiveViewport.setter def ActiveViewport(self, value): self._viewport = value self.viewport_sets += 1 def SendCommand(self, macro): self.commands.append(macro) @pytest.fixture def com_backend(monkeypatch): pytest.importorskip("win32com.client", reason="pywin32 not installed") from backends import com_backend as module document = _FakeDocument() variables = { "CMDACTIVE": 0, "UCSNAME": "", "WORLDUCS": 1, "UCSORG": (0.0, 0.0, 0.0), "UCSXDIR": (1.0, 0.0, 0.0), "UCSYDIR": (0.0, 1.0, 0.0), } app = types.SimpleNamespace(GetVariable=lambda name: variables[name]) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_regen", lambda: None) monkeypatch.setattr(module, "_apoint", lambda x, y, z=0.0: (float(x), float(y), float(z))) monkeypatch.setattr(module, "_av", lambda values: tuple(float(v) for v in values)) backend = module.ComBackend() async def _run_inline(func, *args, **kwargs): return func(*args, **kwargs) monkeypatch.setattr(backend, "_run", _run_inline) return backend, document, variables async def test_com_view_save_defaults_to_the_active_viewport(com_backend): backend, document, _ = com_backend result = await backend.view_named_save("HOME") assert document.Views.calls == [("Add", ("HOME",))] (view,) = document.Views.views assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0 assert result["center"] == [30.0, 40.0] and result["replaced"] is False again = await backend.view_named_save("home", center=[1, 2], height=10) assert again["replaced"] is True and len(document.Views.views) == 1 assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0 async def test_com_view_restore_sets_the_active_viewport(com_backend): backend, document, _ = com_backend await backend.view_named_save("HOME", center=[5, 6], height=20, width=30) result = await backend.view_named_restore("HOME") assert result["applied"] == "active_viewport" assert document.ActiveViewport.Center == (5.0, 6.0) assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0 assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment" assert await backend.view_named_list() == [ {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0} ] async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend): backend, document, variables = com_backend result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 5, 0], [-1, 0, 0]) assert document.UserCoordinateSystems.calls == [ ("Add", ((10.0, 10.0, 0.0), (10.0, 11.0, 0.0), (9.0, 10.0, 0.0), "FRONT")) ] assert document.ActiveUCS is document.UserCoordinateSystems.items[0] assert result["x_axis"] == [0.0, 1.0, 0.0] and result["current"] is True variables["UCSNAME"] = "FRONT" rows = await backend.ucs_list() assert rows[0]["name"] == "world" and rows[0]["current"] is False assert rows[1] == { "name": "FRONT", "origin": [10.0, 10.0, 0.0], "x_axis": [0.0, 1.0, 0.0], "y_axis": [-1.0, 0.0, 0.0], "current": True, } async def test_com_ucs_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_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 == []