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 7da9f77..5993c66 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:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 185 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,8 @@ 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=…)` | +| 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 | @@ -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 | -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. +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/base.py b/backends/base.py index 65be4ce..b024f48 100644 --- a/backends/base.py +++ b/backends/base.py @@ -30,6 +30,7 @@ from backends.contracts import ( LayerContract, LayoutContract, LinetypeContract, + PageSetupContract, PremiumContract, SettingsContract, SolidContract, @@ -169,6 +170,7 @@ class AutoCADBackend( IdentityContract, DrawingContract, LayoutContract, + PageSetupContract, SolidContract, EntityCreationContract, DimensionContract, diff --git a/backends/com_backend.py b/backends/com_backend.py index 8955885..3030e34 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -14,6 +14,7 @@ import os import sys import time import uuid +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -170,6 +171,71 @@ def _acad_app(): return _COM_STATE["app"] +#: ``AcSaveAsType`` template constants, newest first: ac2018_Template, +#: ac2013_Template, ac2010_Template. A seat older than the first refuses it +#: with a COM error and the next one is tried. +_TEMPLATE_SAVE_FORMATS = (66, 62, 50) + + +def _template_cache_dir() -> Path: + import tempfile + + return Path(tempfile.gettempdir()) / "acadmcp-templates" + + +def _template_as_dwt(app, source: Path) -> tuple[Path, bool]: + """A real ``.dwt`` for ``source``, built once through AutoCAD and cached. + + ``Documents.Add()`` 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() @@ -1349,6 +1415,7 @@ class ComBackend(AutoCADBackend): "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), + "dwt_write": FeatureCapability(True, "native"), "viewport_render": FeatureCapability(True, "native"), "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), @@ -1531,13 +1598,36 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def drawing_new(self, template: str | None = None) -> dict: + """``Documents.Add()``, or ``Documents.Add()`` from a template file. + + A template that is not already a ``.dwt`` (the bundled ``.dxf`` files, + an explicit ``.dxf`` path) is first converted into one through AutoCAD + itself — see ``_template_as_dwt`` for the measurement that makes this + necessary — and the result reports ``template_dwt: {path, cached}``. + A template path that is not a file is refused here rather than handed + to ``Add``, which would silently create the default drawing instead. + """ + def _sync(): app = _acad_app() - if template: - doc = app.Documents.Add(template) - else: + if not template: doc = app.Documents.Add() - return {"ok": True, "name": doc.Name} + return {"ok": True, "name": doc.Name} + source = Path(template) + if not source.is_file(): + raise FileNotFoundError( + f"template file not found: {template} (Documents.Add would silently " + "create the default drawing instead)" + ) + result: dict[str, Any] = {"ok": True} + if source.suffix.lower() == ".dwt": + dwt = source + else: + dwt, cached = _template_as_dwt(app, source) + result["template_dwt"] = {"path": str(dwt), "cached": cached} + doc = app.Documents.Add(str(dwt)) + result["name"] = doc.Name + return result result = await self._run(_sync) await self._ensure_document_state() @@ -1567,8 +1657,10 @@ class ComBackend(AutoCADBackend): async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: def _sync(): doc = _acad_doc() - # AutoCAD SaveAs format constants: 12=DWG R2010, 61=DXF R2010 - fmt_map = {"dwg": 12, "dxf": 61, "dwt": 5} + # AutoCAD AcSaveAsType: 12 = ac2000_dwg, 61 = ac2013_dxf, 66 = ac2018_Template. + # "dwt" was 5 (acR13_dxf) until v1.6: a DWT request wrote an R13 DXF + # under a .dwt name. + fmt_map = {"dwg": 12, "dxf": 61, "dwt": 66} acad_fmt = fmt_map.get(fmt.lower(), 12) doc.SaveAs(path, acad_fmt) return {"ok": True, "path": path, "format": fmt} @@ -1579,23 +1671,73 @@ class ComBackend(AutoCADBackend): return await self.drawing_save_as(path, "dxf") async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: + """Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file. + + ``layout=None`` means model space, as the tool advertises — the Model + tab is made current for the plot (and the previous tab restored), so + the row is never silently the sheet that happened to be active. + + Measured on AutoCAD 2026 with the operator's default BACKGROUNDPLOT=2: + ``PlotToFile`` returns True immediately, the job is queued to the + background plotter, and no file exists when the call comes back — + seconds later the folder was still empty, and a foreground plot + attempted after such a stranded job raised E_FAIL. So the plot is + forced to the foreground (``BACKGROUNDPLOT=0``, restored afterwards), + the method's boolean is honoured, and ``ok`` is reported only once the + file is on disk with a size. + """ + def _sync(): doc = _acad_doc() - previous = None - if layout: - previous = doc.ActiveLayout.Name - if previous != layout: - doc.ActiveLayout = doc.Layouts.Item(layout) + target = layout.strip() if layout and layout.strip() else "Model" + previous = doc.ActiveLayout.Name + switched = False + if previous.lower() != target.lower(): + doc.ActiveLayout = doc.Layouts.Item(target) + switched = True + old_background = None try: - plot = doc.Plot - plot.PlotToFile(path, "DWG To PDF.pc3") - result = {"ok": True, "path": path} - if layout: - result["layout"] = layout - return result + try: + old_background = int(doc.GetVariable("BACKGROUNDPLOT")) + except Exception as exc: + log.debug("BACKGROUNDPLOT unreadable (%s); plotting as configured", exc) + if old_background not in (None, 0): + doc.SetVariable("BACKGROUNDPLOT", 0) + plotted = doc.Plot.PlotToFile(path, "DWG To PDF.pc3") + written = Path(path) + if not plotted: + return { + "ok": False, + "path": path, + "layout": target, + "error": "PlotToFile returned False: AutoCAD refused the plot " + "(check the layout's plot area and the 'DWG To PDF.pc3' device)", + } + if not written.is_file(): + return { + "ok": False, + "path": path, + "layout": target, + "error": "PlotToFile reported success but wrote no file at " + f"{path} (BACKGROUNDPLOT was {old_background})", + } + return { + "ok": True, + "path": path, + "layout": target, + "bytes": written.stat().st_size, + } finally: - if previous is not None and previous != layout: - doc.ActiveLayout = doc.Layouts.Item(previous) + if old_background not in (None, 0): + try: + doc.SetVariable("BACKGROUNDPLOT", old_background) + except Exception as exc: + log.warning("could not restore BACKGROUNDPLOT=%s: %s", old_background, exc) + if switched: + try: + doc.ActiveLayout = doc.Layouts.Item(previous) + except Exception as exc: + log.warning("could not restore the active layout %r: %s", previous, exc) return await self._run(_sync) @@ -2494,6 +2636,393 @@ class ComBackend(AutoCADBackend): # CopyObjects-and-delete that would destroy and recreate geometry in the # operator's open drawing when it goes wrong. + # ── page setup (track E) ───────────────────────────────────────────────── + # + # Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026 + # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0; + # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16 + # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE). + # CenterPlot (ActiveX Reference, acadauto.chm): "This property cannot be + # set to True on a layout object whose PlotType property is set to + # acLayout" — so a layout plot never writes it (resolve_page_setup hands + # over None), an extents plot writes it *after* PlotType, and a stale True + # is cleared *before* PlotType moves to acLayout. Every write is journaled + # and unwound in reverse when AutoCAD refuses one, so a failed apply leaves + # the layout as it was — and the read-back after the unwind is compared to + # the read-back before the first write, so a value that did not go back is + # named rather than claimed restored. + # + # Two behaviours measured live (AutoCAD 2026, 2026-09-17) that the ActiveX + # Reference does not document: + # * GetPaperSize / GetPaperMargins answer in millimetres whatever + # PaperUnits says (ISO_A3 under PaperUnits=0 -> (420.0, 297.0); ANSI_B + # under 0 and 1 -> (431.8, 279.4) both times). They are never scaled. + # PaperUnits itself still governs what one paper-space unit means at + # the plot scale, so the row reports it (``paper_units``) and an apply + # that moves it says so in ``changed`` and ``warnings``. + # * Both answer in the *media's* frame (the PLOTSETTINGS fields the DXF + # row reads); under PlotRotation 1/3 the row turns size and margins + # together through ``papers.turned_sheet``, the one mapping the headless + # row and renderer use. The rotated read-back is not yet measured live + # (scripts/smoke_settings_com.py, Task 24, is where it gets measured). + # * Writing ConfigName replaces CanonicalMediaName with the new device's + # default when the current media does not exist on it, and writing the + # old device back does *not* bring the old media with it (DWG To PDF / + # ISO_A3 -> Microsoft Print to PDF -> 'psk:ISOA4' -> DWG To PDF -> + # 'ANSI_A_(11.00_x_8.50_Inches)'). The device write therefore journals + # one combined undo: device back, then the media / units / rotation + # snapshotted before the switch re-applied. + # + # Fake-tested in tests/test_page_setup.py (the fake enforces the CenterPlot + # rule and replays both measurements); executed live by + # scripts/smoke_settings_com.py (Task 24). + + _AC_NO_ROTATION = 0 + #: AcSaveAsType, AutoCAD 2026 typelib: ac2018_Template = 66 (acNative = 64, + #: ac2013_dxf = 61). The spec's "acTemplateDwg" is not a member of the enum. + _AC_SAVEAS_TEMPLATE = 66 + _AC_SAVEAS_DXF = 61 + #: Layout properties a ConfigName write can move as a side effect (measured + #: live for CanonicalMediaName; units / rotation are re-applied on the same + #: evidence-before-trust basis) — snapshotted before the device switch and + #: put back by its undo. + _DEVICE_SIDE_EFFECTS = ("CanonicalMediaName", "PaperUnits", "PlotRotation") + + @staticmethod + def _com_page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + activex_scale_label, + center_applies, + paper_from_size, + paper_units_name, + scale_label, + turned_sheet, + ) + + def _get(attr): + try: + return getattr(layout, attr) + except Exception as exc: + log.debug("Layout.%s read failed: %s", attr, exc) + return None + + # GetPaperSize / GetPaperMargins are millimetres regardless of + # PaperUnits (measured live; see the block comment above) — no factor. + size = None + try: + width, height = layout.GetPaperSize() + size = [round(float(width), 2), round(float(height), 2)] + except Exception as exc: + log.debug("Layout.GetPaperSize failed: %s", exc) + margins = None + try: + (left, bottom), (right, top) = layout.GetPaperMargins() + margins = [round(float(v), 3) for v in (top, bottom, left, right)] + except Exception as exc: + log.debug("Layout.GetPaperMargins failed: %s", exc) + rotation = _get("PlotRotation") or 0 + if size and margins: + size, margins = turned_sheet(size[0], size[1], margins, rotation) + size = [round(v, 2) for v in size] + margins = [round(v, 3) for v in margins] + elif size and int(rotation) in (1, 3): + size = [size[1], size[0]] + if _get("UseStandardScale"): + scale = activex_scale_label(_get("StandardScale") or 0) + else: + try: + numerator, denominator = layout.GetCustomScale() + scale = scale_label(float(numerator), float(denominator)) + except Exception as exc: + log.debug("Layout.GetCustomScale failed: %s", exc) + scale = None + media = _get("CanonicalMediaName") + plot_type = _get("PlotType") + return { + "layout": layout.Name, + "paper": (paper_from_size(*size) if size else None) or media, + "canonical_media_name": media, + "size_mm": size, + "orientation": ( + None if not size else ("landscape" if size[0] >= size[1] else "portrait") + ), + "plot_style": _get("StyleSheet"), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), + "device": _get("ConfigName"), + "margins_mm": margins, + "paper_units": paper_units_name(_get("PaperUnits")), + "center": bool(_get("CenterPlot")) if center_applies(plot_type) else None, + } + + def _com_paper_layout(self, doc, raw: str, operation: str): + resolved = self._find_layout(doc, raw) + if resolved is None: + available = [n for n in self._layout_names(doc) if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.Layouts.Item(resolved) + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = _acad_doc() + if layout is not None and self._layout_name(layout): + target = self._com_paper_layout(doc, layout, "page_setup_list") + return [self._com_page_setup_row(target)] + return [ + self._com_page_setup_row(doc.Layouts.Item(name)) + for name in self._layout_names(doc) + if name != "Model" + ] + + return await self._run(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import ( + PAPER_UNITS, + page_setup_warnings, + require_page_setup, + ) + + resolved = require_page_setup(setup) + if resolved["margins_mm"] is not None: + raise ValueError( + "page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD " + "takes the printable margins from the plotter configuration (.pc3). Omit " + "margins_mm; the device's margins are read back in the result." + ) + + def _sync(): + doc = _acad_doc() + target = self._com_paper_layout(doc, layout, "page_setup_apply") + before = self._com_page_setup_row(target) + # Journal of (undo callable, label) for every write that landed, + # unwound in reverse when a later one is refused. + journal: list[tuple[Callable[[], None], str]] = [] + + def _set(attr: str, value): + previous = getattr(target, attr) + setattr(target, attr, value) + journal.append((lambda: setattr(target, attr, previous), attr)) + + def _set_custom_scale(numerator: float, denominator: float): + previous = tuple(float(v) for v in target.GetCustomScale()) + target.SetCustomScale(numerator, denominator) + journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale")) + + def _set_device(device: str): + # A device switch moves the media (and can move units / + # rotation) as a side effect, and switching back does not + # move them back — so the undo re-applies what was there. + snapshot = {attr: getattr(target, attr) for attr in self._DEVICE_SIDE_EFFECTS} + previous = target.ConfigName + target.ConfigName = device + + def _undo(): + target.ConfigName = previous + target.RefreshPlotDeviceInfo() + for attr, value in snapshot.items(): + if getattr(target, attr) != value: + setattr(target, attr, value) + + journal.append((_undo, "ConfigName")) + + step = "ConfigName" + try: + target.RefreshPlotDeviceInfo() + _set_device(resolved["device"]) + target.RefreshPlotDeviceInfo() + media = resolved["canonical_media_name"] + names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] + if media not in names: + nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] + raise ValueError( + f"page_setup_apply: device {resolved['device']!r} has no media " + f"{media!r} (same paper on this device: " + f"{', '.join(nearest) or 'none'}; {len(names)} media in total)" + ) + step = "CanonicalMediaName" + _set("CanonicalMediaName", media) + if resolved["paper_units"] is not None: + # None keeps the layout's units — what AutoCAD's own Page + # Setup does when a media is chosen. + step = "PaperUnits" + _set("PaperUnits", PAPER_UNITS[resolved["paper_units"]]) + step = "PlotRotation" + _set("PlotRotation", self._AC_NO_ROTATION) + step = "StyleSheet" + _set("StyleSheet", resolved["plot_style"]) + step = "PlotWithPlotStyles" + _set("PlotWithPlotStyles", True) + center = resolved["center"] + if center is None and self._com_center_plot_is_set(target): + # Clearing is legal under any PlotType; the layout plot + # then carries the flag AutoCAD stores for acLayout. + step = "CenterPlot" + _set("CenterPlot", False) + step = "PlotType" + _set("PlotType", int(resolved["plot_type"])) + code = resolved["activex_standard_scale"] + if code is not None: + step = "StandardScale" + _set("StandardScale", int(code)) + step = "UseStandardScale" + _set("UseStandardScale", True) + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + step = "SetCustomScale" + _set_custom_scale(numerator, denominator) + step = "UseStandardScale" + _set("UseStandardScale", False) + if center is not None: + # Extents plot: PlotType is already acExtents, so True is legal. + step = "CenterPlot" + _set("CenterPlot", bool(center)) + except Exception as exc: + failed = self._com_unwind_page_setup(journal) + # The unwind's own word is not evidence: read the layout back + # and name every field that is not what it was. + failed += [ + key + for key, value in self._com_page_setup_row(target).items() + if key != "layout" and value != before.get(key) and key not in failed + ] + if isinstance(exc, ValueError) and not failed: + raise # our own refusal (unknown media), nothing left changed + restored = ( + f"layout {target.Name!r} restored to its previous page setup" + if not failed + else f"layout {target.Name!r} could NOT be fully restored " + f"(still changed: {', '.join(failed)})" + ) + if isinstance(exc, ValueError): + raise ValueError(f"{exc}; {restored}") from exc + raise ValueError( + f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}" + ) from exc + after = self._com_page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.Name, + "applied": dict(resolved), + "changed": changed, + "warnings": page_setup_warnings(changed), + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "com", + } + + return await self._run(_sync) + + @staticmethod + def _com_center_plot_is_set(layout) -> bool: + """Whether the layout carries CenterPlot=True (a failed read counts as no).""" + try: + return bool(layout.CenterPlot) + except Exception as exc: + log.debug("Layout.CenterPlot read failed: %s", exc) + return False + + @staticmethod + def _com_unwind_page_setup(journal: list[tuple[Callable[[], None], str]]) -> list[str]: + """Undo every journaled page-setup write in reverse; return what would not go back.""" + failed: list[str] = [] + for undo, label in reversed(journal): + try: + undo() + except Exception as exc: + log.warning("page_setup_apply: could not restore Layout.%s: %s", label, exc) + failed.append(label) + return failed + + async def plot_style_list(self) -> list[dict]: + """The ctb catalogue, marked against the files in AutoCAD's plot style path. + + ``Preferences.Files.PrinterStyleSheetPath`` is a ``;``-separated list of + folders. Every read is guarded: a folder that cannot be listed is + skipped, and when the Preferences object is unreachable the catalogue + rows come back with ``installed: None`` rather than a fabricated False. + """ + from engineering.standards.papers import CTB_CATALOG + + def _sync(): + rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + try: + raw = str(_acad_app().Preferences.Files.PrinterStyleSheetPath or "") + except Exception as exc: + log.debug("Preferences.Files.PrinterStyleSheetPath unreadable: %s", exc) + return rows + installed: dict[str, str] = {} + for folder in (part.strip() for part in raw.split(";")): + if not folder: + continue + try: + entries = sorted(Path(folder).iterdir()) + except OSError as exc: + log.debug("plot style folder %r not listable: %s", folder, exc) + continue + for entry in entries: + if entry.suffix.lower() in (".ctb", ".stb"): + installed.setdefault(entry.name.lower(), entry.name) + catalog_lower = {row["name"].lower() for row in rows} + for row in rows: + row["installed"] = row["name"].lower() in installed + for key, name in installed.items(): + if key not in catalog_lower: + rows.append({"name": name, "source": "installed", "installed": True}) + return rows + + return await self._run(_sync) + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + """``SaveAs(path, ac2018_Template)`` for ``.dwt``, the DXF code for ``.dxf``. + + AutoCAD's template description is its summary-info Comments field, so + ``description`` is written there first (``description_written`` says + whether it took). SaveAs rebinds the active document to the new file, + as the SAVEAS command does — reported as ``document_rebound``. + Fake-tested; executed live by ``scripts/smoke_settings_com.py --build-dwt``. + """ + suffix = Path(path).suffix.lower() + if suffix not in (".dwt", ".dxf"): + raise ValueError(f"drawing_template_save: path must end in .dwt or .dxf, got {path!r}") + + def _sync(): + doc = _acad_doc() + description_written = False + if description is not None: + try: + doc.SummaryInfo.Comments = str(description) + description_written = True + except Exception as exc: + log.debug("SummaryInfo.Comments write failed: %s", exc) + code = self._AC_SAVEAS_TEMPLATE if suffix == ".dwt" else self._AC_SAVEAS_DXF + doc.SaveAs(path, code) + return { + "ok": True, + "path": path, + "format": suffix[1:], + "backend": "com", + "name": name or Path(path).stem, + "description_written": description_written, + "document_rebound": True, + } + + return await self._run(_sync) + # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─ async def solid_box( diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index 36d2f56..1fc45cb 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 @@ -43,6 +44,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..35827c4 --- /dev/null +++ b/backends/contracts/pagesetup.py @@ -0,0 +1,82 @@ +"""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], 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. ``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. + """ + ... + + @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]}, + 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. + """ + ... + + @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 51aa61e..59ae718 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -178,6 +178,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" @@ -1402,6 +1414,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 @@ -1897,6 +1912,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(): @@ -1907,18 +1932,41 @@ 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 - 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"] + 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": result["layout"] = resolved result["note"] = ( @@ -2681,6 +2729,397 @@ 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 _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"))) + # 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: + 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, + "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(plot_type, str(dxf.plot_type)), + "device": str(dxf.plot_configuration_file), + "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, + } + + @staticmethod + 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 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 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"))) + 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 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 + + @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 + 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 + 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 — + ``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 + rotation = int(dxf.plot_rotation) + unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 + (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) + 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 + # ``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 + 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, 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(): + 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 ( + PAPER_UNITS, + page_setup_warnings, + require_page_setup, + ) + + resolved = require_page_setup(setup) + + def _sync(): + 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"]) + # 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 + 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.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) + target.use_standard_scale(True) + else: + dxf.standard_scale_type = 16 + 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. + 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, + "warnings": page_setup_warnings(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: + """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) ───── @staticmethod diff --git a/discovery/aliases.py b/discovery/aliases.py index e67976e..559b6a3 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -78,6 +78,12 @@ class ToolAliases: # destination for every one of them. # DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and # Custom (drawing_properties_get / drawing_properties_set). +# 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. +# 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( @@ -92,8 +98,11 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "LAYOUT", "MEASUREGEOM", "MLEADERSTYLE", + "PAGESETUP", "PLINE", + "PLOT", "QSELECT", + "SAVEAS", "SETVAR", "STYLE", "ZOOM", @@ -451,7 +460,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",), @@ -1592,6 +1607,71 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "active text style", ), ), + # ── 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", + ), + ), + "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", + ), + ), + "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=( + "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 63962dc..58a28b1 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": 179, + "tools": 185, "resources": 8, "prompts": 5, "groups": 21 @@ -20,13 +20,13 @@ "entity_modification": 16, "entity_query": 9, "layers": 14, - "layouts": 12, + "layouts": 16, "pid": 9, "premium": 12, "solids": 5, "styles": 10, "system": 8, - "templates": 2, + "templates": 4, "transactions": 3, "validation": 1, "view": 4 @@ -176,6 +176,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", @@ -652,7 +667,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" ], @@ -810,6 +825,35 @@ "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_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", @@ -1844,6 +1888,35 @@ "readOnlyHint": true } }, + { + "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", @@ -1971,6 +2044,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/papers.py b/engineering/standards/papers.py new file mode 100644 index 0000000..d9279d2 --- /dev/null +++ b/engineering/standards/papers.py @@ -0,0 +1,593 @@ +"""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 +enum — one numbering, shared by the file and by ActiveX: + +* 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. + +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 + +import math +import re + +__all__ = [ + "ACTIVEX_PLOT_SCALE", + "CTB_CATALOG", + "DXF_STANDARD_SCALE_TYPE", + "DXF_STD_SCALES", + "ORIENTATIONS", + "PAPER_INCHES", + "PAPER_SIZES", + "PAPER_UNITS", + "PAPER_UNITS_NAMES", + "PLOT_AREAS", + "PLOT_TYPE_LAYOUT", + "PLOT_TYPE_NAMES", + "REQUIRED_SETUP_KEYS", + "SCALES", + "activex_scale_label", + "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. +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 → (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": 21, + "1:20": 23, + "1:50": 26, + "1:100": 27, + "2:1": 28, + "10:1": 31, +} + +#: 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: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} +#: ``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", + 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}" + + +_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 (``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: + """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} + + +#: 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``. + + 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", + plot_style: str = "monochrome.ctb", + scale: str = "fit", + plot_area: str = "layout", + 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. + + Raises ``ValueError`` naming the offending field; nothing here touches a + 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. + + ``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)) + + 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}") + 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, + "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_type, + "device": device_name, + "margins_mm": margins, + "center": center if center_applies(plot_type) else None, + "paper_units": units, + } + + +#: 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", + "paper_units", +) + + +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/engineering/standards/plot.py b/engineering/standards/plot.py new file mode 100644 index 0000000..3fe29cd --- /dev/null +++ b/engineering/standards/plot.py @@ -0,0 +1,147 @@ +"""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, and is passed to the engine by name so the model row is model + 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 + 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" + # 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)) + 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/engineering/standards/templates.py b/engineering/standards/templates.py new file mode 100644 index 0000000..056b8cb --- /dev/null +++ b/engineering/standards/templates.py @@ -0,0 +1,190 @@ +"""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 — 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: + 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..447dae3 --- /dev/null +++ b/scripts/build_templates.py @@ -0,0 +1,289 @@ +"""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 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 +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 = {} + 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)", + } + + +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 e8698c0..d803cf1 100644 --- a/server.py +++ b/server.py @@ -1090,7 +1090,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( @@ -1098,7 +1098,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( @@ -1109,19 +1113,54 @@ 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}`. + + 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` + (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} + 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: from engineering import ( @@ -7230,6 +7269,234 @@ async def drawing_apply_standard( return await apply_standard(_backend(ctx), standard, layers, units) +# --------------------------------------------------------------------------- +# ── SECTION 19: Page Setup & Templates (6 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.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. + """ + 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 + + +@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, + } + + +@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 + + # --------------------------------------------------------------------------- # ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── # --------------------------------------------------------------------------- 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/templates/ansi_b_mech.dxf b/templates/ansi_b_mech.dxf new file mode 100644 index 0000000..fa99f5d --- /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.9345486113 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9345486113 + 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 +{C3A95304-315D-4482-8B26-4A154C8AC81B} + 9 +$VERSIONGUID + 2 +{020A53F4-F6E6-4A10-988E-58908DDBD5B7} + 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 +3.0 + 42 +1.5 + 43 +3.75 + 44 +1.5 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +3.0 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +1.0 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +1 + 74 +1 + 75 +0 + 76 +0 + 77 +0 + 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:25:45.807766+00:00 + 0 +DICTIONARYVAR + 5 +3F +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:25:45.814791+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..2a5042d --- /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.9345486113 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9345486113 + 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 +{EA8DAB3B-221B-4973-A128-0F97D36F69C3} + 9 +$VERSIONGUID + 2 +{41F085E9-E128-43F7-931E-FEE570A4F621} + 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 +3.0 + 42 +1.5 + 43 +3.75 + 44 +1.5 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +3.0 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +1.0 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +1 + 74 +1 + 75 +0 + 76 +0 + 77 +0 + 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:25:45.818106+00:00 + 0 +DICTIONARYVAR + 5 +3F +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:25:45.824109+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..e43499c --- /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.9345486113 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9345486113 + 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 +{569A5E5D-D8AE-4EE2-8CC8-222F1BC5A1B6} + 9 +$VERSIONGUID + 2 +{CA16A478-EDAC-4921-A9AD-1AD1114D5AB2} + 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:25:45.777979+00:00 + 0 +DICTIONARYVAR + 5 +3F +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:25:45.783975+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..d0332f7 --- /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.9345486113 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9345486113 + 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 +{6791163C-4461-4A1B-8F3F-5AD608DBCCCE} + 9 +$VERSIONGUID + 2 +{9A1CD7BF-015F-4598-B3DB-2BDBFBCC12FA} + 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:25:45.743145+00:00 + 0 +DICTIONARYVAR + 5 +61 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:25:45.772937+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..1bed4fb --- /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.9345486113 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461305.9345486113 + 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 +{B5F8B467-F9F9-4DC6-8A95-A266D8246CA8} + 9 +$VERSIONGUID + 2 +{F759C60A-BBAF-4155-A187-77BF78127E81} + 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:25:45.787707+00:00 + 0 +DICTIONARYVAR + 5 +68 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.4 @ 2026-09-21T19:25:45.803766+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py new file mode 100644 index 0000000..7092e69 --- /dev/null +++ b/tests/test_batch_plot.py @@ -0,0 +1,434 @@ +"""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 +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 + +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" + + +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 + + async def _recording(path, layout=None): + seen.append(layout) + return await original(path, layout=layout) + + backend.drawing_export_pdf = _recording + with_sheet_current = await batch_plot(backend, ["Model", "A3"], str(tmp_path / "sheet")) + assert seen == ["Model", "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): + """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 ─────────────────────────────────────────────────────────────── + + +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(PrinterStyleSheetPath=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) + + +# ── 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 diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py new file mode 100644 index 0000000..0ccd473 --- /dev/null +++ b/tests/test_page_setup.py @@ -0,0 +1,1380 @@ +"""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 ( + 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, + scale_label, + scale_ratio, + turned_sheet, +) +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 + 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(): + 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") + # 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] + # 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", + [ + ({"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 + + +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 ────────────────────────────────────────────────────────── + + +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 None, "layout plot: centring is not applicable" + 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 + + +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 _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 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)) + 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) + + +@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. + + 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( + paper, + "landscape", + scale="fit", + plot_area="extents", + center=True, + paper_units=paper_units, + ), + ) + lay = backend._doc.layouts.get(SHEET) + 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" + # 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") + # 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): + 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.""" + await _sheet(backend) + 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)" + 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): + 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 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) + 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) + + +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) + 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. + + 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. + + 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", []) + 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, + "_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): + 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)) + 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")) + + def RefreshPlotDeviceInfo(self): + self.log.append(("call", "RefreshPlotDeviceInfo")) + + def GetCanonicalMediaNames(self): + self.log.append(("call", "GetCanonicalMediaNames")) + 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 = round(width * 25.4, 2), round(height * 25.4, 2) + 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), + ], "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["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): + 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", {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"): + 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" + 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") + 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(): + 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 + + +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]] diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..2ba62ac --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,487 @@ +"""Bundled templates: catalogue, resolution, reproducible build, drawing_new(template=).""" + +from __future__ import annotations + +import importlib.util +import types +from pathlib import Path + +import pytest +from fastmcp.exceptions import ToolError + +import server +from backends.base import UnsupportedCapabilityError +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"] == "." + + +#: 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 + 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" + + +# ── 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"])] + + +# ── 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 5e0c77c..26b1980 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 (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 @@ -528,8 +528,15 @@ async def test_tool_group_sizes_are_unchanged(): as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on both engines) joined SECTION 20 — tagged `drawing` and `settings`, and - `settings` is not a group tag, so they file under drawing. Every other - number here has been unchanged since the snapshot was taken. + `settings` is not a group tag, so they file under drawing. + `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. `templates` + moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19 + (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()} assert sizes == { @@ -544,15 +551,15 @@ 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, "styles": 10, "system": 8, - "templates": 2, + "templates": 4, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 179 + assert sum(sizes.values()) == 185