merge: track-e group P

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-22 11:14:02 +03:00
co-authored by Claude Opus 5
28 changed files with 24378 additions and 46 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 .
+4 -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:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> **v1.5 release snapshot:** 185 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.
@@ -103,7 +103,8 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
| 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), `drawing_export_pdf(layout=…)` |
| 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`, `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 |
@@ -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>166 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.
+2
View File
@@ -30,6 +30,7 @@ from backends.contracts import (
LayerContract,
LayoutContract,
LinetypeContract,
PageSetupContract,
PremiumContract,
SettingsContract,
SolidContract,
@@ -169,6 +170,7 @@ class AutoCADBackend(
IdentityContract,
DrawingContract,
LayoutContract,
PageSetupContract,
SolidContract,
EntityCreationContract,
DimensionContract,
+545 -16
View File
@@ -14,6 +14,7 @@ import os
import sys
import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
@@ -170,6 +171,71 @@ def _acad_app():
return _COM_STATE["app"]
#: ``AcSaveAsType`` template constants, newest first: ac2018_Template,
#: ac2013_Template, ac2010_Template. A seat older than the first refuses it
#: with a COM error and the next one is tried.
_TEMPLATE_SAVE_FORMATS = (66, 62, 50)
def _template_cache_dir() -> Path:
import tempfile
return Path(tempfile.gettempdir()) / "acadmcp-templates"
def _template_as_dwt(app, source: Path) -> tuple[Path, bool]:
"""A real ``.dwt`` for ``source``, built once through AutoCAD and cached.
``Documents.Add(<file>)`` accepts only a genuine DWT: measured on AutoCAD
2026, ``Add(templates/iso_a3_mech.dxf)`` returned the default acadiso
drawing (layers ``['0']``, tabs ``Layout1``/``Layout2``) exactly as
``Add()`` and ``Add(<nonexistent.dwt>)`` did, and the DXF bytes renamed
``.dwt`` fared no better. Opening the file and saving it as a template
(``SaveAs(path, ac2018_Template)``) is what yields the eleven layers and
the ``A3`` tab, so that is done here — once per file content, keyed on the
path, size and mtime, under the temp folder — and the ``.dwt`` is what
``Add`` is handed. Returns ``(dwt_path, cached)``.
Must only be called from the COM executor thread. The conversion document
is opened read-only and closed without saving in a ``finally``; the
``.dwt`` on disk is the only thing it leaves behind.
"""
import hashlib
source = Path(source).resolve()
stat = source.stat()
digest = hashlib.sha1(
f"{source}|{stat.st_size}|{stat.st_mtime_ns}".encode("utf-8", "surrogateescape")
).hexdigest()[:12]
cache_dir = _template_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
dwt = cache_dir / f"{source.stem}-{digest}.dwt"
if dwt.is_file() and dwt.stat().st_size > 0:
return dwt, True
doc = app.Documents.Open(str(source), True)
try:
last_exc: Exception | None = None
for fmt in _TEMPLATE_SAVE_FORMATS:
try:
doc.SaveAs(str(dwt), fmt)
break
except _COM_ERROR as exc:
last_exc = exc
log.debug("SaveAs(%s, %d) refused: %s", dwt, fmt, exc)
else:
raise RuntimeError(
f"could not save {source.name} as a .dwt template: {last_exc}"
) from last_exc
finally:
try:
doc.Close(False)
except Exception as exc: # the temp document must never stay open
log.warning("template conversion document did not close: %s", exc)
if not dwt.is_file() or dwt.stat().st_size == 0:
raise RuntimeError(f"AutoCAD reported the template saved but {dwt} is not on disk")
return dwt, False
def _acad_doc():
"""Return active AutoCAD document."""
app = _acad_app()
@@ -1349,6 +1415,7 @@ class ComBackend(AutoCADBackend):
"refiner": FeatureCapability(True, "shared"),
"delivery": FeatureCapability(True, "shared"),
"paper_space": FeatureCapability(True, "native"),
"dwt_write": FeatureCapability(True, "native"),
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
@@ -1531,13 +1598,36 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def drawing_new(self, template: str | None = None) -> dict:
"""``Documents.Add()``, or ``Documents.Add(<dwt>)`` from a template file.
A template that is not already a ``.dwt`` (the bundled ``.dxf`` files,
an explicit ``.dxf`` path) is first converted into one through AutoCAD
itself — see ``_template_as_dwt`` for the measurement that makes this
necessary — and the result reports ``template_dwt: {path, cached}``.
A template path that is not a file is refused here rather than handed
to ``Add``, which would silently create the default drawing instead.
"""
def _sync():
app = _acad_app()
if template:
doc = app.Documents.Add(template)
else:
if not template:
doc = app.Documents.Add()
return {"ok": True, "name": doc.Name}
source = Path(template)
if not source.is_file():
raise FileNotFoundError(
f"template file not found: {template} (Documents.Add would silently "
"create the default drawing instead)"
)
result: dict[str, Any] = {"ok": True}
if source.suffix.lower() == ".dwt":
dwt = source
else:
dwt, cached = _template_as_dwt(app, source)
result["template_dwt"] = {"path": str(dwt), "cached": cached}
doc = app.Documents.Add(str(dwt))
result["name"] = doc.Name
return result
result = await self._run(_sync)
await self._ensure_document_state()
@@ -1567,8 +1657,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}
@@ -1579,23 +1671,73 @@ class ComBackend(AutoCADBackend):
return await self.drawing_save_as(path, "dxf")
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
"""Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file.
``layout=None`` means model space, as the tool advertises — the Model
tab is made current for the plot (and the previous tab restored), so
the row is never silently the sheet that happened to be active.
Measured on AutoCAD 2026 with the operator's default BACKGROUNDPLOT=2:
``PlotToFile`` returns True immediately, the job is queued to the
background plotter, and no file exists when the call comes back —
seconds later the folder was still empty, and a foreground plot
attempted after such a stranded job raised E_FAIL. So the plot is
forced to the foreground (``BACKGROUNDPLOT=0``, restored afterwards),
the method's boolean is honoured, and ``ok`` is reported only once the
file is on disk with a size.
"""
def _sync():
doc = _acad_doc()
previous = None
if layout:
target = layout.strip() if layout and layout.strip() else "Model"
previous = doc.ActiveLayout.Name
if previous != layout:
doc.ActiveLayout = doc.Layouts.Item(layout)
switched = False
if previous.lower() != target.lower():
doc.ActiveLayout = doc.Layouts.Item(target)
switched = True
old_background = None
try:
plot = doc.Plot
plot.PlotToFile(path, "DWG To PDF.pc3")
result = {"ok": True, "path": path}
if layout:
result["layout"] = layout
return result
try:
old_background = int(doc.GetVariable("BACKGROUNDPLOT"))
except Exception as exc:
log.debug("BACKGROUNDPLOT unreadable (%s); plotting as configured", exc)
if old_background not in (None, 0):
doc.SetVariable("BACKGROUNDPLOT", 0)
plotted = doc.Plot.PlotToFile(path, "DWG To PDF.pc3")
written = Path(path)
if not plotted:
return {
"ok": False,
"path": path,
"layout": target,
"error": "PlotToFile returned False: AutoCAD refused the plot "
"(check the layout's plot area and the 'DWG To PDF.pc3' device)",
}
if not written.is_file():
return {
"ok": False,
"path": path,
"layout": target,
"error": "PlotToFile reported success but wrote no file at "
f"{path} (BACKGROUNDPLOT was {old_background})",
}
return {
"ok": True,
"path": path,
"layout": target,
"bytes": written.stat().st_size,
}
finally:
if previous is not None and previous != layout:
if old_background not in (None, 0):
try:
doc.SetVariable("BACKGROUNDPLOT", old_background)
except Exception as exc:
log.warning("could not restore BACKGROUNDPLOT=%s: %s", old_background, exc)
if switched:
try:
doc.ActiveLayout = doc.Layouts.Item(previous)
except Exception as exc:
log.warning("could not restore the active layout %r: %s", previous, exc)
return await self._run(_sync)
@@ -2494,6 +2636,393 @@ class ComBackend(AutoCADBackend):
# CopyObjects-and-delete that would destroy and recreate geometry in the
# operator's open drawing when it goes wrong.
# ── page setup (track E) ─────────────────────────────────────────────────
#
# Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026
# typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0;
# AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16
# (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE).
# CenterPlot (ActiveX Reference, acadauto.chm): "This property cannot be
# set to True on a layout object whose PlotType property is set to
# acLayout" — so a layout plot never writes it (resolve_page_setup hands
# over None), an extents plot writes it *after* PlotType, and a stale True
# is cleared *before* PlotType moves to acLayout. Every write is journaled
# and unwound in reverse when AutoCAD refuses one, so a failed apply leaves
# the layout as it was — and the read-back after the unwind is compared to
# the read-back before the first write, so a value that did not go back is
# named rather than claimed restored.
#
# Two behaviours measured live (AutoCAD 2026, 2026-09-17) that the ActiveX
# Reference does not document:
# * GetPaperSize / GetPaperMargins answer in millimetres whatever
# PaperUnits says (ISO_A3 under PaperUnits=0 -> (420.0, 297.0); ANSI_B
# under 0 and 1 -> (431.8, 279.4) both times). They are never scaled.
# PaperUnits itself still governs what one paper-space unit means at
# the plot scale, so the row reports it (``paper_units``) and an apply
# that moves it says so in ``changed`` and ``warnings``.
# * Both answer in the *media's* frame (the PLOTSETTINGS fields the DXF
# row reads); under PlotRotation 1/3 the row turns size and margins
# together through ``papers.turned_sheet``, the one mapping the headless
# row and renderer use. The rotated read-back is not yet measured live
# (scripts/smoke_settings_com.py, Task 24, is where it gets measured).
# * Writing ConfigName replaces CanonicalMediaName with the new device's
# default when the current media does not exist on it, and writing the
# old device back does *not* bring the old media with it (DWG To PDF /
# ISO_A3 -> Microsoft Print to PDF -> 'psk:ISOA4' -> DWG To PDF ->
# 'ANSI_A_(11.00_x_8.50_Inches)'). The device write therefore journals
# one combined undo: device back, then the media / units / rotation
# snapshotted before the switch re-applied.
#
# Fake-tested in tests/test_page_setup.py (the fake enforces the CenterPlot
# rule and replays both measurements); executed live by
# 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
#: put back by its undo.
_DEVICE_SIDE_EFFECTS = ("CanonicalMediaName", "PaperUnits", "PlotRotation")
@staticmethod
def _com_page_setup_row(layout) -> dict:
from engineering.standards.papers import (
PLOT_TYPE_NAMES,
activex_scale_label,
center_applies,
paper_from_size,
paper_units_name,
scale_label,
turned_sheet,
)
def _get(attr):
try:
return getattr(layout, attr)
except Exception as exc:
log.debug("Layout.%s read failed: %s", attr, exc)
return None
# GetPaperSize / GetPaperMargins are millimetres regardless of
# PaperUnits (measured live; see the block comment above) — no factor.
size = None
try:
width, height = layout.GetPaperSize()
size = [round(float(width), 2), round(float(height), 2)]
except Exception as exc:
log.debug("Layout.GetPaperSize failed: %s", exc)
margins = None
try:
(left, bottom), (right, top) = layout.GetPaperMargins()
margins = [round(float(v), 3) for v in (top, bottom, left, right)]
except Exception as exc:
log.debug("Layout.GetPaperMargins failed: %s", exc)
rotation = _get("PlotRotation") or 0
if size and margins:
size, margins = turned_sheet(size[0], size[1], margins, rotation)
size = [round(v, 2) for v in size]
margins = [round(v, 3) for v in margins]
elif size and int(rotation) in (1, 3):
size = [size[1], size[0]]
if _get("UseStandardScale"):
scale = activex_scale_label(_get("StandardScale") or 0)
else:
try:
numerator, denominator = layout.GetCustomScale()
scale = scale_label(float(numerator), float(denominator))
except Exception as exc:
log.debug("Layout.GetCustomScale failed: %s", exc)
scale = None
media = _get("CanonicalMediaName")
plot_type = _get("PlotType")
return {
"layout": layout.Name,
"paper": (paper_from_size(*size) if size else None) or media,
"canonical_media_name": media,
"size_mm": size,
"orientation": (
None if not size else ("landscape" if size[0] >= size[1] else "portrait")
),
"plot_style": _get("StyleSheet"),
"scale": scale,
"plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type),
"device": _get("ConfigName"),
"margins_mm": margins,
"paper_units": paper_units_name(_get("PaperUnits")),
"center": bool(_get("CenterPlot")) if center_applies(plot_type) else None,
}
def _com_paper_layout(self, doc, raw: str, operation: str):
resolved = self._find_layout(doc, raw)
if resolved is None:
available = [n for n in self._layout_names(doc) if n != "Model"]
raise ValueError(
f"{operation}: layout {raw!r} not found (paper-space layouts: "
f"{', '.join(available) or 'none'})"
)
if resolved == "Model":
raise ValueError(
f"{operation}: Model space has no sheet; plot it with "
"drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
)
return doc.Layouts.Item(resolved)
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
def _sync():
doc = _acad_doc()
if layout is not None and self._layout_name(layout):
target = self._com_paper_layout(doc, layout, "page_setup_list")
return [self._com_page_setup_row(target)]
return [
self._com_page_setup_row(doc.Layouts.Item(name))
for name in self._layout_names(doc)
if name != "Model"
]
return await self._run(_sync)
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
from engineering.standards.papers import (
PAPER_UNITS,
page_setup_warnings,
require_page_setup,
)
resolved = require_page_setup(setup)
if resolved["margins_mm"] is not None:
raise ValueError(
"page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD "
"takes the printable margins from the plotter configuration (.pc3). Omit "
"margins_mm; the device's margins are read back in the result."
)
def _sync():
doc = _acad_doc()
target = self._com_paper_layout(doc, layout, "page_setup_apply")
before = self._com_page_setup_row(target)
# Journal of (undo callable, label) for every write that landed,
# unwound in reverse when a later one is refused.
journal: list[tuple[Callable[[], None], str]] = []
def _set(attr: str, value):
previous = getattr(target, attr)
setattr(target, attr, value)
journal.append((lambda: setattr(target, attr, previous), attr))
def _set_custom_scale(numerator: float, denominator: float):
previous = tuple(float(v) for v in target.GetCustomScale())
target.SetCustomScale(numerator, denominator)
journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale"))
def _set_device(device: str):
# A device switch moves the media (and can move units /
# rotation) as a side effect, and switching back does not
# move them back — so the undo re-applies what was there.
snapshot = {attr: getattr(target, attr) for attr in self._DEVICE_SIDE_EFFECTS}
previous = target.ConfigName
target.ConfigName = device
def _undo():
target.ConfigName = previous
target.RefreshPlotDeviceInfo()
for attr, value in snapshot.items():
if getattr(target, attr) != value:
setattr(target, attr, value)
journal.append((_undo, "ConfigName"))
step = "ConfigName"
try:
target.RefreshPlotDeviceInfo()
_set_device(resolved["device"])
target.RefreshPlotDeviceInfo()
media = resolved["canonical_media_name"]
names = [str(n) for n in (target.GetCanonicalMediaNames() or ())]
if media not in names:
nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]]
raise ValueError(
f"page_setup_apply: device {resolved['device']!r} has no media "
f"{media!r} (same paper on this device: "
f"{', '.join(nearest) or 'none'}; {len(names)} media in total)"
)
step = "CanonicalMediaName"
_set("CanonicalMediaName", media)
if resolved["paper_units"] is not None:
# None keeps the layout's units — what AutoCAD's own Page
# Setup does when a media is chosen.
step = "PaperUnits"
_set("PaperUnits", PAPER_UNITS[resolved["paper_units"]])
step = "PlotRotation"
_set("PlotRotation", self._AC_NO_ROTATION)
step = "StyleSheet"
_set("StyleSheet", resolved["plot_style"])
step = "PlotWithPlotStyles"
_set("PlotWithPlotStyles", True)
center = resolved["center"]
if center is None and self._com_center_plot_is_set(target):
# Clearing is legal under any PlotType; the layout plot
# then carries the flag AutoCAD stores for acLayout.
step = "CenterPlot"
_set("CenterPlot", False)
step = "PlotType"
_set("PlotType", int(resolved["plot_type"]))
code = resolved["activex_standard_scale"]
if code is not None:
step = "StandardScale"
_set("StandardScale", int(code))
step = "UseStandardScale"
_set("UseStandardScale", True)
else:
numerator, denominator = (float(v) for v in resolved["scale_ratio"])
step = "SetCustomScale"
_set_custom_scale(numerator, denominator)
step = "UseStandardScale"
_set("UseStandardScale", False)
if center is not None:
# Extents plot: PlotType is already acExtents, so True is legal.
step = "CenterPlot"
_set("CenterPlot", bool(center))
except Exception as exc:
failed = self._com_unwind_page_setup(journal)
# The unwind's own word is not evidence: read the layout back
# and name every field that is not what it was.
failed += [
key
for key, value in self._com_page_setup_row(target).items()
if key != "layout" and value != before.get(key) and key not in failed
]
if isinstance(exc, ValueError) and not failed:
raise # our own refusal (unknown media), nothing left changed
restored = (
f"layout {target.Name!r} restored to its previous page setup"
if not failed
else f"layout {target.Name!r} could NOT be fully restored "
f"(still changed: {', '.join(failed)})"
)
if isinstance(exc, ValueError):
raise ValueError(f"{exc}; {restored}") from exc
raise ValueError(
f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}"
) from exc
after = self._com_page_setup_row(target)
changed = {
key: [before[key], after[key]]
for key in after
if key != "layout" and before.get(key) != after[key]
}
return {
"ok": True,
"layout": target.Name,
"applied": dict(resolved),
"changed": changed,
"warnings": page_setup_warnings(changed),
"plot_style_known": bool(resolved["plot_style_known"]),
"viewports_kept": True,
"backend": "com",
}
return await self._run(_sync)
@staticmethod
def _com_center_plot_is_set(layout) -> bool:
"""Whether the layout carries CenterPlot=True (a failed read counts as no)."""
try:
return bool(layout.CenterPlot)
except Exception as exc:
log.debug("Layout.CenterPlot read failed: %s", exc)
return False
@staticmethod
def _com_unwind_page_setup(journal: list[tuple[Callable[[], None], str]]) -> list[str]:
"""Undo every journaled page-setup write in reverse; return what would not go back."""
failed: list[str] = []
for undo, label in reversed(journal):
try:
undo()
except Exception as exc:
log.warning("page_setup_apply: could not restore Layout.%s: %s", label, exc)
failed.append(label)
return failed
async def plot_style_list(self) -> list[dict]:
"""The ctb catalogue, marked against the files in AutoCAD's plot style path.
``Preferences.Files.PrinterStyleSheetPath`` is a ``;``-separated list of
folders. Every read is guarded: a folder that cannot be listed is
skipped, and when the Preferences object is unreachable the catalogue
rows come back with ``installed: None`` rather than a fabricated False.
"""
from engineering.standards.papers import CTB_CATALOG
def _sync():
rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
try:
raw = str(_acad_app().Preferences.Files.PrinterStyleSheetPath or "")
except Exception as exc:
log.debug("Preferences.Files.PrinterStyleSheetPath unreadable: %s", exc)
return rows
installed: dict[str, str] = {}
for folder in (part.strip() for part in raw.split(";")):
if not folder:
continue
try:
entries = sorted(Path(folder).iterdir())
except OSError as exc:
log.debug("plot style folder %r not listable: %s", folder, exc)
continue
for entry in entries:
if entry.suffix.lower() in (".ctb", ".stb"):
installed.setdefault(entry.name.lower(), entry.name)
catalog_lower = {row["name"].lower() for row in rows}
for row in rows:
row["installed"] = row["name"].lower() in installed
for key, name in installed.items():
if key not in catalog_lower:
rows.append({"name": name, "source": "installed", "installed": True})
return rows
return await self._run(_sync)
async def drawing_template_save(self, path, name=None, description=None) -> dict:
"""``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) ─
async def solid_box(
+2
View File
@@ -21,6 +21,7 @@ from backends.contracts.identity import IdentityContract
from backends.contracts.layers import LayerContract
from backends.contracts.layouts import LayoutContract
from backends.contracts.linetypes import LinetypeContract
from backends.contracts.pagesetup import PageSetupContract
from backends.contracts.premium import PremiumContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
@@ -43,6 +44,7 @@ __all__ = [
"LayerContract",
"LayoutContract",
"LinetypeContract",
"PageSetupContract",
"PremiumContract",
"SettingsContract",
"SolidContract",
+82
View File
@@ -0,0 +1,82 @@
"""Page setup, plot styles and templates (v1.6 track E, group P).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class PageSetupContract(ABC):
@abstractmethod
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
"""Page setup of one paper-space layout, or of every one.
Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h],
orientation, plot_style, scale, plot_area, device,
margins_mm: [top, bottom, left, right], paper_units, center}``.
``size_mm`` and ``margins_mm`` are the sheet *as plotted*: under
``plot_rotation`` 1/3 (AutoCAD's landscape-on-portrait-media
convention) both are turned together, through the one mapping the
renderer plots by (``papers.turned_sheet``), so the printable area a
row implies is the area the same engine plots. ``paper`` is the
catalogue name when the size matches one within 0.5 mm, otherwise the
media name as stored. ``paper_units`` (``"mm"`` | ``"inches"`` |
``"pixels"``) is what one paper-space unit means at the plot scale;
sizes and margins are millimetres whatever it says. ``center`` is a
bool for an extents plot and ``None`` for a layout plot, where
centring is not applicable (AutoCAD greys it out; ActiveX refuses
``CenterPlot = True`` under ``acLayout``). Raises ``ValueError`` for
an unknown layout.
``Model`` is never listed: model space is plotted through
``drawing_export_pdf(layout=None)`` and carries no sheet.
"""
...
@abstractmethod
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
"""Write a page setup resolved by
``engineering.standards.papers.resolve_page_setup`` to a layout.
Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]},
warnings: [str], plot_style_known, viewports_kept}``. ``changed`` is
computed from the read-back before and after (the rows
``page_setup_list`` reports), so re-applying the same setup reports
``{}``. The write resets ``plot_rotation`` to 0 (orientation rides on
the media's width/height); headlessly, with no ``margins_mm`` asked
for, the turned margins are re-written un-turned so the sheet keeps
the margins it had, and on the live engine (margins come from the
.pc3) the ones that moved show in ``changed``. ``setup["paper_units"]``
(``"mm"`` default, ``"inches"``, or ``None`` to keep the layout's
units as AutoCAD's own Page Setup does) is written when given; a move
rescales every paper-space unit by 25.4 at the plot scale and is named
in ``changed`` *and* ``warnings`` (``papers.page_setup_warnings``).
Refuses ``Model``, an unknown layout, and a ``setup`` missing a
resolved key, before anything is written. A write the engine refuses
part-way through is unwound (every landed value put back) before the
``ValueError`` names the refused property. Never deletes viewports.
"""
...
@abstractmethod
async def plot_style_list(self) -> list[dict]:
"""``[{name, source: "catalog"|"installed", installed: bool|None}]`` —
the authored ctb catalogue plus, on a live engine, the files present
in the plot style search path. ``installed`` is ``None`` where no
installation can be scanned."""
...
@abstractmethod
async def drawing_template_save(
self, path: str, name: str | None = None, description: str | None = None
) -> dict:
"""Save the current drawing as a template.
``.dxf`` writes on both engines; ``.dwt`` writes on the live engine
(``SaveAs(path, ac2018_Template)``) and raises
``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns
``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``.
"""
...
+442 -3
View File
@@ -178,6 +178,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"
@@ -1402,6 +1414,9 @@ class EzdxfBackend(AutoCADBackend):
"refiner": FeatureCapability(True, "shared"),
"delivery": FeatureCapability(True, "shared"),
"paper_space": FeatureCapability(True, "native"),
"dwt_write": FeatureCapability(
False, reason="dwt_is_a_dwg_container_requires_live_autocad"
),
# Measured, not assumed: a sheet holding nothing but a viewport
# renders the model geometry that viewport looks at, at its
# scale and position, clipped to its window. v1.4 declared this
@@ -1897,6 +1912,16 @@ class EzdxfBackend(AutoCADBackend):
the geometry that viewport shows, and geometry outside the window is
correctly left out. What is genuinely missing is the viewport *border*,
which the headless renderer does not draw.
``layout=None`` and ``layout="Model"`` both mean model space, as the
tool advertises and as the COM engine plots. This used to route them
through ``_msp()``, which follows the *current* tab, so with a sheet
current the "Model" PDF was that sheet's paper-space content — a wrong
page with no refusal. Measured: circle in model space, 390 x 270
rectangle on an A3 sheet, sheet current — the Model row's /MediaBox
was 176.1 x 121.9 mm (the rectangle's aspect) instead of the circle's
121.9 x 121.9. The target is now ``doc.modelspace()`` whichever tab
is current.
"""
def _sync():
@@ -1907,18 +1932,41 @@ class EzdxfBackend(AutoCADBackend):
return {"ok": False, "error": f"Layout not found: {layout}"}
target = doc.layouts.get(resolved)
else:
target = self._msp()
target = doc.modelspace()
try:
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
fig = _new_agg_figure()
# Track E: for a sheet, the figure *is* the paper. Sized from
# the layout's page setup and left unadjusted, so the PDF's
# /MediaBox is the paper size — the evidence page_setup_apply
# is judged by. Measured: adjust_figure=True gave 691.2 x 345.6
# pt whatever the sheet was.
paper = None
if resolved is not None and resolved != "Model":
paper = self._paper_frame(target)
fig = _new_agg_figure(figsize=paper["figsize"] if paper else None)
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
out = MatplotlibBackend(ax, adjust_figure=paper is None)
Frontend(ctx, out).draw_layout(target, finalize=True)
if paper is not None:
ax.set_xlim(*paper["xlim"])
ax.set_ylim(*paper["ylim"])
ax.set_aspect("equal", adjustable="box")
fig.savefig(path, dpi=150)
result = {"ok": True, "path": path}
if paper is not None:
result["paper_mm"] = paper["size_mm"]
result["scale"] = paper["scale"]
result["effective_scale"] = paper["effective_scale"]
result["scale_applied"] = paper["scale_applied"]
result["plot_area"] = paper["plot_area"]
result["plot_area_applied"] = paper["plot_area_applied"]
if "plot_area_note" in paper:
result["plot_area_note"] = paper["plot_area_note"]
if not paper["rotation_applied"]:
result["rotation_applied"] = False
if resolved is not None and resolved != "Model":
result["layout"] = resolved
result["note"] = (
@@ -2681,6 +2729,397 @@ class EzdxfBackend(AutoCADBackend):
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
return text or "0"
# ── page setup (track E) ─────────────────────────────────────────────────
def _paper_layout(self, raw: str, operation: str):
"""Resolve a paper-space layout by name, refusing Model and unknowns."""
doc = self._require_doc()
resolved = self._find_layout(raw)
if resolved is None:
available = [n for n in doc.layouts.names_in_taborder() if n != "Model"]
raise ValueError(
f"{operation}: layout {raw!r} not found (paper-space layouts: "
f"{', '.join(available) or 'none'})"
)
if resolved == "Model":
raise ValueError(
f"{operation}: Model space has no sheet; plot it with "
"drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
)
return doc.layouts.get(resolved)
@staticmethod
def _stored_sheet(dxf) -> tuple[list[float], list[float]]:
"""``(size_mm, margins_mm)`` of the sheet *as plotted*, from the media
as stored: ``papers.turned_sheet`` over PLOTSETTINGS 40-47 and
``plot_rotation``. DXF stores every paper size and margin in
millimetres, whatever ``plot_paper_units`` says (ezdxf's
acdb_plot_settings, verified). ``margins_mm`` is [top, bottom, left, right].
"""
from engineering.standards.papers import turned_sheet
return turned_sheet(
dxf.paper_width,
dxf.paper_height,
(dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin),
dxf.plot_rotation,
)
@classmethod
def _page_setup_row(cls, layout) -> dict:
from engineering.standards.papers import (
PLOT_TYPE_NAMES,
center_applies,
dxf_scale_label,
paper_from_size,
paper_units_name,
scale_label,
)
dxf = layout.dxf_layout.dxf
flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
# The sheet as plotted: size *and* margins turned by plot_rotation
# (the same mapping _paper_frame plots by). Swapping only the size
# reported a 297 x 210 sheet with its portrait margins.
(width, height), margins = cls._stored_sheet(dxf)
media = str(dxf.paper_size)
numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator)
if flags & layout.USE_STANDARD_SCALE:
scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator)
else:
scale = scale_label(numerator, denominator)
plot_type = int(dxf.plot_type)
return {
"layout": layout.name,
"paper": paper_from_size(width, height) or media,
"canonical_media_name": media,
"size_mm": [round(width, 2), round(height, 2)],
"orientation": "landscape" if width >= height else "portrait",
"plot_style": str(dxf.current_style_sheet),
"scale": scale,
"plot_area": PLOT_TYPE_NAMES.get(plot_type, str(dxf.plot_type)),
"device": str(dxf.plot_configuration_file),
"margins_mm": [round(v, 3) for v in margins],
# What one paper-space unit means at the plot scale (DXF group 72).
"paper_units": paper_units_name(dxf.plot_paper_units),
# A layout plot has no centring (AutoCAD greys it out; ActiveX
# refuses CenterPlot=True under acLayout): None, not a bit nobody honours.
"center": bool(flags & layout.PLOT_CENTERED) if center_applies(plot_type) else None,
}
@staticmethod
def _plot_scale(layout) -> tuple[str, float | None, float | None]:
"""``(label, numerator, denominator)`` of the layout's plot scale; the
terms are ``None`` for *scaled to fit* (code 75 = 0).
Reads the same bits ``_page_setup_row`` reports, so the label here is
the label ``page_setup_list`` shows: a standard code under
``USE_STANDARD_SCALE`` resolves through the authored ``DXF_STD_SCALES``
(the ObjectARX enum AutoCAD writes — ezdxf's ``STD_SCALES`` is one off
above 18 and would render an AutoCAD-authored 1:10 sheet at 1:16),
anything else through the stored numerator / denominator.
"""
from engineering.standards.papers import DXF_STD_SCALES, dxf_scale_label, scale_label
dxf = layout.dxf_layout.dxf
flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator)
if flags & layout.USE_STANDARD_SCALE:
code = int(dxf.standard_scale_type)
if code == 0:
return "fit", None, None
if code in DXF_STD_SCALES:
numerator, denominator = (float(v) for v in DXF_STD_SCALES[code])
return dxf_scale_label(code, numerator, denominator), numerator, denominator
return scale_label(numerator, denominator), numerator, denominator
@staticmethod
def _plot_extents(layout):
"""Paper-space extents AutoCAD's *extents* plot area means, or ``None``.
``ezdxf.bbox`` counts the main paper-space viewport (id 1, the whole
sheet) as an entity; AutoCAD does not, so it is left out here.
"""
from ezdxf import bbox
entities = [
e for e in layout if not (e.dxftype() == "VIEWPORT" and int(e.dxf.get("id", 0)) == 1)
]
if not entities:
return None
box = bbox.extents(entities, fast=True)
return box if box.has_data else None
@classmethod
def _paper_frame(cls, layout) -> dict:
"""The sheet as a matplotlib figure: size in inches plus the axes window.
Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper
and the content lands on it at the layout's plot scale.
**Scale.** The axes window spans the sheet in paper-space units, and
that is ``sheet_mm / unit x denominator / numerator`` — at 1:2 an A3
sheet holds 840 x 594 units, not 420 x 297. It used to be the sheet at
1:1 whatever ``page_setup_apply`` had written (measured: a 1:2 sheet
exported pixel-identical to the same sheet at 1:1, ``ok: True``, and
the MediaBox read as correct, so nothing downstream could see it).
``fit`` on a *layout* plot is 1:1: AutoCAD greys "Fit to paper" out
under that plot area (reported as ``effective_scale``).
**Plot area.** ``layout`` (5) is the sheet from the plot origin.
``extents`` (1) is the paper-space extents (the main viewport excluded,
as AutoCAD excludes it) placed at the plot origin, or centred on the
printable area under ``PLOT_CENTERED``; with ``fit`` the extents are
scaled to the printable area. The plot areas the headless engine cannot
know (display, view) and the ones it does not read (limits, window)
fall back to the sheet window and say so in ``plot_area_applied``.
``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media
convention: the media stays 210 x 297 and the sheet is turned, so
paper-space X runs along the 297 mm side. The figure *and* the axes
window both take the turned size — sizing only the figure left the
window 210 wide inside a 297 mm page, and ``set_aspect("equal")`` then
shrank the axes box to 0.707 and cut everything past x = 210 minus the
margin (measured: a 277 mm frame lost its right 27%, with ``ok: True``
and a MediaBox that read as correct). The margins turn with the sheet,
mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them —
``papers.turned_sheet``, the one mapping ``page_setup_list`` reports
by, so the row and the plot never disagree. What the headless renderer
still does not do is rotate the *content* on the media (reported as
``rotation_applied``, not hidden).
"""
from engineering.standards.papers import PLOT_TYPE_NAMES, scale_label
dxf = layout.dxf_layout.dxf
rotation = int(dxf.plot_rotation)
unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0
(sheet_w, sheet_h), (top, bottom, left, right) = cls._stored_sheet(dxf)
offset_x = float(dxf.plot_origin_x_offset)
offset_y = float(dxf.plot_origin_y_offset)
printable_w = max(sheet_w - left - right, 1e-9)
printable_h = max(sheet_h - top - bottom, 1e-9)
label, numerator, denominator = cls._plot_scale(layout)
plot_type = int(dxf.plot_type)
plot_area = PLOT_TYPE_NAMES.get(plot_type, str(plot_type))
flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
centered = bool(flags & layout.PLOT_CENTERED)
box = cls._plot_extents(layout) if plot_type == 1 else None
plot_area_applied = plot_type == 5 or box is not None
note = None
if plot_type == 1 and box is None:
note = "extents: the layout holds no entities, so the sheet window was plotted"
elif plot_type not in (1, 5):
note = (
f"plot area {plot_area!r} is not rendered headlessly; the sheet window was plotted"
)
# Paper-space units per millimetre of sheet.
scale_applied = True
effective = label
if (
numerator is not None
and denominator is not None
and (numerator <= 0 or denominator <= 0)
):
scale_applied, effective, units_per_mm = False, "1:1", 1.0 / unit
elif numerator is None: # scaled to fit
if box is not None:
ratios = [
printable_w / box.size.x if box.size.x > 1e-12 else None,
printable_h / box.size.y if box.size.y > 1e-12 else None,
]
usable = [r for r in ratios if r is not None]
mm_per_unit = min(usable) if usable else 1.0
units_per_mm = 1.0 / mm_per_unit
# ``unit`` is millimetres per paper unit (25.4 under inches),
# so paper units per drawing unit *divides* by it. Multiplying
# reported a 1:1.06 inch fit as ``607.06:1`` (off by 25.4^2)
# while the render itself was right, ``units_per_mm`` being
# unit-free; the mm sheet hid it because there ``unit`` is 1.
paper_per_unit = mm_per_unit / unit
effective = (
scale_label(paper_per_unit, 1.0)
if paper_per_unit >= 1.0
else scale_label(1.0, 1.0 / paper_per_unit)
)
else:
# Fit under a layout plot is not a choice AutoCAD offers: the
# Page Setup dialog greys it out and the sheet plots at 1:1.
units_per_mm, effective = 1.0 / unit, "1:1"
else:
units_per_mm = denominator / numerator / unit
# Lower-left corner of the *sheet* in paper-space units.
if box is None:
x0 = -(left + offset_x) * units_per_mm
y0 = -(bottom + offset_y) * units_per_mm
elif centered:
x0 = box.center.x - (left + printable_w / 2.0) * units_per_mm
y0 = box.center.y - (bottom + printable_h / 2.0) * units_per_mm
else:
x0 = box.extmin.x - (left + offset_x) * units_per_mm
y0 = box.extmin.y - (bottom + offset_y) * units_per_mm
frame = {
"size_mm": [round(sheet_w, 2), round(sheet_h, 2)],
"figsize": (sheet_w / 25.4, sheet_h / 25.4),
"xlim": (x0, x0 + sheet_w * units_per_mm),
"ylim": (y0, y0 + sheet_h * units_per_mm),
"rotation_applied": rotation == 0,
"scale": label,
"effective_scale": effective,
"scale_applied": scale_applied,
"plot_area": plot_area,
"plot_area_applied": plot_area_applied,
}
if note:
frame["plot_area_note"] = note
return frame
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
def _sync():
doc = self._require_doc()
if layout is not None and self._layout_name(layout):
return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))]
return [
self._page_setup_row(doc.layouts.get(name))
for name in doc.layouts.names_in_taborder()
if name != "Model"
]
return await self._async(_sync)
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
from engineering.standards.papers import (
PAPER_UNITS,
page_setup_warnings,
require_page_setup,
)
resolved = require_page_setup(setup)
def _sync():
target = self._paper_layout(layout, "page_setup_apply")
before = self._page_setup_row(target)
dxf = target.dxf_layout.dxf
# Measured: a fresh layout has no stored plot_layout_flags, and
# set_flag_state starts from 0 — toggling one bit would drop
# plot-styles (32), lineweights (128) and viewports-first (512).
if not dxf.hasattr("plot_layout_flags"):
dxf.plot_layout_flags = dxf.get_default("plot_layout_flags")
width, height = (float(v) for v in resolved["size_mm"])
# Orientation rides on width/height, as AutoCAD names media, so
# plot_rotation goes to 0. The stored margins only meant what
# they meant *through* the old turn: with none asked for, the
# sheet keeps the margins it had (the turned ones are written as
# the unturned ones), instead of silently swapping to the media's
# portrait margins with nothing in ``changed``.
margins = resolved["margins_mm"]
if margins is None:
margins = self._stored_sheet(dxf)[1] # turned, un-rounded
dxf.page_setup_name = ""
dxf.plot_configuration_file = resolved["device"]
dxf.paper_size = resolved["canonical_media_name"]
dxf.paper_width = width
dxf.paper_height = height
if resolved["paper_units"] is not None:
dxf.plot_paper_units = PAPER_UNITS[resolved["paper_units"]]
dxf.plot_rotation = 0
top, bottom, left, right = (float(v) for v in margins)
dxf.top_margin, dxf.bottom_margin = top, bottom
dxf.left_margin, dxf.right_margin = left, right
dxf.plot_origin_x_offset = 0.0
dxf.plot_origin_y_offset = 0.0
dxf.current_style_sheet = resolved["plot_style"]
target.use_plot_styles(True)
target.set_plot_type(int(resolved["plot_type"]))
# The scale is stored three ways and AutoCAD reads a different
# one per route (measured, AutoCAD 2026 — papers.py docstring):
# under USE_STANDARD_SCALE its plot engine scales by group 147
# alone (a sheet with code 21 and 147 = 1.0 plots 1:10 geometry
# at 1:1 while page_setup_list says 1:10); under the custom
# route it scales by 142/143. All three are written, on every
# route, from the one resolved ratio, so no reader can disagree
# with the plot. ``fit`` has ratio 1/1 and 147 = 1.0, as AutoCAD
# writes it.
code = resolved["dxf_standard_scale_type"]
numerator, denominator = (float(v) for v in resolved["scale_ratio"])
if code is not None:
dxf.standard_scale_type = int(code)
target.use_standard_scale(True)
else:
dxf.standard_scale_type = 16
target.use_standard_scale(False)
dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
dxf.unit_factor = numerator / denominator
# resolve_page_setup hands over None for a layout plot: the bit is
# cleared, never set — the state AutoCAD itself stores for acLayout,
# and the mirror of the live engine never writing CenterPlot there.
target.plot_centered(bool(resolved["center"]))
# Limits follow the paper; viewports are deliberately left alone
# (Paperspace.page_setup() would delete them).
target.reset_paper_limits()
self._mark_dirty()
after = self._page_setup_row(target)
changed = {
key: [before[key], after[key]]
for key in after
if key != "layout" and before.get(key) != after[key]
}
return {
"ok": True,
"layout": target.name,
"applied": dict(resolved),
"changed": changed,
"warnings": page_setup_warnings(changed),
"plot_style_known": bool(resolved["plot_style_known"]),
"viewports_kept": True,
"backend": "ezdxf",
}
return await self._async(_sync)
async def plot_style_list(self) -> list[dict]:
from engineering.standards.papers import CTB_CATALOG
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
async def drawing_template_save(self, path, name=None, description=None) -> dict:
"""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) ─────
@staticmethod
+81 -1
View File
@@ -78,6 +78,12 @@ class ToolAliases:
# destination for every one of them.
# DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and
# Custom (drawing_properties_get / drawing_properties_set).
# PAGESETUP -- the Page Setup Manager both shows and edits a sheet's setup
# (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(
@@ -92,8 +98,11 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"LAYOUT",
"MEASUREGEOM",
"MLEADERSTYLE",
"PAGESETUP",
"PLINE",
"PLOT",
"QSELECT",
"SAVEAS",
"SETVAR",
"STYLE",
"ZOOM",
@@ -451,7 +460,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",),
@@ -1592,6 +1607,71 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"active text style",
),
),
# ── Page setup & templates (track E) ────────────────────────────────────
"batch_plot": ToolAliases(
acad=("PUBLISH", "PLOT"),
synonyms=(
"print all sheets to pdf",
"plot every layout",
"publish the sheet set",
"batch print",
"one pdf per sheet",
"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",
),
),
"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=(
"page setup",
"set the paper size",
"a3 landscape",
"plot scale",
"monochrome ctb",
"which printer",
"set up the sheet for printing",
),
),
"page_setup_list": ToolAliases(
acad=("PAGESETUP",),
synonyms=(
"what paper is this sheet",
"page setup of each layout",
"plot settings",
"sheet size and orientation",
"which ctb is set",
),
),
"plot_style_list": ToolAliases(
acad=("STYLESMANAGER",),
synonyms=(
"ctb files",
"plot style table",
"pen assignments",
"monochrome or grayscale",
"which plot styles are installed",
),
),
}
+92 -4
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 179,
"tools": 185,
"resources": 8,
"prompts": 5,
"groups": 21
@@ -20,13 +20,13 @@
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layouts": 12,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
"styles": 10,
"system": 8,
"templates": 2,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4
@@ -176,6 +176,21 @@
"readOnlyHint": true
}
},
{
"name": "batch_plot",
"group": "layouts",
"cost": "mutate",
"summary": "Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
"tags": [
"export",
"layout",
"plot"
],
"annotations": {
"title": "Batch Plot",
"destructiveHint": false
}
},
{
"name": "block_create_from_entities",
"group": "blocks",
@@ -652,7 +667,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"
],
@@ -810,6 +825,35 @@
"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_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",
@@ -1844,6 +1888,35 @@
"readOnlyHint": true
}
},
{
"name": "page_setup_apply",
"group": "layouts",
"cost": "safe",
"summary": "Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
"tags": [
"layout",
"plot"
],
"annotations": {
"title": "Apply Page Setup",
"destructiveHint": false
}
},
{
"name": "page_setup_list",
"group": "layouts",
"cost": "read",
"summary": "Read each sheet's page setup: paper, orientation, ctb, scale, device.",
"tags": [
"layout",
"plot",
"query"
],
"annotations": {
"title": "List Page Setups",
"readOnlyHint": true
}
},
{
"name": "pid_equipment_list",
"group": "pid",
@@ -1971,6 +2044,21 @@
"readOnlyHint": true
}
},
{
"name": "plot_style_list",
"group": "layouts",
"cost": "read",
"summary": "List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.",
"tags": [
"layout",
"plot",
"query"
],
"annotations": {
"title": "List Plot Styles",
"readOnlyHint": true
}
},
{
"name": "point_from_snap",
"group": "premium",
+593
View File
@@ -0,0 +1,593 @@
"""Paper sizes, plot-style catalogue and plot scales — authored data.
Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as
AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*),
the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale
enum — one numbering, shared by the file and by ActiveX:
* DXF group code 75 (``standard_scale_type``) is the ObjectARX
``AcDbPlotSettings::StdScaleType`` enum: 0=fit, 1..15 the imperial
architectural scales, 16=1:1, 17=1:2, 18=1:4, 19=1:5, 20=1:8, 21=1:10,
22=1:16, 23=1:20, 24=1:30, 25=1:40, 26=1:50, 27=1:100, 28=2:1, 29=4:1,
30=8:1, 31=10:1, 32=100:1, 33=1000:1 (``DXF_STD_SCALES``). Measured live
on AutoCAD 2026 (COM ``SaveAs(..., 61)`` after ``StandardScale = n``):
1:10 saves as 21, 1:50 as 26, 2:1 as 28, 10:1 as 31. ezdxf's
``STD_SCALES`` table (and the DXF Reference page it follows) has no 19=1:5
and numbers everything above 18 one lower (20=1:10, 25=1:50, 30=10:1) —
reading an AutoCAD-authored 1:50 sheet through it reports 1:100, and a
1:10 sheet renders at 1:16 — so nothing here goes through that table.
* DXF group code 147 (``unit_factor`` in ezdxf; "a floating point scale
factor that represents the standard scale value specified in code 75" in
the DXF Reference — the Reference is right) is the factor AutoCAD's plot
engine *uses* under ``USE_STANDARD_SCALE`` (bit 16): AutoCAD writes it as
numerator / denominator (0.1 for 1:10, 2.0 for 2:1, 1.0 for fit and 1:1),
and a file with code 21 but 147 = 1.0 plots at 1:1 while claiming 1:10
(measured: a 100-unit line spans 99.99 mm of paper). Under the custom
route (bit 16 cleared) AutoCAD honours 142/143 and leaves 75 and 147
stale. The headless writer therefore writes 147 alongside 142/143 on
every route.
* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib)
is the same numbering (ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23,
ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0). The typelib
declares ``ac1_5=19`` too, but AutoCAD 2026 refuses
``Layout.StandardScale = 19`` with "Invalid input" in every condition tried
(PaperUnits mm and inches, UseStandardScale True and False; measured live
2026-09-17), so 1:5 is not in ``ACTIVEX_PLOT_SCALE`` and takes the custom
route on the live engine (``SetCustomScale(1, 5)`` reads back ``1:5``).
The headless writer mirrors that — 1:5 is not in ``DXF_STANDARD_SCALE_TYPE``
either, so both engines store the same 1:5 (custom, 142/143 = 1/5) — while
the reader knows code 19 for files AutoCAD wrote.
A scale without a code on an engine goes through the custom numerator /
denominator on that engine, and the read-back reports the same label either way.
Centring belongs to an *extents* plot only. The ActiveX Reference (CenterPlot
Property, acadauto.chm, AutoCAD 2026): "This property cannot be set to True on
a layout object whose PlotType property is set to acLayout" — a layout plot is
the whole sheet from its own origin, and the Page Setup dialog greys the box
out. ``resolve_page_setup`` therefore resolves ``center`` to ``None`` (not
applicable, never written, read back as ``None``) when ``plot_area`` is
``layout``; the bool is kept only for ``extents``. Both engines follow the
same resolved value, so the DXF never carries a centre bit AutoCAD would
ignore and the live engine never issues the write AutoCAD refuses.
"""
from __future__ import annotations
import math
import re
__all__ = [
"ACTIVEX_PLOT_SCALE",
"CTB_CATALOG",
"DXF_STANDARD_SCALE_TYPE",
"DXF_STD_SCALES",
"ORIENTATIONS",
"PAPER_INCHES",
"PAPER_SIZES",
"PAPER_UNITS",
"PAPER_UNITS_NAMES",
"PLOT_AREAS",
"PLOT_TYPE_LAYOUT",
"PLOT_TYPE_NAMES",
"REQUIRED_SETUP_KEYS",
"SCALES",
"activex_scale_label",
"canonical_media_name",
"center_applies",
"dxf_scale_label",
"page_setup_warnings",
"paper_from_size",
"paper_size_mm",
"paper_units_name",
"plot_style_known",
"require_page_setup",
"resolve_page_setup",
"scale_label",
"scale_ratio",
"turned_sheet",
]
#: Portrait width × height in millimetres. Landscape swaps.
PAPER_SIZES: dict[str, tuple[int, int]] = {
"ISO_A0": (841, 1189),
"ISO_A1": (594, 841),
"ISO_A2": (420, 594),
"ISO_A3": (297, 420),
"ISO_A4": (210, 297),
"ANSI_A": (216, 279),
"ANSI_B": (279, 432),
"ANSI_C": (432, 559),
"ANSI_D": (559, 864),
"ANSI_E": (864, 1118),
}
#: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3
#: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the
#: sheet measures. Portrait width × height.
PAPER_INCHES: dict[str, tuple[float, float]] = {
"ANSI_A": (8.5, 11.0),
"ANSI_B": (11.0, 17.0),
"ANSI_C": (17.0, 22.0),
"ANSI_D": (22.0, 34.0),
"ANSI_E": (34.0, 44.0),
}
#: Spellings resolve_page_setup accepts, mapped to the canonical key.
_PAPER_ALIASES: dict[str, str] = {
**{name.lower(): name for name in PAPER_SIZES},
**{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")},
**{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
**{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
}
#: Plot style tables AutoCAD ships (``<install>/…/Plot Styles``).
CTB_CATALOG: tuple[str, ...] = (
"monochrome.ctb",
"acad.ctb",
"Grayscale.ctb",
"Screening 100%.ctb",
"Screening 75%.ctb",
"Screening 50%.ctb",
"Screening 25%.ctb",
"Fill Patterns.ctb",
"DWF Virtual Pens.ctb",
)
#: Label → paper:drawing factor (``None`` = scaled to fit).
SCALES: dict[str, float | None] = {
"fit": None,
"1:1": 1.0,
"1:2": 0.5,
"1:5": 0.2,
"1:10": 0.1,
"1:20": 0.05,
"1:50": 0.02,
"1:100": 0.01,
"2:1": 2.0,
"5:1": 5.0,
"10:1": 10.0,
}
#: DXF group code 75 → (numerator, denominator): the ObjectARX
#: ``AcDbPlotSettings::StdScaleType`` enum, which is what AutoCAD writes and
#: reads (module docstring — not ezdxf's ``STD_SCALES``, which is one off
#: above 18). Code 0 is *scaled to fit* and has no ratio.
DXF_STD_SCALES: dict[int, tuple[float, float]] = {
1: (1.0 / 128.0, 12.0),
2: (1.0 / 64.0, 12.0),
3: (1.0 / 32.0, 12.0),
4: (1.0 / 16.0, 12.0),
5: (3.0 / 32.0, 12.0),
6: (1.0 / 8.0, 12.0),
7: (3.0 / 16.0, 12.0),
8: (1.0 / 4.0, 12.0),
9: (3.0 / 8.0, 12.0),
10: (1.0 / 2.0, 12.0),
11: (3.0 / 4.0, 12.0),
12: (1.0, 12.0),
13: (3.0, 12.0),
14: (6.0, 12.0),
15: (12.0, 12.0),
16: (1.0, 1.0),
17: (1.0, 2.0),
18: (1.0, 4.0),
19: (1.0, 5.0),
20: (1.0, 8.0),
21: (1.0, 10.0),
22: (1.0, 16.0),
23: (1.0, 20.0),
24: (1.0, 30.0),
25: (1.0, 40.0),
26: (1.0, 50.0),
27: (1.0, 100.0),
28: (2.0, 1.0),
29: (4.0, 1.0),
30: (8.0, 1.0),
31: (10.0, 1.0),
32: (100.0, 1.0),
33: (1000.0, 1.0),
}
#: AutoCAD's names for the imperial codes (inches of paper = feet of drawing).
_DXF_IMPERIAL_LABELS: dict[int, str] = {
1: "1/128\"=1'",
2: "1/64\"=1'",
3: "1/32\"=1'",
4: "1/16\"=1'",
5: "3/32\"=1'",
6: "1/8\"=1'",
7: "3/16\"=1'",
8: "1/4\"=1'",
9: "3/8\"=1'",
10: "1/2\"=1'",
11: "3/4\"=1'",
12: "1\"=1'",
13: "3\"=1'",
14: "6\"=1'",
15: "1'=1'",
}
#: DXF group code 75 the headless *writer* uses per catalogue label. Labels
#: missing here use the custom numerator/denominator: ``1:5`` (code 19 exists
#: in the file enum, but the live engine cannot set it, so both engines store
#: 1:5 the same custom way) and ``5:1`` (no code anywhere).
DXF_STANDARD_SCALE_TYPE: dict[str, int] = {
"fit": 0,
"1:1": 16,
"1:2": 17,
"1:10": 21,
"1:20": 23,
"1:50": 26,
"1:100": 27,
"2:1": 28,
"10:1": 31,
}
#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale.
#: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring).
ACTIVEX_PLOT_SCALE: dict[str, int] = {
"fit": 0,
"1:1": 16,
"1:2": 17,
"1:10": 21,
"1:20": 23,
"1:50": 26,
"1:100": 27,
"2:1": 28,
"10:1": 31,
}
_ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items()}
#: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5).
PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1}
#: ``AcPlotType.acLayout`` — the plot type under which centring does not apply.
PLOT_TYPE_LAYOUT = 5
PLOT_TYPE_NAMES: dict[int, str] = {
0: "display",
1: "extents",
2: "limits",
3: "view",
4: "window",
5: "layout",
}
ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait")
_SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")
def _paper_key(paper) -> str:
key = str(paper or "").strip().lower().replace(" ", "_")
resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " "))
if resolved is None:
raise ValueError(
f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} "
"(short forms A0–A4 and ANSI_A–ANSI_E are accepted)"
)
return resolved
def _orientation(orientation) -> str:
if orientation not in ORIENTATIONS:
raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}")
return orientation
def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]:
"""(width, height) in millimetres for ``paper`` in ``orientation``."""
name = _paper_key(paper)
short, long = PAPER_SIZES[name]
if _orientation(orientation) == "landscape":
return float(long), float(short)
return float(short), float(long)
def canonical_media_name(paper: str, orientation: str = "landscape") -> str:
"""AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for
landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B."""
name = _paper_key(paper)
if name in PAPER_INCHES:
short, long = PAPER_INCHES[name]
unit = "Inches"
else:
short, long = PAPER_SIZES[name]
unit = "MM"
width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long)
return f"{name}_({width:.2f}_x_{height:.2f}_{unit})"
def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None:
"""The catalogue paper whose size (either orientation) is within ``tolerance`` mm."""
try:
w, h = float(width_mm), float(height_mm)
except (TypeError, ValueError):
return None
for name, (short, long) in PAPER_SIZES.items():
if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or (
abs(w - short) <= tolerance and abs(h - long) <= tolerance
):
return name
return None
def scale_ratio(label: str) -> tuple[float, float]:
"""``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio."""
match = _SCALE_RE.match(str(label or ""))
if match is None:
raise ValueError(f"scale: {label!r} is not an N:M ratio")
numerator, denominator = float(match.group(1)), float(match.group(2))
if numerator <= 0 or denominator <= 0:
raise ValueError(f"scale: {label!r} must have positive terms")
return numerator, denominator
def scale_label(numerator, denominator) -> str:
"""The ``N:M`` label for a ratio: a catalogue label when the factor matches
one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats."""
num, den = float(numerator), float(denominator)
if num <= 0 or den <= 0:
return f"{num:g}:{den:g}"
for label, factor in SCALES.items():
if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9):
return label
if num.is_integer() and den.is_integer():
divisor = math.gcd(int(num), int(den))
return f"{int(num) // divisor}:{int(den) // divisor}"
return f"{num:g}:{den:g}"
_DXF_SCALE_LABEL: dict[int, str] = {
0: "fit",
**_DXF_IMPERIAL_LABELS,
**{
code: scale_label(num, den)
for code, (num, den) in DXF_STD_SCALES.items()
if code not in _DXF_IMPERIAL_LABELS
},
}
def dxf_scale_label(code, numerator, denominator) -> str:
"""Read-back for the headless engine: the code-75 label (``fit`` for 0,
the ratio ``DXF_STD_SCALES`` gives the code, an AutoCAD-style name for an
imperial code), else the custom ratio."""
label = _DXF_SCALE_LABEL.get(int(code))
if label is not None:
return label
return scale_label(numerator, denominator)
def activex_scale_label(code) -> str:
"""Read-back for the live engine: the AcPlotScale label, else ``standard:<code>``."""
return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}")
def plot_style_known(name) -> bool:
return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG}
#: Plot paper units — the same codes in DXF group 72 (``plot_paper_units``) and
#: ActiveX ``AcPlotPaperUnits`` (acInches=0, acMillimeters=1, acPixels=2). The
#: paper size and margins are stored in millimetres whatever this says (ezdxf's
#: acdb_plot_settings; GetPaperSize / GetPaperMargins measured live): the units
#: govern what one paper-space unit *means* at the plot scale — under inches a
#: 1:1 sheet holds 17 units across ANSI B, under millimetres 431.8.
PAPER_UNITS: dict[str, int] = {"inches": 0, "mm": 1}
PAPER_UNITS_NAMES: dict[int, str] = {0: "inches", 1: "mm", 2: "pixels"}
_PAPER_UNITS_ALIASES = {"in": "inches", "inch": "inches", "inches": "inches", "mm": "mm"}
def paper_units_name(code) -> str | None:
"""Read-back for both engines: ``inches`` / ``mm`` / ``pixels``, ``None``
for an unreadable value, ``code:<n>`` for one neither enum names."""
if code is None:
return None
try:
value = int(code)
except (TypeError, ValueError):
return None
return PAPER_UNITS_NAMES.get(value, f"code:{value}")
def turned_sheet(width_mm, height_mm, margins_mm, rotation) -> tuple[list[float], list[float]]:
"""The sheet as plotted from the media as stored: ``(size_mm, margins_mm)``.
``margins_mm`` in and out is ``[top, bottom, left, right]`` — the row's
order. ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media
convention (media 210 x 297, sheet 297 x 210): the size swaps *and* the
margins turn with it, mapped the way ezdxf's ``Page.from_dxf_layout``
maps them (rotation 1: top<-right, right<-bottom, bottom<-left, left<-top;
2: opposite sides; 3: the inverse of 1). Every read-back and the headless
renderer go through this one function, so a row's printable area is the
area the same engine plots — swapping only the size reported a 297 x 210
sheet with [20, 20, 7.5, 7.5] (282 x 170 printable) whose true margins
were [7.5, 7.5, 20, 20] (257 x 195).
"""
width, height = float(width_mm), float(height_mm)
top, bottom, left, right = (float(v) for v in margins_mm)
turn = int(rotation or 0)
if turn == 1:
width, height = height, width
top, right, bottom, left = right, bottom, left, top
elif turn == 2:
top, right, bottom, left = bottom, left, top, right
elif turn == 3:
width, height = height, width
top, right, bottom, left = left, top, right, bottom
return [width, height], [top, bottom, left, right]
def page_setup_warnings(changed: dict) -> list[str]:
"""What a ``page_setup_apply`` moved that ``changed`` alone under-states.
A paper-units move is a 25.4x rescale of every paper-space unit at the
plot scale (a 16-unit frame on an inch sheet spans 406.4 mm of paper;
after a move to millimetres, 16 mm) with ``ok: True`` and the scale
reported as applied — so it is named here, on both engines, with the
way back. Both engines build the same list from the same ``changed``.
"""
warnings: list[str] = []
move = changed.get("paper_units")
if move and move[0] is not None and move[0] != move[1]:
old, new = move
if (old, new) == ("inches", "mm"):
effect = "plots 25.4x smaller"
elif (old, new) == ("mm", "inches"):
effect = "plots 25.4x larger"
else:
effect = "plots at a different size"
warnings.append(
f"paper units moved from {old} to {new}: one paper-space unit now plots as one "
f"{new} at the plot scale, so paper-space geometry authored in {old} {effect} "
f"at the same scale; pass paper_units=None to keep a layout's units or "
f"paper_units={old!r} to put them back"
)
return warnings
def center_applies(plot_type) -> bool:
"""Whether "centre the plot" means anything under this ``AcPlotType``.
False for ``acLayout`` (5): the sheet is plotted from its origin, AutoCAD
greys the option out and ActiveX refuses ``CenterPlot = True``. Read-backs
report ``center: None`` there instead of a stored flag nothing honours.
"""
try:
return int(plot_type) != PLOT_TYPE_LAYOUT
except (TypeError, ValueError):
return False
def resolve_page_setup(
paper: str,
orientation: str = "landscape",
plot_style: str = "monochrome.ctb",
scale: str = "fit",
plot_area: str = "layout",
device: str = "DWG To PDF.pc3",
margins_mm=None,
center: bool = True,
paper_units: str | None = "mm",
) -> dict:
"""Validate a page setup request and resolve it to what both engines write.
Raises ``ValueError`` naming the offending field; nothing here touches a
drawing. ``margins_mm`` is ``[top, bottom, left, right]``. ``center`` is
validated as a bool and resolved to ``None`` for ``plot_area="layout"``,
where centring is not applicable (see the module docstring) — the default
call (layout, centre) is therefore a layout plot with no centring write.
``paper_units`` is what one paper-space unit means at the plot scale:
``"mm"`` (the default — this server draws in millimetres), ``"inches"``,
or ``None`` to keep the layout's current units, which is what AutoCAD's
own Page Setup does when a media is chosen. A write that moves the units
rescales every paper-space unit by 25.4; both engines report it in
``changed`` and ``warnings``.
"""
name = _paper_key(paper)
width, height = paper_size_mm(name, _orientation(orientation))
style = str(plot_style or "").strip()
if not style:
raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)")
label = str(scale or "").strip()
if label not in SCALES:
raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}")
ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label)
if plot_area not in PLOT_AREAS:
raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}")
device_name = str(device or "").strip()
if not device_name:
raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')")
margins = None
if margins_mm is not None:
try:
values = [float(v) for v in margins_mm]
except (TypeError, ValueError):
raise ValueError(
"margins_mm: must be four numbers [top, bottom, left, right]"
) from None
if len(values) != 4:
raise ValueError(
f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}"
)
if any(v < 0 for v in values):
raise ValueError("margins_mm: margins cannot be negative")
top, bottom, left, right = values
if top + bottom >= height or left + right >= width:
raise ValueError(
f"margins_mm: {values} leave no printable area on a {width:g} x {height:g} mm sheet"
)
margins = values
if not isinstance(center, bool):
raise ValueError(f"center: must be true or false, got {center!r}")
plot_type = PLOT_AREAS[plot_area]
units = None
if paper_units is not None:
units = _PAPER_UNITS_ALIASES.get(str(paper_units).strip().lower())
if units is None:
raise ValueError(
f"paper_units: must be 'mm', 'inches' or None (keep the layout's units), "
f"got {paper_units!r}"
)
return {
"paper": name,
"orientation": orientation,
"size_mm": [width, height],
"canonical_media_name": canonical_media_name(name, orientation),
"plot_style": style,
"plot_style_known": plot_style_known(style),
"scale": label,
"scale_factor": SCALES[label],
"scale_ratio": [ratio[0], ratio[1]],
"dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label),
"activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label),
"plot_area": plot_area,
"plot_type": plot_type,
"device": device_name,
"margins_mm": margins,
"center": center if center_applies(plot_type) else None,
"paper_units": units,
}
#: Every key resolve_page_setup emits that a backend reads. A backend refuses
#: a dict missing any of them before it writes.
REQUIRED_SETUP_KEYS: tuple[str, ...] = (
"paper",
"orientation",
"size_mm",
"canonical_media_name",
"plot_style",
"plot_style_known",
"scale",
"scale_ratio",
"dxf_standard_scale_type",
"activex_standard_scale",
"plot_area",
"plot_type",
"device",
"margins_mm",
"center",
"paper_units",
)
def require_page_setup(setup) -> dict:
"""The backend-side check: ``setup`` is a complete resolve_page_setup dict."""
if not isinstance(setup, dict):
raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns")
missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup]
if missing:
raise ValueError(
f"page_setup_apply: setup is missing {', '.join(missing)} — build it with "
"engineering.standards.papers.resolve_page_setup"
)
return setup
+59
View File
@@ -0,0 +1,59 @@
"""Read a PDF's page size back from the file — no dependency, no rendering.
The evidence rule for page setup (spec §8.3): the sheet size a plot produced is
what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the
one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both
write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a
FlateDecode object stream, so when the plain scan finds nothing every stream is
inflated and searched. Anything else raises rather than guessing.
"""
from __future__ import annotations
import re
import zlib
from pathlib import Path
__all__ = ["PT_TO_MM", "read_mediabox"]
#: PostScript points to millimetres.
PT_TO_MM = 25.4 / 72.0
_NUMBER = rb"([-+]?\d*\.?\d+)"
_MEDIABOX_RE = re.compile(
rb"/MediaBox\s*\[\s*"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s*\]"
)
_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
def read_mediabox(path: str) -> tuple[float, float]:
"""Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``.
Raises ``ValueError`` when the file is not a PDF or carries no MediaBox.
``/Rotate`` is not applied: the box is reported as written.
"""
data = Path(path).read_bytes()
if not data.startswith(b"%PDF"):
raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)")
match = _MEDIABOX_RE.search(data)
if match is None:
for stream in _STREAM_RE.finditer(data):
try:
inflated = zlib.decompress(stream.group(1))
except zlib.error:
continue
match = _MEDIABOX_RE.search(inflated)
if match is not None:
break
if match is None:
raise ValueError(f"read_mediabox: no /MediaBox found in {path}")
x0, y0, x1, y1 = (float(value) for value in match.groups())
return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3))
+147
View File
@@ -0,0 +1,147 @@
"""batch_plot: every sheet through drawing_export_pdf, each PDF measured back.
Composes the existing per-layout export; the only thing it adds is the
evidence — ``mediabox_mm`` is parsed from the file each sheet produced, so a
caller sees the size that was plotted, not the size that was asked for.
"""
from __future__ import annotations
import re
from pathlib import Path
from engineering.standards.papers import paper_from_size
from engineering.standards.pdfinfo import read_mediabox
__all__ = ["DEFAULT_FILE_PATTERN", "batch_plot", "safe_filename"]
DEFAULT_FILE_PATTERN = "{drawing}-{layout}.pdf"
_UNSAFE = re.compile(r"[<>:\"/\\|?*\s]+")
def safe_filename(text: str) -> str:
"""A layout or drawing name as a filename fragment: no separators, no
reserved characters, runs of them collapsed to one underscore."""
cleaned = _UNSAFE.sub("_", str(text or "").strip()).strip("_")
return cleaned or "untitled"
async def batch_plot(
backend,
layouts: list[str] | None,
output_dir: str,
file_pattern: str = DEFAULT_FILE_PATTERN,
) -> dict:
"""Plot layouts to PDF and read each sheet size back from the file.
``layouts=None`` plots every paper-space layout; ``Model`` is plotted only
when named, and is passed to the engine by name so the model row is model
space whichever tab is current — both engines honour the name (COM plots
the Model tab; ezdxf renders ``doc.modelspace()``, not the current tab;
``tests/test_batch_plot.py`` proves it by content on the headless engine).
A sheet the engine cannot plot, or reports
plotted without a file, is a per-row ``ok: False`` with its ``error``; the
rows before it stay. Refuses (``ValueError``, before any PDF is written): an empty
list, a layout that does not exist, a pattern with a path separator, and
a pattern without ``{layout}`` when more than one sheet would be written.
The output folder is created, then every output path goes through
``security.validate_path(allow_write=True)`` before the first export.
"""
from security import validate_path
listing = await backend.layout_list()
available = [str(name) for name in (listing.get("layouts") or [])]
lowered = {name.lower(): name for name in available}
if layouts is None:
targets = [name for name in available if name.lower() != "model"]
if not targets:
raise ValueError("batch_plot: the drawing has no paper-space layout to plot")
else:
requested = [str(name).strip() for name in layouts]
if not requested:
raise ValueError("batch_plot: layouts is empty — pass null for every sheet")
unknown = [name for name in requested if name.lower() not in lowered]
if unknown:
raise ValueError(
f"batch_plot: layout(s) not found: {', '.join(unknown)} "
f"(available: {', '.join(available)})"
)
targets = [lowered[name.lower()] for name in requested]
pattern = str(file_pattern or DEFAULT_FILE_PATTERN)
if "/" in pattern or "\\" in pattern:
raise ValueError("batch_plot: file_pattern must be a file name, not a path")
if "{layout}" not in pattern and len(targets) > 1:
raise ValueError(
"batch_plot: file_pattern needs {layout} when more than one sheet is plotted, "
"or every sheet would overwrite the last"
)
try:
pattern.format(drawing="x", layout="y")
except (KeyError, IndexError, ValueError) as exc:
raise ValueError(
f"batch_plot: file_pattern {pattern!r} may only use {{drawing}} and {{layout}}"
) from exc
info = await backend.drawing_info()
drawing = safe_filename(Path(info.name).stem)
destination = Path(validate_path(output_dir, allow_write=True))
planned = [
(name, destination / pattern.format(drawing=drawing, layout=safe_filename(name)))
for name in targets
]
# validate_path(allow_write=True) requires the parent folder to exist, so
# the output folder is created before the per-file fence check; an empty
# folder is the most a refused call leaves behind.
destination.mkdir(parents=True, exist_ok=True)
for _name, path in planned:
validate_path(str(path), allow_write=True)
sheets: list[dict] = []
for name, path in planned:
is_model = name.lower() == "model"
# The layout is always named — "Model" included — so the engine plots
# the tab this row is labelled with, never the one that happens to be
# current. A plot that fails inside the engine (COM raises RuntimeError
# for an AutoCAD error) or that reports ok without leaving a file is a
# failed row, not a lost batch: the sheets already plotted are kept.
try:
result = await backend.drawing_export_pdf(str(path), layout=name)
except RuntimeError as exc:
sheets.append({"layout": name, "ok": False, "path": str(path), "error": str(exc)})
continue
if not result.get("ok", False):
sheets.append(
{"layout": name, "ok": False, "path": str(path), "error": result.get("error")}
)
continue
if not path.is_file():
sheets.append(
{
"layout": name,
"ok": False,
"path": str(path),
"error": "the engine reported the plot ok but no file was written",
}
)
continue
row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size}
try:
width, height = read_mediabox(str(path))
row["mediabox_mm"] = [width, height]
row["paper"] = None if is_model else paper_from_size(width, height)
except ValueError as exc:
row["mediabox_mm"] = None
row["paper"] = None
row["error"] = str(exc)
sheets.append(row)
return {
"ok": all(row["ok"] for row in sheets),
"drawing": drawing,
"output_dir": str(destination),
"format": "pdf",
"sheets": sheets,
"count": len(sheets),
}
+190
View File
@@ -0,0 +1,190 @@
"""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 — the live
engine then converts that DXF into a real ``.dwt`` itself before
``Documents.Add`` (``ComBackend.drawing_new``), because AutoCAD ignores a
DXF handed to ``Add`` and silently returns the default drawing. 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]
+289
View File
@@ -0,0 +1,289 @@
"""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 twice — as header DIM variables, which
is what a live seat dimensions with, *and* onto the ``Standard`` dimstyle
entry, which is what the headless renderer reads (it never consults the
header; ``_with_header_dimvars`` folds six of the variables, so the other
eight — DIMTAD, DIMTIH, DIMTOH, DIMEXE, DIMEXO, DIMGAP, DIMLUNIT, DIMLWD/E —
were a header-only no-op: measured, ``ansi_b_mech`` dimensioned with ISO text
placement headlessly while AutoCAD honoured the header). Both engines now
dimension the same template the same way; 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 = {}
standard = backend._doc.dimstyles.get("Standard") # what the headless renderer reads
for variable, value in _FALLBACK_DIMVARS[spec.dimstyle_preset].items():
applied[variable] = (await backend.system_set_variable(variable, value))["ok"]
standard.dxf.set(variable.lower(), value)
return {
"mode": "header_dimvars",
"applied": applied,
"dimstyle": standard.dxf.name,
"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())
+277 -10
View File
@@ -1090,7 +1090,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(
@@ -1098,7 +1098,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(
@@ -1109,19 +1113,54 @@ 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}`.
On the live engine only a genuine `.dwt` reaches `Documents.Add` —
measured on AutoCAD 2026, `Add(<file.dxf>)` silently returns the default
drawing — so a `.dxf` template (bundled or a path) is first converted
through AutoCAD into a cached `.dwt` and `template.dwt` names it
(`template.dwt_cached` says whether the conversion was reused). The
drawing then really carries the template's layers, tabs and page setup.
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}
converted = result.pop("template_dwt", None)
if template_info is not None:
if converted: # the live engine built a .dwt from the .dxf (see ComBackend.drawing_new)
template_info["dwt"] = converted["path"]
template_info["dwt_cached"] = bool(converted.get("cached"))
result["template"] = template_info
if bootstrap:
try:
from engineering import (
@@ -7230,6 +7269,234 @@ async def drawing_apply_standard(
return await apply_standard(_backend(ctx), standard, layers, units)
# ---------------------------------------------------------------------------
# ── SECTION 19: Page Setup & Templates (6 tools) ────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Read each sheet's page setup: paper, orientation, ctb, scale, device.",
cost="read",
)
@mcp.tool(
annotations={"title": "List Page Setups", "readOnlyHint": True},
tags={"layout", "plot", "query"},
)
async def page_setup_list(
layout: Annotated[
str | None, "One paper-space layout, or omit for every sheet (Model is never listed)."
] = None,
ctx: Context = None,
) -> dict:
"""The page setup stored on each paper-space layout.
`paper` is the catalogue name (ISO_A3, ANSI_B, …) when the stored size
matches one within 0.5 mm, otherwise the media name as stored; `size_mm`,
`orientation`, `plot_style` (ctb), `scale` ("fit", "1:50", or a custom
ratio), `plot_area`, `device`, `margins_mm` [top, bottom, left, right] and
`center` are read back from the LAYOUT object (headless) or the AutoCAD
Layout (live). Refuses an unknown layout name and `Model` (model space has
no sheet; plot it with `drawing_export_pdf(layout=None)`).
"""
try:
rows = await _backend(ctx).page_setup_list(layout)
except ValueError as exc:
raise ToolError(str(exc)) from exc
return {"ok": True, "layouts": rows, "count": len(rows)}
@cad_tool(
summary="Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
cost="safe",
)
@mcp.tool(
annotations={"title": "Apply Page Setup", "destructiveHint": False},
tags={"layout", "plot"},
)
async def page_setup_apply(
layout: Annotated[str, "Paper-space layout to set up (never 'Model')."],
paper: Annotated[str, "ISO_A0…ISO_A4 or ANSI_A…ANSI_E (short forms A3, ansi_b accepted)."],
orientation: Annotated[str, "landscape | portrait"] = "landscape",
plot_style: Annotated[
str, "Plot style table, e.g. monochrome.ctb, acad.ctb, Grayscale.ctb (plot_style_list)."
] = "monochrome.ctb",
scale: Annotated[
str, "fit | 1:1 | 1:2 | 1:5 | 1:10 | 1:20 | 1:50 | 1:100 | 2:1 | 5:1 | 10:1"
] = "fit",
plot_area: Annotated[str, "layout | extents"] = "layout",
device: Annotated[str, "Plotter configuration (.pc3) or printer name."] = "DWG To PDF.pc3",
margins_mm: Annotated[
list[float] | None,
"[top, bottom, left, right] in mm. Headless only: AutoCAD takes margins from the .pc3.",
] = None,
center: Annotated[bool, "Centre the plot on the paper."] = True,
ctx: Context = None,
) -> dict:
"""AutoCAD's PAGESETUP as one call, on both engines.
Paper sizes are ISO 216 / ANSI Y14.1; the media is written in AutoCAD's own
spelling (`ISO_A3_(420.00_x_297.00_MM)`). `changed` lists only the values
that moved — re-applying the same setup reports `{}`. An unknown ctb is
written and reported `plot_style_known: false` (a missing ctb only matters
at plot time). Viewports on the sheet are never touched.
Refused before anything is written: an unknown paper, orientation, scale
or plot_area, an empty device or plot_style, margins that leave no
printable area, `Model`, an unknown layout. On the live engine `margins_mm`
is refused (the .pc3 owns them) and a media the device does not offer is
refused with the device's names for the same paper.
The proof is the PDF: `batch_plot` reads each sheet's `/MediaBox` back.
"""
from engineering.standards.papers import resolve_page_setup
try:
setup = resolve_page_setup(
paper, orientation, plot_style, scale, plot_area, device, margins_mm, center
)
await ctx.info(f"Applying page setup {setup['paper']} {orientation} to {layout}")
return await _backend(ctx).page_setup_apply(layout, setup)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.",
cost="read",
)
@mcp.tool(
annotations={"title": "List Plot Styles", "readOnlyHint": True},
tags={"layout", "plot", "query"},
)
async def plot_style_list(ctx: Context = None) -> dict:
"""The ctb files AutoCAD ships (`monochrome.ctb`, `acad.ctb`, `Grayscale.ctb`,
the Screening set, …) and, on the live engine, the files actually present
in `Preferences.Files.PrinterStyleSheetPath` (`source: "installed"`,
`installed: true/false` per row). Headlessly `installed` is `null`: there
is no installation to scan, and the catalogue is not evidence of one.
Never refuses; an unreadable Preferences object degrades to the catalogue.
"""
backend = _backend(ctx)
rows = await backend.plot_style_list()
return {"ok": True, "styles": rows, "count": len(rows), "backend": backend.name}
@cad_tool(
summary="Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Batch Plot", "destructiveHint": False},
tags={"layout", "plot", "export"},
)
async def batch_plot(
output_dir: Annotated[str, "Folder for the PDFs (created if missing)."],
layouts: Annotated[
list[str] | None,
"Layouts to plot; omit for every paper-space layout. 'Model' only when named.",
] = None,
format: Annotated[str, "pdf (the only format this release plots)."] = "pdf",
file_pattern: Annotated[
str, "File name per sheet; {drawing} and {layout} are substituted."
] = "{drawing}-{layout}.pdf",
ctx: Context = None,
) -> dict:
"""AutoCAD's PUBLISH for a PDF set, through `drawing_export_pdf(layout=…)`.
One row per sheet: `path`, `bytes`, and `mediabox_mm` **parsed from the
PDF that was written** (`/MediaBox`, points → mm), with `paper` naming the
catalogue size it matches within 0.5 mm — so the sheet size is verified,
never assumed. Model space is included only when named.
Refused before any file is written: a format other than pdf, an empty
`layouts` list, a layout that does not exist, a `file_pattern` containing a
path separator or lacking `{layout}` when more than one sheet is plotted,
and any output path outside the allowed directories. A sheet whose PDF has
no readable MediaBox is reported with `mediabox_mm: null` and an `error`.
"""
from engineering.standards.plot import batch_plot as _batch_plot
if str(format).lower() != "pdf":
raise ToolError(
f"batch_plot: format must be 'pdf' (got {format!r}); PDF is the only plot format"
)
destination = validate_path(output_dir, allow_write=True)
await ctx.info(f"Batch plotting {layouts or 'every sheet'} to {destination}")
try:
return await _batch_plot(_backend(ctx), layouts, str(destination), file_pattern)
except ValueError as exc:
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,
}
@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
# ---------------------------------------------------------------------------
# ── SECTION 20: Environment (3 tools) ───────────────────────────────────────
# ---------------------------------------------------------------------------
+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.
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
+434
View File
@@ -0,0 +1,434 @@
"""batch_plot: every sheet to PDF, each sheet size read back from the PDF.
The composition is exercised on the headless engine end to end; the COM-only
pieces of this task (the installed ctb scan, and the plot call's shape —
foreground, by layout name, confirmed on disk) are fake-tested; the SECTION 19
tools are called directly with a minimal context.
"""
from __future__ import annotations
import types
from pathlib import Path
import pytest
from fastmcp.exceptions import ToolError
import server
from engineering.standards.papers import CTB_CATALOG, resolve_page_setup
from engineering.standards.plot import batch_plot, safe_filename
pytestmark = pytest.mark.asyncio
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
async def _two_sheets(backend):
"""Exactly two paper layouts: the default tab renamed, plus one more."""
await backend.layout_rename("Layout1", "A3")
await backend.layout_create("B")
await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
await backend.page_setup_apply("B", resolve_page_setup("ANSI_B", "landscape"))
await backend.entity_create_line(0, 0, 100, 50)
def test_safe_filename_strips_what_a_filesystem_refuses():
assert safe_filename("Sheet 1: A3/rev*B") == "Sheet_1_A3_rev_B"
assert safe_filename("Layout1") == "Layout1"
async def test_every_paper_layout_is_plotted_and_measured(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
result = await batch_plot(backend, None, str(tmp_path / "out"))
assert result["ok"] is True and result["format"] == "pdf"
assert result["count"] == 2
by_layout = {row["layout"]: row for row in result["sheets"]}
assert set(by_layout) == {"A3", "B"}, "Model is not plotted unless named"
a3, b = by_layout["A3"], by_layout["B"]
assert a3["ok"] and a3["bytes"] > 0 and a3["path"].endswith("untitled-A3.pdf")
assert abs(a3["mediabox_mm"][0] - 420) <= 0.5 and abs(a3["mediabox_mm"][1] - 297) <= 0.5
assert a3["paper"] == "ISO_A3"
assert abs(b["mediabox_mm"][0] - 432) <= 0.5 and abs(b["mediabox_mm"][1] - 279) <= 0.5
assert b["paper"] == "ANSI_B"
async def test_model_is_included_only_when_named(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
names = [row["layout"] for row in result["sheets"]]
assert names == ["Model", "A3"]
model = result["sheets"][0]
assert model["ok"] and model["mediabox_mm"] is not None
assert model["paper"] is None, "model space has no sheet; the size is reported, not named"
async def test_refusals_write_nothing(backend, tmp_path):
await _two_sheets(backend)
out = tmp_path / "nothing"
with pytest.raises(ValueError, match="Nope"):
await batch_plot(backend, ["Nope"], str(out))
with pytest.raises(ValueError, match="layout"):
await batch_plot(backend, None, str(out), file_pattern="all.pdf")
with pytest.raises(ValueError, match="file_pattern"):
await batch_plot(backend, None, str(out), file_pattern="sub/{layout}.pdf")
with pytest.raises(ValueError, match="layouts"):
await batch_plot(backend, [], str(out))
assert not out.exists() or not any(out.iterdir())
async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_size(
backend, tmp_path
):
"""No page setup ever applied: the sheet still plots, at ezdxf's stored
default (A3, 420 x 297), and the row says so rather than guessing."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
result = await batch_plot(backend, None, str(tmp_path), file_pattern="{layout}.pdf")
assert [row["layout"] for row in result["sheets"]] == ["Layout1"]
row = result["sheets"][0]
assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3"
async def _model_circle_and_sheet_rectangle(backend):
"""Distinct geometry per space, told apart by the PDF's aspect ratio: a
circle in model space (a square page) and a 390 x 270 rectangle on the A3
sheet (a landscape page)."""
await backend.entity_create_circle(50, 50, 25)
await backend.layout_rename("Layout1", "A3")
await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
await backend.layout_set_current("A3")
await backend.entity_create_polyline([[10, 10], [400, 10], [400, 280], [10, 280]], closed=True)
def _aspect(mediabox_mm):
width, height = mediabox_mm
return width / height
async def test_the_model_row_is_model_space_even_when_a_sheet_is_current(backend, tmp_path):
"""The Model row must be model space whichever tab is current — proven by
content, not by the row's shape. The headless export used to route the
model target through ``_msp()`` (the *current* tab), so with a sheet
current the "Model" PDF was that sheet's paper-space content: measured
176.1 x 121.9 mm (the rectangle's aspect) against 121.9 x 121.9 (the
circle) with Model current. COM had the mirror defect (``ActiveLayout``
for ``layout=None``). batch_plot names the layout for every row, Model
included, and the engine honours the name."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _model_circle_and_sheet_rectangle(backend)
seen: list[str | None] = []
original = backend.drawing_export_pdf
async def _recording(path, layout=None):
seen.append(layout)
return await original(path, layout=layout)
backend.drawing_export_pdf = _recording
with_sheet_current = await batch_plot(backend, ["Model", "A3"], str(tmp_path / "sheet"))
assert seen == ["Model", "A3"]
await backend.layout_set_current("Model")
with_model_current = await batch_plot(backend, ["Model"], str(tmp_path / "model"))
model_row = with_sheet_current["sheets"][0]
assert with_sheet_current["ok"] and model_row["paper"] is None
assert model_row["mediabox_mm"] == pytest.approx(
with_model_current["sheets"][0]["mediabox_mm"], abs=0.05
), "the Model row changed with the current tab"
assert _aspect(model_row["mediabox_mm"]) == pytest.approx(1.0, abs=0.02), (
"the Model row is the circle (square page), not the sheet's rectangle"
)
a3_row = with_sheet_current["sheets"][1]
assert a3_row["paper"] == "ISO_A3"
assert _aspect(a3_row["mediabox_mm"]) == pytest.approx(420 / 297, abs=0.02)
async def test_export_pdf_without_a_layout_is_model_space_even_when_a_sheet_is_current(
backend, tmp_path
):
"""``drawing_export_pdf(path)`` advertises model space as its default; a
current sheet must not turn it into that sheet."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
from engineering.standards.plot import read_mediabox
await _model_circle_and_sheet_rectangle(backend)
default = tmp_path / "default.pdf"
named = tmp_path / "named.pdf"
assert (await backend.drawing_export_pdf(str(default)))["ok"]
assert (await backend.drawing_export_pdf(str(named), layout="Model"))["ok"]
assert read_mediabox(str(default)) == pytest.approx(read_mediabox(str(named)), abs=0.05)
assert _aspect(read_mediabox(str(default))) == pytest.approx(1.0, abs=0.02)
async def test_a_sheet_the_engine_cannot_plot_is_a_failed_row_not_a_lost_batch(backend, tmp_path):
"""COM raises RuntimeError for an AutoCAD error (E_FAIL after a stranded
background job, say). That is one row's failure; the sheets already
written stay in the report with their evidence."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
original = backend.drawing_export_pdf
async def _flaky(path, layout=None):
if layout == "B":
raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Unspecified error")
return await original(path, layout=layout)
backend.drawing_export_pdf = _flaky
result = await batch_plot(backend, None, str(tmp_path))
assert result["ok"] is False and result["count"] == 2
a3, b = result["sheets"]
assert a3["ok"] and a3["paper"] == "ISO_A3"
assert b == {
"layout": "B",
"ok": False,
"path": str(tmp_path / "untitled-B.pdf"),
"error": "AutoCAD COM error (-0x7ffdfff7): Unspecified error",
}
async def test_a_plot_reported_ok_without_a_file_is_a_failed_row(backend, tmp_path):
"""A background plot answers ok before any file exists; the row says so
instead of dying on ``path.stat()`` with an unhandled FileNotFoundError."""
await _two_sheets(backend)
async def _phantom(path, layout=None):
return {"ok": True, "path": path}
backend.drawing_export_pdf = _phantom
result = await batch_plot(backend, ["A3"], str(tmp_path))
assert result["ok"] is False
row = result["sheets"][0]
assert row["ok"] is False and "no file" in row["error"]
assert "bytes" not in row and "mediabox_mm" not in row
# ── the tools ───────────────────────────────────────────────────────────────
async def test_page_setup_tools_round_trip(backend):
ctx = _Ctx(backend)
# A fresh drawing already carries Layout1; renaming it keeps exactly one
# sheet, so the unfiltered listing has one row and it is the A3.
await backend.layout_rename("Layout1", "A3")
applied = await server.page_setup_apply("A3", "ISO_A3", ctx=ctx)
assert applied["ok"] and applied["applied"]["paper"] == "ISO_A3"
listed = await server.page_setup_list(ctx=ctx)
assert listed["count"] == 1 and listed["layouts"][0]["paper"] == "ISO_A3"
only = await server.page_setup_list("A3", ctx=ctx)
assert only["layouts"][0]["layout"] == "A3"
async def test_page_setup_apply_tool_refuses_before_writing(backend):
ctx = _Ctx(backend)
await backend.layout_create("A3")
with pytest.raises(ToolError, match="scale"):
await server.page_setup_apply("A3", "ISO_A3", scale="1:3", ctx=ctx)
with pytest.raises(ToolError, match="Model"):
await server.page_setup_apply("Model", "ISO_A3", ctx=ctx)
row = (await backend.page_setup_list("A3"))[0]
assert row["device"] == "", "nothing was written"
async def test_plot_style_list_tool(backend):
result = await server.plot_style_list(ctx=_Ctx(backend))
assert result["ok"] and result["count"] == len(CTB_CATALOG)
assert result["backend"] == "ezdxf"
async def test_batch_plot_tool_validates_the_output_directory(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
with pytest.raises(ToolError):
await server.batch_plot("../../outside", ctx=_Ctx(backend))
with pytest.raises(ToolError, match="format"):
await server.batch_plot(str(tmp_path), format="png", ctx=_Ctx(backend))
result = await server.batch_plot(str(tmp_path), layouts=["A3"], ctx=_Ctx(backend))
assert result["count"] == 1 and result["sheets"][0]["paper"] == "ISO_A3"
# ── COM: the installed ctb scan ─────────────────────────────────────────────
@pytest.fixture
def com_backend(monkeypatch, tmp_path):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
styles = tmp_path / "Plot Styles"
styles.mkdir()
(styles / "monochrome.ctb").write_bytes(b"")
(styles / "Anka.ctb").write_bytes(b"")
(styles / "readme.txt").write_bytes(b"")
app = types.SimpleNamespace(
Preferences=types.SimpleNamespace(
Files=types.SimpleNamespace(PrinterStyleSheetPath=f"{styles};C:\\does\\not\\exist")
)
)
monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app
async def test_com_plot_style_list_marks_installed_and_adds_local_files(com_backend):
backend, _ = com_backend
rows = await backend.plot_style_list()
by_name = {row["name"]: row for row in rows}
assert by_name["monochrome.ctb"] == {
"name": "monochrome.ctb",
"source": "catalog",
"installed": True,
}
assert by_name["acad.ctb"]["installed"] is False
assert by_name["Anka.ctb"] == {"name": "Anka.ctb", "source": "installed", "installed": True}
assert "readme.txt" not in by_name
async def test_com_plot_style_list_survives_an_unreachable_preferences_object(
com_backend, monkeypatch
):
backend, _ = com_backend
from backends import com_backend as module
def _boom():
raise RuntimeError("no application")
monkeypatch.setattr(module, "_acad_app", _boom)
rows = await backend.plot_style_list()
assert [row["name"] for row in rows] == list(CTB_CATALOG)
assert all(row["installed"] is None for row in rows)
# ── COM: the plot call's shape ──────────────────────────────────────────────
@pytest.fixture
def com_plot(monkeypatch):
"""A fake document whose ``Plot.PlotToFile`` records the active layout and
BACKGROUNDPLOT at the moment of the plot — the two things AutoCAD acts on
and the reviewer measured wrong: with a sheet current, ``layout=None``
plotted that sheet; with the operator's BACKGROUNDPLOT=2 the file did not
exist when the call returned."""
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
state = {
"vars": {"BACKGROUNDPLOT": 2},
"sets": [],
"active": "Layout1",
"plots": [],
"write_file": True,
"plotted": True,
}
names = ["Model", "Layout1", "Layout2"]
class _Layout:
def __init__(self, name):
self.Name = name
class _Layouts:
def Item(self, key):
for name in names:
if name.lower() == str(key).lower():
return _Layout(name)
raise KeyError(key)
class _Plot:
def PlotToFile(self, path, config):
state["plots"].append((state["active"], config, state["vars"]["BACKGROUNDPLOT"]))
if state["write_file"]:
Path(path).write_bytes(b"%PDF-1.4 fake")
return state["plotted"]
class _Doc:
Layouts = _Layouts()
Plot = _Plot()
@property
def ActiveLayout(self):
return _Layout(state["active"])
@ActiveLayout.setter
def ActiveLayout(self, layout):
state["active"] = layout.Name
def GetVariable(self, name):
return state["vars"][name]
def SetVariable(self, name, value):
state["sets"].append((name, value))
state["vars"][name] = value
monkeypatch.setattr(module, "_acad_doc", lambda: _Doc())
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, state
async def test_com_export_pdf_plots_model_space_by_name_in_the_foreground(com_plot, tmp_path):
backend, state = com_plot
out = tmp_path / "model.pdf"
result = await backend.drawing_export_pdf(str(out))
assert state["plots"] == [("Model", "DWG To PDF.pc3", 0)], (
"layout=None is model space, plotted with BACKGROUNDPLOT forced to 0"
)
assert result == {"ok": True, "path": str(out), "layout": "Model", "bytes": out.stat().st_size}
assert state["active"] == "Layout1", "the sheet that was current is current again"
assert state["vars"]["BACKGROUNDPLOT"] == 2, "the operator's setting is restored"
assert state["sets"] == [("BACKGROUNDPLOT", 0), ("BACKGROUNDPLOT", 2)]
async def test_com_export_pdf_plots_the_named_sheet_and_restores_the_tab(com_plot, tmp_path):
backend, state = com_plot
result = await backend.drawing_export_pdf(str(tmp_path / "l2.pdf"), layout="layout2")
assert [row[0] for row in state["plots"]] == ["Layout2"]
assert result["ok"] and result["layout"] == "layout2"
assert state["active"] == "Layout1"
async def test_com_export_pdf_leaves_backgroundplot_alone_when_already_foreground(
com_plot, tmp_path
):
backend, state = com_plot
state["vars"]["BACKGROUNDPLOT"] = 0
await backend.drawing_export_pdf(str(tmp_path / "m.pdf"))
assert state["sets"] == []
async def test_com_export_pdf_reports_a_refused_plot(com_plot, tmp_path):
backend, state = com_plot
state["plotted"] = False
state["write_file"] = False
result = await backend.drawing_export_pdf(str(tmp_path / "no.pdf"), layout="Layout1")
assert result["ok"] is False and "PlotToFile returned False" in result["error"]
assert state["vars"]["BACKGROUNDPLOT"] == 2
async def test_com_export_pdf_reports_a_plot_that_left_no_file(com_plot, tmp_path):
"""PlotToFile answering True with nothing on disk is the background-plot
signature; it is a failure, not an ok."""
backend, state = com_plot
state["write_file"] = False
result = await backend.drawing_export_pdf(str(tmp_path / "gone.pdf"))
assert result["ok"] is False and "wrote no file" in result["error"]
assert "bytes" not in result
File diff suppressed because it is too large Load Diff
+487
View File
@@ -0,0 +1,487 @@
"""Bundled templates: catalogue, resolution, reproducible build, drawing_new(template=)."""
from __future__ import annotations
import importlib.util
import types
from pathlib import Path
import pytest
from fastmcp.exceptions import ToolError
import server
from backends.base import UnsupportedCapabilityError
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"] == "."
#: Spec §4.1, the eight variables `_with_header_dimvars` does not fold — the
#: ones a header-only write silently loses on the headless renderer.
_PLACEMENT = ("dimtad", "dimtih", "dimtoh", "dimexo", "dimexe", "dimgap", "dimlunit", "dimlwd")
async def _effective_dimvars(backend) -> dict:
"""What the next dimension is really rendered with, read off the entity."""
from ezdxf.entities import DimStyleOverride
info = await backend.dimension_linear(0, 0, 100, 0, 50, 20)
override = DimStyleOverride(backend._doc.entitydb[info.handle])
return {name: override.get(name) for name in _PLACEMENT + ("dimtxt", "dimasz")}
async def test_templates_dimension_with_their_own_preset_headlessly(backend):
"""The build's fallback lands on the ``Standard`` dimstyle, not only the header.
Measured before the fix: ``ansi_b_mech`` reported DIMTAD 0 / DIMTIH 1 from
``system_get_variable`` while the dimension it drew used dimtad=1 / dimtih=0
(the ISO values on ``Standard``), so the same template dimensioned
differently per engine. The header and the style must agree.
"""
await server.drawing_new(template="ansi_b_mech", ctx=_Ctx(backend))
ansi = await _effective_dimvars(backend)
assert ansi == {
"dimtad": 0,
"dimtih": 1,
"dimtoh": 1,
"dimexo": 1.5,
"dimexe": 1.5,
"dimgap": 1.0,
"dimlunit": 2,
"dimlwd": -2,
"dimtxt": 3.0,
"dimasz": 3.0,
}
for name, value in ansi.items():
header = await backend.system_get_variable(name.upper())
assert float(header) == float(value), f"{name}: header {header} != style {value}"
await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend))
iso = await _effective_dimvars(backend)
assert (iso["dimtad"], iso["dimtih"], iso["dimtoh"]) == (1, 0, 0)
assert (iso["dimexo"], iso["dimexe"], iso["dimgap"]) == (0.625, 1.25, 0.625)
assert (iso["dimtxt"], iso["dimasz"]) == (2.5, 2.5)
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"
# ── COM: only a real .dwt reaches Documents.Add ─────────────────────────────
class _FakeDocuments:
"""Records the ActiveX calls; ``Add`` on a non-``.dwt`` answers blank, as
AutoCAD 2026 does (measured: ``Add(<dxf>)`` == ``Add()`` == ``Add(<missing.dwt>)``)."""
def __init__(self, dwt_bytes: bytes = b"AC1032\x00template"):
self.calls: list[tuple] = []
self.dwt_bytes = dwt_bytes
self.open_docs: list[_FakeDoc] = []
def Add(self, template=None):
self.calls.append(("Add", template))
real = template is not None and str(template).lower().endswith(".dwt")
real = real and Path(template).is_file() and Path(template).read_bytes()[:6] == b"AC1032"
return _FakeDoc(self, "Drawing1.dwg", layers=["0", "GEOMETRY"] if real else ["0"])
def Open(self, path, read_only=False, password=None):
self.calls.append(("Open", path, read_only))
doc = _FakeDoc(self, Path(path).name, layers=["0", "GEOMETRY"])
self.open_docs.append(doc)
return doc
class _FakeDoc:
def __init__(self, documents, name, layers):
self._documents = documents
self.Name = name
self.layers = layers
self.closed = None
def SaveAs(self, path, fmt=None):
self._documents.calls.append(("SaveAs", path, fmt))
if fmt != 66:
raise RuntimeError(f"fake AutoCAD 2026 refuses SaveAs format {fmt}")
Path(path).write_bytes(self._documents.dwt_bytes)
def Close(self, save=True):
self._documents.calls.append(("Close", self.Name, save))
self.closed = save
self._documents.open_docs.remove(self)
@pytest.fixture
def com_new(monkeypatch, tmp_path):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
documents = _FakeDocuments()
app = types.SimpleNamespace(Documents=documents)
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_template_cache_dir", lambda: tmp_path / "cache")
monkeypatch.setattr(module, "_COM_ERROR", (RuntimeError,))
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
async def _no_state():
pass
monkeypatch.setattr(backend, "_run", _run_inline)
monkeypatch.setattr(backend, "_ensure_document_state", _no_state)
return backend, documents
async def test_com_drawing_new_converts_a_dxf_into_a_real_dwt_once(com_new, tmp_path):
backend, documents = com_new
dxf = TEMPLATES_DIR / "iso_a3_pid.dxf"
result = await backend.drawing_new(str(dxf))
assert result["ok"] and result["name"] == "Drawing1.dwg"
dwt = Path(result["template_dwt"]["path"])
assert result["template_dwt"]["cached"] is False
assert dwt.suffix == ".dwt" and dwt.is_file() and dwt.parent == tmp_path / "cache"
assert documents.calls == [
("Open", str(dxf), True),
("SaveAs", str(dwt), 66),
("Close", "iso_a3_pid.dxf", False),
("Add", str(dwt)),
], "open read-only, save as ac2018_Template, close without saving, then Add the .dwt"
assert documents.open_docs == [], "the conversion document never stays open"
documents.calls.clear()
again = await backend.drawing_new(str(dxf))
assert again["template_dwt"] == {"path": str(dwt), "cached": True}
assert documents.calls == [("Add", str(dwt))], "the second call reuses the cached .dwt"
async def test_com_drawing_new_hands_a_dwt_straight_to_add_and_refuses_a_missing_file(
com_new, tmp_path
):
backend, documents = com_new
mine = tmp_path / "mine.dwt"
mine.write_bytes(documents.dwt_bytes)
result = await backend.drawing_new(str(mine))
assert result == {"ok": True, "name": "Drawing1.dwg"}
assert documents.calls == [("Add", str(mine))]
documents.calls.clear()
with pytest.raises(FileNotFoundError, match="not found"):
await backend.drawing_new(str(tmp_path / "gone.dwt"))
assert documents.calls == [], "Documents.Add(<missing>) would silently create the default"
async def test_com_drawing_new_falls_back_to_an_older_template_format(com_new, monkeypatch):
backend, documents = com_new
accepted = []
def _save_as(self, path, fmt=None):
documents.calls.append(("SaveAs", path, fmt))
if fmt == 66:
raise RuntimeError("older seat: unknown SaveAs type")
accepted.append(fmt)
Path(path).write_bytes(documents.dwt_bytes)
monkeypatch.setattr(_FakeDoc, "SaveAs", _save_as)
result = await backend.drawing_new(str(TEMPLATES_DIR / "ansi_b_mech.dxf"))
assert result["ok"] and accepted == [62]
assert documents.open_docs == []
async def test_com_drawing_new_tool_reports_the_converted_dwt(com_new):
backend, documents = com_new
result = await server.drawing_new(template="iso_a3_pid", bootstrap=False, ctx=_Ctx(backend))
assert result["ok"] is True
assert "template_dwt" not in result
template = result["template"]
assert template["name"] == "iso_a3_pid"
assert template["source"] in ("bundled", "bundled_dxf")
if template["source"] == "bundled_dxf": # no .dwt twin committed: the live engine built one
assert template["path"] == str(TEMPLATES_DIR / "iso_a3_pid.dxf")
assert template["dwt"].endswith(".dwt") and template["dwt_cached"] is False
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 == []
+13 -6
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 (161 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
@@ -528,8 +528,15 @@ async def test_tool_group_sizes_are_unchanged():
as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when
v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on
both engines) joined SECTION 20 — tagged `drawing` and `settings`, and
`settings` is not a group tag, so they file under drawing. Every other
number here has been unchanged since the snapshot was taken.
`settings` is not a group tag, so they file under drawing.
`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. `templates`
moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19
(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()}
assert sizes == {
@@ -544,15 +551,15 @@ async def test_tool_group_sizes_are_unchanged():
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layouts": 12,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
"styles": 10,
"system": 8,
"templates": 2,
"templates": 4,
"transactions": 3,
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 179
assert sum(sizes.values()) == 185