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,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)
|
||||
Reference in New Issue
Block a user