merge: track-e group V

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-22 11:18:03 +03:00
co-authored by Claude Opus 5
21 changed files with 5901 additions and 81 deletions
+23
View File
@@ -0,0 +1,23 @@
"""Environment helpers (v1.6 track E): portable layer states, view and UCS
argument resolution, the preference whitelist. 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 .preferences import ( # noqa: F401
PREFERENCE_KEYS,
READ_ONLY_KEYS,
describe_preferences,
validate_preference,
)
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
+146
View File
@@ -0,0 +1,146 @@
"""The AutoCAD preferences a model may read and write, as data.
``Preferences.*`` lives in the running application (registry-backed), not in
a drawing, so the headless engine refuses with ``capability: "preferences"``.
The whitelist is deliberately short: every key has a type and a range so a
write is refused *by name* before any ActiveX call, and the four ``Files.*``
paths are read-only because rewriting a support path is how an installation
gets broken. ``OpenSave.SaveAsType`` speaks AutoCAD's ``AcSaveAsType`` enum
by name in both directions.
"""
from __future__ import annotations
#: AcSaveAsType: each release adds 12; dwg, dxf (+1), template (+2).
SAVE_AS_TYPES: dict[str, int] = {
"ac2000_dwg": 12,
"ac2000_dxf": 13,
"ac2000_template": 14,
"ac2004_dwg": 24,
"ac2004_dxf": 25,
"ac2004_template": 26,
"ac2007_dwg": 36,
"ac2007_dxf": 37,
"ac2007_template": 38,
"ac2010_dwg": 48,
"ac2010_dxf": 49,
"ac2010_template": 50,
"ac2013_dwg": 60,
"ac2013_dxf": 61,
"ac2013_template": 62,
"ac2018_dwg": 64,
"ac2018_dxf": 65,
"ac2018_template": 66,
}
#: key -> (kind, spec). kind: "bool" | "int" (spec = (lo, hi)) | "str" | "enum" (spec = name -> value)
PREFERENCE_KEYS: dict[str, tuple[str, object]] = {
"OpenSave.SaveAsType": ("enum", SAVE_AS_TYPES),
"OpenSave.AutoSaveInterval": ("int", (0, 600)),
"OpenSave.CreateBackup": ("bool", None),
"OpenSave.IncrementalSavePercent": ("int", (0, 100)),
"Display.CursorSize": ("int", (1, 100)),
"Drafting.AutoSnapMarkerSize": ("int", (1, 20)),
"Drafting.AutoSnapTooltip": ("bool", None),
"Selection.PickBoxSize": ("int", (0, 50)),
"Output.DefaultPlotStyleTable": ("str", None),
"Output.DefaultOutputDevice": ("str", None),
}
#: Every key is ``<Preferences member>.<its member>`` verbatim, so ``split_key``
#: reaches the ActiveX property without a translation table. The spec spelled
#: the third one ``PrintStyleSheetPath``; ``IAcadPreferencesFiles`` has no such
#: member (measured on AutoCAD 2026 / R25.1: ``PrinterStyleSheetPath``), and a
#: misspelling here would make ``preferences_get()`` raise on every live call.
READ_ONLY_KEYS: tuple[str, ...] = (
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath",
)
def split_key(key: str) -> tuple[str, str]:
"""``"OpenSave.AutoSaveInterval"`` → ``("OpenSave", "AutoSaveInterval")``."""
section, _, member = key.partition(".")
return section, member
def known_keys() -> list[str]:
return list(PREFERENCE_KEYS) + list(READ_ONLY_KEYS)
def validate_preference(key: str, value):
"""The value to write, coerced to the key's type — or a refusal naming the key.
Writes nothing; both engines call it before touching anything.
"""
if key in READ_ONLY_KEYS:
raise ValueError(f"preference {key!r} is read-only")
if key not in PREFERENCE_KEYS:
raise ValueError(
f"unknown preference {key!r}; writable keys: {list(PREFERENCE_KEYS)}; "
f"read-only keys: {list(READ_ONLY_KEYS)}"
)
kind, spec = PREFERENCE_KEYS[key]
if kind == "bool":
if not isinstance(value, bool):
raise TypeError(f"preference {key!r} takes a bool, got {value!r}")
return value
if kind == "int":
whole = isinstance(value, int) and not isinstance(value, bool)
whole = whole or (isinstance(value, float) and value.is_integer())
if not whole:
raise TypeError(f"preference {key!r} takes an int, got {value!r}")
number = int(value)
lo, hi = spec
if not lo <= number <= hi:
raise ValueError(f"preference {key!r} must be between {lo} and {hi}, got {number}")
return number
if kind == "str":
if not isinstance(value, str) or not value.strip():
raise TypeError(f"preference {key!r} takes a non-empty string, got {value!r}")
return value.strip()
# enum
if isinstance(value, str):
name = value.strip().lower()
if name in spec:
return spec[name]
raise ValueError(f"preference {key!r} must be one of {sorted(spec)}, got {value!r}")
if isinstance(value, bool) or not isinstance(value, int) or value not in spec.values():
raise ValueError(
f"preference {key!r} must be one of {sorted(spec)} (or their values), got {value!r}"
)
return value
def decode_value(key: str, raw):
"""What the application returned, in the whitelist's vocabulary (enum names, real bools)."""
kind, spec = PREFERENCE_KEYS.get(key, (None, None))
if kind == "enum":
for name, code in spec.items():
if code == raw:
return name
return raw
if kind == "bool":
return bool(raw)
if kind == "int":
return int(raw)
return raw
def describe_preferences() -> list[dict]:
rows = []
for key, (kind, spec) in PREFERENCE_KEYS.items():
rows.append(
{
"key": key,
"kind": kind,
"range": list(spec) if kind == "int" else None,
"enum": sorted(spec) if kind == "enum" else None,
"writable": True,
}
)
for key in READ_ONLY_KEYS:
rows.append({"key": key, "kind": "str", "range": None, "enum": None, "writable": False})
return rows
+82
View File
@@ -0,0 +1,82 @@
"""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
#: The WCS frame — what ``$UCSORG`` / ``$UCSXDIR`` / ``$UCSYDIR`` (and the
#: UCSORG / UCSXDIR / UCSYDIR system variables) hold while no UCS is active.
WORLD_ORIGIN = (0.0, 0.0, 0.0)
WORLD_X_AXIS = (1.0, 0.0, 0.0)
WORLD_Y_AXIS = (0.0, 1.0, 0.0)
WORLD_TOLERANCE = 1e-9
def is_world_axes(origin, x_axis, y_axis, *, tol: float = WORLD_TOLERANCE) -> bool:
"""True when the frame *is* the WCS — the test ``$UCSNAME == ""`` cannot make.
An unnamed UCS (``UCS Origin`` / ``UCS 3P`` without saving — the common
kind) also has an empty name, so "is the drawing in WCS" has to be
answered from the frame itself: AutoCAD's own answer is WORLDUCS, and
this is the same comparison for a header that has no such variable.
"""
for got, want in (
(origin, WORLD_ORIGIN),
(x_axis, WORLD_X_AXIS),
(y_axis, WORLD_Y_AXIS),
):
coords = tuple(float(c) for c in got)
if len(coords) != 3:
return False
if any(abs(a - b) > tol for a, b in zip(coords, want, strict=True)):
return False
return True
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