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
+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