feat(templates): drawing_template_save on both engines — dwt_write is a declared capability

Live AutoCAD writes a real .dwt through SaveAs(path, ac2018_Template = 66) and
puts the description into the summary Comments; headlessly a .dxf template is
written through drawing_save_as and a .dwt request is refused with capability
dwt_write, naming the DXF route and the COM route. drawing_save_as's "dwt"
entry was 5 (acR13_dxf): a DWT request wrote an R13 DXF under a .dwt name.

Probed live on AutoCAD 2026 (typelib: ac2018_Template = 66, acR13_dxf = 5,
acTemplateDwg absent; the saved file starts AC1032, the document is rebound,
Comments takes). SECTION 19 is now 6 tools; SAVEAS joins SHARED_ACAD_COMMANDS;
templates/README.md documents the two files per template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 22:48:31 +03:00
co-authored by Claude Opus 5
parent 17f7228fd6
commit b458923e19
9 changed files with 322 additions and 20 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 171 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> **v1.5 release snapshot:** 172 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
@@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list` |
| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly) |
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
@@ -114,7 +114,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>171 tools in 20 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>172 tools in 20 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+42 -6
View File
@@ -1117,8 +1117,10 @@ class ComBackend(AutoCADBackend):
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict:
def _sync():
doc = _acad_doc()
# AutoCAD SaveAs format constants: 12=DWG R2010, 61=DXF R2010
fmt_map = {"dwg": 12, "dxf": 61, "dwt": 5}
# 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}
acad_fmt = fmt_map.get(fmt.lower(), 12)
doc.SaveAs(path, acad_fmt)
return {"ok": True, "path": path, "format": fmt}
@@ -2136,6 +2138,10 @@ class ComBackend(AutoCADBackend):
# scripts/smoke_settings_com.py (Task 24).
_AC_NO_ROTATION = 0
#: 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
#: Layout properties a ConfigName write can move as a side effect (measured
#: live for CanonicalMediaName; units / rotation are re-applied on the same
#: evidence-before-trust basis) — snapshotted before the device switch and
@@ -2442,10 +2448,40 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
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)"
)
"""``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)
# ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─
+45 -4
View File
@@ -123,6 +123,18 @@ _DWG_WRITE_REFUSAL = (
)
# 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."
)
def _is_dwg_path(path: str) -> bool:
"""True when `path` names a DWG file. The extension is authoritative (N2)."""
return Path(path).suffix.lower() == ".dwg"
@@ -2687,10 +2699,39 @@ class EzdxfBackend(AutoCADBackend):
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)"
)
"""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
# ── 3D solids (unsupported headlessly — honest capability boundary) ─────
+13
View File
@@ -76,6 +76,8 @@ class ToolAliases:
# (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.
# SAVEAS -- the same command saves a copy (drawing_save_as) or a template
# (drawing_template_save, its "Drawing Template" file type).
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -91,6 +93,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"PLINE",
"PLOT",
"QSELECT",
"SAVEAS",
"SETVAR",
"ZOOM",
}
@@ -1462,6 +1465,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"dwt list",
),
),
"drawing_template_save": ToolAliases(
acad=("SAVEAS",),
synonyms=(
"save as template",
"make a dwt",
"template from this drawing",
"save as dwt",
"reuse this sheet setup",
),
),
"page_setup_apply": ToolAliases(
acad=("PAGESETUP",),
synonyms=(
+16 -2
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 171,
"tools": 172,
"resources": 8,
"prompts": 5,
"groups": 20
@@ -25,7 +25,7 @@
"premium": 12,
"solids": 5,
"system": 7,
"templates": 3,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4
@@ -741,6 +741,20 @@
"readOnlyHint": true
}
},
{
"name": "drawing_template_save",
"group": "templates",
"cost": "mutate",
"summary": "Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.",
"tags": [
"drawing",
"template"
],
"annotations": {
"title": "Save As Template",
"destructiveHint": false
}
},
{
"name": "drawing_undo",
"group": "drawing",
+39 -1
View File
@@ -6855,7 +6855,7 @@ async def pid_tag_parse(
# ---------------------------------------------------------------------------
# ── SECTION 19: Page Setup & Templates (5 tools) ────────────────────────────
# ── SECTION 19: Page Setup & Templates (6 tools) ────────────────────────────
# ---------------------------------------------------------------------------
@@ -7044,6 +7044,44 @@ async def drawing_template_list(ctx: Context = None) -> dict:
}
@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=<path>)` 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.
Pack: core · lean: no (`drawing_save_as` is not lean either).
"""
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
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+45
View File
@@ -0,0 +1,45 @@
# 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
- `<name>.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.
- `<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
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`.
The live engine does not open that DXF blank: `Documents.Add` accepts only
a genuine DWT (measured on AutoCAD 2026), so the COM `drawing_new` converts
the DXF through `SaveAs(…, ac2018_Template)` once, caches the result under
the temp folder keyed on path, size and mtime, and reports
`template_dwt: {path, cached}`.
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.
+114
View File
@@ -10,6 +10,7 @@ import pytest
from fastmcp.exceptions import ToolError
import server
from backends.base import UnsupportedCapabilityError
from engineering.standards.templates import (
TEMPLATE_CATALOG,
TEMPLATES_DIR,
@@ -371,3 +372,116 @@ async def test_com_drawing_new_tool_reports_the_converted_dwt(com_new):
assert documents.calls[-1] == ("Add", template["dwt"])
else:
assert "dwt" not in template and documents.calls == [("Add", template["path"])]
# ── 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 == []
+5 -4
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (171 tools, 20 groups).
"""Frozen snapshot of the surface (172 tools, 20 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -523,7 +523,8 @@ async def test_tool_group_sizes_are_unchanged():
— all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above
`query`/`export`, so they file with the sheets they set up. `templates`
moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19
(tagged `template`, which ranks above `drawing`). Every other number
(tagged `template`, which ranks above `drawing`), then 3 -> 4 when
`drawing_template_save` joined. Every other number
here has been unchanged since the snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
@@ -544,9 +545,9 @@ async def test_tool_group_sizes_are_unchanged():
"premium": 12,
"solids": 5,
"system": 7,
"templates": 3,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 171
assert sum(sizes.values()) == 172