merge: track-e group V

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 11:18:03 +03:00
co-authored by Claude Opus 5
21 changed files with 5901 additions and 81 deletions
+3 -2
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 185 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -105,6 +105,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| 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), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly) |
| 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)*; live only: launch/attach, a preference whitelist (`OPTIONS`), and operator prompts — pick a point, select on screen, a command-line message — where ESC is `cancelled`, not an error |
| 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 |
+2
View File
@@ -25,6 +25,7 @@ from backends.contracts import (
EntityCreationContract,
EntityModificationContract,
EntityQueryContract,
EnvironmentContract,
GdtContract,
IdentityContract,
LayerContract,
@@ -188,6 +189,7 @@ class AutoCADBackend(
GdtContract,
SettingsContract,
StylesContract,
EnvironmentContract,
):
"""All backends implement this interface.
+870 -7
View File
@@ -111,6 +111,11 @@ def _com_teardown():
pass
# AcCoordinateSystem, as Utility.TranslateCoordinates takes it (From / To).
_AC_WORLD = 0
_AC_UCS = 1
def _apoint(x: float, y: float, z: float = 0.0):
"""Create a VARIANT double-array point for AutoCAD COM."""
return win32com.client.VARIANT(
@@ -135,6 +140,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:
@@ -1421,6 +1434,23 @@ class ComBackend(AutoCADBackend):
"lisp": FeatureCapability(True, "sanitized"),
"registry_sysvar": FeatureCapability(True, "native"),
"dwgprops": FeatureCapability(True, "native"),
"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(
True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
),
},
)
@@ -1958,6 +1988,840 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
# ---------------------------------------------------------------------------
# ── environment (track E) ───────────────────────────────────────────────────
# ---------------------------------------------------------------------------
@staticmethod
def _com_documents(app) -> list:
return [app.Documents.Item(i) for i in range(int(app.Documents.Count))]
@staticmethod
def _com_document_row(doc, active) -> dict:
full_name = str(doc.FullName)
return {
"name": str(doc.Name),
"path": full_name or None,
"active": active is not None
and (str(doc.Name), full_name) == (str(active.Name), str(active.FullName)),
"saved": bool(doc.Saved),
"entity_count": int(doc.ModelSpace.Count),
}
def _com_find_document(self, app, name_or_path: str):
"""Match a key the way ``_resolve_document_key`` does: full path, then name."""
if not isinstance(name_or_path, str):
raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}")
wanted = name_or_path.strip()
if not wanted:
raise ValueError("name_or_path must not be empty")
docs = self._com_documents(app)
try:
as_path = str(Path(wanted).resolve()).lower()
except (OSError, ValueError):
as_path = wanted.lower()
by_path = [
d
for d in docs
if str(d.FullName) and str(Path(str(d.FullName)).resolve()).lower() == as_path
]
by_name = [d for d in docs if str(d.Name).lower() == wanted.lower()]
hits = by_path or by_name
if len(hits) == 1:
return hits[0]
names = [str(d.Name) for d in docs]
if len(hits) > 1:
raise ValueError(f"document {wanted!r} is ambiguous: {names}; pass the full path")
raise ValueError(f"no open document named {wanted!r}; open documents: {names}")
async def document_list(self) -> list[dict]:
def _sync():
app = _acad_app()
active = app.ActiveDocument if int(app.Documents.Count) else None
return [self._com_document_row(doc, active) for doc in self._com_documents(app)]
return await self._run(_sync)
async def document_activate(self, name_or_path: str) -> dict:
def _sync():
app = _acad_app()
previous = str(app.ActiveDocument.Name) if int(app.Documents.Count) else None
doc = self._com_find_document(app, name_or_path)
doc.Activate()
return {
"ok": True,
"active": str(doc.Name),
"previous": previous,
"name": str(doc.Name),
"path": str(doc.FullName) or None,
}
result = await self._run(_sync)
await self._ensure_document_state()
return result
async def document_close(
self, name_or_path: str | None = None, save: bool = False, discard: bool = False
) -> dict:
if save and discard:
raise ValueError("document_close: save and discard are mutually exclusive")
def _sync():
app = _acad_app()
doc = (
_acad_doc() if name_or_path is None else self._com_find_document(app, name_or_path)
)
name = str(doc.Name)
path = str(doc.FullName) or None
dirty = not bool(doc.Saved)
if dirty:
if save:
if not path:
raise ValueError(
f"document_close: {name!r} has never been saved; Close(True) would "
"open AutoCAD's Save dialog and block the COM thread. "
"drawing_save_as(path) first, or pass discard=True"
)
elif not discard:
where = f" to {path}" if path else " (after drawing_save_as)"
raise ValueError(
f"document_close: {name!r} has unsaved changes; pass save=True to "
f"write them{where}, or discard=True to drop them"
)
# A clean or untitled document is never asked to save: Close(True)
# on a clean file is a no-op write, on an untitled one a dialog.
doc.Close(bool(save and dirty and path))
count = int(app.Documents.Count)
return {
"ok": True,
"closed": name,
"path": path,
"saved": bool(save and dirty and path),
"discarded_changes": bool(dirty and not save),
"active": str(app.ActiveDocument.Name) if count else None,
"open_documents": count,
"backend": "com",
}
result = await self._run(_sync)
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.
# And it refuses to make a *frozen* layer current (measured, AutoCAD
# 2026: ``doc.ActiveLayer = <frozen>`` raises 'Error setting active
# layer'), so when the state thaws its own current layer that thaw
# comes before the ActiveLayer write instead of after it in the
# loop. Only a requested property is touched: without "frozen" the
# refusal stands and lands in ``warnings`` like every other one.
if "current" in props and state["current_layer"] not in missing:
target_name = state["current_layer"]
target = doc.Layers.Item(target_name)
try:
if "frozen" in props and not bool(state["layers"][target_name]["frozen"]):
target.Freeze = False
doc.ActiveLayer = target
current = target_name
except Exception as exc: # e.g. the state's current layer is frozen
warnings.append(f"{target_name}: cannot be made current: {exc}")
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_display_aspect(doc) -> float:
"""Width / height of the current viewport's display, from SCREENSIZE."""
size = doc.GetVariable("SCREENSIZE")
sx, sy = float(size[0]), float(size[1])
return sx / sy if sx > 0 and sy > 0 else 1.0
@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()
# "The current view" is VIEWCTR / VIEWSIZE at the display's aspect
# (SCREENSIZE), which is what AutoCAD's own `-VIEW _S` records.
# NOT ``doc.ActiveViewport.Center/Height/Width``: that object does
# not track the display — measured (AutoCAD 2026) it still
# answered the document's initial view after `_.ZOOM _C 5,6 20`,
# after ``app.ZoomCenter`` and after its own values were written.
if args["center"] is None or args["height"] is None or args["width"] is None:
view_center = doc.GetVariable("VIEWCTR")
view_height = float(doc.GetVariable("VIEWSIZE")) or 1.0
aspect = self._com_display_aspect(doc)
else:
view_center, view_height, aspect = None, 1.0, 1.0
cx, cy = args["center"] or (float(view_center[0]), float(view_center[1]))
h = args["height"] or view_height
w = args["width"] or h * aspect
view = self._com_view(doc, clean)
replaced = view is not None
if view is None:
# Measured (AutoCAD 2026): Views.Add creates a plan view —
# Direction (0, 0, 1), Target (0, 0, 0) — so unlike ezdxf's
# VIEW default of (1, 1, 1) nothing has to be forced here.
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)
# NOT ``vport.Center/Height/Width`` + ``doc.ActiveViewport = vport``:
# AutoCAD reconciles a width that does not match the display aspect
# by anchoring the viewport's lower-left corner and widening, so the
# view lands off-centre (measured, AutoCAD 2026, display aspect
# 2.013: a 10x20 view at (5, 6) restored to VIEWCTR (20.134, 6)).
# `-VIEW _R` centres the saved window and fits it — VIEWCTR = the
# saved centre, VIEWSIZE = max(height, width / aspect) — and so
# does ZOOM Window on the same rectangle, without a SendCommand.
(cx, cy), h, w = row["center"], row["height"], row["width"]
_acad_app().ZoomWindow(
_apoint(cx - w / 2.0, cy - h / 2.0), _apoint(cx + w / 2.0, cy + h / 2.0)
)
result = {"ok": True, **row, "applied": "zoom_window", "backend": "com"}
try: # the read-back is what a reviewer measures the tool against
ctr = doc.GetVariable("VIEWCTR")
result["viewctr"] = [float(ctr[0]), float(ctr[1])]
result["viewsize"] = float(doc.GetVariable("VIEWSIZE"))
except Exception as exc:
log.debug("VIEWCTR/VIEWSIZE read-back failed: %s", exc)
return result
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, WORLD_ORIGIN, WORLD_X_AXIS, WORLD_Y_AXIS
def _sync():
# GetVariable is an AcadDocument member; AcadApplication has none
# (measured: ``hasattr(app, "GetVariable") is False``).
doc = _acad_doc()
current = str(doc.GetVariable("UCSNAME") or "")
rows = [
{
"name": WORLD,
"origin": list(WORLD_ORIGIN),
"x_axis": list(WORLD_X_AXIS),
"y_axis": list(WORLD_Y_AXIS),
"current": False,
}
]
collection = doc.UserCoordinateSystems
for index in range(int(collection.Count)):
rows.append(self._com_ucs_row(collection.Item(index), current))
if any(row["current"] for row in rows[1:]):
return rows
# No saved entry is current. UCSNAME is empty for an unnamed UCS
# (UCS Origin / 3P without saving) as well as for WCS — verified
# live (AutoCAD 2026: after `_.UCS _O 10,10,0`, UCSNAME="" and
# WORLDUCS=0). WORLDUCS is AutoCAD's own answer to "is this WCS".
if int(doc.GetVariable("WORLDUCS")) == 1:
rows[0]["current"] = True
else:
rows.append(
{
"name": None,
"origin": [float(c) for c in doc.GetVariable("UCSORG")],
"x_axis": [float(c) for c in doc.GetVariable("UCSXDIR")],
"y_axis": [float(c) for c in doc.GetVariable("UCSYDIR")],
"current": True,
}
)
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():
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. The
# read is a document member, and a failed read REFUSES: a
# swallowed error here once forced cmd_active to 0 and sent
# `_.UCS _W` into whatever prompt was open.
try:
cmd_active = int(doc.GetVariable("CMDACTIVE"))
except Exception as exc:
raise RuntimeError(
"ucs_restore: cannot verify AutoCAD is idle (CMDACTIVE read "
f"failed: {exc}); nothing was sent"
) from exc
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)
# ── application, preferences, operator prompts ────────────────────────────
async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
"""Attach to the running application, or start it; optionally open a file.
Honours ``CAD_PROGID`` on both paths, like ``_acad_app``: ``Dispatch``
launches the application, so falling back to AutoCAD would start the
very product the operator said they were not using. The path is
validated before any COM call.
"""
path = str(validate_path(open_path, allow_write=False)) if open_path else None
def _sync():
progid = config.settings.cad_progid
app = _COM_STATE.get("app")
launched = False
if app is None:
try:
app = win32com.client.GetActiveObject(progid)
except Exception as exc:
log.debug("GetActiveObject(%r) failed, dispatching: %s", progid, exc)
app = win32com.client.Dispatch(progid)
launched = True
_COM_STATE["app"] = app
try:
app.Visible = bool(visible)
except Exception as exc: # some hosts refuse to hide
log.debug("Visible=%s refused: %s", visible, exc)
document = None
if path:
document = str(app.Documents.Open(path).Name)
elif int(app.Documents.Count):
document = str(app.ActiveDocument.Name)
return {
"launched": launched,
"attached": not launched,
"version": str(app.Version),
"document": document,
"visible": bool(visible),
"progid": progid,
"backend": "com",
}
result = await self._run(_sync)
await self._ensure_document_state()
return result
async def preferences_get(self, keys: list[str] | None = None) -> dict:
from engineering.environment.preferences import (
PREFERENCE_KEYS,
READ_ONLY_KEYS,
decode_value,
known_keys,
split_key,
)
if keys is None:
wanted = known_keys()
else:
if isinstance(keys, str) or not isinstance(keys, (list, tuple)):
raise TypeError("preferences_get: keys must be a list of preference names")
unknown = [k for k in keys if k not in PREFERENCE_KEYS and k not in READ_ONLY_KEYS]
if unknown:
raise ValueError(
f"preferences_get: unknown preference keys {unknown}; known: {known_keys()}"
)
wanted = list(keys)
def _sync():
prefs = _acad_app().Preferences
values = {}
for key in wanted:
section, member = split_key(key)
values[key] = decode_value(key, getattr(getattr(prefs, section), member))
return {
"values": values,
"read_only": [k for k in wanted if k in READ_ONLY_KEYS],
"backend": "com",
}
return await self._run(_sync)
async def preferences_set(self, key: str, value) -> dict:
from engineering.environment.preferences import (
decode_value,
split_key,
validate_preference,
)
coerced = validate_preference(key, value) # read-only / unknown / range: refused here
section, member = split_key(key)
def _sync():
target = getattr(_acad_app().Preferences, section)
old = getattr(target, member)
setattr(target, member, coerced)
new = getattr(target, member)
return {
"ok": True,
"key": key,
"old": decode_value(key, old),
"new": decode_value(key, new),
"changed": old != new,
"backend": "com",
}
return await self._run(_sync)
@staticmethod
def _prompt_text(prompt, where: str, max_len: int = 255) -> str:
from engineering.environment.names import validate_name
return validate_name(prompt, what=f"{where}: prompt", max_len=max_len)
@staticmethod
def _com_cancel_reason(exc) -> str | None:
"""The operator's cancel as ActiveX reports it, or None for any other COM error.
ESC in GetPoint / GetEntity / SelectOnScreen raises DISP_E_EXCEPTION
(-2147352567) with AutoCAD's description ("User input is a keyword",
"Function cancelled"). Anything else is a real failure and propagates.
"""
args = getattr(exc, "args", ())
if not args or args[0] != -2147352567:
return None
detail = args[2] if len(args) > 2 and isinstance(args[2], tuple) else ()
description = detail[2] if len(detail) > 2 and detail[2] else "cancelled"
return str(description)
async def _interactive(self, func):
"""Run an operator prompt; a wall-clock timeout is an answer, not a crash.
``_run`` has already rebuilt the STA executor by the time its
"did not respond" error surfaces (the worker is still blocked inside
the prompt); the next call re-attaches lazily, exactly as after any
other timeout. ``COM_CALL_TIMEOUT`` is the budget the operator has.
"""
try:
return await self._run(func)
except RuntimeError as exc:
if str(exc).startswith("AutoCAD did not respond"):
return {
"timed_out": True,
"timeout_s": config.settings.com_call_timeout,
"error": str(exc),
}
raise
async def user_pick_point(self, prompt: str) -> dict:
text = self._prompt_text(prompt, "user_pick_point")
def _sync():
doc = _acad_doc()
try:
point = doc.Utility.GetPoint(pythoncom.Missing, f"\n{text}")
except _COM_ERROR as exc:
reason = self._com_cancel_reason(exc)
if reason is None:
raise
return {"cancelled": True, "reason": reason, "backend": "com"}
return {
"cancelled": False,
"x": float(point[0]),
"y": float(point[1]),
"z": float(point[2]) if len(point) > 2 else 0.0,
"backend": "com",
}
return await self._interactive(_sync)
async def user_select(self, prompt: str, mode: str = "single") -> dict:
if mode not in ("single", "multiple"):
raise ValueError(f"user_select: mode must be 'single' or 'multiple', got {mode!r}")
text = self._prompt_text(prompt, "user_select")
def _sync():
doc = _acad_doc()
if mode == "single":
try:
obj, picked = doc.Utility.GetEntity(f"\n{text}")
except _COM_ERROR as exc:
reason = self._com_cancel_reason(exc)
if reason is None:
raise
return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"}
# MEASURED (AutoCAD 2026): GetEntity's PickedPoint is in the
# *current UCS* — under a UCS at (100,50) rotated 90 deg, a pick
# on a circle at WCS (105,70) came back as (20,-5,0) — whereas
# Utility.GetPoint already answers in WCS. Every coordinate out
# of a tool is WCS, so translate acUCS → acWorld. The point must
# be a VT_ARRAY|VT_R8 VARIANT: the tuple GetEntity hands back is
# refused as "Invalid argument Point".
world = doc.Utility.TranslateCoordinates(
_apoint(picked[0], picked[1], picked[2] if len(picked) > 2 else 0.0),
_AC_UCS,
_AC_WORLD,
False,
)
return {
"cancelled": False,
"mode": "single",
"handles": [str(obj.Handle)],
"picked": [float(world[0]), float(world[1])],
"backend": "com",
}
doc.Utility.Prompt(f"\n{text}\n")
ss = doc.SelectionSets.Add(f"_PICK_{uuid.uuid4().hex[:8]}")
try:
try:
ss.SelectOnScreen()
except _COM_ERROR as exc:
reason = self._com_cancel_reason(exc)
if reason is None:
raise
return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"}
handles = [str(ss.Item(i).Handle) for i in range(int(ss.Count))]
# Enter with nothing selected is an empty answer, not a cancel.
return {
"cancelled": False,
"mode": "multiple",
"handles": handles,
"count": len(handles),
"backend": "com",
}
finally:
try:
ss.Delete()
except Exception as exc:
log.debug("SelectionSet cleanup failed: %s", exc)
return await self._interactive(_sync)
async def system_prompt_message(self, text: str) -> dict:
clean = self._prompt_text(text, "system_prompt_message", max_len=2000)
def _sync():
_acad_doc().Utility.Prompt(f"\n{clean}\n")
return {"ok": True, "text": clean, "backend": "com"}
return await self._run(_sync)
# ── selection filters (M8 / F1) ──────────────────────────────────────────
#
# VERIFIED against a live AutoCAD 2026 (2026-08-05).
@@ -3188,14 +4052,13 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def drawing_close(self, save: bool = True) -> dict:
def _sync():
doc = _acad_doc()
doc.Close(save)
return {"ok": True}
"""The active-document case of ``document_close``.
result = await self._run(_sync)
await self._ensure_document_state()
return result
``Close(True)`` on a drawing that has never been saved opens AutoCAD's
Save dialog and blocks the single STA thread until ``COM_CALL_TIMEOUT``;
that is refused here by name rather than waited out.
"""
return await self.document_close(None, save=save, discard=not save)
async def drawing_undo(self) -> dict:
def _sync():
+2
View File
@@ -16,6 +16,7 @@ from backends.contracts.drawing import DrawingContract
from backends.contracts.entity_creation import EntityCreationContract
from backends.contracts.entity_modification import EntityModificationContract
from backends.contracts.entity_query import EntityQueryContract
from backends.contracts.environment import EnvironmentContract
from backends.contracts.gdt import GdtContract
from backends.contracts.identity import IdentityContract
from backends.contracts.layers import LayerContract
@@ -39,6 +40,7 @@ __all__ = [
"EntityCreationContract",
"EntityModificationContract",
"EntityQueryContract",
"EnvironmentContract",
"GdtContract",
"IdentityContract",
"LayerContract",
+178
View File
@@ -0,0 +1,178 @@
"""Environment: documents, layer states, named views, UCS, application,
preferences, drawing properties and operator prompts (v1.6 track E).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
Ownership: group V owns this module. The ``# ── properties ──`` block is
declared here and *implemented* by group C on both backends; the six
``@capability`` members are implemented on the COM backend only and refuse on
ezdxf with a key both capability maps declare.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from backends.capability import capability
class EnvironmentContract(ABC):
# ── documents ────────────────────────────────────────────────────────────
@abstractmethod
async def document_list(self) -> list[dict]:
"""``[{"name", "path", "active": bool, "saved": bool, "entity_count"}]``."""
...
@abstractmethod
async def document_activate(self, name_or_path: str) -> dict:
"""Make one open document the target of every later call.
→ ``{"ok", "active", "previous"}``."""
...
@abstractmethod
async def document_close(
self, name_or_path: str | None = None, save: bool = False, discard: bool = False
) -> dict:
"""Close one document (default: the active one).
Unsaved and neither ``save`` nor ``discard`` → ``ValueError`` naming the
document; ``save`` on a document that has never been saved →
``ValueError`` (no path to write, and the live engine would open a
modal Save dialog). → ``{"ok", "closed", "saved": bool, "active": name|None}``.
"""
...
# ── 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:
"""COM: ZOOM Window on the saved rectangle (``"applied": "zoom_window"``
plus the read-back ``viewctr`` / ``viewsize``); ezdxf: fits the window
into the ``*Active`` VPORT without touching its aspect ratio,
``"applied": "header_only"`` plus ``vport_height`` / ``aspect_ratio``."""
...
@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".
Exactly one row is current. An *unnamed* current UCS (``UCS Origin`` /
``3P`` without saving) is a trailing row with ``name: None`` carrying
its frame — "world" is current only when the frame is the WCS
(WORLDUCS live, the ``$UCS*`` header headless), never because the
name is empty.
"""
...
@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) ───────────────
@capability( # noqa: B027 — @capability supplies the body
"live_application",
reason="only a live CAD application can be launched or attached",
)
async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
"""→ ``{"launched": bool, "attached": bool, "version", "document"}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"preferences",
reason="AutoCAD preferences live in the running application, not in a file",
)
async def preferences_get(self, keys: list[str] | None = None) -> dict:
"""→ ``{"values": {key: value}, "read_only": [keys]}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"preferences",
reason="AutoCAD preferences live in the running application, not in a file",
)
async def preferences_set(self, key: str, value) -> dict:
"""→ ``{"ok", "key", "old", "new"}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"interactive_prompt",
reason="prompting the operator needs a live AutoCAD session",
)
async def user_pick_point(self, prompt: str) -> dict:
"""→ ``{"cancelled": bool, "x", "y"}`` | ``{"timed_out": True}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"interactive_prompt",
reason="prompting the operator needs a live AutoCAD session",
)
async def user_select(self, prompt: str, mode: str = "single") -> dict:
"""→ ``{"cancelled", "handles": [...]}``; ``mode="single"`` adds ``"picked": [x, y]`` in WCS."""
...
@capability( # noqa: B027 — @capability supplies the body
"interactive_prompt",
reason="prompting the operator needs a live AutoCAD session",
)
async def system_prompt_message(self, text: str) -> dict:
"""→ ``{"ok"}``."""
...
# ── properties (declared here; implemented by group C on both engines) ───
@abstractmethod
async def drawing_properties_get(self) -> dict:
"""→ ``{"summary": {"title","subject","author","keywords","comments"}
(None each headless), "summary_available": bool, "custom": {k: v}}``."""
...
@abstractmethod
async def drawing_properties_set(
self, summary: dict | None = None, custom: dict | None = None
) -> dict:
"""ezdxf: ``summary`` with any non-None field → raise
``UnsupportedCapabilityError("dwgprops")``; ``custom`` → header
custom_vars (replace keys given; value None deletes).
→ ``{"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}``."""
...
+7
View File
@@ -36,11 +36,18 @@ class PremiumContract(ABC):
_plan_spec: PlanSpec | None = None
_preflight_result: Any = None
# The standard `drawing_apply_iso_layers` last bootstrapped on *this*
# drawing (None until it runs) — `_active_layer_set_id` reads it ahead of
# the PlanSpec. Drawing-scoped like the plan: a live backend clears it
# when AutoCAD's active document changes, the headless registry keeps one
# per open document.
_active_layer_set: LayerSetId | None = None
def _reset_document_state(self) -> None:
"""Clear metadata that is valid only for the current drawing."""
self._plan_spec = None
self._preflight_result = None
self._active_layer_set = None
self._gdt_datums_defined = set()
self._gdt_datums_referenced = set()
+1015 -52
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -84,20 +84,30 @@ class ToolAliases:
# (batch_plot); a drafter typing it could mean either.
# SAVEAS -- the same command saves a copy (drawing_save_as) or a template
# (drawing_template_save, its "Drawing Template" file type).
# 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.
# OPTIONS -- one dialog both reads and writes a preference; the server
# splits read (system_preferences_get) and write (system_preferences_set),
# the SETVAR precedent.
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
{
"ARRAY",
"BLOCK",
"CLOSE",
"DIMSTYLE",
"DWGPROPS",
"ERASE",
"INSERT",
"LAYER",
"LAYERSTATE",
"LAYOUT",
"MEASUREGEOM",
"MLEADERSTYLE",
"OPTIONS",
"PAGESETUP",
"PLINE",
"PLOT",
@@ -105,6 +115,8 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"SAVEAS",
"SETVAR",
"STYLE",
"UCS",
"VIEW",
"ZOOM",
}
)
@@ -1672,6 +1684,181 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"which plot styles are installed",
),
),
# ── Environment (track E, group V) ──────────────────────────────────────
"document_activate": ToolAliases(
acad=(),
synonyms=(
"switch drawing",
"switch to the other drawing",
"make this drawing active",
"go to the other open file",
"change the current document",
),
),
"document_close": ToolAliases(
acad=("CLOSE",),
synonyms=(
"close a drawing by name",
"close the other drawing",
"close without saving",
"discard changes and close",
"close all but this one",
),
),
# No "which ... are" / "which one is" phrasings here: the interrogative
# tokens are rare in the corpus, so two of them made this tool outrank
# `block_list` for the holdout "which blocks are defined". "drawings",
# "open", "active" and "documents" carry every document-list question
# to #1 on their own (measured 2026-09-16).
"document_list": ToolAliases(
acad=(),
synonyms=(
"open drawings",
"list documents",
"what files are open",
"the active drawing",
"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",
),
),
"system_launch": ToolAliases(
acad=(),
synonyms=(
"start autocad",
"launch autocad",
"attach to the running autocad",
"is autocad running",
"open autocad with this file",
),
),
"system_preferences_get": ToolAliases(
acad=("OPTIONS",),
synonyms=(
"read a preference",
"autosave interval",
"support file search path",
"what is the pickbox size",
"options dialog value",
),
),
"system_preferences_set": ToolAliases(
acad=("OPTIONS",),
synonyms=(
"change a preference",
"set the autosave interval",
"cursor size",
"pickbox size",
"default plot style table",
),
),
"system_prompt_message": ToolAliases(
acad=(),
synonyms=(
"message on the command line",
"tell the operator",
"print to the command line",
"command line note",
"say something in autocad",
),
),
"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",
),
),
"user_pick_point": ToolAliases(
acad=("ID",),
synonyms=(
"pick a point",
"click a point on screen",
"ask the operator for a point",
"where should this go",
"let me click",
),
),
"user_select": ToolAliases(
acad=("SELECT",),
synonyms=(
"let me select",
"pick objects on screen",
"ask the operator to select",
"select on screen",
"hand me the selection",
),
),
"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",
),
),
}
+262 -5
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 185,
"tools": 204,
"resources": 8,
"prompts": 5,
"groups": 21
@@ -14,22 +14,22 @@
"blocks": 9,
"corner_ops": 4,
"dimensions": 5,
"drawing": 13,
"drawing": 16,
"engineering": 10,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layers": 18,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
"styles": 10,
"system": 8,
"system": 14,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4
"view": 10
},
"tools": [
{
@@ -522,6 +522,46 @@
"readOnlyHint": false
}
},
{
"name": "document_activate",
"group": "drawing",
"cost": "safe",
"summary": "Switch which open document every later tool call targets.",
"tags": [
"drawing"
],
"annotations": {
"title": "Activate Document",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "document_close",
"group": "drawing",
"cost": "destructive",
"summary": "Close one open document by name; never drops unsaved work unless told to.",
"tags": [
"drawing"
],
"annotations": {
"title": "Close Document",
"destructiveHint": true
}
},
{
"name": "document_list",
"group": "drawing",
"cost": "read",
"summary": "List the open documents: which is active, which have unsaved changes.",
"tags": [
"drawing"
],
"annotations": {
"title": "List Documents",
"readOnlyHint": true
}
},
{
"name": "drawing_apply_iso_layers",
"group": "premium",
@@ -1714,6 +1754,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",
@@ -2262,6 +2356,61 @@
"readOnlyHint": true
}
},
{
"name": "system_launch",
"group": "system",
"cost": "safe",
"summary": "Attach to the running AutoCAD or start it, optionally opening a file (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Launch / Attach AutoCAD",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "system_preferences_get",
"group": "system",
"cost": "read",
"summary": "Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).",
"tags": [
"system"
],
"annotations": {
"title": "Get Preferences",
"readOnlyHint": true
}
},
{
"name": "system_preferences_set",
"group": "system",
"cost": "safe",
"summary": "Change one whitelisted AutoCAD preference; reports old and new (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Set Preference",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "system_prompt_message",
"group": "system",
"cost": "safe",
"summary": "Print a message on AutoCAD's command line (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Command-Line Message",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "system_run_command",
"group": "system",
@@ -2482,6 +2631,73 @@
"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": "user_pick_point",
"group": "system",
"cost": "read",
"summary": "Ask the operator to pick a point on screen (live only; blocks until they do).",
"tags": [
"system"
],
"annotations": {
"title": "Ask Operator: Pick Point",
"readOnlyHint": true
}
},
{
"name": "user_select",
"group": "system",
"cost": "read",
"summary": "Ask the operator to select one entity or a set on screen (live only).",
"tags": [
"system"
],
"annotations": {
"title": "Ask Operator: Select",
"readOnlyHint": true
}
},
{
"name": "validation_check",
"group": "validation",
@@ -2496,6 +2712,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",
+23
View File
@@ -0,0 +1,23 @@
"""Environment helpers (v1.6 track E): portable layer states, view and UCS
argument resolution, the preference whitelist. Pure data and validation — no
ezdxf, no COM."""
from .layer_states import ( # noqa: F401
DICT_NAME,
PROPERTIES,
decode_state,
diff_snapshot,
encode_state,
snapshot_from_layers,
validate_properties,
validate_state_name,
)
from .names import validate_name # noqa: F401
from .preferences import ( # noqa: F401
PREFERENCE_KEYS,
READ_ONLY_KEYS,
describe_preferences,
validate_preference,
)
from .ucs import WORLD, resolve_ucs_axes # noqa: F401
from .views import resolve_view_args # noqa: F401
+122
View File
@@ -0,0 +1,122 @@
"""Portable layer states: a layer-table snapshot <-> XRECORD string chunks.
These are the server's own layer states, not AutoCAD's. AutoCAD keeps its
Layer States Manager entries under the layer table's extension dictionary in
a layout neither engine authors; this codec stores a JSON snapshot as
1000-code string chunks in an XRECORD under the drawing's named object
dictionary ``ACADMCP_LAYERSTATES``. They live in the file and travel with it,
and they do **not** appear in AutoCAD's Layer States Manager (LAYERSTATE).
That sentence is repeated in the tool docstrings, in ``system_capabilities``
(feature ``layer_states``) and in the README.
Chunks are ASCII (``ensure_ascii``), so a 255-character boundary can never
split a code point, and 255 is AutoCAD's ``SetXRecordData`` string limit —
ezdxf itself would accept longer tags (measured: a 300-character tag
round-trips through save/reopen unchanged).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from .names import validate_name
if TYPE_CHECKING:
from backends.base import LayerInfo
DICT_NAME = "ACADMCP_LAYERSTATES"
PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current")
LAYER_PROPERTIES = PROPERTIES[:-1]
CHUNK_SIZE = 255
FORMAT_VERSION = 1
def validate_state_name(name) -> str:
return validate_name(name, what="layer state name")
def validate_properties(properties) -> tuple[str, ...]:
"""The subset of ``PROPERTIES`` to apply; ``None`` means all of them."""
if properties is None:
return PROPERTIES
if isinstance(properties, str) or not isinstance(properties, (list, tuple)):
raise TypeError("properties must be a list of property names")
out: list[str] = []
for index, prop in enumerate(properties):
if prop not in PROPERTIES:
raise ValueError(f"properties[{index}]: {prop!r} is not one of {PROPERTIES}")
if prop not in out:
out.append(prop)
if not out:
raise ValueError(f"properties must name at least one of {PROPERTIES}")
return tuple(out)
def snapshot_from_layers(
layers: list[LayerInfo],
current_layer: str,
*,
plot: dict[str, bool] | None = None,
description: str | None = None,
) -> dict:
"""The state dict for a layer table.
``plot`` is per-layer plottability (``LayerInfo`` has no such field; each
engine reads it and passes it in), defaulting to plottable.
"""
plot = plot or {}
return {
"version": FORMAT_VERSION,
"description": description,
"current_layer": str(current_layer),
"layers": {
layer.name: {
"on": bool(layer.is_on),
"frozen": bool(layer.is_frozen),
"locked": bool(layer.is_locked),
"color": int(layer.color),
"linetype": str(layer.linetype),
"lineweight": int(round(float(layer.lineweight))),
"plot": bool(plot.get(layer.name, True)),
}
for layer in layers
},
}
def encode_state(state: dict) -> list[str]:
text = json.dumps(state, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
return [text[i : i + CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE)] or [""]
def decode_state(chunks) -> dict:
text = "".join(str(chunk) for chunk in chunks)
try:
state = json.loads(text)
except ValueError as exc:
raise ValueError(f"layer state payload is not valid JSON: {exc}") from exc
if not isinstance(state, dict) or state.get("version") != FORMAT_VERSION:
found = state.get("version") if isinstance(state, dict) else type(state).__name__
raise ValueError(
f"layer state payload is not a version {FORMAT_VERSION} state (got {found!r})"
)
layers = state.get("layers")
if not isinstance(layers, dict):
raise ValueError("layer state payload has no 'layers' mapping")
for name, props in layers.items():
if not isinstance(props, dict):
raise ValueError(f"layer state entry {name!r} is not a mapping")
missing = [prop for prop in LAYER_PROPERTIES if prop not in props]
if missing:
raise ValueError(f"layer state entry {name!r} lacks {missing}")
state.setdefault("description", None)
state.setdefault("current_layer", "0")
return state
def diff_snapshot(state: dict, layers: list[LayerInfo]) -> tuple[list[str], list[str]]:
"""``(missing, new)``: in the state but not the drawing / in the drawing but not the state."""
in_state = set(state["layers"])
in_drawing = {layer.name for layer in layers}
return sorted(in_state - in_drawing), sorted(in_drawing - in_state)
+17
View File
@@ -0,0 +1,17 @@
"""One rule for the names this package stores in the drawing."""
from __future__ import annotations
def validate_name(name, *, what: str, max_len: int = 255) -> str:
"""A non-empty, stripped string without control characters, else ``ValueError``."""
if not isinstance(name, str):
raise ValueError(f"{what} must be a string, got {type(name).__name__}")
clean = name.strip()
if not clean:
raise ValueError(f"{what} must not be empty")
if len(clean) > max_len:
raise ValueError(f"{what} is longer than {max_len} characters")
if any(ord(ch) < 32 for ch in clean):
raise ValueError(f"{what} must not contain control characters")
return clean
+146
View File
@@ -0,0 +1,146 @@
"""The AutoCAD preferences a model may read and write, as data.
``Preferences.*`` lives in the running application (registry-backed), not in
a drawing, so the headless engine refuses with ``capability: "preferences"``.
The whitelist is deliberately short: every key has a type and a range so a
write is refused *by name* before any ActiveX call, and the four ``Files.*``
paths are read-only because rewriting a support path is how an installation
gets broken. ``OpenSave.SaveAsType`` speaks AutoCAD's ``AcSaveAsType`` enum
by name in both directions.
"""
from __future__ import annotations
#: AcSaveAsType: each release adds 12; dwg, dxf (+1), template (+2).
SAVE_AS_TYPES: dict[str, int] = {
"ac2000_dwg": 12,
"ac2000_dxf": 13,
"ac2000_template": 14,
"ac2004_dwg": 24,
"ac2004_dxf": 25,
"ac2004_template": 26,
"ac2007_dwg": 36,
"ac2007_dxf": 37,
"ac2007_template": 38,
"ac2010_dwg": 48,
"ac2010_dxf": 49,
"ac2010_template": 50,
"ac2013_dwg": 60,
"ac2013_dxf": 61,
"ac2013_template": 62,
"ac2018_dwg": 64,
"ac2018_dxf": 65,
"ac2018_template": 66,
}
#: key -> (kind, spec). kind: "bool" | "int" (spec = (lo, hi)) | "str" | "enum" (spec = name -> value)
PREFERENCE_KEYS: dict[str, tuple[str, object]] = {
"OpenSave.SaveAsType": ("enum", SAVE_AS_TYPES),
"OpenSave.AutoSaveInterval": ("int", (0, 600)),
"OpenSave.CreateBackup": ("bool", None),
"OpenSave.IncrementalSavePercent": ("int", (0, 100)),
"Display.CursorSize": ("int", (1, 100)),
"Drafting.AutoSnapMarkerSize": ("int", (1, 20)),
"Drafting.AutoSnapTooltip": ("bool", None),
"Selection.PickBoxSize": ("int", (0, 50)),
"Output.DefaultPlotStyleTable": ("str", None),
"Output.DefaultOutputDevice": ("str", None),
}
#: Every key is ``<Preferences member>.<its member>`` verbatim, so ``split_key``
#: reaches the ActiveX property without a translation table. The spec spelled
#: the third one ``PrintStyleSheetPath``; ``IAcadPreferencesFiles`` has no such
#: member (measured on AutoCAD 2026 / R25.1: ``PrinterStyleSheetPath``), and a
#: misspelling here would make ``preferences_get()`` raise on every live call.
READ_ONLY_KEYS: tuple[str, ...] = (
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath",
)
def split_key(key: str) -> tuple[str, str]:
"""``"OpenSave.AutoSaveInterval"`` → ``("OpenSave", "AutoSaveInterval")``."""
section, _, member = key.partition(".")
return section, member
def known_keys() -> list[str]:
return list(PREFERENCE_KEYS) + list(READ_ONLY_KEYS)
def validate_preference(key: str, value):
"""The value to write, coerced to the key's type — or a refusal naming the key.
Writes nothing; both engines call it before touching anything.
"""
if key in READ_ONLY_KEYS:
raise ValueError(f"preference {key!r} is read-only")
if key not in PREFERENCE_KEYS:
raise ValueError(
f"unknown preference {key!r}; writable keys: {list(PREFERENCE_KEYS)}; "
f"read-only keys: {list(READ_ONLY_KEYS)}"
)
kind, spec = PREFERENCE_KEYS[key]
if kind == "bool":
if not isinstance(value, bool):
raise TypeError(f"preference {key!r} takes a bool, got {value!r}")
return value
if kind == "int":
whole = isinstance(value, int) and not isinstance(value, bool)
whole = whole or (isinstance(value, float) and value.is_integer())
if not whole:
raise TypeError(f"preference {key!r} takes an int, got {value!r}")
number = int(value)
lo, hi = spec
if not lo <= number <= hi:
raise ValueError(f"preference {key!r} must be between {lo} and {hi}, got {number}")
return number
if kind == "str":
if not isinstance(value, str) or not value.strip():
raise TypeError(f"preference {key!r} takes a non-empty string, got {value!r}")
return value.strip()
# enum
if isinstance(value, str):
name = value.strip().lower()
if name in spec:
return spec[name]
raise ValueError(f"preference {key!r} must be one of {sorted(spec)}, got {value!r}")
if isinstance(value, bool) or not isinstance(value, int) or value not in spec.values():
raise ValueError(
f"preference {key!r} must be one of {sorted(spec)} (or their values), got {value!r}"
)
return value
def decode_value(key: str, raw):
"""What the application returned, in the whitelist's vocabulary (enum names, real bools)."""
kind, spec = PREFERENCE_KEYS.get(key, (None, None))
if kind == "enum":
for name, code in spec.items():
if code == raw:
return name
return raw
if kind == "bool":
return bool(raw)
if kind == "int":
return int(raw)
return raw
def describe_preferences() -> list[dict]:
rows = []
for key, (kind, spec) in PREFERENCE_KEYS.items():
rows.append(
{
"key": key,
"kind": kind,
"range": list(spec) if kind == "int" else None,
"enum": sorted(spec) if kind == "enum" else None,
"writable": True,
}
)
for key in READ_ONLY_KEYS:
rows.append({"key": key, "kind": "str", "range": None, "enum": None, "writable": False})
return rows
+82
View File
@@ -0,0 +1,82 @@
"""UCS axes, validated and normalised before either engine writes.
Tool coordinates stay WCS on both engines (the repository rule): a UCS is
stored and made current for the operator's benefit, and nothing here starts
interpreting inputs in it.
"""
from __future__ import annotations
import math
WORLD = "world"
ORTHO_TOLERANCE_DEG = 1e-3
#: The WCS frame — what ``$UCSORG`` / ``$UCSXDIR`` / ``$UCSYDIR`` (and the
#: UCSORG / UCSXDIR / UCSYDIR system variables) hold while no UCS is active.
WORLD_ORIGIN = (0.0, 0.0, 0.0)
WORLD_X_AXIS = (1.0, 0.0, 0.0)
WORLD_Y_AXIS = (0.0, 1.0, 0.0)
WORLD_TOLERANCE = 1e-9
def is_world_axes(origin, x_axis, y_axis, *, tol: float = WORLD_TOLERANCE) -> bool:
"""True when the frame *is* the WCS — the test ``$UCSNAME == ""`` cannot make.
An unnamed UCS (``UCS Origin`` / ``UCS 3P`` without saving — the common
kind) also has an empty name, so "is the drawing in WCS" has to be
answered from the frame itself: AutoCAD's own answer is WORLDUCS, and
this is the same comparison for a header that has no such variable.
"""
for got, want in (
(origin, WORLD_ORIGIN),
(x_axis, WORLD_X_AXIS),
(y_axis, WORLD_Y_AXIS),
):
coords = tuple(float(c) for c in got)
if len(coords) != 3:
return False
if any(abs(a - b) > tol for a, b in zip(coords, want, strict=True)):
return False
return True
def _vec3(value, where: str) -> tuple[float, float, float]:
if not isinstance(value, (list, tuple)) or len(value) != 3:
raise ValueError(f"{where} must be an [x, y, z] triple")
out = []
for axis, item in zip("xyz", value, strict=True):
if isinstance(item, bool) or not isinstance(item, (int, float)):
raise ValueError(f"{where}.{axis} must be a number, got {item!r}")
number = float(item)
if not math.isfinite(number):
raise ValueError(f"{where}.{axis} must be finite")
out.append(number)
return out[0], out[1], out[2]
def _unit(vector, where: str) -> tuple[float, float, float]:
length = math.sqrt(sum(c * c for c in vector))
if length < 1e-12:
raise ValueError(f"{where} must not be a zero vector")
return vector[0] / length, vector[1] / length, vector[2] / length
def resolve_ucs_axes(origin, x_axis, y_axis) -> dict:
"""Unit axes plus the measured angle; non-orthogonal axes are refused by that angle."""
o = _vec3(origin, "origin")
x = _unit(_vec3(x_axis, "x_axis"), "x_axis")
y = _unit(_vec3(y_axis, "y_axis"), "y_axis")
dot = max(-1.0, min(1.0, sum(a * b for a, b in zip(x, y, strict=True))))
angle = math.degrees(math.acos(dot))
if abs(angle - 90.0) > ORTHO_TOLERANCE_DEG:
raise ValueError(
f"ucs_set: x_axis and y_axis must be perpendicular; measured {angle:.2f}° "
f"between them (tolerance {ORTHO_TOLERANCE_DEG}°)"
)
z = (
x[1] * y[2] - x[2] * y[1],
x[2] * y[0] - x[0] * y[2],
x[0] * y[1] - x[1] * y[0],
)
return {"origin": o, "x_axis": x, "y_axis": y, "z_axis": z, "angle_deg": angle}
+32
View File
@@ -0,0 +1,32 @@
"""Named-view arguments, validated before either engine writes."""
from __future__ import annotations
import math
def _finite(value, where: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{where} must be a number, got {value!r}")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{where} must be finite")
return number
def resolve_view_args(center, height, width) -> dict:
"""``{"center": (x, y) | None, "height": float | None, "width": float | None}``."""
out: dict = {"center": None, "height": None, "width": None}
if center is not None:
if not isinstance(center, (list, tuple)) or len(center) != 2:
raise ValueError("center must be an [x, y] pair")
out["center"] = (_finite(center[0], "center.x"), _finite(center[1], "center.y"))
if height is not None:
out["height"] = _finite(height, "height")
if out["height"] <= 0:
raise ValueError(f"height must be > 0, got {out['height']}")
if width is not None:
out["width"] = _finite(width, "width")
if out["width"] <= 0:
raise ValueError(f"width must be > 0, got {out['width']}")
return out
+451 -8
View File
@@ -1340,9 +1340,12 @@ async def drawing_close(
save: Annotated[bool, "Save the drawing before closing"] = True,
ctx: Context = None,
) -> dict:
"""Close the current drawing. If save is True (default), the drawing is
saved to its current path before closing. After this call, you must call
drawing_new or drawing_open before any other tool."""
"""Close the active document. If save is True (default), the drawing is
saved to its current path before closing; an untitled document is closed
and `warning` says its changes were discarded (headless) or the call is
refused (live, where the alternative is a modal Save dialog). The
active-document case of `document_close`; after the last document is
closed, call drawing_new or drawing_open before any other tool."""
await ctx.info(f"Closing drawing (save={save})")
return await _backend(ctx).drawing_close(save)
@@ -7498,12 +7501,452 @@ async def drawing_template_save(
# ---------------------------------------------------------------------------
# ── SECTION 20: Environment (3 tools) ───────────────────────────────────────
# ── SECTION 20: Environment (22 tools) ──────────────────────────────────────
# ---------------------------------------------------------------------------
# Group C (settings) opens this section with `system_variable_describe` and,
# in Task 16, the two `drawing_properties_*` tools; group V's merge extends the
# header count to the full environment surface (documents, layer states,
# named views, UCS, launch, preferences, interactive prompts).
@cad_tool(
summary="List the open documents: which is active, which have unsaved changes.",
cost="read",
)
@mcp.tool(
annotations={"title": "List Documents", "readOnlyHint": True},
tags={"drawing"},
)
async def document_list(ctx: Context = None) -> dict:
"""Every open document with `name`, `path`, `active`, `saved` and
`entity_count`.
Headless, the server keeps its own registry: `drawing_new` / `drawing_open`
add an entry (`untitled-N` or the file path) instead of replacing the only
one, and `document_activate` chooses which one every later tool targets.
Live, this is AutoCAD's `Documents` collection. No refusals: an empty
backend answers with an empty list. Pack: settings · lean: no.
"""
rows = await _backend(ctx).document_list()
return {
"documents": rows,
"count": len(rows),
"active": next((r["name"] for r in rows if r["active"]), None),
}
@cad_tool(summary="Switch which open document every later tool call targets.", cost="safe")
@mcp.tool(
annotations={"title": "Activate Document", "readOnlyHint": False, "destructiveHint": False},
tags={"drawing"},
)
async def document_activate(
name_or_path: Annotated[
str, "A name from document_list (`gear.dxf`, `untitled-2`) or the full path"
],
ctx: Context = None,
) -> dict:
"""Make one open document the active one; reports `previous`.
Matches the full path first, then the file name; an ambiguous name is
refused with the candidates, an unknown one with the list of open
documents. Headless, a document quarantined after an abandoned call cannot
be switched away from — `drawing_new`, `drawing_open` or closing it are
the ways out. Pack: settings · lean: no.
"""
await ctx.info(f"Activating document {name_or_path!r}")
return await _backend(ctx).document_activate(name_or_path)
@cad_tool(
summary="Close one open document by name; never drops unsaved work unless told to.",
cost="destructive",
)
@mcp.tool(
annotations={"title": "Close Document", "destructiveHint": True},
tags={"drawing"},
)
async def document_close(
name_or_path: Annotated[
str | None, "A name from document_list or the full path; omit for the active document"
] = None,
save: Annotated[bool, "Write unsaved changes to the document's own path first"] = False,
discard: Annotated[bool, "Close even with unsaved changes, dropping them"] = False,
ctx: Context = None,
) -> dict:
"""Close a document and report `saved`, `discarded_changes` and the new `active`.
Refusals, all before anything is closed: unsaved changes with neither
`save` nor `discard`; `save` on a document that has never been saved (no
path to write — live, `Close(True)` would open AutoCAD's Save dialog and
block the COM thread); `save` together with `discard`. Closing the last
document leaves the backend with none open, which every tool already
reports. `drawing_close` is the same operation for the active document
with its 1.4 contract kept. Pack: settings · lean: no.
"""
await ctx.info(
f"Closing document {name_or_path or '(active)'} (save={save}, discard={discard})"
)
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, making a frozen layer current) lands in `warnings` instead
of failing the call; the state's current layer is thawed before it is
made current when `frozen` is being restored, so save → freeze → restore
round-trips. 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: zooms the active viewport to the saved window the way `-VIEW _R`
does — VIEWCTR becomes the saved centre and VIEWSIZE the saved height
(or width / display aspect when the window is wider than the display);
`applied: "zoom_window"` plus the read-back `viewctr` / `viewsize`.
Headless there is no display: the saved window is fitted into the
`*Active` VPORT — the view AutoCAD opens the file on — whose aspect ratio
is left as the file carries it (AutoCAD reconciles a changed aspect by the
viewport's lower-left corner, which shifts the centre); the result says
`applied: "header_only"` with the written `vport_height` and the kept
`aspect_ratio`, 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. `world` is current only when the frame
IS the WCS (WORLDUCS live, the `$UCS*` header headless) — an unnamed UCS
(`UCS Origin` / `3P` without saving) has an empty name too and is
reported as a trailing row with `name: null` and its origin/axes, so
`current` is `null` then and never `world`. Tool coordinates stay WCS on
both engines whatever is current. No refusals. Pack: settings · lean: no."""
rows = await _backend(ctx).ucs_list()
active = next((r for r in rows if r["current"]), None)
return {
"ucs": rows,
"count": len(rows),
"current": active["name"] if active else None,
"current_unnamed": bool(active and active["name"] is 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)
@cad_tool(
summary="Attach to the running AutoCAD or start it, optionally opening a file (live only).",
cost="safe",
)
@mcp.tool(
annotations={
"title": "Launch / Attach AutoCAD",
"readOnlyHint": False,
"destructiveHint": False,
},
tags={"system"},
)
async def system_launch(
visible: Annotated[bool, "Show the application window"] = True,
open_path: Annotated[str | None, "A .dwg/.dxf to open after attaching"] = None,
ctx: Context = None,
) -> dict:
"""Connect to the application named by `CAD_PROGID` (attach if running,
launch otherwise) and report `launched`, `attached`, `version` and the
active `document`. Refusals: headless, `capability: "live_application"`
(there is no application to launch); an `open_path` that fails path
validation. Pack: settings · lean: no.
"""
if open_path is not None:
open_path = str(validate_path(open_path, allow_write=False))
await ctx.info(f"Launching or attaching to {config.settings.cad_progid}")
return await _backend(ctx).system_launch(visible, open_path)
@cad_tool(
summary="Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).",
cost="read",
)
@mcp.tool(
annotations={"title": "Get Preferences", "readOnlyHint": True},
tags={"system"},
)
async def system_preferences_get(
keys: Annotated[
list[str] | None,
"Subset of OpenSave.SaveAsType, OpenSave.AutoSaveInterval, OpenSave.CreateBackup, "
"OpenSave.IncrementalSavePercent, Display.CursorSize, Drafting.AutoSnapMarkerSize, "
"Drafting.AutoSnapTooltip, Selection.PickBoxSize, Output.DefaultPlotStyleTable, "
"Output.DefaultOutputDevice, Files.SupportPath, Files.TemplateDwgPath, "
"Files.PrinterStyleSheetPath, Files.PrinterConfigPath; omit for all",
] = None,
ctx: Context = None,
) -> dict:
"""The whitelisted `Preferences.*` values, enum values as names
(`SaveAsType: "ac2018_dwg"`), with `read_only` naming the four `Files.*`
paths. Refusals: headless, `capability: "preferences"` (they live in the
running application, not in a file); a key outside the whitelist.
Pack: settings · lean: no.
"""
return await _backend(ctx).preferences_get(keys)
@cad_tool(
summary="Change one whitelisted AutoCAD preference; reports old and new (live only).",
cost="safe",
)
@mcp.tool(
annotations={"title": "Set Preference", "readOnlyHint": False, "destructiveHint": False},
tags={"system"},
)
async def system_preferences_set(
key: Annotated[str, "A writable key from system_preferences_get"],
value: Annotated[Any, "New value: bool, int within the key's range, string, or an enum name"],
ctx: Context = None,
) -> dict:
"""Write one preference and report `old`, `new` and `changed`.
Refusals, all before any write: a read-only key (`Files.*`), an unknown
key, a value of the wrong type, an int outside the authored range
(`AutoSaveInterval` 0–600 min, `CursorSize` 1–100, `PickBoxSize` 0–50, …),
an unknown `SaveAsType` name; headless, `capability: "preferences"`.
Pack: settings · lean: no.
"""
await ctx.info(f"Setting preference {key} = {value!r}")
return await _backend(ctx).preferences_set(key, value)
@cad_tool(
summary="Ask the operator to pick a point on screen (live only; blocks until they do).",
cost="read",
)
@mcp.tool(
annotations={"title": "Ask Operator: Pick Point", "readOnlyHint": True},
tags={"system"},
)
async def user_pick_point(
prompt: Annotated[str, "Shown on the command line, e.g. 'Pick the base point'"],
ctx: Context = None,
) -> dict:
"""`Utility.GetPoint`: returns the WCS `x`, `y` (`z`) the operator clicks.
ESC is an answer, not an error: `cancelled: true` with AutoCAD's reason.
Waiting longer than `COM_CALL_TIMEOUT` returns `timed_out: true` (the
prompt is abandoned on AutoCAD's side; press ESC there). Refusals: an
empty prompt; headless, `capability: "interactive_prompt"` (no operator).
Pack: settings · lean: no.
"""
await ctx.info(f"Asking the operator to pick a point: {prompt}")
return await _backend(ctx).user_pick_point(prompt)
@cad_tool(
summary="Ask the operator to select one entity or a set on screen (live only).",
cost="read",
)
@mcp.tool(
annotations={"title": "Ask Operator: Select", "readOnlyHint": True},
tags={"system"},
)
async def user_select(
prompt: Annotated[str, "Shown on the command line"],
mode: Annotated[str, "single (GetEntity) | multiple (SelectOnScreen)"] = "single",
ctx: Context = None,
) -> dict:
"""Returns `handles` the operator picked — one with `mode="single"` (plus
the `picked` point `[x, y]` in **WCS** — AutoCAD reports it in the current
UCS and the tool translates, so a UCS made current by `ucs_set` never
shifts it), any number with `mode="multiple"` (Enter with nothing
selected is `count: 0`, not a cancel). ESC → `cancelled: true`; a wait
longer than `COM_CALL_TIMEOUT` → `timed_out: true`. Refusals: a mode
outside the two, an empty prompt; headless, `capability:
"interactive_prompt"`. Pack: settings · lean: no.
"""
await ctx.info(f"Asking the operator to select ({mode}): {prompt}")
return await _backend(ctx).user_select(prompt, mode)
@cad_tool(summary="Print a message on AutoCAD's command line (live only).", cost="safe")
@mcp.tool(
annotations={"title": "Command-Line Message", "readOnlyHint": False, "destructiveHint": False},
tags={"system"},
)
async def system_prompt_message(
text: Annotated[str, "The message; one line is best"],
ctx: Context = None,
) -> dict:
"""`Utility.Prompt`: tell the operator something where they are looking.
Nothing in the drawing changes. Refusals: an empty message; headless,
`capability: "interactive_prompt"`. Pack: settings · lean: no."""
return await _backend(ctx).system_prompt_message(text)
@cad_tool(
+817
View File
@@ -0,0 +1,817 @@
"""Multi-document on both engines.
The headless backend used to hold exactly one ``Drawing``: ``drawing_new``
replaced it and silently discarded whatever was there. It now keeps a
registry keyed by path (or ``untitled-N``); every later tool call targets the
active entry, and ``drawing_close`` is the active-document case of
``document_close``.
Two rules are pinned here. ``document_close`` never drops unsaved work
without being told to (``discard=True``), and it refuses ``save=True`` on a
document that has nowhere to write (an untitled one) instead of guessing a
path — on the live engine ``Close(True)`` on an untitled drawing opens
AutoCAD's Save dialog and blocks the COM thread until the timeout, so the
refusal is what keeps the server answerable. ``drawing_close`` keeps its 1.4
contract (``save=True`` saves when it can; an untitled document is closed and
the result says the changes were discarded) because two shipped tests rely on
it; the strict path is the new tool.
"""
from __future__ import annotations
import os
import tempfile
import types
import pytest
from fastmcp import Client
import config
import server
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
# ── headless engine ─────────────────────────────────────────────────────────
async def test_fixture_document_is_registered_and_active(backend):
rows = await backend.document_list()
assert rows == [
{
"name": "untitled-1",
"path": None,
"key": "untitled-1",
"active": True,
"saved": True,
"entity_count": 0,
}
]
async def test_two_documents_hold_separate_entities(backend):
first = (await backend.document_list())[0]["name"]
second = (await backend.drawing_new())["document"]
assert second == "untitled-2"
await backend.entity_create_circle(0, 0, 5)
rows = {r["name"]: r for r in await backend.document_list()}
assert rows[second]["entity_count"] == 1 and rows[second]["active"] is True
assert rows[first]["entity_count"] == 0 and rows[first]["active"] is False
switched = await backend.document_activate(first)
assert switched == {
"ok": True,
"active": first,
"previous": second,
"name": first,
"path": None,
}
assert (await backend.drawing_info()).entity_count == 0
await backend.entity_create_line(0, 0, 1, 1)
await backend.document_activate(second)
assert (await backend.drawing_info()).entity_count == 1
rows = {r["name"]: r for r in await backend.document_list()}
assert rows[first]["entity_count"] == 1 and rows[first]["saved"] is False
async def test_per_document_state_follows_the_active_entry(backend):
"""Layer, current space, dirty flag and undo stack are per document."""
await backend.layer_create("A-ONLY")
await backend.layer_set_current("A-ONLY")
await backend.drawing_new()
assert backend._current_layer == "0"
assert "A-ONLY" not in [lyr.name for lyr in await backend.layer_list()]
await backend.document_activate("untitled-1")
assert backend._current_layer == "A-ONLY"
assert backend._dirty is True
await backend.document_activate("untitled-2")
assert backend._dirty is False
async def test_active_layer_set_is_per_document(backend):
"""The standard ``drawing_apply_iso_layers`` bootstrapped is per drawing.
It drives ``_role_layer`` — where ``dimension_auto`` and
``construction_xline`` file their output — and the critique that checks
them. Held on the backend it bled across documents: an iso13567 sheet
next to a mech one pushed the mech drawing's dimensions onto
``M-DIMEN-T-N`` (created on demand), silently, and ``drawing_critique``
could not see it because the same stale set drove the critique.
"""
await backend.drawing_apply_iso_layers("iso13567")
assert backend._role_layer("dim") == "M-DIMEN-T-N"
second = (await backend.drawing_new())["document"]
assert backend._active_layer_set is None, "a fresh drawing has no explicit set"
assert backend._role_layer("dim") == "DIM"
line = await backend.entity_create_line(0, 0, 50, 0)
dims = await backend.dimension_auto([line.handle], style="chain")
assert dims[0].layer == "DIM"
assert "M-DIMEN-T-N" not in [lyr.name for lyr in await backend.layer_list()]
# Bidirectional: switching back restores the first document's set …
await backend.document_activate("untitled-1")
assert backend._active_layer_set == "iso13567"
assert backend._role_layer("construction") == "M-CONST-E-N"
# … and a set applied on B while A is open does not reach A.
await backend.document_activate(second)
await backend.drawing_apply_iso_layers("pid")
assert backend._role_layer("dim") == "DIM"
await backend.document_activate("untitled-1")
assert backend._active_layer_set == "iso13567"
async def test_opening_a_file_twice_reloads_it_instead_of_registering_twice(backend, tmp_path):
path = tmp_path / "part.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 1) # unsaved edit
reopened = await backend.drawing_open(str(path))
assert reopened["reloaded"] is True
assert reopened["open_documents"] == 1
assert (await backend.drawing_info()).entity_count == 0, "reload reads the disk, not memory"
async def test_saving_rekeys_an_untitled_document_to_its_file(backend, tmp_path):
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
rows = await backend.document_list()
assert rows[0]["key"] == os.path.abspath(str(path))
assert rows[0]["name"] == "gear.dxf" and rows[0]["path"] == str(path)
assert backend._active_key == os.path.abspath(str(path))
async def test_save_refuses_a_path_that_another_open_document_holds(backend, tmp_path):
"""One entry per file: SAVEAS onto an open drawing is refused, as AutoCAD does.
Without the refusal the registry held two entries for one path, the
resolver's exact-key shortcut picked the stale one on close, and the file
ended up holding the *other* document's bytes while ``saved`` stayed True.
"""
import ezdxf
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.entity_create_line(0, 0, 1, 1) # A: dirty, on disk empty
second = (await backend.drawing_new())["document"]
await backend.entity_create_circle(0, 0, 5)
before = path.read_bytes()
for call in (backend.drawing_save_as, backend.drawing_save):
with pytest.raises(ValueError, match="gear.dxf.*already open.*document_close"):
await call(str(path))
assert path.read_bytes() == before, "a refusal writes nothing"
rows = {r["key"]: r for r in await backend.document_list()}
assert set(rows) == {key, second}, "a refusal re-keys nothing"
assert rows[second]["path"] is None and rows[second]["saved"] is False
assert rows[key]["saved"] is False
# The rejected document is untouched and can still be saved elsewhere.
other = tmp_path / "other.dxf"
await backend.drawing_save_as(str(other))
assert [e.dxftype() for e in ezdxf.readfile(str(other)).modelspace()] == ["CIRCLE"]
# The by-name lookup is unambiguous and closes the entry that owns the file.
await backend.entity_create_line(2, 2, 3, 3)
closed = await backend.document_close("gear.dxf", save=True)
assert closed["closed"] == key and closed["saved"] is True
assert [e.dxftype() for e in ezdxf.readfile(str(path)).modelspace()] == ["LINE"]
async def test_save_to_the_document_own_path_is_not_a_collision(backend, tmp_path):
path = tmp_path / "self.dxf"
await backend.drawing_save(str(path))
await backend.drawing_new()
await backend.document_activate("self.dxf")
await backend.entity_create_circle(0, 0, 1)
assert (await backend.drawing_save())["ok"] is True
await backend.entity_create_circle(0, 0, 2)
assert (await backend.drawing_save(str(path)))["ok"] is True
await backend.entity_create_circle(0, 0, 3)
# A spelling that differs from the key still names the same file.
spelled = str(tmp_path / "sub" / ".." / "self.dxf")
assert (await backend.drawing_save_as(spelled))["ok"] is True
assert len(await backend.document_list()) == 2
assert backend._active_key == os.path.abspath(str(path))
async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend, tmp_path):
"""The save refusal keeps the registry one-entry-per-file; if that invariant
is ever broken anyway, every lookup of the pair refuses instead of guessing."""
path = tmp_path / "dup.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
second = (await backend.drawing_new())["document"]
with pytest.raises(RuntimeError, match="already holds"):
backend._doc_path = str(path) # the setter enforces the invariant
backend._docs[second].path = str(path) # corrupt the registry behind its back
for wanted in (key, second, "dup.dxf", str(path)):
with pytest.raises(ValueError, match="share the file"):
backend._resolve_document_key(wanted)
with pytest.raises(ValueError, match="share the file"):
await backend.document_close(key, save=True)
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
def _filesystem_folds_case() -> bool:
"""Whether the temp filesystem answers to a case variant of a file's name.
Probed on disk rather than read off ``os.path.normcase``: the platform's
``normcase`` is the identity on macOS while the default APFS volume folds
case, so a ``normcase`` guard skipped every case-variant test exactly
where the old spelling-based registry identity was wrong.
"""
with tempfile.TemporaryDirectory() as folder:
probe = os.path.join(folder, "Probe.dxf")
with open(probe, "w", encoding="utf-8"):
pass
return os.path.exists(os.path.join(folder, "probe.dxf"))
# Two spellings of one file only exist where the filesystem folds case.
_case_folding_fs = pytest.mark.skipif(
not _filesystem_folds_case(), reason="filesystem is case-sensitive"
)
def _hard_link(target: str, link: str) -> None:
try:
os.link(target, link)
except (OSError, NotImplementedError, AttributeError) as exc:
pytest.skip(f"hard links unavailable here: {exc}")
def _junction(target_dir: str, link_dir: str) -> None:
"""A second name for a directory: an NTFS junction, or a symlink elsewhere."""
if os.name == "nt":
try:
import _winapi
_winapi.CreateJunction(target_dir, link_dir)
return
except (ImportError, AttributeError, OSError) as exc:
pytest.skip(f"NTFS junctions unavailable here: {exc}")
try:
os.symlink(target_dir, link_dir, target_is_directory=True)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"directory symlinks unavailable here: {exc}")
def _modelspace_types(path) -> list[str]:
import ezdxf
return [e.dxftype() for e in ezdxf.readfile(str(path)).modelspace()]
@_case_folding_fs
async def test_opening_a_case_variant_of_an_open_file_reloads_it(backend, tmp_path):
"""Registration keys by file identity, not by spelling.
``drawing_open`` of an already-open file spelled with another case used to
register a *second* entry (key compared as plain strings), and from there
every lookup of either entry was refused as ambiguous.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 5) # unsaved edit
reopened = await backend.drawing_open(str(path).lower())
assert reopened["reloaded"] is True
assert reopened["open_documents"] == 1
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["entity_count"] == 0, "reload reads the disk"
assert (await backend.document_activate(str(path)))["ok"] is True
@_case_folding_fs
async def test_resolver_accepts_any_spelling_of_a_registered_path(backend, tmp_path):
"""``document_activate`` / ``document_close`` compare paths as the OS does.
The live engine lowercases before comparing (``_com_find_document``); a
lowercase drive letter or a case variant used to be refused headless as
'no open document named ...' - a dual-engine parity break.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.drawing_new()
spelled = str(path)
for variant in (
spelled.lower(),
spelled[0].lower() + spelled[1:],
spelled[0].upper() + spelled[1:],
spelled.upper(),
):
await backend.document_activate("untitled-2")
assert (await backend.document_activate(variant))["active"] == key, variant
assert len(await backend.document_list()) == 2
closed = await backend.document_close(spelled.lower())
assert closed["closed"] == key and closed["open_documents"] == 1
@_case_folding_fs
async def test_saving_to_a_case_variant_of_the_own_path_keeps_one_entry(backend, tmp_path):
path = tmp_path / "Self.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.entity_create_circle(0, 0, 1)
assert (await backend.drawing_save_as(str(path).lower()))["ok"] is True
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["saved"] is True
assert backend._active_key == key, "the same file keeps its key"
assert (await backend.document_activate(key))["ok"] is True
async def test_a_hard_link_to_an_open_file_reloads_instead_of_registering_twice(backend, tmp_path):
"""Registry identity is the file (``st_dev``/``st_ino``), not the spelling.
``normcase(abspath)`` folded case and separators only, so a hard link -
which ``Path.resolve()`` cannot fold either - registered a second entry:
both rows said ``saved: True``, a save from either overwrote the other's
bytes, and closing the 'clean' one dropped its work as ``discarded_changes:
False``.
"""
target = tmp_path / "gear.dxf"
link = tmp_path / "gear_link.dxf"
await backend.drawing_save(str(target))
_hard_link(str(target), str(link))
assert os.path.samefile(target, link)
await backend.entity_create_line(0, 0, 1, 1) # unsaved edit
reopened = await backend.drawing_open(str(link))
assert reopened["reloaded"] is True and reopened["open_documents"] == 1
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["entity_count"] == 0, "the reload reads the disk"
key = rows[0]["key"]
await backend.entity_create_circle(0, 0, 5)
await backend.drawing_save()
assert _modelspace_types(target) == ["CIRCLE"], "one entry, one file, one save"
assert (await backend.document_activate(str(target)))["active"] == key
assert (await backend.document_activate(str(link)))["active"] == key
async def test_a_junction_to_an_open_file_reloads_instead_of_registering_twice(backend, tmp_path):
"""Same rule through a directory junction (``mklink /J``), which
``os.path.abspath`` leaves untouched - the route in-process callers take."""
real_dir = tmp_path / "real"
real_dir.mkdir()
target = real_dir / "gear.dxf"
await backend.drawing_save(str(target))
_junction(str(real_dir), str(tmp_path / "via_junction"))
aliased = tmp_path / "via_junction" / "gear.dxf"
assert os.path.samefile(target, aliased)
await backend.entity_create_line(0, 0, 1, 1)
reopened = await backend.drawing_open(str(aliased))
assert reopened["reloaded"] is True and reopened["open_documents"] == 1
await backend.entity_create_circle(0, 0, 5)
await backend.drawing_save()
assert _modelspace_types(target) == ["CIRCLE"]
await backend.drawing_new()
with pytest.raises(ValueError, match="already open as document"):
await backend.drawing_save_as(str(aliased))
assert _modelspace_types(target) == ["CIRCLE"], "a refusal writes nothing"
closed = await backend.document_close(str(aliased))
assert closed["discarded_changes"] is False and closed["open_documents"] == 1
@_case_folding_fs
async def test_case_variant_identity_does_not_depend_on_normcase(backend, tmp_path, monkeypatch):
"""macOS: ``os.path.normcase`` is the identity, the default APFS volume
folds case. Reproduced here by giving ``normcase`` its POSIX definition -
the registry must still see one file, because it asks the disk."""
monkeypatch.setattr(os.path, "normcase", lambda s: s)
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_line(0, 0, 1, 1)
reopened = await backend.drawing_open(str(path).lower())
assert reopened["reloaded"] is True and reopened["open_documents"] == 1
await backend.entity_create_circle(0, 0, 5)
await backend.drawing_save()
assert _modelspace_types(path) == ["CIRCLE"]
assert len(await backend.document_list()) == 1
async def test_save_target_that_does_not_exist_yet_is_compared_by_spelling(backend, tmp_path):
"""A path with no file behind it cannot be stat'ed; it is still one file
with the spelling that another entry holds (an owner whose file was
removed from disk), and a save onto it is refused."""
path = tmp_path / "gone.dxf"
await backend.drawing_save(str(path))
path.unlink()
await backend.drawing_new()
with pytest.raises(ValueError, match="already open as document"):
await backend.drawing_save_as(str(path))
assert not path.exists(), "a refusal writes nothing"
assert (await backend.drawing_save_as(str(tmp_path / "other.dxf")))["ok"] is True
async def test_drawing_close_with_save_refuses_a_shared_path(backend, tmp_path):
"""``drawing_close`` bypasses the resolver, so the close itself must check.
With two entries on one file, ``drawing_close(save=True)`` wrote the active
entry's bytes over the file the other entry holds and reported
``saved: True`` - the surviving row then claimed the disk held its content.
"""
path = tmp_path / "dup.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_line(0, 0, 1, 1) # the owner is dirty
await backend.drawing_new()
await backend.entity_create_circle(0, 0, 5)
backend._docs[backend._active_key].path = str(path) # corrupt the registry behind its back
before = path.read_bytes()
with pytest.raises(ValueError, match="share the file"):
await backend.drawing_close(save=True)
assert path.read_bytes() == before, "a refusal writes nothing"
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
async def test_activate_by_basename_full_path_or_key(backend, tmp_path):
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.drawing_new()
assert (await backend.document_activate("gear.dxf"))["active"] == key
await backend.document_activate("untitled-2")
assert (await backend.document_activate(str(path)))["name"] == "gear.dxf"
await backend.document_activate("untitled-2")
assert (await backend.document_activate(key))["previous"] == "untitled-2"
with pytest.raises(ValueError, match="no open document named 'nope'"):
await backend.document_activate("nope")
with pytest.raises(TypeError, match="name_or_path"):
await backend.document_activate(None)
async def test_close_unsaved_refuses_unless_discard(backend):
await backend.entity_create_line(0, 0, 1, 1)
with pytest.raises(ValueError, match="untitled-1.*unsaved changes"):
await backend.document_close("untitled-1")
assert len(await backend.document_list()) == 1, "a refusal closes nothing"
with pytest.raises(ValueError, match="never been saved"):
await backend.document_close("untitled-1", save=True)
with pytest.raises(ValueError, match="mutually exclusive"):
await backend.document_close("untitled-1", save=True, discard=True)
result = await backend.document_close("untitled-1", discard=True)
assert result == {
"ok": True,
"closed": "untitled-1",
"saved": False,
"discarded_changes": True,
"active": None,
"open_documents": 0,
}
async def _create_layout(backend):
assert (await backend.layout_create("A3-Sheet"))["ok"] is True
async def _set_current_layout(backend):
assert (await backend.layout_set_current("Layout1"))["ok"] is True
async def _create_viewport(backend):
result = await backend.viewport_create("Layout1", 150, 100, 200, 150, 0, 0, 1.0)
assert result["ok"] is True
def _layout_names(path):
import ezdxf
return set(ezdxf.readfile(str(path)).layouts.names())
def _layout1_has_viewport(path):
import ezdxf
return any(
e.dxftype() == "VIEWPORT" and e.dxf.id != 1
for e in ezdxf.readfile(str(path)).layouts.get("Layout1")
)
def _tilemode(path):
import ezdxf
return int(ezdxf.readfile(str(path)).header.get("$TILEMODE", 1))
@pytest.mark.parametrize(
("mutate", "changed_on_disk"),
[
(_create_layout, lambda p: "A3-Sheet" in _layout_names(p)),
(_set_current_layout, lambda p: _tilemode(p) == 0),
(_create_viewport, _layout1_has_viewport),
],
ids=["layout_create", "layout_set_current", "viewport_create"],
)
async def test_layout_and_viewport_mutators_mark_the_document_unsaved(
monkeypatch, tmp_path, mutate, changed_on_disk
):
"""The close refusal is only as good as the dirty flag behind it.
``layout_create``, ``layout_set_current`` and ``viewport_create`` change
the file (a new layout tab, ``$TILEMODE`` plus the active layout, a
VIEWPORT) but used to leave ``_DocState.dirty`` untouched, so
``document_list`` reported ``saved: True``, ``document_close`` closed
without refusing and reported ``discarded_changes: False`` while the work
was gone — and the live engine, whose ``Saved`` is AutoCAD's DBMOD,
refused the same sequence. Marking dirty also gives each an undo step,
which is why undo is switched on here before the document exists (the
S0 snapshot is seeded by ``drawing_new`` only when history is enabled).
"""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
backend = EzdxfBackend()
await backend.connect()
try:
await backend.drawing_new()
path = tmp_path / "sheet.dxf"
await backend.drawing_save(str(path))
assert (await backend.document_list())[0]["saved"] is True
assert not changed_on_disk(path)
await mutate(backend)
assert (await backend.document_list())[0]["saved"] is False
with pytest.raises(ValueError, match="unsaved changes"):
await backend.document_close(None)
assert len(await backend.document_list()) == 1, "a refusal closes nothing"
# Every mutator pushes an undo snapshot; these must too.
assert (await backend.drawing_undo())["undo_depth"] == 0
assert (await backend.drawing_redo())["ok"] is True
result = await backend.document_close(None, save=True)
assert result["saved"] is True and result["discarded_changes"] is False
assert changed_on_disk(path)
finally:
await backend.disconnect()
async def test_close_with_save_writes_the_file(backend, tmp_path):
path = tmp_path / "saved.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 2)
before = path.read_bytes()
result = await backend.document_close(None, save=True)
assert result["saved"] is True and result["discarded_changes"] is False
assert path.read_bytes() != before
async def test_closing_the_last_document_leaves_the_no_document_state(backend):
await backend.document_close("untitled-1")
assert await backend.document_list() == []
status = await backend.system_status()
assert status["has_document"] is False
assert status["open_documents"] == 0 and status["active_document"] is None
with pytest.raises(RuntimeError, match="No document open"):
await backend.drawing_info()
# ...and the backend recovers exactly as before.
assert (await backend.drawing_new())["document"] == "untitled-2"
async def test_closing_the_active_document_activates_the_most_recently_used(backend):
await backend.drawing_new() # untitled-2
await backend.drawing_new() # untitled-3
await backend.document_activate("untitled-1")
await backend.document_activate("untitled-3")
result = await backend.document_close("untitled-3")
assert result["active"] == "untitled-1", "most recently activated, not most recently created"
async def test_closing_a_background_document_keeps_the_active_one(backend):
await backend.drawing_new() # untitled-2, active
await backend.entity_create_circle(0, 0, 1)
result = await backend.document_close("untitled-1")
assert result["active"] == "untitled-2"
assert (await backend.drawing_info()).entity_count == 1
async def test_drawing_close_keeps_its_legacy_contract(backend, tmp_path):
await backend.entity_create_line(0, 0, 1, 1)
closed = await backend.drawing_close(save=True)
assert closed["ok"] is True and closed["saved"] is False
assert closed["discarded_changes"] is True
assert "never been saved" in closed["warning"]
assert await backend.document_list() == []
await backend.drawing_new()
path = tmp_path / "keep.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 3)
before = path.read_bytes()
closed = await backend.drawing_close(save=True)
assert closed["saved"] is True and "warning" not in closed
assert path.read_bytes() != before
async def test_disconnect_drops_every_document_and_its_snapshots(monkeypatch):
import config
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 3)
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
await backend.entity_create_line(0, 0, 1, 1)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 2, 2)
snapshots = [p for state in backend._docs.values() for p in state.undo_stack]
assert len(snapshots) == 4 and all(p.exists() for p in snapshots)
await backend.disconnect()
assert backend._docs == {} and backend._active_key is None
assert not any(p.exists() for p in snapshots)
async def test_document_list_tool_over_the_wire(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_new", {"bootstrap": False})
payload = (await client.call_tool("document_list", {})).structured_content
assert payload["count"] == 2 and payload["active"] == "untitled-2"
closed = (
await client.call_tool("document_close", {"name_or_path": "untitled-1"})
).structured_content
assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2"
@_case_folding_fs
async def test_document_activate_accepts_a_lowercase_drive_over_the_wire(monkeypatch, tmp_path):
"""The wire canonicalises ``drawing_open`` paths (``validate_path`` resolves
them to disk casing) but not ``document_activate`` / ``document_close``
names, so a client spelling the drive letter in lowercase used to be
refused headless while the live engine accepted it."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
path = tmp_path / "Gear.dxf"
spelled = str(path)
lowered_drive = spelled[0].lower() + spelled[1:]
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_save", {"path": spelled})
await client.call_tool("drawing_new", {"bootstrap": False})
activated = (
await client.call_tool("document_activate", {"name_or_path": lowered_drive})
).structured_content
assert activated["name"] == "Gear.dxf" and activated["previous"] == "untitled-2"
closed = (
await client.call_tool("document_close", {"name_or_path": spelled.lower()})
).structured_content
assert closed["closed"] == os.path.abspath(spelled) and closed["open_documents"] == 1
async def test_hard_link_over_the_wire_is_one_document(monkeypatch, tmp_path):
"""Route (a) of the review finding: ``validate_path().resolve()`` does not
fold a hard link, so the wire hands the backend a second name for the
open file. One entry, one save, and the disk holds the last edit."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
target = tmp_path / "gear.dxf"
link = tmp_path / "gear_link.dxf"
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_save", {"path": str(target)})
_hard_link(str(target), str(link))
await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 1, "y2": 1})
opened = (await client.call_tool("drawing_open", {"path": str(link)})).structured_content
assert opened["reloaded"] is True and opened["open_documents"] == 1
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 5})
await client.call_tool("drawing_save", {})
rows = (await client.call_tool("document_list", {})).structured_content
assert rows["count"] == 1 and rows["documents"][0]["saved"] is True
assert _modelspace_types(target) == ["CIRCLE"]
# ── live engine, against a fake ActiveX surface ──────────────────────────────
class _FakeDoc:
def __init__(self, app, name, full_name, *, saved=True, count=0):
self._app = app
self.Name = name
self.FullName = full_name
self.Saved = saved
self.ModelSpace = types.SimpleNamespace(Count=count)
self.calls: list[tuple[str, tuple]] = []
def Activate(self):
self.calls.append(("Activate", ()))
self._app.ActiveDocument = self
def Close(self, save_changes):
self.calls.append(("Close", (save_changes,)))
self._app.Documents._docs.remove(self)
docs = self._app.Documents._docs
self._app.ActiveDocument = docs[-1] if docs else None
class _FakeDocuments:
def __init__(self):
self._docs: list[_FakeDoc] = []
@property
def Count(self):
return len(self._docs)
def Item(self, index):
return self._docs[index]
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
app = types.SimpleNamespace(Documents=_FakeDocuments(), ActiveDocument=None)
first = _FakeDoc(app, "First.dwg", "C:/work/First.dwg", saved=True, count=3)
second = _FakeDoc(app, "Second.dwg", "C:/work/Second.dwg", saved=False, count=1)
untitled = _FakeDoc(app, "Drawing1.dwg", "", saved=False, count=0)
app.Documents._docs.extend([first, second, untitled])
app.ActiveDocument = first
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_acad_doc", lambda: app.ActiveDocument)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app
async def test_com_document_list_reads_the_documents_collection(com_backend):
backend, app = com_backend
rows = await backend.document_list()
assert rows == [
{
"name": "First.dwg",
"path": "C:/work/First.dwg",
"active": True,
"saved": True,
"entity_count": 3,
},
{
"name": "Second.dwg",
"path": "C:/work/Second.dwg",
"active": False,
"saved": False,
"entity_count": 1,
},
{"name": "Drawing1.dwg", "path": None, "active": False, "saved": False, "entity_count": 0},
]
async def test_com_activate_calls_activate_and_resets_document_scope(com_backend):
backend, app = com_backend
await backend._ensure_document_state()
backend._plan_spec = object()
backend._active_layer_set = "iso13567"
result = await backend.document_activate("Second.dwg")
assert result == {
"ok": True,
"active": "Second.dwg",
"previous": "First.dwg",
"name": "Second.dwg",
"path": "C:/work/Second.dwg",
}
assert app.Documents._docs[1].calls == [("Activate", ())]
assert backend._plan_spec is None, "a document switch clears drawing-scoped state"
assert backend._active_layer_set is None, "the layer set is drawing-scoped too"
assert backend._role_layer("dim") == "DIM"
with pytest.raises(ValueError, match="no open document named 'Third.dwg'"):
await backend.document_activate("Third.dwg")
async def test_com_close_refuses_before_touching_activex(com_backend):
backend, app = com_backend
second = app.Documents._docs[1]
with pytest.raises(ValueError, match="Second.dwg.*unsaved changes"):
await backend.document_close("Second.dwg")
untitled = app.Documents._docs[2]
with pytest.raises(ValueError, match="never been saved"):
await backend.document_close("Drawing1.dwg", save=True)
assert second.calls == [] and untitled.calls == []
async def test_com_close_passes_savechanges_through(com_backend):
backend, app = com_backend
first, second, untitled = app.Documents._docs
saved = await backend.document_close("Second.dwg", save=True)
assert second.calls == [("Close", (True,))]
assert saved["saved"] is True and saved["open_documents"] == 2
dropped = await backend.document_close("Drawing1.dwg", discard=True)
assert untitled.calls == [("Close", (False,))]
assert dropped["discarded_changes"] is True and dropped["open_documents"] == 1
clean = await backend.document_close(None) # the active, clean First.dwg
assert first.calls == [("Close", (False,))], "nothing to save, so nothing is asked to be saved"
assert clean["active"] is None
async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend):
backend, app = com_backend
app.ActiveDocument = app.Documents._docs[2] # the untitled one
with pytest.raises(ValueError, match="Save dialog"):
await backend.drawing_close(save=True)
assert app.Documents._docs[2].calls == []
+537
View File
@@ -0,0 +1,537 @@
"""The live-only environment trio: launch, preferences, operator prompts.
Headless there is no application to launch, no registry of preferences and
no operator to prompt, so ``EzdxfBackend`` inherits the ``@capability``
defaults and refuses with a key ``system_capabilities`` declares. Live, the
COM paths are exercised against fake ActiveX objects asserting the call
shapes; ``scripts/smoke_settings_com.py`` runs them once for real.
A cancelled pick is an answer (``cancelled: true``), not an error; a
wall-clock timeout is ``timed_out: true``. Preference writes are refused by
name before any ActiveX call: read-only keys, unknown keys, values outside
the authored range.
"""
from __future__ import annotations
import types
import pytest
from fastmcp import Client
import config
import server
from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default
from backends.ezdxf_backend import EzdxfBackend
from engineering.environment.preferences import (
PREFERENCE_KEYS,
READ_ONLY_KEYS,
SAVE_AS_TYPES,
decode_value,
describe_preferences,
split_key,
validate_preference,
)
pytestmark = pytest.mark.asyncio
# ── the whitelist, pure ─────────────────────────────────────────────────────
def test_whitelist_is_the_spec_list():
assert set(PREFERENCE_KEYS) == {
"OpenSave.SaveAsType",
"OpenSave.AutoSaveInterval",
"OpenSave.CreateBackup",
"OpenSave.IncrementalSavePercent",
"Display.CursorSize",
"Drafting.AutoSnapMarkerSize",
"Drafting.AutoSnapTooltip",
"Selection.PickBoxSize",
"Output.DefaultPlotStyleTable",
"Output.DefaultOutputDevice",
}
assert READ_ONLY_KEYS == (
"Files.SupportPath",
"Files.TemplateDwgPath",
"Files.PrinterStyleSheetPath",
"Files.PrinterConfigPath",
)
assert split_key("OpenSave.AutoSaveInterval") == ("OpenSave", "AutoSaveInterval")
assert SAVE_AS_TYPES["ac2018_dwg"] == 64 and SAVE_AS_TYPES["ac2000_dwg"] == 12
def test_validate_preference_coerces_and_refuses_by_name():
assert validate_preference("OpenSave.AutoSaveInterval", 10) == 10
assert validate_preference("OpenSave.AutoSaveInterval", 10.0) == 10
assert validate_preference("OpenSave.CreateBackup", False) is False
assert validate_preference("OpenSave.SaveAsType", "AC2018_DWG") == 64
assert validate_preference("OpenSave.SaveAsType", 48) == 48
assert (
validate_preference("Output.DefaultPlotStyleTable", " monochrome.ctb ") == "monochrome.ctb"
)
with pytest.raises(ValueError, match="'Files.SupportPath' is read-only"):
validate_preference("Files.SupportPath", "C:/x")
with pytest.raises(ValueError, match="unknown preference 'Display.Theme'"):
validate_preference("Display.Theme", 1)
with pytest.raises(ValueError, match="between 0 and 600, got 601"):
validate_preference("OpenSave.AutoSaveInterval", 601)
with pytest.raises(TypeError, match="takes an int"):
validate_preference("Selection.PickBoxSize", 2.5)
with pytest.raises(TypeError, match="takes a bool"):
validate_preference("OpenSave.CreateBackup", 1)
with pytest.raises(ValueError, match="must be one of"):
validate_preference("OpenSave.SaveAsType", "ac1999_dwg")
with pytest.raises(TypeError, match="takes a non-empty string"):
validate_preference("Output.DefaultOutputDevice", "")
def test_decode_value_maps_enum_values_back_to_names():
assert decode_value("OpenSave.SaveAsType", 64) == "ac2018_dwg"
assert decode_value("OpenSave.SaveAsType", 999) == 999
assert decode_value("OpenSave.CreateBackup", 1) is True
assert decode_value("Files.SupportPath", "C:/a;C:/b") == "C:/a;C:/b"
def test_describe_preferences_covers_every_key():
rows = {row["key"]: row for row in describe_preferences()}
assert set(rows) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
assert rows["OpenSave.AutoSaveInterval"] == {
"key": "OpenSave.AutoSaveInterval",
"kind": "int",
"range": [0, 600],
"enum": None,
"writable": True,
}
assert rows["Files.SupportPath"]["writable"] is False
# ── headless: declared refusals ─────────────────────────────────────────────
@pytest.mark.parametrize(
"method, args, key",
[
("system_launch", (), "live_application"),
("preferences_get", (), "preferences"),
("preferences_set", ("Display.CursorSize", 5), "preferences"),
("user_pick_point", ("Pick",), "interactive_prompt"),
("user_select", ("Select",), "interactive_prompt"),
("system_prompt_message", ("hi",), "interactive_prompt"),
],
)
async def test_headless_refuses_with_the_declared_key(backend, method, args, key):
assert is_capability_default(EzdxfBackend, method)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await getattr(backend, method)(*args)
assert excinfo.value.capability == key
assert "ezdxf" in str(excinfo.value)
feature = backend.capabilities().features[key]
assert feature.supported is False and feature.reason
async def test_refusal_reaches_the_client_with_the_key(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
result = await client.call_tool(
"user_pick_point", {"prompt": "Pick the base point"}, raise_on_error=False
)
assert result.is_error is True
payload = result.structured_content
assert payload["ok"] is False and payload["capability"] == "interactive_prompt"
caps = (await client.call_tool("system_capabilities", {})).structured_content
assert caps["features"]["interactive_prompt"]["supported"] is False
# ── live engine, against a fake ActiveX surface ──────────────────────────────
def _com_error(description: str):
pywintypes = pytest.importorskip("pywintypes")
return pywintypes.com_error(
-2147352567, "Exception occurred.", (0, None, description, None, 0, -2145320928), None
)
class _FakeUtility:
"""IAcadUtility as measured on AutoCAD 2026.
``GetPoint`` answers in WCS; ``GetEntity``'s PickedPoint is in the
*current UCS* (``ucs_origin`` / ``ucs_xdir`` / ``ucs_ydir``, world by
default); ``TranslateCoordinates`` only accepts a VT_ARRAY|VT_R8 VARIANT —
the plain tuple GetEntity returns is refused as "Invalid argument Point".
"""
def __init__(self):
self.calls: list[tuple[str, tuple]] = []
self.point = (12.5, 7.25, 0.0)
self.picked_ucs = (3.0, 4.0, 0.0)
self.ucs_origin = (0.0, 0.0, 0.0)
self.ucs_xdir = (1.0, 0.0, 0.0)
self.ucs_ydir = (0.0, 1.0, 0.0)
self.raise_on_pick = None
self.entity = types.SimpleNamespace(Handle="1F3")
def GetPoint(self, base, prompt):
self.calls.append(("GetPoint", (base, prompt)))
if self.raise_on_pick is not None:
raise self.raise_on_pick
return self.point
def GetEntity(self, prompt):
self.calls.append(("GetEntity", (prompt,)))
if self.raise_on_pick is not None:
raise self.raise_on_pick
return self.entity, self.picked_ucs
def TranslateCoordinates(self, point, from_cs, to_cs, displacement):
import pythoncom
from win32com.client import VARIANT
if not isinstance(point, VARIANT) or point.varianttype != (
pythoncom.VT_ARRAY | pythoncom.VT_R8
):
raise _com_error("Invalid argument Point in TranslateCoordinates")
self.calls.append(
("TranslateCoordinates", (tuple(point.value), from_cs, to_cs, displacement))
)
assert (from_cs, to_cs, displacement) == (1, 0, False) # acUCS → acWorld, a point
u, v, w = point.value
ox, oy, oz = self.ucs_origin
xx, xy, xz = self.ucs_xdir
yx, yy, yz = self.ucs_ydir
zx, zy, zz = (
xy * yz - xz * yy,
xz * yx - xx * yz,
xx * yy - xy * yx,
)
return (
ox + u * xx + v * yx + w * zx,
oy + u * xy + v * yy + w * zy,
oz + u * xz + v * yz + w * zz,
)
def Prompt(self, text):
self.calls.append(("Prompt", (text,)))
class _FakeSelectionSet:
def __init__(self, name, items):
self.Name = name
self._items = items
self.calls: list[str] = []
@property
def Count(self):
return len(self._items)
def Item(self, index):
return self._items[index]
def SelectOnScreen(self):
self.calls.append("SelectOnScreen")
def Delete(self):
self.calls.append("Delete")
class _FakeSelectionSets:
def __init__(self, items):
self.items = items
self.sets: list[_FakeSelectionSet] = []
def Add(self, name):
ss = _FakeSelectionSet(name, self.items)
self.sets.append(ss)
return ss
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
utility = _FakeUtility()
selection = [types.SimpleNamespace(Handle="A1"), types.SimpleNamespace(Handle="B2")]
document = types.SimpleNamespace(
Name="Part.dwg",
FullName="C:/work/Part.dwg",
Utility=utility,
SelectionSets=_FakeSelectionSets(selection),
)
preferences = types.SimpleNamespace(
OpenSave=types.SimpleNamespace(
SaveAsType=64, AutoSaveInterval=10, CreateBackup=True, IncrementalSavePercent=50
),
Display=types.SimpleNamespace(CursorSize=5),
Drafting=types.SimpleNamespace(AutoSnapMarkerSize=5, AutoSnapTooltip=True),
Selection=types.SimpleNamespace(PickBoxSize=3),
Output=types.SimpleNamespace(
DefaultPlotStyleTable="monochrome.ctb", DefaultOutputDevice="DWG To PDF.pc3"
),
Files=types.SimpleNamespace(
SupportPath="C:/support",
TemplateDwgPath="C:/t",
PrinterStyleSheetPath="C:/p",
PrinterConfigPath="C:/pc3",
),
)
app = types.SimpleNamespace(
Preferences=preferences,
Version="25.0",
Documents=types.SimpleNamespace(Count=1),
ActiveDocument=document,
)
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app, document
async def test_com_no_longer_inherits_the_defaults():
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends.com_backend import ComBackend
for method in (
"system_launch",
"preferences_get",
"preferences_set",
"user_pick_point",
"user_select",
"system_prompt_message",
):
assert not is_capability_default(ComBackend, method), method
async def test_com_pick_point_returns_the_operator_point(com_backend):
backend, app, document = com_backend
import pythoncom
result = await backend.user_pick_point("Pick the base point")
assert result == {"cancelled": False, "x": 12.5, "y": 7.25, "z": 0.0, "backend": "com"}
(call,) = document.Utility.calls
assert call[0] == "GetPoint" and call[1][0] is pythoncom.Missing
assert call[1][1] == "\nPick the base point"
async def test_com_pick_point_cancel_is_an_answer_not_an_error(com_backend):
backend, app, document = com_backend
document.Utility.raise_on_pick = _com_error("User input is a keyword")
result = await backend.user_pick_point("Pick")
assert result == {"cancelled": True, "reason": "User input is a keyword", "backend": "com"}
async def test_com_pick_point_other_com_errors_still_raise(com_backend):
backend, app, document = com_backend
pywintypes = pytest.importorskip("pywintypes")
document.Utility.raise_on_pick = pywintypes.com_error(
-2147418111, "Call was rejected", None, None
)
with pytest.raises(pywintypes.com_error):
await backend.user_pick_point("Pick")
async def test_com_pick_point_timeout_reports_timed_out(com_backend, monkeypatch):
backend, app, document = com_backend
monkeypatch.setattr(config.settings, "com_call_timeout", 7.0)
async def _timed_out(func, *args, **kwargs):
raise RuntimeError(
"AutoCAD did not respond within 7s. The application may be showing a modal dialog"
)
monkeypatch.setattr(backend, "_run", _timed_out)
result = await backend.user_pick_point("Pick")
assert result["timed_out"] is True and result["timeout_s"] == 7.0
assert "did not respond" in result["error"]
result = await backend.user_select("Pick", "multiple")
assert result["timed_out"] is True
async def test_com_prompt_validation_happens_before_activex(com_backend):
backend, app, document = com_backend
with pytest.raises(ValueError, match="prompt must not be empty"):
await backend.user_pick_point(" ")
with pytest.raises(ValueError, match="mode must be 'single' or 'multiple'"):
await backend.user_select("Pick", "many")
assert document.Utility.calls == [] and document.SelectionSets.sets == []
async def test_com_select_single_uses_getentity(com_backend):
backend, app, document = com_backend
result = await backend.user_select("Pick the flange")
assert result == {
"cancelled": False,
"mode": "single",
"handles": ["1F3"],
"picked": [3.0, 4.0],
"backend": "com",
}
assert document.Utility.calls == [
("GetEntity", ("\nPick the flange",)),
("TranslateCoordinates", ((3.0, 4.0, 0.0), 1, 0, False)),
]
document.Utility.raise_on_pick = _com_error("Function cancelled")
assert (await backend.user_select("Pick"))["cancelled"] is True
async def test_com_select_single_reports_the_pick_in_wcs_under_a_ucs(com_backend):
"""MEASURED (AutoCAD 2026): GetEntity's PickedPoint is in the current UCS.
UCS origin (100,50,0) rotated 90 deg (X dir (0,1,0)); the operator picked
a circle at WCS (105,70): GetEntity reported (20,-5,0) and
TranslateCoordinates(acUCS → acWorld) gave (105,70,0). GetPoint already
answers in WCS, so ``user_pick_point`` and ``user_select`` must agree.
"""
backend, app, document = com_backend
utility = document.Utility
utility.ucs_origin = (100.0, 50.0, 0.0)
utility.ucs_xdir = (0.0, 1.0, 0.0)
utility.ucs_ydir = (-1.0, 0.0, 0.0)
utility.picked_ucs = (20.0, -5.0, 0.0)
result = await backend.user_select("Pick the circle")
assert result["picked"] == [105.0, 70.0]
assert result["handles"] == ["1F3"] and result["cancelled"] is False
# A translated UCS without rotation (origin (100,50)): (15,20) → (115,70).
utility.ucs_xdir, utility.ucs_ydir = (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)
utility.picked_ucs = (15.0, 20.0, 0.0)
assert (await backend.user_select("Pick"))["picked"] == [115.0, 70.0]
# GetPoint is not translated — it already answers in WCS.
utility.point = (110.0, 70.0, 0.0)
point = await backend.user_pick_point("Pick")
assert (point["x"], point["y"]) == (110.0, 70.0)
assert not any(name == "TranslateCoordinates" for name, _ in utility.calls[-1:])
async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend):
backend, app, document = com_backend
result = await backend.user_select("Select the pipes", mode="multiple")
assert result == {
"cancelled": False,
"mode": "multiple",
"handles": ["A1", "B2"],
"count": 2,
"backend": "com",
}
assert document.Utility.calls == [("Prompt", ("\nSelect the pipes\n",))]
(ss,) = document.SelectionSets.sets
assert ss.Name.startswith("_PICK_") and ss.calls == ["SelectOnScreen", "Delete"]
async def test_com_prompt_message_writes_to_the_command_line(com_backend):
backend, app, document = com_backend
result = await backend.system_prompt_message("Drawing saved by the assistant")
assert result == {"ok": True, "text": "Drawing saved by the assistant", "backend": "com"}
assert document.Utility.calls == [("Prompt", ("\nDrawing saved by the assistant\n",))]
async def test_com_preferences_get_reads_the_whitelist(com_backend):
backend, app, document = com_backend
result = await backend.preferences_get()
assert result["read_only"] == list(READ_ONLY_KEYS)
assert result["values"]["OpenSave.SaveAsType"] == "ac2018_dwg"
assert result["values"]["OpenSave.AutoSaveInterval"] == 10
assert result["values"]["Files.SupportPath"] == "C:/support"
assert set(result["values"]) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
subset = await backend.preferences_get(["Display.CursorSize"])
assert subset == {"values": {"Display.CursorSize": 5}, "read_only": [], "backend": "com"}
with pytest.raises(ValueError, match="unknown preference keys \\['Display.Theme'\\]"):
await backend.preferences_get(["Display.Theme"])
async def test_com_preferences_set_reports_old_and_new(com_backend):
backend, app, document = com_backend
result = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
assert result == {
"ok": True,
"key": "OpenSave.AutoSaveInterval",
"old": 10,
"new": 15,
"changed": True,
"backend": "com",
}
assert app.Preferences.OpenSave.AutoSaveInterval == 15
same = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
assert same["changed"] is False
enum = await backend.preferences_set("OpenSave.SaveAsType", "ac2013_dwg")
assert enum["old"] == "ac2018_dwg" and enum["new"] == "ac2013_dwg"
assert app.Preferences.OpenSave.SaveAsType == 60
async def test_com_preferences_set_refuses_before_any_write(com_backend):
backend, app, document = com_backend
with pytest.raises(ValueError, match="read-only"):
await backend.preferences_set("Files.SupportPath", "C:/evil")
with pytest.raises(ValueError, match="between 1 and 100"):
await backend.preferences_set("Display.CursorSize", 0)
with pytest.raises(ValueError, match="unknown preference"):
await backend.preferences_set("Display.Theme", 1)
assert app.Preferences.Files.SupportPath == "C:/support"
assert app.Preferences.Display.CursorSize == 5
async def test_com_launch_attaches_first_and_dispatches_otherwise(com_backend, monkeypatch):
backend, app, document = com_backend
from backends import com_backend as module
monkeypatch.setattr(module, "_COM_STATE", {})
calls: list[str] = []
def _get_active(progid):
calls.append(f"GetActiveObject:{progid}")
raise RuntimeError("not running")
def _dispatch(progid):
calls.append(f"Dispatch:{progid}")
app.Visible = False
return app
monkeypatch.setattr(module.win32com.client, "GetActiveObject", _get_active)
monkeypatch.setattr(module.win32com.client, "Dispatch", _dispatch)
result = await backend.system_launch(visible=True)
assert result == {
"launched": True,
"attached": False,
"version": "25.0",
"document": "Part.dwg",
"visible": True,
"progid": config.settings.cad_progid,
"backend": "com",
}
assert calls == [
f"GetActiveObject:{config.settings.cad_progid}",
f"Dispatch:{config.settings.cad_progid}",
]
assert app.Visible is True and module._COM_STATE["app"] is app
again = await backend.system_launch()
assert again["launched"] is False and again["attached"] is True
assert len(calls) == 2, "an app already cached is attached, not re-dispatched"
async def test_com_launch_opens_a_validated_path(com_backend, monkeypatch, tmp_path):
backend, app, document = com_backend
from backends import com_backend as module
opened: list[str] = []
app.Documents = types.SimpleNamespace(
Count=1,
Open=lambda path: opened.append(path) or types.SimpleNamespace(Name="gear.dwg"),
)
monkeypatch.setattr(module, "_COM_STATE", {"app": app})
target = tmp_path / "gear.dwg"
target.write_bytes(b"")
result = await backend.system_launch(open_path=str(target))
assert result["document"] == "gear.dwg" and opened == [str(target.resolve())]
from fastmcp.exceptions import ToolError
with pytest.raises(ToolError):
await backend.system_launch(open_path="../../etc/passwd")
+514
View File
@@ -0,0 +1,514 @@
"""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 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:
"""Models the two refusals measured live (AutoCAD 2026): the active layer
cannot be frozen, and a frozen layer cannot be made active."""
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._frozen = props.get("frozen", False)
self.Lock = props.get("locked", False)
self.Plottable = props.get("plot", True)
self.document = None
@property
def Freeze(self):
return self._frozen
@Freeze.setter
def Freeze(self, value):
if value and self.document is not None and self.document.ActiveLayer is self:
raise RuntimeError("AutoCAD COM error: cannot freeze the current layer")
self._frozen = bool(value)
class _FakeDocument:
def __init__(self, layers, current):
self.Dictionaries = _FakeDictionaries()
self.Layers = _FakeLayers(layers)
self._active = current
for lyr in layers:
lyr.document = self
@property
def ActiveLayer(self):
return self._active
@ActiveLayer.setter
def ActiveLayer(self, layer):
if layer.Freeze:
# Measured: com_error 'Error setting active layer' (-2145320874).
raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Error setting active layer")
self._active = layer
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 = _FakeDocument(layers, current=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_restore_thaws_the_states_current_layer_before_making_it_current(com_backend):
"""The spec's own acceptance path, measured live: save with GEOMETRY
current and HIDDEN frozen → thaw HIDDEN, make 0 current, freeze GEOMETRY
→ restore. AutoCAD refuses ``ActiveLayer = <frozen>``, so the write
before the loop raised an opaque COM error and nothing was applied."""
backend, document = com_backend
await backend.layer_state_save("PLOT")
geometry = document.Layers.Item("GEOMETRY")
hidden = document.Layers.Item("HIDDEN")
hidden.Freeze = False
document.ActiveLayer = document.Layers.Item("0")
geometry.Freeze = True
result = await backend.layer_state_restore("PLOT")
assert "warnings" not in result, result
assert document.ActiveLayer is geometry and geometry.Freeze is False
assert hidden.Freeze is True
assert result["applied"] == {
"layers": 3,
"properties": list(PROPERTIES),
"current_layer": "GEOMETRY",
}
async def test_com_restore_reports_a_refused_current_layer_instead_of_failing(com_backend):
"""Without ``frozen`` in the requested properties the thaw is not ours to
make: AutoCAD's refusal lands in ``warnings`` and the rest still applies."""
backend, document = com_backend
await backend.layer_state_save("PLOT")
geometry = document.Layers.Item("GEOMETRY")
hidden = document.Layers.Item("HIDDEN")
hidden.Color = 3
document.ActiveLayer = document.Layers.Item("0")
geometry.Freeze = True
result = await backend.layer_state_restore("PLOT", properties=["color", "current"])
assert result["applied"]["current_layer"] is None
assert result["applied"]["layers"] == 3 and hidden.Color == 8
assert geometry.Freeze is True and document.ActiveLayer.Name == "0"
(warning,) = result["warnings"]
assert warning.startswith("GEOMETRY: cannot be made current: ")
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")
+16 -7
View File
@@ -535,8 +535,17 @@ async def test_tool_group_sizes_are_unchanged():
`query`/`export`, so they file with the sheets they set up. `templates`
moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19
(tagged `template`, which ranks above `drawing`), then 3 -> 4 when
`drawing_template_save` joined. Every other number
here has been unchanged since the snapshot was taken.
`drawing_template_save` joined. `drawing`
moved 13 -> 16 when v1.6's `document_list` / `document_activate` /
`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. `system`
moved 8 -> 14 with v1.6's live-only environment tools (`system_launch`,
`system_preferences_get/set`, `user_pick_point`, `user_select`,
`system_prompt_message`), which refuse headlessly with declared
capability keys. 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()}
assert sizes == {
@@ -545,21 +554,21 @@ async def test_tool_group_sizes_are_unchanged():
"blocks": 9,
"corner_ops": 4,
"dimensions": 5,
"drawing": 13,
"drawing": 16,
"engineering": 10,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layers": 18,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
"styles": 10,
"system": 8,
"system": 14,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4,
"view": 10,
}
assert sum(sizes.values()) == 185
assert sum(sizes.values()) == 204
+618
View File
@@ -0,0 +1,618 @@
"""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 fastmcp import Client
import server
from engineering.environment.ucs import is_world_axes, 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])
def test_world_is_decided_by_the_frame_not_the_name():
assert is_world_axes((0, 0, 0), (1, 0, 0), (0, 1, 0))
assert is_world_axes([0.0, 0.0, 1e-12], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0])
assert not is_world_axes((0, 20, 0), (1, 0, 0), (0, 1, 0)) # UCS Origin
assert not is_world_axes((0, 0, 0), (0, 1, 0), (-1, 0, 0)) # rotated 90°
assert not is_world_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",
"vport_height": 20.0,
"aspect_ratio": pytest.approx(1.34),
"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
async def test_view_restore_keeps_the_vport_aspect_and_fits_the_window(backend):
"""Measured (AutoCAD 2026, display aspect 2.013): the *Active VPORT's
aspect ratio is reconciled against the real display by the viewport's
lower-left corner — a 10x20 view at (5, 6) written with aspect 0.5
opened at VIEWCTR (20.134, 6), and the default 1.34 at (11.734, 6). The
aspect is the display's (AutoCAD writes it at save), so restore leaves it
alone and fits the window into it, like `-VIEW _R` does."""
(vport,) = backend._doc.viewports.get("*Active") or [backend._doc.viewports.new("*Active")]
vport.dxf.aspect_ratio = 2.0125 # what AutoCAD wrote for a 2262 x 1124 display
await backend.view_named_save("TALL", center=[5, 6], height=20, width=10)
await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100)
tall = await backend.view_named_restore("TALL")
assert vport.dxf.aspect_ratio == pytest.approx(2.0125), "never rewritten from w / h"
assert tuple(vport.dxf.center)[:2] == (5.0, 6.0)
assert vport.dxf.height == 20.0 and tall["vport_height"] == 20.0
assert tall["aspect_ratio"] == pytest.approx(2.0125)
wide = await backend.view_named_restore("WIDE")
assert vport.dxf.aspect_ratio == pytest.approx(2.0125)
assert vport.dxf.height == pytest.approx(100.0 / 2.0125)
assert wide["vport_height"] == pytest.approx(100.0 / 2.0125) and wide["height"] == 20.0
async def test_view_is_written_as_a_plan_view_on_disk(backend, tmp_path):
"""ezdxf's VIEW default is ``direction=(1, 1, 1)`` — an isometric view. The
tool stores the plan window of ``center``/``height``, so the entry must
say so explicitly (DXF 11/21/31, 12/22/32 and 50) or AutoCAD restores it
oblique and reads ``center`` in that DCS."""
await backend.entity_create_line(0, 0, 100, 50)
await backend.view_named_save("OVERALL")
view = backend._doc.views.get("OVERALL")
assert tuple(view.dxf.direction) == (0.0, 0.0, 1.0)
assert tuple(view.dxf.target) == (0.0, 0.0, 0.0)
assert view.dxf.view_twist == 0.0
import ezdxf
path = tmp_path / "views.dxf"
await backend.drawing_save_as(str(path))
reopened = ezdxf.readfile(str(path)).views.get("OVERALL")
assert tuple(reopened.dxf.direction) == (0.0, 0.0, 1.0)
assert tuple(reopened.dxf.target) == (0.0, 0.0, 0.0)
assert tuple(reopened.dxf.center)[:2] == (50.0, 25.0)
async def test_view_restore_carries_the_orientation_into_the_vport(backend):
"""A foreign drawing's VIEW may be oblique or twisted; restoring it must
not silently flatten it to a plan view of the same center."""
backend._doc.views.add(
"ISO",
dxfattribs={
"center": (5.0, 6.0, 0.0),
"height": 20.0,
"width": 30.0,
"direction": (1.0, 1.0, 1.0),
"target": (1.0, 2.0, 3.0),
"view_twist": 15.0,
},
)
result = await backend.view_named_restore("iso")
assert result["name"] == "ISO" and result["center"] == [5.0, 6.0]
(vport,) = backend._doc.viewports.get("*Active")
assert tuple(vport.dxf.direction) == (1.0, 1.0, 1.0)
assert tuple(vport.dxf.target) == (1.0, 2.0, 3.0)
assert vport.dxf.view_twist == 15.0
# ...and restoring a view the tool saved puts the VPORT back on plan.
await backend.view_named_save("PLAN", center=[0, 0], height=10)
await backend.view_named_restore("PLAN")
(vport,) = backend._doc.viewports.get("*Active")
assert tuple(vport.dxf.direction) == (0.0, 0.0, 1.0)
assert tuple(vport.dxf.target) == (0.0, 0.0, 0.0)
assert vport.dxf.view_twist == 0.0
# ── 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
async def test_ucs_list_does_not_call_an_unnamed_ucs_world(backend):
"""``UCS Origin`` / ``UCS 3P`` without saving — the common kind — leaves
``$UCSNAME`` empty exactly like WCS does (AutoCAD 2026 writes
``$UCSNAME=''``, ``$UCSORG=(0,20,0)`` after ``_.UCS _O``). The frame, not
the name, says whether the drawing is in WCS."""
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
header = backend._doc.header
header["$UCSNAME"] = ""
header["$UCSORG"] = (50.0, 20.0, 0.0)
header["$UCSXDIR"] = (0.0, 1.0, 0.0)
header["$UCSYDIR"] = (-1.0, 0.0, 0.0)
rows = await backend.ucs_list()
assert [r["name"] for r in rows] == ["world", "FRONT", None]
assert [r["current"] for r in rows] == [False, False, True]
assert rows[2] == {
"name": None,
"origin": [50.0, 20.0, 0.0],
"x_axis": [0.0, 1.0, 0.0],
"y_axis": [-1.0, 0.0, 0.0],
"current": True,
}
# ...and the same empty name with the WCS frame really is world.
await backend.ucs_restore("world")
rows = await backend.ucs_list()
assert [r["name"] for r in rows] == ["world", "FRONT"]
assert rows[0]["current"] is True and rows[1]["current"] is False
async def test_ucs_list_a_stale_name_falls_back_to_the_frame(backend):
"""``$UCSNAME`` naming an entry the table no longer holds is not a
current row; the frame decides between world and unnamed."""
header = backend._doc.header
header["$UCSNAME"] = "GONE"
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", True)]
header["$UCSORG"] = (0.0, 20.0, 0.0)
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", False), (None, True)]
assert rows[1]["origin"] == [0.0, 20.0, 0.0]
async def test_ucs_list_tool_reports_an_unnamed_ucs_over_the_wire(monkeypatch, tmp_path):
"""The exact file AutoCAD writes after ``_.UCS _O 0,20,0`` without saving:
``$UCSNAME=''`` and ``$UCSORG=(0,20,0)``. The tool used to answer
``current: "world"``."""
import ezdxf
doc = ezdxf.new("R2018")
doc.ucs.add(
"FRONT", dxfattribs={"origin": (10, 10, 0), "xaxis": (0, 1, 0), "yaxis": (-1, 0, 0)}
)
doc.header["$UCSNAME"] = ""
doc.header["$UCSORG"] = (0.0, 20.0, 0.0)
path = tmp_path / "unnamed_ucs.dxf"
doc.saveas(str(path))
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as client:
await client.call_tool("drawing_open", {"path": str(path)})
payload = (await client.call_tool("ucs_list", {})).structured_content
assert payload["current"] is None and payload["current_unnamed"] is True
assert payload["count"] == 3
assert [r["name"] for r in payload["ucs"]] == ["world", "FRONT", None]
assert payload["ucs"][0]["current"] is False
assert payload["ucs"][2]["current"] is True
assert payload["ucs"][2]["origin"] == [0.0, 20.0, 0.0]
# ── 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
# Measured (AutoCAD 2026): `-VIEW _S` of VIEWSIZE 20 recorded width 40.267
# and the corner-anchored restores landed at x = 10.134 / 20.134 — all three
# say the display aspect was 2.0134, i.e. a 2263 x 1124 SCREENSIZE.
_SCREEN = (2263.0, 1124.0)
_DISPLAY_ASPECT = _SCREEN[0] / _SCREEN[1] # 2.0134
class _FakeDocument:
"""``GetVariable`` is an AcadDocument member (``hasattr(app, "GetVariable")``
is False live). ``ActiveViewport`` is deliberately STALE — measured, the
AcadViewport object keeps the document's initial view whatever the
display shows — so any code that reads it saves the wrong window."""
def __init__(self, variables):
self.Views = _FakeViews()
self.UserCoordinateSystems = _FakeUcsCollection()
self.variables = variables
self._viewport = types.SimpleNamespace(Center=(298.98, 148.5), Height=297.0, Width=597.97)
self.viewport_sets = 0
self.ActiveUCS = None
self.commands: list[str] = []
def GetVariable(self, name):
return self.variables[name]
@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)
class _FakeApp:
"""No ``GetVariable``. ``ZoomWindow`` models ZOOM Window / `-VIEW _R`:
the window is centred and fitted to the display aspect (measured live:
`-VIEW _R` of a 10x20 view at (5, 6) gives VIEWCTR (5, 6), VIEWSIZE 20)."""
def __init__(self, document):
self.document = document
self.zoom_windows: list[tuple[tuple, tuple]] = []
def ZoomWindow(self, lower_left, upper_right):
self.zoom_windows.append((tuple(lower_left), tuple(upper_right)))
w = upper_right[0] - lower_left[0]
h = upper_right[1] - lower_left[1]
cx, cy = lower_left[0] + w / 2.0, lower_left[1] + h / 2.0
self.document.variables["VIEWCTR"] = (cx, cy, 0.0)
self.document.variables["VIEWSIZE"] = max(h, w / _DISPLAY_ASPECT)
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
variables = {
"CMDACTIVE": 0,
"UCSNAME": "",
"WORLDUCS": 1,
"UCSORG": (0.0, 0.0, 0.0),
"UCSXDIR": (1.0, 0.0, 0.0),
"UCSYDIR": (0.0, 1.0, 0.0),
"VIEWCTR": (5.0, 6.0, 0.0),
"VIEWSIZE": 20.0,
"SCREENSIZE": _SCREEN,
}
document = _FakeDocument(variables)
app = _FakeApp(document)
assert not hasattr(app, "GetVariable"), "AcadApplication has no GetVariable member"
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_viewctr_viewsize_not_the_viewport_object(com_backend):
"""Measured (AutoCAD 2026): after ``_.ZOOM _C 5,6 20`` VIEWCTR is (5, 6)
and VIEWSIZE 20 while ``doc.ActiveViewport`` still answers the initial
view (298.98, 148.5) x 297 — and AutoCAD's own `-VIEW _S` records
centre (5, 6), height 20, width 20 x the display aspect (40.267)."""
backend, document, _ = com_backend
result = await backend.view_named_save("HOME")
assert document.Views.calls == [("Add", ("HOME",))]
(view,) = document.Views.views
assert view.Center == (5.0, 6.0) and view.Height == 20.0
assert view.Width == pytest.approx(20.0 * _DISPLAY_ASPECT)
assert result["center"] == [5.0, 6.0] and result["replaced"] is False
assert result["width"] == pytest.approx(40.267, abs=1e-3), "what `-VIEW _S` recorded live"
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
assert view.Width == pytest.approx(10.0 * _DISPLAY_ASPECT), (
"width defaults to the display aspect"
)
assert document.viewport_sets == 0
async def test_com_view_restore_zooms_the_window_like_view_r(com_backend):
"""Measured (AutoCAD 2026, display aspect 2.013): writing Center/Height/
Width into the AcadViewport lands a 30x20 view at (5, 6) on VIEWCTR
(10.134, 6) and a 10x20 one on (20.134, 6) — AutoCAD anchors the
lower-left corner and widens. `-VIEW _R` gives VIEWCTR (5, 6), VIEWSIZE
20 for both, and so does ZOOM Window on the saved rectangle."""
backend, document, variables = com_backend
from backends import com_backend as module
app = module._acad_app() # the fixture's _FakeApp
await backend.view_named_save("HOME", center=[5, 6], height=20, width=30)
await backend.view_named_save("TALL", center=[5, 6], height=20, width=10)
await backend.view_named_save("WIDE", center=[5, 6], height=20, width=100)
for name, expect_size in (("HOME", 20.0), ("TALL", 20.0), ("WIDE", 100.0 / _DISPLAY_ASPECT)):
result = await backend.view_named_restore(name)
assert result["applied"] == "zoom_window" and result["center"] == [5.0, 6.0]
assert result["viewctr"] == [5.0, 6.0], name
assert result["viewsize"] == pytest.approx(expect_size), name
assert variables["VIEWCTR"][:2] == (5.0, 6.0)
assert app.zoom_windows == [
((-10.0, -4.0, 0.0), (20.0, 16.0, 0.0)),
((0.0, -4.0, 0.0), (10.0, 16.0, 0.0)),
((-45.0, -4.0, 0.0), (55.0, 16.0, 0.0)),
]
# The stale AcadViewport object was neither written nor re-assigned.
assert document.viewport_sets == 0
assert document.ActiveViewport.Center == (298.98, 148.5)
assert await backend.view_named_list() == [
{"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0},
{"name": "TALL", "center": [5.0, 6.0], "height": 20.0, "width": 10.0},
{"name": "WIDE", "center": [5.0, 6.0], "height": 20.0, "width": 100.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_list_reads_worlducs_not_the_empty_name(com_backend):
"""Verified live on AutoCAD 2026: after ``_.UCS _O 10,10,0`` (unnamed),
``UCSNAME=""`` and ``WORLDUCS=0`` — the same empty name WCS has."""
backend, document, variables = com_backend
await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
variables.update(
{
"UCSNAME": "",
"WORLDUCS": 0,
"UCSORG": (0.0, 20.0, 0.0),
"UCSXDIR": (1.0, 0.0, 0.0),
"UCSYDIR": (0.0, 1.0, 0.0),
}
)
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [
("world", False),
("FRONT", False),
(None, True),
]
assert rows[2]["origin"] == [0.0, 20.0, 0.0] and rows[2]["x_axis"] == [1.0, 0.0, 0.0]
# WORLDUCS=1 with the same empty name is world, and no unnamed row appears.
variables.update({"WORLDUCS": 1, "UCSORG": (0.0, 0.0, 0.0)})
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", True), ("FRONT", False)]
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_restore_world_refuses_when_cmdactive_cannot_be_read(com_backend):
"""The guard once swallowed a failed read as "idle" — with the read on the
wrong object (``app.GetVariable``) it failed on every call, so `_.UCS _W`
went into whatever prompt was open. A read failure now refuses."""
backend, document, variables = com_backend
del variables["CMDACTIVE"]
with pytest.raises(RuntimeError, match="cannot verify AutoCAD is idle"):
await backend.ucs_restore("world")
assert document.commands == []
async def test_com_ucs_reads_sysvars_from_the_document_never_the_application(com_backend):
"""Live, ``AutoCAD.Application.GetVariable`` raises AttributeError: the
member is on AcadDocument. The fixture's app has none, so every UCS path
here would blow up on the old code."""
backend, document, variables = com_backend
from backends import com_backend as module
assert not hasattr(module._acad_app(), "GetVariable")
rows = await backend.ucs_list()
assert [(r["name"], r["current"]) for r in rows] == [("world", True)]
assert (await backend.ucs_restore("world"))["current"] is True
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 == []