feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name)

SECTION 19 grows to five tools with drawing_template_list; drawing_new takes a
bundled name (iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch)
or a .dwt/.dxf path and reports template: {name|path, path, source}.

scripts/build_templates.py builds templates/*.dxf through EzdxfBackend
(layer set, header DIM variables until group S's styles land, drawing_settings,
sheet tab, ISO 5457 frame on A3, page_setup_apply) and --check pins them
reproducible. One drift the plan's same-process measurement could not see is
fixed at the source rather than masked: ezdxf orders CLASSES by set iteration,
so two processes disagreed on LAYOUT / ACDBPLACEHOLDER; the section is re-keyed
(required list first, then by name) before the save.

The root .gitignore ignores *.dxf as generated output; templates/*.dxf is
un-ignored there, since the bundled set is the one DXF that is committed.

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:03:41 +03:00
co-authored by Claude Opus 5
parent 86c5d6ad65
commit a76d9e08a3
17 changed files with 19981 additions and 22 deletions
+2
View File
@@ -1,2 +1,4 @@
# Auto detect text files and perform LF normalization
* text=auto
templates/*.dxf text eol=crlf
templates/*.dwt binary
+3
View File
@@ -47,6 +47,9 @@ benchmarks/results/*
!benchmarks/results/published/**
benchmarks/.competitors/
*.dxf
# The bundled templates are the one committed DXF set: built by
# scripts/build_templates.py and pinned reproducible by its --check.
!templates/*.dxf
*.dwg
*.bak
+1
View File
@@ -9,6 +9,7 @@ COPY server.py config.py security.py version.py ./
COPY backends/ ./backends/
COPY engineering/ ./engineering/
COPY discovery/ ./discovery/
COPY templates/ ./templates/
RUN pip install --no-cache-dir -e .
+3 -2
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 170 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> **v1.5 release snapshot:** 171 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,6 +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` |
| 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 |
@@ -113,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>170 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>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>
**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.
+18 -1
View File
@@ -445,7 +445,13 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
),
"drawing_new": ToolAliases(
acad=("NEW", "QNEW"),
synonyms=("new drawing", "start a drawing", "blank sheet", "create a file"),
synonyms=(
"new drawing",
"start a drawing",
"blank sheet",
"create a file",
"start from a bundled template",
),
),
"drawing_open": ToolAliases(
acad=("OPEN",),
@@ -1445,6 +1451,17 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"plot the whole drawing set",
),
),
"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",
),
),
"page_setup_apply": ToolAliases(
acad=("PAGESETUP",),
synonyms=(
+18 -3
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 170,
"tools": 171,
"resources": 8,
"prompts": 5,
"groups": 20
@@ -25,7 +25,7 @@
"premium": 12,
"solids": 5,
"system": 7,
"templates": 2,
"templates": 3,
"transactions": 3,
"validation": 1,
"view": 4
@@ -596,7 +596,7 @@
"name": "drawing_new",
"group": "drawing",
"cost": "mutate",
"summary": "Start a blank drawing, pre-seeded with the standard engineering layers.",
"summary": "Start a drawing: blank with the engineering layers, or from a bundled/own template.",
"tags": [
"drawing"
],
@@ -726,6 +726,21 @@
"destructiveHint": false
}
},
{
"name": "drawing_template_list",
"group": "templates",
"cost": "read",
"summary": "List the five bundled drawing templates: standard, sheet, layers, styles, page setup.",
"tags": [
"drawing",
"query",
"template"
],
"annotations": {
"title": "List Drawing Templates",
"readOnlyHint": true
}
},
{
"name": "drawing_undo",
"group": "drawing",
+187
View File
@@ -0,0 +1,187 @@
"""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",
]
#: ``<repo>/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/<name>.dxf`` (headless) or
``templates/<name>.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"
)
+1
View File
@@ -138,6 +138,7 @@ only-include = [
"backends",
"engineering",
"discovery",
"templates",
]
[tool.ruff]
+280
View File
@@ -0,0 +1,280 @@
"""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. One more source of
drift is removed before the save rather than masked: the CLASSES section, which
ezdxf otherwise orders by set iteration (see ``_pin_class_order``).
"""
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)",
}
def _pin_class_order(doc) -> list[str]:
"""Put the CLASSES section in a stable order before the save.
ezdxf fills CLASSES from ``entitydb.dxf_types_in_use()`` — a ``set[str]``,
so its order follows the process's string-hash seed and two builds in
different processes put ``LAYOUT`` / ``ACDBPLACEHOLDER`` in different slots
(measured 2026-09-21: the same-process double build agreed, ``--check``
from a fresh process did not). The backend's undo baseline has already
written the document once by now, so the section is already populated in
that random order; it is re-keyed here as ezdxf's required list first, then
every other class by name.
"""
from ezdxf.sections.classes import REQ_R2004, REQUIRED_CLASSES
section = doc.classes
section.add_required_classes(doc.dxfversion)
for name in sorted(doc.entitydb.dxf_types_in_use()):
section.add_class(name)
rank = {name: i for i, name in enumerate(REQUIRED_CLASSES.get(doc.dxfversion, REQ_R2004))}
ordered = sorted(
section.classes.items(),
key=lambda item: (rank.get(item[1].dxf.name, len(rank)), item[1].dxf.name, item[0]),
)
section.classes = dict(ordered)
return [cls.dxf.name for cls in section.classes.values()]
async def build_template(spec: TemplateSpec, out_dir: Path) -> dict:
"""Build one template into ``out_dir/<name>.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; no unprintable band
True,
)
page_setup = await backend.page_setup_apply(spec.layout, setup)
await backend.layout_set_current("Model")
classes = _pin_class_order(backend._doc)
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"]},
"classes": classes,
}
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("<volatile>")
continue
stripped = line.strip()
if stripped in VOLATILE_HEADER_VARS:
mask_at = index + 2 # " 9" / "$TDCREATE" / " 40" / <value>
if _EZDXF_STAMP.match(stripped):
out.append("<ezdxf-stamp>")
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, strict=False)) 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())
+70 -11
View File
@@ -1082,7 +1082,7 @@ async def drawing_info(ctx: Context) -> dict:
@cad_tool(
summary="Start a blank drawing, pre-seeded with the standard engineering layers.",
summary="Start a drawing: blank with the engineering layers, or from a bundled/own template.",
cost="mutate",
)
@mcp.tool(
@@ -1090,7 +1090,11 @@ async def drawing_info(ctx: Context) -> dict:
tags={"drawing"},
)
async def drawing_new(
template: Annotated[str | None, "Optional path to .dwt template file"] = None,
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(
@@ -1101,19 +1105,43 @@ async def drawing_new(
] = True,
ctx: Context = None,
) -> dict:
"""Create a new empty drawing, optionally from a template (.dwt).
"""Create a new drawing, optionally from a template.
When ``bootstrap=True`` (default), the drawing is also seeded with the
standard engineering linetypes (CENTER/HIDDEN/PHANTOM) and layers
(GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK).
A bundled name resolves to `templates/<name>.dxf` headlessly and
`templates/<name>.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.
"""
if template is not None:
validated_template = validate_path(template, allow_write=False)
template = str(validated_template)
await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})")
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 (
@@ -6816,7 +6844,7 @@ async def pid_tag_parse(
# ---------------------------------------------------------------------------
# ── SECTION 19: Page Setup & Templates (4 tools) ────────────────────────────
# ── SECTION 19: Page Setup & Templates (5 tools) ────────────────────────────
# ---------------------------------------------------------------------------
@@ -6974,6 +7002,37 @@ async def batch_plot(
raise ToolError(str(exc)) from exc
@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=<name>)` 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,
}
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
"""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)
assert second # the second build is only there to be compared with the 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"
assert 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"
+7 -5
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 (170 tools, 20 groups).
"""Frozen snapshot of the surface (171 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
@@ -521,8 +521,10 @@ async def test_tool_group_sizes_are_unchanged():
`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. Every
other number here has been unchanged since the snapshot was taken.
`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
here has been unchanged since the snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -542,9 +544,9 @@ async def test_tool_group_sizes_are_unchanged():
"premium": 12,
"solids": 5,
"system": 7,
"templates": 2,
"templates": 3,
"transactions": 3,
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 170
assert sum(sizes.values()) == 171