From f25421709f734eb568837c41e21e3ff488b04b52 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:39:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(pagesetup):=20SECTION=2019=20=E2=80=94=20p?= =?UTF-8?q?age=5Fsetup=5Flist/apply,=20plot=5Fstyle=5Flist,=20batch=5Fplot?= =?UTF-8?q?=20with=20/MediaBox=20read-back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 37 ++++++- discovery/aliases.py | 50 +++++++++ docs/tool-inventory.json | 63 ++++++++++- engineering/standards/plot.py | 122 +++++++++++++++++++++ server.py | 159 +++++++++++++++++++++++++++ tests/test_batch_plot.py | 201 ++++++++++++++++++++++++++++++++++ tests/test_tool_registry.py | 12 +- 8 files changed, 639 insertions(+), 11 deletions(-) create mode 100644 engineering/standards/plot.py create mode 100644 tests/test_batch_plot.py diff --git a/README.md b/README.md index 0864612..3dec326 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **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 | -166 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +170 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **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. diff --git a/backends/com_backend.py b/backends/com_backend.py index d0373da..0967b7d 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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. diff --git a/discovery/aliases.py b/discovery/aliases.py index e8b9af9..a59e657 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -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", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 264451c..401303e 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -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", diff --git a/engineering/standards/plot.py b/engineering/standards/plot.py new file mode 100644 index 0000000..ac035c5 --- /dev/null +++ b/engineering/standards/plot.py @@ -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), + } diff --git a/server.py b/server.py index 27a06b2..1200130 100644 --- a/server.py +++ b/server.py @@ -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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py new file mode 100644 index 0000000..91e8631 --- /dev/null +++ b/tests/test_batch_plot.py @@ -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) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 13ab419..b09f14c 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -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