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:
Umut CAN
2026-09-17 09:47:39 +03:00
co-authored by Claude Opus 5
parent 2805697d16
commit dcc8f07bf4
15 changed files with 2379 additions and 12 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div> </div>
> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests. > **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 169 is the **registered** count; a default install advertises 164 over `tools/list`, > 179 is the **registered** count; a default install advertises 174 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority. > because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists ## Why this exists
@@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog | | Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools | | Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` | | Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
| Environment | `document_list/activate/close` — several drawings open at once on both engines, the active one is what every tool targets | | Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)* |
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box | | Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* | | Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle | | Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
+427
View File
@@ -132,6 +132,14 @@ def _ai(values: list[int]):
) )
def _avar(values):
"""Create a VARIANT array of VARIANTs (what SetXRecordData takes for its values)."""
return win32com.client.VARIANT(
pythoncom.VT_ARRAY | pythoncom.VT_VARIANT,
list(values),
)
def _solid_3d_capability() -> FeatureCapability: def _solid_3d_capability() -> FeatureCapability:
"""3D solids are opt-in (ENABLE_3D=true) even on the live COM backend.""" """3D solids are opt-in (ENABLE_3D=true) even on the live COM backend."""
if config.settings.enable_3d: if config.settings.enable_3d:
@@ -815,6 +823,17 @@ class ComBackend(AutoCADBackend):
"solid_3d": _solid_3d_capability(), "solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"), "lisp": FeatureCapability(True, "sanitized"),
"documents": FeatureCapability(True, "native"), "documents": FeatureCapability(True, "native"),
# Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads
# and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim.
"layer_states": FeatureCapability(
True,
"xrecord",
reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
),
"named_views": FeatureCapability(True, "native"),
"ucs": FeatureCapability(
True, "native", reason="world_restore_via_ucs_command;tool_coordinates_stay_wcs"
),
"live_application": FeatureCapability(True, "native"), "live_application": FeatureCapability(True, "native"),
"preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"), "preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"),
"interactive_prompt": FeatureCapability( "interactive_prompt": FeatureCapability(
@@ -1403,6 +1422,414 @@ class ComBackend(AutoCADBackend):
await self._ensure_document_state() await self._ensure_document_state()
return result return result
# ── layer states ──────────────────────────────────────────────────────────
@staticmethod
def _com_layer_states(doc, *, create: bool):
from engineering.environment.layer_states import DICT_NAME
try:
return doc.Dictionaries.Item(DICT_NAME)
except Exception as exc: # absent
log.debug("Dictionaries.Item(%s): %s", DICT_NAME, exc)
if not create:
return None
return doc.Dictionaries.Add(DICT_NAME)
@staticmethod
def _com_dict_entries(states) -> list[tuple[str, Any]]:
if states is None:
return []
out = []
for index in range(int(states.Count)):
obj = states.Item(index)
out.append((str(states.GetName(obj)), obj))
return out
def _com_layer_state_entry(self, states, name: str):
for key, obj in self._com_dict_entries(states):
if key.lower() == name.lower():
return key, obj
return None, None
@staticmethod
def _com_xrecord_chunks(xrecord) -> list[str]:
# pywin32 returns the two [out] parameters as a tuple, exactly as
# GetBoundingBox() / GetXData() do elsewhere in this file.
codes, values = xrecord.GetXRecordData()
return [
str(value)
for code, value in zip(list(codes or []), list(values or []), strict=False)
if int(code) == 1000
]
@staticmethod
def _com_layer_snapshot(doc, description):
from engineering.environment.layer_states import snapshot_from_layers
current = str(doc.ActiveLayer.Name)
layers, plot = [], {}
for index in range(int(doc.Layers.Count)):
lyr = doc.Layers.Item(index)
layers.append(_layer_info(lyr, current))
plot[str(lyr.Name)] = bool(lyr.Plottable)
return snapshot_from_layers(layers, current, plot=plot, description=description)
async def layer_state_save(self, name, description=None) -> dict:
from engineering.environment.layer_states import encode_state, validate_state_name
clean = validate_state_name(name)
if description is not None and not isinstance(description, str):
raise TypeError("layer_state_save: description must be a string")
def _sync():
doc = _acad_doc()
state = self._com_layer_snapshot(doc, description)
chunks = encode_state(state)
states = self._com_layer_states(doc, create=True)
key, xrecord = self._com_layer_state_entry(states, clean)
replaced = xrecord is not None
if xrecord is None:
xrecord = states.AddXRecord(clean)
key = clean
xrecord.SetXRecordData(_ai([1000] * len(chunks)), _avar(chunks))
return {
"ok": True,
"name": key,
"layer_count": len(state["layers"]),
"replaced": replaced,
"chunks": len(chunks),
"backend": "com",
}
return await self._run(_sync)
async def layer_state_restore(self, name, properties=None) -> dict:
from engineering.environment.layer_states import (
decode_state,
diff_snapshot,
validate_properties,
validate_state_name,
)
clean = validate_state_name(name)
props = validate_properties(properties)
def _sync():
doc = _acad_doc()
states = self._com_layer_states(doc, create=False)
key, xrecord = self._com_layer_state_entry(states, clean)
if xrecord is None:
raise ValueError(
f"layer_state_restore: no layer state named {clean!r}; "
f"saved states: {[k for k, _ in self._com_dict_entries(states)]}"
)
state = decode_state(self._com_xrecord_chunks(xrecord))
current_name = str(doc.ActiveLayer.Name)
layers = [
_layer_info(doc.Layers.Item(i), current_name) for i in range(int(doc.Layers.Count))
]
missing, new = diff_snapshot(state, layers)
current = None
warnings: list[str] = []
# Current first: AutoCAD refuses to freeze the active layer, so the
# active one must already be the state's before the loop freezes.
if "current" in props and state["current_layer"] not in missing:
doc.ActiveLayer = doc.Layers.Item(state["current_layer"])
current = state["current_layer"]
applied = 0
for layer_name, snap in state["layers"].items():
if layer_name in missing:
continue
lyr = doc.Layers.Item(layer_name)
try:
if "color" in props:
lyr.Color = int(snap["color"])
if "linetype" in props:
_ensure_linetype_loaded(snap["linetype"])
lyr.Linetype = snap["linetype"]
if "lineweight" in props:
lyr.LineWeight = int(snap["lineweight"])
if "plot" in props:
lyr.Plottable = bool(snap["plot"])
if "on" in props:
lyr.LayerOn = bool(snap["on"])
if "frozen" in props:
lyr.Freeze = bool(snap["frozen"])
if "locked" in props:
lyr.Lock = bool(snap["locked"])
except Exception as exc: # e.g. freezing the active layer
warnings.append(f"{layer_name}: {exc}")
continue
applied += 1
_regen()
result = {
"ok": True,
"name": key,
"applied": {
"layers": applied,
"properties": list(props),
"current_layer": current,
},
"missing_layers": missing,
"new_layers": new,
"backend": "com",
}
if warnings:
result["warnings"] = warnings
return result
return await self._run(_sync)
async def layer_state_list(self) -> list[dict]:
from engineering.environment.layer_states import decode_state
def _sync():
doc = _acad_doc()
states = self._com_layer_states(doc, create=False)
rows = []
for key, xrecord in self._com_dict_entries(states):
state = decode_state(self._com_xrecord_chunks(xrecord))
rows.append(
{
"name": key,
"description": state.get("description"),
"layer_count": len(state["layers"]),
}
)
return rows
return await self._run(_sync)
async def layer_state_delete(self, name) -> dict:
from engineering.environment.layer_states import validate_state_name
clean = validate_state_name(name)
def _sync():
doc = _acad_doc()
states = self._com_layer_states(doc, create=False)
key, xrecord = self._com_layer_state_entry(states, clean)
if xrecord is None:
raise ValueError(
f"layer_state_delete: no layer state named {clean!r}; "
f"saved states: {[k for k, _ in self._com_dict_entries(states)]}"
)
removed = states.Remove(key)
try:
removed.Delete()
except Exception as exc: # Remove already detached it
log.debug("XRecord.Delete after Remove: %s", exc)
return {"ok": True, "deleted": key, "backend": "com"}
return await self._run(_sync)
# ── named views ───────────────────────────────────────────────────────────
@staticmethod
def _com_view(doc, name: str):
try:
return doc.Views.Item(name)
except Exception:
return None
@staticmethod
def _com_view_row(view) -> dict:
center = view.Center
return {
"name": str(view.Name),
"center": [float(center[0]), float(center[1])],
"height": float(view.Height),
"width": float(view.Width),
}
async def view_named_save(self, name, center=None, height=None, width=None) -> dict:
from engineering.environment.names import validate_name
from engineering.environment.views import resolve_view_args
clean = validate_name(name, what="view name")
args = resolve_view_args(center, height, width)
def _sync():
doc = _acad_doc()
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
w = args["width"] or h * aspect
view = self._com_view(doc, clean)
replaced = view is not None
if view is None:
view = doc.Views.Add(clean)
view.Center = _av([cx, cy])
view.Height = float(h)
view.Width = float(w)
return {
"ok": True,
"name": str(view.Name),
"center": [float(cx), float(cy)],
"height": float(h),
"width": float(w),
"replaced": replaced,
"backend": "com",
}
return await self._run(_sync)
async def view_named_restore(self, name) -> dict:
from engineering.environment.names import validate_name
clean = validate_name(name, what="view name")
def _sync():
doc = _acad_doc()
view = self._com_view(doc, clean)
if view is None:
names = [str(doc.Views.Item(i).Name) for i in range(int(doc.Views.Count))]
raise ValueError(
f"view_named_restore: no named view {clean!r}; saved views: {names}"
)
row = self._com_view_row(view)
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"}
return await self._run(_sync)
async def view_named_list(self) -> list[dict]:
def _sync():
doc = _acad_doc()
return [self._com_view_row(doc.Views.Item(i)) for i in range(int(doc.Views.Count))]
return await self._run(_sync)
# ── UCS ───────────────────────────────────────────────────────────────────
@staticmethod
def _com_ucs(doc, name: str):
try:
return doc.UserCoordinateSystems.Item(name)
except Exception:
return None
@staticmethod
def _com_ucs_row(ucs, current: str) -> dict:
return {
"name": str(ucs.Name),
"origin": [float(c) for c in ucs.Origin],
"x_axis": [float(c) for c in ucs.XVector],
"y_axis": [float(c) for c in ucs.YVector],
"current": current != "" and str(ucs.Name).lower() == current.lower(),
}
async def ucs_list(self) -> list[dict]:
from engineering.environment.ucs import WORLD
def _sync():
app = _acad_app()
doc = _acad_doc()
current = str(app.GetVariable("UCSNAME") or "")
rows = [
{
"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": current == "",
}
]
collection = doc.UserCoordinateSystems
for index in range(int(collection.Count)):
rows.append(self._com_ucs_row(collection.Item(index), current))
return rows
return await self._run(_sync)
async def ucs_set(self, name, origin, x_axis, y_axis) -> dict:
from engineering.environment.names import validate_name
from engineering.environment.ucs import WORLD, resolve_ucs_axes
clean = validate_name(name, what="UCS name")
if clean.lower() == WORLD:
raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS")
axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any ActiveX call
def _sync():
doc = _acad_doc()
o, x, y = axes["origin"], axes["x_axis"], axes["y_axis"]
ucs = self._com_ucs(doc, clean)
replaced = ucs is not None
if ucs is None:
# ActiveX takes POINTS on the axes, not direction vectors.
ucs = doc.UserCoordinateSystems.Add(
_apoint(*o),
_apoint(o[0] + x[0], o[1] + x[1], o[2] + x[2]),
_apoint(o[0] + y[0], o[1] + y[1], o[2] + y[2]),
clean,
)
else:
ucs.Origin = _apoint(*o)
ucs.XVector = _apoint(*x)
ucs.YVector = _apoint(*y)
doc.ActiveUCS = ucs
return {
"ok": True,
"name": str(ucs.Name),
"origin": list(o),
"x_axis": list(x),
"y_axis": list(y),
"replaced": replaced,
"current": True,
"backend": "com",
}
return await self._run(_sync)
async def ucs_restore(self, name) -> dict:
from engineering.environment.names import validate_name
from engineering.environment.ucs import WORLD
clean = validate_name(name, what="UCS name")
def _sync():
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.
try:
cmd_active = int(app.GetVariable("CMDACTIVE"))
except Exception as exc:
log.debug("CMDACTIVE read failed, proceeding: %s", exc)
cmd_active = 0
if cmd_active:
raise RuntimeError(
"AutoCAD has an active command or prompt (CMDACTIVE="
f"{cmd_active}). Press ESC in AutoCAD to cancel, then retry."
)
doc.SendCommand("_.UCS _W\n")
return {"ok": True, "name": WORLD, "current": True, "backend": "com"}
ucs = self._com_ucs(doc, clean)
if ucs is None:
names = [
str(doc.UserCoordinateSystems.Item(i).Name)
for i in range(int(doc.UserCoordinateSystems.Count))
]
raise ValueError(
f"ucs_restore: no UCS named {clean!r}; saved: {names} (or 'world')"
)
doc.ActiveUCS = ucs
return {"ok": True, "name": str(ucs.Name), "current": True, "backend": "com"}
return await self._run(_sync)
# ── selection filters (M8 / F1) ────────────────────────────────────────── # ── selection filters (M8 / F1) ──────────────────────────────────────────
# #
# VERIFIED against a live AutoCAD 2026 (2026-08-05). # VERIFIED against a live AutoCAD 2026 (2026-08-05).
+54 -1
View File
@@ -44,7 +44,60 @@ class EnvironmentContract(ABC):
""" """
... ...
# ── layer states, named views, UCS: appended by Task 22 ────────────────── # ── layer states (portable ACADMCP_LAYERSTATES XRECORDs, both engines) ──
@abstractmethod
async def layer_state_save(self, name: str, description: str | None = None) -> dict:
"""→ ``{"ok", "name", "layer_count", "replaced"}``."""
...
@abstractmethod
async def layer_state_restore(self, name: str, properties: list[str] | None = None) -> dict:
"""``properties`` ⊆ {"on","frozen","locked","color","linetype","lineweight","plot","current"};
→ ``{"ok", "name", "applied", "missing_layers", "new_layers"}``."""
...
@abstractmethod
async def layer_state_list(self) -> list[dict]:
"""→ ``[{"name", "description", "layer_count"}]``."""
...
@abstractmethod
async def layer_state_delete(self, name: str) -> dict: ...
# ── named views ─────────────────────────────────────────────────────────
@abstractmethod
async def view_named_save(
self, name: str, center=None, height: float | None = None, width: float | None = None
) -> dict:
"""→ ``{"ok", "name", "center": [x, y], "height", "width", "replaced"}``."""
...
@abstractmethod
async def view_named_restore(self, name: str) -> dict:
"""ezdxf: sets the ``*Active`` VPORT, ``"applied": "header_only"``."""
...
@abstractmethod
async def view_named_list(self) -> list[dict]: ...
# ── UCS (stored and made current; tool coordinates stay WCS) ────────────
@abstractmethod
async def ucs_list(self) -> list[dict]:
"""→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world"."""
...
@abstractmethod
async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict:
"""non-orthogonal → ``ValueError`` with the measured angle."""
...
@abstractmethod
async def ucs_restore(self, name: str) -> dict:
"""``"world"`` resets."""
...
# ── application, preferences, operator prompts (COM only) ─────────────── # ── application, preferences, operator prompts (COM only) ───────────────
+440
View File
@@ -1246,6 +1246,21 @@ class EzdxfBackend(AutoCADBackend):
), ),
"lisp": FeatureCapability(False, reason="live_com_only"), "lisp": FeatureCapability(False, reason="live_com_only"),
"documents": FeatureCapability(True, "registry"), "documents": FeatureCapability(True, "registry"),
# Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads
# and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim.
"layer_states": FeatureCapability(
True,
"xrecord",
reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
),
"named_views": FeatureCapability(
True,
"vport_active",
reason="restore_sets_the_view_the_file_opens_on;no_live_display",
),
"ucs": FeatureCapability(
True, "stored_not_interpreted", reason="tool_coordinates_stay_wcs"
),
# Track E keys. `@capability` registers its key at import, and # Track E keys. `@capability` registers its key at import, and
# tests/test_capability_contract.py checks both maps from that # tests/test_capability_contract.py checks both maps from that
# moment on — so every key the contract module uses is declared # moment on — so every key the contract module uses is declared
@@ -2312,6 +2327,431 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync, quarantine_exit=closing_active) return await self._async(_sync, quarantine_exit=closing_active)
# ── layer states ──────────────────────────────────────────────────────────
@staticmethod
def _layer_state_entry(states, name: str):
"""Case-insensitive lookup → ``(stored_key, xrecord)`` or ``(None, None)``."""
if states is None:
return None, None
if name in states:
return name, states.get(name)
for key in states.keys():
if key.lower() == name.lower():
return key, states.get(key)
return None, None
@staticmethod
def _layer_state_chunks(xrecord) -> list[str]:
return [str(tag.value) for tag in xrecord.tags if tag.code == 1000]
@staticmethod
def _layer_states(doc):
from engineering.environment.layer_states import DICT_NAME
return doc.rootdict.get(DICT_NAME) if DICT_NAME in doc.rootdict else None
@staticmethod
def _layer_state_names(states) -> list[str]:
return list(states.keys()) if states is not None else []
def _layer_state_snapshot(self, doc, description):
from engineering.environment.layer_states import snapshot_from_layers
layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers]
plot = {lyr.dxf.name: bool(lyr.dxf.get("plot", 1)) for lyr in doc.layers}
return snapshot_from_layers(layers, self._current_layer, plot=plot, description=description)
async def layer_state_save(self, name, description=None) -> dict:
"""Snapshot the layer table into ``ACADMCP_LAYERSTATES/<name>``.
Portable and file-resident; not an AutoCAD Layer States Manager entry.
"""
from engineering.environment.layer_states import (
DICT_NAME,
encode_state,
validate_state_name,
)
clean = validate_state_name(name)
if description is not None and not isinstance(description, str):
raise TypeError("layer_state_save: description must be a string")
def _sync():
doc = self._require_doc()
state = self._layer_state_snapshot(doc, description)
chunks = encode_state(state)
states = doc.rootdict.get_required_dict(DICT_NAME)
existing_key, _ = self._layer_state_entry(states, clean)
replaced = existing_key is not None
if replaced:
states.remove(existing_key)
xrecord = states.add_xrecord(clean)
xrecord.extend([(1000, chunk) for chunk in chunks])
self._mark_dirty()
return {
"ok": True,
"name": clean,
"layer_count": len(state["layers"]),
"replaced": replaced,
"chunks": len(chunks),
"backend": "ezdxf",
}
return await self._async(_sync)
async def layer_state_restore(self, name, properties=None) -> dict:
from engineering.environment.layer_states import (
decode_state,
diff_snapshot,
validate_properties,
validate_state_name,
)
clean = validate_state_name(name)
props = validate_properties(properties)
def _sync():
doc = self._require_doc()
states = self._layer_states(doc)
key, xrecord = self._layer_state_entry(states, clean)
if xrecord is None:
raise ValueError(
f"layer_state_restore: no layer state named {clean!r}; "
f"saved states: {self._layer_state_names(states)}"
)
state = decode_state(self._layer_state_chunks(xrecord))
layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers]
missing, new = diff_snapshot(state, layers)
applied = 0
for layer_name, snap in state["layers"].items():
if layer_name not in doc.layers:
continue # missing: reported, never created
lyr = doc.layers.get(layer_name)
# Colour first: `Layer.color` keeps the on/off sign, and the
# on/off call below must see the final colour.
if "color" in props:
lyr.color = int(snap["color"])
if "linetype" in props:
_ensure_linetype_loaded(doc, snap["linetype"])
lyr.dxf.linetype = snap["linetype"]
if "lineweight" in props:
lyr.dxf.lineweight = int(snap["lineweight"])
if "plot" in props:
lyr.dxf.plot = 1 if snap["plot"] else 0
if "on" in props:
if snap["on"]:
lyr.on()
else:
lyr.off()
if "frozen" in props:
if snap["frozen"]:
lyr.freeze()
else:
lyr.thaw()
if "locked" in props:
if snap["locked"]:
lyr.lock()
else:
lyr.unlock()
applied += 1
current = None
if "current" in props and state["current_layer"] in doc.layers:
current = state["current_layer"]
self._current_layer = current
doc.header["$CLAYER"] = current
self._mark_dirty()
return {
"ok": True,
"name": key,
"applied": {
"layers": applied,
"properties": list(props),
"current_layer": current,
},
"missing_layers": missing,
"new_layers": new,
"backend": "ezdxf",
}
return await self._async(_sync)
async def layer_state_list(self) -> list[dict]:
from engineering.environment.layer_states import decode_state
def _sync():
doc = self._require_doc()
states = self._layer_states(doc)
rows = []
for key in self._layer_state_names(states):
state = decode_state(self._layer_state_chunks(states.get(key)))
rows.append(
{
"name": key,
"description": state.get("description"),
"layer_count": len(state["layers"]),
}
)
return rows
return await self._async(_sync)
async def layer_state_delete(self, name) -> dict:
from engineering.environment.layer_states import validate_state_name
clean = validate_state_name(name)
def _sync():
doc = self._require_doc()
states = self._layer_states(doc)
key, xrecord = self._layer_state_entry(states, clean)
if xrecord is None:
raise ValueError(
f"layer_state_delete: no layer state named {clean!r}; "
f"saved states: {self._layer_state_names(states)}"
)
states.remove(key)
self._mark_dirty()
return {"ok": True, "deleted": key, "backend": "ezdxf"}
return await self._async(_sync)
# ── named views ───────────────────────────────────────────────────────────
# TEMPORARY (track-e/environment worktree only): group C's Task 14 defines
# the same `_active_vport` in its `# ── settings (track E, group C) ──`
# block, and C's is the one that ships. This copy exists so this worktree
# runs without C's code; the merge agent deletes THIS definition (two in
# one class are ruff F811, and the later one would silently win).
@staticmethod
def _active_vport(doc):
"""The ``*Active`` VPORT entry — the view the file opens on. Created if absent."""
entries = doc.viewports.get("*Active")
if entries:
return entries[0]
return doc.viewports.new("*Active")
@staticmethod
def _view_entry(doc, name: str):
if name in doc.views: # case-insensitive in ezdxf's table
return doc.views.get(name)
return None
async def view_named_save(self, name, center=None, height=None, width=None) -> dict:
from engineering.environment.names import validate_name
from engineering.environment.views import resolve_view_args
clean = validate_name(name, what="view name")
args = resolve_view_args(center, height, width)
def _sync():
doc = self._require_doc()
vport = self._active_vport(doc)
aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34
if args["center"] is None or args["height"] is None:
# Headless there is no display to read a "current view" from;
# the drawing's extents are the honest default.
bb = ezdxf_bbox.extents(doc.modelspace()) if _BBOX_OK else None
if bb is None or not bb.has_data:
raise ValueError(
f"view_named_save: {clean!r} — an empty drawing has no extents; "
"pass center and height"
)
ext_center = (
(bb.extmin.x + bb.extmax.x) / 2.0,
(bb.extmin.y + bb.extmax.y) / 2.0,
)
# The window must hold the extents at the viewport's aspect:
# whichever side binds decides the height.
ext_height = max(bb.extmax.y - bb.extmin.y, (bb.extmax.x - bb.extmin.x) / aspect)
if ext_height <= 0:
ext_height = 1.0
else:
ext_center, ext_height = args["center"], args["height"]
cx, cy = args["center"] or ext_center
h = args["height"] or ext_height
w = args["width"] or h * aspect
existing = self._view_entry(doc, clean)
replaced = existing is not None
stored = existing.dxf.name if replaced else clean
if replaced:
doc.views.remove(stored)
doc.views.add(stored, dxfattribs={"center": (cx, cy, 0.0), "height": h, "width": w})
self._mark_dirty()
return {
"ok": True,
"name": stored,
"center": [float(cx), float(cy)],
"height": float(h),
"width": float(w),
"replaced": replaced,
"backend": "ezdxf",
}
return await self._async(_sync)
async def view_named_restore(self, name) -> dict:
from engineering.environment.names import validate_name
clean = validate_name(name, what="view name")
def _sync():
doc = self._require_doc()
view = self._view_entry(doc, clean)
if view is None:
raise ValueError(
f"view_named_restore: no named view {clean!r}; "
f"saved views: {[v.dxf.name for v in doc.views]}"
)
cx, cy = float(view.dxf.center.x), float(view.dxf.center.y)
h, w = float(view.dxf.height), float(view.dxf.width)
vport = self._active_vport(doc)
vport.dxf.center = (cx, cy)
vport.dxf.height = h
if h > 0:
vport.dxf.aspect_ratio = w / h
self._mark_dirty()
return {
"ok": True,
"name": view.dxf.name,
"center": [cx, cy],
"height": h,
"width": w,
# The plan's literal. Measured: ezdxf's header refuses $VIEWCTR,
# so the store is the *Active VPORT — what AutoCAD reads as the
# initial view when it opens the file. No live display moves.
"applied": "header_only",
"store": "vport_active",
"backend": "ezdxf",
}
return await self._async(_sync)
async def view_named_list(self) -> list[dict]:
def _sync():
doc = self._require_doc()
return [
{
"name": view.dxf.name,
"center": [float(view.dxf.center.x), float(view.dxf.center.y)],
"height": float(view.dxf.height),
"width": float(view.dxf.width),
}
for view in doc.views
]
return await self._async(_sync)
# ── UCS ───────────────────────────────────────────────────────────────────
@staticmethod
def _ucs_entry(doc, name: str):
for ucs in doc.ucs:
if ucs.dxf.name.lower() == name.lower():
return ucs
return None
@staticmethod
def _set_ucs_header(doc, name: str, origin, x_axis, y_axis) -> None:
doc.header["$UCSNAME"] = name
doc.header["$UCSORG"] = tuple(origin)
doc.header["$UCSXDIR"] = tuple(x_axis)
doc.header["$UCSYDIR"] = tuple(y_axis)
@staticmethod
def _ucs_row(name, origin, x_axis, y_axis, current: bool) -> dict:
return {
"name": name,
"origin": [float(c) for c in origin],
"x_axis": [float(c) for c in x_axis],
"y_axis": [float(c) for c in y_axis],
"current": current,
}
async def ucs_list(self) -> list[dict]:
from engineering.environment.ucs import WORLD
def _sync():
doc = self._require_doc()
current = str(doc.header.get("$UCSNAME", "") or "")
rows = [self._ucs_row(WORLD, (0, 0, 0), (1, 0, 0), (0, 1, 0), current == "")]
for ucs in doc.ucs:
rows.append(
self._ucs_row(
ucs.dxf.name,
ucs.dxf.origin,
ucs.dxf.xaxis,
ucs.dxf.yaxis,
current != "" and ucs.dxf.name.lower() == current.lower(),
)
)
return rows
return await self._async(_sync)
async def ucs_set(self, name, origin, x_axis, y_axis) -> dict:
from engineering.environment.names import validate_name
from engineering.environment.ucs import WORLD, resolve_ucs_axes
clean = validate_name(name, what="UCS name")
if clean.lower() == WORLD:
raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS")
axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any write
def _sync():
doc = self._require_doc()
existing = self._ucs_entry(doc, clean)
replaced = existing is not None
stored = existing.dxf.name if replaced else clean
if replaced:
doc.ucs.remove(stored)
doc.ucs.add(
stored,
dxfattribs={
"origin": axes["origin"],
"xaxis": axes["x_axis"],
"yaxis": axes["y_axis"],
},
)
self._set_ucs_header(doc, stored, axes["origin"], axes["x_axis"], axes["y_axis"])
self._mark_dirty()
return {
"ok": True,
"name": stored,
"origin": list(axes["origin"]),
"x_axis": list(axes["x_axis"]),
"y_axis": list(axes["y_axis"]),
"replaced": replaced,
"current": True,
"backend": "ezdxf",
}
return await self._async(_sync)
async def ucs_restore(self, name) -> dict:
from engineering.environment.names import validate_name
from engineering.environment.ucs import WORLD
clean = validate_name(name, what="UCS name")
def _sync():
doc = self._require_doc()
if clean.lower() == WORLD:
self._set_ucs_header(doc, "", (0, 0, 0), (1, 0, 0), (0, 1, 0))
self._mark_dirty()
return {"ok": True, "name": WORLD, "current": True, "backend": "ezdxf"}
ucs = self._ucs_entry(doc, clean)
if ucs is None:
raise ValueError(
f"ucs_restore: no UCS named {clean!r}; "
f"saved: {[u.dxf.name for u in doc.ucs]} (or 'world')"
)
self._set_ucs_header(doc, ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis)
self._mark_dirty()
return {"ok": True, "name": ucs.dxf.name, "current": True, "backend": "ezdxf"}
return await self._async(_sync)
# ── viewports ──────────────────────────────────────────────────────────── # ── viewports ────────────────────────────────────────────────────────────
#: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM). #: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM).
+84
View File
@@ -74,6 +74,8 @@ class ToolAliases:
# class, a number and markers; a drafter typing it could mean either. # class, a number and markers; a drafter typing it could mean either.
# CLOSE -- the active document (drawing_close) or one named document # CLOSE -- the active document (drawing_close) or one named document
# (document_close); a drafter typing it could mean either. # (document_close); a drafter typing it could mean either.
# LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore,
# list, delete in one) that this server splits into discrete tools.
# #
# Every other AutoCAD command must map to exactly one tool. # Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -84,11 +86,14 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"ERASE", "ERASE",
"INSERT", "INSERT",
"LAYER", "LAYER",
"LAYERSTATE",
"LAYOUT", "LAYOUT",
"MEASUREGEOM", "MEASUREGEOM",
"PLINE", "PLINE",
"QSELECT", "QSELECT",
"SETVAR", "SETVAR",
"UCS",
"VIEW",
"ZOOM", "ZOOM",
} }
) )
@@ -1466,6 +1471,85 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"currently open files", "currently open files",
), ),
), ),
"layer_state_delete": ToolAliases(
acad=("LAYERSTATE",),
synonyms=("delete a layer state", "remove a saved layer setup", "drop the layer snapshot"),
),
# Same rule as `document_list` above: no "which ... are" phrasings on the
# three list tools here — "which layer setups are saved" alone pushed
# `block_list` to #4 for the holdout "which blocks are defined" (measured
# 2026-09-17); the nouns carry the questions without the interrogative.
"layer_state_list": ToolAliases(
acad=("LAYERSTATE",),
synonyms=("saved layer states", "saved layer setups", "list layer snapshots"),
),
"layer_state_restore": ToolAliases(
acad=("LAYERSTATE",),
synonyms=(
"restore the layer state",
"put the layers back",
"go back to the plot layer setup",
"reapply saved layer settings",
"layer snapshot restore",
),
),
"layer_state_save": ToolAliases(
acad=("LAYERSTATE",),
synonyms=(
"save the layer state",
"remember the layer setup",
"snapshot the layers",
"layer configuration for plotting",
"save which layers are frozen",
),
),
"ucs_list": ToolAliases(
acad=("UCS", "UCSMAN"),
synonyms=("the current ucs", "list coordinate systems", "named ucs", "ucs manager"),
),
"ucs_restore": ToolAliases(
acad=("UCS",),
synonyms=(
"back to world coordinates",
"reset the ucs",
"ucs world",
"switch to a saved ucs",
"make that coordinate system current",
),
),
"ucs_set": ToolAliases(
acad=("UCS",),
synonyms=(
"user coordinate system",
"define a ucs",
"new coordinate system at this origin",
"rotate the ucs",
"ucs origin",
),
),
"view_named_list": ToolAliases(
acad=("VIEW",),
synonyms=("saved views", "existing named views", "list the views"),
),
"view_named_restore": ToolAliases(
acad=("VIEW",),
synonyms=(
"go to the saved view",
"restore a named view",
"jump to the detail view",
"recall the view",
),
),
"view_named_save": ToolAliases(
acad=("VIEW",),
synonyms=(
"save this view",
"named view",
"remember where i am looking",
"bookmark the view",
"save a detail view",
),
),
} }
+139 -3
View File
@@ -3,7 +3,7 @@
"schema_version": 1, "schema_version": 1,
"package_version": "1.5.1", "package_version": "1.5.1",
"totals": { "totals": {
"tools": 169, "tools": 179,
"resources": 8, "resources": 8,
"prompts": 5, "prompts": 5,
"groups": 20 "groups": 20
@@ -19,7 +19,7 @@
"entity_creation": 18, "entity_creation": 18,
"entity_modification": 16, "entity_modification": 16,
"entity_query": 9, "entity_query": 9,
"layers": 14, "layers": 18,
"layouts": 12, "layouts": 12,
"pid": 9, "pid": 9,
"premium": 12, "premium": 12,
@@ -28,7 +28,7 @@
"templates": 2, "templates": 2,
"transactions": 3, "transactions": 3,
"validation": 1, "validation": 1,
"view": 4 "view": 10
}, },
"tools": [ "tools": [
{ {
@@ -1611,6 +1611,60 @@
"title": "Show Layer" "title": "Show Layer"
} }
}, },
{
"name": "layer_state_delete",
"group": "layers",
"cost": "destructive",
"summary": "Delete one saved layer state from the drawing.",
"tags": [
"layer"
],
"annotations": {
"title": "Delete Layer State",
"destructiveHint": true
}
},
{
"name": "layer_state_list",
"group": "layers",
"cost": "read",
"summary": "List the layer states saved in this drawing.",
"tags": [
"layer"
],
"annotations": {
"title": "List Layer States",
"readOnlyHint": true
}
},
{
"name": "layer_state_restore",
"group": "layers",
"cost": "safe",
"summary": "Restore a saved layer state, all properties or a chosen subset.",
"tags": [
"layer"
],
"annotations": {
"title": "Restore Layer State",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "layer_state_save",
"group": "layers",
"cost": "safe",
"summary": "Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, in the file.",
"tags": [
"layer"
],
"annotations": {
"title": "Save Layer State",
"readOnlyHint": false,
"destructiveHint": false
}
},
{ {
"name": "layer_thaw", "name": "layer_thaw",
"group": "layers", "group": "layers",
@@ -2251,6 +2305,47 @@
"destructiveHint": true "destructiveHint": true
} }
}, },
{
"name": "ucs_list",
"group": "view",
"cost": "read",
"summary": "List the user coordinate systems, with the implicit world one.",
"tags": [
"view"
],
"annotations": {
"title": "List UCS",
"readOnlyHint": true
}
},
{
"name": "ucs_restore",
"group": "view",
"cost": "safe",
"summary": "Make a saved UCS current, or `world` to reset to WCS.",
"tags": [
"view"
],
"annotations": {
"title": "Restore UCS",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "ucs_set",
"group": "view",
"cost": "safe",
"summary": "Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.",
"tags": [
"view"
],
"annotations": {
"title": "Set UCS",
"readOnlyHint": false,
"destructiveHint": false
}
},
{ {
"name": "validation_check", "name": "validation_check",
"group": "validation", "group": "validation",
@@ -2265,6 +2360,47 @@
"readOnlyHint": true "readOnlyHint": true
} }
}, },
{
"name": "view_named_list",
"group": "view",
"cost": "read",
"summary": "List the named views saved in this drawing.",
"tags": [
"view"
],
"annotations": {
"title": "List Named Views",
"readOnlyHint": true
}
},
{
"name": "view_named_restore",
"group": "view",
"cost": "safe",
"summary": "Restore a named view.",
"tags": [
"view"
],
"annotations": {
"title": "Restore Named View",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "view_named_save",
"group": "view",
"cost": "safe",
"summary": "Save a named view: a centre and height (and width) to come back to.",
"tags": [
"view"
],
"annotations": {
"title": "Save Named View",
"readOnlyHint": false,
"destructiveHint": false
}
},
{ {
"name": "view_screenshot", "name": "view_screenshot",
"group": "view", "group": "view",
+16
View File
@@ -0,0 +1,16 @@
"""Environment helpers (v1.6 track E): portable layer states, view and UCS
argument resolution. Pure data and validation — no ezdxf, no COM."""
from .layer_states import ( # noqa: F401
DICT_NAME,
PROPERTIES,
decode_state,
diff_snapshot,
encode_state,
snapshot_from_layers,
validate_properties,
validate_state_name,
)
from .names import validate_name # noqa: F401
from .ucs import WORLD, resolve_ucs_axes # noqa: F401
from .views import resolve_view_args # noqa: F401
+122
View File
@@ -0,0 +1,122 @@
"""Portable layer states: a layer-table snapshot <-> XRECORD string chunks.
These are the server's own layer states, not AutoCAD's. AutoCAD keeps its
Layer States Manager entries under the layer table's extension dictionary in
a layout neither engine authors; this codec stores a JSON snapshot as
1000-code string chunks in an XRECORD under the drawing's named object
dictionary ``ACADMCP_LAYERSTATES``. They live in the file and travel with it,
and they do **not** appear in AutoCAD's Layer States Manager (LAYERSTATE).
That sentence is repeated in the tool docstrings, in ``system_capabilities``
(feature ``layer_states``) and in the README.
Chunks are ASCII (``ensure_ascii``), so a 255-character boundary can never
split a code point, and 255 is AutoCAD's ``SetXRecordData`` string limit —
ezdxf itself would accept longer tags (measured: a 300-character tag
round-trips through save/reopen unchanged).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from .names import validate_name
if TYPE_CHECKING:
from backends.base import LayerInfo
DICT_NAME = "ACADMCP_LAYERSTATES"
PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current")
LAYER_PROPERTIES = PROPERTIES[:-1]
CHUNK_SIZE = 255
FORMAT_VERSION = 1
def validate_state_name(name) -> str:
return validate_name(name, what="layer state name")
def validate_properties(properties) -> tuple[str, ...]:
"""The subset of ``PROPERTIES`` to apply; ``None`` means all of them."""
if properties is None:
return PROPERTIES
if isinstance(properties, str) or not isinstance(properties, (list, tuple)):
raise TypeError("properties must be a list of property names")
out: list[str] = []
for index, prop in enumerate(properties):
if prop not in PROPERTIES:
raise ValueError(f"properties[{index}]: {prop!r} is not one of {PROPERTIES}")
if prop not in out:
out.append(prop)
if not out:
raise ValueError(f"properties must name at least one of {PROPERTIES}")
return tuple(out)
def snapshot_from_layers(
layers: list[LayerInfo],
current_layer: str,
*,
plot: dict[str, bool] | None = None,
description: str | None = None,
) -> dict:
"""The state dict for a layer table.
``plot`` is per-layer plottability (``LayerInfo`` has no such field; each
engine reads it and passes it in), defaulting to plottable.
"""
plot = plot or {}
return {
"version": FORMAT_VERSION,
"description": description,
"current_layer": str(current_layer),
"layers": {
layer.name: {
"on": bool(layer.is_on),
"frozen": bool(layer.is_frozen),
"locked": bool(layer.is_locked),
"color": int(layer.color),
"linetype": str(layer.linetype),
"lineweight": int(round(float(layer.lineweight))),
"plot": bool(plot.get(layer.name, True)),
}
for layer in layers
},
}
def encode_state(state: dict) -> list[str]:
text = json.dumps(state, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
return [text[i : i + CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE)] or [""]
def decode_state(chunks) -> dict:
text = "".join(str(chunk) for chunk in chunks)
try:
state = json.loads(text)
except ValueError as exc:
raise ValueError(f"layer state payload is not valid JSON: {exc}") from exc
if not isinstance(state, dict) or state.get("version") != FORMAT_VERSION:
found = state.get("version") if isinstance(state, dict) else type(state).__name__
raise ValueError(
f"layer state payload is not a version {FORMAT_VERSION} state (got {found!r})"
)
layers = state.get("layers")
if not isinstance(layers, dict):
raise ValueError("layer state payload has no 'layers' mapping")
for name, props in layers.items():
if not isinstance(props, dict):
raise ValueError(f"layer state entry {name!r} is not a mapping")
missing = [prop for prop in LAYER_PROPERTIES if prop not in props]
if missing:
raise ValueError(f"layer state entry {name!r} lacks {missing}")
state.setdefault("description", None)
state.setdefault("current_layer", "0")
return state
def diff_snapshot(state: dict, layers: list[LayerInfo]) -> tuple[list[str], list[str]]:
"""``(missing, new)``: in the state but not the drawing / in the drawing but not the state."""
in_state = set(state["layers"])
in_drawing = {layer.name for layer in layers}
return sorted(in_state - in_drawing), sorted(in_drawing - in_state)
+17
View File
@@ -0,0 +1,17 @@
"""One rule for the names this package stores in the drawing."""
from __future__ import annotations
def validate_name(name, *, what: str, max_len: int = 255) -> str:
"""A non-empty, stripped string without control characters, else ``ValueError``."""
if not isinstance(name, str):
raise ValueError(f"{what} must be a string, got {type(name).__name__}")
clean = name.strip()
if not clean:
raise ValueError(f"{what} must not be empty")
if len(clean) > max_len:
raise ValueError(f"{what} is longer than {max_len} characters")
if any(ord(ch) < 32 for ch in clean):
raise ValueError(f"{what} must not contain control characters")
return clean
+54
View File
@@ -0,0 +1,54 @@
"""UCS axes, validated and normalised before either engine writes.
Tool coordinates stay WCS on both engines (the repository rule): a UCS is
stored and made current for the operator's benefit, and nothing here starts
interpreting inputs in it.
"""
from __future__ import annotations
import math
WORLD = "world"
ORTHO_TOLERANCE_DEG = 1e-3
def _vec3(value, where: str) -> tuple[float, float, float]:
if not isinstance(value, (list, tuple)) or len(value) != 3:
raise ValueError(f"{where} must be an [x, y, z] triple")
out = []
for axis, item in zip("xyz", value, strict=True):
if isinstance(item, bool) or not isinstance(item, (int, float)):
raise ValueError(f"{where}.{axis} must be a number, got {item!r}")
number = float(item)
if not math.isfinite(number):
raise ValueError(f"{where}.{axis} must be finite")
out.append(number)
return out[0], out[1], out[2]
def _unit(vector, where: str) -> tuple[float, float, float]:
length = math.sqrt(sum(c * c for c in vector))
if length < 1e-12:
raise ValueError(f"{where} must not be a zero vector")
return vector[0] / length, vector[1] / length, vector[2] / length
def resolve_ucs_axes(origin, x_axis, y_axis) -> dict:
"""Unit axes plus the measured angle; non-orthogonal axes are refused by that angle."""
o = _vec3(origin, "origin")
x = _unit(_vec3(x_axis, "x_axis"), "x_axis")
y = _unit(_vec3(y_axis, "y_axis"), "y_axis")
dot = max(-1.0, min(1.0, sum(a * b for a, b in zip(x, y, strict=True))))
angle = math.degrees(math.acos(dot))
if abs(angle - 90.0) > ORTHO_TOLERANCE_DEG:
raise ValueError(
f"ucs_set: x_axis and y_axis must be perpendicular; measured {angle:.2f}° "
f"between them (tolerance {ORTHO_TOLERANCE_DEG}°)"
)
z = (
x[1] * y[2] - x[2] * y[1],
x[2] * y[0] - x[0] * y[2],
x[0] * y[1] - x[1] * y[0],
)
return {"origin": o, "x_axis": x, "y_axis": y, "z_axis": z, "angle_deg": angle}
+32
View File
@@ -0,0 +1,32 @@
"""Named-view arguments, validated before either engine writes."""
from __future__ import annotations
import math
def _finite(value, where: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{where} must be a number, got {value!r}")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{where} must be finite")
return number
def resolve_view_args(center, height, width) -> dict:
"""``{"center": (x, y) | None, "height": float | None, "width": float | None}``."""
out: dict = {"center": None, "height": None, "width": None}
if center is not None:
if not isinstance(center, (list, tuple)) or len(center) != 2:
raise ValueError("center must be an [x, y] pair")
out["center"] = (_finite(center[0], "center.x"), _finite(center[1], "center.y"))
if height is not None:
out["height"] = _finite(height, "height")
if out["height"] <= 0:
raise ValueError(f"height must be > 0, got {out['height']}")
if width is not None:
out["width"] = _finite(width, "width")
if out["width"] <= 0:
raise ValueError(f"width must be > 0, got {out['width']}")
return out
+202 -1
View File
@@ -6819,7 +6819,7 @@ async def pid_tag_parse(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── # ── SECTION 20: Environment (13 tools) ──────────────────────────────────────
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -6904,6 +6904,207 @@ async def document_close(
return await _backend(ctx).document_close(name_or_path, save, discard) return await _backend(ctx).document_close(name_or_path, save, discard)
@cad_tool(
summary="Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, in the file.",
cost="safe",
)
@mcp.tool(
annotations={"title": "Save Layer State", "readOnlyHint": False, "destructiveHint": False},
tags={"layer"},
)
async def layer_state_save(
name: Annotated[str, "State name, e.g. PLOT-SET or DESIGN"],
description: Annotated[str | None, "Free text stored with the state"] = None,
ctx: Context = None,
) -> dict:
"""Snapshot every layer's on/frozen/locked/color/linetype/lineweight/plot
flags plus the current layer as a named state stored in the drawing.
These are the server's own portable layer states: JSON chunks in an XRECORD
under the `ACADMCP_LAYERSTATES` dictionary. They live in the file and
travel with it, work on both engines, and do **not** appear in AutoCAD's
Layer States Manager (LAYERSTATE). Same name replaces (`replaced: true`).
Refusals: an empty or control-character name, a non-string description.
Pack: settings · lean: no.
"""
await ctx.info(f"Saving layer state {name!r}")
return await _backend(ctx).layer_state_save(name, description)
@cad_tool(summary="Restore a saved layer state, all properties or a chosen subset.", cost="safe")
@mcp.tool(
annotations={"title": "Restore Layer State", "readOnlyHint": False, "destructiveHint": False},
tags={"layer"},
)
async def layer_state_restore(
name: Annotated[str, "A name from layer_state_list"],
properties: Annotated[
list[str] | None,
"Subset of on, frozen, locked, color, linetype, lineweight, plot, current; omit for all",
] = None,
ctx: Context = None,
) -> dict:
"""Apply a saved state to the layers that still exist.
`missing_layers` (in the state, not in the drawing) are skipped, never
created; `new_layers` (in the drawing, not in the state) are untouched;
`applied` says how many layers and which properties moved. Refusals: an
unknown state name (lists the saved ones), a property outside the eight
(names the index). Live, a layer AutoCAD refuses to change (freezing the
active layer) lands in `warnings` instead of failing the call. Portable
server states, not Layer States Manager entries. Pack: settings · lean: no.
"""
await ctx.info(f"Restoring layer state {name!r}")
return await _backend(ctx).layer_state_restore(name, properties)
@cad_tool(summary="List the layer states saved in this drawing.", cost="read")
@mcp.tool(
annotations={"title": "List Layer States", "readOnlyHint": True},
tags={"layer"},
)
async def layer_state_list(ctx: Context = None) -> dict:
"""Every state under `ACADMCP_LAYERSTATES` with its description and layer
count. These are the server's portable states, not AutoCAD Layer States
Manager entries. No refusals. Pack: settings · lean: no."""
rows = await _backend(ctx).layer_state_list()
return {"states": rows, "count": len(rows)}
@cad_tool(summary="Delete one saved layer state from the drawing.", cost="destructive")
@mcp.tool(
annotations={"title": "Delete Layer State", "destructiveHint": True},
tags={"layer"},
)
async def layer_state_delete(
name: Annotated[str, "A name from layer_state_list"],
ctx: Context = None,
) -> dict:
"""Remove the named state's XRECORD. Layers themselves are untouched.
Refusal: an unknown name (lists the saved ones). Pack: settings · lean: no."""
await ctx.info(f"Deleting layer state {name!r}")
return await _backend(ctx).layer_state_delete(name)
@cad_tool(
summary="Save a named view: a centre and height (and width) to come back to.", cost="safe"
)
@mcp.tool(
annotations={"title": "Save Named View", "readOnlyHint": False, "destructiveHint": False},
tags={"view"},
)
async def view_named_save(
name: Annotated[str, "View name, e.g. DETAIL-A"],
center: Annotated[
list[float] | None,
"[x, y] in WCS; default: the current view (live) or the drawing extents (headless)",
] = None,
height: Annotated[float | None, "View height in drawing units; default as for center"] = None,
width: Annotated[
float | None, "View width; default: height × the current viewport aspect"
] = None,
ctx: Context = None,
) -> dict:
"""Store a VIEW table entry (AutoCAD's VIEW command). Same name replaces
(`replaced: true`). Refusals: `height`/`width` ≤ 0 or non-finite, a
malformed `center`, and headless an empty drawing with no `center`/`height`
(there are no extents to default to). Pack: settings · lean: no."""
await ctx.info(f"Saving named view {name!r}")
return await _backend(ctx).view_named_save(name, center, height, width)
@cad_tool(summary="Restore a named view.", cost="safe")
@mcp.tool(
annotations={"title": "Restore Named View", "readOnlyHint": False, "destructiveHint": False},
tags={"view"},
)
async def view_named_restore(
name: Annotated[str, "A name from view_named_list"],
ctx: Context = None,
) -> dict:
"""Live: 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."""
return await _backend(ctx).view_named_restore(name)
@cad_tool(summary="List the named views saved in this drawing.", cost="read")
@mcp.tool(
annotations={"title": "List Named Views", "readOnlyHint": True},
tags={"view"},
)
async def view_named_list(ctx: Context = None) -> dict:
"""Every VIEW table entry with centre, height and width. No refusals.
Pack: settings · lean: no."""
rows = await _backend(ctx).view_named_list()
return {"views": rows, "count": len(rows)}
@cad_tool(summary="List the user coordinate systems, with the implicit world one.", cost="read")
@mcp.tool(
annotations={"title": "List UCS", "readOnlyHint": True},
tags={"view"},
)
async def ucs_list(ctx: Context = None) -> dict:
"""The `world` row first, then every UCS table entry with origin and unit
axes, `current` on the active one. Tool coordinates stay WCS on both
engines whatever is current. No refusals. Pack: settings · lean: no."""
rows = await _backend(ctx).ucs_list()
return {
"ucs": rows,
"count": len(rows),
"current": next((r["name"] for r in rows if r["current"]), None),
}
@cad_tool(
summary="Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.",
cost="safe",
)
@mcp.tool(
annotations={"title": "Set UCS", "readOnlyHint": False, "destructiveHint": False},
tags={"view"},
)
async def ucs_set(
name: Annotated[str, "UCS name"],
origin: Annotated[list[float], "[x, y, z] in WCS"],
x_axis: Annotated[list[float], "Direction of the new X axis (any length)"],
y_axis: Annotated[list[float], "Direction of the new Y axis; must be perpendicular to x_axis"],
ctx: Context = None,
) -> dict:
"""Store a UCS table entry and make it current (AutoCAD's UCS command).
Axes are normalised to unit vectors. **Every tool keeps taking and
returning WCS coordinates** — the repository rule; a UCS is for the
operator's own drafting, and no tool starts interpreting inputs in it.
Refusals, before any write: a name of `world` (reserved), a zero-length
axis, and non-perpendicular axes — the message carries the measured angle
(tolerance 0.001°). Pack: settings · lean: no.
"""
await ctx.info(f"Setting UCS {name!r}")
return await _backend(ctx).ucs_set(name, origin, x_axis, y_axis)
@cad_tool(summary="Make a saved UCS current, or `world` to reset to WCS.", cost="safe")
@mcp.tool(
annotations={"title": "Restore UCS", "readOnlyHint": False, "destructiveHint": False},
tags={"view"},
)
async def ucs_restore(
name: Annotated[str, "A name from ucs_list, or `world`"],
ctx: Context = None,
) -> dict:
"""Live: `ActiveUCS` for a named entry; `world` runs `UCS World`, which is
refused while AutoCAD has an active command (CMDACTIVE — press ESC).
Headless: the `$UCSNAME/$UCSORG/$UCSXDIR/$UCSYDIR` header variables.
Refusal: an unknown name (lists the saved ones). Tool coordinates stay
WCS. Pack: settings · lean: no."""
return await _backend(ctx).ucs_restore(name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ── RESOURCES ─────────────────────────────────────────────────────────────── # ── RESOURCES ───────────────────────────────────────────────────────────────
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+444
View File
@@ -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")
+7 -4
View File
@@ -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 tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
`pid` rather than under their secondary `query` / `create` tags. `drawing` `pid` rather than under their secondary `query` / `create` tags. `drawing`
moved 11 -> 14 when v1.6's `document_list` / `document_activate` / 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. other number here has been unchanged since the snapshot was taken.
""" """
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} 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_creation": 18,
"entity_modification": 16, "entity_modification": 16,
"entity_query": 9, "entity_query": 9,
"layers": 14, "layers": 18,
"layouts": 12, "layouts": 12,
"pid": 9, "pid": 9,
"premium": 12, "premium": 12,
@@ -543,6 +546,6 @@ async def test_tool_group_sizes_are_unchanged():
"templates": 2, "templates": 2,
"transactions": 3, "transactions": 3,
"validation": 1, "validation": 1,
"view": 4, "view": 10,
} }
assert sum(sizes.values()) == 169 assert sum(sizes.values()) == 179
+338
View File
@@ -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 == []