feat(environment): portable layer states, named views and UCS on both engines
Layer states are ACADMCP_LAYERSTATES XRECORD chunks (<= 255 ASCII chars each), in the file and not in AutoCAD's Layer States Manager. Headless view restore writes the *Active VPORT (ezdxf's header has no $VIEWCTR; measured). UCS is stored and made current; tool coordinates stay WCS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
2805697d16
commit
dcc8f07bf4
@@ -0,0 +1,444 @@
|
||||
"""Portable layer states: save → change → restore equals the snapshot.
|
||||
|
||||
These are the server's own layer states — JSON chunks in an XRECORD under
|
||||
``ACADMCP_LAYERSTATES`` in the drawing's named object dictionary. They live
|
||||
in the file and travel with it; they do NOT appear in AutoCAD's Layer States
|
||||
Manager. The codec is engine-neutral, so the ezdxf tests prove the format and
|
||||
the COM tests prove the ActiveX call shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import LayerInfo
|
||||
from engineering.environment.layer_states import (
|
||||
DICT_NAME,
|
||||
PROPERTIES,
|
||||
decode_state,
|
||||
diff_snapshot,
|
||||
encode_state,
|
||||
snapshot_from_layers,
|
||||
validate_properties,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _layer(name, **overrides) -> LayerInfo:
|
||||
base = dict(
|
||||
name=name,
|
||||
color=7,
|
||||
linetype="Continuous",
|
||||
lineweight=-3,
|
||||
is_on=True,
|
||||
is_frozen=False,
|
||||
is_locked=False,
|
||||
is_current=False,
|
||||
)
|
||||
base.update(overrides)
|
||||
return LayerInfo(**base)
|
||||
|
||||
|
||||
# ── codec ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_snapshot_records_every_property_and_the_current_layer():
|
||||
state = snapshot_from_layers(
|
||||
[_layer("0"), _layer("HIDDEN", color=8, linetype="HIDDEN", lineweight=35, is_frozen=True)],
|
||||
"HIDDEN",
|
||||
plot={"HIDDEN": False},
|
||||
description="before plotting",
|
||||
)
|
||||
assert state == {
|
||||
"version": 1,
|
||||
"description": "before plotting",
|
||||
"current_layer": "HIDDEN",
|
||||
"layers": {
|
||||
"0": {
|
||||
"on": True,
|
||||
"frozen": False,
|
||||
"locked": False,
|
||||
"color": 7,
|
||||
"linetype": "Continuous",
|
||||
"lineweight": -3,
|
||||
"plot": True,
|
||||
},
|
||||
"HIDDEN": {
|
||||
"on": True,
|
||||
"frozen": True,
|
||||
"locked": False,
|
||||
"color": 8,
|
||||
"linetype": "HIDDEN",
|
||||
"lineweight": 35,
|
||||
"plot": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_encode_chunks_are_at_most_255_ascii_characters_and_round_trip():
|
||||
layers = [_layer(f"LAYER-{i:03d}-{'x' * 40}") for i in range(60)]
|
||||
state = snapshot_from_layers(layers, "0")
|
||||
chunks = encode_state(state)
|
||||
assert len(chunks) > 5
|
||||
assert all(len(c) <= 255 and c.isascii() for c in chunks)
|
||||
assert decode_state(chunks) == state
|
||||
|
||||
|
||||
def test_unicode_layer_names_survive_the_ascii_chunking():
|
||||
state = snapshot_from_layers([_layer("KÖRPER-Ø20")], "KÖRPER-Ø20")
|
||||
chunks = encode_state(state)
|
||||
assert all(c.isascii() for c in chunks), "escaped so a chunk boundary never splits a code point"
|
||||
assert decode_state(chunks)["current_layer"] == "KÖRPER-Ø20"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chunks, fragment",
|
||||
[
|
||||
(["not json"], "not valid JSON"),
|
||||
([json.dumps({"version": 2, "layers": {}})], "version"),
|
||||
([json.dumps({"version": 1, "layers": {"A": {"on": True}}})], "lacks"),
|
||||
([json.dumps([1, 2])], "version"),
|
||||
],
|
||||
)
|
||||
def test_decode_refuses_anything_that_is_not_a_state(chunks, fragment):
|
||||
with pytest.raises(ValueError, match=fragment):
|
||||
decode_state(chunks)
|
||||
|
||||
|
||||
def test_diff_reports_missing_and_new_layers_sorted():
|
||||
state = snapshot_from_layers([_layer("A"), _layer("B"), _layer("C")], "0")
|
||||
missing, new = diff_snapshot(state, [_layer("C"), _layer("Z"), _layer("A"), _layer("Y")])
|
||||
assert missing == ["B"] and new == ["Y", "Z"]
|
||||
|
||||
|
||||
def test_validate_properties_defaults_to_all_and_names_the_bad_one():
|
||||
assert validate_properties(None) == PROPERTIES
|
||||
assert validate_properties(["color", "on", "color"]) == ("color", "on")
|
||||
with pytest.raises(ValueError, match=r"properties\[1\]: 'colour'"):
|
||||
validate_properties(["on", "colour"])
|
||||
with pytest.raises(TypeError, match="list"):
|
||||
validate_properties("on")
|
||||
with pytest.raises(ValueError, match="at least one"):
|
||||
validate_properties([])
|
||||
|
||||
|
||||
# ── headless engine ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _seed(backend):
|
||||
await backend.layer_create("GEOMETRY", color=7, linetype="Continuous", lineweight=50)
|
||||
await backend.layer_create("HIDDEN", color=8, linetype="HIDDEN", lineweight=35)
|
||||
await backend.layer_create("CENTER", color=1, linetype="CENTER", lineweight=25)
|
||||
await backend.layer_set_current("GEOMETRY")
|
||||
await backend.layer_freeze("CENTER")
|
||||
await backend.layer_hide("HIDDEN")
|
||||
|
||||
|
||||
async def _table(backend) -> dict:
|
||||
return {
|
||||
lyr.name: (
|
||||
lyr.color,
|
||||
lyr.linetype,
|
||||
lyr.lineweight,
|
||||
lyr.is_on,
|
||||
lyr.is_frozen,
|
||||
lyr.is_locked,
|
||||
lyr.is_current,
|
||||
)
|
||||
for lyr in await backend.layer_list()
|
||||
}
|
||||
|
||||
|
||||
async def test_save_change_restore_equals_the_snapshot(backend):
|
||||
await _seed(backend)
|
||||
before = await _table(backend)
|
||||
saved = await backend.layer_state_save("PLOT", description="plot set")
|
||||
# measured: ezdxf.new() already has "0" and "Defpoints", so the seeded
|
||||
# table is five layers and serialises to three <= 255-character chunks
|
||||
assert saved == {
|
||||
"ok": True,
|
||||
"name": "PLOT",
|
||||
"layer_count": 5,
|
||||
"replaced": False,
|
||||
"chunks": 3,
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
|
||||
await backend.layer_thaw("CENTER")
|
||||
await backend.layer_show("HIDDEN")
|
||||
await backend.layer_lock("GEOMETRY")
|
||||
await backend.layer_modify("HIDDEN", color=3, linetype="Continuous", lineweight=13)
|
||||
await backend.layer_set_current("0")
|
||||
assert await _table(backend) != before
|
||||
|
||||
restored = await backend.layer_state_restore("PLOT")
|
||||
assert restored["applied"] == {
|
||||
"layers": 5,
|
||||
"properties": list(PROPERTIES),
|
||||
"current_layer": "GEOMETRY",
|
||||
}
|
||||
assert restored["missing_layers"] == [] and restored["new_layers"] == []
|
||||
assert await _table(backend) == before
|
||||
|
||||
|
||||
async def test_restore_applies_only_the_requested_properties(backend):
|
||||
await _seed(backend)
|
||||
await backend.layer_state_save("S")
|
||||
await backend.layer_thaw("CENTER")
|
||||
await backend.layer_modify("CENTER", color=5)
|
||||
result = await backend.layer_state_restore("S", properties=["color"])
|
||||
assert result["applied"]["properties"] == ["color"]
|
||||
center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER")
|
||||
assert center.color == 1 and center.is_frozen is False, "frozen was not requested"
|
||||
|
||||
|
||||
async def test_restore_reports_missing_and_new_layers_and_never_creates(backend):
|
||||
await _seed(backend)
|
||||
await backend.layer_state_save("S")
|
||||
await backend.layer_delete("CENTER")
|
||||
await backend.layer_create("NEW-ONE")
|
||||
result = await backend.layer_state_restore("S")
|
||||
assert result["missing_layers"] == ["CENTER"] and result["new_layers"] == ["NEW-ONE"]
|
||||
assert "CENTER" not in [lyr.name for lyr in await backend.layer_list()]
|
||||
|
||||
|
||||
async def test_plot_flag_and_current_layer_round_trip(backend):
|
||||
await _seed(backend)
|
||||
backend._doc.layers.get("HIDDEN").dxf.plot = 0
|
||||
await backend.layer_state_save("S")
|
||||
backend._doc.layers.get("HIDDEN").dxf.plot = 1
|
||||
await backend.layer_set_current("0")
|
||||
await backend.layer_state_restore("S", properties=["plot", "current"])
|
||||
assert backend._doc.layers.get("HIDDEN").dxf.plot == 0
|
||||
assert backend._current_layer == "GEOMETRY"
|
||||
assert backend._doc.header["$CLAYER"] == "GEOMETRY"
|
||||
|
||||
|
||||
async def test_state_survives_save_and_reopen(backend, tmp_path):
|
||||
await _seed(backend)
|
||||
await backend.layer_state_save("PLOT", description="d")
|
||||
path = tmp_path / "states.dxf"
|
||||
await backend.drawing_save(str(path))
|
||||
await backend.document_close(None)
|
||||
await backend.drawing_open(str(path))
|
||||
assert await backend.layer_state_list() == [
|
||||
{"name": "PLOT", "description": "d", "layer_count": 5}
|
||||
]
|
||||
await backend.layer_thaw("CENTER")
|
||||
await backend.layer_state_restore("PLOT")
|
||||
center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER")
|
||||
assert center.is_frozen is True
|
||||
|
||||
|
||||
async def test_xrecord_chunks_stay_under_256_characters(backend):
|
||||
for i in range(80):
|
||||
await backend.layer_create(f"LONG-LAYER-NAME-{i:03d}-{'x' * 30}")
|
||||
saved = await backend.layer_state_save("BIG")
|
||||
assert saved["chunks"] > 10
|
||||
xrecord = backend._doc.rootdict.get(DICT_NAME).get("BIG")
|
||||
tags = [t for t in xrecord.tags if t.code == 1000]
|
||||
assert len(tags) == saved["chunks"]
|
||||
assert all(len(t.value) <= 255 for t in tags)
|
||||
|
||||
|
||||
async def test_replace_delete_and_missing_names(backend):
|
||||
await backend.layer_state_save("S")
|
||||
again = await backend.layer_state_save("s")
|
||||
assert again["replaced"] is True, "AutoCAD dictionaries are case-insensitive"
|
||||
assert [row["name"] for row in await backend.layer_state_list()] == ["s"]
|
||||
assert await backend.layer_state_delete("S") == {"ok": True, "deleted": "s", "backend": "ezdxf"}
|
||||
assert await backend.layer_state_list() == []
|
||||
with pytest.raises(ValueError, match="no layer state named 'S'"):
|
||||
await backend.layer_state_restore("S")
|
||||
with pytest.raises(ValueError, match="no layer state named 'S'"):
|
||||
await backend.layer_state_delete("S")
|
||||
|
||||
|
||||
async def test_refusals_happen_before_any_write(backend):
|
||||
with pytest.raises(ValueError, match="layer state name"):
|
||||
await backend.layer_state_save(" ")
|
||||
with pytest.raises(TypeError, match="description"):
|
||||
await backend.layer_state_save("S", description=3)
|
||||
assert DICT_NAME not in backend._doc.rootdict, "a refused save creates no dictionary"
|
||||
await backend.layer_state_save("S")
|
||||
with pytest.raises(ValueError, match=r"properties\[0\]: 'plottable'"):
|
||||
await backend.layer_state_restore("S", properties=["plottable"])
|
||||
assert len(list(backend._doc.rootdict.get(DICT_NAME).keys())) == 1
|
||||
|
||||
|
||||
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
||||
|
||||
|
||||
class _FakeXRecord:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.types = None
|
||||
self.values = None
|
||||
self.deleted = False
|
||||
|
||||
def SetXRecordData(self, types_, values):
|
||||
self.types, self.values = list(types_), list(values)
|
||||
|
||||
def GetXRecordData(self):
|
||||
return self.types, self.values
|
||||
|
||||
def Delete(self):
|
||||
self.deleted = True
|
||||
|
||||
|
||||
class _FakeDict:
|
||||
def __init__(self, name):
|
||||
self.Name = name
|
||||
self.entries: list[_FakeXRecord] = []
|
||||
self.calls: list[tuple[str, tuple]] = []
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.entries)
|
||||
|
||||
def Item(self, index):
|
||||
return self.entries[index]
|
||||
|
||||
def GetName(self, obj):
|
||||
return obj.name
|
||||
|
||||
def AddXRecord(self, keyword):
|
||||
self.calls.append(("AddXRecord", (keyword,)))
|
||||
rec = _FakeXRecord(keyword)
|
||||
self.entries.append(rec)
|
||||
return rec
|
||||
|
||||
def Remove(self, name):
|
||||
self.calls.append(("Remove", (name,)))
|
||||
rec = next(e for e in self.entries if e.name == name)
|
||||
self.entries.remove(rec)
|
||||
return rec
|
||||
|
||||
|
||||
class _FakeDictionaries:
|
||||
def __init__(self):
|
||||
self.dicts: dict[str, _FakeDict] = {}
|
||||
self.calls: list[tuple[str, tuple]] = []
|
||||
|
||||
def Item(self, name):
|
||||
self.calls.append(("Item", (name,)))
|
||||
if name not in self.dicts:
|
||||
raise RuntimeError(f"no dictionary {name}")
|
||||
return self.dicts[name]
|
||||
|
||||
def Add(self, name):
|
||||
self.calls.append(("Add", (name,)))
|
||||
self.dicts[name] = _FakeDict(name)
|
||||
return self.dicts[name]
|
||||
|
||||
|
||||
class _FakeLayer:
|
||||
def __init__(self, name, **props):
|
||||
self.Name = name
|
||||
self.Color = props.get("color", 7)
|
||||
self.Linetype = props.get("linetype", "Continuous")
|
||||
self.LineWeight = props.get("lineweight", -3)
|
||||
self.LayerOn = props.get("on", True)
|
||||
self.Freeze = props.get("frozen", False)
|
||||
self.Lock = props.get("locked", False)
|
||||
self.Plottable = props.get("plot", True)
|
||||
|
||||
|
||||
class _FakeLayers:
|
||||
def __init__(self, layers):
|
||||
self._layers = layers
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self._layers)
|
||||
|
||||
def Item(self, key):
|
||||
if isinstance(key, int):
|
||||
return self._layers[key]
|
||||
for lyr in self._layers:
|
||||
if lyr.Name == key:
|
||||
return lyr
|
||||
raise RuntimeError(f"no layer {key}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from backends import com_backend as module
|
||||
|
||||
layers = [
|
||||
_FakeLayer("0"),
|
||||
_FakeLayer("GEOMETRY", lineweight=50),
|
||||
_FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False),
|
||||
]
|
||||
document = types.SimpleNamespace(
|
||||
Dictionaries=_FakeDictionaries(),
|
||||
Layers=_FakeLayers(layers),
|
||||
ActiveLayer=layers[1],
|
||||
)
|
||||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||||
monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None)
|
||||
monkeypatch.setattr(module, "_regen", lambda: None)
|
||||
monkeypatch.setattr(module, "_ai", lambda values: list(values))
|
||||
monkeypatch.setattr(module, "_avar", lambda values: list(values))
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, document
|
||||
|
||||
|
||||
async def test_com_save_creates_the_dictionary_and_one_xrecord_of_1000_chunks(com_backend):
|
||||
backend, document = com_backend
|
||||
result = await backend.layer_state_save("PLOT", description="d")
|
||||
assert result["ok"] and result["backend"] == "com" and result["replaced"] is False
|
||||
assert document.Dictionaries.calls == [("Item", (DICT_NAME,)), ("Add", (DICT_NAME,))]
|
||||
states = document.Dictionaries.dicts[DICT_NAME]
|
||||
assert states.calls == [("AddXRecord", ("PLOT",))]
|
||||
(rec,) = states.entries
|
||||
assert rec.types == [1000] * len(rec.values)
|
||||
assert all(len(v) <= 255 for v in rec.values)
|
||||
state = decode_state(rec.values)
|
||||
assert state["current_layer"] == "GEOMETRY"
|
||||
assert state["layers"]["HIDDEN"]["plot"] is False
|
||||
again = await backend.layer_state_save("plot")
|
||||
assert again["replaced"] is True and again["name"] == "PLOT"
|
||||
assert len(states.entries) == 1, "SetXRecordData on the existing record, no second AddXRecord"
|
||||
|
||||
|
||||
async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_backend):
|
||||
backend, document = com_backend
|
||||
await backend.layer_state_save("S")
|
||||
hidden = document.Layers.Item("HIDDEN")
|
||||
hidden.Freeze = False
|
||||
hidden.Color = 3
|
||||
hidden.Plottable = True
|
||||
document.ActiveLayer = document.Layers.Item("0")
|
||||
result = await backend.layer_state_restore("S")
|
||||
assert result["applied"]["layers"] == 3
|
||||
assert result["applied"]["current_layer"] == "GEOMETRY"
|
||||
assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False
|
||||
assert document.ActiveLayer.Name == "GEOMETRY"
|
||||
assert result["missing_layers"] == [] and result["new_layers"] == []
|
||||
|
||||
|
||||
async def test_com_list_and_delete(com_backend):
|
||||
backend, document = com_backend
|
||||
await backend.layer_state_save("A", description="one")
|
||||
await backend.layer_state_save("B")
|
||||
assert await backend.layer_state_list() == [
|
||||
{"name": "A", "description": "one", "layer_count": 3},
|
||||
{"name": "B", "description": None, "layer_count": 3},
|
||||
]
|
||||
assert (await backend.layer_state_delete("a"))["deleted"] == "A"
|
||||
states = document.Dictionaries.dicts[DICT_NAME]
|
||||
assert states.calls[-1] == ("Remove", ("A",))
|
||||
with pytest.raises(ValueError, match="no layer state named 'A'"):
|
||||
await backend.layer_state_delete("A")
|
||||
@@ -519,7 +519,10 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||
`pid` rather than under their secondary `query` / `create` tags. `drawing`
|
||||
moved 11 -> 14 when v1.6's `document_list` / `document_activate` /
|
||||
`document_close` (multi-document on both engines) opened SECTION 20. Every
|
||||
`document_close` (multi-document on both engines) opened SECTION 20;
|
||||
`layers` moved 14 -> 18 with the four `layer_state_*` tools and `view`
|
||||
4 -> 10 with `view_named_*` and `ucs_*` (UCS files under `view`, where
|
||||
AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew. Every
|
||||
other number here has been unchanged since the snapshot was taken.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
@@ -534,7 +537,7 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
"layers": 14,
|
||||
"layers": 18,
|
||||
"layouts": 12,
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
@@ -543,6 +546,6 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
"view": 10,
|
||||
}
|
||||
assert sum(sizes.values()) == 169
|
||||
assert sum(sizes.values()) == 179
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Named views and UCS on both engines.
|
||||
|
||||
Headless, a named view is a VIEW table entry and "restore" writes the
|
||||
``*Active`` VPORT (the view the file opens on) — ezdxf's header has no
|
||||
``$VIEWCTR``; measured. A UCS is a UCS table entry plus the ``$UCS*`` header
|
||||
variables. Tool coordinates stay WCS on both engines: a UCS is stored and
|
||||
made current for the operator, and no tool interprets its inputs in it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.environment.ucs import resolve_ucs_axes
|
||||
from engineering.environment.views import resolve_view_args
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── pure helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_view_args_validate_before_any_write():
|
||||
assert resolve_view_args([10, 20], 50, None) == {
|
||||
"center": (10.0, 20.0),
|
||||
"height": 50.0,
|
||||
"width": None,
|
||||
}
|
||||
assert resolve_view_args(None, None, None) == {"center": None, "height": None, "width": None}
|
||||
with pytest.raises(ValueError, match="height must be > 0"):
|
||||
resolve_view_args(None, 0, None)
|
||||
with pytest.raises(ValueError, match="width must be > 0"):
|
||||
resolve_view_args(None, 10, -1)
|
||||
with pytest.raises(ValueError, match="center"):
|
||||
resolve_view_args([1], None, None)
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
resolve_view_args([float("nan"), 0], None, None)
|
||||
|
||||
|
||||
def test_ucs_axes_are_normalised_and_orthogonality_is_measured():
|
||||
axes = resolve_ucs_axes([10, 10, 0], [0, 2, 0], [-3, 0, 0])
|
||||
assert axes["origin"] == (10.0, 10.0, 0.0)
|
||||
assert axes["x_axis"] == (0.0, 1.0, 0.0) and axes["y_axis"] == (-1.0, 0.0, 0.0)
|
||||
assert axes["z_axis"] == (0.0, 0.0, 1.0)
|
||||
assert axes["angle_deg"] == pytest.approx(90.0)
|
||||
with pytest.raises(ValueError, match=r"measured 45\.00"):
|
||||
resolve_ucs_axes([0, 0, 0], [1, 0, 0], [1, 1, 0])
|
||||
with pytest.raises(ValueError, match="x_axis.*zero"):
|
||||
resolve_ucs_axes([0, 0, 0], [0, 0, 0], [0, 1, 0])
|
||||
with pytest.raises(ValueError, match="origin"):
|
||||
resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0])
|
||||
|
||||
|
||||
# ── headless: named views ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_view_defaults_to_the_drawing_extents(backend):
|
||||
await backend.entity_create_line(0, 0, 100, 50)
|
||||
result = await backend.view_named_save("OVERALL")
|
||||
assert result["ok"] and result["replaced"] is False
|
||||
assert result["center"] == [50.0, 25.0]
|
||||
# extents 100 x 50 at the *Active VPORT's default aspect 1.34: the width
|
||||
# is the binding side, so height = 100 / 1.34
|
||||
assert result["height"] == pytest.approx(100.0 / 1.34)
|
||||
assert result["width"] == pytest.approx(100.0)
|
||||
view = backend._doc.views.get("OVERALL")
|
||||
assert tuple(view.dxf.center)[:2] == (50.0, 25.0)
|
||||
|
||||
|
||||
async def test_view_on_an_empty_drawing_needs_an_explicit_window(backend):
|
||||
with pytest.raises(ValueError, match="no extents"):
|
||||
await backend.view_named_save("EMPTY")
|
||||
assert "EMPTY" not in backend._doc.views
|
||||
result = await backend.view_named_save("EMPTY", center=[0, 0], height=10)
|
||||
assert result["center"] == [0.0, 0.0] and result["height"] == 10.0
|
||||
assert result["width"] == pytest.approx(13.4)
|
||||
|
||||
|
||||
async def test_view_replace_restore_and_list(backend):
|
||||
await backend.view_named_save("V", center=[1, 2], height=10, width=15)
|
||||
again = await backend.view_named_save("v", center=[5, 5], height=20)
|
||||
assert again["replaced"] is True and again["name"] == "V"
|
||||
assert len(list(backend._doc.views)) == 1
|
||||
restored = await backend.view_named_restore("v")
|
||||
assert restored == {
|
||||
"ok": True,
|
||||
"name": "V",
|
||||
"center": [5.0, 5.0],
|
||||
"height": 20.0,
|
||||
"width": pytest.approx(26.8),
|
||||
"applied": "header_only",
|
||||
"store": "vport_active",
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
(vport,) = backend._doc.viewports.get("*Active")
|
||||
assert tuple(vport.dxf.center)[:2] == (5.0, 5.0) and vport.dxf.height == 20.0
|
||||
assert await backend.view_named_list() == [
|
||||
{"name": "V", "center": [5.0, 5.0], "height": 20.0, "width": pytest.approx(26.8)}
|
||||
]
|
||||
with pytest.raises(ValueError, match="no named view 'NOPE'"):
|
||||
await backend.view_named_restore("NOPE")
|
||||
with pytest.raises(ValueError, match="height must be > 0"):
|
||||
await backend.view_named_save("BAD", center=[0, 0], height=0)
|
||||
assert "BAD" not in backend._doc.views
|
||||
|
||||
|
||||
# ── headless: UCS ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_ucs_set_stores_the_entry_and_makes_it_current(backend):
|
||||
result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"name": "FRONT",
|
||||
"origin": [10.0, 10.0, 0.0],
|
||||
"x_axis": [0.0, 1.0, 0.0],
|
||||
"y_axis": [-1.0, 0.0, 0.0],
|
||||
"replaced": False,
|
||||
"current": True,
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
header = backend._doc.header
|
||||
assert header["$UCSNAME"] == "FRONT" and tuple(header["$UCSORG"]) == (10.0, 10.0, 0.0)
|
||||
assert tuple(header["$UCSXDIR"]) == (0.0, 1.0, 0.0)
|
||||
rows = await backend.ucs_list()
|
||||
assert rows[0] == {
|
||||
"name": "world",
|
||||
"origin": [0.0, 0.0, 0.0],
|
||||
"x_axis": [1.0, 0.0, 0.0],
|
||||
"y_axis": [0.0, 1.0, 0.0],
|
||||
"current": False,
|
||||
}
|
||||
assert rows[1]["name"] == "FRONT" and rows[1]["current"] is True
|
||||
|
||||
|
||||
async def test_ucs_non_orthogonal_is_refused_before_any_write(backend):
|
||||
with pytest.raises(ValueError, match="measured 60"):
|
||||
await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [0.5, 0.8660254, 0])
|
||||
assert "SKEW" not in backend._doc.ucs and backend._doc.header["$UCSNAME"] == ""
|
||||
|
||||
|
||||
async def test_ucs_restore_world_and_named(backend):
|
||||
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
|
||||
world = await backend.ucs_restore("world")
|
||||
assert world["name"] == "world" and backend._doc.header["$UCSNAME"] == ""
|
||||
assert tuple(backend._doc.header["$UCSXDIR"]) == (1.0, 0.0, 0.0)
|
||||
assert (await backend.ucs_list())[0]["current"] is True
|
||||
named = await backend.ucs_restore("front")
|
||||
assert named["name"] == "FRONT" and backend._doc.header["$UCSNAME"] == "FRONT"
|
||||
with pytest.raises(ValueError, match="no UCS named 'NOPE'"):
|
||||
await backend.ucs_restore("NOPE")
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
await backend.ucs_set("world", [0, 0, 0], [1, 0, 0], [0, 1, 0])
|
||||
|
||||
|
||||
async def test_ucs_replace_keeps_one_entry(backend):
|
||||
await backend.ucs_set("A", [0, 0, 0], [1, 0, 0], [0, 1, 0])
|
||||
again = await backend.ucs_set("a", [1, 1, 0], [1, 0, 0], [0, 1, 0])
|
||||
assert again["replaced"] is True and again["name"] == "A"
|
||||
assert len(list(backend._doc.ucs)) == 1
|
||||
|
||||
|
||||
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
||||
|
||||
|
||||
class _FakeView:
|
||||
def __init__(self, name):
|
||||
self.Name = name
|
||||
self.Center = (0.0, 0.0)
|
||||
self.Height = 100.0
|
||||
self.Width = 150.0
|
||||
|
||||
|
||||
class _FakeViews:
|
||||
def __init__(self):
|
||||
self.views: list[_FakeView] = []
|
||||
self.calls: list[tuple[str, tuple]] = []
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.views)
|
||||
|
||||
def Item(self, key):
|
||||
if isinstance(key, int):
|
||||
return self.views[key]
|
||||
for v in self.views:
|
||||
if v.Name.lower() == key.lower():
|
||||
return v
|
||||
raise RuntimeError(f"no view {key}")
|
||||
|
||||
def Add(self, name):
|
||||
self.calls.append(("Add", (name,)))
|
||||
v = _FakeView(name)
|
||||
self.views.append(v)
|
||||
return v
|
||||
|
||||
|
||||
class _FakeUcs:
|
||||
def __init__(self, name, origin, xv, yv):
|
||||
self.Name = name
|
||||
self.Origin = origin
|
||||
self.XVector = xv
|
||||
self.YVector = yv
|
||||
|
||||
|
||||
class _FakeUcsCollection:
|
||||
def __init__(self):
|
||||
self.items: list[_FakeUcs] = []
|
||||
self.calls: list[tuple[str, tuple]] = []
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.items)
|
||||
|
||||
def Item(self, key):
|
||||
if isinstance(key, int):
|
||||
return self.items[key]
|
||||
for u in self.items:
|
||||
if u.Name.lower() == key.lower():
|
||||
return u
|
||||
raise RuntimeError(f"no ucs {key}")
|
||||
|
||||
def Add(self, origin, x_point, y_point, name):
|
||||
self.calls.append(("Add", (origin, x_point, y_point, name)))
|
||||
# ActiveX stores unit vectors derived from the two points.
|
||||
xv = tuple(p - o for p, o in zip(x_point, origin, strict=True))
|
||||
yv = tuple(p - o for p, o in zip(y_point, origin, strict=True))
|
||||
u = _FakeUcs(name, origin, xv, yv)
|
||||
self.items.append(u)
|
||||
return u
|
||||
|
||||
|
||||
class _FakeDocument:
|
||||
def __init__(self):
|
||||
self.Views = _FakeViews()
|
||||
self.UserCoordinateSystems = _FakeUcsCollection()
|
||||
self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0)
|
||||
self.viewport_sets = 0
|
||||
self.ActiveUCS = None
|
||||
self.commands: list[str] = []
|
||||
|
||||
@property
|
||||
def ActiveViewport(self):
|
||||
return self._viewport
|
||||
|
||||
@ActiveViewport.setter
|
||||
def ActiveViewport(self, value):
|
||||
self._viewport = value
|
||||
self.viewport_sets += 1
|
||||
|
||||
def SendCommand(self, macro):
|
||||
self.commands.append(macro)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from backends import com_backend as module
|
||||
|
||||
document = _FakeDocument()
|
||||
variables = {"CMDACTIVE": 0, "UCSNAME": ""}
|
||||
app = types.SimpleNamespace(GetVariable=lambda name: variables[name])
|
||||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||||
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
||||
monkeypatch.setattr(module, "_regen", lambda: None)
|
||||
monkeypatch.setattr(module, "_apoint", lambda x, y, z=0.0: (float(x), float(y), float(z)))
|
||||
monkeypatch.setattr(module, "_av", lambda values: tuple(float(v) for v in values))
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, document, variables
|
||||
|
||||
|
||||
async def test_com_view_save_defaults_to_the_active_viewport(com_backend):
|
||||
backend, document, _ = com_backend
|
||||
result = await backend.view_named_save("HOME")
|
||||
assert document.Views.calls == [("Add", ("HOME",))]
|
||||
(view,) = document.Views.views
|
||||
assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0
|
||||
assert result["center"] == [30.0, 40.0] and result["replaced"] is False
|
||||
again = await backend.view_named_save("home", center=[1, 2], height=10)
|
||||
assert again["replaced"] is True and len(document.Views.views) == 1
|
||||
assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0
|
||||
|
||||
|
||||
async def test_com_view_restore_sets_the_active_viewport(com_backend):
|
||||
backend, document, _ = com_backend
|
||||
await backend.view_named_save("HOME", center=[5, 6], height=20, width=30)
|
||||
result = await backend.view_named_restore("HOME")
|
||||
assert result["applied"] == "active_viewport"
|
||||
assert document.ActiveViewport.Center == (5.0, 6.0)
|
||||
assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0
|
||||
assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment"
|
||||
assert await backend.view_named_list() == [
|
||||
{"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0}
|
||||
]
|
||||
|
||||
|
||||
async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend):
|
||||
backend, document, variables = com_backend
|
||||
result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 5, 0], [-1, 0, 0])
|
||||
assert document.UserCoordinateSystems.calls == [
|
||||
("Add", ((10.0, 10.0, 0.0), (10.0, 11.0, 0.0), (9.0, 10.0, 0.0), "FRONT"))
|
||||
]
|
||||
assert document.ActiveUCS is document.UserCoordinateSystems.items[0]
|
||||
assert result["x_axis"] == [0.0, 1.0, 0.0] and result["current"] is True
|
||||
variables["UCSNAME"] = "FRONT"
|
||||
rows = await backend.ucs_list()
|
||||
assert rows[0]["name"] == "world" and rows[0]["current"] is False
|
||||
assert rows[1] == {
|
||||
"name": "FRONT",
|
||||
"origin": [10.0, 10.0, 0.0],
|
||||
"x_axis": [0.0, 1.0, 0.0],
|
||||
"y_axis": [-1.0, 0.0, 0.0],
|
||||
"current": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend):
|
||||
backend, document, variables = com_backend
|
||||
result = await backend.ucs_restore("world")
|
||||
assert document.commands == ["_.UCS _W\n"] and result["name"] == "world"
|
||||
variables["CMDACTIVE"] = 1
|
||||
with pytest.raises(RuntimeError, match="CMDACTIVE"):
|
||||
await backend.ucs_restore("world")
|
||||
assert len(document.commands) == 1
|
||||
|
||||
|
||||
async def test_com_ucs_refuses_before_activex(com_backend):
|
||||
backend, document, _ = com_backend
|
||||
with pytest.raises(ValueError, match="measured"):
|
||||
await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [1, 1, 0])
|
||||
assert document.UserCoordinateSystems.calls == []
|
||||
Reference in New Issue
Block a user