From f46fda10cbaaf08399959512602f68b9ce516afa Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:13:48 +0300 Subject: [PATCH 01/14] =?UTF-8?q?feat(pagesetup):=20page=5Fsetup=5Flist/ap?= =?UTF-8?q?ply=20on=20both=20engines=20=E2=80=94=20the=20PDF's=20own=20/Me?= =?UTF-8?q?diaBox=20is=20the=20evidence?= 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 --- backends/base.py | 2 + backends/com_backend.py | 178 +++++++++++ backends/contracts/__init__.py | 2 + backends/contracts/pagesetup.py | 59 ++++ backends/ezdxf_backend.py | 199 ++++++++++++- engineering/standards/papers.py | 355 ++++++++++++++++++++++ engineering/standards/pdfinfo.py | 59 ++++ tests/test_page_setup.py | 493 +++++++++++++++++++++++++++++++ 8 files changed, 1345 insertions(+), 2 deletions(-) create mode 100644 backends/contracts/pagesetup.py create mode 100644 engineering/standards/papers.py create mode 100644 engineering/standards/pdfinfo.py create mode 100644 tests/test_page_setup.py diff --git a/backends/base.py b/backends/base.py index 0adbddc..27fed19 100644 --- a/backends/base.py +++ b/backends/base.py @@ -30,6 +30,7 @@ from backends.contracts import ( LayerContract, LayoutContract, LinetypeContract, + PageSetupContract, PremiumContract, SettingsContract, SolidContract, @@ -168,6 +169,7 @@ class AutoCADBackend( IdentityContract, DrawingContract, LayoutContract, + PageSetupContract, SolidContract, EntityCreationContract, DimensionContract, diff --git a/backends/com_backend.py b/backends/com_backend.py index b0aa28d..227dea0 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -811,6 +811,7 @@ class ComBackend(AutoCADBackend): "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), + "dwt_write": FeatureCapability(True, "native"), "viewport_render": FeatureCapability(True, "native"), "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), @@ -1954,6 +1955,183 @@ class ComBackend(AutoCADBackend): # CopyObjects-and-delete that would destroy and recreate geometry in the # operator's open drawing when it goes wrong. + # ── page setup (track E) ───────────────────────────────────────────────── + # + # Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026 + # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0; + # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16 + # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE). + # Fake-tested in tests/test_page_setup.py; executed live by + # scripts/smoke_settings_com.py (Task 24). + + _AC_MILLIMETERS = 1 + _AC_NO_ROTATION = 0 + + @staticmethod + def _com_page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + activex_scale_label, + paper_from_size, + scale_label, + ) + + def _get(attr): + try: + return getattr(layout, attr) + except Exception as exc: + log.debug("Layout.%s read failed: %s", attr, exc) + return None + + units = _get("PaperUnits") + factor = 25.4 if units == 0 else 1.0 + size = None + try: + width, height = layout.GetPaperSize() + size = [round(float(width) * factor, 2), round(float(height) * factor, 2)] + except Exception as exc: + log.debug("Layout.GetPaperSize failed: %s", exc) + margins = None + try: + (left, bottom), (right, top) = layout.GetPaperMargins() + margins = [round(float(v) * factor, 3) for v in (top, bottom, left, right)] + except Exception as exc: + log.debug("Layout.GetPaperMargins failed: %s", exc) + rotation = _get("PlotRotation") or 0 + if size and int(rotation) in (1, 3): + size = [size[1], size[0]] + if _get("UseStandardScale"): + scale = activex_scale_label(_get("StandardScale") or 0) + else: + try: + numerator, denominator = layout.GetCustomScale() + scale = scale_label(float(numerator), float(denominator)) + except Exception as exc: + log.debug("Layout.GetCustomScale failed: %s", exc) + scale = None + media = _get("CanonicalMediaName") + plot_type = _get("PlotType") + return { + "layout": layout.Name, + "paper": (paper_from_size(*size) if size else None) or media, + "canonical_media_name": media, + "size_mm": size, + "orientation": ( + None if not size else ("landscape" if size[0] >= size[1] else "portrait") + ), + "plot_style": _get("StyleSheet"), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), + "device": _get("ConfigName"), + "margins_mm": margins, + "center": bool(_get("CenterPlot")), + } + + def _com_paper_layout(self, doc, raw: str, operation: str): + resolved = self._find_layout(doc, raw) + if resolved is None: + available = [n for n in self._layout_names(doc) if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.Layouts.Item(resolved) + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = _acad_doc() + if layout is not None and self._layout_name(layout): + target = self._com_paper_layout(doc, layout, "page_setup_list") + return [self._com_page_setup_row(target)] + return [ + self._com_page_setup_row(doc.Layouts.Item(name)) + for name in self._layout_names(doc) + if name != "Model" + ] + + return await self._run(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import require_page_setup + + resolved = require_page_setup(setup) + if resolved["margins_mm"] is not None: + raise ValueError( + "page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD " + "takes the printable margins from the plotter configuration (.pc3). Omit " + "margins_mm; the device's margins are read back in the result." + ) + + def _sync(): + doc = _acad_doc() + target = self._com_paper_layout(doc, layout, "page_setup_apply") + before = self._com_page_setup_row(target) + previous_device = before["device"] + target.RefreshPlotDeviceInfo() + target.ConfigName = resolved["device"] + target.RefreshPlotDeviceInfo() + media = resolved["canonical_media_name"] + names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] + if media not in names: + # Roll the one write back before refusing, so a refusal leaves + # the layout as it was. + target.ConfigName = previous_device + nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] + raise ValueError( + f"page_setup_apply: device {resolved['device']!r} has no media " + f"{media!r} (same paper on this device: {', '.join(nearest) or 'none'}; " + f"{len(names)} media in total)" + ) + target.CanonicalMediaName = media + target.PaperUnits = self._AC_MILLIMETERS + target.PlotRotation = self._AC_NO_ROTATION + target.StyleSheet = resolved["plot_style"] + target.PlotWithPlotStyles = True + target.PlotType = int(resolved["plot_type"]) + code = resolved["activex_standard_scale"] + if code is not None: + target.StandardScale = int(code) + target.UseStandardScale = True + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + target.SetCustomScale(numerator, denominator) + target.UseStandardScale = False + target.CenterPlot = bool(resolved["center"]) + after = self._com_page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.Name, + "applied": dict(resolved), + "changed": changed, + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "com", + } + + return await self._run(_sync) + + async def plot_style_list(self) -> list[dict]: + # Completed in Task 18 (installed ctb scan); the catalogue rows already answer. + from engineering.standards.papers import CTB_CATALOG + + return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + # Completed in Task 20; declared here so the ABC instantiates. + raise UnsupportedCapabilityError( + "dwt_write", "drawing_template_save: not implemented yet (Task 20)" + ) + # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─ async def solid_box( diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index fb04be8..f23c208 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -21,6 +21,7 @@ from backends.contracts.identity import IdentityContract from backends.contracts.layers import LayerContract from backends.contracts.layouts import LayoutContract from backends.contracts.linetypes import LinetypeContract +from backends.contracts.pagesetup import PageSetupContract from backends.contracts.premium import PremiumContract from backends.contracts.settings import SettingsContract from backends.contracts.solids import SolidContract @@ -42,6 +43,7 @@ __all__ = [ "LayerContract", "LayoutContract", "LinetypeContract", + "PageSetupContract", "PremiumContract", "SettingsContract", "SolidContract", diff --git a/backends/contracts/pagesetup.py b/backends/contracts/pagesetup.py new file mode 100644 index 0000000..464e00e --- /dev/null +++ b/backends/contracts/pagesetup.py @@ -0,0 +1,59 @@ +"""Page setup, plot styles and templates (v1.6 track E, group P). + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class PageSetupContract(ABC): + @abstractmethod + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + """Page setup of one paper-space layout, or of every one. + + Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h], + orientation, plot_style, scale, plot_area, device, + margins_mm: [top, bottom, left, right], center}``. ``paper`` is the + catalogue name when the size matches one within 0.5 mm, otherwise the + media name as stored. Raises ``ValueError`` for an unknown layout. + ``Model`` is never listed: model space is plotted through + ``drawing_export_pdf(layout=None)`` and carries no sheet. + """ + ... + + @abstractmethod + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + """Write a page setup resolved by + ``engineering.standards.papers.resolve_page_setup`` to a layout. + + Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]}, + plot_style_known, viewports_kept}``. ``changed`` is computed from the + read-back before and after, so re-applying the same setup reports + ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a + resolved key, before anything is written. Never deletes viewports. + """ + ... + + @abstractmethod + async def plot_style_list(self) -> list[dict]: + """``[{name, source: "catalog"|"installed", installed: bool|None}]`` — + the authored ctb catalogue plus, on a live engine, the files present + in the plot style search path. ``installed`` is ``None`` where no + installation can be scanned.""" + ... + + @abstractmethod + async def drawing_template_save( + self, path: str, name: str | None = None, description: str | None = None + ) -> dict: + """Save the current drawing as a template. + + ``.dxf`` writes on both engines; ``.dwt`` writes on the live engine + (``SaveAs(path, ac2018_Template)``) and raises + ``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns + ``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``. + """ + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 465d1d8..80a1311 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1044,6 +1044,9 @@ class EzdxfBackend(AutoCADBackend): "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), + "dwt_write": FeatureCapability( + False, reason="dwt_is_a_dwg_container_requires_live_autocad" + ), # Measured, not assumed: a sheet holding nothing but a viewport # renders the model geometry that viewport looks at, at its # scale and position, clipped to its window. v1.4 declared this @@ -1525,13 +1528,29 @@ class EzdxfBackend(AutoCADBackend): from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend - fig = _new_agg_figure() + # Track E: for a sheet, the figure *is* the paper. Sized from + # the layout's page setup and left unadjusted, so the PDF's + # /MediaBox is the paper size — the evidence page_setup_apply + # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6 + # pt whatever the sheet was. + paper = None + if resolved is not None and resolved != "Model": + paper = self._paper_frame(target) + fig = _new_agg_figure(figsize=paper["figsize"] if paper else None) ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) - out = MatplotlibBackend(ax) + out = MatplotlibBackend(ax, adjust_figure=paper is None) Frontend(ctx, out).draw_layout(target, finalize=True) + if paper is not None: + ax.set_xlim(*paper["xlim"]) + ax.set_ylim(*paper["ylim"]) + ax.set_aspect("equal", adjustable="box") fig.savefig(path, dpi=150) result = {"ok": True, "path": path} + if paper is not None: + result["paper_mm"] = paper["size_mm"] + if not paper["rotation_applied"]: + result["rotation_applied"] = False if resolved is not None and resolved != "Model": result["layout"] = resolved result["note"] = ( @@ -2294,6 +2313,182 @@ class EzdxfBackend(AutoCADBackend): text = f"{float(value):.4f}".rstrip("0").rstrip(".") return text or "0" + # ── page setup (track E) ───────────────────────────────────────────────── + + def _paper_layout(self, raw: str, operation: str): + """Resolve a paper-space layout by name, refusing Model and unknowns.""" + doc = self._require_doc() + resolved = self._find_layout(raw) + if resolved is None: + available = [n for n in doc.layouts.names_in_taborder() if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.layouts.get(resolved) + + @staticmethod + def _page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + dxf_scale_label, + paper_from_size, + scale_label, + ) + + dxf = layout.dxf_layout.dxf + flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) + # DXF stores every paper size and margin in millimetres, whatever + # plot_paper_units says (ezdxf's acdb_plot_settings, verified). + width, height = float(dxf.paper_width), float(dxf.paper_height) + rotation = int(dxf.plot_rotation) + if rotation in (1, 3): + width, height = height, width + media = str(dxf.paper_size) + numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) + if flags & layout.USE_STANDARD_SCALE: + scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator) + else: + scale = scale_label(numerator, denominator) + return { + "layout": layout.name, + "paper": paper_from_size(width, height) or media, + "canonical_media_name": media, + "size_mm": [round(width, 2), round(height, 2)], + "orientation": "landscape" if width >= height else "portrait", + "plot_style": str(dxf.current_style_sheet), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)), + "device": str(dxf.plot_configuration_file), + "margins_mm": [ + round(float(dxf.top_margin), 3), + round(float(dxf.bottom_margin), 3), + round(float(dxf.left_margin), 3), + round(float(dxf.right_margin), 3), + ], + "center": bool(flags & layout.PLOT_CENTERED), + } + + @staticmethod + def _paper_frame(layout) -> dict: + """The sheet as a matplotlib figure: size in inches plus the axes window. + + Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper. + ``plot_rotation`` 1/3 swaps the figure; the content is not rotated by + the headless renderer (reported, not hidden). + """ + dxf = layout.dxf_layout.dxf + width, height = float(dxf.paper_width), float(dxf.paper_height) + rotation = int(dxf.plot_rotation) + unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 + x0 = -(float(dxf.left_margin) + float(dxf.plot_origin_x_offset)) + y0 = -(float(dxf.bottom_margin) + float(dxf.plot_origin_y_offset)) + fig_w, fig_h = (height, width) if rotation in (1, 3) else (width, height) + return { + "size_mm": [round(fig_w, 2), round(fig_h, 2)], + "figsize": (fig_w / 25.4, fig_h / 25.4), + "xlim": (x0 / unit, (x0 + width) / unit), + "ylim": (y0 / unit, (y0 + height) / unit), + "rotation_applied": rotation == 0, + } + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = self._require_doc() + if layout is not None and self._layout_name(layout): + return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))] + return [ + self._page_setup_row(doc.layouts.get(name)) + for name in doc.layouts.names_in_taborder() + if name != "Model" + ] + + return await self._async(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import require_page_setup + + resolved = require_page_setup(setup) + + def _sync(): + from ezdxf.lldxf.const import STD_SCALES + + target = self._paper_layout(layout, "page_setup_apply") + before = self._page_setup_row(target) + dxf = target.dxf_layout.dxf + # Measured: a fresh layout has no stored plot_layout_flags, and + # set_flag_state starts from 0 — toggling one bit would drop + # plot-styles (32), lineweights (128) and viewports-first (512). + if not dxf.hasattr("plot_layout_flags"): + dxf.plot_layout_flags = dxf.get_default("plot_layout_flags") + width, height = (float(v) for v in resolved["size_mm"]) + dxf.page_setup_name = "" + dxf.plot_configuration_file = resolved["device"] + dxf.paper_size = resolved["canonical_media_name"] + dxf.paper_width = width + dxf.paper_height = height + dxf.plot_paper_units = 1 # millimetres + dxf.plot_rotation = 0 # orientation rides on width/height, as AutoCAD names media + if resolved["margins_mm"] is not None: + top, bottom, left, right = (float(v) for v in resolved["margins_mm"]) + dxf.top_margin, dxf.bottom_margin = top, bottom + dxf.left_margin, dxf.right_margin = left, right + dxf.plot_origin_x_offset = 0.0 + dxf.plot_origin_y_offset = 0.0 + dxf.unit_factor = 1.0 + dxf.current_style_sheet = resolved["plot_style"] + target.use_plot_styles(True) + target.set_plot_type(int(resolved["plot_type"])) + code = resolved["dxf_standard_scale_type"] + if code is not None: + dxf.standard_scale_type = int(code) + numerator, denominator = STD_SCALES.get(int(code), (1.0, 1.0)) + dxf.scale_numerator, dxf.scale_denominator = float(numerator), float(denominator) + target.use_standard_scale(True) + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + dxf.standard_scale_type = 16 + dxf.scale_numerator, dxf.scale_denominator = numerator, denominator + target.use_standard_scale(False) + target.plot_centered(bool(resolved["center"])) + # Limits follow the paper; viewports are deliberately left alone + # (Paperspace.page_setup() would delete them). + target.reset_paper_limits() + self._mark_dirty() + after = self._page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.name, + "applied": dict(resolved), + "changed": changed, + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def plot_style_list(self) -> list[dict]: + from engineering.standards.papers import CTB_CATALOG + + return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + # Completed in Task 20; declared here so the ABC instantiates. + raise UnsupportedCapabilityError( + "dwt_write", "drawing_template_save: not implemented yet (Task 20)" + ) + # ── 3D solids (unsupported headlessly — honest capability boundary) ───── @staticmethod diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py new file mode 100644 index 0000000..54e7248 --- /dev/null +++ b/engineering/standards/papers.py @@ -0,0 +1,355 @@ +"""Paper sizes, plot-style catalogue and plot scales — authored data. + +Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as +AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*), +the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale +tables of two enums that do not agree with each other: + +* DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1, + 17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5. +* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib): + ac1_1=16, ac1_2=17, ac1_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, + ac2_1=28, ac10_1=31, acScaleToFit=0. + +A scale without a code on an engine goes through the custom numerator / +denominator on that engine, and the read-back reports the same label either way. +""" + +from __future__ import annotations + +import math +import re + +__all__ = [ + "ACTIVEX_PLOT_SCALE", + "CTB_CATALOG", + "DXF_STANDARD_SCALE_TYPE", + "ORIENTATIONS", + "PAPER_INCHES", + "PAPER_SIZES", + "PLOT_AREAS", + "PLOT_TYPE_NAMES", + "REQUIRED_SETUP_KEYS", + "SCALES", + "activex_scale_label", + "canonical_media_name", + "dxf_scale_label", + "paper_from_size", + "paper_size_mm", + "plot_style_known", + "require_page_setup", + "resolve_page_setup", + "scale_label", + "scale_ratio", +] + +#: Portrait width × height in millimetres. Landscape swaps. +PAPER_SIZES: dict[str, tuple[int, int]] = { + "ISO_A0": (841, 1189), + "ISO_A1": (594, 841), + "ISO_A2": (420, 594), + "ISO_A3": (297, 420), + "ISO_A4": (210, 297), + "ANSI_A": (216, 279), + "ANSI_B": (279, 432), + "ANSI_C": (432, 559), + "ANSI_D": (559, 864), + "ANSI_E": (864, 1118), +} + +#: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3 +#: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the +#: sheet measures. Portrait width × height. +PAPER_INCHES: dict[str, tuple[float, float]] = { + "ANSI_A": (8.5, 11.0), + "ANSI_B": (11.0, 17.0), + "ANSI_C": (17.0, 22.0), + "ANSI_D": (22.0, 34.0), + "ANSI_E": (34.0, 44.0), +} + +#: Spellings resolve_page_setup accepts, mapped to the canonical key. +_PAPER_ALIASES: dict[str, str] = { + **{name.lower(): name for name in PAPER_SIZES}, + **{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")}, + **{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")}, + **{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")}, +} + +#: Plot style tables AutoCAD ships (``/…/Plot Styles``). +CTB_CATALOG: tuple[str, ...] = ( + "monochrome.ctb", + "acad.ctb", + "Grayscale.ctb", + "Screening 100%.ctb", + "Screening 75%.ctb", + "Screening 50%.ctb", + "Screening 25%.ctb", + "Fill Patterns.ctb", + "DWF Virtual Pens.ctb", +) + +#: Label → paper:drawing factor (``None`` = scaled to fit). +SCALES: dict[str, float | None] = { + "fit": None, + "1:1": 1.0, + "1:2": 0.5, + "1:5": 0.2, + "1:10": 0.1, + "1:20": 0.05, + "1:50": 0.02, + "1:100": 0.01, + "2:1": 2.0, + "5:1": 5.0, + "10:1": 10.0, +} + +#: DXF group code 75. Labels missing here use the custom numerator/denominator. +DXF_STANDARD_SCALE_TYPE: dict[str, int] = { + "fit": 0, + "1:1": 16, + "1:2": 17, + "1:10": 20, + "1:20": 22, + "1:50": 25, + "1:100": 26, + "2:1": 27, + "10:1": 30, +} +_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()} + +#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale. +ACTIVEX_PLOT_SCALE: dict[str, int] = { + "fit": 0, + "1:1": 16, + "1:2": 17, + "1:5": 19, + "1:10": 21, + "1:20": 23, + "1:50": 26, + "1:100": 27, + "2:1": 28, + "10:1": 31, +} +_ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items()} + +#: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5). +PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1} +PLOT_TYPE_NAMES: dict[int, str] = { + 0: "display", + 1: "extents", + 2: "limits", + 3: "view", + 4: "window", + 5: "layout", +} + +ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait") + +_SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$") + + +def _paper_key(paper) -> str: + key = str(paper or "").strip().lower().replace(" ", "_") + resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " ")) + if resolved is None: + raise ValueError( + f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} " + "(short forms A0–A4 and ANSI_A–ANSI_E are accepted)" + ) + return resolved + + +def _orientation(orientation) -> str: + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}") + return orientation + + +def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]: + """(width, height) in millimetres for ``paper`` in ``orientation``.""" + name = _paper_key(paper) + short, long = PAPER_SIZES[name] + if _orientation(orientation) == "landscape": + return float(long), float(short) + return float(short), float(long) + + +def canonical_media_name(paper: str, orientation: str = "landscape") -> str: + """AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for + landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B.""" + name = _paper_key(paper) + if name in PAPER_INCHES: + short, long = PAPER_INCHES[name] + unit = "Inches" + else: + short, long = PAPER_SIZES[name] + unit = "MM" + width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long) + return f"{name}_({width:.2f}_x_{height:.2f}_{unit})" + + +def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None: + """The catalogue paper whose size (either orientation) is within ``tolerance`` mm.""" + try: + w, h = float(width_mm), float(height_mm) + except (TypeError, ValueError): + return None + for name, (short, long) in PAPER_SIZES.items(): + if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or ( + abs(w - short) <= tolerance and abs(h - long) <= tolerance + ): + return name + return None + + +def scale_ratio(label: str) -> tuple[float, float]: + """``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio.""" + match = _SCALE_RE.match(str(label or "")) + if match is None: + raise ValueError(f"scale: {label!r} is not an N:M ratio") + numerator, denominator = float(match.group(1)), float(match.group(2)) + if numerator <= 0 or denominator <= 0: + raise ValueError(f"scale: {label!r} must have positive terms") + return numerator, denominator + + +def scale_label(numerator, denominator) -> str: + """The ``N:M`` label for a ratio: a catalogue label when the factor matches + one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats.""" + num, den = float(numerator), float(denominator) + if num <= 0 or den <= 0: + return f"{num:g}:{den:g}" + for label, factor in SCALES.items(): + if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9): + return label + if num.is_integer() and den.is_integer(): + divisor = math.gcd(int(num), int(den)) + return f"{int(num) // divisor}:{int(den) // divisor}" + return f"{num:g}:{den:g}" + + +def dxf_scale_label(code, numerator, denominator) -> str: + """Read-back for the headless engine: the code-75 label, else the custom ratio.""" + return _DXF_SCALE_LABEL.get(int(code)) or scale_label(numerator, denominator) + + +def activex_scale_label(code) -> str: + """Read-back for the live engine: the AcPlotScale label, else ``standard:``.""" + return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}") + + +def plot_style_known(name) -> bool: + return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG} + + +def resolve_page_setup( + paper: str, + orientation: str = "landscape", + plot_style: str = "monochrome.ctb", + scale: str = "fit", + plot_area: str = "layout", + device: str = "DWG To PDF.pc3", + margins_mm=None, + center: bool = True, +) -> dict: + """Validate a page setup request and resolve it to what both engines write. + + Raises ``ValueError`` naming the offending field; nothing here touches a + drawing. ``margins_mm`` is ``[top, bottom, left, right]``. + """ + name = _paper_key(paper) + width, height = paper_size_mm(name, _orientation(orientation)) + + style = str(plot_style or "").strip() + if not style: + raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)") + + label = str(scale or "").strip() + if label not in SCALES: + raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}") + ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label) + + if plot_area not in PLOT_AREAS: + raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}") + + device_name = str(device or "").strip() + if not device_name: + raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')") + + margins = None + if margins_mm is not None: + try: + values = [float(v) for v in margins_mm] + except (TypeError, ValueError): + raise ValueError( + "margins_mm: must be four numbers [top, bottom, left, right]" + ) from None + if len(values) != 4: + raise ValueError( + f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}" + ) + if any(v < 0 for v in values): + raise ValueError("margins_mm: margins cannot be negative") + top, bottom, left, right = values + if top + bottom >= height or left + right >= width: + raise ValueError( + f"margins_mm: {values} leave no printable area on a {width:g} x {height:g} mm sheet" + ) + margins = values + + if not isinstance(center, bool): + raise ValueError(f"center: must be true or false, got {center!r}") + + return { + "paper": name, + "orientation": orientation, + "size_mm": [width, height], + "canonical_media_name": canonical_media_name(name, orientation), + "plot_style": style, + "plot_style_known": plot_style_known(style), + "scale": label, + "scale_factor": SCALES[label], + "scale_ratio": [ratio[0], ratio[1]], + "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label), + "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label), + "plot_area": plot_area, + "plot_type": PLOT_AREAS[plot_area], + "device": device_name, + "margins_mm": margins, + "center": center, + } + + +#: Every key resolve_page_setup emits that a backend reads. A backend refuses +#: a dict missing any of them before it writes. +REQUIRED_SETUP_KEYS: tuple[str, ...] = ( + "paper", + "orientation", + "size_mm", + "canonical_media_name", + "plot_style", + "plot_style_known", + "scale", + "scale_ratio", + "dxf_standard_scale_type", + "activex_standard_scale", + "plot_area", + "plot_type", + "device", + "margins_mm", + "center", +) + + +def require_page_setup(setup) -> dict: + """The backend-side check: ``setup`` is a complete resolve_page_setup dict.""" + if not isinstance(setup, dict): + raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns") + missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup] + if missing: + raise ValueError( + f"page_setup_apply: setup is missing {', '.join(missing)} — build it with " + "engineering.standards.papers.resolve_page_setup" + ) + return setup diff --git a/engineering/standards/pdfinfo.py b/engineering/standards/pdfinfo.py new file mode 100644 index 0000000..629d90d --- /dev/null +++ b/engineering/standards/pdfinfo.py @@ -0,0 +1,59 @@ +"""Read a PDF's page size back from the file — no dependency, no rendering. + +The evidence rule for page setup (spec §8.3): the sheet size a plot produced is +what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the +one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both +write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a +FlateDecode object stream, so when the plain scan finds nothing every stream is +inflated and searched. Anything else raises rather than guessing. +""" + +from __future__ import annotations + +import re +import zlib +from pathlib import Path + +__all__ = ["PT_TO_MM", "read_mediabox"] + +#: PostScript points to millimetres. +PT_TO_MM = 25.4 / 72.0 + +_NUMBER = rb"([-+]?\d*\.?\d+)" +_MEDIABOX_RE = re.compile( + rb"/MediaBox\s*\[\s*" + + _NUMBER + + rb"\s+" + + _NUMBER + + rb"\s+" + + _NUMBER + + rb"\s+" + + _NUMBER + + rb"\s*\]" +) +_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL) + + +def read_mediabox(path: str) -> tuple[float, float]: + """Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``. + + Raises ``ValueError`` when the file is not a PDF or carries no MediaBox. + ``/Rotate`` is not applied: the box is reported as written. + """ + data = Path(path).read_bytes() + if not data.startswith(b"%PDF"): + raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)") + match = _MEDIABOX_RE.search(data) + if match is None: + for stream in _STREAM_RE.finditer(data): + try: + inflated = zlib.decompress(stream.group(1)) + except zlib.error: + continue + match = _MEDIABOX_RE.search(inflated) + if match is not None: + break + if match is None: + raise ValueError(f"read_mediabox: no /MediaBox found in {path}") + x0, y0, x1, y1 = (float(value) for value in match.groups()) + return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3)) diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py new file mode 100644 index 0000000..427764e --- /dev/null +++ b/tests/test_page_setup.py @@ -0,0 +1,493 @@ +"""Page setup on both engines; the PDF's own /MediaBox is the evidence. + +ezdxf: apply a paper, export the layout, parse the PDF back and compare to the +paper size. COM: a fake ActiveX layout records the property sequence. +""" + +from __future__ import annotations + +import re +import types +import zlib + +import pytest + +from engineering.standards.papers import ( + CTB_CATALOG, + PAPER_SIZES, + SCALES, + canonical_media_name, + paper_from_size, + resolve_page_setup, + scale_label, + scale_ratio, +) +from engineering.standards.pdfinfo import read_mediabox + +pytestmark = pytest.mark.asyncio + +SHEET = "Sheet" + + +# ── pure data ─────────────────────────────────────────────────────────────── + + +def test_paper_sizes_are_iso_216_and_ansi_y14_1(): + assert PAPER_SIZES == { + "ISO_A0": (841, 1189), + "ISO_A1": (594, 841), + "ISO_A2": (420, 594), + "ISO_A3": (297, 420), + "ISO_A4": (210, 297), + "ANSI_A": (216, 279), + "ANSI_B": (279, 432), + "ANSI_C": (432, 559), + "ANSI_D": (559, 864), + "ANSI_E": (864, 1118), + } + assert CTB_CATALOG[0] == "monochrome.ctb" and "acad.ctb" in CTB_CATALOG + assert SCALES["fit"] is None and SCALES["1:50"] == 0.02 and SCALES["10:1"] == 10.0 + + +def test_canonical_media_name_uses_autocad_spelling(): + assert canonical_media_name("ISO_A3", "landscape") == "ISO_A3_(420.00_x_297.00_MM)" + assert canonical_media_name("ISO_A3", "portrait") == "ISO_A3_(297.00_x_420.00_MM)" + assert canonical_media_name("ANSI_B", "landscape") == "ANSI_B_(17.00_x_11.00_Inches)" + + +def test_paper_from_size_matches_either_orientation_within_half_a_millimetre(): + assert paper_from_size(420, 297) == "ISO_A3" + assert paper_from_size(297, 420) == "ISO_A3" + assert paper_from_size(431.8, 279.4) == "ANSI_B" + assert paper_from_size(400, 300) is None + + +def test_scale_ratio_and_label_round_trip(): + assert scale_ratio("1:50") == (1.0, 50.0) + assert scale_ratio("2:1") == (2.0, 1.0) + assert scale_label(1.0, 50.0) == "1:50" + assert scale_label(1.0, 3.0) == "1:3" + with pytest.raises(ValueError, match="scale"): + scale_ratio("fit") + + +def test_resolve_page_setup_defaults(): + setup = resolve_page_setup("ISO_A3") + assert setup["paper"] == "ISO_A3" + assert setup["orientation"] == "landscape" + assert setup["size_mm"] == [420.0, 297.0] + assert setup["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert setup["plot_style"] == "monochrome.ctb" and setup["plot_style_known"] is True + assert setup["scale"] == "fit" + assert setup["dxf_standard_scale_type"] == 0 and setup["activex_standard_scale"] == 0 + assert setup["scale_ratio"] == [1.0, 1.0] + assert setup["plot_area"] == "layout" and setup["plot_type"] == 5 + assert setup["device"] == "DWG To PDF.pc3" + assert setup["margins_mm"] is None and setup["center"] is True + + +def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): + assert resolve_page_setup("iso_a4", "portrait")["size_mm"] == [210.0, 297.0] + assert resolve_page_setup("A3")["paper"] == "ISO_A3" + assert resolve_page_setup("ansi_b")["canonical_media_name"] == "ANSI_B_(17.00_x_11.00_Inches)" + + +def test_resolve_page_setup_scale_codes(): + one_to_five = resolve_page_setup("ISO_A3", scale="1:5") + assert one_to_five["dxf_standard_scale_type"] is None # DXF code 75 has no 1:5 + assert one_to_five["activex_standard_scale"] == 19 # AcPlotScale.ac1_5 + assert one_to_five["scale_ratio"] == [1.0, 5.0] + five_to_one = resolve_page_setup("ISO_A3", scale="5:1") + assert five_to_one["dxf_standard_scale_type"] is None + assert five_to_one["activex_standard_scale"] is None + assert five_to_one["scale_ratio"] == [5.0, 1.0] + assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 25 + assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26 + + +@pytest.mark.parametrize( + "kwargs, field", + [ + ({"paper": "ISO_A9"}, "paper"), + ({"paper": "ISO_A3", "orientation": "sideways"}, "orientation"), + ({"paper": "ISO_A3", "scale": "1:3"}, "scale"), + ({"paper": "ISO_A3", "plot_area": "window"}, "plot_area"), + ({"paper": "ISO_A3", "device": ""}, "device"), + ({"paper": "ISO_A3", "plot_style": ""}, "plot_style"), + ({"paper": "ISO_A3", "margins_mm": [1, 2, 3]}, "margins_mm"), + ({"paper": "ISO_A3", "margins_mm": [-1, 0, 0, 0]}, "margins_mm"), + ({"paper": "ISO_A3", "margins_mm": [150, 150, 0, 0]}, "margins_mm"), + ({"paper": "ISO_A3", "center": "yes"}, "center"), + ], +) +def test_resolve_page_setup_refuses_naming_the_field(kwargs, field): + with pytest.raises(ValueError, match=field): + resolve_page_setup(**kwargs) + + +def test_unknown_ctb_is_allowed_but_flagged(): + setup = resolve_page_setup("ISO_A3", plot_style="company.ctb") + assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False + + +# ── the PDF reader ────────────────────────────────────────────────────────── + + +def test_read_mediabox_refuses_a_non_pdf(tmp_path): + bogus = tmp_path / "x.pdf" + bogus.write_bytes(b"not a pdf") + with pytest.raises(ValueError, match="PDF"): + read_mediabox(str(bogus)) + + +def test_read_mediabox_finds_a_box_inside_a_flate_stream(tmp_path): + body = zlib.compress(b"<< /Type /Page /MediaBox [0 0 595.276 841.89] >>") + pdf = tmp_path / "c.pdf" + pdf.write_bytes( + b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /Filter /FlateDecode >>\nstream\n" + + body + + b"\nendstream\nendobj\n%%EOF" + ) + width, height = read_mediabox(str(pdf)) + assert abs(width - 210.0) < 0.05 and abs(height - 297.0) < 0.05 + + +def test_read_mediabox_reports_a_missing_box(tmp_path): + pdf = tmp_path / "n.pdf" + pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF") + with pytest.raises(ValueError, match="MediaBox"): + read_mediabox(str(pdf)) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def _sheet(backend, name: str = SHEET) -> str: + result = await backend.layout_create(name) + assert result["ok"], result + return name + + +async def _mediabox_after_export(backend, tmp_path, layout: str): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + pdf = tmp_path / f"{layout}.pdf" + result = await backend.drawing_export_pdf(str(pdf), layout=layout) + assert result["ok"] is True, result + return read_mediabox(str(pdf)) + + +async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_path): + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", "landscape")) + assert result["ok"] is True and result["layout"] == SHEET + assert result["applied"]["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert result["plot_style_known"] is True + assert result["viewports_kept"] is True + + rows = await backend.page_setup_list(SHEET) + assert len(rows) == 1 + row = rows[0] + assert row["layout"] == SHEET + assert row["paper"] == "ISO_A3" + assert row["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert row["size_mm"] == [420.0, 297.0] + assert row["orientation"] == "landscape" + assert row["plot_style"] == "monochrome.ctb" + assert row["scale"] == "fit" + assert row["plot_area"] == "layout" + assert row["device"] == "DWG To PDF.pc3" + assert row["center"] is True + assert len(row["margins_mm"]) == 4 + + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 420.0) <= 0.5 and abs(height - 297.0) <= 0.5 + + +async def test_apply_ansi_b(backend, tmp_path): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ANSI_B", "landscape", scale="1:1")) + row = (await backend.page_setup_list(SHEET))[0] + assert row["paper"] == "ANSI_B" and row["size_mm"] == [432.0, 279.0] + assert row["scale"] == "1:1" + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 432.0) <= 0.5 and abs(height - 279.0) <= 0.5 + + +async def test_apply_portrait_a4(backend, tmp_path): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A4", "portrait")) + row = (await backend.page_setup_list(SHEET))[0] + assert row["orientation"] == "portrait" and row["size_mm"] == [210.0, 297.0] + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5 + + +async def test_apply_materialises_the_default_flags_before_setting_centre(backend): + """Measured: set_flag_state starts from 0 on a fresh layout, so a naive + plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first.""" + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert flags & 4, "centre-plot bit" + assert flags & 32, "plot with plot styles" + assert flags & 128, "plot entity lineweights" + assert flags & 512, "draw viewports first" + assert flags & 16, "use standard scale (fit)" + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert not flags & 4 and flags & 32 and flags & 128 and flags & 512 + + +async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5")) + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + assert not int(dxf.plot_layout_flags) & 16 + assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0) + assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5" + + +async def test_apply_keeps_the_viewports(backend): + await _sheet(backend) + await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0) + before = (await backend.viewport_list(SHEET))["count"] + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert (await backend.viewport_list(SHEET))["count"] == before + + +async def test_changed_reports_only_what_moved(backend): + await _sheet(backend) + first = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert first["changed"], "a fresh layout differs from an A3 setup somewhere" + second = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert second["changed"] == {} + third = await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_style="acad.ctb") + ) + assert third["changed"] == {"plot_style": ["monochrome.ctb", "acad.ctb"]} + + +async def test_unknown_ctb_is_written_and_flagged(backend): + await _sheet(backend) + result = await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_style="company.ctb") + ) + assert result["plot_style_known"] is False + assert (await backend.page_setup_list(SHEET))[0]["plot_style"] == "company.ctb" + + +async def test_refusals_happen_before_any_write(backend): + await _sheet(backend) + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + before = dxf.get("paper_size") + with pytest.raises(ValueError, match="Model"): + await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="canonical_media_name"): + await backend.page_setup_apply(SHEET, {"paper": "ISO_A3"}) + assert dxf.get("paper_size") == before + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_list("Nope") + + +async def test_list_covers_every_paper_layout_and_skips_model(backend): + await _sheet(backend, "A") + await _sheet(backend, "B") + names = [row["layout"] for row in await backend.page_setup_list()] + assert "Model" not in names and {"A", "B"} <= set(names) + + +async def test_plot_style_list_headless_is_the_catalog(backend): + rows = await backend.plot_style_list() + assert [row["name"] for row in rows] == list(CTB_CATALOG) + assert all(row["source"] == "catalog" and row["installed"] is None for row in rows) + + +async def test_dwt_write_capability_is_declared_false_headlessly(backend): + assert backend.capabilities().to_dict()["features"]["dwt_write"]["supported"] is False + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + +_MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)") + + +class _FakeLayout: + """Records every property set and method call, in order.""" + + def __init__(self, name, media_names): + object.__setattr__(self, "log", []) + object.__setattr__(self, "_media", tuple(media_names)) + object.__setattr__( + self, + "_props", + { + "Name": name, + "ConfigName": "None", + "CanonicalMediaName": "ISO_A4_(210.00_x_297.00_MM)", + "StyleSheet": "", + "PlotType": 5, + "StandardScale": 16, + "UseStandardScale": True, + "PlotRotation": 0, + "PaperUnits": 1, + "CenterPlot": False, + "PlotWithPlotStyles": True, + }, + ) + + def __getattr__(self, attr): + props = object.__getattribute__(self, "_props") + if attr in props: + return props[attr] + raise AttributeError(attr) + + def __setattr__(self, attr, value): + self._props[attr] = value + self.log.append(("set", attr, value)) + + def RefreshPlotDeviceInfo(self): + self.log.append(("call", "RefreshPlotDeviceInfo")) + + def GetCanonicalMediaNames(self): + self.log.append(("call", "GetCanonicalMediaNames")) + return self._media + + def GetPaperSize(self): + match = _MEDIA_RE.search(self._props["CanonicalMediaName"]) + width, height = float(match.group(1)), float(match.group(2)) + if match.group(3) == "Inches": + width, height = width * 25.4, height * 25.4 + return (width, height) + + def GetPaperMargins(self): + return ((7.5, 20.0), (7.5, 20.0)) + + def GetCustomScale(self): + return (1.0, 1.0) + + def SetCustomScale(self, numerator, denominator): + self.log.append(("call", "SetCustomScale", numerator, denominator)) + + +class _FakeLayouts: + def __init__(self, layouts): + self.layouts = list(layouts) + + @property + def Count(self): + return len(self.layouts) + + def Item(self, key): + if isinstance(key, int): + return self.layouts[key] + for layout in self.layouts: + if layout.Name == key: + return layout + raise RuntimeError(f"no layout {key}") + + +MEDIA = ( + "ISO_A3_(420.00_x_297.00_MM)", + "ISO_A4_(210.00_x_297.00_MM)", + "ANSI_B_(17.00_x_11.00_Inches)", +) + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = types.SimpleNamespace( + Layouts=_FakeLayouts([_FakeLayout("Model", MEDIA), _FakeLayout("Layout1", MEDIA)]) + ) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_apply_issues_the_property_sequence(com_backend): + backend, document = com_backend + result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", "landscape")) + assert result["ok"] is True and result["plot_style_known"] is True + layout = document.Layouts.Item("Layout1") + assert layout.log == [ + ("call", "RefreshPlotDeviceInfo"), + ("set", "ConfigName", "DWG To PDF.pc3"), + ("call", "RefreshPlotDeviceInfo"), + ("call", "GetCanonicalMediaNames"), + ("set", "CanonicalMediaName", "ISO_A3_(420.00_x_297.00_MM)"), + ("set", "PaperUnits", 1), + ("set", "PlotRotation", 0), + ("set", "StyleSheet", "monochrome.ctb"), + ("set", "PlotWithPlotStyles", True), + ("set", "PlotType", 5), + ("set", "StandardScale", 0), + ("set", "UseStandardScale", True), + ("set", "CenterPlot", True), + ] + assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"] + assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"] + assert result["changed"]["center"] == [False, True] + + +async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend): + backend, document = com_backend + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1")) + log = document.Layouts.Item("Layout1").log + assert ("call", "SetCustomScale", 5.0, 1.0) in log + assert ("set", "UseStandardScale", False) in log + assert not any(entry[:2] == ("set", "StandardScale") for entry in log) + + +async def test_com_apply_refuses_margins_before_any_write(com_backend): + backend, document = com_backend + with pytest.raises(ValueError, match="margins_mm"): + await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", margins_mm=[10, 10, 10, 10]) + ) + assert document.Layouts.Item("Layout1").log == [] + + +async def test_com_apply_restores_the_device_when_the_media_name_is_unknown(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + object.__setattr__(layout, "_media", ("ISO_A4_(210.00_x_297.00_MM)",)) + with pytest.raises(ValueError, match=r"ISO_A3_\(420.00_x_297.00_MM\)"): + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) + assert layout.ConfigName == "None", "the device write is rolled back" + assert layout.CanonicalMediaName == "ISO_A4_(210.00_x_297.00_MM)" + + +async def test_com_apply_refuses_model_and_unknown_layouts(com_backend): + backend, _ = com_backend + with pytest.raises(ValueError, match="Model"): + await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3")) + + +async def test_com_list_reads_the_properties(com_backend): + backend, _ = com_backend + rows = await backend.page_setup_list() + assert [row["layout"] for row in rows] == ["Layout1"] + row = rows[0] + assert row["paper"] == "ISO_A4" and row["size_mm"] == [210.0, 297.0] + assert row["orientation"] == "portrait" + assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + assert row["scale"] == "1:1" and row["plot_area"] == "layout" + assert row["device"] == "None" and row["center"] is False + + +async def test_com_declares_dwt_write(): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends.com_backend import ComBackend + + assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True From 70f18efb4b0362bae573522429f6634b3076bfb5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:30:52 +0300 Subject: [PATCH 02/14] fix(task-17): rotated sheets (plot_rotation 1/3) render the whole page instead of a 0.707-scaled, clipped window AutoCAD stores ISO A4 landscape as 210 x 297 media with plot_rotation 1; paper-space X then runs along the 297 mm side. _paper_frame turned the figure but not the axes window, so a 297 x 210 page carried a 210-wide data window; set_aspect("equal") shrank the axes box to 0.707 and everything past x = 210 minus the margin was cut, with ok: True and a MediaBox that read as correct. The window now takes the turned size, and the margins turn with it the way ezdxf's own Page.from_dxf_layout maps them (rotation 2 included). Test: a rotated layout with a 277 mm frame exported to PNG; the ink must reach the right of the sheet (it stopped at column 1314/1753, now 1752), and the MediaBox still reads 297 x 210. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 37 +++++++++++++++++++------- tests/test_page_setup.py | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 80a1311..717512f 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2379,21 +2379,40 @@ class EzdxfBackend(AutoCADBackend): """The sheet as a matplotlib figure: size in inches plus the axes window. Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper. - ``plot_rotation`` 1/3 swaps the figure; the content is not rotated by - the headless renderer (reported, not hidden). + + ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media + convention: the media stays 210 x 297 and the sheet is turned, so + paper-space X runs along the 297 mm side. The figure *and* the axes + window both take the turned size — sizing only the figure left the + window 210 wide inside a 297 mm page, and ``set_aspect("equal")`` then + shrank the axes box to 0.707 and cut everything past x = 210 minus the + margin (measured: a 277 mm frame lost its right 27%, with ``ok: True`` + and a MediaBox that read as correct). The margins turn with the sheet, + mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them. What + the headless renderer still does not do is rotate the *content* on the + media (reported as ``rotation_applied``, not hidden). """ dxf = layout.dxf_layout.dxf width, height = float(dxf.paper_width), float(dxf.paper_height) rotation = int(dxf.plot_rotation) unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 - x0 = -(float(dxf.left_margin) + float(dxf.plot_origin_x_offset)) - y0 = -(float(dxf.bottom_margin) + float(dxf.plot_origin_y_offset)) - fig_w, fig_h = (height, width) if rotation in (1, 3) else (width, height) + top, right = float(dxf.top_margin), float(dxf.right_margin) + bottom, left = float(dxf.bottom_margin), float(dxf.left_margin) + if rotation == 1: + sheet_w, sheet_h, left, bottom = height, width, top, left + elif rotation == 2: + sheet_w, sheet_h, left, bottom = width, height, right, top + elif rotation == 3: + sheet_w, sheet_h, left, bottom = height, width, bottom, right + else: + sheet_w, sheet_h = width, height + x0 = -(left + float(dxf.plot_origin_x_offset)) + y0 = -(bottom + float(dxf.plot_origin_y_offset)) return { - "size_mm": [round(fig_w, 2), round(fig_h, 2)], - "figsize": (fig_w / 25.4, fig_h / 25.4), - "xlim": (x0 / unit, (x0 + width) / unit), - "ylim": (y0 / unit, (y0 + height) / unit), + "size_mm": [round(sheet_w, 2), round(sheet_h, 2)], + "figsize": (sheet_w / 25.4, sheet_h / 25.4), + "xlim": (x0 / unit, (x0 + sheet_w) / unit), + "ylim": (y0 / unit, (y0 + sheet_h) / unit), "rotation_applied": rotation == 0, } diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 427764e..afd0604 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -222,6 +222,62 @@ async def test_apply_portrait_a4(backend, tmp_path): assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5 +def _ink_columns(png_path: str) -> tuple[int, int, int]: + """(first inked column, last inked column, image width) of a rendered PNG.""" + from PIL import Image + + image = Image.open(png_path).convert("L") + width, height = image.size + pixels = image.load() + inked = [x for x in range(width) if any(pixels[x, y] < 128 for y in range(height))] + assert inked, "the render is blank" + return inked[0], inked[-1], width + + +async def test_rotated_layout_renders_the_whole_sheet(backend, tmp_path): + """plot_rotation 1 is AutoCAD's landscape-on-portrait-media convention. + + A drawing authored in AutoCAD stores ISO A4 landscape as paper 210 x 297 + with ``plot_rotation`` 1; paper-space X then runs along the 297 mm side. + The MediaBox alone cannot see a frame that has been clipped inside a + correctly sized page, so this test looks at the ink: the frame's right + edge (x = 277, the printable width) must reach the right of the sheet. + """ + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + pytest.importorskip("PIL", reason="pixel check needs Pillow") + await _sheet(backend, "Rot") + lay = backend._doc.layouts.get("Rot") + lay.page_setup( + size=(210, 297), + margins=(20, 7.5, 20, 7.5), + units="mm", + rotation=1, + scale=16, + name="ISO_A4", + device="DWG To PDF.pc3", + ) + lay.add_lwpolyline([(0, 0), (277, 0), (277, 190), (0, 190)], close=True) + + row = (await backend.page_setup_list("Rot"))[0] + assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape" + + png = tmp_path / "rot.png" + result = await backend.drawing_export_pdf(str(png), layout="Rot") + assert result["ok"] is True, result + assert result["paper_mm"] == [297.0, 210.0] + assert result["rotation_applied"] is False + + first, last, width = _ink_columns(str(png)) + # x = 277 sits at 277 / 297 of the sheet from the left margin, i.e. well + # past the 0.707-scaled box the unrotated window produced (col 1314/1753). + assert last >= int(width * 0.9), (first, last, width) + + pdf = tmp_path / "rot.pdf" + await backend.drawing_export_pdf(str(pdf), layout="Rot") + mm_w, mm_h = read_mediabox(str(pdf)) + assert abs(mm_w - 297.0) <= 0.5 and abs(mm_h - 210.0) <= 0.5 + + async def test_apply_materialises_the_default_flags_before_setting_centre(backend): """Measured: set_flag_state starts from 0 on a fresh layout, so a naive plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first.""" From 20ca39d28a71700595d33a8d5191f3a2487099a3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:55:14 +0300 Subject: [PATCH 03/14] fix(task-17): a layout plot never writes CenterPlot, and a refused COM write unwinds every landed one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ActiveX Reference (CenterPlot Property, acadauto.chm, AutoCAD 2026): "This property cannot be set to True on a layout object whose PlotType property is set to acLayout." The shipped sequence wrote PlotType=5 and then CenterPlot=True for the *default* call, so page_setup_apply(layout, resolve_page_setup("ISO_A3")) could not succeed live — either COM raised with nine writes landed and nothing put back, or the result claimed applied.center=True over a read-back of False. - resolve_page_setup resolves `center` to None for plot_area="layout" (validated as a bool first; kept as the bool for "extents"), and `center_applies(plot_type)` carries the rule for both read-backs. - Both engines: page_setup_list reports `center: None` under acLayout instead of a stored flag nothing honours; apply never sets the centre bit / CenterPlot for a layout plot (ezdxf clears bit 4, the state AutoCAD stores for acLayout; COM clears a stale CenterPlot=True *before* PlotType moves to acLayout, and writes CenterPlot for an extents plot only *after* PlotType=acExtents). - COM: every write is journaled and unwound in reverse when AutoCAD refuses one; the ValueError names the refused property and says the layout was restored (or what could not be). The old device-only rollback is the same mechanism now. - tests: the fake enforces the documented CenterPlot rule (the shipped sequence fails under it, which is the reviewer's repro), and can refuse a named property; new cases pin the no-write, the ordering both ways, the reverse unwind including SetCustomScale, and the None read-backs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 138 +++++++++++++++++++++++------- backends/contracts/pagesetup.py | 9 +- backends/ezdxf_backend.py | 11 ++- engineering/standards/papers.py | 37 +++++++- tests/test_page_setup.py | 146 +++++++++++++++++++++++++++++--- 5 files changed, 292 insertions(+), 49 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 227dea0..17809bf 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -13,6 +13,7 @@ import math import sys import time import uuid +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -1961,7 +1962,14 @@ class ComBackend(AutoCADBackend): # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0; # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16 # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE). - # Fake-tested in tests/test_page_setup.py; executed live by + # CenterPlot (ActiveX Reference, acadauto.chm): "This property cannot be + # set to True on a layout object whose PlotType property is set to + # acLayout" — so a layout plot never writes it (resolve_page_setup hands + # over None), an extents plot writes it *after* PlotType, and a stale True + # is cleared *before* PlotType moves to acLayout. Every write is journaled + # and unwound in reverse when AutoCAD refuses one, so a failed apply leaves + # the layout as it was. Fake-tested in tests/test_page_setup.py (the fake + # enforces the CenterPlot rule); executed live by # scripts/smoke_settings_com.py (Task 24). _AC_MILLIMETERS = 1 @@ -1972,6 +1980,7 @@ class ComBackend(AutoCADBackend): from engineering.standards.papers import ( PLOT_TYPE_NAMES, activex_scale_label, + center_applies, paper_from_size, scale_label, ) @@ -2024,7 +2033,7 @@ class ComBackend(AutoCADBackend): "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), "device": _get("ConfigName"), "margins_mm": margins, - "center": bool(_get("CenterPlot")), + "center": bool(_get("CenterPlot")) if center_applies(plot_type) else None, } def _com_paper_layout(self, doc, raw: str, operation: str): @@ -2071,37 +2080,81 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() target = self._com_paper_layout(doc, layout, "page_setup_apply") before = self._com_page_setup_row(target) - previous_device = before["device"] - target.RefreshPlotDeviceInfo() - target.ConfigName = resolved["device"] - target.RefreshPlotDeviceInfo() - media = resolved["canonical_media_name"] - names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] - if media not in names: - # Roll the one write back before refusing, so a refusal leaves - # the layout as it was. - target.ConfigName = previous_device - nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] - raise ValueError( - f"page_setup_apply: device {resolved['device']!r} has no media " - f"{media!r} (same paper on this device: {', '.join(nearest) or 'none'}; " - f"{len(names)} media in total)" - ) - target.CanonicalMediaName = media - target.PaperUnits = self._AC_MILLIMETERS - target.PlotRotation = self._AC_NO_ROTATION - target.StyleSheet = resolved["plot_style"] - target.PlotWithPlotStyles = True - target.PlotType = int(resolved["plot_type"]) - code = resolved["activex_standard_scale"] - if code is not None: - target.StandardScale = int(code) - target.UseStandardScale = True - else: - numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + # Journal of (undo callable, label) for every write that landed, + # unwound in reverse when a later one is refused. + journal: list[tuple[Callable[[], None], str]] = [] + + def _set(attr: str, value): + previous = getattr(target, attr) + setattr(target, attr, value) + journal.append((lambda: setattr(target, attr, previous), attr)) + + def _set_custom_scale(numerator: float, denominator: float): + previous = tuple(float(v) for v in target.GetCustomScale()) target.SetCustomScale(numerator, denominator) - target.UseStandardScale = False - target.CenterPlot = bool(resolved["center"]) + journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale")) + + step = "ConfigName" + try: + target.RefreshPlotDeviceInfo() + _set("ConfigName", resolved["device"]) + target.RefreshPlotDeviceInfo() + media = resolved["canonical_media_name"] + names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] + if media not in names: + nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] + raise ValueError( + f"page_setup_apply: device {resolved['device']!r} has no media " + f"{media!r} (same paper on this device: " + f"{', '.join(nearest) or 'none'}; {len(names)} media in total)" + ) + step = "CanonicalMediaName" + _set("CanonicalMediaName", media) + step = "PaperUnits" + _set("PaperUnits", self._AC_MILLIMETERS) + step = "PlotRotation" + _set("PlotRotation", self._AC_NO_ROTATION) + step = "StyleSheet" + _set("StyleSheet", resolved["plot_style"]) + step = "PlotWithPlotStyles" + _set("PlotWithPlotStyles", True) + center = resolved["center"] + if center is None and self._com_center_plot_is_set(target): + # Clearing is legal under any PlotType; the layout plot + # then carries the flag AutoCAD stores for acLayout. + step = "CenterPlot" + _set("CenterPlot", False) + step = "PlotType" + _set("PlotType", int(resolved["plot_type"])) + code = resolved["activex_standard_scale"] + if code is not None: + step = "StandardScale" + _set("StandardScale", int(code)) + step = "UseStandardScale" + _set("UseStandardScale", True) + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + step = "SetCustomScale" + _set_custom_scale(numerator, denominator) + step = "UseStandardScale" + _set("UseStandardScale", False) + if center is not None: + # Extents plot: PlotType is already acExtents, so True is legal. + step = "CenterPlot" + _set("CenterPlot", bool(center)) + except Exception as exc: + failed = self._com_unwind_page_setup(journal) + if isinstance(exc, ValueError) and not failed: + raise + restored = ( + f"layout {target.Name!r} restored to its previous page setup" + if not failed + else f"layout {target.Name!r} could NOT be fully restored " + f"(still changed: {', '.join(failed)})" + ) + raise ValueError( + f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}" + ) from exc after = self._com_page_setup_row(target) changed = { key: [before[key], after[key]] @@ -2120,6 +2173,27 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + @staticmethod + def _com_center_plot_is_set(layout) -> bool: + """Whether the layout carries CenterPlot=True (a failed read counts as no).""" + try: + return bool(layout.CenterPlot) + except Exception as exc: + log.debug("Layout.CenterPlot read failed: %s", exc) + return False + + @staticmethod + def _com_unwind_page_setup(journal: list[tuple[Callable[[], None], str]]) -> list[str]: + """Undo every journaled page-setup write in reverse; return what would not go back.""" + failed: list[str] = [] + for undo, label in reversed(journal): + try: + undo() + except Exception as exc: + log.warning("page_setup_apply: could not restore Layout.%s: %s", label, exc) + failed.append(label) + return failed + async def plot_style_list(self) -> list[dict]: # Completed in Task 18 (installed ctb scan); the catalogue rows already answer. from engineering.standards.papers import CTB_CATALOG diff --git a/backends/contracts/pagesetup.py b/backends/contracts/pagesetup.py index 464e00e..9226d8f 100644 --- a/backends/contracts/pagesetup.py +++ b/backends/contracts/pagesetup.py @@ -18,7 +18,10 @@ class PageSetupContract(ABC): orientation, plot_style, scale, plot_area, device, margins_mm: [top, bottom, left, right], center}``. ``paper`` is the catalogue name when the size matches one within 0.5 mm, otherwise the - media name as stored. Raises ``ValueError`` for an unknown layout. + media name as stored. ``center`` is a bool for an extents plot and + ``None`` for a layout plot, where centring is not applicable (AutoCAD + greys it out; ActiveX refuses ``CenterPlot = True`` under + ``acLayout``). Raises ``ValueError`` for an unknown layout. ``Model`` is never listed: model space is plotted through ``drawing_export_pdf(layout=None)`` and carries no sheet. """ @@ -33,7 +36,9 @@ class PageSetupContract(ABC): plot_style_known, viewports_kept}``. ``changed`` is computed from the read-back before and after, so re-applying the same setup reports ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a - resolved key, before anything is written. Never deletes viewports. + resolved key, before anything is written. A write the engine refuses + part-way through is unwound (every landed value put back) before the + ``ValueError`` names the refused property. Never deletes viewports. """ ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 717512f..61f01e9 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2336,6 +2336,7 @@ class EzdxfBackend(AutoCADBackend): def _page_setup_row(layout) -> dict: from engineering.standards.papers import ( PLOT_TYPE_NAMES, + center_applies, dxf_scale_label, paper_from_size, scale_label, @@ -2355,6 +2356,7 @@ class EzdxfBackend(AutoCADBackend): scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator) else: scale = scale_label(numerator, denominator) + plot_type = int(dxf.plot_type) return { "layout": layout.name, "paper": paper_from_size(width, height) or media, @@ -2363,7 +2365,7 @@ class EzdxfBackend(AutoCADBackend): "orientation": "landscape" if width >= height else "portrait", "plot_style": str(dxf.current_style_sheet), "scale": scale, - "plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)), + "plot_area": PLOT_TYPE_NAMES.get(plot_type, str(dxf.plot_type)), "device": str(dxf.plot_configuration_file), "margins_mm": [ round(float(dxf.top_margin), 3), @@ -2371,7 +2373,9 @@ class EzdxfBackend(AutoCADBackend): round(float(dxf.left_margin), 3), round(float(dxf.right_margin), 3), ], - "center": bool(flags & layout.PLOT_CENTERED), + # A layout plot has no centring (AutoCAD greys it out; ActiveX + # refuses CenterPlot=True under acLayout): None, not a bit nobody honours. + "center": bool(flags & layout.PLOT_CENTERED) if center_applies(plot_type) else None, } @staticmethod @@ -2474,6 +2478,9 @@ class EzdxfBackend(AutoCADBackend): dxf.standard_scale_type = 16 dxf.scale_numerator, dxf.scale_denominator = numerator, denominator target.use_standard_scale(False) + # resolve_page_setup hands over None for a layout plot: the bit is + # cleared, never set — the state AutoCAD itself stores for acLayout, + # and the mirror of the live engine never writing CenterPlot there. target.plot_centered(bool(resolved["center"])) # Limits follow the paper; viewports are deliberately left alone # (Paperspace.page_setup() would delete them). diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py index 54e7248..0f97c6c 100644 --- a/engineering/standards/papers.py +++ b/engineering/standards/papers.py @@ -13,6 +13,16 @@ tables of two enums that do not agree with each other: A scale without a code on an engine goes through the custom numerator / denominator on that engine, and the read-back reports the same label either way. + +Centring belongs to an *extents* plot only. The ActiveX Reference (CenterPlot +Property, acadauto.chm, AutoCAD 2026): "This property cannot be set to True on +a layout object whose PlotType property is set to acLayout" — a layout plot is +the whole sheet from its own origin, and the Page Setup dialog greys the box +out. ``resolve_page_setup`` therefore resolves ``center`` to ``None`` (not +applicable, never written, read back as ``None``) when ``plot_area`` is +``layout``; the bool is kept only for ``extents``. Both engines follow the +same resolved value, so the DXF never carries a centre bit AutoCAD would +ignore and the live engine never issues the write AutoCAD refuses. """ from __future__ import annotations @@ -28,11 +38,13 @@ __all__ = [ "PAPER_INCHES", "PAPER_SIZES", "PLOT_AREAS", + "PLOT_TYPE_LAYOUT", "PLOT_TYPE_NAMES", "REQUIRED_SETUP_KEYS", "SCALES", "activex_scale_label", "canonical_media_name", + "center_applies", "dxf_scale_label", "paper_from_size", "paper_size_mm", @@ -135,6 +147,8 @@ _ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items( #: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5). PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1} +#: ``AcPlotType.acLayout`` — the plot type under which centring does not apply. +PLOT_TYPE_LAYOUT = 5 PLOT_TYPE_NAMES: dict[int, str] = { 0: "display", 1: "extents", @@ -243,6 +257,19 @@ def plot_style_known(name) -> bool: return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG} +def center_applies(plot_type) -> bool: + """Whether "centre the plot" means anything under this ``AcPlotType``. + + False for ``acLayout`` (5): the sheet is plotted from its origin, AutoCAD + greys the option out and ActiveX refuses ``CenterPlot = True``. Read-backs + report ``center: None`` there instead of a stored flag nothing honours. + """ + try: + return int(plot_type) != PLOT_TYPE_LAYOUT + except (TypeError, ValueError): + return False + + def resolve_page_setup( paper: str, orientation: str = "landscape", @@ -256,7 +283,10 @@ def resolve_page_setup( """Validate a page setup request and resolve it to what both engines write. Raises ``ValueError`` naming the offending field; nothing here touches a - drawing. ``margins_mm`` is ``[top, bottom, left, right]``. + drawing. ``margins_mm`` is ``[top, bottom, left, right]``. ``center`` is + validated as a bool and resolved to ``None`` for ``plot_area="layout"``, + where centring is not applicable (see the module docstring) — the default + call (layout, centre) is therefore a layout plot with no centring write. """ name = _paper_key(paper) width, height = paper_size_mm(name, _orientation(orientation)) @@ -300,6 +330,7 @@ def resolve_page_setup( if not isinstance(center, bool): raise ValueError(f"center: must be true or false, got {center!r}") + plot_type = PLOT_AREAS[plot_area] return { "paper": name, @@ -314,10 +345,10 @@ def resolve_page_setup( "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label), "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label), "plot_area": plot_area, - "plot_type": PLOT_AREAS[plot_area], + "plot_type": plot_type, "device": device_name, "margins_mm": margins, - "center": center, + "center": center if center_applies(plot_type) else None, } diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index afd0604..1e93940 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -83,7 +83,21 @@ def test_resolve_page_setup_defaults(): assert setup["scale_ratio"] == [1.0, 1.0] assert setup["plot_area"] == "layout" and setup["plot_type"] == 5 assert setup["device"] == "DWG To PDF.pc3" - assert setup["margins_mm"] is None and setup["center"] is True + assert setup["margins_mm"] is None + assert setup["center"] is None, "a layout plot has no centring (AutoCAD greys it out)" + + +def test_resolve_page_setup_center_applies_to_an_extents_plot_only(): + """ActiveX Reference, CenterPlot: 'This property cannot be set to True on a + layout object whose PlotType property is set to acLayout.' The resolver + carries the rule so both engines write the same thing: a bool for an + extents plot, ``None`` (not applicable, never written) for a layout plot.""" + assert resolve_page_setup("ISO_A3", plot_area="extents")["center"] is True + assert resolve_page_setup("ISO_A3", plot_area="extents", center=False)["center"] is False + assert resolve_page_setup("ISO_A3", plot_area="layout", center=True)["center"] is None + assert resolve_page_setup("ISO_A3", plot_area="layout", center=False)["center"] is None + with pytest.raises(ValueError, match="center"): + resolve_page_setup("ISO_A3", plot_area="layout", center="yes") def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): @@ -196,7 +210,7 @@ async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_p assert row["scale"] == "fit" assert row["plot_area"] == "layout" assert row["device"] == "DWG To PDF.pc3" - assert row["center"] is True + assert row["center"] is None, "layout plot: centring is not applicable" assert len(row["margins_mm"]) == 4 width, height = await _mediabox_after_export(backend, tmp_path, SHEET) @@ -282,16 +296,45 @@ async def test_apply_materialises_the_default_flags_before_setting_centre(backen """Measured: set_flag_state starts from 0 on a fresh layout, so a naive plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first.""" await _sheet(backend) - await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True)) + extents = resolve_page_setup("ISO_A3", plot_area="extents", center=True) + result = await backend.page_setup_apply(SHEET, extents) flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) assert flags & 4, "centre-plot bit" assert flags & 32, "plot with plot styles" assert flags & 128, "plot entity lineweights" assert flags & 512, "draw viewports first" assert flags & 16, "use standard scale (fit)" - await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False)) + assert result["applied"]["center"] is True + assert (await backend.page_setup_list(SHEET))[0]["center"] is True + await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_area="extents", center=False) + ) flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) assert not flags & 4 and flags & 32 and flags & 128 and flags & 512 + assert (await backend.page_setup_list(SHEET))[0]["center"] is False + + +async def test_layout_plot_never_sets_the_centre_bit_and_reports_it_not_applicable(backend): + """A layout plot is the whole sheet from its origin: AutoCAD disables + 'Center the plot' (ActiveX refuses CenterPlot=True under acLayout). The + headless engine mirrors that: bit 4 is not written, and the read-back says + None rather than a bool AutoCAD would ignore.""" + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert not flags & 4, "centre bit must not be set for a layout plot" + assert result["applied"]["center"] is None + assert "center" not in result["changed"], "None before (fresh layout plot) and None after" + assert (await backend.page_setup_list(SHEET))[0]["center"] is None + # Switching an extents+centred sheet to a layout plot clears the stale bit + # (what AutoCAD stores for a layout plot) and reports the move honestly. + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", plot_area="extents")) + assert (await backend.page_setup_list(SHEET))[0]["center"] is True + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert not flags & 4 + assert result["changed"]["center"] == [True, None] + assert result["changed"]["plot_area"] == ["extents", "layout"] async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend): @@ -370,11 +413,21 @@ _MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)") class _FakeLayout: - """Records every property set and method call, in order.""" + """Records every property set and method call, in order. - def __init__(self, name, media_names): + Enforces the one rule the ActiveX Reference documents for this surface + (CenterPlot Property, acadauto.chm, AutoCAD 2026): "This property cannot + be set to True on a layout object whose PlotType property is set to + acLayout." ``refuse`` names properties whose write raises, the way a live + AutoCAD refuses a value it cannot take. + """ + + AC_LAYOUT = 5 + + def __init__(self, name, media_names, refuse=()): object.__setattr__(self, "log", []) object.__setattr__(self, "_media", tuple(media_names)) + object.__setattr__(self, "_refuse", tuple(refuse)) object.__setattr__( self, "_props", @@ -400,9 +453,16 @@ class _FakeLayout: raise AttributeError(attr) def __setattr__(self, attr, value): + if attr in self._refuse: + raise RuntimeError(f"AutoCAD refused Layout.{attr} = {value!r}") + if attr == "CenterPlot" and value and self._props["PlotType"] == self.AC_LAYOUT: + raise RuntimeError("CenterPlot cannot be True while PlotType is acLayout") self._props[attr] = value self.log.append(("set", attr, value)) + def snapshot(self) -> dict: + return dict(object.__getattribute__(self, "_props")) + def RefreshPlotDeviceInfo(self): self.log.append(("call", "RefreshPlotDeviceInfo")) @@ -487,11 +547,67 @@ async def test_com_apply_issues_the_property_sequence(com_backend): ("set", "PlotType", 5), ("set", "StandardScale", 0), ("set", "UseStandardScale", True), - ("set", "CenterPlot", True), - ] + ], "a layout plot never writes CenterPlot (ActiveX refuses True under acLayout)" assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"] assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"] - assert result["changed"]["center"] == [False, True] + assert result["applied"]["center"] is None + assert "center" not in result["changed"] + + +async def test_com_apply_extents_sets_plot_type_before_center_plot(com_backend): + backend, document = com_backend + result = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", plot_area="extents", center=True) + ) + log = document.Layouts.Item("Layout1").log + assert log.index(("set", "PlotType", 1)) < log.index(("set", "CenterPlot", True)) + assert result["changed"]["center"] == [None, True] + assert result["changed"]["plot_area"] == ["layout", "extents"] + assert (await backend.page_setup_list("Layout1"))[0]["center"] is True + + +async def test_com_apply_clears_a_stale_center_plot_before_switching_to_layout(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PlotType = 1 + layout.CenterPlot = True + del layout.log[:] + result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) + log = layout.log + assert log.index(("set", "CenterPlot", False)) < log.index(("set", "PlotType", 5)) + assert layout.CenterPlot is False and layout.PlotType == 5 + assert result["changed"]["center"] == [True, None] + + +async def test_com_apply_restores_every_write_when_autocad_refuses_a_property(com_backend): + """Nine writes had landed before CenterPlot in the shipped sequence, with + nothing put back. Any refusal now unwinds the journal in reverse.""" + backend, document = com_backend + layout = _FakeLayout("Layout1", MEDIA, refuse=("PlotWithPlotStyles",)) + document.Layouts.layouts[1] = layout + before = layout.snapshot() + with pytest.raises(ValueError, match="PlotWithPlotStyles") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) + assert "restored" in str(info.value) + assert layout.snapshot() == before, "every property AutoCAD accepted is put back" + restores = [entry for entry in layout.log if entry[0] == "set"][5:] + assert restores == [ + ("set", "StyleSheet", ""), + ("set", "PlotRotation", 0), + ("set", "PaperUnits", 1), + ("set", "CanonicalMediaName", "ISO_A4_(210.00_x_297.00_MM)"), + ("set", "ConfigName", "None"), + ], "unwound in reverse write order" + + +async def test_com_apply_restores_the_custom_scale_too(com_backend): + backend, document = com_backend + layout = _FakeLayout("Layout1", MEDIA, refuse=("UseStandardScale",)) + document.Layouts.layouts[1] = layout + with pytest.raises(ValueError, match="UseStandardScale"): + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1")) + calls = [entry for entry in layout.log if entry[0] == "call" and entry[1] == "SetCustomScale"] + assert calls == [("call", "SetCustomScale", 5.0, 1.0), ("call", "SetCustomScale", 1.0, 1.0)] async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend): @@ -539,7 +655,17 @@ async def test_com_list_reads_the_properties(com_backend): assert row["orientation"] == "portrait" assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] assert row["scale"] == "1:1" and row["plot_area"] == "layout" - assert row["device"] == "None" and row["center"] is False + assert row["device"] == "None" + assert row["center"] is None, "acLayout: CenterPlot is not applicable" + + +async def test_com_list_reports_center_for_an_extents_plot(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PlotType = 1 + assert (await backend.page_setup_list("Layout1"))[0]["center"] is False + layout.CenterPlot = True + assert (await backend.page_setup_list("Layout1"))[0]["center"] is True async def test_com_declares_dwt_write(): From 376dc812a52c1780f7ab9ae900c242c774b42757 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:42:13 +0300 Subject: [PATCH 04/14] fix(task-17): COM page setup reads mm as mm, 1:5 takes the custom route, and a refused apply puts the media back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three live-measured behaviours of AutoCAD 2026 that the fake did not replay: * GetPaperSize / GetPaperMargins answer in millimetres whatever PaperUnits says; the 25.4 factor listed every imperial layout as [10668.0, 7543.8] and lost the paper name. No factor now; the fake pins PaperUnits=0. * Layout.StandardScale = 19 (ac1_5) is refused with "Invalid input" in every condition; '1:5' is dropped from ACTIVEX_PLOT_SCALE so it goes through SetCustomScale(1, 5) like 5:1 already did. * Writing ConfigName replaces CanonicalMediaName with the new device's default, and writing the old device back does not bring the media back — the unknown-media refusal and a late refusal both left the layout on the old device's default paper while the error claimed it was restored. The device write now snapshots media / units / rotation and journals one combined undo; the read-back after every unwind is compared to the read-back before the first write and the message names what did not go back instead of asserting it did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 64 ++++++++++++++-- engineering/standards/papers.py | 11 ++- tests/test_page_setup.py | 125 ++++++++++++++++++++++++++++++-- 3 files changed, 184 insertions(+), 16 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 17809bf..7010cb1 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1968,12 +1968,34 @@ class ComBackend(AutoCADBackend): # over None), an extents plot writes it *after* PlotType, and a stale True # is cleared *before* PlotType moves to acLayout. Every write is journaled # and unwound in reverse when AutoCAD refuses one, so a failed apply leaves - # the layout as it was. Fake-tested in tests/test_page_setup.py (the fake - # enforces the CenterPlot rule); executed live by + # the layout as it was — and the read-back after the unwind is compared to + # the read-back before the first write, so a value that did not go back is + # named rather than claimed restored. + # + # Two behaviours measured live (AutoCAD 2026, 2026-09-17) that the ActiveX + # Reference does not document: + # * GetPaperSize / GetPaperMargins answer in millimetres whatever + # PaperUnits says (ISO_A3 under PaperUnits=0 -> (420.0, 297.0); ANSI_B + # under 0 and 1 -> (431.8, 279.4) both times). They are never scaled. + # * Writing ConfigName replaces CanonicalMediaName with the new device's + # default when the current media does not exist on it, and writing the + # old device back does *not* bring the old media with it (DWG To PDF / + # ISO_A3 -> Microsoft Print to PDF -> 'psk:ISOA4' -> DWG To PDF -> + # 'ANSI_A_(11.00_x_8.50_Inches)'). The device write therefore journals + # one combined undo: device back, then the media / units / rotation + # snapshotted before the switch re-applied. + # + # Fake-tested in tests/test_page_setup.py (the fake enforces the CenterPlot + # rule and replays both measurements); executed live by # scripts/smoke_settings_com.py (Task 24). _AC_MILLIMETERS = 1 _AC_NO_ROTATION = 0 + #: Layout properties a ConfigName write can move as a side effect (measured + #: live for CanonicalMediaName; units / rotation are re-applied on the same + #: evidence-before-trust basis) — snapshotted before the device switch and + #: put back by its undo. + _DEVICE_SIDE_EFFECTS = ("CanonicalMediaName", "PaperUnits", "PlotRotation") @staticmethod def _com_page_setup_row(layout) -> dict: @@ -1992,18 +2014,18 @@ class ComBackend(AutoCADBackend): log.debug("Layout.%s read failed: %s", attr, exc) return None - units = _get("PaperUnits") - factor = 25.4 if units == 0 else 1.0 + # GetPaperSize / GetPaperMargins are millimetres regardless of + # PaperUnits (measured live; see the block comment above) — no factor. size = None try: width, height = layout.GetPaperSize() - size = [round(float(width) * factor, 2), round(float(height) * factor, 2)] + size = [round(float(width), 2), round(float(height), 2)] except Exception as exc: log.debug("Layout.GetPaperSize failed: %s", exc) margins = None try: (left, bottom), (right, top) = layout.GetPaperMargins() - margins = [round(float(v) * factor, 3) for v in (top, bottom, left, right)] + margins = [round(float(v), 3) for v in (top, bottom, left, right)] except Exception as exc: log.debug("Layout.GetPaperMargins failed: %s", exc) rotation = _get("PlotRotation") or 0 @@ -2094,10 +2116,27 @@ class ComBackend(AutoCADBackend): target.SetCustomScale(numerator, denominator) journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale")) + def _set_device(device: str): + # A device switch moves the media (and can move units / + # rotation) as a side effect, and switching back does not + # move them back — so the undo re-applies what was there. + snapshot = {attr: getattr(target, attr) for attr in self._DEVICE_SIDE_EFFECTS} + previous = target.ConfigName + target.ConfigName = device + + def _undo(): + target.ConfigName = previous + target.RefreshPlotDeviceInfo() + for attr, value in snapshot.items(): + if getattr(target, attr) != value: + setattr(target, attr, value) + + journal.append((_undo, "ConfigName")) + step = "ConfigName" try: target.RefreshPlotDeviceInfo() - _set("ConfigName", resolved["device"]) + _set_device(resolved["device"]) target.RefreshPlotDeviceInfo() media = resolved["canonical_media_name"] names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] @@ -2144,14 +2183,23 @@ class ComBackend(AutoCADBackend): _set("CenterPlot", bool(center)) except Exception as exc: failed = self._com_unwind_page_setup(journal) + # The unwind's own word is not evidence: read the layout back + # and name every field that is not what it was. + failed += [ + key + for key, value in self._com_page_setup_row(target).items() + if key != "layout" and value != before.get(key) and key not in failed + ] if isinstance(exc, ValueError) and not failed: - raise + raise # our own refusal (unknown media), nothing left changed restored = ( f"layout {target.Name!r} restored to its previous page setup" if not failed else f"layout {target.Name!r} could NOT be fully restored " f"(still changed: {', '.join(failed)})" ) + if isinstance(exc, ValueError): + raise ValueError(f"{exc}; {restored}") from exc raise ValueError( f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}" ) from exc diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py index 0f97c6c..b3fb960 100644 --- a/engineering/standards/papers.py +++ b/engineering/standards/papers.py @@ -8,8 +8,13 @@ tables of two enums that do not agree with each other: * DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1, 17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5. * ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib): - ac1_1=16, ac1_2=17, ac1_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, - ac2_1=28, ac10_1=31, acScaleToFit=0. + ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28, + ac10_1=31, acScaleToFit=0. The typelib also declares ``ac1_5=19``, but + AutoCAD 2026 refuses ``Layout.StandardScale = 19`` with "Invalid input" in + every condition tried (PaperUnits mm and inches, UseStandardScale True and + False; measured live 2026-09-17) — its standard-scale list has no 1:5, the + same gap as DXF code 75 — so 1:5 is *not* in the table and takes the custom + route on the live engine too (``SetCustomScale(1, 5)`` reads back ``1:5``). A scale without a code on an engine goes through the custom numerator / denominator on that engine, and the read-back reports the same label either way. @@ -131,11 +136,11 @@ DXF_STANDARD_SCALE_TYPE: dict[str, int] = { _DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()} #: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale. +#: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring). ACTIVEX_PLOT_SCALE: dict[str, int] = { "fit": 0, "1:1": 16, "1:2": 17, - "1:5": 19, "1:10": 21, "1:20": 23, "1:50": 26, diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 1e93940..0e8fd8c 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -109,7 +109,9 @@ def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): def test_resolve_page_setup_scale_codes(): one_to_five = resolve_page_setup("ISO_A3", scale="1:5") assert one_to_five["dxf_standard_scale_type"] is None # DXF code 75 has no 1:5 - assert one_to_five["activex_standard_scale"] == 19 # AcPlotScale.ac1_5 + # The typelib declares ac1_5 = 19, but AutoCAD 2026 refuses + # StandardScale = 19 ("Invalid input", measured live): custom route. + assert one_to_five["activex_standard_scale"] is None assert one_to_five["scale_ratio"] == [1.0, 5.0] five_to_one = resolve_page_setup("ISO_A3", scale="5:1") assert five_to_one["dxf_standard_scale_type"] is None @@ -420,13 +422,24 @@ class _FakeLayout: be set to True on a layout object whose PlotType property is set to acLayout." ``refuse`` names properties whose write raises, the way a live AutoCAD refuses a value it cannot take. + + Replays two behaviours measured live on AutoCAD 2026 (2026-09-17): + + * ``GetPaperSize`` / ``GetPaperMargins`` answer in millimetres whatever + ``PaperUnits`` says (ANSI_B under PaperUnits 0 and 1 -> (431.8, 279.4)). + * Writing ``ConfigName`` replaces ``CanonicalMediaName`` with the new + device's default when the current media does not exist on it, and + writing the old device back does not bring the old media back. + ``media_names`` is therefore either one tuple (every device carries it) + or ``{device: tuple}``; a device missing from the dict carries nothing. """ AC_LAYOUT = 5 def __init__(self, name, media_names, refuse=()): object.__setattr__(self, "log", []) - object.__setattr__(self, "_media", tuple(media_names)) + media = media_names if isinstance(media_names, dict) else {None: tuple(media_names)} + object.__setattr__(self, "_media", {k: tuple(v) for k, v in media.items()}) object.__setattr__(self, "_refuse", tuple(refuse)) object.__setattr__( self, @@ -459,6 +472,14 @@ class _FakeLayout: raise RuntimeError("CenterPlot cannot be True while PlotType is acLayout") self._props[attr] = value self.log.append(("set", attr, value)) + if attr == "ConfigName": + names = self._device_media() + if names and self._props["CanonicalMediaName"] not in names: + self._props["CanonicalMediaName"] = names[0] # the device's default + + def _device_media(self): + media = object.__getattribute__(self, "_media") + return media.get(self._props["ConfigName"], media.get(None, ())) def snapshot(self) -> dict: return dict(object.__getattribute__(self, "_props")) @@ -468,13 +489,14 @@ class _FakeLayout: def GetCanonicalMediaNames(self): self.log.append(("call", "GetCanonicalMediaNames")) - return self._media + return self._device_media() def GetPaperSize(self): + # Millimetres whatever PaperUnits says (measured live, see the class docstring). match = _MEDIA_RE.search(self._props["CanonicalMediaName"]) width, height = float(match.group(1)), float(match.group(2)) if match.group(3) == "Inches": - width, height = width * 25.4, height * 25.4 + width, height = round(width * 25.4, 2), round(height * 25.4, 2) return (width, height) def GetPaperMargins(self): @@ -631,13 +653,90 @@ async def test_com_apply_refuses_margins_before_any_write(com_backend): async def test_com_apply_restores_the_device_when_the_media_name_is_unknown(com_backend): backend, document = com_backend layout = document.Layouts.Item("Layout1") - object.__setattr__(layout, "_media", ("ISO_A4_(210.00_x_297.00_MM)",)) + object.__setattr__(layout, "_media", {None: ("ISO_A4_(210.00_x_297.00_MM)",)}) with pytest.raises(ValueError, match=r"ISO_A3_\(420.00_x_297.00_MM\)"): await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) assert layout.ConfigName == "None", "the device write is rolled back" assert layout.CanonicalMediaName == "ISO_A4_(210.00_x_297.00_MM)" +PDF_DEVICE = "DWG To PDF.pc3" +MS_DEVICE = "Microsoft Print to PDF" +ISO_A3_MEDIA = "ISO_A3_(420.00_x_297.00_MM)" +#: Two devices whose media do not overlap, the way the two PDF drivers on the +#: machine measured: switching to MS moves an ISO_A3 layout to psk:ISOA4, and +#: switching back lands on DWG To PDF's default, not on ISO_A3. +TWO_DEVICES = { + PDF_DEVICE: ("ANSI_A_(11.00_x_8.50_Inches)", ISO_A3_MEDIA), + MS_DEVICE: ("psk:ISOA4", "ISO_A0_(1189.00_x_841.00_MM)"), +} + + +def _layout_on_pdf_device(document, **kwargs): + layout = _FakeLayout("Layout1", TWO_DEVICES, **kwargs) + layout.ConfigName = PDF_DEVICE + layout.CanonicalMediaName = ISO_A3_MEDIA + del layout.log[:] + document.Layouts.layouts[1] = layout + return layout + + +async def test_com_apply_puts_the_media_back_when_the_device_switch_replaced_it(com_backend): + """Measured live: refusing a media unknown on the new device used to leave + the layout on the old device with that device's *default* media (ANSI_A), + not the ISO_A3 it had — while the error said it was restored.""" + backend, document = com_backend + layout = _layout_on_pdf_device(document) + before = layout.snapshot() + with pytest.raises(ValueError, match="has no media") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE)) + assert "could NOT" not in str(info.value) + assert layout.snapshot() == before, "device back, then the original media re-applied" + assert (await backend.page_setup_list("Layout1"))[0]["paper"] == "ISO_A3" + + +async def test_com_apply_late_refusal_unwinds_through_the_device_switch(com_backend): + """The media journaled *after* the device switch is the new device's + default; only the pre-switch snapshot can put ISO_A3 back.""" + backend, document = com_backend + layout = _layout_on_pdf_device(document, refuse=("PlotWithPlotStyles",)) + before = layout.snapshot() + with pytest.raises(ValueError, match="PlotWithPlotStyles") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A0", device=MS_DEVICE)) + assert "restored to its previous page setup" in str(info.value) + assert layout.snapshot() == before + sets = [entry for entry in layout.log if entry[0] == "set"] + assert sets[-2:] == [ + ("set", "ConfigName", PDF_DEVICE), + ("set", "CanonicalMediaName", ISO_A3_MEDIA), + ] + + +class _StickyMediaLayout(_FakeLayout): + """Accepts every write but silently drops CanonicalMediaName once the device + has been switched back — what a driver that ignores the write looks like.""" + + def __setattr__(self, attr, value): + if attr == "CanonicalMediaName" and self._props["ConfigName"] == PDF_DEVICE and self.log: + return + super().__setattr__(attr, value) + + +async def test_com_apply_reports_what_the_unwind_could_not_put_back(com_backend): + backend, document = com_backend + layout = _StickyMediaLayout("Layout1", TWO_DEVICES) + layout.ConfigName = PDF_DEVICE + object.__getattribute__(layout, "_props")["CanonicalMediaName"] = ISO_A3_MEDIA + del layout.log[:] + document.Layouts.layouts[1] = layout + with pytest.raises(ValueError, match="has no media") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE)) + message = str(info.value) + assert "could NOT be fully restored" in message + assert "canonical_media_name" in message and "paper" in message + assert layout.ConfigName == PDF_DEVICE and layout.CanonicalMediaName != ISO_A3_MEDIA + + async def test_com_apply_refuses_model_and_unknown_layouts(com_backend): backend, _ = com_backend with pytest.raises(ValueError, match="Model"): @@ -659,6 +758,22 @@ async def test_com_list_reads_the_properties(com_backend): assert row["center"] is None, "acLayout: CenterPlot is not applicable" +async def test_com_list_never_scales_the_paper_by_paper_units(com_backend): + """GetPaperSize / GetPaperMargins are millimetres under PaperUnits=0 too + (measured live) — the 25.4 factor listed every imperial layout as + [10668.0, 7543.8] and lost the paper name.""" + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PaperUnits = 0 + layout.CanonicalMediaName = "ANSI_B_(17.00_x_11.00_Inches)" + row = (await backend.page_setup_list("Layout1"))[0] + assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B" + assert row["orientation"] == "landscape" + assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + layout.CanonicalMediaName = "ISO_A3_(420.00_x_297.00_MM)" + assert (await backend.page_setup_list("Layout1"))[0]["size_mm"] == [420.0, 297.0] + + async def test_com_list_reports_center_for_an_extents_plot(com_backend): backend, document = com_backend layout = document.Layouts.Item("Layout1") From 4a6591843ff8260bfae8501fa338974e49fedb88 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 10:01:49 +0300 Subject: [PATCH 05/14] fix(task-17): the headless sheet export plots at the layout's scale and plot area, and reports both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drawing_export_pdf sized the axes window as the sheet at 1:1 whatever page_setup_apply had written: a 1:2 sheet exported pixel-identical to the same sheet at 1:1, ok: True, with a MediaBox that read as correct, so nothing downstream (batch_plot's mediabox gate included) could see it. _paper_frame now spans the sheet in paper-space units — sheet_mm / unit x denominator / numerator, the custom ratio for the labels code 75 lacks (1:5, 5:1) — and honours the plot area: layout (5) from the plot origin; extents (1) as the paper-space extents (the main viewport excluded, as AutoCAD excludes it) at the plot origin or centred, scaled to the printable area under fit. fit under a layout plot is 1:1, the choice AutoCAD greys out. display/view/limits/window fall back to the sheet window and say so. The result carries scale, effective_scale, scale_applied, plot_area, plot_area_applied (and plot_area_note), the way rotation_applied is already reported. Tests look at the ink, since the MediaBox cannot see this: an 800-unit frame ends at (left + 400) / 420 of the width at 1:2 and runs off the sheet at 1:1; extents + fit spans the printable width exactly; the custom 1:5 window is 2100 units; an empty extents plot and a display plot are reported, not hidden. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 162 +++++++++++++++++++++++++++++++++++--- tests/test_page_setup.py | 126 +++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 10 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 61f01e9..42fbdd8 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1549,6 +1549,13 @@ class EzdxfBackend(AutoCADBackend): result = {"ok": True, "path": path} if paper is not None: result["paper_mm"] = paper["size_mm"] + result["scale"] = paper["scale"] + result["effective_scale"] = paper["effective_scale"] + result["scale_applied"] = paper["scale_applied"] + result["plot_area"] = paper["plot_area"] + result["plot_area_applied"] = paper["plot_area_applied"] + if "plot_area_note" in paper: + result["plot_area_note"] = paper["plot_area_note"] if not paper["rotation_applied"]: result["rotation_applied"] = False if resolved is not None and resolved != "Model": @@ -2379,10 +2386,71 @@ class EzdxfBackend(AutoCADBackend): } @staticmethod - def _paper_frame(layout) -> dict: + def _plot_scale(layout) -> tuple[str, float | None, float | None]: + """``(label, numerator, denominator)`` of the layout's plot scale; the + terms are ``None`` for *scaled to fit* (code 75 = 0). + + Reads the same bits ``_page_setup_row`` reports, so the label here is + the label ``page_setup_list`` shows: a standard code under + ``USE_STANDARD_SCALE`` resolves through ezdxf's ``STD_SCALES`` table, + anything else through the stored numerator / denominator. + """ + from ezdxf.lldxf.const import STD_SCALES + + from engineering.standards.papers import dxf_scale_label, scale_label + + dxf = layout.dxf_layout.dxf + flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) + numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) + if flags & layout.USE_STANDARD_SCALE: + code = int(dxf.standard_scale_type) + if code == 0: + return "fit", None, None + if code in STD_SCALES: + numerator, denominator = (float(v) for v in STD_SCALES[code]) + return dxf_scale_label(code, numerator, denominator), numerator, denominator + return scale_label(numerator, denominator), numerator, denominator + + @staticmethod + def _plot_extents(layout): + """Paper-space extents AutoCAD's *extents* plot area means, or ``None``. + + ``ezdxf.bbox`` counts the main paper-space viewport (id 1, the whole + sheet) as an entity; AutoCAD does not, so it is left out here. + """ + from ezdxf import bbox + + entities = [ + e for e in layout if not (e.dxftype() == "VIEWPORT" and int(e.dxf.get("id", 0)) == 1) + ] + if not entities: + return None + box = bbox.extents(entities, fast=True) + return box if box.has_data else None + + @classmethod + def _paper_frame(cls, layout) -> dict: """The sheet as a matplotlib figure: size in inches plus the axes window. - Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper. + Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper + and the content lands on it at the layout's plot scale. + + **Scale.** The axes window spans the sheet in paper-space units, and + that is ``sheet_mm / unit x denominator / numerator`` — at 1:2 an A3 + sheet holds 840 x 594 units, not 420 x 297. It used to be the sheet at + 1:1 whatever ``page_setup_apply`` had written (measured: a 1:2 sheet + exported pixel-identical to the same sheet at 1:1, ``ok: True``, and + the MediaBox read as correct, so nothing downstream could see it). + ``fit`` on a *layout* plot is 1:1: AutoCAD greys "Fit to paper" out + under that plot area (reported as ``effective_scale``). + + **Plot area.** ``layout`` (5) is the sheet from the plot origin. + ``extents`` (1) is the paper-space extents (the main viewport excluded, + as AutoCAD excludes it) placed at the plot origin, or centred on the + printable area under ``PLOT_CENTERED``; with ``fit`` the extents are + scaled to the printable area. The plot areas the headless engine cannot + know (display, view) and the ones it does not read (limits, window) + fall back to the sheet window and say so in ``plot_area_applied``. ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media convention: the media stays 210 x 297 and the sheet is turned, so @@ -2396,29 +2464,103 @@ class EzdxfBackend(AutoCADBackend): the headless renderer still does not do is rotate the *content* on the media (reported as ``rotation_applied``, not hidden). """ + from engineering.standards.papers import PLOT_TYPE_NAMES, scale_label + dxf = layout.dxf_layout.dxf width, height = float(dxf.paper_width), float(dxf.paper_height) rotation = int(dxf.plot_rotation) unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 top, right = float(dxf.top_margin), float(dxf.right_margin) bottom, left = float(dxf.bottom_margin), float(dxf.left_margin) + # (top, right, bottom, left) after the turn — ezdxf's Page.from_dxf_layout. if rotation == 1: - sheet_w, sheet_h, left, bottom = height, width, top, left + sheet_w, sheet_h = height, width + top, right, bottom, left = right, bottom, left, top elif rotation == 2: - sheet_w, sheet_h, left, bottom = width, height, right, top + sheet_w, sheet_h = width, height + top, right, bottom, left = bottom, left, top, right elif rotation == 3: - sheet_w, sheet_h, left, bottom = height, width, bottom, right + sheet_w, sheet_h = height, width + top, right, bottom, left = left, top, right, bottom else: sheet_w, sheet_h = width, height - x0 = -(left + float(dxf.plot_origin_x_offset)) - y0 = -(bottom + float(dxf.plot_origin_y_offset)) - return { + offset_x = float(dxf.plot_origin_x_offset) + offset_y = float(dxf.plot_origin_y_offset) + printable_w = max(sheet_w - left - right, 1e-9) + printable_h = max(sheet_h - top - bottom, 1e-9) + + label, numerator, denominator = cls._plot_scale(layout) + plot_type = int(dxf.plot_type) + plot_area = PLOT_TYPE_NAMES.get(plot_type, str(plot_type)) + flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) + centered = bool(flags & layout.PLOT_CENTERED) + + box = cls._plot_extents(layout) if plot_type == 1 else None + plot_area_applied = plot_type == 5 or box is not None + note = None + if plot_type == 1 and box is None: + note = "extents: the layout holds no entities, so the sheet window was plotted" + elif plot_type not in (1, 5): + note = ( + f"plot area {plot_area!r} is not rendered headlessly; the sheet window was plotted" + ) + + # Paper-space units per millimetre of sheet. + scale_applied = True + effective = label + if ( + numerator is not None + and denominator is not None + and (numerator <= 0 or denominator <= 0) + ): + scale_applied, effective, units_per_mm = False, "1:1", 1.0 / unit + elif numerator is None: # scaled to fit + if box is not None: + ratios = [ + printable_w / box.size.x if box.size.x > 1e-12 else None, + printable_h / box.size.y if box.size.y > 1e-12 else None, + ] + usable = [r for r in ratios if r is not None] + mm_per_unit = min(usable) if usable else 1.0 + units_per_mm = 1.0 / mm_per_unit + paper_per_unit = mm_per_unit * unit + effective = ( + scale_label(paper_per_unit, 1.0) + if paper_per_unit >= 1.0 + else scale_label(1.0, 1.0 / paper_per_unit) + ) + else: + # Fit under a layout plot is not a choice AutoCAD offers: the + # Page Setup dialog greys it out and the sheet plots at 1:1. + units_per_mm, effective = 1.0 / unit, "1:1" + else: + units_per_mm = denominator / numerator / unit + + # Lower-left corner of the *sheet* in paper-space units. + if box is None: + x0 = -(left + offset_x) * units_per_mm + y0 = -(bottom + offset_y) * units_per_mm + elif centered: + x0 = box.center.x - (left + printable_w / 2.0) * units_per_mm + y0 = box.center.y - (bottom + printable_h / 2.0) * units_per_mm + else: + x0 = box.extmin.x - (left + offset_x) * units_per_mm + y0 = box.extmin.y - (bottom + offset_y) * units_per_mm + frame = { "size_mm": [round(sheet_w, 2), round(sheet_h, 2)], "figsize": (sheet_w / 25.4, sheet_h / 25.4), - "xlim": (x0 / unit, (x0 + sheet_w) / unit), - "ylim": (y0 / unit, (y0 + sheet_h) / unit), + "xlim": (x0, x0 + sheet_w * units_per_mm), + "ylim": (y0, y0 + sheet_h * units_per_mm), "rotation_applied": rotation == 0, + "scale": label, + "effective_scale": effective, + "scale_applied": scale_applied, + "plot_area": plot_area, + "plot_area_applied": plot_area_applied, } + if note: + frame["plot_area_note"] = note + return frame async def page_setup_list(self, layout: str | None = None) -> list[dict]: def _sync(): diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 0e8fd8c..0f5232f 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -294,6 +294,132 @@ async def test_rotated_layout_renders_the_whole_sheet(backend, tmp_path): assert abs(mm_w - 297.0) <= 0.5 and abs(mm_h - 210.0) <= 0.5 +async def _export_png(backend, tmp_path, layout: str, stem: str): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + pytest.importorskip("PIL", reason="pixel check needs Pillow") + png = tmp_path / f"{stem}.png" + result = await backend.drawing_export_pdf(str(png), layout=layout) + assert result["ok"] is True, result + return result, _ink_columns(str(png)) + + +async def test_layout_plot_honours_the_plot_scale(backend, tmp_path): + """A 1:2 sheet holds twice the paper-space units a 1:1 sheet holds. + + The MediaBox is the same 420 x 297 either way, so it cannot see this; + the ink can. An 800-unit-wide frame at 1:2 spans 400 mm and ends inside + the sheet; at 1:1 it runs off the right edge. Before the fix the two + exports were pixel-identical (both clipped at the edge) with ``ok: True``. + """ + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:2")) + lay = backend._doc.layouts.get(SHEET) + lay.add_lwpolyline([(0, 0), (800, 0), (800, 500), (0, 500)], close=True) + _, _, left_mm, _ = (await backend.page_setup_list(SHEET))[0]["margins_mm"] + + frame = backend._paper_frame(lay) + assert frame["scale"] == "1:2" and frame["scale_applied"] is True + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(840.0) + assert frame["ylim"][1] - frame["ylim"][0] == pytest.approx(594.0) + + result, (first, last, width) = await _export_png(backend, tmp_path, SHEET, "half") + assert result["scale"] == "1:2" and result["effective_scale"] == "1:2" + assert result["scale_applied"] is True + assert result["plot_area"] == "layout" and result["plot_area_applied"] is True + expected_last = int(width * (left_mm + 400.0) / 420.0) + assert abs(last - expected_last) <= 3, (first, last, width, expected_last) + + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:1")) + result, (_, last_full, _) = await _export_png(backend, tmp_path, SHEET, "full") + assert result["scale"] == "1:1" + assert last_full >= width - 2, "at 1:1 the 800-unit frame runs off the 420 mm sheet" + assert last < last_full - 20, "1:2 and 1:1 must not render the same" + + +async def test_custom_scale_sizes_the_window_too(backend): + """1:5 has no DXF code 75, so it rides on the numerator / denominator.""" + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5")) + frame = backend._paper_frame(backend._doc.layouts.get(SHEET)) + assert frame["scale"] == "1:5" and frame["scale_applied"] is True + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(2100.0) + + +async def test_fit_on_a_layout_plot_is_one_to_one(backend): + """AutoCAD greys "Fit to paper" out under the layout plot area.""" + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="fit")) + frame = backend._paper_frame(backend._doc.layouts.get(SHEET)) + assert frame["scale"] == "fit" and frame["effective_scale"] == "1:1" + assert frame["scale_applied"] is True + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0) + + +async def test_extents_plot_fits_the_printable_area_and_centres(backend, tmp_path): + """extents + fit: the paper-space extents (not the main viewport, which + ezdxf's bbox would count) scale to the printable area and sit centred.""" + await _sheet(backend) + await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", scale="fit", plot_area="extents", center=True) + ) + lay = backend._doc.layouts.get(SHEET) + lay.add_lwpolyline([(0, 0), (800, 0), (800, 500), (0, 500)], close=True) + top, bottom, left_mm, right_mm = (await backend.page_setup_list(SHEET))[0]["margins_mm"] + printable_w, printable_h = 420.0 - left_mm - right_mm, 297.0 - top - bottom + mm_per_unit = min(printable_w / 800.0, printable_h / 500.0) + + frame = backend._paper_frame(lay) + assert frame["plot_area"] == "extents" and frame["plot_area_applied"] is True + assert frame["scale"] == "fit" + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0 / mm_per_unit) + + result, (first, last, width) = await _export_png(backend, tmp_path, SHEET, "extents") + assert result["effective_scale"] == scale_label(1.0, 1.0 / mm_per_unit) + # The frame is the wider of the two: it spans the printable width exactly. + assert abs(first - int(width * left_mm / 420.0)) <= 3, (first, last, width) + assert abs(last - int(width * (420.0 - right_mm) / 420.0)) <= 3, (first, last, width) + + +async def test_extents_plot_at_a_fixed_scale_starts_at_the_plot_origin(backend): + await _sheet(backend) + await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", scale="1:2", plot_area="extents", center=False) + ) + lay = backend._doc.layouts.get(SHEET) + lay.add_lwpolyline([(100, 50), (900, 50), (900, 550), (100, 550)], close=True) + _, _, left_mm, _ = (await backend.page_setup_list(SHEET))[0]["margins_mm"] + frame = backend._paper_frame(lay) + assert frame["effective_scale"] == "1:2" and frame["plot_area_applied"] is True + # The extents' lower-left corner lands on the printable corner: sheet x0 + # is that corner less the left margin, in paper-space units at 1:2. + assert frame["xlim"][0] == pytest.approx(100.0 - left_mm * 2.0) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(840.0) + + +async def test_extents_plot_of_an_empty_layout_falls_back_to_the_sheet(backend): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", plot_area="extents")) + frame = backend._paper_frame(backend._doc.layouts.get(SHEET)) + assert frame["plot_area_applied"] is False + assert "no entities" in frame["plot_area_note"] + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0) + + +async def test_unrenderable_plot_area_is_reported_not_hidden(backend, tmp_path): + """display / view need a screen; the headless engine plots the sheet and says so.""" + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:1")) + lay = backend._doc.layouts.get(SHEET) + lay.dxf_layout.dxf.plot_type = 0 # display + lay.add_line((0, 0), (100, 100)) + result = await backend.drawing_export_pdf(str(tmp_path / "display.pdf"), layout=SHEET) + assert result["ok"] is True + assert result["plot_area"] == "display" and result["plot_area_applied"] is False + assert "display" in result["plot_area_note"] + assert result["scale_applied"] is True + + async def test_apply_materialises_the_default_flags_before_setting_centre(backend): """Measured: set_flag_state starts from 0 on a fresh layout, so a naive plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first.""" From f2386a9fddbadab03e7a5bac27eb489f48a3543f Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 14:58:17 +0300 Subject: [PATCH 06/14] fix(task-17): a rotated sheet's margins turn with it, and a paper-units move is named in changed and warnings Finding 1: page_setup_list swapped size_mm for plot_rotation 1/3 but reported the margins un-turned, so a 297 x 210 sheet carried its portrait margins ([20, 20, 7.5, 7.5], printable 282 x 170) while ezdxf's Page and _paper_frame plotted [7.5, 7.5, 20, 20] (257 x 195). The mapping now lives once in papers.turned_sheet (cross-checked against Page.from_dxf_layout for all four rotations) and is used by the ezdxf row, _paper_frame and the COM row. page_setup_apply resets plot_rotation to 0; headlessly it now re-writes the turned margins un-turned so the sheet keeps the margins it had (nothing moves, changed says nothing), and on COM, where margins come from the .pc3, the ones that moved show up in changed. Finding 2: both engines forced plot paper units to millimetres with no units field in the row, so an inch-authored ANSI sheet was rescaled 25.4x with ok: True and changed naming only the media. Rows now carry paper_units (mm | inches | pixels, DXF group 72 / AcPlotPaperUnits); resolve_page_setup takes paper_units="mm" (default) | "inches" | None (keep the layout's units, as AutoCAD's own Page Setup does); a move is reported in changed and, with its 25.4x consequence and the way back, in the new warnings list (papers.page_setup_warnings, identical on both engines). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 32 +++- backends/contracts/pagesetup.py | 34 +++- backends/ezdxf_backend.py | 91 +++++----- engineering/standards/papers.py | 103 +++++++++++ tests/test_page_setup.py | 292 ++++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+), 51 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 7010cb1..d0373da 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1977,6 +1977,14 @@ class ComBackend(AutoCADBackend): # * GetPaperSize / GetPaperMargins answer in millimetres whatever # PaperUnits says (ISO_A3 under PaperUnits=0 -> (420.0, 297.0); ANSI_B # under 0 and 1 -> (431.8, 279.4) both times). They are never scaled. + # PaperUnits itself still governs what one paper-space unit means at + # the plot scale, so the row reports it (``paper_units``) and an apply + # that moves it says so in ``changed`` and ``warnings``. + # * Both answer in the *media's* frame (the PLOTSETTINGS fields the DXF + # row reads); under PlotRotation 1/3 the row turns size and margins + # together through ``papers.turned_sheet``, the one mapping the headless + # row and renderer use. The rotated read-back is not yet measured live + # (scripts/smoke_settings_com.py, Task 24, is where it gets measured). # * Writing ConfigName replaces CanonicalMediaName with the new device's # default when the current media does not exist on it, and writing the # old device back does *not* bring the old media with it (DWG To PDF / @@ -1989,7 +1997,6 @@ class ComBackend(AutoCADBackend): # rule and replays both measurements); executed live by # scripts/smoke_settings_com.py (Task 24). - _AC_MILLIMETERS = 1 _AC_NO_ROTATION = 0 #: Layout properties a ConfigName write can move as a side effect (measured #: live for CanonicalMediaName; units / rotation are re-applied on the same @@ -2004,7 +2011,9 @@ class ComBackend(AutoCADBackend): activex_scale_label, center_applies, paper_from_size, + paper_units_name, scale_label, + turned_sheet, ) def _get(attr): @@ -2029,7 +2038,11 @@ class ComBackend(AutoCADBackend): except Exception as exc: log.debug("Layout.GetPaperMargins failed: %s", exc) rotation = _get("PlotRotation") or 0 - if size and int(rotation) in (1, 3): + if size and margins: + size, margins = turned_sheet(size[0], size[1], margins, rotation) + size = [round(v, 2) for v in size] + margins = [round(v, 3) for v in margins] + elif size and int(rotation) in (1, 3): size = [size[1], size[0]] if _get("UseStandardScale"): scale = activex_scale_label(_get("StandardScale") or 0) @@ -2055,6 +2068,7 @@ class ComBackend(AutoCADBackend): "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), "device": _get("ConfigName"), "margins_mm": margins, + "paper_units": paper_units_name(_get("PaperUnits")), "center": bool(_get("CenterPlot")) if center_applies(plot_type) else None, } @@ -2088,7 +2102,11 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def page_setup_apply(self, layout: str, setup: dict) -> dict: - from engineering.standards.papers import require_page_setup + from engineering.standards.papers import ( + PAPER_UNITS, + page_setup_warnings, + require_page_setup, + ) resolved = require_page_setup(setup) if resolved["margins_mm"] is not None: @@ -2149,8 +2167,11 @@ class ComBackend(AutoCADBackend): ) step = "CanonicalMediaName" _set("CanonicalMediaName", media) - step = "PaperUnits" - _set("PaperUnits", self._AC_MILLIMETERS) + if resolved["paper_units"] is not None: + # None keeps the layout's units — what AutoCAD's own Page + # Setup does when a media is chosen. + step = "PaperUnits" + _set("PaperUnits", PAPER_UNITS[resolved["paper_units"]]) step = "PlotRotation" _set("PlotRotation", self._AC_NO_ROTATION) step = "StyleSheet" @@ -2214,6 +2235,7 @@ class ComBackend(AutoCADBackend): "layout": target.Name, "applied": dict(resolved), "changed": changed, + "warnings": page_setup_warnings(changed), "plot_style_known": bool(resolved["plot_style_known"]), "viewports_kept": True, "backend": "com", diff --git a/backends/contracts/pagesetup.py b/backends/contracts/pagesetup.py index 9226d8f..35827c4 100644 --- a/backends/contracts/pagesetup.py +++ b/backends/contracts/pagesetup.py @@ -16,12 +16,20 @@ class PageSetupContract(ABC): Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h], orientation, plot_style, scale, plot_area, device, - margins_mm: [top, bottom, left, right], center}``. ``paper`` is the + margins_mm: [top, bottom, left, right], paper_units, center}``. + ``size_mm`` and ``margins_mm`` are the sheet *as plotted*: under + ``plot_rotation`` 1/3 (AutoCAD's landscape-on-portrait-media + convention) both are turned together, through the one mapping the + renderer plots by (``papers.turned_sheet``), so the printable area a + row implies is the area the same engine plots. ``paper`` is the catalogue name when the size matches one within 0.5 mm, otherwise the - media name as stored. ``center`` is a bool for an extents plot and - ``None`` for a layout plot, where centring is not applicable (AutoCAD - greys it out; ActiveX refuses ``CenterPlot = True`` under - ``acLayout``). Raises ``ValueError`` for an unknown layout. + media name as stored. ``paper_units`` (``"mm"`` | ``"inches"`` | + ``"pixels"``) is what one paper-space unit means at the plot scale; + sizes and margins are millimetres whatever it says. ``center`` is a + bool for an extents plot and ``None`` for a layout plot, where + centring is not applicable (AutoCAD greys it out; ActiveX refuses + ``CenterPlot = True`` under ``acLayout``). Raises ``ValueError`` for + an unknown layout. ``Model`` is never listed: model space is plotted through ``drawing_export_pdf(layout=None)`` and carries no sheet. """ @@ -33,9 +41,19 @@ class PageSetupContract(ABC): ``engineering.standards.papers.resolve_page_setup`` to a layout. Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]}, - plot_style_known, viewports_kept}``. ``changed`` is computed from the - read-back before and after, so re-applying the same setup reports - ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a + warnings: [str], plot_style_known, viewports_kept}``. ``changed`` is + computed from the read-back before and after (the rows + ``page_setup_list`` reports), so re-applying the same setup reports + ``{}``. The write resets ``plot_rotation`` to 0 (orientation rides on + the media's width/height); headlessly, with no ``margins_mm`` asked + for, the turned margins are re-written un-turned so the sheet keeps + the margins it had, and on the live engine (margins come from the + .pc3) the ones that moved show in ``changed``. ``setup["paper_units"]`` + (``"mm"`` default, ``"inches"``, or ``None`` to keep the layout's + units as AutoCAD's own Page Setup does) is written when given; a move + rescales every paper-space unit by 25.4 at the plot scale and is named + in ``changed`` *and* ``warnings`` (``papers.page_setup_warnings``). + Refuses ``Model``, an unknown layout, and a ``setup`` missing a resolved key, before anything is written. A write the engine refuses part-way through is unwound (every landed value put back) before the ``ValueError`` names the refused property. Never deletes viewports. diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 42fbdd8..61c09e6 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2340,23 +2340,39 @@ class EzdxfBackend(AutoCADBackend): return doc.layouts.get(resolved) @staticmethod - def _page_setup_row(layout) -> dict: + def _stored_sheet(dxf) -> tuple[list[float], list[float]]: + """``(size_mm, margins_mm)`` of the sheet *as plotted*, from the media + as stored: ``papers.turned_sheet`` over PLOTSETTINGS 40-47 and + ``plot_rotation``. DXF stores every paper size and margin in + millimetres, whatever ``plot_paper_units`` says (ezdxf's + acdb_plot_settings, verified). ``margins_mm`` is [top, bottom, left, right]. + """ + from engineering.standards.papers import turned_sheet + + return turned_sheet( + dxf.paper_width, + dxf.paper_height, + (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin), + dxf.plot_rotation, + ) + + @classmethod + def _page_setup_row(cls, layout) -> dict: from engineering.standards.papers import ( PLOT_TYPE_NAMES, center_applies, dxf_scale_label, paper_from_size, + paper_units_name, scale_label, ) dxf = layout.dxf_layout.dxf flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) - # DXF stores every paper size and margin in millimetres, whatever - # plot_paper_units says (ezdxf's acdb_plot_settings, verified). - width, height = float(dxf.paper_width), float(dxf.paper_height) - rotation = int(dxf.plot_rotation) - if rotation in (1, 3): - width, height = height, width + # The sheet as plotted: size *and* margins turned by plot_rotation + # (the same mapping _paper_frame plots by). Swapping only the size + # reported a 297 x 210 sheet with its portrait margins. + (width, height), margins = cls._stored_sheet(dxf) media = str(dxf.paper_size) numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) if flags & layout.USE_STANDARD_SCALE: @@ -2374,12 +2390,9 @@ class EzdxfBackend(AutoCADBackend): "scale": scale, "plot_area": PLOT_TYPE_NAMES.get(plot_type, str(dxf.plot_type)), "device": str(dxf.plot_configuration_file), - "margins_mm": [ - round(float(dxf.top_margin), 3), - round(float(dxf.bottom_margin), 3), - round(float(dxf.left_margin), 3), - round(float(dxf.right_margin), 3), - ], + "margins_mm": [round(v, 3) for v in margins], + # What one paper-space unit means at the plot scale (DXF group 72). + "paper_units": paper_units_name(dxf.plot_paper_units), # A layout plot has no centring (AutoCAD greys it out; ActiveX # refuses CenterPlot=True under acLayout): None, not a bit nobody honours. "center": bool(flags & layout.PLOT_CENTERED) if center_applies(plot_type) else None, @@ -2460,30 +2473,18 @@ class EzdxfBackend(AutoCADBackend): shrank the axes box to 0.707 and cut everything past x = 210 minus the margin (measured: a 277 mm frame lost its right 27%, with ``ok: True`` and a MediaBox that read as correct). The margins turn with the sheet, - mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them. What - the headless renderer still does not do is rotate the *content* on the - media (reported as ``rotation_applied``, not hidden). + mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them — + ``papers.turned_sheet``, the one mapping ``page_setup_list`` reports + by, so the row and the plot never disagree. What the headless renderer + still does not do is rotate the *content* on the media (reported as + ``rotation_applied``, not hidden). """ from engineering.standards.papers import PLOT_TYPE_NAMES, scale_label dxf = layout.dxf_layout.dxf - width, height = float(dxf.paper_width), float(dxf.paper_height) rotation = int(dxf.plot_rotation) unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 - top, right = float(dxf.top_margin), float(dxf.right_margin) - bottom, left = float(dxf.bottom_margin), float(dxf.left_margin) - # (top, right, bottom, left) after the turn — ezdxf's Page.from_dxf_layout. - if rotation == 1: - sheet_w, sheet_h = height, width - top, right, bottom, left = right, bottom, left, top - elif rotation == 2: - sheet_w, sheet_h = width, height - top, right, bottom, left = bottom, left, top, right - elif rotation == 3: - sheet_w, sheet_h = height, width - top, right, bottom, left = left, top, right, bottom - else: - sheet_w, sheet_h = width, height + (sheet_w, sheet_h), (top, bottom, left, right) = cls._stored_sheet(dxf) offset_x = float(dxf.plot_origin_x_offset) offset_y = float(dxf.plot_origin_y_offset) printable_w = max(sheet_w - left - right, 1e-9) @@ -2576,7 +2577,11 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def page_setup_apply(self, layout: str, setup: dict) -> dict: - from engineering.standards.papers import require_page_setup + from engineering.standards.papers import ( + PAPER_UNITS, + page_setup_warnings, + require_page_setup, + ) resolved = require_page_setup(setup) @@ -2592,17 +2597,26 @@ class EzdxfBackend(AutoCADBackend): if not dxf.hasattr("plot_layout_flags"): dxf.plot_layout_flags = dxf.get_default("plot_layout_flags") width, height = (float(v) for v in resolved["size_mm"]) + # Orientation rides on width/height, as AutoCAD names media, so + # plot_rotation goes to 0. The stored margins only meant what + # they meant *through* the old turn: with none asked for, the + # sheet keeps the margins it had (the turned ones are written as + # the unturned ones), instead of silently swapping to the media's + # portrait margins with nothing in ``changed``. + margins = resolved["margins_mm"] + if margins is None: + margins = self._stored_sheet(dxf)[1] # turned, un-rounded dxf.page_setup_name = "" dxf.plot_configuration_file = resolved["device"] dxf.paper_size = resolved["canonical_media_name"] dxf.paper_width = width dxf.paper_height = height - dxf.plot_paper_units = 1 # millimetres - dxf.plot_rotation = 0 # orientation rides on width/height, as AutoCAD names media - if resolved["margins_mm"] is not None: - top, bottom, left, right = (float(v) for v in resolved["margins_mm"]) - dxf.top_margin, dxf.bottom_margin = top, bottom - dxf.left_margin, dxf.right_margin = left, right + if resolved["paper_units"] is not None: + dxf.plot_paper_units = PAPER_UNITS[resolved["paper_units"]] + dxf.plot_rotation = 0 + top, bottom, left, right = (float(v) for v in margins) + dxf.top_margin, dxf.bottom_margin = top, bottom + dxf.left_margin, dxf.right_margin = left, right dxf.plot_origin_x_offset = 0.0 dxf.plot_origin_y_offset = 0.0 dxf.unit_factor = 1.0 @@ -2639,6 +2653,7 @@ class EzdxfBackend(AutoCADBackend): "layout": target.name, "applied": dict(resolved), "changed": changed, + "warnings": page_setup_warnings(changed), "plot_style_known": bool(resolved["plot_style_known"]), "viewports_kept": True, "backend": "ezdxf", diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py index b3fb960..2da375a 100644 --- a/engineering/standards/papers.py +++ b/engineering/standards/papers.py @@ -42,6 +42,8 @@ __all__ = [ "ORIENTATIONS", "PAPER_INCHES", "PAPER_SIZES", + "PAPER_UNITS", + "PAPER_UNITS_NAMES", "PLOT_AREAS", "PLOT_TYPE_LAYOUT", "PLOT_TYPE_NAMES", @@ -51,13 +53,16 @@ __all__ = [ "canonical_media_name", "center_applies", "dxf_scale_label", + "page_setup_warnings", "paper_from_size", "paper_size_mm", + "paper_units_name", "plot_style_known", "require_page_setup", "resolve_page_setup", "scale_label", "scale_ratio", + "turned_sheet", ] #: Portrait width × height in millimetres. Landscape swaps. @@ -262,6 +267,85 @@ def plot_style_known(name) -> bool: return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG} +#: Plot paper units — the same codes in DXF group 72 (``plot_paper_units``) and +#: ActiveX ``AcPlotPaperUnits`` (acInches=0, acMillimeters=1, acPixels=2). The +#: paper size and margins are stored in millimetres whatever this says (ezdxf's +#: acdb_plot_settings; GetPaperSize / GetPaperMargins measured live): the units +#: govern what one paper-space unit *means* at the plot scale — under inches a +#: 1:1 sheet holds 17 units across ANSI B, under millimetres 431.8. +PAPER_UNITS: dict[str, int] = {"inches": 0, "mm": 1} +PAPER_UNITS_NAMES: dict[int, str] = {0: "inches", 1: "mm", 2: "pixels"} +_PAPER_UNITS_ALIASES = {"in": "inches", "inch": "inches", "inches": "inches", "mm": "mm"} + + +def paper_units_name(code) -> str | None: + """Read-back for both engines: ``inches`` / ``mm`` / ``pixels``, ``None`` + for an unreadable value, ``code:`` for one neither enum names.""" + if code is None: + return None + try: + value = int(code) + except (TypeError, ValueError): + return None + return PAPER_UNITS_NAMES.get(value, f"code:{value}") + + +def turned_sheet(width_mm, height_mm, margins_mm, rotation) -> tuple[list[float], list[float]]: + """The sheet as plotted from the media as stored: ``(size_mm, margins_mm)``. + + ``margins_mm`` in and out is ``[top, bottom, left, right]`` — the row's + order. ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media + convention (media 210 x 297, sheet 297 x 210): the size swaps *and* the + margins turn with it, mapped the way ezdxf's ``Page.from_dxf_layout`` + maps them (rotation 1: top<-right, right<-bottom, bottom<-left, left<-top; + 2: opposite sides; 3: the inverse of 1). Every read-back and the headless + renderer go through this one function, so a row's printable area is the + area the same engine plots — swapping only the size reported a 297 x 210 + sheet with [20, 20, 7.5, 7.5] (282 x 170 printable) whose true margins + were [7.5, 7.5, 20, 20] (257 x 195). + """ + width, height = float(width_mm), float(height_mm) + top, bottom, left, right = (float(v) for v in margins_mm) + turn = int(rotation or 0) + if turn == 1: + width, height = height, width + top, right, bottom, left = right, bottom, left, top + elif turn == 2: + top, right, bottom, left = bottom, left, top, right + elif turn == 3: + width, height = height, width + top, right, bottom, left = left, top, right, bottom + return [width, height], [top, bottom, left, right] + + +def page_setup_warnings(changed: dict) -> list[str]: + """What a ``page_setup_apply`` moved that ``changed`` alone under-states. + + A paper-units move is a 25.4x rescale of every paper-space unit at the + plot scale (a 16-unit frame on an inch sheet spans 406.4 mm of paper; + after a move to millimetres, 16 mm) with ``ok: True`` and the scale + reported as applied — so it is named here, on both engines, with the + way back. Both engines build the same list from the same ``changed``. + """ + warnings: list[str] = [] + move = changed.get("paper_units") + if move and move[0] is not None and move[0] != move[1]: + old, new = move + if (old, new) == ("inches", "mm"): + effect = "plots 25.4x smaller" + elif (old, new) == ("mm", "inches"): + effect = "plots 25.4x larger" + else: + effect = "plots at a different size" + warnings.append( + f"paper units moved from {old} to {new}: one paper-space unit now plots as one " + f"{new} at the plot scale, so paper-space geometry authored in {old} {effect} " + f"at the same scale; pass paper_units=None to keep a layout's units or " + f"paper_units={old!r} to put them back" + ) + return warnings + + def center_applies(plot_type) -> bool: """Whether "centre the plot" means anything under this ``AcPlotType``. @@ -284,6 +368,7 @@ def resolve_page_setup( device: str = "DWG To PDF.pc3", margins_mm=None, center: bool = True, + paper_units: str | None = "mm", ) -> dict: """Validate a page setup request and resolve it to what both engines write. @@ -292,6 +377,13 @@ def resolve_page_setup( validated as a bool and resolved to ``None`` for ``plot_area="layout"``, where centring is not applicable (see the module docstring) — the default call (layout, centre) is therefore a layout plot with no centring write. + + ``paper_units`` is what one paper-space unit means at the plot scale: + ``"mm"`` (the default — this server draws in millimetres), ``"inches"``, + or ``None`` to keep the layout's current units, which is what AutoCAD's + own Page Setup does when a media is chosen. A write that moves the units + rescales every paper-space unit by 25.4; both engines report it in + ``changed`` and ``warnings``. """ name = _paper_key(paper) width, height = paper_size_mm(name, _orientation(orientation)) @@ -337,6 +429,15 @@ def resolve_page_setup( raise ValueError(f"center: must be true or false, got {center!r}") plot_type = PLOT_AREAS[plot_area] + units = None + if paper_units is not None: + units = _PAPER_UNITS_ALIASES.get(str(paper_units).strip().lower()) + if units is None: + raise ValueError( + f"paper_units: must be 'mm', 'inches' or None (keep the layout's units), " + f"got {paper_units!r}" + ) + return { "paper": name, "orientation": orientation, @@ -354,6 +455,7 @@ def resolve_page_setup( "device": device_name, "margins_mm": margins, "center": center if center_applies(plot_type) else None, + "paper_units": units, } @@ -375,6 +477,7 @@ REQUIRED_SETUP_KEYS: tuple[str, ...] = ( "device", "margins_mm", "center", + "paper_units", ) diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 0f5232f..6491bbc 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -18,9 +18,11 @@ from engineering.standards.papers import ( SCALES, canonical_media_name, paper_from_size, + paper_units_name, resolve_page_setup, scale_label, scale_ratio, + turned_sheet, ) from engineering.standards.pdfinfo import read_mediabox @@ -146,6 +148,59 @@ def test_unknown_ctb_is_allowed_but_flagged(): assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False +def test_resolve_page_setup_paper_units_default_mm_and_keep(): + assert resolve_page_setup("ISO_A3")["paper_units"] == "mm" + assert resolve_page_setup("ANSI_B", paper_units="inches")["paper_units"] == "inches" + assert resolve_page_setup("ANSI_B", paper_units="inch")["paper_units"] == "inches" + assert resolve_page_setup("ANSI_B", paper_units=None)["paper_units"] is None, "keep" + with pytest.raises(ValueError, match="paper_units"): + resolve_page_setup("ISO_A3", paper_units="cm") + + +def test_paper_units_name_covers_the_three_dxf_and_activex_codes(): + assert paper_units_name(0) == "inches" and paper_units_name(1) == "mm" + assert paper_units_name(2) == "pixels" + assert paper_units_name(None) is None and paper_units_name(7) == "code:7" + + +@pytest.mark.parametrize( + "rotation, size, margins", + [ + # stored media 210 x 297 with (top, bottom, left, right) = (1, 3, 4, 2), + # i.e. ezdxf's clockwise (top, right, bottom, left) = (1, 2, 3, 4). + (0, [210.0, 297.0], [1.0, 3.0, 4.0, 2.0]), + (1, [297.0, 210.0], [2.0, 4.0, 1.0, 3.0]), + (2, [210.0, 297.0], [3.0, 1.0, 2.0, 4.0]), + (3, [297.0, 210.0], [4.0, 2.0, 3.0, 1.0]), + ], +) +def test_turned_sheet_maps_size_and_margins_like_ezdxf_page_from_dxf_layout( + rotation, size, margins +): + """The one mapping ``page_setup_list``, ``_paper_frame`` and the COM row share. + + Cross-checked against ezdxf's ``Page.from_dxf_layout`` (the renderer's own + reading of PLOTSETTINGS 40-43 under ``plot_rotation``), so the row a + client reads is the sheet the same engine plots. + """ + assert turned_sheet(210, 297, [1, 3, 4, 2], rotation) == (size, margins) + + +def test_turned_sheet_agrees_with_ezdxf_page_from_dxf_layout(): + ezdxf = pytest.importorskip("ezdxf") + from ezdxf.addons.drawing.layout import Page + + doc = ezdxf.new() + lay = doc.layouts.new("Turn") + for rotation in (0, 1, 2, 3): + lay.page_setup(size=(210, 297), margins=(1, 2, 3, 4), units="mm", rotation=rotation) + page = Page.from_dxf_layout(lay.dxf_layout) + size, (top, bottom, left, right) = turned_sheet(210, 297, [1, 3, 4, 2], rotation) + assert size == [page.width, page.height], rotation + assert (page.margins.top, page.margins.bottom) == (top, bottom), rotation + assert (page.margins.left, page.margins.right) == (left, right), rotation + + # ── the PDF reader ────────────────────────────────────────────────────────── @@ -525,6 +580,170 @@ async def test_list_covers_every_paper_layout_and_skips_model(backend): assert "Model" not in names and {"A", "B"} <= set(names) +def _rotated_a4(backend, name: str = "Rot", rotation: int = 1): + """AutoCAD's landscape-on-portrait-media convention: media 210 x 297, + ezdxf clockwise margins (top, right, bottom, left) = (20, 7.5, 20, 7.5), + turned by ``plot_rotation``.""" + lay = backend._doc.layouts.get(name) + lay.page_setup( + size=(210, 297), + margins=(20, 7.5, 20, 7.5), + units="mm", + rotation=rotation, + scale=16, + name="ISO_A4", + device="DWG To PDF.pc3", + ) + return lay + + +async def test_list_turns_the_margins_with_the_sheet(backend): + """The row used to swap ``size_mm`` for rotation 1/3 but report the + margins un-turned: [20, 20, 7.5, 7.5] on a 297 x 210 sheet whose true + margins (ezdxf's Page, and the window ``_paper_frame`` plots) are + [7.5, 7.5, 20, 20] — a printable area of 282 x 170 from the row against + 257 x 195 on paper.""" + await _sheet(backend, "Rot") + lay = _rotated_a4(backend) + row = (await backend.page_setup_list("Rot"))[0] + assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape" + assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0] + top, bottom, left, right = row["margins_mm"] + assert (297 - left - right, 210 - top - bottom) == (257.0, 195.0) + + # The same sheet the renderer plots: at 1:1 the axes window starts one + # (turned) left margin before x = 0 and one (turned) bottom margin below y = 0. + frame = backend._paper_frame(lay) + assert frame["size_mm"] == row["size_mm"] + assert frame["xlim"][0] == pytest.approx(-left) and frame["ylim"][0] == pytest.approx(-bottom) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(297.0) + assert frame["ylim"][1] - frame["ylim"][0] == pytest.approx(210.0) + + +@pytest.mark.parametrize("rotation", [1, 2, 3]) +async def test_apply_on_a_turned_sheet_keeps_the_effective_margins(backend, rotation): + """``page_setup_apply`` writes ``plot_rotation`` 0 (orientation rides on + width/height). With no ``margins_mm`` asked for, the stored margins used + to stay as they were while the turn that gave them their meaning went + away, so the effective margins moved from [7.5, 7.5, 20, 20] to + [20, 20, 7.5, 7.5] (printable 257 x 195 -> 282 x 170) and ``changed`` + said nothing. The stored margins are now re-mapped so the sheet keeps + the margins it had, and the row before equals the row after.""" + await _sheet(backend, "Rot") + lay = _rotated_a4(backend, rotation=rotation) + before = (await backend.page_setup_list("Rot"))[0] + result = await backend.page_setup_apply( + "Rot", resolve_page_setup("ISO_A4", before["orientation"], scale="1:1") + ) + after = (await backend.page_setup_list("Rot"))[0] + assert int(lay.dxf_layout.dxf.plot_rotation) == 0 + assert after["margins_mm"] == before["margins_mm"] + assert after["size_mm"] == before["size_mm"] + assert "margins_mm" not in result["changed"] and "size_mm" not in result["changed"] + dxf = lay.dxf_layout.dxf + top, bottom, left, right = after["margins_mm"] + assert (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin) == ( + top, + bottom, + left, + right, + ) + + +async def test_apply_with_margins_on_a_turned_sheet_writes_them_as_asked(backend): + await _sheet(backend, "Rot") + _rotated_a4(backend) + result = await backend.page_setup_apply( + "Rot", resolve_page_setup("ISO_A4", "landscape", margins_mm=[5, 6, 7, 8]) + ) + row = (await backend.page_setup_list("Rot"))[0] + assert row["margins_mm"] == [5.0, 6.0, 7.0, 8.0] + assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [5.0, 6.0, 7.0, 8.0]] + + +def _inch_sheet(backend, name: str = "In"): + """An inch-authored ANSI B sheet (what ANSI_A-E exist for): paper-space + units are inches, so at 1:1 a 16-unit frame spans 16 inches of paper.""" + lay = backend._doc.layouts.get(name) + lay.page_setup( + size=(17, 11), + margins=(0.5, 0.5, 0.5, 0.5), + units="inch", + scale=16, + name="ANSI_B", + device="DWG To PDF.pc3", + ) + lay.add_lwpolyline([(0, 0), (16, 0), (16, 10), (0, 10)], close=True) + return lay + + +async def test_row_reports_the_paper_units(backend): + await _sheet(backend, "In") + lay = _inch_sheet(backend) + row = (await backend.page_setup_list("In"))[0] + assert row["paper_units"] == "inches" + assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B" + assert row["margins_mm"] == [12.7, 12.7, 12.7, 12.7] + lay.dxf_layout.dxf.plot_paper_units = 1 + assert (await backend.page_setup_list("In"))[0]["paper_units"] == "mm" + + +async def test_apply_names_a_paper_units_flip_and_its_consequence(backend): + """The default setup plots in millimetres. On an inch-authored sheet that + is a 25.4x rescale of every paper-space unit: the 16-unit frame used to + go from 406.4 mm to 16 mm of paper with ``changed`` naming only the + media and the plot style — ``ok: True``, ``scale_applied: True``.""" + await _sheet(backend, "In") + lay = _inch_sheet(backend) + result = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert result["changed"]["paper_units"] == ["inches", "mm"] + assert int(lay.dxf_layout.dxf.plot_paper_units) == 1 + assert result["warnings"], "a units flip is never silent" + assert any("inches" in w and "mm" in w and "25.4" in w for w in result["warnings"]) + assert result["applied"]["paper_units"] == "mm" + # Re-applying the same setup moves nothing and warns of nothing. + again = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert again["changed"] == {} and again["warnings"] == [] + + +async def test_apply_can_keep_or_set_the_paper_units(backend): + await _sheet(backend, "In") + lay = _inch_sheet(backend) + kept = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units=None) + ) + assert int(lay.dxf_layout.dxf.plot_paper_units) == 0 + assert "paper_units" not in kept["changed"] and kept["warnings"] == [] + assert kept["applied"]["paper_units"] is None + # The catalogue's ANSI_B is 432 x 279 mm (ANSI Y14.1's metric value), so + # at 1:1 under inches the sheet holds 432 / 25.4 units across. + frame = backend._paper_frame(lay) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432 / 25.4), "inches at 1:1" + + to_mm = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="mm") + ) + assert to_mm["changed"]["paper_units"] == ["inches", "mm"] and to_mm["warnings"] + frame = backend._paper_frame(lay) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432.0), "432 mm at 1:1" + + back = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="inches") + ) + assert back["changed"]["paper_units"] == ["mm", "inches"] and back["warnings"] + assert int(lay.dxf_layout.dxf.plot_paper_units) == 0 + + +async def test_apply_on_a_mm_sheet_warns_of_nothing(backend): + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert result["warnings"] == [] and "paper_units" not in result["changed"] + + async def test_plot_style_list_headless_is_the_catalog(backend): rows = await backend.plot_style_list() assert [row["name"] for row in rows] == list(CTB_CATALOG) @@ -914,3 +1133,76 @@ async def test_com_declares_dwt_write(): from backends.com_backend import ComBackend assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True + + +async def test_com_list_reports_the_paper_units(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "mm" + layout.PaperUnits = 0 + assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "inches" + + +async def test_com_list_turns_the_margins_with_a_rotated_sheet(com_backend): + """GetPaperMargins reads PLOTSETTINGS 40-43 — the media's own frame, the + fields the DXF row reads — so a rotated sheet turns them the same way.""" + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PlotRotation = 1 + row = (await backend.page_setup_list("Layout1"))[0] + assert row["size_mm"] == [297.0, 210.0] + # fake margins: left 7.5, bottom 20, right 7.5, top 20 -> rotation 1: + # top=right, right=bottom, bottom=left, left=top -> [7.5, 7.5, 20, 20]. + assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0] + layout.PlotRotation = 0 + assert (await backend.page_setup_list("Layout1"))[0]["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + + +async def test_com_apply_names_a_paper_units_flip(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PaperUnits = 0 + layout.log.clear() + result = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert ("set", "PaperUnits", 1) in layout.log + assert result["changed"]["paper_units"] == ["inches", "mm"] + assert any("25.4" in w for w in result["warnings"]) + again = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert again["changed"] == {} and again["warnings"] == [] + + +async def test_com_apply_keeps_or_sets_the_paper_units(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PaperUnits = 0 + layout.log.clear() + kept = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units=None) + ) + assert not [entry for entry in layout.log if entry[:2] == ("set", "PaperUnits")] + assert layout.PaperUnits == 0 and "paper_units" not in kept["changed"] + assert kept["warnings"] == [] + layout.log.clear() + inches = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units="inches") + ) + assert ("set", "PaperUnits", 0) in layout.log and inches["changed"] == {} + assert inches["warnings"] == [] + + +async def test_com_apply_on_a_rotated_sheet_reports_the_margins_that_moved(com_backend): + """COM cannot write margins (they come from the .pc3), so resetting + PlotRotation on a turned sheet moves the effective margins — and the row + now says so instead of listing only the media.""" + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PlotRotation = 1 + result = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", "landscape", scale="1:1") + ) + assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [20.0, 20.0, 7.5, 7.5]] + assert result["changed"]["size_mm"] == [[297.0, 210.0], [420.0, 297.0]] From aeb27c252b91e517c41d47f369e6b45ec07bb305 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 15:29:47 +0300 Subject: [PATCH 07/14] fix(task-17): the headless page setup writes the scale AutoCAD plots by, and reads the code 75 AutoCAD writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two measured root causes on the headless engine, both silent: 1. page_setup_apply wrote PLOTSETTINGS group 147 (unit_factor) as 1.0 for every scale. Under USE_STANDARD_SCALE AutoCAD's plot engine scales by group 147 alone (a 1:10 sheet with code 21 and 147 = 1.0 plotted a 100-unit line at 99.99 mm while the row, `applied` and 142/143 said 1:10; patching 147 to 0.1 alone gave 9.99 mm). AutoCAD writes 147 as numerator / denominator (measured live on AutoCAD 2026 for fit, 1:1, 1:2, 1:10, 1:50, 2:1, 10:1: 1.0, 1.0, 0.5, 0.1, 0.02, 2.0, 10.0). The writer now stores 75, 142/143 and 147 from the one resolved ratio on every route, so no reader can disagree with the plot. 2. DXF_STANDARD_SCALE_TYPE and _plot_scale followed ezdxf's STD_SCALES, which has no 19 = 1:5 and numbers everything above 18 one lower than the ObjectARX StdScaleType enum AutoCAD actually writes (1:10 = 21, 1:20 = 23, 1:50 = 26, 1:100 = 27, 2:1 = 28, 10:1 = 31 — measured via COM SaveAs DXF). An AutoCAD-authored 1:50 sheet listed as "1:100" and a 1:10 sheet rendered at 1:16. papers.py now authors the full file enum (DXF_STD_SCALES, codes 1-33 with AutoCAD's imperial names), the writer uses the same codes ActiveX uses, and nothing goes through ezdxf's table. 1:5 stays on the custom route on both engines (the live setter refuses 19) while the reader knows code 19 for files AutoCAD wrote. Tests: the plan's `1:50 -> 25` assertion was the stale numbering and now pins 26; a pure-data test pins the enum against the measured codes and the ActiveX numbering; a parametrised writer test checks 75/142/143/147 for every catalogue scale; a reader test stores the scale exactly as AutoCAD saves it (including codes 19 and 22 the writer never emits) and checks page_setup_list and _paper_frame report and render it at its own scale. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 31 ++++--- engineering/standards/papers.py | 141 +++++++++++++++++++++++++----- tests/test_page_setup.py | 148 ++++++++++++++++++++++++++++++-- 3 files changed, 281 insertions(+), 39 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 61c09e6..3d8bad8 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2405,12 +2405,12 @@ class EzdxfBackend(AutoCADBackend): Reads the same bits ``_page_setup_row`` reports, so the label here is the label ``page_setup_list`` shows: a standard code under - ``USE_STANDARD_SCALE`` resolves through ezdxf's ``STD_SCALES`` table, + ``USE_STANDARD_SCALE`` resolves through the authored ``DXF_STD_SCALES`` + (the ObjectARX enum AutoCAD writes — ezdxf's ``STD_SCALES`` is one off + above 18 and would render an AutoCAD-authored 1:10 sheet at 1:16), anything else through the stored numerator / denominator. """ - from ezdxf.lldxf.const import STD_SCALES - - from engineering.standards.papers import dxf_scale_label, scale_label + from engineering.standards.papers import DXF_STD_SCALES, dxf_scale_label, scale_label dxf = layout.dxf_layout.dxf flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) @@ -2419,8 +2419,8 @@ class EzdxfBackend(AutoCADBackend): code = int(dxf.standard_scale_type) if code == 0: return "fit", None, None - if code in STD_SCALES: - numerator, denominator = (float(v) for v in STD_SCALES[code]) + if code in DXF_STD_SCALES: + numerator, denominator = (float(v) for v in DXF_STD_SCALES[code]) return dxf_scale_label(code, numerator, denominator), numerator, denominator return scale_label(numerator, denominator), numerator, denominator @@ -2586,8 +2586,6 @@ class EzdxfBackend(AutoCADBackend): resolved = require_page_setup(setup) def _sync(): - from ezdxf.lldxf.const import STD_SCALES - target = self._paper_layout(layout, "page_setup_apply") before = self._page_setup_row(target) dxf = target.dxf_layout.dxf @@ -2619,21 +2617,28 @@ class EzdxfBackend(AutoCADBackend): dxf.left_margin, dxf.right_margin = left, right dxf.plot_origin_x_offset = 0.0 dxf.plot_origin_y_offset = 0.0 - dxf.unit_factor = 1.0 dxf.current_style_sheet = resolved["plot_style"] target.use_plot_styles(True) target.set_plot_type(int(resolved["plot_type"])) + # The scale is stored three ways and AutoCAD reads a different + # one per route (measured, AutoCAD 2026 — papers.py docstring): + # under USE_STANDARD_SCALE its plot engine scales by group 147 + # alone (a sheet with code 21 and 147 = 1.0 plots 1:10 geometry + # at 1:1 while page_setup_list says 1:10); under the custom + # route it scales by 142/143. All three are written, on every + # route, from the one resolved ratio, so no reader can disagree + # with the plot. ``fit`` has ratio 1/1 and 147 = 1.0, as AutoCAD + # writes it. code = resolved["dxf_standard_scale_type"] + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) if code is not None: dxf.standard_scale_type = int(code) - numerator, denominator = STD_SCALES.get(int(code), (1.0, 1.0)) - dxf.scale_numerator, dxf.scale_denominator = float(numerator), float(denominator) target.use_standard_scale(True) else: - numerator, denominator = (float(v) for v in resolved["scale_ratio"]) dxf.standard_scale_type = 16 - dxf.scale_numerator, dxf.scale_denominator = numerator, denominator target.use_standard_scale(False) + dxf.scale_numerator, dxf.scale_denominator = numerator, denominator + dxf.unit_factor = numerator / denominator # resolve_page_setup hands over None for a layout plot: the bit is # cleared, never set — the state AutoCAD itself stores for acLayout, # and the mirror of the live engine never writing CenterPlot there. diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py index 2da375a..d9279d2 100644 --- a/engineering/standards/papers.py +++ b/engineering/standards/papers.py @@ -3,18 +3,40 @@ Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*), the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale -tables of two enums that do not agree with each other: +enum — one numbering, shared by the file and by ActiveX: -* DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1, - 17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5. -* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib): - ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28, - ac10_1=31, acScaleToFit=0. The typelib also declares ``ac1_5=19``, but - AutoCAD 2026 refuses ``Layout.StandardScale = 19`` with "Invalid input" in - every condition tried (PaperUnits mm and inches, UseStandardScale True and - False; measured live 2026-09-17) — its standard-scale list has no 1:5, the - same gap as DXF code 75 — so 1:5 is *not* in the table and takes the custom - route on the live engine too (``SetCustomScale(1, 5)`` reads back ``1:5``). +* DXF group code 75 (``standard_scale_type``) is the ObjectARX + ``AcDbPlotSettings::StdScaleType`` enum: 0=fit, 1..15 the imperial + architectural scales, 16=1:1, 17=1:2, 18=1:4, 19=1:5, 20=1:8, 21=1:10, + 22=1:16, 23=1:20, 24=1:30, 25=1:40, 26=1:50, 27=1:100, 28=2:1, 29=4:1, + 30=8:1, 31=10:1, 32=100:1, 33=1000:1 (``DXF_STD_SCALES``). Measured live + on AutoCAD 2026 (COM ``SaveAs(..., 61)`` after ``StandardScale = n``): + 1:10 saves as 21, 1:50 as 26, 2:1 as 28, 10:1 as 31. ezdxf's + ``STD_SCALES`` table (and the DXF Reference page it follows) has no 19=1:5 + and numbers everything above 18 one lower (20=1:10, 25=1:50, 30=10:1) — + reading an AutoCAD-authored 1:50 sheet through it reports 1:100, and a + 1:10 sheet renders at 1:16 — so nothing here goes through that table. +* DXF group code 147 (``unit_factor`` in ezdxf; "a floating point scale + factor that represents the standard scale value specified in code 75" in + the DXF Reference — the Reference is right) is the factor AutoCAD's plot + engine *uses* under ``USE_STANDARD_SCALE`` (bit 16): AutoCAD writes it as + numerator / denominator (0.1 for 1:10, 2.0 for 2:1, 1.0 for fit and 1:1), + and a file with code 21 but 147 = 1.0 plots at 1:1 while claiming 1:10 + (measured: a 100-unit line spans 99.99 mm of paper). Under the custom + route (bit 16 cleared) AutoCAD honours 142/143 and leaves 75 and 147 + stale. The headless writer therefore writes 147 alongside 142/143 on + every route. +* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib) + is the same numbering (ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23, + ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0). The typelib + declares ``ac1_5=19`` too, but AutoCAD 2026 refuses + ``Layout.StandardScale = 19`` with "Invalid input" in every condition tried + (PaperUnits mm and inches, UseStandardScale True and False; measured live + 2026-09-17), so 1:5 is not in ``ACTIVEX_PLOT_SCALE`` and takes the custom + route on the live engine (``SetCustomScale(1, 5)`` reads back ``1:5``). + The headless writer mirrors that — 1:5 is not in ``DXF_STANDARD_SCALE_TYPE`` + either, so both engines store the same 1:5 (custom, 142/143 = 1/5) — while + the reader knows code 19 for files AutoCAD wrote. A scale without a code on an engine goes through the custom numerator / denominator on that engine, and the read-back reports the same label either way. @@ -39,6 +61,7 @@ __all__ = [ "ACTIVEX_PLOT_SCALE", "CTB_CATALOG", "DXF_STANDARD_SCALE_TYPE", + "DXF_STD_SCALES", "ORIENTATIONS", "PAPER_INCHES", "PAPER_SIZES", @@ -126,19 +149,79 @@ SCALES: dict[str, float | None] = { "10:1": 10.0, } -#: DXF group code 75. Labels missing here use the custom numerator/denominator. +#: DXF group code 75 → (numerator, denominator): the ObjectARX +#: ``AcDbPlotSettings::StdScaleType`` enum, which is what AutoCAD writes and +#: reads (module docstring — not ezdxf's ``STD_SCALES``, which is one off +#: above 18). Code 0 is *scaled to fit* and has no ratio. +DXF_STD_SCALES: dict[int, tuple[float, float]] = { + 1: (1.0 / 128.0, 12.0), + 2: (1.0 / 64.0, 12.0), + 3: (1.0 / 32.0, 12.0), + 4: (1.0 / 16.0, 12.0), + 5: (3.0 / 32.0, 12.0), + 6: (1.0 / 8.0, 12.0), + 7: (3.0 / 16.0, 12.0), + 8: (1.0 / 4.0, 12.0), + 9: (3.0 / 8.0, 12.0), + 10: (1.0 / 2.0, 12.0), + 11: (3.0 / 4.0, 12.0), + 12: (1.0, 12.0), + 13: (3.0, 12.0), + 14: (6.0, 12.0), + 15: (12.0, 12.0), + 16: (1.0, 1.0), + 17: (1.0, 2.0), + 18: (1.0, 4.0), + 19: (1.0, 5.0), + 20: (1.0, 8.0), + 21: (1.0, 10.0), + 22: (1.0, 16.0), + 23: (1.0, 20.0), + 24: (1.0, 30.0), + 25: (1.0, 40.0), + 26: (1.0, 50.0), + 27: (1.0, 100.0), + 28: (2.0, 1.0), + 29: (4.0, 1.0), + 30: (8.0, 1.0), + 31: (10.0, 1.0), + 32: (100.0, 1.0), + 33: (1000.0, 1.0), +} +#: AutoCAD's names for the imperial codes (inches of paper = feet of drawing). +_DXF_IMPERIAL_LABELS: dict[int, str] = { + 1: "1/128\"=1'", + 2: "1/64\"=1'", + 3: "1/32\"=1'", + 4: "1/16\"=1'", + 5: "3/32\"=1'", + 6: "1/8\"=1'", + 7: "3/16\"=1'", + 8: "1/4\"=1'", + 9: "3/8\"=1'", + 10: "1/2\"=1'", + 11: "3/4\"=1'", + 12: "1\"=1'", + 13: "3\"=1'", + 14: "6\"=1'", + 15: "1'=1'", +} + +#: DXF group code 75 the headless *writer* uses per catalogue label. Labels +#: missing here use the custom numerator/denominator: ``1:5`` (code 19 exists +#: in the file enum, but the live engine cannot set it, so both engines store +#: 1:5 the same custom way) and ``5:1`` (no code anywhere). DXF_STANDARD_SCALE_TYPE: dict[str, int] = { "fit": 0, "1:1": 16, "1:2": 17, - "1:10": 20, - "1:20": 22, - "1:50": 25, - "1:100": 26, - "2:1": 27, - "10:1": 30, + "1:10": 21, + "1:20": 23, + "1:50": 26, + "1:100": 27, + "2:1": 28, + "10:1": 31, } -_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()} #: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale. #: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring). @@ -253,9 +336,25 @@ def scale_label(numerator, denominator) -> str: return f"{num:g}:{den:g}" +_DXF_SCALE_LABEL: dict[int, str] = { + 0: "fit", + **_DXF_IMPERIAL_LABELS, + **{ + code: scale_label(num, den) + for code, (num, den) in DXF_STD_SCALES.items() + if code not in _DXF_IMPERIAL_LABELS + }, +} + + def dxf_scale_label(code, numerator, denominator) -> str: - """Read-back for the headless engine: the code-75 label, else the custom ratio.""" - return _DXF_SCALE_LABEL.get(int(code)) or scale_label(numerator, denominator) + """Read-back for the headless engine: the code-75 label (``fit`` for 0, + the ratio ``DXF_STD_SCALES`` gives the code, an AutoCAD-style name for an + imperial code), else the custom ratio.""" + label = _DXF_SCALE_LABEL.get(int(code)) + if label is not None: + return label + return scale_label(numerator, denominator) def activex_scale_label(code) -> str: diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 6491bbc..a3ec5ab 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -13,10 +13,14 @@ import zlib import pytest from engineering.standards.papers import ( + ACTIVEX_PLOT_SCALE, CTB_CATALOG, + DXF_STANDARD_SCALE_TYPE, + DXF_STD_SCALES, PAPER_SIZES, SCALES, canonical_media_name, + dxf_scale_label, paper_from_size, paper_units_name, resolve_page_setup, @@ -110,19 +114,58 @@ def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): def test_resolve_page_setup_scale_codes(): one_to_five = resolve_page_setup("ISO_A3", scale="1:5") - assert one_to_five["dxf_standard_scale_type"] is None # DXF code 75 has no 1:5 - # The typelib declares ac1_5 = 19, but AutoCAD 2026 refuses - # StandardScale = 19 ("Invalid input", measured live): custom route. + # Code 19 is 1:5 in the file enum, but the typelib's ac1_5 = 19 is refused + # by AutoCAD 2026 ("Invalid input", measured live), so both engines store + # 1:5 the same way: the custom route, 142/143 = 1/5. + assert one_to_five["dxf_standard_scale_type"] is None assert one_to_five["activex_standard_scale"] is None assert one_to_five["scale_ratio"] == [1.0, 5.0] five_to_one = resolve_page_setup("ISO_A3", scale="5:1") assert five_to_one["dxf_standard_scale_type"] is None assert five_to_one["activex_standard_scale"] is None assert five_to_one["scale_ratio"] == [5.0, 1.0] - assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 25 + # One numbering: AutoCAD 2026 saves StandardScale = 26 (ac1_50) as + # group 75 = 26 (measured, COM SaveAs DXF) — not ezdxf's 25. + assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 26 assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26 +def test_dxf_standard_scale_codes_are_the_enum_autocad_writes(): + """Group 75 is the ObjectARX ``StdScaleType`` enum, the same numbering as + ActiveX ``AcPlotScale`` — measured on AutoCAD 2026 (``StandardScale = n`` + then ``SaveAs(..., 61)``): 1:2→17, 1:10→21, 1:50→26, 2:1→28, 10:1→31. + ezdxf's ``STD_SCALES`` has no 19 = 1:5 and is one lower above 18; read + through it, an AutoCAD 1:50 sheet is "1:100" and a 1:10 sheet plots 1:16. + """ + measured = {"fit": 0, "1:1": 16, "1:2": 17, "1:10": 21, "1:50": 26, "2:1": 28, "10:1": 31} + for label, code in measured.items(): + assert DXF_STANDARD_SCALE_TYPE[label] == code, label + # Every code the writer uses is the ActiveX code for the same label … + for label, code in DXF_STANDARD_SCALE_TYPE.items(): + assert ACTIVEX_PLOT_SCALE[label] == code, label + # … and its authored ratio is the catalogue factor. + for label, code in DXF_STANDARD_SCALE_TYPE.items(): + if code: + numerator, denominator = DXF_STD_SCALES[code] + assert numerator / denominator == pytest.approx(SCALES[label]), label + # The full file enum, including what the writer never emits. + assert DXF_STD_SCALES[19] == (1.0, 5.0) + assert DXF_STD_SCALES[20] == (1.0, 8.0) + assert DXF_STD_SCALES[22] == (1.0, 16.0) + assert DXF_STD_SCALES[25] == (1.0, 40.0) + assert DXF_STD_SCALES[33] == (1000.0, 1.0) + assert set(DXF_STD_SCALES) == set(range(1, 34)) + # Read-back labels come from the code, never from a stale 142/143. + assert dxf_scale_label(0, 3.0, 7.0) == "fit" + assert dxf_scale_label(26, 1.0, 100.0) == "1:50" + assert dxf_scale_label(21, 1.0, 1.0) == "1:10" + assert dxf_scale_label(19, 1.0, 1.0) == "1:5" + assert dxf_scale_label(22, 1.0, 1.0) == "1:16" + assert dxf_scale_label(28, 1.0, 0.5) == "2:1" + assert dxf_scale_label(8, 1.0, 1.0) == "1/4\"=1'" + assert dxf_scale_label(99, 1.0, 3.0) == "1:3" # unknown code: the stored ratio + + @pytest.mark.parametrize( "kwargs, field", [ @@ -392,7 +435,8 @@ async def test_layout_plot_honours_the_plot_scale(backend, tmp_path): async def test_custom_scale_sizes_the_window_too(backend): - """1:5 has no DXF code 75, so it rides on the numerator / denominator.""" + """1:5 takes the custom route (as on the live engine), so it rides on the + numerator / denominator.""" await _sheet(backend) await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5")) frame = backend._paper_frame(backend._doc.layouts.get(SHEET)) @@ -526,9 +570,103 @@ async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(b dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf assert not int(dxf.plot_layout_flags) & 16 assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0) + assert float(dxf.unit_factor) == pytest.approx(0.2) assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5" +@pytest.mark.parametrize( + "label, code, factor", + [ + ("fit", 0, 1.0), + ("1:1", 16, 1.0), + ("1:2", 17, 0.5), + ("1:10", 21, 0.1), + ("1:20", 23, 0.05), + ("1:50", 26, 0.02), + ("1:100", 27, 0.01), + ("2:1", 28, 2.0), + ("10:1", 31, 10.0), + ], +) +async def test_standard_scale_writes_the_code_and_the_factor_autocad_plots_by( + backend, label, code, factor +): + """Under USE_STANDARD_SCALE AutoCAD's plot engine scales by group 147 + alone (measured, AutoCAD 2026: a headless 1:10 sheet with code 21 and + 147 = 1.0 plotted a 100-unit line at 99.99 mm — 1:1 — while the row, + ``applied`` and 142/143 all said 1:10; patching 147 to 0.1 alone gave + 9.99 mm). AutoCAD writes 147 = numerator / denominator, 1.0 for fit; so + does the headless writer, on every route, with the code AutoCAD uses. + """ + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale=label)) + assert result["ok"] is True and result["applied"]["scale"] == label + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + assert int(dxf.plot_layout_flags) & 16 + assert int(dxf.standard_scale_type) == code + assert float(dxf.unit_factor) == pytest.approx(factor) + ratio = [1.0, 1.0] if label == "fit" else list(scale_ratio(label)) + assert [float(dxf.scale_numerator), float(dxf.scale_denominator)] == ratio + assert (await backend.page_setup_list(SHEET))[0]["scale"] == label + frame = backend._paper_frame(backend._doc.layouts.get(SHEET)) + assert frame["scale"] == label and frame["scale_applied"] is True + if label != "fit": + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0 / factor) + + +def _autocad_authored_scale(layout, code, numerator, denominator, factor): + """Store the plot scale exactly as AutoCAD 2026 saves it (measured via COM + ``SaveAs(..., 61)``): group 75 from the ObjectARX enum, 142/143 as the + ratio (AutoCAD normalises 2:1 to 1 / 0.5), 147 as the factor, bit 16 set. + """ + dxf = layout.dxf_layout.dxf + if not dxf.hasattr("plot_layout_flags"): + dxf.plot_layout_flags = dxf.get_default("plot_layout_flags") + dxf.paper_size = "ISO_A3_(420.00_x_297.00_MM)" + dxf.paper_width, dxf.paper_height = 420.0, 297.0 + dxf.plot_paper_units = 1 + dxf.plot_rotation = 0 + dxf.standard_scale_type = code + dxf.scale_numerator, dxf.scale_denominator = numerator, denominator + dxf.unit_factor = factor + layout.use_standard_scale(True) + layout.set_plot_type(5) + + +@pytest.mark.parametrize( + "code, numerator, denominator, factor, label, units_across", + [ + (21, 1.0, 10.0, 0.1, "1:10", 4200.0), + (23, 1.0, 20.0, 0.05, "1:20", 8400.0), + (26, 1.0, 50.0, 0.02, "1:50", 21000.0), + (27, 1.0, 100.0, 0.01, "1:100", 42000.0), + (28, 1.0, 0.5, 2.0, "2:1", 210.0), + (31, 1.0, 0.1, 10.0, "10:1", 42.0), + (19, 1.0, 5.0, 0.2, "1:5", 2100.0), + (22, 1.0, 16.0, 0.0625, "1:16", 6720.0), + ], +) +async def test_list_and_render_read_an_autocad_authored_sheet_at_its_own_scale( + backend, code, numerator, denominator, factor, label, units_across +): + """A sheet AutoCAD saved at 1:50 (group 75 = 26) reads back as 1:50 and + renders 21000 paper-space units across the 420 mm — not "1:100" / 42000 + (what ezdxf's one-off ``STD_SCALES`` made of it: 1:10 rendered at 1:16, + 1:20 at 1:30, 1:50 at 1:100, 1:100 at 2:1, 2:1 at 4:1, 10:1 at 100:1, + with the row and the plot disagreeing). Codes 19 and 22 are ones this + server never writes, so this reads the file enum, not the writer's table. + """ + await _sheet(backend) + lay = backend._doc.layouts.get(SHEET) + _autocad_authored_scale(lay, code, numerator, denominator, factor) + row = (await backend.page_setup_list(SHEET))[0] + assert row["scale"] == label + frame = backend._paper_frame(lay) + assert frame["scale"] == label and frame["effective_scale"] == label + assert frame["scale_applied"] is True + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(units_across) + + async def test_apply_keeps_the_viewports(backend): await _sheet(backend) await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0) From 54bbf72312306b9887e95f8850f6d2fb495a311a Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 16:55:05 +0300 Subject: [PATCH 08/14] fix(task-17): an inch sheet's fit reports paper units per drawing unit, not the mm ratio times 25.4 `_paper_frame` computed `paper_per_unit = mm_per_unit * unit` where `unit` is millimetres per paper unit, so an extents+fit plot on an inch sheet reported `effective_scale: "607.06:1"` for a fit that is `1:1.06276` (off by 25.4 squared). The render was right (`units_per_mm` is unit-free); only the label was wrong, and it flowed into `drawing_export_pdf(layout=...)`. The mm sheet could not see it because there `unit` is 1. `test_extents_plot_fits_the_printable_area_and_centres` is parametrized over ISO A3 (mm, width-bound) and ANSI B (inches, height-bound); the ink position is derived from the centred frame so both cases check the same invariant. The inch case fails without the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 7 ++++- tests/test_page_setup.py | 58 +++++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 3d8bad8..3c5352d 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2524,7 +2524,12 @@ class EzdxfBackend(AutoCADBackend): usable = [r for r in ratios if r is not None] mm_per_unit = min(usable) if usable else 1.0 units_per_mm = 1.0 / mm_per_unit - paper_per_unit = mm_per_unit * unit + # ``unit`` is millimetres per paper unit (25.4 under inches), + # so paper units per drawing unit *divides* by it. Multiplying + # reported a 1:1.06 inch fit as ``607.06:1`` (off by 25.4^2) + # while the render itself was right, ``units_per_mm`` being + # unit-free; the mm sheet hid it because there ``unit`` is 1. + paper_per_unit = mm_per_unit / unit effective = ( scale_label(paper_per_unit, 1.0) if paper_per_unit >= 1.0 diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index a3ec5ab..0ccd473 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -454,29 +454,63 @@ async def test_fit_on_a_layout_plot_is_one_to_one(backend): assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0) -async def test_extents_plot_fits_the_printable_area_and_centres(backend, tmp_path): +@pytest.mark.parametrize( + ("paper", "paper_units", "sheet_mm", "frame_units"), + [ + pytest.param("ISO_A3", "mm", (420.0, 297.0), (800.0, 500.0), id="mm"), + pytest.param("ANSI_B", "inches", (432.0, 279.0), (16.0, 10.0), id="inches"), + ], +) +async def test_extents_plot_fits_the_printable_area_and_centres( + backend, tmp_path, paper, paper_units, sheet_mm, frame_units +): """extents + fit: the paper-space extents (not the main viewport, which - ezdxf's bbox would count) scale to the printable area and sit centred.""" + ezdxf's bbox would count) scale to the printable area and sit centred. + + The inch sheet is the label's regression: ``effective_scale`` is paper + units per drawing unit, and 25.4 mm per paper unit *divides* the mm ratio. + Measured before the fix: the ANSI B fit reported ``607.06:1`` for a fit + that is ``1:1.06276`` (off by 25.4 squared) while the ink landed right, + and the mm sheet could not see it because there the unit is 1. + """ await _sheet(backend) await backend.page_setup_apply( - SHEET, resolve_page_setup("ISO_A3", scale="fit", plot_area="extents", center=True) + SHEET, + resolve_page_setup( + paper, + "landscape", + scale="fit", + plot_area="extents", + center=True, + paper_units=paper_units, + ), ) lay = backend._doc.layouts.get(SHEET) - lay.add_lwpolyline([(0, 0), (800, 0), (800, 500), (0, 500)], close=True) - top, bottom, left_mm, right_mm = (await backend.page_setup_list(SHEET))[0]["margins_mm"] - printable_w, printable_h = 420.0 - left_mm - right_mm, 297.0 - top - bottom - mm_per_unit = min(printable_w / 800.0, printable_h / 500.0) + sheet_w, sheet_h = sheet_mm + frame_w, frame_h = frame_units + lay.add_lwpolyline([(0, 0), (frame_w, 0), (frame_w, frame_h), (0, frame_h)], close=True) + row = (await backend.page_setup_list(SHEET))[0] + assert row["paper_units"] == paper_units and row["size_mm"] == list(sheet_mm) + top, bottom, left_mm, right_mm = row["margins_mm"] + printable_w, printable_h = sheet_w - left_mm - right_mm, sheet_h - top - bottom + mm_per_unit = min(printable_w / frame_w, printable_h / frame_h) + mm_per_paper_unit = 25.4 if paper_units == "inches" else 1.0 frame = backend._paper_frame(lay) assert frame["plot_area"] == "extents" and frame["plot_area_applied"] is True assert frame["scale"] == "fit" - assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0 / mm_per_unit) + # The axes window is unit-free: the sheet in drawing units. + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(sheet_w / mm_per_unit) result, (first, last, width) = await _export_png(backend, tmp_path, SHEET, "extents") - assert result["effective_scale"] == scale_label(1.0, 1.0 / mm_per_unit) - # The frame is the wider of the two: it spans the printable width exactly. - assert abs(first - int(width * left_mm / 420.0)) <= 3, (first, last, width) - assert abs(last - int(width * (420.0 - right_mm) / 420.0)) <= 3, (first, last, width) + # Paper units per drawing unit, not millimetres per drawing unit. + assert result["effective_scale"] == scale_label(1.0, mm_per_paper_unit / mm_per_unit) + # The frame sits centred in the printable area; the mm case is width-bound + # (it spans the printable width exactly), the inch case is height-bound. + frame_mm = frame_w * mm_per_unit + ink_left_mm = left_mm + (printable_w - frame_mm) / 2.0 + assert abs(first - int(width * ink_left_mm / sheet_w)) <= 3, (first, last, width) + assert abs(last - int(width * (ink_left_mm + frame_mm) / sheet_w)) <= 3, (first, last, width) async def test_extents_plot_at_a_fixed_scale_starts_at_the_plot_origin(backend): From f25421709f734eb568837c41e21e3ff488b04b52 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:39:34 +0300 Subject: [PATCH 09/14] =?UTF-8?q?feat(pagesetup):=20SECTION=2019=20?= =?UTF-8?q?=E2=80=94=20page=5Fsetup=5Flist/apply,=20plot=5Fstyle=5Flist,?= =?UTF-8?q?=20batch=5Fplot=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 From f6951e2597eac6e241f426e84dca174d91602113 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:36:07 +0300 Subject: [PATCH 10/14] fix(task-18): the COM plot is foreground, by layout name, and confirmed on disk; plot_style_list reads PrinterStyleSheetPath Three review findings against SECTION 19 on the live engine, each measured on AutoCAD 2026 in a scratch document with the operator's BACKGROUNDPLOT=2: - plot_style_list read Preferences.Files.PrintStyleSheetPath; the ActiveX member is PrinterStyleSheetPath, so the AttributeError was swallowed and every row came back installed=None. Renamed in the backend, the server docstring and the test fake (which faked the same wrong name). Live: 13 rows, all 9 catalogue ctb files installed, four .stb files as installed. - ComBackend.drawing_export_pdf returned ok before any file existed under background plotting, so batch_plot died on path.stat() with an unhandled FileNotFoundError and a later foreground plot raised E_FAIL. The export now sets BACKGROUNDPLOT=0 around PlotToFile (restored after), honours the method's bool and reports ok only with the file on disk (bytes included). batch_plot turns a backend RuntimeError or a phantom ok into a per-row ok:false, keeping the rows already plotted. - With a sheet current, the Model row was that sheet plotted again: the COM export switched tabs only when a layout was named. layout=None now makes Model current (restoring the tab), and batch_plot names every layout, Model included, so both engines plot the tab the row is labelled with. Live: Model row /Title (Model) at 210x297 beside the A3 sheet, the active tab and BACKGROUNDPLOT restored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 82 ++++++++++++--- engineering/standards/plot.py | 26 ++++- server.py | 2 +- tests/test_batch_plot.py | 189 +++++++++++++++++++++++++++++++++- 4 files changed, 278 insertions(+), 21 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 0967b7d..256aa22 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1041,23 +1041,73 @@ class ComBackend(AutoCADBackend): return await self.drawing_save_as(path, "dxf") async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: + """Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file. + + ``layout=None`` means model space, as the tool advertises — the Model + tab is made current for the plot (and the previous tab restored), so + the row is never silently the sheet that happened to be active. + + Measured on AutoCAD 2026 with the operator's default BACKGROUNDPLOT=2: + ``PlotToFile`` returns True immediately, the job is queued to the + background plotter, and no file exists when the call comes back — + seconds later the folder was still empty, and a foreground plot + attempted after such a stranded job raised E_FAIL. So the plot is + forced to the foreground (``BACKGROUNDPLOT=0``, restored afterwards), + the method's boolean is honoured, and ``ok`` is reported only once the + file is on disk with a size. + """ + def _sync(): doc = _acad_doc() - previous = None - if layout: - previous = doc.ActiveLayout.Name - if previous != layout: - doc.ActiveLayout = doc.Layouts.Item(layout) + target = layout.strip() if layout and layout.strip() else "Model" + previous = doc.ActiveLayout.Name + switched = False + if previous.lower() != target.lower(): + doc.ActiveLayout = doc.Layouts.Item(target) + switched = True + old_background = None try: - plot = doc.Plot - plot.PlotToFile(path, "DWG To PDF.pc3") - result = {"ok": True, "path": path} - if layout: - result["layout"] = layout - return result + try: + old_background = int(doc.GetVariable("BACKGROUNDPLOT")) + except Exception as exc: + log.debug("BACKGROUNDPLOT unreadable (%s); plotting as configured", exc) + if old_background not in (None, 0): + doc.SetVariable("BACKGROUNDPLOT", 0) + plotted = doc.Plot.PlotToFile(path, "DWG To PDF.pc3") + written = Path(path) + if not plotted: + return { + "ok": False, + "path": path, + "layout": target, + "error": "PlotToFile returned False: AutoCAD refused the plot " + "(check the layout's plot area and the 'DWG To PDF.pc3' device)", + } + if not written.is_file(): + return { + "ok": False, + "path": path, + "layout": target, + "error": "PlotToFile reported success but wrote no file at " + f"{path} (BACKGROUNDPLOT was {old_background})", + } + return { + "ok": True, + "path": path, + "layout": target, + "bytes": written.stat().st_size, + } finally: - if previous is not None and previous != layout: - doc.ActiveLayout = doc.Layouts.Item(previous) + if old_background not in (None, 0): + try: + doc.SetVariable("BACKGROUNDPLOT", old_background) + except Exception as exc: + log.warning("could not restore BACKGROUNDPLOT=%s: %s", old_background, exc) + if switched: + try: + doc.ActiveLayout = doc.Layouts.Item(previous) + except Exception as exc: + log.warning("could not restore the active layout %r: %s", previous, exc) return await self._run(_sync) @@ -2267,7 +2317,7 @@ class ComBackend(AutoCADBackend): async def plot_style_list(self) -> list[dict]: """The ctb catalogue, marked against the files in AutoCAD's plot style path. - ``Preferences.Files.PrintStyleSheetPath`` is a ``;``-separated list of + ``Preferences.Files.PrinterStyleSheetPath`` is a ``;``-separated list of folders. Every read is guarded: a folder that cannot be listed is skipped, and when the Preferences object is unreachable the catalogue rows come back with ``installed: None`` rather than a fabricated False. @@ -2277,9 +2327,9 @@ class ComBackend(AutoCADBackend): def _sync(): rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] try: - raw = str(_acad_app().Preferences.Files.PrintStyleSheetPath or "") + raw = str(_acad_app().Preferences.Files.PrinterStyleSheetPath or "") except Exception as exc: - log.debug("Preferences.Files.PrintStyleSheetPath unreadable: %s", exc) + log.debug("Preferences.Files.PrinterStyleSheetPath unreadable: %s", exc) return rows installed: dict[str, str] = {} for folder in (part.strip() for part in raw.split(";")): diff --git a/engineering/standards/plot.py b/engineering/standards/plot.py index ac035c5..bec01ef 100644 --- a/engineering/standards/plot.py +++ b/engineering/standards/plot.py @@ -36,7 +36,10 @@ async def batch_plot( """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 + when named, and is passed to the engine by name so the model row is model + space whichever tab is current. A sheet the engine cannot plot, or reports + plotted without a file, is a per-row ``ok: False`` with its ``error``; the + rows before it stay. 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 @@ -95,12 +98,31 @@ async def batch_plot( 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) + # The layout is always named — "Model" included — so the engine plots + # the tab this row is labelled with, never the one that happens to be + # current. A plot that fails inside the engine (COM raises RuntimeError + # for an AutoCAD error) or that reports ok without leaving a file is a + # failed row, not a lost batch: the sheets already plotted are kept. + try: + result = await backend.drawing_export_pdf(str(path), layout=name) + except RuntimeError as exc: + sheets.append({"layout": name, "ok": False, "path": str(path), "error": str(exc)}) + continue if not result.get("ok", False): sheets.append( {"layout": name, "ok": False, "path": str(path), "error": result.get("error")} ) continue + if not path.is_file(): + sheets.append( + { + "layout": name, + "ok": False, + "path": str(path), + "error": "the engine reported the plot ok but no file was written", + } + ) + continue row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size} try: width, height = read_mediabox(str(path)) diff --git a/server.py b/server.py index 1200130..990e6eb 100644 --- a/server.py +++ b/server.py @@ -6917,7 +6917,7 @@ async def page_setup_apply( 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"`, + in `Preferences.Files.PrinterStyleSheetPath` (`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. diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py index 91e8631..7af9a84 100644 --- a/tests/test_batch_plot.py +++ b/tests/test_batch_plot.py @@ -1,13 +1,15 @@ """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 +pieces of this task (the installed ctb scan, and the plot call's shape — +foreground, by layout name, confirmed on disk) are fake-tested; the SECTION 19 tools are called directly with a minimal context. """ from __future__ import annotations import types +from pathlib import Path import pytest from fastmcp.exceptions import ToolError @@ -100,6 +102,70 @@ async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_si assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3" +async def test_model_is_named_to_the_engine_even_when_a_sheet_is_current(backend, tmp_path): + """The Model row must be model space whichever tab is current. On COM the + export used to plot ``ActiveLayout`` for ``layout=None``, so with a sheet + current the "Model" PDF was that sheet again; batch_plot now names the + layout for every row, Model included.""" + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _two_sheets(backend) + await backend.layout_set_current("A3") + seen: list[str | None] = [] + original = backend.drawing_export_pdf + + async def _recording(path, layout=None): + seen.append(layout) + return await original(path, layout=layout) + + backend.drawing_export_pdf = _recording + result = await batch_plot(backend, ["Model", "A3"], str(tmp_path)) + assert seen == ["Model", "A3"] + assert result["ok"] and result["sheets"][0]["paper"] is None + assert result["sheets"][1]["paper"] == "ISO_A3" + + +async def test_a_sheet_the_engine_cannot_plot_is_a_failed_row_not_a_lost_batch(backend, tmp_path): + """COM raises RuntimeError for an AutoCAD error (E_FAIL after a stranded + background job, say). That is one row's failure; the sheets already + written stay in the report with their evidence.""" + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _two_sheets(backend) + original = backend.drawing_export_pdf + + async def _flaky(path, layout=None): + if layout == "B": + raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Unspecified error") + return await original(path, layout=layout) + + backend.drawing_export_pdf = _flaky + result = await batch_plot(backend, None, str(tmp_path)) + assert result["ok"] is False and result["count"] == 2 + a3, b = result["sheets"] + assert a3["ok"] and a3["paper"] == "ISO_A3" + assert b == { + "layout": "B", + "ok": False, + "path": str(tmp_path / "untitled-B.pdf"), + "error": "AutoCAD COM error (-0x7ffdfff7): Unspecified error", + } + + +async def test_a_plot_reported_ok_without_a_file_is_a_failed_row(backend, tmp_path): + """A background plot answers ok before any file exists; the row says so + instead of dying on ``path.stat()`` with an unhandled FileNotFoundError.""" + await _two_sheets(backend) + + async def _phantom(path, layout=None): + return {"ok": True, "path": path} + + backend.drawing_export_pdf = _phantom + result = await batch_plot(backend, ["A3"], str(tmp_path)) + assert result["ok"] is False + row = result["sheets"][0] + assert row["ok"] is False and "no file" in row["error"] + assert "bytes" not in row and "mediabox_mm" not in row + + # ── the tools ─────────────────────────────────────────────────────────────── @@ -159,7 +225,7 @@ def com_backend(monkeypatch, tmp_path): (styles / "readme.txt").write_bytes(b"") app = types.SimpleNamespace( Preferences=types.SimpleNamespace( - Files=types.SimpleNamespace(PrintStyleSheetPath=f"{styles};C:\\does\\not\\exist") + Files=types.SimpleNamespace(PrinterStyleSheetPath=f"{styles};C:\\does\\not\\exist") ) ) monkeypatch.setattr(module, "_acad_app", lambda: app) @@ -199,3 +265,122 @@ async def test_com_plot_style_list_survives_an_unreachable_preferences_object( 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) + + +# ── COM: the plot call's shape ────────────────────────────────────────────── + + +@pytest.fixture +def com_plot(monkeypatch): + """A fake document whose ``Plot.PlotToFile`` records the active layout and + BACKGROUNDPLOT at the moment of the plot — the two things AutoCAD acts on + and the reviewer measured wrong: with a sheet current, ``layout=None`` + plotted that sheet; with the operator's BACKGROUNDPLOT=2 the file did not + exist when the call returned.""" + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + state = { + "vars": {"BACKGROUNDPLOT": 2}, + "sets": [], + "active": "Layout1", + "plots": [], + "write_file": True, + "plotted": True, + } + names = ["Model", "Layout1", "Layout2"] + + class _Layout: + def __init__(self, name): + self.Name = name + + class _Layouts: + def Item(self, key): + for name in names: + if name.lower() == str(key).lower(): + return _Layout(name) + raise KeyError(key) + + class _Plot: + def PlotToFile(self, path, config): + state["plots"].append((state["active"], config, state["vars"]["BACKGROUNDPLOT"])) + if state["write_file"]: + Path(path).write_bytes(b"%PDF-1.4 fake") + return state["plotted"] + + class _Doc: + Layouts = _Layouts() + Plot = _Plot() + + @property + def ActiveLayout(self): + return _Layout(state["active"]) + + @ActiveLayout.setter + def ActiveLayout(self, layout): + state["active"] = layout.Name + + def GetVariable(self, name): + return state["vars"][name] + + def SetVariable(self, name, value): + state["sets"].append((name, value)) + state["vars"][name] = value + + monkeypatch.setattr(module, "_acad_doc", lambda: _Doc()) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, state + + +async def test_com_export_pdf_plots_model_space_by_name_in_the_foreground(com_plot, tmp_path): + backend, state = com_plot + out = tmp_path / "model.pdf" + result = await backend.drawing_export_pdf(str(out)) + assert state["plots"] == [("Model", "DWG To PDF.pc3", 0)], ( + "layout=None is model space, plotted with BACKGROUNDPLOT forced to 0" + ) + assert result == {"ok": True, "path": str(out), "layout": "Model", "bytes": out.stat().st_size} + assert state["active"] == "Layout1", "the sheet that was current is current again" + assert state["vars"]["BACKGROUNDPLOT"] == 2, "the operator's setting is restored" + assert state["sets"] == [("BACKGROUNDPLOT", 0), ("BACKGROUNDPLOT", 2)] + + +async def test_com_export_pdf_plots_the_named_sheet_and_restores_the_tab(com_plot, tmp_path): + backend, state = com_plot + result = await backend.drawing_export_pdf(str(tmp_path / "l2.pdf"), layout="layout2") + assert [row[0] for row in state["plots"]] == ["Layout2"] + assert result["ok"] and result["layout"] == "layout2" + assert state["active"] == "Layout1" + + +async def test_com_export_pdf_leaves_backgroundplot_alone_when_already_foreground( + com_plot, tmp_path +): + backend, state = com_plot + state["vars"]["BACKGROUNDPLOT"] = 0 + await backend.drawing_export_pdf(str(tmp_path / "m.pdf")) + assert state["sets"] == [] + + +async def test_com_export_pdf_reports_a_refused_plot(com_plot, tmp_path): + backend, state = com_plot + state["plotted"] = False + state["write_file"] = False + result = await backend.drawing_export_pdf(str(tmp_path / "no.pdf"), layout="Layout1") + assert result["ok"] is False and "PlotToFile returned False" in result["error"] + assert state["vars"]["BACKGROUNDPLOT"] == 2 + + +async def test_com_export_pdf_reports_a_plot_that_left_no_file(com_plot, tmp_path): + """PlotToFile answering True with nothing on disk is the background-plot + signature; it is a failure, not an ok.""" + backend, state = com_plot + state["write_file"] = False + result = await backend.drawing_export_pdf(str(tmp_path / "gone.pdf")) + assert result["ok"] is False and "wrote no file" in result["error"] + assert "bytes" not in result From 86c5d6ad656260e4f8ed6fb54a4d68e4ded88abd Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:47:05 +0300 Subject: [PATCH 11/14] fix(task-18): the headless Model plot is doc.modelspace(), not the current tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EzdxfBackend.drawing_export_pdf routed layout=None / layout="Model" through _msp(), which follows the current tab, so with a sheet current the "Model" PDF was that sheet's paper-space content with no refusal — the same defect finding 3 named on COM, still live on the headless engine. Measured: circle in model space, 390x270 rectangle on an A3 sheet, sheet current — the Model row's /MediaBox was 176.1x121.9 mm (the rectangle) instead of 121.9x121.9 (the circle). The model target is now doc.modelspace() whichever tab is current, on both entry points. The test that vouched for this only asserted the row's shape (paper is None, true by construction). It is replaced by two tests that prove content: the Model row's mediabox with a sheet current equals the one with Model current and carries the circle's square aspect, and drawing_export_pdf(path) with no layout does the same. Both fail without the backend change. plot.py's docstring now says which engine does what. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 12 ++++++- engineering/standards/plot.py | 5 ++- tests/test_batch_plot.py | 68 +++++++++++++++++++++++++++++------ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 3c5352d..66119df 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1513,6 +1513,16 @@ class EzdxfBackend(AutoCADBackend): the geometry that viewport shows, and geometry outside the window is correctly left out. What is genuinely missing is the viewport *border*, which the headless renderer does not draw. + + ``layout=None`` and ``layout="Model"`` both mean model space, as the + tool advertises and as the COM engine plots. This used to route them + through ``_msp()``, which follows the *current* tab, so with a sheet + current the "Model" PDF was that sheet's paper-space content — a wrong + page with no refusal. Measured: circle in model space, 390 x 270 + rectangle on an A3 sheet, sheet current — the Model row's /MediaBox + was 176.1 x 121.9 mm (the rectangle's aspect) instead of the circle's + 121.9 x 121.9. The target is now ``doc.modelspace()`` whichever tab + is current. """ def _sync(): @@ -1523,7 +1533,7 @@ class EzdxfBackend(AutoCADBackend): return {"ok": False, "error": f"Layout not found: {layout}"} target = doc.layouts.get(resolved) else: - target = self._msp() + target = doc.modelspace() try: from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend diff --git a/engineering/standards/plot.py b/engineering/standards/plot.py index bec01ef..3fe29cd 100644 --- a/engineering/standards/plot.py +++ b/engineering/standards/plot.py @@ -37,7 +37,10 @@ async def batch_plot( ``layouts=None`` plots every paper-space layout; ``Model`` is plotted only when named, and is passed to the engine by name so the model row is model - space whichever tab is current. A sheet the engine cannot plot, or reports + space whichever tab is current — both engines honour the name (COM plots + the Model tab; ezdxf renders ``doc.modelspace()``, not the current tab; + ``tests/test_batch_plot.py`` proves it by content on the headless engine). + A sheet the engine cannot plot, or reports plotted without a file, is a per-row ``ok: False`` with its ``error``; the rows before it stay. Refuses (``ValueError``, before any PDF is written): an empty list, a layout that does not exist, a pattern with a path separator, and diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py index 7af9a84..7092e69 100644 --- a/tests/test_batch_plot.py +++ b/tests/test_batch_plot.py @@ -102,14 +102,33 @@ async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_si assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3" -async def test_model_is_named_to_the_engine_even_when_a_sheet_is_current(backend, tmp_path): - """The Model row must be model space whichever tab is current. On COM the - export used to plot ``ActiveLayout`` for ``layout=None``, so with a sheet - current the "Model" PDF was that sheet again; batch_plot now names the - layout for every row, Model included.""" - pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") - await _two_sheets(backend) +async def _model_circle_and_sheet_rectangle(backend): + """Distinct geometry per space, told apart by the PDF's aspect ratio: a + circle in model space (a square page) and a 390 x 270 rectangle on the A3 + sheet (a landscape page).""" + await backend.entity_create_circle(50, 50, 25) + await backend.layout_rename("Layout1", "A3") + await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape")) await backend.layout_set_current("A3") + await backend.entity_create_polyline([[10, 10], [400, 10], [400, 280], [10, 280]], closed=True) + + +def _aspect(mediabox_mm): + width, height = mediabox_mm + return width / height + + +async def test_the_model_row_is_model_space_even_when_a_sheet_is_current(backend, tmp_path): + """The Model row must be model space whichever tab is current — proven by + content, not by the row's shape. The headless export used to route the + model target through ``_msp()`` (the *current* tab), so with a sheet + current the "Model" PDF was that sheet's paper-space content: measured + 176.1 x 121.9 mm (the rectangle's aspect) against 121.9 x 121.9 (the + circle) with Model current. COM had the mirror defect (``ActiveLayout`` + for ``layout=None``). batch_plot names the layout for every row, Model + included, and the engine honours the name.""" + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _model_circle_and_sheet_rectangle(backend) seen: list[str | None] = [] original = backend.drawing_export_pdf @@ -118,10 +137,39 @@ async def test_model_is_named_to_the_engine_even_when_a_sheet_is_current(backend return await original(path, layout=layout) backend.drawing_export_pdf = _recording - result = await batch_plot(backend, ["Model", "A3"], str(tmp_path)) + with_sheet_current = await batch_plot(backend, ["Model", "A3"], str(tmp_path / "sheet")) assert seen == ["Model", "A3"] - assert result["ok"] and result["sheets"][0]["paper"] is None - assert result["sheets"][1]["paper"] == "ISO_A3" + await backend.layout_set_current("Model") + with_model_current = await batch_plot(backend, ["Model"], str(tmp_path / "model")) + + model_row = with_sheet_current["sheets"][0] + assert with_sheet_current["ok"] and model_row["paper"] is None + assert model_row["mediabox_mm"] == pytest.approx( + with_model_current["sheets"][0]["mediabox_mm"], abs=0.05 + ), "the Model row changed with the current tab" + assert _aspect(model_row["mediabox_mm"]) == pytest.approx(1.0, abs=0.02), ( + "the Model row is the circle (square page), not the sheet's rectangle" + ) + a3_row = with_sheet_current["sheets"][1] + assert a3_row["paper"] == "ISO_A3" + assert _aspect(a3_row["mediabox_mm"]) == pytest.approx(420 / 297, abs=0.02) + + +async def test_export_pdf_without_a_layout_is_model_space_even_when_a_sheet_is_current( + backend, tmp_path +): + """``drawing_export_pdf(path)`` advertises model space as its default; a + current sheet must not turn it into that sheet.""" + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + from engineering.standards.plot import read_mediabox + + await _model_circle_and_sheet_rectangle(backend) + default = tmp_path / "default.pdf" + named = tmp_path / "named.pdf" + assert (await backend.drawing_export_pdf(str(default)))["ok"] + assert (await backend.drawing_export_pdf(str(named), layout="Model"))["ok"] + assert read_mediabox(str(default)) == pytest.approx(read_mediabox(str(named)), abs=0.05) + assert _aspect(read_mediabox(str(default))) == pytest.approx(1.0, abs=0.02) async def test_a_sheet_the_engine_cannot_plot_is_a_failed_row_not_a_lost_batch(backend, tmp_path): From a76d9e08a3a48afb06a7cc881329fbfde4af7ec4 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:03:41 +0300 Subject: [PATCH 12/14] feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name) SECTION 19 grows to five tools with drawing_template_list; drawing_new takes a bundled name (iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch) or a .dwt/.dxf path and reports template: {name|path, path, source}. scripts/build_templates.py builds templates/*.dxf through EzdxfBackend (layer set, header DIM variables until group S's styles land, drawing_settings, sheet tab, ISO 5457 frame on A3, page_setup_apply) and --check pins them reproducible. One drift the plan's same-process measurement could not see is fixed at the source rather than masked: ezdxf orders CLASSES by set iteration, so two processes disagreed on LAYOUT / ACDBPLACEHOLDER; the section is re-keyed (required list first, then by name) before the save. The root .gitignore ignores *.dxf as generated output; templates/*.dxf is un-ignored there, since the bundled set is the one DXF that is committed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- .gitattributes | 2 + .gitignore | 3 + Dockerfile | 1 + README.md | 5 +- discovery/aliases.py | 19 +- docs/tool-inventory.json | 21 +- engineering/standards/templates.py | 187 ++ pyproject.toml | 1 + scripts/build_templates.py | 280 ++ server.py | 81 +- templates/ansi_b_mech.dxf | 3444 +++++++++++++++++++++ templates/ansi_d_arch.dxf | 3444 +++++++++++++++++++++ templates/iso_a1_arch.dxf | 3444 +++++++++++++++++++++ templates/iso_a3_mech.dxf | 4352 ++++++++++++++++++++++++++ templates/iso_a3_pid.dxf | 4520 ++++++++++++++++++++++++++++ tests/test_templates.py | 187 ++ tests/test_tool_registry.py | 12 +- 17 files changed, 19981 insertions(+), 22 deletions(-) create mode 100644 engineering/standards/templates.py create mode 100644 scripts/build_templates.py create mode 100644 templates/ansi_b_mech.dxf create mode 100644 templates/ansi_d_arch.dxf create mode 100644 templates/iso_a1_arch.dxf create mode 100644 templates/iso_a3_mech.dxf create mode 100644 templates/iso_a3_pid.dxf create mode 100644 tests/test_templates.py diff --git a/.gitattributes b/.gitattributes index dfe0770..ada0244 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ # Auto detect text files and perform LF normalization * text=auto +templates/*.dxf text eol=crlf +templates/*.dwt binary diff --git a/.gitignore b/.gitignore index d46dc2e..13517c3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ benchmarks/results/* !benchmarks/results/published/** benchmarks/.competitors/ *.dxf +# The bundled templates are the one committed DXF set: built by +# scripts/build_templates.py and pinned reproducible by its --check. +!templates/*.dxf *.dwg *.bak diff --git a/Dockerfile b/Dockerfile index aac0f9b..e43dfab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ COPY server.py config.py security.py version.py ./ COPY backends/ ./backends/ COPY engineering/ ./engineering/ COPY discovery/ ./discovery/ +COPY templates/ ./templates/ RUN pip install --no-cache-dir -e . diff --git a/README.md b/README.md index 3dec326..dacc503 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:** 170 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 171 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. @@ -104,6 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | 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), `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=…)` | +| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list` | | 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 +114,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 | -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. +171 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/discovery/aliases.py b/discovery/aliases.py index a59e657..bd77614 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -445,7 +445,13 @@ TOOL_ALIASES: dict[str, ToolAliases] = { ), "drawing_new": ToolAliases( acad=("NEW", "QNEW"), - synonyms=("new drawing", "start a drawing", "blank sheet", "create a file"), + synonyms=( + "new drawing", + "start a drawing", + "blank sheet", + "create a file", + "start from a bundled template", + ), ), "drawing_open": ToolAliases( acad=("OPEN",), @@ -1445,6 +1451,17 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "plot the whole drawing set", ), ), + "drawing_template_list": ToolAliases( + acad=(), + synonyms=( + "which templates are there", + "bundled templates", + "iso a3 template", + "ansi b sheet", + "start from a standard sheet", + "dwt list", + ), + ), "page_setup_apply": ToolAliases( acad=("PAGESETUP",), synonyms=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 401303e..62e22ca 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": 170, + "tools": 171, "resources": 8, "prompts": 5, "groups": 20 @@ -25,7 +25,7 @@ "premium": 12, "solids": 5, "system": 7, - "templates": 2, + "templates": 3, "transactions": 3, "validation": 1, "view": 4 @@ -596,7 +596,7 @@ "name": "drawing_new", "group": "drawing", "cost": "mutate", - "summary": "Start a blank drawing, pre-seeded with the standard engineering layers.", + "summary": "Start a drawing: blank with the engineering layers, or from a bundled/own template.", "tags": [ "drawing" ], @@ -726,6 +726,21 @@ "destructiveHint": false } }, + { + "name": "drawing_template_list", + "group": "templates", + "cost": "read", + "summary": "List the five bundled drawing templates: standard, sheet, layers, styles, page setup.", + "tags": [ + "drawing", + "query", + "template" + ], + "annotations": { + "title": "List Drawing Templates", + "readOnlyHint": true + } + }, { "name": "drawing_undo", "group": "drawing", diff --git a/engineering/standards/templates.py b/engineering/standards/templates.py new file mode 100644 index 0000000..8306b18 --- /dev/null +++ b/engineering/standards/templates.py @@ -0,0 +1,187 @@ +"""The five bundled drawing templates: what each contains, where its files live. + +Built by ``scripts/build_templates.py`` through the server's own backend +(layers, styles, settings, the ISO 5457 A3 frame where the sheet is A3, page +setup) and saved as DXF; the ``.dwt`` twins are produced on a live AutoCAD by +``scripts/smoke_settings_com.py --build-dwt`` and committed once. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path + +__all__ = [ + "TEMPLATES_DIR", + "TEMPLATE_CATALOG", + "TemplateSpec", + "resolve_template", + "template_rows", +] + +#: ``/templates`` — shipped in the wheel (pyproject ``only-include``) and the Docker image. +TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates" + +_ISO_SETTINGS = { + "units": "mm", + "ltscale": 1.0, + "dimscale": 1.0, + "linear_precision": 2, + "angular_precision": 0, + "decimal_separator": ",", +} +_ANSI_SETTINGS = {**_ISO_SETTINGS, "decimal_separator": "."} + + +@dataclass(frozen=True) +class TemplateSpec: + name: str + standard: str # "ISO" | "ANSI" + sheet: str # human label, e.g. "A3 landscape" + paper: str # engineering.standards.papers.PAPER_SIZES key + orientation: str + layout: str # the paper-space tab's name + layer_set: str # engineering.layers.LAYER_SET_REGISTRY key + dimstyle_preset: str # group S preset id: "iso-25" | "ansi" + dimstyle: str # style name in the drawing + textstyle: str # "ISOCP" | "ROMANS" + scale: str # page setup scale label + title_block: bool + settings: dict = field(default_factory=dict) # drawing_settings facade keys + description: str = "" + + +TEMPLATE_CATALOG: dict[str, TemplateSpec] = { + spec.name: spec + for spec in ( + TemplateSpec( + name="iso_a3_mech", + standard="ISO", + sheet="A3 landscape", + paper="ISO_A3", + orientation="landscape", + layout="A3", + layer_set="mech", + dimstyle_preset="iso-25", + dimstyle="ISO-25", + textstyle="ISOCP", + scale="1:1", + title_block=True, + settings=dict(_ISO_SETTINGS), + description="ISO A3 mechanical part drawing", + ), + TemplateSpec( + name="iso_a1_arch", + standard="ISO", + sheet="A1 landscape", + paper="ISO_A1", + orientation="landscape", + layout="A1", + layer_set="iso13567", + dimstyle_preset="iso-25", + dimstyle="ISO-25", + textstyle="ISOCP", + scale="fit", + title_block=False, + settings={**_ISO_SETTINGS, "annotation_scale": "1:50"}, + description="ISO A1 architectural sheet, ISO 13567 layers, 1:50 annotation", + ), + TemplateSpec( + name="iso_a3_pid", + standard="ISO", + sheet="A3 landscape", + paper="ISO_A3", + orientation="landscape", + layout="A3", + layer_set="pid", + dimstyle_preset="iso-25", + dimstyle="ISO-25", + textstyle="ISOCP", + scale="1:1", + title_block=True, + settings=dict(_ISO_SETTINGS), + description="ISO A3 P&ID sheet", + ), + TemplateSpec( + name="ansi_b_mech", + standard="ANSI", + sheet="ANSI B landscape", + paper="ANSI_B", + orientation="landscape", + layout="B", + layer_set="mech", + dimstyle_preset="ansi", + dimstyle="ANSI", + textstyle="ROMANS", + scale="1:1", + title_block=False, + settings=dict(_ANSI_SETTINGS), + description="ANSI B mechanical part drawing (metric)", + ), + TemplateSpec( + name="ansi_d_arch", + standard="ANSI", + sheet="ANSI D landscape", + paper="ANSI_D", + orientation="landscape", + layout="D", + layer_set="iso13567", + dimstyle_preset="ansi", + dimstyle="ANSI", + textstyle="ROMANS", + scale="fit", + title_block=False, + settings=dict(_ANSI_SETTINGS), + description="ANSI D architectural sheet (metric)", + ), + ) +} + + +def _file(name: str, suffix: str) -> dict: + path = TEMPLATES_DIR / f"{name}{suffix}" + return {"path": str(path), "present": path.is_file()} + + +def template_rows() -> list[dict]: + """Catalogue rows for ``drawing_template_list``: the spec plus its files.""" + rows = [] + for spec in TEMPLATE_CATALOG.values(): + row = asdict(spec) + row["files"] = {"dxf": _file(spec.name, ".dxf"), "dwt": _file(spec.name, ".dwt")} + rows.append(row) + return rows + + +def resolve_template(name_or_path: str, engine: str) -> tuple[str, str]: + """``(path, source)`` for a catalogue name or a template file path. + + A catalogue name resolves to ``templates/.dxf`` (headless) or + ``templates/.dwt`` (live), falling back to the DXF with + ``source="bundled_dxf"`` when the ``.dwt`` twin is missing. Anything with a + path separator or a ``.dwt``/``.dxf`` suffix is a path (``source="path"``, + not checked here). A bare word that is not in the catalogue is refused + with the catalogue names. + """ + raw = str(name_or_path or "").strip() + if not raw: + raise ValueError("template: pass a bundled template name or a path to a .dwt/.dxf file") + key = raw.lower() + if key in TEMPLATE_CATALOG: + dxf = TEMPLATES_DIR / f"{key}.dxf" + dwt = TEMPLATES_DIR / f"{key}.dwt" + if engine == "com" and dwt.is_file(): + return str(dwt), "bundled" + if dxf.is_file(): + return str(dxf), "bundled" if engine != "com" else "bundled_dxf" + raise ValueError( + f"template {key!r} is in the catalogue but {dxf} is missing — run " + "`uv run --frozen python scripts/build_templates.py`" + ) + looks_like_path = ("/" in raw or "\\" in raw) or Path(raw).suffix.lower() in (".dwt", ".dxf") + if looks_like_path: + return raw, "path" + raise ValueError( + f"unknown template {raw!r}. Bundled templates: {', '.join(TEMPLATE_CATALOG)}; " + "or pass a path to a .dwt/.dxf file" + ) diff --git a/pyproject.toml b/pyproject.toml index 09cd8d9..5243780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,7 @@ only-include = [ "backends", "engineering", "discovery", + "templates", ] [tool.ruff] diff --git a/scripts/build_templates.py b/scripts/build_templates.py new file mode 100644 index 0000000..af0e51e --- /dev/null +++ b/scripts/build_templates.py @@ -0,0 +1,280 @@ +"""Build the bundled templates through the server's own backend, reproducibly. + + uv run --frozen python scripts/build_templates.py # write templates/*.dxf + uv run --frozen python scripts/build_templates.py --check # exit 1 if a rebuild differs + uv run --frozen python scripts/build_templates.py --out DIR # build elsewhere + +Each template is: drawing_new -> apply_layer_set -> styles -> drawing_settings +-> layout renamed to the sheet -> ISO 5457 frame (A3 sheets) -> page_setup_apply +-> drawing_save_as DXF. Nothing is hand-authored, so what the tools produce is +what ships. + +Styles: once group S's ``dimstyle_create`` / ``textstyle_create`` are on the +backend they are used (ISO-25 / ISOCP, ANSI / ROMANS, set current). Until then +the same values (spec §4.1) are written as header DIM variables so the template +already dimensions correctly; the final task re-runs this script after the S +merge and re-commits the five files. + +``--check`` rebuilds into a temporary folder and compares line by line after +masking the values ezdxf cannot keep constant — measured on 2026-09-16, exactly +six lines differ between two builds: $TDCREATE, $TDUPDATE, $FINGERPRINTGUID, +$VERSIONGUID and the two EZDXF_META DICTIONARYVAR stamps. One more source of +drift is removed before the save rather than masked: the CLASSES section, which +ezdxf otherwise orders by set iteration (see ``_pin_class_order``). +""" + +from __future__ import annotations + +import argparse +import asyncio +import re +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from backends.ezdxf_backend import EzdxfBackend # noqa: E402 +from engineering import TitleBlockMetadata, apply_iso_a3_titleblock # noqa: E402 +from engineering.layers import apply_layer_set # noqa: E402 +from engineering.standards.papers import resolve_page_setup # noqa: E402 +from engineering.standards.templates import ( # noqa: E402 + TEMPLATE_CATALOG, + TEMPLATES_DIR, + TemplateSpec, +) + +#: Header variables whose values change on every save. +VOLATILE_HEADER_VARS = frozenset( + { + "$TDCREATE", + "$TDUPDATE", + "$TDUCREATE", + "$TDUUPDATE", + "$TDINDWG", + "$TDUSRTIMER", + "$FINGERPRINTGUID", + "$VERSIONGUID", + } +) +#: The EZDXF_META stamps: ``1.4.4 @ 2026-09-16T12:33:38.192529+00:00``. +_EZDXF_STAMP = re.compile(r"^\d+\.\d+\.\d+\S* @ \d{4}-\d{2}-\d{2}T") + +#: Spec §4.1 as header variables — the fallback until dimstyle_create lands. +#: DIMDSEP goes through the facade (decimal_separator); DIMTXSTY is omitted +#: because the text style it names is created by textstyle_create. +_FALLBACK_DIMVARS: dict[str, dict[str, float | int]] = { + "iso-25": { + "DIMTXT": 2.5, + "DIMASZ": 2.5, + "DIMEXE": 1.25, + "DIMEXO": 0.625, + "DIMGAP": 0.625, + "DIMTAD": 1, + "DIMTIH": 0, + "DIMTOH": 0, + "DIMDEC": 2, + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, + }, + "ansi": { + "DIMTXT": 3.0, + "DIMASZ": 3.0, + "DIMEXE": 1.5, + "DIMEXO": 1.5, + "DIMGAP": 1.0, + "DIMTAD": 0, + "DIMTIH": 1, + "DIMTOH": 1, + "DIMDEC": 2, + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, + }, +} + + +async def _apply_styles(backend, spec: TemplateSpec) -> dict: + if hasattr(backend, "dimstyle_create") and hasattr(backend, "textstyle_create"): + from engineering.standards.dimstyles import resolve_dimstyle + from engineering.standards.textstyles import TEXT_PRESETS + + font, width_factor, oblique = TEXT_PRESETS[spec.textstyle] + text = await backend.textstyle_create( + spec.textstyle, font, 0.0, width_factor, oblique, set_current=True + ) + dim = await backend.dimstyle_create( + spec.dimstyle, resolve_dimstyle(spec.dimstyle_preset, None), set_current=True + ) + return {"mode": "styles", "dimstyle": dim["name"], "textstyle": text["name"]} + applied = {} + for variable, value in _FALLBACK_DIMVARS[spec.dimstyle_preset].items(): + applied[variable] = (await backend.system_set_variable(variable, value))["ok"] + return { + "mode": "header_dimvars", + "applied": applied, + "pending": "dimstyle_create/textstyle_create not on this backend yet (group S)", + } + + +def _pin_class_order(doc) -> list[str]: + """Put the CLASSES section in a stable order before the save. + + ezdxf fills CLASSES from ``entitydb.dxf_types_in_use()`` — a ``set[str]``, + so its order follows the process's string-hash seed and two builds in + different processes put ``LAYOUT`` / ``ACDBPLACEHOLDER`` in different slots + (measured 2026-09-21: the same-process double build agreed, ``--check`` + from a fresh process did not). The backend's undo baseline has already + written the document once by now, so the section is already populated in + that random order; it is re-keyed here as ezdxf's required list first, then + every other class by name. + """ + from ezdxf.sections.classes import REQ_R2004, REQUIRED_CLASSES + + section = doc.classes + section.add_required_classes(doc.dxfversion) + for name in sorted(doc.entitydb.dxf_types_in_use()): + section.add_class(name) + rank = {name: i for i, name in enumerate(REQUIRED_CLASSES.get(doc.dxfversion, REQ_R2004))} + ordered = sorted( + section.classes.items(), + key=lambda item: (rank.get(item[1].dxf.name, len(rank)), item[1].dxf.name, item[0]), + ) + section.classes = dict(ordered) + return [cls.dxf.name for cls in section.classes.values()] + + +async def build_template(spec: TemplateSpec, out_dir: Path) -> dict: + """Build one template into ``out_dir/.dxf`` and report what went in.""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_new() + layers = await apply_layer_set(backend, spec.layer_set) + styles = await _apply_styles(backend, spec) + settings = await backend.drawing_settings(dict(spec.settings)) + pending = sorted(settings.get("errors") or {}) # keys the facade does not know yet + renamed = await backend.layout_rename("Layout1", spec.layout) + if not renamed.get("ok"): + raise RuntimeError(f"{spec.name}: layout rename failed: {renamed}") + title_block = None + if spec.title_block: + title_block = await apply_iso_a3_titleblock( + backend, + metadata=TitleBlockMetadata( + title=spec.description.upper(), + drawing_no="TEMPLATE", + scale=spec.scale if spec.scale != "fit" else "1:1", + ), + layout=spec.layout, + ) + setup = resolve_page_setup( + spec.paper, + spec.orientation, + "monochrome.ctb", + spec.scale, + "layout", + "DWG To PDF.pc3", + [0.0, 0.0, 0.0, 0.0], # the frame is drawn at the paper corner; no unprintable band + True, + ) + page_setup = await backend.page_setup_apply(spec.layout, setup) + await backend.layout_set_current("Model") + classes = _pin_class_order(backend._doc) + path = out_dir / f"{spec.name}.dxf" + await backend.drawing_save_as(str(path), "dxf") + return { + "name": spec.name, + "path": str(path), + "layers": layers, + "styles": styles, + "settings_applied": settings.get("applied", {}), + "settings_pending": pending, + "title_block": bool(title_block), + "page_setup": {"ok": page_setup["ok"], "paper": page_setup["applied"]["paper"]}, + "classes": classes, + } + finally: + await backend.disconnect() + + +async def build_all(out_dir: Path = TEMPLATES_DIR) -> list[dict]: + return [await build_template(spec, out_dir) for spec in TEMPLATE_CATALOG.values()] + + +def normalised_lines(path: Path) -> list[str]: + """The DXF's lines with every value that legitimately changes per save masked.""" + lines = Path(path).read_text(encoding="utf-8").splitlines() + out: list[str] = [] + mask_at = -1 + for index, line in enumerate(lines): + if index == mask_at: + out.append("") + continue + stripped = line.strip() + if stripped in VOLATILE_HEADER_VARS: + mask_at = index + 2 # " 9" / "$TDCREATE" / " 40" / + if _EZDXF_STAMP.match(stripped): + out.append("") + continue + out.append(line) + return out + + +def drifted_names(rebuilt: Path) -> list[str]: + """Names whose committed DXF differs from the build in ``rebuilt`` (masked compare).""" + drifted = [] + for name in TEMPLATE_CATALOG: + committed = TEMPLATES_DIR / f"{name}.dxf" + if not committed.is_file(): + drifted.append(f"{name} (missing)") + continue + fresh = normalised_lines(Path(rebuilt) / f"{name}.dxf") + current = normalised_lines(committed) + if fresh != current: + first = next( + (i for i, (a, b) in enumerate(zip(fresh, current, strict=False)) if a != b), + min(len(fresh), len(current)), + ) + drifted.append(f"{name} (first difference at line {first + 1})") + return drifted + + +def check_templates() -> list[str]: + """CLI ``--check``: rebuild into a temporary folder and report drift.""" + with tempfile.TemporaryDirectory() as folder: + asyncio.run(build_all(Path(folder))) + return drifted_names(Path(folder)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--check", action="store_true", help="rebuild to a temp dir and diff") + parser.add_argument("--out", type=Path, default=TEMPLATES_DIR, help="output folder") + args = parser.parse_args(argv) + if args.check: + drifted = check_templates() + if drifted: + print("templates drifted from scripts/build_templates.py: " + ", ".join(drifted)) + return 1 + print(f"{len(TEMPLATE_CATALOG)} templates reproduce byte-for-byte (volatile stamps masked)") + return 0 + for row in asyncio.run(build_all(args.out)): + pending = f" pending={row['settings_pending']}" if row["settings_pending"] else "" + print(f"{row['name']}: {row['path']} styles={row['styles']['mode']}{pending}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server.py b/server.py index 990e6eb..5e96773 100644 --- a/server.py +++ b/server.py @@ -1082,7 +1082,7 @@ async def drawing_info(ctx: Context) -> dict: @cad_tool( - summary="Start a blank drawing, pre-seeded with the standard engineering layers.", + summary="Start a drawing: blank with the engineering layers, or from a bundled/own template.", cost="mutate", ) @mcp.tool( @@ -1090,7 +1090,11 @@ async def drawing_info(ctx: Context) -> dict: tags={"drawing"}, ) async def drawing_new( - template: Annotated[str | None, "Optional path to .dwt template file"] = None, + template: Annotated[ + str | None, + "Bundled template name (drawing_template_list: iso_a3_mech, iso_a1_arch, iso_a3_pid, " + "ansi_b_mech, ansi_d_arch) or a path to a .dwt/.dxf template file.", + ] = None, bootstrap: Annotated[ bool, Field( @@ -1101,19 +1105,43 @@ async def drawing_new( ] = True, ctx: Context = None, ) -> dict: - """Create a new empty drawing, optionally from a template (.dwt). + """Create a new drawing, optionally from a template. - When ``bootstrap=True`` (default), the drawing is also seeded with the - standard engineering linetypes (CENTER/HIDDEN/PHANTOM) and layers - (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). + A bundled name resolves to `templates/.dxf` headlessly and + `templates/.dwt` on the live engine (falling back to the DXF with + `source: "bundled_dxf"` when no `.dwt` twin is committed); a path is used + as given. The result carries `template: {name|path, path, source}`. + + Refused before anything is replaced: a bare name that is not in the + catalogue (the message lists the five), a template path that does not + exist, and a path outside the allowed directories. With `bootstrap=True` + (default) the standard engineering linetypes and layers are ensured + afterwards — idempotent on a template that already has them. """ - if template is not None: - validated_template = validate_path(template, allow_write=False) - template = str(validated_template) - await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})") + from pathlib import Path as _P + + from engineering.standards.templates import resolve_template + backend = _backend(ctx) + template_info: dict | None = None + if template is not None: + try: + resolved, source = resolve_template(template, backend.name) + except ValueError as exc: + raise ToolError(f"drawing_new: {exc}") from exc + if source == "path": + resolved = str(validate_path(resolved, allow_write=False)) + if not _P(resolved).is_file(): + raise ToolError(f"drawing_new: template file not found: {resolved}") + template_info = {"path": resolved, "source": source} + else: + template_info = {"name": template.strip().lower(), "path": resolved, "source": source} + template = resolved + await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})") raw = await backend.drawing_new(template) result = dict(raw) if isinstance(raw, dict) else {"result": raw} + if template_info is not None: + result["template"] = template_info if bootstrap: try: from engineering import ( @@ -6816,7 +6844,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 19: Page Setup & Templates (4 tools) ──────────────────────────── +# ── SECTION 19: Page Setup & Templates (5 tools) ──────────────────────────── # --------------------------------------------------------------------------- @@ -6974,6 +7002,37 @@ async def batch_plot( raise ToolError(str(exc)) from exc +@cad_tool( + summary="List the five bundled drawing templates: standard, sheet, layers, styles, page setup.", + cost="read", +) +@mcp.tool( + annotations={"title": "List Drawing Templates", "readOnlyHint": True}, + tags={"template", "drawing", "query"}, +) +async def drawing_template_list(ctx: Context = None) -> dict: + """The templates `drawing_new(template=)` can start from. + + Each row: `standard` (ISO/ANSI), `sheet`, `paper`, `layout` (the sheet tab's + name), `layer_set` (mech / pid / iso13567), `dimstyle` / `textstyle` + (ISO-25 / ISOCP or ANSI / ROMANS), `scale`, `title_block` (the ISO 5457 A3 + frame, A3 sheets only), the `settings` applied, and `files.dxf` / + `files.dwt` with `present` flags — the `.dwt` twins exist only once built + on a live AutoCAD (`scripts/smoke_settings_com.py --build-dwt`). Never + refuses; it reads a catalogue. + """ + from engineering.standards.templates import TEMPLATES_DIR, template_rows + + rows = template_rows() + return { + "ok": True, + "templates": rows, + "count": len(rows), + "templates_dir": str(TEMPLATES_DIR), + "engine": _backend(ctx).name, + } + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/templates/ansi_b_mech.dxf b/templates/ansi_b_mech.dxf new file mode 100644 index 0000000..3c59c40 --- /dev/null +++ b/templates/ansi_b_mech.dxf @@ -0,0 +1,3444 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +3.0 + 9 +$DIMEXO + 40 +1.5 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.5 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +3.0 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +1 + 9 +$DIMTOH + 70 +1 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +0 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +1.0 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +46 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +2 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461305.9168518516 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9168518516 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +40 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +432.0 + 20 +279.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{C86D7286-E19E-4B12-95FA-2422D8FA8D21} + 9 +$VERSIONGUID + 2 +{6AD177C1-789E-403D-83B3-B9A1E5C62637} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +23 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +0.0 + 22 +0.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +1000.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +8 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.0 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +HIDDEN + 70 +0 + 3 +Hidden __ __ __ __ __ __ __ __ __ + 72 +65 + 73 +2 + 40 +7.5 + 49 +5.0 + 74 +0 + 49 +-2.5 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +2.5 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +0.6 + 49 +0.5 + 74 +0 + 49 +-0.1 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +1.4 + 49 +1.0 + 74 +0 + 49 +-0.2 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.2 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +11 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +34 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +GEOMETRY + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +35 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +HIDDEN + 70 +0 + 62 +3 + 6 +HIDDEN +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +36 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CENTER + 70 +0 + 62 +1 + 6 +CENTER +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +37 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PHANTOM + 70 +0 + 62 +6 + 6 +PHANTOM +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +38 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DIM + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +39 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +HATCH + 70 +0 + 62 +8 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +3B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TITLEBLOCK + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +3C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CONSTRUCTION + 70 +0 + 62 +250 + 6 +Continuous +370 +5 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +3D +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +3E +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +B +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG To PDF.pc3 + 4 +ANSI_B_(17.00_x_11.00_Inches) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +432.0 + 45 +279.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +0 + 74 +5 + 7 +monochrome.ctb + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +B + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +432.0 + 21 +279.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +3F + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.253966+00:00 + 0 +DICTIONARYVAR + 5 +3F +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.261077+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/templates/ansi_d_arch.dxf b/templates/ansi_d_arch.dxf new file mode 100644 index 0000000..1313fa4 --- /dev/null +++ b/templates/ansi_d_arch.dxf @@ -0,0 +1,3444 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +3.0 + 9 +$DIMEXO + 40 +1.5 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.5 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +3.0 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +1 + 9 +$DIMTOH + 70 +1 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +0 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +1.0 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +46 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +2 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461305.9168518516 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9168518516 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +40 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +864.0 + 20 +559.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{5DBCBFFD-85BB-4756-97CD-DF14393BA48D} + 9 +$VERSIONGUID + 2 +{8BCC5654-8976-41B9-B27D-2DE6DFEDDA75} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +23 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +0.0 + 22 +0.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +1000.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +8 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.0 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +HIDDEN + 70 +0 + 3 +Hidden __ __ __ __ __ __ __ __ __ + 72 +65 + 73 +2 + 40 +7.5 + 49 +5.0 + 74 +0 + 49 +-2.5 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +2.5 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +0.6 + 49 +0.5 + 74 +0 + 49 +-0.1 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +1.4 + 49 +1.0 + 74 +0 + 49 +-0.2 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.2 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +11 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +34 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-GEOMET-E-N + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +35 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-HIDDEN-E-N + 70 +0 + 62 +3 + 6 +HIDDEN +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +36 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-CENTL-E-N + 70 +0 + 62 +1 + 6 +CENTER +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +37 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-PHANT-E-N + 70 +0 + 62 +6 + 6 +PHANTOM +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +38 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-DIMEN-T-N + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +39 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-TEXT-T-N + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-HATCH-H-N + 70 +0 + 62 +8 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +3B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-TITLE-T-N + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +3C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-CONST-E-N + 70 +0 + 62 +250 + 6 +Continuous +370 +5 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +3D +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +3E +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +D +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG To PDF.pc3 + 4 +ANSI_D_(34.00_x_22.00_Inches) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +864.0 + 45 +559.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +0 + 74 +5 + 7 +monochrome.ctb + 75 +0 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +D + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +864.0 + 21 +559.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +3F + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.265081+00:00 + 0 +DICTIONARYVAR + 5 +3F +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.272301+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/templates/iso_a1_arch.dxf b/templates/iso_a1_arch.dxf new file mode 100644 index 0000000..8904c76 --- /dev/null +++ b/templates/iso_a1_arch.dxf @@ -0,0 +1,3444 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +2 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461305.9168518516 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9168518516 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +40 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +841.0 + 20 +594.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{06CA53E2-CDAA-439F-9CAD-D12BED7E531C} + 9 +$VERSIONGUID + 2 +{DFE1833D-4A98-418F-8215-C1956203953D} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +23 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +0.0 + 22 +0.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +1000.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +8 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.0 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +HIDDEN + 70 +0 + 3 +Hidden __ __ __ __ __ __ __ __ __ + 72 +65 + 73 +2 + 40 +7.5 + 49 +5.0 + 74 +0 + 49 +-2.5 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +2.5 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +0.6 + 49 +0.5 + 74 +0 + 49 +-0.1 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +1.4 + 49 +1.0 + 74 +0 + 49 +-0.2 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.2 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +11 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +34 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-GEOMET-E-N + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +35 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-HIDDEN-E-N + 70 +0 + 62 +3 + 6 +HIDDEN +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +36 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-CENTL-E-N + 70 +0 + 62 +1 + 6 +CENTER +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +37 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-PHANT-E-N + 70 +0 + 62 +6 + 6 +PHANTOM +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +38 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-DIMEN-T-N + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +39 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-TEXT-T-N + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-HATCH-H-N + 70 +0 + 62 +8 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +3B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-TITLE-T-N + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +3C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +M-CONST-E-N + 70 +0 + 62 +250 + 6 +Continuous +370 +5 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +3D +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +3E +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +A1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG To PDF.pc3 + 4 +ISO_A1_(841.00_x_594.00_MM) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +841.0 + 45 +594.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +0 + 74 +5 + 7 +monochrome.ctb + 75 +0 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A1 + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +841.0 + 21 +594.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +3F + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.220288+00:00 + 0 +DICTIONARYVAR + 5 +3F +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.228248+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/templates/iso_a3_mech.dxf b/templates/iso_a3_mech.dxf new file mode 100644 index 0000000..d2b5f9a --- /dev/null +++ b/templates/iso_a3_mech.dxf @@ -0,0 +1,4352 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +2 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461305.9168518516 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9168518516 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +62 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{7664E9B7-DDAC-4E40-A6C4-4BA2274F50FC} + 9 +$VERSIONGUID + 2 +{AA05654D-61E8-4264-879F-0A0EB2E7F4EE} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +23 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +0.0 + 22 +0.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +1000.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +8 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.0 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +HIDDEN + 70 +0 + 3 +Hidden __ __ __ __ __ __ __ __ __ + 72 +65 + 73 +2 + 40 +7.5 + 49 +5.0 + 74 +0 + 49 +-2.5 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +2.5 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +0.6 + 49 +0.5 + 74 +0 + 49 +-0.1 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +1.4 + 49 +1.0 + 74 +0 + 49 +-0.2 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.2 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +11 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +34 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +GEOMETRY + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +35 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +HIDDEN + 70 +0 + 62 +3 + 6 +HIDDEN +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +36 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CENTER + 70 +0 + 62 +1 + 6 +CENTER +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +37 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PHANTOM + 70 +0 + 62 +6 + 6 +PHANTOM +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +38 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DIM + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +39 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +HATCH + 70 +0 + 62 +8 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +3B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TITLEBLOCK + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +3C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CONSTRUCTION + 70 +0 + 62 +250 + 6 +Continuous +370 +5 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +5F +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +60 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +3D +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +420.0 + 20 +0.0 + 10 +420.0 + 20 +297.0 + 10 +0.0 + 20 +297.0 + 0 +LWPOLYLINE + 5 +3E +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +287.0 + 10 +20.0 + 20 +287.0 + 0 +LWPOLYLINE + 5 +3F +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +230.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +70.0 + 10 +230.0 + 20 +70.0 + 0 +LINE + 5 +40 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +230.0 + 20 +20.0 + 30 +0.0 + 11 +410.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +41 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +230.0 + 20 +35.0 + 30 +0.0 + 11 +410.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +42 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +230.0 + 20 +50.0 + 30 +0.0 + 11 +410.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +43 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +320.0 + 20 +35.0 + 30 +0.0 + 11 +320.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +44 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +365.0 + 20 +35.0 + 30 +0.0 + 11 +365.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +45 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +290.0 + 20 +20.0 + 30 +0.0 + 11 +290.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +46 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +350.0 + 20 +20.0 + 30 +0.0 + 11 +350.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +47 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +275.0 + 20 +10.0 + 30 +0.0 + 11 +275.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +48 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +320.0 + 20 +10.0 + 30 +0.0 + 11 +320.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +49 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +365.0 + 20 +10.0 + 30 +0.0 + 11 +365.0 + 21 +20.0 + 31 +0.0 + 0 +TEXT + 5 +4A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +275.0 + 20 +57.5 + 30 +0.0 + 40 +5.0 + 1 +ISO A3 MECHANICAL PART DRAWING +100 +AcDbText + 0 +TEXT + 5 +4B +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +231.5 + 20 +46.5 + 30 +0.0 + 40 +2.0 + 1 +DWG NO +100 +AcDbText + 0 +TEXT + 5 +4C +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +233.0 + 20 +40.0 + 30 +0.0 + 40 +3.5 + 1 +TEMPLATE +100 +AcDbText + 0 +TEXT + 5 +4D +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +321.5 + 20 +46.5 + 30 +0.0 + 40 +2.0 + 1 +REV +100 +AcDbText + 0 +TEXT + 5 +4E +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +323.0 + 20 +40.0 + 30 +0.0 + 40 +3.5 + 1 +A +100 +AcDbText + 0 +TEXT + 5 +4F +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +366.5 + 20 +46.5 + 30 +0.0 + 40 +2.0 + 1 +SHEET +100 +AcDbText + 0 +TEXT + 5 +50 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +368.0 + 20 +40.0 + 30 +0.0 + 40 +3.5 + 1 +1/1 +100 +AcDbText + 0 +TEXT + 5 +51 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +231.5 + 20 +31.5 + 30 +0.0 + 40 +2.0 + 1 +PART NO +100 +AcDbText + 0 +TEXT + 5 +52 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +233.0 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +53 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +291.5 + 20 +31.5 + 30 +0.0 + 40 +2.0 + 1 +MATERIAL +100 +AcDbText + 0 +TEXT + 5 +54 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +293.0 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +55 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +351.5 + 20 +31.5 + 30 +0.0 + 40 +2.0 + 1 +SCALE +100 +AcDbText + 0 +TEXT + 5 +56 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +353.0 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 1 +1:1 +100 +AcDbText + 0 +TEXT + 5 +57 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +231.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +DRAWN +100 +AcDbText + 0 +TEXT + 5 +58 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +233.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +59 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +276.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +CHECKED +100 +AcDbText + 0 +TEXT + 5 +5A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +278.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +5B +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +321.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +DATE +100 +AcDbText + 0 +TEXT + 5 +5C +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +323.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +5D +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +366.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +COMPANY +100 +AcDbText + 0 +TEXT + 5 +5E +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +368.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 +Anka-Makine +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +A3 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG To PDF.pc3 + 4 +ISO_A3_(420.00_x_297.00_MM) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +0 + 74 +5 + 7 +monochrome.ctb + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A3 + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +61 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.186887+00:00 + 0 +DICTIONARYVAR + 5 +61 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.215553+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/templates/iso_a3_pid.dxf b/templates/iso_a3_pid.dxf new file mode 100644 index 0000000..0518178 --- /dev/null +++ b/templates/iso_a3_pid.dxf @@ -0,0 +1,4520 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +2 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461305.9168518516 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9168518516 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +69 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +4 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{A7BD53ED-11FA-44DC-94CD-4208EB90B212} + 9 +$VERSIONGUID + 2 +{E08D8D46-7798-4863-A73A-C6527F804C81} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +23 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +0.0 + 22 +0.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +1000.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +8 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.0 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +HIDDEN + 70 +0 + 3 +Hidden __ __ __ __ __ __ __ __ __ + 72 +65 + 73 +2 + 40 +7.5 + 49 +5.0 + 74 +0 + 49 +-2.5 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +2.5 + 49 +1.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 49 +0.25 + 74 +0 + 49 +-0.25 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +0.6 + 49 +0.5 + 74 +0 + 49 +-0.1 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +1.4 + 49 +1.0 + 74 +0 + 49 +-0.2 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.2 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +18 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +34 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PROCESS-PIPING-MAIN + 70 +0 + 62 +1 + 6 +Continuous +370 +70 +390 +13 +347 +21 + 0 +LAYER + 5 +35 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PROCESS-PIPING-SECONDARY + 70 +0 + 62 +2 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +36 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PROCESS-EQUIPMENT + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +37 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PROCESS-VALVES + 70 +0 + 62 +3 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +38 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +INSTRUMENT-SYMBOL + 70 +0 + 62 +4 + 6 +Continuous +370 +35 +390 +13 +347 +21 + 0 +LAYER + 5 +39 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +INSTRUMENT-LINE-SIGNAL + 70 +0 + 62 +4 + 6 +DASHED +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +3A +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +INSTRUMENT-TAG-TEXT + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3B +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +PROCESS-LINE-TEXT + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +ELECTRICAL-LINE + 70 +0 + 62 +6 + 6 +DASHDOT +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +3D +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +UTILITY-LINE + 70 +0 + 62 +5 + 6 +PHANTOM +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +3E +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +INSULATION-HATCH + 70 +0 + 62 +8 + 6 +Continuous +370 +13 +390 +13 +347 +21 + 0 +LAYER + 5 +3F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +DIM + 70 +0 + 62 +2 + 6 +Continuous +370 +18 +390 +13 +347 +21 + 0 +LAYER + 5 +40 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT-NOTES + 70 +0 + 62 +7 + 6 +Continuous +370 +25 +390 +13 +347 +21 + 0 +LAYER + 5 +41 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TITLEBLOCK + 70 +0 + 62 +7 + 6 +Continuous +370 +50 +390 +13 +347 +21 + 0 +LAYER + 5 +42 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +CONSTRUCTION + 70 +0 + 62 +250 + 6 +Continuous +370 +5 +390 +13 +347 +21 + 0 +LAYER + 5 +51 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +TEXT + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +66 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +67 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LWPOLYLINE + 5 +43 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +420.0 + 20 +0.0 + 10 +420.0 + 20 +297.0 + 10 +0.0 + 20 +297.0 + 0 +LWPOLYLINE + 5 +44 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +20.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +287.0 + 10 +20.0 + 20 +287.0 + 0 +LWPOLYLINE + 5 +45 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +230.0 + 20 +10.0 + 10 +410.0 + 20 +10.0 + 10 +410.0 + 20 +70.0 + 10 +230.0 + 20 +70.0 + 0 +LINE + 5 +46 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +230.0 + 20 +20.0 + 30 +0.0 + 11 +410.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +47 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +230.0 + 20 +35.0 + 30 +0.0 + 11 +410.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +48 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +230.0 + 20 +50.0 + 30 +0.0 + 11 +410.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +49 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +320.0 + 20 +35.0 + 30 +0.0 + 11 +320.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +4A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +365.0 + 20 +35.0 + 30 +0.0 + 11 +365.0 + 21 +50.0 + 31 +0.0 + 0 +LINE + 5 +4B +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +290.0 + 20 +20.0 + 30 +0.0 + 11 +290.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +4C +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +350.0 + 20 +20.0 + 30 +0.0 + 11 +350.0 + 21 +35.0 + 31 +0.0 + 0 +LINE + 5 +4D +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +275.0 + 20 +10.0 + 30 +0.0 + 11 +275.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +4E +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +320.0 + 20 +10.0 + 30 +0.0 + 11 +320.0 + 21 +20.0 + 31 +0.0 + 0 +LINE + 5 +4F +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TITLEBLOCK +100 +AcDbLine + 10 +365.0 + 20 +10.0 + 30 +0.0 + 11 +365.0 + 21 +20.0 + 31 +0.0 + 0 +TEXT + 5 +50 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +294.5 + 20 +57.5 + 30 +0.0 + 40 +5.0 + 1 +ISO A3 P&ID SHEET +100 +AcDbText + 0 +TEXT + 5 +52 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +231.5 + 20 +46.5 + 30 +0.0 + 40 +2.0 + 1 +DWG NO +100 +AcDbText + 0 +TEXT + 5 +53 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +233.0 + 20 +40.0 + 30 +0.0 + 40 +3.5 + 1 +TEMPLATE +100 +AcDbText + 0 +TEXT + 5 +54 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +321.5 + 20 +46.5 + 30 +0.0 + 40 +2.0 + 1 +REV +100 +AcDbText + 0 +TEXT + 5 +55 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +323.0 + 20 +40.0 + 30 +0.0 + 40 +3.5 + 1 +A +100 +AcDbText + 0 +TEXT + 5 +56 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +366.5 + 20 +46.5 + 30 +0.0 + 40 +2.0 + 1 +SHEET +100 +AcDbText + 0 +TEXT + 5 +57 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +368.0 + 20 +40.0 + 30 +0.0 + 40 +3.5 + 1 +1/1 +100 +AcDbText + 0 +TEXT + 5 +58 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +231.5 + 20 +31.5 + 30 +0.0 + 40 +2.0 + 1 +PART NO +100 +AcDbText + 0 +TEXT + 5 +59 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +233.0 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +5A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +291.5 + 20 +31.5 + 30 +0.0 + 40 +2.0 + 1 +MATERIAL +100 +AcDbText + 0 +TEXT + 5 +5B +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +293.0 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +5C +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +351.5 + 20 +31.5 + 30 +0.0 + 40 +2.0 + 1 +SCALE +100 +AcDbText + 0 +TEXT + 5 +5D +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +353.0 + 20 +25.0 + 30 +0.0 + 40 +3.5 + 1 +1:1 +100 +AcDbText + 0 +TEXT + 5 +5E +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +231.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +DRAWN +100 +AcDbText + 0 +TEXT + 5 +5F +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +233.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +60 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +276.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +CHECKED +100 +AcDbText + 0 +TEXT + 5 +61 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +278.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +62 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +321.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +DATE +100 +AcDbText + 0 +TEXT + 5 +63 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +323.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 + +100 +AcDbText + 0 +TEXT + 5 +64 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +366.5 + 20 +16.5 + 30 +0.0 + 40 +2.0 + 1 +COMPANY +100 +AcDbText + 0 +TEXT + 5 +65 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +TEXT +100 +AcDbText + 10 +368.0 + 20 +11.5 + 30 +0.0 + 40 +3.5 + 1 +Anka-Makine +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +A3 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +DWG To PDF.pc3 + 4 +ISO_A3_(420.00_x_297.00_MM) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +688 + 72 +1 + 73 +0 + 74 +5 + 7 +monochrome.ctb + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A3 + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +68 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.231308+00:00 + 0 +DICTIONARYVAR + 5 +68 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:00:16.248961+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..fa49c77 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,187 @@ +"""Bundled templates: catalogue, resolution, reproducible build, drawing_new(template=).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest +from fastmcp.exceptions import ToolError + +import server +from engineering.standards.templates import ( + TEMPLATE_CATALOG, + TEMPLATES_DIR, + resolve_template, + template_rows, +) + +pytestmark = pytest.mark.asyncio + +ROOT = Path(__file__).resolve().parents[1] +NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch") + + +def _build_module(): + spec = importlib.util.spec_from_file_location( + "build_templates", ROOT / "scripts" / "build_templates.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +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 + + +# ── catalogue ─────────────────────────────────────────────────────────────── + + +def test_catalog_has_the_five_spec_rows(): + assert tuple(TEMPLATE_CATALOG) == NAMES + rows = {spec.name: spec for spec in TEMPLATE_CATALOG.values()} + assert (rows["iso_a3_mech"].paper, rows["iso_a3_mech"].layer_set) == ("ISO_A3", "mech") + assert (rows["iso_a1_arch"].paper, rows["iso_a1_arch"].layer_set) == ("ISO_A1", "iso13567") + assert rows["iso_a1_arch"].settings["annotation_scale"] == "1:50" + assert (rows["iso_a3_pid"].paper, rows["iso_a3_pid"].layer_set) == ("ISO_A3", "pid") + assert (rows["ansi_b_mech"].paper, rows["ansi_b_mech"].dimstyle) == ("ANSI_B", "ANSI") + assert (rows["ansi_d_arch"].paper, rows["ansi_d_arch"].scale) == ("ANSI_D", "fit") + assert [spec.title_block for spec in TEMPLATE_CATALOG.values()] == [ + True, + False, + True, + False, + False, + ] + assert all(spec.orientation == "landscape" for spec in TEMPLATE_CATALOG.values()) + assert {spec.textstyle for spec in TEMPLATE_CATALOG.values()} == {"ISOCP", "ROMANS"} + + +def test_template_rows_report_files(): + rows = template_rows() + assert [row["name"] for row in rows] == list(NAMES) + for row in rows: + assert set(row["files"]) == {"dxf", "dwt"} + assert row["files"]["dxf"]["path"].endswith(f"{row['name']}.dxf") + assert isinstance(row["files"]["dxf"]["present"], bool) + + +def test_resolve_template_bundled_path_and_unknown(tmp_path): + path, source = resolve_template("iso_a3_mech", "ezdxf") + assert source == "bundled" and path == str(TEMPLATES_DIR / "iso_a3_mech.dxf") + path, source = resolve_template("ISO_A3_MECH", "ezdxf") + assert source == "bundled" + # COM prefers the .dwt twin and says when it fell back to the DXF + path, source = resolve_template("iso_a3_mech", "com") + assert source in ("bundled", "bundled_dxf") + assert path.endswith(".dwt" if source == "bundled" else ".dxf") + explicit = tmp_path / "mine.dwt" + assert resolve_template(str(explicit), "ezdxf") == (str(explicit), "path") + assert resolve_template("sheet.dxf", "com") == ("sheet.dxf", "path") + with pytest.raises( + ValueError, match="iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch" + ): + resolve_template("iso_a2_mech", "ezdxf") + with pytest.raises(ValueError, match="template"): + resolve_template("", "ezdxf") + + +# ── the build ─────────────────────────────────────────────────────────────── + + +async def test_build_is_deterministic_run_to_run(tmp_path): + build = _build_module() + first = await build.build_all(tmp_path / "one") + second = await build.build_all(tmp_path / "two") + assert [row["name"] for row in first] == list(NAMES) + for name in NAMES: + a = build.normalised_lines(tmp_path / "one" / f"{name}.dxf") + b = build.normalised_lines(tmp_path / "two" / f"{name}.dxf") + assert a == b, f"{name} differs between two builds" + assert all(row["page_setup"]["ok"] for row in first) + assert all(row["layers"] for row in first) + assert second # the second build is only there to be compared with the first + + +async def test_committed_templates_match_a_fresh_build(tmp_path): + """The spec's reproducibility claim — the same comparison the script's --check runs.""" + build = _build_module() + missing = [name for name in NAMES if not (TEMPLATES_DIR / f"{name}.dxf").is_file()] + assert not missing, f"bundled templates missing: {missing} — run scripts/build_templates.py" + await build.build_all(tmp_path) + drifted = build.drifted_names(tmp_path) + assert drifted == [], f"templates drifted from the script: {drifted}" + + +# ── drawing_new(template=) ────────────────────────────────────────────────── + + +async def test_drawing_new_from_a_bundled_template(backend): + result = await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend)) + assert result["ok"] is True + assert result["template"] == { + "name": "iso_a3_mech", + "path": str(TEMPLATES_DIR / "iso_a3_mech.dxf"), + "source": "bundled", + } + layers = {layer.name for layer in await backend.layer_list()} + assert {"GEOMETRY", "DIM", "HIDDEN", "CENTER", "TITLEBLOCK"} <= layers + row = (await backend.page_setup_list("A3"))[0] + assert row["paper"] == "ISO_A3" and row["orientation"] == "landscape" + assert row["plot_style"] == "monochrome.ctb" and row["scale"] == "1:1" + sheet = backend._doc.layouts.get("A3") + assert any(e.dxftype() == "LWPOLYLINE" for e in sheet), "the ISO 5457 frame is on the sheet" + assert float(await backend.system_get_variable("DIMTXT")) == 2.5 + if hasattr(backend, "dimstyle_create"): # after group S merges + assert await backend.system_get_variable("DIMSTYLE") == "ISO-25" + + +async def test_drawing_new_from_the_pid_and_ansi_templates(backend): + await server.drawing_new(template="iso_a3_pid", ctx=_Ctx(backend)) + assert "PROCESS-PIPING-MAIN" in {layer.name for layer in await backend.layer_list()} + await server.drawing_new(template="ansi_d_arch", ctx=_Ctx(backend)) + assert "M-GEOMET-E-N" in {layer.name for layer in await backend.layer_list()} + row = (await backend.page_setup_list("D"))[0] + assert row["paper"] == "ANSI_D" and row["size_mm"] == [864.0, 559.0] and row["scale"] == "fit" + assert (await backend.drawing_settings())["settings"]["decimal_separator"] == "." + + +async def test_drawing_new_unknown_template_is_refused_with_the_names(backend): + await backend.entity_create_line(0, 0, 1, 1) + before = (await backend.drawing_info()).entity_count + with pytest.raises(ToolError, match="iso_a3_mech"): + await server.drawing_new(template="iso_a2_mech", ctx=_Ctx(backend)) + assert (await backend.drawing_info()).entity_count == before, "the refusal replaced nothing" + + +async def test_drawing_new_missing_template_path_is_refused(backend, tmp_path): + with pytest.raises(ToolError, match="not found"): + await server.drawing_new(template=str(tmp_path / "gone.dxf"), ctx=_Ctx(backend)) + + +async def test_drawing_new_from_an_explicit_dxf_path_reports_source_path(backend, tmp_path): + await backend.layer_create("MINE", color=3) + saved = tmp_path / "mine.dxf" + await backend.drawing_save_as(str(saved), "dxf") + result = await server.drawing_new(template=str(saved), ctx=_Ctx(backend)) + assert result["template"]["source"] == "path" + assert result["template"]["path"] == str(saved.resolve()) + assert "MINE" in {layer.name for layer in await backend.layer_list()} + + +async def test_drawing_template_list_tool(backend): + result = await server.drawing_template_list(ctx=_Ctx(backend)) + assert result["ok"] and result["count"] == 5 + assert [row["name"] for row in result["templates"]] == list(NAMES) + assert result["engine"] == "ezdxf" diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index b09f14c..1970ce7 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 (170 tools, 20 groups). + """Frozen snapshot of the surface (171 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 @@ -521,8 +521,10 @@ async def test_tool_group_sizes_are_unchanged(): `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. + `query`/`export`, so they file with the sheets they set up. `templates` + moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19 + (tagged `template`, which ranks above `drawing`). 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()} assert sizes == { @@ -542,9 +544,9 @@ async def test_tool_group_sizes_are_unchanged(): "premium": 12, "solids": 5, "system": 7, - "templates": 2, + "templates": 3, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 170 + assert sum(sizes.values()) == 171 From 17f7228fd6fb800abb5c68c10a3da2c4290ebd97 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:31:44 +0300 Subject: [PATCH 13/14] fix(task-19): a .dxf template really reaches the live seat; the fallback dimvars land on Standard Documents.Add() accepts only a genuine DWT. Measured on AutoCAD 2026: Add(templates/iso_a3_pid.dxf) returned the default acadiso drawing (layers ['0'], Layout1/Layout2) exactly as Add() and Add() did, so drawing_new(template=) reported ok:true with source 'bundled_dxf' over a blank document, and the explicit .dxf path route was just as blank. ComBackend.drawing_new now converts any non-.dwt template through AutoCAD itself (Documents.Open read-only -> SaveAs ac2018_Template, older constants tried next -> Close(False)), caches the .dwt in the temp folder keyed on path/size/mtime, hands Add the .dwt, and refuses a missing template file instead of letting Add default silently. The tool result carries template.dwt / template.dwt_cached. Re-measured end to end through server.drawing_new on the real ComBackend: ansi_b_mech -> 11 layers, tab B, DIMTAD 0 / DIMTIH 1 / DIMEXO 1.5; iso_a3_pid -> 17 layers, tab A3, second call reuses the cached .dwt (0.8 s conversion once). Fake-ActiveX tests pin the call shape and that the conversion document never stays open. Headlessly the build's fallback wrote spec 4.1 only as header DIM variables, and the renderer folds six of them, so DIMTAD/TIH/TOH/EXE/EXO/GAP/LUNIT/LWD were a no-op: ansi_b_mech dimensioned with ISO text placement while system_get_variable and AutoCAD said ANSI. The fallback now also stores each value on the Standard dimstyle; the five DXFs are rebuilt (--check passes) and a test reads the effective values off a DIMENSION drawn from each template and asserts header and style agree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 96 ++++++++++++++- engineering/standards/templates.py | 11 +- scripts/build_templates.py | 15 ++- server.py | 11 ++ templates/ansi_b_mech.dxf | 28 ++--- templates/ansi_d_arch.dxf | 28 ++--- templates/iso_a1_arch.dxf | 12 +- templates/iso_a3_mech.dxf | 12 +- templates/iso_a3_pid.dxf | 12 +- tests/test_templates.py | 186 +++++++++++++++++++++++++++++ 10 files changed, 354 insertions(+), 57 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 256aa22..d1819f2 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -169,6 +169,71 @@ def _acad_app(): return _COM_STATE["app"] +#: ``AcSaveAsType`` template constants, newest first: ac2018_Template, +#: ac2013_Template, ac2010_Template. A seat older than the first refuses it +#: with a COM error and the next one is tried. +_TEMPLATE_SAVE_FORMATS = (66, 62, 50) + + +def _template_cache_dir() -> Path: + import tempfile + + return Path(tempfile.gettempdir()) / "acadmcp-templates" + + +def _template_as_dwt(app, source: Path) -> tuple[Path, bool]: + """A real ``.dwt`` for ``source``, built once through AutoCAD and cached. + + ``Documents.Add()`` accepts only a genuine DWT: measured on AutoCAD + 2026, ``Add(templates/iso_a3_mech.dxf)`` returned the default acadiso + drawing (layers ``['0']``, tabs ``Layout1``/``Layout2``) exactly as + ``Add()`` and ``Add()`` did, and the DXF bytes renamed + ``.dwt`` fared no better. Opening the file and saving it as a template + (``SaveAs(path, ac2018_Template)``) is what yields the eleven layers and + the ``A3`` tab, so that is done here — once per file content, keyed on the + path, size and mtime, under the temp folder — and the ``.dwt`` is what + ``Add`` is handed. Returns ``(dwt_path, cached)``. + + Must only be called from the COM executor thread. The conversion document + is opened read-only and closed without saving in a ``finally``; the + ``.dwt`` on disk is the only thing it leaves behind. + """ + import hashlib + + source = Path(source).resolve() + stat = source.stat() + digest = hashlib.sha1( + f"{source}|{stat.st_size}|{stat.st_mtime_ns}".encode("utf-8", "surrogateescape") + ).hexdigest()[:12] + cache_dir = _template_cache_dir() + cache_dir.mkdir(parents=True, exist_ok=True) + dwt = cache_dir / f"{source.stem}-{digest}.dwt" + if dwt.is_file() and dwt.stat().st_size > 0: + return dwt, True + doc = app.Documents.Open(str(source), True) + try: + last_exc: Exception | None = None + for fmt in _TEMPLATE_SAVE_FORMATS: + try: + doc.SaveAs(str(dwt), fmt) + break + except _COM_ERROR as exc: + last_exc = exc + log.debug("SaveAs(%s, %d) refused: %s", dwt, fmt, exc) + else: + raise RuntimeError( + f"could not save {source.name} as a .dwt template: {last_exc}" + ) from last_exc + finally: + try: + doc.Close(False) + except Exception as exc: # the temp document must never stay open + log.warning("template conversion document did not close: %s", exc) + if not dwt.is_file() or dwt.stat().st_size == 0: + raise RuntimeError(f"AutoCAD reported the template saved but {dwt} is not on disk") + return dwt, False + + def _acad_doc(): """Return active AutoCAD document.""" app = _acad_app() @@ -993,13 +1058,36 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def drawing_new(self, template: str | None = None) -> dict: + """``Documents.Add()``, or ``Documents.Add()`` from a template file. + + A template that is not already a ``.dwt`` (the bundled ``.dxf`` files, + an explicit ``.dxf`` path) is first converted into one through AutoCAD + itself — see ``_template_as_dwt`` for the measurement that makes this + necessary — and the result reports ``template_dwt: {path, cached}``. + A template path that is not a file is refused here rather than handed + to ``Add``, which would silently create the default drawing instead. + """ + def _sync(): app = _acad_app() - if template: - doc = app.Documents.Add(template) - else: + if not template: doc = app.Documents.Add() - return {"ok": True, "name": doc.Name} + return {"ok": True, "name": doc.Name} + source = Path(template) + if not source.is_file(): + raise FileNotFoundError( + f"template file not found: {template} (Documents.Add would silently " + "create the default drawing instead)" + ) + result: dict[str, Any] = {"ok": True} + if source.suffix.lower() == ".dwt": + dwt = source + else: + dwt, cached = _template_as_dwt(app, source) + result["template_dwt"] = {"path": str(dwt), "cached": cached} + doc = app.Documents.Add(str(dwt)) + result["name"] = doc.Name + return result result = await self._run(_sync) await self._ensure_document_state() diff --git a/engineering/standards/templates.py b/engineering/standards/templates.py index 8306b18..056b8cb 100644 --- a/engineering/standards/templates.py +++ b/engineering/standards/templates.py @@ -158,10 +158,13 @@ def resolve_template(name_or_path: str, engine: str) -> tuple[str, str]: A catalogue name resolves to ``templates/.dxf`` (headless) or ``templates/.dwt`` (live), falling back to the DXF with - ``source="bundled_dxf"`` when the ``.dwt`` twin is missing. Anything with a - path separator or a ``.dwt``/``.dxf`` suffix is a path (``source="path"``, - not checked here). A bare word that is not in the catalogue is refused - with the catalogue names. + ``source="bundled_dxf"`` when the ``.dwt`` twin is missing — the live + engine then converts that DXF into a real ``.dwt`` itself before + ``Documents.Add`` (``ComBackend.drawing_new``), because AutoCAD ignores a + DXF handed to ``Add`` and silently returns the default drawing. Anything + with a path separator or a ``.dwt``/``.dxf`` suffix is a path + (``source="path"``, not checked here). A bare word that is not in the + catalogue is refused with the catalogue names. """ raw = str(name_or_path or "").strip() if not raw: diff --git a/scripts/build_templates.py b/scripts/build_templates.py index af0e51e..447dae3 100644 --- a/scripts/build_templates.py +++ b/scripts/build_templates.py @@ -11,9 +11,15 @@ what ships. Styles: once group S's ``dimstyle_create`` / ``textstyle_create`` are on the backend they are used (ISO-25 / ISOCP, ANSI / ROMANS, set current). Until then -the same values (spec §4.1) are written as header DIM variables so the template -already dimensions correctly; the final task re-runs this script after the S -merge and re-commits the five files. +the same values (spec §4.1) are written twice — as header DIM variables, which +is what a live seat dimensions with, *and* onto the ``Standard`` dimstyle +entry, which is what the headless renderer reads (it never consults the +header; ``_with_header_dimvars`` folds six of the variables, so the other +eight — DIMTAD, DIMTIH, DIMTOH, DIMEXE, DIMEXO, DIMGAP, DIMLUNIT, DIMLWD/E — +were a header-only no-op: measured, ``ansi_b_mech`` dimensioned with ISO text +placement headlessly while AutoCAD honoured the header). Both engines now +dimension the same template the same way; the final task re-runs this script +after the S merge and re-commits the five files. ``--check`` rebuilds into a temporary folder and compares line by line after masking the values ezdxf cannot keep constant — measured on 2026-09-16, exactly @@ -115,11 +121,14 @@ async def _apply_styles(backend, spec: TemplateSpec) -> dict: ) return {"mode": "styles", "dimstyle": dim["name"], "textstyle": text["name"]} applied = {} + standard = backend._doc.dimstyles.get("Standard") # what the headless renderer reads for variable, value in _FALLBACK_DIMVARS[spec.dimstyle_preset].items(): applied[variable] = (await backend.system_set_variable(variable, value))["ok"] + standard.dxf.set(variable.lower(), value) return { "mode": "header_dimvars", "applied": applied, + "dimstyle": standard.dxf.name, "pending": "dimstyle_create/textstyle_create not on this backend yet (group S)", } diff --git a/server.py b/server.py index 5e96773..58cce6f 100644 --- a/server.py +++ b/server.py @@ -1112,6 +1112,13 @@ async def drawing_new( `source: "bundled_dxf"` when no `.dwt` twin is committed); a path is used as given. The result carries `template: {name|path, path, source}`. + On the live engine only a genuine `.dwt` reaches `Documents.Add` — + measured on AutoCAD 2026, `Add()` silently returns the default + drawing — so a `.dxf` template (bundled or a path) is first converted + through AutoCAD into a cached `.dwt` and `template.dwt` names it + (`template.dwt_cached` says whether the conversion was reused). The + drawing then really carries the template's layers, tabs and page setup. + Refused before anything is replaced: a bare name that is not in the catalogue (the message lists the five), a template path that does not exist, and a path outside the allowed directories. With `bootstrap=True` @@ -1140,7 +1147,11 @@ async def drawing_new( await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})") raw = await backend.drawing_new(template) result = dict(raw) if isinstance(raw, dict) else {"result": raw} + converted = result.pop("template_dwt", None) if template_info is not None: + if converted: # the live engine built a .dwt from the .dxf (see ComBackend.drawing_new) + template_info["dwt"] = converted["path"] + template_info["dwt_cached"] = bool(converted.get("cached")) result["template"] = template_info if bootstrap: try: diff --git a/templates/ansi_b_mech.dxf b/templates/ansi_b_mech.dxf index 3c59c40..fa99f5d 100644 --- a/templates/ansi_b_mech.dxf +++ b/templates/ansi_b_mech.dxf @@ -489,7 +489,7 @@ $SKPOLY 9 $TDCREATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUCREATE 40 @@ -497,7 +497,7 @@ $TDUCREATE 9 $TDUPDATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUUPDATE 40 @@ -929,11 +929,11 @@ $PSTYLEMODE 9 $FINGERPRINTGUID 2 -{C86D7286-E19E-4B12-95FA-2422D8FA8D21} +{C3A95304-315D-4482-8B26-4A154C8AC81B} 9 $VERSIONGUID 2 -{6AD177C1-789E-403D-83B3-B9A1E5C62637} +{020A53F4-F6E6-4A10-988E-58908DDBD5B7} 9 $EXTNAMES 290 @@ -2157,13 +2157,13 @@ Standard 40 1.0 41 -2.5 +3.0 42 -0.625 +1.5 43 3.75 44 -1.25 +1.5 45 0.0 46 @@ -2175,7 +2175,7 @@ Standard 49 2.5 140 -2.5 +3.0 141 2.5 142 @@ -2189,7 +2189,7 @@ Standard 146 1.0 147 -0.625 +1.0 148 0.0 69 @@ -2201,15 +2201,15 @@ Standard 72 0 73 -0 +1 74 -0 +1 75 0 76 0 77 -1 +0 78 8 79 @@ -3425,7 +3425,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.253966+00:00 +1.4.4 @ 2026-09-21T19:25:45.807766+00:00 0 DICTIONARYVAR 5 @@ -3437,7 +3437,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.261077+00:00 +1.4.4 @ 2026-09-21T19:25:45.814791+00:00 0 ENDSEC 0 diff --git a/templates/ansi_d_arch.dxf b/templates/ansi_d_arch.dxf index 1313fa4..2a5042d 100644 --- a/templates/ansi_d_arch.dxf +++ b/templates/ansi_d_arch.dxf @@ -489,7 +489,7 @@ $SKPOLY 9 $TDCREATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUCREATE 40 @@ -497,7 +497,7 @@ $TDUCREATE 9 $TDUPDATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUUPDATE 40 @@ -929,11 +929,11 @@ $PSTYLEMODE 9 $FINGERPRINTGUID 2 -{5DBCBFFD-85BB-4756-97CD-DF14393BA48D} +{EA8DAB3B-221B-4973-A128-0F97D36F69C3} 9 $VERSIONGUID 2 -{8BCC5654-8976-41B9-B27D-2DE6DFEDDA75} +{41F085E9-E128-43F7-931E-FEE570A4F621} 9 $EXTNAMES 290 @@ -2157,13 +2157,13 @@ Standard 40 1.0 41 -2.5 +3.0 42 -0.625 +1.5 43 3.75 44 -1.25 +1.5 45 0.0 46 @@ -2175,7 +2175,7 @@ Standard 49 2.5 140 -2.5 +3.0 141 2.5 142 @@ -2189,7 +2189,7 @@ Standard 146 1.0 147 -0.625 +1.0 148 0.0 69 @@ -2201,15 +2201,15 @@ Standard 72 0 73 -0 +1 74 -0 +1 75 0 76 0 77 -1 +0 78 8 79 @@ -3425,7 +3425,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.265081+00:00 +1.4.4 @ 2026-09-21T19:25:45.818106+00:00 0 DICTIONARYVAR 5 @@ -3437,7 +3437,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.272301+00:00 +1.4.4 @ 2026-09-21T19:25:45.824109+00:00 0 ENDSEC 0 diff --git a/templates/iso_a1_arch.dxf b/templates/iso_a1_arch.dxf index 8904c76..e43499c 100644 --- a/templates/iso_a1_arch.dxf +++ b/templates/iso_a1_arch.dxf @@ -489,7 +489,7 @@ $SKPOLY 9 $TDCREATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUCREATE 40 @@ -497,7 +497,7 @@ $TDUCREATE 9 $TDUPDATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUUPDATE 40 @@ -929,11 +929,11 @@ $PSTYLEMODE 9 $FINGERPRINTGUID 2 -{06CA53E2-CDAA-439F-9CAD-D12BED7E531C} +{569A5E5D-D8AE-4EE2-8CC8-222F1BC5A1B6} 9 $VERSIONGUID 2 -{DFE1833D-4A98-418F-8215-C1956203953D} +{CA16A478-EDAC-4921-A9AD-1AD1114D5AB2} 9 $EXTNAMES 290 @@ -3425,7 +3425,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.220288+00:00 +1.4.4 @ 2026-09-21T19:25:45.777979+00:00 0 DICTIONARYVAR 5 @@ -3437,7 +3437,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.228248+00:00 +1.4.4 @ 2026-09-21T19:25:45.783975+00:00 0 ENDSEC 0 diff --git a/templates/iso_a3_mech.dxf b/templates/iso_a3_mech.dxf index d2b5f9a..d0332f7 100644 --- a/templates/iso_a3_mech.dxf +++ b/templates/iso_a3_mech.dxf @@ -489,7 +489,7 @@ $SKPOLY 9 $TDCREATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUCREATE 40 @@ -497,7 +497,7 @@ $TDUCREATE 9 $TDUPDATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUUPDATE 40 @@ -929,11 +929,11 @@ $PSTYLEMODE 9 $FINGERPRINTGUID 2 -{7664E9B7-DDAC-4E40-A6C4-4BA2274F50FC} +{6791163C-4461-4A1B-8F3F-5AD608DBCCCE} 9 $VERSIONGUID 2 -{AA05654D-61E8-4264-879F-0A0EB2E7F4EE} +{9A1CD7BF-015F-4598-B3DB-2BDBFBCC12FA} 9 $EXTNAMES 290 @@ -4333,7 +4333,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.186887+00:00 +1.4.4 @ 2026-09-21T19:25:45.743145+00:00 0 DICTIONARYVAR 5 @@ -4345,7 +4345,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.215553+00:00 +1.4.4 @ 2026-09-21T19:25:45.772937+00:00 0 ENDSEC 0 diff --git a/templates/iso_a3_pid.dxf b/templates/iso_a3_pid.dxf index 0518178..1bed4fb 100644 --- a/templates/iso_a3_pid.dxf +++ b/templates/iso_a3_pid.dxf @@ -489,7 +489,7 @@ $SKPOLY 9 $TDCREATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUCREATE 40 @@ -497,7 +497,7 @@ $TDUCREATE 9 $TDUPDATE 40 -2461305.9168518516 +2461305.9345486113 9 $TDUUPDATE 40 @@ -929,11 +929,11 @@ $PSTYLEMODE 9 $FINGERPRINTGUID 2 -{A7BD53ED-11FA-44DC-94CD-4208EB90B212} +{B5F8B467-F9F9-4DC6-8A95-A266D8246CA8} 9 $VERSIONGUID 2 -{E08D8D46-7798-4863-A73A-C6527F804C81} +{F759C60A-BBAF-4155-A187-77BF78127E81} 9 $EXTNAMES 290 @@ -4501,7 +4501,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.231308+00:00 +1.4.4 @ 2026-09-21T19:25:45.787707+00:00 0 DICTIONARYVAR 5 @@ -4513,7 +4513,7 @@ DictionaryVariables 280 0 1 -1.4.4 @ 2026-09-21T19:00:16.248961+00:00 +1.4.4 @ 2026-09-21T19:25:45.803766+00:00 0 ENDSEC 0 diff --git a/tests/test_templates.py b/tests/test_templates.py index fa49c77..7a98b29 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import types from pathlib import Path import pytest @@ -157,6 +158,53 @@ async def test_drawing_new_from_the_pid_and_ansi_templates(backend): assert (await backend.drawing_settings())["settings"]["decimal_separator"] == "." +#: Spec §4.1, the eight variables `_with_header_dimvars` does not fold — the +#: ones a header-only write silently loses on the headless renderer. +_PLACEMENT = ("dimtad", "dimtih", "dimtoh", "dimexo", "dimexe", "dimgap", "dimlunit", "dimlwd") + + +async def _effective_dimvars(backend) -> dict: + """What the next dimension is really rendered with, read off the entity.""" + from ezdxf.entities import DimStyleOverride + + info = await backend.dimension_linear(0, 0, 100, 0, 50, 20) + override = DimStyleOverride(backend._doc.entitydb[info.handle]) + return {name: override.get(name) for name in _PLACEMENT + ("dimtxt", "dimasz")} + + +async def test_templates_dimension_with_their_own_preset_headlessly(backend): + """The build's fallback lands on the ``Standard`` dimstyle, not only the header. + + Measured before the fix: ``ansi_b_mech`` reported DIMTAD 0 / DIMTIH 1 from + ``system_get_variable`` while the dimension it drew used dimtad=1 / dimtih=0 + (the ISO values on ``Standard``), so the same template dimensioned + differently per engine. The header and the style must agree. + """ + await server.drawing_new(template="ansi_b_mech", ctx=_Ctx(backend)) + ansi = await _effective_dimvars(backend) + assert ansi == { + "dimtad": 0, + "dimtih": 1, + "dimtoh": 1, + "dimexo": 1.5, + "dimexe": 1.5, + "dimgap": 1.0, + "dimlunit": 2, + "dimlwd": -2, + "dimtxt": 3.0, + "dimasz": 3.0, + } + for name, value in ansi.items(): + header = await backend.system_get_variable(name.upper()) + assert float(header) == float(value), f"{name}: header {header} != style {value}" + + await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend)) + iso = await _effective_dimvars(backend) + assert (iso["dimtad"], iso["dimtih"], iso["dimtoh"]) == (1, 0, 0) + assert (iso["dimexo"], iso["dimexe"], iso["dimgap"]) == (0.625, 1.25, 0.625) + assert (iso["dimtxt"], iso["dimasz"]) == (2.5, 2.5) + + async def test_drawing_new_unknown_template_is_refused_with_the_names(backend): await backend.entity_create_line(0, 0, 1, 1) before = (await backend.drawing_info()).entity_count @@ -185,3 +233,141 @@ async def test_drawing_template_list_tool(backend): assert result["ok"] and result["count"] == 5 assert [row["name"] for row in result["templates"]] == list(NAMES) assert result["engine"] == "ezdxf" + + +# ── COM: only a real .dwt reaches Documents.Add ───────────────────────────── + + +class _FakeDocuments: + """Records the ActiveX calls; ``Add`` on a non-``.dwt`` answers blank, as + AutoCAD 2026 does (measured: ``Add()`` == ``Add()`` == ``Add()``).""" + + def __init__(self, dwt_bytes: bytes = b"AC1032\x00template"): + self.calls: list[tuple] = [] + self.dwt_bytes = dwt_bytes + self.open_docs: list[_FakeDoc] = [] + + def Add(self, template=None): + self.calls.append(("Add", template)) + real = template is not None and str(template).lower().endswith(".dwt") + real = real and Path(template).is_file() and Path(template).read_bytes()[:6] == b"AC1032" + return _FakeDoc(self, "Drawing1.dwg", layers=["0", "GEOMETRY"] if real else ["0"]) + + def Open(self, path, read_only=False, password=None): + self.calls.append(("Open", path, read_only)) + doc = _FakeDoc(self, Path(path).name, layers=["0", "GEOMETRY"]) + self.open_docs.append(doc) + return doc + + +class _FakeDoc: + def __init__(self, documents, name, layers): + self._documents = documents + self.Name = name + self.layers = layers + self.closed = None + + def SaveAs(self, path, fmt=None): + self._documents.calls.append(("SaveAs", path, fmt)) + if fmt != 66: + raise RuntimeError(f"fake AutoCAD 2026 refuses SaveAs format {fmt}") + Path(path).write_bytes(self._documents.dwt_bytes) + + def Close(self, save=True): + self._documents.calls.append(("Close", self.Name, save)) + self.closed = save + self._documents.open_docs.remove(self) + + +@pytest.fixture +def com_new(monkeypatch, tmp_path): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + documents = _FakeDocuments() + app = types.SimpleNamespace(Documents=documents) + monkeypatch.setattr(module, "_acad_app", lambda: app) + monkeypatch.setattr(module, "_template_cache_dir", lambda: tmp_path / "cache") + monkeypatch.setattr(module, "_COM_ERROR", (RuntimeError,)) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + async def _no_state(): + pass + + monkeypatch.setattr(backend, "_run", _run_inline) + monkeypatch.setattr(backend, "_ensure_document_state", _no_state) + return backend, documents + + +async def test_com_drawing_new_converts_a_dxf_into_a_real_dwt_once(com_new, tmp_path): + backend, documents = com_new + dxf = TEMPLATES_DIR / "iso_a3_pid.dxf" + result = await backend.drawing_new(str(dxf)) + assert result["ok"] and result["name"] == "Drawing1.dwg" + dwt = Path(result["template_dwt"]["path"]) + assert result["template_dwt"]["cached"] is False + assert dwt.suffix == ".dwt" and dwt.is_file() and dwt.parent == tmp_path / "cache" + assert documents.calls == [ + ("Open", str(dxf), True), + ("SaveAs", str(dwt), 66), + ("Close", "iso_a3_pid.dxf", False), + ("Add", str(dwt)), + ], "open read-only, save as ac2018_Template, close without saving, then Add the .dwt" + assert documents.open_docs == [], "the conversion document never stays open" + + documents.calls.clear() + again = await backend.drawing_new(str(dxf)) + assert again["template_dwt"] == {"path": str(dwt), "cached": True} + assert documents.calls == [("Add", str(dwt))], "the second call reuses the cached .dwt" + + +async def test_com_drawing_new_hands_a_dwt_straight_to_add_and_refuses_a_missing_file( + com_new, tmp_path +): + backend, documents = com_new + mine = tmp_path / "mine.dwt" + mine.write_bytes(documents.dwt_bytes) + result = await backend.drawing_new(str(mine)) + assert result == {"ok": True, "name": "Drawing1.dwg"} + assert documents.calls == [("Add", str(mine))] + + documents.calls.clear() + with pytest.raises(FileNotFoundError, match="not found"): + await backend.drawing_new(str(tmp_path / "gone.dwt")) + assert documents.calls == [], "Documents.Add() would silently create the default" + + +async def test_com_drawing_new_falls_back_to_an_older_template_format(com_new, monkeypatch): + backend, documents = com_new + accepted = [] + + def _save_as(self, path, fmt=None): + documents.calls.append(("SaveAs", path, fmt)) + if fmt == 66: + raise RuntimeError("older seat: unknown SaveAs type") + accepted.append(fmt) + Path(path).write_bytes(documents.dwt_bytes) + + monkeypatch.setattr(_FakeDoc, "SaveAs", _save_as) + result = await backend.drawing_new(str(TEMPLATES_DIR / "ansi_b_mech.dxf")) + assert result["ok"] and accepted == [62] + assert documents.open_docs == [] + + +async def test_com_drawing_new_tool_reports_the_converted_dwt(com_new): + backend, documents = com_new + result = await server.drawing_new(template="iso_a3_pid", bootstrap=False, ctx=_Ctx(backend)) + assert result["ok"] is True + assert "template_dwt" not in result + template = result["template"] + assert template["name"] == "iso_a3_pid" + assert template["source"] in ("bundled", "bundled_dxf") + if template["source"] == "bundled_dxf": # no .dwt twin committed: the live engine built one + assert template["path"] == str(TEMPLATES_DIR / "iso_a3_pid.dxf") + assert template["dwt"].endswith(".dwt") and template["dwt_cached"] is False + assert documents.calls[-1] == ("Add", template["dwt"]) + else: + assert "dwt" not in template and documents.calls == [("Add", template["path"])] From b458923e19143cff6447b3ca3040f6c259034a02 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:48:31 +0300 Subject: [PATCH 14/14] =?UTF-8?q?feat(templates):=20drawing=5Ftemplate=5Fs?= =?UTF-8?q?ave=20on=20both=20engines=20=E2=80=94=20dwt=5Fwrite=20is=20a=20?= =?UTF-8?q?declared=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live AutoCAD writes a real .dwt through SaveAs(path, ac2018_Template = 66) and puts the description into the summary Comments; headlessly a .dxf template is written through drawing_save_as and a .dwt request is refused with capability dwt_write, naming the DXF route and the COM route. drawing_save_as's "dwt" entry was 5 (acR13_dxf): a DWT request wrote an R13 DXF under a .dwt name. Probed live on AutoCAD 2026 (typelib: ac2018_Template = 66, acR13_dxf = 5, acTemplateDwg absent; the saved file starts AC1032, the document is rebound, Comments takes). SECTION 19 is now 6 tools; SAVEAS joins SHARED_ACAD_COMMANDS; templates/README.md documents the two files per template. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 48 +++++++++++++-- backends/ezdxf_backend.py | 49 ++++++++++++++-- discovery/aliases.py | 13 ++++ docs/tool-inventory.json | 18 +++++- server.py | 40 ++++++++++++- templates/README.md | 45 ++++++++++++++ tests/test_templates.py | 114 ++++++++++++++++++++++++++++++++++++ tests/test_tool_registry.py | 9 +-- 9 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 templates/README.md diff --git a/README.md b/README.md index dacc503..21ab84c 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:** 171 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 172 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. @@ -104,7 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | 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), `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=…)` | -| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list` | +| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly) | | 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 | @@ -114,7 +114,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 | -171 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. +172 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 d1819f2..bec6896 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1117,8 +1117,10 @@ class ComBackend(AutoCADBackend): async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: def _sync(): doc = _acad_doc() - # AutoCAD SaveAs format constants: 12=DWG R2010, 61=DXF R2010 - fmt_map = {"dwg": 12, "dxf": 61, "dwt": 5} + # AutoCAD AcSaveAsType: 12 = ac2000_dwg, 61 = ac2013_dxf, 66 = ac2018_Template. + # "dwt" was 5 (acR13_dxf) until v1.6: a DWT request wrote an R13 DXF + # under a .dwt name. + fmt_map = {"dwg": 12, "dxf": 61, "dwt": 66} acad_fmt = fmt_map.get(fmt.lower(), 12) doc.SaveAs(path, acad_fmt) return {"ok": True, "path": path, "format": fmt} @@ -2136,6 +2138,10 @@ class ComBackend(AutoCADBackend): # scripts/smoke_settings_com.py (Task 24). _AC_NO_ROTATION = 0 + #: AcSaveAsType, AutoCAD 2026 typelib: ac2018_Template = 66 (acNative = 64, + #: ac2013_dxf = 61). The spec's "acTemplateDwg" is not a member of the enum. + _AC_SAVEAS_TEMPLATE = 66 + _AC_SAVEAS_DXF = 61 #: Layout properties a ConfigName write can move as a side effect (measured #: live for CanonicalMediaName; units / rotation are re-applied on the same #: evidence-before-trust basis) — snapshotted before the device switch and @@ -2442,10 +2448,40 @@ class ComBackend(AutoCADBackend): 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. - raise UnsupportedCapabilityError( - "dwt_write", "drawing_template_save: not implemented yet (Task 20)" - ) + """``SaveAs(path, ac2018_Template)`` for ``.dwt``, the DXF code for ``.dxf``. + + AutoCAD's template description is its summary-info Comments field, so + ``description`` is written there first (``description_written`` says + whether it took). SaveAs rebinds the active document to the new file, + as the SAVEAS command does — reported as ``document_rebound``. + Fake-tested; executed live by ``scripts/smoke_settings_com.py --build-dwt``. + """ + suffix = Path(path).suffix.lower() + if suffix not in (".dwt", ".dxf"): + raise ValueError(f"drawing_template_save: path must end in .dwt or .dxf, got {path!r}") + + def _sync(): + doc = _acad_doc() + description_written = False + if description is not None: + try: + doc.SummaryInfo.Comments = str(description) + description_written = True + except Exception as exc: + log.debug("SummaryInfo.Comments write failed: %s", exc) + code = self._AC_SAVEAS_TEMPLATE if suffix == ".dwt" else self._AC_SAVEAS_DXF + doc.SaveAs(path, code) + return { + "ok": True, + "path": path, + "format": suffix[1:], + "backend": "com", + "name": name or Path(path).stem, + "description_written": description_written, + "document_rebound": True, + } + + return await self._run(_sync) # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─ diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 66119df..d10cf0b 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -123,6 +123,18 @@ _DWG_WRITE_REFUSAL = ( ) +# A DWT is a DWG container with a template flag. ezdxf cannot write it, and a +# DXF under a .dwt name is exactly the mislabelled file the DWG refusal exists +# to prevent. The DXF route works headlessly end to end: drawing_new(template=) +# opens a .dxf template. +_DWT_WRITE_REFUSAL = ( + "drawing_template_save: the headless ezdxf backend cannot write DWT — a .dwt is a DWG " + "container, and ezdxf has no DWG writer. Save the template as .dxf instead " + "(drawing_new(template='{stem}.dxf') opens it headlessly), or switch to the live COM " + "backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for a real .dwt." +) + + def _is_dwg_path(path: str) -> bool: """True when `path` names a DWG file. The extension is authoritative (N2).""" return Path(path).suffix.lower() == ".dwg" @@ -2687,10 +2699,39 @@ class EzdxfBackend(AutoCADBackend): return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] async def drawing_template_save(self, path, name=None, description=None) -> dict: - # Completed in Task 20; declared here so the ABC instantiates. - raise UnsupportedCapabilityError( - "dwt_write", "drawing_template_save: not implemented yet (Task 20)" - ) + """Save the current drawing as a template — DXF only, headlessly. + + ``.dwt`` is refused with capability ``dwt_write`` before anything is + written; ``.dxf`` goes through ``drawing_save_as`` (the document is + rebound to the new path, as AutoCAD's SAVEAS does). A description has + no home in a DXF header — it is a DWG/DWT summary property — so it is + reported ``description_written: False`` rather than dropped silently. + """ + suffix = Path(path).suffix.lower() + if suffix == ".dwt": + raise UnsupportedCapabilityError( + "dwt_write", _DWT_WRITE_REFUSAL.format(stem=Path(path).stem) + ) + if suffix != ".dxf": + raise ValueError( + f"drawing_template_save: path must end in .dxf (headless) or .dwt (live " + f"AutoCAD), got {path!r}" + ) + saved = await self.drawing_save_as(path, "dxf") + result = { + "ok": True, + "path": saved["path"], + "format": "dxf", + "backend": "ezdxf", + "name": name or Path(path).stem, + "description_written": False, + } + if description is not None: + result["description_note"] = ( + "DXF has no template description field; it is a DWG/DWT summary property " + "(capability 'dwgprops')" + ) + return result # ── 3D solids (unsupported headlessly — honest capability boundary) ───── diff --git a/discovery/aliases.py b/discovery/aliases.py index bd77614..87cdfcd 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -76,6 +76,8 @@ class ToolAliases: # (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. +# SAVEAS -- the same command saves a copy (drawing_save_as) or a template +# (drawing_template_save, its "Drawing Template" file type). # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( @@ -91,6 +93,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "PLINE", "PLOT", "QSELECT", + "SAVEAS", "SETVAR", "ZOOM", } @@ -1462,6 +1465,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "dwt list", ), ), + "drawing_template_save": ToolAliases( + acad=("SAVEAS",), + synonyms=( + "save as template", + "make a dwt", + "template from this drawing", + "save as dwt", + "reuse this sheet setup", + ), + ), "page_setup_apply": ToolAliases( acad=("PAGESETUP",), synonyms=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 62e22ca..4891dd0 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": 171, + "tools": 172, "resources": 8, "prompts": 5, "groups": 20 @@ -25,7 +25,7 @@ "premium": 12, "solids": 5, "system": 7, - "templates": 3, + "templates": 4, "transactions": 3, "validation": 1, "view": 4 @@ -741,6 +741,20 @@ "readOnlyHint": true } }, + { + "name": "drawing_template_save", + "group": "templates", + "cost": "mutate", + "summary": "Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.", + "tags": [ + "drawing", + "template" + ], + "annotations": { + "title": "Save As Template", + "destructiveHint": false + } + }, { "name": "drawing_undo", "group": "drawing", diff --git a/server.py b/server.py index 58cce6f..17b954e 100644 --- a/server.py +++ b/server.py @@ -6855,7 +6855,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 19: Page Setup & Templates (5 tools) ──────────────────────────── +# ── SECTION 19: Page Setup & Templates (6 tools) ──────────────────────────── # --------------------------------------------------------------------------- @@ -7044,6 +7044,44 @@ async def drawing_template_list(ctx: Context = None) -> dict: } +@cad_tool( + summary="Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Save As Template", "destructiveHint": False}, + tags={"template", "drawing"}, +) +async def drawing_template_save( + path: Annotated[str, "Destination .dwt (live AutoCAD) or .dxf (either engine)."], + name: Annotated[str | None, "Template name for the report (default: the file stem)."] = None, + description: Annotated[ + str | None, "Template description → DWT summary Comments on the live engine." + ] = None, + ctx: Context = None, +) -> dict: + """AutoCAD's SAVEAS → Drawing Template, on both engines. + + Live AutoCAD writes a real `.dwt` (`SaveAs(path, ac2018_Template)`) and + puts `description` into the template's summary Comments; the active + document is rebound to the new file, as SAVEAS does. Headlessly a `.dxf` + template is written — `drawing_new(template=)` opens it — and a + `.dwt` request is refused with capability `dwt_write` (the message names + the DXF route and the COM route); a description is reported + `description_written: false` because DXF has nowhere to keep it. Any other + suffix, and any path outside the allowed directories, is refused before + a byte is written. + + Pack: core · lean: no (`drawing_save_as` is not lean either). + """ + validated = validate_path(path, allow_write=True) + await ctx.info(f"Saving template: {validated}") + try: + return await _backend(ctx).drawing_template_save(str(validated), name, description) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..9528d5a --- /dev/null +++ b/templates/README.md @@ -0,0 +1,45 @@ +# Bundled drawing templates + +Five templates, built by the server's own tools — never hand-edited — and +listed by `drawing_template_list`. Start from one with +`drawing_new(template="iso_a3_mech")`. + +| name | standard | sheet | layer set | styles | page setup | +|---|---|---|---|---|---| +| `iso_a3_mech` | ISO | A3 landscape, ISO 5457 frame + ISO 7200 title block | mech | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 | +| `iso_a1_arch` | ISO | A1 landscape | iso13567 | ISO-25 / ISOCP, annotation scale 1:50 | ISO_A1, monochrome.ctb, fit | +| `iso_a3_pid` | ISO | A3 landscape, ISO 5457 frame + title block | pid | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 | +| `ansi_b_mech` | ANSI (metric) | ANSI B landscape | mech | ANSI / ROMANS | ANSI_B, monochrome.ctb, 1:1 | +| `ansi_d_arch` | ANSI (metric) | ANSI D landscape | iso13567 | ANSI / ROMANS | ANSI_D, monochrome.ctb, fit | + +## The two files per template + +- `.dxf` — the headless twin. Produced by + `uv run --frozen python scripts/build_templates.py`, which runs + `drawing_new → apply_layer_set → styles → drawing_settings → layout rename → + titleblock_apply_iso_a3 (A3 sheets) → page_setup_apply → drawing_save_as`. + `scripts/build_templates.py --check` rebuilds into a temporary folder and + fails if any committed DXF differs (the save-time stamps `$TDCREATE`, + `$TDUPDATE`, `$FINGERPRINTGUID`, `$VERSIONGUID` and the two `EZDXF_META` + entries are masked; everything else must be byte-identical). + `tests/test_templates.py` runs the same check. +- `.dwt` — the live twin, used by `drawing_new(template=…)` on the COM + backend. AutoCAD's DWT is a DWG container, which no headless library + writes, so these are produced **on the live machine** by + `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 24's + smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)` + (`SaveAs(path, ac2018_Template)`), then the files are committed once. While + a `.dwt` is missing, `resolve_template` falls back to the DXF and reports + `source: "bundled_dxf"`; `drawing_template_list` shows `files.dwt.present`. + The live engine does not open that DXF blank: `Documents.Add` accepts only + a genuine DWT (measured on AutoCAD 2026), so the COM `drawing_new` converts + the DXF through `SaveAs(…, ac2018_Template)` once, caches the result under + the temp folder keyed on path, size and mtime, and reports + `template_dwt: {path, cached}`. + +Line endings: ezdxf writes CRLF and `.gitattributes` pins `templates/*.dxf` +to CRLF so a checkout on any platform equals a fresh build. + +Rebuild after changing anything the build depends on (layer sets, the title +block, the dimension presets, the settings facade) and commit the result +together with the change — the reproducibility test is the gate. diff --git a/tests/test_templates.py b/tests/test_templates.py index 7a98b29..2ba62ac 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -10,6 +10,7 @@ import pytest from fastmcp.exceptions import ToolError import server +from backends.base import UnsupportedCapabilityError from engineering.standards.templates import ( TEMPLATE_CATALOG, TEMPLATES_DIR, @@ -371,3 +372,116 @@ async def test_com_drawing_new_tool_reports_the_converted_dwt(com_new): assert documents.calls[-1] == ("Add", template["dwt"]) else: assert "dwt" not in template and documents.calls == [("Add", template["path"])] + + +# ── drawing_template_save ─────────────────────────────────────────────────── + + +async def test_template_save_dxf_headless_round_trips(backend, tmp_path): + await backend.layer_create("MINE", color=3) + out = tmp_path / "mine_template.dxf" + result = await backend.drawing_template_save(str(out), name="Mine", description="my sheet") + assert result == { + "ok": True, + "path": str(out), + "format": "dxf", + "backend": "ezdxf", + "name": "Mine", + "description_written": False, + "description_note": ( + "DXF has no template description field; it is a DWG/DWT summary property " + "(capability 'dwgprops')" + ), + } + assert out.is_file() + await server.drawing_new(template=str(out), ctx=_Ctx(backend)) + assert "MINE" in {layer.name for layer in await backend.layer_list()} + + +async def test_template_save_dwt_headless_is_refused_with_the_capability(backend, tmp_path): + out = tmp_path / "mine.dwt" + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_template_save(str(out)) + assert excinfo.value.capability == "dwt_write" + message = str(excinfo.value) + assert ".dxf" in message and "com" in message.lower() + assert not out.exists() + + +async def test_template_save_refuses_other_suffixes(backend, tmp_path): + with pytest.raises(ValueError, match=r"\.dxf"): + await backend.drawing_template_save(str(tmp_path / "mine.txt")) + + +async def test_template_save_tool_validates_the_path(backend, tmp_path): + with pytest.raises(ToolError): + await server.drawing_template_save("../../outside.dxf", ctx=_Ctx(backend)) + result = await server.drawing_template_save(str(tmp_path / "t.dxf"), ctx=_Ctx(backend)) + assert result["ok"] and result["name"] == "t" + + +class _SaveRecorder: + def __init__(self): + self.calls = [] + self.SummaryInfo = types.SimpleNamespace() + self.Name = "Drawing1.dwg" + + def SaveAs(self, path, fmt=None): + self.calls.append(("SaveAs", path, fmt)) + self.Name = Path(path).name + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = _SaveRecorder() + monkeypatch.setattr(module, "_acad_doc", lambda: document) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_template_save_dwt_uses_ac2018_template(com_backend, tmp_path): + backend, document = com_backend + out = str(tmp_path / "mine.dwt") + result = await backend.drawing_template_save(out, description="A3 mech") + assert document.calls == [("SaveAs", out, 66)] + assert document.SummaryInfo.Comments == "A3 mech" + assert result == { + "ok": True, + "path": out, + "format": "dwt", + "backend": "com", + "name": "mine", + "description_written": True, + "document_rebound": True, + } + + +async def test_com_template_save_dxf_uses_the_dxf_code(com_backend, tmp_path): + backend, document = com_backend + out = str(tmp_path / "mine.dxf") + result = await backend.drawing_template_save(out) + assert document.calls == [("SaveAs", out, 61)] + assert result["format"] == "dxf" and result["description_written"] is False + + +async def test_com_save_as_dwt_no_longer_writes_an_r13_dxf(com_backend, tmp_path): + """drawing_save_as mapped 'dwt' to 5, which is AcSaveAsType.acR13_dxf.""" + backend, document = com_backend + out = str(tmp_path / "x.dwt") + await backend.drawing_save_as(out, "dwt") + assert document.calls == [("SaveAs", out, 66)] + + +async def test_com_template_save_refuses_other_suffixes_before_saving(com_backend, tmp_path): + backend, document = com_backend + with pytest.raises(ValueError, match=r"\.dwt"): + await backend.drawing_template_save(str(tmp_path / "x.dwg")) + assert document.calls == [] diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 1970ce7..cd547ae 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 (171 tools, 20 groups). + """Frozen snapshot of the surface (172 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 @@ -523,7 +523,8 @@ async def test_tool_group_sizes_are_unchanged(): — all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above `query`/`export`, so they file with the sheets they set up. `templates` moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19 - (tagged `template`, which ranks above `drawing`). Every other number + (tagged `template`, which ranks above `drawing`), then 3 -> 4 when + `drawing_template_save` joined. 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()} @@ -544,9 +545,9 @@ async def test_tool_group_sizes_are_unchanged(): "premium": 12, "solids": 5, "system": 7, - "templates": 3, + "templates": 4, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 171 + assert sum(sizes.values()) == 172