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:
Umut CAN
2026-09-21 18:39:34 +03:00
co-authored by Claude Opus 5
parent 54bbf72312
commit f25421709f
8 changed files with 639 additions and 11 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div> </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`, > 166 is the **registered** count; a default install advertises 161 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority. > 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 | | 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 | | 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 | | 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 | | 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)* | | 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 | | 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) | | 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 | | 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. **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
View File
@@ -2265,10 +2265,43 @@ class ComBackend(AutoCADBackend):
return failed return failed
async def plot_style_list(self) -> list[dict]: 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 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: async def drawing_template_save(self, path, name=None, description=None) -> dict:
# Completed in Task 20; declared here so the ABC instantiates. # Completed in Task 20; declared here so the ABC instantiates.
+50
View File
@@ -72,6 +72,10 @@ class ToolAliases:
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run # 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 # between two ports (pid_line_draw), which is an LWPOLYLINE with a
# class, a number and markers; a drafter typing it could mean either. # 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. # Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -83,7 +87,9 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"LAYER", "LAYER",
"LAYOUT", "LAYOUT",
"MEASUREGEOM", "MEASUREGEOM",
"PAGESETUP",
"PLINE", "PLINE",
"PLOT",
"QSELECT", "QSELECT",
"SETVAR", "SETVAR",
"ZOOM", "ZOOM",
@@ -1427,6 +1433,50 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"loop number", "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",
),
),
} }
+61 -2
View File
@@ -3,7 +3,7 @@
"schema_version": 1, "schema_version": 1,
"package_version": "1.5.1", "package_version": "1.5.1",
"totals": { "totals": {
"tools": 166, "tools": 170,
"resources": 8, "resources": 8,
"prompts": 5, "prompts": 5,
"groups": 20 "groups": 20
@@ -20,7 +20,7 @@
"entity_modification": 16, "entity_modification": 16,
"entity_query": 9, "entity_query": 9,
"layers": 14, "layers": 14,
"layouts": 12, "layouts": 16,
"pid": 9, "pid": 9,
"premium": 12, "premium": 12,
"solids": 5, "solids": 5,
@@ -175,6 +175,21 @@
"readOnlyHint": true "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", "name": "block_create_from_entities",
"group": "blocks", "group": "blocks",
@@ -1717,6 +1732,35 @@
"readOnlyHint": false "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", "name": "pid_equipment_list",
"group": "pid", "group": "pid",
@@ -1844,6 +1888,21 @@
"readOnlyHint": true "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", "name": "point_from_snap",
"group": "premium", "group": "premium",
+122
View File
@@ -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),
}
+159
View File
@@ -6815,6 +6815,165 @@ async def pid_tag_parse(
return parse_tag(tag, kind, equipment_prefixes) 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 ─────────────────────────────────────────────────────────────── # ── RESOURCES ───────────────────────────────────────────────────────────────
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+201
View File
@@ -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)
+8 -4
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged(): 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 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 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) when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
joined; the `pid` joined; the `pid`
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under 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. other number here has been unchanged since the snapshot was taken.
""" """
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} 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_modification": 16,
"entity_query": 9, "entity_query": 9,
"layers": 14, "layers": 14,
"layouts": 12, "layouts": 16,
"pid": 9, "pid": 9,
"premium": 12, "premium": 12,
"solids": 5, "solids": 5,
@@ -543,4 +547,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1, "validation": 1,
"view": 4, "view": 4,
} }
assert sum(sizes.values()) == 166 assert sum(sizes.values()) == 170