feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
54bbf72312
commit
f25421709f
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> **v1.5 release snapshot:** 170 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,7 @@ 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=…)` |
|
||||
| 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 +113,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>170 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.
|
||||
|
||||
|
||||
+35
-2
@@ -2265,10 +2265,43 @@ class ComBackend(AutoCADBackend):
|
||||
return failed
|
||||
|
||||
async def plot_style_list(self) -> list[dict]:
|
||||
# Completed in Task 18 (installed ctb scan); the catalogue rows already answer.
|
||||
"""The ctb catalogue, marked against the files in AutoCAD's plot style path.
|
||||
|
||||
``Preferences.Files.PrintStyleSheetPath`` 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
|
||||
|
||||
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
|
||||
def _sync():
|
||||
rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
|
||||
try:
|
||||
raw = str(_acad_app().Preferences.Files.PrintStyleSheetPath or "")
|
||||
except Exception as exc:
|
||||
log.debug("Preferences.Files.PrintStyleSheetPath 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:
|
||||
# Completed in Task 20; declared here so the ABC instantiates.
|
||||
|
||||
@@ -72,6 +72,10 @@ class ToolAliases:
|
||||
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
|
||||
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
|
||||
# class, a number and markers; a drafter typing it could mean either.
|
||||
# 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.
|
||||
#
|
||||
# Every other AutoCAD command must map to exactly one tool.
|
||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
@@ -83,7 +87,9 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
"LAYER",
|
||||
"LAYOUT",
|
||||
"MEASUREGEOM",
|
||||
"PAGESETUP",
|
||||
"PLINE",
|
||||
"PLOT",
|
||||
"QSELECT",
|
||||
"SETVAR",
|
||||
"ZOOM",
|
||||
@@ -1427,6 +1433,50 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"loop number",
|
||||
),
|
||||
),
|
||||
# ── 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",
|
||||
),
|
||||
),
|
||||
"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": 166,
|
||||
"tools": 170,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 20
|
||||
@@ -20,7 +20,7 @@
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
"layers": 14,
|
||||
"layouts": 12,
|
||||
"layouts": 16,
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
"solids": 5,
|
||||
@@ -175,6 +175,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",
|
||||
@@ -1717,6 +1732,35 @@
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -1844,6 +1888,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,122 @@
|
||||
"""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. 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"
|
||||
result = await backend.drawing_export_pdf(str(path), layout=None if is_model else name)
|
||||
if not result.get("ok", False):
|
||||
sheets.append(
|
||||
{"layout": name, "ok": False, "path": str(path), "error": result.get("error")}
|
||||
)
|
||||
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),
|
||||
}
|
||||
@@ -6815,6 +6815,165 @@ async def pid_tag_parse(
|
||||
return parse_tag(tag, kind, equipment_prefixes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 19: Page Setup & Templates (4 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.PrintStyleSheetPath` (`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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""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
|
||||
piece of this task (the installed ctb scan) is fake-tested; the SECTION 19
|
||||
tools are called directly with a minimal context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ── 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(PrintStyleSheetPath=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)
|
||||
@@ -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 (170 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
|
||||
@@ -517,7 +517,11 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
||||
joined; the `pid`
|
||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||
`pid` rather than under their secondary `query` / `create` tags. Every
|
||||
`pid` rather than under their secondary `query` / `create` tags.
|
||||
`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. 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()}
|
||||
@@ -533,7 +537,7 @@ 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,
|
||||
@@ -543,4 +547,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 166
|
||||
assert sum(sizes.values()) == 170
|
||||
|
||||
Reference in New Issue
Block a user