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:
+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(
|
||||
|
||||
Reference in New Issue
Block a user