Four review findings, all measured by the reviewer on AutoCAD 2026:
1. ucs_list and the ucs_restore('world') CMDACTIVE guard read
app.GetVariable, but GetVariable is an AcadDocument member — the tool
was dead live and the guard's swallowed read forced cmd_active to 0,
sending `_.UCS _W` into any open prompt. Both read the document now,
and a failed CMDACTIVE read refuses instead of proceeding. The fake
app in tests/test_views_ucs.py has no GetVariable any more, so the
old code cannot pass.
2. view_named_save defaulted from doc.ActiveViewport.Center/Height/Width,
which does not track the display (it answered the initial view after
`_.ZOOM _C 5,6 20`). The current view is VIEWCTR / VIEWSIZE at the
SCREENSIZE aspect — what `-VIEW _S` records (width 40.267 for height
20). The fake viewport is deliberately stale so a read of it fails.
3. view_named_restore wrote Center/Height/Width into the AcadViewport;
AutoCAD anchors the lower-left corner and widens to the display
aspect, landing a 10x20 view at (5, 6) on VIEWCTR (20.134, 6). It now
zooms the saved rectangle (app.ZoomWindow), which centres and fits
like `-VIEW _R` (VIEWCTR = saved centre, VIEWSIZE = max(h, w/aspect)),
reports `applied: "zoom_window"` and reads VIEWCTR / VIEWSIZE back.
Headless the same reconciliation moved the centre whenever the
*Active VPORT's aspect was rewritten from w/h (even the 1.34 default
opened at x = 11.734): the aspect the file carries is left alone —
it is the display's, written by AutoCAD at save — and the window is
fitted into it; the payload adds `vport_height` / `aspect_ratio`.
4. layer_state_restore set ActiveLayer before the loop and outside the
try; AutoCAD refuses to make a frozen layer current, so the spec's
own save → freeze → restore path raised an opaque COM error with
nothing applied. When `frozen` is being restored the state's current
layer is thawed first; the write is wrapped and a refusal lands in
`warnings`. The fake document and layer now model both refusals.
Live AutoCAD was not running in this round (GetActiveObject failed, no
acad process), so the fix is measured against the reviewer's numbers,
not re-measured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
184 lines
7.7 KiB
Python
184 lines
7.7 KiB
Python
"""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": [...]}``."""
|
|
...
|
|
|
|
@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) ───
|
|
|
|
# TEMPORARY (track-e/environment worktree only — the merge agent deletes this
|
|
# block and the two decorators below become @abstractmethod): until group C's
|
|
# implementations land, declaring these abstract would stop both backends from
|
|
# instantiating here. They refuse with a declared key rather than pretend.
|
|
|
|
@capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027
|
|
async def drawing_properties_get(self) -> dict:
|
|
"""→ ``{"summary": {"title","subject","author","keywords","comments"}
|
|
(None each headless), "summary_available": bool, "custom": {k: v}}``."""
|
|
...
|
|
|
|
@capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027
|
|
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": [...]}``."""
|
|
...
|