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
+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())