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
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> 169 is the **registered** count; a default install advertises 164 over `tools/list`,
|
||||
> **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> 179 is the **registered** count; a default install advertises 174 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
## 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 |
|
||||
| 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=…)` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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:
|
||||
"""3D solids are opt-in (ENABLE_3D=true) even on the live COM backend."""
|
||||
if config.settings.enable_3d:
|
||||
@@ -815,6 +823,17 @@ class ComBackend(AutoCADBackend):
|
||||
"solid_3d": _solid_3d_capability(),
|
||||
"lisp": FeatureCapability(True, "sanitized"),
|
||||
"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"),
|
||||
"preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"),
|
||||
"interactive_prompt": FeatureCapability(
|
||||
@@ -1403,6 +1422,414 @@ class ComBackend(AutoCADBackend):
|
||||
await self._ensure_document_state()
|
||||
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) ──────────────────────────────────────────
|
||||
#
|
||||
# VERIFIED against a live AutoCAD 2026 (2026-08-05).
|
||||
|
||||
@@ -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) ───────────────
|
||||
|
||||
|
||||
@@ -1246,6 +1246,21 @@ class EzdxfBackend(AutoCADBackend):
|
||||
),
|
||||
"lisp": FeatureCapability(False, reason="live_com_only"),
|
||||
"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
|
||||
# tests/test_capability_contract.py checks both maps from that
|
||||
# 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)
|
||||
|
||||
# ── 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
#: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM).
|
||||
|
||||
@@ -74,6 +74,8 @@ class ToolAliases:
|
||||
# class, a number and markers; a drafter typing it could mean either.
|
||||
# CLOSE -- the active document (drawing_close) or one named document
|
||||
# (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.
|
||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
@@ -84,11 +86,14 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
"ERASE",
|
||||
"INSERT",
|
||||
"LAYER",
|
||||
"LAYERSTATE",
|
||||
"LAYOUT",
|
||||
"MEASUREGEOM",
|
||||
"PLINE",
|
||||
"QSELECT",
|
||||
"SETVAR",
|
||||
"UCS",
|
||||
"VIEW",
|
||||
"ZOOM",
|
||||
}
|
||||
)
|
||||
@@ -1466,6 +1471,85 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"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
@@ -3,7 +3,7 @@
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 169,
|
||||
"tools": 179,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 20
|
||||
@@ -19,7 +19,7 @@
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
"layers": 14,
|
||||
"layers": 18,
|
||||
"layouts": 12,
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
@@ -28,7 +28,7 @@
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
"view": 4
|
||||
"view": 10
|
||||
},
|
||||
"tools": [
|
||||
{
|
||||
@@ -1611,6 +1611,60 @@
|
||||
"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",
|
||||
"group": "layers",
|
||||
@@ -2251,6 +2305,47 @@
|
||||
"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",
|
||||
"group": "validation",
|
||||
@@ -2265,6 +2360,47 @@
|
||||
"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",
|
||||
"group": "view",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
`pid` rather than under their secondary `query` / `create` tags. `drawing`
|
||||
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.
|
||||
"""
|
||||
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_modification": 16,
|
||||
"entity_query": 9,
|
||||
"layers": 14,
|
||||
"layers": 18,
|
||||
"layouts": 12,
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
@@ -543,6 +546,6 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
"view": 10,
|
||||
}
|
||||
assert sum(sizes.values()) == 169
|
||||
assert sum(sizes.values()) == 179
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user