diff --git a/.gitattributes b/.gitattributes index ada0244..bb81e50 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ * text=auto templates/*.dxf text eol=crlf templates/*.dwt binary +*.dwt binary +*.dwg binary diff --git a/.gitignore b/.gitignore index 13517c3..9b07d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ benchmarks/.competitors/ # committed one was stale and unvetted — but CI now installs from it with # `uv sync --locked`, so ignoring it would silently disarm every locked lane. # Regenerate with `uv lock` whenever pyproject.toml dependencies change. + +# AutoCAD writes its plot-and-publish log into the cwd of the plotting process +# (scripts/smoke_settings_com.py plots from the repo root). +plot.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f45963..e44aba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,460 @@ AutoCAD type library (a package directory holding only a `__pycache__`) — an environment fault, not a backend one; deleting `%TEMP%\gen_py\3.11` cleared it and no backend change was needed. +### Live COM smoke — settings (track E) + +`scripts/smoke_settings_com.py --build-dwt`, AutoCAD 2026 (`25.1s (LMS Tech)`), +Windows 11, 2026-09-22, in a new document (`Drawing27.dwg`) — 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, all +through the COM engine, exit 0: + +```json +{ + "autocad": "25.1s (LMS Tech)", + "system_launch": { + "launched": false, + "attached": true, + "version": "25.1s (LMS Tech)", + "document": "Drawing9.dwg", + "visible": true, + "progid": "AutoCAD.Application", + "backend": "com" + }, + "dwt_built": [ + { + "name": "iso_a3_mech", + "path": "templates\\iso_a3_mech.dwt", + "bytes": 18240, + "magic": "AC1032", + "is_dwg_container": true, + "format": "dwt" + }, + { + "name": "iso_a1_arch", + "path": "templates\\iso_a1_arch.dwt", + "bytes": 17281, + "magic": "AC1032", + "is_dwg_container": true, + "format": "dwt" + }, + { + "name": "iso_a3_pid", + "path": "templates\\iso_a3_pid.dwt", + "bytes": 18528, + "magic": "AC1032", + "is_dwg_container": true, + "format": "dwt" + }, + { + "name": "ansi_b_mech", + "path": "templates\\ansi_b_mech.dwt", + "bytes": 17250, + "magic": "AC1032", + "is_dwg_container": true, + "format": "dwt" + }, + { + "name": "ansi_d_arch", + "path": "templates\\ansi_d_arch.dwt", + "bytes": 17278, + "magic": "AC1032", + "is_dwg_container": true, + "format": "dwt" + } + ], + "document": "Drawing27.dwg", + "styles": { + "dimstyle": { + "ok": true, + "name": "ISO-25-SMOKE", + "values": { + "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 + }, + "written": [ + "DIMASZ", + "DIMBLK", + "DIMDEC", + "DIMDSEP", + "DIMEXE", + "DIMEXO", + "DIMGAP", + "DIMLUNIT", + "DIMLWD", + "DIMLWE", + "DIMSCALE", + "DIMTAD", + "DIMTIH", + "DIMTOH", + "DIMTXSTY", + "DIMTXT", + "DIMZIN" + ], + "current": true, + "textstyle_created": true + }, + "textstyle": { + "ok": true, + "name": "ISOCP-SMOKE", + "font": "isocp.shx", + "font_resolved": true, + "current": false + }, + "mleaderstyle": { + "ok": true, + "name": "SMOKE", + "values": { + "arrow_size": 2.5, + "landing_gap": 1.0, + "text_style": "ISOCP", + "text_height": 2.5 + }, + "textstyle_created": false + }, + "mleaderstyles_on_seat": [ + "SMOKE", + "Standard" + ] + }, + "page_setup": { + "applied": { + "paper": "ISO_A3", + "orientation": "landscape", + "size_mm": [ + 420.0, + 297.0 + ], + "canonical_media_name": "ISO_A3_(420.00_x_297.00_MM)", + "plot_style": "monochrome.ctb", + "plot_style_known": true, + "scale": "1:1", + "scale_factor": 1.0, + "scale_ratio": [ + 1.0, + 1.0 + ], + "dxf_standard_scale_type": 16, + "activex_standard_scale": 16, + "plot_area": "layout", + "plot_type": 5, + "device": "DWG To PDF.pc3", + "margins_mm": null, + "center": null, + "paper_units": "mm" + }, + "changed": { + "paper": [ + "", + "ISO_A3" + ], + "canonical_media_name": [ + "", + "ISO_A3_(420.00_x_297.00_MM)" + ], + "size_mm": [ + [ + 0.0, + 0.0 + ], + [ + 420.0, + 297.0 + ] + ], + "plot_style": [ + "", + "monochrome.ctb" + ], + "device": [ + "Microsoft Print to PDF", + "DWG To PDF.pc3" + ], + "margins_mm": [ + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 17.0, + 17.0, + 5.0, + 5.0 + ] + ], + "paper_units": [ + "inches", + "mm" + ] + }, + "pdf": { + "layout": "Layout1", + "ok": true, + "path": "C:\\Users\\VECTOR\\AppData\\Local\\Temp\\acadmcp_settings_smoke_sapms54v\\Drawing27-Layout1.pdf", + "bytes": 2774, + "mediabox_mm": [ + 420.158, + 297.039 + ], + "paper": "ISO_A3" + }, + "installed_plot_styles": [ + "Autodesk-Color.stb", + "Autodesk-MONO.stb", + "DWF Virtual Pens.ctb", + "Fill Patterns.ctb", + "Grayscale.ctb", + "Screening 100%.ctb", + "Screening 25%.ctb", + "Screening 50%.ctb", + "Screening 75%.ctb", + "acad.ctb", + "acad.stb", + "monochrome.ctb", + "monochrome.stb" + ] + }, + "template": { + "path": "C:\\Users\\VECTOR\\Documents\\GitHub\\Autocad-MCP\\templates\\iso_a3_mech.dwt", + "source": "bundled", + "document": "Drawing28.dwg", + "dimstyle_current": [ + "ISO-25" + ], + "papers": [ + "ISO_A3" + ] + }, + "documents": [ + { + "name": "Drawing9.dwg", + "path": null, + "active": false, + "saved": false, + "entity_count": 0 + }, + { + "name": "Drawing27.dwg", + "path": null, + "active": true, + "saved": false, + "entity_count": 0 + } + ], + "layer_state": { + "saved": { + "ok": true, + "name": "SMOKE", + "layer_count": 3, + "replaced": false, + "chunks": 2, + "backend": "com" + }, + "restored": { + "ok": true, + "name": "SMOKE", + "applied": { + "layers": 3, + "properties": [ + "on", + "frozen", + "locked", + "color", + "linetype", + "lineweight", + "plot", + "current" + ], + "current_layer": "0" + }, + "missing_layers": [], + "new_layers": [], + "backend": "com" + }, + "deleted": { + "ok": true, + "deleted": "SMOKE", + "backend": "com" + } + }, + "view": { + "saved": { + "ok": true, + "name": "SMOKE_VIEW", + "center": [ + 100.0, + 50.0 + ], + "height": 80.0, + "width": 160.9964412811388, + "replaced": false, + "backend": "com" + }, + "restored": { + "ok": true, + "name": "SMOKE_VIEW", + "center": [ + 100.0, + 50.0 + ], + "height": 80.0, + "width": 160.9964412811388, + "applied": "zoom_window", + "backend": "com", + "viewctr": [ + 100.0, + 50.0 + ], + "viewsize": 80.0 + } + }, + "ucs": { + "set": { + "ok": true, + "name": "SMOKE_UCS", + "origin": [ + 10.0, + 20.0, + 0.0 + ], + "x_axis": [ + 1.0, + 0.0, + 0.0 + ], + "y_axis": [ + 0.0, + 1.0, + 0.0 + ], + "replaced": false, + "current": true, + "backend": "com" + }, + "world": { + "ok": true, + "name": "world", + "current": true, + "backend": "com" + } + }, + "preferences": { + "cursor_size": 5, + "read_only": [ + "Files.SupportPath", + "Files.TemplateDwgPath", + "Files.PrinterStyleSheetPath", + "Files.PrinterConfigPath" + ], + "template_dwg_path": "C:\\Users\\VECTOR\\AppData\\Local\\Autodesk\\AutoCAD 2026\\R25.1\\enu\\Template" + }, + "properties": { + "set": { + "ok": true, + "summary_written": [ + "author", + "title" + ], + "custom_written": [ + "ACADMCP_SMOKE" + ], + "custom_deleted": [], + "backend": "com" + }, + "get": { + "summary": { + "title": "ACADMCP settings smoke", + "subject": "", + "author": "smoke_settings_com.py", + "keywords": "", + "comments": "" + }, + "summary_available": true, + "custom": { + "ACADMCP_SMOKE": "1" + }, + "backend": "com" + } + }, + "prompt_message": { + "ok": true, + "text": "ACADMCP settings smoke: done", + "backend": "com" + }, + "failures": [] +} +``` + +The headless engine reports the identical `mediabox_mm` (to 1e-9: matplotlib +writes an exact 420 × 297; AutoCAD's PDF driver writes 420.158 × 297.039, +inside spec §9's ±0.5 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`). + +What the run refused before the green one, each fixed in the backend and +pinned by a fake-ActiveX test: + +- Three runs died with `RPC_E_CALL_REJECTED` (-2147418111) on the read that + follows a document switch — `Documents.Count` straight after + `Documents.Open`, after `Close(False)` inside `document_close`, and in + `_ensure_document_state` — AutoCAD's own message filter refusing the call + while it still switches documents. pywin32 has no + `CoRegisterMessageFilter`, so the backend now waits that window out itself + (`_wait_out_rejected_call`, read-only callables only, 0.25 s → 1 s pauses + inside a 20 s budget) at those sites; any other error is not retried. +- `layer_list` on the reopened template raised `AttributeError: 'IAcadLayer' + object has no attribute 'Color'. Did you mean: 'color'?`. The type library + spells `IAcadEntity` / `IAcadLayer` `color` and `Lineweight`; late-bound + dispatch resolved `.Color` / `.LineWeight` case-insensitively, but once a + makepy cache exists (this machine's was regenerated on 2026-09-17) every + object a method returns is a generated wrapper and the lookup is exact. The + backend now spells both members as the type library does (works on both + wrappers) and the fakes model the measured names. +- `layer_state_restore` raised `'IAcadObject' object has no attribute + 'Count'`: `Dictionaries.Item` and `IAcadDictionary.Item` are declared + `IAcadObject*` (the narrowing `_com_unnarrow` already handled for MLEADER + styles); the save before it went through the typed `Dictionaries.Add`, so + only the reopen paths failed. Both now un-narrow. +- A state saved with layer `0` current came back with two warnings and `0` + unapplied: the `Freeze` put on layer `0` raises 'Invalid layer' whatever + the value, even `False` while thawed (measured on a scratch document; + `color` / `Linetype` / `Lineweight` / `Plottable` / `LayerOn` / `Lock` / + `ActiveLayer` all fine on it). `Freeze` is now written only when it would + change the layer. +- Two of the smoke's own expectations predated the merged tree and were + corrected to it: `mleaderstyle_create` is implemented on COM through the + `ACAD_MLEADERSTYLE` dictionary (spec §4: no `mleaderstyle` key exists), so + the smoke now asserts the created style is listed with `arrow_size` 2.5; + and `plot_style_list` marks catalogue rows with `installed: true` (the + `source: "installed"` rows are files the catalogue does not know), so the + `monochrome.ctb` check reads that flag. + +`--interactive` (`user_pick_point`) was not run in this session: the seat is +shared with other agents and nobody was confirmed at the keyboard, and an +unanswered `Utility.GetPoint` holds AutoCAD's command line — every COM client +gets `RPC_E_CALL_REJECTED` — until someone presses Esc, past the 60 s timeout. +The pick path was executed live earlier in the track (Task 23's measurement: +`GetPoint` answers in WCS while `GetEntity` answers in the current UCS). Each +plot also dropped AutoCAD's `plot.log` into the repo root; it is ignored now. + ## [1.5.1] — 2026-08-06 A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the diff --git a/scripts/smoke_settings_com.py b/scripts/smoke_settings_com.py new file mode 100644 index 0000000..c604491 --- /dev/null +++ b/scripts/smoke_settings_com.py @@ -0,0 +1,388 @@ +# 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_create iso -> listed with arrow_size 2.5 + 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) + + +#: Names of the documents this run opened and has not closed yet. A run that +#: dies half-way closes them in ``main``'s ``finally`` (unless ``--keep``), so +#: a failed smoke never leaves its scratch documents on the operator's seat. +_OPENED: list[str] = [] + + +async def _opened_now(backend) -> str: + """Record the document a ``drawing_new`` / ``drawing_open`` just made active.""" + name = (await backend.system_status()).get("active_document") + if name and name not in _OPENED: + _OPENED.append(name) + return name + + +async def _close_ours(backend, name: str) -> None: + await backend.document_close(name, discard=True) + if name in _OPENED: + _OPENED.remove(name) + + +async def _close_leftovers(backend) -> None: + open_names = {row["name"] for row in await backend.document_list()} + for name in list(_OPENED): + if name in open_names: + try: + await backend.document_close(name, discard=True) + except Exception as exc: # best effort on the way out + print(f"could not close {name}: {exc}", file=sys.stderr) + _OPENED.remove(name) + + +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)) + opened_as = await _opened_now(backend) + saved = await backend.drawing_template_save( + str(dwt), name=name, description=f"AutoCAD MCP Pro bundled template {name}" + ) + # SaveAs rebinds the document to the .dwt name (``document_rebound``). + if opened_as in _OPENED: + _OPENED.remove(opened_as) + await _close_ours(backend, await _opened_now(backend)) + 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 + 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 _opened_now(backend) + 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) + # Both engines create MLEADER styles (spec: COM through the + # ACAD_MLEADERSTYLE dictionary's AddObject; no `mleaderstyle` key). + mleader_created = await backend.mleaderstyle_create( + "SMOKE", resolve_mleaderstyle("iso", None) + ) + mleader_styles = {row["name"]: row for row in await backend.mleaderstyle_list()} + _check(failures, "mleaderstyle created on COM", mleader_created["ok"] is True) + _check(failures, "mleaderstyle listed", "SMOKE" in mleader_styles) + _check( + failures, + "mleaderstyle ISO arrow 2.5", + abs(float(mleader_styles.get("SMOKE", {}).get("arrow_size", 0.0)) - 2.5) < 1e-6, + ) + report["styles"] = { + "dimstyle": created, + "textstyle": textstyle, + "mleaderstyle": mleader_created, + "mleaderstyles_on_seat": sorted(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, + ) + # Catalogue rows carry ``installed`` (True/False, None when the + # Preferences object is unreachable); ``source: "installed"`` marks + # files the catalogue does not know. + installed = sorted( + row["name"] for row in await backend.plot_style_list() if row.get("installed") is True + ) + _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) + await _opened_now(backend) + 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 _close_ours(backend, opened["name"]) + 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"] == []) + _check( + failures, + "restore applied every layer", + restored["applied"]["layers"] == saved["layer_count"], + ) + _check(failures, "restore raised no warnings", "warnings" not in restored) + 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 _close_ours(backend, smoke_document) + return 0 if not failures else 1 + finally: + if not args.keep: + await _close_leftovers(backend) + 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()))) diff --git a/templates/README.md b/templates/README.md index 9528d5a..1a5825d 100644 --- a/templates/README.md +++ b/templates/README.md @@ -26,7 +26,7 @@ listed by `drawing_template_list`. Start from one with - `.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 + `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 25'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 @@ -43,3 +43,9 @@ 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. + +The `.dwt` twins were built on 2026-09-22 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. diff --git a/templates/ansi_b_mech.dwt b/templates/ansi_b_mech.dwt new file mode 100644 index 0000000..001b9ea Binary files /dev/null and b/templates/ansi_b_mech.dwt differ diff --git a/templates/ansi_d_arch.dwt b/templates/ansi_d_arch.dwt new file mode 100644 index 0000000..54ce5dd Binary files /dev/null and b/templates/ansi_d_arch.dwt differ diff --git a/templates/iso_a1_arch.dwt b/templates/iso_a1_arch.dwt new file mode 100644 index 0000000..dcf15ba Binary files /dev/null and b/templates/iso_a1_arch.dwt differ diff --git a/templates/iso_a3_mech.dwt b/templates/iso_a3_mech.dwt new file mode 100644 index 0000000..4776547 Binary files /dev/null and b/templates/iso_a3_mech.dwt differ diff --git a/templates/iso_a3_pid.dwt b/templates/iso_a3_pid.dwt new file mode 100644 index 0000000..dc75287 Binary files /dev/null and b/templates/iso_a3_pid.dwt differ