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:
@@ -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,
|
||||
|
||||
+548
-19
@@ -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}
|
||||
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:
|
||||
previous = doc.ActiveLayout.Name
|
||||
if previous != layout:
|
||||
doc.ActiveLayout = doc.Layouts.Item(layout)
|
||||
target = layout.strip() if layout and layout.strip() else "Model"
|
||||
previous = doc.ActiveLayout.Name
|
||||
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:
|
||||
doc.ActiveLayout = doc.Layouts.Item(previous)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user