feat(settings): live COM smoke on AutoCAD 2026 builds the five .dwt twins and records the run
scripts/smoke_settings_com.py --build-dwt exited 0 on AutoCAD 2026 (25.1s), 2026-09-22: five templates/*.dwt written through drawing_template_save (SaveAs code 66, each file opens with AC1032), page setup plotted and the PDF's own /MediaBox read back as 420.158 x 297.039 mm, iso_a3_mech.dwt reopened as source "bundled" with ISO-25 current, layer state / named view / UCS / preferences / SummaryInfo / prompt line all through the COM engine. The record is in CHANGELOG.md under Unreleased. The smoke keeps a ledger of the documents it opens and closes them on the way out, so a failed run never leaves scratch documents on the operator's seat. Two of its expectations were corrected to the merged tree: mleaderstyle_create is implemented on COM (spec: no `mleaderstyle` key), and plot_style_list marks catalogue rows with `installed`. --interactive was not run unattended on the shared seat (an unanswered GetPoint holds the command line); Task 23 measured the pick live. *.dwt / *.dwg are binary in .gitattributes; AutoCAD's plot.log is ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
b54271f4ac
commit
02dbb133b5
@@ -2,3 +2,5 @@
|
||||
* text=auto
|
||||
templates/*.dxf text eol=crlf
|
||||
templates/*.dwt binary
|
||||
*.dwt binary
|
||||
*.dwg binary
|
||||
|
||||
@@ -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
|
||||
|
||||
+454
@@ -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
|
||||
|
||||
@@ -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/<name>.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())))
|
||||
+7
-1
@@ -26,7 +26,7 @@ listed by `drawing_template_list`. Start from one with
|
||||
- `<name>.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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user