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