diff --git a/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md b/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md new file mode 100644 index 0000000..60a51ea --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md @@ -0,0 +1,19568 @@ +# v1.6 Track E — Settings and Environment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give a model control of the environment a drawing lives in — ISO-25/ANSI dimension, text and leader styles, a grown `drawing_settings` facade with a system-variable catalogue, page setup with PDF-verified plotting and five bundled templates, multiple documents, portable layer states, named views and UCS, and (live only) launching, preferences, document properties and operator prompts — as 38 tools, plus the Track A hardening fixes. + +**Architecture:** Three new contract modules (`styles`, `pagesetup`, `environment`), authored standards data under `engineering/standards/`, a portable layer-state codec under `engineering/environment/`, three new `server.py` sections (18 Styles, 19 Page setup & templates, 20 Environment). The ezdxf backend gains a document registry so `document_*` works headlessly. COM-only members use `@capability` and both capability maps declare the keys. + +**Tech Stack:** Python 3.11+, FastMCP 3, ezdxf 1.4, pywin32 (fake-tested + live smoke), pytest-asyncio. + +**Spec:** `docs/superpowers/specs/2026-09-16-v1.6-settings-design.md` (parent `docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md`, track E). Track A's plan (`docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md`) is the style reference: every task = failing test → run → implement → run → gates → commit. + +## Global Constraints + +- Run every command through `uv run --frozen ...` (extras already installed; never `uv sync`). +- The user's principle for this track: tool count is not the goal. Every tool: `@cad_tool(summary=, cost=)` above `@mcp.tool(...)`, a rich alias record (`discovery/aliases.py`: AutoCAD command names + shop-floor synonyms), a docstring that names its refusals, membership in a pack (`core` or `settings`), and an explicit `lean` decision (§8.2 of the spec). No near-duplicate registrations. +- Every tool-adding task: regenerate `docs/tool-inventory.json` (`uv run --frozen python scripts/generate_tool_inventory.py`), bump the README release-snapshot tool count, keep the SECTION header count right, bump the frozen snapshot in `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` (precedent: Track A tasks 3/4/11/13/14/17), and run the gates (`tests/test_discovery_aliases.py`, `tests/test_tool_inventory.py`, `tests/test_release_consistency.py`, `tests/test_tool_registry.py`, `tests/test_capability_contract.py`). +- Validation before any write; refusals are `ValueError`/`TypeError` naming the key — with one standing exception: a symbol *name* (block, layer, and from S Task 10 dimstyle / textstyle / mleaderstyle) that fails `security.sanitize_symbol_name` raises `ToolError`, exactly as the existing block and layer tools do; COM paths fake-tested with the `tests/test_block_define.py` `_Recorder` pattern and executed live once by `scripts/smoke_settings_com.py` (Task 24). +- Commit per task with the task's message and the two attribution lines (`Co-Authored-By: Claude Opus 5 (1M context) `, `Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd`); use `-c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com"` if git asks for an identity. +- Worktree groups (see §Execution): tasks inside one group are sequential; groups run in parallel worktrees and are merged by a merge agent that regenerates the three count files. A task must therefore never touch another group's files. + +## File Structure + +| Path | Responsibility | Group | +|---|---|---| +| `engineering/standards/__init__.py`, `dimstyles.py`, `textstyles.py`, `mleaderstyles.py` | presets as data, whitelist, `resolve_dimstyle(preset, overrides) -> dict`, `resolve_textstyle(...)`, `resolve_mleaderstyle(...)` | S | +| `engineering/standards/sysvars.py` | `SYSVAR_CATALOG`, `describe_sysvar(name)`, `search_sysvars(text)`, `check_sysvar_value(name, value)` | C | +| `engineering/standards/papers.py` | `PAPER_SIZES`, `CTB_CATALOG`, `SCALES`, `resolve_page_setup(...) -> dict` | P | +| `engineering/standards/templates.py` | `TEMPLATE_CATALOG`, `resolve_template(name_or_path, engine) -> (path, source)` | P | +| `engineering/standards/pdfinfo.py` | `read_mediabox(path) -> (w_mm, h_mm)` (pure PDF parse, no dependency) | P | +| `engineering/standards/apply.py` | `apply_standard(backend, standard, layers, units) -> dict` | S | +| `engineering/environment/__init__.py`, `layer_states.py` | snapshot ↔ XRECORD chunks codec, `diff_snapshot(state, layers)` | V | +| `backends/contracts/styles.py` (new) | `StylesContract` | S | +| `backends/contracts/pagesetup.py` (new) | `PageSetupContract` | P | +| `backends/contracts/environment.py` (new) | `EnvironmentContract` | V | +| `backends/contracts/settings.py` (edit) | facade keys | C | +| `backends/base.py` (edit) | compose the three contracts into `AutoCADBackend` | merge task | +| `backends/ezdxf_backend.py`, `backends/com_backend.py` | implementations (each group adds its own methods in its own clearly delimited block) | all | +| `templates/*.dxf`, `templates/*.dwt`, `templates/README.md`, `scripts/build_templates.py` | bundled templates | P | +| `scripts/smoke_settings_com.py` | live COM smoke | final | +| `server.py` | SECTION 18 (S), SECTION 19 (P), SECTION 20 (V + C's `system_variable_describe`, `drawing_properties_*`); `drawing_new(template=)` growth (P); `drawing_settings` docstring (C) | per group | +| `discovery/aliases.py` | 38 records (each group adds its own block, alphabetical within the block) | per group | +| `benchmarks/tasks_v4.py`, `benchmarks/adapters/autocad_mcp_pro.py`, `benchmarks/correctness_suite.py` | `page_setup_truth`, +3 checks | final | +| `docs/analysis/track-a-review-backlog.md` | Track A minor findings, dispositions | H | +| tests: `tests/test_standards_presets.py`, `tests/test_styles.py`, `tests/test_settings_facade.py`, `tests/test_sysvar_catalog.py`, `tests/test_drawing_properties.py`, `tests/test_page_setup.py`, `tests/test_templates.py`, `tests/test_batch_plot.py`, `tests/test_documents.py`, `tests/test_layer_states.py`, `tests/test_views_ucs.py`, `tests/test_environment_live.py`, `tests/test_track_a_hardening.py`, `tests/test_benchmark_v4.py` (edit) | | | + +## Interfaces (the contract every task builds to — copy signatures verbatim) + +### Styles (`backends/contracts/styles.py`, group S) + +```python +class StylesContract(ABC): + @abstractmethod + async def dimstyle_list(self) -> list[dict]: ... + # [{"name", "current": bool, "values": {DIMTXT, DIMASZ, DIMEXE, DIMEXO, DIMGAP, DIMTAD, DIMTIH, DIMTOH, + # DIMDEC, DIMDSEP, DIMLUNIT, DIMZIN, DIMBLK, DIMTXSTY, DIMLWD, DIMLWE, DIMSCALE}}] + @abstractmethod + async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict: ... + # values: the fully resolved dict from engineering.standards.dimstyles.resolve_dimstyle(preset, overrides) + # → {"ok": True, "name", "values", "current": bool, "textstyle_created": bool} + @abstractmethod + async def dimstyle_modify(self, name: str, values: dict) -> dict: ... + # → {"ok", "name", "changed": {VAR: [old, new]}, "dimensions_using_style": [handles], "rerender_required": bool} + @abstractmethod + async def dimstyle_set_current(self, name: str) -> dict: ... # → {"ok", "current", "previous"} + @abstractmethod + async def textstyle_list(self) -> list[dict]: ... + # [{"name", "font", "height", "width_factor", "oblique_deg", "current": bool}] + @abstractmethod + async def textstyle_create(self, name: str, font: str, height: float = 0.0, width_factor: float = 1.0, + oblique_deg: float = 0.0, set_current: bool = False) -> dict: ... + # → {"ok", "name", "font", "font_resolved": bool, "current"} + @abstractmethod + async def textstyle_set_current(self, name: str) -> dict: ... + @abstractmethod + async def mleaderstyle_list(self) -> list[dict]: ... # [{"name", "arrow_size", "landing_gap", "text_style", "text_height"}] + @capability("mleaderstyle", reason="ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's per-leader parameters") + async def mleaderstyle_create(self, name: str, values: dict) -> dict: ... # ezdxf only +``` + +`engineering/standards/dimstyles.py`: `PRESETS = {"iso-25": {...}, "ansi": {...}}` (values exactly as spec §4.1, DIMDSEP as a one-character string), `DIM_VARIABLE_WHITELIST: frozenset`, `DIM_VARIABLE_RANGES: dict[str, tuple|set]`, `resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict` (raises `ValueError` naming the key on unknown/out-of-range; preset `None` means the ISO-25 base with overrides), `describe_preset(name) -> dict`. +`engineering/standards/textstyles.py`: `TEXT_PRESETS = {"ISOCP": ("isocp.shx", 1.0, 0.0), "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), "ARIAL": ("arial.ttf", 1.0, 0.0), "ROMANS": ("romans.shx", 1.0, 0.0)}`, `resolve_font(font) -> (font_file, known: bool)`. +`engineering/standards/mleaderstyles.py`: `MLEADER_PRESETS = {"iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0}}`, `resolve_mleaderstyle(preset, overrides)`. +`engineering/standards/apply.py`: `async def apply_standard(backend, standard: str, layers: bool = True, units: bool = True) -> dict` → `{"standard", "dimstyle": {"name", "created", "current": True}, "textstyle": {"name", "created", "current": True}, "settings": {"changed": {VAR: [old, new]}, "applied": bool}, "layers": {"layer_set", "layers": {name: "created"|"exists"|"failed: ..."}} | None}` — the shape S Task 12 ships and its tests pin (`settings` is the two-key dict `{changed, applied}`, not the bare `changed` map); also `STANDARDS`, `STANDARD_SYSVARS`, `resolve_standard(standard) -> (key, row)`. + +### Settings (group C) + +- `backends/contracts/settings.py`: `_SETTING_MAP` gains `limits` (kind `"limits"` → `$LIMMIN`/`$LIMMAX`), `grid` (`"bool"` → GRIDMODE), `grid_spacing` (`"spacing"` → GRIDUNIT, both axes), `snap` (`"bool"` → SNAPMODE), `snap_spacing` (`"spacing"` → SNAPUNIT), `ortho` (`"bool"` → ORTHOMODE), `polar` (`"bit"` → **AUTOSNAP bit 8** — measured by C Task 14: the F10 toggle is AUTOSNAP bit 8, POLARMODE bit 0 only selects relative polar angles, so the spec's "POLARMODE bit 0" is corrected), `polar_angle` (`"degrees"` → POLARANG, stored in radians), `psltscale` (`"bool"` → PSLTSCALE), `annotation_scale` (`"scale"` → CANNOSCALE `"1:50"`; CANNOSCALEVALUE 0.02 is derived and read-only), `linear_units` (`"lunits"` enum → LUNITS 1–5), `angular_units` (`"aunits"` enum → AUNITS 0–4), `dimstyle` (`"name"` → `dimstyle_set_current`), `textstyle` (`"name"` → `textstyle_set_current`). `drawing_settings(get)` reports every key; ranges in `_SETTING_RANGES`; `parse_scale(text) -> (name, paper, drawing)` and `scale_value(text) -> float` are module-level (the ezdxf backend imports them). +- `engineering/standards/sysvars.py`: `SYSVAR_CATALOG: dict[str, SysVar]` — 90 authored rows (`SysVar(name, type: "int"|"float"|"str"|"bool"|"point"|"enum", range: tuple|None, enum: dict|None, default, meaning, saved_in: "drawing"|"registry"|"not_saved", engines: {"ezdxf": bool, "com": bool}, friendly_key: str|None, read_only: bool = False)` — `read_only` is the one field beyond the spec's signature, trailing and defaulted: DIMSTYLE, CANNOSCALEVALUE, UCSNAME, VIEWCTR, VIEWSIZE, DWGNAME, DWGPREFIX are read-only in AutoCAD and `check_sysvar_value` needs to know), `describe_sysvar(name) -> dict` (`known: False` + `nearest: [names]` on miss), `search_sysvars(text) -> list[dict]`, `check_sysvar_value(name, value) -> None | str` (message when out of range; `None` when fine or unknown). +- `server.py`: `system_variable_describe(name=None, search=None)`; `system_set_variable` calls `check_sysvar_value` first and refuses with its message. +- `backends/contracts/environment.py` (this group owns these two members; group V owns the rest of the module — coordinate by adding them in a clearly marked `# ── properties ──` block): + +```python + @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": [...]} +``` + +### Page setup and templates (`backends/contracts/pagesetup.py`, group P) + +```python +class PageSetupContract(ABC): + @abstractmethod + async def page_setup_list(self, layout: str | None = None) -> list[dict]: ... + # [{"layout", "paper", "size_mm": [w, h], "orientation", "plot_style", "scale", "plot_area", "device", "margins_mm": [t, b, l, r], "center": bool}] + @abstractmethod + async def page_setup_apply(self, layout: str, setup: dict) -> dict: ... + # setup: resolved by engineering.standards.papers.resolve_page_setup(paper, orientation, plot_style, scale, plot_area, device, margins_mm, center) + # → {"ok", "layout", "applied": {...setup}, "changed": {key: [old, new]}, "plot_style_known": bool} + @abstractmethod + async def plot_style_list(self) -> list[dict]: ... # [{"name", "source": "catalog"|"installed"}] + @abstractmethod + async def drawing_template_save(self, path: str, name: str | None = None, description: str | None = None) -> dict: ... + # ezdxf: suffix .dxf writes; suffix .dwt → raise UnsupportedCapabilityError("dwt_write"); COM: SaveAs(path, acTemplateDwg) for .dwt, DXF export for .dxf + # → {"ok", "path", "format": "dxf"|"dwt", "backend"} +``` + +`engineering/standards/papers.py`: `PAPER_SIZES = {"ISO_A0": (841, 1189), "ISO_A1": (594, 841), "ISO_A2": (420, 594), "ISO_A3": (297, 420), "ISO_A4": (210, 297), "ANSI_A": (216, 279), "ANSI_B": (279, 432), "ANSI_C": (432, 559), "ANSI_D": (559, 864), "ANSI_E": (864, 1118)}` (portrait w×h; landscape swaps), `CTB_CATALOG = ("monochrome.ctb", "acad.ctb", "Grayscale.ctb", "Screening 100%.ctb", "Screening 75%.ctb", "Screening 50%.ctb", "Screening 25%.ctb", "Fill Patterns.ctb", "DWF Virtual Pens.ctb")`, `SCALES = {"fit": None, "1:1": 1.0, "1:2": 0.5, "1:5": 0.2, "1:10": 0.1, "1:20": 0.05, "1:50": 0.02, "1:100": 0.01, "2:1": 2.0, "5:1": 5.0, "10:1": 10.0}`, `resolve_page_setup(paper, orientation="landscape", plot_style="monochrome.ctb", scale="fit", plot_area="layout", device="DWG To PDF.pc3", margins_mm=None, center=True) -> dict` (raises `ValueError` naming the field), `canonical_media_name(paper, orientation) -> str` (`"ISO_A3_(420.00_x_297.00_MM)"` — AutoCAD's spelling: landscape A3 is 420×297). +`engineering/standards/pdfinfo.py`: `read_mediabox(path) -> tuple[float, float]` in mm (parses the first `/MediaBox [x0 y0 x1 y1]`, points → mm ×25.4/72; raises `ValueError` when absent). +`engineering/standards/templates.py`: `TEMPLATE_CATALOG` (five rows as spec §6), `resolve_template(name_or_path: str, engine: str) -> tuple[str, str]` (`(path, "bundled"|"path")`; bundled = `templates/.dxf` for ezdxf, `templates/.dwt` for com, falling back to the `.dxf` with `source: "bundled_dxf"` when the `.dwt` is missing), `TEMPLATES_DIR`. +Server-level compositions in `engineering/standards/plot.py`: `async def batch_plot(backend, layouts, output_dir, file_pattern="{drawing}-{layout}.pdf") -> dict` → `{ok, drawing, output_dir, format: "pdf", sheets: [{layout, ok, path, bytes, mediabox_mm: [w, h] | None, paper: str | None, error?}], count}` — the per-sheet list is under **`sheets`** (P Task 18's key, pinned by its tests and tool docstring; F Task 25 reads `report["sheets"][0]`), each row's `mediabox_mm` from `read_mediabox`; `safe_filename(text) -> str`. + +### Environment (`backends/contracts/environment.py`, group V) + +```python +class EnvironmentContract(ABC): + @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: ... # → {"ok", "active", "previous"} + @abstractmethod + async def document_close(self, name_or_path: str | None = None, save: bool = False, discard: bool = False) -> dict: ... + # unsaved and not save and not discard → ValueError naming the document; → {"ok", "closed", "saved": bool, "active": name|None} + @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: ... + @abstractmethod + async def view_named_save(self, name: str, center=None, height: float | None = None, width: float | None = None) -> dict: ... + # → {"ok", "name", "center": [x, y], "height", "width", "replaced"} + @abstractmethod + async def view_named_restore(self, name: str) -> dict: ... # ezdxf: sets $VIEWCTR/$VIEWSIZE, "applied": "header_only" + @abstractmethod + async def view_named_list(self) -> list[dict]: ... + @abstractmethod + async def ucs_list(self) -> list[dict]: ... # [{"name", "origin", "x_axis", "y_axis", "current": bool}] + the implicit "world" + @abstractmethod + async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict: ... # non-orthogonal → ValueError with the measured angle + @abstractmethod + async def ucs_restore(self, name: str) -> dict: ... # "world" resets + @capability("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("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("preferences", reason=...) + async def preferences_set(self, key: str, value) -> dict: ... # → {"ok", "key", "old", "new"} + @capability("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("interactive_prompt", reason=...) + async def user_select(self, prompt: str, mode: str = "single") -> dict: ... # → {"cancelled", "handles": [...]} + @capability("interactive_prompt", reason=...) + async def system_prompt_message(self, text: str) -> dict: ... # → {"ok"} +``` + +`engineering/environment/layer_states.py`: `DICT_NAME = "ACADMCP_LAYERSTATES"`, `PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current")`, `snapshot_from_layers(layers: list[LayerInfo], current_layer: str, *, plot: dict[str, bool] | None = None, description: str | None = None) -> dict` (the two keyword-only extensions V Task 22 ships: `LayerInfo` carries no plot flag, so the engines read `layer.dxf.plot` / `Plottable` themselves and pass it in), `encode_state(state: dict) -> list[str]` (JSON, ASCII-escaped chunks ≤ 255), `decode_state(chunks) -> dict` (`ValueError` on anything that is not a version-1 state), `diff_snapshot(state, layers) -> (missing, new)`, `validate_properties(properties) -> tuple[str, ...]`, `validate_state_name(name) -> str`. +Preference whitelist (COM): `PREFERENCE_KEYS = {"OpenSave.SaveAsType": ("enum", {...}), "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)}` and `READ_ONLY_KEYS = ("Files.SupportPath", "Files.TemplateDwgPath", "Files.PrintStyleSheetPath", "Files.PrinterConfigPath")` in `engineering/environment/preferences.py` (pure data + `validate_preference(key, value)`). + +### Capability keys (both maps, every group's task that introduces one) + +`mleaderstyle` (COM False), `dwgprops` (ezdxf False), `dwt_write` (ezdxf False), `live_application`, `preferences`, `interactive_prompt` (ezdxf False, COM True). `tests/test_capability_contract.py` fails until both maps agree. Map-only feature entries (no decorator): `registry_sysvar` (C; ezdxf False), `documents`, `named_views`, `ucs` (V; True on both) and `layer_states` (V; True on both), whose spelling is pinned by F Task 24's `test_layer_states_are_declared_as_ours_not_autocads` and quoted by the README and CLAUDE.md, so V writes it verbatim in both maps: `FeatureCapability(True, "xrecord", reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager")`. + +### Packs and profile (final task) + +`PACK_TOOL_NAMES["settings"]` = the 22 SECTION 20 tools; `LEAN_TOOL_NAMES` += `drawing_apply_standard`, `page_setup_apply`, `batch_plot`, `dimstyle_set_current`, `textstyle_set_current`; `TOOL_PACK_NAMES = ("core", "pid", "settings")`. + +## Execution + +| Group | Tasks | Worktree | Files it owns | +|---|---|---|---| +| H (hardening) | 1–8 | `track-e/hardening` | the files each fix names + `tests/test_track_a_hardening.py` + `docs/analysis/track-a-review-backlog.md`; Task 6 also moves the two assertion lines of `benchmarks/adapters/autocad_mcp_pro.py::_task_pid_roundtrip` (the Final group's file — the fix changes the truth the adapter asserts; named in H's commit message, and F Task 25 appends `_task_page_setup_truth` directly after that method, so the merge is additive) | +| S (styles) | 9–13 | `track-e/styles` | `engineering/standards/{__init__,dimstyles,textstyles,mleaderstyles,apply}.py`, `backends/contracts/styles.py`, backend blocks `# ── styles (track E) ──`, `server.py` SECTION 18, alias block, `tests/test_standards_presets.py`, `tests/test_styles.py`; Task 13 also appends five tuning cases to `tests/data/discovery_golden.py` (docstring → `Seventy-one cases (56 tuning + 15 holdout)`) — a Final-group file, so F Task 24 appends its ten *after* S's five and writes `Eighty-one cases (66 tuning + 15 holdout)` | +| C (settings) | 14–16 | `track-e/settings` | `backends/contracts/settings.py`, `engineering/standards/sysvars.py`, properties members, `server.py` `system_variable_describe`/`drawing_properties_*` in SECTION 20 stub + `system_set_variable` check, `tests/test_settings_facade.py`, `tests/test_sysvar_catalog.py`, `tests/test_drawing_properties.py` | +| P (page setup, templates) | 17–20 | `track-e/pagesetup` | `engineering/standards/{papers,pdfinfo,templates,plot}.py`, `backends/contracts/pagesetup.py`, backend blocks `# ── page setup (track E) ──`, `templates/`, `scripts/build_templates.py`, `server.py` SECTION 19 + `drawing_new` template resolution, `tests/test_page_setup.py`, `tests/test_templates.py`, `tests/test_batch_plot.py` | +| V (environment) | 21–23 | `track-e/environment` | `backends/contracts/environment.py` (whole module; C's two members are added here too, C only implements them), `engineering/environment/`, backend blocks `# ── environment (track E) ──`, `server.py` SECTION 20, `tests/test_documents.py`, `tests/test_layer_states.py`, `tests/test_views_ucs.py`, `tests/test_environment_live.py` | +| Final | 24–26 | main | `backends/base.py` composition (if not already merged), packs/lean, aliases golden queries, benchmark + correctness, live smoke, docs, gates | + +Because C and V both need `EnvironmentContract`: **V creates the module** with every member including `drawing_properties_*`; **C adds the two implementations** to both backends and registers its three tools (`system_variable_describe` in Task 15, which opens the stub at `(1 tool)`, then `drawing_properties_get/set` in Task 16) under a `# ── SECTION 20: Environment (3 tools) ──` header that V's merge extends to the full count (V ends at `(19 tools)`; merged `(22 tools)`, resolved by F Task 24 Step 3.3, which moves all three C blocks under V's header). The merge agent resolves the header count and the three count files. `backends/base.py` composition of the three new contracts is done by **whichever group merges first** (each group's first task adds its own contract to the `AutoCADBackend` bases if absent — an idempotent one-line edit). + +### Merge notes (binding for every group and for the merge agent) + +**Double-owned members — exactly two, no others.** (1) `drawing_template_save`: P Task 17 declares it (`@abstractmethod` in `PageSetupContract`) and gives both backends a stub that raises `UnsupportedCapabilityError("dwt_write", "drawing_template_save: not implemented yet (Task 20)")`; P Task 20 replaces both stubs with the implementations. Same group, sequential tasks — nothing for the merge agent. (2) `drawing_properties_get` / `drawing_properties_set`: V Task 21 declares them in `EnvironmentContract` under a temporary `@capability("dwgprops", reason="drawing properties arrive with group C's merge")` (so V's worktree instantiates both backends without C's code); C Task 16 implements them on both backends. **The merge agent flips V's two decorators to `@abstractmethod`** (deleting V's `# TEMPORARY` comment block) once C's implementations are present, and keeps exactly one `dwgprops` entry per capability map — C's (V's line is byte-identical to C's by construction, see below, and is deleted together with the decorators; a repeated dict key is ruff F601). Every other contract member has one declaring task and one implementing task. + +**One `_active_vport`.** `EzdxfBackend._active_vport(doc)` (staticmethod: the `*Active` VPORT entry, created when absent) is defined by **C Task 14** in the `# ── settings (track E, group C) ──` block — grid/snap live on that entry — and C's definition is the one that ships. V Task 22 needs the same helper for `view_named_*` and its worktree has no C code, so V carries a byte-equivalent copy marked `# TEMPORARY (track-e/environment worktree only)`; **the merge agent deletes V's copy** (two definitions in one class are ruff F811, and the later one silently wins). V's callers (`self._active_vport(doc)`) need no change. + +**Method-block anchors in `backends/ezdxf_backend.py` and `backends/com_backend.py` — one distinct, non-adjacent position per group, so the four groups never touch the same lines:** + +| Group | Block banner | ezdxf anchor | COM anchor | +|---|---|---|---| +| S | `# ── styles (track E) ──` | immediately before ` # ── corner ops` (i.e. directly after `system_run_lisp`) | immediately before ` # ── corner ops` | +| C | `# ── settings (track E, group C) ──` | **at the very end of the class** — after `_create_xline`, the file's last method (below the `# ── premium meta-tools live on AutoCADBackend …` banner) | at the very end of the class, after `_create_xline` | +| V | `# ── environment (track E) ──` | immediately after the `# ── layouts / paper space ──` block, i.e. directly before ` # ── viewports ──` (after `layout_copy`) | immediately after the `# ── layouts / paper space ──` block, i.e. directly before ` # ── selection filters (M8 / F1) ──` (after `layout_copy`) | +| P | `# ── page setup (track E) ──` | immediately before ` # ── 3D solids (unsupported headlessly …)` (after the change-space block) | immediately before ` # ── 3D solids (native ActiveX; …)` (after the viewports block) | + +C's in-place replacement of `system_get_variable` / `system_set_variable` (Task 14) and V's two-key edit inside `system_status` (Task 21) are both in the `# ── system ──` section but 15+ lines apart; S's block starts after `system_run_lisp`, 20+ lines below C's replacement. Later tasks of a group append inside that group's own block (C Task 16 after `_set_annotation_scale`; V Tasks 22/23 after `document_close`; P Tasks 18/20 inside the page-setup block). + +**Capability-map entries (`features` dict of `capabilities()`, both backends).** In each worktree the only shared anchor that exists is the `"lisp"` entry, so S, C and V each insert directly after `"lisp"` there and the merge agent resolves that one hunk by concatenating in this order, by key name — S's entries directly after `"lisp"`, C's after S's last key, V's after C's last key: `"lisp"` → **S** `"mleaderstyle"` → **C** `"registry_sysvar"`, `"dwgprops"` → **V** `"documents"`, `"layer_states"`, `"named_views"`, `"ucs"`, `"live_application"`, `"preferences"`, `"interactive_prompt"` (V's own `"dwgprops"` line dropped, see above). **P** anchors after an existing key nobody else touches — `"paper_space"` — so its `"dwt_write"` entry never conflicts. The two `dwgprops` lines are byte-identical: V Task 21 copies C Task 16's `FeatureCapability(False, reason="summary_fields_need_live_autocad;custom_properties_supported")` (ezdxf) and `FeatureCapability(True, "native")` (COM). + +**Textual overlaps that are conflicts but not decisions.** `server.py`: every group inserts its SECTION immediately before the `# ── RESOURCES` banner (after `pid_tag_parse`) — merged order is SECTION 18 (S) → SECTION 19 (P) → SECTION 20 (C's three blocks folded under V's header, F Task 24 Step 3.3); in `discovery/aliases.py` the S, P and V alias blocks all land after the P&ID block at the end of `TOOL_ALIASES` (merged order S → P → V, each block alphabetical within itself), while C edits existing records in place (`drawing_settings`, `system_variable_describe` next to `system_set_variable`, the `DWGPROPS` line of `SHARED_ACAD_COMMANDS`); the `SHARED_ACAD_COMMANDS` tuple gains S's `DIMSTYLE` / `MLEADERSTYLE` / `STYLE`, P's `PAGESETUP` / `PLOT` / `SAVEAS`, C's `DWGPROPS` and V's `CLOSE` / `LAYERSTATE` / `OPTIONS` / `UCS` / `VIEW` — alphabetical position each, so the hunks interleave but do not contradict. README "What you get" table: P Task 19 inserts a **Templates** row after **Paper space** and V Task 21 inserts an **Environment** row after **Paper space** — same insertion point; merged order is Paper space → Templates → Environment (F Task 26 later adds the Styles row and the final Environment wording). `.gitattributes`: P Task 19's `templates/*.dwt binary` and F Task 25's `*.dwt binary` overlap harmlessly — keep both. `tests/test_tool_registry.py` snapshot, `README.md` tool count, `docs/tool-inventory.json` and the SECTION 20 header count are the three-plus-one count files the merge agent re-derives (F Task 24 pins the final numbers: 204 tools, 23 groups, `(22 tools)`). `tests/data/discovery_golden.py`: S Task 13's five cases land first, F Task 24's ten after them. + +--- + + +# v1.6 Track E — Group H: Track A hardening (Tasks 1–8) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Group:** H (hardening) · **Worktree:** `track-e/hardening` · **Parent plan:** `docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md` (its Global Constraints, File Structure, Interfaces and Execution sections are binding here) · **Spec:** `docs/superpowers/specs/2026-09-16-v1.6-settings-design.md` §2 (wave 0) · **Baseline:** main at `ab258d1` (166 tools, 2423 collected tests). + +**Goal:** Close the seven Track A review findings that are real defects — on both engines, each refused *before* any write and pinned by a test — and file every remaining minor finding with a disposition. + +**Architecture:** No new tools, no new contract members except one optional kwarg (`block_define(create_layers=False)`). Shared validation lives in `backends/block_specs.py` (both engines import it); the P&ID fixes live in `engineering/pid/{lines,drawlines,spec}.py`. Headless tests run against a real ezdxf document; COM against a recording fake ActiveX surface (`tests/test_block_define.py::_Recorder` pattern, grown into `_FakeObject` / `_FakeBlocks` / `_FakeLayers` / `_FakeSpace` in the new test module). + +**Tech Stack:** Python 3.11+, ezdxf 1.4.4, pywin32 (fake-tested), pytest-asyncio. + +## Group constraints (in addition to the parent plan's Global Constraints) + +- Run everything through `uv run --frozen ...`. +- Every fix: refusal is measured (the plan quotes the probe), named (`ValueError`/`TypeError` naming the key or path), raised before any write, and tested on both engines. +- No file owned by another group is edited. The one deliberate exception is recorded in Task 6 (`benchmarks/adapters/autocad_mcp_pro.py`, owned by the final group): the fix changes the truth that adapter asserts, so the adapter's two assertion lines move with it; the merge agent is told in the commit message. +- The README `collected tests` figure is regenerated by the merge agent (parent plan, Execution §), so every gate step below deselects `tests/test_release_consistency.py::test_document_numbers_match_the_tree`; it fails only on that figure and every other test in the file must pass. +- Tool steps keep the user's principle: the touched tools keep their alias record (grown where the fix adds vocabulary), their docstring names the refusals, and pack / `lean` membership is stated (unchanged in every task here: `block_*` and `entity_create_block_ref` are `core` and not lean; `pid_line_draw` is `pid` and lean; `pid_from_spec` is `pid` and not lean). +- Commit per task with the message given and the two attribution lines: + ``` + Co-Authored-By: Claude Opus 5 (1M context) + Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd + ``` + (`-c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com"` if git asks for an identity.) + +## Measured defects (2026-09-16, ezdxf 1.4.4, main `ab258d1`) + +| # | Probe | Result on main | +|---|---|---| +| 1 | `await b.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2)` | handle `33`, `block_name 'NO_SUCH_BLOCK'`, modelspace 0→1; `doc.audit()` reports 0 errors and the modelspace is empty afterwards (the INSERT is silently removed). `block_insert("*Model_Space", 0, 0)` → handle `35` (a reference cycle). | +| 2 | insert `TB` with `{"TAG": "P-101"}`, `block_explode(handle)` | `inserted_handles == [LINE]` only — the value `P-101` is gone. `ent.virtual_entities()` yields `['LINE']` (no ATTRIB, no ATTDEF). | +| 3 | `block_insert("TB", 0, 0, attributes={"TAG": None})` | `block_get_attributes == {"TAG": "None"}`; `attributes=[("TAG","x")]` → `AttributeError: 'list' object has no attribute 'items'` *after* `add_blockref` wrote the INSERT. | +| 4 | `block_define("PID_NAN", CIRC, base_x=nan, base_y=inf)` | `ok: True`, BLOCK base point `Vec3(nan, inf, 0)`. A primitive on `NOT_A_LAYER` is written with no layer-table entry; `doc.audit()` 0 errors. | +| 5 | `run_spec` with `equipment[0].x = nan` | INSERT placed at `[nan, 0]`; with `draw_line` monkeypatched to raise `asyncio.CancelledError`: 5 entities left, `transaction_depth 1`. `_num(nan)` returns `nan`. | +| 6 | pneumatic line from a bubble | `line_number "1"`, a `"1"` label on `PROCESS-LINE-TEXT`, next process line is seq 2. Bubble at (100,160) with `route=[[130,160],[130,100]]` → first vertex `[100,155]` (exit towards the far end), first segment 4.93 mm from the bubble centre (r = 5). | +| 7 | payload `{"seq": True, "from": "garbage", "to": None}` on a P&ID-layer polyline, then `draw_line` | `TypeError: string indices must be integers, not 'str'`. A bulge −1 semicircle (0,0)→(100,0) and a utility line at x=30, y 20→60: `crossings 0` (the chord never crosses; the arc does). | + +--- + +### Task 1: `entity_create_block_ref` refuses an undefined (or layout) block before writing, on both engines + +The Track A fix (`6f4bda5`) gated `block_insert` only; its sibling `entity_create_block_ref` still returns a handle for a typo'd name and `doc.audit()` later deletes the INSERT. Both engines get one shared gate per engine, used by both placing tools, and the gate also refuses the layout blocks `*Model_Space` / `*Paper_Space` (they are in `doc.blocks`; inserting one is the reference cycle AutoCAD's INSERT refuses). + +**Files:** +- Modify: `backends/ezdxf_backend.py` (new `_require_block` after `_require_doc` ≈ line 1085; `entity_create_block_ref` ≈ line 2993; `block_insert` ≈ line 4468) +- Modify: `backends/com_backend.py` (new module function `_require_block_defined` before `_BUILTIN_LINETYPES` ≈ line 195; `entity_create_block_ref` ≈ line 2471; `block_insert` ≈ line 3239) +- Modify: `server.py` (`entity_create_block_ref` docstring ≈ line 1660) +- Create: `tests/test_track_a_hardening.py` + +**Interfaces:** +- Produces: `EzdxfBackend._require_block(name) -> BlockLayout` and `com_backend._require_block_defined(doc, name) -> Block`. Both raise `TypeError("block name must be a non-empty string")`, `ValueError(f"block {name!r} is not defined")` (the message `tests/test_block_insert_attributes.py` already pins) or `ValueError(f"block {name!r} is a layout block and cannot be inserted")`, before any write. +- Consumes: ezdxf `BlockLayout.block_record.is_any_layout` (measured: `True` for `*Model_Space`, `doc.blocks` membership is case-insensitive); ActiveX `Blocks.Item(name)` raises for an unknown name, `Block.IsLayout`. + +- [ ] **Step 1: Write the failing tests (the module's fakes serve Tasks 1–4)** + +```python +# tests/test_track_a_hardening.py +"""Track A hardening (v1.6 track E, wave 0): the block-tool defects the Track A +review found, fixed on both engines. + +Headless tests run against a real ezdxf document; the live engine runs against +a fake ActiveX surface that records every call, so the tests assert the call +shape (what is dispatched, in which order, and — for a refusal — that nothing +is dispatched at all). +""" + +from __future__ import annotations + +import types + +import pytest + +from backends.base import EntityInfo + +pytestmark = pytest.mark.asyncio + + +def _define_tagged_block(backend, name: str = "TB") -> None: + blk = backend._doc.blocks.new(name=name) + blk.add_line((0, 0), (10, 0)) + blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5}) + + +# ── fake ActiveX surface ────────────────────────────────────────────────────── + + +class _FakeObject: + """An ActiveX object with only the members it is given; property writes are recorded.""" + + def __init__(self, object_name: str, handle: str, **members): + object.__setattr__(self, "ObjectName", object_name) + object.__setattr__(self, "Handle", handle) + object.__setattr__(self, "writes", []) + object.__setattr__(self, "deleted", False) + for name, value in members.items(): + object.__setattr__(self, name, value) + + def __setattr__(self, name, value): + self.writes.append((name, value)) + object.__setattr__(self, name, value) + + def __getattr__(self, name): + raise AttributeError(name) + + def Delete(self): + object.__setattr__(self, "deleted", True) + + +class _FakeBlock: + def __init__(self, name: str, is_layout: bool = False): + self.Name = name + self.IsLayout = is_layout + self.Count = 0 + self.calls: list[tuple[str, tuple]] = [] + + def __getattr__(self, attr): + if attr.startswith("Add"): + + def _call(*args): + self.calls.append((attr, args)) + return _FakeObject("AcDbEntity", f"M{len(self.calls)}") + + return _call + raise AttributeError(attr) + + +class _FakeBlocks: + def __init__(self, *names: str): + self.blocks: dict[str, _FakeBlock] = { + "*Model_Space": _FakeBlock("*Model_Space", is_layout=True), + } + for name in names: + self.blocks[name] = _FakeBlock(name) + + def Add(self, base_point, name): + self.blocks[name] = _FakeBlock(name) + self.blocks[name].base_point = tuple(base_point.value) + return self.blocks[name] + + def Item(self, name): + if name not in self.blocks: + raise RuntimeError(f"no block {name}") + return self.blocks[name] + + +class _FakeLayers: + def __init__(self, *names: str): + self.names = {"0", *names} + self.added: list[str] = [] + + def Item(self, name): + if name not in self.names: + raise RuntimeError(f"no layer {name}") + return types.SimpleNamespace(Name=name) + + def Add(self, name): + self.names.add(name) + self.added.append(name) + return types.SimpleNamespace(Name=name) + + +class _FakeSpace: + """The active layout block: records InsertBlock / AddText and hands back styled objects.""" + + def __init__(self, attributes: dict[str, str] | None = None): + self.calls: list[tuple[str, tuple]] = [] + self.attributes = attributes or {} + self.inserted: list[_FakeObject] = [] + + def InsertBlock(self, point, name, sx, sy, sz, rotation): + self.calls.append(("InsertBlock", (tuple(point.value), name, sx, sy, sz, rotation))) + attrs = tuple( + _FakeObject("AcDbAttribute", f"A{i}", TagString=tag, TextString=value) + for i, (tag, value) in enumerate(self.attributes.items()) + ) + ref = _FakeObject( + "AcDbBlockReference", f"R{len(self.inserted)}", Name=name, GetAttributes=lambda: attrs + ) + self.inserted.append(ref) + return ref + + def AddText(self, text, point, height): + self.calls.append(("AddText", (text, tuple(point.value), height))) + return _FakeObject("AcDbText", f"T{len(self.calls)}") + + +@pytest.fixture +def com(monkeypatch): + """A ComBackend over a recording fake: ``document`` and ``space`` are inspectable.""" + from backends import com_backend as module + + document = types.SimpleNamespace(Blocks=_FakeBlocks("TB"), Layers=_FakeLayers(), objects={}) + document.HandleToObject = lambda handle: document.objects[handle] + space = _FakeSpace() + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_msp", lambda: space) + monkeypatch.setattr(module, "_regen", lambda: None) + # The real converter reads a dozen ActiveX members (bounding box, block + # geometry, ...); the fixture only needs the handle and the name back. + monkeypatch.setattr( + module, + "_entity_info", + lambda ent: EntityInfo( + handle=ent.Handle, + type="INSERT", + layer="0", + color=256, + linetype="ByLayer", + visible=True, + properties={"block_name": ent.Name}, + ), + ) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document, space + + +# ── Task 1: entity_create_block_ref refuses an undefined block ──────────────── + + +async def test_block_ref_of_an_undefined_block_is_refused_before_writing(backend): + before = len(list(backend._msp())) + with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"): + await backend.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2) + assert len(list(backend._msp())) == before, "a refused insert must not write" + assert "NO_SUCH_BLOCK" not in backend._doc.blocks + + +async def test_block_ref_of_a_defined_block_still_inserts(backend): + _define_tagged_block(backend) + ref = await backend.entity_create_block_ref("TB", 5, 5, rotation=90) + info = await backend.entity_get(ref.handle) + assert info.type == "INSERT" and info.properties["block_name"] == "TB" + assert info.properties["rotation_deg"] == pytest.approx(90.0) + + +@pytest.mark.parametrize("name", ["*Model_Space", "*Paper_Space"]) +async def test_layout_blocks_are_refused_by_both_placing_tools(backend, name): + with pytest.raises(ValueError, match="layout block"): + await backend.entity_create_block_ref(name, 0, 0) + with pytest.raises(ValueError, match="layout block"): + await backend.block_insert(name, 0, 0) + assert backend._doc.audit().errors == [], "nothing was written, nothing to fix" + + +async def test_a_non_string_block_name_is_a_type_error_on_both_placing_tools(backend): + with pytest.raises(TypeError, match="non-empty string"): + await backend.entity_create_block_ref(None, 0, 0) + with pytest.raises(TypeError, match="non-empty string"): + await backend.block_insert("", 0, 0) + + +async def test_com_block_ref_probes_the_block_table_before_insert_block(com): + backend, document, space = com + with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"): + await backend.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2) + assert space.calls == [], "InsertBlock must not be dispatched for an unknown name" + with pytest.raises(ValueError, match="layout block"): + await backend.entity_create_block_ref("*Model_Space", 0, 0) + assert space.calls == [] + + ref = await backend.entity_create_block_ref("TB", 1, 2, 2.0, 2.0, 90.0) + assert ref.properties["block_name"] == "TB" + assert space.calls == [ + ("InsertBlock", ((1.0, 2.0, 0.0), "TB", 2.0, 2.0, 1.0, pytest.approx(1.5708, abs=1e-4))) + ] + + +async def test_com_block_insert_shares_the_same_gate(com): + backend, document, space = com + with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"): + await backend.block_insert("NO_SUCH_BLOCK", 1, 2, attributes={"TAG": "P-1"}) + assert space.calls == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py -v` +Expected: 6 FAIL, 1 PASS. `test_block_ref_of_an_undefined_block_is_refused_before_writing`, the two `test_layout_blocks_…` cases and `test_com_block_ref_…` fail with `Failed: DID NOT RAISE ValueError` (ezdxf returns a handle; the COM fake's `InsertBlock` is dispatched and recorded); `test_a_non_string_block_name_…` fails with `ezdxf.lldxf.const.DXFValueError: Block name as string required` instead of `TypeError`; `test_com_block_insert_shares_the_same_gate` fails `DID NOT RAISE` (the fake's `InsertBlock` accepts any name). `test_block_ref_of_a_defined_block_still_inserts` passes. + +- [ ] **Step 3: ezdxf — one gate for both placing tools** + +In `backends/ezdxf_backend.py`, insert after `_require_doc` (directly before `def _msp(self):`): + +```python + def _require_block(self, name): + """The block definition ``name`` names, or a refusal before any write. + + ``add_blockref`` never checks the name and ``add_auto_attribs`` skips + autofill when ``Insert.block()`` resolves to None, so a typo'd name + used to yield a dangling INSERT that dropped its attribute values and + that ``doc.audit()`` later deletes. A layout block (``*Model_Space``, + ``*Paper_Space``) is in ``doc.blocks`` too, but inserting one is the + reference cycle AutoCAD's INSERT refuses, so it is refused here by + name. Shared by ``block_insert`` and ``entity_create_block_ref`` so the + two tools that place a block reference agree on what a typo does. + """ + doc = self._require_doc() + if not isinstance(name, str) or not name.strip(): + raise TypeError("block name must be a non-empty string") + if name not in doc.blocks: + raise ValueError(f"block {name!r} is not defined") + blk = doc.blocks.get(name) + if blk.block_record.is_any_layout: + raise ValueError(f"block {name!r} is a layout block and cannot be inserted") + return blk +``` + +In `entity_create_block_ref`, add the gate as the first statement after `msp = self._msp()`: + +```python + def _sync(): + msp = self._msp() + self._require_block(name) + ent = msp.add_blockref( +``` + +In `block_insert`, replace the inline check + +```python + # `add_blockref` never checks the name, and `add_auto_attribs` + # silently skips autofill when `Insert.block()` resolves to None, + # so a typo'd name used to yield a dangling INSERT that dropped its + # attribute values and that `doc.audit()` later deletes. Refuse + # before writing, as COM's `InsertBlock` does for an unknown name. + if name not in self._doc.blocks: + raise ValueError(f"block {name!r} is not defined") + ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) +``` + +with + +```python + self._require_block(name) + ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) +``` + +- [ ] **Step 4: COM — the same gate as a module function** + +In `backends/com_backend.py`, insert before `_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}`: + +```python +def _require_block_defined(doc, name): + """``doc.Blocks.Item(name)``, or a ``ValueError`` before ``InsertBlock`` runs. + + ``InsertBlock`` with an unknown name raises a bare COM error after the call + has already been dispatched; refusing here names the block, costs one + ``Blocks.Item`` probe and matches the headless engine's message. A layout + block (``IsLayout``) cannot be inserted into itself — same refusal by name. + Shared by ``block_insert`` and ``entity_create_block_ref``. + """ + if not isinstance(name, str) or not name.strip(): + raise TypeError("block name must be a non-empty string") + try: + block = doc.Blocks.Item(name) + except Exception as exc: + raise ValueError(f"block {name!r} is not defined") from exc + try: + is_layout = bool(block.IsLayout) + except Exception: + is_layout = False + if is_layout: + raise ValueError(f"block {name!r} is a layout block and cannot be inserted") + return block +``` + +In **both** `entity_create_block_ref` and `block_insert`, make the gate the first statement of `_sync` (before `mspace = _msp()`): + +```python + def _sync(): + _require_block_defined(_acad_doc(), name) + mspace = _msp() + ref = mspace.InsertBlock( +``` + +- [ ] **Step 5: The tool docstring names the refusals** + +In `server.py`, `entity_create_block_ref` — replace + +```python + """Insert a block reference (instance of an existing block definition).""" + await ctx.debug(f"Inserting block '{name}' at ({x},{y})") +``` + +with + +```python + """Insert a block reference (instance of an existing block definition). + + Refused before any write, on both engines: a name that is not a block + definition in this drawing (`block 'X' is not defined` — check + `block_list`), a layout block (`*Model_Space` / `*Paper_Space`, a + reference cycle) and a non-string name. The same gate as `block_insert`; + this tool is the no-attributes form. + """ + await ctx.debug(f"Inserting block '{name}' at ({x},{y})") +``` + +Alias record: `entity_create_block_ref` already carries `acad=("INSERT",)` (shared with `block_insert` and `pid_symbol_insert` through `SHARED_ACAD_COMMANDS`) and four synonyms — unchanged. Pack `core`, not lean — unchanged. + +- [ ] **Step 6: Run the tests and their neighbours** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py tests/test_block_insert_attributes.py tests/test_block_define.py tests/test_layer_block.py tests/test_pid_insert.py -v` +Expected: all PASS (7 in the new module; `test_insert_of_undefined_block_is_refused_before_writing` keeps matching `'NO_SUCH_BLOCK' is not defined`). + +- [ ] **Step 7: Gates** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py tests/test_track_a_hardening.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: ruff clean; all PASS. (The deselected test fails only on the README `collected tests` figure, which the merge agent regenerates.) + +- [ ] **Step 8: Commit** + +```bash +git add backends/ezdxf_backend.py backends/com_backend.py server.py tests/test_track_a_hardening.py +git commit -m "fix(blocks): entity_create_block_ref refuses an undefined or layout block before writing, on both engines + +Track A's task-1 gate covered block_insert only; the sibling tool still +returned a handle for a typo'd name that doc.audit() later deleted, and +both tools accepted *Model_Space (a reference cycle). One gate per engine +(_require_block / _require_block_defined) is now shared by both placing +tools: non-string name -> TypeError, unknown -> ValueError naming it, +layout block -> ValueError, all before add_blockref / InsertBlock. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 2: `block_explode` keeps ATTRIB text on both engines (the BURST rule) + +Measured: ezdxf `Insert.virtual_entities()` yields the definition's geometry only (`['LINE']` for a block with a LINE and an ATTDEF), so the exploded symbol lost its tag value. ezdxf's own `explode_block_reference` documents the intended rule — "attached ATTRIB entities are converted to TEXT entities, this is the behavior of the BURST command of the AutoCAD Express Tools" — and reuses the ATTRIB's handle for the TEXT; the implementation below builds the TEXT explicitly (`msp.add_text` with the ATTRIB's WCS `insert`/`align_point`, `height`, `rotation`, `style`, `layer`, `halign`/`valign`) so no handle is recycled, and marks an invisible ATTRIB's TEXT invisible (`dxf.invisible = 1`) instead of making hidden data appear. Measured on the prototype: alignment `CENTER` survives, `doc.audit()` reports 0 errors, the TEXTs survive a write/read round trip. + +On COM the brief's premise ("Explode already returns ATTDEF-derived TEXT") does not match AutoCAD's documented behaviour: EXPLODE keeps the attribute *definitions* (ATTDEF placeholders showing the tag name) and discards the values, and ActiveX `Explode()` additionally leaves the original reference in place. So the COM path applies the same BURST rule explicitly: read `GetAttributes()` before exploding, delete every `AcDbAttributeDefinition` the explode returns, `AddText` one TEXT per captured ATTRIB, delete the reference. The fake asserts exactly that call shape; the final group's live smoke (`scripts/smoke_settings_com.py`, Task 24) runs it once against AutoCAD. + +**Files:** +- Modify: `backends/ezdxf_backend.py` (`block_explode` ≈ line 4507) +- Modify: `backends/com_backend.py` (`block_explode` ≈ line 3275) +- Modify: `server.py` (`block_explode` docstring ≈ line 3018) +- Modify: `discovery/aliases.py` (`block_explode` record ≈ line 222) +- Test: `tests/test_track_a_hardening.py` (append) + +**Interfaces:** +- Produces: `block_explode(handle) -> {"ok": True, "exploded_handle": handle, "inserted_handles": [geometry handles], "attribute_texts": [one TEXT handle per ATTRIB], "backend": "ezdxf"|"com"}` on both engines. Refusal (unchanged type): `RuntimeError("Entity is not a block reference (INSERT)")` before any write — on COM now checked on `ObjectName` instead of letting `Explode()` fail on a non-INSERT. + +- [ ] **Step 1: Append the failing tests** + +```python +# tests/test_track_a_hardening.py (append) + + +# ── Task 2: block_explode keeps ATTRIB text ────────────────────────────────── + + +async def test_explode_emits_a_text_per_attrib_at_the_attribs_placement(backend): + from ezdxf.enums import TextEntityAlignment + + blk = backend._doc.blocks.new(name="TB2") + blk.add_line((0, 0), (10, 0)) + tag = blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5}) + tag.set_placement((0, 3), align=TextEntityAlignment.CENTER) + hidden = blk.add_attdef("LINK", insert=(0, -3), text="L-1", dxfattribs={"height": 2.0}) + hidden.is_invisible = True + ref = await backend.block_insert( + "TB2", 10, 10, scale_x=2.0, scale_y=2.0, rotation=30.0, attributes={"TAG": "P-101"} + ) + raw_ref = backend._doc.entitydb[ref.handle] + expected = {a.dxf.tag: (tuple(a.dxf.insert)[:2], a.dxf.height) for a in raw_ref.attribs} + + result = await backend.block_explode(ref.handle) + + assert result["ok"] is True and result["exploded_handle"] == ref.handle + assert result["backend"] == "ezdxf" + assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"] + assert len(result["attribute_texts"]) == 2 + texts = {} + for handle in result["attribute_texts"]: + info = await backend.entity_get(handle) + assert info.type == "TEXT" + texts[info.properties["text"]] = info + tag_text = texts["P-101"] + assert tuple(tag_text.properties["insertion"]) == pytest.approx(expected["TAG"][0]) + assert tag_text.properties["height"] == expected["TAG"][1] == 5.0 + assert tag_text.properties["rotation"] == pytest.approx(30.0) + raw = backend._doc.entitydb[tag_text.handle] + assert raw.get_align_enum().name == "CENTER", "alignment survives the conversion" + assert raw.dxf.invisible == 0 + assert backend._doc.entitydb[texts["L-1"].handle].dxf.invisible == 1 + with pytest.raises(RuntimeError, match="not found"): + await backend.entity_get(ref.handle) + assert backend._doc.audit().errors == [] + + +async def test_explode_without_attribs_reports_an_empty_list(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + await backend.block_create_from_entities("PLAIN", [line.handle]) + ref = await backend.block_insert("PLAIN", 0, 0) + result = await backend.block_explode(ref.handle) + assert result["attribute_texts"] == [] and len(result["inserted_handles"]) == 1 + + +async def test_explode_refuses_a_non_insert_without_writing(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + before = len(list(backend._msp())) + with pytest.raises(RuntimeError, match="not a block reference"): + await backend.block_explode(line.handle) + assert len(list(backend._msp())) == before + + +async def test_com_explode_deletes_attdefs_adds_text_per_attrib_and_deletes_the_reference(com): + backend, document, space = com + attdef = _FakeObject("AcDbAttributeDefinition", "D1") + line = _FakeObject("AcDbLine", "L1") + visible = _FakeObject( + "AcDbAttribute", + "A1", + TagString="TAG", + TextString="P-101", + InsertionPoint=(7.0, 15.2, 0.0), + Height=5.0, + Rotation=0.5236, + Layer="PID-TAG", + Invisible=False, + ) + hidden = _FakeObject( + "AcDbAttribute", + "A2", + TagString="LINK", + TextString="L-1", + InsertionPoint=(13.0, 4.8, 0.0), + Height=4.0, + Rotation=0.5236, + Layer="0", + Invisible=True, + ) + ref = _FakeObject( + "AcDbBlockReference", + "2F", + Name="TB", + GetAttributes=lambda: (visible, hidden), + Explode=lambda: (line, attdef), + ) + document.objects["2F"] = ref + + result = await backend.block_explode("2F") + + assert result == { + "ok": True, + "exploded_handle": "2F", + "inserted_handles": ["L1"], + "attribute_texts": ["T1", "T2"], + "backend": "com", + } + assert attdef.deleted is True, "the value-less ATTDEF placeholder is removed" + assert line.deleted is False + assert ref.deleted is True, "ActiveX Explode leaves the reference; BURST removes it" + assert [c for c in space.calls] == [ + ("AddText", ("P-101", (7.0, 15.2, 0.0), 5.0)), + ("AddText", ("L-1", (13.0, 4.8, 0.0), 4.0)), + ] + + +async def test_com_explode_refuses_a_non_insert_before_any_call(com): + backend, document, space = com + document.objects["3A"] = _FakeObject("AcDbLine", "3A") + with pytest.raises(RuntimeError, match="not a block reference"): + await backend.block_explode("3A") + assert space.calls == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py -k explode -v` +Expected: 4 FAIL, 1 PASS. ezdxf: `KeyError: 'exploded_handle'` and `KeyError: 'attribute_texts'`; COM: the result dict has only `ok`/`exploded_handle` (assertion diff) and the non-INSERT case raises `AttributeError: Explode` from the fake instead of the refusal. `test_explode_refuses_a_non_insert_without_writing` already passes. + +- [ ] **Step 3: ezdxf — a TEXT per ATTRIB** + +In `backends/ezdxf_backend.py`, replace the whole `block_explode` method + +```python + async def block_explode(self, handle) -> dict: + def _sync(): + ent = self._get_entity(handle) + if ent.dxftype() != "INSERT": + raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") + msp = self._msp() + # Decompose: add individual entities to modelspace + inserted = [] + for sub in ent.virtual_entities(): + sub_copy = sub.copy() + msp.add_entity(sub_copy) + inserted.append(sub_copy.dxf.handle) + msp.delete_entity(ent) + self._mark_dirty() + return {"ok": True, "inserted_handles": inserted} + + return await self._async(_sync) +``` + +with + +```python + #: ATTRIB DXF attributes carried onto the TEXT that replaces it on explode. + #: ``insert``/``align_point`` are already WCS on an attached ATTRIB. + _ATTRIB_TO_TEXT = ( + "layer", + "color", + "style", + "height", + "rotation", + "width", + "oblique", + "halign", + "valign", + "text_generation_flag", + "insert", + "align_point", + ) + + async def block_explode(self, handle) -> dict: + """Explode an INSERT into its members; ATTRIB values survive as TEXT. + + ``virtual_entities()`` yields the definition's geometry only — no + ATTDEF, no ATTRIB — so an exploded tagged symbol used to lose its tag + text silently. This is the BURST rule (Express Tools): every attached + ATTRIB becomes a TEXT at the same WCS placement, height, rotation, + style and layer, carrying the *value*; an invisible ATTRIB becomes an + invisible TEXT rather than appearing. The new handles are reported as + ``attribute_texts``, separate from the geometry's ``inserted_handles``. + """ + + def _sync(): + ent = self._get_entity(handle) + if ent.dxftype() != "INSERT": + raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") + msp = self._msp() + inserted = [] + for sub in ent.virtual_entities(): + sub_copy = sub.copy() + msp.add_entity(sub_copy) + inserted.append(sub_copy.dxf.handle) + attribute_texts = [] + for attrib in ent.attribs: + dxfattribs = { + key: attrib.dxf.get(key) + for key in self._ATTRIB_TO_TEXT + if attrib.dxf.hasattr(key) + } + text = msp.add_text(attrib.dxf.text, dxfattribs=dxfattribs) + if attrib.is_invisible: + text.dxf.invisible = 1 + attribute_texts.append(text.dxf.handle) + msp.delete_entity(ent) + self._mark_dirty() + return { + "ok": True, + "exploded_handle": handle, + "inserted_handles": inserted, + "attribute_texts": attribute_texts, + "backend": "ezdxf", + } + + return await self._async(_sync) +``` + +- [ ] **Step 4: COM — capture, explode, drop ATTDEFs, AddText, delete the reference** + +In `backends/com_backend.py`, replace the whole `block_explode` method + +```python + async def block_explode(self, handle) -> dict: + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + ent.Explode() + return {"ok": True, "exploded_handle": handle} + + return await self._run(_sync) +``` + +with + +```python + async def block_explode(self, handle) -> dict: + """Explode an INSERT through ActiveX; ATTRIB values survive as TEXT. + + AutoCAD's EXPLODE keeps the attribute *definitions* (the tag names as + ATTDEF entities) and discards the values; ActiveX ``Explode()`` does + the same and, unlike the command, leaves the original reference in + place. This is the BURST rule instead: each ATTRIB's value, placement, + height, rotation and layer are read before the explode, the ATTDEFs + the explode returns are deleted, one ``AddText`` per ATTRIB carries the + value (an invisible ATTRIB becomes an invisible TEXT), and the + reference itself is deleted. Unit-tested against a fake ActiveX + surface; ``Explode()``'s return shape is the ActiveX documented one + (an array of the new objects) and is exercised live by the settings + smoke. + """ + + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(handle) + if ent.ObjectName != "AcDbBlockReference": + raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") + captured = [] + try: + attrs = ent.GetAttributes() + except Exception as exc: + log.debug("GetAttributes failed before explode: %s", exc) + attrs = () + for attr in attrs: + try: + invisible = bool(attr.Invisible) + except Exception: + invisible = False + captured.append( + { + "text": str(attr.TextString), + "insertion": tuple(attr.InsertionPoint), + "height": float(attr.Height), + "rotation": float(attr.Rotation), + "layer": str(attr.Layer), + "invisible": invisible, + } + ) + exploded = ent.Explode() + inserted = [] + for obj in exploded or (): + if obj.ObjectName == "AcDbAttributeDefinition": + obj.Delete() # the tag placeholder EXPLODE leaves; the value is below + continue + inserted.append(str(obj.Handle)) + mspace = _msp() + attribute_texts = [] + for item in captured: + ins = item["insertion"] + text = mspace.AddText( + item["text"], + _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0), + item["height"], + ) + text.Rotation = item["rotation"] + text.Layer = item["layer"] + if item["invisible"]: + text.Visible = False + attribute_texts.append(str(text.Handle)) + ent.Delete() + _regen() + return { + "ok": True, + "exploded_handle": handle, + "inserted_handles": inserted, + "attribute_texts": attribute_texts, + "backend": "com", + } + + return await self._run(_sync) +``` + +- [ ] **Step 5: Tool docstring and alias record** + +In `server.py`, `block_explode` — replace + +```python + """Explode a block reference into its individual component entities.""" + await ctx.warning(f"Exploding block reference {handle}") +``` + +with + +```python + """Explode a block reference into its component entities, keeping its + attribute text (the Express Tools BURST rule, on both engines). + + Every attached ATTRIB becomes a TEXT with the same value, placement, + height, rotation and layer (an invisible attribute becomes an invisible + TEXT); AutoCAD's plain EXPLODE would keep only the tag-name placeholders + and drop the values. Returns `inserted_handles` (the geometry), + `attribute_texts` (one TEXT handle per attribute), `exploded_handle` and + `backend`. Refused: a handle that is not a block reference (nothing is + written). Not undoable except through `drawing_undo` / a transaction. + """ + await ctx.warning(f"Exploding block reference {handle}") +``` + +In `discovery/aliases.py`, replace the `block_explode` record with (BURST is the Express Tools command whose behaviour this now is; no other record claims it): + +```python + "block_explode": ToolAliases( + acad=("EXPLODE", "BURST"), + synonyms=( + "explode", + "break a block apart", + "ungroup", + "split a block into entities", + "flatten a symbol", + "burst a block", + "explode and keep the attribute text", + ), + ), +``` + +Pack `core`, not lean — unchanged; `cost="destructive"` unchanged. + +- [ ] **Step 6: Run the tests** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py tests/test_layer_block.py tests/test_discovery_aliases.py -v` +Expected: all PASS (`tests/test_layer_block.py::test_block_explode` only checks `ok`). + +- [ ] **Step 7: Gates** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py discovery tests/test_track_a_hardening.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all PASS (`docs/tool-inventory.json` is unchanged: the summary line did not move, and `python scripts/generate_tool_inventory.py --check` prints `ok`). + +- [ ] **Step 8: Commit** + +```bash +git add backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_track_a_hardening.py +git commit -m "fix(blocks): block_explode keeps attribute text as TEXT (BURST rule) on both engines + +virtual_entities() yields geometry only, so an exploded tagged symbol lost +its tag value silently. Every attached ATTRIB now becomes a TEXT at the +same WCS placement/height/rotation/style/layer (invisible stays +invisible), reported as attribute_texts. COM reads GetAttributes before +Explode, deletes the ATTDEF placeholders Explode returns, AddText per +value, and deletes the reference ActiveX Explode leaves behind. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 3: attribute values are validated before any write — `null` / bool / objects refused by tag, numbers written as plain text + +Measured: `block_insert("TB", 0, 0, attributes={"TAG": None})` writes the text `None` (`True` → `True`, a dict → its repr) on both engines, and a non-mapping (`[("TAG", "x")]`) fails with `AttributeError` *after* `add_blockref` has written the INSERT (the modelspace grows, `_mark_dirty` is skipped). One shared validator in `backends/block_specs.py` runs before the write on both engines and in both tools. + +**Files:** +- Modify: `backends/block_specs.py` (new `validate_attribute_values`, before `solid_vertices`) +- Modify: `backends/ezdxf_backend.py` (`block_insert`, `block_set_attributes`) +- Modify: `backends/com_backend.py` (`block_insert`, `block_set_attributes`) +- Modify: `server.py` (`block_insert`, `block_set_attributes` docstrings) +- Test: `tests/test_track_a_hardening.py` (append) + +**Interfaces:** +- Produces: `validate_attribute_values(attributes, where="attributes") -> dict[str, str]` — `None` → `{}`; a non-dict → `TypeError("attributes must be an object of {TAG: value} pairs")`; a non-string/empty tag → `TypeError("attributes: every tag must be a non-empty string")`; a string value → itself after the module's `_one_line` rule (`TypeError("attributes['TAG'] must be a single line …")`); an int or finite float → `str(value)` (`101` → `"101"`, `2.5` → `"2.5"`); `bool`, `None`, list, dict → `TypeError("attributes['TAG'] must be a string or a number, got ")`; NaN/inf → `TypeError("attributes['TAG'] must be finite")`. +- Consumes: `_one_line` (same module, Track A Task 2). + +- [ ] **Step 1: Append the failing tests** + +```python +# tests/test_track_a_hardening.py (append) + + +# ── Task 3: attribute values are validated before any write ────────────────── + + +@pytest.mark.parametrize( + "value, fragment", + [ + (None, "NoneType"), + (True, "bool"), + ({"a": 1}, "dict"), + ([1, 2], "list"), + (float("nan"), "finite"), + ("two\nlines", "single line"), + ], +) +async def test_insert_refuses_a_non_text_attribute_value_by_tag(backend, value, fragment): + _define_tagged_block(backend) + before = len(list(backend._msp())) + with pytest.raises(TypeError, match=r"attributes\['TAG'\]") as exc: + await backend.block_insert("TB", 0, 0, attributes={"TAG": value}) + assert fragment in str(exc.value) + assert len(list(backend._msp())) == before, "a refused insert must not write" + + +async def test_insert_refuses_a_non_mapping_before_writing(backend): + _define_tagged_block(backend) + before = len(list(backend._msp())) + with pytest.raises(TypeError, match="attributes must be an object"): + await backend.block_insert("TB", 0, 0, attributes=[("TAG", "x")]) + assert len(list(backend._msp())) == before + + +async def test_insert_writes_numbers_as_plain_text(backend): + _define_tagged_block(backend) + ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": 101}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "101"} + ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": 2.5}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "2.5"} + + +async def test_set_attributes_refuses_by_tag_and_leaves_the_value_alone(backend): + _define_tagged_block(backend) + ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": "P-101"}) + with pytest.raises(TypeError, match=r"attributes\['TAG'\] must be a string or a number"): + await backend.block_set_attributes(ref.handle, {"TAG": None}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "P-101"} + result = await backend.block_set_attributes(ref.handle, {"TAG": 7}) + assert result == {"ok": True, "updated_tags": ["TAG"]} + assert await backend.block_get_attributes(ref.handle) == {"TAG": "7"} + + +async def test_com_insert_refuses_before_insert_block_and_writes_numbers_as_text(com): + backend, document, space = com + space.attributes = {"TAG": ""} + with pytest.raises(TypeError, match=r"attributes\['TAG'\]"): + await backend.block_insert("TB", 0, 0, attributes={"TAG": None}) + assert space.calls == [], "the refusal fires before InsertBlock" + + await backend.block_insert("TB", 0, 0, attributes={"TAG": 101}) + assert [name for name, _ in space.calls] == ["InsertBlock"] + attr = space.inserted[0].GetAttributes()[0] + assert attr.TextString == "101" and ("TextString", "101") in attr.writes + + +async def test_com_set_attributes_refuses_before_touching_the_reference(com): + backend, document, space = com + attr = _FakeObject("AcDbAttribute", "A1", TagString="TAG", TextString="P-101") + calls = [] + + def get_attributes(): + calls.append("GetAttributes") + return (attr,) + + document.objects["2F"] = _FakeObject( + "AcDbBlockReference", "2F", Name="TB", GetAttributes=get_attributes + ) + with pytest.raises(TypeError, match=r"attributes\['TAG'\]"): + await backend.block_set_attributes("2F", {"TAG": True}) + assert calls == [] and attr.TextString == "P-101" + assert await backend.block_set_attributes("2F", {"TAG": 2.5}) == { + "ok": True, + "updated_tags": ["TAG"], + } + assert attr.TextString == "2.5" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py -k "attribute or set_attributes or non_mapping or numbers" -v` +Expected: 10 FAIL, 1 PASS. The six parametrized cases, both `set_attributes` tests and the COM insert test fail with `Failed: DID NOT RAISE TypeError` (`None` is written as `"None"`, `True` as `"True"`); `test_insert_refuses_a_non_mapping_before_writing` fails with `AttributeError: 'list' object has no attribute 'items'` — raised after the write, which is the defect. `test_insert_writes_numbers_as_plain_text` already passes (`str(101)`), and stays as the regression pin. + +- [ ] **Step 3: The shared validator** + +In `backends/block_specs.py`, insert before `def solid_vertices(...)`: + +```python +def validate_attribute_values(attributes, where: str = "attributes") -> dict[str, str]: + """``{TAG: value}`` for ``block_insert`` / ``block_set_attributes`` as the + strings that will be written, refused before any write. + + ``str(value)`` used to be applied to whatever arrived, so a JSON ``null`` + became the text ``None``, ``true`` became ``True`` and an object became its + Python repr — silently, on both engines. A string is taken as is (one line, + the DXF rule ``_one_line`` enforces); an int or a finite float is written + with ``str`` (``101`` → ``"101"``, ``2.5`` → ``"2.5"``); ``bool``, ``None``, + lists and objects are refused by tag. ``None`` for the whole mapping means + "no attributes". + """ + if attributes is None: + return {} + if not isinstance(attributes, dict): + raise TypeError(f"{where} must be an object of {{TAG: value}} pairs") + out: dict[str, str] = {} + for tag, value in attributes.items(): + if not isinstance(tag, str) or not tag.strip(): + raise TypeError(f"{where}: every tag must be a non-empty string") + label = f"{where}[{tag!r}]" + if isinstance(value, str): + out[tag] = _one_line(value, label) + elif isinstance(value, bool) or value is None or not isinstance(value, (int, float)): + raise TypeError(f"{label} must be a string or a number, got {type(value).__name__}") + else: + if isinstance(value, float) and not math.isfinite(value): + raise TypeError(f"{label} must be finite") + out[tag] = str(value) + return out +``` + +- [ ] **Step 4: ezdxf — validate first, write the validated strings** + +In `backends/ezdxf_backend.py`, `block_insert`: replace + +```python + attributes=None, + layer=None, + ) -> EntityInfo: + def _sync(): + msp = self._msp() + attribs: dict = { +``` + +with + +```python + attributes=None, + layer=None, + ) -> EntityInfo: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + + def _sync(): + msp = self._msp() + attribs: dict = { +``` + +and, in the same method, replace + +```python + if attributes: + # `add_auto_blockref` wraps the INSERT in an anonymous *U block, + # so the returned handle names `*U1` and carries no ATTRIBs. + # `add_auto_attribs` attaches ATTRIBs to this INSERT for every + # ATTDEF the definition has; values for tags it lacks are ignored. + ref.add_auto_attribs({str(k): str(v) for k, v in attributes.items()}) +``` + +with + +```python + if values: + # `add_auto_blockref` wraps the INSERT in an anonymous *U block, + # so the returned handle names `*U1` and carries no ATTRIBs. + # `add_auto_attribs` attaches ATTRIBs to this INSERT for every + # ATTDEF the definition has; values for tags it lacks are ignored. + ref.add_auto_attribs(values) +``` + +`block_set_attributes`: replace the whole method with + +```python + async def block_set_attributes(self, handle, attributes) -> dict: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + + def _sync(): + ent = self._get_entity(handle) + if ent.dxftype() != "INSERT": + raise RuntimeError(f"Entity {handle} is not a block reference") + updated = [] + for attrib in ent.attribs: + tag = attrib.dxf.tag + if tag in values: + attrib.dxf.text = values[tag] + updated.append(tag) + self._mark_dirty() + return {"ok": True, "updated_tags": updated} + + return await self._async(_sync) +``` + +- [ ] **Step 5: COM — the same, before `InsertBlock` / `GetAttributes`** + +In `backends/com_backend.py`, `block_insert`: replace + +```python + attributes=None, + layer=None, + ) -> EntityInfo: + def _sync(): + _require_block_defined(_acad_doc(), name) + mspace = _msp() +``` + +with + +```python + attributes=None, + layer=None, + ) -> EntityInfo: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + + def _sync(): + _require_block_defined(_acad_doc(), name) + mspace = _msp() +``` + +and replace + +```python + if attributes: + try: + attrs = ref.GetAttributes() + for attr in attrs: + tag = attr.TagString + if tag in attributes: + attr.TextString = str(attributes[tag]) +``` + +with + +```python + if values: + try: + attrs = ref.GetAttributes() + for attr in attrs: + tag = attr.TagString + if tag in values: + attr.TextString = values[tag] +``` + +`block_set_attributes`: replace the whole method with + +```python + async def block_set_attributes(self, handle, attributes) -> dict: + from backends.block_specs import validate_attribute_values + + values = validate_attribute_values(attributes) + + def _sync(): + doc = _acad_doc() + ref = doc.HandleToObject(handle) + attrs = ref.GetAttributes() + updated = [] + for attr in attrs: + tag = attr.TagString + if tag in values: + attr.TextString = values[tag] + updated.append(tag) + return {"ok": True, "updated_tags": updated} + + return await self._run(_sync) +``` + +- [ ] **Step 6: Tool docstrings name the value rule** + +In `server.py`, `block_insert` — replace + +```python + """Insert a block and optionally set attribute values.""" + await ctx.info(f"Inserting block '{name}' at ({x},{y})") +``` + +with + +```python + """Insert a block and optionally set attribute values. + + Refused before any write, on both engines: an undefined or layout block + name, and an attribute value that is not text — a string is written as + is (one line), an int or finite float as its plain digits (`101` → + `"101"`), while `null`, booleans, lists and objects are refused naming + the tag (they used to be written as their Python repr). Values for tags + the block does not define are ignored. + """ + await ctx.info(f"Inserting block '{name}' at ({x},{y})") +``` + +`block_set_attributes` — replace + +```python + """Update attribute values in a block reference.""" + return await _backend(ctx).block_set_attributes(handle, attributes) +``` + +with + +```python + """Update attribute values in a block reference. + + Same value rule as `block_insert`: strings and numbers are written, + `null` / booleans / lists / objects are refused naming the tag before + anything changes. `updated_tags` lists the tags that exist on the + reference and were written; unknown tags are ignored. + """ + return await _backend(ctx).block_set_attributes(handle, attributes) +``` + +Alias records (`INSERT` / `EATTEDIT`, `ATTEDIT` and their synonyms) and pack/lean membership are unchanged. + +- [ ] **Step 7: Run the tests** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py tests/test_block_insert_attributes.py tests/test_block_specs.py tests/test_pid_insert.py tests/test_layer_block.py -v` +Expected: all PASS (`pid_symbol_insert` writes strings only, so `engineering/pid/insert.py` is unaffected). + +- [ ] **Step 8: Gates** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py tests/test_track_a_hardening.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all PASS. + +- [ ] **Step 9: Commit** + +```bash +git add backends/block_specs.py backends/ezdxf_backend.py backends/com_backend.py server.py tests/test_track_a_hardening.py +git commit -m "fix(blocks): attribute values are validated before any write; null/bool/objects refused by tag + +block_insert and block_set_attributes applied str() to whatever arrived, so +a JSON null became the text None and a non-mapping failed after the INSERT +was written. validate_attribute_values (backends/block_specs.py) runs first +on both engines: strings pass the one-line rule, ints and finite floats are +written as plain digits, everything else is a TypeError naming the tag. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 4: `block_define` refuses a non-finite base point and a missing primitive layer (unless `create_layers=True`) + +Measured: `block_define("PID_NAN", CIRC, base_x=nan, base_y=inf)` returns `ok: True` and the BLOCK record carries `Vec3(nan, inf, 0)`; a primitive on `NOT_A_LAYER` is written with no layer-table entry and `doc.audit()` reports nothing. On COM `obj.Layer = name` for an unknown layer runs mid-loop after `Blocks.Add`, so a refusal there would leave the definition half written. Both engines now check the base point with the module's `_scalar` rule and probe the layer table before the first write; `create_layers=True` creates the missing layers (engine defaults) and reports them. The kwarg is optional, so every Track A caller (`engineering/pid/insert.py::ensure_block` passes `0.0, 0.0` on layer `0`) and the parent plan's Interfaces stay valid. + +**Files:** +- Modify: `backends/block_specs.py` (new `validate_base_point`, before `validate_attribute_values`) +- Modify: `backends/contracts/blocks.py` (`block_define` signature + docstring) +- Modify: `backends/ezdxf_backend.py` (`block_define` ≈ line 4670) +- Modify: `backends/com_backend.py` (`block_define` ≈ line 3431) +- Modify: `server.py` (`block_define` tool: new parameter, docstring) +- Modify: `tests/test_block_define.py` (the pinned result dict gains `layers_created`) +- Test: `tests/test_track_a_hardening.py` (append) + +**Interfaces:** +- Produces: `validate_base_point(base_x, base_y) -> tuple[float, float]` (`TypeError` naming `'base_x'` / `'base_y'`: non-number, bool, non-finite, overflowing int — `_scalar` gains the `OverflowError` guard, so every `block_define` scalar benefits). `block_define(name, entities, attdefs=None, base_x=0.0, base_y=0.0, overwrite=False, create_layers=False) -> {ok, name, entity_count, attdef_count, replaced, layers_created: [names], backend}`. Refusal: `ValueError("block_define: layer(s) 'A', 'B' do not exist in the drawing; create them with layer_create or pass create_layers=true")`, before `blocks.new` / `Blocks.Add`. +- Consumes: ezdxf `doc.layers` (`in` is case-insensitive) / `doc.layers.add(name)`; ActiveX `Layers.Item(name)` (raises for unknown) / `Layers.Add(name)`. + +- [ ] **Step 1: Append the failing tests** + +```python +# tests/test_track_a_hardening.py (append) + + +# ── Task 4: block_define validates the base point and the layer table ──────── + +CIRC = [{"type": "circle", "cx": 0, "cy": 0, "r": 1}] +ON_LAYER = [{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1, "layer": "NOT_A_LAYER"}] + + +@pytest.mark.parametrize( + "kwargs, key", + [ + ({"base_x": float("nan")}, "base_x"), + ({"base_y": float("inf")}, "base_y"), + ({"base_x": "3"}, "base_x"), + ({"base_y": True}, "base_y"), + ({"base_x": 10**400}, "base_x"), + ], +) +async def test_define_refuses_a_bad_base_point_before_writing(backend, kwargs, key): + with pytest.raises(TypeError, match=f"'{key}'"): + await backend.block_define("PID_BASE", CIRC, **kwargs) + assert "PID_BASE" not in backend._doc.blocks + + +async def test_define_refuses_a_missing_layer_and_names_it(backend): + with pytest.raises(ValueError, match="'NOT_A_LAYER' do not exist") as exc: + await backend.block_define("PID_LYR", ON_LAYER) + assert "create_layers=true" in str(exc.value) + assert "PID_LYR" not in backend._doc.blocks + assert "NOT_A_LAYER" not in backend._doc.layers + + +async def test_define_creates_the_layer_on_request_and_reports_it(backend): + result = await backend.block_define("PID_LYR", ON_LAYER, create_layers=True) + assert result["layers_created"] == ["NOT_A_LAYER"] and result["ok"] is True + assert "NOT_A_LAYER" in backend._doc.layers + member = next(iter(backend._doc.blocks.get("PID_LYR"))) + assert member.dxf.layer == "NOT_A_LAYER" + + +async def test_define_on_an_existing_layer_reports_nothing_created(backend): + await backend.layer_create("PID-EQUIP") + spec = [{**ON_LAYER[0], "layer": "PID-EQUIP"}] + result = await backend.block_define("PID_OK", spec) + assert result["layers_created"] == [] + result = await backend.block_define("PID_ZERO", CIRC) + assert result["layers_created"] == [], "layer 0 always exists" + + +async def test_com_define_probes_layers_before_blocks_add(com): + backend, document, space = com + with pytest.raises(ValueError, match="'NOT_A_LAYER' do not exist"): + await backend.block_define("PID_LYR", ON_LAYER) + assert "PID_LYR" not in document.Blocks.blocks and document.Layers.added == [] + + result = await backend.block_define("PID_LYR", ON_LAYER, create_layers=True) + assert result["layers_created"] == ["NOT_A_LAYER"] and result["backend"] == "com" + assert document.Layers.added == ["NOT_A_LAYER"] + assert [name for name, _ in document.Blocks.blocks["PID_LYR"].calls] == ["AddLine"] + + +async def test_com_define_refuses_a_bad_base_point_before_blocks_add(com): + backend, document, space = com + with pytest.raises(TypeError, match="'base_y'"): + await backend.block_define("PID_BASE", CIRC, base_y=float("nan")) + assert "PID_BASE" not in document.Blocks.blocks + result = await backend.block_define("PID_BASE", CIRC, base_x=1.5, base_y=2.5) + assert document.Blocks.blocks["PID_BASE"].base_point == (1.5, 2.5, 0.0) + assert result["layers_created"] == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py -k define -v` +Expected: 10 FAIL. Four base-point cases and the COM base-point test fail `DID NOT RAISE TypeError` (a NaN base point is written), the `10**400` case with `OverflowError: int too large to convert to float`; `test_define_refuses_a_missing_layer_and_names_it` and the COM layer probe fail `DID NOT RAISE ValueError`; `test_define_creates_the_layer_on_request_and_reports_it` fails with `TypeError: EzdxfBackend.block_define() got an unexpected keyword argument 'create_layers'`; `test_define_on_an_existing_layer_reports_nothing_created` with `KeyError: 'layers_created'`. + +- [ ] **Step 3: The shared base-point validator (and `_scalar` learns about overflow)** + +In `backends/block_specs.py`, `_scalar` let a 400-digit int escape as `OverflowError` (wave-2 finding) — replace + +```python + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{where} must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number): + raise TypeError(f"{where} must be finite") +``` + +with + +```python + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{where} must be a number, got {value!r}") + try: + number = float(value) + except OverflowError as exc: # a 400-digit JSON integer is an int, not a float + raise TypeError(f"{where} must be finite") from exc + if not math.isfinite(number): + raise TypeError(f"{where} must be finite") +``` + +Then insert before `def validate_attribute_values(...)` (Task 3): + +```python +def validate_base_point(base_x, base_y) -> tuple[float, float]: + """``block_define``'s base point as finite floats; ``TypeError`` names the key. + + Only ``float()`` was applied before, so ``float("nan")`` was written into + the BLOCK record headlessly and would reach AutoCAD as a NaN VARIANT. + """ + return ( + _scalar(base_x, "block_define: 'base_x'"), + _scalar(base_y, "block_define: 'base_y'"), + ) +``` + +- [ ] **Step 4: The contract** + +In `backends/contracts/blocks.py`, replace the `block_define` member with: + +```python + @abstractmethod + async def block_define( + self, + name: str, + entities: list[dict], + attdefs: list[dict] | None = None, + base_x: float = 0.0, + base_y: float = 0.0, + overwrite: bool = False, + create_layers: bool = False, + ) -> dict: + """Create (or with ``overwrite`` replace the contents of) a block + definition from typed primitive specs and ATTDEF specs — see + ``backends/block_specs.py`` for the spec vocabulary. + + Refusals, all before any write: a malformed spec (``TypeError`` naming + ``entities[i]``/``attdefs[i]`` and the key), a non-finite or non-numeric + ``base_x``/``base_y`` (``TypeError`` naming it), a name clash without + ``overwrite`` (``ValueError``), and a primitive ``layer`` that does not + exist in the drawing (``ValueError`` listing the missing names) unless + ``create_layers`` is true, in which case the missing layers are created + with the engine's defaults and listed in ``layers_created``. Returns + ``{ok, name, entity_count, attdef_count, replaced, layers_created, + backend}``.""" + ... +``` + +- [ ] **Step 5: ezdxf** + +In `backends/ezdxf_backend.py`, `block_define` — replace the signature and docstring head + +```python + base_x=0.0, + base_y=0.0, + overwrite=False, + ) -> dict: + """A block definition with ATTDEFs from typed specs. + + Validation runs before any write, so a malformed entry leaves no + definition behind. ``overwrite`` replaces the *contents* of an existing + definition rather than deleting it: existing INSERTs keep pointing at + the name and show the new geometry (deleting a referenced block is + refused by both engines; replacing contents works on both). + """ + from ezdxf.enums import TextEntityAlignment + + from backends.block_specs import ( + solid_vertices, + validate_attdef_specs, + validate_entity_specs, + ) +``` + +with + +```python + base_x=0.0, + base_y=0.0, + overwrite=False, + create_layers=False, + ) -> dict: + """A block definition with ATTDEFs from typed specs. + + Validation runs before any write, so a malformed entry leaves no + definition behind: specs, the base point (``base_x``/``base_y`` must be + finite numbers — ``float("nan")`` used to be written into the BLOCK + record) and the layer table (a primitive's ``layer`` must exist, or + ``create_layers`` must be true — a dangling layer reference passes + ``doc.audit()`` silently). ``overwrite`` replaces the *contents* of an + existing definition rather than deleting it: existing INSERTs keep + pointing at the name and show the new geometry (deleting a referenced + block is refused by both engines; replacing contents works on both). + """ + from ezdxf.enums import TextEntityAlignment + + from backends.block_specs import ( + solid_vertices, + validate_attdef_specs, + validate_base_point, + validate_entity_specs, + ) +``` + +Then replace + +```python + clean_name = sanitize_symbol_name(name, kind="block") + ents = validate_entity_specs(entities) + atts = validate_attdef_specs(attdefs or []) + align_map = { + "left": TextEntityAlignment.LEFT, +``` + +with + +```python + clean_name = sanitize_symbol_name(name, kind="block") + ents = validate_entity_specs(entities) + atts = validate_attdef_specs(attdefs or []) + base = (*validate_base_point(base_x, base_y), 0.0) + wanted_layers = sorted({spec["layer"] for spec in ents if spec["layer"] != "0"}) + align_map = { + "left": TextEntityAlignment.LEFT, +``` + +and, inside `_sync`, replace + +```python + replaced = existing is not None + base = (float(base_x), float(base_y), 0.0) + if existing is None: + blk = doc.blocks.new(name=clean_name, base_point=base) +``` + +with + +```python + replaced = existing is not None + missing_layers = [layer for layer in wanted_layers if layer not in doc.layers] + if missing_layers and not create_layers: + raise ValueError( + "block_define: layer(s) " + + ", ".join(repr(layer) for layer in missing_layers) + + " do not exist in the drawing; create them with layer_create " + "or pass create_layers=true" + ) + for layer in missing_layers: + doc.layers.add(layer) + if existing is None: + blk = doc.blocks.new(name=clean_name, base_point=base) +``` + +and in the return dict add `"layers_created": missing_layers,` between `"replaced": replaced,` and `"backend": "ezdxf",`. + +- [ ] **Step 6: COM** + +In `backends/com_backend.py`, `block_define` — replace the signature and docstring head + +```python + base_x=0.0, + base_y=0.0, + overwrite=False, + ) -> dict: + """A block definition with ATTDEFs from typed specs, through ActiveX. + + ``Blocks.Add`` creates the definition; each primitive is one ``Add*`` + call on the Block object, each ATTDEF one ``AddAttribute``. Overwrite + deletes the existing definition's members and re-adds — the name and + its INSERTs survive. Unit-tested against a fake ActiveX surface; + executed live by ``scripts/smoke_pid_com.py``. + """ + from backends.block_specs import ( + solid_vertices, + validate_attdef_specs, + validate_entity_specs, + ) +``` + +with + +```python + base_x=0.0, + base_y=0.0, + overwrite=False, + create_layers=False, + ) -> dict: + """A block definition with ATTDEFs from typed specs, through ActiveX. + + ``Blocks.Add`` creates the definition; each primitive is one ``Add*`` + call on the Block object, each ATTDEF one ``AddAttribute``. Overwrite + deletes the existing definition's members and re-adds — the name and + its INSERTs survive. The base point must be finite (a NaN would reach + AutoCAD as a VARIANT) and every primitive layer must exist + (``Layers.Item`` probe before ``Blocks.Add`` — ``obj.Layer = name`` for + an unknown layer would otherwise fail mid-loop with the definition half + written) unless ``create_layers`` adds them first. Unit-tested against + a fake ActiveX surface; executed live by ``scripts/smoke_pid_com.py``. + """ + from backends.block_specs import ( + solid_vertices, + validate_attdef_specs, + validate_base_point, + validate_entity_specs, + ) +``` + +Then replace + +```python + clean_name = sanitize_symbol_name(name, kind="block") + ents = validate_entity_specs(entities) + atts = validate_attdef_specs(attdefs or []) + # acAlignmentLeft / Center / Right / MiddleCenter + align_map = {"left": 0, "center": 1, "right": 2, "middle_center": 10} +``` + +with + +```python + clean_name = sanitize_symbol_name(name, kind="block") + ents = validate_entity_specs(entities) + atts = validate_attdef_specs(attdefs or []) + base_xy = validate_base_point(base_x, base_y) + wanted_layers = sorted({spec["layer"] for spec in ents if spec["layer"] != "0"}) + # acAlignmentLeft / Center / Right / MiddleCenter + align_map = {"left": 0, "center": 1, "right": 2, "middle_center": 10} +``` + +and, inside `_sync`, replace + +```python + replaced = existing is not None + base = _apoint(float(base_x), float(base_y), 0.0) + if existing is None: + block = doc.Blocks.Add(base, clean_name) +``` + +with + +```python + replaced = existing is not None + missing_layers = [] + for layer in wanted_layers: + try: + doc.Layers.Item(layer) + except Exception: + missing_layers.append(layer) + if missing_layers and not create_layers: + raise ValueError( + "block_define: layer(s) " + + ", ".join(repr(layer) for layer in missing_layers) + + " do not exist in the drawing; create them with layer_create " + "or pass create_layers=true" + ) + for layer in missing_layers: + doc.Layers.Add(layer) + base = _apoint(base_xy[0], base_xy[1], 0.0) + if existing is None: + block = doc.Blocks.Add(base, clean_name) +``` + +and in the return dict add `"layers_created": missing_layers,` between `"replaced": replaced,` and `"backend": "com",`. + +- [ ] **Step 7: The tool grows the kwarg; the docstring names every refusal** + +In `server.py`, `block_define` — replace + +```python + overwrite: Annotated[ + bool, "Replace the contents of an existing definition of this name" + ] = False, + ctx: Context = None, +) -> dict: + """Create a block definition with attribute definitions from typed specs. + + The whole request is validated before anything is written: one malformed + entry refuses the call and leaves no definition behind. `overwrite=true` + replaces the *contents* of an existing block so its INSERTs keep pointing + at the name and show the new geometry; `replaced` reports it. Geometry + inside the block is ByBlock so the INSERT's layer supplies colour and + lineweight. Insert with `block_insert(attributes={TAG: value})`. + """ + await ctx.info(f"Defining block '{name}' from {len(entities)} primitives") + return await _backend(ctx).block_define(name, entities, attdefs, base_x, base_y, overwrite) +``` + +with + +```python + overwrite: Annotated[ + bool, "Replace the contents of an existing definition of this name" + ] = False, + create_layers: Annotated[ + bool, "Create primitive layers that do not exist yet (default: refuse and name them)" + ] = False, + ctx: Context = None, +) -> dict: + """Create a block definition with attribute definitions from typed specs. + + The whole request is validated before anything is written: one malformed + entry refuses the call and leaves no definition behind. Refusals: a + malformed primitive or ATTDEF (names `entities[i]`/`attdefs[i]` and the + key), a non-finite base point (names `base_x`/`base_y`), a name clash + without `overwrite`, an anonymous `*` name, and a primitive `layer` that + is not in the drawing's layer table (names the missing layers) unless + `create_layers=true`, which creates them and lists them in + `layers_created`. `overwrite=true` replaces the *contents* of an existing + block so its INSERTs keep pointing at the name and show the new geometry; + `replaced` reports it. Geometry inside the block is ByBlock so the + INSERT's layer supplies colour and lineweight. Insert with + `block_insert(attributes={TAG: value})`. + """ + await ctx.info(f"Defining block '{name}' from {len(entities)} primitives") + return await _backend(ctx).block_define( + name, entities, attdefs, base_x, base_y, overwrite, create_layers + ) +``` + +Alias record (`BLOCK`, `BEDIT`, `ATTDEF` + five synonyms), pack `core`, not lean — unchanged. `docs/tool-inventory.json` records no parameters and the summary is unchanged, so it does not move. + +- [ ] **Step 8: The Track A pin gains the new key** + +In `tests/test_block_define.py::test_defines_geometry_and_attdefs`, the exact result dict gains one line — replace + +```python + "replaced": False, + "backend": "ezdxf", + } +``` + +with + +```python + "replaced": False, + "layers_created": [], + "backend": "ezdxf", + } +``` + +- [ ] **Step 9: Run the tests** + +Run: `uv run --frozen pytest tests/test_track_a_hardening.py tests/test_block_define.py tests/test_block_specs.py tests/test_pid_insert.py tests/test_pid_symbols.py tests/test_capability_contract.py -v` +Expected: all PASS (both backends still instantiate against the grown contract; the P&ID catalogue defines every block on layer `0`, so `ensure_block` is unaffected). + +- [ ] **Step 10: Gates** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py tests/test_track_a_hardening.py tests/test_block_define.py && uv run --frozen python scripts/generate_tool_inventory.py --check && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: ruff clean; `ok: docs/tool-inventory.json matches the live surface`; all PASS. + +- [ ] **Step 11: Commit** + +```bash +git add backends/block_specs.py backends/contracts/blocks.py backends/ezdxf_backend.py backends/com_backend.py server.py tests/test_block_define.py tests/test_track_a_hardening.py +git commit -m "fix(blocks): block_define refuses a non-finite base point and a missing layer unless create_layers + +base_x/base_y were only float()-cast, so a NaN base point was written into +the BLOCK record; a primitive on a layer the drawing lacks was written as a +dangling reference doc.audit() never flags (and on COM would fail mid-loop +after Blocks.Add). validate_base_point names the key; the layer table is +probed before the first write and the missing names are refused, or created +and reported as layers_created when create_layers=true (new optional kwarg, +contract + both engines + tool). + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 5: `pid_from_spec` — `_num` refuses non-finite numbers by path; `run_spec` rolls back on `BaseException` + +Measured on main: `validate_spec` accepts `equipment[0].x = nan` and the INSERT is placed at `[nan, 0]` (the later `draw_line` refuses with `lines[0]: start.x is nan`, so the path named is the wrong one); `10**400` escapes as `OverflowError`; with `draw_line` monkeypatched to raise `asyncio.CancelledError`, `run_spec` leaves 5 entities in the drawing and `transaction_depth 1` — `except Exception` does not catch a `BaseException`, and on COM that is an open undo mark that refuses every later `pid_from_spec`. + +**Files:** +- Modify: `engineering/pid/spec.py` (`_num` ≈ line 137; `run_spec` ≈ line 422) +- Modify: `server.py` (`pid_from_spec` docstring ≈ line 6780) +- Test: `tests/test_pid_from_spec.py` (append) + +**Interfaces:** +- Produces: `_num(value, where) -> float` raising `ValueError(f"{where} must be a finite number")` for NaN/±inf/overflowing ints (the existing `must be a number` message for non-numbers is kept); `run_spec` rolls back on any `BaseException` and re-raises it. + +- [ ] **Step 1: Append the failing tests** + +```python +# tests/test_pid_from_spec.py (append) + + +# ── Track A hardening (track E wave 0, Task 5) ─────────────────────────────── + + +@pytest.mark.parametrize( + "mutate, fragment", + [ + (lambda s: s["equipment"][0].update({"x": float("nan")}), "equipment[0].x"), + (lambda s: s["valves"][0].update({"y": float("inf")}), "valves[0].y"), + (lambda s: s["instruments"][0].update({"rotation": 10**400}), "instruments[0].rotation"), + (lambda s: s["lines"][0].update({"stub": float("-inf")}), "lines[0].stub"), + ], +) +async def test_non_finite_numbers_are_refused_by_path_before_any_write(backend, mutate, fragment): + before = await backend.entity_count() + spec = copy.deepcopy(EXAMPLE_SPEC) + mutate(spec) + with pytest.raises(ValueError, match="finite") as exc: + await run_spec(backend, spec) + assert fragment in str(exc.value) + assert await backend.entity_count() == before + assert (await backend.system_status())["transaction_depth"] == 0 + + +async def test_a_cancellation_mid_run_rolls_the_sheet_back(backend, monkeypatch): + """``CancelledError`` is a ``BaseException``; ``except Exception`` let the + placed symbols stay inside an open transaction.""" + import asyncio + + from engineering.pid import spec as spec_module + + before = await backend.entity_count() + + async def cancelled(*args, **kwargs): + raise asyncio.CancelledError() + + monkeypatch.setattr(spec_module, "draw_line", cancelled) + with pytest.raises(asyncio.CancelledError): + await run_spec(backend, EXAMPLE_SPEC) + assert await backend.entity_count() == before, "the placed symbols were rolled back" + assert (await backend.system_status())["transaction_depth"] == 0, "no open transaction" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_pid_from_spec.py -v` +Expected: 5 FAIL, 9 PASS — 14 collected items, counted by pytest item not by function: the file's existing nine (four plain tests plus `test_validation_names_the_path` parametrized five ways) keep passing; the five appended items (the four parametrized non-finite cases plus the cancellation test) fail. The `nan`/`inf` cases fail because the refusal names `lines[0]: start.x` / `lines[0]: end.y` (raised by `route()` after the symbol was placed and rolled back) instead of `equipment[0].x` / `valves[0].y`; `10**400` fails with `OverflowError: int too large to convert to float`; the stub case names `lines[0]: stub must be a finite length` (from `route`), not the spec path; the cancellation test fails `assert 5 == 0` — five symbols left in the drawing. + +- [ ] **Step 3: Finite numbers, named by path** + +In `engineering/pid/spec.py`, add `import math` after `import copy`: + +```python +import copy +import math +from typing import TYPE_CHECKING +``` + +and replace + +```python +def _num(value, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where} must be a number") + return float(value) +``` + +with + +```python +def _num(value, where: str) -> float: + """A finite number, or a ``ValueError`` naming the path. + + ``float("nan")`` used to pass and be placed as an INSERT at ``[nan, 0]``; + the transaction committed and ``build_graph`` then crashed on the drawing + with no path in its message. Only in-process callers can deliver a + non-finite (the MCP pipeline coerces them first), but ``run_spec`` is a + public interface and its docstring promises nothing malformed reaches + the drawing. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{where} must be a number") + try: + number = float(value) + except OverflowError as exc: # a 400-digit int is a number but not a float + raise ValueError(f"{where} must be a finite number") from exc + if not math.isfinite(number): + raise ValueError(f"{where} must be a finite number") + return number +``` + +- [ ] **Step 4: Roll back on anything** + +In `run_spec`, replace + +```python + crossings_total += drawn["crossings"] + except Exception: + await backend.transaction_rollback() + raise + await backend.transaction_commit() +``` + +with + +```python + crossings_total += drawn["crossings"] + except BaseException: + # ``Exception`` alone let a client cancellation (``CancelledError`` is + # a ``BaseException``) leave the placed symbols in the drawing inside + # an open transaction — on COM with the undo mark still open, so every + # later ``pid_from_spec`` was refused until a manual rollback. + await backend.transaction_rollback() + raise + await backend.transaction_commit() +``` + +(`await` inside the handler is safe: once `CancelledError` has been thrown into the coroutine the task's cancel flag is cleared, so the rollback runs to completion before the re-raise; the test proves it.) + +- [ ] **Step 5: The tool docstring names both** + +In `server.py`, `pid_from_spec` — replace + +```python + drawn port-to-port, inside one transaction: any bad item rolls the whole + sheet back and the error names it (`lines[2].to`). The response carries the + graph read back from the drawing and the P&ID critique issues, so the + caller sees dangling ends or duplicate tags in the same round trip. + `dry_run` returns the planned vertices and crossings without touching the + drawing. + """ +``` + +with + +```python + drawn port-to-port, inside one transaction: any bad item rolls the whole + sheet back and the error names it (`lines[2].to`); so does an interruption + (a client cancellation mid-run leaves no half-drawn sheet and no open + transaction). A non-finite coordinate, rotation, scale or stub is refused + by path before anything is placed. The response carries the graph read + back from the drawing and the P&ID critique issues, so the caller sees + dangling ends or duplicate tags in the same round trip. `dry_run` returns + the planned vertices and crossings without touching the drawing. + """ +``` + +Alias record (four synonyms, `acad=()`), pack `pid`, not lean — unchanged. + +- [ ] **Step 6: Run the tests** + +Run: `uv run --frozen pytest tests/test_pid_from_spec.py tests/test_pid_critique.py tests/test_pid_graph.py -q` +Expected: all PASS. + +- [ ] **Step 7: Gates and commit** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check engineering server.py tests/test_pid_from_spec.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all PASS. + +```bash +git add engineering/pid/spec.py server.py tests/test_pid_from_spec.py +git commit -m "fix(pid): pid_from_spec refuses non-finite numbers by path and rolls back on any interruption + +_num let NaN/inf (and a 400-digit int, as OverflowError) through, so an +INSERT was placed at [nan, 0] and the refusal, when it came, named the +line instead of equipment[0].x. run_spec caught Exception only, so a +CancelledError mid-run left the placed symbols inside an open transaction +(an open undo mark on COM). Finite check in _num; except BaseException. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 6: `pid_line_draw` — signal lines carry no fabricated number; a bubble exits towards the first waypoint + +Measured on main: a pneumatic line from a bubble gets `line_number "1"`, a `"1"` TEXT on `PROCESS-LINE-TEXT`, and the next process line is seq 2 — `format_line_number` drops the empty fields and leaves the bare sequence, but an ISA-5.1 signal line has no pipe line number. And `_anchor` snaps a radial port's exit towards the *far end* even when `route` is a waypoint list: bubble (100,160) r 5, `route=[[130,160],[130,100]]` → first vertex `[100,155]`, first segment 4.93 mm from the centre (through the bubble). The dry run in `spec.py::_plan_lines` has the same anchor rule, so the pure helper `aim_points` serves both. + +**Files:** +- Modify: `engineering/pid/lines.py` (new `aim_points`, before `label_placement`) +- Modify: `engineering/pid/drawlines.py` (`_anchor`, `draw_line`) +- Modify: `engineering/pid/spec.py` (`_plan_lines` anchor) +- Modify: `server.py` (`pid_line_draw` docstring), `discovery/aliases.py` (`pid_line_draw` synonyms) +- Modify: `tests/test_pid_deliverables.py::test_line_list_rows` (its pinned numbering was the defect) +- Modify: `benchmarks/adapters/autocad_mcp_pro.py::_task_pid_roundtrip` (two assertion lines — **final group's file**, see the group constraints; the change is additive to the region the final group edits — F Task 25 appends `_task_page_setup_truth` directly *after* this method — and is named in the commit message for the merge agent; the skeleton's H ownership row lists this edit) +- Test: `tests/test_pid_line_draw.py` (append) + +**Interfaces:** +- Produces: `aim_points(start, end, mode) -> (aim_for_start, aim_for_end)` — the other end, or with a waypoint list the first waypoint for the start and the last for the end (`ValueError` naming `waypoints[i]` on a non-finite one, the existing `_finite_point` rule). `draw_line` for `LINE_CLASSES[cls].kind == "signal"`: `line_number` stays `None` unless given verbatim, `label_handle` is `None` unless a verbatim number was given, payload `seq` is `None`, the process `seq` is untouched. +- Consumes: `LineClass.kind` (`"process"` | `"signal"`), `_finite_point`, `snap_axis`. + +- [ ] **Step 1: Append the failing tests** + +```python +# tests/test_pid_line_draw.py (append) + + +# ── Track A hardening (track E wave 0, Task 6) ─────────────────────────────── + + +async def _bubble_and_valve(backend): + cv = await place_symbol(backend, "globe", 100, 100, tag="FCV-1", actuator="diaphragm") + fic = await place_symbol( + backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary" + ) + return cv, fic + + +async def test_signal_lines_carry_no_fabricated_number_and_do_not_consume_the_sequence(backend): + """A signal line has no pipe line number: no auto number, no label, and the + process sequence is left alone.""" + cv, fic = await _bubble_and_valve(backend) + pump, vessel = await _pump_and_vessel(backend) + signal = await draw_line( + backend, {"handle": fic["handle"]}, {"handle": cv["handle"], "port": "signal"}, "pneumatic" + ) + assert signal["line_number"] is None and signal["label_handle"] is None + payload = await read_payload(backend, signal["handle"]) + assert payload["number"] is None and payload["seq"] is None + labels = await backend.entity_count(type_filter="TEXT", layer_filter="PROCESS-LINE-TEXT") + assert labels == 0, "no label text was written for the signal line" + + process = await draw_line( + backend, + {"handle": pump["handle"], "port": "discharge"}, + {"handle": vessel["handle"], "port": "N3"}, + size="100", + service="P", + ) + assert process["line_number"] == "100-P-1", "the signal line did not consume seq 1" + assert (await read_payload(backend, process["handle"]))["seq"] == 1 + + # A signal line given size/service still gets no auto number (kind rules). + electric = await draw_line( + backend, {"handle": fic["handle"]}, {"x": 160, "y": 160}, "electric", size="IA", service="S" + ) + assert electric["line_number"] is None and electric["label_handle"] is None + + # A verbatim number is honoured and labelled, seq stays None. + named = await draw_line( + backend, {"handle": fic["handle"]}, {"x": 40, "y": 160}, "electric", line_number="IA-7" + ) + assert named["line_number"] == "IA-7" and named["label_handle"] + assert (await backend.entity_get(named["label_handle"])).properties["text"] == "IA-7" + assert (await read_payload(backend, named["handle"]))["seq"] is None + + # The next process line is 2, not 4. + second = await draw_line( + backend, + {"handle": vessel["handle"], "port": "N4"}, + {"x": 300, "y": 128}, + size="80", + service="P", + ) + assert second["line_number"] == "80-P-2" + + +async def test_a_bubble_exit_aims_at_the_first_waypoint(backend): + """With waypoints the radial port must leave towards the first waypoint; + aiming at the far end put the exit on the wrong side and the first segment + cut through the bubble (measured: 4.93 mm from the centre of a r=5 bubble).""" + fic = await place_symbol( + backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary" + ) + result = await draw_line( + backend, + {"handle": fic["handle"]}, + {"x": 100, "y": 100}, + "electric", + route_mode=[[130, 160], [130, 100]], + ) + assert result["vertices"] == [[105.0, 160.0], [130.0, 160.0], [130.0, 100.0], [100.0, 100.0]] + # Every vertex of the drawn line is on or outside the bubble. + for x, y in result["vertices"]: + assert ((x - 100) ** 2 + (y - 160) ** 2) ** 0.5 >= 5.0 - 1e-9 + + +async def test_a_bubble_entry_aims_at_the_last_waypoint(backend): + fic = await place_symbol( + backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary" + ) + result = await draw_line( + backend, + {"x": 100, "y": 100}, + {"handle": fic["handle"]}, + "electric", + route_mode=[[130, 100], [130, 160]], + ) + assert result["vertices"] == [[100.0, 100.0], [130.0, 100.0], [130.0, 160.0], [105.0, 160.0]] + + +async def test_dry_run_plans_the_same_bubble_exit_as_the_real_run(backend): + from engineering.pid.spec import run_spec + + spec = { + "instruments": [ + { + "id": "FIC-1", + "type": "dcs", + "location": "primary", + "x": 100, + "y": 160, + "tag": "FIC-1", + } + ], + "valves": [ + { + "id": "FCV-1", + "symbol": "globe", + "actuator": "diaphragm", + "x": 100, + "y": 100, + "tag": "FCV-1", + } + ], + "lines": [ + { + "from": "FIC-1", + "to": "FCV-1.signal", + "class": "electric", + "route": [[130, 160], [130, 109]], + } + ], + } + planned = await run_spec(backend, spec, dry_run=True) + assert planned["lines"][0]["vertices"][0] == [105.0, 160.0] + drawn = await run_spec(backend, spec) + edge = drawn["graph"]["edges"][0] + assert edge["line_number"] is None, "signal lines from a spec are unnumbered too" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_pid_line_draw.py -k "signal_lines_carry or bubble or dry_run_plans" -v` +Expected: 4 FAIL, 1 PASS (the Track A `test_signal_line_from_a_bubble_gets_markers_and_no_arrow` also matches and keeps passing). `…no_fabricated_number…` fails on `assert ('1' is None)`; both waypoint tests fail on the first/last vertex `[100.0, 155.0] != [105.0, 160.0]`; the dry-run test fails on `[100.0, 155.0] == [105.0, 160.0]`. + +- [ ] **Step 3: The pure aim rule** + +In `engineering/pid/lines.py`, insert before `def label_placement(...)`: + +```python +def aim_points(start, end, mode) -> tuple[Point, Point]: + """What each end of a line aims at: the other end, or — with waypoints — + the first waypoint for the start and the last one for the end. + + A radial (bubble) port leaves its circle along the axis towards its aim + point. Aiming at the far end while the route is a waypoint list put the + exit on the wrong side of the bubble, so the first segment cut through it. + """ + if isinstance(mode, (list, tuple)) and len(mode) > 0: + return _finite_point("waypoints[0]", mode[0]), _finite_point( + f"waypoints[{len(mode) - 1}]", mode[-1] + ) + return _finite_point("end", end), _finite_point("start", start) +``` + +- [ ] **Step 4: `draw_line` — aim, and the signal rule** + +In `engineering/pid/drawlines.py`, add `aim_points,` to the `.lines` import block (alphabetical, before `count_crossings,`): + +```python +from .lines import ( + AXIS, + DEFAULT_NUMBER_FORMAT, + LINE_CLASSES, + PID_LINE_LAYERS, + aim_points, + count_crossings, + format_line_number, + label_placement, + marker_positions, + route, + snap_axis, +) +``` + +Replace `_anchor`'s head + +```python +def _anchor(endpoint: dict, other: dict) -> tuple[tuple[float, float], float | None]: + """Where the line actually starts/ends: on the circle for radial ports.""" + if endpoint["radius"] > 0: + axis = snap_axis(other["x"] - endpoint["x"], other["y"] - endpoint["y"]) +``` + +with + +```python +def _anchor(endpoint: dict, aim: tuple[float, float]) -> tuple[tuple[float, float], float | None]: + """Where the line actually starts/ends: on the circle for radial ports, + leaving along the axis towards ``aim`` (see ``aim_points``).""" + if endpoint["radius"] > 0: + axis = snap_axis(aim[0] - endpoint["x"], aim[1] - endpoint["y"]) +``` + +In `draw_line`, replace + +```python + s_point, s_dir = _anchor(start, end) + e_point, e_dir = _anchor(end, start) + path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode) +``` + +with + +```python + s_aim, e_aim = aim_points((start["x"], start["y"]), (end["x"], end["y"]), route_mode) + s_point, s_dir = _anchor(start, s_aim) + e_point, e_dir = _anchor(end, e_aim) + path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode) +``` + +and replace + +```python + seq = max_seq + 1 + if line_number is None: + fmt = number_format or DEFAULT_NUMBER_FORMAT + fields = { + "size": size, + "service": service, + "seq": seq, + "spec": spec, + "insulation": insulation, + } + line_number = format_line_number(fmt, **fields) or None +``` + +with + +```python + if cls.kind == "signal": + # An ISA-5.1 signal line carries no pipe line number: no fabricated + # number, no label, and the process sequence is left alone. A + # verbatim ``line_number`` is still honoured (and labelled). + seq = None + else: + seq = max_seq + 1 + if line_number is None: + fmt = number_format or DEFAULT_NUMBER_FORMAT + fields = { + "size": size, + "service": service, + "seq": seq, + "spec": spec, + "insulation": insulation, + } + line_number = format_line_number(fmt, **fields) or None +``` + +(`line_payload(..., seq, ...)` already accepts `seq: int | None`; the label block `if label and line_number:` already skips a `None` number.) + +- [ ] **Step 5: The dry run aims the same way** + +In `engineering/pid/spec.py`, replace the import line + +```python +from .lines import AXIS, LINE_CLASSES, count_crossings, route, snap_axis +``` + +with + +```python +from .lines import AXIS, LINE_CLASSES, aim_points, count_crossings, route, snap_axis +``` + +and in `_plan_lines` replace + +```python + def anchor(ep: dict, other: dict): + if ep["radius"] > 0: + axis = snap_axis(other["x"] - ep["x"], other["y"] - ep["y"]) + ux, uy = AXIS[axis] + return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis + return (ep["x"], ep["y"]), ep["direction_deg"] + + planned: list[dict] = [] + for index, (where, line) in enumerate(_all_lines(norm)): + try: + start, end = endpoint(line["from"]), endpoint(line["to"]) + s, sd = anchor(start, end) + e, ed = anchor(end, start) + vertices = route( + s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=line.get("route", "auto") + ) +``` + +with + +```python + def anchor(ep: dict, aim): + if ep["radius"] > 0: + axis = snap_axis(aim[0] - ep["x"], aim[1] - ep["y"]) + ux, uy = AXIS[axis] + return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis + return (ep["x"], ep["y"]), ep["direction_deg"] + + planned: list[dict] = [] + for index, (where, line) in enumerate(_all_lines(norm)): + try: + start, end = endpoint(line["from"]), endpoint(line["to"]) + mode = line.get("route", "auto") + s_aim, e_aim = aim_points((start["x"], start["y"]), (end["x"], end["y"]), mode) + s, sd = anchor(start, s_aim) + e, ed = anchor(end, e_aim) + vertices = route(s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=mode) +``` + +- [ ] **Step 6: The line-list test and the benchmark adapter assert the new truth** + +`tests/test_pid_deliverables.py::test_line_list_rows` pinned the defect (`"3"` for the pneumatic line). Replace + +```python + numbered = [r for r in rows if r["line_number"]] + # The signal line carries no size/service/spec, so its default-format + # number is the bare sequence "3" (empty fields drop out, spec 6.4) — and + # a natural sort files 3 before 100. + assert [r["line_number"] for r in numbered] == ["3", "100-P-1-CS1", "100-P-2-CS1"] + signal = numbered[0] + assert signal["line_class"] == "pneumatic" and signal["number_source"] == "xdata" + assert signal["from_tag"] == "FIC-101" and signal["to_tag"] == "FCV-101" + assert signal["to_port"] == "signal" + first = numbered[1] + assert first["from_tag"] == "P-101" and first["from_port"] == "discharge" + assert first["to_tag"] == "FCV-101" and first["length_mm"] > 0 + assert rows[-1]["line_number"] is None, "untagged rows sort last" + assert rows[-1]["from_tag"] == "V-201" and rows[-1]["from_port"] == "N4" + assert rows[-1]["to_tag"] is None and rows[-1]["number_source"] is None +``` + +with + +```python + numbered = [r for r in rows if r["line_number"]] + # An ISA-5.1 signal line carries no pipe line number (track E, Task 6): + # the pneumatic line is unnumbered and did not consume a sequence number, + # so the two process lines are 1 and 2. + assert [r["line_number"] for r in numbered] == ["100-P-1-CS1", "100-P-2-CS1"] + signal = next(r for r in rows if r["line_class"] == "pneumatic") + assert signal["line_number"] is None and signal["number_source"] is None + assert signal["from_tag"] == "FIC-101" and signal["to_tag"] == "FCV-101" + assert signal["to_port"] == "signal" + first = numbered[0] + assert first["from_tag"] == "P-101" and first["from_port"] == "discharge" + assert first["to_tag"] == "FCV-101" and first["length_mm"] > 0 + unnumbered = [r for r in rows if r["line_number"] is None] + assert rows[-2:] == unnumbered, "untagged rows sort last" + hand_drawn = next(r for r in unnumbered if r["from_tag"] == "V-201") + assert hand_drawn["from_port"] == "N4" + assert hand_drawn["to_tag"] is None and hand_drawn["number_source"] is None +``` + +`benchmarks/adapters/autocad_mcp_pro.py::_task_pid_roundtrip` asserts two digit-only sequenced rows (`r["line_number"].isdigit()` would now raise on the signal row's `None`). Replace + +```python + # The spec numbers its two process lines; the signal line and the + # connector line are left to the default ``number_format``, which with + # no size/service/spec/insulation collapses to the bare sequence + # number. Both must come back through XDATA, not a label search. + authored = "100-P-1001-CS1" + numbers = {(r["from_tag"], r["to_tag"]): r["line_number"] for r in lines} + authored_ok = ( + numbers.get(("P-101", "FCV-101")) == authored + and numbers.get(("FCV-101", "V-201")) == authored + ) + sequenced = [r for r in lines if r["line_number"] != authored] + sequenced_ok = len(sequenced) == 2 and all( + r["line_number"].isdigit() and r["number_source"] == "xdata" for r in sequenced + ) +``` + +with + +```python + # The spec numbers its two process lines; the connector line is left + # to the default ``number_format``, which with no size/service/spec/ + # insulation collapses to the bare sequence number, and it must come + # back through XDATA, not a label search. The electric signal line is + # unnumbered by rule (ISA-5.1 signal lines carry no pipe line number) + # and must not have consumed a sequence number: the connector is "3". + authored = "100-P-1001-CS1" + numbers = {(r["from_tag"], r["to_tag"]): r["line_number"] for r in lines} + authored_ok = ( + numbers.get(("P-101", "FCV-101")) == authored + and numbers.get(("FCV-101", "V-201")) == authored + ) + others = [r for r in lines if r["line_number"] != authored] + signal = [r for r in others if r["line_class"] == "electric"] + sequenced = [r for r in others if r["line_class"] != "electric"] + sequenced_ok = ( + len(signal) == 1 + and signal[0]["line_number"] is None + and len(sequenced) == 1 + and sequenced[0]["line_number"] == "3" + and sequenced[0]["number_source"] == "xdata" + ) +``` + +Then prove the benchmark still passes: `uv run --frozen python benchmarks/run_competitors.py --matrix v4 --server autocad-mcp-pro --task pid_roundtrip` → `"passed": 1`, `"authored_line_numbers_kept": true`, `"unnumbered_lines_sequenced": true` (measured on the prototype). + +- [ ] **Step 7: Tool docstring and alias record** + +In `server.py`, `pid_line_draw` — replace the docstring's last two lines + +```python + line (branching is legal). Signal classes get ISA-5.1 markers (`//`, `X`, + `L`, `o`) as small blocks on segments >= 15 mm; electric is dashed by layer. + """ +``` + +with + +```python + line (branching is legal). Signal classes get ISA-5.1 markers (`//`, `X`, + `L`, `o`) as small blocks on segments >= 15 mm; electric is dashed by layer. + A signal line carries no pipe line number: it gets no auto-built number + and no label, and does not consume the process sequence — a verbatim + `line_number` is still written and labelled. With waypoints, a bubble's + exit leaves towards the first waypoint (and the entry arrives from the + last one); `crossings` follows existing arcs, not their chords. + """ +``` + +(The last clause is delivered by Task 7, the next commit on this branch.) In `discovery/aliases.py`, the `pid_line_draw` record gains two synonyms — replace + +```python + "connect the pump to the vessel", + "pneumatic signal", + ), + ), +``` + +with + +```python + "connect the pump to the vessel", + "pneumatic signal", + "unnumbered signal line", + "route a line through waypoints", + ), + ), +``` + +Pack `pid`, lean — unchanged. + +- [ ] **Step 8: Run the tests** + +Run: `uv run --frozen pytest tests/test_pid_line_draw.py tests/test_pid_lines.py tests/test_pid_deliverables.py tests/test_pid_from_spec.py tests/test_pid_graph.py tests/test_pid_critique.py tests/test_benchmark_v4.py -q` +Expected: all PASS. + +- [ ] **Step 9: Gates and commit** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check engineering server.py discovery benchmarks tests/test_pid_line_draw.py tests/test_pid_deliverables.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all PASS. + +```bash +git add engineering/pid/lines.py engineering/pid/drawlines.py engineering/pid/spec.py server.py discovery/aliases.py tests/test_pid_line_draw.py tests/test_pid_deliverables.py benchmarks/adapters/autocad_mcp_pro.py +git commit -m "fix(pid): signal lines carry no fabricated number; a bubble exits towards the first waypoint + +An ISA-5.1 signal line has no pipe line number, yet every pneumatic / +electric line got the bare sequence as its number and a label, and ate a +process sequence number. Signal classes now leave line_number None (a +verbatim one is still honoured), seq None, no label. A radial port aimed +at the far end even with waypoints, so the first segment cut through the +bubble; aim_points (lines.py) aims at the first/last waypoint for both +draw_line and the dry run. + +Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file) +had its two _task_pid_roundtrip assertion lines moved to the new truth — +the signal row is unnumbered, the connector row is 3. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 7: `pid_line_draw` — tolerant existing-line payloads; crossings follow arcs, not chords + +Measured on main: a P&ID-layer polyline carrying `{"seq": True, "from": "garbage", "to": None}` makes the next `draw_line` raise `TypeError: string indices must be integers, not 'str'` (a reader result must never raise); and an existing line with a bulge −1 semicircle (0,0)→(100,0) (apex (50,50)) is tested as its chord, so a utility line at x = 30 from y = 20 to 60 reports `crossings 0` although it crosses the arc at y ≈ 45.8. + +Bulge flattening is pure arithmetic in `engineering/pid/lines.py` (the package rule since `engineering/measure.py`: no ezdxf in the engineering package) using the same formulas `measure.py` and `graph._point_segment_distance` already use; the test pins it against `ezdxf.math.bulge_to_arc` + `ConstructionArc.flattening` as the oracle — measured on the prototype: identical vertex counts (13/13/9/7/19/6 for bulges 1, −1, 0.5, −0.3, 2, 0.2 at sagitta 0.05 on a 10 mm chord) and positions to < 1e-14. + +**Files:** +- Modify: `engineering/pid/lines.py` (new `FLATTEN_SAGITTA`, `flatten_bulges`, before `_orient`) +- Modify: `engineering/pid/drawlines.py` (new `_bulges`, `payload_port_refs`, `payload_seq`; `existing_pid_lines` rows gain `bulges`; `draw_line` uses them) +- Test: `tests/test_pid_lines.py` (append; add `import math`), `tests/test_pid_line_draw.py` (append) + +**Interfaces:** +- Produces: `flatten_bulges(points, bulges, sagitta=FLATTEN_SAGITTA) -> list[Point]` (straight edges pass through; a short `bulges` list is zero-padded; < 2 points returned as is). `payload_port_refs(payload) -> list[tuple[str, str]]` (only `from`/`to` that are dicts with string `handle` **and** string `port`), `payload_seq(payload) -> int | None` (`type(seq) is int`, so `True` and `"9"` are not sequence numbers). `existing_pid_lines` rows: `{"handle", "vertices", "bulges", "payload"}` (`vertices` unchanged — the COM test from Track A keeps passing). +- Consumes: `info.properties["bulges"]` (both engines report it next to `points` since Track A Task 14; COM pays `GetBulge` only when `Length` exceeds the chord walk). + +- [ ] **Step 1: Append the failing tests** + +`tests/test_pid_lines.py` — add `import math` above `import pytest` (a blank line between), then append: + +```python +# tests/test_pid_lines.py (append) + + +# ── Track A hardening (track E wave 0, Task 7): bulge flattening ───────────── + + +@pytest.mark.parametrize("bulge", [1.0, -1.0, 0.5, -0.3, 2.0, 0.2]) +def test_flatten_bulges_matches_ezdxf_flattening(bulge): + """Pinned against ezdxf's own arc flattening: same vertex count, same points.""" + from ezdxf.math import ConstructionArc, Vec2, bulge_to_arc + + from engineering.pid.lines import FLATTEN_SAGITTA, flatten_bulges + + a, b = (0.0, 0.0), (10.0, 0.0) + mine = flatten_bulges([a, b], [bulge]) + centre, start_rad, end_rad, radius = bulge_to_arc(Vec2(a), Vec2(b), bulge) + arc = ConstructionArc( + center=centre, + radius=radius, + start_angle=math.degrees(start_rad), + end_angle=math.degrees(end_rad), + ) + reference = [(p.x, p.y) for p in arc.flattening(FLATTEN_SAGITTA)] + assert len(mine) == len(reference) + assert mine[0] == a and mine[-1] == b + assert all(math.hypot(p[0] - centre.x, p[1] - centre.y) == pytest.approx(radius) for p in mine) + assert mine[len(mine) // 2] == pytest.approx(reference[len(reference) // 2]) + + +def test_flatten_bulges_passes_straight_edges_through_and_pads_short_bulge_lists(): + from engineering.pid.lines import flatten_bulges + + pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)] + assert flatten_bulges(pts, []) == pts + assert flatten_bulges(pts, [0.0, 0.0, 0.0]) == pts + flat = flatten_bulges(pts, [0.0, 1.0]) + assert flat[0] == (0.0, 0.0) and flat[1] == (10.0, 0.0) and flat[-1] == (10.0, 10.0) + assert len(flat) > 3, "the second edge became a semicircle of chords" + assert flatten_bulges([(1.0, 1.0)], [1.0]) == [(1.0, 1.0)] +``` + +`tests/test_pid_line_draw.py` — append: + +```python +# tests/test_pid_line_draw.py (append) + + +# ── Track A hardening (track E wave 0, Task 7) ─────────────────────────────── + + +async def test_foreign_line_payloads_are_tolerated_not_raised(backend): + """A hand-edited payload with a non-dict ``from``, a ``None`` ``to`` and a + bool ``seq`` used to escape as ``TypeError: string indices must be + integers``; reader results never raise.""" + poly = await backend.entity_create_polyline([[0, 0], [10, 0]], layer="PROCESS-PIPING-MAIN") + await write_payload( + backend, + poly.handle, + {"v": 1, "kind": "line", "seq": True, "from": "garbage", "to": None, "number": 7}, + ) + other = await backend.entity_create_polyline([[0, 5], [10, 5]], layer="PROCESS-PIPING-MAIN") + await write_payload( + backend, + other.handle, + {"v": 1, "kind": "line", "seq": "9", "from": {"handle": 3, "port": "x"}, "to": 4}, + ) + result = await draw_line( + backend, {"x": 0, "y": 50}, {"x": 50, "y": 50}, size="100", service="P" + ) + assert result["line_number"] == "100-P-1", "neither True nor '9' counts as a sequence number" + assert result["port_reuse"] == [] + + +def test_payload_helpers_accept_only_well_formed_values(): + from engineering.pid.drawlines import payload_port_refs, payload_seq + + good = {"from": {"handle": "2A", "port": "discharge"}, "to": {"handle": "2B", "port": None}} + assert payload_port_refs(good) == [("2A", "discharge")] + assert payload_port_refs("garbage") == [] and payload_port_refs(None) == [] + assert payload_port_refs({"from": "x", "to": 5}) == [] + assert payload_seq({"seq": 7}) == 7 + assert payload_seq({"seq": True}) is None and payload_seq({"seq": "9"}) is None + assert payload_seq({"seq": 7.0}) is None and payload_seq(None) is None + + +async def test_crossings_follow_a_bulged_line_not_its_chord(backend): + """A foreign P&ID line with a semicircular jump (bulge -1 from (0,0) to + (100,0) bows to +Y, apex (50,50)): a vertical utility line at x=30 from + y=20 to y=60 crosses the arc once at y≈45.8 and its chord never.""" + msp = backend._msp() + msp.add_lwpolyline( + [(0, 0, 0, 0, -1.0), (100, 0, 0, 0, 0)], + format="xyseb", + dxfattribs={"layer": "PROCESS-PIPING-MAIN"}, + ) + backend._mark_dirty() + lines = await existing_pid_lines(backend) + assert lines[0]["vertices"] == [(0.0, 0.0), (100.0, 0.0)] + assert lines[0]["bulges"] == [-1.0, 0.0] + result = await draw_line(backend, {"x": 30, "y": 20}, {"x": 30, "y": 60}, line_class="utility") + assert result["crossings"] == 1 + below = await draw_line(backend, {"x": 70, "y": -20}, {"x": 70, "y": -60}, line_class="utility") + assert below["crossings"] == 0, "the chord side of the arc is empty" + + +async def test_com_existing_lines_carry_bulges(monkeypatch): + payload = line_payload("process_major", "100-P-7", "100", "P", None, None, 7, None, None) + poly = _com_pid_polyline("3E", (0.0, 0.0, 100.0, 0.0), payload) + poly.Length = 157.0796 # a semicircle: longer than the 100 mm chord walk + poly.GetBulge = lambda index: -1.0 if index == 0 else 0.0 + backend = _fake_com(monkeypatch, [poly]) + lines = await existing_pid_lines(backend) + assert lines[0]["vertices"] == [(0.0, 0.0), (100.0, 0.0)] + assert lines[0]["bulges"] == [-1.0, 0.0] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_pid_lines.py tests/test_pid_line_draw.py -k "flatten or payload or bulge" -v` +Expected: 11 FAIL. The seven `test_pid_lines.py` tests fail with `ImportError: cannot import name 'FLATTEN_SAGITTA' / 'flatten_bulges' from 'engineering.pid.lines'`; `test_foreign_line_payloads_are_tolerated_not_raised` with `TypeError: string indices must be integers, not 'str'`; `test_payload_helpers_…` with `ImportError: cannot import name 'payload_port_refs'`; both bulge tests with `KeyError: 'bulges'`. + +- [ ] **Step 3: Pure flattening in `lines.py`** + +In `engineering/pid/lines.py`, insert before `def _orient(a: Point, b: Point, c: Point) -> float:`: + +```python +#: Max chord deviation (mm) when a bulged polyline edge is flattened for the +#: crossing test — a tenth of the thinnest ISO 128 lineweight. +FLATTEN_SAGITTA = 0.05 + + +def flatten_bulges(points, bulges, sagitta: float = FLATTEN_SAGITTA) -> list[Point]: + """The polyline as straight segments, every bulged edge replaced by chords + that deviate from the arc by at most ``sagitta``. + + Same arc arithmetic as ``engineering/measure.py`` (sweep ``4·atan(bulge)``, + radius ``chord / 2·sin(|sweep|/2)``) and ``graph._point_segment_distance`` + (centre on the chord's left normal, ``radius - sagitta`` carrying it across + the chord past a semicircle). Checked against ``ezdxf.math.bulge_to_arc`` + + ``ConstructionArc.flattening`` in ``tests/test_pid_lines.py``: identical + vertex count and positions to 1e-12 for bulges 0.2 … 2.0 of either sign. + A straight edge (bulge 0) is passed through; ``bulges`` shorter than the + edge count is padded with zeros. + """ + pts = [(float(x), float(y)) for x, y in points] + if len(pts) < 2: + return pts + out: list[Point] = [] + for i, (a, b) in enumerate(zip(pts, pts[1:], strict=False)): + out.append(a) + bulge = float(bulges[i]) if i < len(bulges) and bulges[i] else 0.0 + chord = _dist(a, b) + if abs(bulge) < 1e-12 or chord < 1e-12: + continue + theta = 4.0 * math.atan(bulge) # signed sweep, radians + radius = chord / (2.0 * math.sin(abs(theta) / 2.0)) + sag = abs(bulge) * chord / 2.0 + nx, ny = -(b[1] - a[1]) / chord, (b[0] - a[0]) / chord # left of a→b + offset = (radius - sag) * (1.0 if bulge > 0 else -1.0) + cx = (a[0] + b[0]) / 2.0 + offset * nx + cy = (a[1] + b[1]) / 2.0 + offset * ny + start = math.atan2(a[1] - cy, a[0] - cx) + if sagitta >= radius: + segments = 1 + else: + segments = max(1, math.ceil(abs(theta) / (2.0 * math.acos(1.0 - sagitta / radius)))) + for k in range(1, segments): + angle = start + theta * k / segments + out.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle))) + out.append(pts[-1]) + return out +``` + +- [ ] **Step 4: `drawlines.py` — tolerant readers, bulges on every row, flattened crossings** + +Add `flatten_bulges,` to the `.lines` import block (after `count_crossings,`): + +```python + aim_points, + count_crossings, + flatten_bulges, + format_line_number, +``` + +Insert after `_vertices` (before `async def existing_pid_lines`): + +```python +def _bulges(info, count: int) -> list[float]: + """Per-vertex bulges as the engine reports them (both engines put a + ``bulges`` list next to ``points``); straight when it reports none.""" + raw = info.properties.get("bulges") or [] + out = [] + for value in raw[:count]: + try: + out.append(float(value or 0.0)) + except (TypeError, ValueError): + out.append(0.0) + return out + [0.0] * (count - len(out)) + + +def payload_port_refs(payload) -> list[tuple[str, str]]: + """``(handle, port)`` for each well-formed ``from``/``to`` in a line payload. + + A foreign or hand-edited payload can carry anything under those keys + (``"garbage"``, ``None``, a number); reading it used to raise + ``TypeError`` out of ``draw_line``. Reader results never raise: a + malformed end is simply not a reference. + """ + refs = [] + if not isinstance(payload, dict): + return refs + for key in ("from", "to"): + ref = payload.get(key) + if not isinstance(ref, dict): + continue + handle, port = ref.get("handle"), ref.get("port") + if isinstance(handle, str) and isinstance(port, str): + refs.append((handle, port)) + return refs + + +def payload_seq(payload) -> int | None: + """The payload's sequence number when it is a genuine int (``True`` is not).""" + if not isinstance(payload, dict): + return None + seq = payload.get("seq") + return seq if type(seq) is int else None +``` + +In `existing_pid_lines`, extend the docstring's last sentence and the row — replace + +```python + on the other — every auto-built number would restart at 1 and no crossing + or port reuse would ever be reported there. + """ +``` + +with + +```python + on the other — every auto-built number would restart at 1 and no crossing + or port reuse would ever be reported there. ``vertices`` are the raw + vertices; ``bulges`` (one per vertex, DXF convention) let the crossing test + follow an arc instead of its chord. + """ +``` + +and replace + +```python + payload = await read_payload(backend, info.handle) if info.type in _POLY_TYPES else None + out.append({"handle": info.handle, "vertices": _vertices(info), "payload": payload}) +``` + +with + +```python + payload = await read_payload(backend, info.handle) if info.type in _POLY_TYPES else None + vertices = _vertices(info) + out.append( + { + "handle": info.handle, + "vertices": vertices, + "bulges": _bulges(info, len(vertices)), + "payload": payload, + } + ) +``` + +In `draw_line`, replace + +```python + existing = await existing_pid_lines(backend) + crossings = count_crossings(path, [ln["vertices"] for ln in existing]) + used: set[tuple[str, str]] = set() + max_seq = 0 + for ln in existing: + payload = ln["payload"] or {} + for key in ("from", "to"): + ref = payload.get(key) + if ref: + used.add((ref["handle"], ref["port"])) + if isinstance(payload.get("seq"), int): + max_seq = max(max_seq, payload["seq"]) +``` + +with + +```python + existing = await existing_pid_lines(backend) + crossings = count_crossings( + path, [flatten_bulges(ln["vertices"], ln["bulges"]) for ln in existing] + ) + used: set[tuple[str, str]] = set() + max_seq = 0 + for ln in existing: + used.update(payload_port_refs(ln["payload"])) + seq = payload_seq(ln["payload"]) + if seq is not None: + max_seq = max(max_seq, seq) +``` + +The tool docstring already states the arc rule (Task 6, Step 7); alias record, pack `pid`, lean — unchanged. + +- [ ] **Step 5: Run the tests** + +Run: `uv run --frozen pytest tests/test_pid_lines.py tests/test_pid_line_draw.py tests/test_pid_graph.py tests/test_pid_from_spec.py tests/test_pid_deliverables.py -q` +Expected: all PASS (the Track A COM test `test_existing_lines_are_seen_on_the_com_engine` still passes: `vertices` is unchanged and the new key is additive). + +- [ ] **Step 6: Gates and commit** + +Run: `uv run --frozen ruff check . && uv run --frozen ruff format --check engineering tests/test_pid_lines.py tests/test_pid_line_draw.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` +Expected: all PASS. + +```bash +git add engineering/pid/lines.py engineering/pid/drawlines.py tests/test_pid_lines.py tests/test_pid_line_draw.py +git commit -m "fix(pid): existing line payloads are read tolerantly and crossings follow arcs, not chords + +A foreign or hand-edited ACADMCP_PID payload (from: 'garbage', seq: True) +made the next pid_line_draw raise TypeError; payload_port_refs / +payload_seq now accept only well-formed values and a reader result never +raises. An existing polyline with a bulge was tested as its chord, so a +line through the arc reported crossings 0; flatten_bulges (pure, pinned +against ezdxf.math's flattening) feeds count_crossings on both engines. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 8: `docs/analysis/track-a-review-backlog.md` — every minor finding with a disposition + +The Track A review rounds produced ~170 minor findings (wave 1 and wave 2 as files in the orchestrator's scratchpad, waves 3–4 inside the workflow result payloads); the seven that are real defects are Tasks 1–7 of this plan. None of the findings is stored in the repository — the merge commits carry no body — so this task files all of them, de-duplicated (the Task 14 rounds repeated one another), one line each, with one of four dispositions: `fixed-in-task-N` (this plan), `fixed-in-track-A` (already resolved on main by a later Track A commit — the file cites the evidence), `accepted-limitation (reason)`, `deferred-1.7`. Findings that were only about *reporting* (an unlisted file, an unreported deviation, a plan/spec wording gap) are filed as accepted with the reason, so the list is complete rather than curated. + +**Files:** +- Create: `docs/analysis/track-a-review-backlog.md` + +**Interfaces:** none (documentation). The `docs/analysis/` folder already holds the recon/diagnostics/roadmap dossiers, so no index needs updating; `scripts/check_doc_numbers.py` quotes no figure from it. + +- [ ] **Step 1: Write the backlog** + +```markdown +# Track A (v1.6 P&ID) — review backlog + +**Source:** the plan-compliance and adversarial review rounds of Track A (2026-09-15/16), +waves 1–4. Blocking findings were fixed inside Track A (`fix(task-N)` commits) and are +not listed. Every *minor* finding is here, de-duplicated, with one disposition: + +- **fixed-in-task-N** — Track E group H (`docs/superpowers/plans/_settings_part_H.md`) +- **fixed-in-track-A** — resolved on main by a later Track A commit (evidence cited) +- **accepted-limitation** — kept as is, with the reason +- **deferred-1.7** — a real item for the 1.7 roadmap + +Reading key: `W1-T1-03` = wave 1, Track A Task 1, finding 3. Files are repository-relative. + +## Wave 1 + +### Task 1 — `block_insert(attributes=…)` (ezdxf) + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W1-T1-01 | backends/ezdxf_backend.py | The undefined-block gate (`6f4bda5`) was added without being listed as a deviation. | accepted-limitation (reporting gap; the gate is kept and shared by Task 1) | +| W1-T1-02 | plan | Branch forked before the docs commits; no code diverged. | accepted-limitation (process; merged clean) | +| W1-T1-03 | backends/ezdxf_backend.py | `attributes` was coerced after `add_blockref`, so a non-mapping failed after the write and skipped `_mark_dirty`. | fixed-in-task-3 | +| W1-T1-04 | backends/ezdxf_backend.py | `str(v)` wrote Python reprs (`None`, `True`, a dict) into ATTRIBs on both engines. | fixed-in-task-3 | +| W1-T1-05 | backends/ezdxf_backend.py | Tag matching is exact-case; `{"tag": …}` silently drops against ATTDEF `TAG`. | deferred-1.7 (normalise keys to upper case; the server's own ATTDEF tags are upper-case by `ATTDEF_TAG_RE`) | +| W1-T1-06 | backends/ezdxf_backend.py | `*Model_Space` / `*Paper_Space` pass the existence check and insert a reference cycle. | fixed-in-task-1 | +| W1-T1-07 | backends/ezdxf_backend.py | `entity_create_block_ref` still accepts an undefined block; `doc.audit()` deletes the INSERT later. | fixed-in-task-1 | +| W1-T1-08 | tests/test_block_insert_attributes.py | `attributes=None` yields no ATTRIBs headlessly while COM's `InsertBlock` instantiates ATTDEF defaults; refusal types diverged (ValueError vs RuntimeError). | refusal type: fixed-in-task-1 (COM raises the same ValueError); default ATTRIBs: deferred-1.7 (engine divergence on ATTDEF defaults) | +| W1-T1-09 | backends/ezdxf_backend.py | `block_explode` drops the ATTRIB text. | fixed-in-task-2 | + +### Task 2 — `backends/block_specs.py` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W1-T2-01 | security.py | `illegal_symbol_name_chars` added outside the task's file list, unreported. | accepted-limitation (reporting gap; harmless, tested) | +| W1-T2-02 | backends/block_specs.py | `fullmatch`, layer-character gate and `_one_line` are stricter than the plan stated. | accepted-limitation (all serve the validate-before-write rule; pinned by tests) | +| W1-T2-03 | plan / spec | Spec §4.7 per-entity `color`/`linetype`/`lineweight` overrides were dropped by the plan; unknown keys are silently ignored. | deferred-1.7 (carry the three keys through or refuse unknown keys by index) | +| W1-T2-04 | backends/block_specs.py | `_scalar` let `10**400` escape as `OverflowError` instead of the named `TypeError`. | fixed-in-task-4 | +| W1-T2-05 | backends/block_specs.py | A non-list `entities` (dict, str, generator) is refused as "empty". | deferred-1.7 | +| W1-T2-06 | backends/block_specs.py | `_layer` gates characters but not the 255-char length rule. | deferred-1.7 | +| W1-T2-07 | backends/block_specs.py | Refusal messages echo the caller's value unbounded (`got {value!r}`; measured 2 MB messages). | deferred-1.7 (truncate with reprlib) | +| W1-T2-08 | backends/block_specs.py | Same as W1-T2-03 (unknown keys such as `rotation` silently dropped). | deferred-1.7 (with W1-T2-03) | + +### Task 6 — ISA-5.1 tag grammar + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W1-T6-01 | engineering/pid/tags.py | Three grammar changes (X user-defined, no output after V, O/C only after ZS) unreported. | accepted-limitation (the fixture is the contract; documented in `describe_tables()['rules']`) | +| W1-T6-02 | engineering/pid/tags.py | Two of those go past the spec §8 wording. | accepted-limitation (spec text to be reconciled by the 1.7 docs pass) | +| W1-T6-03 | spec | Spec example meanings (`Flow rate`/`Indicate`/`Control`) differ from the tables (`Flow`/`Indicator`/`Controller`). | accepted-limitation (keys match; consumers pin the tables) | +| W1-T6-04 | engineering/pid/tags.py | The "unexpected letter" error names the first trailing letter, not the offender (`LAHX-1` → reports H). | deferred-1.7 | +| W1-T6-05 | engineering/pid/tags.py | `equipment_prefixes={}` falls back to the default map (`or` instead of `is None`); non-string values pass through. | deferred-1.7 (also W4-T19-05) | +| W1-T6-06 | tests/data/isa51_tags.json | No fixture row for K (rate of change / control station), M trailing, O readout, B/N/X user-defined readouts. | deferred-1.7 (≈5 rows) | +| W1-T6-07 | engineering/pid/tags.py | O/C trailing narrowed to Z-first tags beyond the spec text. | accepted-limitation (documented resolution of a spec/fixture conflict) | + +### Task 12 — `engineering/pid/lines.py` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W1-T12-01 | engineering/pid/lines.py | `axis_direction`, the `string.Formatter` parser and finiteness checks were added unreported. | accepted-limitation (reporting gap; pinned by tests) | +| W1-T12-02 | engineering/pid/__init__.py | Created outside the task's file list (branch predated Task 5). | accepted-limitation (byte-identical to Task 5's) | +| W1-T12-03 | engineering/pid/lines.py | `direct` and waypoint modes accept a negative / NaN `stub`. | deferred-1.7 (hoist the stub check above the mode branch) | +| W1-T12-04 | engineering/pid/lines.py | A malformed waypoint (`{"x":…}`, a flat list) escapes as `KeyError` / `TypeError` instead of `ValueError` naming `waypoints[i]`. | deferred-1.7 (also W3-T13-11) | +| W1-T12-05 | engineering/pid/lines.py | `count_crossings` undercounts a crossing that lands exactly on the other polyline's vertex. | deferred-1.7 (reported metric; orthogonal auto routes cannot produce it) | +| W1-T12-06 | engineering/pid/lines.py | A format spec containing `-` (`{seq:-05d}`) is refused with a misleading message. | accepted-limitation (a refusal, not a wrong number; hyphen inside a field spec is unsupported) | +| W1-T12-07 | engineering/pid/lines.py | `label_placement([(0,0)])` raises an unnamed error; a closed-loop waypoint route is accepted; `{{seq}}` is copied through. | accepted-limitation (unreachable through the tools; cosmetic) | + +## Wave 2 + +### Task 3 — `block_define` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T3-01 | README.md | Snapshot bumped while lines 23/30/106 still said 154. | fixed-in-track-A (Task 21 README rewrite: "166 tools in 20 groups") | +| W2-T3-02 | tests/test_tool_registry.py | Frozen snapshot edited outside the file list. | accepted-limitation (required by the gate; the precedent every later task follows) | +| W2-T3-03 | backends/ezdxf_backend.py | `base_x`/`base_y` only `float()`-cast; NaN written into the BLOCK record. | fixed-in-task-4 | +| W2-T3-04 | backends/ezdxf_backend.py | A primitive layer is written without a layer-table entry; COM would fail mid-loop. | fixed-in-task-4 | +| W2-T3-05 | README.md | Same as W2-T3-01. | fixed-in-track-A | +| W2-T3-06 | backends/ezdxf_backend.py | ATTDEF linetype stays BYLAYER on ezdxf vs ByBlock on COM; neither sets lineweight ByBlock. | deferred-1.7 (cosmetic; layer-0 members resolve to the INSERT's layer) | +| W2-T3-07 | backends/ezdxf_backend.py | `attdefs or []` turns `{}`/`''` into "no ATTDEFs"; a non-str `name` raises AttributeError. | deferred-1.7 | +| W2-T3-08 | backends/ezdxf_backend.py | `block_insert` without `attributes` carries none of the ATTDEF defaults (COM does). | deferred-1.7 (with W1-T1-08) | + +### Task 4 — XDATA + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T4-01 | discovery/aliases.py | `entity_get_xdata` ships `XDLIST` only (XDATA would break the single-route gate). | accepted-limitation (still discoverable; reporting gap) | +| W2-T4-02 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) | +| W2-T4-03 | backends/ezdxf_backend.py | APPID names are case-sensitive headlessly; `APP_A` and `app_a` become two groups and double-count the budget. | deferred-1.7 | +| W2-T4-04 | backends/com_backend.py | A no-op removal registers the APPID on COM but not on ezdxf. | deferred-1.7 | +| W2-T4-05 | backends/xdata_specs.py | 1004 binary chunks decode to the repr of a bytes object. | deferred-1.7 (hex string) | +| W2-T4-06 | backends/xdata_specs.py | The reserved-app rule blocks reading `ACAD` by name while the unfiltered read returns it; other AutoCAD-owned apps are writable. | deferred-1.7 | +| W2-T4-07 | README.md | Snapshot paragraph contradicted itself; collected-tests drift. | fixed-in-track-A (Task 21) | + +### Task 5 — `engineering/pid/xdata.py` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T5-01 | engineering/pid/__init__.py | Listed as "create" but already existed. | accepted-limitation (nothing missing) | +| W2-T5-02 | tests/test_pid_xdata.py | Sync tests under a module-level `pytest.mark.asyncio` emit PytestWarnings. | deferred-1.7 (hygiene sweep across the pid test modules) | +| W2-T5-03 | spec | Spec payload (`actuator`, 3-element ports, no `seq`) vs plan (`variant`, 5-element ports, `seq`). | accepted-limitation (plan governs; spec text to reconcile) | +| W2-T5-04 | engineering/pid/xdata.py | `encode_payload` uses `allow_nan=True`; a NaN would be serialised as a non-RFC literal. | deferred-1.7 (`allow_nan=False`; no current caller can deliver one after Task 5) | +| W2-T5-05 | engineering/pid/xdata.py | `symbol_payload(params={})` is replaced by the spec's params (truthiness). | deferred-1.7 | +| W2-T5-06 | engineering/pid/xdata.py | Duplicate port names collapse silently (last wins). | deferred-1.7 (refuse in `make_spec`) | +| W2-T5-07 | engineering/pid/xdata.py | A non-dict payload is written and can never be read back. | deferred-1.7 | +| W2-T5-08 | tests/test_pid_xdata.py | No multi-chunk payload round trip through a backend. | deferred-1.7 (test gap; the path was verified by hand) | + +### Task 7 — symbol framework + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T7-01 | engineering/pid/symbols.py | Per-type outlines (no circle for computer/plc) deviate from the plan snippet, unreported. | accepted-limitation (the spec's ISA table governs; tests added) | +| W2-T7-02 | tests/test_pid_symbols.py | Unused import dropped, `math` added. | accepted-limitation (no functional effect) | +| W2-T7-03 | engineering/pid/symbols.py | Rear-location dashed lines stop up to 1 mm short on the right. | accepted-limitation (cosmetic; no port, bbox or gate depends on it) | +| W2-T7-04 | engineering/pid/symbols.py | A single radial `signal` port cannot describe the hexagon/square outlines (0.67 mm gap on vertical lines into `computer`). | accepted-limitation (spec §4.4 model; a per-axis reach is a 1.7 item) | +| W2-T7-05 | engineering/pid/symbols.py | `all_specs()` swallows builder ValueErrors and nothing pins the catalogue size. | deferred-1.7 (assert `len(all_specs())`) | +| W2-T7-06 | engineering/pid/symbols.py | `register()` overwrites silently; `list_symbols()` returns variants by reference. | deferred-1.7 | +| W2-T7-07 | engineering/pid/geometry.py | `bbox_of` samples arcs at 1° and can under-report by 3.8e-5·r. | accepted-limitation (sub-lineweight) | + +### Task 8 — valves + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T8-01 | plan | The plan file was edited to match the shipped geometry, unreported. | accepted-limitation (the original geometry floated the dome; evidence in the commit) | +| W2-T8-02 | engineering/pid/symbols_valves.py | `check`/`relief` advertise actuator variants they refuse. | deferred-1.7 (variants `["none"]` for those bodies) | +| W2-T8-03 | engineering/pid/symbols_valves.py | The diaphragm *body* dome floats 1 mm above the bow-tie. | deferred-1.7 (cosmetic) | +| W2-T8-04 | engineering/pid/symbols_valves.py | The `signal` port and the TAG/DESC text share x = 0 above the actuator; a signal line crosses the tag. | accepted-limitation (spec §4.7 placement; a tag/line overlap critique is a 1.7 item, with W2-T9-05) | + +### Task 9 — equipment + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T9-01 | engineering/pid/symbols_equipment.py | `pd_pump` ports are `in`/`out`, spec says `suction`/`discharge`. | accepted-limitation (plan governs; spec text to reconcile; `pid_from_spec` names ports explicitly) | +| W2-T9-02 | engineering/pid/symbols_equipment.py | `spectacle_blind` cites ISO 10628-2 instead of PIP PIC001; no spec names its figure/table. | deferred-1.7 (provenance strings) | +| W2-T9-03 | engineering/pid/symbols_equipment.py | Same as W2-T9-01. | accepted-limitation | +| W2-T9-04 | engineering/pid/symbols_equipment.py | `build_equipment` ignores `shape` for non-reducers and its signature is narrower than `**options`. | accepted-limitation (`resolve()` validates options; builders are internal) | +| W2-T9-05 | engineering/pid/symbols_equipment.py | TAG/DESC sit on the outward ray of every +Y port. | accepted-limitation (with W2-T8-04) | +| W2-T9-06 | engineering/pid/symbols_equipment.py | `air_cooler` fan cross pokes 0.046 mm outside its circle. | deferred-1.7 (cosmetic, ±1.75) | +| W2-T9-07 | engineering/pid/symbols_equipment.py | Seven origins are not the bbox centre (spec §4.2). | accepted-limitation (deliberate mounting points; spec text to reconcile) | + +### Task 10 — vessels + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W2-T10-01 | engineering/pid/geometry.py | `scan_hits` added outside the file list, unreported. | accepted-limitation (additive, tested) | +| W2-T10-02 | engineering/pid/symbols_vessels.py | Stricter size rules and outline-based stubs, unreported. | accepted-limitation (evidence in the commit; plan tests unchanged) | +| W2-T10-03 | spec | `PID_VESSEL_VERTICAL_` vs `PID_VESSEL_VERTICAL_VESSEL_`. | accepted-limitation (plan's general rule governs) | +| W2-T10-04 | engineering/pid/symbols_vessels.py | Non-finite sizes/fractions pass `validate_vessel_params` (NaN spec, `RuntimeError` for inf). | deferred-1.7 (`math.isfinite` next to `> 0`) | +| W2-T10-05 | engineering/pid/symbols_vessels.py | Column tray lines poke through dished heads (up to 1.8 mm). | deferred-1.7 (clip to the outline with `scan_hits`) | +| W2-T10-06 | engineering/pid/symbols_vessels.py | Nozzle fractions near 0/1 on curved/sloped sides give degenerate long stubs from a corner. | deferred-1.7 | +| W2-T10-07 | engineering/pid/symbols_vessels.py | `trays` is unbounded (1 000 000 trays build in 6.3 s / 272 MB). | deferred-1.7 (cap by spacing ≥ 1 mm) | +| W2-T10-08 | engineering/pid/symbols_vessels.py | Two nozzles at the same side+fraction duplicate the stub; `-0.0` hashes to a different block name. | deferred-1.7 | + +## Waves 3–4 + +### Task 11 — `pid_symbol_list` / `pid_symbol_insert` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W3-T11-01 | engineering/layers.py | `PROCESS-LINE-TEXT` inserted at position 9; spec said "16th entry". | accepted-limitation (count matches; ordinal wording only) | + +### Task 13 — `pid_line_draw` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W3-T13-01 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) | +| W3-T13-02 | discovery/aliases.py | `PLINE` added to `SHARED_ACAD_COMMANDS`. | accepted-limitation (required by the single-route gate; documented in the allowlist comment) | +| W3-T13-03 | backends/com_backend.py | `e924b5d` touched three files outside the list, unreported (COM `AcDbPolyline` naming, mirrored ports). | accepted-limitation (both were defects; tests added) | +| W3-T13-04 | server.py | `>=` for `≥` in a docstring. | accepted-limitation (cosmetic) | +| W3-T13-05 | engineering/pid/drawlines.py | Line sequence is per current space, not per document (spec §6.3). | deferred-1.7 (document-wide scan of existing lines) | +| W3-T13-06 | engineering/pid/drawlines.py | Every signal line got a fabricated number and label and consumed the process sequence. | fixed-in-task-6 | +| W3-T13-07 | engineering/pid/drawlines.py | A bubble's exit aimed at the far end even with waypoints, so the first segment cut through the bubble. | fixed-in-task-6 | +| W3-T13-08 | engineering/pid/drawlines.py | A foreign/hand-edited payload with a non-dict `from`/`to` raised `TypeError` out of `draw_line`. | fixed-in-task-7 | +| W3-T13-09 | engineering/pid/drawlines.py | (a) crossings tested against chords, not arcs; (b) COM heavy 2DPOLYLINE `Coordinates` read with stride 2. | (a) fixed-in-task-7; (b) deferred-1.7 (heavy polylines the tool never creates) | +| W3-T13-10 | README.md | Snapshot line edited without the collected-tests figure. | fixed-in-track-A (Task 21) | +| W3-T13-11 | server.py | An unknown endpoint handle and a flat waypoint list surface as "Internal error" rather than a named refusal. | deferred-1.7 (with W1-T12-04) | + +### Task 14 — `pid_graph` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W3-T14-01 | engineering/pid/graph.py | Block-name keywords match whole tokens, not substrings (spec §9.2); `GATEVALVE` no longer classifies. | accepted-limitation (the plan's own test demands it; documented in code) | +| W3-T14-02 | backends/ezdxf_backend.py | A public `bulges` key was added to every LWPOLYLINE `entity_get`, unreported. | accepted-limitation (additive; spec §9.4 "geometry from the drawing"; now consumed by Task 7) | +| W3-T14-03 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) | +| W3-T14-04 | engineering/pid/graph.py | Edge dict carries `length_approximate` and (with geometry) `bulges` beyond the spec. | accepted-limitation (additive) | +| W3-T14-05 | tests/test_pid_graph.py | Three sync tests under the asyncio mark warn. | deferred-1.7 (hygiene, with W2-T5-02) | +| W3-T14-06 | engineering/pid/graph.py | `_Edge.segments` omits a closed polyline's closing edge while `length` includes it. | deferred-1.7 | +| W3-T14-07 | engineering/pid/graph.py | The dangling `nearest` hint never names a radial (bubble) port. | deferred-1.7 | +| W3-T14-08 | engineering/pid/graph.py | Text-tag fallback searched a circle, not "above the box". | fixed-in-track-A (`d17a261`, `6ad3734`: tags above the box, `LINE_NUMBER_RE` excluded) | +| W3-T14-09 | engineering/pid/graph.py | `scope="all"` restores `current` but not the paper-space layout binding. | deferred-1.7 | +| W3-T14-10 | engineering/pid/graph.py | A −Z-extruded catalogue INSERT reported mirror-image ports at confidence 1.0. | fixed-in-track-A (`6ad3734`: both engines report `mirrored`; `transform_port(mirrored=)`) | +| W3-T14-11 | engineering/pid/graph.py | A `PID_*` INSERT whose XDATA was stripped falls to the 0.6 heuristic with no ports (spec says catalogue 1.0). | deferred-1.7 | +| W3-T14-12 | README.md | Release-consistency gate red on the collected-tests figure. | fixed-in-track-A (Task 21) | +| W3-T14-13 | engineering/pid/symbols.py | `6ad3734` touched five files outside the list; `transform_port` grew `mirrored`/`strict`. | accepted-limitation (justified; interface kept by keyword defaults) | +| W3-T14-14 | engineering/pid/graph.py | `_text_near` accepts a baseline half a height inside the box and excludes line-number texts — beyond the spec wording. | accepted-limitation (documented, tested) | +| W3-T14-15 | engineering/pid/graph.py | Node dicts gained `y_scale`, `mirrored`, `notes`, `space`, `description`; junctions `space`. | accepted-limitation (additive) | +| W3-T14-16 | docs (plan/spec) | `ruff format --check` was red on the plan and spec Markdown. | fixed-in-track-A (Task 21 formatted them) | +| W3-T14-17 | spec | §9.1 model not extended for the added node/junction keys. | deferred-1.7 (docs) | +| W3-T14-18 | engineering/pid/symbols.py | `strict=False` keeps a diagonal port's local angle under non-uniform scale. | accepted-limitation (unreachable: all 243 catalogue ports are axis-aligned) | +| W3-T14-19 | backends/ezdxf_backend.py | `mirrored` is `True` for a tilted INSERT with a negative-z normal (COM twin too). | deferred-1.7 (`flat and z < 0`) | + +### Task 15 — deliverables + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W3-T15-01 | tests/test_pid_deliverables.py | Four plan deviations (line-list rows, `_far_ends`, CSV refusal, `deliverable()` ValueError) unreported. | accepted-limitation (each stronger than the plan; the plan's own numbers were wrong) | +| W3-T15-02 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) | +| W3-T15-03 | tests/_probe_task15.py | A stray probe file appeared during review. | fixed-in-track-A (not in the tree) | +| W3-T15-04 | engineering/pid/deliverables.py | `natural_key` crashes on `str.isdigit()` runs that are not `\d` (①, ²). | deferred-1.7 (`isdecimal`) | +| W3-T15-05 | engineering/pid/deliverables.py | `_far_ends` resolves junctions at the ends of the instrument's line only, not on it. | deferred-1.7 | +| W3-T15-06 | engineering/pid/deliverables.py | `_tag_of` writes the internal junction id into a tag column; untagged node ends are `None` vs handle in the index. | deferred-1.7 | +| W3-T15-07 | engineering/pid/deliverables.py | `write_csv` edge cases: 0-byte file for zero rows, `csv_path=''` treated as None, directory path escapes as PermissionError, dead `mkdir`, late validation. | deferred-1.7 | +| W3-T15-08 | engineering/pid/deliverables.py | CSV formula injection: cells starting with `=+-@` are written verbatim. | deferred-1.7 (first item of the 1.7 deliverables pass) | +| W3-T15-09 | server.py | The three deliverable tools expose only `tolerance`/`scope`, not `label_search`/`include_foreign`. | deferred-1.7 | +| W3-T15-10 | engineering/pid/deliverables.py | `signal_lines` counts every edge on a bubble regardless of class. | deferred-1.7 | + +### Task 16 — critique focuses + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W3-T16-01 | tests/test_pid_critique.py | The refine test calls `refine_drawing` (no backend `drawing_refine` exists), unreported. | accepted-limitation (the plan's call could not run; stricter test) | +| W3-T16-02 | engineering/pid/critique.py | `has_pid_content` guard instead of `not graph['nodes']`, unreported. | accepted-limitation (spec intent; pinned by tests) | +| W3-T16-03 | engineering/pid/critique.py | `_incompatible` skips inferred ports; `_dangling` reports unknown_block ends — beyond the plan. | accepted-limitation (spec §9.4 / §11.1; tested) | +| W3-T16-04 | tests/test_pid_critique.py | Two sync tests warn under the asyncio mark. | deferred-1.7 (hygiene) | +| W3-T16-05 | engineering/pid/critique.py | `_untagged` tests the composed tag, not FUNC/LOOP; an empty LOOP files as `pid_illegal_tag`. | deferred-1.7 | +| W3-T16-06 | engineering/pid/critique.py | A mechanical block whose name holds a keyword token (`OIL_FILTER`) counts as P&ID content and can fail finalize. | accepted-limitation (spec §9.2 mandates the heuristic; README "Known limitations" states it) | +| W3-T16-07 | engineering/pid/critique.py | `_duplicate_tags` scores a text-derived (guessed) tag as a gate-failing error. | deferred-1.7 (warning for `tag_source: text`) | + +### Task 17 — `pid_from_spec` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W4-T17-01 | tests/test_pid_from_spec.py | Four reported deviations verified legitimate (save_path, score dict, async test, snapshot). | accepted-limitation | +| W4-T17-02 | engineering/pid/spec.py | The module is a refactor of the plan's listing; interfaces preserved. | accepted-limitation | +| W4-T17-03 | README.md | `pid-showcase.png` rendered but not referenced. | fixed-in-track-A (README line 20 embeds it) | +| W4-T17-04 | tests/test_release_consistency.py | Collected-tests drift. | fixed-in-track-A (Task 21) | +| W4-T17-05 | engineering/pid/spec.py | `_num` accepts NaN/inf; an INSERT lands at `[nan, 0]` and `build_graph` crashes. | fixed-in-task-5 | +| W4-T17-06 | engineering/pid/spec.py | `run_spec` catches `Exception` only; a cancellation leaves an open transaction. | fixed-in-task-5 | +| W4-T17-07 | engineering/pid/spec.py | `dry_run` skips `place_symbol`'s option checks (`scale=-1` approved; wrong item blamed). | deferred-1.7 (`validate_spec` owns scale/fail/actuator rules) | +| W4-T17-08 | engineering/pid/spec.py | `dry_run` crossings count the spec's own lines only, unlabelled. | deferred-1.7 (`crossings_scope` field) | +| W4-T17-09 | engineering/pid/spec.py | Nested transactions: ezdxf nests, COM refuses; `drawing_plan` is set before the refusal. | deferred-1.7 | +| W4-T17-10 | engineering/pid/spec.py | The `sheet` block is not validated (unknown layer set / sheet size / keys accepted). | deferred-1.7 | +| W4-T17-11 | engineering/pid/spec.py | String-typed optional keys unvalidated (`arrow: "no"` truthy; `number: 123` bare TypeError; `id` with a dot). | deferred-1.7 | +| W4-T17-12 | README.md | README showcase half incomplete; gate red. | fixed-in-track-A (Task 21) | + +### Task 19 — tag parse, resources, prompt + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W4-T19-01 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) | +| W4-T19-02 | scripts/render_pid_catalog.py | Renders through its own matplotlib path instead of `view_screenshot`, unreported. | accepted-limitation (byte-reproducible output; interface kept) | +| W4-T19-03 | README.md | Lines 23/106 still said 154 / 6 resources. | fixed-in-track-A (Task 21) | +| W4-T19-04 | server.py | The prompt's OFF-PAGE example emits a tag warning every time it is followed (`TO P&ID-002` parsed as ISA-5.1). | deferred-1.7 (skip `parse_tag` for connectors at insert) | +| W4-T19-05 | engineering/pid/tags.py | `equipment_prefixes={}` discarded; non-string values pass. | deferred-1.7 (with W1-T6-05) | +| W4-T19-06 | README.md | Release gate red on the collected-tests figure. | fixed-in-track-A (Task 21) | + +### Task 18 — `TOOL_PACKS` + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W4-T18-01 | tests/test_tool_packs.py | A sync test warns under the asyncio mark. | deferred-1.7 (hygiene) | +| W4-T18-02 | README.md / config.py / .env.example | Token table and prose said 47 while lean has 50. | README: fixed-in-track-A (Task 21); `config.py:99` / `.env.example:52` "~47": deferred-1.7 (docs) | +| W4-T18-03 | server.py | `TOOL_PACKS=all,mech3000` drops the unknown entry without a warning or `ignored`. | deferred-1.7 | +| W4-T18-04 | server.py | `_enabled_packs()` runs twice per apply; unknown entries warn twice and duplicate in `ignored`. | deferred-1.7 | +| W4-T18-05 | tests/test_tool_packs.py | The pack test compares against a hand-copied set, never the registry. | deferred-1.7 (assert against `_registered_tools()` tags) | +| W4-T18-06 | server.py | Under `TOOL_PACKS=core` the P&ID prompt and resources are still advertised. | accepted-limitation (spec §12 scopes packs to tools) | + +### Task 20 — benchmark v4 + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W4-T20-01 | scripts/check_doc_numbers.py | The gate short-circuited on the 29-vs-26 A/B drift. | fixed-in-track-A (Task 21 regenerated the A/B report) | +| W4-T20-02 | CLAUDE.md | An extra clause beyond the dictated sentence. | accepted-limitation (accurate) | +| W4-T20-03 | benchmarks/README.md | "16/16 on the v4 matrix" has no published artifact behind it. | deferred-1.7 (publish a v4 reference report at the 1.6 release) | +| W4-T20-04 | tests/test_benchmark_v4.py | No test executes `_task_pid_roundtrip`. | deferred-1.7 (Task 6 of the hardening plan runs it by hand) | +| W4-T20-05 | benchmarks/README.md | A/B rows said 29 checks over a 26-check report. | fixed-in-track-A (Task 21) | + +### Task 21 — docs and smoke + +| ID | File | Finding | Disposition | +|---|---|---|---| +| W4-T21-01 | scripts/smoke_pid_com.py | Not re-run live during review (no AutoCAD running). | accepted-limitation (the implementer's live run is recorded in the CHANGELOG) | +| W4-T21-02 | README.md | "Known limitations of the P&ID track" was not a plan item. | accepted-limitation (claims verified against the code) | +| W4-T21-03 | docs (plan) | The plan file was ruff-formatted outside the file list. | accepted-limitation (mechanical) | +| W4-T21-04 | benchmarks/README.md | Tables moved for the new gate labels. | accepted-limitation (required by `check_doc_numbers.py`) | +| W4-T21-05 | CHANGELOG.md | "12.5 % short on a semicircular jump" is the v1.5 square figure; the tested geometry is 10.25 %. | deferred to the final docs task of Track E (CHANGELOG `Unreleased` edit) | +| W4-T21-06 | README.md | The snapshot is labelled v1.5 while quoting the 1.6-dev surface. | accepted-limitation (pinned to pyproject's minor until the 1.6 bump) | +| W4-T21-07 | scripts/smoke_pid_com.py | Exit code checks `dangling` only; critique/confidence/xdata are printed, not asserted. | deferred-1.7 | +| W4-T21-08 | CHANGELOG.md | `LAYER_SET_INTENT_MISMATCH` is claimed for `drawing_plan`, which only appends a warning string. | deferred to the final docs task of Track E | + +## Totals + +165 rows. Fixed in this track (Tasks 1–7): 15 · fixed inside Track A: 17 · accepted with a reason: 62 · deferred (1.7 or the Track E docs task): 71. +``` + +- [ ] **Step 2: Check the numbers and the links** + +Run: + +```bash +uv run --frozen python - <<'EOF' +import re, pathlib +text = pathlib.Path("docs/analysis/track-a-review-backlog.md").read_text(encoding="utf-8") +rows = [line for line in text.splitlines() if re.match(r"\| W\d-T\d+-\d+ \|", line)] +ids = [row.split("|")[1].strip() for row in rows] +assert len(ids) == len(set(ids)), "duplicate ids" +kinds = {"fixed-in-task": 0, "fixed-in-track-A": 0, "accepted-limitation": 0, "deferred": 0} +for row in rows: + disposition = row.rsplit("|", 2)[1] + for key in kinds: + if key in disposition: + kinds[key] += 1 + break + else: + raise SystemExit(f"no disposition: {row}") +print(len(rows), kinds) +EOF +``` + +Expected: `165 {'fixed-in-task': 15, 'fixed-in-track-A': 17, 'accepted-limitation': 62, 'deferred': 71}` — the Totals line; no duplicate ids. + +- [ ] **Step 3: Commit** + +```bash +git add docs/analysis/track-a-review-backlog.md +git commit -m "docs(analysis): Track A review backlog — every minor finding with a disposition + +Waves 1-4 of the Track A review produced ~170 minor findings that lived +only in the orchestrator's scratchpad. Filed de-duplicated, one line each: +fixed-in-task-N (this track's hardening tasks), fixed-in-track-A (with the +commit), accepted-limitation (with the reason) or deferred-1.7. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +## Group H exit + +After Task 8 the `track-e/hardening` worktree carries eight commits and no file outside its ownership except the two adapter lines named in Task 6. Before handing the branch to the merge agent run the whole suite once: + +`uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree` + +Expected: everything green (measured on the prototype: 2473 passed, 1 xfailed; the tree collects 2476 tests, 53 more than the README's 2423 — the merge agent regenerates that figure with the other count files). + + +# Track E — Group S (styles): Tasks 9–13 + +Part of `docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`. Its Global Constraints, File Structure, Interfaces and Execution sections are binding here; this part writes the five tasks of group **S** in full. Worktree: `track-e/styles`. Every command runs through `uv run --frozen`. + +**Group S owns:** `engineering/standards/{__init__,dimstyles,textstyles,mleaderstyles,apply}.py`, `backends/contracts/styles.py`, the two backend blocks headed `# ── styles (track E) ──`, `server.py` SECTION 18, the `# ── Styles (track E, group S) ──` alias block, `tests/test_standards_presets.py`, `tests/test_styles.py`. The shared count files (`docs/tool-inventory.json`, the README snapshot line, `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`) are bumped per task exactly as Track A did; the merge agent regenerates them once more after the groups merge. + +**Two edits outside the delimited blocks, decided here and flagged for the merge agent (Task 10, steps 6–7):** the five headless dimension creators pass `dimstyle=_DIMSTYLE_NAME` (a literal `"Standard"`) and the two text creators pass no style at all, so `$DIMSTYLE` / `$TEXTSTYLE` would be header-only no-ops headlessly — exactly the silent success v1.5's honesty pass exists to delete. The task replaces those seven literals with `_current_dimstyle_name(doc)` / `_current_textstyle_name(doc)` (one-line edits at the call sites, nothing else in those methods changes). No other group edits those lines. + +**Measured on 2026-09-16 with ezdxf 1.4.4 (Python 3.11), and relied on below:** + +- `ezdxf.new()` writes `$DIMSTYLE = "ISO-25"` into a fresh header **with no such table entry** (`doc.dimstyles` holds only `Standard`). Creating a style literally named `ISO-25` would therefore make it current by accident; `dimstyle_create` pins the header to the previously *effective* style when `set_current` is false. +- The header fold (`_with_header_dimvars`) compares `$DIM*` against the *stored* style value and folds any difference onto the next dimension. After `dimstyle_set_current("ISO-25")` without a header sync the fresh header's `$DIMDSEP 46` won and the dimension rendered `33.33`; with the style's stored values loaded into `$DIM*` (what a live seat does on `-DIMSTYLE Restore`) the fold is empty and the dimension renders `33,33` at 2.5 mm text, `_CLOSEDFILLED` arrows at xscale 2.5. ANSI renders `33.33` at 3.0 with 3.0 arrows. +- A DIMSTYLE entry stores `dimtxsty` as a name (`dxf.get("dimtxsty") == "ISOCP"`), round-trips through `write`/`read`, and `hasattr` is true once set; the DXF schema defaults (`acdb_dimstyle.attribs[x].default`) for the seventeen reported variables are `2.5, 2.5, 1.25, 0.625, 0.625, 1, 0, 0, 2, 44, 2, 8, "", None, -2, -2, 1`. +- `doc.header["$DIMDSEP"] = ","` raises `DXFValueError`; every other `$DIM*` in the whitelist accepts its typed value; `$DIMTXSTY`/`$DIMSTYLE`/`$TEXTSTYLE` accept a name string. +- `doc.mleader_styles.new(name)` creates an entry with only `handle/name/owner`; `arrow_head_size`, `landing_gap_size`, `char_height`, `text_style_handle` are settable, round-trip, and `audit()` reports 0 errors. The schema defaults are 4 / 2.0 / 4. `has_entry` is case-insensitive on every table. +- `ezdxf.fonts.fonts.font_manager.has_font("arial.ttf")` is `True` and `has_font("isocp.shx")` is `False` on this machine (no AutoCAD support path is scanned), which is why `font_resolved` is *preset-or-font-manager*, never font-manager alone. +- `ezdxf.new()` sets `$INSUNITS = 6` (metres), so `drawing_apply_standard`'s first call genuinely changes `INSUNITS 6 → 4`. + +--- + +### Task 9: `engineering/standards/` — presets as data, whitelist, ranges, resolvers, provenance tests + +**Files:** +- Create: `engineering/standards/__init__.py`, `engineering/standards/dimstyles.py`, `engineering/standards/textstyles.py`, `engineering/standards/mleaderstyles.py` +- Test: `tests/test_standards_presets.py` + +**Interfaces:** +- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`. +- Every refusal is `ValueError` (wrong type: `TypeError`) naming the key, and nothing in this package writes anything — the backends call these before they touch a table. +- Groups C and P add `sysvars.py`, `papers.py`, `pdfinfo.py`, `templates.py`, `plot.py` to the same package and import them by path; this `__init__.py` re-exports only group S's names, so those groups never need to edit it. + +- [ ] **Step 1: Write the failing provenance tests** + +```python +# tests/test_standards_presets.py +"""Provenance for the authored style presets, pinned by name. + +Every ISO-25 and ANSI value is asserted against the table in spec §4.1 — a +reader with ISO 129-1 / ISO 3098-1 / ASME Y14.2 open can check each line. +The refusals are pinned too: a value the standards data lets through is a +value a backend writes, so the data is where "nothing written on refusal" +starts. +""" + +from __future__ import annotations + +import pytest + +from engineering.standards.dimstyles import ( + DIM_VARIABLE_RANGES, + DIM_VARIABLE_WHITELIST, + LINEWEIGHT_CODES, + PRESET_VARIABLES, + PRESETS, + check_dim_value, + describe_preset, + resolve_dimstyle, + validate_overrides, +) +from engineering.standards.mleaderstyles import ( + MLEADER_KEYS, + MLEADER_PRESETS, + resolve_mleaderstyle, +) +from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle + +# ── ISO-25: ISO 129-1:2018 at the 2.5 mm row of ISO 3098-1 ────────────────── + +ISO_25_EXPECTED = { + "DIMTXT": 2.5, + "DIMASZ": 2.5, + "DIMEXE": 1.25, + "DIMEXO": 0.625, + "DIMGAP": 0.625, + "DIMTAD": 1, + "DIMTIH": 0, + "DIMTOH": 0, + "DIMDEC": 2, + "DIMDSEP": ",", + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMBLK": "", + "DIMTXSTY": "ISOCP", + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, +} + +# ── ANSI: ASME Y14.2-2014, metric values ──────────────────────────────────── + +ANSI_EXPECTED = { + "DIMTXT": 3.0, + "DIMASZ": 3.0, + "DIMEXE": 1.5, + "DIMEXO": 1.5, + "DIMGAP": 1.0, + "DIMTAD": 0, + "DIMTIH": 1, + "DIMTOH": 1, + "DIMDEC": 2, + "DIMDSEP": ".", + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMBLK": "", + "DIMTXSTY": "ROMANS", + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, +} + + +@pytest.mark.parametrize(("variable", "expected"), sorted(ISO_25_EXPECTED.items())) +def test_iso25_value_by_name(variable, expected): + assert PRESETS["iso-25"][variable] == expected + assert type(PRESETS["iso-25"][variable]) is type(expected) + + +@pytest.mark.parametrize(("variable", "expected"), sorted(ANSI_EXPECTED.items())) +def test_ansi_value_by_name(variable, expected): + assert PRESETS["ansi"][variable] == expected + assert type(PRESETS["ansi"][variable]) is type(expected) + + +def test_presets_set_exactly_the_seventeen_spec_variables(): + assert len(PRESET_VARIABLES) == 17 + for name, preset in PRESETS.items(): + assert tuple(preset) == PRESET_VARIABLES, name + + +def test_the_whitelist_is_the_seventeen_plus_the_sixteen_spec_extras(): + extras = set( + "DIMTOL DIMTP DIMTM DIMTOLJ DIMTFAC DIMLFAC DIMRND DIMATFIT " + "DIMTMOVE DIMCLRD DIMCLRE DIMCLRT DIMSAH DIMBLK1 DIMBLK2 DIMCEN".split() + ) + assert DIM_VARIABLE_WHITELIST == set(PRESET_VARIABLES) | extras + assert set(DIM_VARIABLE_RANGES) == DIM_VARIABLE_WHITELIST + + +def test_iso_lineweights_are_all_legal_codes(): + for mm in (0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0): + assert round(mm * 100) in LINEWEIGHT_CODES + + +def test_describe_preset_names_the_standard(): + iso = describe_preset("ISO-25") + assert iso["name"] == "iso-25" + assert "ISO 129-1" in iso["source"] and "ISO 3098-1" in iso["source"] + assert iso["values"] == ISO_25_EXPECTED and iso["textstyle"] == "ISOCP" + ansi = describe_preset("ansi") + assert "ASME Y14.2" in ansi["source"] and ansi["textstyle"] == "ROMANS" + with pytest.raises(ValueError, match="preset"): + describe_preset("din") + + +# ── resolve_dimstyle ──────────────────────────────────────────────────────── + + +def test_none_preset_is_the_iso25_base(): + assert resolve_dimstyle(None, None) == ISO_25_EXPECTED + assert resolve_dimstyle("ISO-25", {}) == ISO_25_EXPECTED + assert resolve_dimstyle("ansi", None) == ANSI_EXPECTED + + +def test_overrides_apply_on_top_and_are_upper_cased_and_typed(): + values = resolve_dimstyle("iso-25", {"dimtxt": 3, "DIMDEC": 3.0, "dimdsep": "."}) + assert values["DIMTXT"] == 3.0 and type(values["DIMTXT"]) is float + assert values["DIMDEC"] == 3 and type(values["DIMDEC"]) is int + assert values["DIMDSEP"] == "." + assert values["DIMASZ"] == 2.5, "untouched values stay the preset's" + + +def test_resolve_leaves_the_preset_table_untouched(): + resolve_dimstyle("iso-25", {"DIMTXT": 9.0}) + assert PRESETS["iso-25"]["DIMTXT"] == 2.5 + + +@pytest.mark.parametrize( + ("overrides", "fragment"), + [ + ({"DIMFOO": 1}, "DIMFOO"), + ({"DIMDEC": 9}, "DIMDEC"), + ({"DIMDEC": -1}, "DIMDEC"), + ({"DIMDEC": 2.5}, "whole number"), + ({"DIMTXT": 0}, "DIMTXT"), + ({"DIMASZ": -1}, "DIMASZ"), + ({"DIMTAD": 5}, "DIMTAD"), + ({"DIMTIH": 2}, "DIMTIH"), + ({"DIMDSEP": ",,"}, "DIMDSEP"), + ({"DIMDSEP": ""}, "DIMDSEP"), + ({"DIMLWD": 27}, "DIMLWD"), + ({"DIMLUNIT": 0}, "DIMLUNIT"), + ({"DIMZIN": 16}, "DIMZIN"), + ({"DIMTXSTY": ""}, "DIMTXSTY"), + ({"DIMTXSTY": "a/b"}, "DIMTXSTY"), + ({"DIMLFAC": 0}, "DIMLFAC"), + ({"DIMTFAC": 0}, "DIMTFAC"), + ({"DIMCLRT": 257}, "DIMCLRT"), + ({"DIMRND": -0.5}, "DIMRND"), + ({"DIMSCALE": float("nan")}, "DIMSCALE"), + ], +) +def test_out_of_range_or_unknown_overrides_are_refused_by_name(overrides, fragment): + with pytest.raises(ValueError, match=fragment): + resolve_dimstyle("iso-25", overrides) + + +@pytest.mark.parametrize( + ("overrides", "fragment"), + [ + ({"DIMTXT": "2.5"}, "DIMTXT"), + ({"DIMTAD": True}, "DIMTAD"), + ({"DIMDSEP": 44}, "DIMDSEP"), + ({"DIMBLK": 0}, "DIMBLK"), + ], +) +def test_wrong_types_are_a_type_error_naming_the_key(overrides, fragment): + with pytest.raises(TypeError, match=fragment): + resolve_dimstyle("iso-25", overrides) + + +def test_unknown_preset_is_refused(): + with pytest.raises(ValueError, match="preset"): + resolve_dimstyle("din", None) + with pytest.raises(TypeError, match="overrides"): + resolve_dimstyle("iso-25", [("DIMTXT", 2.5)]) + + +def test_validate_overrides_alone_is_what_modify_uses(): + assert validate_overrides(None) == {} + assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text" + assert check_dim_value("dimlwe", 25) == 25 + assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE" + + +# ── text styles ───────────────────────────────────────────────────────────── + + +def test_text_presets_are_the_four_spec_fonts(): + assert TEXT_PRESETS == { + "ISOCP": ("isocp.shx", 1.0, 0.0), + "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), + "ARIAL": ("arial.ttf", 1.0, 0.0), + "ROMANS": ("romans.shx", 1.0, 0.0), + } + + +@pytest.mark.parametrize( + ("font", "expected"), + [ + ("ISOCP", "isocp.shx"), + ("isocp", "isocp.shx"), + ("isocp.shx", "isocp.shx"), + ("ISOCP.SHX", "isocp.shx"), + ("ISOCPEUR", "isocpeur.ttf"), + ("arial", "arial.ttf"), + ("Arial.ttf", "arial.ttf"), + ("romans.shx", "romans.shx"), + ], +) +def test_a_preset_font_resolves_known(font, expected): + assert resolve_font(font) == (expected, True) + + +def test_an_unknown_font_passes_through_unknown_never_refused(): + assert resolve_font("simplex.shx") == ("simplex.shx", False) + assert resolve_font(" c:/fonts/custom.ttf ") == ("c:/fonts/custom.ttf", False) + + +def test_empty_or_control_font_names_are_refused(): + with pytest.raises(ValueError, match="font"): + resolve_font("") + with pytest.raises(ValueError, match="font"): + resolve_font("bad\nfont.shx") + with pytest.raises(TypeError, match="font"): + resolve_font(None) + + +def test_validate_textstyle_types_the_request(): + spec = validate_textstyle(" ISOCP ", "isocp", 0, 1, 0) + assert spec == { + "name": "ISOCP", + "font_file": "isocp.shx", + "known": True, + "height": 0.0, + "width_factor": 1.0, + "oblique_deg": 0.0, + } + + +@pytest.mark.parametrize( + ("kwargs", "fragment"), + [ + ({"name": ""}, "name"), + ({"name": "A:B"}, "name"), + ({"height": -1}, "height"), + ({"width_factor": 0}, "width_factor"), + ({"oblique_deg": 86}, "oblique_deg"), + ({"oblique_deg": -90}, "oblique_deg"), + ], +) +def test_validate_textstyle_refuses_by_field(kwargs, fragment): + base = {"name": "T", "font": "arial.ttf", "height": 0.0, "width_factor": 1.0, "oblique_deg": 0.0} + base.update(kwargs) + with pytest.raises(ValueError, match=fragment): + validate_textstyle(**base) + + +def test_validate_textstyle_refuses_wrong_types(): + with pytest.raises(TypeError, match="width_factor"): + validate_textstyle("T", "arial.ttf", 0.0, "1", 0.0) + with pytest.raises(TypeError, match="height"): + validate_textstyle("T", "arial.ttf", True, 1.0, 0.0) + + +# ── multileader styles ────────────────────────────────────────────────────── + + +def test_mleader_presets_match_the_plan(): + assert MLEADER_PRESETS == { + "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, + "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0}, + } + assert MLEADER_KEYS == ("arrow_size", "landing_gap", "text_style", "text_height") + + +def test_resolve_mleaderstyle_applies_overrides(): + values = resolve_mleaderstyle("ISO", {"arrow_size": 3, "text_style": " ARIAL "}) + assert values == { + "arrow_size": 3.0, + "landing_gap": 1.0, + "text_style": "ARIAL", + "text_height": 2.5, + } + assert resolve_mleaderstyle("ansi") == MLEADER_PRESETS["ansi"] + assert resolve_mleaderstyle("iso", {"landing_gap": 0})["landing_gap"] == 0.0 + + +@pytest.mark.parametrize( + ("preset", "overrides", "exc", "fragment"), + [ + ("din", None, ValueError, "preset"), + (None, None, ValueError, "preset"), + ("iso", {"colour": 1}, ValueError, "colour"), + ("iso", {"arrow_size": 0}, ValueError, "arrow_size"), + ("iso", {"text_height": -2}, ValueError, "text_height"), + ("iso", {"text_style": ""}, ValueError, "text_style"), + ("iso", {"text_style": "a|b"}, ValueError, "text_style"), + ("iso", {"arrow_size": "2.5"}, TypeError, "arrow_size"), + ("iso", ["arrow_size"], TypeError, "overrides"), + ], +) +def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment): + with pytest.raises(exc, match=fragment): + resolve_mleaderstyle(preset, overrides) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_standards_presets.py -q` +Expected: collection error `ModuleNotFoundError: No module named 'engineering.standards'`. + +- [ ] **Step 3: Write `engineering/standards/dimstyles.py`** + +```python +"""ISO-25 and ANSI dimension style presets as data. + +Every value in ``PRESETS`` is pinned by name in +``tests/test_standards_presets.py`` against the table in §4.1 of +``docs/superpowers/specs/2026-09-16-v1.6-settings-design.md``: + +* ``iso-25`` — ISO 129-1:2018 dimensioning at the 2.5 mm lettering row of + ISO 3098-1: 2.5 mm text and arrowheads, text above and aligned with the + dimension line, decimal comma (ISO 80000-1 allows either marker; AutoCAD's + own ISO-25 uses the comma). +* ``ansi`` — ASME Y14.2-2014 line conventions and lettering (3 mm / .12 in + lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads, + text centred in a gap and horizontal, decimal point. + +``DIMDSEP`` is a one-character *string* here. DXF stores the character code and +ezdxf raises on a string in the header, so the translation belongs to the +backends, not to the data. + +Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the +tools and the provenance tests all read one copy. +""" + +from __future__ import annotations + +import math +from typing import Any + +__all__ = [ + "ANSI", + "DIM_VARIABLE_RANGES", + "DIM_VARIABLE_WHITELIST", + "ISO_25", + "LINEWEIGHT_CODES", + "PRESETS", + "PRESET_SOURCES", + "PRESET_VARIABLES", + "check_dim_value", + "describe_preset", + "resolve_dimstyle", + "validate_overrides", +] + +#: The seventeen variables every preset sets, in the order the spec table +#: lists them. ``dimstyle_list`` reports exactly these. +PRESET_VARIABLES: tuple[str, ...] = ( + "DIMTXT", + "DIMASZ", + "DIMEXE", + "DIMEXO", + "DIMGAP", + "DIMTAD", + "DIMTIH", + "DIMTOH", + "DIMDEC", + "DIMDSEP", + "DIMLUNIT", + "DIMZIN", + "DIMBLK", + "DIMTXSTY", + "DIMLWD", + "DIMLWE", + "DIMSCALE", +) + +ISO_25: dict[str, Any] = { + "DIMTXT": 2.5, # text height, mm — ISO 3098-1 lettering row 2.5 + "DIMASZ": 2.5, # arrowhead length, matched to the text + "DIMEXE": 1.25, # extension line beyond the dimension line + "DIMEXO": 0.625, # extension line offset from the feature + "DIMGAP": 0.625, # gap between dimension line and text + "DIMTAD": 1, # text above the dimension line + "DIMTIH": 0, # inside text aligned with the dimension line + "DIMTOH": 0, # outside text aligned with the dimension line + "DIMDEC": 2, # linear decimals + "DIMDSEP": ",", # decimal comma + "DIMLUNIT": 2, # decimal linear units + "DIMZIN": 8, # trailing zeros suppressed + "DIMBLK": "", # closed filled arrowhead + "DIMTXSTY": "ISOCP", # ISO 3098 type B lettering (created if missing) + "DIMLWD": -2, # dimension line lineweight ByBlock + "DIMLWE": -2, # extension line lineweight ByBlock + "DIMSCALE": 1.0, +} + +ANSI: dict[str, Any] = { + "DIMTXT": 3.0, # 3 mm / .12 in lettering, ASME Y14.2-2014 + "DIMASZ": 3.0, # .125 in arrowheads, metric + "DIMEXE": 1.5, + "DIMEXO": 1.5, + "DIMGAP": 1.0, + "DIMTAD": 0, # text centred in a gap in the dimension line + "DIMTIH": 1, # inside text horizontal + "DIMTOH": 1, # outside text horizontal + "DIMDEC": 2, + "DIMDSEP": ".", # decimal point + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMBLK": "", + "DIMTXSTY": "ROMANS", # AutoCAD's simplex roman, the ASME Gothic look + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, +} + +PRESETS: dict[str, dict[str, Any]] = {"iso-25": ISO_25, "ansi": ANSI} + +PRESET_SOURCES: dict[str, str] = { + "iso-25": ( + "ISO 129-1:2018 (dimensioning) with the 2.5 mm lettering row of " + "ISO 3098-1 (text and arrow sizes); decimal comma per ISO 80000-1" + ), + "ansi": ( + "ASME Y14.2-2014 line conventions and lettering: 3 mm / .12 in lettering, " + ".125 in arrowheads, metric values" + ), +} + +#: The lineweights AutoCAD accepts, in hundredths of a millimetre, plus the +#: ByLayer / ByBlock / Default sentinels. Every ISO 128 weight is in here. +_LINEWEIGHT_TEXT = ( + "-3 -2 -1 0 5 9 13 15 18 20 25 30 35 40 50 53 60 70 80 90 100 106 120 140 158 200 211" +) +LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split()) + +#: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each +#: value must sit in. The shape of each entry: +#: +#: * ``("float", low, high)`` — a number (bool refused) within the inclusive +#: bounds; a ``low`` of ``1e-9`` is the repo's spelling of "strictly positive" +#: (``backends/contracts/settings.py::_SETTING_RANGES``). +#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted). +#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused. +#: * ``("char",)`` — exactly one character. +#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow). +#: * ``("name",)`` — a non-empty DXF symbol-table name. +#: * a ``set`` — one of its members. +DIM_VARIABLE_RANGES: dict[str, tuple | set] = { + # the seventeen the presets set + "DIMTXT": ("float", 1e-9, 1e6), + "DIMASZ": ("float", 1e-9, 1e6), + "DIMEXE": ("float", 0.0, 1e6), + "DIMEXO": ("float", 0.0, 1e6), + "DIMGAP": ("float", -1e6, 1e6), # negative draws the basic-dimension box + "DIMTAD": ("int", 0, 4), + "DIMTIH": {0, 1}, + "DIMTOH": {0, 1}, + "DIMDEC": ("int", 0, 8), + "DIMDSEP": ("char",), + "DIMLUNIT": ("int", 1, 6), + "DIMZIN": ("int", 0, 15), # a bitmask + "DIMBLK": ("str",), + "DIMTXSTY": ("name",), + "DIMLWD": LINEWEIGHT_CODES, + "DIMLWE": LINEWEIGHT_CODES, + "DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport" + # tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist + "DIMTOL": {0, 1}, + "DIMTP": ("float", 0.0, 1e6), + "DIMTM": ("float", 0.0, 1e6), + "DIMTOLJ": ("int", 0, 2), + "DIMTFAC": ("float", 1e-9, 1e6), + "DIMLFAC": ("nonzero_float", -1e6, 1e6), + "DIMRND": ("float", 0.0, 1e6), # 0 = no rounding; the backends discard it, never store it + "DIMATFIT": ("int", 0, 3), + "DIMTMOVE": ("int", 0, 2), + "DIMCLRD": ("int", 0, 256), + "DIMCLRE": ("int", 0, 256), + "DIMCLRT": ("int", 0, 256), + "DIMSAH": {0, 1}, + "DIMBLK1": ("str",), + "DIMBLK2": ("str",), + "DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none +} + +DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES) + +#: DXF forbids these in a symbol-table name; the same rule ``security.py`` +#: applies to block and layer names, restated here so this module stays free +#: of fastmcp. +_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + + +def _number(key: str, value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {value!r} is not a finite number") + return number + + +def check_dim_value(name: str, value: Any) -> Any: + """``value`` coerced to what ``name`` stores, or ``TypeError``/``ValueError`` naming it. + + Nothing here writes; the backends call this before touching a table. + """ + key = str(name).strip().upper() + spec = DIM_VARIABLE_RANGES.get(key) + if spec is None: + raise ValueError( + f"{name}: not a dimension variable this tool writes; allowed: " + f"{sorted(DIM_VARIABLE_WHITELIST)}" + ) + if isinstance(spec, (set, frozenset)): + number = _number(key, value) + if number != int(number) or int(number) not in spec: + raise ValueError(f"{key}: {value!r} is not one of {sorted(spec)}") + return int(number) + kind = spec[0] + if kind in ("float", "nonzero_float"): + number = _number(key, value) + low, high = spec[1], spec[2] + if not low <= number <= high: + low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}" + raise ValueError( + f"{key}: {number:g} is out of range - must be {low_text} and at most {high:g}." + ) + if kind == "nonzero_float" and number == 0.0: + raise ValueError(f"{key}: 0 is refused (AutoCAD treats it as no factor at all)") + return number + if kind == "int": + number = _number(key, value) + if number != int(number): + raise ValueError(f"{key}: {value!r} must be a whole number") + low, high = spec[1], spec[2] + if not low <= number <= high: + raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}") + return int(number) + if not isinstance(value, str): + raise TypeError(f"{key}: expected a string, got {type(value).__name__}") + if kind == "char": + if len(value) != 1: + raise ValueError(f"{key}: {value!r} must be exactly one character") + return value + if kind == "name": + text = value.strip() + if not text: + raise ValueError(f"{key}: a style name cannot be empty") + bad = sorted(set(text) & _ILLEGAL_NAME_CHARS) + if bad: + raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}") + return text + return value # "str" + + +def validate_overrides(overrides: dict | None) -> dict[str, Any]: + """``overrides`` as a typed, upper-cased dict — every key checked, nothing written.""" + if overrides is None: + return {} + if not isinstance(overrides, dict): + raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}") + typed: dict[str, Any] = {} + for raw_key, value in overrides.items(): + key = str(raw_key).strip().upper() + typed[key] = check_dim_value(key, value) + return typed + + +def resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict[str, Any]: + """The preset's seventeen values with ``overrides`` applied on top. + + ``preset`` ``None`` means the ISO-25 base. An unknown preset, an override + key outside :data:`DIM_VARIABLE_WHITELIST` or a value outside its range + raises ``ValueError`` (``TypeError`` for the wrong type) naming the key — + before anything is written, because nothing here writes. + """ + key = "iso-25" if preset is None else str(preset).strip().lower() + if key not in PRESETS: + raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(PRESETS)}") + values = dict(PRESETS[key]) + values.update(validate_overrides(overrides)) + return values + + +def describe_preset(name: str) -> dict: + """``{name, source, values, textstyle}`` for a preset, or ``ValueError``.""" + key = str(name).strip().lower() + if key not in PRESETS: + raise ValueError(f"preset: unknown value {name!r}; use one of {sorted(PRESETS)}") + return { + "name": key, + "source": PRESET_SOURCES[key], + "values": dict(PRESETS[key]), + "textstyle": PRESETS[key]["DIMTXSTY"], + } +``` + +- [ ] **Step 4: Write `engineering/standards/textstyles.py`** + +```python +"""Text style presets: the four fonts the standards data refers to by name. + +``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique +degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape +file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the +general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face +ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font +*name*, so an unknown file is never refused here: ``resolve_font`` says +whether it is a bundled preset, the backend adds whether its font path has +it, and the caller sees ``font_resolved``. +""" + +from __future__ import annotations + +from pathlib import PurePath + +__all__ = [ + "MAX_OBLIQUE_DEG", + "TEXT_PRESETS", + "resolve_font", + "validate_textstyle", +] + +TEXT_PRESETS: dict[str, tuple[str, float, float]] = { + "ISOCP": ("isocp.shx", 1.0, 0.0), + "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), + "ARIAL": ("arial.ttf", 1.0, 0.0), + "ROMANS": ("romans.shx", 1.0, 0.0), +} + +#: AutoCAD's own limit for the STYLE command's obliquing angle. +MAX_OBLIQUE_DEG = 85.0 + +_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + + +def resolve_font(font: str) -> tuple[str, bool]: + """``(font_file, known)``: a preset name or file resolves to the bundled + file with ``known=True``; anything else is passed through with ``False``. + + Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem + (``isocp``), case-insensitively. Refuses an empty string or control + characters — the only inputs a DXF cannot hold. + """ + if not isinstance(font, str): + raise TypeError(f"font: expected a string, got {type(font).__name__}") + text = font.strip() + if not text: + raise ValueError("font: a font file name cannot be empty") + if any(ord(ch) < 32 for ch in text): + raise ValueError("font: control characters are refused") + upper = text.upper() + if upper in TEXT_PRESETS: + return TEXT_PRESETS[upper][0], True + for font_file, _width, _oblique in TEXT_PRESETS.values(): + if upper in (font_file.upper(), PurePath(font_file).stem.upper()): + return font_file, True + return text, False + + +def validate_textstyle( + name: str, + font: str, + height: float = 0.0, + width_factor: float = 1.0, + oblique_deg: float = 0.0, +) -> dict: + """The typed request both engines write, or a ``ValueError``/``TypeError`` + naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal; + the width factor must be positive; the oblique angle is AutoCAD's ±85°.""" + if not isinstance(name, str) or not name.strip(): + raise ValueError("name: a text style name cannot be empty") + clean = name.strip() + bad = sorted(set(clean) & _ILLEGAL_NAME_CHARS) + if bad: + raise ValueError(f"name: {clean!r} contains characters DXF forbids in a name: {bad}") + font_file, known = resolve_font(font) + for key, value in ( + ("height", height), + ("width_factor", width_factor), + ("oblique_deg", oblique_deg), + ): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + if float(height) < 0.0: + raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'") + if float(width_factor) <= 0.0: + raise ValueError(f"width_factor: {width_factor!r} must be greater than 0") + if abs(float(oblique_deg)) > MAX_OBLIQUE_DEG: + raise ValueError( + f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees" + ) + return { + "name": clean, + "font_file": font_file, + "known": known, + "height": float(height), + "width_factor": float(width_factor), + "oblique_deg": float(oblique_deg), + } +``` + +- [ ] **Step 5: Write `engineering/standards/mleaderstyles.py`** + +```python +"""Multileader style presets: arrow, landing gap, text style and height. + +Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows +and lettering; ANSI: 3 mm), so a leader note and a dimension on the same +sheet read at one size. Only ezdxf can author an MLEADERSTYLE object; +ActiveX exposes no collection for it (see ``backends/contracts/styles.py``). +""" + +from __future__ import annotations + +import math +from typing import Any + +__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"] + +MLEADER_PRESETS: dict[str, dict[str, Any]] = { + "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, + "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0}, +} + +MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height") + +_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + + +def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {value!r} is not a finite number") + if number < 0.0 or (number == 0.0 and not allow_zero): + raise ValueError(f"{key}: {value!r} must be greater than 0") + return number + + +def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str, Any]: + """The preset's four values with ``overrides`` applied; refusals name the key.""" + key = str(preset).strip().lower() if preset is not None else "" + if key not in MLEADER_PRESETS: + raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(MLEADER_PRESETS)}") + values = dict(MLEADER_PRESETS[key]) + if overrides is None: + return values + if not isinstance(overrides, dict): + raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}") + for raw_key, value in overrides.items(): + name = str(raw_key).strip().lower() + if name not in MLEADER_KEYS: + raise ValueError( + f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}" + ) + if name == "text_style": + if not isinstance(value, str) or not value.strip(): + raise ValueError("text_style: a text style name cannot be empty") + bad = sorted(set(value.strip()) & _ILLEGAL_NAME_CHARS) + if bad: + raise ValueError( + f"text_style: {value!r} contains characters DXF forbids in a name: {bad}" + ) + values[name] = value.strip() + elif name == "landing_gap": + values[name] = _positive(name, value, allow_zero=True) + else: + values[name] = _positive(name, value) + return values +``` + +- [ ] **Step 6: Write `engineering/standards/__init__.py`** + +```python +"""Authored drafting standards data. + +Dimension, text and leader style presets live here (track E, group S); the +system-variable catalogue, paper sizes, plot styles and templates are sibling +modules added by their own groups and imported by path. Everything in this +package is data plus validation — no I/O, no ezdxf, no COM — so the backends, +the tools and the provenance tests read one copy. +""" + +from __future__ import annotations + +from engineering.standards.dimstyles import ( + DIM_VARIABLE_RANGES, + DIM_VARIABLE_WHITELIST, + PRESET_VARIABLES, + PRESETS, + check_dim_value, + describe_preset, + resolve_dimstyle, + validate_overrides, +) +from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle +from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle + +__all__ = [ + "DIM_VARIABLE_RANGES", + "DIM_VARIABLE_WHITELIST", + "MLEADER_PRESETS", + "PRESETS", + "PRESET_VARIABLES", + "TEXT_PRESETS", + "check_dim_value", + "describe_preset", + "resolve_dimstyle", + "resolve_font", + "resolve_mleaderstyle", + "validate_overrides", + "validate_textstyle", +] +``` + +- [ ] **Step 7: Run the tests** + +Run: `uv run --frozen pytest tests/test_standards_presets.py -q` +Expected: `97 passed` (measured against these exact files on 2026-09-16). + +- [ ] **Step 8: Lint and commit** + +Run: `uv run --frozen ruff check engineering/standards tests/test_standards_presets.py && uv run --frozen ruff format --check engineering/standards tests/test_standards_presets.py` +Expected: clean (run `ruff format` on the four modules first if the check reports the `LINEWEIGHT_CODES` set literal reflowed). + +```bash +git add engineering/standards tests/test_standards_presets.py +git commit -m "feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data" +``` + +(End every commit message in this part with the two attribution lines from the plan's Global Constraints.) + +--- + +### Task 10: `StylesContract` + both engines + capability key `mleaderstyle` + +**Files:** +- Create: `backends/contracts/styles.py` +- Modify: `backends/contracts/__init__.py` (import + `__all__`), `backends/base.py` (compose `StylesContract`; idempotent — skip if another group merged it first) +- Modify: `backends/ezdxf_backend.py` — a module-level helper block after `_with_header_dimvars`, a method block immediately before `# ── corner ops` (group S's reserved anchor — C's block goes at the very end of the class, V's after the layouts block, P's before `# ── 3D solids`; skeleton, Merge notes), the capability map (`mleaderstyle`), and the seven call-site literals described in the header of this part +- Modify: `backends/com_backend.py` — a module-level helper block after `_regen`, a method block immediately before `# ── corner ops` (same reserved anchor), the capability map (`mleaderstyle`) +- Test: `tests/test_styles.py` + +**Interfaces:** +- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9); `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM). +- Produces: the nine `StylesContract` methods on both backends, exactly the plan's signatures. Extra keys beyond the plan's row shapes, on both engines: `dimstyle_list` rows carry `values_available: bool`; `dimstyle_create` carries `written: [vars]`; `dimstyle_set_current` / `textstyle_set_current` carry `changed: bool`; `mleaderstyle_create` carries `textstyle_created`. Names are matched case-insensitively (AutoCAD's rule) and reported in the table's own spelling. +- The COM rule, stated once in the code and enforced by the fake: `AcadDimStyle` has `Name` and `CopyFrom` only. Values are written by setting the `DIM*` document variables and `CopyFrom(document)`; making a style current (`ActiveDimStyle`) restores its saved settings and drops unsaved overrides; reading a non-current style's values is impossible, so `dimstyle_list` reports `values` for the current style only. `mleaderstyle_create` stays the `@capability("mleaderstyle")` default on COM (never defined there); `mleaderstyle_list` reads names from the `ACAD_MLEADERSTYLE` dictionary with `values_available: false`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_styles.py +"""Dimension, text and multileader styles on both engines. + +Headless: real ezdxf tables, and the *rendered* dimension block as the witness +that a current style reaches the next dimension (the header alone was never +the thing that could lie — see tests/test_dimension_header_vars.py). Live: +a fake ActiveX surface that records the call shapes the COM block relies on +and models the one AutoCAD rule it depends on — making a style current +restores that style's saved DIM* settings. +""" + +from __future__ import annotations + +import types + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.capability import is_capability_default +from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name +from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle +from engineering.standards.mleaderstyles import resolve_mleaderstyle + +pytestmark = pytest.mark.asyncio + + +def _rendered(backend, handle): + """(text, text height, arrow xscales) out of the dimension's geometry block.""" + dim = backend._doc.entitydb.get(handle) + block = backend._doc.blocks.get(dim.dxf.geometry) + text = height = None + arrows = [] + for entity in block: + if entity.dxftype() == "MTEXT" and text is None: + text, height = entity.text, round(float(entity.dxf.char_height), 4) + elif entity.dxftype() == "TEXT" and text is None: + text, height = entity.dxf.text, round(float(entity.dxf.height), 4) + elif entity.dxftype() == "INSERT": + arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4)) + if text is None: + raise AssertionError("the dimension block carries no text") + return text, height, sorted(set(arrows)) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_dimstyle_list_reports_standard_current_with_the_seventeen_values(backend): + rows = await backend.dimstyle_list() + assert [r["name"] for r in rows] == ["Standard"] + assert rows[0]["current"] is True and rows[0]["values_available"] is True + assert tuple(rows[0]["values"]) == PRESET_VARIABLES + assert rows[0]["values"]["DIMTXT"] == 2.5 + assert rows[0]["values"]["DIMDSEP"] == ".", "drawing_new's ISO point" + assert rows[0]["values"]["DIMTXSTY"] == "Standard", "unstored code reports the schema default" + + +async def test_dimstyle_create_iso25_writes_every_value_and_creates_isocp(backend): + result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + assert result["ok"] is True and result["name"] == "ISO-25" + assert result["textstyle_created"] is True and result["current"] is False + assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP" + assert result["written"] == sorted(PRESET_VARIABLES) + style = backend._doc.dimstyles.get("ISO-25") + assert style.dxf.dimtxt == 2.5 and style.dxf.dimdsep == 44 and style.dxf.dimtad == 1 + assert style.dxf.dimtxsty == "ISOCP" and style.dxf.dimlwd == -2 and style.dxf.dimblk == "" + assert backend._doc.styles.get("ISOCP").dxf.font == "isocp.shx" + # ezdxf.new() writes $DIMSTYLE "ISO-25" with no such entry; creating the + # entry must not make it current by accident. + assert backend._doc.header["$DIMSTYLE"] == "Standard" + assert _current_dimstyle_name(backend._doc) == "Standard" + + +async def test_set_current_syncs_the_header_and_the_next_dimension_renders_with_it(backend): + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + header = backend._doc.header + assert header["$DIMSTYLE"] == "ISO-25" + assert header["$DIMDSEP"] == 44 and header["$DIMTXT"] == 2.5 and header["$DIMTXSTY"] == "ISOCP" + + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert backend._doc.entitydb.get(dim.handle).dxf.dimstyle == "ISO-25" + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + + await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True) + dim2 = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60) + assert backend._doc.entitydb.get(dim2.handle).dxf.dimstyle == "ANSI" + assert _rendered(backend, dim2.handle) == ("33.33", 3.0, [3.0]) + for creator, args in ( + (backend.dimension_aligned, (0, 0, 10, 10, 5, 15)), + (backend.dimension_radius, (50, 50, 60, 50)), + (backend.dimension_diameter, (70, 0, 90, 0)), + (backend.dimension_angular, (0, 0, 10, 0, 0, 10, 5, 5)), + ): + created = await creator(*args) + assert backend._doc.entitydb.get(created.handle).dxf.dimstyle == "ANSI", creator.__name__ + + +async def test_dimstyle_modify_reports_only_what_moved_and_the_dimensions_using_it(backend): + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + dim = await backend.dimension_linear(0, 0, 10, 0, 5, -10) + result = await backend.dimstyle_modify("iso-25", {"DIMTXT": 2.5, "DIMDEC": 3, "dimrnd": 0}) + assert result["name"] == "ISO-25" + assert result["changed"] == {"DIMDEC": [2, 3]} + assert result["dimensions_using_style"] == [dim.handle] + assert result["rerender_required"] is True + assert backend._doc.header["$DIMDEC"] == 3, "the current style's header twin follows" + assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimrnd"), ( + "DIMRND 0 is 'no rounding' and must be discarded, not stored (xround(x, 0.0) rounds)" + ) + again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3}) + assert again["changed"] == {} and again["rerender_required"] is False + on_standard = await backend.dimstyle_modify("Standard", {"DIMDEC": 4}) + assert on_standard["dimensions_using_style"] == [] and on_standard["rerender_required"] is False + assert backend._doc.header["$DIMDEC"] == 3, "a non-current style does not touch the header" + + +async def test_refusals_happen_before_anything_is_written(backend): + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + with pytest.raises(ValueError, match="already exists"): + await backend.dimstyle_create("iso-25", resolve_dimstyle("iso-25", None)) + with pytest.raises(ValueError, match="DIMTXSTY"): + await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"})) + assert not backend._doc.dimstyles.has_entry("X") + with pytest.raises(ValueError, match="DIMFOO"): + await backend.dimstyle_create("Y", {"DIMFOO": 1}) + assert not backend._doc.dimstyles.has_entry("Y") + with pytest.raises(ValueError, match="DIMDEC"): + await backend.dimstyle_modify("ISO-25", {"DIMDEC": 9}) + with pytest.raises(ValueError, match="does not exist"): + await backend.dimstyle_modify("NOPE", {"DIMDEC": 1}) + with pytest.raises(ValueError, match="empty"): + await backend.dimstyle_modify("ISO-25", {}) + with pytest.raises(ValueError, match="does not exist"): + await backend.dimstyle_set_current("NOPE") + assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2 + + +async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path): + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + result = await backend.dimstyle_set_current("iso-25") + assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True} + same = await backend.dimstyle_set_current("ISO-25") + assert same["changed"] is False and same["previous"] == "ISO-25" + path = str(tmp_path / "styles.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + rows = await backend.dimstyle_list() + assert [r["name"] for r in rows if r["current"]] == ["ISO-25"] + assert rows[0]["name"] == "ISO-25" and rows[0]["values"]["DIMDSEP"] == "," + + +async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend): + result = await backend.textstyle_create("ISOCP", "isocp", set_current=True) + assert result == { + "ok": True, + "name": "ISOCP", + "font": "isocp.shx", + "font_resolved": True, + "current": True, + } + rows = await backend.textstyle_list() + assert [r["name"] for r in rows] == ["ISOCP", "Standard"] + assert rows[0] == { + "name": "ISOCP", + "font": "isocp.shx", + "height": 0.0, + "width_factor": 1.0, + "oblique_deg": 0.0, + "current": True, + } + assert backend._doc.header["$TEXTSTYLE"] == "ISOCP" + assert _current_textstyle_name(backend._doc) == "ISOCP" + text = await backend.entity_create_text("note", 0, 0) + mtext = await backend.entity_create_mtext("note", 0, 10) + assert backend._doc.entitydb.get(text.handle).dxf.style == "ISOCP" + assert backend._doc.entitydb.get(mtext.handle).dxf.style == "ISOCP" + back = await backend.textstyle_set_current("standard") + assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True} + assert backend._doc.entitydb.get((await backend.entity_create_text("x", 0, 0)).handle).dxf.style == "Standard" + + +async def test_textstyle_unknown_font_is_written_but_reported_unresolved(backend): + result = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15) + assert result["font_resolved"] is False and result["current"] is False + style = backend._doc.styles.get("ODD") + assert style.dxf.font == "acadmcp-missing-font.ttf" + assert style.dxf.height == 3.5 and style.dxf.width == 0.8 and style.dxf.oblique == 15.0 + + +async def test_textstyle_refusals_write_nothing(backend): + await backend.textstyle_create("ISOCP", "isocp.shx") + with pytest.raises(ValueError, match="already exists"): + await backend.textstyle_create("isocp", "isocp.shx") + with pytest.raises(ValueError, match="width_factor"): + await backend.textstyle_create("Z", "arial.ttf", width_factor=0) + with pytest.raises(ValueError, match="oblique_deg"): + await backend.textstyle_create("Z", "arial.ttf", oblique_deg=90) + with pytest.raises(ValueError, match="height"): + await backend.textstyle_create("Z", "arial.ttf", height=-1) + with pytest.raises(ValueError, match="font"): + await backend.textstyle_create("Z", "") + assert not backend._doc.styles.has_entry("Z") + with pytest.raises(ValueError, match="does not exist"): + await backend.textstyle_set_current("NOPE") + + +async def test_mleaderstyle_create_iso_and_list(backend): + result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) + assert result["ok"] is True and result["textstyle_created"] is True + assert result["values"] == { + "arrow_size": 2.5, + "landing_gap": 1.0, + "text_style": "ISOCP", + "text_height": 2.5, + } + style = backend._doc.mleader_styles.get("ISO") + assert style.dxf.arrow_head_size == 2.5 and style.dxf.landing_gap_size == 1.0 + assert backend._doc.entitydb[style.dxf.text_style_handle].dxf.name == "ISOCP" + rows = await backend.mleaderstyle_list() + assert rows == [ + { + "name": "ISO", + "arrow_size": 2.5, + "landing_gap": 1.0, + "text_style": "ISOCP", + "text_height": 2.5, + "values_available": True, + }, + { + "name": "Standard", + "arrow_size": 4.0, + "landing_gap": 2.0, + "text_style": "Standard", + "text_height": 4.0, + "values_available": True, + }, + ] + + +async def test_mleaderstyle_refusals_write_nothing(backend): + await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) + with pytest.raises(ValueError, match="already exists"): + await backend.mleaderstyle_create("iso", resolve_mleaderstyle("iso", None)) + with pytest.raises(ValueError, match="text_style"): + await backend.mleaderstyle_create("X", resolve_mleaderstyle("iso", {"text_style": "NOPE"})) + with pytest.raises(ValueError, match="missing"): + await backend.mleaderstyle_create("X", {"arrow_size": 2.5}) + assert not backend._doc.mleader_styles.has_entry("X") + + +async def test_both_capability_maps_declare_mleaderstyle(backend): + from backends.com_backend import ComBackend + + assert backend.capabilities().to_dict()["features"]["mleaderstyle"]["supported"] is True + com = ComBackend().capabilities().to_dict()["features"]["mleaderstyle"] + assert com["supported"] is False and "leader_create_mleader" in com["reason"] + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _FakeStyle: + def __init__(self, name, calls): + self.Name = name + self._calls = calls + self.fontFile = "txt.shx" + self.Width = 1.0 + self.ObliqueAngle = 0.0 + self.Height = 0.0 + self.saved: dict | None = None # the DIM* values this style holds, once saved + + def CopyFrom(self, source): + # AcadDimStyle.CopyFrom(document) saves the document's current + # dimension settings into this style. + self._calls.append(("CopyFrom", self.Name, source)) + self.saved = dict(source.variables) + + +class _FakeCollection: + def __init__(self, kind, calls, *names): + self._kind = kind + self._calls = calls + self.entries = [_FakeStyle(name, calls) for name in names] + + @property + def Count(self): + return len(self.entries) + + def Item(self, index): + return self.entries[index] + + def Add(self, name): + self._calls.append((f"{self._kind}.Add", name)) + style = _FakeStyle(name, self._calls) + self.entries.append(style) + return style + + +class _FakeBlock: + def __init__(self, *entities): + self.entities = list(entities) + + @property + def Count(self): + return len(self.entities) + + def Item(self, index): + return self.entities[index] + + +class _FakeLayouts: + def __init__(self, *blocks): + self.layouts = [types.SimpleNamespace(Block=block) for block in blocks] + + @property + def Count(self): + return len(self.layouts) + + def Item(self, index): + return self.layouts[index] + + +class _FakeDictionary: + def __init__(self, *names): + self.objects = [types.SimpleNamespace(mleader_name=name) for name in names] + + @property + def Count(self): + return len(self.objects) + + def Item(self, index): + return self.objects[index] + + def GetName(self, obj): + return obj.mleader_name + + +class _FakeDictionaries: + def __init__(self, entries): + self.entries = entries + + def Item(self, name): + if name not in self.entries: + raise RuntimeError(f"no dictionary {name}") + return self.entries[name] + + +CURRENT_DIMVARS = { + "DIMTXT": 2.5, "DIMASZ": 2.5, "DIMEXE": 1.25, "DIMEXO": 0.625, "DIMGAP": 0.625, + "DIMTAD": 1, "DIMTIH": 0, "DIMTOH": 0, "DIMDEC": 2, "DIMDSEP": ".", "DIMLUNIT": 2, + "DIMZIN": 8, "DIMBLK": "", "DIMTXSTY": "Standard", "DIMLWD": -2, "DIMLWE": -2, + "DIMSCALE": 1.0, +} + + +class _FakeDocument: + def __init__(self): + self.calls: list[tuple] = [] + self.DimStyles = _FakeCollection("DimStyles", self.calls, "Standard") + self.TextStyles = _FakeCollection("TextStyles", self.calls, "Standard") + self.variables = dict(CURRENT_DIMVARS) + self._active_dim = self.DimStyles.entries[0] + self._active_dim.saved = dict(CURRENT_DIMVARS) + self._active_text = self.TextStyles.entries[0] + dim = types.SimpleNamespace( + ObjectName="AcDbRotatedDimension", StyleName="ISO-25", Handle="2A" + ) + line = types.SimpleNamespace(ObjectName="AcDbLine", Handle="2B") + sheet_dim = types.SimpleNamespace( + ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C" + ) + self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) + self.Dictionaries = _FakeDictionaries( + {"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")} + ) + + def GetVariable(self, name): + return self.variables[name] + + def SetVariable(self, name, value): + self.calls.append(("SetVariable", name, value)) + self.variables[name] = value + + @property + def ActiveDimStyle(self): + return self._active_dim + + @ActiveDimStyle.setter + def ActiveDimStyle(self, style): + # Making a style current restores its saved settings as the current + # DIM* variables and discards unsaved overrides (AutoCAD's -DIMSTYLE + # Restore rule; scripts/smoke_settings_com.py confirms it live). A + # style never saved (fresh DimStyles.Add) keeps the current variables. + self.calls.append(("ActiveDimStyle", style.Name)) + self._active_dim = style + if style.saved is not None: + self.variables.update(style.saved) + + @property + def ActiveTextStyle(self): + return self._active_text + + @ActiveTextStyle.setter + def ActiveTextStyle(self, style): + self.calls.append(("ActiveTextStyle", style.Name)) + self._active_text = style + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = _FakeDocument() + app = types.SimpleNamespace( + Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath="")) + ) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_acad_app", lambda: app) + monkeypatch.setattr(module, "_regen", lambda: None) + 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_create_sets_variables_then_copies_from_the_document(com_backend): + backend, document = com_backend + result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + assert result["ok"] is True and result["current"] is False + assert result["textstyle_created"] is True + names = [call[0] for call in document.calls] + assert names[:2] == ["TextStyles.Add", "DimStyles.Add"], "the text style exists before DIMTXSTY names it" + assert document.calls[2] == ("ActiveDimStyle", "ISO-25") + sets = [call for call in document.calls if call[0] == "SetVariable"] + assert len(sets) == 17 + assert ("SetVariable", "DIMDSEP", ",") in sets, "DIMDSEP is a string on ActiveX" + assert ("SetVariable", "DIMTXSTY", "ISOCP") in sets + assert ("SetVariable", "DIMTXT", 2.5) in sets and ("SetVariable", "DIMTAD", 1) in sets + copy_index = names.index("CopyFrom") + assert copy_index > names.index("SetVariable") + assert document.calls[copy_index] == ("CopyFrom", "ISO-25", document) + assert document.calls[-1] == ("ActiveDimStyle", "Standard"), "restored: set_current was False" + assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP" + isocp = document.TextStyles.entries[-1] + assert isocp.Name == "ISOCP" and isocp.fontFile == "isocp.shx" + + +async def test_com_create_set_current_keeps_the_new_style_active(com_backend): + backend, document = com_backend + result = await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True) + assert result["current"] is True + activations = [c for c in document.calls if c[0] == "ActiveDimStyle"] + assert activations == [("ActiveDimStyle", "ANSI")] + assert document.ActiveDimStyle.Name == "ANSI" + + +async def test_com_create_refuses_before_touching_activex(com_backend): + backend, document = com_backend + with pytest.raises(ValueError, match="already exists"): + await backend.dimstyle_create("standard", resolve_dimstyle("iso-25", None)) + with pytest.raises(ValueError, match="DIMTXSTY"): + await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"})) + with pytest.raises(ValueError, match="DIMDEC"): + await backend.dimstyle_create("X", {"DIMDEC": 9}) + assert document.calls == [] + + +async def test_com_list_reports_values_for_the_current_style_only(com_backend): + backend, document = com_backend + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + rows = await backend.dimstyle_list() + assert [(r["name"], r["current"], r["values_available"]) for r in rows] == [ + ("ISO-25", False, False), + ("Standard", True, True), + ] + assert rows[0]["values"] is None + assert rows[1]["values"]["DIMDSEP"] == "." and tuple(rows[1]["values"]) == PRESET_VARIABLES + + +async def test_com_modify_switches_saves_and_switches_back(com_backend): + backend, document = com_backend + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + document.calls.clear() + result = await backend.dimstyle_modify("iso-25", {"DIMDEC": 3, "DIMTXT": 2.5}) + assert result["name"] == "ISO-25" + assert result["changed"] == {"DIMDEC": [2, 3]} + assert result["dimensions_using_style"] == ["2A"], "model and paper space, dimensions only" + assert result["rerender_required"] is False, "AutoCAD re-renders on regen" + assert document.calls == [ + ("ActiveDimStyle", "ISO-25"), + ("SetVariable", "DIMDEC", 3), + ("CopyFrom", "ISO-25", document), + ("ActiveDimStyle", "Standard"), + ] + document.calls.clear() + again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3}) + assert again["changed"] == {} + assert [c[0] for c in document.calls] == ["ActiveDimStyle", "ActiveDimStyle"], ( + "no CopyFrom when nothing moved" + ) + with pytest.raises(ValueError, match="does not exist"): + await backend.dimstyle_modify("NOPE", {"DIMDEC": 1}) + + +async def test_com_set_current(com_backend): + backend, document = com_backend + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + result = await backend.dimstyle_set_current("ISO-25") + assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True} + assert document.ActiveDimStyle.Name == "ISO-25" + with pytest.raises(ValueError, match="does not exist"): + await backend.dimstyle_set_current("NOPE") + + +async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend): + backend, document = com_backend + result = await backend.textstyle_create("ISOCP", "isocp", 0.0, 1.0, 0.0, set_current=True) + assert result == { + "ok": True, + "name": "ISOCP", + "font": "isocp.shx", + "font_resolved": True, + "current": True, + } + style = document.TextStyles.entries[-1] + assert (style.fontFile, style.Width, style.ObliqueAngle, style.Height) == ( + "isocp.shx", 1.0, 0.0, 0.0, + ) + assert document.ActiveTextStyle.Name == "ISOCP" + odd = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15.0) + assert odd["font_resolved"] is False + assert document.TextStyles.entries[-1].ObliqueAngle == pytest.approx(0.2617993878), "radians" + rows = await backend.textstyle_list() + assert [(r["name"], r["current"]) for r in rows] == [ + ("ISOCP", True), ("ODD", False), ("Standard", False), + ] + assert rows[1]["oblique_deg"] == pytest.approx(15.0) + with pytest.raises(ValueError, match="already exists"): + await backend.textstyle_create("isocp", "isocp.shx") + back = await backend.textstyle_set_current("standard") + assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True} + + +async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend): + backend, document = com_backend + rows = await backend.mleaderstyle_list() + assert rows == [ + {"name": "Annotative", "arrow_size": None, "landing_gap": None, "text_style": None, + "text_height": None, "values_available": False}, + {"name": "Standard", "arrow_size": None, "landing_gap": None, "text_style": None, + "text_height": None, "values_available": False}, + ] + + +async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend): + from backends.com_backend import ComBackend + + backend, document = com_backend + assert is_capability_default(ComBackend, "mleaderstyle_create") + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) + assert excinfo.value.capability == "mleaderstyle" + assert "leader_create_mleader" in str(excinfo.value) + assert document.calls == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_styles.py -q` +Expected: collection error `ImportError: cannot import name '_current_dimstyle_name' from 'backends.ezdxf_backend'`. + +- [ ] **Step 3: Write the contract** + +```python +# backends/contracts/styles.py +"""Dimension, text and multileader styles (track E, group S). + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. + +Names are matched case-insensitively — AutoCAD's rule for every symbol +table — and reported in the table's own spelling. Every refusal is raised +before a table is touched: ``ValueError`` naming the key (a missing style, a +clash, a variable outside ``engineering.standards.dimstyles.DIM_VARIABLE_RANGES``), +``TypeError`` for a wrong type. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from backends.capability import capability + + +class StylesContract(ABC): + @abstractmethod + async def dimstyle_list(self) -> list[dict]: + """Every DIMSTYLE entry: ``[{name, current, values, values_available}]``. + + ``values`` holds the seventeen preset variables + (``engineering.standards.dimstyles.PRESET_VARIABLES``) with ``DIMDSEP`` + as the character. Headless every row carries them (the DXF's stored + value, else the schema default AutoCAD applies to an omitted code). + On the live engine ActiveX has no per-style getters — ``AcadDimStyle`` + exposes ``Name`` and ``CopyFrom`` only — so ``values`` is read for the + *current* style and the other rows carry ``values: None`` with + ``values_available: False`` rather than cycling the active style + behind the operator's back. + """ + ... + + @abstractmethod + async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict: + """Create a dimension style from a fully resolved ``values`` dict + (``engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)``). + + Returns ``{ok, name, values, written, current, textstyle_created}``. + ``DIMTXSTY`` must name a text style that exists or a bundled preset + (ISOCP / ISOCPEUR / ARIAL / ROMANS), which is created first and + reported in ``textstyle_created``; anything else is refused by name. + A name clash is refused (``dimstyle_modify`` changes an existing + style). With ``set_current`` the style becomes ``$DIMSTYLE`` *and* its + values become the current ``DIM*`` settings — what a live seat does — + so the next dimension carries them on both engines. + """ + ... + + @abstractmethod + async def dimstyle_modify(self, name: str, values: dict) -> dict: + """Change variables on an existing style. + + Returns ``{ok, name, changed: {VAR: [old, new]}, dimensions_using_style, + rerender_required}``. ``changed`` names only the variables that moved + (the ``hatch_edit`` rule: re-setting a value to itself is not a + change). Dimensions already drawn with the style are listed by handle; + ``rerender_required`` is ``True`` headlessly when any exist and + something changed, because a headless DIMENSION carries its rendered + block and shows the old style until it is redrawn — AutoCAD re-renders + on the next regen, so the live engine reports ``False``. Modifying the + current style also updates the ``DIM*`` current settings. + """ + ... + + @abstractmethod + async def dimstyle_set_current(self, name: str) -> dict: + """Make ``name`` the current dimension style. + + Returns ``{ok, current, previous, changed}``. Sets ``$DIMSTYLE`` and + loads the style's stored variables into the current ``DIM*`` settings + (AutoCAD's ``-DIMSTYLE Restore``; on the live engine unsaved overrides + of the previous style are discarded, which is AutoCAD's own rule). + The dimension tools then create dimensions with it; the per-dimension + override path (tolerances, fits, text override) is unchanged. + """ + ... + + @abstractmethod + async def textstyle_list(self) -> list[dict]: + """``[{name, font, height, width_factor, oblique_deg, current}]``.""" + ... + + @abstractmethod + async def textstyle_create( + self, + name: str, + font: str, + height: float = 0.0, + width_factor: float = 1.0, + oblique_deg: float = 0.0, + set_current: bool = False, + ) -> dict: + """Create a text style. Returns ``{ok, name, font, font_resolved, current}``. + + ``font`` is a preset (``ISOCP`` / ``isocp.shx`` / ``isocp``, any case) + or any file name. A file that is neither a preset nor found on the + engine's font path is *written* and reported ``font_resolved: False`` + — a DXF stores only the name and AutoCAD substitutes at open — never + refused. Refuses a clash, ``width_factor <= 0``, ``height < 0``, an + oblique angle outside ±85°, an empty font. + """ + ... + + @abstractmethod + async def textstyle_set_current(self, name: str) -> dict: + """``$TEXTSTYLE``. Returns ``{ok, current, previous, changed}``; new + TEXT/MTEXT then carry the style on both engines.""" + ... + + @abstractmethod + async def mleaderstyle_list(self) -> list[dict]: + """``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``. + + Headless the values are read from the MLEADERSTYLE object (schema + defaults for an unset field). ActiveX exposes no MLeaderStyle object, + so the live engine reads the *names* from the ``ACAD_MLEADERSTYLE`` + dictionary and reports the four values ``None`` with + ``values_available: False``. + """ + ... + + # B027 (empty method, no @abstractmethod) is what this pattern looks like + # from the outside: the decorator replaces the `...` body with the refusing + # default, so the source really is empty on purpose. + @capability( # noqa: B027 + "mleaderstyle", + reason=( + "ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's " + "per-leader parameters (arrow size, landing, text height) or the headless " + "backend (AUTOCAD_MCP_BACKEND=ezdxf)" + ), + ) + async def mleaderstyle_create(self, name: str, values: dict) -> dict: + """Create a multileader style from a resolved ``values`` dict + (``engineering.standards.mleaderstyles.resolve_mleaderstyle``). + ezdxf only. Returns ``{ok, name, values, textstyle_created}``; the + text style follows the ``dimstyle_create`` rule (exists or preset, + else refused by name); a clash is refused.""" + ... +``` + +Register it in `backends/contracts/__init__.py`: add `from backends.contracts.styles import StylesContract` to the import block (alphabetical, after `solids`) and `"StylesContract"` to `__all__` (after `"SolidContract"`). In `backends/base.py`, add `StylesContract,` to the `from backends.contracts import (...)` list (after `SolidContract,`) and to the `AutoCADBackend` bases after `SettingsContract,`. If another group's merge already did both, leave them. + +- [ ] **Step 4: Run the contract gate to see both backends fail at instantiation** + +Run: `uv run --frozen pytest tests/test_capability_contract.py -q` +Expected: FAIL with `TypeError: Can't instantiate abstract class EzdxfBackend with abstract methods dimstyle_create, ...` (nine names on ezdxf, eight on COM — `mleaderstyle_create` is optional). + +- [ ] **Step 5: ezdxf — module helpers** + +Insert immediately after the end of `_with_header_dimvars` (before `def _round_measure`) in `backends/ezdxf_backend.py`: + +```python +# ── styles (track E): module helpers ───────────────────────────────────────── +# +# `$DIMSTYLE` names the style new dimensions are created with, exactly as on +# a live seat. Two measured ezdxf facts shape everything below: +# +# * `ezdxf.new()` writes `$DIMSTYLE "ISO-25"` into a fresh header with no such +# table entry (only `Standard` exists). `_current_dimstyle_name` therefore +# falls back to `Standard` when the header names nothing in the table, and +# `dimstyle_create` pins the header so creating a style *called* ISO-25 does +# not make it current by accident. +# * `_with_header_dimvars` folds every `$DIM*` that differs from the current +# style's stored value onto the next dimension. Making a style current +# without loading its values into the header leaves the previous style's +# header winning: measured, ISO-25 (comma) after `drawing_new` (point) +# rendered "33.33". `_sync_header_to_dimstyle` is what a live seat's +# `-DIMSTYLE Restore` does, and it makes the fold empty. + + +def _current_dimstyle_name(doc) -> str: + """The dimension style new dimensions are created with. + + `$DIMSTYLE` when the table holds it (in the table's spelling); otherwise + `Standard` — ezdxf's own substitution for a missing style, made explicit. + """ + try: + name = doc.header.get("$DIMSTYLE", _DIMSTYLE_NAME) + except Exception: + return _DIMSTYLE_NAME + if isinstance(name, str) and name and doc.dimstyles.has_entry(name): + return doc.dimstyles.get(name).dxf.name + return _DIMSTYLE_NAME + + +def _current_textstyle_name(doc) -> str: + """The text style new TEXT/MTEXT carry: `$TEXTSTYLE`, else `Standard`.""" + try: + name = doc.header.get("$TEXTSTYLE", "Standard") + except Exception: + return "Standard" + if isinstance(name, str) and name and doc.styles.has_entry(name): + return doc.styles.get(name).dxf.name + return "Standard" + + +def _dimstyle_schema_default(var: str): + """What AutoCAD reads for a DIMSTYLE entry that omits the code.""" + from ezdxf.entities.dimstyle import acdb_dimstyle + + attrib = acdb_dimstyle.attribs.get(var.lower()) + return None if attrib is None else attrib.default + + +def _dimstyle_value(style, var: str): + """A style's value for ``var`` in the tool vocabulary (DIMDSEP as the character).""" + attr = var.lower() + raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None + if raw is None: + raw = _dimstyle_schema_default(var) + if var == "DIMDSEP": + code = 44 if raw is None else int(raw) + return "," if code == 0 else chr(code) + if var == "DIMTXSTY": + return str(raw) if raw else "Standard" + return raw + + +def _read_dimstyle_values(style, variables=None) -> dict[str, Any]: + from engineering.standards.dimstyles import PRESET_VARIABLES + + return {var: _dimstyle_value(style, var) for var in (variables or PRESET_VARIABLES)} + + +def _write_dimstyle_values(style, values: dict[str, Any]) -> None: + """Store typed values (from ``validate_overrides``) on a DIMSTYLE entry. + + DIMRND 0 is AutoCAD's "no rounding"; ezdxf applies ``xround(x, 0.0)`` to a + *stored* 0.0 and rounds every dimension to a whole unit, so it is + discarded rather than stored (the `_normalise_dimstyle_rounding` lesson). + """ + for var, value in values.items(): + attr = var.lower() + if var == "DIMRND" and float(value) == 0.0: + style.dxf.discard("dimrnd") + continue + style.dxf.set(attr, ord(value) if var == "DIMDSEP" else value) + + +def _sync_header_to_dimstyle(doc, style) -> None: + """Load the style's stored variables into ``$DIM*`` — a live seat's Restore.""" + from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST + + for var in sorted(DIM_VARIABLE_WHITELIST): + attr = var.lower() + if not style.dxf.hasattr(attr): + continue + raw = style.dxf.get(attr) + if raw is None: + continue + doc.header[f"${var}"] = raw + + +def _font_available(font_file: str) -> bool: + """Whether ezdxf's font manager can find ``font_file`` on this machine.""" + try: + from ezdxf.fonts import fonts + + return bool(fonts.font_manager.has_font(font_file)) + except Exception: + return False + + +def _ensure_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]: + """``(table spelling, created)`` for a text style that must exist. + + A bundled preset (ISOCP / ISOCPEUR / ARIAL / ROMANS) is created on demand; + any other missing name is refused by ``refusal_key`` before a write. + """ + from engineering.standards.textstyles import TEXT_PRESETS + + if doc.styles.has_entry(name): + return doc.styles.get(name).dxf.name, False + preset = TEXT_PRESETS.get(name.upper()) + if preset is None: + raise ValueError( + f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a " + f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create" + ) + font_file, width, oblique = preset + doc.styles.new( + name.upper(), + dxfattribs={"font": font_file, "width": width, "oblique": oblique, "height": 0.0}, + ) + return name.upper(), True + + +def _dimensions_using(doc, style_name: str) -> list[str]: + """Handles of every DIMENSION, in any layout, drawn with ``style_name``.""" + wanted = style_name.lower() + handles: list[str] = [] + for layout in doc.layouts: + for entity in layout.query("DIMENSION"): + if str(entity.dxf.get("dimstyle", "")).lower() == wanted: + handles.append(entity.dxf.handle) + return handles +``` + +- [ ] **Step 6: ezdxf — the seven call-site edits** + +In `_style_dimvar` (module level, ~line 594) change + +```python + style = doc.dimstyles.get(_DIMSTYLE_NAME) +``` +to +```python + style = doc.dimstyles.get(_current_dimstyle_name(doc)) +``` + +In the five dimension creators (`dimension_linear`, `dimension_aligned`, `dimension_angular`, `dimension_radius`, `dimension_diameter`, ~lines 3334–3491) replace every + +```python + dimstyle=_DIMSTYLE_NAME, +``` +with +```python + dimstyle=_current_dimstyle_name(self._require_doc()), +``` + +(five occurrences; `_DIMSTYLE_NAME` itself stays — it is the fallback). In `entity_create_text` add `"style": _current_textstyle_name(self._require_doc()),` as the first key of the `dxfattribs` dict, and in `entity_create_mtext` change `dxfattribs={"char_height": float(height), "width": float(width)}` to `dxfattribs={"style": _current_textstyle_name(self._require_doc()), "char_height": float(height), "width": float(width)}`. + +- [ ] **Step 7: ezdxf — the method block and the capability key** + +Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly after `system_run_lisp`; this position is reserved for group S — C, V and P anchor elsewhere, see the skeleton's Merge notes): + +```python + # ── styles (track E) ───────────────────────────────────────────────────── + + async def dimstyle_list(self) -> list[dict]: + def _sync(): + doc = self._require_doc() + current = _current_dimstyle_name(doc) + rows = [ + { + "name": style.dxf.name, + "current": style.dxf.name == current, + "values": _read_dimstyle_values(style), + "values_available": True, + } + for style in doc.dimstyles + ] + rows.sort(key=lambda row: row["name"].lower()) + return rows + + return await self._async(_sync) + + async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict: + from engineering.standards.dimstyles import validate_overrides + from security import sanitize_symbol_name + + clean = sanitize_symbol_name(name, kind="dimstyle") + typed = validate_overrides(values) + if not typed: + raise ValueError("dimstyle_create: values is empty; resolve a preset first") + + def _sync(): + doc = self._require_doc() + if doc.dimstyles.has_entry(clean): + raise ValueError( + f"dimstyle_create: dimension style {clean!r} already exists; " + "use dimstyle_modify to change it" + ) + previous = _current_dimstyle_name(doc) + txsty, textstyle_created = _ensure_textstyle( + doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY" + ) + style = doc.dimstyles.new(clean) + _write_dimstyle_values(style, {**typed, "DIMTXSTY": txsty}) + if set_current: + doc.header["$DIMSTYLE"] = clean + _sync_header_to_dimstyle(doc, style) + else: + # Measured: ezdxf.new() writes $DIMSTYLE "ISO-25" into a fresh + # header with no such table entry, so creating a style of that + # name would silently make it current. Pin the header to the + # style that was effectively current before this call. + doc.header["$DIMSTYLE"] = previous + self._mark_dirty() + return { + "ok": True, + "name": clean, + "values": _read_dimstyle_values(style), + "written": sorted(typed), + "current": bool(set_current), + "textstyle_created": textstyle_created, + } + + return await self._async(_sync) + + async def dimstyle_modify(self, name: str, values: dict) -> dict: + from engineering.standards.dimstyles import validate_overrides + + typed = validate_overrides(values) + if not typed: + raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable") + + def _sync(): + doc = self._require_doc() + if not doc.dimstyles.has_entry(name): + raise ValueError( + f"dimstyle_modify: dimension style {name!r} does not exist; " + "dimstyle_list names the styles this drawing holds" + ) + style = doc.dimstyles.get(name) + if "DIMTXSTY" in typed and not doc.styles.has_entry(typed["DIMTXSTY"]): + raise ValueError( + f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; " + "create it first with textstyle_create" + ) + before = _read_dimstyle_values(style, tuple(typed)) + _write_dimstyle_values(style, typed) + after = _read_dimstyle_values(style, tuple(typed)) + changed = {var: [before[var], after[var]] for var in typed if before[var] != after[var]} + if changed and style.dxf.name == _current_dimstyle_name(doc): + _sync_header_to_dimstyle(doc, style) + using = _dimensions_using(doc, style.dxf.name) + if changed: + self._mark_dirty() + return { + "ok": True, + "name": style.dxf.name, + "changed": changed, + "dimensions_using_style": using, + # A headless DIMENSION carries its rendered block; it shows the + # old style until redrawn. Stated, not silent. + "rerender_required": bool(changed and using), + } + + return await self._async(_sync) + + async def dimstyle_set_current(self, name: str) -> dict: + def _sync(): + doc = self._require_doc() + if not doc.dimstyles.has_entry(name): + raise ValueError( + f"dimstyle_set_current: dimension style {name!r} does not exist; " + "dimstyle_list names the styles this drawing holds" + ) + style = doc.dimstyles.get(name) + previous = _current_dimstyle_name(doc) + doc.header["$DIMSTYLE"] = style.dxf.name + _sync_header_to_dimstyle(doc, style) + changed = style.dxf.name != previous + if changed: + self._mark_dirty() + return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed} + + return await self._async(_sync) + + async def textstyle_list(self) -> list[dict]: + def _sync(): + doc = self._require_doc() + current = _current_textstyle_name(doc) + rows = [ + { + "name": style.dxf.name, + "font": str(style.dxf.get("font", "") or ""), + "height": float(style.dxf.get("height", 0.0) or 0.0), + "width_factor": float(style.dxf.get("width", 1.0) or 1.0), + "oblique_deg": float(style.dxf.get("oblique", 0.0) or 0.0), + "current": style.dxf.name == current, + } + for style in doc.styles + ] + rows.sort(key=lambda row: row["name"].lower()) + return rows + + return await self._async(_sync) + + async def textstyle_create( + self, + name: str, + font: str, + height: float = 0.0, + width_factor: float = 1.0, + oblique_deg: float = 0.0, + set_current: bool = False, + ) -> dict: + from engineering.standards.textstyles import validate_textstyle + + spec = validate_textstyle(name, font, height, width_factor, oblique_deg) + + def _sync(): + doc = self._require_doc() + if doc.styles.has_entry(spec["name"]): + raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists") + doc.styles.new( + spec["name"], + dxfattribs={ + "font": spec["font_file"], + "width": spec["width_factor"], + "oblique": spec["oblique_deg"], + "height": spec["height"], + }, + ) + if set_current: + doc.header["$TEXTSTYLE"] = spec["name"] + self._mark_dirty() + return { + "ok": True, + "name": spec["name"], + "font": spec["font_file"], + # A DXF stores the name only; a font nobody can find is written + # and *said* to be unresolved, never refused. + "font_resolved": spec["known"] or _font_available(spec["font_file"]), + "current": bool(set_current), + } + + return await self._async(_sync) + + async def textstyle_set_current(self, name: str) -> dict: + def _sync(): + doc = self._require_doc() + if not doc.styles.has_entry(name): + raise ValueError( + f"textstyle_set_current: text style {name!r} does not exist; " + "textstyle_list names the styles this drawing holds" + ) + style = doc.styles.get(name) + previous = _current_textstyle_name(doc) + doc.header["$TEXTSTYLE"] = style.dxf.name + changed = style.dxf.name != previous + if changed: + self._mark_dirty() + return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed} + + return await self._async(_sync) + + async def mleaderstyle_list(self) -> list[dict]: + def _sync(): + doc = self._require_doc() + rows = [] + for name, style in doc.mleader_styles: + handle = style.dxf.get("text_style_handle") + text_style = doc.entitydb.get(handle) if handle else None + rows.append( + { + "name": name, + # schema defaults (measured: 4 / 2.0 / 4) for an unset field + "arrow_size": float(style.dxf.get("arrow_head_size", 4.0)), + "landing_gap": float(style.dxf.get("landing_gap_size", 2.0)), + "text_style": text_style.dxf.name if text_style is not None else "Standard", + "text_height": float(style.dxf.get("char_height", 4.0)), + "values_available": True, + } + ) + rows.sort(key=lambda row: row["name"].lower()) + return rows + + return await self._async(_sync) + + async def mleaderstyle_create(self, name: str, values: dict) -> dict: + from engineering.standards.mleaderstyles import MLEADER_KEYS + from security import sanitize_symbol_name + + clean = sanitize_symbol_name(name, kind="mleaderstyle") + missing = [key for key in MLEADER_KEYS if key not in values] + if missing: + raise ValueError( + f"mleaderstyle_create: values is missing {missing}; resolve a preset first" + ) + + def _sync(): + doc = self._require_doc() + if doc.mleader_styles.has_entry(clean): + raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists") + text_style_name, textstyle_created = _ensure_textstyle( + doc, str(values["text_style"]), refusal_key="text_style" + ) + style = doc.mleader_styles.new(clean) + style.dxf.arrow_head_size = float(values["arrow_size"]) + style.dxf.landing_gap_size = float(values["landing_gap"]) + style.dxf.char_height = float(values["text_height"]) + style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle + self._mark_dirty() + return { + "ok": True, + "name": clean, + "values": { + "arrow_size": float(values["arrow_size"]), + "landing_gap": float(values["landing_gap"]), + "text_style": text_style_name, + "text_height": float(values["text_height"]), + }, + "textstyle_created": textstyle_created, + } + + return await self._async(_sync) +``` + +In `EzdxfBackend.capabilities()` add, after the `"lisp"` entry (S's entry is the first of the chain the merge agent assembles at that spot: `"lisp"` → this `"mleaderstyle"` → C's `"registry_sysvar"`, `"dwgprops"` → V's `"documents"` … `"interactive_prompt"`; skeleton, Merge notes): + +```python + "mleaderstyle": FeatureCapability(True, "native"), +``` + +- [ ] **Step 8: COM — module helpers** + +Insert immediately after `_regen` (before `_apply_dim_tolerance`) in `backends/com_backend.py`: + +```python +# ── styles (track E): module helpers ───────────────────────────────────────── +# +# The ActiveX object model has no per-style getters or setters for dimension +# variables: `AcadDimStyle` exposes `Name` and `CopyFrom` and nothing else. A +# style's values are therefore written the way AutoCAD's own DIMSTYLE command +# writes them — set the DIM* system variables on the document (which makes +# them overrides of the *current* style) and `CopyFrom(document)` to save +# those settings into the target style. Assigning `ActiveDimStyle` restores +# that style's saved settings and discards unsaved overrides (AutoCAD's +# `-DIMSTYLE Restore` rule; the fake in tests/test_styles.py models exactly +# this, and scripts/smoke_settings_com.py confirms it live). Reading is +# one-sided: `GetVariable` reads the *current* style only, so `dimstyle_list` +# reports values for the current style and `values_available: False` for the +# rest rather than cycling the active style behind the operator's back. + + +def _com_named(collection, name: str): + """A collection member by name, case-insensitive (AutoCAD's rule), or None.""" + wanted = name.strip().lower() + for index in range(collection.Count): + item = collection.Item(index) + if str(item.Name).lower() == wanted: + return item + return None + + +def _com_dimvar_for_write(var: str, value: Any) -> Any: + """ActiveX takes DIMDSEP and the arrowhead/text-style names as strings.""" + if var in ("DIMDSEP", "DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"): + return str(value) + if isinstance(value, float): + return float(value) + return int(value) + + +def _com_dimvar_for_report(var: str, raw: Any) -> Any: + if var == "DIMDSEP": + if isinstance(raw, int): + return "," if raw == 0 else chr(raw) + text = str(raw) + return text[:1] if text else "," + if var in ("DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"): + return str(raw) + return raw + + +def _same_dimvar(old: Any, new: Any) -> bool: + if isinstance(old, (int, float)) and isinstance(new, (int, float)): + return abs(float(old) - float(new)) < 1e-9 + return str(old) == str(new) + + +def _com_dimensions_using(doc, style_name: str) -> list[str]: + """Handles of every dimension, in every layout, whose StyleName is ``style_name``.""" + wanted = style_name.lower() + handles: list[str] = [] + for index in range(doc.Layouts.Count): + block = doc.Layouts.Item(index).Block + for position in range(block.Count): + obj = block.Item(position) + try: + if "Dimension" in str(obj.ObjectName) and str(obj.StyleName).lower() == wanted: + handles.append(str(obj.Handle)) + except Exception: + continue + return handles + + +def _com_font_on_support_path(font_file: str) -> bool: + """Whether AutoCAD's support path (Preferences.Files.SupportPath) holds the file.""" + path = Path(font_file) + if path.is_absolute(): + return path.is_file() + try: + support = str(_acad_app().Preferences.Files.SupportPath) + except Exception: + return False + return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder) + + +def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]: + """``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused.""" + from engineering.standards.textstyles import TEXT_PRESETS + + existing = _com_named(doc.TextStyles, name) + if existing is not None: + return str(existing.Name), False + preset = TEXT_PRESETS.get(name.upper()) + if preset is None: + raise ValueError( + f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a " + f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create" + ) + font_file, width, oblique = preset + style = doc.TextStyles.Add(name.upper()) + style.fontFile = font_file + style.Width = float(width) + style.ObliqueAngle = deg2rad(oblique) + style.Height = 0.0 + return name.upper(), True +``` + +- [ ] **Step 9: COM — the method block and the capability key** + +Insert immediately before ` # ── corner ops` in `ComBackend` — S's reserved anchor on the COM side too (no `mleaderstyle_create` here — the contract's refusing default is the implementation): + +```python + # ── styles (track E) ───────────────────────────────────────────────────── + # + # Fake-tested in tests/test_styles.py; executed live once by + # scripts/smoke_settings_com.py. See the module helper block for the + # ActiveX rules every method here relies on. + + async def dimstyle_list(self) -> list[dict]: + from engineering.standards.dimstyles import PRESET_VARIABLES + + def _sync(): + doc = _acad_doc() + current = str(doc.ActiveDimStyle.Name) + rows = [] + for index in range(doc.DimStyles.Count): + name = str(doc.DimStyles.Item(index).Name) + is_current = name.lower() == current.lower() + values = ( + { + var: _com_dimvar_for_report(var, doc.GetVariable(var)) + for var in PRESET_VARIABLES + } + if is_current + else None + ) + rows.append( + { + "name": name, + "current": is_current, + "values": values, + "values_available": is_current, + } + ) + rows.sort(key=lambda row: row["name"].lower()) + return rows + + return await self._run(_sync) + + async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict: + from engineering.standards.dimstyles import PRESET_VARIABLES, validate_overrides + + clean = sanitize_symbol_name(name, kind="dimstyle") + typed = validate_overrides(values) + if not typed: + raise ValueError("dimstyle_create: values is empty; resolve a preset first") + + def _sync(): + doc = _acad_doc() + if _com_named(doc.DimStyles, clean) is not None: + raise ValueError( + f"dimstyle_create: dimension style {clean!r} already exists; " + "use dimstyle_modify to change it" + ) + # The text style must exist before DIMTXSTY can name it. + txsty, textstyle_created = _ensure_com_textstyle( + doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY" + ) + previous = doc.ActiveDimStyle + style = doc.DimStyles.Add(clean) + doc.ActiveDimStyle = style + for var, value in {**typed, "DIMTXSTY": txsty}.items(): + doc.SetVariable(var, _com_dimvar_for_write(var, value)) + style.CopyFrom(doc) + written = { + var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in PRESET_VARIABLES + } + if not set_current: + doc.ActiveDimStyle = previous + _regen() + return { + "ok": True, + "name": clean, + "values": written, + "written": sorted(typed), + "current": bool(set_current), + "textstyle_created": textstyle_created, + } + + return await self._run(_sync) + + async def dimstyle_modify(self, name: str, values: dict) -> dict: + from engineering.standards.dimstyles import validate_overrides + + typed = validate_overrides(values) + if not typed: + raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable") + + def _sync(): + doc = _acad_doc() + style = _com_named(doc.DimStyles, name) + if style is None: + raise ValueError( + f"dimstyle_modify: dimension style {name!r} does not exist; " + "dimstyle_list names the styles this drawing holds" + ) + if "DIMTXSTY" in typed and _com_named(doc.TextStyles, typed["DIMTXSTY"]) is None: + raise ValueError( + f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; " + "create it first with textstyle_create" + ) + previous = doc.ActiveDimStyle + switched = str(previous.Name).lower() != str(style.Name).lower() + if switched: + doc.ActiveDimStyle = style + before = {var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in typed} + changed: dict[str, list] = {} + for var, value in typed.items(): + if _same_dimvar(before[var], value): + continue + doc.SetVariable(var, _com_dimvar_for_write(var, value)) + changed[var] = [before[var], value] + if changed: + style.CopyFrom(doc) + if switched: + doc.ActiveDimStyle = previous + using = _com_dimensions_using(doc, str(style.Name)) + if changed: + _regen() + return { + "ok": True, + "name": str(style.Name), + "changed": changed, + "dimensions_using_style": using, + "rerender_required": False, # AutoCAD re-renders on regen + } + + return await self._run(_sync) + + async def dimstyle_set_current(self, name: str) -> dict: + def _sync(): + doc = _acad_doc() + style = _com_named(doc.DimStyles, name) + if style is None: + raise ValueError( + f"dimstyle_set_current: dimension style {name!r} does not exist; " + "dimstyle_list names the styles this drawing holds" + ) + previous = str(doc.ActiveDimStyle.Name) + changed = previous.lower() != str(style.Name).lower() + if changed: + doc.ActiveDimStyle = style + return { + "ok": True, + "current": str(style.Name), + "previous": previous, + "changed": changed, + } + + return await self._run(_sync) + + async def textstyle_list(self) -> list[dict]: + def _sync(): + doc = _acad_doc() + current = str(doc.ActiveTextStyle.Name) + rows = [] + for index in range(doc.TextStyles.Count): + style = doc.TextStyles.Item(index) + rows.append( + { + "name": str(style.Name), + "font": str(style.fontFile), + "height": float(style.Height), + "width_factor": float(style.Width), + "oblique_deg": rad2deg(float(style.ObliqueAngle)), + "current": str(style.Name).lower() == current.lower(), + } + ) + rows.sort(key=lambda row: row["name"].lower()) + return rows + + return await self._run(_sync) + + async def textstyle_create( + self, + name: str, + font: str, + height: float = 0.0, + width_factor: float = 1.0, + oblique_deg: float = 0.0, + set_current: bool = False, + ) -> dict: + from engineering.standards.textstyles import validate_textstyle + + spec = validate_textstyle(name, font, height, width_factor, oblique_deg) + + def _sync(): + doc = _acad_doc() + if _com_named(doc.TextStyles, spec["name"]) is not None: + raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists") + style = doc.TextStyles.Add(spec["name"]) + style.fontFile = spec["font_file"] + style.Width = spec["width_factor"] + style.ObliqueAngle = deg2rad(spec["oblique_deg"]) + style.Height = spec["height"] + if set_current: + doc.ActiveTextStyle = style + return { + "ok": True, + "name": spec["name"], + "font": spec["font_file"], + "font_resolved": spec["known"] or _com_font_on_support_path(spec["font_file"]), + "current": bool(set_current), + } + + return await self._run(_sync) + + async def textstyle_set_current(self, name: str) -> dict: + def _sync(): + doc = _acad_doc() + style = _com_named(doc.TextStyles, name) + if style is None: + raise ValueError( + f"textstyle_set_current: text style {name!r} does not exist; " + "textstyle_list names the styles this drawing holds" + ) + previous = str(doc.ActiveTextStyle.Name) + changed = previous.lower() != str(style.Name).lower() + if changed: + doc.ActiveTextStyle = style + return { + "ok": True, + "current": str(style.Name), + "previous": previous, + "changed": changed, + } + + return await self._run(_sync) + + async def mleaderstyle_list(self) -> list[dict]: + def _sync(): + doc = _acad_doc() + try: + dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE") + except Exception: + return [] + rows = [] + for index in range(dictionary.Count): + rows.append( + { + "name": str(dictionary.GetName(dictionary.Item(index))), + # ActiveX exposes no MLeaderStyle object: names only. + "arrow_size": None, + "landing_gap": None, + "text_style": None, + "text_height": None, + "values_available": False, + } + ) + rows.sort(key=lambda row: row["name"].lower()) + return rows + + return await self._run(_sync) +``` + +In `ComBackend.capabilities()` add, after the `"lisp"` entry (first of the S → C → V chain at that spot, as on ezdxf): + +```python + "mleaderstyle": FeatureCapability( + False, + reason=( + "no_activex_mleaderstyle_collection;" + "use_leader_create_mleader_per_leader_parameters" + ), + ), +``` + +- [ ] **Step 10: Run the tests and the dimension regression suites** + +Run: `uv run --frozen pytest tests/test_styles.py tests/test_capability_contract.py tests/test_dimension_header_vars.py tests/test_dimension_style.py tests/test_dimensions.py tests/test_dimension_tolerances.py tests/test_fits.py tests/test_meta_tools.py tests/test_entity_creation.py -q` +Expected: all PASS (the header-vars and dimension-style suites prove the call-site change is invisible while `Standard` is current). + +- [ ] **Step 11: Lint and commit** + +Run: `uv run --frozen ruff check backends tests/test_styles.py && uv run --frozen ruff format --check backends tests/test_styles.py` + +```bash +git add backends/contracts/styles.py backends/contracts/__init__.py backends/base.py backends/ezdxf_backend.py backends/com_backend.py tests/test_styles.py +git commit -m "feat(styles): dimension, text and multileader styles on both engines; \$DIMSTYLE/\$TEXTSTYLE reach new entities headlessly" +``` + +--- + +### Task 11: SECTION 18 — nine style tools, aliases, group, counts, gates + +**Files:** +- Modify: `server.py` (`_GROUP_TAG_PRIORITY` + `_GROUP_TAG_LABELS`; new `# ── SECTION 18: Styles (9 tools)` after `pid_tag_parse`, before the `# ── RESOURCES` banner) +- Modify: `discovery/aliases.py` (`SHARED_ACAD_COMMANDS` + a `# ── Styles (track E, group S)` block at the end of `TOOL_ALIASES`) +- Modify: `README.md` snapshot line `166 tools` → `175 tools`; regenerate `docs/tool-inventory.json`; `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` gains `"styles": 9`, total `175` +- Test: `tests/test_styles.py` (append a tool section) + +**Interfaces:** +- Produces MCP tools `dimstyle_list`, `dimstyle_create(name, preset=None, overrides=None, set_current=False)`, `dimstyle_modify(name, overrides)`, `dimstyle_set_current(name)`, `textstyle_list`, `textstyle_create(name, font, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False)`, `textstyle_set_current(name)`, `mleaderstyle_list`, `mleaderstyle_create(name, preset, overrides=None)`. All tagged `style` (+ `query`/`create`/`modify`), which files them under a new `styles` group; pack `core` (no `PACK_TOOL_NAMES` change — core is everything unclaimed); lean decision deferred to Task 13 (`dimstyle_set_current` and `textstyle_set_current` join, the rest do not). +- `DIMSTYLE`, `STYLE`, `MLEADERSTYLE` are umbrella commands (one dialog for list/create/modify/set-current) and join `SHARED_ACAD_COMMANDS`; `DDIM` is the one command unique to a single tool (`dimstyle_create`). + +- [ ] **Step 1: Write the failing tool tests** + +Append to `tests/test_styles.py`: + +```python +# ── the MCP tools (SECTION 18) ────────────────────────────────────────────── + +STYLE_TOOLS = { + "dimstyle_list", + "dimstyle_create", + "dimstyle_modify", + "dimstyle_set_current", + "textstyle_list", + "textstyle_create", + "textstyle_set_current", + "mleaderstyle_list", + "mleaderstyle_create", +} + + +class _Ctx: + """Enough of a fastmcp Context for a direct tool call (tests/test_capabilities.py).""" + + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *_args, **_kwargs): + return None + + +async def test_style_tools_are_registered_and_grouped_under_styles(): + import server + + names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)} + assert STYLE_TOOLS <= names + groups = await server._tool_groups() + assert STYLE_TOOLS <= set(groups["styles"]), "the style tag must win over query/create/modify" + for other in ("layers", "drawing", "entity_query", "entity_creation", "entity_modification"): + assert not STYLE_TOOLS & set(groups.get(other, [])), other + + +async def test_dimstyle_tools_resolve_the_preset_then_write(backend): + import server + + ctx = _Ctx(backend) + created = await server.dimstyle_create( + name="ISO-25", preset="iso-25", overrides={"dimdec": 3}, set_current=True, ctx=ctx + ) + assert created["current"] is True and created["values"]["DIMDEC"] == 3 + listed = await server.dimstyle_list(ctx=ctx) + assert listed["count"] == 2 and listed["current"] == "ISO-25" + modified = await server.dimstyle_modify(name="ISO-25", overrides={"DIMTXT": 3.5}, ctx=ctx) + assert modified["changed"] == {"DIMTXT": [2.5, 3.5]} + switched = await server.dimstyle_set_current(name="Standard", ctx=ctx) + assert switched["previous"] == "ISO-25" and switched["current"] == "Standard" + + +async def test_dimstyle_create_tool_refuses_an_unknown_override_before_writing(backend): + import server + + ctx = _Ctx(backend) + with pytest.raises(ValueError, match="DIMFOO"): + await server.dimstyle_create(name="X", overrides={"DIMFOO": 1}, ctx=ctx) + with pytest.raises(ValueError, match="preset"): + await server.dimstyle_create(name="X", preset="din", ctx=ctx) + assert (await server.dimstyle_list(ctx=ctx))["count"] == 1 + + +async def test_textstyle_and_mleaderstyle_tools_round_trip(backend): + import server + + ctx = _Ctx(backend) + made = await server.textstyle_create(name="ISOCP", font="isocp", set_current=True, ctx=ctx) + assert made["font_resolved"] is True + assert (await server.textstyle_list(ctx=ctx))["current"] == "ISOCP" + assert (await server.textstyle_set_current(name="Standard", ctx=ctx))["previous"] == "ISOCP" + leader = await server.mleaderstyle_create( + name="ISO", preset="iso", overrides={"arrow_size": 3}, ctx=ctx + ) + assert leader["values"]["arrow_size"] == 3.0 and leader["textstyle_created"] is False + assert (await server.mleaderstyle_list(ctx=ctx))["count"] == 2 + with pytest.raises(ValueError, match="preset"): + await server.mleaderstyle_create(name="X", preset="din", ctx=ctx) +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_styles.py -q -k "tools or tool"` +Expected: four FAILs — `KeyError: 'styles'` in the registration test and `AttributeError: module 'server' has no attribute 'dimstyle_create'` (or `textstyle_create`) in the three call tests. + +- [ ] **Step 3: The `styles` group** + +In `server.py::_GROUP_TAG_PRIORITY` insert `"style",` immediately before `"layer",`; in `_GROUP_TAG_LABELS` insert `"style": "styles",` immediately before `"layer": "layers",`. + +- [ ] **Step 4: Register the nine tools** + +Insert after the `pid_tag_parse` tool and before the `# ── RESOURCES` banner in `server.py`: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 18: Styles (9 tools) ──────────────────────────────────────────── +# --------------------------------------------------------------------------- +# +# Pack: core (drafting essentials). Lean profile: dimstyle_set_current and +# textstyle_set_current only (Task 13) — a lean client needs to *use* a +# style a template already holds far more often than to author one. + + +@cad_tool( + summary="List dimension styles with their ISO-25/ANSI variables and which one is current.", + cost="read", +) +@mcp.tool( + annotations={"title": "Styles: List Dimension Styles", "readOnlyHint": True}, + tags={"style", "query"}, +) +async def dimstyle_list(ctx: Context = None) -> dict: + """Every DIMSTYLE table entry with the seventeen preset variables + (DIMTXT, DIMASZ, DIMEXE, DIMEXO, DIMGAP, DIMTAD, DIMTIH, DIMTOH, DIMDEC, + DIMDSEP, DIMLUNIT, DIMZIN, DIMBLK, DIMTXSTY, DIMLWD, DIMLWE, DIMSCALE). + + Headless, `values` is what the DXF stores (the schema default where an + entry omits a code). On the live engine ActiveX has no per-style getters, + so `values` is read for the *current* style only and the other rows carry + `values: null`, `values_available: false` — `dimstyle_set_current` a + style to read it. Never refuses on an open drawing. + """ + rows = await _backend(ctx).dimstyle_list() + return { + "ok": True, + "styles": rows, + "count": len(rows), + "current": next((row["name"] for row in rows if row["current"]), None), + } + + +@cad_tool( + summary="Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Create Dimension Style", "readOnlyHint": False}, + tags={"style", "create"}, +) +async def dimstyle_create( + name: Annotated[str, "New dimension style name, e.g. ISO-25, ANSI, MECH-A3"], + preset: Annotated[ + str | None, "iso-25 (ISO 129-1, 2.5 mm, comma) | ansi (ASME Y14.2, 3 mm, point); omit for the ISO-25 base" + ] = None, + overrides: Annotated[ + dict | None, + "DIM* variables applied on top of the preset, e.g. {DIMTXT: 3.5, DIMDSEP: '.', DIMTAD: 0}", + ] = None, + set_current: Annotated[bool, "Also make it the current dimension style"] = False, + ctx: Context = None, +) -> dict: + """Create a dimension style: preset values first, then `overrides`. + + Refuses, before anything is written: an unknown preset; an override key + outside the 33-variable whitelist (the 17 preset variables plus DIMTOL, + DIMTP, DIMTM, DIMTOLJ, DIMTFAC, DIMLFAC, DIMRND, DIMATFIT, DIMTMOVE, + DIMCLRD/E/T, DIMSAH, DIMBLK1/2, DIMCEN); a value outside its range + (DIMDEC 0-8, sizes > 0, DIMTAD 0-4, DIMDSEP one character, lineweights an + AutoCAD code); a name that already exists (use `dimstyle_modify`); a + DIMTXSTY that is neither an existing text style nor a bundled preset + (ISOCP, ISOCPEUR, ARIAL, ROMANS — those are created, `textstyle_created`). + With `set_current` the next dimension carries the style on both engines. + """ + from engineering.standards.dimstyles import resolve_dimstyle + + values = resolve_dimstyle(preset, overrides) + await ctx.info(f"Dimension style {name!r} from preset {preset or 'iso-25'}") + return await _backend(ctx).dimstyle_create(name, values, set_current) + + +@cad_tool( + summary="Change DIM* variables on an existing dimension style; reports only what moved.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Modify Dimension Style", "readOnlyHint": False}, + tags={"style", "modify"}, +) +async def dimstyle_modify( + name: Annotated[str, "Existing dimension style name"], + overrides: Annotated[dict, "DIM* variables to change, e.g. {DIMDEC: 3, DIMTXT: 3.5}"], + ctx: Context = None, +) -> dict: + """Change variables on an existing dimension style. + + `changed` names only the variables that actually moved — re-setting a + value to itself is not a change. `dimensions_using_style` lists the + dimensions already drawn with it; headlessly `rerender_required: true` + says they still show the old style until redrawn (AutoCAD re-renders on + the next regen). Refuses a missing style, an empty `overrides`, a key + outside the whitelist, a value outside its range, a DIMTXSTY that does + not exist — all before any write. On the live engine, editing a + non-current style makes it current for the duration of the call and the + previous style is restored (its unsaved overrides are discarded, which is + AutoCAD's own DIMSTYLE rule). + """ + await ctx.info(f"Modifying dimension style {name!r}") + return await _backend(ctx).dimstyle_modify(name, overrides) + + +@cad_tool(summary="Make a dimension style current so new dimensions use it.", cost="safe") +@mcp.tool( + annotations={"title": "Styles: Set Current Dimension Style", "readOnlyHint": False}, + tags={"style", "modify"}, +) +async def dimstyle_set_current( + name: Annotated[str, "Dimension style to make current (case-insensitive)"], + ctx: Context = None, +) -> dict: + """Set `$DIMSTYLE` and load the style's variables as the current DIM* + settings — what a live seat's DIMSTYLE Restore does — so `dimension_*` + and `dimension_auto` draw with it on both engines. Per-dimension + tolerances, fits and text overrides still apply on top. Refuses a style + the drawing does not hold. Returns `previous` and `changed`. + """ + return await _backend(ctx).dimstyle_set_current(name) + + +@cad_tool(summary="List text styles: font file, height, width factor, oblique angle.", cost="read") +@mcp.tool( + annotations={"title": "Styles: List Text Styles", "readOnlyHint": True}, + tags={"style", "query"}, +) +async def textstyle_list(ctx: Context = None) -> dict: + """Every STYLE table entry and which one new TEXT/MTEXT will use.""" + rows = await _backend(ctx).textstyle_list() + return { + "ok": True, + "styles": rows, + "count": len(rows), + "current": next((row["name"] for row in rows if row["current"]), None), + } + + +@cad_tool( + summary="Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Create Text Style", "readOnlyHint": False}, + tags={"style", "create"}, +) +async def textstyle_create( + name: Annotated[str, "New text style name, e.g. ISOCP"], + font: Annotated[ + str, "isocp.shx | isocpeur.ttf | arial.ttf | romans.shx (name or file, any case) or any font file" + ], + height: Annotated[float, Field(default=0.0, ge=0, description="Fixed height; 0 = ask per text")] = 0.0, + width_factor: Annotated[float, Field(default=1.0, gt=0, description="Width factor")] = 1.0, + oblique_deg: Annotated[float, "Obliquing angle in degrees, -85..85"] = 0.0, + set_current: Annotated[bool, "Also make it the current text style"] = False, + ctx: Context = None, +) -> dict: + """Create a text style. + + A font that is neither a bundled preset nor on the engine's font path is + *written* and reported `font_resolved: false` (AutoCAD substitutes at + open; the headless renderer falls back) — never refused, because a DXF + stores only the name. Refuses, before any write: a name that exists, an + empty font, `width_factor <= 0`, `height < 0`, an oblique angle outside + ±85°. + """ + await ctx.info(f"Text style {name!r} with font {font!r}") + return await _backend(ctx).textstyle_create( + name, font, height, width_factor, oblique_deg, set_current + ) + + +@cad_tool(summary="Make a text style current so new text uses it.", cost="safe") +@mcp.tool( + annotations={"title": "Styles: Set Current Text Style", "readOnlyHint": False}, + tags={"style", "modify"}, +) +async def textstyle_set_current( + name: Annotated[str, "Text style to make current (case-insensitive)"], + ctx: Context = None, +) -> dict: + """Set `$TEXTSTYLE`; `entity_create_text` / `entity_create_mtext` then + carry the style on both engines. Refuses a style the drawing does not + hold. Returns `previous` and `changed`.""" + return await _backend(ctx).textstyle_set_current(name) + + +@cad_tool(summary="List multileader styles: arrow size, landing gap, text style and height.", cost="read") +@mcp.tool( + annotations={"title": "Styles: List Multileader Styles", "readOnlyHint": True}, + tags={"style", "query"}, +) +async def mleaderstyle_list(ctx: Context = None) -> dict: + """Every MLEADERSTYLE. Headless the four values are read from the object. + ActiveX exposes no MLeaderStyle object, so the live engine lists the + *names* from the ACAD_MLEADERSTYLE dictionary with `values_available: + false`; the values themselves are set per leader through + `leader_create_mleader`.""" + rows = await _backend(ctx).mleaderstyle_list() + return {"ok": True, "styles": rows, "count": len(rows)} + + +@cad_tool( + summary="Create a multileader style from the ISO or ANSI preset (headless engine only).", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Create Multileader Style", "readOnlyHint": False}, + tags={"style", "create"}, +) +async def mleaderstyle_create( + name: Annotated[str, "New multileader style name"], + preset: Annotated[str, "iso (2.5 mm arrow, 1.0 landing, ISOCP 2.5) | ansi (3.0, 1.5, ROMANS 3.0)"], + overrides: Annotated[ + dict | None, "arrow_size | landing_gap | text_style | text_height on top of the preset" + ] = None, + ctx: Context = None, +) -> dict: + """Create a multileader style on `doc.mleader_styles`. + + Refuses, before any write: an unknown preset; an override key outside the + four; `arrow_size`/`text_height <= 0`, `landing_gap < 0`; a name that + exists; a `text_style` that is neither present nor a bundled preset. + On the live engine this is refused with `capability: "mleaderstyle"` + (ActiveX exposes no MLeaderStyle collection) — use + `leader_create_mleader`'s per-leader parameters there. + """ + from engineering.standards.mleaderstyles import resolve_mleaderstyle + + values = resolve_mleaderstyle(preset, overrides) + await ctx.info(f"Multileader style {name!r} from preset {preset}") + return await _backend(ctx).mleaderstyle_create(name, values) +``` + +- [ ] **Step 5: Alias records and the shared-command allowlist** + +In `discovery/aliases.py::SHARED_ACAD_COMMANDS` add, with these comment lines above the set alongside the existing rationale: + +```python +# DIMSTYLE, STYLE, MLEADERSTYLE -- one dialog each in AutoCAD for +# list / create / modify / set-current; this server splits each into +# discrete style tools (track E), so the command is a correct +# destination for every one of them. +``` + +and the three members `"DIMSTYLE",`, `"MLEADERSTYLE",`, `"STYLE",` in alphabetical position. Then append to `TOOL_ALIASES` after `pid_tag_parse`: + +```python + # ── Styles (track E, group S) ─────────────────────────────────────────── + "dimstyle_create": ToolAliases( + acad=("DIMSTYLE", "DDIM"), + synonyms=( + "new dimension style", + "create a dimension style", + "iso-25 dimension style", + "ansi dimension style", + "dimension style from a preset", + "set up dimension text height and arrows", + ), + ), + "dimstyle_list": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "list dimension styles", + "which dimension styles are defined", + "dimension style table", + "current dimstyle", + "dim styles", + ), + ), + "dimstyle_modify": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "change a dimension style", + "edit the dimension style", + "change dimension text height for the whole drawing", + "set dimdec on a style", + "modify dimstyle variables", + ), + ), + "dimstyle_set_current": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "current dimension style", + "make this dimension style current", + "switch dimension style", + "use iso-25 for new dimensions", + "activate a dimstyle", + ), + ), + "mleaderstyle_create": ToolAliases( + acad=("MLEADERSTYLE",), + synonyms=( + "new leader style", + "multileader style", + "leader arrow size and landing", + "iso leader style", + ), + ), + "mleaderstyle_list": ToolAliases( + acad=("MLEADERSTYLE",), + synonyms=("list leader styles", "which multileader styles exist", "mleader styles"), + ), + "textstyle_create": ToolAliases( + acad=("STYLE",), + synonyms=( + "new text style", + "create a text style", + "isocp font", + "set the font", + "text style with width factor and oblique angle", + ), + ), + "textstyle_list": ToolAliases( + acad=("STYLE",), + synonyms=( + "list text styles", + "which fonts are defined", + "text style table", + "current text style", + ), + ), + "textstyle_set_current": ToolAliases( + acad=("STYLE", "TEXTSTYLE"), + synonyms=( + "make this text style current", + "switch the text style", + "use isocp for new text", + "active text style", + ), + ), +``` + +- [ ] **Step 6: Counts — inventory, README, registry snapshot** + +Run: `uv run --frozen python scripts/generate_tool_inventory.py` +Then change the README line to `> **v1.5 release snapshot:** 175 tools · 8 resources · 5 prompt templates · 2423 collected tests.` (leave the collected-tests figure; the final task re-derives it). In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` add `"styles": 9,` between `"solids": 5,` and `"system": 7,`, change the total to `175`, and append to the docstring: `styles` appeared (0 -> 9) when track E's SECTION 18 opened with the dimension, text and multileader style tools; the `style` tag sits ahead of `layer` in `_GROUP_TAG_PRIORITY` so they never file under their secondary `query` / `create` / `modify` tags. + +- [ ] **Step 7: Run the gates** + +Run: `uv run --frozen pytest tests/test_styles.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_discovery_transform.py -q` +Expected: all PASS except, possibly, `test_document_numbers_match_the_tree` on the README `collected tests` figure alone — that drift predates this track and belongs to the final task; any other failure is this task's. (`test_shared_command_allowlist_has_no_stale_entries` passes because each of the three new shared commands is claimed by at least two tools.) + +- [ ] **Step 8: Commit** + +```bash +git add server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py tests/test_styles.py +git commit -m "feat(server): SECTION 18 styles — dimstyle/textstyle/mleaderstyle tools with DIMSTYLE/STYLE/MLEADERSTYLE aliases" +``` + +--- + +### Task 12: `apply_standard` + `drawing_apply_standard` (SECTION 18 → 10 tools) + +**Files:** +- Create: `engineering/standards/apply.py` +- Modify: `server.py` (SECTION 18 header `(9 tools)` → `(10 tools)`; tool after `mleaderstyle_create`), `discovery/aliases.py` (record after `dimstyle_set_current`), `README.md` (`175` → `176`), `docs/tool-inventory.json` (regenerate), `tests/test_tool_registry.py` (`"styles": 10`, total `176`) +- Test: `tests/test_styles.py` (append) + +**Interfaces:** +- Produces: `async def apply_standard(backend, standard: str, layers: bool = True, units: bool = True) -> dict` → `{"standard", "dimstyle": {"name", "created", "current": True}, "textstyle": {"name", "created", "current": True}, "settings": {"changed": {VAR: [old, new]}, "applied": bool}, "layers": {"layer_set", "layers": {name: "created"|"exists"|"failed: ..."}} | None}`; `STANDARDS`, `STANDARD_SYSVARS`, `resolve_standard(standard) -> (key, row)`. MCP tool `drawing_apply_standard(standard, layers=True, units=True)`. +- Composes only existing backend methods (`textstyle_*`, `dimstyle_*`, `system_get/set_variable`, `drawing_apply_iso_layers`), so it needs nothing from group C: units are written as the five system variables directly (`INSUNITS 4`, `LUNITS 2`, `AUNITS 0`, `LTSCALE 1.0`, `DIMSCALE 1.0`); the decimal separator comes from the preset through the dimstyle. An existing `ISO-25` / `ANSI` style is used as it is (`created: false`) and made current, never reset — `dimstyle_modify` is the deliberate way to change one. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_styles.py`: + +```python +# ── drawing_apply_standard ────────────────────────────────────────────────── + + +async def test_apply_iso_then_dimension_linear_carries_iso25(backend): + from engineering.standards.apply import apply_standard + + result = await apply_standard(backend, "iso") + assert result["standard"] == "iso" + assert result["dimstyle"] == {"name": "ISO-25", "created": True, "current": True} + assert result["textstyle"] == {"name": "ISOCP", "created": True, "current": True} + assert result["settings"]["changed"] == {"INSUNITS": [6, 4]}, "ezdxf.new() starts in metres" + assert result["layers"]["layer_set"] == "mech" + assert result["layers"]["layers"]["DIM"] in ("created", "exists") + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + assert backend._doc.header["$TEXTSTYLE"] == "ISOCP" + assert await backend.system_get_variable("LUNITS") == 2 + assert await backend.system_get_variable("INSUNITS") == 4 + + +async def test_apply_ansi_then_dimension_linear_carries_ansi(backend): + from engineering.standards.apply import apply_standard + + result = await apply_standard(backend, "ANSI", layers=False) + assert result["dimstyle"]["name"] == "ANSI" and result["textstyle"]["name"] == "ROMANS" + assert result["layers"] is None + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33.33", 3.0, [3.0]) + + +async def test_second_apply_is_idempotent_and_reports_nothing_created(backend): + from engineering.standards.apply import apply_standard + + await apply_standard(backend, "iso") + again = await apply_standard(backend, "iso") + assert again["dimstyle"]["created"] is False and again["textstyle"]["created"] is False + assert again["settings"]["changed"] == {} + assert set(again["layers"]["layers"].values()) == {"exists"} + swapped = await apply_standard(backend, "ansi", layers=False) + assert swapped["dimstyle"]["created"] is True + assert (await backend.dimstyle_list())[0]["name"] == "ANSI" + back = await apply_standard(backend, "iso", layers=False, units=False) + assert back["dimstyle"]["created"] is False and back["settings"] == {"changed": {}, "applied": False} + assert backend._doc.header["$DIMSTYLE"] == "ISO-25" + + +async def test_apply_refuses_an_unknown_standard_before_writing(backend): + from engineering.standards.apply import apply_standard + + with pytest.raises(ValueError, match="standard"): + await apply_standard(backend, "din") + with pytest.raises(TypeError, match="layers"): + await apply_standard(backend, "iso", layers="yes") + assert [r["name"] for r in await backend.dimstyle_list()] == ["Standard"] + assert await backend.system_get_variable("INSUNITS") == 6 + + +async def test_apply_standard_tool_is_registered_in_the_styles_group(backend): + import server + + names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)} + assert "drawing_apply_standard" in names + styles_group = set((await server._tool_groups())["styles"]) + assert styles_group == STYLE_TOOLS | {"drawing_apply_standard"}, "SECTION 18 is exactly ten" + result = await server.drawing_apply_standard(standard="iso", layers=False, ctx=_Ctx(backend)) + assert result["dimstyle"]["created"] is True + + +async def test_com_apply_standard_creates_both_styles_and_sets_units(com_backend): + from engineering.standards.apply import apply_standard + + backend, document = com_backend + document.variables.update({"INSUNITS": 1, "LUNITS": 4, "AUNITS": 0, "LTSCALE": 1.0, "DIMSCALE": 1.0}) + result = await apply_standard(backend, "iso", layers=False) + assert result["dimstyle"]["created"] is True and result["textstyle"]["created"] is True + assert result["settings"]["changed"] == {"INSUNITS": [1, 4], "LUNITS": [4, 2]} + names = [call[0] for call in document.calls] + assert names[0] == "TextStyles.Add" and "DimStyles.Add" in names + assert ("ActiveTextStyle", "ISOCP") in document.calls + assert document.ActiveDimStyle.Name == "ISO-25" + assert document.variables["DIMDSEP"] == "," and document.variables["INSUNITS"] == 4 +``` + +The COM case needs `system_get_variable` / `system_set_variable` to reach the fake document: those methods call `_acad_app().GetVariable/SetVariable`, so extend the fixture's `app` namespace with `GetVariable=document.GetVariable, SetVariable=document.SetVariable` (edit the `app = types.SimpleNamespace(...)` line in the `com_backend` fixture to `app = types.SimpleNamespace(Preferences=..., GetVariable=document.GetVariable, SetVariable=document.SetVariable)`). + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_styles.py -q -k apply` +Expected: FAIL with `ModuleNotFoundError: No module named 'engineering.standards.apply'`. + +- [ ] **Step 3: Write `engineering/standards/apply.py`** + +```python +"""One call to put a drawing on a drafting standard: styles, units, layers. + +Composes only backend methods that already exist (``textstyle_*``, +``dimstyle_*``, ``system_get/set_variable``, ``drawing_apply_iso_layers``), +so it runs on both engines and needs nothing from the settings facade. Units +are the five system variables both standards share; the decimal separator is +the preset's and arrives through the dimension style. ANSI layer naming is +company-specific, so both standards use the ``mech`` layer set — only styles +and the separator differ (spec §4.2). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from engineering.standards.dimstyles import resolve_dimstyle +from engineering.standards.textstyles import TEXT_PRESETS + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = ["STANDARDS", "STANDARD_SYSVARS", "apply_standard", "resolve_standard"] + +STANDARDS: dict[str, dict[str, str]] = { + "iso": {"preset": "iso-25", "dimstyle": "ISO-25", "textstyle": "ISOCP", "layer_set": "mech"}, + "ansi": {"preset": "ansi", "dimstyle": "ANSI", "textstyle": "ROMANS", "layer_set": "mech"}, +} + +#: Millimetres, decimal linear and angular units, 1:1 linetype and dimension +#: scale. Written by name so the report can say what moved. +STANDARD_SYSVARS: tuple[tuple[str, Any], ...] = ( + ("INSUNITS", 4), + ("LUNITS", 2), + ("AUNITS", 0), + ("LTSCALE", 1.0), + ("DIMSCALE", 1.0), +) + + +def resolve_standard(standard: str) -> tuple[str, dict[str, str]]: + """``(key, row)`` for ``iso`` / ``ansi`` (any case), else ``ValueError``.""" + key = str(standard).strip().lower() if standard is not None else "" + if key not in STANDARDS: + raise ValueError(f"standard: unknown value {standard!r}; use one of {sorted(STANDARDS)}") + return key, STANDARDS[key] + + +def _same(old: Any, new: Any) -> bool: + if old is None: + return False + try: + return abs(float(old) - float(new)) < 1e-9 + except (TypeError, ValueError): + return str(old) == str(new) + + +async def apply_standard( + backend: AutoCADBackend, standard: str, layers: bool = True, units: bool = True +) -> dict: + """Styles created (or reused) and made current, units written, layers bootstrapped. + + Every item reports whether it was created or already present; ``settings`` + reports only the variables that moved. An existing style of the standard's + name is used as it is — ``dimstyle_modify`` changes one on purpose. + """ + key, row = resolve_standard(standard) + if not isinstance(layers, bool) or not isinstance(units, bool): + raise TypeError("layers and units must be booleans") + + text_names = {entry["name"].lower() for entry in await backend.textstyle_list()} + textstyle_created = row["textstyle"].lower() not in text_names + if textstyle_created: + font_file, width, oblique = TEXT_PRESETS[row["textstyle"]] + await backend.textstyle_create(row["textstyle"], font_file, 0.0, width, oblique, False) + await backend.textstyle_set_current(row["textstyle"]) + + dim_names = {entry["name"].lower() for entry in await backend.dimstyle_list()} + dimstyle_created = row["dimstyle"].lower() not in dim_names + if dimstyle_created: + await backend.dimstyle_create( + row["dimstyle"], resolve_dimstyle(row["preset"], None), set_current=True + ) + else: + await backend.dimstyle_set_current(row["dimstyle"]) + + changed: dict[str, list] = {} + if units: + for var, value in STANDARD_SYSVARS: + old = await backend.system_get_variable(var) + if _same(old, value): + continue + await backend.system_set_variable(var, value) + changed[var] = [old, value] + + layer_result = None + if layers: + applied = await backend.drawing_apply_iso_layers(row["layer_set"]) + layer_result = {"layer_set": row["layer_set"], "layers": applied["layers"]} + + return { + "standard": key, + "dimstyle": {"name": row["dimstyle"], "created": dimstyle_created, "current": True}, + "textstyle": {"name": row["textstyle"], "created": textstyle_created, "current": True}, + "settings": {"changed": changed, "applied": units}, + "layers": layer_result, + } +``` + +- [ ] **Step 4: Register the tool, alias, counts** + +Change the SECTION 18 header to `# ── SECTION 18: Styles (10 tools) ──…` (keep the rule width) and add after `mleaderstyle_create`: + +```python +@cad_tool( + summary="Put the drawing on ISO or ANSI in one call: dimstyle, text style, units, mech layers.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Drawing: Apply Drafting Standard", "readOnlyHint": False}, + tags={"style", "drawing"}, +) +async def drawing_apply_standard( + standard: Annotated[str, "iso (ISO-25 / ISOCP, decimal comma) | ansi (ANSI / ROMANS, decimal point)"], + layers: Annotated[bool, "Also bootstrap the mech layer set with ISO 128 lineweights"] = True, + units: Annotated[bool, "Also write INSUNITS mm, LUNITS/AUNITS decimal, LTSCALE 1, DIMSCALE 1"] = True, + ctx: Context = None, +) -> dict: + """One call for the common case: the standard's dimension style (created + if missing, then current), its text style (created if missing, then + current), the shared units, and with `layers` the `mech` layer set (ANSI + layer naming is company-specific, so both standards share it). + + Every item reports `created` or already present; `settings.changed` + names only the variables that moved, so a second call reports nothing. + An existing ISO-25 / ANSI style is reused as it is, not reset — use + `dimstyle_modify` to change one. Refuses an unknown standard before any + write; the underlying style refusals (`dimstyle_create`, + `textstyle_create`) apply unchanged. In the `lean` profile (Task 13). + """ + from engineering.standards.apply import apply_standard + + await ctx.info(f"Applying the {standard} drafting standard") + return await apply_standard(_backend(ctx), standard, layers, units) +``` + +Alias record, inserted after `dimstyle_set_current` in the styles block: + +```python + "drawing_apply_standard": ToolAliases( + acad=(), + synonyms=( + "set the drawing up to iso", + "iso drafting standard", + "ansi drawing setup", + "apply the drafting standard", + "iso-25 and isocp in one go", + "standard styles units and layers", + ), + ), +``` + +Then: `uv run --frozen python scripts/generate_tool_inventory.py`; README `175 tools` → `176 tools`; registry snapshot `"styles": 10`, total `176`. + +- [ ] **Step 5: Run the tests and gates** + +Run: `uv run --frozen pytest tests/test_styles.py tests/test_standards_presets.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_meta_tools.py -q` +Expected: all PASS (same caveat on the README collected-tests figure as Task 11). + +- [ ] **Step 6: Lint and commit** + +Run: `uv run --frozen ruff check engineering/standards server.py discovery tests/test_styles.py && uv run --frozen ruff format --check engineering/standards tests/test_styles.py` + +```bash +git add engineering/standards/apply.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py tests/test_styles.py +git commit -m "feat(styles): drawing_apply_standard — ISO or ANSI styles, units and layers in one idempotent call" +``` + +--- + +### Task 13: Discovery golden queries for styles + the lean decision + +**Files:** +- Modify: `tests/data/discovery_golden.py` (five tuning cases; docstring count), `server.py` (`LEAN_TOOL_NAMES` + 3) +- Test: `tests/test_styles.py` (append the lean assertion) + +**Interfaces:** +- Lean decision (spec §8.2): `dimstyle_set_current`, `textstyle_set_current`, `drawing_apply_standard` join `LEAN_TOOL_NAMES` (50 → 53; the final task adds P's two for 55). The seven authoring/list tools stay full-only: a lean client uses a style a template holds and, when it must author, `drawing_apply_standard` does the whole job in one call. Pack: all ten stay `core` (no `PACK_TOOL_NAMES` entry). +- The holdout set is frozen (its docstring forbids edits); the five new cases go into `TUNING_CASES`. If one misses, the honest fix is vocabulary in the tool's alias record — never a relabelled case. + +- [ ] **Step 1: Write the failing lean test** + +Append to `tests/test_styles.py`: + +```python +# ── profile decision ──────────────────────────────────────────────────────── + + +def test_lean_carries_the_two_set_current_tools_and_apply_standard_only(): + import server + + lean = server.LEAN_TOOL_NAMES + assert {"dimstyle_set_current", "textstyle_set_current", "drawing_apply_standard"} <= lean + assert not (STYLE_TOOLS - {"dimstyle_set_current", "textstyle_set_current"}) & lean, ( + "authoring and listing styles is full-profile work" + ) + claimed = set().union(*server.PACK_TOOL_NAMES.values()) + assert not claimed & (STYLE_TOOLS | {"drawing_apply_standard"}), ( + "styles are core: no pack may claim them" + ) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run --frozen pytest tests/test_styles.py -q -k lean` +Expected: FAIL on the first assertion (none of the three is in `LEAN_TOOL_NAMES`). + +- [ ] **Step 3: The lean profile** + +In `server.py::LEAN_TOOL_NAMES`, after the three `pid_*` entries add: + +```python + # Styles (track E) — a lean client *uses* a style far more often than + # it authors one; drawing_apply_standard authors the whole set in one + # call when it must. + "dimstyle_set_current", + "textstyle_set_current", + "drawing_apply_standard", +``` + +- [ ] **Step 4: The golden queries** + +In `tests/data/discovery_golden.py`, change the docstring's first sentence to `Seventy-one cases (56 tuning + 15 holdout) split into a **tuning** set and a **holdout** set.` and append to `TUNING_CASES` (before its closing parenthesis): + +```python + # Styles (track E). DIMSTYLE / STYLE / MLEADERSTYLE are umbrella commands + # shared by their list/create/modify/set-current tools, so the one command + # case uses the command unique to a single tool and the rest are phrased. + GoldenCase("DDIM", "dimstyle_create", "command"), + GoldenCase("make ISO-25 the current dimension style", "dimstyle_set_current", "paraphrase"), + GoldenCase( + "set the drawing up to the ISO drafting standard in one go", + "drawing_apply_standard", + "paraphrase", + ), + GoldenCase("create a text style that uses the isocp font", "textstyle_create", "paraphrase"), + GoldenCase("which dimension styles are defined", "dimstyle_list", "counting", risk="read"), +``` + +- [ ] **Step 5: Run the discovery suite** + +Run: `uv run --frozen pytest tests/test_discovery_transform.py tests/test_discovery_aliases.py tests/test_styles.py tests/test_tool_profiles.py tests/test_tool_packs.py tests/test_token_suite.py -q` +Expected: all PASS — each of the five new cases in the top 3 (`test_tuning_case_finds_its_tool`), the tuning bars (top-1 ≥ 0.85, top-3 ≥ 0.94 over 56) and the stock A/B (`mine1 >= 2 * stock1`) still cleared. If a new case misses, add the missing phrase to that tool's record in `discovery/aliases.py` (e.g. `"drafting standard in one go"` to `drawing_apply_standard`) and re-run; do not edit the case or any constant in the transform. `test_tool_profiles.py` re-derives the lean count from `LEAN_TOOL_NAMES`, and `tests/test_token_suite.py` compares `advertised_tools` against it, so neither needs a hand-typed number. + +- [ ] **Step 6: Commit** + +```bash +git add server.py tests/data/discovery_golden.py tests/test_styles.py +git commit -m "feat(discovery): style golden queries; dimstyle/textstyle set_current and drawing_apply_standard join the lean profile" +``` + +The README configuration row (`lean (50 curated tools)`), the surface table, and CLAUDE.md's SECTION 18 / profile prose are the final task's, which also carries group P's two lean additions and re-derives the collected-tests figure — leaving one edit site for all of it. + + +## Group C — settings (Tasks 14–16, worktree `track-e/settings`) + +Group C owns `backends/contracts/settings.py`, `engineering/standards/sysvars.py`, the two `drawing_properties_*` implementations on both backends (inside a `# ── settings (track E, group C) ──` block each), the `system_get_variable` / `system_set_variable` methods of both backends (the sysvar path is this group's domain; no other group's task touches them), `server.py`'s `system_variable_describe` / `drawing_properties_*` under the SECTION 20 stub, the `system_set_variable` tool's catalogue check and the `drawing_settings` tool docstring, its own alias records, and `tests/test_settings_facade.py`, `tests/test_sysvar_catalog.py`, `tests/test_drawing_properties.py`. + +**Measured before these tasks were written (ezdxf 1.4.4, `uv run --frozen python`, 2026-09-16) — every decision below follows from one of these:** + +1. `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError: Invalid header variable $GRIDMODE.` — the same for `$GRIDUNIT`, `$SNAPMODE`, `$SNAPUNIT`, `$POLARMODE`, `$POLARANG`, `$CANNOSCALE`, `$CANNOSCALEVALUE`, `$CTAB`, `$VIEWCTR`, `$VIEWSIZE`, `$HPNAME`, `$FILEDIA`, `$XREFCTL`, `$ISOLINES`, `$FACETRES`. None of them is a DXF HEADER variable. Today `system_set_variable("GRIDMODE", 1)` therefore raises that cryptic error headlessly and `system_get_variable("GRIDMODE")` silently returns `None`. +2. Grid and snap live on the active VPORT table entry (`doc.viewports.get("*Active")[0].dxf.grid_on / grid_spacing / snap_on / snap_spacing`, DXF codes 76 / 15 / 75 / 14) and round-trip through save + reopen on an R2010 document. +3. `CANNOSCALE` is a `DICTIONARYVAR` in the root dictionary's `AcDbVariableDictionary` (ezdxf's own `sections/header.py` says so); a fresh ezdxf document has neither the dictionary nor any `ACAD_SCALELIST` entries, and ezdxf has no `SCALE` entity class — but a raw-tag `SCALE` object loaded through `ezdxf.entities.factory.load` round-trips, ezdxf registers its CLASS, and `doc.audit()` is clean after reopen. `CANNOSCALEVALUE` is derived (read-only in AutoCAD too). +4. A `$LIMMAX` written to the header is overwritten at save: `ezdxf.document.Drawing.update_limits()` copies the model-space LAYOUT's `limmin` / `limmax` into the header on every `write()`. `system_set_variable("LIMMAX", (500, 350))` reported `ok: True` today and the file reopened at `(420, 297)`. +5. `$OSMODE`, `$ATTREQ`, `$ATTDIA` are capped at `maxdxf="AC1009"` in `HEADER_VAR_MAP`: ezdxf holds them in memory but never exports them for R2000+ files. +6. `POLARANG`, `POLARMODE`, `AUTOSNAP`, `ATTREQ`, `ATTDIA`, `SAVETIME`, `FILEDIA`, `XREFCTL`, `PROXYSHOW` are registry-saved and `CMDECHO`, `HIGHLIGHT`, `HPNAME`, `HPSCALE`, `HPANG`, `DWGNAME`, `DWGPREFIX` are never saved (AutoCAD System Variables reference, "Saved in" column): a headless file has nowhere to keep them. Polar tracking's on/off switch is **AUTOSNAP bit 8** (the F10 toggle); POLARMODE bit 0 means "measure polar angles relative to the last segment". The spec's `$POLARMODE bit 0` wording is corrected here. +7. `engineering/standards/` does not exist in this worktree (group S creates `__init__.py`); `import engineering.standards.sysvars` works as a namespace package without it — verified with a probe module — so Task 15 does **not** create `__init__.py`. +8. `ruff format --check .` formats Python fenced blocks inside Markdown (ruff 0.16.1), so this file itself is run through `uv run --frozen ruff format docs/superpowers/plans/_settings_part_C.md` before it is committed. + +--- + +### Task 14: `drawing_settings` grows 14 keys, on both engines, with honest headless storage + +**Files:** +- Modify: `backends/contracts/settings.py` (rewritten in full below — the facade gains `_read_setting` / `_write_setting`, `parse_scale` / `scale_value`, the new kinds and ranges) +- Modify: `backends/ezdxf_backend.py` (`system_get_variable` / `system_set_variable` replaced in place; `_VPORT_SYSVARS` / `_UNSAVED_SYSVARS` constants; a `# ── settings (track E, group C) ──` helper block **at the very end of the class**, after `_create_xline` — group C's reserved anchor, see the skeleton's Merge notes; `registry_sysvar` in `capabilities()`) +- Modify: `backends/com_backend.py` (`system_set_variable` learns to send point variables as VARIANT double arrays; `registry_sysvar` in `capabilities()`) +- Modify: `server.py` (`drawing_settings` tool: parameter description + docstring name the new keys and the refusals) +- Modify: `discovery/aliases.py` (`drawing_settings` record grows: `LIMITS`, `GRID`, `SNAP`, `ORTHO`, `PSLTSCALE`, `CANNOSCALE` + synonyms) +- Test: `tests/test_settings_facade.py`; `tests/test_drawing_settings.py` and `tests/test_dimension_header_vars.py` must stay green + +**Interfaces:** +- Consumes: `system_get_variable(name)` / `system_set_variable(name, value)` on both backends; `dimstyle_set_current(name)` / `textstyle_set_current(name)` from group S **when present** (`callable(getattr(self, ...))`), otherwise the key is refused by name. +- Produces: + - `_SETTING_MAP` gains `limits` (`"limits"` → `LIMMIN` + `LIMMAX`), `grid` (`"bool"` → GRIDMODE), `grid_spacing` (`"spacing"` → GRIDUNIT, both axes), `snap` (`"bool"` → SNAPMODE), `snap_spacing` (`"spacing"` → SNAPUNIT), `ortho` (`"bool"` → ORTHOMODE), `polar` (`"bit"` → AUTOSNAP bit 8), `polar_angle` (`"degrees"` → POLARANG in radians), `psltscale` (`"bool"` → PSLTSCALE), `annotation_scale` (`"scale"` → CANNOSCALE `"1:50"`; the value 0.02 is derived), `linear_units` (`"lunits"` enum → LUNITS 1–5), `angular_units` (`"aunits"` enum → AUNITS 0–4), `dimstyle` / `textstyle` (`"name"` → `dimstyle_set_current` / `textstyle_set_current`; read through DIMSTYLE / TEXTSTYLE). + - `drawing_settings(dict)` → `{"ok", "applied", "changed": {key: [old, new]}, "errors"?}`; `drawing_settings()` → `{"ok": True, "settings": {every key}}` where a key the engine cannot hold reads `None`. + - `parse_scale(text) -> (name, paper, drawing)` and `scale_value(text) -> float` (module-level, imported by the ezdxf backend). + - `EzdxfBackend.system_set_variable`: GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT → active VPORT; LIMMIN / LIMMAX → header **and** model-space layout; CANNOSCALE → `AcDbVariableDictionary` + an `ACAD_SCALELIST` SCALE entry; registry / never-saved names → `UnsupportedCapabilityError("registry_sysvar", …)` before any write; an unknown header name → `ValueError` carrying ezdxf's message (keeps the pinned `$$DIMTXT` behaviour). `system_get_variable` mirrors the reads (`CANNOSCALE` defaults to `"1:1"`, `CANNOSCALEVALUE` is computed). + - Capability key `registry_sysvar` in both maps (ezdxf `False`, COM `True`). +- Pack / lean: `drawing_settings` is an existing core tool; unchanged. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_settings_facade.py +"""drawing_settings, track E: limits, grid/snap, ortho/polar, PSLTSCALE, annotation +scale, unit formats and the current styles — on both engines. + +Measured before writing (ezdxf 1.4.4, 2026-09-16): + +* `doc.header["$GRIDMODE"]` (and GRIDUNIT / SNAPMODE / SNAPUNIT / POLARMODE / + POLARANG / CANNOSCALE / CANNOSCALEVALUE) raises `DXFKeyError: Invalid header + variable` — none of them is a DXF header variable. Grid and snap live on the + active VPORT; CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary; the + polar pair is registry-saved and has no home in a file at all. +* A `$LIMMAX` written to the header is overwritten at save by + `Drawing.update_limits()`, which copies the model-space LAYOUT's limits into + the header — so a header-only write reported success and vanished on reload. +""" + +from __future__ import annotations + +import math + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.contracts.settings import _SETTING_MAP, parse_scale, scale_value + +pytestmark = pytest.mark.asyncio + +NEW_KEYS = ( + "limits", + "grid", + "grid_spacing", + "snap", + "snap_spacing", + "ortho", + "polar", + "polar_angle", + "psltscale", + "annotation_scale", + "linear_units", + "angular_units", + "dimstyle", + "textstyle", +) + + +def _active_vport(backend): + return backend._doc.viewports.get("*Active")[0] + + +# ── pure helpers ──────────────────────────────────────────────────────────── + + +def test_parse_scale_canonicalises_and_refuses(): + assert parse_scale("1:50") == ("1:50", 1.0, 50.0) + assert parse_scale(" 2 : 1 ") == ("2:1", 2.0, 1.0) + assert parse_scale("1:2.5") == ("1:2.5", 1.0, 2.5) + assert scale_value("1:50") == pytest.approx(0.02) + for bad in ("fifty", "1:0", "0:1", "1-50", "", True): + with pytest.raises(ValueError, match="annotation_scale"): + parse_scale(bad) + + +def test_every_new_key_is_in_the_map(): + for key in NEW_KEYS: + assert key in _SETTING_MAP, key + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_snapshot_reports_every_key_including_the_unavailable_ones(backend): + snap = (await backend.drawing_settings())["settings"] + for key in NEW_KEYS: + assert key in snap, key + assert snap["limits"] == [[0.0, 0.0], [420.0, 297.0]] + assert snap["grid"] is False and snap["snap"] is False and snap["ortho"] is False + assert snap["psltscale"] is True + assert snap["annotation_scale"] == {"name": "1:1", "value": 1.0} + assert snap["linear_units"] == {"code": 2, "name": "decimal"} + assert snap["angular_units"] == {"code": 0, "name": "degrees"} + assert snap["dimstyle"] == "ISO-25" + assert snap["textstyle"] == "Standard" + # registry-saved: a file holds no value, and None says so rather than a guess + assert snap["polar"] is None + assert snap["polar_angle"] is None + + +async def test_limits_survive_a_save_and_reopen(backend, tmp_path): + res = await backend.drawing_settings({"limits": [[0, 0], [500, 350]]}) + assert res["ok"] is True, res.get("errors") + assert res["changed"]["limits"] == [ + [[0.0, 0.0], [420.0, 297.0]], + [[0.0, 0.0], [500.0, 350.0]], + ] + layout = backend._doc.modelspace().dxf_layout.dxf + assert tuple(layout.limmax)[:2] == (500.0, 350.0), "the layout is what survives write()" + + path = str(tmp_path / "limits.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_settings())["settings"]["limits"] == [ + [0.0, 0.0], + [500.0, 350.0], + ] + + +async def test_grid_and_snap_live_on_the_active_vport(backend, tmp_path): + res = await backend.drawing_settings( + {"grid": True, "grid_spacing": 5, "snap": "on", "snap_spacing": [2.5, 5]} + ) + assert res["ok"] is True, res.get("errors") + vport = _active_vport(backend).dxf + assert vport.grid_on == 1 and vport.snap_on == 1 + assert tuple(vport.grid_spacing)[:2] == (5.0, 5.0) + assert tuple(vport.snap_spacing)[:2] == (2.5, 5.0) + + snap = (await backend.drawing_settings())["settings"] + assert snap["grid"] is True and snap["grid_spacing"] == 5.0 + assert snap["snap"] is True and snap["snap_spacing"] == [2.5, 5.0] + + path = str(tmp_path / "grid.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + again = (await backend.drawing_settings())["settings"] + assert again["grid"] is True and again["grid_spacing"] == 5.0 + assert again["snap_spacing"] == [2.5, 5.0] + + +async def test_ortho_and_psltscale_are_header_variables(backend): + res = await backend.drawing_settings({"ortho": True, "psltscale": False}) + assert res["ok"] is True, res.get("errors") + assert backend._doc.header["$ORTHOMODE"] == 1 + assert backend._doc.header["$PSLTSCALE"] == 0 + snap = (await backend.drawing_settings())["settings"] + assert snap["ortho"] is True and snap["psltscale"] is False + + +async def test_polar_is_refused_headlessly_by_capability(backend): + res = await backend.drawing_settings({"polar": True, "polar_angle": 45, "ortho": True}) + assert res["ok"] is False + assert res["applied"] == {"ortho": True}, "the key that has a home is still applied" + assert "registry" in res["errors"]["polar"] + assert "registry" in res["errors"]["polar_angle"] + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.system_set_variable("POLARANG", 0.5) + assert excinfo.value.capability == "registry_sysvar" + + +async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(backend, tmp_path): + res = await backend.drawing_settings({"annotation_scale": "1:50"}) + assert res["ok"] is True, res.get("errors") + assert res["changed"]["annotation_scale"] == [ + {"name": "1:1", "value": 1.0}, + {"name": "1:50", "value": 0.02}, + ] + vardict = backend._doc.rootdict.get("AcDbVariableDictionary") + assert vardict["CANNOSCALE"].dxf.value == "1:50" + scales = backend._doc.rootdict.get("ACAD_SCALELIST") + assert scales.get("1:50") is not None, "AutoCAD only honours a scale that is in the list" + assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02) + + # re-setting the same scale is not a change and adds no second list entry + again = await backend.drawing_settings({"annotation_scale": "1:50"}) + assert again["changed"] == {} + assert len(list(scales.keys())) == 1 + + path = str(tmp_path / "scale.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_settings())["settings"]["annotation_scale"] == { + "name": "1:50", + "value": 0.02, + } + assert backend._doc.audit().has_errors is False + + +async def test_unit_formats_by_name_and_by_code(backend): + res = await backend.drawing_settings({"linear_units": "architectural", "angular_units": "dms"}) + assert res["ok"] is True, res.get("errors") + assert backend._doc.header["$LUNITS"] == 4 + assert backend._doc.header["$AUNITS"] == 1 + snap = (await backend.drawing_settings())["settings"] + assert snap["linear_units"] == {"code": 4, "name": "architectural"} + assert snap["angular_units"] == {"code": 1, "name": "dms"} + + by_code = await backend.drawing_settings({"linear_units": 1, "angular_units": 3}) + assert by_code["ok"] is True + assert backend._doc.header["$LUNITS"] == 1 and backend._doc.header["$AUNITS"] == 3 + + bad = await backend.drawing_settings({"linear_units": "furlongs", "angular_units": 9}) + assert "architectural" in bad["errors"]["linear_units"] + assert "angular_units" in bad["errors"]["angular_units"] + assert backend._doc.header["$LUNITS"] == 1, "a refused value writes nothing" + + +async def test_current_styles_delegate_to_the_styles_contract(backend, monkeypatch): + calls: list[tuple[str, str]] = [] + + async def _dimstyle(name): + calls.append(("dimstyle_set_current", name)) + backend._doc.header["$DIMSTYLE"] = name + return {"ok": True, "current": name, "previous": "ISO-25"} + + async def _textstyle(name): + calls.append(("textstyle_set_current", name)) + backend._doc.header["$TEXTSTYLE"] = name + return {"ok": True, "current": name, "previous": "Standard"} + + monkeypatch.setattr(backend, "dimstyle_set_current", _dimstyle, raising=False) + monkeypatch.setattr(backend, "textstyle_set_current", _textstyle, raising=False) + res = await backend.drawing_settings({"dimstyle": " ANSI ", "textstyle": "ISOCP"}) + assert res["ok"] is True, res.get("errors") + assert calls == [("dimstyle_set_current", "ANSI"), ("textstyle_set_current", "ISOCP")] + assert res["changed"]["dimstyle"] == ["ISO-25", "ANSI"] + snap = (await backend.drawing_settings())["settings"] + assert snap["dimstyle"] == "ANSI" and snap["textstyle"] == "ISOCP" + + +async def test_current_styles_are_refused_when_no_styles_contract_exists(backend, monkeypatch): + # Group S may or may not have merged: force the "absent" branch either way. + monkeypatch.setattr(backend, "dimstyle_set_current", None, raising=False) + res = await backend.drawing_settings({"dimstyle": "ANSI"}) + assert res["ok"] is False + assert "dimstyle_set_current" in res["errors"]["dimstyle"] + assert (await backend.drawing_settings())["settings"]["dimstyle"] == "ISO-25" + + +async def test_changed_reports_only_values_that_moved(backend): + same = await backend.drawing_settings({"psltscale": True, "ortho": False}) + assert same["ok"] is True and same["changed"] == {} + moved = await backend.drawing_settings({"psltscale": False, "ortho": False}) + assert moved["changed"] == {"psltscale": [True, False]} + + +async def test_malformed_values_are_refused_before_any_write(backend): + before_limmax = tuple(backend._doc.header["$LIMMAX"]) + res = await backend.drawing_settings( + { + "limits": [[0, 0], [0, 5]], + "annotation_scale": "fifty", + "grid_spacing": 0, + "snap_spacing": [1, 2, 3], + "polar": "maybe", + "dimstyle": "", + } + ) + assert res["ok"] is False and res["applied"] == {} + assert set(res["errors"]) == { + "limits", + "annotation_scale", + "grid_spacing", + "snap_spacing", + "polar", + "dimstyle", + } + assert "upper-right" in res["errors"]["limits"] + assert "paper:drawing" in res["errors"]["annotation_scale"] + assert "greater than 0" in res["errors"]["grid_spacing"] + assert tuple(backend._doc.header["$LIMMAX"]) == before_limmax + assert backend._doc.rootdict.get("AcDbVariableDictionary") is None + assert tuple(_active_vport(backend).dxf.grid_spacing)[:2] != (0.0, 0.0) + + +async def test_raw_gridmode_routes_to_the_vport_and_the_old_error_is_gone(backend): + assert await backend.system_get_variable("GRIDMODE") == 0 + out = await backend.system_set_variable("GRIDMODE", 1) + assert out["ok"] is True + assert _active_vport(backend).dxf.grid_on == 1 + assert await backend.system_get_variable("GRIDMODE") == 1 + + +async def test_raw_unknown_header_name_is_a_named_value_error(backend): + with pytest.raises(ValueError, match=r"\$HPNAME2"): + await backend.system_set_variable("HPNAME2", "x") + + +async def test_cannoscalevalue_is_read_only(backend): + with pytest.raises(ValueError, match="CANNOSCALEVALUE is read-only"): + await backend.system_set_variable("CANNOSCALEVALUE", 0.5) + + +def test_both_capability_maps_declare_registry_sysvar(): + from backends.com_backend import ComBackend + from backends.ezdxf_backend import EzdxfBackend + + ezdxf_features = EzdxfBackend().capabilities().to_dict()["features"] + com_features = ComBackend().capabilities().to_dict()["features"] + assert ezdxf_features["registry_sysvar"]["supported"] is False + assert com_features["registry_sysvar"]["supported"] is True + + +# ── server tool wiring ────────────────────────────────────────────────────── + + +class _FakeCtx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *a, **k): + pass + + +async def test_server_tool_carries_the_new_keys(backend): + import server + + ctx = _FakeCtx(backend) + write = await server.drawing_settings( + settings={"limits": [[0, 0], [297, 210]], "grid": True, "annotation_scale": "1:2"}, + ctx=ctx, + ) + assert write["ok"] is True, write.get("errors") + read = await server.drawing_settings(ctx=ctx) + assert read["settings"]["limits"] == [[0.0, 0.0], [297.0, 210.0]] + assert read["settings"]["annotation_scale"] == {"name": "1:2", "value": 0.5} + + +# ── live engine, against a fake ActiveX application ───────────────────────── + + +class _FakeApp: + """Records GetVariable / SetVariable; stores what AutoCAD would store.""" + + def __init__(self): + self.store = { + "INSUNITS": 4, + "LIMMIN": (0.0, 0.0), + "LIMMAX": (420.0, 297.0), + "GRIDMODE": 0, + "GRIDUNIT": (10.0, 10.0), + "SNAPMODE": 0, + "SNAPUNIT": (10.0, 10.0), + "ORTHOMODE": 0, + "AUTOSNAP": 63, + "POLARANG": math.pi / 2, + "PSLTSCALE": 1, + "CANNOSCALE": "1:1", + "LUNITS": 2, + "AUNITS": 0, + "DIMSTYLE": "ISO-25", + "TEXTSTYLE": "Standard", + "DIMSCALE": 1.0, + } + self.calls: list[tuple] = [] + + def GetVariable(self, name): + self.calls.append(("GetVariable", name)) + return self.store[name] + + def SetVariable(self, name, value): + stored = getattr(value, "value", value) # a VARIANT array → its Python sequence + if isinstance(stored, list): + stored = tuple(stored) + self.calls.append(("SetVariable", name, stored)) + self.store[name] = stored + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + app = _FakeApp() + monkeypatch.setattr(module, "_acad_app", lambda: app) + 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_facade_sends_autocad_shaped_values(com_backend): + backend, app = com_backend + res = await backend.drawing_settings( + { + "limits": [[0, 0], [500, 350]], + "grid": True, + "grid_spacing": 5, + "polar": False, + "polar_angle": 45, + "annotation_scale": "1:50", + "linear_units": "architectural", + "psltscale": 0, + } + ) + assert res["ok"] is True, res.get("errors") + sets = [call[1:] for call in app.calls if call[0] == "SetVariable"] + assert sets == [ + ("LIMMIN", (0.0, 0.0)), + ("LIMMAX", (500.0, 350.0)), + ("GRIDMODE", 1), + ("GRIDUNIT", (5.0, 5.0)), + ("AUTOSNAP", 55), # 63 with bit 8 cleared: polar off, everything else kept + ("POLARANG", pytest.approx(math.radians(45))), + ("CANNOSCALE", "1:50"), + ("LUNITS", 4), + ("PSLTSCALE", 0), + ] + assert res["changed"]["polar"] == [True, False] + assert res["changed"]["polar_angle"] == [90.0, 45.0] + + +async def test_com_point_variables_travel_as_variant_double_arrays(com_backend): + import pythoncom + + backend, app = com_backend + seen: dict = {} + real_set = app.SetVariable + + def _capture(name, value): + seen[name] = value + real_set(name, value) + + app.SetVariable = _capture + await backend.system_set_variable("LIMMAX", (300, 200)) + variant = seen["LIMMAX"] + assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8 + assert tuple(variant.value) == (300.0, 200.0) + + +async def test_com_refuses_before_touching_activex(com_backend): + backend, app = com_backend + res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"}) + assert res["ok"] is False + assert not [call for call in app.calls if call[0] == "SetVariable"] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_settings_facade.py -v` +Expected: collection ERROR — `ImportError: cannot import name 'parse_scale' from 'backends.contracts.settings'`. + +- [ ] **Step 3: Rewrite `backends/contracts/settings.py`** + +Replace the whole module with: + +```python +"""Friendly facade over system variables. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. + +v1.6 (track E) grows the facade from 15 keys to 29: drawing limits, grid and +snap, ortho and polar tracking, PSLTSCALE, the annotation scale, linear and +angular unit formats, and the current dimension / text style. Every key still +goes through ``system_get_variable`` / ``system_set_variable`` so the facade +stays backend-agnostic; the *engines* are where a variable's real home is +known (header, VPORT, the variable dictionary, or — headlessly — nowhere). +""" + +from __future__ import annotations + +import math +import re +from typing import Any + + +class SettingsContract: + # A user-facing wrapper over system_get_variable / system_set_variable that + # maps memorable names ("units", "dimscale", …) to AutoCAD system variables. + # Concrete + backend-agnostic: both engines accept the bare sysvar name, so + # the same call reads/writes on live COM and headless ezdxf alike. + + async def drawing_settings(self, settings: dict | None = None) -> dict: + """Read (no args) or apply (with args) common drawing settings. + + With ``settings=None`` returns a snapshot of every known setting. With a + dict, applies each provided key and returns ``{applied, changed, + errors}`` — ``changed`` holds ``[old, new]`` only for keys whose value + actually moved (re-setting a value to itself is not a change). + + Friendly keys: units, linear_precision, angular_precision, ltscale, + dimscale, dim_text_height, dim_arrow_size, dim_decimals, + decimal_separator, zero_suppression, text_size, point_mode, point_size, + osmode, fillet_radius, limits, grid, grid_spacing, snap, snap_spacing, + ortho, polar, polar_angle, psltscale, annotation_scale, linear_units, + angular_units, dimstyle, textstyle. + + Refusals (per key, in ``errors``; the other keys still apply): an + unknown key; a value outside ``_SETTING_RANGES``; a malformed + ``limits`` / ``annotation_scale``; ``polar`` / ``polar_angle`` on the + headless engine (registry-saved — ``capability: registry_sysvar``); + ``dimstyle`` / ``textstyle`` when the backend has no styles contract. + + Note that a no-argument snapshot is one ``system_get_variable`` per key + (two for ``limits``). Free headless; a COM round trip each on a live + seat, and a write costs a read before and after it for ``changed``.""" + if not settings: + snapshot: dict = {} + for key, spec in _SETTING_MAP.items(): + try: + snapshot[key] = await self._read_setting(key, spec) + except Exception as exc: + snapshot[key] = {"error": str(exc)} + return {"ok": True, "settings": snapshot} + + applied: dict = {} + changed: dict = {} + errors: dict = {} + for key, value in settings.items(): + spec = _SETTING_MAP.get(key) + if spec is None: + errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})" + continue + try: + before = await self._read_setting(key, spec) + await self._write_setting(key, spec, value) + after = await self._read_setting(key, spec) + applied[key] = value + if after != before: + changed[key] = [before, after] + except Exception as exc: + errors[key] = str(exc) + + result = {"ok": not errors, "applied": applied, "changed": changed} + if errors: + result["errors"] = errors + return result + + async def _read_setting(self, key: str, spec: tuple[str, str]) -> Any: + var, kind = spec + if kind == "limits": + low = await self.system_get_variable("LIMMIN") + high = await self.system_get_variable("LIMMAX") + if low is None or high is None: + return None + return [_point2(low), _point2(high)] + return _decode_setting(key, kind, await self.system_get_variable(var)) + + async def _write_setting(self, key: str, spec: tuple[str, str], value: Any) -> None: + var, kind = spec + if kind == "limits": + low, high = _encode_limits(key, value) # validated before either write + await self.system_set_variable("LIMMIN", low) + await self.system_set_variable("LIMMAX", high) + return + if kind == "name": + name = _encode_name(key, value) + setter = getattr(self, _STYLE_SETTERS[key], None) + if not callable(setter): + raise ValueError( + f"{key}: this backend has no {_STYLE_SETTERS[key]} (styles contract " + f"absent); the current {key} can be read but not changed here." + ) + await setter(name) + return + if kind == "bit": + flag = _encode_bool(key, value) + raw = await self.system_get_variable(var) + current = int(raw) if raw is not None else 0 + mask = _BIT_MASKS[key] + await self.system_set_variable(var, (current | mask) if flag else (current & ~mask)) + return + await self.system_set_variable(var, _encode_setting(key, kind, value)) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +# Friendly setting name -> (AutoCAD system variable, value kind). +_SETTING_MAP: dict[str, tuple[str, str]] = { + "units": ("INSUNITS", "units"), + "linear_precision": ("LUPREC", "int"), + "angular_precision": ("AUPREC", "int"), + "ltscale": ("LTSCALE", "float"), + "dimscale": ("DIMSCALE", "float"), + # The dimension variables had no friendly name until v1.5.2, so the only + # way to reach dimension lettering was a raw `system_set_variable` — which + # is precisely the path that silently did nothing on the headless engine. + # `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has + # never had anything to do with the numbers on a dimension. + "dim_text_height": ("DIMTXT", "float"), + "dim_arrow_size": ("DIMASZ", "float"), + "dim_decimals": ("DIMDEC", "int"), + "decimal_separator": ("DIMDSEP", "char"), + "zero_suppression": ("DIMZIN", "int"), + "text_size": ("TEXTSIZE", "float"), + "point_mode": ("PDMODE", "int"), + "point_size": ("PDSIZE", "float"), + "osmode": ("OSMODE", "int"), + "fillet_radius": ("FILLETRAD", "float"), + # ── track E: the environment a drawing lives in ────────────────────── + # `limits` is one friendly key over two variables; `_read_setting` and + # `_write_setting` special-case the kind, the variable named here is the + # first of the pair. + "limits": ("LIMMIN", "limits"), + # GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT are not header variables in a + # DXF file — AutoCAD keeps them on the active VPORT — so the headless engine + # routes them there (see `EzdxfBackend.system_set_variable`). + "grid": ("GRIDMODE", "bool"), + "grid_spacing": ("GRIDUNIT", "spacing"), + "snap": ("SNAPMODE", "bool"), + "snap_spacing": ("SNAPUNIT", "spacing"), + "ortho": ("ORTHOMODE", "bool"), + # Polar tracking on/off is AUTOSNAP bit 8 (the F10 toggle), not POLARMODE + # — POLARMODE's bits choose *how* polar angles are measured. Both AUTOSNAP + # and POLARANG are registry-saved, so a headless file cannot hold them. + "polar": ("AUTOSNAP", "bit"), + "polar_angle": ("POLARANG", "degrees"), + "psltscale": ("PSLTSCALE", "bool"), + # CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary, not a header + # variable; CANNOSCALEVALUE is derived from it and read-only. + "annotation_scale": ("CANNOSCALE", "scale"), + "linear_units": ("LUNITS", "lunits"), + "angular_units": ("AUNITS", "aunits"), + # The current style names read through the header / GetVariable; a write + # delegates to the styles contract (`dimstyle_set_current` / + # `textstyle_set_current`) because DIMSTYLE is a read-only variable. + "dimstyle": ("DIMSTYLE", "name"), + "textstyle": ("TEXTSTYLE", "name"), +} + +_STYLE_SETTERS = {"dimstyle": "dimstyle_set_current", "textstyle": "textstyle_set_current"} + +#: AUTOSNAP bit 8 = polar tracking on. (1 marker, 2 magnet, 4 tooltip, 16 +#: object snap tracking, 32 tracking tooltips, 64 dynamic input aperture.) +_BIT_MASKS = {"polar": 8} + +# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code. +_UNIT_TO_CODE: dict[str, int] = { + "unitless": 0, + "inch": 1, + "inches": 1, + "in": 1, + "feet": 2, + "ft": 2, + "foot": 2, + "mm": 4, + "millimeter": 4, + "millimeters": 4, + "cm": 5, + "centimeter": 5, + "centimeters": 5, + "m": 6, + "meter": 6, + "meters": 6, +} +_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"} + +# LUNITS (linear unit format) and AUNITS (angular unit format) code tables. +_LUNITS_TO_CODE: dict[str, int] = { + "scientific": 1, + "decimal": 2, + "engineering": 3, + "architectural": 4, + "fractional": 5, +} +_CODE_TO_LUNITS: dict[int, str] = {code: name for name, code in _LUNITS_TO_CODE.items()} +_AUNITS_TO_CODE: dict[str, int] = { + "degrees": 0, + "dms": 1, + "grads": 2, + "radians": 3, + "surveyor": 4, +} +_CODE_TO_AUNITS: dict[int, str] = {code: name for name, code in _AUNITS_TO_CODE.items()} + +#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError +#: on a string in the header — so the friendly key is where the translation +#: belongs. Only two markers are in real use: ISO 129's point and the comma most +#: of continental Europe drafts with. Anything else lands an arbitrary glyph in +#: the middle of every number on the sheet, so it is refused rather than stored. +_DECIMAL_MARKERS = (".", ",") + +#: "1:50", "2:1", "1:2.5" — paper units : drawing units, both strictly positive. +SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$") + + +def parse_scale(text: Any) -> tuple[str, float, float]: + """``"1:50"`` → ``("1:50", 1.0, 50.0)``; raises ``ValueError`` naming the input. + + Returns the canonical name (whitespace stripped, integers without a + trailing ``.0``), the paper units and the drawing units. The annotation + scale value AutoCAD reports as CANNOSCALEVALUE is ``paper / drawing``. + """ + match = SCALE_RE.match(str(text)) if not isinstance(text, bool) else None + if match is None: + raise ValueError( + f"annotation_scale: {text!r} is not a scale — use 'paper:drawing' such as " + "'1:1', '1:50' or '2:1'." + ) + paper, drawing = float(match.group(1)), float(match.group(2)) + if paper <= 0 or drawing <= 0: + raise ValueError(f"annotation_scale: both sides of {text!r} must be greater than 0.") + return f"{_trim(paper)}:{_trim(drawing)}", paper, drawing + + +def scale_value(text: Any) -> float: + """CANNOSCALEVALUE for a scale name: ``"1:50"`` → ``0.02``.""" + _, paper, drawing = parse_scale(text) + return paper / drawing + + +def _trim(number: float) -> str: + return str(int(number)) if number == int(number) else f"{number:g}" + + +def _point2(raw: Any) -> list[float]: + """A 2D point from whatever the engine returned (tuple, list, Vec2/Vec3).""" + try: + return [float(raw[0]), float(raw[1])] + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"expected a point, got {raw!r}") from exc + + +def _finite(key: str, value: Any, what: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{key}: {what} must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {what} must be finite") + return number + + +def _encode_limits(key: str, value: Any) -> tuple[tuple[float, float], tuple[float, float]]: + """``[[xmin, ymin], [xmax, ymax]]`` → two 2D tuples; refused before any write.""" + if ( + not isinstance(value, (list, tuple)) + or len(value) != 2 + or any(not isinstance(corner, (list, tuple)) or len(corner) != 2 for corner in value) + ): + raise ValueError(f"{key}: expected [[xmin, ymin], [xmax, ymax]], got {value!r}") + xmin = _finite(key, value[0][0], "xmin") + ymin = _finite(key, value[0][1], "ymin") + xmax = _finite(key, value[1][0], "xmax") + ymax = _finite(key, value[1][1], "ymax") + if xmax <= xmin or ymax <= ymin: + raise ValueError( + f"{key}: the upper-right corner must be above and right of the lower-left " + f"(got {[[xmin, ymin], [xmax, ymax]]})" + ) + return (xmin, ymin), (xmax, ymax) + + +_TRUE_WORDS = {"1", "on", "true", "yes"} +_FALSE_WORDS = {"0", "off", "false", "no"} + + +def _encode_bool(key: str, value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value) + if isinstance(value, str): + word = value.strip().lower() + if word in _TRUE_WORDS: + return True + if word in _FALSE_WORDS: + return False + raise ValueError(f"{key}: expected true/false (or 1/0, on/off), got {value!r}") + + +def _encode_name(key: str, value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key}: expected a non-empty style name, got {value!r}") + return value.strip() + + +def _encode_enum(key: str, value: Any, table: dict[str, int]) -> int: + if isinstance(value, (int, float)) and not isinstance(value, bool): + code = int(value) + if code in table.values(): + return code + raise ValueError(f"{key}: code {code} is not one of {sorted(table.values())}") + name = str(value).strip().lower() + if name in table: + return table[name] + raise ValueError(f"{key}: unknown value {value!r}. Use one of {sorted(table)}.") + + +def _encode_setting(key: str, kind: str, value: Any) -> Any: + """Coerce a friendly value into the raw system-variable value.""" + if kind == "units": + if isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) + code = _UNIT_TO_CODE.get(str(value).strip().lower()) + if code is None: + raise ValueError( + f"units: unknown value {value!r}. Use one of " + f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer." + ) + return code + if kind == "lunits": + return _encode_enum(key, value, _LUNITS_TO_CODE) + if kind == "aunits": + return _encode_enum(key, value, _AUNITS_TO_CODE) + if kind == "bool": + return 1 if _encode_bool(key, value) else 0 + if kind == "scale": + name, _paper, _drawing = parse_scale(value) + return name + if kind == "spacing": + if isinstance(value, (list, tuple)): + if len(value) != 2: + raise ValueError(f"{key}: expected a number or [x, y], got {value!r}") + x = _in_range(key, _finite(key, value[0], "x")) + y = _in_range(key, _finite(key, value[1], "y")) + return (x, y) + step = _in_range(key, _finite(key, value, "spacing")) + return (step, step) + if kind == "degrees": + degrees = _in_range(key, _finite(key, value, "angle")) + return math.radians(degrees) + if kind == "char": + if isinstance(value, bool): + marker = "" + elif isinstance(value, (int, float)): + code = int(value) + marker = chr(code) if 0 < code < 0x110000 else "" + else: + marker = str(value).strip() + if marker not in _DECIMAL_MARKERS: + raise ValueError( + f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}." + ) + return ord(marker) + if kind == "int": + return _in_range(key, int(float(value))) + if kind == "float": + return _in_range(key, float(value)) + return value + + +#: AutoCAD's own ranges for the settings that now reach the renderer. +#: +#: Before the DIM* header fold, an out-of-range value died harmlessly in the +#: header. Folding it into the per-dimension override is what gave it teeth: +#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension +#: with `ValueError: Format specifier missing precision` raised from inside +#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40` +#: printed forty digits of float noise onto the sheet; and a non-positive +#: length was reported applied and silently discarded. `applied` has to mean +#: applied. +_SETTING_RANGES: dict[str, tuple[float, float]] = { + "dim_decimals": (0, 8), # DIMDEC + "zero_suppression": (0, 15), # DIMZIN is a bitmask + "linear_precision": (0, 8), # LUPREC + "angular_precision": (0, 8), # AUPREC + "dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive + "dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive + "text_size": (1e-9, 1e6), # TEXTSIZE + "dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal + "ltscale": (1e-9, 1e6), + "point_size": (-100, 1e6), # negative is a percentage of the screen + "fillet_radius": (0, 1e6), + # track E + "grid_spacing": (1e-9, 1e9), # GRIDUNIT, strictly positive per axis + "snap_spacing": (1e-9, 1e9), # SNAPUNIT, strictly positive per axis + "polar_angle": (1e-9, 360), # POLARANG increment, degrees at the facade +} + + +def _in_range(key: str, value: float) -> float: + """Refuse a value the renderer cannot use, naming the range.""" + bounds = _SETTING_RANGES.get(key) + if bounds is None: + return value + low, high = bounds + if not low <= value <= high: + low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}" + raise ValueError( + f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}." + ) + return value + + +def _decode_setting(key: str, kind: str, raw: Any) -> Any: + """Present a raw system-variable value in friendly form.""" + if raw is None: + return None + if kind == "units": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")} + if kind == "lunits": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + return {"code": code, "name": _CODE_TO_LUNITS.get(code, "unknown")} + if kind == "aunits": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + return {"code": code, "name": _CODE_TO_AUNITS.get(code, "unknown")} + if kind == "bool": + try: + return bool(int(raw)) + except (TypeError, ValueError): + return raw + if kind == "bit": + try: + return bool(int(raw) & _BIT_MASKS[key]) + except (TypeError, ValueError): + return raw + if kind == "degrees": + try: + return round(math.degrees(float(raw)), 6) + except (TypeError, ValueError): + return raw + if kind == "spacing": + try: + x, y = _point2(raw) + except ValueError: + return raw + return x if x == y else [x, y] + if kind == "scale": + try: + name, paper, drawing = parse_scale(raw) + except ValueError: + return {"name": str(raw), "value": None} + return {"name": name, "value": paper / drawing} + if kind == "name": + return str(raw) + if kind == "char": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + # ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset", + # so report the marker that would actually be drawn. + return "," if code == 0 else chr(code) + if kind == "int": + try: + return int(raw) + except (TypeError, ValueError): + return raw + if kind == "float": + try: + return float(raw) + except (TypeError, ValueError): + return raw + return raw +``` + +- [ ] **Step 4: Teach the ezdxf backend where each variable really lives** + +In `backends/ezdxf_backend.py`, add between the `from .base import (...)` block and the `from .quarantine import (...)` block (isort order: `.base`, `.contracts`, `.quarantine` — `ruff check` enforces it): + +```python +from .contracts.settings import parse_scale, scale_value +``` + +Add the two constants directly after `_MAX_ABANDONED_RECORDS = 10`: + +```python +#: System variables that AutoCAD does not keep in the HEADER section. Grid and +#: snap belong to the active VPORT table entry (DXF codes 76 / 15 / 75 / 14); +#: measured 2026-09-16: `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError`. +_VPORT_SYSVARS: dict[str, tuple[str, str]] = { + "GRIDMODE": ("grid_on", "flag"), + "SNAPMODE": ("snap_on", "flag"), + "GRIDUNIT": ("grid_spacing", "point"), + "SNAPUNIT": ("snap_spacing", "point"), +} + +#: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved +#: in" column). A file has nowhere to keep them, so the headless engine refuses +#: a write with `capability: registry_sysvar` instead of storing a value that +#: would vanish; `engineering.standards.sysvars` pins this set to its catalogue. +_UNSAVED_SYSVARS: frozenset[str] = frozenset( + { + "AUTOSNAP", + "POLARANG", + "POLARMODE", + "ATTREQ", + "ATTDIA", + "SAVETIME", + "FILEDIA", + "XREFCTL", + "PROXYSHOW", + "CMDECHO", + "HIGHLIGHT", + "HPNAME", + "HPSCALE", + "HPANG", + "DWGNAME", + "DWGPREFIX", + } +) +``` + +In `capabilities()`, add after the `"lisp"` entry of the `features` dict (the only shared anchor that exists in this worktree; the merge agent orders that hunk S → C → V by key name — `"mleaderstyle"`, then this and Task 16's `"dwgprops"`, then V's — skeleton, Merge notes): + +```python + "registry_sysvar": FeatureCapability( + False, reason="registry_saved_variables_have_no_home_in_a_file" + ), +``` + +Replace `system_get_variable` and `system_set_variable` (the two methods between `system_status` and `system_run_command`) with the following — shown at class-body indentation, paste as-is inside `EzdxfBackend`: + +```python + async def system_get_variable(self, name) -> Any: + def _sync(): + doc = self._require_doc() + key = str(name).upper() + if key in _VPORT_SYSVARS: + attr, shape = _VPORT_SYSVARS[key] + raw = getattr(self._active_vport(doc).dxf, attr) + if shape == "point": + return (float(raw[0]), float(raw[1])) + return int(raw) + if key == "CANNOSCALE": + return self._dictionary_variable(doc, "CANNOSCALE", "1:1") + if key == "CANNOSCALEVALUE": + return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1")) + return doc.header.get(f"${key}", None) + + return await self._async(_sync) + + async def system_set_variable(self, name, value) -> dict: + from ezdxf.lldxf.const import DXFKeyError + + def _sync(): + doc = self._require_doc() + key = str(name).upper() + if key in _UNSAVED_SYSVARS: + raise UnsupportedCapabilityError( + "registry_sysvar", + f"system_set_variable: {key} is saved in the AutoCAD registry (or not " + "saved at all), never in the drawing, so the headless ezdxf backend has " + "nowhere to keep it. Set it on the live COM backend.", + ) + if key in _VPORT_SYSVARS: + attr, shape = _VPORT_SYSVARS[key] + vport = self._active_vport(doc) + if shape == "point": + x, y = float(value[0]), float(value[1]) + if x <= 0 or y <= 0: + raise ValueError(f"{key}: spacing must be greater than 0, got {value!r}") + vport.dxf.set(attr, (x, y)) + else: + vport.dxf.set(attr, 1 if int(value) else 0) + elif key == "CANNOSCALE": + self._set_annotation_scale(doc, value) + elif key == "CANNOSCALEVALUE": + raise ValueError("CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead.") + else: + try: + doc.header[f"${key}"] = value + except DXFKeyError as exc: + # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py) + # while saying why the write is refused rather than dropped. + raise ValueError( + f"system_set_variable: {exc} ezdxf has no header slot for it, so the " + "value would be dropped at save; system_variable_describe(name) says " + "where the variable lives." + ) from exc + if key in ("LIMMIN", "LIMMAX"): + # Measured: `Drawing.update_limits()` copies the model-space + # LAYOUT's limits into the header on every write(), so a + # header-only value is overwritten at save. The layout is + # the source of truth; keep both in step. + doc.modelspace().dxf_layout.dxf.set(key.lower(), (float(value[0]), float(value[1]))) + self._mark_dirty() + return {"ok": True, "variable": name, "value": value} + + return await self._async(_sync) +``` + +Add the helper block **at the very end of `EzdxfBackend`** — after `_create_xline`, the file's last method (below the `# ── premium meta-tools live on AutoCADBackend …` banner). This is group C's reserved anchor: S's styles block sits before `# ── corner ops`, V's environment block after the layouts block, P's page-setup block before `# ── 3D solids` — four non-adjacent positions, so no two groups edit the same lines (skeleton, Merge notes). Task 16 appends inside this block. Shown at class-body indentation, paste as-is: + +```python + # ── settings (track E, group C) ─────────────────────────────────────────── + # + # Where the variables the `drawing_settings` facade reaches actually live + # in a DXF file when they are not header variables. + + @staticmethod + def _active_vport(doc): + """The `*Active` VPORT entry — grid and snap state live on it. + + Owned here (group C). Group V's named views use the same entry and + carry a worktree-only copy that the merge agent deletes; this + definition is the one that ships. + """ + entries = doc.viewports.get("*Active") + if not entries: + entries = [doc.viewports.new("*Active")] + return entries[0] + + @staticmethod + def _variable_dictionary(doc, create: bool): + """`AcDbVariableDictionary` under the root dictionary (CANNOSCALE lives there).""" + vardict = doc.rootdict.get("AcDbVariableDictionary") + if vardict is None and create: + vardict = doc.rootdict.add_new_dict("AcDbVariableDictionary") + return vardict + + def _dictionary_variable(self, doc, key: str, default: str) -> str: + vardict = self._variable_dictionary(doc, create=False) + if vardict is None: + return default + entry = vardict.get(key) + return default if entry is None else str(entry.dxf.value) + + @staticmethod + def _ensure_scale_entry(doc, name: str, paper: float, drawing: float) -> bool: + """Add `name` to ACAD_SCALELIST unless present; True when added. + + ezdxf has no SCALE entity class, so the object is authored as raw tags + and loaded through the factory (a `DXFTagStorage` that exports + verbatim). Measured: it round-trips, ezdxf registers the CLASS, and + `doc.audit()` is clean after reopen. AutoCAD only honours a CANNOSCALE + that names an entry of this list. + """ + from ezdxf.entities import factory + from ezdxf.lldxf.extendedtags import ExtendedTags + + scales = doc.rootdict.get("ACAD_SCALELIST") + if scales is None: + scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") + if scales.get(name) is not None: + return False + handle = doc.entitydb.next_handle() + unit = 1 if paper == drawing else 0 + text = ( + f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n" + f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{unit}\n" + ) + entry = factory.load(ExtendedTags.from_text(text), doc) + doc.entitydb.add(entry) + doc.objects.add_object(entry) + scales.add(name, entry) + return True + + def _set_annotation_scale(self, doc, value: Any) -> str: + name, paper, drawing = parse_scale(value) # refused before any write + self._ensure_scale_entry(doc, name, paper, drawing) + vardict = self._variable_dictionary(doc, create=True) + entry = vardict.get("CANNOSCALE") + if entry is None: + vardict.add_dict_var("CANNOSCALE", name) + else: + entry.dxf.value = name + return name +``` + +- [ ] **Step 5: COM — point variables as VARIANT double arrays, and the capability key** + +In `backends/com_backend.py::system_set_variable`, extend the coercion chain (after the `elif isinstance(current, float):` line): + +```python + elif isinstance(current, (tuple, list)): + # 2D/3D point variables (LIMMIN, GRIDUNIT, SNAPUNIT, ...) travel + # as VARIANT double arrays of the length AutoCAD itself reports; + # a bare Python tuple is marshalled as VT_VARIANT and rejected. + coords = [float(v) for v in value] + coords += [0.0] * (len(current) - len(coords)) + coerced = _av(coords[: len(current)]) +``` + +In `capabilities()`, after the `"lisp"` entry (same rule as the ezdxf map: merged order S → C → V at that spot): + +```python + "registry_sysvar": FeatureCapability(True, "native"), +``` + +- [ ] **Step 6: The `drawing_settings` tool names its new keys and refusals** + +In `server.py`, the `settings` parameter of `drawing_settings` is declared as `Annotated[dict | None, Field(default=None, description=(...))] = None`. Replace the whole `Field(...)` call with the following, verbatim (the `Annotated[dict | None, … ] = None` wrapper around it is unchanged), and then the docstring: + +```python + Field( + default=None, + description=( + "Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: " + "units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, " + "dimscale, dim_text_height, dim_arrow_size, dim_decimals, " + 'decimal_separator ("." or ","), zero_suppression, text_size, point_mode, ' + "point_size, osmode, fillet_radius, limits ([[xmin,ymin],[xmax,ymax]]), " + "grid (bool), grid_spacing, snap (bool), snap_spacing, ortho (bool), " + "polar (bool), polar_angle (degrees), psltscale (bool), annotation_scale " + '("1:50"), linear_units (decimal|engineering|architectural|fractional|' + "scientific), angular_units (degrees|dms|grads|radians|surveyor), " + "dimstyle, textstyle (current style names). " + 'Example: {"units": "mm", "limits": [[0, 0], [420, 297]], "grid": true}.' + ), + ), +``` + +```python + """Read or change common AutoCAD drawing settings by friendly name. + + A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE, + DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, LIMMIN/ + LIMMAX, GRIDMODE/GRIDUNIT, SNAPMODE/SNAPUNIT, ORTHOMODE, AUTOSNAP/POLARANG, + PSLTSCALE, CANNOSCALE, LUNITS, AUNITS, DIMSTYLE, TEXTSTYLE) so the user can + say "set units to mm, limits to A3 and the grid on" without memorising + sysvar names. Call with no argument for a full snapshot; a write returns + `applied`, `changed` ({key: [old, new]} — only what moved) and `errors`. + + `dim_text_height` / `dim_arrow_size` / `dim_decimals` / `decimal_separator` + / `zero_suppression` shape the *dimension* — `text_size` is TEXTSIZE, the + height of a standalone TEXT entity, and does not touch dimensions. + + Refusals, per key, nothing else rolled back: an unknown key; a value outside + its range (grid/snap spacing > 0, polar_angle 0–360, precision 0–8, …); a + malformed `limits` or `annotation_scale`; `polar` / `polar_angle` on the + headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the + registry, a file cannot); `dimstyle` / `textstyle` when the backend has no + styles contract. On the live engine `annotation_scale` must name a scale in + the drawing's scale list (SCALELISTEDIT); the headless engine adds it. + Headlessly, grid/snap are stored on the active VPORT and the annotation + scale in the variable dictionary — the places AutoCAD reads them from. + """ +``` + +- [ ] **Step 7: Alias record** + +In `discovery/aliases.py`, replace the `drawing_settings` record with: + +```python + "drawing_settings": ToolAliases( + acad=( + "UNITS", + "DDUNITS", + "OSNAP", + "DSETTINGS", + "LTSCALE", + "DIMSCALE", + "LIMITS", + "GRID", + "SNAP", + "ORTHO", + "PSLTSCALE", + "CANNOSCALE", + ), + synonyms=( + "drawing units", + "millimetres or inches", + "decimal precision", + "dimension scale", + "linetype scale", + "object snap settings", + "drawing limits", + "turn the grid on", + "grid spacing", + "snap spacing", + "ortho mode", + "polar tracking", + "annotation scale", + "architectural units", + "current dimension style", + "current text style", + ), + ), +``` + +- [ ] **Step 8: Run the tests** + +Run: `uv run --frozen pytest tests/test_settings_facade.py tests/test_drawing_settings.py tests/test_dimension_header_vars.py tests/test_capability_contract.py tests/test_discovery_aliases.py tests/test_com_sprint3.py -v` +Expected: all PASS (the COM fake tests skip with "pywin32 not installed" on a machine without it; on this Windows box they run). `test_the_dollar_prefixed_name_is_still_rejected` still matches `$$DIMTXT` because the new `ValueError` carries ezdxf's message. + +- [ ] **Step 9: Format and commit** + +```bash +uv run --frozen ruff format backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_settings_facade.py +uv run --frozen ruff check backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_settings_facade.py +git add backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_settings_facade.py +git commit -m "feat(settings): drawing_settings gains limits, grid/snap, ortho/polar, psltscale, annotation scale, unit formats and current styles + +Headlessly, grid/snap are stored on the active VPORT, CANNOSCALE in +AcDbVariableDictionary (+ an ACAD_SCALELIST entry), and LIMMIN/LIMMAX on +the model-space layout as well as the header — measured: the header alone +is overwritten at save. Registry-saved variables (AUTOSNAP, POLARANG, …) +are refused with capability registry_sysvar instead of vanishing. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 15: `SYSVAR_CATALOG`, `system_variable_describe`, and a `system_set_variable` that refuses out-of-range values + +**Files:** +- Create: `engineering/standards/sysvars.py` (no `__init__.py` — group S owns it; the namespace-package import is measured to work) +- Modify: `server.py` (new `# ── SECTION 20: Environment (1 tool) ──` header above the `# ── RESOURCES ──` banner with `system_variable_describe`; `system_set_variable` calls `check_sysvar_value` first) +- Modify: `discovery/aliases.py` (record `system_variable_describe`) +- Modify: `README.md` snapshot line `166 tools` → `167 tools`; regenerate `docs/tool-inventory.json` +- Modify: `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` (`system` 7 → 8, total 166 → 167) +- Test: `tests/test_sysvar_catalog.py` + +**Interfaces:** +- Consumes: `EzdxfBackend._UNSAVED_SYSVARS` (Task 14) — the catalogue's `saved_in != "drawing"` set is pinned equal to it; `backends.contracts.settings._SETTING_MAP` — every `friendly_key` is pinned to it. +- Produces (`engineering/standards/sysvars.py`): + - `SysVar(name, type, range, enum, default, meaning, saved_in, engines, friendly_key, read_only=False)` — `type ∈ {"int","float","str","bool","point","enum"}`, `saved_in ∈ {"drawing","registry","not_saved"}`, `engines = {"ezdxf": bool, "com": bool}`. `read_only` is one field beyond the skeleton's signature (defaulted, trailing): DIMSTYLE, CANNOSCALEVALUE, UCSNAME, VIEWCTR, VIEWSIZE, DWGNAME, DWGPREFIX are read-only in AutoCAD and the check needs to know. + - `SYSVAR_CATALOG: dict[str, SysVar]` — 90 entries (the 33-variable DIM whitelist + every name spec §5 lists + AUTOSNAP and CANNOSCALEVALUE, which the facade needs). + - `describe_sysvar(name) -> dict` (`known: True` + every field, or `{"known": False, "name", "nearest": [...]}`), `search_sysvars(text) -> list[dict]`, `check_sysvar_value(name, value) -> str | None`. +- Produces (`server.py`): tool `system_variable_describe(name=None, search=None)` → the row (+ `current` when a backend can read it), `{"query", "matches"}` for a search, or the index `{"count", "names"}` with neither. `system_set_variable` raises `ToolError` with the check's message before calling the backend. +- Pack: `settings` (the final task registers the name in `PACK_TOOL_NAMES["settings"]`; until then it is advertised as core, which is everything unclaimed). Lean: **no** — a lean client has `drawing_settings`' docstring. Cost `read`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_sysvar_catalog.py +"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines. + +Every `engines["ezdxf"]` claim is checked against the real headless backend +(read, and write of the default where the variable is writable); every +`saved_in` that is not "drawing" is checked to be exactly the set the backend +refuses with `capability: registry_sysvar`. The catalogue cannot say a thing +the backend does not do. +""" + +from __future__ import annotations + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.contracts.settings import _SETTING_MAP +from backends.ezdxf_backend import _UNSAVED_SYSVARS +from engineering.standards.sysvars import ( + SAVED_IN, + SYSVAR_CATALOG, + SYSVAR_TYPES, + SysVar, + check_sysvar_value, + describe_sysvar, + search_sysvars, +) + +asyncio_test = pytest.mark.asyncio + +DIM_WHITELIST = ( + "DIMTXT", + "DIMASZ", + "DIMEXE", + "DIMEXO", + "DIMGAP", + "DIMTAD", + "DIMTIH", + "DIMTOH", + "DIMDEC", + "DIMDSEP", + "DIMLUNIT", + "DIMZIN", + "DIMBLK", + "DIMTXSTY", + "DIMLWD", + "DIMLWE", + "DIMSCALE", + "DIMTOL", + "DIMTP", + "DIMTM", + "DIMTOLJ", + "DIMTFAC", + "DIMLFAC", + "DIMRND", + "DIMATFIT", + "DIMTMOVE", + "DIMCLRD", + "DIMCLRE", + "DIMCLRT", + "DIMSAH", + "DIMBLK1", + "DIMBLK2", + "DIMCEN", +) + +SPEC_NAMES = ( + "LTSCALE PSLTSCALE INSUNITS LUNITS LUPREC AUNITS AUPREC LIMMIN LIMMAX GRIDMODE GRIDUNIT " + "SNAPMODE SNAPUNIT ORTHOMODE POLARMODE POLARANG OSMODE CANNOSCALE TEXTSIZE TEXTSTYLE " + "DIMSTYLE CLAYER CELTYPE CECOLOR CELWEIGHT PDMODE PDSIZE FILLETRAD MIRRTEXT ATTREQ ATTDIA " + "TILEMODE CTAB DWGNAME DWGPREFIX SAVETIME FILEDIA CMDECHO HIGHLIGHT REGENMODE UCSNAME " + "VIEWCTR VIEWSIZE ANGBASE ANGDIR PLINEWID HPNAME HPSCALE HPANG LWDISPLAY XREFCTL PROXYSHOW " + "ISOLINES FACETRES DISPSILH" +).split() + +#: The two engines legitimately represent these differently (a character vs. +#: its code; a colour name vs. its ACI number), so their default cannot be +#: written verbatim headlessly. Everything else round-trips its default. +_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"} + + +# ── the data is complete ──────────────────────────────────────────────────── + + +def test_catalogue_is_authored_not_sparse(): + assert len(SYSVAR_CATALOG) >= 60 + + +def test_every_dim_whitelist_variable_and_every_spec_name_is_present(): + missing = [n for n in (*DIM_WHITELIST, *SPEC_NAMES) if n not in SYSVAR_CATALOG] + assert missing == [] + + +def test_every_entry_is_complete_and_well_typed(): + for name, var in SYSVAR_CATALOG.items(): + assert isinstance(var, SysVar) and var.name == name + assert var.type in SYSVAR_TYPES, name + assert var.meaning.strip(), name + assert var.saved_in in SAVED_IN, name + assert set(var.engines) == {"ezdxf", "com"}, name + assert var.friendly_key is None or var.friendly_key in _SETTING_MAP, name + if var.type == "enum": + assert var.enum and all(isinstance(k, int) for k in var.enum), name + assert var.default in var.enum, name + else: + assert var.enum is None, name + if var.range is not None: + low, high = var.range + assert low < high, name + + +def test_friendly_keys_cover_the_whole_facade(): + covered = {var.friendly_key for var in SYSVAR_CATALOG.values() if var.friendly_key} + assert covered == set(_SETTING_MAP) + for key, (variable, _kind) in _SETTING_MAP.items(): + assert SYSVAR_CATALOG[variable].friendly_key == key, (key, variable) + + +def test_non_drawing_variables_are_exactly_the_ones_the_headless_engine_refuses(): + unsaved = {n for n, v in SYSVAR_CATALOG.items() if v.saved_in != "drawing"} + assert unsaved == set(_UNSAVED_SYSVARS) + for name in unsaved: + assert SYSVAR_CATALOG[name].engines["ezdxf"] is False, name + + +# ── describe / search / check ─────────────────────────────────────────────── + + +def test_describe_is_case_insensitive_and_complete(): + row = describe_sysvar("ltscale") + assert row["known"] is True and row["name"] == "LTSCALE" + assert row["type"] == "float" and row["range"][0] > 0 + assert row["saved_in"] == "drawing" + assert row["engines"] == {"ezdxf": True, "com": True} + assert row["friendly_key"] == "ltscale" + assert describe_sysvar("$LIMMAX")["default"] == [420.0, 297.0] + + +def test_describe_unknown_names_the_nearest_without_raising(): + row = describe_sysvar("LTSCAL") + assert row["known"] is False and row["name"] == "LTSCAL" + assert "LTSCALE" in row["nearest"] + assert describe_sysvar("")["known"] is False + + +def test_search_matches_names_and_meanings(): + names = [row["name"] for row in search_sysvars("scale")] + for expected in ("LTSCALE", "DIMSCALE", "CANNOSCALE", "PSLTSCALE", "HPSCALE"): + assert expected in names + assert [row["name"] for row in search_sysvars("decimal separator")] == ["DIMDSEP"] + assert search_sysvars(" ") == [] + + +@pytest.mark.parametrize( + "name, value, fragment", + [ + ("DIMDEC", 9, "0..8"), + ("dimdec", -1, "0..8"), + ("DIMDEC", 2.5, "integer"), + ("DIMTXT", "big", "number"), + ("DIMTXT", 0, "out of range"), + ("LUNITS", 7, "architectural"), + ("ORTHOMODE", 2, "0 or 1"), + ("LIMMAX", 5, "[x, y]"), + ("DIMSTYLE", "ANSI", "read-only"), + ("CANNOSCALEVALUE", 0.5, "read-only"), + ("POLARANG", 0, "out of range"), + ], +) +def test_check_refuses_with_a_message_naming_the_variable(name, value, fragment): + message = check_sysvar_value(name, value) + assert message is not None + assert name.upper() in message and fragment in message + + +@pytest.mark.parametrize( + "name, value", + [ + ("DIMDEC", 3), + ("DIMTXT", "3.5"), + ("LUNITS", 4), + ("ORTHOMODE", "on"), + ("LIMMAX", [297, 210]), + ("DIMDSEP", ","), + ("NOSUCHVARIABLE", 12345), + ], +) +def test_check_passes_good_values_and_unknown_names(name, value): + assert check_sysvar_value(name, value) is None + + +# ── the engine claims are true ────────────────────────────────────────────── + + +@asyncio_test +async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend): + unreadable = [] + for name, var in SYSVAR_CATALOG.items(): + if var.engines["ezdxf"] and await backend.system_get_variable(name) is None: + unreadable.append(name) + assert unreadable == [] + + +@asyncio_test +async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend): + for name, var in SYSVAR_CATALOG.items(): + if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS: + continue + await backend.system_set_variable(name, var.default) + back = await backend.system_get_variable(name) + if var.type == "point": + assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name + elif var.type in ("bool", "int", "enum"): + assert int(back) == int(var.default), name + elif var.type == "float": + assert float(back) == pytest.approx(float(var.default)), name + else: + assert back == var.default, name + + +@asyncio_test +async def test_every_ezdxf_false_entry_is_refused_before_any_write(backend): + for name, var in SYSVAR_CATALOG.items(): + if var.engines["ezdxf"]: + continue + if var.saved_in == "drawing": + with pytest.raises(ValueError, match=name): # no header slot in ezdxf + await backend.system_set_variable(name, var.default) + else: + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.system_set_variable(name, var.default) + assert excinfo.value.capability == "registry_sysvar", name + + +# ── server tools ──────────────────────────────────────────────────────────── + + +class _FakeCtx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *a, **k): + pass + + +@asyncio_test +async def test_set_variable_tool_refuses_out_of_range_before_the_backend_sees_it(backend): + from fastmcp.exceptions import ToolError + + import server + + ctx = _FakeCtx(backend) + with pytest.raises(ToolError, match="DIMDEC"): + await server.system_set_variable(name="DIMDEC", value=99, ctx=ctx) + assert await backend.system_get_variable("DIMDEC") == 2 + with pytest.raises(ToolError, match="read-only"): + await server.system_set_variable(name="DIMSTYLE", value="ANSI", ctx=ctx) + # a name the catalogue does not know still passes straight through + out = await server.system_set_variable(name="MEASUREMENT", value=0, ctx=ctx) + assert out["ok"] is True + assert await backend.system_get_variable("MEASUREMENT") == 0 + + +@asyncio_test +async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from fastmcp.exceptions import ToolError + + import server + from backends import com_backend as module + + calls: list[tuple] = [] + + class _App: + def GetVariable(self, name): + calls.append(("GetVariable", name)) + return 2 + + def SetVariable(self, name, value): + calls.append(("SetVariable", name, value)) + + monkeypatch.setattr(module, "_acad_app", lambda: _App()) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + with pytest.raises(ToolError, match="DIMDEC"): + await server.system_set_variable(name="DIMDEC", value=40, ctx=_FakeCtx(backend)) + assert calls == [] + + +@asyncio_test +async def test_describe_tool_returns_the_row_with_the_current_value(backend): + import server + + ctx = _FakeCtx(backend) + row = await server.system_variable_describe(name="dimdec", ctx=ctx) + assert row["known"] is True and row["name"] == "DIMDEC" and row["current"] == 2 + unknown = await server.system_variable_describe(name="DIMDEK", ctx=ctx) + assert unknown["known"] is False and "DIMDEC" in unknown["nearest"] + found = await server.system_variable_describe(search="decimal separator", ctx=ctx) + assert [row["name"] for row in found["matches"]] == ["DIMDSEP"] + index = await server.system_variable_describe(ctx=ctx) + assert index["count"] == len(SYSVAR_CATALOG) and "LTSCALE" in index["names"] + + +@asyncio_test +async def test_describe_tool_works_without_a_backend(): + import server + + class _NoBackendCtx: + lifespan_context = {"backend": None} + + row = await server.system_variable_describe(name="LTSCALE", ctx=_NoBackendCtx()) + assert row["known"] is True and "current" not in row +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_sysvar_catalog.py -v` +Expected: collection ERROR — `ModuleNotFoundError: No module named 'engineering.standards'`. + +- [ ] **Step 3: Write the catalogue** + +```python +# engineering/standards/sysvars.py +"""AutoCAD system-variable catalogue: what each one is, where it lives, who honours it. + +Authored data, pinned by ``tests/test_sysvar_catalog.py``. Every entry says +its type, range or enum, AutoCAD's default, a one-line meaning, where AutoCAD +saves it (``drawing`` / ``registry`` / ``not_saved``), whether each engine can +read *and write* it (``engines``), and the ``drawing_settings`` key that wraps +it when one exists (``friendly_key``). + +``engines["ezdxf"]`` is a measured claim, not a hope: it is ``True`` only when +the headless backend has a real home for the value in the DXF file - a header +variable ezdxf will export, the active VPORT (grid/snap), the variable +dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved +and never-saved variables are ``False`` because a file has nowhere to keep +them, and ``EzdxfBackend.system_set_variable`` refuses them with +``capability: registry_sysvar``. A handful of drawing-saved variables are also +``False`` because ezdxf 1.4 has no header slot for them (CTAB, VIEWCTR, +VIEWSIZE, ISOLINES, FACETRES); that refusal is a plain ``ValueError``. + +Sources: the AutoCAD 2026 System Variables reference (type / saved-in / +initial value columns), the DXF Reference HEADER and VPORT sections, and +``ezdxf.sections.headervars.HEADER_VAR_MAP`` for what the headless engine +exports. +""" + +from __future__ import annotations + +import difflib +import math +from dataclasses import asdict, dataclass, field +from typing import Any + +__all__ = [ + "SAVED_IN", + "SYSVAR_CATALOG", + "SYSVAR_TYPES", + "SysVar", + "check_sysvar_value", + "describe_sysvar", + "search_sysvars", +] + +SYSVAR_TYPES = ("int", "float", "str", "bool", "point", "enum") +SAVED_IN = ("drawing", "registry", "not_saved") + + +@dataclass(frozen=True) +class SysVar: + name: str + type: str + range: tuple[float, float] | None + enum: dict[int, str] | None + default: Any + meaning: str + saved_in: str + engines: dict[str, bool] = field(default_factory=lambda: {"ezdxf": True, "com": True}) + friendly_key: str | None = None + read_only: bool = False + + +_BOTH = {"ezdxf": True, "com": True} +_COM_ONLY = {"ezdxf": False, "com": True} + + +def _v( + name: str, + type_: str, + meaning: str, + *, + range_: tuple[float, float] | None = None, + enum: dict[int, str] | None = None, + default: Any = None, + saved_in: str = "drawing", + engines: dict[str, bool] | None = None, + friendly_key: str | None = None, + read_only: bool = False, +) -> SysVar: + if engines is None: + engines = _BOTH if saved_in == "drawing" else _COM_ONLY + return SysVar( + name=name, + type=type_, + range=range_, + enum=enum, + default=default, + meaning=meaning, + saved_in=saved_in, + engines=dict(engines), + friendly_key=friendly_key, + read_only=read_only, + ) + + +_POSITIVE = (1e-9, 1e9) +_NON_NEGATIVE = (0.0, 1e9) +_ACI = (0, 256) +_LINEWEIGHT = (-3, 211) + +_ENTRIES: tuple[SysVar, ...] = ( + # ── dimension variables (the dimstyle whitelist) ──────────────────────── + _v( + "DIMTXT", + "float", + "dimension text height", + range_=_POSITIVE, + default=2.5, + friendly_key="dim_text_height", + ), + _v( + "DIMASZ", + "float", + "arrowhead size", + range_=_POSITIVE, + default=2.5, + friendly_key="dim_arrow_size", + ), + _v( + "DIMEXE", + "float", + "extension line extension beyond the dimension line", + range_=_POSITIVE, + default=1.25, + ), + _v( + "DIMEXO", + "float", + "extension line offset from the origin points", + range_=_NON_NEGATIVE, + default=0.625, + ), + _v( + "DIMGAP", + "float", + "gap between dimension line and text; a negative value draws a box around the text", + default=0.625, + ), + _v( + "DIMTAD", + "enum", + "vertical text placement relative to the dimension line", + enum={0: "centered", 1: "above", 2: "outside", 3: "JIS", 4: "below"}, + default=1, + ), + _v( + "DIMTIH", + "bool", + "text inside the extension lines is horizontal (1) or aligned with the line (0)", + default=0, + ), + _v( + "DIMTOH", + "bool", + "text outside the extension lines is horizontal (1) or aligned with the line (0)", + default=0, + ), + _v( + "DIMDEC", + "int", + "decimal places of the primary dimension value", + range_=(0, 8), + default=2, + friendly_key="dim_decimals", + ), + _v( + "DIMDSEP", + "str", + "decimal separator character for decimal dimensions (the headless engine stores its character code: 44 is ',')", + default=",", + friendly_key="decimal_separator", + ), + _v( + "DIMLUNIT", + "enum", + "linear unit format for dimensions", + enum={ + 1: "scientific", + 2: "decimal", + 3: "engineering", + 4: "architectural", + 5: "fractional", + 6: "windows desktop", + }, + default=2, + ), + _v( + "DIMZIN", + "int", + "zero suppression bitmask: 1 feet, 2 inches, 4 leading, 8 trailing", + range_=(0, 15), + default=8, + friendly_key="zero_suppression", + ), + _v("DIMBLK", "str", "arrowhead block name; empty means closed filled", default=""), + _v("DIMTXSTY", "str", "text style used by dimension text", default="Standard"), + _v( + "DIMLWD", + "int", + "dimension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)", + range_=_LINEWEIGHT, + default=-2, + ), + _v( + "DIMLWE", + "int", + "extension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)", + range_=_LINEWEIGHT, + default=-2, + ), + _v( + "DIMSCALE", + "float", + "overall scale factor applied to dimension sizes; 0 scales to the viewport", + range_=(0.0, 1e6), + default=1.0, + friendly_key="dimscale", + ), + _v("DIMTOL", "bool", "append tolerances to dimension text", default=0), + _v("DIMTP", "float", "upper tolerance limit", default=0.0), + _v("DIMTM", "float", "lower tolerance limit", default=0.0), + _v( + "DIMTOLJ", + "enum", + "vertical justification of tolerance text", + enum={0: "bottom", 1: "middle", 2: "top"}, + default=1, + ), + _v( + "DIMTFAC", + "float", + "tolerance text height as a factor of DIMTXT", + range_=_POSITIVE, + default=1.0, + ), + _v( + "DIMLFAC", + "float", + "linear measurement scale factor; negative applies in paper space only", + default=1.0, + ), + _v( + "DIMRND", + "float", + "rounding increment for linear dimensions; 0 rounds nothing", + range_=_NON_NEGATIVE, + default=0.0, + ), + _v( + "DIMATFIT", + "enum", + "what moves outside when text and arrows do not fit", + enum={0: "text and arrows", 1: "arrows first", 2: "text first", 3: "best fit"}, + default=3, + ), + _v( + "DIMTMOVE", + "enum", + "how the dimension line behaves when text is moved", + enum={0: "move line with text", 1: "add a leader", 2: "text moves freely"}, + default=0, + ), + _v( + "DIMCLRD", + "int", + "dimension line colour (ACI: 0 ByBlock, 256 ByLayer)", + range_=_ACI, + default=0, + ), + _v( + "DIMCLRE", + "int", + "extension line colour (ACI: 0 ByBlock, 256 ByLayer)", + range_=_ACI, + default=0, + ), + _v( + "DIMCLRT", + "int", + "dimension text colour (ACI: 0 ByBlock, 256 ByLayer)", + range_=_ACI, + default=0, + ), + _v("DIMSAH", "bool", "use DIMBLK1 / DIMBLK2 as separate arrowheads", default=0), + _v("DIMBLK1", "str", "first arrowhead block when DIMSAH is on", default=""), + _v("DIMBLK2", "str", "second arrowhead block when DIMSAH is on", default=""), + _v( + "DIMCEN", + "float", + "centre mark size for radial dimensions; 0 none, negative draws centre lines", + default=2.5, + ), + # ── scales, units, precision ───────────────────────────────────────────── + _v( + "LTSCALE", + "float", + "global linetype scale factor", + range_=_POSITIVE, + default=1.0, + friendly_key="ltscale", + ), + _v( + "PSLTSCALE", + "bool", + "scale linetypes by the viewport scale in paper space", + default=1, + friendly_key="psltscale", + ), + _v( + "INSUNITS", + "enum", + "drawing units for inserted content and scaling", + enum={ + 0: "unitless", + 1: "inches", + 2: "feet", + 3: "miles", + 4: "millimeters", + 5: "centimeters", + 6: "meters", + 7: "kilometers", + 8: "microinches", + 9: "mils", + 10: "yards", + 11: "angstroms", + 12: "nanometers", + 13: "microns", + 14: "decimeters", + 15: "decameters", + 16: "hectometers", + 17: "gigameters", + 18: "astronomical units", + 19: "light years", + 20: "parsecs", + }, + default=4, + friendly_key="units", + ), + _v( + "LUNITS", + "enum", + "linear unit display format", + enum={1: "scientific", 2: "decimal", 3: "engineering", 4: "architectural", 5: "fractional"}, + default=2, + friendly_key="linear_units", + ), + _v( + "LUPREC", + "int", + "linear unit display precision (decimal places)", + range_=(0, 8), + default=4, + friendly_key="linear_precision", + ), + _v( + "AUNITS", + "enum", + "angular unit display format", + enum={ + 0: "decimal degrees", + 1: "degrees/minutes/seconds", + 2: "grads", + 3: "radians", + 4: "surveyor", + }, + default=0, + friendly_key="angular_units", + ), + _v( + "AUPREC", + "int", + "angular unit display precision (decimal places)", + range_=(0, 8), + default=0, + friendly_key="angular_precision", + ), + _v( + "CANNOSCALE", + "str", + "current annotation scale name, e.g. '1:50' (a DICTIONARYVAR in AcDbVariableDictionary, not a header variable)", + default="1:1", + friendly_key="annotation_scale", + ), + _v( + "CANNOSCALEVALUE", + "float", + "current annotation scale as a number (paper / drawing), derived from CANNOSCALE", + default=1.0, + read_only=True, + ), + # ── limits, grid, snap, tracking ───────────────────────────────────────── + _v( + "LIMMIN", + "point", + "lower-left corner of the drawing limits (LIMITS)", + default=(0.0, 0.0), + friendly_key="limits", + ), + _v( + "LIMMAX", + "point", + "upper-right corner of the drawing limits (LIMITS)", + default=(420.0, 297.0), + friendly_key="limits", + ), + _v( + "GRIDMODE", + "bool", + "grid display on/off (stored on the active VPORT, not in the header)", + default=0, + friendly_key="grid", + ), + _v( + "GRIDUNIT", + "point", + "grid spacing [x, y] (stored on the active VPORT)", + default=(10.0, 10.0), + friendly_key="grid_spacing", + ), + _v( + "SNAPMODE", + "bool", + "snap on/off (stored on the active VPORT)", + default=0, + friendly_key="snap", + ), + _v( + "SNAPUNIT", + "point", + "snap spacing [x, y] (stored on the active VPORT)", + default=(10.0, 10.0), + friendly_key="snap_spacing", + ), + _v( + "ORTHOMODE", + "bool", + "ortho mode: cursor movement constrained to horizontal/vertical", + default=0, + friendly_key="ortho", + ), + _v( + "POLARMODE", + "int", + "polar tracking bitmask: 1 relative angles, 2 polar in osnap tracking, 4 additional angles, 8 tooltips (polar on/off itself is AUTOSNAP bit 8)", + range_=(0, 15), + default=0, + saved_in="registry", + ), + _v( + "POLARANG", + "float", + "polar angle increment in radians (90 degrees = 1.5708)", + range_=(1e-9, 2 * math.pi), + default=math.pi / 2, + saved_in="registry", + friendly_key="polar_angle", + ), + _v( + "AUTOSNAP", + "int", + "AutoSnap bitmask: 1 marker, 2 magnet, 4 tooltip, 8 polar tracking on, 16 object snap tracking, 32 tracking tooltips, 64 dynamic input aperture", + range_=(0, 127), + default=63, + saved_in="registry", + friendly_key="polar", + ), + _v( + "OSMODE", + "int", + "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); headlessly held in memory only, DXF R2000+ does not carry it", + range_=(0, 32767), + default=4133, + friendly_key="osmode", + ), + # ── current properties ─────────────────────────────────────────────────── + _v( + "TEXTSIZE", + "float", + "default height for new TEXT entities (not dimension text)", + range_=_POSITIVE, + default=2.5, + friendly_key="text_size", + ), + _v("TEXTSTYLE", "str", "current text style name", default="Standard", friendly_key="textstyle"), + _v( + "DIMSTYLE", + "str", + "current dimension style name (read-only: change it with dimstyle_set_current or drawing_settings {'dimstyle': ...})", + default="ISO-25", + friendly_key="dimstyle", + read_only=True, + ), + _v("CLAYER", "str", "current layer name", default="0"), + _v( + "CELTYPE", + "str", + "current entity linetype (ByLayer, ByBlock or a loaded linetype)", + default="ByLayer", + ), + _v( + "CECOLOR", + "str", + "current entity colour ('BYLAYER', 'BYBLOCK' or an ACI number; the headless engine stores the ACI code, 256 is ByLayer)", + default="BYLAYER", + ), + _v( + "CELWEIGHT", + "int", + "current entity lineweight code (-1 ByLayer, -2 ByBlock, -3 default, else hundredths of mm)", + range_=_LINEWEIGHT, + default=-1, + ), + _v( + "PDMODE", + "int", + "point display mode (0-4, or those plus 32 / 64 / 96)", + range_=(0, 100), + default=0, + friendly_key="point_mode", + ), + _v( + "PDSIZE", + "float", + "point display size; 0 is 5 percent of the view, negative is a percentage of the viewport", + default=0.0, + friendly_key="point_size", + ), + _v( + "FILLETRAD", + "float", + "current fillet radius", + range_=_NON_NEGATIVE, + default=0.0, + friendly_key="fillet_radius", + ), + _v("MIRRTEXT", "bool", "mirror text with MIRROR (1) or keep it readable (0)", default=0), + _v("ATTREQ", "bool", "prompt for attribute values on INSERT", default=1, saved_in="registry"), + _v( + "ATTDIA", + "bool", + "use a dialog for attribute values on INSERT", + default=0, + saved_in="registry", + ), + _v("PLINEWID", "float", "default width for new polylines", range_=_NON_NEGATIVE, default=0.0), + _v( + "ANGBASE", + "float", + "direction of angle 0 relative to the current UCS, in radians", + default=0.0, + ), + _v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0), + # ── layouts, views, UCS ────────────────────────────────────────────────── + _v("TILEMODE", "bool", "1 model tab active, 0 a layout tab active", default=1), + _v( + "CTAB", + "str", + "name of the current tab (Model or a layout); headlessly use layout_list / layout_set_current", + default="Model", + engines=_COM_ONLY, + ), + _v( + "UCSNAME", + "str", + "name of the current UCS; empty when unnamed or world", + default="", + read_only=True, + ), + _v( + "VIEWCTR", + "point", + "centre of the current view (read-only; VPORT centre in a file)", + default=(0.0, 0.0), + engines=_COM_ONLY, + read_only=True, + ), + _v( + "VIEWSIZE", + "float", + "height of the current view in drawing units (read-only)", + default=100.0, + engines=_COM_ONLY, + read_only=True, + ), + _v("REGENMODE", "bool", "automatic regeneration on", default=1), + _v("LWDISPLAY", "bool", "display lineweights on screen", default=0), + # ── file, session ──────────────────────────────────────────────────────── + _v( + "DWGNAME", + "str", + "name of the current drawing file (read-only)", + default="Drawing1.dwg", + saved_in="not_saved", + read_only=True, + ), + _v( + "DWGPREFIX", + "str", + "folder of the current drawing file (read-only)", + default="", + saved_in="not_saved", + read_only=True, + ), + _v( + "SAVETIME", + "int", + "automatic save interval in minutes; 0 disables", + range_=(0, 600), + default=10, + saved_in="registry", + ), + _v( + "FILEDIA", + "bool", + "show file dialogs (1) or take file names from the command line (0)", + default=1, + saved_in="registry", + ), + _v( + "CMDECHO", + "bool", + "echo prompts and input during AutoLISP command execution", + default=1, + saved_in="not_saved", + ), + _v("HIGHLIGHT", "bool", "highlight selected objects", default=1, saved_in="not_saved"), + _v( + "XREFCTL", + "bool", + "write external reference log (.xlg) files", + default=0, + saved_in="registry", + ), + _v( + "PROXYSHOW", + "enum", + "how proxy objects are displayed", + enum={0: "not shown", 1: "shown", 2: "bounding box"}, + default=1, + saved_in="registry", + ), + # ── hatch ──────────────────────────────────────────────────────────────── + _v( + "HPNAME", + "str", + "default hatch pattern name for HATCH", + default="ANSI31", + saved_in="not_saved", + ), + _v( + "HPSCALE", + "float", + "default hatch pattern scale", + range_=_POSITIVE, + default=1.0, + saved_in="not_saved", + ), + _v( + "HPANG", + "float", + "default hatch pattern angle in degrees", + default=0.0, + saved_in="not_saved", + ), + # ── 3D display ─────────────────────────────────────────────────────────── + _v( + "ISOLINES", + "int", + "contour lines per surface on 3D solids", + range_=(0, 2047), + default=4, + engines=_COM_ONLY, + ), + _v( + "FACETRES", + "float", + "smoothness of shaded and rendered curved solids", + range_=(0.01, 10.0), + default=0.5, + engines=_COM_ONLY, + ), + _v("DISPSILH", "bool", "display silhouette edges of 3D solids in wireframe", default=0), +) + +SYSVAR_CATALOG: dict[str, SysVar] = {entry.name: entry for entry in _ENTRIES} + + +def _as_dict(var: SysVar) -> dict: + out = asdict(var) + out["known"] = True + out["range"] = list(var.range) if var.range is not None else None + out["default"] = list(var.default) if isinstance(var.default, tuple) else var.default + return out + + +def describe_sysvar(name: str) -> dict: + """The catalogue row for ``name`` (case-insensitive), or ``known: False`` plus the nearest names.""" + key = str(name).strip().upper().lstrip("$") + var = SYSVAR_CATALOG.get(key) + if var is not None: + return _as_dict(var) + names = sorted(SYSVAR_CATALOG) + nearest = difflib.get_close_matches(key, names, n=5, cutoff=0.6) + for candidate in names: + if len(nearest) >= 5: + break + if key and candidate.startswith(key[:3]) and candidate not in nearest: + nearest.append(candidate) + return {"known": False, "name": key, "nearest": nearest} + + +def search_sysvars(text: str) -> list[dict]: + """Catalogue rows whose name, meaning or friendly key contains every word of ``text``.""" + words = [word for word in str(text).lower().split() if word] + if not words: + return [] + hits = [] + for var in SYSVAR_CATALOG.values(): + haystack = " ".join((var.name, var.meaning, var.friendly_key or "")).lower() + if all(word in haystack for word in words): + hits.append(_as_dict(var)) + return sorted(hits, key=lambda row: row["name"]) + + +_TRUTHY = {"0", "1", "on", "off", "true", "false"} + + +def check_sysvar_value(name: str, value: Any) -> str | None: + """A refusal message when ``value`` cannot be ``name``'s value; ``None`` when fine or unknown. + + Soft by design: a variable the catalogue has not heard of is passed + through untouched, so ``system_set_variable`` keeps working for the + hundreds of variables nobody has authored a row for. + """ + var = SYSVAR_CATALOG.get(str(name).strip().upper().lstrip("$")) + if var is None: + return None + if var.read_only: + return f"{var.name} is read-only: {var.meaning}" + if var.type in ("int", "float"): + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + return f"{var.name}: expected a number, got {value!r}" + try: + number = float(value) + except ValueError: + return f"{var.name}: expected a number, got {value!r}" + if not math.isfinite(number): + return f"{var.name}: must be a finite number" + if var.type == "int" and number != int(number): + return f"{var.name}: expected an integer, got {value!r}" + if var.range is not None: + low, high = var.range + if not low <= number <= high: + return f"{var.name}: {number:g} is out of range {low:g}..{high:g} - {var.meaning}" + return None + if var.type == "bool": + if value in (0, 1) or str(value).strip().lower() in _TRUTHY: + return None + return f"{var.name}: expected 0 or 1 (on/off), got {value!r}" + if var.type == "enum": + try: + code = int(value) + except (TypeError, ValueError): + return f"{var.name}: expected one of {sorted(var.enum)}, got {value!r}" + if code not in var.enum: + choices = ", ".join(f"{k}={v}" for k, v in var.enum.items()) + return f"{var.name}: {code} is not a valid code ({choices})" + return None + if var.type == "point": + try: + float(value[0]), float(value[1]) + except (TypeError, IndexError, ValueError): + return f"{var.name}: expected an [x, y] point, got {value!r}" + return None + return None +``` + +(`ruff format` will re-wrap the long `_v(...)` lines; `E501` is ignored in this project, so either shape passes the gate.) + +- [ ] **Step 4: Run the pure and engine tests** + +Run: `uv run --frozen pytest tests/test_sysvar_catalog.py -v -k "not tool"` +Expected: all PASS — the server-tool tests are deselected by `-k` until Step 5 registers the tool; the engine-truth tests prove the catalogue against the Task 14 backend. + +- [ ] **Step 5: Register the tool, the check, the alias, the header, inventory, README, snapshot** + +In `server.py`, replace `system_set_variable`'s docstring and body: + +```python +async def system_set_variable( + name: Annotated[str, "System variable name"], + value: Annotated[Any, "New variable value"], + ctx: Context = None, +) -> dict: + """Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT). + + Refusals, before anything is written: a value outside the catalogued + range or enum of a known variable, or a write to a read-only variable + (DIMSTYLE, CANNOSCALEVALUE, DWGNAME, …), is refused with the catalogue's + message — `system_variable_describe(name)` shows the range. A variable the + catalogue does not know is passed through unchanged. The headless engine + additionally refuses registry-saved variables (`capability: + registry_sysvar`) because a file has nowhere to keep them, and names that + ezdxf has no header slot for (a `ValueError` naming the variable). + """ + from engineering.standards.sysvars import check_sysvar_value + + message = check_sysvar_value(name, value) + if message: + raise ToolError(f"system_set_variable refused: {message}") + return await _backend(ctx).system_set_variable(name, value) +``` + +Insert directly above the `# ── RESOURCES ──` banner (after `pid_tag_parse`): + +```python +# --------------------------------------------------------------------------- +# ── SECTION 20: Environment (1 tool) ──────────────────────────────────────── +# --------------------------------------------------------------------------- +# 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="Explain a system variable: type, range, default, where it is saved, which engine honours it.", + cost="read", +) +@mcp.tool( + annotations={"title": "Describe System Variable", "readOnlyHint": True}, + tags={"system", "settings"}, +) +async def system_variable_describe( + name: Annotated[ + str | None, "System variable name, e.g. LTSCALE (case-insensitive, '$' optional)" + ] = None, + search: Annotated[ + str | None, "Free text matched against names and meanings, e.g. 'decimal separator'" + ] = None, + ctx: Context = None, +) -> dict: + """What an AutoCAD system variable means, from an authored catalogue of 90. + + With `name`: `{known, name, type, range|enum, default, meaning, saved_in + (drawing|registry|not_saved), engines: {ezdxf, com}, friendly_key, + read_only}` plus `current` when the active backend can read it (omitted, + with `current_unavailable`, when it cannot — no document open, or a + registry variable headlessly). An unknown name is never an error: `known: + false` and the nearest catalogue names come back. With `search`: every row + whose name or meaning contains all the words. With neither: the index of + names. `friendly_key` names the `drawing_settings` key that wraps the + variable, which is the preferred way to set it; `engines.ezdxf: false` + means the headless engine refuses a write (registry-saved, or no header + slot in ezdxf). + """ + from engineering.standards.sysvars import SYSVAR_CATALOG, describe_sysvar, search_sysvars + + if name: + row = describe_sysvar(name) + backend = ctx.lifespan_context.get("backend") if ctx is not None else None + if row["known"] and backend is not None: + try: + row["current"] = await backend.system_get_variable(row["name"]) + except Exception as exc: # no document, or the engine cannot hold it + row["current_unavailable"] = str(exc) + return row + if search: + return {"query": search, "matches": search_sysvars(search)} + return { + "count": len(SYSVAR_CATALOG), + "names": sorted(SYSVAR_CATALOG), + "hint": "pass name= for one row or search= to filter", + } +``` + +In `discovery/aliases.py`, add next to `system_set_variable` (alphabetical within the system block): + +```python + "system_variable_describe": ToolAliases( + acad=("SETVAR", "SYSVARMONITOR", "SYSVDLG"), + synonyms=( + "sysvar", + "what does ltscale do", + "explain a system variable", + "system variable reference", + "which variables are saved in the drawing", + "valid range of a system variable", + "list system variables", + ), + ), +``` + +(`SETVAR` is already in `SHARED_ACAD_COMMANDS`; `SYSVAR` is not an AutoCAD command, so it lives in `synonyms`, which the search index weights identically.) + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: append to the docstring `` `system` moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue as a read-only tool) opened SECTION 20. `` and change `"system": 7,` → `"system": 8,` and `assert sum(sizes.values()) == 166` → `== 167`. + +Then: `uv run --frozen python scripts/generate_tool_inventory.py`, and change the README line 30 snapshot to `167 tools`. + +- [ ] **Step 6: Run the gates** + +Run: `uv run --frozen pytest tests/test_sysvar_catalog.py tests/test_settings_facade.py tests/test_drawing_settings.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v` +Expected: all PASS. (If `test_document_numbers_match_the_tree` fails only on the README `collected tests` figure, that drift predates this track — the final task settles it.) + +- [ ] **Step 7: Format and commit** + +```bash +uv run --frozen ruff format engineering/standards/sysvars.py server.py discovery/aliases.py tests/test_sysvar_catalog.py tests/test_tool_registry.py +uv run --frozen ruff check engineering/standards/sysvars.py server.py discovery/aliases.py tests/test_sysvar_catalog.py tests/test_tool_registry.py +git add engineering/standards/sysvars.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_sysvar_catalog.py tests/test_tool_registry.py +git commit -m "feat(settings): SYSVAR_CATALOG + system_variable_describe; system_set_variable refuses out-of-range values + +90 authored rows (type, range/enum, default, meaning, saved-in, per-engine +support, friendly key); every ezdxf claim is checked against the real +backend and the registry-saved set is pinned to what the backend refuses. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 16: `drawing_properties_get` / `drawing_properties_set` — SummaryInfo on COM, custom properties on both engines + +**Files:** +- Modify: `backends/contracts/settings.py` (append `SUMMARY_FIELDS` and `validate_drawing_properties` — shared validation, refused before either engine writes) +- Modify: `backends/ezdxf_backend.py` (two methods in the `# ── settings (track E, group C) ──` block; `dwgprops` in `capabilities()`; import grows) +- Modify: `backends/com_backend.py` (two methods in a `# ── settings (track E, group C) ──` block **at the very end of the class**, after `_create_xline` — C's reserved anchor; `_SUMMARY_ATTRS`; `dwgprops` in `capabilities()`; import) +- Modify: `server.py` (SECTION 20 header `(1 tool)` → `(3 tools)`; two tools after `system_variable_describe`) +- Modify: `discovery/aliases.py` (records `drawing_properties_get`, `drawing_properties_set`; `DWGPROPS` joins `SHARED_ACAD_COMMANDS`) +- Modify: `README.md` snapshot `167 tools` → `169 tools`; regenerate `docs/tool-inventory.json` +- Modify: `tests/test_tool_registry.py` (`drawing` 11 → 13, total 167 → 169) +- Test: `tests/test_drawing_properties.py` + +**Interfaces:** +- Consumes: `doc.header.custom_vars` (ezdxf `CustomVars`: `append` / `replace` / `remove(tag, all=True)` / `has_tag` / iteration as `(tag, value)`); COM `Document.SummaryInfo` (`Title`, `Subject`, `Author`, `Keywords`, `Comments`, `NumCustomInfo()`, `GetCustomByIndex(i) -> (key, value)`, `AddCustomInfo(key, value)`, `SetCustomByKey(key, value)`, `RemoveCustomByKey(key)`; pywin32 returns `[out]` parameters as a tuple, the convention the backend already relies on for `GetBoundingBox` / `GetXData`). +- Produces (verbatim from the skeleton; group V declares the same two as `@abstractmethod` in `EnvironmentContract`, C only implements — whichever merges first, both backends define them): + +```python +async def drawing_properties_get(self) -> dict: ... +# → {"summary": {"title","subject","author","keywords","comments"} (None each headless), "summary_available": bool, "custom": {k: v}} +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": [...]} +``` + + Both payloads also carry `"backend"`. `validate_drawing_properties(summary, custom) -> (summary_to_write, custom_to_write, custom_to_delete)`: unknown summary field → `ValueError`; non-string summary value, non-string custom key, non-string/non-null custom value → `TypeError` naming the key; nothing is written on either engine when it raises. +- Tools: `drawing_properties_get()` (cost `read`), `drawing_properties_set(title=None, subject=None, author=None, keywords=None, comments=None, custom=None)` (cost `safe`), both `tags={"drawing", "settings"}`. Capability key `dwgprops` in both maps (ezdxf `False` — summary fields; COM `True`). +- Pack: `settings` (registered by the final task). Lean: **no** — document metadata is not a drafting essential. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_drawing_properties.py +"""drawing_properties_get / set: DWG summary and custom properties on both engines. + +Headlessly the five summary fields live in the DWG SummaryInfo stream, which +ezdxf cannot write, so any non-null summary field is refused with +`capability: dwgprops` before anything is touched; custom properties are +`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both +engines (measured: they survive save + reopen). +""" + +from __future__ import annotations + +import types + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties + +pytestmark = pytest.mark.asyncio + +EMPTY_SUMMARY = {field: None for field in SUMMARY_FIELDS} + + +# ── shared validation ─────────────────────────────────────────────────────── + + +def test_validation_splits_writes_and_deletes(): + written, to_write, to_delete = validate_drawing_properties( + {"title": "Gearbox", "author": None}, {"PROJECT": "X-1", "OLD": None} + ) + assert written == {"title": "Gearbox"} + assert to_write == {"PROJECT": "X-1"} and to_delete == ["OLD"] + assert validate_drawing_properties(None, None) == ({}, {}, []) + + +@pytest.mark.parametrize( + "summary, custom, exc, fragment", + [ + ({"edition": "1"}, None, ValueError, "edition"), + ({"title": 3}, None, TypeError, "summary.title"), + (None, {"N": 3}, TypeError, "custom['N']"), + (None, {"": "x"}, TypeError, "non-empty"), + ("Gearbox", None, TypeError, "summary"), + (None, ["PROJECT"], TypeError, "custom"), + ], +) +def test_validation_refuses_by_name(summary, custom, exc, fragment): + with pytest.raises(exc) as excinfo: + validate_drawing_properties(summary, custom) + assert fragment in str(excinfo.value) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_get_reports_no_summary_and_empty_custom(backend): + assert await backend.drawing_properties_get() == { + "summary": EMPTY_SUMMARY, + "summary_available": False, + "custom": {}, + "backend": "ezdxf", + } + + +async def test_custom_properties_write_replace_delete_and_survive_reopen(backend, tmp_path): + first = await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"}) + assert first == { + "ok": True, + "summary_written": [], + "custom_written": ["PROJECT", "REV"], + "custom_deleted": [], + "backend": "ezdxf", + } + second = await backend.drawing_properties_set( + custom={"REV": None, "NOPE": None, "PROJECT": "X-2"} + ) + assert second["custom_written"] == ["PROJECT"] + assert second["custom_deleted"] == ["REV"], "a key that was never there is not 'deleted'" + assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"} + assert [t for t, _ in backend._doc.header.custom_vars] == ["PROJECT"], "no duplicate tags" + + path = str(tmp_path / "props.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"} + + +async def test_summary_is_refused_headlessly_before_any_write(backend): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"}) + assert excinfo.value.capability == "dwgprops" + assert "title" in str(excinfo.value) + assert (await backend.drawing_properties_get())["custom"] == {} + + +async def test_all_null_summary_is_not_a_summary_write(backend): + res = await backend.drawing_properties_set(summary={"title": None, "author": None}) + assert res["ok"] is True and res["summary_written"] == [] + + +async def test_malformed_custom_is_refused_by_name_before_any_write(backend): + with pytest.raises(TypeError, match=r"custom\['N'\]"): + await backend.drawing_properties_set(custom={"OK": "1", "N": 3}) + assert (await backend.drawing_properties_get())["custom"] == {} + + +def test_both_capability_maps_declare_dwgprops(): + from backends.com_backend import ComBackend + from backends.ezdxf_backend import EzdxfBackend + + assert EzdxfBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is False + assert ComBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is True + + +# ── server tools ──────────────────────────────────────────────────────────── + + +class _FakeCtx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *a, **k): + pass + + +async def test_server_tools_round_trip_custom_and_refuse_summary(backend): + import server + + ctx = _FakeCtx(backend) + res = await server.drawing_properties_set(custom={"PROJECT": "X-9"}, ctx=ctx) + assert res["custom_written"] == ["PROJECT"] + got = await server.drawing_properties_get(ctx=ctx) + assert got["custom"] == {"PROJECT": "X-9"} and got["summary_available"] is False + with pytest.raises(UnsupportedCapabilityError): + await server.drawing_properties_set(title="Gearbox", ctx=ctx) + + +# ── live engine, against a fake SummaryInfo ───────────────────────────────── + + +class _FakeSummaryInfo: + def __init__(self): + self.Title = "Old title" + self.Subject = "" + self.Author = "" + self.Keywords = "gear" + self.Comments = "" + self._custom: list[tuple[str, str]] = [("PROJECT", "X-1"), ("OLD", "1")] + self.calls: list[tuple] = [] + + def NumCustomInfo(self): + self.calls.append(("NumCustomInfo",)) + return len(self._custom) + + def GetCustomByIndex(self, index): + self.calls.append(("GetCustomByIndex", index)) + return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple + + def AddCustomInfo(self, key, value): + self.calls.append(("AddCustomInfo", key, value)) + self._custom.append((key, value)) + + def SetCustomByKey(self, key, value): + self.calls.append(("SetCustomByKey", key, value)) + self._custom = [(k, value if k == key else v) for k, v in self._custom] + + def RemoveCustomByKey(self, key): + self.calls.append(("RemoveCustomByKey", key)) + self._custom = [(k, v) for k, v in self._custom if k != key] + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + info = _FakeSummaryInfo() + document = types.SimpleNamespace(SummaryInfo=info) + 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, info + + +async def test_com_get_reads_summary_and_custom(com_backend): + backend, info = com_backend + assert await backend.drawing_properties_get() == { + "summary": { + "title": "Old title", + "subject": "", + "author": "", + "keywords": "gear", + "comments": "", + }, + "summary_available": True, + "custom": {"PROJECT": "X-1", "OLD": "1"}, + "backend": "com", + } + + +async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend): + backend, info = com_backend + res = await backend.drawing_properties_set( + summary={"title": "Gearbox", "author": "U. Can", "subject": None}, + custom={"PROJECT": "X-2", "REV": "B", "OLD": None, "NOPE": None}, + ) + assert res == { + "ok": True, + "summary_written": ["author", "title"], + "custom_written": ["PROJECT", "REV"], + "custom_deleted": ["OLD"], + "backend": "com", + } + assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear" + mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] + assert mutations == [ + ("SetCustomByKey", "PROJECT", "X-2"), # existed → set + ("AddCustomInfo", "REV", "B"), # new → add + ("RemoveCustomByKey", "OLD"), # existed → remove; NOPE never touched + ] + + +async def test_com_validates_before_touching_activex(com_backend): + backend, info = com_backend + with pytest.raises(TypeError): + await backend.drawing_properties_set(summary={"title": "T"}, custom={"N": 3}) + with pytest.raises(ValueError): + await backend.drawing_properties_set(summary={"edition": "1"}) + assert info.calls == [] and info.Title == "Old title" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_drawing_properties.py -v` +Expected: collection ERROR — `ImportError: cannot import name 'SUMMARY_FIELDS' from 'backends.contracts.settings'`. + +- [ ] **Step 3: Shared validation in `backends/contracts/settings.py`** + +Append at the end of the module: + +```python +# --------------------------------------------------------------------------- +# Document properties (DWGPROPS) — validation shared by both engines +# --------------------------------------------------------------------------- + +#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab. +SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") + + +def validate_drawing_properties( + summary: dict | None, custom: dict | None +) -> tuple[dict[str, str], dict[str, str], list[str]]: + """→ (summary fields to write, custom keys to write, custom keys to delete). + + Refuses the whole request by name before either engine writes anything: + an unknown summary field is a ``ValueError``; a non-string summary value, + a non-string or empty custom key, or a custom value that is neither a + string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never + coerced with ``str()`` — a number the caller meant as text is theirs to + format. + """ + if summary is not None and not isinstance(summary, dict): + raise TypeError("summary: must be an object of {field: text}") + written: dict[str, str] = {} + for field, value in (summary or {}).items(): + if field not in SUMMARY_FIELDS: + raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})") + if value is None: + continue + if not isinstance(value, str): + raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}") + written[field] = value + if custom is not None and not isinstance(custom, dict): + raise TypeError("custom: must be an object of {key: text | null}") + to_write: dict[str, str] = {} + to_delete: list[str] = [] + for key, value in (custom or {}).items(): + if not isinstance(key, str) or not key.strip(): + raise TypeError(f"custom: key {key!r} must be a non-empty string") + if value is None: + to_delete.append(key) + elif isinstance(value, str): + to_write[key] = value + else: + raise TypeError( + f"custom[{key!r}]: must be a string or null (null deletes the key), " + f"got {type(value).__name__}" + ) + return written, to_write, to_delete +``` + +- [ ] **Step 4: Implement on ezdxf** + +Change the Task 14 import line to: + +```python +from .contracts.settings import ( + SUMMARY_FIELDS, + parse_scale, + scale_value, + validate_drawing_properties, +) +``` + +In `capabilities()`, after `"registry_sysvar"` (group V's Task 21 writes this exact entry in its worktree too, so the merge agent keeps one copy — this one): + +```python + "dwgprops": FeatureCapability( + False, + reason="summary_fields_need_live_autocad;custom_properties_supported", + ), +``` + +Append to the `# ── settings (track E, group C) ──` block at the end of `EzdxfBackend` (after `_set_annotation_scale`; class-body indentation, paste as-is): + +```python + async def drawing_properties_get(self) -> dict: + def _sync(): + doc = self._require_doc() + return { + "summary": {field: None for field in SUMMARY_FIELDS}, + "summary_available": False, + "custom": {tag: value for tag, value in doc.header.custom_vars}, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + written, to_write, to_delete = validate_drawing_properties(summary, custom) + if written: + # The five summary fields live in the DWG SummaryInfo stream, which + # a DXF has no slot for; refuse before the custom keys are touched. + raise UnsupportedCapabilityError( + "dwgprops", + "drawing_properties_set: the summary fields (title, subject, author, keywords, " + "comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot " + f"write (asked for {sorted(written)}). Custom properties work on both engines; " + "set the summary on the live COM backend.", + ) + + def _sync(): + doc = self._require_doc() + custom_vars = doc.header.custom_vars + for key, value in to_write.items(): + if custom_vars.has_tag(key): + custom_vars.replace(key, value) + else: + custom_vars.append(key, value) + deleted = [] + for key in to_delete: + if custom_vars.has_tag(key): + custom_vars.remove(key, all=True) + deleted.append(key) + if to_write or deleted: + self._mark_dirty() + return { + "ok": True, + "summary_written": [], + "custom_written": sorted(to_write), + "custom_deleted": deleted, + "backend": "ezdxf", + } + + return await self._async(_sync) +``` + +- [ ] **Step 5: Implement on COM** + +In `backends/com_backend.py`, directly after the `from .base import (...)` block (the relative-import group; isort order `.base` then `.contracts`): + +```python +from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties +``` + +Below `_BUILTIN_LINETYPES = {...}`: + +```python +#: `drawing_properties_*` field → `IAcadSummaryInfo` property. +_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS} +``` + +In `capabilities()`, after `"registry_sysvar"`: + +```python + "dwgprops": FeatureCapability(True, "native"), +``` + +**At the very end of `ComBackend`** — after `_create_xline`, the file's last method (group C's reserved anchor, skeleton Merge notes; class-body indentation, paste as-is): + +```python + # ── settings (track E, group C) ─────────────────────────────────────────── + + async def drawing_properties_get(self) -> dict: + def _sync(): + info = _acad_doc().SummaryInfo + summary = {field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items()} + custom: dict[str, str] = {} + for index in range(int(info.NumCustomInfo())): + key, value = info.GetCustomByIndex(index) + custom[str(key)] = str(value) + return { + "summary": summary, + "summary_available": True, + "custom": custom, + "backend": "com", + } + + return await self._run(_sync) + + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + # Validated before any ActiveX call: a bad field or value raises here + # and SummaryInfo is never touched. + written, to_write, to_delete = validate_drawing_properties(summary, custom) + + def _sync(): + info = _acad_doc().SummaryInfo + existing: set[str] = set() + for index in range(int(info.NumCustomInfo())): + key, _value = info.GetCustomByIndex(index) + existing.add(str(key)) + for field, value in written.items(): + setattr(info, _SUMMARY_ATTRS[field], value) + for key, value in to_write.items(): + # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey + # on a missing one does too — route by what is really there. + if key in existing: + info.SetCustomByKey(key, value) + else: + info.AddCustomInfo(key, value) + deleted = [] + for key in to_delete: + if key in existing: + info.RemoveCustomByKey(key) + deleted.append(key) + return { + "ok": True, + "summary_written": sorted(written), + "custom_written": sorted(to_write), + "custom_deleted": deleted, + "backend": "com", + } + + return await self._run(_sync) +``` + +- [ ] **Step 6: Run the backend tests** + +Run: `uv run --frozen pytest tests/test_drawing_properties.py tests/test_capability_contract.py -v -k "not server"` +Expected: all PASS (`test_capability_contract` proves both backends still instantiate with the new methods, whether or not group V's abstract declarations have merged). + +- [ ] **Step 7: Register the tools, aliases, header, inventory, README, snapshot** + +In `server.py`, change the SECTION 20 header to `# ── SECTION 20: Environment (3 tools) ──` (pad the dashes so the line keeps its width) and add after `system_variable_describe`: + +```python +@cad_tool( + summary="Read the drawing's title, subject, author, keywords, comments and custom properties.", + cost="read", +) +@mcp.tool( + annotations={"title": "Drawing Properties (read)", "readOnlyHint": True}, + tags={"drawing", "settings"}, +) +async def drawing_properties_get(ctx: Context = None) -> dict: + """The DWGPROPS dialog as data: `summary` (title, subject, author, keywords, + comments), `summary_available`, and `custom` ({key: value}). + + On the live engine the summary comes from the document's SummaryInfo. The + headless engine cannot read that stream, so the five summary fields are + `null` with `summary_available: false` — not empty strings, which would + claim the drawing has no title. Custom properties (`$CUSTOMPROPERTYTAG` / + `$CUSTOMPROPERTY` header pairs) are read on both engines. + """ + return await _backend(ctx).drawing_properties_get() + + +@cad_tool( + summary="Set the drawing's summary fields and add, change or delete custom properties.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Drawing Properties (write)", "readOnlyHint": False}, + tags={"drawing", "settings"}, +) +async def drawing_properties_set( + title: Annotated[str | None, "SummaryInfo Title"] = None, + subject: Annotated[str | None, "SummaryInfo Subject"] = None, + author: Annotated[str | None, "SummaryInfo Author"] = None, + keywords: Annotated[str | None, "SummaryInfo Keywords"] = None, + comments: Annotated[str | None, "SummaryInfo Comments"] = None, + custom: Annotated[ + dict | None, + "Custom properties to write: {key: text}; a null value deletes the key. " + "Keys not mentioned are left alone.", + ] = None, + ctx: Context = None, +) -> dict: + """Write DWGPROPS fields. Only the arguments given are touched; returns + `summary_written`, `custom_written` and `custom_deleted` (a key that was + never there is not reported deleted). + + Refusals, before anything is written: a summary field on the headless + engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has + no slot for it; custom properties still work headlessly), a non-string + value (`TypeError` naming the field or key — nothing is coerced with + `str()`), an empty custom key. On the live engine a custom key is added + with AddCustomInfo when new and changed with SetCustomByKey when present. + """ + summary = { + "title": title, + "subject": subject, + "author": author, + "keywords": keywords, + "comments": comments, + } + touched = [k for k, v in summary.items() if v is not None] + sorted(custom or {}) + await ctx.info(f"Setting drawing properties: {', '.join(touched) or 'nothing'}") + return await _backend(ctx).drawing_properties_set(summary, custom) +``` + +In `discovery/aliases.py`: add `"DWGPROPS"` to `SHARED_ACAD_COMMANDS` with this comment line in the block above it: + +```python +# DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and +# Custom (drawing_properties_get / drawing_properties_set). +``` + +and add the two records next to `drawing_open` (alphabetical: after `drawing_plan`, before `drawing_preflight`): + +```python + "drawing_properties_get": ToolAliases( + acad=("DWGPROPS",), + synonyms=( + "drawing properties", + "document properties", + "summary info", + "title subject author keywords", + "custom properties", + "who made this drawing", + ), + ), + "drawing_properties_set": ToolAliases( + acad=("DWGPROPS",), + synonyms=( + "set the drawing title", + "set author and keywords", + "add a custom property", + "project number in the file properties", + "edit summary info", + "delete a custom property", + ), + ), +``` + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: append to the docstring `` `drawing` moved 11 -> 13 when v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on both engines) joined SECTION 20 — tagged `drawing` and `settings`, and `settings` is not a group tag, so they file under drawing. `` and change `"drawing": 11,` → `"drawing": 13,` and `== 167` → `== 169`. + +Then: `uv run --frozen python scripts/generate_tool_inventory.py`, and change the README line 30 snapshot to `169 tools`. + +- [ ] **Step 8: Run the gates** + +Run: `uv run --frozen pytest tests/test_drawing_properties.py tests/test_sysvar_catalog.py tests/test_settings_facade.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v` +Expected: all PASS (same `collected tests` caveat as Task 15). Then the whole suite once: `uv run --frozen pytest -x -q` — green. + +- [ ] **Step 9: Format and commit** + +```bash +uv run --frozen ruff format backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_drawing_properties.py tests/test_tool_registry.py +uv run --frozen ruff check backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_drawing_properties.py tests/test_tool_registry.py +git add backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_drawing_properties.py tests/test_tool_registry.py +git commit -m "feat(settings): drawing_properties_get/set — SummaryInfo on COM, custom properties on both engines + +Summary fields are refused headlessly with capability dwgprops before any +write; custom properties are \$CUSTOMPROPERTYTAG/\$CUSTOMPROPERTY pairs and +survive save + reopen. Validation is shared, by name, and never str()-coerces. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +**Group C hand-off to the merge agent:** SECTION 20 header says `(3 tools)`; the registry snapshot says `system: 8`, `drawing: 13`, total 169; README snapshot 169; `PACK_TOOL_NAMES["settings"]` still needs `system_variable_describe`, `drawing_properties_get`, `drawing_properties_set` (final task); README lines 31/38/116 still quote 166 in prose (final docs task). Capability keys added: `registry_sysvar`, `dwgprops` (both maps). + + +# v1.6 Track E — Group P: page setup, plotting, templates (Tasks 17–20) + +Group **P** · worktree `track-e/pagesetup` · files owned: `engineering/standards/{papers,pdfinfo,templates,plot}.py`, `backends/contracts/pagesetup.py`, the `# ── page setup (track E) ──` blocks in both backends, `templates/`, `scripts/build_templates.py`, `server.py` SECTION 19 + `drawing_new` template resolution, `tests/test_page_setup.py`, `tests/test_templates.py`, `tests/test_batch_plot.py`. The plan skeleton's Global Constraints, File Structure, Interfaces and Execution sections apply verbatim; every command runs through `uv run --frozen`. + +**Measured before writing (ezdxf 1.4.4, AutoCAD 2026 typelib on this machine, 2026-09-16):** + +- `Paperspace.page_setup()` ends with `reset_viewports()` — it deletes every viewport on the sheet — and forces `use_standard_scale(False)`. The implementation below writes the same DXF attributes `page_setup()` writes, calls `reset_paper_limits()`, and never touches the viewports. +- A fresh layout has **no stored** `plot_layout_flags`; the attribute default is 688 (= 512 draw-viewports-first + 128 plot lineweights + 32 plot with plot styles + 16 use standard scale). `DXFEntity.set_flag_state` starts from `dxf.get(name, 0)`, so `layout.plot_centered(True)` on a fresh layout writes `4` and silently drops the other three bits. The default must be materialised first (`page_setup()` does exactly this with `dxf.get_default`). After that, `plot_centered(True)` → 692, `plot_centered(False)` → 688. The centre-plot bit is 4. +- `paper_size` is written as `f"{name}_({w:.2f}_x_{h:.2f}_{MM|Inches})"`, so `name="ISO_A3"` produces AutoCAD's canonical `ISO_A3_(420.00_x_297.00_MM)` verbatim. +- DXF code 75 (`standard_scale_type`) and ActiveX `AcPlotScale` disagree: DXF has 16=1:1, 17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 and **no 1:5**; ActiveX has `ac1_1=16, ac1_2=17, ac1_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0`. Neither has 5:1. Scales without a code go through the custom numerator/denominator (`SetCustomScale` on COM). +- The headless `drawing_export_pdf` renders into a matplotlib figure that `MatplotlibBackend(adjust_figure=True)` resizes to the data aspect: today's `/MediaBox` is `[0 0 691.2 345.6]` whatever the sheet is. Sizing the figure from `paper_width/paper_height` with `adjust_figure=False` and clamping the axes to the paper frame gives `/MediaBox [0 0 1190.5511811024 841.8897637795]` = 420.000 × 297.000 mm on A3, 432 × 279 on ANSI B, 210 × 297 on portrait A4 (measured through `_new_agg_figure`). +- Two builds of the same DXF differ on exactly six lines: the values of `$TDCREATE`, `$TDUPDATE`, `$FINGERPRINTGUID`, `$VERSIONGUID`, and the two `EZDXF_META` DICTIONARYVAR stamps (`1.4.4 @ 2026-09-16T12:33:38.192529+00:00`). ezdxf writes CRLF; the repository's `.gitattributes` has `* text=auto`. +- `AcSaveAsType` in the AutoCAD 2026 typelib (`%LOCALAPPDATA%/Temp/gen_py/3.11/0EA72311-DF28-4318-A51B-92D7FC7DF50Bx0x1x0.py`) has **no `acTemplateDwg`**. The template members are `ac2000_Template=14 … ac2013_Template=62, ac2018_Template=66`; `acNative=64`, `ac2018_dxf=65`, `ac2013_dxf=61`. Task 20 uses `66`. The existing `ComBackend.drawing_save_as` maps `"dwt"` to `5`, which is `acR13_dxf` — a DWT request there writes an R13 DXF under a `.dwt` name; Task 20 fixes that map in passing. +- `IAcadPlotConfiguration` (what a `Layout` is) exposes `ConfigName`, `CanonicalMediaName`, `StyleSheet`, `PlotType`, `StandardScale`, `UseStandardScale`, `PlotRotation`, `PaperUnits`, `CenterPlot`, `PlotWithPlotStyles`, `RefreshPlotDeviceInfo()`, `GetCanonicalMediaNames()`, `GetPaperSize()`, `GetPaperMargins()`, `GetCustomScale()`, `SetCustomScale()`, `GetPlotStyleTableNames()`. `AcPlotType`: `acDisplay=0, acExtents=1, acLimits=2, acView=3, acWindow=4, acLayout=5`; `AcPlotPaperUnits`: `acInches=0, acMillimeters=1`. +- `$CANNOSCALE` / `$CANNOSCALEVALUE` are **not** header variables ezdxf accepts (`Invalid header variable`); the annotation scale lives in the `ACAD_SCALELIST` dictionary. The template build asks the settings facade for `annotation_scale` (group C's key) and records a `pending` note when the facade does not know it yet. +- Header variables the build script's fallback uses were all accepted by `system_set_variable` headlessly: `DIMLWD/DIMLWE=-2`, `DIMZIN=8`, `DIMBLK=""`, `DIMTAD`, `DIMLUNIT`, `DIMEXE/DIMEXO/DIMGAP`, `DIMTIH/DIMTOH`, `PSLTSCALE`, `LUNITS`, `AUNITS`, `DIMSTYLE`; the facade accepted `units/ltscale/dimscale/dim_text_height/dim_arrow_size/dim_decimals/decimal_separator/zero_suppression/linear_precision/angular_precision`. + +--- + +### Task 17: Paper / ctb / scale data, PDF MediaBox reader, `PageSetupContract` on both engines + +**Files:** +- Create: `engineering/standards/papers.py`, `engineering/standards/pdfinfo.py`, `backends/contracts/pagesetup.py` (no `engineering/standards/__init__.py` — group S's Task 9 owns that file; the namespace-package import works without it, as group C relies on too) +- Modify: `backends/contracts/__init__.py` (import + `__all__`), `backends/base.py` (compose `PageSetupContract` — the idempotent one-line edit the skeleton allows a group's first task) +- Modify: `backends/ezdxf_backend.py` — new block `# ── page setup (track E) ──` after the change-space block (immediately before `# ── 3D solids` — group P's reserved anchor, see the skeleton's Merge notes), the `dwt_write` capability line (after `"paper_space"`), and one delimited edit inside `drawing_export_pdf` (figure sized from the layout's paper) +- Modify: `backends/com_backend.py` — new block `# ── page setup (track E) ──` immediately before `# ── 3D solids` (after the viewports block — the end of the layouts block is group V's anchor), the `dwt_write` capability line (after `"paper_space"`) +- Test: `tests/test_page_setup.py` + +**Interfaces:** +- Produces (`engineering/standards/papers.py`): `PAPER_SIZES`, `PAPER_INCHES`, `CTB_CATALOG`, `SCALES`, `DXF_STANDARD_SCALE_TYPE`, `ACTIVEX_PLOT_SCALE`, `PLOT_AREAS`, `PLOT_TYPE_NAMES`, `paper_size_mm(paper, orientation) -> tuple[float, float]`, `canonical_media_name(paper, orientation) -> str`, `paper_from_size(width_mm, height_mm, tolerance=0.5) -> str | None`, `scale_ratio(label) -> tuple[float, float]`, `scale_label(numerator, denominator) -> str`, `dxf_scale_label(code, numerator, denominator) -> str`, `activex_scale_label(code) -> str`, `plot_style_known(name) -> bool`, `REQUIRED_SETUP_KEYS`, `require_page_setup(setup) -> dict` (the backend-side completeness check), `resolve_page_setup(paper, orientation="landscape", plot_style="monochrome.ctb", scale="fit", plot_area="layout", device="DWG To PDF.pc3", margins_mm=None, center=True) -> dict`. +- Produces (`engineering/standards/pdfinfo.py`): `read_mediabox(path) -> tuple[float, float]` (mm; raises `ValueError`). +- Produces (`backends/contracts/pagesetup.py`): `PageSetupContract` with `page_setup_list`, `page_setup_apply`, `plot_style_list`, `drawing_template_save` — signatures verbatim from the skeleton. `drawing_template_save` is *implemented* in Task 20; this task declares it (abstract) and both backends get a stub that raises `UnsupportedCapabilityError("dwt_write", …)` so the ABC instantiates and the capability gate already sees the key. +- Consumes: `EzdxfBackend._find_layout`, `_layout_name`, `_require_doc`, `_mark_dirty`, `_async`; `ComBackend._find_layout(doc, raw)`, `_layout_names(doc)`, `_acad_doc`, `_run`; `UnsupportedCapabilityError`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_page_setup.py +"""Page setup on both engines; the PDF's own /MediaBox is the evidence. + +ezdxf: apply a paper, export the layout, parse the PDF back and compare to the +paper size. COM: a fake ActiveX layout records the property sequence. +""" + +from __future__ import annotations + +import re +import types +import zlib + +import pytest + +from engineering.standards.papers import ( + CTB_CATALOG, + PAPER_SIZES, + SCALES, + canonical_media_name, + paper_from_size, + resolve_page_setup, + scale_label, + scale_ratio, +) +from engineering.standards.pdfinfo import read_mediabox + +pytestmark = pytest.mark.asyncio + +SHEET = "Sheet" + + +# ── pure data ─────────────────────────────────────────────────────────────── + + +def test_paper_sizes_are_iso_216_and_ansi_y14_1(): + assert PAPER_SIZES == { + "ISO_A0": (841, 1189), + "ISO_A1": (594, 841), + "ISO_A2": (420, 594), + "ISO_A3": (297, 420), + "ISO_A4": (210, 297), + "ANSI_A": (216, 279), + "ANSI_B": (279, 432), + "ANSI_C": (432, 559), + "ANSI_D": (559, 864), + "ANSI_E": (864, 1118), + } + assert CTB_CATALOG[0] == "monochrome.ctb" and "acad.ctb" in CTB_CATALOG + assert SCALES["fit"] is None and SCALES["1:50"] == 0.02 and SCALES["10:1"] == 10.0 + + +def test_canonical_media_name_uses_autocad_spelling(): + assert canonical_media_name("ISO_A3", "landscape") == "ISO_A3_(420.00_x_297.00_MM)" + assert canonical_media_name("ISO_A3", "portrait") == "ISO_A3_(297.00_x_420.00_MM)" + assert canonical_media_name("ANSI_B", "landscape") == "ANSI_B_(17.00_x_11.00_Inches)" + + +def test_paper_from_size_matches_either_orientation_within_half_a_millimetre(): + assert paper_from_size(420, 297) == "ISO_A3" + assert paper_from_size(297, 420) == "ISO_A3" + assert paper_from_size(431.8, 279.4) == "ANSI_B" + assert paper_from_size(400, 300) is None + + +def test_scale_ratio_and_label_round_trip(): + assert scale_ratio("1:50") == (1.0, 50.0) + assert scale_ratio("2:1") == (2.0, 1.0) + assert scale_label(1.0, 50.0) == "1:50" + assert scale_label(1.0, 3.0) == "1:3" + with pytest.raises(ValueError, match="scale"): + scale_ratio("fit") + + +def test_resolve_page_setup_defaults(): + setup = resolve_page_setup("ISO_A3") + assert setup["paper"] == "ISO_A3" + assert setup["orientation"] == "landscape" + assert setup["size_mm"] == [420.0, 297.0] + assert setup["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert setup["plot_style"] == "monochrome.ctb" and setup["plot_style_known"] is True + assert setup["scale"] == "fit" + assert setup["dxf_standard_scale_type"] == 0 and setup["activex_standard_scale"] == 0 + assert setup["scale_ratio"] == [1.0, 1.0] + assert setup["plot_area"] == "layout" and setup["plot_type"] == 5 + assert setup["device"] == "DWG To PDF.pc3" + assert setup["margins_mm"] is None and setup["center"] is True + + +def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): + assert resolve_page_setup("iso_a4", "portrait")["size_mm"] == [210.0, 297.0] + assert resolve_page_setup("A3")["paper"] == "ISO_A3" + assert resolve_page_setup("ansi_b")["canonical_media_name"] == "ANSI_B_(17.00_x_11.00_Inches)" + + +def test_resolve_page_setup_scale_codes(): + one_to_five = resolve_page_setup("ISO_A3", scale="1:5") + assert one_to_five["dxf_standard_scale_type"] is None # DXF code 75 has no 1:5 + assert one_to_five["activex_standard_scale"] == 19 # AcPlotScale.ac1_5 + assert one_to_five["scale_ratio"] == [1.0, 5.0] + five_to_one = resolve_page_setup("ISO_A3", scale="5:1") + assert five_to_one["dxf_standard_scale_type"] is None + assert five_to_one["activex_standard_scale"] is None + assert five_to_one["scale_ratio"] == [5.0, 1.0] + assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 25 + assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26 + + +@pytest.mark.parametrize( + "kwargs, field", + [ + ({"paper": "ISO_A9"}, "paper"), + ({"paper": "ISO_A3", "orientation": "sideways"}, "orientation"), + ({"paper": "ISO_A3", "scale": "1:3"}, "scale"), + ({"paper": "ISO_A3", "plot_area": "window"}, "plot_area"), + ({"paper": "ISO_A3", "device": ""}, "device"), + ({"paper": "ISO_A3", "plot_style": ""}, "plot_style"), + ({"paper": "ISO_A3", "margins_mm": [1, 2, 3]}, "margins_mm"), + ({"paper": "ISO_A3", "margins_mm": [-1, 0, 0, 0]}, "margins_mm"), + ({"paper": "ISO_A3", "margins_mm": [150, 150, 0, 0]}, "margins_mm"), + ({"paper": "ISO_A3", "center": "yes"}, "center"), + ], +) +def test_resolve_page_setup_refuses_naming_the_field(kwargs, field): + with pytest.raises(ValueError, match=field): + resolve_page_setup(**kwargs) + + +def test_unknown_ctb_is_allowed_but_flagged(): + setup = resolve_page_setup("ISO_A3", plot_style="company.ctb") + assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False + + +# ── the PDF reader ────────────────────────────────────────────────────────── + + +def test_read_mediabox_refuses_a_non_pdf(tmp_path): + bogus = tmp_path / "x.pdf" + bogus.write_bytes(b"not a pdf") + with pytest.raises(ValueError, match="PDF"): + read_mediabox(str(bogus)) + + +def test_read_mediabox_finds_a_box_inside_a_flate_stream(tmp_path): + body = zlib.compress(b"<< /Type /Page /MediaBox [0 0 595.276 841.89] >>") + pdf = tmp_path / "c.pdf" + pdf.write_bytes( + b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /Filter /FlateDecode >>\nstream\n" + + body + + b"\nendstream\nendobj\n%%EOF" + ) + width, height = read_mediabox(str(pdf)) + assert abs(width - 210.0) < 0.05 and abs(height - 297.0) < 0.05 + + +def test_read_mediabox_reports_a_missing_box(tmp_path): + pdf = tmp_path / "n.pdf" + pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF") + with pytest.raises(ValueError, match="MediaBox"): + read_mediabox(str(pdf)) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def _sheet(backend, name: str = SHEET) -> str: + result = await backend.layout_create(name) + assert result["ok"], result + return name + + +async def _mediabox_after_export(backend, tmp_path, layout: str): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + pdf = tmp_path / f"{layout}.pdf" + result = await backend.drawing_export_pdf(str(pdf), layout=layout) + assert result["ok"] is True, result + return read_mediabox(str(pdf)) + + +async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_path): + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", "landscape")) + assert result["ok"] is True and result["layout"] == SHEET + assert result["applied"]["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert result["plot_style_known"] is True + assert result["viewports_kept"] is True + + rows = await backend.page_setup_list(SHEET) + assert len(rows) == 1 + row = rows[0] + assert row["layout"] == SHEET + assert row["paper"] == "ISO_A3" + assert row["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert row["size_mm"] == [420.0, 297.0] + assert row["orientation"] == "landscape" + assert row["plot_style"] == "monochrome.ctb" + assert row["scale"] == "fit" + assert row["plot_area"] == "layout" + assert row["device"] == "DWG To PDF.pc3" + assert row["center"] is True + assert len(row["margins_mm"]) == 4 + + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 420.0) <= 0.5 and abs(height - 297.0) <= 0.5 + + +async def test_apply_ansi_b(backend, tmp_path): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ANSI_B", "landscape", scale="1:1")) + row = (await backend.page_setup_list(SHEET))[0] + assert row["paper"] == "ANSI_B" and row["size_mm"] == [432.0, 279.0] + assert row["scale"] == "1:1" + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 432.0) <= 0.5 and abs(height - 279.0) <= 0.5 + + +async def test_apply_portrait_a4(backend, tmp_path): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A4", "portrait")) + row = (await backend.page_setup_list(SHEET))[0] + assert row["orientation"] == "portrait" and row["size_mm"] == [210.0, 297.0] + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5 + + +async def test_apply_materialises_the_default_flags_before_setting_centre(backend): + """Measured: set_flag_state starts from 0 on a fresh layout, so a naive + plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first.""" + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert flags & 4, "centre-plot bit" + assert flags & 32, "plot with plot styles" + assert flags & 128, "plot entity lineweights" + assert flags & 512, "draw viewports first" + assert flags & 16, "use standard scale (fit)" + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert not flags & 4 and flags & 32 and flags & 128 and flags & 512 + + +async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5")) + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + assert not int(dxf.plot_layout_flags) & 16 + assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0) + assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5" + + +async def test_apply_keeps_the_viewports(backend): + await _sheet(backend) + await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0) + before = (await backend.viewport_list(SHEET))["count"] + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert (await backend.viewport_list(SHEET))["count"] == before + + +async def test_changed_reports_only_what_moved(backend): + await _sheet(backend) + first = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert first["changed"], "a fresh layout differs from an A3 setup somewhere" + second = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert second["changed"] == {} + third = await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_style="acad.ctb") + ) + assert third["changed"] == {"plot_style": ["monochrome.ctb", "acad.ctb"]} + + +async def test_unknown_ctb_is_written_and_flagged(backend): + await _sheet(backend) + result = await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_style="company.ctb") + ) + assert result["plot_style_known"] is False + assert (await backend.page_setup_list(SHEET))[0]["plot_style"] == "company.ctb" + + +async def test_refusals_happen_before_any_write(backend): + await _sheet(backend) + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + before = dxf.get("paper_size") + with pytest.raises(ValueError, match="Model"): + await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="canonical_media_name"): + await backend.page_setup_apply(SHEET, {"paper": "ISO_A3"}) + assert dxf.get("paper_size") == before + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_list("Nope") + + +async def test_list_covers_every_paper_layout_and_skips_model(backend): + await _sheet(backend, "A") + await _sheet(backend, "B") + names = [row["layout"] for row in await backend.page_setup_list()] + assert "Model" not in names and {"A", "B"} <= set(names) + + +async def test_plot_style_list_headless_is_the_catalog(backend): + rows = await backend.plot_style_list() + assert [row["name"] for row in rows] == list(CTB_CATALOG) + assert all(row["source"] == "catalog" and row["installed"] is None for row in rows) + + +async def test_dwt_write_capability_is_declared_false_headlessly(backend): + assert backend.capabilities().to_dict()["features"]["dwt_write"]["supported"] is False + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + +_MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)") + + +class _FakeLayout: + """Records every property set and method call, in order.""" + + def __init__(self, name, media_names): + object.__setattr__(self, "log", []) + object.__setattr__(self, "_media", tuple(media_names)) + object.__setattr__( + self, + "_props", + { + "Name": name, + "ConfigName": "None", + "CanonicalMediaName": "ISO_A4_(210.00_x_297.00_MM)", + "StyleSheet": "", + "PlotType": 5, + "StandardScale": 16, + "UseStandardScale": True, + "PlotRotation": 0, + "PaperUnits": 1, + "CenterPlot": False, + "PlotWithPlotStyles": True, + }, + ) + + def __getattr__(self, attr): + props = object.__getattribute__(self, "_props") + if attr in props: + return props[attr] + raise AttributeError(attr) + + def __setattr__(self, attr, value): + self._props[attr] = value + self.log.append(("set", attr, value)) + + def RefreshPlotDeviceInfo(self): + self.log.append(("call", "RefreshPlotDeviceInfo")) + + def GetCanonicalMediaNames(self): + self.log.append(("call", "GetCanonicalMediaNames")) + return self._media + + def GetPaperSize(self): + match = _MEDIA_RE.search(self._props["CanonicalMediaName"]) + width, height = float(match.group(1)), float(match.group(2)) + if match.group(3) == "Inches": + width, height = width * 25.4, height * 25.4 + return (width, height) + + def GetPaperMargins(self): + return ((7.5, 20.0), (7.5, 20.0)) + + def GetCustomScale(self): + return (1.0, 1.0) + + def SetCustomScale(self, numerator, denominator): + self.log.append(("call", "SetCustomScale", numerator, denominator)) + + +class _FakeLayouts: + def __init__(self, layouts): + self.layouts = list(layouts) + + @property + def Count(self): + return len(self.layouts) + + def Item(self, key): + if isinstance(key, int): + return self.layouts[key] + for layout in self.layouts: + if layout.Name == key: + return layout + raise RuntimeError(f"no layout {key}") + + +MEDIA = ( + "ISO_A3_(420.00_x_297.00_MM)", + "ISO_A4_(210.00_x_297.00_MM)", + "ANSI_B_(17.00_x_11.00_Inches)", +) + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = types.SimpleNamespace( + Layouts=_FakeLayouts([_FakeLayout("Model", MEDIA), _FakeLayout("Layout1", MEDIA)]) + ) + 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, document + + +async def test_com_apply_issues_the_property_sequence(com_backend): + backend, document = com_backend + result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", "landscape")) + assert result["ok"] is True and result["plot_style_known"] is True + layout = document.Layouts.Item("Layout1") + assert layout.log == [ + ("call", "RefreshPlotDeviceInfo"), + ("set", "ConfigName", "DWG To PDF.pc3"), + ("call", "RefreshPlotDeviceInfo"), + ("call", "GetCanonicalMediaNames"), + ("set", "CanonicalMediaName", "ISO_A3_(420.00_x_297.00_MM)"), + ("set", "PaperUnits", 1), + ("set", "PlotRotation", 0), + ("set", "StyleSheet", "monochrome.ctb"), + ("set", "PlotWithPlotStyles", True), + ("set", "PlotType", 5), + ("set", "StandardScale", 0), + ("set", "UseStandardScale", True), + ("set", "CenterPlot", True), + ] + assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"] + assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"] + assert result["changed"]["center"] == [False, True] + + +async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend): + backend, document = com_backend + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1")) + log = document.Layouts.Item("Layout1").log + assert ("call", "SetCustomScale", 5.0, 1.0) in log + assert ("set", "UseStandardScale", False) in log + assert not any(entry[:2] == ("set", "StandardScale") for entry in log) + + +async def test_com_apply_refuses_margins_before_any_write(com_backend): + backend, document = com_backend + with pytest.raises(ValueError, match="margins_mm"): + await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", margins_mm=[10, 10, 10, 10]) + ) + assert document.Layouts.Item("Layout1").log == [] + + +async def test_com_apply_restores_the_device_when_the_media_name_is_unknown(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + object.__setattr__(layout, "_media", ("ISO_A4_(210.00_x_297.00_MM)",)) + with pytest.raises(ValueError, match=r"ISO_A3_\(420.00_x_297.00_MM\)"): + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) + assert layout.ConfigName == "None", "the device write is rolled back" + assert layout.CanonicalMediaName == "ISO_A4_(210.00_x_297.00_MM)" + + +async def test_com_apply_refuses_model_and_unknown_layouts(com_backend): + backend, _ = com_backend + with pytest.raises(ValueError, match="Model"): + await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3")) + + +async def test_com_list_reads_the_properties(com_backend): + backend, _ = com_backend + rows = await backend.page_setup_list() + assert [row["layout"] for row in rows] == ["Layout1"] + row = rows[0] + assert row["paper"] == "ISO_A4" and row["size_mm"] == [210.0, 297.0] + assert row["orientation"] == "portrait" + assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + assert row["scale"] == "1:1" and row["plot_area"] == "layout" + assert row["device"] == "None" and row["center"] is False + + +async def test_com_declares_dwt_write(): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends.com_backend import ComBackend + + assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_page_setup.py -v` +Expected: collection FAILS with `ModuleNotFoundError: No module named 'engineering.standards'` (or `No module named 'engineering.standards.papers'` once group S's package has merged). + +- [ ] **Step 3: The standards data — `engineering/standards/papers.py`** + +Do **not** create `engineering/standards/__init__.py` in this worktree — group S's Task 9 owns it, and a second copy is a cross-group file the merge agent would have to discard. `import engineering.standards.papers` resolves as a namespace-package portion without it (group C measured this with a probe module and relies on it the same way for `sysvars.py`), so nothing of S's is needed here. Write: + +```python +# engineering/standards/papers.py +"""Paper sizes, plot-style catalogue and plot scales — authored data. + +Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as +AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*), +the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale +tables of two enums that do not agree with each other: + +* DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1, + 17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5. +* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib): + ac1_1=16, ac1_2=17, ac1_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, + ac2_1=28, ac10_1=31, acScaleToFit=0. + +A scale without a code on an engine goes through the custom numerator / +denominator on that engine, and the read-back reports the same label either way. +""" + +from __future__ import annotations + +import math +import re + +__all__ = [ + "ACTIVEX_PLOT_SCALE", + "CTB_CATALOG", + "DXF_STANDARD_SCALE_TYPE", + "ORIENTATIONS", + "PAPER_INCHES", + "PAPER_SIZES", + "PLOT_AREAS", + "PLOT_TYPE_NAMES", + "REQUIRED_SETUP_KEYS", + "SCALES", + "activex_scale_label", + "canonical_media_name", + "dxf_scale_label", + "paper_from_size", + "paper_size_mm", + "plot_style_known", + "require_page_setup", + "resolve_page_setup", + "scale_label", + "scale_ratio", +] + +#: Portrait width × height in millimetres. Landscape swaps. +PAPER_SIZES: dict[str, tuple[int, int]] = { + "ISO_A0": (841, 1189), + "ISO_A1": (594, 841), + "ISO_A2": (420, 594), + "ISO_A3": (297, 420), + "ISO_A4": (210, 297), + "ANSI_A": (216, 279), + "ANSI_B": (279, 432), + "ANSI_C": (432, 559), + "ANSI_D": (559, 864), + "ANSI_E": (864, 1118), +} + +#: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3 +#: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the +#: sheet measures. Portrait width × height. +PAPER_INCHES: dict[str, tuple[float, float]] = { + "ANSI_A": (8.5, 11.0), + "ANSI_B": (11.0, 17.0), + "ANSI_C": (17.0, 22.0), + "ANSI_D": (22.0, 34.0), + "ANSI_E": (34.0, 44.0), +} + +#: Spellings resolve_page_setup accepts, mapped to the canonical key. +_PAPER_ALIASES: dict[str, str] = { + **{name.lower(): name for name in PAPER_SIZES}, + **{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")}, + **{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")}, + **{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")}, +} + +#: Plot style tables AutoCAD ships (``/…/Plot Styles``). +CTB_CATALOG: tuple[str, ...] = ( + "monochrome.ctb", + "acad.ctb", + "Grayscale.ctb", + "Screening 100%.ctb", + "Screening 75%.ctb", + "Screening 50%.ctb", + "Screening 25%.ctb", + "Fill Patterns.ctb", + "DWF Virtual Pens.ctb", +) + +#: Label → paper:drawing factor (``None`` = scaled to fit). +SCALES: dict[str, float | None] = { + "fit": None, + "1:1": 1.0, + "1:2": 0.5, + "1:5": 0.2, + "1:10": 0.1, + "1:20": 0.05, + "1:50": 0.02, + "1:100": 0.01, + "2:1": 2.0, + "5:1": 5.0, + "10:1": 10.0, +} + +#: DXF group code 75. Labels missing here use the custom numerator/denominator. +DXF_STANDARD_SCALE_TYPE: dict[str, int] = { + "fit": 0, + "1:1": 16, + "1:2": 17, + "1:10": 20, + "1:20": 22, + "1:50": 25, + "1:100": 26, + "2:1": 27, + "10:1": 30, +} +_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()} + +#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale. +ACTIVEX_PLOT_SCALE: dict[str, int] = { + "fit": 0, + "1:1": 16, + "1:2": 17, + "1:5": 19, + "1:10": 21, + "1:20": 23, + "1:50": 26, + "1:100": 27, + "2:1": 28, + "10:1": 31, +} +_ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items()} + +#: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5). +PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1} +PLOT_TYPE_NAMES: dict[int, str] = { + 0: "display", + 1: "extents", + 2: "limits", + 3: "view", + 4: "window", + 5: "layout", +} + +ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait") + +_SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$") + + +def _paper_key(paper) -> str: + key = str(paper or "").strip().lower().replace(" ", "_") + resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " ")) + if resolved is None: + raise ValueError( + f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} " + "(short forms A0–A4 and ANSI_A–ANSI_E are accepted)" + ) + return resolved + + +def _orientation(orientation) -> str: + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}") + return orientation + + +def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]: + """(width, height) in millimetres for ``paper`` in ``orientation``.""" + name = _paper_key(paper) + short, long = PAPER_SIZES[name] + if _orientation(orientation) == "landscape": + return float(long), float(short) + return float(short), float(long) + + +def canonical_media_name(paper: str, orientation: str = "landscape") -> str: + """AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for + landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B.""" + name = _paper_key(paper) + if name in PAPER_INCHES: + short, long = PAPER_INCHES[name] + unit = "Inches" + else: + short, long = PAPER_SIZES[name] + unit = "MM" + width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long) + return f"{name}_({width:.2f}_x_{height:.2f}_{unit})" + + +def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None: + """The catalogue paper whose size (either orientation) is within ``tolerance`` mm.""" + try: + w, h = float(width_mm), float(height_mm) + except (TypeError, ValueError): + return None + for name, (short, long) in PAPER_SIZES.items(): + if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or ( + abs(w - short) <= tolerance and abs(h - long) <= tolerance + ): + return name + return None + + +def scale_ratio(label: str) -> tuple[float, float]: + """``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio.""" + match = _SCALE_RE.match(str(label or "")) + if match is None: + raise ValueError(f"scale: {label!r} is not an N:M ratio") + numerator, denominator = float(match.group(1)), float(match.group(2)) + if numerator <= 0 or denominator <= 0: + raise ValueError(f"scale: {label!r} must have positive terms") + return numerator, denominator + + +def scale_label(numerator, denominator) -> str: + """The ``N:M`` label for a ratio: a catalogue label when the factor matches + one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats.""" + num, den = float(numerator), float(denominator) + if num <= 0 or den <= 0: + return f"{num:g}:{den:g}" + for label, factor in SCALES.items(): + if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9): + return label + if num.is_integer() and den.is_integer(): + divisor = math.gcd(int(num), int(den)) + return f"{int(num) // divisor}:{int(den) // divisor}" + return f"{num:g}:{den:g}" + + +def dxf_scale_label(code, numerator, denominator) -> str: + """Read-back for the headless engine: the code-75 label, else the custom ratio.""" + return _DXF_SCALE_LABEL.get(int(code)) or scale_label(numerator, denominator) + + +def activex_scale_label(code) -> str: + """Read-back for the live engine: the AcPlotScale label, else ``standard:``.""" + return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}") + + +def plot_style_known(name) -> bool: + return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG} + + +def resolve_page_setup( + paper: str, + orientation: str = "landscape", + plot_style: str = "monochrome.ctb", + scale: str = "fit", + plot_area: str = "layout", + device: str = "DWG To PDF.pc3", + margins_mm=None, + center: bool = True, +) -> dict: + """Validate a page setup request and resolve it to what both engines write. + + Raises ``ValueError`` naming the offending field; nothing here touches a + drawing. ``margins_mm`` is ``[top, bottom, left, right]``. + """ + name = _paper_key(paper) + width, height = paper_size_mm(name, _orientation(orientation)) + + style = str(plot_style or "").strip() + if not style: + raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)") + + label = str(scale or "").strip() + if label not in SCALES: + raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}") + ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label) + + if plot_area not in PLOT_AREAS: + raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}") + + device_name = str(device or "").strip() + if not device_name: + raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')") + + margins = None + if margins_mm is not None: + try: + values = [float(v) for v in margins_mm] + except (TypeError, ValueError): + raise ValueError( + "margins_mm: must be four numbers [top, bottom, left, right]" + ) from None + if len(values) != 4: + raise ValueError( + f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}" + ) + if any(v < 0 for v in values): + raise ValueError("margins_mm: margins cannot be negative") + top, bottom, left, right = values + if top + bottom >= height or left + right >= width: + raise ValueError( + f"margins_mm: {values} leave no printable area on a " + f"{width:g} x {height:g} mm sheet" + ) + margins = values + + if not isinstance(center, bool): + raise ValueError(f"center: must be true or false, got {center!r}") + + return { + "paper": name, + "orientation": orientation, + "size_mm": [width, height], + "canonical_media_name": canonical_media_name(name, orientation), + "plot_style": style, + "plot_style_known": plot_style_known(style), + "scale": label, + "scale_factor": SCALES[label], + "scale_ratio": [ratio[0], ratio[1]], + "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label), + "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label), + "plot_area": plot_area, + "plot_type": PLOT_AREAS[plot_area], + "device": device_name, + "margins_mm": margins, + "center": center, + } + + +#: Every key resolve_page_setup emits that a backend reads. A backend refuses +#: a dict missing any of them before it writes. +REQUIRED_SETUP_KEYS: tuple[str, ...] = ( + "paper", + "orientation", + "size_mm", + "canonical_media_name", + "plot_style", + "plot_style_known", + "scale", + "scale_ratio", + "dxf_standard_scale_type", + "activex_standard_scale", + "plot_area", + "plot_type", + "device", + "margins_mm", + "center", +) + + +def require_page_setup(setup) -> dict: + """The backend-side check: ``setup`` is a complete resolve_page_setup dict.""" + if not isinstance(setup, dict): + raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns") + missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup] + if missing: + raise ValueError( + f"page_setup_apply: setup is missing {', '.join(missing)} — build it with " + "engineering.standards.papers.resolve_page_setup" + ) + return setup +``` + +- [ ] **Step 4: The PDF reader — `engineering/standards/pdfinfo.py`** + +```python +# engineering/standards/pdfinfo.py +"""Read a PDF's page size back from the file — no dependency, no rendering. + +The evidence rule for page setup (spec §8.3): the sheet size a plot produced is +what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the +one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both +write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a +FlateDecode object stream, so when the plain scan finds nothing every stream is +inflated and searched. Anything else raises rather than guessing. +""" + +from __future__ import annotations + +import re +import zlib +from pathlib import Path + +__all__ = ["PT_TO_MM", "read_mediabox"] + +#: PostScript points to millimetres. +PT_TO_MM = 25.4 / 72.0 + +_NUMBER = rb"([-+]?\d*\.?\d+)" +_MEDIABOX_RE = re.compile( + rb"/MediaBox\s*\[\s*" + _NUMBER + rb"\s+" + _NUMBER + rb"\s+" + _NUMBER + rb"\s+" + _NUMBER + rb"\s*\]" +) +_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL) + + +def read_mediabox(path: str) -> tuple[float, float]: + """Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``. + + Raises ``ValueError`` when the file is not a PDF or carries no MediaBox. + ``/Rotate`` is not applied: the box is reported as written. + """ + data = Path(path).read_bytes() + if not data.startswith(b"%PDF"): + raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)") + match = _MEDIABOX_RE.search(data) + if match is None: + for stream in _STREAM_RE.finditer(data): + try: + inflated = zlib.decompress(stream.group(1)) + except zlib.error: + continue + match = _MEDIABOX_RE.search(inflated) + if match is not None: + break + if match is None: + raise ValueError(f"read_mediabox: no /MediaBox found in {path}") + x0, y0, x1, y1 = (float(value) for value in match.groups()) + return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3)) +``` + +- [ ] **Step 5: The contract — `backends/contracts/pagesetup.py`** + +```python +# backends/contracts/pagesetup.py +"""Page setup, plot styles and templates (v1.6 track E, group P). + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class PageSetupContract(ABC): + @abstractmethod + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + """Page setup of one paper-space layout, or of every one. + + Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h], + orientation, plot_style, scale, plot_area, device, + margins_mm: [top, bottom, left, right], center}``. ``paper`` is the + catalogue name when the size matches one within 0.5 mm, otherwise the + media name as stored. Raises ``ValueError`` for an unknown layout. + ``Model`` is never listed: model space is plotted through + ``drawing_export_pdf(layout=None)`` and carries no sheet. + """ + ... + + @abstractmethod + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + """Write a page setup resolved by + ``engineering.standards.papers.resolve_page_setup`` to a layout. + + Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]}, + plot_style_known, viewports_kept}``. ``changed`` is computed from the + read-back before and after, so re-applying the same setup reports + ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a + resolved key, before anything is written. Never deletes viewports. + """ + ... + + @abstractmethod + async def plot_style_list(self) -> list[dict]: + """``[{name, source: "catalog"|"installed", installed: bool|None}]`` — + the authored ctb catalogue plus, on a live engine, the files present + in the plot style search path. ``installed`` is ``None`` where no + installation can be scanned.""" + ... + + @abstractmethod + async def drawing_template_save( + self, path: str, name: str | None = None, description: str | None = None + ) -> dict: + """Save the current drawing as a template. + + ``.dxf`` writes on both engines; ``.dwt`` writes on the live engine + (``SaveAs(path, ac2018_Template)``) and raises + ``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns + ``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``. + """ + ... +``` + +Register it. In `backends/contracts/__init__.py` add `from backends.contracts.pagesetup import PageSetupContract` (alphabetical, after `LinetypeContract`) and `"PageSetupContract"` to `__all__`. In `backends/base.py` add `PageSetupContract` to the `from backends.contracts import (...)` list and to the `AutoCADBackend(...)` bases directly after `LayoutContract,` — **only if not already present** (another group may have merged first). + +- [ ] **Step 6: ezdxf implementation** + +Add the capability line to `EzdxfBackend.capabilities()` after `"paper_space": FeatureCapability(True, "native"),` — an existing key no other group touches, so this hunk never conflicts (S, C and V all insert after `"lisp"`; skeleton, Merge notes): + +```python + "dwt_write": FeatureCapability( + False, reason="dwt_is_a_dwg_container_requires_live_autocad" + ), +``` + +Add the block after the change-space block (immediately before ` # ── 3D solids (unsupported headlessly …)`) — group P's reserved anchor; S's block sits before `# ── corner ops`, C's at the very end of the class, V's directly after the layouts block (skeleton, Merge notes). Tasks 18 and 20 edit inside this block: + +```python + # ── page setup (track E) ───────────────────────────────────────────────── + + def _paper_layout(self, raw: str, operation: str): + """Resolve a paper-space layout by name, refusing Model and unknowns.""" + doc = self._require_doc() + resolved = self._find_layout(raw) + if resolved is None: + available = [n for n in doc.layouts.names_in_taborder() if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.layouts.get(resolved) + + @staticmethod + def _page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + dxf_scale_label, + paper_from_size, + scale_label, + ) + + dxf = layout.dxf_layout.dxf + flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) + # DXF stores every paper size and margin in millimetres, whatever + # plot_paper_units says (ezdxf's acdb_plot_settings, verified). + width, height = float(dxf.paper_width), float(dxf.paper_height) + rotation = int(dxf.plot_rotation) + if rotation in (1, 3): + width, height = height, width + media = str(dxf.paper_size) + numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) + if flags & layout.USE_STANDARD_SCALE: + scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator) + else: + scale = scale_label(numerator, denominator) + return { + "layout": layout.name, + "paper": paper_from_size(width, height) or media, + "canonical_media_name": media, + "size_mm": [round(width, 2), round(height, 2)], + "orientation": "landscape" if width >= height else "portrait", + "plot_style": str(dxf.current_style_sheet), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)), + "device": str(dxf.plot_configuration_file), + "margins_mm": [ + round(float(dxf.top_margin), 3), + round(float(dxf.bottom_margin), 3), + round(float(dxf.left_margin), 3), + round(float(dxf.right_margin), 3), + ], + "center": bool(flags & layout.PLOT_CENTERED), + } + + @staticmethod + def _paper_frame(layout) -> dict: + """The sheet as a matplotlib figure: size in inches plus the axes window. + + Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper. + ``plot_rotation`` 1/3 swaps the figure; the content is not rotated by + the headless renderer (reported, not hidden). + """ + dxf = layout.dxf_layout.dxf + width, height = float(dxf.paper_width), float(dxf.paper_height) + rotation = int(dxf.plot_rotation) + unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 + x0 = -(float(dxf.left_margin) + float(dxf.plot_origin_x_offset)) + y0 = -(float(dxf.bottom_margin) + float(dxf.plot_origin_y_offset)) + fig_w, fig_h = (height, width) if rotation in (1, 3) else (width, height) + return { + "size_mm": [round(fig_w, 2), round(fig_h, 2)], + "figsize": (fig_w / 25.4, fig_h / 25.4), + "xlim": (x0 / unit, (x0 + width) / unit), + "ylim": (y0 / unit, (y0 + height) / unit), + "rotation_applied": rotation == 0, + } + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = self._require_doc() + if layout is not None and self._layout_name(layout): + return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))] + return [ + self._page_setup_row(doc.layouts.get(name)) + for name in doc.layouts.names_in_taborder() + if name != "Model" + ] + + return await self._async(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import require_page_setup + + resolved = require_page_setup(setup) + + def _sync(): + from ezdxf.lldxf.const import STD_SCALES + + target = self._paper_layout(layout, "page_setup_apply") + before = self._page_setup_row(target) + dxf = target.dxf_layout.dxf + # Measured: a fresh layout has no stored plot_layout_flags, and + # set_flag_state starts from 0 — toggling one bit would drop + # plot-styles (32), lineweights (128) and viewports-first (512). + if not dxf.hasattr("plot_layout_flags"): + dxf.plot_layout_flags = dxf.get_default("plot_layout_flags") + width, height = (float(v) for v in resolved["size_mm"]) + dxf.page_setup_name = "" + dxf.plot_configuration_file = resolved["device"] + dxf.paper_size = resolved["canonical_media_name"] + dxf.paper_width = width + dxf.paper_height = height + dxf.plot_paper_units = 1 # millimetres + dxf.plot_rotation = 0 # orientation rides on width/height, as AutoCAD names media + if resolved["margins_mm"] is not None: + top, bottom, left, right = (float(v) for v in resolved["margins_mm"]) + dxf.top_margin, dxf.bottom_margin = top, bottom + dxf.left_margin, dxf.right_margin = left, right + dxf.plot_origin_x_offset = 0.0 + dxf.plot_origin_y_offset = 0.0 + dxf.unit_factor = 1.0 + dxf.current_style_sheet = resolved["plot_style"] + target.use_plot_styles(True) + target.set_plot_type(int(resolved["plot_type"])) + code = resolved["dxf_standard_scale_type"] + if code is not None: + dxf.standard_scale_type = int(code) + numerator, denominator = STD_SCALES.get(int(code), (1.0, 1.0)) + dxf.scale_numerator, dxf.scale_denominator = float(numerator), float(denominator) + target.use_standard_scale(True) + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + dxf.standard_scale_type = 16 + dxf.scale_numerator, dxf.scale_denominator = numerator, denominator + target.use_standard_scale(False) + target.plot_centered(bool(resolved["center"])) + # Limits follow the paper; viewports are deliberately left alone + # (Paperspace.page_setup() would delete them). + target.reset_paper_limits() + self._mark_dirty() + after = self._page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.name, + "applied": dict(resolved), + "changed": changed, + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def plot_style_list(self) -> list[dict]: + from engineering.standards.papers import CTB_CATALOG + + return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + # Completed in Task 20; declared here so the ABC instantiates. + raise UnsupportedCapabilityError( + "dwt_write", "drawing_template_save: not implemented yet (Task 20)" + ) +``` + +Then the delimited edit inside `drawing_export_pdf` — replace the **seven** lines from `fig = _new_agg_figure()` through `result = {"ok": True, "path": path}` (`fig = …`, `ax = …`, `ctx = …`, `out = …`, `Frontend(ctx, out).draw_layout(…)`, `fig.savefig(…)`, `result = …`; the `from ezdxf.addons.drawing …` imports above them and the `if resolved is not None and resolved != "Model":` block below them stay) with: + +```python + # Track E: for a sheet, the figure *is* the paper. Sized from + # the layout's page setup and left unadjusted, so the PDF's + # /MediaBox is the paper size — the evidence page_setup_apply + # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6 + # pt whatever the sheet was. + paper = None + if resolved is not None and resolved != "Model": + paper = self._paper_frame(target) + fig = _new_agg_figure(figsize=paper["figsize"] if paper else None) + ax = fig.add_axes([0, 0, 1, 1]) + ctx = RenderContext(doc) + out = MatplotlibBackend(ax, adjust_figure=paper is None) + Frontend(ctx, out).draw_layout(target, finalize=True) + if paper is not None: + ax.set_xlim(*paper["xlim"]) + ax.set_ylim(*paper["ylim"]) + ax.set_aspect("equal", adjustable="box") + fig.savefig(path, dpi=150) + result = {"ok": True, "path": path} + if paper is not None: + result["paper_mm"] = paper["size_mm"] + if not paper["rotation_applied"]: + result["rotation_applied"] = False +``` + +(`tests/test_viewport_ops.py::_ink` counts the pixels of a PNG rendered through this path; a layout that was never set up now renders at the DXF default 420 × 297 mm — 2480 × 1754 px at 150 dpi — so that test runs a few seconds longer. It still passes: its subtraction argument does not depend on the raster size.) + +- [ ] **Step 7: COM implementation** + +Add to `ComBackend.capabilities()` after `"paper_space": FeatureCapability(True, "native"),` (same rule as on ezdxf — an anchor nobody else touches): + +```python + "dwt_write": FeatureCapability(True, "native"), +``` + +Add the block immediately before ` # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer)` (after the viewports block — the same position as on ezdxf; the end of the layouts block, right after `layout_copy`, is group V's anchor and must stay untouched): + +```python + # ── page setup (track E) ───────────────────────────────────────────────── + # + # Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026 + # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0; + # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16 + # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE). + # Fake-tested in tests/test_page_setup.py; executed live by + # scripts/smoke_settings_com.py (Task 24). + + _AC_MILLIMETERS = 1 + _AC_NO_ROTATION = 0 + + @staticmethod + def _com_page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + activex_scale_label, + paper_from_size, + scale_label, + ) + + def _get(attr): + try: + return getattr(layout, attr) + except Exception as exc: + log.debug("Layout.%s read failed: %s", attr, exc) + return None + + units = _get("PaperUnits") + factor = 25.4 if units == 0 else 1.0 + size = None + try: + width, height = layout.GetPaperSize() + size = [round(float(width) * factor, 2), round(float(height) * factor, 2)] + except Exception as exc: + log.debug("Layout.GetPaperSize failed: %s", exc) + margins = None + try: + (left, bottom), (right, top) = layout.GetPaperMargins() + margins = [round(float(v) * factor, 3) for v in (top, bottom, left, right)] + except Exception as exc: + log.debug("Layout.GetPaperMargins failed: %s", exc) + rotation = _get("PlotRotation") or 0 + if size and int(rotation) in (1, 3): + size = [size[1], size[0]] + if _get("UseStandardScale"): + scale = activex_scale_label(_get("StandardScale") or 0) + else: + try: + numerator, denominator = layout.GetCustomScale() + scale = scale_label(float(numerator), float(denominator)) + except Exception as exc: + log.debug("Layout.GetCustomScale failed: %s", exc) + scale = None + media = _get("CanonicalMediaName") + plot_type = _get("PlotType") + return { + "layout": layout.Name, + "paper": (paper_from_size(*size) if size else None) or media, + "canonical_media_name": media, + "size_mm": size, + "orientation": ( + None if not size else ("landscape" if size[0] >= size[1] else "portrait") + ), + "plot_style": _get("StyleSheet"), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), + "device": _get("ConfigName"), + "margins_mm": margins, + "center": bool(_get("CenterPlot")), + } + + def _com_paper_layout(self, doc, raw: str, operation: str): + resolved = self._find_layout(doc, raw) + if resolved is None: + available = [n for n in self._layout_names(doc) if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.Layouts.Item(resolved) + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = _acad_doc() + if layout is not None and self._layout_name(layout): + target = self._com_paper_layout(doc, layout, "page_setup_list") + return [self._com_page_setup_row(target)] + return [ + self._com_page_setup_row(doc.Layouts.Item(name)) + for name in self._layout_names(doc) + if name != "Model" + ] + + return await self._run(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import require_page_setup + + resolved = require_page_setup(setup) + if resolved["margins_mm"] is not None: + raise ValueError( + "page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD " + "takes the printable margins from the plotter configuration (.pc3). Omit " + "margins_mm; the device's margins are read back in the result." + ) + + def _sync(): + doc = _acad_doc() + target = self._com_paper_layout(doc, layout, "page_setup_apply") + before = self._com_page_setup_row(target) + previous_device = before["device"] + target.RefreshPlotDeviceInfo() + target.ConfigName = resolved["device"] + target.RefreshPlotDeviceInfo() + media = resolved["canonical_media_name"] + names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] + if media not in names: + # Roll the one write back before refusing, so a refusal leaves + # the layout as it was. + target.ConfigName = previous_device + nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] + raise ValueError( + f"page_setup_apply: device {resolved['device']!r} has no media " + f"{media!r} (same paper on this device: {', '.join(nearest) or 'none'}; " + f"{len(names)} media in total)" + ) + target.CanonicalMediaName = media + target.PaperUnits = self._AC_MILLIMETERS + target.PlotRotation = self._AC_NO_ROTATION + target.StyleSheet = resolved["plot_style"] + target.PlotWithPlotStyles = True + target.PlotType = int(resolved["plot_type"]) + code = resolved["activex_standard_scale"] + if code is not None: + target.StandardScale = int(code) + target.UseStandardScale = True + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + target.SetCustomScale(numerator, denominator) + target.UseStandardScale = False + target.CenterPlot = bool(resolved["center"]) + after = self._com_page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.Name, + "applied": dict(resolved), + "changed": changed, + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "com", + } + + return await self._run(_sync) + + async def plot_style_list(self) -> list[dict]: + # Completed in Task 18 (installed ctb scan); the catalogue rows already answer. + from engineering.standards.papers import CTB_CATALOG + + return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + # Completed in Task 20; declared here so the ABC instantiates. + raise UnsupportedCapabilityError( + "dwt_write", "drawing_template_save: not implemented yet (Task 20)" + ) +``` + +`UnsupportedCapabilityError` is already imported into `com_backend.py` from `.base` (check the `from .base import (...)` list; add it there if it is not). + +- [ ] **Step 8: Run the tests** + +Run: `uv run --frozen pytest tests/test_page_setup.py tests/test_capability_contract.py tests/test_viewport_ops.py tests/test_layouts_solids.py -v` +Expected: all PASS. `test_capability_contract` proves both backends still instantiate with the new abstract members. + +- [ ] **Step 9: Commit** + +```bash +git add engineering/standards/papers.py engineering/standards/pdfinfo.py backends/contracts/pagesetup.py backends/contracts/__init__.py backends/base.py backends/ezdxf_backend.py backends/com_backend.py tests/test_page_setup.py +git commit -m "feat(pagesetup): page_setup_list/apply on both engines — the PDF's own /MediaBox is the evidence" +``` + +--- + +### Task 18: SECTION 19 opens — `page_setup_list`, `page_setup_apply`, `plot_style_list`, `batch_plot` (+ installed-ctb scan, mediabox read-back) + +**Files:** +- Create: `engineering/standards/plot.py` +- Modify: `backends/com_backend.py` (`plot_style_list` in the `# ── page setup (track E) ──` block: installed ctb scan) +- Modify: `server.py` — new `# ── SECTION 19: Page Setup & Templates (4 tools) ──` inserted immediately before the `# ── RESOURCES` banner (group S inserts SECTION 18 at the same spot; the merge agent orders them 18 then 19) +- Modify: `discovery/aliases.py` — `SHARED_ACAD_COMMANDS` gains `PAGESETUP` and `PLOT`; a `# ── Page setup & templates (track E) ──` alias block +- Modify: `README.md` snapshot line `166 tools` → `170 tools`, the `166 tools in 20 groups` line → `170 tools in 20 groups`, "Paper space" row; regenerate `docs/tool-inventory.json` +- Modify: `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` — `layouts: 12 → 16`, total `166 → 170`, docstring sentence +- Test: `tests/test_batch_plot.py` + +**Interfaces:** +- Produces (`engineering/standards/plot.py`): `async def batch_plot(backend, layouts, output_dir, file_pattern="{drawing}-{layout}.pdf") -> dict` → `{ok, drawing, output_dir, format: "pdf", sheets: [{layout, ok, path, bytes, mediabox_mm: [w, h] | None, paper: str | None, error?}], count}`; `safe_filename(text) -> str`. +- Produces (MCP tools): `page_setup_list(layout=None)`, `page_setup_apply(layout, paper, orientation="landscape", plot_style="monochrome.ctb", scale="fit", plot_area="layout", device="DWG To PDF.pc3", margins_mm=None, center=True)`, `plot_style_list()`, `batch_plot(output_dir, layouts=None, format="pdf", file_pattern="{drawing}-{layout}.pdf")`. (The spec writes `batch_plot(layouts=None, output_dir, …)`; a required parameter cannot follow a defaulted one in Python, so `output_dir` is first.) +- Pack: all four are `core`. Lean: `page_setup_apply` and `batch_plot` join `LEAN_TOOL_NAMES` in the final task (spec §8.2); `page_setup_list` and `plot_style_list` do not. +- Consumes: `resolve_page_setup`, `read_mediabox`, `validate_path(allow_write=True)`, `backend.layout_list/drawing_info/drawing_export_pdf`, `_acad_app().Preferences.Files.PrintStyleSheetPath`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_batch_plot.py +"""batch_plot: every sheet to PDF, each sheet size read back from the PDF. + +The composition is exercised on the headless engine end to end; the COM-only +piece of this task (the installed ctb scan) is fake-tested; the SECTION 19 +tools are called directly with a minimal context. +""" + +from __future__ import annotations + +import types + +import pytest +from fastmcp.exceptions import ToolError + +import server +from engineering.standards.papers import CTB_CATALOG, resolve_page_setup +from engineering.standards.plot import batch_plot, safe_filename + +pytestmark = pytest.mark.asyncio + + +class _Ctx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, message): + pass + + async def warning(self, message): + pass + + async def report_progress(self, progress, total): + pass + + +async def _two_sheets(backend): + """Exactly two paper layouts: the default tab renamed, plus one more.""" + await backend.layout_rename("Layout1", "A3") + await backend.layout_create("B") + await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape")) + await backend.page_setup_apply("B", resolve_page_setup("ANSI_B", "landscape")) + await backend.entity_create_line(0, 0, 100, 50) + + +def test_safe_filename_strips_what_a_filesystem_refuses(): + assert safe_filename("Sheet 1: A3/rev*B") == "Sheet_1_A3_rev_B" + assert safe_filename("Layout1") == "Layout1" + + +async def test_every_paper_layout_is_plotted_and_measured(backend, tmp_path): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _two_sheets(backend) + result = await batch_plot(backend, None, str(tmp_path / "out")) + assert result["ok"] is True and result["format"] == "pdf" + assert result["count"] == 2 + by_layout = {row["layout"]: row for row in result["sheets"]} + assert set(by_layout) == {"A3", "B"}, "Model is not plotted unless named" + a3, b = by_layout["A3"], by_layout["B"] + assert a3["ok"] and a3["bytes"] > 0 and a3["path"].endswith("untitled-A3.pdf") + assert abs(a3["mediabox_mm"][0] - 420) <= 0.5 and abs(a3["mediabox_mm"][1] - 297) <= 0.5 + assert a3["paper"] == "ISO_A3" + assert abs(b["mediabox_mm"][0] - 432) <= 0.5 and abs(b["mediabox_mm"][1] - 279) <= 0.5 + assert b["paper"] == "ANSI_B" + + +async def test_model_is_included_only_when_named(backend, tmp_path): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _two_sheets(backend) + result = await batch_plot(backend, ["Model", "A3"], str(tmp_path)) + names = [row["layout"] for row in result["sheets"]] + assert names == ["Model", "A3"] + model = result["sheets"][0] + assert model["ok"] and model["mediabox_mm"] is not None + assert model["paper"] is None, "model space has no sheet; the size is reported, not named" + + +async def test_refusals_write_nothing(backend, tmp_path): + await _two_sheets(backend) + out = tmp_path / "nothing" + with pytest.raises(ValueError, match="Nope"): + await batch_plot(backend, ["Nope"], str(out)) + with pytest.raises(ValueError, match="layout"): + await batch_plot(backend, None, str(out), file_pattern="all.pdf") + with pytest.raises(ValueError, match="file_pattern"): + await batch_plot(backend, None, str(out), file_pattern="sub/{layout}.pdf") + with pytest.raises(ValueError, match="layouts"): + await batch_plot(backend, [], str(out)) + assert not out.exists() or not any(out.iterdir()) + + +async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_size(backend, tmp_path): + """No page setup ever applied: the sheet still plots, at ezdxf's stored + default (A3, 420 x 297), and the row says so rather than guessing.""" + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + result = await batch_plot(backend, None, str(tmp_path), file_pattern="{layout}.pdf") + assert [row["layout"] for row in result["sheets"]] == ["Layout1"] + row = result["sheets"][0] + assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3" + + +# ── the tools ─────────────────────────────────────────────────────────────── + + +async def test_page_setup_tools_round_trip(backend): + ctx = _Ctx(backend) + await backend.layout_create("A3") + applied = await server.page_setup_apply("A3", "ISO_A3", ctx=ctx) + assert applied["ok"] and applied["applied"]["paper"] == "ISO_A3" + listed = await server.page_setup_list(ctx=ctx) + assert listed["count"] == 1 and listed["layouts"][0]["paper"] == "ISO_A3" + only = await server.page_setup_list("A3", ctx=ctx) + assert only["layouts"][0]["layout"] == "A3" + + +async def test_page_setup_apply_tool_refuses_before_writing(backend): + ctx = _Ctx(backend) + await backend.layout_create("A3") + with pytest.raises(ToolError, match="scale"): + await server.page_setup_apply("A3", "ISO_A3", scale="1:3", ctx=ctx) + with pytest.raises(ToolError, match="Model"): + await server.page_setup_apply("Model", "ISO_A3", ctx=ctx) + row = (await backend.page_setup_list("A3"))[0] + assert row["device"] == "", "nothing was written" + + +async def test_plot_style_list_tool(backend): + result = await server.plot_style_list(ctx=_Ctx(backend)) + assert result["ok"] and result["count"] == len(CTB_CATALOG) + assert result["backend"] == "ezdxf" + + +async def test_batch_plot_tool_validates_the_output_directory(backend, tmp_path): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _two_sheets(backend) + with pytest.raises(ToolError): + await server.batch_plot("../../outside", ctx=_Ctx(backend)) + with pytest.raises(ToolError, match="format"): + await server.batch_plot(str(tmp_path), format="png", ctx=_Ctx(backend)) + result = await server.batch_plot(str(tmp_path), layouts=["A3"], ctx=_Ctx(backend)) + assert result["count"] == 1 and result["sheets"][0]["paper"] == "ISO_A3" + + +# ── COM: the installed ctb scan ───────────────────────────────────────────── + + +@pytest.fixture +def com_backend(monkeypatch, tmp_path): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + styles = tmp_path / "Plot Styles" + styles.mkdir() + (styles / "monochrome.ctb").write_bytes(b"") + (styles / "Anka.ctb").write_bytes(b"") + (styles / "readme.txt").write_bytes(b"") + app = types.SimpleNamespace( + Preferences=types.SimpleNamespace( + Files=types.SimpleNamespace(PrintStyleSheetPath=f"{styles};C:\\does\\not\\exist") + ) + ) + monkeypatch.setattr(module, "_acad_app", lambda: app) + 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_plot_style_list_marks_installed_and_adds_local_files(com_backend): + backend, _ = com_backend + rows = await backend.plot_style_list() + by_name = {row["name"]: row for row in rows} + assert by_name["monochrome.ctb"] == {"name": "monochrome.ctb", "source": "catalog", "installed": True} + assert by_name["acad.ctb"]["installed"] is False + assert by_name["Anka.ctb"] == {"name": "Anka.ctb", "source": "installed", "installed": True} + assert "readme.txt" not in by_name + + +async def test_com_plot_style_list_survives_an_unreachable_preferences_object(com_backend, monkeypatch): + backend, _ = com_backend + from backends import com_backend as module + + def _boom(): + raise RuntimeError("no application") + + monkeypatch.setattr(module, "_acad_app", _boom) + rows = await backend.plot_style_list() + assert [row["name"] for row in rows] == list(CTB_CATALOG) + assert all(row["installed"] is None for row in rows) +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_batch_plot.py -v` +Expected: collection FAILS with `ModuleNotFoundError: No module named 'engineering.standards.plot'`. + +- [ ] **Step 3: The composition — `engineering/standards/plot.py`** + +```python +# engineering/standards/plot.py +"""batch_plot: every sheet through drawing_export_pdf, each PDF measured back. + +Composes the existing per-layout export; the only thing it adds is the +evidence — ``mediabox_mm`` is parsed from the file each sheet produced, so a +caller sees the size that was plotted, not the size that was asked for. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from engineering.standards.papers import paper_from_size +from engineering.standards.pdfinfo import read_mediabox + +__all__ = ["DEFAULT_FILE_PATTERN", "batch_plot", "safe_filename"] + +DEFAULT_FILE_PATTERN = "{drawing}-{layout}.pdf" + +_UNSAFE = re.compile(r"[<>:\"/\\|?*\s]+") + + +def safe_filename(text: str) -> str: + """A layout or drawing name as a filename fragment: no separators, no + reserved characters, runs of them collapsed to one underscore.""" + cleaned = _UNSAFE.sub("_", str(text or "").strip()).strip("_") + return cleaned or "untitled" + + +async def batch_plot( + backend, + layouts: list[str] | None, + output_dir: str, + file_pattern: str = DEFAULT_FILE_PATTERN, +) -> dict: + """Plot layouts to PDF and read each sheet size back from the file. + + ``layouts=None`` plots every paper-space layout; ``Model`` is plotted only + when named. Refuses (``ValueError``, before any PDF is written): an empty + list, a layout that does not exist, a pattern with a path separator, and + a pattern without ``{layout}`` when more than one sheet would be written. + The output folder is created, then every output path goes through + ``security.validate_path(allow_write=True)`` before the first export. + """ + from security import validate_path + + listing = await backend.layout_list() + available = [str(name) for name in (listing.get("layouts") or [])] + lowered = {name.lower(): name for name in available} + if layouts is None: + targets = [name for name in available if name.lower() != "model"] + if not targets: + raise ValueError("batch_plot: the drawing has no paper-space layout to plot") + else: + requested = [str(name).strip() for name in layouts] + if not requested: + raise ValueError("batch_plot: layouts is empty — pass null for every sheet") + unknown = [name for name in requested if name.lower() not in lowered] + if unknown: + raise ValueError( + f"batch_plot: layout(s) not found: {', '.join(unknown)} " + f"(available: {', '.join(available)})" + ) + targets = [lowered[name.lower()] for name in requested] + + pattern = str(file_pattern or DEFAULT_FILE_PATTERN) + if "/" in pattern or "\\" in pattern: + raise ValueError("batch_plot: file_pattern must be a file name, not a path") + if "{layout}" not in pattern and len(targets) > 1: + raise ValueError( + "batch_plot: file_pattern needs {layout} when more than one sheet is plotted, " + "or every sheet would overwrite the last" + ) + try: + pattern.format(drawing="x", layout="y") + except (KeyError, IndexError, ValueError) as exc: + raise ValueError( + f"batch_plot: file_pattern {pattern!r} may only use {{drawing}} and {{layout}}" + ) from exc + + info = await backend.drawing_info() + drawing = safe_filename(Path(info.name).stem) + destination = Path(validate_path(output_dir, allow_write=True)) + planned = [ + (name, destination / pattern.format(drawing=drawing, layout=safe_filename(name))) + for name in targets + ] + # validate_path(allow_write=True) requires the parent folder to exist, so + # the output folder is created before the per-file fence check; an empty + # folder is the most a refused call leaves behind. + destination.mkdir(parents=True, exist_ok=True) + for _name, path in planned: + validate_path(str(path), allow_write=True) + + sheets: list[dict] = [] + for name, path in planned: + is_model = name.lower() == "model" + result = await backend.drawing_export_pdf(str(path), layout=None if is_model else name) + if not result.get("ok", False): + sheets.append({"layout": name, "ok": False, "path": str(path), "error": result.get("error")}) + continue + row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size} + try: + width, height = read_mediabox(str(path)) + row["mediabox_mm"] = [width, height] + row["paper"] = None if is_model else paper_from_size(width, height) + except ValueError as exc: + row["mediabox_mm"] = None + row["paper"] = None + row["error"] = str(exc) + sheets.append(row) + + return { + "ok": all(row["ok"] for row in sheets), + "drawing": drawing, + "output_dir": str(destination), + "format": "pdf", + "sheets": sheets, + "count": len(sheets), + } +``` + +- [ ] **Step 4: COM `plot_style_list` — the installed scan** + +Replace the Task 17 stub in `backends/com_backend.py`: + +```python + async def plot_style_list(self) -> list[dict]: + """The ctb catalogue, marked against the files in AutoCAD's plot style path. + + ``Preferences.Files.PrintStyleSheetPath`` is a ``;``-separated list of + folders. Every read is guarded: a folder that cannot be listed is + skipped, and when the Preferences object is unreachable the catalogue + rows come back with ``installed: None`` rather than a fabricated False. + """ + from engineering.standards.papers import CTB_CATALOG + + def _sync(): + rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + try: + raw = str(_acad_app().Preferences.Files.PrintStyleSheetPath or "") + except Exception as exc: + log.debug("Preferences.Files.PrintStyleSheetPath unreadable: %s", exc) + return rows + installed: dict[str, str] = {} + for folder in (part.strip() for part in raw.split(";")): + if not folder: + continue + try: + entries = sorted(Path(folder).iterdir()) + except OSError as exc: + log.debug("plot style folder %r not listable: %s", folder, exc) + continue + for entry in entries: + if entry.suffix.lower() in (".ctb", ".stb"): + installed.setdefault(entry.name.lower(), entry.name) + catalog_lower = {row["name"].lower() for row in rows} + for row in rows: + row["installed"] = row["name"].lower() in installed + for key, name in installed.items(): + if key not in catalog_lower: + rows.append({"name": name, "source": "installed", "installed": True}) + return rows + + return await self._run(_sync) +``` + +- [ ] **Step 5: Register SECTION 19 in `server.py`** + +Insert immediately before the `# ── RESOURCES` banner: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 19: Page Setup & Templates (4 tools) ──────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool(summary="Read each sheet's page setup: paper, orientation, ctb, scale, device.", cost="read") +@mcp.tool( + annotations={"title": "List Page Setups", "readOnlyHint": True}, + tags={"layout", "plot", "query"}, +) +async def page_setup_list( + layout: Annotated[ + str | None, "One paper-space layout, or omit for every sheet (Model is never listed)." + ] = None, + ctx: Context = None, +) -> dict: + """The page setup stored on each paper-space layout. + + `paper` is the catalogue name (ISO_A3, ANSI_B, …) when the stored size + matches one within 0.5 mm, otherwise the media name as stored; `size_mm`, + `orientation`, `plot_style` (ctb), `scale` ("fit", "1:50", or a custom + ratio), `plot_area`, `device`, `margins_mm` [top, bottom, left, right] and + `center` are read back from the LAYOUT object (headless) or the AutoCAD + Layout (live). Refuses an unknown layout name and `Model` (model space has + no sheet; plot it with `drawing_export_pdf(layout=None)`). + """ + try: + rows = await _backend(ctx).page_setup_list(layout) + except ValueError as exc: + raise ToolError(str(exc)) from exc + return {"ok": True, "layouts": rows, "count": len(rows)} + + +@cad_tool( + summary="Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).", + cost="safe", +) +@mcp.tool( + annotations={"title": "Apply Page Setup", "destructiveHint": False}, + tags={"layout", "plot"}, +) +async def page_setup_apply( + layout: Annotated[str, "Paper-space layout to set up (never 'Model')."], + paper: Annotated[ + str, "ISO_A0…ISO_A4 or ANSI_A…ANSI_E (short forms A3, ansi_b accepted)." + ], + orientation: Annotated[str, "landscape | portrait"] = "landscape", + plot_style: Annotated[ + str, "Plot style table, e.g. monochrome.ctb, acad.ctb, Grayscale.ctb (plot_style_list)." + ] = "monochrome.ctb", + scale: Annotated[ + str, "fit | 1:1 | 1:2 | 1:5 | 1:10 | 1:20 | 1:50 | 1:100 | 2:1 | 5:1 | 10:1" + ] = "fit", + plot_area: Annotated[str, "layout | extents"] = "layout", + device: Annotated[str, "Plotter configuration (.pc3) or printer name."] = "DWG To PDF.pc3", + margins_mm: Annotated[ + list[float] | None, + "[top, bottom, left, right] in mm. Headless only: AutoCAD takes margins from the .pc3.", + ] = None, + center: Annotated[bool, "Centre the plot on the paper."] = True, + ctx: Context = None, +) -> dict: + """AutoCAD's PAGESETUP as one call, on both engines. + + Paper sizes are ISO 216 / ANSI Y14.1; the media is written in AutoCAD's own + spelling (`ISO_A3_(420.00_x_297.00_MM)`). `changed` lists only the values + that moved — re-applying the same setup reports `{}`. An unknown ctb is + written and reported `plot_style_known: false` (a missing ctb only matters + at plot time). Viewports on the sheet are never touched. + + Refused before anything is written: an unknown paper, orientation, scale + or plot_area, an empty device or plot_style, margins that leave no + printable area, `Model`, an unknown layout. On the live engine `margins_mm` + is refused (the .pc3 owns them) and a media the device does not offer is + refused with the device's names for the same paper. + + The proof is the PDF: `batch_plot` reads each sheet's `/MediaBox` back. + """ + from engineering.standards.papers import resolve_page_setup + + try: + setup = resolve_page_setup( + paper, orientation, plot_style, scale, plot_area, device, margins_mm, center + ) + await ctx.info(f"Applying page setup {setup['paper']} {orientation} to {layout}") + return await _backend(ctx).page_setup_apply(layout, setup) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool(summary="List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.", cost="read") +@mcp.tool( + annotations={"title": "List Plot Styles", "readOnlyHint": True}, + tags={"layout", "plot", "query"}, +) +async def plot_style_list(ctx: Context = None) -> dict: + """The ctb files AutoCAD ships (`monochrome.ctb`, `acad.ctb`, `Grayscale.ctb`, + the Screening set, …) and, on the live engine, the files actually present + in `Preferences.Files.PrintStyleSheetPath` (`source: "installed"`, + `installed: true/false` per row). Headlessly `installed` is `null`: there + is no installation to scan, and the catalogue is not evidence of one. + Never refuses; an unreadable Preferences object degrades to the catalogue. + """ + backend = _backend(ctx) + rows = await backend.plot_style_list() + return {"ok": True, "styles": rows, "count": len(rows), "backend": backend.name} + + +@cad_tool( + summary="Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Batch Plot", "destructiveHint": False}, + tags={"layout", "plot", "export"}, +) +async def batch_plot( + output_dir: Annotated[str, "Folder for the PDFs (created if missing)."], + layouts: Annotated[ + list[str] | None, + "Layouts to plot; omit for every paper-space layout. 'Model' only when named.", + ] = None, + format: Annotated[str, "pdf (the only format this release plots)."] = "pdf", + file_pattern: Annotated[ + str, "File name per sheet; {drawing} and {layout} are substituted." + ] = "{drawing}-{layout}.pdf", + ctx: Context = None, +) -> dict: + """AutoCAD's PUBLISH for a PDF set, through `drawing_export_pdf(layout=…)`. + + One row per sheet: `path`, `bytes`, and `mediabox_mm` **parsed from the + PDF that was written** (`/MediaBox`, points → mm), with `paper` naming the + catalogue size it matches within 0.5 mm — so the sheet size is verified, + never assumed. Model space is included only when named. + + Refused before any file is written: a format other than pdf, an empty + `layouts` list, a layout that does not exist, a `file_pattern` containing a + path separator or lacking `{layout}` when more than one sheet is plotted, + and any output path outside the allowed directories. A sheet whose PDF has + no readable MediaBox is reported with `mediabox_mm: null` and an `error`. + """ + from engineering.standards.plot import batch_plot as _batch_plot + + if str(format).lower() != "pdf": + raise ToolError(f"batch_plot: format must be 'pdf' (got {format!r}); PDF is the only plot format") + destination = validate_path(output_dir, allow_write=True) + await ctx.info(f"Batch plotting {layouts or 'every sheet'} to {destination}") + try: + return await _batch_plot(_backend(ctx), layouts, str(destination), file_pattern) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +- [ ] **Step 6: Aliases** + +In `discovery/aliases.py`, extend the comment block above `SHARED_ACAD_COMMANDS` and the set: + +```python +# PAGESETUP -- the Page Setup Manager both shows and edits a sheet's setup +# (page_setup_list vs page_setup_apply). +# PLOT -- one sheet to PDF (drawing_export_pdf) or every sheet in one go +# (batch_plot); a drafter typing it could mean either. +``` + +```python + "PAGESETUP", + "PLINE", + "PLOT", +``` + +Add the block at the end of `TOOL_ALIASES` (before the closing `}`), alphabetical within it: + +```python + # ── Page setup & templates (track E) ──────────────────────────────────── + "batch_plot": ToolAliases( + acad=("PUBLISH", "PLOT"), + synonyms=( + "print all sheets to pdf", + "plot every layout", + "publish the sheet set", + "batch print", + "one pdf per sheet", + "plot the whole drawing set", + ), + ), + "page_setup_apply": ToolAliases( + acad=("PAGESETUP",), + synonyms=( + "page setup", + "set the paper size", + "a3 landscape", + "plot scale", + "monochrome ctb", + "which printer", + "set up the sheet for printing", + ), + ), + "page_setup_list": ToolAliases( + acad=("PAGESETUP",), + synonyms=( + "what paper is this sheet", + "page setup of each layout", + "plot settings", + "sheet size and orientation", + "which ctb is set", + ), + ), + "plot_style_list": ToolAliases( + acad=("STYLESMANAGER",), + synonyms=( + "ctb files", + "plot style table", + "pen assignments", + "monochrome or grayscale", + "which plot styles are installed", + ), + ), +``` + +- [ ] **Step 7: Inventory, README, registry snapshot** + +Run `uv run --frozen python scripts/generate_tool_inventory.py`. In `README.md`: snapshot line `166 tools` → `170 tools`; the `166 tools in 20 groups` line → `170 tools in 20 groups`; the "Paper space" row becomes: + +``` +| 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=…)` | +``` + +In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: `"layouts": 12` → `16`, `assert sum(sizes.values()) == 166` → `170`, and add to the docstring: "`layouts` moved 12 -> 16 when v1.6's SECTION 19 opened with `page_setup_list` / `page_setup_apply` / `plot_style_list` / `batch_plot` — all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above `query`/`export`, so they file with the sheets they set up." Update the docstring's headline `(161 tools, 20 groups)` figure the same way the earlier bumps did (`170 tools, 20 groups`). + +- [ ] **Step 8: Run the gates** + +Run: `uv run --frozen pytest tests/test_batch_plot.py tests/test_page_setup.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v` +Expected: all PASS (`test_discovery_transform` is included because `PLOT` now routes to two tools; no golden case expects `drawing_export_pdf`, so the accuracy bars hold — the ≥ 10 new golden queries for PAGESETUP / PLOT / PUBLISH / SAVEAS are the final task's). (If `test_document_numbers_match_the_tree` fails only on the README `collected tests` figure, that drift predates this track and is the final task's; every other check must pass.) + +- [ ] **Step 9: Commit** + +```bash +git add engineering/standards/plot.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_batch_plot.py +git commit -m "feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back" +``` + +--- + +### Task 19: Five bundled templates built by the server's own tools, `drawing_new(template=name)`, `drawing_template_list` + +**Files:** +- Create: `engineering/standards/templates.py`, `scripts/build_templates.py`, `templates/iso_a3_mech.dxf`, `templates/iso_a1_arch.dxf`, `templates/iso_a3_pid.dxf`, `templates/ansi_b_mech.dxf`, `templates/ansi_d_arch.dxf` (built, then committed) +- Modify: `server.py` — `drawing_new` (template resolution + `template:` in the result), SECTION 19 header `(4 tools)` → `(5 tools)`, new tool `drawing_template_list` after `batch_plot` +- Modify: `discovery/aliases.py` — `drawing_template_list` record in the track-E block; `drawing_new` gains one synonym +- Modify: `pyproject.toml` (`only-include` += `"templates"`), `Dockerfile` (`COPY templates/ ./templates/`), `.gitattributes` (`templates/*.dxf text eol=crlf`, `templates/*.dwt binary`) — the wheel/Docker parity gate (`test_dockerfile_copies_every_wheel_include`) requires both edits together; no other group touches these files +- Modify: `README.md` snapshot `170 tools` → `171 tools`, `… 171 tools in 20 groups`, a "Templates" row; regenerate `docs/tool-inventory.json` +- Modify: `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` — `templates: 2 → 3`, total `170 → 171` +- Test: `tests/test_templates.py` + +**Interfaces:** +- Produces (`engineering/standards/templates.py`): `TEMPLATES_DIR: Path`, `TemplateSpec` (frozen dataclass: `name, standard, sheet, paper, orientation, layout, layer_set, dimstyle_preset, dimstyle, textstyle, scale, title_block, settings, description`), `TEMPLATE_CATALOG: dict[str, TemplateSpec]` (five rows, spec §6), `template_rows() -> list[dict]` (catalogue rows + `files: {dxf, dwt}` with `present` flags), `resolve_template(name_or_path: str, engine: str) -> tuple[str, str]` (`(path, "bundled"|"bundled_dxf"|"path")`, `ValueError` naming the catalogue on an unknown name or a missing bundled file). +- Produces (`scripts/build_templates.py`): `build_template(spec, out_dir) -> dict`, `build_all(out_dir) -> list[dict]`, `normalised_lines(path) -> list[str]`, `drifted_names(rebuilt_dir) -> list[str]` (the masked compare), `check_templates() -> list[str]` (CLI: temp rebuild + `drifted_names`), CLI `--check`, `--out DIR`. +- Produces (MCP): `drawing_new(template=)` returns `template: {name|path, path, source}`; tool `drawing_template_list()`. +- Pack `core`; lean: no (`drawing_new` already is; the list is a lookup). +- Consumes: `apply_layer_set`, `apply_iso_a3_titleblock`/`TitleBlockMetadata`, `resolve_page_setup`, `backend.page_setup_apply/layout_rename/layout_set_current/drawing_settings/system_set_variable/drawing_save_as`; group S's `dimstyle_create`/`textstyle_create` + `engineering.standards.dimstyles.resolve_dimstyle` / `textstyles.TEXT_PRESETS` **when present** (after the S merge), else header DIM variables. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_templates.py +"""Bundled templates: catalogue, resolution, reproducible build, drawing_new(template=).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest +from fastmcp.exceptions import ToolError + +import server +from engineering.standards.templates import ( + TEMPLATE_CATALOG, + TEMPLATES_DIR, + resolve_template, + template_rows, +) + +pytestmark = pytest.mark.asyncio + +ROOT = Path(__file__).resolve().parents[1] +NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch") + + +def _build_module(): + spec = importlib.util.spec_from_file_location( + "build_templates", ROOT / "scripts" / "build_templates.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class _Ctx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, message): + pass + + async def warning(self, message): + pass + + async def report_progress(self, progress, total): + pass + + +# ── catalogue ─────────────────────────────────────────────────────────────── + + +def test_catalog_has_the_five_spec_rows(): + assert tuple(TEMPLATE_CATALOG) == NAMES + rows = {spec.name: spec for spec in TEMPLATE_CATALOG.values()} + assert (rows["iso_a3_mech"].paper, rows["iso_a3_mech"].layer_set) == ("ISO_A3", "mech") + assert (rows["iso_a1_arch"].paper, rows["iso_a1_arch"].layer_set) == ("ISO_A1", "iso13567") + assert rows["iso_a1_arch"].settings["annotation_scale"] == "1:50" + assert (rows["iso_a3_pid"].paper, rows["iso_a3_pid"].layer_set) == ("ISO_A3", "pid") + assert (rows["ansi_b_mech"].paper, rows["ansi_b_mech"].dimstyle) == ("ANSI_B", "ANSI") + assert (rows["ansi_d_arch"].paper, rows["ansi_d_arch"].scale) == ("ANSI_D", "fit") + assert [spec.title_block for spec in TEMPLATE_CATALOG.values()] == [True, False, True, False, False] + assert all(spec.orientation == "landscape" for spec in TEMPLATE_CATALOG.values()) + assert {spec.textstyle for spec in TEMPLATE_CATALOG.values()} == {"ISOCP", "ROMANS"} + + +def test_template_rows_report_files(): + rows = template_rows() + assert [row["name"] for row in rows] == list(NAMES) + for row in rows: + assert set(row["files"]) == {"dxf", "dwt"} + assert row["files"]["dxf"]["path"].endswith(f"{row['name']}.dxf") + assert isinstance(row["files"]["dxf"]["present"], bool) + + +def test_resolve_template_bundled_path_and_unknown(tmp_path): + path, source = resolve_template("iso_a3_mech", "ezdxf") + assert source == "bundled" and path == str(TEMPLATES_DIR / "iso_a3_mech.dxf") + path, source = resolve_template("ISO_A3_MECH", "ezdxf") + assert source == "bundled" + # COM prefers the .dwt twin and says when it fell back to the DXF + path, source = resolve_template("iso_a3_mech", "com") + assert source in ("bundled", "bundled_dxf") + assert path.endswith(".dwt" if source == "bundled" else ".dxf") + explicit = tmp_path / "mine.dwt" + assert resolve_template(str(explicit), "ezdxf") == (str(explicit), "path") + assert resolve_template("sheet.dxf", "com") == ("sheet.dxf", "path") + with pytest.raises(ValueError, match="iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch"): + resolve_template("iso_a2_mech", "ezdxf") + with pytest.raises(ValueError, match="template"): + resolve_template("", "ezdxf") + + +# ── the build ─────────────────────────────────────────────────────────────── + + +async def test_build_is_deterministic_run_to_run(tmp_path): + build = _build_module() + first = await build.build_all(tmp_path / "one") + second = await build.build_all(tmp_path / "two") + assert [row["name"] for row in first] == list(NAMES) + for name in NAMES: + a = build.normalised_lines(tmp_path / "one" / f"{name}.dxf") + b = build.normalised_lines(tmp_path / "two" / f"{name}.dxf") + assert a == b, f"{name} differs between two builds" + assert all(row["page_setup"]["ok"] for row in first) + assert all(row["layers"] for row in first) + + +async def test_committed_templates_match_a_fresh_build(tmp_path): + """The spec's reproducibility claim — the same comparison the script's --check runs.""" + build = _build_module() + missing = [name for name in NAMES if not (TEMPLATES_DIR / f"{name}.dxf").is_file()] + assert not missing, f"bundled templates missing: {missing} — run scripts/build_templates.py" + await build.build_all(tmp_path) + drifted = build.drifted_names(tmp_path) + assert drifted == [], f"templates drifted from the script: {drifted}" + + +# ── drawing_new(template=) ────────────────────────────────────────────────── + + +async def test_drawing_new_from_a_bundled_template(backend): + result = await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend)) + assert result["ok"] is True + assert result["template"] == { + "name": "iso_a3_mech", + "path": str(TEMPLATES_DIR / "iso_a3_mech.dxf"), + "source": "bundled", + } + layers = {layer.name for layer in await backend.layer_list()} + assert {"GEOMETRY", "DIM", "HIDDEN", "CENTER", "TITLEBLOCK"} <= layers + row = (await backend.page_setup_list("A3"))[0] + assert row["paper"] == "ISO_A3" and row["orientation"] == "landscape" + assert row["plot_style"] == "monochrome.ctb" and row["scale"] == "1:1" + sheet = backend._doc.layouts.get("A3") + assert any(e.dxftype() == "LWPOLYLINE" for e in sheet), "the ISO 5457 frame is on the sheet" + assert float(await backend.system_get_variable("DIMTXT")) == 2.5 + if hasattr(backend, "dimstyle_create"): # after group S merges + assert await backend.system_get_variable("DIMSTYLE") == "ISO-25" + + +async def test_drawing_new_from_the_pid_and_ansi_templates(backend): + await server.drawing_new(template="iso_a3_pid", ctx=_Ctx(backend)) + assert "PROCESS-PIPING-MAIN" in {layer.name for layer in await backend.layer_list()} + await server.drawing_new(template="ansi_d_arch", ctx=_Ctx(backend)) + assert "M-GEOMET-E-N" in {layer.name for layer in await backend.layer_list()} + row = (await backend.page_setup_list("D"))[0] + assert row["paper"] == "ANSI_D" and row["size_mm"] == [864.0, 559.0] and row["scale"] == "fit" + assert (await backend.drawing_settings())["settings"]["decimal_separator"] == "." + + +async def test_drawing_new_unknown_template_is_refused_with_the_names(backend): + await backend.entity_create_line(0, 0, 1, 1) + before = (await backend.drawing_info()).entity_count + with pytest.raises(ToolError, match="iso_a3_mech"): + await server.drawing_new(template="iso_a2_mech", ctx=_Ctx(backend)) + assert (await backend.drawing_info()).entity_count == before, "the refusal replaced nothing" + + +async def test_drawing_new_missing_template_path_is_refused(backend, tmp_path): + with pytest.raises(ToolError, match="not found"): + await server.drawing_new(template=str(tmp_path / "gone.dxf"), ctx=_Ctx(backend)) + + +async def test_drawing_new_from_an_explicit_dxf_path_reports_source_path(backend, tmp_path): + await backend.layer_create("MINE", color=3) + saved = tmp_path / "mine.dxf" + await backend.drawing_save_as(str(saved), "dxf") + result = await server.drawing_new(template=str(saved), ctx=_Ctx(backend)) + assert result["template"]["source"] == "path" and result["template"]["path"] == str(saved.resolve()) + assert "MINE" in {layer.name for layer in await backend.layer_list()} + + +async def test_drawing_template_list_tool(backend): + result = await server.drawing_template_list(ctx=_Ctx(backend)) + assert result["ok"] and result["count"] == 5 + assert [row["name"] for row in result["templates"]] == list(NAMES) + assert result["engine"] == "ezdxf" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_templates.py -v` +Expected: collection FAILS with `ModuleNotFoundError: No module named 'engineering.standards.templates'`. + +- [ ] **Step 3: The catalogue — `engineering/standards/templates.py`** + +```python +# engineering/standards/templates.py +"""The five bundled drawing templates: what each contains, where its files live. + +Built by ``scripts/build_templates.py`` through the server's own backend +(layers, styles, settings, the ISO 5457 A3 frame where the sheet is A3, page +setup) and saved as DXF; the ``.dwt`` twins are produced on a live AutoCAD by +``scripts/smoke_settings_com.py --build-dwt`` and committed once. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path + +__all__ = ["TEMPLATES_DIR", "TEMPLATE_CATALOG", "TemplateSpec", "resolve_template", "template_rows"] + +#: ``/templates`` — shipped in the wheel (pyproject ``only-include``) and the Docker image. +TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates" + +_ISO_SETTINGS = { + "units": "mm", + "ltscale": 1.0, + "dimscale": 1.0, + "linear_precision": 2, + "angular_precision": 0, + "decimal_separator": ",", +} +_ANSI_SETTINGS = {**_ISO_SETTINGS, "decimal_separator": "."} + + +@dataclass(frozen=True) +class TemplateSpec: + name: str + standard: str # "ISO" | "ANSI" + sheet: str # human label, e.g. "A3 landscape" + paper: str # engineering.standards.papers.PAPER_SIZES key + orientation: str + layout: str # the paper-space tab's name + layer_set: str # engineering.layers.LAYER_SET_REGISTRY key + dimstyle_preset: str # group S preset id: "iso-25" | "ansi" + dimstyle: str # style name in the drawing + textstyle: str # "ISOCP" | "ROMANS" + scale: str # page setup scale label + title_block: bool + settings: dict = field(default_factory=dict) # drawing_settings facade keys + description: str = "" + + +TEMPLATE_CATALOG: dict[str, TemplateSpec] = { + spec.name: spec + for spec in ( + TemplateSpec( + name="iso_a3_mech", + standard="ISO", + sheet="A3 landscape", + paper="ISO_A3", + orientation="landscape", + layout="A3", + layer_set="mech", + dimstyle_preset="iso-25", + dimstyle="ISO-25", + textstyle="ISOCP", + scale="1:1", + title_block=True, + settings=dict(_ISO_SETTINGS), + description="ISO A3 mechanical part drawing", + ), + TemplateSpec( + name="iso_a1_arch", + standard="ISO", + sheet="A1 landscape", + paper="ISO_A1", + orientation="landscape", + layout="A1", + layer_set="iso13567", + dimstyle_preset="iso-25", + dimstyle="ISO-25", + textstyle="ISOCP", + scale="fit", + title_block=False, + settings={**_ISO_SETTINGS, "annotation_scale": "1:50"}, + description="ISO A1 architectural sheet, ISO 13567 layers, 1:50 annotation", + ), + TemplateSpec( + name="iso_a3_pid", + standard="ISO", + sheet="A3 landscape", + paper="ISO_A3", + orientation="landscape", + layout="A3", + layer_set="pid", + dimstyle_preset="iso-25", + dimstyle="ISO-25", + textstyle="ISOCP", + scale="1:1", + title_block=True, + settings=dict(_ISO_SETTINGS), + description="ISO A3 P&ID sheet", + ), + TemplateSpec( + name="ansi_b_mech", + standard="ANSI", + sheet="ANSI B landscape", + paper="ANSI_B", + orientation="landscape", + layout="B", + layer_set="mech", + dimstyle_preset="ansi", + dimstyle="ANSI", + textstyle="ROMANS", + scale="1:1", + title_block=False, + settings=dict(_ANSI_SETTINGS), + description="ANSI B mechanical part drawing (metric)", + ), + TemplateSpec( + name="ansi_d_arch", + standard="ANSI", + sheet="ANSI D landscape", + paper="ANSI_D", + orientation="landscape", + layout="D", + layer_set="iso13567", + dimstyle_preset="ansi", + dimstyle="ANSI", + textstyle="ROMANS", + scale="fit", + title_block=False, + settings=dict(_ANSI_SETTINGS), + description="ANSI D architectural sheet (metric)", + ), + ) +} + + +def _file(name: str, suffix: str) -> dict: + path = TEMPLATES_DIR / f"{name}{suffix}" + return {"path": str(path), "present": path.is_file()} + + +def template_rows() -> list[dict]: + """Catalogue rows for ``drawing_template_list``: the spec plus its files.""" + rows = [] + for spec in TEMPLATE_CATALOG.values(): + row = asdict(spec) + row["files"] = {"dxf": _file(spec.name, ".dxf"), "dwt": _file(spec.name, ".dwt")} + rows.append(row) + return rows + + +def resolve_template(name_or_path: str, engine: str) -> tuple[str, str]: + """``(path, source)`` for a catalogue name or a template file path. + + A catalogue name resolves to ``templates/.dxf`` (headless) or + ``templates/.dwt`` (live), falling back to the DXF with + ``source="bundled_dxf"`` when the ``.dwt`` twin is missing. Anything with a + path separator or a ``.dwt``/``.dxf`` suffix is a path (``source="path"``, + not checked here). A bare word that is not in the catalogue is refused + with the catalogue names. + """ + raw = str(name_or_path or "").strip() + if not raw: + raise ValueError("template: pass a bundled template name or a path to a .dwt/.dxf file") + key = raw.lower() + if key in TEMPLATE_CATALOG: + dxf = TEMPLATES_DIR / f"{key}.dxf" + dwt = TEMPLATES_DIR / f"{key}.dwt" + if engine == "com" and dwt.is_file(): + return str(dwt), "bundled" + if dxf.is_file(): + return str(dxf), "bundled" if engine != "com" else "bundled_dxf" + raise ValueError( + f"template {key!r} is in the catalogue but {dxf} is missing — run " + "`uv run --frozen python scripts/build_templates.py`" + ) + looks_like_path = ("/" in raw or "\\" in raw) or Path(raw).suffix.lower() in (".dwt", ".dxf") + if looks_like_path: + return raw, "path" + raise ValueError( + f"unknown template {raw!r}. Bundled templates: {', '.join(TEMPLATE_CATALOG)}; " + "or pass a path to a .dwt/.dxf file" + ) +``` + +- [ ] **Step 4: The build script — `scripts/build_templates.py`** + +```python +# scripts/build_templates.py +"""Build the bundled templates through the server's own backend, reproducibly. + + uv run --frozen python scripts/build_templates.py # write templates/*.dxf + uv run --frozen python scripts/build_templates.py --check # exit 1 if a rebuild differs + uv run --frozen python scripts/build_templates.py --out DIR # build elsewhere + +Each template is: drawing_new -> apply_layer_set -> styles -> drawing_settings +-> layout renamed to the sheet -> ISO 5457 frame (A3 sheets) -> page_setup_apply +-> drawing_save_as DXF. Nothing is hand-authored, so what the tools produce is +what ships. + +Styles: once group S's ``dimstyle_create`` / ``textstyle_create`` are on the +backend they are used (ISO-25 / ISOCP, ANSI / ROMANS, set current). Until then +the same values (spec §4.1) are written as header DIM variables so the template +already dimensions correctly; the final task re-runs this script after the S +merge and re-commits the five files. + +``--check`` rebuilds into a temporary folder and compares line by line after +masking the values ezdxf cannot keep constant — measured on 2026-09-16, exactly +six lines differ between two builds: $TDCREATE, $TDUPDATE, $FINGERPRINTGUID, +$VERSIONGUID and the two EZDXF_META DICTIONARYVAR stamps. +""" + +from __future__ import annotations + +import argparse +import asyncio +import re +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from backends.ezdxf_backend import EzdxfBackend # noqa: E402 +from engineering import TitleBlockMetadata, apply_iso_a3_titleblock # noqa: E402 +from engineering.layers import apply_layer_set # noqa: E402 +from engineering.standards.papers import resolve_page_setup # noqa: E402 +from engineering.standards.templates import ( # noqa: E402 + TEMPLATE_CATALOG, + TEMPLATES_DIR, + TemplateSpec, +) + +#: Header variables whose values change on every save. +VOLATILE_HEADER_VARS = frozenset( + { + "$TDCREATE", + "$TDUPDATE", + "$TDUCREATE", + "$TDUUPDATE", + "$TDINDWG", + "$TDUSRTIMER", + "$FINGERPRINTGUID", + "$VERSIONGUID", + } +) +#: The EZDXF_META stamps: ``1.4.4 @ 2026-09-16T12:33:38.192529+00:00``. +_EZDXF_STAMP = re.compile(r"^\d+\.\d+\.\d+\S* @ \d{4}-\d{2}-\d{2}T") + +#: Spec §4.1 as header variables — the fallback until dimstyle_create lands. +#: DIMDSEP goes through the facade (decimal_separator); DIMTXSTY is omitted +#: because the text style it names is created by textstyle_create. +_FALLBACK_DIMVARS: dict[str, dict[str, float | int]] = { + "iso-25": { + "DIMTXT": 2.5, + "DIMASZ": 2.5, + "DIMEXE": 1.25, + "DIMEXO": 0.625, + "DIMGAP": 0.625, + "DIMTAD": 1, + "DIMTIH": 0, + "DIMTOH": 0, + "DIMDEC": 2, + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, + }, + "ansi": { + "DIMTXT": 3.0, + "DIMASZ": 3.0, + "DIMEXE": 1.5, + "DIMEXO": 1.5, + "DIMGAP": 1.0, + "DIMTAD": 0, + "DIMTIH": 1, + "DIMTOH": 1, + "DIMDEC": 2, + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, + }, +} + + +async def _apply_styles(backend, spec: TemplateSpec) -> dict: + if hasattr(backend, "dimstyle_create") and hasattr(backend, "textstyle_create"): + from engineering.standards.dimstyles import resolve_dimstyle + from engineering.standards.textstyles import TEXT_PRESETS + + font, width_factor, oblique = TEXT_PRESETS[spec.textstyle] + text = await backend.textstyle_create( + spec.textstyle, font, 0.0, width_factor, oblique, set_current=True + ) + dim = await backend.dimstyle_create( + spec.dimstyle, resolve_dimstyle(spec.dimstyle_preset, None), set_current=True + ) + return {"mode": "styles", "dimstyle": dim["name"], "textstyle": text["name"]} + applied = {} + for variable, value in _FALLBACK_DIMVARS[spec.dimstyle_preset].items(): + applied[variable] = (await backend.system_set_variable(variable, value))["ok"] + return { + "mode": "header_dimvars", + "applied": applied, + "pending": "dimstyle_create/textstyle_create not on this backend yet (group S)", + } + + +async def build_template(spec: TemplateSpec, out_dir: Path) -> dict: + """Build one template into ``out_dir/.dxf`` and report what went in.""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_new() + layers = await apply_layer_set(backend, spec.layer_set) + styles = await _apply_styles(backend, spec) + settings = await backend.drawing_settings(dict(spec.settings)) + pending = sorted((settings.get("errors") or {})) # keys the facade does not know yet + renamed = await backend.layout_rename("Layout1", spec.layout) + if not renamed.get("ok"): + raise RuntimeError(f"{spec.name}: layout rename failed: {renamed}") + title_block = None + if spec.title_block: + title_block = await apply_iso_a3_titleblock( + backend, + metadata=TitleBlockMetadata( + title=spec.description.upper(), + drawing_no="TEMPLATE", + scale=spec.scale if spec.scale != "fit" else "1:1", + ), + layout=spec.layout, + ) + setup = resolve_page_setup( + spec.paper, + spec.orientation, + "monochrome.ctb", + spec.scale, + "layout", + "DWG To PDF.pc3", + [0.0, 0.0, 0.0, 0.0], # the frame is drawn at the paper corner; a PDF has no unprintable band + True, + ) + page_setup = await backend.page_setup_apply(spec.layout, setup) + await backend.layout_set_current("Model") + path = out_dir / f"{spec.name}.dxf" + await backend.drawing_save_as(str(path), "dxf") + return { + "name": spec.name, + "path": str(path), + "layers": layers, + "styles": styles, + "settings_applied": settings.get("applied", {}), + "settings_pending": pending, + "title_block": bool(title_block), + "page_setup": {"ok": page_setup["ok"], "paper": page_setup["applied"]["paper"]}, + } + finally: + await backend.disconnect() + + +async def build_all(out_dir: Path = TEMPLATES_DIR) -> list[dict]: + return [await build_template(spec, out_dir) for spec in TEMPLATE_CATALOG.values()] + + +def normalised_lines(path: Path) -> list[str]: + """The DXF's lines with every value that legitimately changes per save masked.""" + lines = Path(path).read_text(encoding="utf-8").splitlines() + out: list[str] = [] + mask_at = -1 + for index, line in enumerate(lines): + if index == mask_at: + out.append("") + continue + stripped = line.strip() + if stripped in VOLATILE_HEADER_VARS: + mask_at = index + 2 # " 9" / "$TDCREATE" / " 40" / + if _EZDXF_STAMP.match(stripped): + out.append("") + continue + out.append(line) + return out + + +def drifted_names(rebuilt: Path) -> list[str]: + """Names whose committed DXF differs from the build in ``rebuilt`` (masked compare).""" + drifted = [] + for name in TEMPLATE_CATALOG: + committed = TEMPLATES_DIR / f"{name}.dxf" + if not committed.is_file(): + drifted.append(f"{name} (missing)") + continue + fresh = normalised_lines(Path(rebuilt) / f"{name}.dxf") + current = normalised_lines(committed) + if fresh != current: + first = next( + (i for i, (a, b) in enumerate(zip(fresh, current)) if a != b), + min(len(fresh), len(current)), + ) + drifted.append(f"{name} (first difference at line {first + 1})") + return drifted + + +def check_templates() -> list[str]: + """CLI ``--check``: rebuild into a temporary folder and report drift.""" + with tempfile.TemporaryDirectory() as folder: + asyncio.run(build_all(Path(folder))) + return drifted_names(Path(folder)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", help="rebuild to a temp dir and diff") + parser.add_argument("--out", type=Path, default=TEMPLATES_DIR, help="output folder") + args = parser.parse_args(argv) + if args.check: + drifted = check_templates() + if drifted: + print("templates drifted from scripts/build_templates.py: " + ", ".join(drifted)) + return 1 + print(f"{len(TEMPLATE_CATALOG)} templates reproduce byte-for-byte (volatile stamps masked)") + return 0 + for row in asyncio.run(build_all(args.out)): + pending = f" pending={row['settings_pending']}" if row["settings_pending"] else "" + print(f"{row['name']}: {row['path']} styles={row['styles']['mode']}{pending}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: `drawing_new(template=)` and `drawing_template_list` in `server.py`** + +Replace the `drawing_new` tool: + +```python +@cad_tool( + summary="Start a drawing: blank with the engineering layers, or from a bundled/own template.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "New Drawing", "destructiveHint": False}, + tags={"drawing"}, +) +async def drawing_new( + template: Annotated[ + str | None, + "Bundled template name (drawing_template_list: iso_a3_mech, iso_a1_arch, iso_a3_pid, " + "ansi_b_mech, ansi_d_arch) or a path to a .dwt/.dxf template file.", + ] = None, + bootstrap: Annotated[ + bool, + Field( + description="Auto-load CENTER/HIDDEN/PHANTOM linetypes and create standard " + "engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, " + "HATCH, TEXT, TITLEBLOCK). Disable for vanilla DXF.", + ), + ] = True, + ctx: Context = None, +) -> dict: + """Create a new drawing, optionally from a template. + + A bundled name resolves to `templates/.dxf` headlessly and + `templates/.dwt` on the live engine (falling back to the DXF with + `source: "bundled_dxf"` when no `.dwt` twin is committed); a path is used + as given. The result carries `template: {name|path, path, source}`. + + Refused before anything is replaced: a bare name that is not in the + catalogue (the message lists the five), a template path that does not + exist, and a path outside the allowed directories. With `bootstrap=True` + (default) the standard engineering linetypes and layers are ensured + afterwards — idempotent on a template that already has them. + """ + from pathlib import Path as _P + + from engineering.standards.templates import resolve_template + + backend = _backend(ctx) + template_info: dict | None = None + if template is not None: + try: + resolved, source = resolve_template(template, backend.name) + except ValueError as exc: + raise ToolError(f"drawing_new: {exc}") from exc + if source == "path": + resolved = str(validate_path(resolved, allow_write=False)) + if not _P(resolved).is_file(): + raise ToolError(f"drawing_new: template file not found: {resolved}") + template_info = {"path": resolved, "source": source} + else: + template_info = {"name": template.strip().lower(), "path": resolved, "source": source} + template = resolved + await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})") + raw = await backend.drawing_new(template) + result = dict(raw) if isinstance(raw, dict) else {"result": raw} + if template_info is not None: + result["template"] = template_info + if bootstrap: + try: + from engineering import ( + ensure_engineering_layers, + ensure_standard_linetypes, + ) + + lt_status = await ensure_standard_linetypes(backend) + layer_status = await ensure_engineering_layers(backend) + result["bootstrap"] = {"ok": True, "linetypes": lt_status, "layers": layer_status} + except Exception as exc: + await ctx.warning(f"Engineering bootstrap failed: {exc}") + result["bootstrap"] = {"ok": False, "error": str(exc)} + result["status"] = "degraded" + return result +``` + +(The bundled file is the server's own, read-only; it is deliberately not passed through `validate_path`, whose `ALLOWED_PATHS` fence is for the caller's files — a deployment that fences paths to a project folder must still be able to start from a bundled template.) + +Change the SECTION 19 header to `(5 tools)` and add after `batch_plot`: + +```python +@cad_tool( + summary="List the five bundled drawing templates: standard, sheet, layers, styles, page setup.", + cost="read", +) +@mcp.tool( + annotations={"title": "List Drawing Templates", "readOnlyHint": True}, + tags={"template", "drawing", "query"}, +) +async def drawing_template_list(ctx: Context = None) -> dict: + """The templates `drawing_new(template=)` can start from. + + Each row: `standard` (ISO/ANSI), `sheet`, `paper`, `layout` (the sheet tab's + name), `layer_set` (mech / pid / iso13567), `dimstyle` / `textstyle` + (ISO-25 / ISOCP or ANSI / ROMANS), `scale`, `title_block` (the ISO 5457 A3 + frame, A3 sheets only), the `settings` applied, and `files.dxf` / + `files.dwt` with `present` flags — the `.dwt` twins exist only once built + on a live AutoCAD (`scripts/smoke_settings_com.py --build-dwt`). Never + refuses; it reads a catalogue. + """ + from engineering.standards.templates import TEMPLATES_DIR, template_rows + + rows = template_rows() + return { + "ok": True, + "templates": rows, + "count": len(rows), + "templates_dir": str(TEMPLATES_DIR), + "engine": _backend(ctx).name, + } +``` + +- [ ] **Step 6: Aliases, packaging, README, snapshot** + +`discovery/aliases.py`, in the track-E block (alphabetical): + +```python + "drawing_template_list": ToolAliases( + acad=(), + synonyms=( + "which templates are there", + "bundled templates", + "iso a3 template", + "ansi b sheet", + "start from a standard sheet", + "dwt list", + ), + ), +``` + +and extend `drawing_new`'s synonyms with `"start from a bundled template"`. + +`pyproject.toml`: append `"templates",` to `only-include`. `Dockerfile`: add `COPY templates/ ./templates/` after the `discovery/` line. `.gitattributes`: append + +``` +templates/*.dxf text eol=crlf +templates/*.dwt binary +``` + +(ezdxf writes CRLF; pinning the checkout to CRLF keeps the committed bytes equal to a fresh build on every platform, and `normalised_lines` compares lines anyway.) + +Build and commit the five files: `uv run --frozen python scripts/build_templates.py` then `uv run --frozen python scripts/build_templates.py --check` (expected: `5 templates reproduce byte-for-byte (volatile stamps masked)`). + +Run `uv run --frozen python scripts/generate_tool_inventory.py`. `README.md`: snapshot `170 tools` → `171 tools`; `170 tools in 20 groups` → `171 tools in 20 groups`; add after the "Paper space" row: + +``` +| 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` | +``` + +`tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: `"templates": 2` → `3`, total `170` → `171`, docstring: "`templates` moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19 (tagged `template`, which ranks above `drawing`)." + +- [ ] **Step 7: Run the gates** + +Run: `uv run --frozen pytest tests/test_templates.py tests/test_page_setup.py tests/test_batch_plot.py tests/test_drawing.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v` +Expected: all PASS (`test_dockerfile_copies_every_wheel_include` now sees `templates` on both sides). + +- [ ] **Step 8: Commit** + +```bash +git add engineering/standards/templates.py scripts/build_templates.py templates/*.dxf server.py discovery/aliases.py pyproject.toml Dockerfile .gitattributes README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_templates.py +git commit -m "feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name)" +``` + +--- + +### Task 20: `drawing_template_save` on both engines, `dwt_write` capability, `templates/README.md` + +**Files:** +- Modify: `backends/ezdxf_backend.py` (`drawing_template_save` in the `# ── page setup (track E) ──` block replaces the Task 17 stub; a `_DWT_WRITE_REFUSAL` message next to `_DWG_WRITE_REFUSAL`) +- Modify: `backends/com_backend.py` (`drawing_template_save` replaces the stub; `_AC_SAVEAS_TEMPLATE = 66`; `drawing_save_as`'s `fmt_map["dwt"]` corrected `5 → 66`) +- Modify: `server.py` — SECTION 19 header `(5 tools)` → `(6 tools)`, tool `drawing_template_save` after `drawing_template_list` +- Modify: `discovery/aliases.py` — `SHARED_ACAD_COMMANDS` gains `SAVEAS`; `drawing_template_save` record +- Create: `templates/README.md` +- Modify: `README.md` snapshot `171 tools` → `172 tools`, `… 172 tools in 20 groups`, "Templates" row gains `drawing_template_save`; regenerate `docs/tool-inventory.json` +- Modify: `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` — `templates: 3 → 4`, total `171 → 172` +- Test: `tests/test_templates.py` (append the `drawing_template_save` tests) + +**Interfaces:** +- Produces: `backend.drawing_template_save(path, name=None, description=None) -> {ok, path, format, backend, name, description_written, …}` (skeleton signature); MCP tool `drawing_template_save(path, name=None, description=None)`. +- Capability key `dwt_write`: ezdxf `False` (declared in Task 17), COM `True`. The headless `.dwt` refusal raises `UnsupportedCapabilityError("dwt_write", …)` naming the DXF route and the COM route; the middleware turns it into the standard `{ok: false, capability: "dwt_write"}` payload. +- COM constant: `AcSaveAsType.ac2018_Template = 66` (measured in the AutoCAD 2026 typelib; the spec's `acTemplateDwg` does not exist there). +- Pack `core`; lean: no (`drawing_save_as` is not lean either). + +- [ ] **Step 1: Write the failing tests** + +Add `import types` and `from backends.base import UnsupportedCapabilityError` to the import block at the top of `tests/test_templates.py`, then append: + +```python +# ── drawing_template_save ─────────────────────────────────────────────────── + + +async def test_template_save_dxf_headless_round_trips(backend, tmp_path): + await backend.layer_create("MINE", color=3) + out = tmp_path / "mine_template.dxf" + result = await backend.drawing_template_save(str(out), name="Mine", description="my sheet") + assert result == { + "ok": True, + "path": str(out), + "format": "dxf", + "backend": "ezdxf", + "name": "Mine", + "description_written": False, + "description_note": ( + "DXF has no template description field; it is a DWG/DWT summary property " + "(capability 'dwgprops')" + ), + } + assert out.is_file() + await server.drawing_new(template=str(out), ctx=_Ctx(backend)) + assert "MINE" in {layer.name for layer in await backend.layer_list()} + + +async def test_template_save_dwt_headless_is_refused_with_the_capability(backend, tmp_path): + out = tmp_path / "mine.dwt" + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_template_save(str(out)) + assert excinfo.value.capability == "dwt_write" + message = str(excinfo.value) + assert ".dxf" in message and "com" in message.lower() + assert not out.exists() + + +async def test_template_save_refuses_other_suffixes(backend, tmp_path): + with pytest.raises(ValueError, match=r"\.dxf"): + await backend.drawing_template_save(str(tmp_path / "mine.txt")) + + +async def test_template_save_tool_validates_the_path(backend, tmp_path): + with pytest.raises(ToolError): + await server.drawing_template_save("../../outside.dxf", ctx=_Ctx(backend)) + result = await server.drawing_template_save(str(tmp_path / "t.dxf"), ctx=_Ctx(backend)) + assert result["ok"] and result["name"] == "t" + + +class _SaveRecorder: + def __init__(self): + self.calls = [] + self.SummaryInfo = types.SimpleNamespace() + self.Name = "Drawing1.dwg" + + def SaveAs(self, path, fmt=None): + self.calls.append(("SaveAs", path, fmt)) + self.Name = Path(path).name + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = _SaveRecorder() + 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, document + + +async def test_com_template_save_dwt_uses_ac2018_template(com_backend, tmp_path): + backend, document = com_backend + out = str(tmp_path / "mine.dwt") + result = await backend.drawing_template_save(out, description="A3 mech") + assert document.calls == [("SaveAs", out, 66)] + assert document.SummaryInfo.Comments == "A3 mech" + assert result == { + "ok": True, + "path": out, + "format": "dwt", + "backend": "com", + "name": "mine", + "description_written": True, + "document_rebound": True, + } + + +async def test_com_template_save_dxf_uses_the_dxf_code(com_backend, tmp_path): + backend, document = com_backend + out = str(tmp_path / "mine.dxf") + result = await backend.drawing_template_save(out) + assert document.calls == [("SaveAs", out, 61)] + assert result["format"] == "dxf" and result["description_written"] is False + + +async def test_com_save_as_dwt_no_longer_writes_an_r13_dxf(com_backend, tmp_path): + """drawing_save_as mapped 'dwt' to 5, which is AcSaveAsType.acR13_dxf.""" + backend, document = com_backend + out = str(tmp_path / "x.dwt") + await backend.drawing_save_as(out, "dwt") + assert document.calls == [("SaveAs", out, 66)] + + +async def test_com_template_save_refuses_other_suffixes_before_saving(com_backend, tmp_path): + backend, document = com_backend + with pytest.raises(ValueError, match=r"\.dwt"): + await backend.drawing_template_save(str(tmp_path / "x.dwg")) + assert document.calls == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_templates.py -k "template_save or save_as_dwt" -v` +Expected: the headless DXF test and both COM save tests FAIL with `UnsupportedCapabilityError: drawing_template_save: not implemented yet (Task 20)`; the headless `.dwt` test fails on `".dxf" in message` (the stub's message names no route); the suffix tests fail with the stub's error instead of `ValueError`; `test_com_save_as_dwt_no_longer_writes_an_r13_dxf` fails with `[("SaveAs", out, 5)]`; `test_template_save_tool_validates_the_path` fails with `AttributeError: module 'server' has no attribute 'drawing_template_save'`. + +- [ ] **Step 3: ezdxf implementation** + +Next to `_DWG_WRITE_REFUSAL` in `backends/ezdxf_backend.py`: + +```python +# A DWT is a DWG container with a template flag. ezdxf cannot write it, and a +# DXF under a .dwt name is exactly the mislabelled file the DWG refusal exists +# to prevent. The DXF route works headlessly end to end: drawing_new(template=) +# opens a .dxf template. +_DWT_WRITE_REFUSAL = ( + "drawing_template_save: the headless ezdxf backend cannot write DWT — a .dwt is a DWG " + "container, and ezdxf has no DWG writer. Save the template as .dxf instead " + "(drawing_new(template='{stem}.dxf') opens it headlessly), or switch to the live COM " + "backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for a real .dwt." +) +``` + +Replace the stub in the page-setup block: + +```python + async def drawing_template_save(self, path, name=None, description=None) -> dict: + """Save the current drawing as a template — DXF only, headlessly. + + ``.dwt`` is refused with capability ``dwt_write`` before anything is + written; ``.dxf`` goes through ``drawing_save_as`` (the document is + rebound to the new path, as AutoCAD's SAVEAS does). A description has + no home in a DXF header — it is a DWG/DWT summary property — so it is + reported ``description_written: False`` rather than dropped silently. + """ + suffix = Path(path).suffix.lower() + if suffix == ".dwt": + raise UnsupportedCapabilityError( + "dwt_write", _DWT_WRITE_REFUSAL.format(stem=Path(path).stem) + ) + if suffix != ".dxf": + raise ValueError( + f"drawing_template_save: path must end in .dxf (headless) or .dwt (live " + f"AutoCAD), got {path!r}" + ) + saved = await self.drawing_save_as(path, "dxf") + result = { + "ok": True, + "path": saved["path"], + "format": "dxf", + "backend": "ezdxf", + "name": name or Path(path).stem, + "description_written": False, + } + if description is not None: + result["description_note"] = ( + "DXF has no template description field; it is a DWG/DWT summary property " + "(capability 'dwgprops')" + ) + return result +``` + +- [ ] **Step 4: COM implementation** + +In `backends/com_backend.py`, in the page-setup block, add the constants below `_AC_NO_ROTATION` and replace the stub: + +```python + #: AcSaveAsType, AutoCAD 2026 typelib: ac2018_Template = 66 (acNative = 64, + #: ac2013_dxf = 61). The spec's "acTemplateDwg" is not a member of the enum. + _AC_SAVEAS_TEMPLATE = 66 + _AC_SAVEAS_DXF = 61 + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + """``SaveAs(path, ac2018_Template)`` for ``.dwt``, the DXF code for ``.dxf``. + + AutoCAD's template description is its summary-info Comments field, so + ``description`` is written there first (``description_written`` says + whether it took). SaveAs rebinds the active document to the new file, + as the SAVEAS command does — reported as ``document_rebound``. + Fake-tested; executed live by ``scripts/smoke_settings_com.py --build-dwt``. + """ + suffix = Path(path).suffix.lower() + if suffix not in (".dwt", ".dxf"): + raise ValueError( + f"drawing_template_save: path must end in .dwt or .dxf, got {path!r}" + ) + + def _sync(): + doc = _acad_doc() + description_written = False + if description is not None: + try: + doc.SummaryInfo.Comments = str(description) + description_written = True + except Exception as exc: + log.debug("SummaryInfo.Comments write failed: %s", exc) + code = self._AC_SAVEAS_TEMPLATE if suffix == ".dwt" else self._AC_SAVEAS_DXF + doc.SaveAs(path, code) + return { + "ok": True, + "path": path, + "format": suffix[1:], + "backend": "com", + "name": name or Path(path).stem, + "description_written": description_written, + "document_rebound": True, + } + + return await self._run(_sync) +``` + +And in `drawing_save_as`, change the map line to: + +```python + # AutoCAD AcSaveAsType: 12 = ac2000_dwg, 61 = ac2013_dxf, 66 = ac2018_Template. + # "dwt" was 5 (acR13_dxf) until v1.6: a DWT request wrote an R13 DXF + # under a .dwt name. + fmt_map = {"dwg": 12, "dxf": 61, "dwt": 66} +``` + +- [ ] **Step 5: The tool, alias, README, snapshot** + +SECTION 19 header → `(6 tools)`; add after `drawing_template_list`: + +```python +@cad_tool( + summary="Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Save As Template", "destructiveHint": False}, + tags={"template", "drawing"}, +) +async def drawing_template_save( + path: Annotated[str, "Destination .dwt (live AutoCAD) or .dxf (either engine)."], + name: Annotated[str | None, "Template name for the report (default: the file stem)."] = None, + description: Annotated[ + str | None, "Template description → DWT summary Comments on the live engine." + ] = None, + ctx: Context = None, +) -> dict: + """AutoCAD's SAVEAS → Drawing Template, on both engines. + + Live AutoCAD writes a real `.dwt` (`SaveAs(path, ac2018_Template)`) and + puts `description` into the template's summary Comments; the active + document is rebound to the new file, as SAVEAS does. Headlessly a `.dxf` + template is written — `drawing_new(template=)` opens it — and a + `.dwt` request is refused with capability `dwt_write` (the message names + the DXF route and the COM route); a description is reported + `description_written: false` because DXF has nowhere to keep it. Any other + suffix, and any path outside the allowed directories, is refused before + a byte is written. + """ + validated = validate_path(path, allow_write=True) + await ctx.info(f"Saving template: {validated}") + try: + return await _backend(ctx).drawing_template_save(str(validated), name, description) + except ValueError as exc: + raise ToolError(str(exc)) from exc +``` + +`discovery/aliases.py`: add to the `SHARED_ACAD_COMMANDS` comment block + +```python +# SAVEAS -- the same command saves a copy (drawing_save_as) or a template +# (drawing_template_save, its "Drawing Template" file type). +``` + +and `"SAVEAS",` to the set (alphabetical, after `"QSELECT"`); in the track-E block: + +```python + "drawing_template_save": ToolAliases( + acad=("SAVEAS",), + synonyms=( + "save as template", + "make a dwt", + "template from this drawing", + "save as dwt", + "reuse this sheet setup", + ), + ), +``` + +Create `templates/README.md`: + +```markdown +# Bundled drawing templates + +Five templates, built by the server's own tools — never hand-edited — and +listed by `drawing_template_list`. Start from one with +`drawing_new(template="iso_a3_mech")`. + +| name | standard | sheet | layer set | styles | page setup | +|---|---|---|---|---|---| +| `iso_a3_mech` | ISO | A3 landscape, ISO 5457 frame + ISO 7200 title block | mech | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 | +| `iso_a1_arch` | ISO | A1 landscape | iso13567 | ISO-25 / ISOCP, annotation scale 1:50 | ISO_A1, monochrome.ctb, fit | +| `iso_a3_pid` | ISO | A3 landscape, ISO 5457 frame + title block | pid | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 | +| `ansi_b_mech` | ANSI (metric) | ANSI B landscape | mech | ANSI / ROMANS | ANSI_B, monochrome.ctb, 1:1 | +| `ansi_d_arch` | ANSI (metric) | ANSI D landscape | iso13567 | ANSI / ROMANS | ANSI_D, monochrome.ctb, fit | + +## The two files per template + +- `.dxf` — the headless twin. Produced by + `uv run --frozen python scripts/build_templates.py`, which runs + `drawing_new → apply_layer_set → styles → drawing_settings → layout rename → + titleblock_apply_iso_a3 (A3 sheets) → page_setup_apply → drawing_save_as`. + `scripts/build_templates.py --check` rebuilds into a temporary folder and + fails if any committed DXF differs (the save-time stamps `$TDCREATE`, + `$TDUPDATE`, `$FINGERPRINTGUID`, `$VERSIONGUID` and the two `EZDXF_META` + entries are masked; everything else must be byte-identical). + `tests/test_templates.py` runs the same check. +- `.dwt` — the live twin, used by `drawing_new(template=…)` on the COM + backend. AutoCAD's DWT is a DWG container, which no headless library + writes, so these are produced **on the live machine** by + `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 24's + smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)` + (`SaveAs(path, ac2018_Template)`), then the files are committed once. While + a `.dwt` is missing, `resolve_template` falls back to the DXF and reports + `source: "bundled_dxf"`; `drawing_template_list` shows `files.dwt.present`. + +Line endings: ezdxf writes CRLF and `.gitattributes` pins `templates/*.dxf` +to CRLF so a checkout on any platform equals a fresh build. + +Rebuild after changing anything the build depends on (layer sets, the title +block, the dimension presets, the settings facade) and commit the result +together with the change — the reproducibility test is the gate. +``` + +Regenerate the inventory; `README.md` snapshot `171 tools` → `172 tools`, `… 172 tools in 20 groups`, the Templates row ends `…, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly)`. `tests/test_tool_registry.py`: `"templates": 3` → `4`, total `171` → `172`, docstring "…then 3 -> 4 when `drawing_template_save` joined." + +- [ ] **Step 6: Run the gates** + +Run: `uv run --frozen pytest tests/test_templates.py tests/test_page_setup.py tests/test_batch_plot.py tests/test_drawing.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v` +Expected: all PASS. Then the whole suite once for this group: `uv run --frozen pytest -x -q` — expected green apart from the README `collected tests` figure (final task). + +- [ ] **Step 7: Commit** + +```bash +git add backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py templates/README.md README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_templates.py +git commit -m "feat(templates): drawing_template_save on both engines — dwt_write is a declared capability" +``` + +--- + +**Hand-off to the final tasks (24–26):** `LEAN_TOOL_NAMES` += `page_setup_apply`, `batch_plot`; discovery golden queries for PAGESETUP / PLOT / PUBLISH / SAVEAS; `scripts/smoke_settings_com.py --build-dwt` produces and commits the five `.dwt` twins and records the live `/MediaBox` of an A3 plot; re-run `scripts/build_templates.py` after group S merges (styles then come from `dimstyle_create` / `textstyle_create`) and re-commit the DXFs; CLAUDE.md SECTION 19 paragraph and CHANGELOG `Unreleased`. + + +# v1.6 Track E — group V (environment): Tasks 21–23 + +> Part of `docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`. Its Global Constraints, File Structure, Interfaces and Execution sections are binding here; this file holds the three tasks of group **V** (worktree `track-e/environment`). Every command runs through `uv run --frozen ...`. + +**What this group ships:** the whole `EnvironmentContract` (including the two `drawing_properties_*` members that group C implements), an ezdxf **document registry** so `document_*` works headlessly, `document_list/activate/close` on both engines with `drawing_close` folded in as the active-document case, portable **layer states** as `ACADMCP_LAYERSTATES` XRECORDs, **named views** and **UCS** on both engines, and the live-only trio — `system_launch`, `system_preferences_get/set`, `user_pick_point` / `user_select` / `system_prompt_message` — with `@capability` refusals on ezdxf declared in both capability maps. + +**Counts this group moves (before the merge agent re-derives them):** baseline is Track A's 166 tools. Task 21 → 169 (SECTION 20 `(3 tools)`), Task 22 → 179 (`(13 tools)`), Task 23 → 185 (`(19 tools)`). Group C's three SECTION 20 tools make the merged header `(22 tools)`. Group-tag buckets in `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` move as: `drawing` 11 → 14 (T21), `layers` 14 → 18 and `view` 4 → 10 (T22), `system` 7 → 13 (T23). No new tag is added to `_GROUP_TAG_PRIORITY`: document tools carry `{"drawing"}`, layer states `{"layer"}`, named views **and UCS** `{"view"}` (AutoCAD's own ribbon files UCS under View ▸ Coordinates), the live trio and preferences `{"system"}`. Every tag set is a **single** tag on purpose: in `_GROUP_TAG_PRIORITY` (repo order: `… layout, solid, view, validation, analysis, block, layer, linetype, create, modify, query, drawing, system`) `view` and `layer` outrank `create` / `modify` / `query`, and all of those outrank `drawing` / `system` — so a document tool tagged `drawing` + `query` would file under `entity_query`, a preferences tool tagged `system` + `create` under `entity_creation`; one tag per tool is what keeps each in its intended bucket. + +**Pack and lean decision (applies to every tool in this group):** all 19 tools belong to the new `settings` pack (`PACK_TOOL_NAMES["settings"]`, wired by the final task with group C's three); **none** joins `LEAN_TOOL_NAMES` — a lean client drives one drawing, does not need layer states or UCS, and cannot prompt an operator it does not have. The decision is recorded in each tool's docstring line `Pack: settings · lean: no`. + +**How this part was checked (2026-09-16):** every ` ```python ` block parses (`ast.parse`; the nine that do not are the dict-entry fragments meant to be pasted into an existing literal). The pure modules, the ezdxf blocks and the COM blocks were exec'd over the real `EzdxfBackend` / `ComBackend` in a scratch harness and the plan's own tests run against them: 38 headless tests (`test_documents`, `test_layer_states`, `test_views_ucs`), 31 COM-fake and pure tests (`test_environment_live` plus the `com_` halves of the other three), and the pre-existing `test_ezdxf_quarantine`, `test_undo_redo`, `test_sprint4_ezdxf`, `test_preflight`, `test_ezdxf_honesty`, `test_layout_lifecycle`, `test_paper_space`, `test_drawing`, `test_layer_block`, `test_change_space` suites (194 tests) all pass against the registry-backed backend. The contract module with Task 22's members spliced in composes onto both patched backends with no abstract member left, and the six `@capability` members refuse headlessly with their keys. Not exercised in the harness: the `server.py` registrations and gates (they need the files in place), and live AutoCAD. + +**Shared-command additions to `discovery/aliases.py::SHARED_ACAD_COMMANDS`:** `CLOSE` (T21: `drawing_close` closes the active document, `document_close` closes one by name — the ERASE precedent), `LAYERSTATE`, `VIEW`, `UCS` (T22: umbrella dialog/option commands that this server splits into save/restore/list/delete — the LAYER / LAYOUT precedent) and `OPTIONS` (T23: one dialog both reads and writes a preference — the SETVAR precedent). `tests/test_discovery_aliases.py::test_shared_command_allowlist_has_no_stale_entries` proves each one is really shared in the task that adds it. + +--- + +### Task 21: Multi-document on both engines — `EnvironmentContract`, ezdxf document registry, `document_list/activate/close`, `drawing_close` folded in + +**Files:** +- Create: `backends/contracts/environment.py` (the whole module — every member of the Interfaces block, including the `# ── properties ──` pair group C implements) +- Modify: `backends/contracts/__init__.py` (export `EnvironmentContract`) +- Modify: `backends/base.py` (compose `EnvironmentContract` into `AutoCADBackend` — idempotent one-line edit; skip if another group merged it first) +- Modify: `backends/ezdxf_backend.py` (`_DocState` registry; `__init__`, `disconnect`, `_cleanup_undo_stack`, `_quarantine_document`, `drawing_new`, `drawing_open`, `drawing_close`, `system_status`, capability map; new `# ── environment (track E) ──` block with `document_*`, anchored immediately after the `# ── layouts / paper space ──` block — group V's reserved position, see the skeleton's Merge notes) +- Modify: `backends/com_backend.py` (`drawing_close`, capability map; new `# ── environment (track E) ──` block with `document_*`, anchored immediately after the `# ── layouts / paper space ──` block) +- Modify: `server.py` (new `# ── SECTION 20: Environment (3 tools) ──` header before `# ── RESOURCES`; `drawing_close` docstring) +- Modify: `discovery/aliases.py` (three records + `CLOSE` in `SHARED_ACAD_COMMANDS`), `README.md` (snapshot `166 tools` → `169 tools`, "What you get" row), `docs/tool-inventory.json` (regenerated), `tests/test_tool_registry.py` (snapshot `drawing` 11 → 14, total 169) +- Test: `tests/test_documents.py` + +**Interfaces:** +- Produces: `EnvironmentContract` — after Task 22 it is the plan's Interfaces block verbatim (`document_list/activate/close`, `layer_state_*`, `view_named_*`, `ucs_*`, `system_launch`, `preferences_get/set`, `user_pick_point`, `user_select`, `system_prompt_message`, `drawing_properties_get/set`). An `@abstractmethod` with no implementation stops **both** backends from instantiating, and two test modules construct `ComBackend` at import, so a task that declared all ten `layer_state_*` / `view_named_*` / `ucs_*` members without implementing them could not end green. This task therefore ships the module with the `document_*` trio (implemented here on both engines), the six `@capability` members (their COM implementations come in Task 23; until then the declared refusal is the behaviour on both engines, which is exactly what `@capability` is for) and the two `drawing_properties_*` members; Task 22 appends the ten abstract members together with their implementations at the marked insertion point. `@capability` is never used as a stand-in for the abstract members — the gate forbids it, and it would also mis-declare a real engine boundary. +- Produces (ezdxf): `_DocState`, `EzdxfBackend._docs: dict[str, _DocState]`, `_active_key`, `_register_document(doc, path) -> (key, reloaded)`, `_resolve_document_key(name_or_path) -> key`, `_close_document_sync(key, *, save, discard) -> dict`; the old attribute names (`_doc`, `_doc_path`, `_dirty`, `_current_layer`, `_current_space`, `_undo_stack`, `_redo_stack`, `_transaction_stack`, `_plan_spec`, `_preflight_result`, `_gdt_datums_defined`, `_gdt_datums_referenced`) keep working as views over the active entry. `drawing_new` / `drawing_open` results gain `document` (the registry key) and `open_documents`; `drawing_open` of a path that is already open reloads it from disk and reports `reloaded: true`. +- Produces (COM): `document_list/activate/close` over `app.Documents`, `doc.Activate()`, `doc.Close(SaveChanges)`. +- Result shapes: `document_list -> [{"name", "path", "active", "saved", "entity_count"}]` (ezdxf rows also carry `"key"`), `document_activate -> {"ok", "active", "previous", "name", "path"}`, `document_close -> {"ok", "closed", "saved", "discarded_changes", "active", "open_documents"}`. + +**Measured before writing (2026-09-16, ezdxf 1.4.4):** the per-document attributes on `EzdxfBackend` are `_doc`, `_doc_path` (not `_path`), `_dirty`, `_current_layer`, `_current_space`, `_undo_stack`, `_redo_stack`, `_transaction_stack` (not `_txn_*`), plus the mixin-owned `_plan_spec`, `_preflight_result`, `_gdt_datums_defined`, `_gdt_datums_referenced` that `PremiumContract._reset_document_state` writes and `GdtContract` reads with `getattr(self, ..., None)`. `_quarantine`, `_abandoned_calls` and `_lock` stay **backend-wide**: one lock guards one worker, and the quarantine's release rule ("the runaway keeps the Drawing it captured; a fresh one is bound") is preserved by *evicting* the quarantined entry from the registry when `drawing_new`/`drawing_open` bind a fresh document — pre-registry that entry was simply replaced, and keeping it would let `document_activate` hand back a drawing another thread is still mutating. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_documents.py +"""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 types + +import pytest +from fastmcp import Client + +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_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_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 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" + + +# ── 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() + 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" + 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 == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_documents.py -v` +Expected: FAIL — `AttributeError: 'EzdxfBackend' object has no attribute 'document_list'` on the headless tests; the COM tests fail the same way (or skip where pywin32 is absent). + +- [ ] **Step 3: Create the contract module and compose it** + +`backends/contracts/environment.py` — the Interfaces block verbatim, plus group C's two members in their marked block. Task 22 appends the ten `layer_state_*` / `view_named_*` / `ucs_*` abstract members at the marked insertion point; nothing else in this module changes later. Every `@capability(` line carries `# noqa: B027` — bugbear flags the empty body that the decorator supplies, every existing contract (`analysis`, `entity_creation`, `layouts`) and group S's `styles.py` carry the same noqa, and `ruff check .` (F Task 24 Step 5, Task 26 Step 4) is red without it. + +```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, named views, UCS: appended by Task 22 ────────────────── + + # ── 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) ─── + + @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": [...]}``.""" + ... +``` + +> The two `drawing_properties_*` members are `@abstractmethod` in the final module, so **both backends would fail to instantiate until group C's implementations exist** — and group C's code is not present in the `track-e/environment` worktree. A stub that returns a value would be the quiet failure the honesty pass forbids, so in this worktree the two members are declared under a temporary guard that the merge agent removes: + +```python +# 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. +``` + +> …and both members carry `@capability("dwgprops", reason="drawing properties arrive with group C's merge")` in this worktree, with `dwgprops` declared in **both** capability maps in steps 4 and 5 — as the **byte-identical** `FeatureCapability` lines group C's Task 16 declares (ezdxf `FeatureCapability(False, reason="summary_fields_need_live_autocad;custom_properties_supported")`, COM `FeatureCapability(True, "native")`), so the merge agent keeps one copy per map (C's) and drops V's line together with the decorators — a repeated dict key is ruff F601. The merge agent replaces the two decorators with `@abstractmethod` when C's implementations are present. This is stated in the commit message. + +Add the temporary import to the module head (`capability` is already imported) and write the two members as: + +```python + @capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027 + async def drawing_properties_get(self) -> dict: + ... + + @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: + ... +``` + +(the docstrings from the listing above stay on them). + +`backends/contracts/__init__.py` — add the import and the `__all__` entry: + +```python +from backends.contracts.environment import EnvironmentContract +``` + +```python + "EnvironmentContract", +``` + +`backends/base.py` — import `EnvironmentContract` alongside the others and add it to the `AutoCADBackend` bases after `SettingsContract`: + +```python + SettingsContract, + EnvironmentContract, +): +``` + +Run: `uv run --frozen python -c "from backends.base import AutoCADBackend; print('EnvironmentContract' in [c.__name__ for c in AutoCADBackend.__mro__])"` → `True`. + +- [ ] **Step 4: The ezdxf document registry** + +In `backends/ezdxf_backend.py`, add to the imports: + +```python +from dataclasses import dataclass, field +``` + +Insert immediately before `class EzdxfBackend(AutoCADBackend):`: + +```python +# --------------------------------------------------------------------------- +# ── environment (track E): document registry ──────────────────────────────── +# --------------------------------------------------------------------------- + + +@dataclass +class _DocState: + """Everything the backend keeps per open document. + + Before v1.6 these were twelve attributes on the backend itself, which is + why it could hold exactly one drawing. The registry holds one of these + per open document; the old attribute names survive as descriptors over + the *active* entry so no existing method had to change. + """ + + key: str + doc: Any = None + path: str | None = None + dirty: bool = False + current_layer: str = "0" + current_space: str = "Model" + undo_stack: list[Path] = field(default_factory=list) + redo_stack: list[Path] = field(default_factory=list) + transaction_stack: list[Path] = field(default_factory=list) + plan_spec: Any = None + preflight_result: Any = None + gdt_datums_defined: set = field(default_factory=set) + gdt_datums_referenced: set = field(default_factory=set) + activated_seq: int = 0 + + @property + def label(self) -> str: + return Path(self.path).name if self.path else self.key + + def discard_snapshots(self) -> None: + for stack in (self.undo_stack, self.redo_stack, self.transaction_stack): + while stack: + try: + stack.pop().unlink() + except OSError: + pass + + +class _ActiveField: + """A backend attribute that lives on the active document's ``_DocState``. + + A data descriptor, so it also overrides the plain class attributes the + ``PremiumContract`` mixin declares (``_plan_spec = None``) — the mixin's + ``self._plan_spec = plan`` lands on the active document. + """ + + def __init__(self, attr: str): + self.attr = attr + + def __get__(self, instance, owner=None): + if instance is None: + return self + return getattr(instance._state, self.attr) + + def __set__(self, instance, value): + setattr(instance._state, self.attr, value) +``` + +Replace the `__init__` body of `EzdxfBackend` and add the descriptors/properties right after the class docstring: + +```python +class EzdxfBackend(AutoCADBackend): + """File-based ezdxf backend – no live AutoCAD needed.""" + + # Old per-backend attributes, now routed to the active document (v1.6). + # `_doc` and `_doc_path` are explicit properties below (binding and + # re-keying rules); the rest are plain views. + _dirty = _ActiveField("dirty") + _current_layer = _ActiveField("current_layer") + _current_space = _ActiveField("current_space") + _undo_stack = _ActiveField("undo_stack") + _redo_stack = _ActiveField("redo_stack") + _transaction_stack = _ActiveField("transaction_stack") + _plan_spec = _ActiveField("plan_spec") + _preflight_result = _ActiveField("preflight_result") + _gdt_datums_defined = _ActiveField("gdt_datums_defined") + _gdt_datums_referenced = _ActiveField("gdt_datums_referenced") + + def __init__(self): + if not _EZDXF_OK: + raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf") + # v1.6: one entry per open document; the old attributes above are + # views over the active one. `_void` is what they read and write when + # nothing is open, so `_reset_document_state()` on disconnect and + # `system_status()` on an empty backend keep working. + self._docs: dict[str, _DocState] = {} + self._active_key: str | None = None + self._void = _DocState(key="") + self._untitled_counter = 0 + self._activation_counter = 0 + self._connected = False + self._lock = asyncio.Lock() + # R32: the document a timed-out call was abandoned on is untrusted until + # it is replaced. See backends/quarantine.py for what that buys. + self._quarantine: QuarantineRecord | None = None + self._abandoned_calls: list[QuarantineRecord] = [] + + @property + def _state(self) -> _DocState: + if self._active_key is None: + return self._void + return self._docs[self._active_key] + + @property + def _doc(self): + return self._state.doc + + @_doc.setter + def _doc(self, value): + # `_restore_snapshot` and `transaction_rollback` rebind the active + # document; nothing may bind a Drawing while none is active — that is + # `_register_document`'s job, and it is what keeps the registry honest. + if self._active_key is None: + if value is None: + return + raise RuntimeError("EzdxfBackend: bind a Drawing through _register_document") + self._state.doc = value + + @property + def _doc_path(self) -> str | None: + return self._state.path + + @_doc_path.setter + def _doc_path(self, value: str | None): + # `drawing_save` / `drawing_save_as` assign here. A document keyed + # `untitled-N` that is saved to a file becomes keyed by that file, so + # `document_list` shows one identity per document and `document_close` + # can be told the file name. Two entries may end up sharing a path + # (save_as onto a file that is open in another entry); the resolver + # then reports the ambiguity instead of guessing. + state = self._state + state.path = value + if self._active_key is None or value is None: + return + new_key = os.path.abspath(value) + if new_key != self._active_key and new_key not in self._docs: + del self._docs[self._active_key] + state.key = new_key + self._docs[new_key] = state + self._active_key = new_key +``` + +Replace `disconnect` and `_cleanup_undo_stack`: + +```python + async def disconnect(self) -> None: + for state in self._docs.values(): + state.discard_snapshots() + self._docs.clear() + self._active_key = None + self._void = _DocState(key="") + self._reset_document_state() + # The documents go away with the backend, so nothing is left to protect. + # The record stays in _abandoned_calls for anyone reading the transcript. + self._clear_quarantine("disconnect") + self._connected = False + + def _cleanup_undo_stack(self): + self._state.discard_snapshots() +``` + +Add the registry helpers right after `_cleanup_undo_stack`: + +```python + # ── document registry (v1.6) ────────────────────────────────────────────── + + def _register_document(self, doc, path: str | None) -> tuple[str, bool]: + """Add ``doc`` to the registry and make it active. + + Returns ``(key, reloaded)``. A path that is already open is *reloaded*: + its old entry (and its snapshots) is dropped and the fresh read takes + its place — the pre-registry behaviour of ``drawing_open``, kept so a + save-then-reopen verification still reads the disk. + """ + if path is None: + self._untitled_counter += 1 + key = f"untitled-{self._untitled_counter}" + reloaded = False + else: + key = os.path.abspath(path) + previous = self._docs.pop(key, None) + reloaded = previous is not None + if previous is not None: + previous.discard_snapshots() + self._docs[key] = _DocState(key=key, doc=doc, path=path) + self._activate_key(key) + return key, reloaded + + def _activate_key(self, key: str) -> None: + self._activation_counter += 1 + self._docs[key].activated_seq = self._activation_counter + self._active_key = key + + def _most_recent_key(self) -> str | None: + if not self._docs: + return None + return max(self._docs.values(), key=lambda state: state.activated_seq).key + + def _resolve_document_key(self, name_or_path: str | None) -> str: + """Registry key for ``None`` (the active document), a key, a path or a basename.""" + if name_or_path is None: + if self._active_key is None: + raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.") + return self._active_key + 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") + if wanted in self._docs: + return wanted + try: + as_path = os.path.abspath(wanted) + except (OSError, ValueError): + as_path = wanted + by_path = [ + key + for key, state in self._docs.items() + if state.path and os.path.abspath(state.path) == as_path + ] + if len(by_path) == 1: + return by_path[0] + by_name = [key for key, state in self._docs.items() if state.label.lower() == wanted.lower()] + if len(by_name) == 1: + return by_name[0] + if len(by_name) > 1: + raise ValueError(f"document {wanted!r} is ambiguous: {by_name}; pass the full path") + raise ValueError(f"no open document named {wanted!r}; open documents: {list(self._docs)}") + + def _evict_quarantined_document(self) -> None: + """Drop the quarantined entry before a fresh document is registered. + + Pre-registry, ``drawing_new`` *replaced* the only document, and the + quarantine relies on that: the runaway keeps the object it captured + and its writes land on an orphan nobody reads. With a registry the + quarantined entry would otherwise survive, and ``document_activate`` + could hand back a drawing another thread is still mutating. + """ + if self._quarantine is None or self._active_key is None: + return + state = self._docs.pop(self._active_key) + state.discard_snapshots() + self._active_key = self._most_recent_key() + + def _document_row(self, key: str, state: _DocState) -> dict: + return { + "name": state.label, + "path": state.path, + "key": key, + "active": key == self._active_key, + "saved": not state.dirty, + "entity_count": len(state.doc.modelspace()), + } + + def _close_document_sync(self, key: str, *, save: bool, discard: bool) -> dict: + """Close one registry entry. Runs on the worker thread under the lock.""" + state = self._docs[key] + closing_active = key == self._active_key + quarantined = closing_active and self._quarantine is not None + label = state.label + saved = False + if state.dirty: + if save: + if quarantined: + raise ValueError( + f"document_close: {label!r} was quarantined after a call was " + "abandoned mid-write and cannot be saved; pass discard=True to drop " + "its changes" + ) + if not state.path: + raise ValueError( + f"document_close: {label!r} has never been saved, so save=True has " + "nowhere to write; drawing_save_as(path) first, or pass discard=True" + ) + state.doc.saveas(state.path) + state.dirty = False + saved = True + elif not discard: + where = f" to {state.path}" if state.path else " (after drawing_save_as)" + raise ValueError( + f"document_close: {label!r} has unsaved changes; pass save=True to write " + f"them{where}, or discard=True to drop them" + ) + discarded = state.dirty + state.discard_snapshots() + del self._docs[key] + result = {"ok": True, "closed": key, "saved": saved, "discarded_changes": discarded} + if closing_active: + released = self._clear_quarantine("document_close") + self._active_key = self._most_recent_key() + if released is not None: + result["quarantine_cleared"] = released.to_dict() + result["active"] = self._active_key + result["open_documents"] = len(self._docs) + return result +``` + +Rewrite `drawing_new`, `drawing_open` and `drawing_close`: + +```python + async def drawing_new(self, template: str | None = None) -> dict: + def _sync(): + if template and Path(template).exists(): + doc = ezdxf.readfile(template) + _normalise_dimstyle_rounding(doc) + else: + doc = ezdxf.new(dxfversion="R2010") + _apply_iso_dimstyle(doc) + # R32: registering a fresh Drawing is the release — the runaway keeps + # the object it captured. The quarantined entry is evicted first so + # it can never be re-activated. Clear before _reset_history_baseline, + # which snapshots the new doc. + self._evict_quarantined_document() + released = self._clear_quarantine("drawing_new") + key, _ = self._register_document(doc, None) + self._reset_document_state() + self._reset_history_baseline() + result = { + "ok": True, + "name": "untitled.dxf", + "document": key, + "open_documents": len(self._docs), + } + if released is not None: + result["quarantine_cleared"] = released.to_dict() + return result + + return await self._async(_sync, quarantine_exit=True) + + async def drawing_open(self, path: str) -> dict: + def _sync(): + max_bytes = config.settings.max_dxf_bytes + if max_bytes > 0: + try: + size = os.path.getsize(path) + except OSError as exc: + raise RuntimeError(f"Cannot stat DXF file: {exc}") from exc + if size > max_bytes: + raise RuntimeError( + f"DXF file exceeds MAX_DXF_BYTES limit " + f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override." + ) + doc = ezdxf.readfile(path) + _normalise_dimstyle_rounding(doc) + # R32: only reached if the read succeeded. Reopening the very path a + # runaway doc.saveas is writing either fails loudly here (acceptable) + # or hands back a truncated document (not) — _clear_quarantine + # compares the two paths and says so. + self._evict_quarantined_document() + released = self._clear_quarantine("drawing_open", path) + key, reloaded = self._register_document(doc, path) + try: + self._current_layer = doc.header.get("$CLAYER", "0") + except Exception as exc: + log.debug("reading $CLAYER from header: %s", exc) + self._current_layer = "0" + self._reset_document_state() + self._reset_history_baseline() + result = { + "ok": True, + "name": Path(path).name, + "path": path, + "document": key, + "open_documents": len(self._docs), + } + if reloaded: + result["reloaded"] = True + if released is not None: + result["quarantine_cleared"] = released.to_dict() + return result + + return await self._async(_sync, quarantine_exit=True) +``` + +```python + async def drawing_close(self, save: bool = True) -> dict: + """The active-document case of ``document_close``, with the 1.4 contract kept. + + ``save=True`` saves when the document has a path and is not quarantined. + An untitled or quarantined document is closed *without* saving and the + result says so (``saved: False``, ``discarded_changes``, ``warning``) — + the pre-registry behaviour two shipped tests pin. ``document_close`` is + the strict tool: it refuses instead of warning. + """ + + def _sync(): + if self._active_key is None: + return {"ok": True, "closed": None, "saved": False, "active": None, "open_documents": 0} + key = self._active_key + state = self._docs[key] + quarantined = self._quarantine is not None + dirty = state.dirty + can_save = save and dirty and bool(state.path) and not quarantined + result = self._close_document_sync(key, save=can_save, discard=not can_save) + if save and dirty and not can_save: + result["warning"] = ( + "the document was quarantined after a call was abandoned mid-write, " + "so unsaved changes were discarded rather than serialised out of a " + "drawing another thread may still have been mutating" + if quarantined + else f"{key} had never been saved, so save=True had nowhere to write; " + "its changes were discarded. drawing_save_as(path) before closing, or " + "document_close(discard=True) to say so explicitly." + ) + return result + + return await self._async(_sync, quarantine_exit=True) +``` + +`system_status` — add two keys after `"document_path"`: + +```python + "open_documents": len(self._docs), + "active_document": self._active_key, +``` + +Capability map (ezdxf) — add after `"lisp"` (the only shared anchor that exists in this worktree; merged order at that spot is S's `"mleaderstyle"`, then C's `"registry_sysvar"` / `"dwgprops"`, then these — the skeleton's Merge notes): + +```python + "documents": FeatureCapability(True, "registry"), + # Track E keys. `@capability` registers its key at import, and + # tests/test_capability_contract.py checks both maps from that + # moment on — so every key the contract module uses is declared + # here in the task that creates the module, not when the COM + # implementation lands (Task 23). + "live_application": FeatureCapability( + False, reason="headless_engine_has_no_application_to_launch_or_attach" + ), + "preferences": FeatureCapability( + False, reason="preferences_live_in_the_running_application_not_in_a_file" + ), + "interactive_prompt": FeatureCapability( + False, reason="no_operator_to_prompt_headlessly" + ), + # Byte-identical to group C's Task 16 entry; the merge agent keeps one. + "dwgprops": FeatureCapability( + False, + reason="summary_fields_need_live_autocad;custom_properties_supported", + ), +``` + +Then the new block. Place it **immediately after the `# ── layouts / paper space ──` block, i.e. directly before ` # ── viewports ──`** (after `layout_copy`). This is group V's reserved anchor: S's block sits before `# ── corner ops`, C's at the very end of the class, P's before `# ── 3D solids` — four non-adjacent positions, so no two groups edit the same lines (skeleton, Merge notes). Tasks 22 and 23 append inside this block. + +```python + # --------------------------------------------------------------------------- + # ── environment (track E) ─────────────────────────────────────────────────── + # --------------------------------------------------------------------------- + + async def document_list(self) -> list[dict]: + def _sync(): + return [self._document_row(key, state) for key, state in self._docs.items()] + + return await self._async(_sync) + + async def document_activate(self, name_or_path: str) -> dict: + if not isinstance(name_or_path, str): + raise TypeError( + f"document_activate: name_or_path must be a string, got {type(name_or_path).__name__}" + ) + + def _sync(): + key = self._resolve_document_key(name_or_path) + previous = self._active_key + self._activate_key(key) + state = self._docs[key] + return { + "ok": True, + "active": key, + "previous": previous, + "name": state.label, + "path": state.path, + } + + # Not a quarantine exit: a quarantined backend refuses to switch, because + # the runaway thread holds the one worker and the one orphaned lock. + return await self._async(_sync) + + 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") + # Resolve once outside the lock to decide whether this is the active + # document (then it is a quarantine exit, like drawing_close); the + # closure resolves again under the lock before touching the registry. + target = self._resolve_document_key(name_or_path) + closing_active = target == self._active_key + + def _sync(): + key = self._resolve_document_key(name_or_path) + return self._close_document_sync(key, save=save, discard=discard) + + return await self._async(_sync, quarantine_exit=closing_active) +``` + +Run: `uv run --frozen pytest tests/test_documents.py -k "not com_" tests/test_ezdxf_quarantine.py tests/test_undo_redo.py tests/test_sprint4_ezdxf.py tests/test_preflight.py tests/test_ezdxf_honesty.py tests/test_layout_lifecycle.py tests/test_paper_space.py -v` +Expected: all PASS — the registry is invisible to every pre-existing test. + +- [ ] **Step 5: COM — `document_*` over `Documents`, `drawing_close` folded in** + +In `backends/com_backend.py`, replace `drawing_close`: + +```python + async def drawing_close(self, save: bool = True) -> dict: + """The active-document case of ``document_close``. + + ``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) +``` + +Add the capability-map entries after `"lisp"` (same rule as the ezdxf map: merged order S → C → V at that spot): + +```python + "documents": FeatureCapability(True, "native"), + "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" + ), + # Byte-identical to group C's Task 16 entry; the merge agent keeps one. + "dwgprops": FeatureCapability(True, "native"), +``` + +Add the new block **immediately after the `# ── layouts / paper space ──` block, i.e. directly before ` # ── selection filters (M8 / F1) ──`** (after `layout_copy`) — V's reserved anchor on the COM side too; P's page-setup block goes before `# ── 3D solids`, C's at the end of the class, S's before `# ── corner ops`: + +```python + # --------------------------------------------------------------------------- + # ── 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 +``` + +Run: `uv run --frozen pytest tests/test_documents.py tests/test_capability_contract.py tests/test_com_backend.py -v` +Expected: all PASS (the capability gate proves both backends instantiate and that `dwgprops` — the temporary key — is declared in both maps). + +- [ ] **Step 6: Register the three tools, alias records, README, inventory, registry snapshot** + +In `server.py`, before the `# ── RESOURCES` block: + +```python +# --------------------------------------------------------------------------- +# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── +# --------------------------------------------------------------------------- + + +@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) +``` + +Update the existing `drawing_close` docstring in SECTION 1 to name the fold: + +```python + """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.""" +``` + +In `discovery/aliases.py`, add `"CLOSE"` to `SHARED_ACAD_COMMANDS` with a comment line in the block above it: + +```python +# CLOSE -- the active document (drawing_close) or one named document +# (document_close); a drafter typing it could mean either. +``` + +and add a new alias block after the P&ID block, alphabetical within it: + +```python + # ── 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", + ), + ), + "document_list": ToolAliases( + acad=(), + synonyms=( + "which drawings are open", + "open drawings", + "list documents", + "what files are open", + "which one is active", + ), + ), +``` + +`README.md`: snapshot line `166 tools` → `169 tools`; add a row to the "What you get" table after **Paper space**: + +```markdown +| Environment | `document_list/activate/close` — several drawings open at once on both engines, the active one is what every tool targets | +``` + +`tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`: `"drawing": 11` → `14`, the total `166` → `169`, and one sentence in the docstring: "`drawing` moved 11 -> 14 when v1.6's `document_list` / `document_activate` / `document_close` (multi-document on both engines) opened SECTION 20." + +Then: `uv run --frozen python scripts/generate_tool_inventory.py`. + +- [ ] **Step 7: Run the gates** + +Run: `uv run --frozen pytest tests/test_documents.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_tool_packs.py -v` +Expected: all PASS. (`test_document_numbers_match_the_tree` failing only on the README `collected tests` figure is the pre-existing drift the roadmap memory records — leave it for the final task.) + +- [ ] **Step 8: Commit** + +```bash +git add backends/contracts/environment.py backends/contracts/__init__.py backends/base.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_documents.py +git commit -m "feat(environment): multi-document on both engines — ezdxf document registry, document_list/activate/close, drawing_close folded in + +EnvironmentContract carries every member of the track E interface; the two +drawing_properties_* members are @capability('dwgprops') in this worktree only +and become @abstractmethod when group C's implementations merge. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 22: Layer states, named views and UCS on both engines + +**Files:** +- Create: `engineering/environment/__init__.py`, `engineering/environment/names.py`, `engineering/environment/layer_states.py`, `engineering/environment/views.py`, `engineering/environment/ucs.py` +- Modify: `backends/contracts/environment.py` (append the ten abstract members at the marked insertion point) +- Modify: `backends/ezdxf_backend.py` (environment block: `layer_state_*`, `view_named_*`, `ucs_*`; capability map) +- Modify: `backends/com_backend.py` (environment block: same ten + module-level `_avar`; capability map) +- Modify: `server.py` (SECTION 20 header `(3 tools)` → `(13 tools)`; ten tools), `discovery/aliases.py` (ten records; `LAYERSTATE`, `VIEW`, `UCS` in `SHARED_ACAD_COMMANDS`), `README.md` (`169 tools` → `179 tools`; Environment row), `docs/tool-inventory.json`, `tests/test_tool_registry.py` (`layers` 14 → 18, `view` 4 → 10, total 179) +- Test: `tests/test_layer_states.py`, `tests/test_views_ucs.py` + +**Interfaces:** +- Produces (`engineering/environment/layer_states.py`): `DICT_NAME = "ACADMCP_LAYERSTATES"`, `PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current")`, `snapshot_from_layers(layers: list[LayerInfo], current_layer: str, *, plot: dict[str, bool] | None = None, description: str | None = None) -> dict`, `encode_state(state) -> list[str]` (JSON, ASCII-escaped, chunks ≤ 255 characters), `decode_state(chunks) -> dict` (`ValueError` on anything that is not a version-1 state), `diff_snapshot(state, layers) -> (missing, new)`, `validate_properties(properties) -> tuple[str, ...]`, `validate_state_name(name) -> str`. The plan's signature for `snapshot_from_layers` is kept; `plot` is a keyword extension because `LayerInfo` carries no plot flag and the engines read it themselves (`layer.dxf.plot` / `Plottable`). +- Produces (`engineering/environment/names.py`): `validate_name(name, *, what: str, max_len: int = 255) -> str` — non-empty string, stripped, no control characters, `ValueError` naming `what`. +- Produces (`engineering/environment/views.py`): `resolve_view_args(center, height, width) -> dict` (`{"center": (x, y) | None, "height": float | None, "width": float | None}`; `height`/`width` ≤ 0 or non-finite → `ValueError`). +- Produces (`engineering/environment/ucs.py`): `WORLD = "world"`, `resolve_ucs_axes(origin, x_axis, y_axis) -> dict` (`{"origin", "x_axis", "y_axis", "z_axis", "angle_deg"}`, unit vectors; non-orthogonal → `ValueError` carrying the measured angle; a zero vector → `ValueError`). +- Backend methods: the ten `EnvironmentContract` members, shapes per the Interfaces block. `view_named_restore` on ezdxf returns `"applied": "header_only"` (the plan's literal) plus `"store": "vport_active"` — see the measured note. `ucs_set` refuses before any write on both engines. +- `EzdxfBackend._active_vport(doc)` is **group C's** helper (Task 14, `# ── settings (track E, group C) ──` block; grid and snap live on the same `*Active` VPORT entry). C's definition ships. This task carries a byte-equivalent copy marked `# TEMPORARY (track-e/environment worktree only)` so the worktree runs without C's code; the merge agent deletes V's copy (two definitions in one class are ruff F811 and the later one silently wins). V's call sites (`self._active_vport(doc)`) are unchanged by the deletion. + +**Measured before writing (2026-09-16, ezdxf 1.4.4):** `doc.header["$VIEWCTR"] = ...` raises `DXFKeyError: Invalid header variable $VIEWCTR` — ezdxf's header table has no `$VIEWCTR`/`$VIEWSIZE` (in DXF they are VPORT properties, not header variables). The headless store for "the view the file opens on" is the `*Active` entry of `doc.viewports` (`center`, `height`, `aspect_ratio`; defaults `(0, 0)`, `1000`, `1.34`; `doc.viewports.get("*Active")` returns a list, `doc.viewports.new("*Active")` *adds* a second entry, so create only when the list is empty), and it survives save/reopen. `doc.views.add(name, dxfattribs={center, height, width})` **replaces** an existing name silently (one entry, a new handle), lookup is case-insensitive, entries survive reopen. `doc.ucs.add(name, dxfattribs={origin, xaxis, yaxis})` plus `$UCSNAME` / `$UCSORG` / `$UCSXDIR` / `$UCSYDIR` (all valid header variables; defaults `''`, `(0,0,0)`, `(1,0,0)`, `(0,1,0)`) survive reopen. `doc.rootdict.get_required_dict(name)` creates or returns the dictionary; `add_xrecord(key)` + `extend([(1000, chunk), ...])` round-trips through save/reopen; `remove(key)` destroys the entry; ezdxf accepts a 300-character 1000-tag and writes it back unchanged, so the ≤ 255 rule is ours (AutoCAD's `SetXRecordData` limit), not the library's. `Layer.color = n` preserves the on/off sign (`dxf.color` stays negative while off); `Layer.dxf.plot` is `1` by default; `Layer.freeze()/thaw()/lock()/unlock()/on()/off()` exist. `Dictionary` keys are case-sensitive in ezdxf (`'state_a' in d` is False after adding `State_A`), so lookups scan for a case-insensitive match (AutoCAD's dictionaries are case-insensitive). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_layer_states.py +"""Portable layer states: save → change → restore equals the snapshot. + +These are the server's own layer states — JSON chunks in an XRECORD under +``ACADMCP_LAYERSTATES`` in the drawing's named object dictionary. They live +in the file and travel with it; they do NOT appear in AutoCAD's Layer States +Manager. The codec is engine-neutral, so the ezdxf tests prove the format and +the COM tests prove the ActiveX call shapes. +""" + +from __future__ import annotations + +import json +import types + +import pytest + +from backends.base import LayerInfo +from engineering.environment.layer_states import ( + DICT_NAME, + PROPERTIES, + decode_state, + diff_snapshot, + encode_state, + snapshot_from_layers, + validate_properties, +) + +pytestmark = pytest.mark.asyncio + + +def _layer(name, **overrides) -> LayerInfo: + base = dict( + name=name, + color=7, + linetype="Continuous", + lineweight=-3, + is_on=True, + is_frozen=False, + is_locked=False, + is_current=False, + ) + base.update(overrides) + return LayerInfo(**base) + + +# ── codec ─────────────────────────────────────────────────────────────────── + + +def test_snapshot_records_every_property_and_the_current_layer(): + state = snapshot_from_layers( + [_layer("0"), _layer("HIDDEN", color=8, linetype="HIDDEN", lineweight=35, is_frozen=True)], + "HIDDEN", + plot={"HIDDEN": False}, + description="before plotting", + ) + assert state == { + "version": 1, + "description": "before plotting", + "current_layer": "HIDDEN", + "layers": { + "0": { + "on": True, "frozen": False, "locked": False, "color": 7, + "linetype": "Continuous", "lineweight": -3, "plot": True, + }, + "HIDDEN": { + "on": True, "frozen": True, "locked": False, "color": 8, + "linetype": "HIDDEN", "lineweight": 35, "plot": False, + }, + }, + } + + +def test_encode_chunks_are_at_most_255_ascii_characters_and_round_trip(): + layers = [_layer(f"LAYER-{i:03d}-{'x' * 40}") for i in range(60)] + state = snapshot_from_layers(layers, "0") + chunks = encode_state(state) + assert len(chunks) > 5 + assert all(len(c) <= 255 and c.isascii() for c in chunks) + assert decode_state(chunks) == state + + +def test_unicode_layer_names_survive_the_ascii_chunking(): + state = snapshot_from_layers([_layer("KÖRPER-Ø20")], "KÖRPER-Ø20") + chunks = encode_state(state) + assert all(c.isascii() for c in chunks), "escaped so a chunk boundary never splits a code point" + assert decode_state(chunks)["current_layer"] == "KÖRPER-Ø20" + + +@pytest.mark.parametrize( + "chunks, fragment", + [ + (["not json"], "not valid JSON"), + ([json.dumps({"version": 2, "layers": {}})], "version"), + ([json.dumps({"version": 1, "layers": {"A": {"on": True}}})], "lacks"), + ([json.dumps([1, 2])], "version"), + ], +) +def test_decode_refuses_anything_that_is_not_a_state(chunks, fragment): + with pytest.raises(ValueError, match=fragment): + decode_state(chunks) + + +def test_diff_reports_missing_and_new_layers_sorted(): + state = snapshot_from_layers([_layer("A"), _layer("B"), _layer("C")], "0") + missing, new = diff_snapshot(state, [_layer("C"), _layer("Z"), _layer("A"), _layer("Y")]) + assert missing == ["B"] and new == ["Y", "Z"] + + +def test_validate_properties_defaults_to_all_and_names_the_bad_one(): + assert validate_properties(None) == PROPERTIES + assert validate_properties(["color", "on", "color"]) == ("color", "on") + with pytest.raises(ValueError, match=r"properties\[1\]: 'colour'"): + validate_properties(["on", "colour"]) + with pytest.raises(TypeError, match="list"): + validate_properties("on") + with pytest.raises(ValueError, match="at least one"): + validate_properties([]) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def _seed(backend): + await backend.layer_create("GEOMETRY", color=7, linetype="Continuous", lineweight=50) + await backend.layer_create("HIDDEN", color=8, linetype="HIDDEN", lineweight=35) + await backend.layer_create("CENTER", color=1, linetype="CENTER", lineweight=25) + await backend.layer_set_current("GEOMETRY") + await backend.layer_freeze("CENTER") + await backend.layer_hide("HIDDEN") + + +async def _table(backend) -> dict: + return { + lyr.name: ( + lyr.color, lyr.linetype, lyr.lineweight, + lyr.is_on, lyr.is_frozen, lyr.is_locked, lyr.is_current, + ) + for lyr in await backend.layer_list() + } + + +async def test_save_change_restore_equals_the_snapshot(backend): + await _seed(backend) + before = await _table(backend) + saved = await backend.layer_state_save("PLOT", description="plot set") + # measured: ezdxf.new() already has "0" and "Defpoints", so the seeded + # table is five layers and serialises to three <= 255-character chunks + assert saved == { + "ok": True, "name": "PLOT", "layer_count": 5, "replaced": False, "chunks": 3, "backend": "ezdxf", + } + + await backend.layer_thaw("CENTER") + await backend.layer_show("HIDDEN") + await backend.layer_lock("GEOMETRY") + await backend.layer_modify("HIDDEN", color=3, linetype="Continuous", lineweight=13) + await backend.layer_set_current("0") + assert await _table(backend) != before + + restored = await backend.layer_state_restore("PLOT") + assert restored["applied"] == { + "layers": 5, "properties": list(PROPERTIES), "current_layer": "GEOMETRY", + } + assert restored["missing_layers"] == [] and restored["new_layers"] == [] + assert await _table(backend) == before + + +async def test_restore_applies_only_the_requested_properties(backend): + await _seed(backend) + await backend.layer_state_save("S") + await backend.layer_thaw("CENTER") + await backend.layer_modify("CENTER", color=5) + result = await backend.layer_state_restore("S", properties=["color"]) + assert result["applied"]["properties"] == ["color"] + center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER") + assert center.color == 1 and center.is_frozen is False, "frozen was not requested" + + +async def test_restore_reports_missing_and_new_layers_and_never_creates(backend): + await _seed(backend) + await backend.layer_state_save("S") + await backend.layer_delete("CENTER") + await backend.layer_create("NEW-ONE") + result = await backend.layer_state_restore("S") + assert result["missing_layers"] == ["CENTER"] and result["new_layers"] == ["NEW-ONE"] + assert "CENTER" not in [lyr.name for lyr in await backend.layer_list()] + + +async def test_plot_flag_and_current_layer_round_trip(backend): + await _seed(backend) + backend._doc.layers.get("HIDDEN").dxf.plot = 0 + await backend.layer_state_save("S") + backend._doc.layers.get("HIDDEN").dxf.plot = 1 + await backend.layer_set_current("0") + await backend.layer_state_restore("S", properties=["plot", "current"]) + assert backend._doc.layers.get("HIDDEN").dxf.plot == 0 + assert backend._current_layer == "GEOMETRY" + assert backend._doc.header["$CLAYER"] == "GEOMETRY" + + +async def test_state_survives_save_and_reopen(backend, tmp_path): + await _seed(backend) + await backend.layer_state_save("PLOT", description="d") + path = tmp_path / "states.dxf" + await backend.drawing_save(str(path)) + await backend.document_close(None) + await backend.drawing_open(str(path)) + assert await backend.layer_state_list() == [ + {"name": "PLOT", "description": "d", "layer_count": 5} + ] + await backend.layer_thaw("CENTER") + await backend.layer_state_restore("PLOT") + center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER") + assert center.is_frozen is True + + +async def test_xrecord_chunks_stay_under_256_characters(backend): + for i in range(80): + await backend.layer_create(f"LONG-LAYER-NAME-{i:03d}-{'x' * 30}") + saved = await backend.layer_state_save("BIG") + assert saved["chunks"] > 10 + xrecord = backend._doc.rootdict.get(DICT_NAME).get("BIG") + tags = [t for t in xrecord.tags if t.code == 1000] + assert len(tags) == saved["chunks"] + assert all(len(t.value) <= 255 for t in tags) + + +async def test_replace_delete_and_missing_names(backend): + await backend.layer_state_save("S") + again = await backend.layer_state_save("s") + assert again["replaced"] is True, "AutoCAD dictionaries are case-insensitive" + assert [row["name"] for row in await backend.layer_state_list()] == ["s"] + assert await backend.layer_state_delete("S") == {"ok": True, "deleted": "s", "backend": "ezdxf"} + assert await backend.layer_state_list() == [] + with pytest.raises(ValueError, match="no layer state named 'S'"): + await backend.layer_state_restore("S") + with pytest.raises(ValueError, match="no layer state named 'S'"): + await backend.layer_state_delete("S") + + +async def test_refusals_happen_before_any_write(backend): + with pytest.raises(ValueError, match="layer state name"): + await backend.layer_state_save(" ") + with pytest.raises(TypeError, match="description"): + await backend.layer_state_save("S", description=3) + assert DICT_NAME not in backend._doc.rootdict, "a refused save creates no dictionary" + await backend.layer_state_save("S") + with pytest.raises(ValueError, match=r"properties\[0\]: 'plottable'"): + await backend.layer_state_restore("S", properties=["plottable"]) + assert len(list(backend._doc.rootdict.get(DICT_NAME).keys())) == 1 + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _FakeXRecord: + def __init__(self, name): + self.name = name + self.types = None + self.values = None + self.deleted = False + + def SetXRecordData(self, types_, values): + self.types, self.values = list(types_), list(values) + + def GetXRecordData(self): + return self.types, self.values + + def Delete(self): + self.deleted = True + + +class _FakeDict: + def __init__(self, name): + self.Name = name + self.entries: list[_FakeXRecord] = [] + self.calls: list[tuple[str, tuple]] = [] + + @property + def Count(self): + return len(self.entries) + + def Item(self, index): + return self.entries[index] + + def GetName(self, obj): + return obj.name + + def AddXRecord(self, keyword): + self.calls.append(("AddXRecord", (keyword,))) + rec = _FakeXRecord(keyword) + self.entries.append(rec) + return rec + + def Remove(self, name): + self.calls.append(("Remove", (name,))) + rec = next(e for e in self.entries if e.name == name) + self.entries.remove(rec) + return rec + + +class _FakeDictionaries: + def __init__(self): + self.dicts: dict[str, _FakeDict] = {} + self.calls: list[tuple[str, tuple]] = [] + + def Item(self, name): + self.calls.append(("Item", (name,))) + if name not in self.dicts: + raise RuntimeError(f"no dictionary {name}") + return self.dicts[name] + + def Add(self, name): + self.calls.append(("Add", (name,))) + self.dicts[name] = _FakeDict(name) + return self.dicts[name] + + +class _FakeLayer: + def __init__(self, name, **props): + self.Name = name + self.Color = props.get("color", 7) + self.Linetype = props.get("linetype", "Continuous") + self.LineWeight = props.get("lineweight", -3) + self.LayerOn = props.get("on", True) + self.Freeze = props.get("frozen", False) + self.Lock = props.get("locked", False) + self.Plottable = props.get("plot", True) + + +class _FakeLayers: + def __init__(self, layers): + self._layers = layers + + @property + def Count(self): + return len(self._layers) + + def Item(self, key): + if isinstance(key, int): + return self._layers[key] + for lyr in self._layers: + if lyr.Name == key: + return lyr + raise RuntimeError(f"no layer {key}") + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + layers = [ + _FakeLayer("0"), + _FakeLayer("GEOMETRY", lineweight=50), + _FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False), + ] + document = types.SimpleNamespace( + Dictionaries=_FakeDictionaries(), + Layers=_FakeLayers(layers), + ActiveLayer=layers[1], + ) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None) + monkeypatch.setattr(module, "_regen", lambda: None) + monkeypatch.setattr(module, "_ai", lambda values: list(values)) + monkeypatch.setattr(module, "_avar", lambda values: list(values)) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_save_creates_the_dictionary_and_one_xrecord_of_1000_chunks(com_backend): + backend, document = com_backend + result = await backend.layer_state_save("PLOT", description="d") + assert result["ok"] and result["backend"] == "com" and result["replaced"] is False + assert document.Dictionaries.calls == [("Item", (DICT_NAME,)), ("Add", (DICT_NAME,))] + states = document.Dictionaries.dicts[DICT_NAME] + assert states.calls == [("AddXRecord", ("PLOT",))] + (rec,) = states.entries + assert rec.types == [1000] * len(rec.values) + assert all(len(v) <= 255 for v in rec.values) + state = decode_state(rec.values) + assert state["current_layer"] == "GEOMETRY" + assert state["layers"]["HIDDEN"]["plot"] is False + again = await backend.layer_state_save("plot") + assert again["replaced"] is True and again["name"] == "PLOT" + assert len(states.entries) == 1, "SetXRecordData on the existing record, no second AddXRecord" + + +async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_backend): + backend, document = com_backend + await backend.layer_state_save("S") + hidden = document.Layers.Item("HIDDEN") + hidden.Freeze = False + hidden.Color = 3 + hidden.Plottable = True + document.ActiveLayer = document.Layers.Item("0") + result = await backend.layer_state_restore("S") + assert result["applied"]["layers"] == 3 + assert result["applied"]["current_layer"] == "GEOMETRY" + assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False + assert document.ActiveLayer.Name == "GEOMETRY" + assert result["missing_layers"] == [] and result["new_layers"] == [] + + +async def test_com_list_and_delete(com_backend): + backend, document = com_backend + await backend.layer_state_save("A", description="one") + await backend.layer_state_save("B") + assert await backend.layer_state_list() == [ + {"name": "A", "description": "one", "layer_count": 3}, + {"name": "B", "description": None, "layer_count": 3}, + ] + assert (await backend.layer_state_delete("a"))["deleted"] == "A" + states = document.Dictionaries.dicts[DICT_NAME] + assert states.calls[-1] == ("Remove", ("A",)) + with pytest.raises(ValueError, match="no layer state named 'A'"): + await backend.layer_state_delete("A") +``` + +```python +# tests/test_views_ucs.py +"""Named views and UCS on both engines. + +Headless, a named view is a VIEW table entry and "restore" writes the +``*Active`` VPORT (the view the file opens on) — ezdxf's header has no +``$VIEWCTR``; measured. A UCS is a UCS table entry plus the ``$UCS*`` header +variables. Tool coordinates stay WCS on both engines: a UCS is stored and +made current for the operator, and no tool interprets its inputs in it. +""" + +from __future__ import annotations + +import types + +import pytest + +from engineering.environment.ucs import resolve_ucs_axes +from engineering.environment.views import resolve_view_args + +pytestmark = pytest.mark.asyncio + + +# ── pure helpers ──────────────────────────────────────────────────────────── + + +def test_view_args_validate_before_any_write(): + assert resolve_view_args([10, 20], 50, None) == { + "center": (10.0, 20.0), "height": 50.0, "width": None, + } + assert resolve_view_args(None, None, None) == {"center": None, "height": None, "width": None} + with pytest.raises(ValueError, match="height must be > 0"): + resolve_view_args(None, 0, None) + with pytest.raises(ValueError, match="width must be > 0"): + resolve_view_args(None, 10, -1) + with pytest.raises(ValueError, match="center"): + resolve_view_args([1], None, None) + with pytest.raises(ValueError, match="finite"): + resolve_view_args([float("nan"), 0], None, None) + + +def test_ucs_axes_are_normalised_and_orthogonality_is_measured(): + axes = resolve_ucs_axes([10, 10, 0], [0, 2, 0], [-3, 0, 0]) + assert axes["origin"] == (10.0, 10.0, 0.0) + assert axes["x_axis"] == (0.0, 1.0, 0.0) and axes["y_axis"] == (-1.0, 0.0, 0.0) + assert axes["z_axis"] == (0.0, 0.0, 1.0) + assert axes["angle_deg"] == pytest.approx(90.0) + with pytest.raises(ValueError, match=r"measured 45\.00"): + resolve_ucs_axes([0, 0, 0], [1, 0, 0], [1, 1, 0]) + with pytest.raises(ValueError, match="x_axis.*zero"): + resolve_ucs_axes([0, 0, 0], [0, 0, 0], [0, 1, 0]) + with pytest.raises(ValueError, match="origin"): + resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0]) + + +# ── headless: named views ─────────────────────────────────────────────────── + + +async def test_view_defaults_to_the_drawing_extents(backend): + await backend.entity_create_line(0, 0, 100, 50) + result = await backend.view_named_save("OVERALL") + assert result["ok"] and result["replaced"] is False + assert result["center"] == [50.0, 25.0] + # extents 100 x 50 at the *Active VPORT's default aspect 1.34: the width + # is the binding side, so height = 100 / 1.34 + assert result["height"] == pytest.approx(100.0 / 1.34) + assert result["width"] == pytest.approx(100.0) + view = backend._doc.views.get("OVERALL") + assert tuple(view.dxf.center)[:2] == (50.0, 25.0) + + +async def test_view_on_an_empty_drawing_needs_an_explicit_window(backend): + with pytest.raises(ValueError, match="no extents"): + await backend.view_named_save("EMPTY") + assert "EMPTY" not in backend._doc.views + result = await backend.view_named_save("EMPTY", center=[0, 0], height=10) + assert result["center"] == [0.0, 0.0] and result["height"] == 10.0 + assert result["width"] == pytest.approx(13.4) + + +async def test_view_replace_restore_and_list(backend): + await backend.view_named_save("V", center=[1, 2], height=10, width=15) + again = await backend.view_named_save("v", center=[5, 5], height=20) + assert again["replaced"] is True and again["name"] == "V" + assert len(list(backend._doc.views)) == 1 + restored = await backend.view_named_restore("v") + assert restored == { + "ok": True, + "name": "V", + "center": [5.0, 5.0], + "height": 20.0, + "width": pytest.approx(26.8), + "applied": "header_only", + "store": "vport_active", + "backend": "ezdxf", + } + (vport,) = backend._doc.viewports.get("*Active") + assert tuple(vport.dxf.center)[:2] == (5.0, 5.0) and vport.dxf.height == 20.0 + assert await backend.view_named_list() == [ + {"name": "V", "center": [5.0, 5.0], "height": 20.0, "width": pytest.approx(26.8)} + ] + with pytest.raises(ValueError, match="no named view 'NOPE'"): + await backend.view_named_restore("NOPE") + with pytest.raises(ValueError, match="height must be > 0"): + await backend.view_named_save("BAD", center=[0, 0], height=0) + assert "BAD" not in backend._doc.views + + +# ── headless: UCS ─────────────────────────────────────────────────────────── + + +async def test_ucs_set_stores_the_entry_and_makes_it_current(backend): + result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0]) + assert result == { + "ok": True, + "name": "FRONT", + "origin": [10.0, 10.0, 0.0], + "x_axis": [0.0, 1.0, 0.0], + "y_axis": [-1.0, 0.0, 0.0], + "replaced": False, + "current": True, + "backend": "ezdxf", + } + header = backend._doc.header + assert header["$UCSNAME"] == "FRONT" and tuple(header["$UCSORG"]) == (10.0, 10.0, 0.0) + assert tuple(header["$UCSXDIR"]) == (0.0, 1.0, 0.0) + rows = await backend.ucs_list() + assert rows[0] == { + "name": "world", + "origin": [0.0, 0.0, 0.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "current": False, + } + assert rows[1]["name"] == "FRONT" and rows[1]["current"] is True + + +async def test_ucs_non_orthogonal_is_refused_before_any_write(backend): + with pytest.raises(ValueError, match="measured 60"): + await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [0.5, 0.8660254, 0]) + assert "SKEW" not in backend._doc.ucs and backend._doc.header["$UCSNAME"] == "" + + +async def test_ucs_restore_world_and_named(backend): + await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0]) + world = await backend.ucs_restore("world") + assert world["name"] == "world" and backend._doc.header["$UCSNAME"] == "" + assert tuple(backend._doc.header["$UCSXDIR"]) == (1.0, 0.0, 0.0) + assert (await backend.ucs_list())[0]["current"] is True + named = await backend.ucs_restore("front") + assert named["name"] == "FRONT" and backend._doc.header["$UCSNAME"] == "FRONT" + with pytest.raises(ValueError, match="no UCS named 'NOPE'"): + await backend.ucs_restore("NOPE") + with pytest.raises(ValueError, match="reserved"): + await backend.ucs_set("world", [0, 0, 0], [1, 0, 0], [0, 1, 0]) + + +async def test_ucs_replace_keeps_one_entry(backend): + await backend.ucs_set("A", [0, 0, 0], [1, 0, 0], [0, 1, 0]) + again = await backend.ucs_set("a", [1, 1, 0], [1, 0, 0], [0, 1, 0]) + assert again["replaced"] is True and again["name"] == "A" + assert len(list(backend._doc.ucs)) == 1 + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _FakeView: + def __init__(self, name): + self.Name = name + self.Center = (0.0, 0.0) + self.Height = 100.0 + self.Width = 150.0 + + +class _FakeViews: + def __init__(self): + self.views: list[_FakeView] = [] + self.calls: list[tuple[str, tuple]] = [] + + @property + def Count(self): + return len(self.views) + + def Item(self, key): + if isinstance(key, int): + return self.views[key] + for v in self.views: + if v.Name.lower() == key.lower(): + return v + raise RuntimeError(f"no view {key}") + + def Add(self, name): + self.calls.append(("Add", (name,))) + v = _FakeView(name) + self.views.append(v) + return v + + +class _FakeUcs: + def __init__(self, name, origin, xv, yv): + self.Name = name + self.Origin = origin + self.XVector = xv + self.YVector = yv + + +class _FakeUcsCollection: + def __init__(self): + self.items: list[_FakeUcs] = [] + self.calls: list[tuple[str, tuple]] = [] + + @property + def Count(self): + return len(self.items) + + def Item(self, key): + if isinstance(key, int): + return self.items[key] + for u in self.items: + if u.Name.lower() == key.lower(): + return u + raise RuntimeError(f"no ucs {key}") + + def Add(self, origin, x_point, y_point, name): + self.calls.append(("Add", (origin, x_point, y_point, name))) + # ActiveX stores unit vectors derived from the two points. + xv = tuple(p - o for p, o in zip(x_point, origin)) + yv = tuple(p - o for p, o in zip(y_point, origin)) + u = _FakeUcs(name, origin, xv, yv) + self.items.append(u) + return u + + +class _FakeDocument: + def __init__(self): + self.Views = _FakeViews() + self.UserCoordinateSystems = _FakeUcsCollection() + self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0) + self.viewport_sets = 0 + self.ActiveUCS = None + self.commands: list[str] = [] + + @property + def ActiveViewport(self): + return self._viewport + + @ActiveViewport.setter + def ActiveViewport(self, value): + self._viewport = value + self.viewport_sets += 1 + + def SendCommand(self, macro): + self.commands.append(macro) + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = _FakeDocument() + variables = {"CMDACTIVE": 0, "UCSNAME": ""} + app = types.SimpleNamespace(GetVariable=lambda name: variables[name]) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_acad_app", lambda: app) + monkeypatch.setattr(module, "_regen", lambda: None) + monkeypatch.setattr(module, "_apoint", lambda x, y, z=0.0: (float(x), float(y), float(z))) + monkeypatch.setattr(module, "_av", lambda values: tuple(float(v) for v in values)) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document, variables + + +async def test_com_view_save_defaults_to_the_active_viewport(com_backend): + backend, document, _ = com_backend + result = await backend.view_named_save("HOME") + assert document.Views.calls == [("Add", ("HOME",))] + (view,) = document.Views.views + assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0 + assert result["center"] == [30.0, 40.0] and result["replaced"] is False + again = await backend.view_named_save("home", center=[1, 2], height=10) + assert again["replaced"] is True and len(document.Views.views) == 1 + assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0 + + +async def test_com_view_restore_sets_the_active_viewport(com_backend): + backend, document, _ = com_backend + await backend.view_named_save("HOME", center=[5, 6], height=20, width=30) + result = await backend.view_named_restore("HOME") + assert result["applied"] == "active_viewport" + assert document.ActiveViewport.Center == (5.0, 6.0) + assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0 + assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment" + assert await backend.view_named_list() == [ + {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0} + ] + + +async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend): + backend, document, variables = com_backend + result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 5, 0], [-1, 0, 0]) + assert document.UserCoordinateSystems.calls == [ + ("Add", ((10.0, 10.0, 0.0), (10.0, 11.0, 0.0), (9.0, 10.0, 0.0), "FRONT")) + ] + assert document.ActiveUCS is document.UserCoordinateSystems.items[0] + assert result["x_axis"] == [0.0, 1.0, 0.0] and result["current"] is True + variables["UCSNAME"] = "FRONT" + rows = await backend.ucs_list() + assert rows[0]["name"] == "world" and rows[0]["current"] is False + assert rows[1] == { + "name": "FRONT", + "origin": [10.0, 10.0, 0.0], + "x_axis": [0.0, 1.0, 0.0], + "y_axis": [-1.0, 0.0, 0.0], + "current": True, + } + + +async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend): + backend, document, variables = com_backend + result = await backend.ucs_restore("world") + assert document.commands == ["_.UCS _W\n"] and result["name"] == "world" + variables["CMDACTIVE"] = 1 + with pytest.raises(RuntimeError, match="CMDACTIVE"): + await backend.ucs_restore("world") + assert len(document.commands) == 1 + + +async def test_com_ucs_refuses_before_activex(com_backend): + backend, document, _ = com_backend + with pytest.raises(ValueError, match="measured"): + await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [1, 1, 0]) + assert document.UserCoordinateSystems.calls == [] +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py -v` +Expected: FAIL at collection with `ModuleNotFoundError: No module named 'engineering.environment'`. + +- [ ] **Step 3: The pure modules** + +```python +# engineering/environment/__init__.py +"""Environment helpers (v1.6 track E): portable layer states, view and UCS +argument resolution. Pure data and validation — no ezdxf, no COM.""" + +from .layer_states import ( # noqa: F401 + DICT_NAME, + PROPERTIES, + decode_state, + diff_snapshot, + encode_state, + snapshot_from_layers, + validate_properties, + validate_state_name, +) +from .names import validate_name # noqa: F401 +from .ucs import WORLD, resolve_ucs_axes # noqa: F401 +from .views import resolve_view_args # noqa: F401 +``` + +```python +# engineering/environment/names.py +"""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 +``` + +```python +# engineering/environment/layer_states.py +"""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) +``` + +```python +# engineering/environment/views.py +"""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 +``` + +```python +# engineering/environment/ucs.py +"""UCS axes, validated and normalised before either engine writes. + +Tool coordinates stay WCS on both engines (the repository rule): a UCS is +stored and made current for the operator's benefit, and nothing here starts +interpreting inputs in it. +""" + +from __future__ import annotations + +import math + +WORLD = "world" +ORTHO_TOLERANCE_DEG = 1e-3 + + +def _vec3(value, where: str) -> tuple[float, float, float]: + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise ValueError(f"{where} must be an [x, y, z] triple") + out = [] + for axis, item in zip("xyz", value): + 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)))) + 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} +``` + +Run: `uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py -k "not backend and not com_" -v` +Expected: the pure tests PASS; the engine tests still fail with `AttributeError: 'EzdxfBackend' object has no attribute 'layer_state_save'`. + +- [ ] **Step 4: Append the ten abstract members to the contract** + +In `backends/contracts/environment.py`, replace the marker comment line that starts with `# ── layer states, named views, UCS: appended by Task 22` with: + +```python + # ── layer states (portable ACADMCP_LAYERSTATES XRECORDs, both engines) ── + + @abstractmethod + async def layer_state_save(self, name: str, description: str | None = None) -> dict: + """→ ``{"ok", "name", "layer_count", "replaced"}``.""" + ... + + @abstractmethod + async def layer_state_restore( + self, name: str, properties: list[str] | None = None + ) -> dict: + """``properties`` ⊆ {"on","frozen","locked","color","linetype","lineweight","plot","current"}; + → ``{"ok", "name", "applied", "missing_layers", "new_layers"}``.""" + ... + + @abstractmethod + async def layer_state_list(self) -> list[dict]: + """→ ``[{"name", "description", "layer_count"}]``.""" + ... + + @abstractmethod + async def layer_state_delete(self, name: str) -> dict: ... + + # ── named views ───────────────────────────────────────────────────────── + + @abstractmethod + async def view_named_save( + self, name: str, center=None, height: float | None = None, width: float | None = None + ) -> dict: + """→ ``{"ok", "name", "center": [x, y], "height", "width", "replaced"}``.""" + ... + + @abstractmethod + async def view_named_restore(self, name: str) -> dict: + """ezdxf: sets the ``*Active`` VPORT, ``"applied": "header_only"``.""" + ... + + @abstractmethod + async def view_named_list(self) -> list[dict]: ... + + # ── UCS (stored and made current; tool coordinates stay WCS) ──────────── + + @abstractmethod + async def ucs_list(self) -> list[dict]: + """→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world".""" + ... + + @abstractmethod + async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict: + """non-orthogonal → ``ValueError`` with the measured angle.""" + ... + + @abstractmethod + async def ucs_restore(self, name: str) -> dict: + """``"world"`` resets.""" + ... +``` + +- [ ] **Step 5: ezdxf implementations** + +Append to the `# ── environment (track E) ──` block in `backends/ezdxf_backend.py` (after `document_close`): + +```python + # ── layer states ────────────────────────────────────────────────────────── + + @staticmethod + def _layer_state_entry(states, name: str): + """Case-insensitive lookup → ``(stored_key, xrecord)`` or ``(None, None)``.""" + if states is None: + return None, None + if name in states: + return name, states.get(name) + for key in states.keys(): + if key.lower() == name.lower(): + return key, states.get(key) + return None, None + + @staticmethod + def _layer_state_chunks(xrecord) -> list[str]: + return [str(tag.value) for tag in xrecord.tags if tag.code == 1000] + + @staticmethod + def _layer_states(doc): + from engineering.environment.layer_states import DICT_NAME + + return doc.rootdict.get(DICT_NAME) if DICT_NAME in doc.rootdict else None + + @staticmethod + def _layer_state_names(states) -> list[str]: + return list(states.keys()) if states is not None else [] + + def _layer_state_snapshot(self, doc, description): + from engineering.environment.layer_states import snapshot_from_layers + + layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] + plot = {lyr.dxf.name: bool(lyr.dxf.get("plot", 1)) for lyr in doc.layers} + return snapshot_from_layers( + layers, self._current_layer, plot=plot, description=description + ) + + async def layer_state_save(self, name, description=None) -> dict: + """Snapshot the layer table into ``ACADMCP_LAYERSTATES/``. + + Portable and file-resident; not an AutoCAD Layer States Manager entry. + """ + from engineering.environment.layer_states import ( + DICT_NAME, + encode_state, + validate_state_name, + ) + + clean = validate_state_name(name) + if description is not None and not isinstance(description, str): + raise TypeError("layer_state_save: description must be a string") + + def _sync(): + doc = self._require_doc() + state = self._layer_state_snapshot(doc, description) + chunks = encode_state(state) + states = doc.rootdict.get_required_dict(DICT_NAME) + existing_key, _ = self._layer_state_entry(states, clean) + replaced = existing_key is not None + if replaced: + states.remove(existing_key) + xrecord = states.add_xrecord(clean) + xrecord.extend([(1000, chunk) for chunk in chunks]) + self._mark_dirty() + return { + "ok": True, + "name": clean, + "layer_count": len(state["layers"]), + "replaced": replaced, + "chunks": len(chunks), + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def layer_state_restore(self, name, properties=None) -> dict: + from engineering.environment.layer_states import ( + decode_state, + diff_snapshot, + validate_properties, + validate_state_name, + ) + + clean = validate_state_name(name) + props = validate_properties(properties) + + def _sync(): + doc = self._require_doc() + states = self._layer_states(doc) + key, xrecord = self._layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_restore: no layer state named {clean!r}; " + f"saved states: {self._layer_state_names(states)}" + ) + state = decode_state(self._layer_state_chunks(xrecord)) + layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] + missing, new = diff_snapshot(state, layers) + applied = 0 + for layer_name, snap in state["layers"].items(): + if layer_name not in doc.layers: + continue # missing: reported, never created + lyr = doc.layers.get(layer_name) + # Colour first: `Layer.color` keeps the on/off sign, and the + # on/off call below must see the final colour. + if "color" in props: + lyr.color = int(snap["color"]) + if "linetype" in props: + _ensure_linetype_loaded(doc, snap["linetype"]) + lyr.dxf.linetype = snap["linetype"] + if "lineweight" in props: + lyr.dxf.lineweight = int(snap["lineweight"]) + if "plot" in props: + lyr.dxf.plot = 1 if snap["plot"] else 0 + if "on" in props: + if snap["on"]: + lyr.on() + else: + lyr.off() + if "frozen" in props: + if snap["frozen"]: + lyr.freeze() + else: + lyr.thaw() + if "locked" in props: + if snap["locked"]: + lyr.lock() + else: + lyr.unlock() + applied += 1 + current = None + if "current" in props and state["current_layer"] in doc.layers: + current = state["current_layer"] + self._current_layer = current + doc.header["$CLAYER"] = current + self._mark_dirty() + return { + "ok": True, + "name": key, + "applied": { + "layers": applied, + "properties": list(props), + "current_layer": current, + }, + "missing_layers": missing, + "new_layers": new, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def layer_state_list(self) -> list[dict]: + from engineering.environment.layer_states import decode_state + + def _sync(): + doc = self._require_doc() + states = self._layer_states(doc) + rows = [] + for key in self._layer_state_names(states): + state = decode_state(self._layer_state_chunks(states.get(key))) + rows.append( + { + "name": key, + "description": state.get("description"), + "layer_count": len(state["layers"]), + } + ) + return rows + + return await self._async(_sync) + + async def layer_state_delete(self, name) -> dict: + from engineering.environment.layer_states import validate_state_name + + clean = validate_state_name(name) + + def _sync(): + doc = self._require_doc() + states = self._layer_states(doc) + key, xrecord = self._layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_delete: no layer state named {clean!r}; " + f"saved states: {self._layer_state_names(states)}" + ) + states.remove(key) + self._mark_dirty() + return {"ok": True, "deleted": key, "backend": "ezdxf"} + + return await self._async(_sync) + + # ── named views ─────────────────────────────────────────────────────────── + + # TEMPORARY (track-e/environment worktree only): group C's Task 14 defines + # the same `_active_vport` in its `# ── settings (track E, group C) ──` + # block, and C's is the one that ships. This copy exists so this worktree + # runs without C's code; the merge agent deletes THIS definition (two in + # one class are ruff F811, and the later one would silently win). + @staticmethod + def _active_vport(doc): + """The ``*Active`` VPORT entry — the view the file opens on. Created if absent.""" + entries = doc.viewports.get("*Active") + if entries: + return entries[0] + return doc.viewports.new("*Active") + + @staticmethod + def _view_entry(doc, name: str): + if name in doc.views: # case-insensitive in ezdxf's table + return doc.views.get(name) + return None + + async def view_named_save(self, name, center=None, height=None, width=None) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.views import resolve_view_args + + clean = validate_name(name, what="view name") + args = resolve_view_args(center, height, width) + + def _sync(): + doc = self._require_doc() + vport = self._active_vport(doc) + aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34 + if args["center"] is None or args["height"] is None: + # Headless there is no display to read a "current view" from; + # the drawing's extents are the honest default. + bb = ezdxf_bbox.extents(doc.modelspace()) if _BBOX_OK else None + if bb is None or not bb.has_data: + raise ValueError( + f"view_named_save: {clean!r} — an empty drawing has no extents; " + "pass center and height" + ) + ext_center = ( + (bb.extmin.x + bb.extmax.x) / 2.0, + (bb.extmin.y + bb.extmax.y) / 2.0, + ) + # The window must hold the extents at the viewport's aspect: + # whichever side binds decides the height. + ext_height = max(bb.extmax.y - bb.extmin.y, (bb.extmax.x - bb.extmin.x) / aspect) + if ext_height <= 0: + ext_height = 1.0 + else: + ext_center, ext_height = args["center"], args["height"] + cx, cy = args["center"] or ext_center + h = args["height"] or ext_height + w = args["width"] or h * aspect + existing = self._view_entry(doc, clean) + replaced = existing is not None + stored = existing.dxf.name if replaced else clean + if replaced: + doc.views.remove(stored) + doc.views.add(stored, dxfattribs={"center": (cx, cy, 0.0), "height": h, "width": w}) + self._mark_dirty() + return { + "ok": True, + "name": stored, + "center": [float(cx), float(cy)], + "height": float(h), + "width": float(w), + "replaced": replaced, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def view_named_restore(self, name) -> dict: + from engineering.environment.names import validate_name + + clean = validate_name(name, what="view name") + + def _sync(): + doc = self._require_doc() + view = self._view_entry(doc, clean) + if view is None: + raise ValueError( + f"view_named_restore: no named view {clean!r}; " + f"saved views: {[v.dxf.name for v in doc.views]}" + ) + cx, cy = float(view.dxf.center.x), float(view.dxf.center.y) + h, w = float(view.dxf.height), float(view.dxf.width) + vport = self._active_vport(doc) + vport.dxf.center = (cx, cy) + vport.dxf.height = h + if h > 0: + vport.dxf.aspect_ratio = w / h + self._mark_dirty() + return { + "ok": True, + "name": view.dxf.name, + "center": [cx, cy], + "height": h, + "width": w, + # The plan's literal. Measured: ezdxf's header refuses $VIEWCTR, + # so the store is the *Active VPORT — what AutoCAD reads as the + # initial view when it opens the file. No live display moves. + "applied": "header_only", + "store": "vport_active", + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def view_named_list(self) -> list[dict]: + def _sync(): + doc = self._require_doc() + return [ + { + "name": view.dxf.name, + "center": [float(view.dxf.center.x), float(view.dxf.center.y)], + "height": float(view.dxf.height), + "width": float(view.dxf.width), + } + for view in doc.views + ] + + return await self._async(_sync) + + # ── UCS ─────────────────────────────────────────────────────────────────── + + @staticmethod + def _ucs_entry(doc, name: str): + for ucs in doc.ucs: + if ucs.dxf.name.lower() == name.lower(): + return ucs + return None + + @staticmethod + def _set_ucs_header(doc, name: str, origin, x_axis, y_axis) -> None: + doc.header["$UCSNAME"] = name + doc.header["$UCSORG"] = tuple(origin) + doc.header["$UCSXDIR"] = tuple(x_axis) + doc.header["$UCSYDIR"] = tuple(y_axis) + + @staticmethod + def _ucs_row(name, origin, x_axis, y_axis, current: bool) -> dict: + return { + "name": name, + "origin": [float(c) for c in origin], + "x_axis": [float(c) for c in x_axis], + "y_axis": [float(c) for c in y_axis], + "current": current, + } + + async def ucs_list(self) -> list[dict]: + from engineering.environment.ucs import WORLD + + def _sync(): + doc = self._require_doc() + current = str(doc.header.get("$UCSNAME", "") or "") + rows = [self._ucs_row(WORLD, (0, 0, 0), (1, 0, 0), (0, 1, 0), current == "")] + for ucs in doc.ucs: + rows.append( + self._ucs_row( + ucs.dxf.name, + ucs.dxf.origin, + ucs.dxf.xaxis, + ucs.dxf.yaxis, + current != "" and ucs.dxf.name.lower() == current.lower(), + ) + ) + return rows + + return await self._async(_sync) + + async def ucs_set(self, name, origin, x_axis, y_axis) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD, resolve_ucs_axes + + clean = validate_name(name, what="UCS name") + if clean.lower() == WORLD: + raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS") + axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any write + + def _sync(): + doc = self._require_doc() + existing = self._ucs_entry(doc, clean) + replaced = existing is not None + stored = existing.dxf.name if replaced else clean + if replaced: + doc.ucs.remove(stored) + doc.ucs.add( + stored, + dxfattribs={ + "origin": axes["origin"], + "xaxis": axes["x_axis"], + "yaxis": axes["y_axis"], + }, + ) + self._set_ucs_header(doc, stored, axes["origin"], axes["x_axis"], axes["y_axis"]) + self._mark_dirty() + return { + "ok": True, + "name": stored, + "origin": list(axes["origin"]), + "x_axis": list(axes["x_axis"]), + "y_axis": list(axes["y_axis"]), + "replaced": replaced, + "current": True, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def ucs_restore(self, name) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD + + clean = validate_name(name, what="UCS name") + + def _sync(): + doc = self._require_doc() + if clean.lower() == WORLD: + self._set_ucs_header(doc, "", (0, 0, 0), (1, 0, 0), (0, 1, 0)) + self._mark_dirty() + return {"ok": True, "name": WORLD, "current": True, "backend": "ezdxf"} + ucs = self._ucs_entry(doc, clean) + if ucs is None: + raise ValueError( + f"ucs_restore: no UCS named {clean!r}; " + f"saved: {[u.dxf.name for u in doc.ucs]} (or 'world')" + ) + self._set_ucs_header(doc, ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis) + self._mark_dirty() + return {"ok": True, "name": ucs.dxf.name, "current": True, "backend": "ezdxf"} + + return await self._async(_sync) +``` + +Capability map (ezdxf), after `"documents"`: + +```python + # Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads + # and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim. + "layer_states": FeatureCapability( + True, + "xrecord", + reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager", + ), + "named_views": FeatureCapability( + True, + "vport_active", + reason="restore_sets_the_view_the_file_opens_on;no_live_display", + ), + "ucs": FeatureCapability( + True, "stored_not_interpreted", reason="tool_coordinates_stay_wcs" + ), +``` + +Run: `uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py -k "not com_" -v` +Expected: the headless tests PASS. + +- [ ] **Step 6: COM implementations** + +Add next to `_ai` at module level in `backends/com_backend.py`: + +```python +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), + ) +``` + +Append to the `# ── environment (track E) ──` block: + +```python + # ── 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 [])) + if int(code) == 1000 + ] + + @staticmethod + def _com_layer_snapshot(doc, description): + from engineering.environment.layer_states import snapshot_from_layers + + current = str(doc.ActiveLayer.Name) + layers, plot = [], {} + for index in range(int(doc.Layers.Count)): + lyr = doc.Layers.Item(index) + layers.append(_layer_info(lyr, current)) + plot[str(lyr.Name)] = bool(lyr.Plottable) + return snapshot_from_layers(layers, current, plot=plot, description=description) + + async def layer_state_save(self, name, description=None) -> dict: + from engineering.environment.layer_states import encode_state, validate_state_name + + clean = validate_state_name(name) + if description is not None and not isinstance(description, str): + raise TypeError("layer_state_save: description must be a string") + + def _sync(): + doc = _acad_doc() + state = self._com_layer_snapshot(doc, description) + chunks = encode_state(state) + states = self._com_layer_states(doc, create=True) + key, xrecord = self._com_layer_state_entry(states, clean) + replaced = xrecord is not None + if xrecord is None: + xrecord = states.AddXRecord(clean) + key = clean + xrecord.SetXRecordData(_ai([1000] * len(chunks)), _avar(chunks)) + return { + "ok": True, + "name": key, + "layer_count": len(state["layers"]), + "replaced": replaced, + "chunks": len(chunks), + "backend": "com", + } + + return await self._run(_sync) + + async def layer_state_restore(self, name, properties=None) -> dict: + from engineering.environment.layer_states import ( + decode_state, + diff_snapshot, + validate_properties, + validate_state_name, + ) + + clean = validate_state_name(name) + props = validate_properties(properties) + + def _sync(): + doc = _acad_doc() + states = self._com_layer_states(doc, create=False) + key, xrecord = self._com_layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_restore: no layer state named {clean!r}; " + f"saved states: {[k for k, _ in self._com_dict_entries(states)]}" + ) + state = decode_state(self._com_xrecord_chunks(xrecord)) + current_name = str(doc.ActiveLayer.Name) + layers = [ + _layer_info(doc.Layers.Item(i), current_name) + for i in range(int(doc.Layers.Count)) + ] + missing, new = diff_snapshot(state, layers) + current = None + warnings: list[str] = [] + # Current first: AutoCAD refuses to freeze the active layer, so the + # active one must already be the state's before the loop freezes. + if "current" in props and state["current_layer"] not in missing: + doc.ActiveLayer = doc.Layers.Item(state["current_layer"]) + current = state["current_layer"] + applied = 0 + for layer_name, snap in state["layers"].items(): + if layer_name in missing: + continue + lyr = doc.Layers.Item(layer_name) + try: + if "color" in props: + lyr.Color = int(snap["color"]) + if "linetype" in props: + _ensure_linetype_loaded(snap["linetype"]) + lyr.Linetype = snap["linetype"] + if "lineweight" in props: + lyr.LineWeight = int(snap["lineweight"]) + if "plot" in props: + lyr.Plottable = bool(snap["plot"]) + if "on" in props: + lyr.LayerOn = bool(snap["on"]) + if "frozen" in props: + lyr.Freeze = bool(snap["frozen"]) + if "locked" in props: + lyr.Lock = bool(snap["locked"]) + except Exception as exc: # e.g. freezing the active layer + warnings.append(f"{layer_name}: {exc}") + continue + applied += 1 + _regen() + result = { + "ok": True, + "name": key, + "applied": { + "layers": applied, + "properties": list(props), + "current_layer": current, + }, + "missing_layers": missing, + "new_layers": new, + "backend": "com", + } + if warnings: + result["warnings"] = warnings + return result + + return await self._run(_sync) + + async def layer_state_list(self) -> list[dict]: + from engineering.environment.layer_states import decode_state + + def _sync(): + doc = _acad_doc() + states = self._com_layer_states(doc, create=False) + rows = [] + for key, xrecord in self._com_dict_entries(states): + state = decode_state(self._com_xrecord_chunks(xrecord)) + rows.append( + { + "name": key, + "description": state.get("description"), + "layer_count": len(state["layers"]), + } + ) + return rows + + return await self._run(_sync) + + async def layer_state_delete(self, name) -> dict: + from engineering.environment.layer_states import validate_state_name + + clean = validate_state_name(name) + + def _sync(): + doc = _acad_doc() + states = self._com_layer_states(doc, create=False) + key, xrecord = self._com_layer_state_entry(states, clean) + if xrecord is None: + raise ValueError( + f"layer_state_delete: no layer state named {clean!r}; " + f"saved states: {[k for k, _ in self._com_dict_entries(states)]}" + ) + removed = states.Remove(key) + try: + removed.Delete() + except Exception as exc: # Remove already detached it + log.debug("XRecord.Delete after Remove: %s", exc) + return {"ok": True, "deleted": key, "backend": "com"} + + return await self._run(_sync) + + # ── named views ─────────────────────────────────────────────────────────── + + @staticmethod + def _com_view(doc, name: str): + try: + return doc.Views.Item(name) + except Exception: + return None + + @staticmethod + def _com_view_row(view) -> dict: + center = view.Center + return { + "name": str(view.Name), + "center": [float(center[0]), float(center[1])], + "height": float(view.Height), + "width": float(view.Width), + } + + async def view_named_save(self, name, center=None, height=None, width=None) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.views import resolve_view_args + + clean = validate_name(name, what="view name") + args = resolve_view_args(center, height, width) + + def _sync(): + doc = _acad_doc() + vport = doc.ActiveViewport + vp_center = vport.Center + vp_height = float(vport.Height) or 1.0 + aspect = float(vport.Width) / vp_height if vp_height else 1.5 + cx, cy = args["center"] or (float(vp_center[0]), float(vp_center[1])) + h = args["height"] or vp_height + w = args["width"] or h * aspect + view = self._com_view(doc, clean) + replaced = view is not None + if view is None: + view = doc.Views.Add(clean) + view.Center = _av([cx, cy]) + view.Height = float(h) + view.Width = float(w) + return { + "ok": True, + "name": str(view.Name), + "center": [float(cx), float(cy)], + "height": float(h), + "width": float(w), + "replaced": replaced, + "backend": "com", + } + + return await self._run(_sync) + + async def view_named_restore(self, name) -> dict: + from engineering.environment.names import validate_name + + clean = validate_name(name, what="view name") + + def _sync(): + doc = _acad_doc() + view = self._com_view(doc, clean) + if view is None: + names = [str(doc.Views.Item(i).Name) for i in range(int(doc.Views.Count))] + raise ValueError( + f"view_named_restore: no named view {clean!r}; saved views: {names}" + ) + row = self._com_view_row(view) + vport = doc.ActiveViewport + vport.Center = _av(row["center"]) + vport.Height = row["height"] + vport.Width = row["width"] + # ActiveX applies viewport changes only when the object is assigned back. + doc.ActiveViewport = vport + _regen() + return {"ok": True, **row, "applied": "active_viewport", "backend": "com"} + + return await self._run(_sync) + + async def view_named_list(self) -> list[dict]: + def _sync(): + doc = _acad_doc() + return [self._com_view_row(doc.Views.Item(i)) for i in range(int(doc.Views.Count))] + + return await self._run(_sync) + + # ── UCS ─────────────────────────────────────────────────────────────────── + + @staticmethod + def _com_ucs(doc, name: str): + try: + return doc.UserCoordinateSystems.Item(name) + except Exception: + return None + + @staticmethod + def _com_ucs_row(ucs, current: str) -> dict: + return { + "name": str(ucs.Name), + "origin": [float(c) for c in ucs.Origin], + "x_axis": [float(c) for c in ucs.XVector], + "y_axis": [float(c) for c in ucs.YVector], + "current": current != "" and str(ucs.Name).lower() == current.lower(), + } + + async def ucs_list(self) -> list[dict]: + from engineering.environment.ucs import WORLD + + def _sync(): + app = _acad_app() + doc = _acad_doc() + current = str(app.GetVariable("UCSNAME") or "") + rows = [ + { + "name": WORLD, + "origin": [0.0, 0.0, 0.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "current": current == "", + } + ] + collection = doc.UserCoordinateSystems + for index in range(int(collection.Count)): + rows.append(self._com_ucs_row(collection.Item(index), current)) + return rows + + return await self._run(_sync) + + async def ucs_set(self, name, origin, x_axis, y_axis) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD, resolve_ucs_axes + + clean = validate_name(name, what="UCS name") + if clean.lower() == WORLD: + raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS") + axes = resolve_ucs_axes(origin, x_axis, y_axis) # refuses before any ActiveX call + + def _sync(): + doc = _acad_doc() + o, x, y = axes["origin"], axes["x_axis"], axes["y_axis"] + ucs = self._com_ucs(doc, clean) + replaced = ucs is not None + if ucs is None: + # ActiveX takes POINTS on the axes, not direction vectors. + ucs = doc.UserCoordinateSystems.Add( + _apoint(*o), + _apoint(o[0] + x[0], o[1] + x[1], o[2] + x[2]), + _apoint(o[0] + y[0], o[1] + y[1], o[2] + y[2]), + clean, + ) + else: + ucs.Origin = _apoint(*o) + ucs.XVector = _apoint(*x) + ucs.YVector = _apoint(*y) + doc.ActiveUCS = ucs + return { + "ok": True, + "name": str(ucs.Name), + "origin": list(o), + "x_axis": list(x), + "y_axis": list(y), + "replaced": replaced, + "current": True, + "backend": "com", + } + + return await self._run(_sync) + + async def ucs_restore(self, name) -> dict: + from engineering.environment.names import validate_name + from engineering.environment.ucs import WORLD + + clean = validate_name(name, what="UCS name") + + def _sync(): + app = _acad_app() + doc = _acad_doc() + if clean.lower() == WORLD: + # No ActiveX member selects WCS; the command does. Same guard as + # system_run_command: never send into an active prompt. + try: + cmd_active = int(app.GetVariable("CMDACTIVE")) + except Exception as exc: + log.debug("CMDACTIVE read failed, proceeding: %s", exc) + cmd_active = 0 + if cmd_active: + raise RuntimeError( + "AutoCAD has an active command or prompt (CMDACTIVE=" + f"{cmd_active}). Press ESC in AutoCAD to cancel, then retry." + ) + doc.SendCommand("_.UCS _W\n") + return {"ok": True, "name": WORLD, "current": True, "backend": "com"} + ucs = self._com_ucs(doc, clean) + if ucs is None: + names = [ + str(doc.UserCoordinateSystems.Item(i).Name) + for i in range(int(doc.UserCoordinateSystems.Count)) + ] + raise ValueError( + f"ucs_restore: no UCS named {clean!r}; saved: {names} (or 'world')" + ) + doc.ActiveUCS = ucs + return {"ok": True, "name": str(ucs.Name), "current": True, "backend": "com"} + + return await self._run(_sync) +``` + +Capability map (COM), after `"documents"`: + +```python + # 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" + ), +``` + +Run: `uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py tests/test_capability_contract.py -v` +Expected: all PASS. + +- [ ] **Step 7: Register the ten tools, aliases, README, inventory, registry snapshot** + +`server.py` SECTION 20 header → `(13 tools)`; append after `document_close`: + +```python +@cad_tool( + summary="Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, inside the file.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Save Layer State", "readOnlyHint": False, "destructiveHint": False}, + tags={"layer"}, +) +async def layer_state_save( + name: Annotated[str, "State name, e.g. PLOT-SET or DESIGN"], + description: Annotated[str | None, "Free text stored with the state"] = None, + ctx: Context = None, +) -> dict: + """Snapshot every layer's on/frozen/locked/color/linetype/lineweight/plot + flags plus the current layer as a named state stored in the drawing. + + These are the server's own portable layer states: JSON chunks in an XRECORD + under the `ACADMCP_LAYERSTATES` dictionary. They live in the file and + travel with it, work on both engines, and do **not** appear in AutoCAD's + Layer States Manager (LAYERSTATE). Same name replaces (`replaced: true`). + Refusals: an empty or control-character name, a non-string description. + Pack: settings · lean: no. + """ + await ctx.info(f"Saving layer state {name!r}") + return await _backend(ctx).layer_state_save(name, description) + + +@cad_tool(summary="Restore a saved layer state, all properties or a chosen subset.", cost="safe") +@mcp.tool( + annotations={"title": "Restore Layer State", "readOnlyHint": False, "destructiveHint": False}, + tags={"layer"}, +) +async def layer_state_restore( + name: Annotated[str, "A name from layer_state_list"], + properties: Annotated[ + list[str] | None, + "Subset of on, frozen, locked, color, linetype, lineweight, plot, current; omit for all", + ] = None, + ctx: Context = None, +) -> dict: + """Apply a saved state to the layers that still exist. + + `missing_layers` (in the state, not in the drawing) are skipped, never + created; `new_layers` (in the drawing, not in the state) are untouched; + `applied` says how many layers and which properties moved. Refusals: an + unknown state name (lists the saved ones), a property outside the eight + (names the index). Live, a layer AutoCAD refuses to change (freezing the + active layer) lands in `warnings` instead of failing the call. Portable + server states, not Layer States Manager entries. Pack: settings · lean: no. + """ + await ctx.info(f"Restoring layer state {name!r}") + return await _backend(ctx).layer_state_restore(name, properties) + + +@cad_tool(summary="List the layer states saved in this drawing.", cost="read") +@mcp.tool( + annotations={"title": "List Layer States", "readOnlyHint": True}, + tags={"layer"}, +) +async def layer_state_list(ctx: Context = None) -> dict: + """Every state under `ACADMCP_LAYERSTATES` with its description and layer + count. These are the server's portable states, not AutoCAD Layer States + Manager entries. No refusals. Pack: settings · lean: no.""" + rows = await _backend(ctx).layer_state_list() + return {"states": rows, "count": len(rows)} + + +@cad_tool(summary="Delete one saved layer state from the drawing.", cost="destructive") +@mcp.tool( + annotations={"title": "Delete Layer State", "destructiveHint": True}, + tags={"layer"}, +) +async def layer_state_delete( + name: Annotated[str, "A name from layer_state_list"], + ctx: Context = None, +) -> dict: + """Remove the named state's XRECORD. Layers themselves are untouched. + Refusal: an unknown name (lists the saved ones). Pack: settings · lean: no.""" + await ctx.info(f"Deleting layer state {name!r}") + return await _backend(ctx).layer_state_delete(name) + + +@cad_tool( + summary="Save a named view: a centre and height (and width) to come back to.", cost="safe" +) +@mcp.tool( + annotations={"title": "Save Named View", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def view_named_save( + name: Annotated[str, "View name, e.g. DETAIL-A"], + center: Annotated[ + list[float] | None, + "[x, y] in WCS; default: the current view (live) or the drawing extents (headless)", + ] = None, + height: Annotated[float | None, "View height in drawing units; default as for center"] = None, + width: Annotated[ + float | None, "View width; default: height × the current viewport aspect" + ] = None, + ctx: Context = None, +) -> dict: + """Store a VIEW table entry (AutoCAD's VIEW command). Same name replaces + (`replaced: true`). Refusals: `height`/`width` ≤ 0 or non-finite, a + malformed `center`, and headless an empty drawing with no `center`/`height` + (there are no extents to default to). Pack: settings · lean: no.""" + await ctx.info(f"Saving named view {name!r}") + return await _backend(ctx).view_named_save(name, center, height, width) + + +@cad_tool(summary="Restore a named view.", cost="safe") +@mcp.tool( + annotations={"title": "Restore Named View", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def view_named_restore( + name: Annotated[str, "A name from view_named_list"], + ctx: Context = None, +) -> dict: + """Live: sets the active viewport to the saved centre/height/width + (`applied: "active_viewport"`). Headless there is no display: the saved + window is written to the `*Active` VPORT — the view AutoCAD opens the file + on — and the result says `applied: "header_only"`, the same reported-no-op + rule as `view_zoom_extents`. Refusal: an unknown name (lists the saved + views). Pack: settings · lean: no.""" + return await _backend(ctx).view_named_restore(name) + + +@cad_tool(summary="List the named views saved in this drawing.", cost="read") +@mcp.tool( + annotations={"title": "List Named Views", "readOnlyHint": True}, + tags={"view"}, +) +async def view_named_list(ctx: Context = None) -> dict: + """Every VIEW table entry with centre, height and width. No refusals. + Pack: settings · lean: no.""" + rows = await _backend(ctx).view_named_list() + return {"views": rows, "count": len(rows)} + + +@cad_tool(summary="List the user coordinate systems, with the implicit world one.", cost="read") +@mcp.tool( + annotations={"title": "List UCS", "readOnlyHint": True}, + tags={"view"}, +) +async def ucs_list(ctx: Context = None) -> dict: + """The `world` row first, then every UCS table entry with origin and unit + axes, `current` on the active one. Tool coordinates stay WCS on both + engines whatever is current. No refusals. Pack: settings · lean: no.""" + rows = await _backend(ctx).ucs_list() + return { + "ucs": rows, + "count": len(rows), + "current": next((r["name"] for r in rows if r["current"]), None), + } + + +@cad_tool( + summary="Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Set UCS", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def ucs_set( + name: Annotated[str, "UCS name"], + origin: Annotated[list[float], "[x, y, z] in WCS"], + x_axis: Annotated[list[float], "Direction of the new X axis (any length)"], + y_axis: Annotated[list[float], "Direction of the new Y axis; must be perpendicular to x_axis"], + ctx: Context = None, +) -> dict: + """Store a UCS table entry and make it current (AutoCAD's UCS command). + + Axes are normalised to unit vectors. **Every tool keeps taking and + returning WCS coordinates** — the repository rule; a UCS is for the + operator's own drafting, and no tool starts interpreting inputs in it. + Refusals, before any write: a name of `world` (reserved), a zero-length + axis, and non-perpendicular axes — the message carries the measured angle + (tolerance 0.001°). Pack: settings · lean: no. + """ + await ctx.info(f"Setting UCS {name!r}") + return await _backend(ctx).ucs_set(name, origin, x_axis, y_axis) + + +@cad_tool(summary="Make a saved UCS current, or `world` to reset to WCS.", cost="safe") +@mcp.tool( + annotations={"title": "Restore UCS", "readOnlyHint": False, "destructiveHint": False}, + tags={"view"}, +) +async def ucs_restore( + name: Annotated[str, "A name from ucs_list, or `world`"], + ctx: Context = None, +) -> dict: + """Live: `ActiveUCS` for a named entry; `world` runs `UCS World`, which is + refused while AutoCAD has an active command (CMDACTIVE — press ESC). + Headless: the `$UCSNAME/$UCSORG/$UCSXDIR/$UCSYDIR` header variables. + Refusal: an unknown name (lists the saved ones). Tool coordinates stay + WCS. Pack: settings · lean: no.""" + return await _backend(ctx).ucs_restore(name) +``` + +`discovery/aliases.py`: add `"LAYERSTATE"`, `"UCS"`, `"VIEW"` to `SHARED_ACAD_COMMANDS` with the comment + +```python +# LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore, +# list, delete in one) that this server splits into discrete tools. +``` + +and these records in the Environment block (alphabetical): + +```python + "layer_state_delete": ToolAliases( + acad=("LAYERSTATE",), + synonyms=("delete a layer state", "remove a saved layer setup", "drop the layer snapshot"), + ), + "layer_state_list": ToolAliases( + acad=("LAYERSTATE",), + synonyms=("saved layer states", "which layer setups are saved", "list layer snapshots"), + ), + "layer_state_restore": ToolAliases( + acad=("LAYERSTATE",), + synonyms=( + "restore the layer state", + "put the layers back", + "go back to the plot layer setup", + "reapply saved layer settings", + "layer snapshot restore", + ), + ), + "layer_state_save": ToolAliases( + acad=("LAYERSTATE",), + synonyms=( + "save the layer state", + "remember the layer setup", + "snapshot the layers", + "layer configuration for plotting", + "save which layers are frozen", + ), + ), + "ucs_list": ToolAliases( + acad=("UCS", "UCSMAN"), + synonyms=("which ucs is current", "list coordinate systems", "named ucs", "ucs manager"), + ), + "ucs_restore": ToolAliases( + acad=("UCS",), + synonyms=( + "back to world coordinates", + "reset the ucs", + "ucs world", + "switch to a saved ucs", + "make that coordinate system current", + ), + ), + "ucs_set": ToolAliases( + acad=("UCS",), + synonyms=( + "user coordinate system", + "define a ucs", + "new coordinate system at this origin", + "rotate the ucs", + "ucs origin", + ), + ), + "view_named_list": ToolAliases( + acad=("VIEW",), + synonyms=("saved views", "which named views exist", "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", + ), + ), +``` + +`README.md`: snapshot `169 tools` → `179 tools`; replace the Environment row with: + +```markdown +| 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)* | +``` + +`tests/test_tool_registry.py`: `"layers": 14` → `18`, `"view": 4` → `10`, total `169` → `179`; docstring sentence: "`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." + +Then: `uv run --frozen python scripts/generate_tool_inventory.py`. + +- [ ] **Step 8: Run the gates** + +Run: `uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py tests/test_documents.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_layer_block.py -v` +Expected: all PASS (`test_layer_block.py` proves the untouched layer tools still behave). + +- [ ] **Step 9: Commit** + +```bash +git add engineering/environment/ backends/contracts/environment.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_layer_states.py tests/test_views_ucs.py +git commit -m "feat(environment): portable layer states, named views and UCS on both engines + +Layer states are ACADMCP_LAYERSTATES XRECORD chunks (<= 255 ASCII chars each), +in the file and not in AutoCAD's Layer States Manager. Headless view restore +writes the *Active VPORT (ezdxf's header has no \$VIEWCTR; measured). UCS is +stored and made current; tool coordinates stay WCS. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +### Task 23: `system_launch`, preferences, operator prompts — COM implementations, headless refusals + +**Files:** +- Create: `engineering/environment/preferences.py` +- Modify: `engineering/environment/__init__.py` (re-export) +- Modify: `backends/com_backend.py` (environment block: `_interactive`, `_com_cancel_reason`, `_prompt_text`, `system_launch`, `preferences_get`, `preferences_set`, `user_pick_point`, `user_select`, `system_prompt_message`) +- Modify: `server.py` (SECTION 20 header `(13 tools)` → `(19 tools)`; six tools), `discovery/aliases.py` (six records; `OPTIONS` in `SHARED_ACAD_COMMANDS`), `README.md` (`179 tools` → `185 tools`; Environment row), `docs/tool-inventory.json`, `tests/test_tool_registry.py` (`system` 7 → 13, total 185) +- Test: `tests/test_environment_live.py` + +**Interfaces:** +- Produces (`engineering/environment/preferences.py`): `PREFERENCE_KEYS` and `READ_ONLY_KEYS` exactly as the plan's Interfaces block (the `OpenSave.SaveAsType` enum is `SAVE_AS_TYPES`, AutoCAD's `AcSaveAsType` values: `ac2000_dwg` 12 … `ac2018_template` 66), `split_key(key) -> (section, member)`, `validate_preference(key, value) -> value` (read-only → `ValueError`; unknown → `ValueError` listing the keys; bool/int/str/enum typed, ranges enforced, enum names ↔ values; nothing is written by it), `decode_value(key, raw)` (enum value → name), `describe_preferences() -> list[dict]`. +- Backend (COM only; ezdxf inherits the `@capability` refusals declared in Task 21): `system_launch(visible=True, open_path=None) -> {"launched", "attached", "version", "document", "visible", "progid"}`, `preferences_get(keys=None) -> {"values", "read_only"}`, `preferences_set(key, value) -> {"ok", "key", "old", "new", "changed"}`, `user_pick_point(prompt) -> {"cancelled": False, "x", "y", "z"} | {"cancelled": True, "reason"} | {"timed_out": True, "timeout_s", "error"}`, `user_select(prompt, mode) -> {"cancelled", "handles", ...}`, `system_prompt_message(text) -> {"ok", "text"}`. +- The three capability keys `live_application`, `preferences`, `interactive_prompt` were declared in **both** maps in Task 21 (the decorator registers at import, so the gate needed them from the moment the contract module existed); this task's test proves the ezdxf default refuses with those keys and that COM no longer inherits the default. + +**Pinned before writing:** pywin32 exposes `pythoncom.Missing` (the "omit this optional argument" marker the dynamic-dispatch layer uses as its own default for optional parameters; probed `hasattr(pythoncom, "Missing") is True`) and `pythoncom.Empty`. `[out]` parameters come back as a tuple and are not passed in — the file already relies on this for `GetBoundingBox()` and `GetXData()`, so `GetEntity(prompt)` returns `(object, picked_point)`. An ESC during `GetPoint`/`GetEntity`/`SelectOnScreen` surfaces as `com_error` with HRESULT `-2147352567` (`DISP_E_EXCEPTION`) and the description AutoCAD supplies ("User input is a keyword" / "Function cancelled"); `ComBackend._run` would rewrap that as a `RuntimeError`, so the `_sync` bodies catch `_COM_ERROR` themselves and answer `{cancelled: true}`. A deadline overrun in `_run` raises `RuntimeError("AutoCAD did not respond within …")` after rebuilding the STA executor; `_interactive` recognises that message and answers `{timed_out: true}` (the executor rebuild still happens — the next call re-attaches lazily as before). `scripts/smoke_settings_com.py` (Task 24) executes `GetPoint` live only with `--interactive`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_environment_live.py +"""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.PrintStyleSheetPath", + "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: + def __init__(self): + self.calls: list[tuple[str, tuple]] = [] + self.point = (12.5, 7.25, 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, (3.0, 4.0, 0.0) + + 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", PrintStyleSheetPath="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",))] + document.Utility.raise_on_pick = _com_error("Function cancelled") + assert (await backend.user_select("Pick"))["cancelled"] is True + + +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") +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest tests/test_environment_live.py -v` +Expected: FAIL at collection with `ModuleNotFoundError: No module named 'engineering.environment.preferences'`. + +- [ ] **Step 3: The preference whitelist** + +```python +# engineering/environment/preferences.py +"""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), +} + +READ_ONLY_KEYS: tuple[str, ...] = ( + "Files.SupportPath", + "Files.TemplateDwgPath", + "Files.PrintStyleSheetPath", + "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 +``` + +Add to `engineering/environment/__init__.py`: + +```python +from .preferences import ( # noqa: F401 + PREFERENCE_KEYS, + READ_ONLY_KEYS, + describe_preferences, + validate_preference, +) +``` + +Run: `uv run --frozen pytest tests/test_environment_live.py -k "whitelist or validate_preference or decode_value or describe or headless or reaches_the_client" -v` +Expected: PASS — the pure tests and the headless refusals (declared in Task 21) already hold; the `com_` tests fail with `is_capability_default(ComBackend, ...)` being `True` / `AttributeError`. + +- [ ] **Step 4: COM implementations** + +Append to the `# ── environment (track E) ──` block in `backends/com_backend.py`: + +```python + # ── 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"} + return { + "cancelled": False, + "mode": "single", + "handles": [str(obj.Handle)], + "picked": [float(picked[0]), float(picked[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) +``` + +Run: `uv run --frozen pytest tests/test_environment_live.py tests/test_capability_contract.py tests/test_com_backend.py -v` +Expected: all PASS. + +- [ ] **Step 5: Register the six tools, aliases, README, inventory, registry snapshot** + +`server.py` SECTION 20 header → `(19 tools)`; append after `ucs_restore`: + +```python +@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.PrintStyleSheetPath, 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), 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) +``` + +`discovery/aliases.py`: add `"OPTIONS"` to `SHARED_ACAD_COMMANDS` with the comment + +```python +# OPTIONS -- one dialog both reads and writes a preference; the server +# splits read (system_preferences_get) and write (system_preferences_set), +# the SETVAR precedent. +``` + +and these records in the Environment block (alphabetical): + +```python + "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", + ), + ), + "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", + ), + ), +``` + +`README.md`: snapshot `179 tools` → `185 tools`; replace the Environment row with: + +```markdown +| 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 | +``` + +`tests/test_tool_registry.py`: `"system": 7` → `13`, total `179` → `185`; docstring sentence: "`system` moved 7 -> 13 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." + +Then: `uv run --frozen python scripts/generate_tool_inventory.py`. + +- [ ] **Step 6: Run the gates** + +Run: `uv run --frozen pytest tests/test_environment_live.py tests/test_documents.py tests/test_layer_states.py tests/test_views_ucs.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_capability_refusal.py tests/test_capabilities.py tests/test_tool_packs.py tests/test_tool_profiles.py -v` +Expected: all PASS. Then the whole suite once: `uv run --frozen pytest -x -q` → PASS (the README `collected tests` drift, if it is the only failure in `test_document_numbers_match_the_tree`, is the pre-existing one left to the final task). + +- [ ] **Step 7: Commit** + +```bash +git add engineering/environment/preferences.py engineering/environment/__init__.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_environment_live.py +git commit -m "feat(environment): system_launch, preference whitelist and operator prompts on COM; declared refusals headlessly + +user_pick_point / user_select answer {cancelled: true} on ESC (DISP_E_EXCEPTION) +and {timed_out: true} past COM_CALL_TIMEOUT; preferences_set refuses read-only, +unknown and out-of-range keys before any ActiveX call. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd" +``` + +--- + +## Hand-off to the merge agent (group V) + +- `backends/contracts/environment.py`: replace the two `@capability("dwgprops", …)` decorators on `drawing_properties_get/set` with `@abstractmethod` (and delete the `# TEMPORARY` comment block) once group C's implementations are present in both backends; the `dwgprops` entries V wrote in both capability maps are byte-identical to C's, so keep exactly one per map (C's) and drop V's. +- `backends/ezdxf_backend.py`: delete V's `# TEMPORARY` copy of `_active_vport` (Task 22, named-views section of the environment block); C's Task 14 definition in the settings block is the one that ships. Two definitions are ruff F811. +- Block anchors are distinct per group (skeleton, Merge notes): V's `# ── environment (track E) ──` block sits directly after the layouts block in both backends; S's before `# ── corner ops`, C's at the end of the class, P's before `# ── 3D solids`. The capability-map hunk after `"lisp"` is the one place S, C and V all insert — merged order S → C → V by key name. +- `server.py` SECTION 20 header: V ends at `(19 tools)`; with C's `system_variable_describe`, `drawing_properties_get`, `drawing_properties_set` the merged header is `(22 tools)`. +- Count files: README snapshot (V ends at `185 tools`), `docs/tool-inventory.json`, `tests/test_tool_registry.py` group sizes — re-derive after the merge. +- Final task wiring: `PACK_TOOL_NAMES["settings"]` = the 22 SECTION 20 tools (all 19 of V's plus C's 3); none of V's tools joins `LEAN_TOOL_NAMES`; the discovery golden file gains queries such as "which drawings are open", "restore the layer state", "pick a point", "ucs world". +- `scripts/smoke_settings_com.py` (Task 24) must execute, live: `document_list` / `document_activate` / `document_close(discard=True)` on a scratch drawing, `layer_state_save` → change → `layer_state_restore`, `view_named_save` / `view_named_restore`, `ucs_set` / `ucs_restore("world")` (verifies the `UCS World` command route and the `GetEntity` out-parameter tuple), `preferences_get` and one reversible `preferences_set` (`OpenSave.AutoSaveInterval` old → old), `system_prompt_message`; `user_pick_point` / `user_select` only with `--interactive`. + + +### Task 24: Merge sanity — packs, lean, group tags, section headers, count files, golden queries + +**Group:** Final (worktree `main`, after the merge agent has merged `track-e/hardening`, `track-e/styles`, `track-e/settings`, `track-e/pagesetup`, `track-e/environment`). This task owns every file it touches; nothing here is edited by another group any more. + +**Files:** +- Modify: `server.py` (`TOOL_PACK_NAMES`, `PACK_TOOL_NAMES["settings"]`, `LEAN_TOOL_NAMES` + 2 — S Task 13 already added its three, `_GROUP_TAG_PRIORITY` / `_GROUP_TAG_LABELS` + 2 with S's `style` entry moved up next to `pid`, SECTION 18/19/20 header counts, section tags on the 38 tools where a group used a different one), `backends/base.py` (composition of `StylesContract`, `PageSetupContract`, `EnvironmentContract` — a no-op if every group's first task already added its own), `backends/contracts/__init__.py` (exports), `backends/ezdxf_backend.py` + `backends/com_backend.py` (`layer_states` feature entry confirmed, see Step 3.6), `config.py` + `.env.example` (pack list text), `tests/test_tool_packs.py`, `tests/test_capability_contract.py`, `tests/data/discovery_golden.py`, `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged`, `docs/tool-inventory.json` (regenerated), `README.md` (release-snapshot line + `` group count + config-table pack list), `discovery/aliases.py` (only where Step 4's routing probe shows a golden query cannot reach its tool) +- Test: `tests/test_tool_packs.py`, `tests/test_capability_contract.py`, `tests/test_discovery_transform.py` (golden set), `tests/test_tool_registry.py`, `tests/test_release_consistency.py`, `tests/test_tool_inventory.py`, `tests/test_discovery_aliases.py` + +**Interfaces:** +- Produces: `server.TOOL_PACK_NAMES == ("core", "pid", "settings")`; `server.PACK_TOOL_NAMES["settings"]` = the 22 SECTION 20 tools below; `len(server.LEAN_TOOL_NAMES) == 55`; `server._GROUP_TAG_LABELS["style"] == "styles"`, `["pagesetup"] == "page_setup"`, `["environment"] == "environment"`; `_tool_groups()` reports `styles: 10`, `page_setup: 6`, `environment: 22`, 23 groups, 204 tools. +- Consumes: the merged SECTION 18 (10 tools), SECTION 19 (6), SECTION 20 (22) registrations; the three contracts; the six capability keys of spec §8.1 declared by their groups. + +The 38 tool names this task pins (from the spec; a group that registered a different spelling is corrected here, on `main`, because the spec is the contract every group built to): + +``` +SECTION 18 (styles, pack core): dimstyle_list dimstyle_create dimstyle_modify dimstyle_set_current + textstyle_list textstyle_create textstyle_set_current + mleaderstyle_list mleaderstyle_create drawing_apply_standard +SECTION 19 (page_setup, pack core): page_setup_list page_setup_apply plot_style_list batch_plot + drawing_template_list drawing_template_save +SECTION 20 (environment, pack settings): + document_list document_activate document_close + layer_state_save layer_state_restore layer_state_list layer_state_delete + view_named_save view_named_restore view_named_list + ucs_list ucs_set ucs_restore + system_launch system_preferences_get system_preferences_set + drawing_properties_get drawing_properties_set system_variable_describe + user_pick_point user_select system_prompt_message +``` + +Decision recorded here because the skeleton left it open: group C's stub header ends at `SECTION 20: Environment (3 tools)` — opened at `(1 tool)` by Task 15 with `system_variable_describe`, grown to `(3 tools)` by Task 16's `drawing_properties_get/set` — and V's ends at `(19 tools)`, so the merged header is **22** and `system_variable_describe` is tagged `environment` and belongs to the `settings` pack — pack, group and section are the same 22 names, which is the only arrangement where `PACK_TOOL_NAMES["settings"]`, `_tool_groups()["environment"]` and the header count can all be checked against one list. Step 3.3 moves all three of C's registration blocks under V's header; if `system_variable_describe` somehow ended up under SECTION 11 instead, the same step moves it from there. + +Fill-in convention for Tasks 24–26: `` (Step 3.8 and the Task 26 CHANGELOG), `` (the five rows of the Task 26 README token table) and `` (the Task 25 CHANGELOG smoke record) are deliberate placeholders — the executor replaces each with the value the named command prints on this machine at that step (never a figure carried over from this plan or an earlier run), and no placeholder may survive into a committed file. + +- [ ] **Step 1: Write the failing tests** + +Replace `tests/test_tool_packs.py` in full: + +```python +# tests/test_tool_packs.py +"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on. + +v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools. +Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting +essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment +surface and nothing else. +""" + +from __future__ import annotations + +import logging + +import pytest +import pytest_asyncio + +import config +import server + +pytestmark = pytest.mark.asyncio + +PID_TOOLS = { + "pid_symbol_list", + "pid_symbol_insert", + "pid_line_draw", + "pid_tag_parse", + "pid_graph", + "pid_instrument_index", + "pid_line_list", + "pid_equipment_list", + "pid_from_spec", +} + +STYLE_TOOLS = { + "dimstyle_list", + "dimstyle_create", + "dimstyle_modify", + "dimstyle_set_current", + "textstyle_list", + "textstyle_create", + "textstyle_set_current", + "mleaderstyle_list", + "mleaderstyle_create", + "drawing_apply_standard", +} + +PAGE_SETUP_TOOLS = { + "page_setup_list", + "page_setup_apply", + "plot_style_list", + "batch_plot", + "drawing_template_list", + "drawing_template_save", +} + +SETTINGS_TOOLS = { + "document_list", + "document_activate", + "document_close", + "layer_state_save", + "layer_state_restore", + "layer_state_list", + "layer_state_delete", + "view_named_save", + "view_named_restore", + "view_named_list", + "ucs_list", + "ucs_set", + "ucs_restore", + "system_launch", + "system_preferences_get", + "system_preferences_set", + "drawing_properties_get", + "drawing_properties_set", + "system_variable_describe", + "user_pick_point", + "user_select", + "system_prompt_message", +} + +LEAN_SETTINGS_ESSENTIALS = { + "drawing_apply_standard", + "page_setup_apply", + "batch_plot", + "dimstyle_set_current", + "textstyle_set_current", +} + +ALL_PACKS = ["core", "pid", "settings"] + + +@pytest_asyncio.fixture(autouse=True) +async def _restore(monkeypatch): + yield + monkeypatch.setattr(config.settings, "tool_packs", "all") + await server._apply_tool_profile("full") + + +def test_pack_registry_names_real_tools_and_only_them(): + assert server.TOOL_PACK_NAMES == ("core", "pid", "settings") + assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) + assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS) + assert len(SETTINGS_TOOLS) == 22 + assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"]) + + +async def test_every_pack_member_is_a_registered_tool(): + registered = {tool.name for tool in await server._registered_tools()} + for pack, names in server.PACK_TOOL_NAMES.items(): + missing = names - registered + assert not missing, f"pack {pack!r} names unregistered tools: {sorted(missing)}" + + +async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert PID_TOOLS <= disabled + assert SETTINGS_TOOLS <= disabled + assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials" + assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core" + assert "block_define" not in disabled and "entity_set_xdata" not in disabled + assert info["tool_packs"] == {"enabled": ["core"], "available": ALL_PACKS, "ignored": []} + + +async def test_settings_pack_alone_keeps_pid_hidden(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "settings") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert PID_TOOLS <= disabled + assert not (SETTINGS_TOOLS & disabled) + assert info["tool_packs"]["enabled"] == ["core", "settings"] + + +async def test_all_is_the_default_and_enables_every_pack(monkeypatch): + monkeypatch.setattr(config.settings, "tool_packs", "all") + info = await server._apply_tool_profile("full") + disabled = set(info["disabled_tools"]) + assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled) + assert info["tool_packs"]["enabled"] == ALL_PACKS + + +async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( + monkeypatch, caplog +): + monkeypatch.setattr(config.settings, "tool_packs", "pid,mech3000") + with caplog.at_level(logging.WARNING, logger="autocad_mcp"): + info = await server._apply_tool_profile("full") + assert info["tool_packs"] == { + "enabled": ["core", "pid"], + "available": ALL_PACKS, + "ignored": ["mech3000"], + } + assert "mech3000" in caplog.text + + +async def test_lean_intersects_with_packs(monkeypatch): + assert {"pid_symbol_insert", "pid_line_draw", "pid_graph"} <= server.LEAN_TOOL_NAMES + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("lean") + assert "pid_graph" in info["disabled_tools"] + assert "entity_create_line" not in info["disabled_tools"] + + +async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack( + monkeypatch, +): + """Spec §8.2: lean = 55. The five are core-pack tools, so `TOOL_PACKS=core` + leaves them on a lean surface; no environment tool is lean.""" + assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES + assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES) + assert len(server.LEAN_TOOL_NAMES) == 55 + monkeypatch.setattr(config.settings, "tool_packs", "core") + info = await server._apply_tool_profile("lean") + disabled = set(info["disabled_tools"]) + assert not (LEAN_SETTINGS_ESSENTIALS & disabled) + assert info["enabled_count"] == 55 - 3, "lean minus the three pid tools" + + +async def test_the_three_track_e_sections_file_under_their_own_groups(): + """Pack, group and SECTION are the same list, so `system_about` and + `docs/tool-inventory.json` describe the surface the packs gate.""" + groups = await server._tool_groups() + assert set(groups["styles"]) == STYLE_TOOLS + assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS + assert set(groups["environment"]) == SETTINGS_TOOLS + assert len(groups) == 23 + + +async def test_system_about_reports_packs(): + from fastmcp import Client + + async with Client(server.mcp) as client: + about = (await client.call_tool("system_about", {})).structured_content + assert about["tool_packs"]["available"] == ALL_PACKS +``` + +Append to `tests/test_capability_contract.py` (after the last test; add the three contract imports next to the existing `from backends.base import ...` line): + +```python +from backends.contracts.environment import EnvironmentContract +from backends.contracts.pagesetup import PageSetupContract +from backends.contracts.styles import StylesContract + +# ── track E: composition and the six agreed keys ──────────────────────────── + +#: spec §8.1 — the flag each engine must report. A key both engines support is +#: still listed so `system_capabilities` names it. +TRACK_E_KEYS = { + "mleaderstyle": {"ezdxf": True, "com": False}, + "dwgprops": {"ezdxf": False, "com": True}, + "dwt_write": {"ezdxf": False, "com": True}, + "live_application": {"ezdxf": False, "com": True}, + "preferences": {"ezdxf": False, "com": True}, + "interactive_prompt": {"ezdxf": False, "com": True}, +} + + +def _feature_keys_full(backend) -> dict: + return backend.capabilities().to_dict()["features"] + + +def test_track_e_contracts_are_composed_into_the_backend(): + for contract in (StylesContract, PageSetupContract, EnvironmentContract): + assert issubclass(AutoCADBackend, contract), f"{contract.__name__} not in AutoCADBackend" + assert issubclass(EzdxfBackend, contract) and issubclass(ComBackend, contract) + + +def test_track_e_capability_keys_carry_the_agreed_flags(): + ezdxf_features = _feature_keys_full(EzdxfBackend()) + com_features = _feature_keys_full(ComBackend()) + for key, flags in TRACK_E_KEYS.items(): + assert key in ezdxf_features, f"{key} undeclared on ezdxf" + assert key in com_features, f"{key} undeclared on com" + assert ezdxf_features[key]["supported"] is flags["ezdxf"], key + assert com_features[key]["supported"] is flags["com"], key + + +def test_layer_states_are_declared_as_ours_not_autocads(): + """Spec §7.2 / §8.3: the states live in the file and travel with it, and do + not appear in AutoCAD's Layer States Manager. Said in the map, not only in + a docstring.""" + for backend in (EzdxfBackend(), ComBackend()): + feature = _feature_keys_full(backend)["layer_states"] + assert feature["supported"] is True + assert feature["mode"] == "xrecord" + assert "layer_states_manager" in (feature["reason"] or ""), backend.name +``` + +Append to `TUNING_CASES` in `tests/data/discovery_golden.py` — after group S's five style cases from Task 13 (the last of them is the `dimstyle_list` counting case), before the closing `)` — and change the module docstring's first sentence, which S's Task 13 left at `Seventy-one cases (56 tuning + 15 holdout)`, to `Eighty-one cases (66 tuning + 15 holdout) split into a **tuning** set and a **holdout** set.` (base 66 + S's 5 + these 10): + +```python + # v1.6 track E — styles, page setup, templates, environment. Written + # before the alias records were measured against them; a miss here is + # fixed by adding vocabulary to the tool's record in discovery/aliases.py, + # never by moving the expectation. + GoldenCase("PAGESETUP", "page_setup_apply", "command"), + GoldenCase("PUBLISH", "batch_plot", "command"), + GoldenCase("MLEADERSTYLE", "mleaderstyle_create", "command"), + GoldenCase("STYLE", "textstyle_create", "command"), + GoldenCase("iso 25 dimension style", "dimstyle_create", "synonym"), + GoldenCase("layer snapshot", "layer_state_save", "synonym"), + GoldenCase("save this drawing as a template", "drawing_template_save", "paraphrase"), + GoldenCase("switch to the other open drawing", "document_activate", "paraphrase"), + GoldenCase("ask the operator to click a point on screen", "user_pick_point", "paraphrase"), + GoldenCase( + "what does the LTSCALE variable mean", + "system_variable_describe", + "counting", + risk="read", + ), +``` + +Why these ten and not `DIMSTYLE` / `LAYERSTATE` / `OPTIONS` / `DWGPROPS`: each of those AutoCAD commands opens a manager dialog that is equally a list, a create and a restore, and the golden file's own rule is that a case with two defensible answers scores the author, not the ranker. `STYLE` is the exception the rule allows — the server has no `textstyle_modify`, so the dialog's only creating action maps to exactly one tool. `PUBLISH` is batch plotting by definition; `PAGESETUP` applies a setup; `MLEADERSTYLE` has one writer. + +- [ ] **Step 2: Run to verify they fail** + +``` +uv run --frozen pytest tests/test_tool_packs.py tests/test_capability_contract.py tests/test_discovery_transform.py -q -x +``` + +Expected first failure: `test_pack_registry_names_real_tools_and_only_them` — `assert ('core', 'pid') == ('core', 'pid', 'settings')`. If the merge agent already added the pack, the first failure moves to `test_the_three_track_e_sections_file_under_their_own_groups` (`KeyError: 'page_setup'`): S Task 11 already put `style` → `styles` in the tag→label map, but no group owns the `pagesetup` / `environment` entries (P tags its tools `layout`, V uses `drawing` / `layer` / `view` / `system`). + +- [ ] **Step 3: Implement** + +**3.1 `server.py` packs and lean.** Replace the `TOOL_PACK_NAMES` / `PACK_TOOL_NAMES` block: + +```python +# Tool packs: vertical domains a client can opt out of advertising. `core` is +# everything not claimed by another pack and is always on. ~300 tools would +# push the full catalog to ~80k idle tokens; a client that only drafts +# mechanically should not pay for the P&ID surface, and a client that never +# talks to a live seat should not pay for the environment surface. Styles and +# page setup (SECTION 18/19) are drafting essentials and stay in core. +TOOL_PACK_NAMES = ("core", "pid", "settings") +PACK_TOOL_NAMES: dict[str, frozenset[str]] = { + "pid": frozenset( + { + "pid_symbol_list", + "pid_symbol_insert", + "pid_line_draw", + "pid_tag_parse", + "pid_graph", + "pid_instrument_index", + "pid_line_list", + "pid_equipment_list", + "pid_from_spec", + } + ), + # SECTION 20 — the 22 environment tools (spec §7, §8.2). + "settings": frozenset( + { + "document_list", + "document_activate", + "document_close", + "layer_state_save", + "layer_state_restore", + "layer_state_list", + "layer_state_delete", + "view_named_save", + "view_named_restore", + "view_named_list", + "ucs_list", + "ucs_set", + "ucs_restore", + "system_launch", + "system_preferences_get", + "system_preferences_set", + "drawing_properties_get", + "drawing_properties_set", + "system_variable_describe", + "user_pick_point", + "user_select", + "system_prompt_message", + } + ), +} +``` + +In `LEAN_TOOL_NAMES`, group S's Task 13 already added `dimstyle_set_current`, `textstyle_set_current` and `drawing_apply_standard` directly after the three `pid_*` entries (50 → 53) — leave them where they are and do **not** repeat them (a repeated name in the frozenset literal is ruff B033, and bugbear is on in `pyproject.toml`). Add only the two page-setup essentials, directly after S's three: + +```python + # Page setup (track E) — the two a lean client needs to set the + # sheet and plot it. Core-pack tools, so TOOL_PACKS=core leaves + # them on the lean surface; with S's three above, lean is 55. + "page_setup_apply", + "batch_plot", +``` + +**3.2 `server.py` group tags.** Group S's Task 11 already inserted `"style",` immediately before `"layer",` in `_GROUP_TAG_PRIORITY` and `"style": "styles",` before `"layer": "layers",` in `_GROUP_TAG_LABELS`. Move S's `"style"` entry up so it sits directly after `"pid"`, and add `"pagesetup"` and `"environment"` after it (a style tool that also carries `dimension`, a page-setup tool that also carries `layout`/`drawing`, and an environment tool that also carries `layer`/`view`/`system` must file under its section). No tag may appear twice. The tuple then reads, in full: + +```python +_GROUP_TAG_PRIORITY = ( + "pid", + "style", + "pagesetup", + "environment", + "engineering", + "premium", + "corner", + "batch", + "template", + "dimension", + "transaction", + "layout", + "solid", + "view", + "validation", + "analysis", + "block", + "layer", + "linetype", + "create", + "modify", + "query", + "drawing", + "system", +) +``` + +and in `_GROUP_TAG_LABELS`, move S's `"style": "styles",` up to directly after `"pid": "pid",` and add the two new labels after it (a repeated dict key is ruff F601), so the dict opens: + +```python + "pid": "pid", + "style": "styles", + "pagesetup": "page_setup", + "environment": "environment", + "engineering": "engineering", +``` + +Then make every SECTION 18 tool carry `"style"`, every SECTION 19 tool `"pagesetup"`, every SECTION 20 tool `"environment"` in its `tags={...}`. Probe rather than read 38 decorators by hand: + +``` +uv run --frozen python -c " +import asyncio, server +groups = asyncio.run(server._tool_groups()) +from tests.test_tool_packs import STYLE_TOOLS, PAGE_SETUP_TOOLS, SETTINGS_TOOLS +for label, want in (('styles', STYLE_TOOLS), ('page_setup', PAGE_SETUP_TOOLS), ('environment', SETTINGS_TOOLS)): + got = set(groups.get(label, [])) + print(label, 'missing', sorted(want - got), 'extra', sorted(got - want)) +" +``` + +Every name printed under `missing` gets the section tag added to its `tags={...}` set (keep the group's other tags — `tests/test_tool_registry.py::test_registered_tags_are_exactly_the_ones_written_in_source` only requires that the live tags equal the source literal). + +**3.3 SECTION headers.** `tests/test_release_consistency.py::test_section_header_counts_match_decorators` counts `@mcp.tool` decorators between headers. The merged file must read: + +``` +# ── SECTION 18: Styles (10 tools) ──────────────────────────────────────────── +# ── SECTION 19: Page setup & templates (6 tools) ───────────────────────────── +# ── SECTION 20: Environment (22 tools) ─────────────────────────────────────── +``` + +Group C's stub `# ── SECTION 20: Environment (3 tools) ──` and group V's `(19 tools)` SECTION 20 header both survive a textual merge; delete C's header line and move all **three** of C's registration blocks — `system_variable_describe`, `drawing_properties_get`, `drawing_properties_set` (each `@cad_tool` + `@mcp.tool` + function, whole) — under V's header, which becomes `(22 tools)`. Should `system_variable_describe` have ended up under SECTION 11 instead, move its block the same way and correct SECTION 11 back to `(8 tools)`. Run the gate after: `uv run --frozen pytest tests/test_release_consistency.py::test_section_header_counts_match_decorators -q`. + +**3.4 Composition.** `backends/base.py` must import and list all three: + +```python +from backends.contracts import ( + AnalysisContract, + BlockContract, + CornerOpsContract, + DimensionContract, + DrawingContract, + EntityCreationContract, + EntityModificationContract, + EntityQueryContract, + EnvironmentContract, + GdtContract, + IdentityContract, + LayerContract, + LayoutContract, + LinetypeContract, + PageSetupContract, + PremiumContract, + SettingsContract, + SolidContract, + StylesContract, + SystemContract, + TransactionContract, + ViewContract, +) +``` + +and the class bases end with: + +```python + SettingsContract, + StylesContract, + PageSetupContract, + EnvironmentContract, +): +``` + +`backends/contracts/__init__.py` gains the three imports (`from backends.contracts.environment import EnvironmentContract`, `from backends.contracts.pagesetup import PageSetupContract`, `from backends.contracts.styles import StylesContract`) and the three names in `__all__`, alphabetically. Each group's first task was told to add its own contract "if absent"; a duplicate base raises `TypeError: duplicate base class` at import, so this step is: read the file, keep exactly one of each. + +**3.5 Pack text.** `config.py` comment: `# list ("core,pid,settings") advertises only those. core cannot be disabled.` `.env.example`: `# Tool packs: all (default) | comma list of core,pid,settings — advertise only the` (second line unchanged). README Configuration table row: ``| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings` (`core` always on) |``. + +**3.6 `layer_states` feature entry.** In both capability maps the entry must be exactly: + +```python + "layer_states": FeatureCapability( + True, + "xrecord", + reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager", + ), +``` + +Group V's Task 22 declares exactly this entry in both maps (spec §7.2 asks for the honesty note in `system_capabilities`; the skeleton's Capability-keys paragraph pins the spelling, and V's tests do not assert on the entry), so this step is read-and-confirm: the README and CLAUDE.md (Task 26) quote this reason string, and one spelling is what makes the three places agree. Only if a merge left a different spelling behind is anything edited here. + +**3.7 Registry snapshot.** In `tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged` replace the dict and the sum: + +```python + sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} + assert sizes == { + "analysis": 12, + "batch": 3, + "blocks": 9, + "corner_ops": 4, + "dimensions": 5, + "drawing": 11, + "engineering": 10, + "entity_creation": 18, + "entity_modification": 16, + "entity_query": 9, + "environment": 22, + "layers": 14, + "layouts": 12, + "page_setup": 6, + "pid": 9, + "premium": 12, + "solids": 5, + "styles": 10, + "system": 7, + "templates": 2, + "transactions": 3, + "validation": 1, + "view": 4, + } + assert sum(sizes.values()) == 204 +``` + +and append to its docstring, before "Every other number here has been unchanged": + +``` + Three groups appeared with v1.6's track E — `styles` (10, SECTION 18), + `page_setup` (6, SECTION 19) and `environment` (22, SECTION 20) — each + tagged with its section name, ranked directly after `pid` in + `_GROUP_TAG_PRIORITY` so a style tool that also carries `dimension` or an + environment tool that also carries `layer` files under its section; the + `environment` group is exactly `PACK_TOOL_NAMES["settings"]`. +``` + +Change the docstring's first line to `"""Frozen snapshot of the surface (204 tools, 23 groups).` + +**3.8 Count files.** + +``` +uv run --frozen python scripts/generate_tool_inventory.py +uv run --frozen pytest --collect-only -q | tail -1 +``` + +README release-snapshot block becomes (`` is the number the second command prints — the README figure is gated against live collection by `scripts/check_doc_numbers.py`, so it must be re-taken here and again in Tasks 25 and 26 after each test addition): + +``` +> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 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. +``` + +README `` line under the What-you-get table: `166 tools in 20 groups` → `204 tools in 23 groups`, and `TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID` → `TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither`. The rest of the README is Task 26. + +- [ ] **Step 4: Golden-set routing probe** + +The alias records were written by four groups that never saw these ten queries. Measure before running the test: + +``` +uv run --frozen python -c " +import asyncio, server +from discovery.transform import CadSearchTransform +from tests.data.discovery_golden import TUNING_CASES +tools = list(asyncio.run(server.mcp._list_tools())) +t = CadSearchTransform() +for case in TUNING_CASES[-10:]: + hits = [h.name for h in t.rank(tools, case.query, limit=5).hits] + print(('ok ' if case.expect in hits[:3] else 'MISS'), case.query, '->', hits[:3]) +" +``` + +For every `MISS`, add the query's vocabulary to *that tool's* record in `discovery/aliases.py` — the routing this set relies on is: `PAGESETUP` in `page_setup_apply.acad`; `PUBLISH` in `batch_plot.acad`; `MLEADERSTYLE` in `mleaderstyle_create.acad`; `STYLE` in `textstyle_create.acad`; synonym `"iso 25 dimension style"` on `dimstyle_create`; `"layer snapshot"` on `layer_state_save`; `"save as a template"` on `drawing_template_save`; `"switch to another open drawing"` on `document_activate`; `"click a point on screen"` on `user_pick_point`; `"what does a system variable mean"` on `system_variable_describe`. An AutoCAD command that a group put on a *different* tool (e.g. `PUBLISH` on `drawing_export_pdf`) moves, it is not duplicated — `tests/test_discovery_aliases.py::test_each_acad_command_routes_to_a_single_tool` refuses a command on two tools unless it is in `SHARED_ACAD_COMMANDS`, and none of these four belongs there. Never edit a `GoldenCase`. + +- [ ] **Step 5: Run the gates** + +``` +uv run --frozen pytest tests/test_tool_packs.py tests/test_capability_contract.py tests/test_discovery_transform.py tests/test_discovery_aliases.py tests/test_tool_registry.py tests/test_tool_profiles.py tests/test_token_suite.py tests/test_tool_inventory.py tests/test_release_consistency.py -q +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen ruff check . && uv run --frozen ruff format --check . +``` + +Expected: all green; `ok: docs/tool-inventory.json matches the live surface`. `test_release_consistency.py::test_dockerfile_copies_every_wheel_include` fails only if group P added `templates` to `pyproject.toml`'s `only-include` (it must, for `drawing_new(template="iso_a3_mech")` to work from an installed wheel) without the matching Dockerfile line — then add `COPY templates/ ./templates/` after the `COPY discovery/ ./discovery/` line. The `check_doc_numbers` gate is green here because the correctness suite is still 29 and the published A/B report still says 29; Task 25 moves both together. + +- [ ] **Step 6: Commit** + +```bash +git add server.py backends/base.py backends/contracts/__init__.py backends/ezdxf_backend.py backends/com_backend.py config.py .env.example README.md Dockerfile discovery/aliases.py docs/tool-inventory.json tests/test_tool_packs.py tests/test_capability_contract.py tests/test_tool_registry.py tests/data/discovery_golden.py +git commit -m "feat(server): settings pack — 22 environment tools behind TOOL_PACKS, lean grows to 55, three groups, golden set +10" +``` + +(Only stage the files that actually changed; `Dockerfile` and the two backend files are listed for the conditional edits above.) + +--- + +### Task 25: Live COM smoke, `.dwt` twins, benchmark `page_setup_truth`, three correctness checks + +**Group:** Final (`main`). + +**Files:** +- Create: `scripts/smoke_settings_com.py` +- Modify: `templates/*.dxf` (the five bundled templates rebuilt on the merged tree and recommitted — Step 5), `.gitattributes` (`*.dwt` / `*.dwg` binary), `templates/*.dwt` (five built artefacts, committed once), `templates/README.md` (provenance line), `CHANGELOG.md` (the smoke record, under `Unreleased`), `benchmarks/tasks_v4.py` (`NEW_TASKS_V4` + `page_setup_truth`), `benchmarks/adapters/autocad_mcp_pro.py` (`_task_page_setup_truth`), `benchmarks/correctness_suite.py` (+3 checks, group `"Settings"`), `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json` (regenerated), `tests/test_benchmark_v4.py`, `README.md` / `benchmarks/README.md` / `CLAUDE.md` (the numbers `scripts/check_doc_numbers.py` gates: 32 checks, 17 tasks, the A/B rows) +- Test: `tests/test_benchmark_v4.py`, `tests/test_release_consistency.py::test_doc_numbers_match_the_tree` + +**Interfaces:** +- Produces: `benchmarks.tasks_v4.NEW_TASKS_V4 == (pid_roundtrip, page_setup_truth)`, `len(TASKS_V4) == 17`; `AutoCADMCPProAdapter._task_page_setup_truth()`; `correctness_suite.CHECKS` += `settings_dimstyle_iso25_values`, `settings_layer_state_roundtrip`, `settings_pdf_mediabox_a3` (group `"Settings"`), `len(CHECKS) == 32`; `scripts/smoke_settings_com.py` exit codes 0 / 1 / 2 and flags `--build-dwt`, `--interactive`, `--keep`. +- Consumes (built by groups S/P/V/C, signatures from the skeleton): `engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)`, `engineering.standards.mleaderstyles.resolve_mleaderstyle(preset, overrides)`, `engineering.standards.papers.resolve_page_setup(paper, ...)`, `engineering.standards.plot.batch_plot(backend, layouts, output_dir, file_pattern) -> dict` (the per-sheet rows are under **`"sheets"`** — P Task 18's key, pinned by its tests and tool docstring — each `{layout, ok, path, bytes, mediabox_mm, paper, error?}`; nothing in this task reads a `"rows"` key), `engineering.standards.templates.TEMPLATE_CATALOG`, `TEMPLATES_DIR`, `resolve_template(name_or_path, engine)`; backend methods `dimstyle_create/list`, `textstyle_create`, `mleaderstyle_list/create`, `page_setup_apply/list`, `plot_style_list`, `drawing_template_save`, `document_list/activate`, `layer_state_save/restore/list/delete`, `view_named_save/list/restore`, `ucs_set/list/restore`, `system_launch`, `preferences_get/set`, `drawing_properties_get/set`, `user_pick_point`, `system_prompt_message`. + +Measured before writing (state of the machine, 2026-09-16): the registered AutoCAD 2026 type library (`AutoCAD/ObjectDBX Common 25.0`) has **no** `acTemplateDwg` constant — the template save constants are `ac2018_Template = 66`, `ac2013_Template = 62`, `acNative = 64`; group P's COM `drawing_template_save` must use 66 (or `acNative`+`.dwt`, which AutoCAD also honours), and the smoke verifies the written file is a DWG container rather than trusting the constant. matplotlib's PDF writer emits one uncompressed `/MediaBox [0 0 1190.5511811024 841.8897637795]` for a 420×297 mm figure, which `read_mediabox` returns as 420.000 × 297.000 mm — the ±0.5 mm tolerance of spec §9 is generous by four orders of magnitude on the headless side and exists for AutoCAD's own PDF driver. A fresh headless document holds dimstyles `['Standard']` only (its `$DIMSTYLE` header says `ISO-25` while no such style exists — an ezdxf quirk), so `dimstyle_create("ISO-25", ...)` has no name clash in the correctness check; `dimdsep` is stored as the int code (`44` for `,`); the document has `Layout1`. + +- [ ] **Step 1: Write the failing test** + +Replace `tests/test_benchmark_v4.py` in full: + +```python +# tests/test_benchmark_v4.py +"""The v4 task matrix: v3 plus what 1.6 added. + +Track A added ``pid_roundtrip``; track E adds ``page_setup_truth`` — apply an +ISO A3 landscape page setup, plot through ``batch_plot`` and read the sheet +size back from the PDF's own ``/MediaBox``. The published competitor reports +were never asked either question and the chart shows them ``not_run`` — the +same rule v3 set for its five. +""" + +from __future__ import annotations + +import pytest + +from benchmarks.tasks_v3 import TASKS_V3 +from benchmarks.tasks_v4 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V4, TASKS_V4, task_by_id + + +def test_v4_extends_v3_without_touching_it(): + assert TASKS_V4[: len(TASKS_V3)] == TASKS_V3 + assert [t.task_id for t in NEW_TASKS_V4] == ["pid_roundtrip", "page_setup_truth"] + assert len(TASKS_V4) == 17 + assert MATRICES["v3"] == TASKS_V3 and MATRICES["v4"] == TASKS_V4 and DEFAULT_MATRIX == "v4" + assert task_by_id("pid_roundtrip").category == "pid" + assert task_by_id("page_setup_truth").category == "pagesetup" + + +def test_the_adapter_implements_every_v4_task(): + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + for task in TASKS_V4: + assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id + + +def test_correctness_suite_has_the_pid_checks(): + from benchmarks.correctness_suite import CHECKS + + for name in ("pid_block_define_attdef_roundtrip", "pid_tag_parse_fic", "pid_graph_edge_count"): + assert CHECKS[name][1] == "P&ID" + + +def test_correctness_suite_has_the_settings_checks(): + from benchmarks.correctness_suite import CHECKS + + for name in ( + "settings_dimstyle_iso25_values", + "settings_layer_state_roundtrip", + "settings_pdf_mediabox_a3", + ): + assert CHECKS[name][1] == "Settings" + assert len(CHECKS) == 32 + + +@pytest.mark.asyncio # asyncio_mode is strict; the four tests above are sync +async def test_page_setup_truth_passes_headlessly(tmp_path): + """The benchmark task itself, run once here so a regression in page setup + or the PDF read-back fails the suite and not only the matrix.""" + from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter + + adapter = AutoCADMCPProAdapter(backend="ezdxf") + await adapter.setup(tmp_path) + try: + await adapter._reset() + passed, metrics, _artifacts = await adapter._task_page_setup_truth() + finally: + await adapter.cleanup() + assert passed, metrics + assert abs(metrics["mediabox_mm"][0] - 420.0) < 0.5 + assert abs(metrics["mediabox_mm"][1] - 297.0) < 0.5 +``` + +(`pyproject.toml` sets `asyncio_mode = "strict"`, so the one async test carries its own marker and the four sync tests carry none — the per-test pattern `tests/test_capability_contract.py` already uses.) + +- [ ] **Step 2: Run to verify it fails** + +``` +uv run --frozen pytest tests/test_benchmark_v4.py -q +``` + +Expected: `test_v4_extends_v3_without_touching_it` fails with `assert ['pid_roundtrip'] == ['pid_roundtrip', 'page_setup_truth']`; `test_correctness_suite_has_the_settings_checks` with `KeyError: 'settings_dimstyle_iso25_values'`; `test_page_setup_truth_passes_headlessly` with `AttributeError: ... has no attribute '_task_page_setup_truth'`. + +- [ ] **Step 3: Matrix, adapter task, checks** + +`benchmarks/tasks_v4.py` — replace the docstring and `NEW_TASKS_V4`: + +```python +"""The v4 task matrix: v3 plus what 1.6 added. + +``pid_roundtrip`` (track A) draws a five-item P&ID through the same code the +tools call and reads it back: the edge count, the absence of dangling ends +and the instrument index are each derived independently of the drawing code. + +``page_setup_truth`` (track E) applies an ISO A3 landscape page setup to a +layout, plots it through ``batch_plot`` and reads the sheet size back from +the PDF's own ``/MediaBox`` — the setup is verified by the file, not by the +setter's return value. + +The published competitor reports were never asked either question; the chart +shows ``not_run`` for them, not zero. +""" + +from __future__ import annotations + +from benchmarks.tasks_v2 import TASKS_V2, TaskSpec +from benchmarks.tasks_v3 import TASKS_V3 + +#: Added in v1.6.0. Each can fail on its own. +NEW_TASKS_V4: tuple[TaskSpec, ...] = ( + TaskSpec( + "pid_roundtrip", + "pid", + "Draw a P&ID from a spec and read back a matching graph, index and line list", + 1.0, + ), + TaskSpec( + "page_setup_truth", + "pagesetup", + "Apply an ISO A3 page setup, plot it, and read the sheet size back from the PDF", + 1.0, + ), +) +``` + +(`TASKS_V4`, `MATRICES`, `DEFAULT_MATRIX`, `task_by_id` unchanged.) + +Adapter method, in `benchmarks/adapters/autocad_mcp_pro.py` directly after `_task_pid_roundtrip`: + +```python + async def _task_page_setup_truth(self): + """ISO A3 landscape on Layout1, plotted, and the sheet read back from the PDF. + + The number that matters is parsed from the file's ``/MediaBox`` — the + setter's ``applied`` block is what we asked for, the page-setup list is + what the LAYOUT now says, and the PDF is what a printer would get. All + three have to agree with ISO 216's 420 × 297. + """ + from engineering.standards.papers import resolve_page_setup + from engineering.standards.plot import batch_plot + + setup = resolve_page_setup( + "ISO_A3", + orientation="landscape", + plot_style="monochrome.ctb", + scale="1:1", + ) + applied = await self.backend.page_setup_apply("Layout1", setup) + listed = (await self.backend.page_setup_list("Layout1"))[0] + out_dir = self.artifact_dir / "page_setup_truth" + out_dir.mkdir(parents=True, exist_ok=True) + report = await batch_plot(self.backend, ["Layout1"], str(out_dir), "{drawing}-{layout}.pdf") + row = report["sheets"][0] + width_mm, height_mm = (float(v) for v in row["mediabox_mm"]) + size_listed = tuple(float(v) for v in listed["size_mm"]) + passed = ( + applied["ok"] is True + and applied["plot_style_known"] is True + and listed["paper"] == "ISO_A3" + and listed["orientation"] == "landscape" + and size_listed == (420.0, 297.0) + and abs(width_mm - 420.0) < 0.5 + and abs(height_mm - 297.0) < 0.5 + and row["bytes"] > 0 + ) + return ( + passed, + { + "paper": listed["paper"], + "orientation": listed["orientation"], + "size_mm_listed": list(size_listed), + "mediabox_mm": [width_mm, height_mm], + "pdf_bytes": row["bytes"], + "plot_style_known": applied["plot_style_known"], + }, + [str(row["path"])], + ) +``` + +Correctness checks — append to `benchmarks/correctness_suite.py` after `pid_graph_edge_count` and before `CHECKS`: + +```python +# ── Settings and environment (v1.6, track E) ───────────────────────────────── + + +async def settings_dimstyle_iso25_values(): + """The ISO-25 preset reaches the DIMSTYLE table with ISO 129-1's numbers. + + Read back through ezdxf directly, not through `dimstyle_list`, so the + check cannot be satisfied by a list that echoes its own input. + """ + from engineering.standards.dimstyles import resolve_dimstyle + + b = await _b() + await b.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + style = b._doc.dimstyles.get("ISO-25") + listed = {row["name"]: row for row in await b.dimstyle_list()} + return ( + abs(float(style.dxf.dimtxt) - 2.5) < 1e-9 + and abs(float(style.dxf.dimasz) - 2.5) < 1e-9 + and abs(float(style.dxf.dimexe) - 1.25) < 1e-9 + and abs(float(style.dxf.dimexo) - 0.625) < 1e-9 + and abs(float(style.dxf.dimgap) - 0.625) < 1e-9 + and int(style.dxf.dimtad) == 1 + and int(style.dxf.dimdsep) == ord(",") + and str(style.dxf.dimtxsty) == "ISOCP" + and b._doc.header["$DIMSTYLE"] == "ISO-25" + and listed["ISO-25"]["current"] is True + and listed["ISO-25"]["values"]["DIMDSEP"] == "," + ) + + +async def settings_layer_state_roundtrip(): + """Save → change → restore puts the table back, and the state survives a + save/reopen because it is an XRECORD in the file, not server memory.""" + b = await _b() + await b.layer_create("LS_A", color=1) + await b.layer_create("LS_B", color=2) + saved = await b.layer_state_save("bench", description="A/B round trip") + await b.layer_modify("LS_A", color=5) + await b.layer_freeze("LS_B") + restored = await b.layer_state_restore("bench") + layers = {layer.name: layer for layer in await b.layer_list()} + path = os.path.join(tempfile.mkdtemp(), "ls.dxf") + await b.drawing_save_as(path, "dxf") + await b.drawing_open(path) + names_after_reopen = {row["name"] for row in await b.layer_state_list()} + return ( + saved["ok"] is True + and restored["missing_layers"] == [] + and restored["new_layers"] == [] + and layers["LS_A"].color == 1 + and layers["LS_B"].is_frozen is False + and "bench" in names_after_reopen + ) + + +async def settings_pdf_mediabox_a3(): + """Page setup evidence is the PDF's own /MediaBox (spec §8.3).""" + from engineering.standards.papers import resolve_page_setup + from engineering.standards.plot import batch_plot + + b = await _b() + await b.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) + out_dir = tempfile.mkdtemp() + report = await batch_plot(b, ["Layout1"], out_dir, "{drawing}-{layout}.pdf") + width_mm, height_mm = report["sheets"][0]["mediabox_mm"] + return abs(width_mm - 420.0) < 0.5 and abs(height_mm - 297.0) < 0.5 +``` + +and the three `CHECKS` entries after the P&ID rows: + +```python + "settings_dimstyle_iso25_values": (settings_dimstyle_iso25_values, "Settings"), + "settings_layer_state_roundtrip": (settings_layer_state_roundtrip, "Settings"), + "settings_pdf_mediabox_a3": (settings_pdf_mediabox_a3, "Settings"), +``` + +Run each once in isolation, the way the A/B runner will: + +``` +uv run --frozen python benchmarks/correctness_suite.py settings_dimstyle_iso25_values +uv run --frozen python benchmarks/correctness_suite.py settings_layer_state_roundtrip +uv run --frozen python benchmarks/correctness_suite.py settings_pdf_mediabox_a3 +uv run --frozen python benchmarks/run_competitors.py --matrix v4 --server autocad-mcp-pro --task page_setup_truth +``` + +Expected: `PASS` three times; the JSON report's `page_setup_truth` row has `"status": "pass"` and `metrics.mediabox_mm` of `[420.0, 297.0]` (to within 1e-9 headlessly). + +- [ ] **Step 4: The live smoke script** + +```python +# scripts/smoke_settings_com.py +"""Exercise the settings track once against the live AutoCAD on this machine. + +Creates a NEW document (never touches the operator's open drawing) and runs +every SECTION 18-20 path that needs a live seat, through the same backend +methods the tools call: + + system_launch (attach) -> {attached, launched, version} + dimstyle_create ISO-25 / textstyle_create ISOCP / mleaderstyle_list + + mleaderstyle_create refused with capability "mleaderstyle" + page_setup_apply ISO_A3 -> batch_plot -> the PDF's own /MediaBox (420 x 297) + plot_style_list (installed .ctb files) + drawing_new(template=iso_a3_mech .dwt) -> mech layers, ISO-25 current, ISO_A3 + document_list / document_activate + layer_state_save -> change -> layer_state_restore -> layer_state_delete + view_named_save / view_named_list / view_named_restore + ucs_set / ucs_list / ucs_restore("world") + a non-orthogonal refusal + preferences_get + ONE reversible preferences_set (Display.CursorSize) + + a read-only key refused + drawing_properties_set / get (SummaryInfo + a custom property) + system_prompt_message + --interactive: user_pick_point (needs the operator at the keyboard) + --build-dwt: open each templates/.dxf and save the .dwt twin + through drawing_template_save, before anything else + +Prints one JSON report. Exit codes: + + 0 every assertion held (``failures`` is empty) + 1 the seat answered but at least one assertion failed (listed) + 2 no live CAD application reachable (pywin32 missing, or the ProgID + cannot be created) -- the COM paths then remain fake-tested only + +Reachability is read from ``system_status()`` because ``connect()`` is lazy. +The ``Dispatch`` fallback *launches* the ProgID when nothing is running and a +cold start can exceed ``COM_CALL_TIMEOUT`` (60 s): open AutoCAD first. + + AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_settings_com.py + uv run --frozen python scripts/smoke_settings_com.py --build-dwt + uv run --frozen python scripts/smoke_settings_com.py --interactive --keep +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +#: The five bundled templates (spec §6). Pinned here rather than read from +#: the catalogue so the smoke also notices a catalogue that lost a row. +TEMPLATE_NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch") + +#: Every DWG/DWT since R13 opens with "AC10xx" (AC1032 for the 2018 format). +DWG_MAGIC = b"AC10" + +#: What the ISO A3 landscape sheet must read back as, from the PDF itself. +A3_LANDSCAPE_MM = (420.0, 297.0) +MEDIABOX_TOLERANCE_MM = 0.5 + + +def _check(failures: list[str], label: str, ok: bool) -> None: + if not ok: + failures.append(label) + + +async def build_dwt(backend) -> list[dict]: + """Open each bundled DXF over COM and save its .dwt twin next to it.""" + from engineering.standards.templates import TEMPLATE_CATALOG, TEMPLATES_DIR + + rows: list[dict] = [] + if len(TEMPLATE_CATALOG) != len(TEMPLATE_NAMES): + raise SystemExit( + f"TEMPLATE_CATALOG has {len(TEMPLATE_CATALOG)} rows, the smoke pins " + f"{len(TEMPLATE_NAMES)}: update TEMPLATE_NAMES deliberately" + ) + for name in TEMPLATE_NAMES: + dxf = Path(TEMPLATES_DIR).resolve() / f"{name}.dxf" + dwt = Path(TEMPLATES_DIR).resolve() / f"{name}.dwt" + if not dxf.exists(): + raise SystemExit(f"{dxf} is missing: run scripts/build_templates.py first") + await backend.drawing_open(str(dxf)) + saved = await backend.drawing_template_save( + str(dwt), name=name, description=f"AutoCAD MCP Pro bundled template {name}" + ) + await backend.drawing_close(save=False) + head = dwt.read_bytes()[:6] + rows.append( + { + "name": name, + "path": str(dwt.relative_to(ROOT)), + "bytes": dwt.stat().st_size, + "magic": head.decode("ascii", "replace"), + "is_dwg_container": head.startswith(DWG_MAGIC), + "format": saved["format"], + } + ) + return rows + + +async def main(args: argparse.Namespace) -> int: + try: + from backends.com_backend import ComBackend + except ImportError as exc: + print(f"pywin32 not available: {exc}") + return 2 + from backends.base import UnsupportedCapabilityError + + backend = ComBackend() + try: + await backend.connect() + except Exception as exc: + print(f"no live AutoCAD reachable: {exc}") + return 2 + + failures: list[str] = [] + report: dict = {} + try: + status = await backend.system_status() + if not status.get("connected"): + print(f"no live AutoCAD reachable: {status.get('error', status)}") + return 2 + report["autocad"] = status.get("autocad_version") or status.get("cad_progid") + report["system_launch"] = await backend.system_launch(visible=True) + _check(failures, "system_launch attached", report["system_launch"]["attached"] is True) + + if args.build_dwt: + report["dwt_built"] = await build_dwt(backend) + _check( + failures, + "every .dwt is a DWG container", + all(row["is_dwg_container"] for row in report["dwt_built"]), + ) + + await backend.drawing_new() + smoke_document = (await backend.system_status()).get("active_document") + report["document"] = smoke_document + + # ── styles ──────────────────────────────────────────────────────── + from engineering.standards.dimstyles import resolve_dimstyle + from engineering.standards.mleaderstyles import resolve_mleaderstyle + + created = await backend.dimstyle_create( + "ISO-25-SMOKE", resolve_dimstyle("iso-25", None), set_current=True + ) + dimstyles = {row["name"]: row for row in await backend.dimstyle_list()} + smoke_style = dimstyles["ISO-25-SMOKE"] + _check(failures, "dimstyle DIMTXT 2.5", abs(smoke_style["values"]["DIMTXT"] - 2.5) < 1e-6) + _check(failures, "dimstyle DIMDSEP ','", smoke_style["values"]["DIMDSEP"] == ",") + _check(failures, "dimstyle current", smoke_style["current"] is True) + textstyle = await backend.textstyle_create("ISOCP-SMOKE", "isocp.shx") + _check(failures, "isocp.shx resolved on the seat", textstyle["font_resolved"] is True) + mleader_styles = await backend.mleaderstyle_list() + try: + await backend.mleaderstyle_create("SMOKE", resolve_mleaderstyle("iso", None)) + _check(failures, "mleaderstyle_create refused on COM", False) + except UnsupportedCapabilityError as exc: + _check(failures, "mleaderstyle refusal carries its key", exc.capability == "mleaderstyle") + report["styles"] = { + "dimstyle": created, + "textstyle": textstyle, + "mleaderstyles_on_seat": [row["name"] for row in mleader_styles], + } + + # ── page setup + the PDF's own mediabox ─────────────────────────── + from engineering.standards.papers import resolve_page_setup + from engineering.standards.plot import batch_plot + + applied = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", orientation="landscape", scale="1:1") + ) + listed = (await backend.page_setup_list("Layout1"))[0] + _check( + failures, + "page_setup_list says 420 x 297", + tuple(float(v) for v in listed["size_mm"]) == A3_LANDSCAPE_MM, + ) + out_dir = Path(tempfile.mkdtemp(prefix="acadmcp_settings_smoke_")) + plotted = await batch_plot(backend, ["Layout1"], str(out_dir), "{drawing}-{layout}.pdf") + pdf_row = plotted["sheets"][0] + width_mm, height_mm = (float(v) for v in pdf_row["mediabox_mm"]) + _check( + failures, + "PDF /MediaBox is 420 x 297 (+-0.5 mm)", + abs(width_mm - A3_LANDSCAPE_MM[0]) < MEDIABOX_TOLERANCE_MM + and abs(height_mm - A3_LANDSCAPE_MM[1]) < MEDIABOX_TOLERANCE_MM, + ) + installed = sorted( + row["name"] for row in await backend.plot_style_list() if row["source"] == "installed" + ) + _check(failures, "monochrome.ctb installed on the seat", "monochrome.ctb" in installed) + report["page_setup"] = { + "applied": applied["applied"], + "changed": applied["changed"], + "pdf": pdf_row, + "installed_plot_styles": installed, + } + + # ── bundled template over COM ───────────────────────────────────── + from engineering.standards.templates import resolve_template + + template_path, template_source = resolve_template("iso_a3_mech", "com") + opened = await backend.drawing_new(template=template_path) + template_layers = {layer.name for layer in await backend.layer_list()} + current_dimstyles = [row["name"] for row in await backend.dimstyle_list() if row["current"]] + template_papers = [row["paper"] for row in await backend.page_setup_list()] + _check( + failures, + "template carries the mech layers", + {"GEOMETRY", "DIM", "CENTER", "HIDDEN", "TITLEBLOCK"} <= template_layers, + ) + _check(failures, "template has ISO-25 current", current_dimstyles == ["ISO-25"]) + _check(failures, "template has an ISO_A3 page setup", "ISO_A3" in template_papers) + report["template"] = { + "path": str(template_path), + "source": template_source, + "document": opened["name"], + "dimstyle_current": current_dimstyles, + "papers": template_papers, + } + await backend.drawing_close(save=False) + activated = await backend.document_activate(smoke_document) + _check(failures, "smoke document re-activated", activated["active"] == smoke_document) + report["documents"] = await backend.document_list() + + # ── layer state round trip ──────────────────────────────────────── + await backend.layer_create("SMOKE_A", color=1) + await backend.layer_create("SMOKE_B", color=2) + saved = await backend.layer_state_save("SMOKE", description="settings smoke") + await backend.layer_modify("SMOKE_A", color=5) + await backend.layer_freeze("SMOKE_B") + restored = await backend.layer_state_restore("SMOKE") + after = {layer.name: layer for layer in await backend.layer_list()} + _check(failures, "layer state restored the colour", after["SMOKE_A"].color == 1) + _check(failures, "layer state thawed SMOKE_B", after["SMOKE_B"].is_frozen is False) + _check(failures, "restore reports nothing missing", restored["missing_layers"] == []) + states = await backend.layer_state_list() + _check(failures, "layer state listed", any(row["name"] == "SMOKE" for row in states)) + deleted = await backend.layer_state_delete("SMOKE") + report["layer_state"] = {"saved": saved, "restored": restored, "deleted": deleted} + + # ── named view ──────────────────────────────────────────────────── + view = await backend.view_named_save("SMOKE_VIEW", center=[100.0, 50.0], height=80.0) + views = await backend.view_named_list() + _check(failures, "named view listed", any(row["name"] == "SMOKE_VIEW" for row in views)) + report["view"] = {"saved": view, "restored": await backend.view_named_restore("SMOKE_VIEW")} + + # ── UCS ─────────────────────────────────────────────────────────── + ucs = await backend.ucs_set( + "SMOKE_UCS", [10.0, 20.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0] + ) + ucs_rows = await backend.ucs_list() + _check( + failures, + "ucs current", + any(row["name"] == "SMOKE_UCS" and row["current"] for row in ucs_rows), + ) + try: + await backend.ucs_set("SMOKE_BAD", [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]) + _check(failures, "non-orthogonal UCS refused", False) + except ValueError as exc: + _check(failures, "non-orthogonal UCS refusal names the angle", "45" in str(exc)) + report["ucs"] = {"set": ucs, "world": await backend.ucs_restore("world")} + + # ── preferences: get, one reversible set, a read-only refusal ───── + prefs = await backend.preferences_get() + cursor = int(prefs["values"]["Display.CursorSize"]) + probe = cursor + 1 if cursor < 100 else cursor - 1 + bumped = await backend.preferences_set("Display.CursorSize", probe) + reverted = await backend.preferences_set("Display.CursorSize", cursor) + _check(failures, "preference set reports old", bumped["old"] == cursor) + _check(failures, "preference set reports new", bumped["new"] == probe) + _check(failures, "preference reverted", reverted["new"] == cursor) + _check(failures, "read-only keys listed", "Files.SupportPath" in prefs["read_only"]) + try: + await backend.preferences_set("Files.SupportPath", "C:/nope") + _check(failures, "read-only preference refused", False) + except ValueError: + _check(failures, "read-only preference refused", True) + report["preferences"] = { + "cursor_size": cursor, + "read_only": prefs["read_only"], + "template_dwg_path": prefs["values"].get("Files.TemplateDwgPath"), + } + + # ── SummaryInfo ─────────────────────────────────────────────────── + written = await backend.drawing_properties_set( + summary={"title": "ACADMCP settings smoke", "author": "smoke_settings_com.py"}, + custom={"ACADMCP_SMOKE": "1"}, + ) + props = await backend.drawing_properties_get() + _check(failures, "summary available on COM", props["summary_available"] is True) + _check(failures, "title round trip", props["summary"]["title"] == "ACADMCP settings smoke") + _check(failures, "custom property round trip", props["custom"].get("ACADMCP_SMOKE") == "1") + report["properties"] = {"set": written, "get": props} + + # ── operator prompt line ────────────────────────────────────────── + report["prompt_message"] = await backend.system_prompt_message( + "ACADMCP settings smoke: done" + ) + + if args.interactive: + picked = await backend.user_pick_point("ACADMCP smoke: pick any point (Esc cancels)") + report["user_pick_point"] = picked + _check( + failures, + "pick returns a point, cancelled or timed_out", + bool(picked.get("cancelled")) + or bool(picked.get("timed_out")) + or ("x" in picked and "y" in picked), + ) + + report["failures"] = failures + print(json.dumps(report, indent=2, default=str)) + if not args.keep: + await backend.drawing_close(save=False) + return 0 if not failures else 1 + finally: + await backend.disconnect() + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--build-dwt", action="store_true", help="build templates/*.dwt first") + parser.add_argument("--interactive", action="store_true", help="also run user_pick_point") + parser.add_argument("--keep", action="store_true", help="leave the smoke document open") + return parser + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main(_parser().parse_args()))) +``` + +Add to `.gitattributes`: + +``` +*.dwt binary +*.dwg binary +``` + +- [ ] **Step 5: Rebuild the five DXF templates on the merged tree** + +`scripts/build_templates.py` (group P, Task 19) builds each template through the server's own methods, and two of its inputs exist only after the merge: the ISO-25 / ANSI dimension and text styles now come from group S's `dimstyle_create` / `textstyle_create` (before S merged, the script wrote the same values as header DIM variables), and `iso_a1_arch`'s `annotation_scale: "1:50"` is applied only once group C's facade key exists (before that the script recorded a `pending` note). The five committed DXFs therefore no longer match a fresh build — `tests/test_templates.py::test_committed_templates_match_a_fresh_build` and `build_templates.py --check` are red on the merged tree until the rebuild — and the `.dwt` twins of Step 6 must be built from the rebuilt DXFs, not the stale ones. + +``` +uv run --frozen python scripts/build_templates.py +uv run --frozen python scripts/build_templates.py --check +uv run --frozen pytest tests/test_templates.py -q +``` + +Expected: `5 templates reproduce byte-for-byte (volatile stamps masked)`; `tests/test_templates.py` green; `git diff --stat templates/` lists the five DXFs (their DIMSTYLE / STYLE table entries and, for `iso_a1_arch`, the `ACAD_SCALELIST` / `CANNOSCALE` objects moved). Commit the DXFs on their own, before anything on the `.dwt` side: + +```bash +git add templates/*.dxf +git commit -m "chore(templates): rebuild the five bundled DXFs on the merged tree (styles via dimstyle_create/textstyle_create, iso_a1_arch annotation scale 1:50)" +``` + +- [ ] **Step 6: Run the smoke on this machine (AutoCAD 2026 open)** + +``` +AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_settings_com.py --build-dwt +``` + +Expected: exit 0, `"failures": []`, five `dwt_built` rows with `"magic": "AC1032"` and `"is_dwg_container": true`, `page_setup.pdf.mediabox_mm` within 0.5 mm of `[420, 297]`, `template.source == "bundled"` (the `.dwt` was just built, so the resolver no longer falls back to `bundled_dxf`). Then `git status` shows five new `templates/*.dwt`. + +If a COM call fails (a wrong ActiveX member, a variant shape, the save constant — see the type-library measurement above), fix the backend, re-run that group's fake-ActiveX tests, and re-run the smoke until it exits 0: that is what the run is for. If it exits 2, the CHANGELOG says the COM paths are fake-tested only and the `.dwt` twins are absent (the resolver's `bundled_dxf` fallback keeps `drawing_new(template=)` working on COM — AutoCAD opens a DXF template through `Documents.Add` — and `tests/test_templates.py` must not require the `.dwt` files to exist headlessly). Run once more with `--interactive` and pick a point when AutoCAD prompts; paste the `user_pick_point` block into the record too. + +Append to `templates/README.md`: + +``` +The `.dwt` twins were built on 2026-09-16 on AutoCAD 2026 by +`scripts/smoke_settings_com.py --build-dwt` — each DXF opened over COM and +saved as a template (`AcSaveAsType` 66, `ac2018_Template`). Rebuild them the +same way after changing a DXF; `scripts/build_templates.py --check` gates the +DXF side only. +``` + +- [ ] **Step 7: Regenerate the published A/B report and the doc numbers** + +``` +uv run --frozen python benchmarks/compare_versions.py v1.5.1 --json benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json +uv run --frozen python scripts/check_doc_numbers.py +``` + +Expected A/B: `baseline v1.5.1: 26/32 (81.2%) current: 32/32 (100.0%) fixed: 6 regressed: 0` — the three P&ID checks and the three settings checks are `miss → pass` (v1.5.1 has none of the methods), the 26 it was released on still pass. `check_doc_numbers.py` then lists exactly the drifts to fix, all of them here: + +- `README.md`: `29 deterministic headless checks` → `32`; the A/B rows become + ``` + | v1.5.1 *(baseline)* | 26 / 32 | 81.2 % | — | — | + | **v1.6.0-dev** *(this branch)* | **32 / 32** | **100 %** | 6 | **0** | + ``` + and the sentence under the table: `The six fixed rows are new capability (`miss → pass`): the three P&ID checks of track A and, from track E, `settings_dimstyle_iso25_values` (the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers), `settings_layer_state_roundtrip` (save → change → restore puts the layer table back, and the state survives save/reopen because it is an XRECORD in the file) and `settings_pdf_mediabox_a3` (the plotted PDF's own `/MediaBox` reads 420 × 297). The 26 checks v1.5.1 was gated on all still pass.` The task-matrix table gains the row ``| `page_setup_truth` | ISO A3 landscape applied to Layout1, plotted through `batch_plot`, and the sheet read back from the PDF's `/MediaBox`: 420 × 297 mm, not the setter's return value |`` and its intro sentence `1.6 adds a sixth that the P&ID reader can fail on its own` → `1.6 adds two more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness`. +- `benchmarks/README.md`: `29 checks, ezdxf backend` → `32 checks, ezdxf backend`; the two A/B rows to `26 / 32 | 81.2 %` and `32 / 32 | 100 % | 6 | **0**`; the paragraph under them names the six; `**16/16 (100.0)** on the v4 matrix` → `**17/17 (100.0)**`; the Matrix v4 heading `— the P&ID round trip` → `— the P&ID round trip and the page-setup truth`, its lead `adds one task the reader can fail on its own` → `adds two tasks that can each fail on their own`, and a table row ``| `page_setup_truth` | pagesetup | `page_setup_apply("Layout1", ISO_A3 landscape 1:1)` then `batch_plot`; the PDF's own `/MediaBox` must parse to 420 × 297 mm (±0.5), the page-setup list must say `ISO_A3` / `landscape` / `[420, 297]`, and the ctb must be a known one |``. +- `CLAUDE.md`: `(16 tasks; ` → `(17 tasks; `; `correctness_suite.py` (29 checks)` → `(32 checks)`; `for the five v3 tasks or the v4 `pid_roundtrip`` → `for the five v3 tasks or the v4 `pid_roundtrip` / `page_setup_truth``. +- README release-snapshot `collected tests` figure: re-take with `uv run --frozen pytest --collect-only -q | tail -1` (this task added five tests). + +`scripts/check_doc_numbers.py` itself needs no edit: `AB_REPORT`, the two row labels and every pattern still match the sentences above. + +- [ ] **Step 8: CHANGELOG smoke record** + +In `CHANGELOG.md`, under `## [Unreleased]`, directly after the existing `### Live COM smoke` block (Track A's), add a second block with the JSON the run printed, verbatim — `report["autocad"]`, `system_launch`, `dwt_built`, `styles`, `page_setup` (the `pdf` row is the evidence), `template`, `layer_state`, `view`, `ucs`, `preferences`, `properties`, `prompt_message`, `user_pick_point` (from the `--interactive` run), `failures: []`: + +```` +### Live COM smoke — settings (track E) + +`scripts/smoke_settings_com.py --build-dwt`, then `--interactive`, AutoCAD 2026 +(`25.1s (LMS Tech)`), Windows 11, 2026-09-16, in a new document — styles → +page setup → `batch_plot` (the PDF's own `/MediaBox`) → the five `.dwt` twins +built and `iso_a3_mech.dwt` reopened → layer state round trip → named view → +UCS → preferences (one reversible set) → SummaryInfo → prompt line → one +operator pick, all through the COM engine, exit 0: + +```json + +``` + +The headless engine reports the identical `mediabox_mm`, `layer_state.restored` +and `properties.get.custom` on the same steps; `summary_available` is `false` +headlessly and the five summary fields are `null` (capability `dwgprops`). +```` + +Anything the run refused or timed out on (`timed_out`, a `failures` entry that was fixed before the final green run) is written down in one sentence each, the way Track A's record names its `gen_py` cache fault. + +- [ ] **Step 9: Run and commit** + +``` +uv run --frozen pytest tests/test_benchmark_v4.py tests/test_benchmark_v3.py tests/test_benchmark_v2.py tests/test_release_consistency.py tests/test_templates.py -q +uv run --frozen ruff check . && uv run --frozen ruff format --check . +``` + +Expected: green; `check_doc_numbers` reports `ok: 16 document numbers match the tree`. + +```bash +git add scripts/smoke_settings_com.py .gitattributes templates/*.dwt templates/README.md CHANGELOG.md +git commit -m "feat(settings): live COM smoke on AutoCAD 2026 builds the five .dwt twins and records the run" +git add benchmarks/tasks_v4.py benchmarks/adapters/autocad_mcp_pro.py benchmarks/correctness_suite.py benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json tests/test_benchmark_v4.py README.md benchmarks/README.md CLAUDE.md +git commit -m "bench: matrix v4 adds page_setup_truth; correctness suite grows to 32 with three settings checks (A/B vs v1.5.1: 26 -> 32 / 32)" +``` + +--- + +### Task 26: Documentation, spec status, full gate + +**Group:** Final (`main`). + +**Files:** +- Modify: `CLAUDE.md` (sections 18–20 in the tool list, the profiles/packs paragraphs, Key Conventions: layer-state honesty, page-setup evidence, UCS rule, `TOOL_PACKS` line), `README.md` (What-you-get rows, `` line, token table, Configuration row, "What this release is bad at"), `CHANGELOG.md` (`Unreleased`: Added / Fixed / Changed for track E), `docs/superpowers/specs/2026-09-16-v1.6-settings-design.md` (status line) +- Test: the full gate (Step 4) + +**Interfaces:** none produced; every number quoted is derived in Task 24/25 (204 tools, 199 advertised, 23 groups, lean 55 / 52 with `TOOL_PACKS=core`, 32 checks, 17 tasks) or measured in Step 2 (token rows). + +- [ ] **Step 1: CLAUDE.md** + +Add after item 17 in the tool-sections list: + +``` +18. Styles (10 tools, pack `core`): `dimstyle_list/create/modify/set_current` (authored ISO-25 / ANSI presets from `engineering/standards/dimstyles.py`; `dimstyle_modify` accepts only `DIM_VARIABLE_WHITELIST` names and refuses out-of-range values by name before writing; `changed` lists only variables that moved, and `rerender_required: true` says a headless DIMENSION keeps its rendered block until redrawn), `textstyle_list/create/set_current` (`font_resolved: false` is reported, never refused — DXF stores only the name), `mleaderstyle_list/create` (ezdxf only for `create`: capability `mleaderstyle`, because ActiveX exposes no MLeaderStyle collection; `leader_create_mleader`'s per-leader parameters are the COM route), and `drawing_apply_standard("iso"|"ansi")` — dimstyle + text style + units + the `mech` layer set in one call, each item reporting `created` or already present. +19. Page setup & templates (6 tools, pack `core`): `page_setup_list/apply` (ISO 216 / ANSI Y14.1 sizes from `PAPER_SIZES`; an unknown ctb is written and reported `plot_style_known: false`), `plot_style_list` (catalog + the files actually installed on a live seat), `batch_plot` (every paper layout through `drawing_export_pdf(layout=)`; `mediabox_mm` is **read back from the PDF's own `/MediaBox`**, so the sheet size is verified, not assumed), `drawing_template_list` (the five bundled templates: `iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch` — DXF headless, `.dwt` twin on COM, `scripts/build_templates.py --check` proves the DXFs reproducible), `drawing_template_save` (a `.dwt` request headlessly is refused with capability `dwt_write` naming the DXF route). `drawing_new(template=)` resolves a catalog name to the bundled file and reports `template: {name, source}`. +20. Environment (22 tools, pack `settings`): documents `document_list/activate/close` (both engines — the headless backend keeps a document registry; `document_close` refuses to discard unsaved work unless `discard=True`), layer states `layer_state_save/restore/list/delete` (portable: JSON chunks in an `ACADMCP_LAYERSTATES` XRECORD that travels with the file and **does not appear in AutoCAD's Layer States Manager** — `system_capabilities.layer_states` says so), named views `view_named_save/restore/list` (headless restore writes `$VIEWCTR/$VIEWSIZE` and reports `applied: "header_only"`), UCS `ucs_list/set/restore` (non-orthogonal axes refused with the measured angle; **tool coordinates stay WCS** — a UCS is stored for the operator, no tool interprets input in it), `system_variable_describe` (the 90-entry `SYSVAR_CATALOG`; an unknown name answers `known: false` plus nearest names, never an error; `system_set_variable` now refuses an out-of-range value for a catalogued name before writing), `drawing_properties_get/set` (custom properties on both engines through `$CUSTOMPROPERTYTAG`; the five summary fields are COM-only, capability `dwgprops`), and the live-only group `system_launch` (`live_application`), `system_preferences_get/set` (`preferences`; a whitelist of `Preferences.*` keys with ranges, read-only keys refused by name, writes report `old`/`new`), `user_pick_point/select`, `system_prompt_message` (`interactive_prompt`; a cancelled pick returns `{cancelled: true}`, a `COM_CALL_TIMEOUT` overrun reports `timed_out`). The COM paths were executed live once by `scripts/smoke_settings_com.py` (AutoCAD 2026), which also built the `.dwt` twins. +``` + +Profiles paragraph: `lean` ≈ 50 curated drafting tools (47 with `TOOL_PACKS=core`; → `lean` = 55 curated drafting tools (52 with `TOOL_PACKS=core`; the five settings essentials — `drawing_apply_standard`, `dimstyle_set_current`, `textstyle_set_current`, `page_setup_apply`, `batch_plot` — are core-pack tools and stay;`. Packs paragraph: `TOOL_PACKS=all|core,pid` → `TOOL_PACKS=all|core,pid,settings`, and add `; `settings` is the 22 SECTION 20 environment tools, `pid` the nine P&ID tools; SECTION 18/19 are core`. Key Conventions: the `TOOL_PACKS` bullet becomes `**`TOOL_PACKS=all|core,pid,settings`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`.` and three bullets are added after it: + +``` +- **Layer states are ours, not AutoCAD's.** `layer_state_*` stores a portable snapshot in an `ACADMCP_LAYERSTATES` XRECORD; it survives save/reopen on both engines and travels with the file, and it is not listed in AutoCAD's Layer States Manager. Said in the tool docstring, in `system_capabilities.layer_states` (`mode: "xrecord"`, reason `portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager`) and in the README. +- **Page-setup evidence is the PDF.** `batch_plot` reports `mediabox_mm` parsed from the file's `/MediaBox` (`engineering/standards/pdfinfo.py`); a page setup is verified by what a printer would get, not by the setter's return value. A style or setting write reports `changed` — re-setting a value to itself is not a change. +- **Nothing interprets coordinates in UCS.** `ucs_set` / `ucs_restore` store and activate a UCS for the operator's benefit; every tool input and output stays WCS (the repository rule above). +``` + +Keep item 11's text; `drawing_settings` gained keys (`limits`, `grid`, `snap`, `ortho`, `polar`, `psltscale`, `annotation_scale`, `linear_units`, `angular_units`, `dimstyle`, `textstyle`) — append to item 11: `; the facade now also covers limits, grid/snap/ortho/polar, PSLTSCALE, annotation scale (`"1:50"`), linear/angular unit formats and the current dimstyle/textstyle, with unknown keys refused and every key reported on read`. + +- [ ] **Step 2: README** + +What-you-get table, three rows after `P&ID`: + +``` +| Styles & standards | ISO-25 / ANSI dimension styles, ISOCP / ROMANS text styles, ISO / ANSI leader styles from authored presets; `drawing_apply_standard("iso")` sets all of it plus units and the `mech` layers in one call; `changed` reports only what moved | +| Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `batch_plot` verified by the PDF's own `/MediaBox`, five bundled templates (`drawing_new(template="iso_a3_mech")`), save any drawing as a template | +| Environment | several open documents headlessly, portable layer states, named views, UCS, a system-variable catalog with ranges, document properties, and on a live seat: launch/attach, preferences, an operator prompt / pick / select | +``` + +`` line: keep Task 24's wording. Token table: measure the four rows again (the surface grew by 38) and add the fifth: + +``` +uv run --frozen python benchmarks/token_suite.py +TOOL_PACKS=core uv run --frozen python benchmarks/token_suite.py --out /tmp/packs-core.json +TOOL_PACKS=core,settings uv run --frozen python benchmarks/token_suite.py --out /tmp/packs-core-settings.json +``` + +and rewrite the table with the printed `default` / `lean` / `search` rows of the first run and the `default` row of each of the other two: + +``` +| Advertised surface | Tools seen | Idle cost | +|---|---:|---:| +| `TOOL_PROFILE=full` (default) | 199 | tokens | +| `TOOL_PACKS=core,settings` (full profile) | 190 | tokens | +| `TOOL_PACKS=core` (full profile) | 168 | tokens | +| `TOOL_PROFILE=lean` | 55 | tokens | +| `DISCOVERY_MODE=search` | 2 | ** tokens** | +``` + +(`` is the `tokens` column the suite prints for that row — the `Tools seen` values are arithmetic on the registry: 204 − 5 solids, minus 9 `pid_*`, minus 22 environment.) The "Why this exists" first sentence's two numbers (`44,930` / `356`) and the task-matrix row `token_budget | 44,930 → 356 tokens` are the same `default` and `search` figures — replace them from the same run. The Discovery row's `of the 161-tool advertised catalog` → `of the 199-tool advertised catalog`, and the corpus sentence `**162 AutoCAD command names and 731 synonym phrases** covering all 166 tools` is re-derived with: + +``` +uv run --frozen python -c " +from discovery.aliases import TOOL_ALIASES as T +print(len({c for r in T.values() for c in r.acad}), sum(len(r.synonyms) for r in T.values()), len(T))" +``` + +"What this release is bad at": add after the P&ID limitations subsection: + +``` +### Known limitations of the settings track + +**Layer states are ours.** `layer_state_save` writes a portable snapshot into an `ACADMCP_LAYERSTATES` XRECORD. It survives save/reopen on both engines and travels with the DWG/DXF; it is **not** an AutoCAD `LAYERSTATE` and does not appear in the Layer States Manager. `system_capabilities` reports `layer_states` as `mode: "xrecord"` for that reason. + +**Half the environment needs a live seat.** Preferences, the five summary fields of document properties, launching, and the operator prompts are refused headlessly with `preferences` / `dwgprops` / `live_application` / `interactive_prompt` — a refusal, not a stub. `mleaderstyle_create` is the mirror case: ActiveX exposes no MLeaderStyle collection, so the live engine refuses it and `leader_create_mleader` carries the per-leader parameters instead. + +**A headless dimension does not re-render.** `dimstyle_modify` reports `dimensions_using_style` and `rerender_required: true` on the ezdxf engine; the DIMENSION keeps its rendered block until it is redrawn. AutoCAD regenerates on the next regen. +``` + +- [ ] **Step 3: CHANGELOG `Unreleased` and the spec** + +Insert directly after the Track A intro paragraph (before `### Added`): + +``` +Track E of 1.6: the environment a drawing lives in. 38 tools in three new +sections — styles (18), page setup and templates (19), environment (20) — +the `drawing_settings` facade grown, a system-variable catalog, five bundled +templates, several open documents headlessly, portable layer states, named +views and UCS, and the live-only group (launch, preferences, document +properties, operator prompts) refused honestly headlessly. Spec: +`docs/superpowers/specs/2026-09-16-v1.6-settings-design.md`. +``` + +Under `### Added`, before the P&ID bullet: + +``` +- **Styles — 10 tools, pack `core`.** `dimstyle_list/create/modify/set_current`, + `textstyle_list/create/set_current`, `mleaderstyle_list/create`, + `drawing_apply_standard`. ISO-25 and ANSI (metric) presets are authored data + pinned by `tests/test_standards_presets.py` against ISO 129-1 / ASME Y14.2 + (DIMTXT 2.5 / 3.0, DIMASZ 2.5 / 3.0, DIMEXO 0.625 / 1.5, DIMDSEP `,` / `.`, + DIMTAD 1 / 0 …). `dimstyle_modify` accepts the 33-name whitelist only (the + 17 preset variables plus 16 more — `DIM_VARIABLE_WHITELIST`) and + refuses an out-of-range value by name before writing; `changed` lists what + moved. A font neither bundled nor on the engine's font path is written and + reported `font_resolved: false`, never refused. `mleaderstyle_create` is + ezdxf-only (capability `mleaderstyle`): ActiveX has no MLeaderStyle + collection. +- **Page setup & templates — 6 tools, pack `core`.** `page_setup_list/apply` + (`PAPER_SIZES` ISO A0–A4 / ANSI A–E, `CTB_CATALOG`, `SCALES`), `plot_style_list` + (catalog + installed), `batch_plot` — `mediabox_mm` parsed from each PDF's + `/MediaBox`, so `ISO_A3` landscape is 420 × 297 because the file says so — + `drawing_template_list`, `drawing_template_save` (`.dwt` refused headlessly, + capability `dwt_write`). Five bundled templates built by + `scripts/build_templates.py` through the server's own methods (`--check` + proves the DXFs byte-reproducible); their `.dwt` twins were built once on + AutoCAD 2026 by the smoke. `drawing_new(template="iso_a3_mech")` resolves the + bundled file and reports `{name, source}`. +- **Environment — 22 tools, pack `settings`.** `document_list/activate/close` + on both engines (the headless backend keeps a document registry; + `drawing_close` is the active-document case); `layer_state_save/restore/ + list/delete` as portable XRECORD snapshots — restore applies only the + requested `properties`, reports `missing_layers` (skipped, never created) + and `new_layers` (untouched), and the state survives save/reopen; + `view_named_save/restore/list`; `ucs_list/set/restore` (non-orthogonal axes + refused with the measured angle; tool coordinates stay WCS); + `system_variable_describe` over a 90-entry catalog (`known: false` + nearest + names on a miss); `drawing_properties_get/set` (custom properties on both + engines, summary fields COM-only: `dwgprops`); `system_launch` + (`live_application`), `system_preferences_get/set` (whitelisted keys with + ranges, read-only keys refused by name, `old`/`new` reported: + `preferences`), `user_pick_point/select` and `system_prompt_message` + (`interactive_prompt`; a cancelled pick is `{cancelled: true}`, a + `COM_CALL_TIMEOUT` overrun is `timed_out`). +- **`drawing_settings` grows** by `limits`, `grid`/`grid_spacing`, + `snap`/`snap_spacing`, `ortho`, `polar`/`polar_angle`, `psltscale`, + `annotation_scale`, `linear_units`, `angular_units`, `dimstyle`, + `textstyle`; `system_set_variable` refuses an out-of-range value for a + catalogued name before writing. +- **Benchmark evidence.** `benchmarks/tasks_v4.py` adds `page_setup_truth`; + the correctness suite grows to 32 with `settings_dimstyle_iso25_values`, + `settings_layer_state_roundtrip`, `settings_pdf_mediabox_a3`. A/B against + `v1.5.1`: **26 / 32 → 32 / 32, six `miss → pass`, zero regressed**. + `scripts/smoke_settings_com.py` runs the track once against the live seat + (record below). +``` + +Under `### Fixed`, before the `block_insert` bullet, the seven Track A hardening fixes (Tasks 1–8 of this plan) — one bullet each, with the reproduction the hardening test pins: + +``` +- **Track A hardening** (`tests/test_track_a_hardening.py`; the remaining + review findings are filed with a disposition each in + `docs/analysis/track-a-review-backlog.md`): + - `entity_create_block_ref` of an undefined block name is refused before + writing (it returned a handle to a dangling INSERT). + - `block_explode` keeps ATTRIB text: each ATTRIB becomes a TEXT entity on + both engines instead of being dropped. + - `pid_from_spec` refuses a NaN coordinate before the transaction opens and + rolls back on `BaseException`, not only `Exception`. + - A signal line no longer receives a fabricated line number and label; the + reader reports `line_number: null` for it. + - A bubble's exit point honours the first waypoint instead of the port + direction. + - `block_insert` attribute values are validated as strings; an int or None + is a `TypeError` naming the tag, not `str()`-coerced. + - `block_define` refuses a NaN base point and a layer that does not exist + in the drawing (it wrote both and `drawing_audit` repaired them later). +``` + +Under `### Changed`, before the `TOOL_PACKS` bullet, replace that bullet's first line with `**`TOOL_PACKS=all|core,pid,settings`** (default `all`)` and its lean sentence with `(which now carries the five settings essentials: 55 tools, 52 with `TOOL_PACKS=core`)`, and its measured-cost sentence with the numbers from Task 26 Step 2. Add: + +``` +- Three tool groups appear in `system_about` and `docs/tool-inventory.json`: + `styles` (10), `page_setup` (6), `environment` (22) — 204 tools in 23 groups; + `LEAN_TOOL_NAMES` 55; capability keys `mleaderstyle`, `dwgprops`, + `dwt_write`, `live_application`, `preferences`, `interactive_prompt` and the + `layer_states` feature (`mode: "xrecord"`) in both maps. +- README release snapshot: 204 tools · 8 resources · 5 prompts · + collected tests (this machine). +``` + +Spec status line (`docs/superpowers/specs/2026-09-16-v1.6-settings-design.md`, line 3–6): + +``` +**Date:** 2026-09-16 · **Status:** implemented 2026-09-16 (26 tasks of +`docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`; live COM smoke on AutoCAD 2026 recorded in `CHANGELOG.md` Unreleased, `.dwt` twins built by it; A/B vs v1.5.1 32 / 32) · **Parent:** +`2026-09-15-v1.6-roadmap-design.md` (track E) · **Baseline:** main after +Track A (166 tools, 8 resources) +``` + +- [ ] **Step 4: Full gate** + +Run, in order, and fix anything red before committing: + +``` +uv run --frozen ruff check . && uv run --frozen ruff format --check . +uv run --frozen pytest -x -q +uv run --frozen python scripts/generate_tool_inventory.py --check +uv run --frozen python scripts/check_doc_numbers.py +uv run --frozen python scripts/build_templates.py --check +uv run --frozen python benchmarks/compare_versions.py v1.5.1 +``` + +Expected: ruff clean; pytest green with every track E test file collected (`tests/test_standards_presets.py`, `test_styles.py`, `test_settings_facade.py`, `test_sysvar_catalog.py`, `test_drawing_properties.py`, `test_page_setup.py`, `test_templates.py`, `test_batch_plot.py`, `test_documents.py`, `test_layer_states.py`, `test_views_ucs.py`, `test_environment_live.py`, `test_track_a_hardening.py`, `test_benchmark_v4.py`, `test_tool_packs.py`); `ok: docs/tool-inventory.json matches the live surface`; `ok: 16 document numbers match the tree` (re-take the README collected-tests figure first if Step 1–3 changed nothing in `tests/` it will not have moved since Task 25); the five DXFs reproducible; A/B `32 / 32`, `fixed: 6 regressed: 0`. + +- [ ] **Step 5: Commit** + +```bash +git add CLAUDE.md README.md CHANGELOG.md docs/superpowers/specs/2026-09-16-v1.6-settings-design.md +git commit -m "docs(settings): track E shipped — CLAUDE.md sections 18-20, README, changelog with the live COM smoke record" +``` + +Track E is done when the gate in Step 4 is green and the spec's §9 evidence is demonstrated by `tests/test_standards_presets.py` (every §4.1 value by name), `tests/test_batch_plot.py` (the parsed `/MediaBox`), `tests/test_layer_states.py` (the XRECORD survives reopen), `tests/test_documents.py` (two headless documents) and the smoke record in the CHANGELOG.