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:
co-authored by
Claude Opus 5
parent
86c5d6ad65
commit
a76d9e08a3
@@ -1,2 +1,4 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
templates/*.dxf text eol=crlf
|
||||
templates/*.dwt binary
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 .
|
||||
|
||||
|
||||
@@ -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
@@ -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=(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -138,6 +138,7 @@ only-include = [
|
||||
"backends",
|
||||
"engineering",
|
||||
"discovery",
|
||||
"templates",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user