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:
@@ -1,2 +1,4 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
templates/*.dxf text eol=crlf
|
||||
templates/*.dwt binary
|
||||
|
||||
@@ -47,6 +47,9 @@ benchmarks/results/*
|
||||
!benchmarks/results/published/**
|
||||
benchmarks/.competitors/
|
||||
*.dxf
|
||||
# The bundled templates are the one committed DXF set: built by
|
||||
# scripts/build_templates.py and pinned reproducible by its --check.
|
||||
!templates/*.dxf
|
||||
*.dwg
|
||||
*.bak
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ COPY server.py config.py security.py version.py ./
|
||||
COPY backends/ ./backends/
|
||||
COPY engineering/ ./engineering/
|
||||
COPY discovery/ ./discovery/
|
||||
COPY templates/ ./templates/
|
||||
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 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.
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -138,6 +138,7 @@ only-include = [
|
||||
"backends",
|
||||
"engineering",
|
||||
"discovery",
|
||||
"templates",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -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())
|
||||
@@ -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) ───────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user