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:
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [...]}``."""
|
||||
...
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Portable layer states: a layer-table snapshot <-> XRECORD string chunks.
|
||||
|
||||
These are the server's own layer states, not AutoCAD's. AutoCAD keeps its
|
||||
Layer States Manager entries under the layer table's extension dictionary in
|
||||
a layout neither engine authors; this codec stores a JSON snapshot as
|
||||
1000-code string chunks in an XRECORD under the drawing's named object
|
||||
dictionary ``ACADMCP_LAYERSTATES``. They live in the file and travel with it,
|
||||
and they do **not** appear in AutoCAD's Layer States Manager (LAYERSTATE).
|
||||
That sentence is repeated in the tool docstrings, in ``system_capabilities``
|
||||
(feature ``layer_states``) and in the README.
|
||||
|
||||
Chunks are ASCII (``ensure_ascii``), so a 255-character boundary can never
|
||||
split a code point, and 255 is AutoCAD's ``SetXRecordData`` string limit —
|
||||
ezdxf itself would accept longer tags (measured: a 300-character tag
|
||||
round-trips through save/reopen unchanged).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .names import validate_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import LayerInfo
|
||||
|
||||
DICT_NAME = "ACADMCP_LAYERSTATES"
|
||||
PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current")
|
||||
LAYER_PROPERTIES = PROPERTIES[:-1]
|
||||
CHUNK_SIZE = 255
|
||||
FORMAT_VERSION = 1
|
||||
|
||||
|
||||
def validate_state_name(name) -> str:
|
||||
return validate_name(name, what="layer state name")
|
||||
|
||||
|
||||
def validate_properties(properties) -> tuple[str, ...]:
|
||||
"""The subset of ``PROPERTIES`` to apply; ``None`` means all of them."""
|
||||
if properties is None:
|
||||
return PROPERTIES
|
||||
if isinstance(properties, str) or not isinstance(properties, (list, tuple)):
|
||||
raise TypeError("properties must be a list of property names")
|
||||
out: list[str] = []
|
||||
for index, prop in enumerate(properties):
|
||||
if prop not in PROPERTIES:
|
||||
raise ValueError(f"properties[{index}]: {prop!r} is not one of {PROPERTIES}")
|
||||
if prop not in out:
|
||||
out.append(prop)
|
||||
if not out:
|
||||
raise ValueError(f"properties must name at least one of {PROPERTIES}")
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def snapshot_from_layers(
|
||||
layers: list[LayerInfo],
|
||||
current_layer: str,
|
||||
*,
|
||||
plot: dict[str, bool] | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict:
|
||||
"""The state dict for a layer table.
|
||||
|
||||
``plot`` is per-layer plottability (``LayerInfo`` has no such field; each
|
||||
engine reads it and passes it in), defaulting to plottable.
|
||||
"""
|
||||
plot = plot or {}
|
||||
return {
|
||||
"version": FORMAT_VERSION,
|
||||
"description": description,
|
||||
"current_layer": str(current_layer),
|
||||
"layers": {
|
||||
layer.name: {
|
||||
"on": bool(layer.is_on),
|
||||
"frozen": bool(layer.is_frozen),
|
||||
"locked": bool(layer.is_locked),
|
||||
"color": int(layer.color),
|
||||
"linetype": str(layer.linetype),
|
||||
"lineweight": int(round(float(layer.lineweight))),
|
||||
"plot": bool(plot.get(layer.name, True)),
|
||||
}
|
||||
for layer in layers
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def encode_state(state: dict) -> list[str]:
|
||||
text = json.dumps(state, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
|
||||
return [text[i : i + CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE)] or [""]
|
||||
|
||||
|
||||
def decode_state(chunks) -> dict:
|
||||
text = "".join(str(chunk) for chunk in chunks)
|
||||
try:
|
||||
state = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"layer state payload is not valid JSON: {exc}") from exc
|
||||
if not isinstance(state, dict) or state.get("version") != FORMAT_VERSION:
|
||||
found = state.get("version") if isinstance(state, dict) else type(state).__name__
|
||||
raise ValueError(
|
||||
f"layer state payload is not a version {FORMAT_VERSION} state (got {found!r})"
|
||||
)
|
||||
layers = state.get("layers")
|
||||
if not isinstance(layers, dict):
|
||||
raise ValueError("layer state payload has no 'layers' mapping")
|
||||
for name, props in layers.items():
|
||||
if not isinstance(props, dict):
|
||||
raise ValueError(f"layer state entry {name!r} is not a mapping")
|
||||
missing = [prop for prop in LAYER_PROPERTIES if prop not in props]
|
||||
if missing:
|
||||
raise ValueError(f"layer state entry {name!r} lacks {missing}")
|
||||
state.setdefault("description", None)
|
||||
state.setdefault("current_layer", "0")
|
||||
return state
|
||||
|
||||
|
||||
def diff_snapshot(state: dict, layers: list[LayerInfo]) -> tuple[list[str], list[str]]:
|
||||
"""``(missing, new)``: in the state but not the drawing / in the drawing but not the state."""
|
||||
in_state = set(state["layers"])
|
||||
in_drawing = {layer.name for layer in layers}
|
||||
return sorted(in_state - in_drawing), sorted(in_drawing - in_state)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""One rule for the names this package stores in the drawing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def validate_name(name, *, what: str, max_len: int = 255) -> str:
|
||||
"""A non-empty, stripped string without control characters, else ``ValueError``."""
|
||||
if not isinstance(name, str):
|
||||
raise ValueError(f"{what} must be a string, got {type(name).__name__}")
|
||||
clean = name.strip()
|
||||
if not clean:
|
||||
raise ValueError(f"{what} must not be empty")
|
||||
if len(clean) > max_len:
|
||||
raise ValueError(f"{what} is longer than {max_len} characters")
|
||||
if any(ord(ch) < 32 for ch in clean):
|
||||
raise ValueError(f"{what} must not contain control characters")
|
||||
return clean
|
||||
@@ -0,0 +1,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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user