diff --git a/README.md b/README.md index d112a0f..51b854b 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 218 tools · 8 resources · 5 prompt templates · 3284 collected tests. -> 218 is the **registered** count; a default install advertises 213 over `tools/list`, +> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 229 is the **registered** count; a default install advertises 224 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists **A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**. -**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **186 AutoCAD command names and 933 synonym phrases** covering all 204 tools. A test refuses to let a tool exist without one. +**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **193 AutoCAD command names and 1019 synonym phrases** covering all 215 tools. A test refuses to let a tool exist without one. | Advertised surface | Tools seen | Idle cost | |---|---:|---:| @@ -117,7 +117,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 | -218 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. 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. +229 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. 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 951cb02..99a24e0 100644 --- a/backends/base.py +++ b/backends/base.py @@ -33,6 +33,7 @@ from backends.contracts import ( LinetypeContract, PageSetupContract, PremiumContract, + RefsContract, SettingsContract, SolidContract, StylesContract, @@ -190,6 +191,7 @@ class AutoCADBackend( SettingsContract, StylesContract, EnvironmentContract, + RefsContract, ): """All backends implement this interface. diff --git a/backends/com_backend.py b/backends/com_backend.py index 667bf8f..fdef6b2 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -16,6 +16,7 @@ import time import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor +from importlib.util import find_spec from pathlib import Path from typing import Any @@ -157,6 +158,28 @@ def _apoint(x: float, y: float, z: float = 0.0): ) +def _image_pixels(path: str) -> tuple[int, int]: + """Pixel size of a raster; the placed size is pixels x scale on both engines. + + Copied from the ezdxf backend rather than imported: the two backends must + not depend on each other. + """ + try: + from PIL import Image + except ImportError as exc: # pragma: no cover - Pillow ships with [pdf] + raise ValueError( + "image_attach needs Pillow to read the image's pixel size; " + 'install it with: pip install -e ".[pdf]"' + ) from exc + try: + with Image.open(path) as image: + return int(image.width), int(image.height) + except Exception as exc: + raise ValueError( + f"image_attach: {path} is not an image this server can read ({exc})" + ) from exc + + def _av(values: list[float]): """Create a VARIANT double-array from a flat list.""" return win32com.client.VARIANT( @@ -371,6 +394,24 @@ def _require_block_defined(doc, name): return block +def _block_names(doc) -> set[str]: + """Every block name the drawing holds, lower-cased. + + ``doc.Blocks.Count`` / ``doc.Blocks.Item(index)`` / ``block.Name`` are the + measured members ``xref_manage`` already walks. The set is lower-cased + because AutoCAD symbol-table names are case-insensitive, so ``BASE`` and + ``Base`` are one block and re-attaching under either name hits the same + definition. + """ + names: set[str] = set() + for index in range(doc.Blocks.Count): + try: + names.add(str(doc.Blocks.Item(index).Name).lower()) + except Exception: # noqa: BLE001 - a block that cannot name itself cannot collide + continue + return names + + _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} #: `drawing_properties_*` field → `IAcadSummaryInfo` property. @@ -1536,6 +1577,16 @@ class ComBackend(AutoCADBackend): "interactive_prompt": FeatureCapability( True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out" ), + # Track G keys. + "dwg_write": FeatureCapability(True, "native", reason="document_saveas"), + "xref_live": FeatureCapability( + True, "native", reason="block_reload_bind_detach_on_the_live_seat" + ), + "xlsx_write": ( + FeatureCapability(True, "openpyxl") + if find_spec("openpyxl") is not None + else FeatureCapability(False, reason="optional_dependency_missing:openpyxl") + ), }, ) @@ -1805,6 +1856,196 @@ class ComBackend(AutoCADBackend): async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") + # ── external references, rasters and DWG (v1.6 track G) ────────────────── + # + # Measured on AutoCAD 2026 (AutoCAD.Application 25.1s), from the live + # seat's type library: + # ModelSpace.AttachExternalReference(PathName, Name, InsertionPoint, + # Xscale, Yscale, Zscale, Rotation, bOverlay, Password) + # ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor, + # RotationAngle) + # Block: IsXRef, Path, Name, Reload(), Unload(), Bind(bPrefixName), + # Detach(), XRefDatabase + # Document.SaveAs(FullFileName, SaveAsType, vSecurityParams) + # ActiveX angles are radians; the tool boundary is degrees, so every + # rotation crosses here and nowhere else. + + #: AcSaveAsType, read off the same type library. + _DWG_SAVE_AS = { + "R2000": 12, + "R2004": 24, + "R2007": 36, + "R2010": 48, + "R2013": 60, + "R2018": 64, + } + + async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict: + from backends.contracts.refs import XREF_KINDS + + if kind not in XREF_KINDS: + raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}") + source = Path(path) + if not source.is_file(): + raise ValueError(f"xref_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"xref_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"xref_attach: scale must be positive, got {scale!r}") + name = source.stem.upper() + factor = float(scale) + radians = math.radians(float(rotation)) + + def _sync(): + doc = _acad_doc() + # The contract and the tool docstring both promise this refusal, + # and the ezdxf engine has always had it (`if name in doc.blocks`). + # Without it a second attach under a name the drawing already holds + # either re-points the existing definition or fails inside AutoCAD, + # while the payload still claims the new `path` was attached. + # Block names are case-insensitive in AutoCAD, so the comparison is. + if name.lower() in _block_names(doc): + raise ValueError( + f"xref_attach: the drawing already holds a block named {name!r}; " + "detach it first with xref_manage(action='detach')" + ) + reference = doc.ModelSpace.AttachExternalReference( + str(source), + name, + _apoint(x, y), + factor, + factor, + factor, + radians, + kind == "overlay", + ) + return { + "ok": True, + "name": name, + "path": str(source), + "kind": kind, + "handle": reference.Handle, + "at": [x, y], + "scale": factor, + "rotation": float(rotation), + "backend": self.name, + } + + return await self._run(_sync) + + async def xref_manage(self, name, action, new_path=None) -> dict: + from backends.contracts.refs import XREF_ACTIONS + + if action not in XREF_ACTIONS: + raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}") + if action == "path" and not (new_path or "").strip(): + raise ValueError("xref_manage(action='path') needs new_path") + + def _sync(): + doc = _acad_doc() + + def _xref_blocks(): + found = [] + for index in range(doc.Blocks.Count): + block = doc.Blocks.Item(index) + try: + if block.IsXRef: + found.append(block) + except Exception: # noqa: BLE001 - a block that cannot answer is not an xref + continue + return found + + if action == "list": + # `kind` is null, not "attach". Measured from the seat's own + # registered type library (acax25*.tlb, "AutoCAD 2025 Type + # Library", shipped with AutoCAD 2026): IAcadBlock's xref + # members are exactly IsXRef, Path, Name, Reload, Unload, + # Bind, Detach, XRefDatabase -- there is no overlay indicator + # anywhere on the interface. An overlay (which xref_attach + # itself creates, kind='overlay' -> bOverlay=True) is + # therefore not knowable here, and a constant "attach" would + # read to the caller as measured. Same honesty as `inserts`. + rows = [ + {"name": b.Name, "path": str(b.Path or ""), "kind": None, "inserts": None} + for b in _xref_blocks() + ] + rows.sort(key=lambda row: row["name"]) + return {"ok": True, "xrefs": rows, "backend": self.name} + + key = str(name).strip() + block = next((b for b in _xref_blocks() if b.Name == key), None) + if block is None: + raise ValueError( + f"xref_manage: {key!r} is not an external reference in this drawing" + ) + if action == "reload": + block.Reload() + return {"ok": True, "name": key, "action": action, "backend": self.name} + if action == "bind": + block.Bind(False) + return {"ok": True, "name": key, "action": action, "backend": self.name} + if action == "detach": + block.Detach() + return {"ok": True, "name": key, "inserts_removed": None, "backend": self.name} + block.Path = str(new_path) + return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} + + return await self._run(_sync) + + async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict: + source = Path(path) + if not source.is_file(): + raise ValueError(f"image_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"image_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"image_attach: scale must be positive, got {scale!r}") + px, py = _image_pixels(str(source)) + factor = float(scale) + radians = math.radians(float(rotation)) + + def _sync(): + doc = _acad_doc() + raster = doc.ModelSpace.AddRaster(str(source), _apoint(x, y), factor, radians) + return { + "ok": True, + "path": str(source), + "handle": raster.Handle, + "at": [x, y], + "scale": factor, + "rotation": float(rotation), + "pixels": [px, py], + "size_mm": [px * factor, py * factor], + "backend": self.name, + } + + return await self._run(_sync) + + async def drawing_export_dwg(self, path, version="R2018") -> dict: + from backends.contracts.refs import DWG_VERSIONS + + if version not in DWG_VERSIONS: + raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}") + save_as_type = self._DWG_SAVE_AS[version] + + def _sync(): + doc = _acad_doc() + doc.SaveAs(path, save_as_type) + target = Path(path) + return { + "ok": True, + "path": path, + "version": version, + "bytes": target.stat().st_size if target.exists() else None, + "backend": self.name, + } + + return await self._run(_sync) + 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. diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index 49835f5..6665641 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -24,6 +24,7 @@ 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.refs import RefsContract from backends.contracts.settings import SettingsContract from backends.contracts.solids import SolidContract from backends.contracts.styles import StylesContract @@ -48,6 +49,7 @@ __all__ = [ "LinetypeContract", "PageSetupContract", "PremiumContract", + "RefsContract", "SettingsContract", "SolidContract", "StylesContract", diff --git a/backends/contracts/refs.py b/backends/contracts/refs.py new file mode 100644 index 0000000..e8df4e2 --- /dev/null +++ b/backends/contracts/refs.py @@ -0,0 +1,103 @@ +"""External references, raster images and DWG output (v1.6 tracks B+G, group G). + +These are the only four operations of the sheet track that genuinely differ per +engine, so they are the only four that become contract members. Everything else +in `engineering/sheet/` composes the generic members and is written once. + +``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 + +#: An xref is attached (its own xrefs come with it) or overlaid (they do not). +XREF_KINDS = ("attach", "overlay") + +#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live +#: seat and are refused with the `xref_live` capability on the ezdxf engine. +XREF_ACTIONS = ("list", "reload", "bind", "detach", "path") + +#: DWG formats both engines name the same way. COM maps them to AcSaveAsType +#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through +#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more. +DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018") + + +class RefsContract(ABC): + @abstractmethod + async def xref_attach( + self, + path: str, + at, + scale: float, + rotation: float, + kind: str, + ) -> dict: + """Attach an external reference and insert it once. + + The block name is the referenced file's stem, which is what both + engines do. Returns ``{ok, name, path, kind, handle, at, scale, + rotation, backend}``. + + Refuses, before anything is written: a `kind` outside + :data:`XREF_KINDS`, a file that does not exist, a non-finite + coordinate/scale/rotation, a non-positive scale, and a block name the + drawing already holds (re-attaching would either silently replace the + definition or raise mid-write). + """ + ... + + @abstractmethod + async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict: + """One of :data:`XREF_ACTIONS` on one external reference. + + ``list`` ignores `name` and returns + ``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``kind`` and + ``inserts`` are ``None`` on a live seat -- ActiveX's ``IAcadBlock`` + carries no overlay indicator and no per-xref insert count (measured on + the registered type library), and a constant would read as measured. + ``path`` rewrites the saved path to `new_path` and returns the new one. + ``detach`` removes every insert of the xref -- in *every* entity space, + model space and each paper-space layout, because an insert left behind + with no block definition is a corrupt drawing -- then the definition, + and reports ``inserts_removed``. ``inserts`` counts the same set. + ``reload`` and ``bind`` need a live seat's xref manager: the headless engine raises + ``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending, + which is why the key exists; ``list``/``detach``/``path`` are *not* + behind it. + + Refuses an action outside the vocabulary, an unknown xref name, and a + ``path`` action with no `new_path`. + """ + ... + + @abstractmethod + async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict: + """Attach a raster image as an underlay. + + `scale` is drawing units per pixel, so the placed size is the image's + pixel size times `scale` and the aspect ratio is the file's. Returns + ``{ok, path, handle, at, scale, rotation, pixels: [w, h], + size_mm: [w, h], backend}``. + + Refuses a file that does not exist, a file no image reader can open + (naming it), a non-positive scale and a non-finite number -- all before + anything is written. + """ + ... + + @abstractmethod + async def drawing_export_dwg(self, path: str, version: str) -> dict: + """Write the current drawing as a real DWG. + + Returns ``{ok, path, version, bytes, backend}``. Refuses a `version` + outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA + File Converter and otherwise raises + ``UnsupportedCapabilityError("dwg_write", ...)`` naming the install + route; on a live seat it is ``Document.SaveAs`` with the matching + ``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the + session bound to the file it just wrote. + """ + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index e020fa8..5e6335e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -202,6 +202,39 @@ def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError: ) +def _odafc_available() -> bool: + """Is the ODA File Converter on this machine right now? + + Re-evaluated per call, the way the matplotlib check behind + ``viewport_render`` is: installing the converter must change the answer + without editing the map. + """ + try: + from ezdxf.addons import odafc + + return bool(odafc.is_installed()) + except Exception: # noqa: BLE001 - an import failure is simply "not installed" + return False + + +def _image_pixels(path: str) -> tuple[int, int]: + """Pixel size of a raster, so the placed image keeps the file's aspect.""" + try: + from PIL import Image + except ImportError as exc: # pragma: no cover - Pillow ships with [pdf] + raise ValueError( + "image_attach needs Pillow to read the image's pixel size; " + 'install it with: pip install -e ".[pdf]"' + ) from exc + try: + with Image.open(path) as image: + return int(image.width), int(image.height) + except Exception as exc: + raise ValueError( + f"image_attach: {path} is not an image this server can read ({exc})" + ) from exc + + def _audit_entry(entry) -> dict: """Flatten an ezdxf ``ErrorEntry`` into something a client can read. @@ -1676,6 +1709,30 @@ class EzdxfBackend(AutoCADBackend): "interactive_prompt": FeatureCapability( False, reason="no_operator_to_prompt_headlessly" ), + # Track G keys. `dwg_write` is a runtime re-evaluation, not a + # hardcoded False: installing the converter must change the + # answer without editing this map, exactly as `viewport_render` + # follows matplotlib. + "dwg_write": ( + FeatureCapability(True, "odafc", reason="oda_file_converter_installed") + if _odafc_available() + else FeatureCapability( + False, + reason=( + "requires_oda_file_converter:" + "https://www.opendesign.com/guestfiles/oda_file_converter" + ), + ) + ), + "xref_live": FeatureCapability( + False, + reason="reload_and_bind_need_a_live_seat;list_detach_path_are_headless", + ), + "xlsx_write": ( + FeatureCapability(True, "openpyxl") + if find_spec("openpyxl") is not None + else FeatureCapability(False, reason="optional_dependency_missing:openpyxl") + ), }, ) @@ -2322,6 +2379,220 @@ class EzdxfBackend(AutoCADBackend): async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") + # ── external references, rasters and DWG (v1.6 track G) ────────────────── + + async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict: + from backends.contracts.refs import XREF_KINDS + + if kind not in XREF_KINDS: + raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}") + source = Path(path) + if not source.is_file(): + raise ValueError(f"xref_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"xref_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"xref_attach: scale must be positive, got {scale!r}") + name = source.stem.upper() + + def _sync(): + from ezdxf import xref + + doc = self._require_doc() + if name in doc.blocks: + raise ValueError( + f"xref_attach: the drawing already holds a block named {name!r}; " + "detach it first with xref_manage(action='detach')" + ) + insert = xref.attach( + doc, + block_name=name, + filename=str(source), + insert=(x, y), + scale=float(scale), + rotation=float(rotation), + overlay=(kind == "overlay"), + ) + self._mark_dirty() + return { + "ok": True, + "name": name, + "path": str(source), + "kind": kind, + "handle": insert.dxf.handle, + "at": [x, y], + "scale": float(scale), + "rotation": float(rotation), + "backend": self.name, + } + + return await self._async(_sync) + + async def xref_manage(self, name, action, new_path=None) -> dict: + from backends.contracts.refs import XREF_ACTIONS + + if action not in XREF_ACTIONS: + raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}") + if action in ("reload", "bind"): + raise UnsupportedCapabilityError( + "xref_live", + f"xref_manage(action={action!r}): the {self.name} backend has no live xref " + "manager. list, detach and path work headlessly; reload and bind need the " + "com backend (a running AutoCAD seat).", + ) + if action == "path" and not (new_path or "").strip(): + raise ValueError("xref_manage(action='path') needs new_path") + + def _sync(): + from ezdxf.lldxf import const + + doc = self._require_doc() + # Measured on ezdxf 1.4.4: `xref.attach` leaves an attachment's + # block flags at 20 and an overlay's at 24, so the question "is + # this an xref" is the *pair* of bits. Masking BLK_XREF alone + # misses every overlay. + xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY + + def _inserts_of(key: str): + """Every INSERT of `key`, paired with the layout that owns it. + + Model space is *not* the drawing. `doc.blocks` is the one + iterable that reaches every entity space -- it yields the + block layouts behind model space and each paper-space tab as + well as the real block definitions -- and it is exactly what + ezdxf's own `delete_block(safe=True)` searches through + `doc.query("INSERT[name==...]i")`. Counting or deleting over + `doc.modelspace()` alone under-reports an xref shown on a + sheet and leaves that INSERT behind with no BLOCK definition. + The name comparison is case-insensitive for the same reason: + the `i` on that query string. + """ + wanted = key.lower() + found = [] + for layout in doc.blocks: + for entity in layout: + if entity.dxftype() != "INSERT": + continue + if str(entity.dxf.name).lower() == wanted: + found.append((layout, entity)) + return found + + def _rows(): + rows = [] + for block in doc.blocks: + flags = int(block.block.dxf.flags) + if not flags & xref_mask: + continue + rows.append( + { + "name": block.name, + "path": str(block.block.dxf.xref_path or ""), + "kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach", + "inserts": len(_inserts_of(block.name)), + } + ) + rows.sort(key=lambda row: row["name"]) + return rows + + if action == "list": + return {"ok": True, "xrefs": _rows(), "backend": self.name} + + key = str(name).strip() + row = next((r for r in _rows() if r["name"] == key), None) + if row is None: + raise ValueError( + f"xref_manage: {key!r} is not an external reference in this drawing " + f"(have: {', '.join(r['name'] for r in _rows()) or 'none'})" + ) + if action == "path": + doc.blocks.get(key).block.dxf.xref_path = str(new_path) + self._mark_dirty() + return {"ok": True, "name": key, "path": str(new_path), "backend": self.name} + + victims = _inserts_of(key) + for layout, entity in victims: + layout.delete_entity(entity) + # safe=True, as the plan pins it: the in-use check is the only + # thing standing between a missed insert and a saved drawing whose + # INSERT has no BLOCK behind it (ezdxf's recover reports that as + # `FIX 103 UNDEFINED_BLOCK` and throws the geometry away). If the + # scan above ever misses one, this raises instead of corrupting. + doc.blocks.delete_block(key, safe=True) + self._mark_dirty() + return { + "ok": True, + "name": key, + "inserts_removed": len(victims), + "backend": self.name, + } + + return await self._async(_sync) + + async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict: + source = Path(path) + if not source.is_file(): + raise ValueError(f"image_attach: {path} does not exist") + x, y = float(at[0]), float(at[1]) + for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)): + if not math.isfinite(float(value)): + raise ValueError(f"image_attach: {label} must be finite, got {value!r}") + if float(scale) <= 0: + raise ValueError(f"image_attach: scale must be positive, got {scale!r}") + px, py = _image_pixels(str(source)) + width, height = px * float(scale), py * float(scale) + + def _sync(): + doc = self._require_doc() + image_def = doc.add_image_def(str(source), size_in_pixel=(px, py)) + image = self._msp().add_image(image_def, (x, y), (width, height), float(rotation)) + self._mark_dirty() + return { + "ok": True, + "path": str(source), + "handle": image.dxf.handle, + "at": [x, y], + "scale": float(scale), + "rotation": float(rotation), + "pixels": [px, py], + "size_mm": [width, height], + "backend": self.name, + } + + return await self._async(_sync) + + async def drawing_export_dwg(self, path, version="R2018") -> dict: + from backends.contracts.refs import DWG_VERSIONS + + if version not in DWG_VERSIONS: + raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}") + if not _odafc_available(): + raise UnsupportedCapabilityError( + "dwg_write", + f"drawing_export_dwg: the {self.name} backend writes DWG only through the " + "ODA File Converter, which is not installed here. Install it from " + "https://www.opendesign.com/guestfiles/oda_file_converter and this call " + "starts working, or export from the com backend (a running AutoCAD seat). " + "drawing_export_dxf writes a DXF that AutoCAD opens unchanged.", + ) + + def _sync(): + from ezdxf.addons import odafc + + doc = self._require_doc() + self._refuse_path_held_elsewhere("drawing_export_dwg", path) + odafc.export_dwg(doc, path, version=version, replace=True) + return { + "ok": True, + "path": path, + "version": version, + "bytes": Path(path).stat().st_size, + "backend": self.name, + } + + return await self._async(_sync) + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: """Export to PDF via ezdxf's matplotlib backend. diff --git a/discovery/aliases.py b/discovery/aliases.py index 01f5073..0af6c0a 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -91,6 +91,11 @@ class ToolAliases: # OPTIONS -- one dialog both reads and writes a preference; the server # splits read (system_preferences_get) and write (system_preferences_set), # the SETVAR precedent. +# REVCLOUD -- one command draws a bare cloud (entity_create_revcloud) or a +# revision cloud that also files a revision row (revision_add); a +# drafter typing it could mean either. +# TABLE -- one command makes an empty table (entity_create_table) or the +# ISO 7573 parts list (bom_table); both are correct destinations. # SECTIONPLANE -- the section a drafter asks for is either a whole section # view of a drawn part (mech_view_add) or the cutting-plane line and its # labels on an existing view (section_line); tracks B+G groups M and A @@ -116,10 +121,12 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( "PLINE", "PLOT", "QSELECT", + "REVCLOUD", "SAVEAS", "SECTIONPLANE", "SETVAR", "STYLE", + "TABLE", "UCS", "VIEW", "ZOOM", @@ -994,6 +1001,156 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "drawing header", ), ), + "sheet_frame": ToolAliases( + acad=("MVSETUP",), + synonyms=( + "sheet frame", + "drawing frame", + "drawing border", + "iso 5457", + "zone grid", + "grid reference system", + "centring marks", + "trimming marks", + "sheet margins", + "filing margin", + "pafta", + "cizim cercevesi", + ), + ), + "titleblock_apply": ToolAliases( + synonyms=( + "title block", + "iso 7200", + "drawing header", + "sheet title block", + "a0 title block", + "a1 title block", + "a2 title block", + "a4 title block", + "projection angle symbol", + "first angle symbol", + "third angle symbol", + "antet", + "baslik blogu", + ), + ), + "revision_add": ToolAliases( + acad=("REVCLOUD",), + synonyms=( + "revision", + "revision block", + "revision cloud", + "revision table", + "change note", + "engineering change", + "as built change", + "revizyon", + "degisiklik", + ), + ), + "bom_extract": ToolAliases( + synonyms=( + "bill of materials", + # Deliberately NOT "parts list" or "count the parts": the bare + # "parts list" query belongs to the tool that DRAWS one + # (entity_create_table / bom_table), and every extra "parts"/"list" + # token here moved this reader above it on plain BM25 mass. + "read the bom", + "bom", + "item list", + "what is on this drawing", + "iso 7573", + "parca listesi", + "malzeme listesi", + ), + ), + "bom_table": ToolAliases( + acad=("TABLE",), + synonyms=( + # Every phrase here names a TABLE. The bare "parts list" query is + # `entity_create_table`'s (the generic table that a parts list is + # one use of); this tool answers when the drafter asks for the + # ISO 7573 one by name. + "bom table", + "item list table", + "materials table", + "iso 7573 table", + "parca listesi tablosu", + ), + ), + "balloon_add": ToolAliases( + synonyms=( + "balloon", + "item reference", + "item number", + "position number", + "iso 6433", + "number the items", + "balon", + "pozisyon numarasi", + ), + ), + "xref_attach": ToolAliases( + acad=("XATTACH",), + synonyms=( + "attach an xref", + "external reference", + "xref", + "reference another drawing", + "overlay a drawing", + "harici referans", + ), + ), + "xref_manage": ToolAliases( + acad=("XREF", "XBIND"), + synonyms=( + "list the xrefs", + "reload an xref", + "bind an xref", + "detach an xref", + "repath an xref", + "xref manager", + "broken xref path", + ), + ), + "image_attach": ToolAliases( + acad=("IMAGEATTACH",), + synonyms=( + "attach an image", + "raster underlay", + "insert a png", + "background image", + "scanned drawing", + "resim ekle", + ), + ), + "data_extract": ToolAliases( + acad=("DATAEXTRACTION", "EATTEXT"), + synonyms=( + # Not the bare phrase "parts list": that is the golden query for + # `entity_create_table`, and a file writer must not outrank the + # tool that draws the thing. This one is asked for by its + # destination -- a file -- which is what distinguishes it. + "export the bill of materials", + "extract attributes", + "bom to csv", + "bom to excel", + "data extraction", + "excele aktar", + ), + ), + "drawing_export_dwg": ToolAliases( + acad=("SAVEAS",), + synonyms=( + "export dwg", + "save as dwg", + "write a dwg", + "autocad 2018 format", + "downgrade to r2000", + "dwg olarak kaydet", + ), + ), # ── Layers & linetypes ────────────────────────────────────────────────── "layer_create": ToolAliases( acad=("LAYER", "LA"), diff --git a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md index 8a883df..d3cdaa3 100644 --- a/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md +++ b/docs/superpowers/plans/2026-09-22-v1.6-mech-implementation.md @@ -16288,7 +16288,12 @@ async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: s f"{caller}: layout {layout!r} does not exist " f"(have: {', '.join(sorted(names))}). Create it with layout_create first." ) - previous = getattr(backend, "_current_space", "Model") + # The caller's tab comes from the listing that was just read -- both engines + # report it there (ezdxf from $TILEMODE / the active layout, COM from + # ``doc.ActiveLayout.Name``). A private attribute would not: only + # EzdxfBackend has ``_current_space``, so reading it left a live caller + # standing on Model after every border. + previous = str(listing.get("current") or "Model") switched = await backend.layout_set_current(layout) if not switched.get("ok", True): raise RuntimeError(f"{caller}: {switched.get('error')}") @@ -16502,29 +16507,87 @@ def test_projection_none_draws_no_symbol_and_first_draws_one(): assert len(first) == len(plain) + 4 -def test_first_angle_puts_the_circles_to_the_right_of_the_cone(): +#: The ISO 5456-2 / ISO 128-30 projection symbol, transcribed from two +#: independent reproductions of the standard's figure (ISO's own is paywalled +#: and was not read). Each entry is that source's OWN coordinates, measured off +#: the file, and the landmark order left-to-right is what the symbol means. +#: +#: 1. FreeCAD's ISO 5457 sheet template, first-angle symbol -- +#: src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg, +#: ids `first_angle_trapezoid`, `first_angle_inner circle`, +#: `first_angle_outer circle`. +#: 2. Wikimedia Commons `Convention placement vues dessin technique.svg`, which +#: draws both symbols side by side, labelled FR (first) and US (third). +#: +#: A test that read these numbers out of `projection_symbol_prims` instead +#: would pass whichever way round the symbol was drawn, which is exactly how +#: the mirrored third-angle cone shipped in the first place. +REFERENCE_FIGURES = { + "first": ( + # source, short-side x, long-side x, end-view (circles) x + ("FreeCAD A3_Landscape_ISO5457_advanced.svg", 379.0, 389.0, 396.0), + ("Commons Convention placement vues dessin technique.svg (FR)", 13.343, 81.869, 148.005), + ), + "third": ( + ("Commons Convention placement vues dessin technique.svg (US)", 380.275, 448.801, 314.140), + ), +} + + +#: The three landmarks, in the order `_landmarks` and REFERENCE_FIGURES list them. +LANDMARKS = ("short", "long", "circles") + + +def _landmarks(angle: str) -> tuple[float, float, float]: + """(short-side x, long-side x, end-view x) of the symbol this module draws.""" + prims = projection_symbol_prims((0.0, 0.0), angle) + cone = next(p for p in prims if isinstance(p, Poly)) + circles = [p for p in prims if isinstance(p, Circle)] + left_x = min(x for x, _ in cone.points) + right_x = max(x for x, _ in cone.points) + left_h = max(y for x, y in cone.points if x == left_x) * 2 + right_h = max(y for x, y in cone.points if x == right_x) * 2 + short_x, long_x = (left_x, right_x) if left_h < right_h else (right_x, left_x) + assert len({c.center[0] for c in circles}) == 1 + return short_x, long_x, circles[0].center[0] + + +@pytest.mark.parametrize("angle", ["first", "third"]) +def test_the_symbol_lands_the_landmarks_in_the_order_the_reference_figures_do(angle): + """The meaning of the symbol is the left-to-right order of three landmarks: + the cone's short side, its long side and the end view. Assert that order + against the measured figures, not against what the module computes.""" + mine = _landmarks(angle) + for source, *reference in REFERENCE_FIGURES[angle]: + expected = [name for _x, name in sorted(zip(reference, LANDMARKS, strict=True))] + got = [name for _x, name in sorted(zip(mine, LANDMARKS, strict=True))] + assert got == expected, f"{angle}-angle symbol disagrees with {source}" + + +def test_the_short_side_points_away_from_the_circles_in_first_and_towards_in_third(): + """The mirror-invariant half of the rule, and the one every source states + in words: Wikipedia's Multiview orthographic projection, Symbol -- + "the first-angle symbol shows the trapezoid with its shortest side away + from the circles", "the third-angle symbol shows the trapezoid with its + shortest side towards the circles".""" + short_x, long_x, circles_x = _landmarks("first") + assert abs(short_x - circles_x) > abs(long_x - circles_x) + short_x, long_x, circles_x = _landmarks("third") + assert abs(short_x - circles_x) < abs(long_x - circles_x) + + +def test_the_symbol_keeps_the_reference_figures_proportions_and_its_axis(): + """Both reference figures draw the end view as two circles of ratio 1:2 + (FreeCAD r 2.5 / r 5) on a centre line; the absolute size is this + module's.""" first = projection_symbol_prims((0.0, 0.0), "first") - third = projection_symbol_prims((0.0, 0.0), "third") - cone_first = next(p for p in first if isinstance(p, Poly)) - circles_first = [p for p in first if isinstance(p, Circle)] - assert max(x for x, _ in cone_first.points) < min(c.center[0] for c in circles_first) - cone_third = next(p for p in third if isinstance(p, Poly)) - circles_third = [p for p in third if isinstance(p, Circle)] - assert min(x for x, _ in cone_third.points) > max(c.center[0] for c in circles_third) - assert {round(c.radius, 6) for c in circles_first} == {4.0, 2.0} + circles = [p for p in first if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {4.0, 2.0} assert any(isinstance(p, Line) and p.role == "center" for p in first) -def test_the_cone_s_small_end_faces_the_circles_side_in_first_angle(): - """The derivation only holds if the view drawn as circles really is the - view from the cone's small end.""" - cone = next(p for p in projection_symbol_prims((0.0, 0.0), "first") if isinstance(p, Poly)) - left_x = min(x for x, _ in cone.points) - right_x = max(x for x, _ in cone.points) - left_height = max(y for x, y in cone.points if x == left_x) * 2 - right_height = max(y for x, y in cone.points if x == right_x) * 2 - assert left_height == pytest.approx(4.0, abs=EPS) - assert right_height == pytest.approx(8.0, abs=EPS) +def test_the_two_symbols_are_not_the_same_picture(): + assert _landmarks("first") != _landmarks("third") def test_an_unknown_projection_angle_is_refused(): @@ -16757,34 +16820,63 @@ def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt: def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]: - """The ISO 128-30 projection-angle symbol, centred on `at`. + """The ISO 5456-2 / ISO 128-30 projection-angle symbol, centred on `at`. - Arrangement, and where it comes from. The symbol is a truncated cone with a - horizontal axis and its small end facing left. The first- and third-angle - symbols are mirror images about a vertical axis, and which is which follows - from the projection rule itself: first-angle projection puts the view seen - from the left ON THE RIGHT, and the view of this cone seen from the left is - the small circle inside the large one. So FIRST angle draws the cone on the - left and the concentric circles on the right; THIRD angle is its mirror. + ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone + drawn in two views: the cone in elevation (a trapezoid) and the end view + (two concentric circles). Which side the end view sits on is what makes the + symbol mean *first* or *third* angle, and it is a convention that has to be + read off the standard's figure: the projection rule alone cannot pick it, + because the same picture is a legal drawing of the mirrored object seen + from the other side. + + ISO 5456-2's own figure is behind ISO's paywall and was not read. Two + independent reproductions of it were downloaded and measured instead, and + they agree on every landmark used here: + + * FreeCAD's ISO 5457 sheet template, first-angle symbol + (`src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg`, + ids `first_angle_trapezoid` / `first_angle_*_circle`): the trapezoid is + `m 389,222 -10,2.5 v 5 l 10,2.5 z` -- a 5-unit side at x = 379 and a + 10-unit side at x = 389 -- and the circles (r 2.5 and r 5) are at + cx = 396. Short side LEFT; end view to the RIGHT of the cone. + * Wikimedia Commons `Convention placement vues dessin technique.svg`, which + draws both symbols side by side, labelled FR and US. FR (first angle): + trapezoid 35.856 units tall at x = 13.343 and 66.135 at x = 81.869, + circles at x = 148.005. US (third angle): trapezoid 35.856 at + x = 380.275 and 66.135 at x = 448.801, circles at x = 314.140. + + So, in both symbols alike, the trapezoid's SHORT side is on the left; only + the end view changes side: + + first angle cone, then the circles to its RIGHT + third angle the circles, then the cone to their RIGHT + + which is the same thing as the rule the standard is usually quoted by -- + the short side points AWAY from the circles in first angle and TOWARDS + them in third. `tests/test_sheet_titleblock.py` pins the ordering against + the measured figures above, so this cannot be "fixed" back by eye. The proportions (large circle diameter 8, small 4, cone 8 mm long) are this - module's; ISO 5456-2's proportion table is not transcribed. The arrangement - is the part that can be *wrong* on a drawing, and it is derived above. + module's; ISO 5456-2's proportion table is not transcribed. They keep the + 1:2 small-to-large ratio both reference figures' circles have. """ if angle not in PROJECTION_ANGLES: raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}") cx, cy = float(at[0]), float(at[1]) half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0 + # The end view sits right of the cone in first angle, left of it in third. sign = 1.0 if angle == "first" else -1.0 - cone_far = -half * sign - cone_near = cone_far + sign * PROJECTION_CONE_LENGTH circles_x = (half - PROJECTION_R) * sign + # The trapezoid itself is the same picture in both symbols: short side left. + cone_left = -half if angle == "first" else half - PROJECTION_CONE_LENGTH + cone_right = cone_left + PROJECTION_CONE_LENGTH cone = Poly( points=( - (cx + cone_far, cy - PROJECTION_SMALL_R), - (cx + cone_near, cy - PROJECTION_R), - (cx + cone_near, cy + PROJECTION_R), - (cx + cone_far, cy + PROJECTION_SMALL_R), + (cx + cone_left, cy - PROJECTION_SMALL_R), + (cx + cone_right, cy - PROJECTION_R), + (cx + cone_right, cy + PROJECTION_R), + (cx + cone_left, cy + PROJECTION_SMALL_R), ), closed=True, ) diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 400eda3..81229f3 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": 218, + "tools": 229, "resources": 8, "prompts": 5, "groups": 24 @@ -11,12 +11,12 @@ "groups": { "analysis": 12, "batch": 3, - "blocks": 9, + "blocks": 11, "corner_ops": 4, "dimensions": 5, - "drawing": 11, - "engineering": 10, - "entity_creation": 18, + "drawing": 12, + "engineering": 17, + "entity_creation": 19, "entity_modification": 16, "entity_query": 9, "environment": 22, @@ -179,6 +179,20 @@ "readOnlyHint": true } }, + { + "name": "balloon_add", + "group": "engineering", + "cost": "mutate", + "summary": "Add an ISO 6433 balloon linked to its item row.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: Add Balloon", + "destructiveHint": false + } + }, { "name": "batch_plot", "group": "page_setup", @@ -304,6 +318,35 @@ "readOnlyHint": false } }, + { + "name": "bom_extract", + "group": "engineering", + "cost": "read", + "summary": "Read the bill of materials out of the drawing's block references.", + "tags": [ + "engineering", + "query", + "sheet" + ], + "annotations": { + "title": "Sheet: Extract Bill of Materials", + "readOnlyHint": true + } + }, + { + "name": "bom_table", + "group": "engineering", + "cost": "mutate", + "summary": "Draw the parts list as a real TABLE above the title block.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: Draw Parts List", + "destructiveHint": false + } + }, { "name": "boundary_from_entities", "group": "analysis", @@ -386,6 +429,21 @@ "destructiveHint": false } }, + { + "name": "data_extract", + "group": "engineering", + "cost": "safe", + "summary": "Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.", + "tags": [ + "engineering", + "query", + "sheet" + ], + "annotations": { + "title": "Sheet: Extract Data", + "destructiveHint": false + } + }, { "name": "datum_feature", "group": "engineering", @@ -669,6 +727,19 @@ "destructiveHint": true } }, + { + "name": "drawing_export_dwg", + "group": "drawing", + "cost": "safe", + "summary": "Write the drawing as a real DWG at a chosen AutoCAD version.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Drawing: Export DWG", + "destructiveHint": false + } + }, { "name": "drawing_export_dxf", "group": "drawing", @@ -1635,6 +1706,20 @@ "destructiveHint": false } }, + { + "name": "image_attach", + "group": "entity_creation", + "cost": "mutate", + "summary": "Attach a raster image (PNG/JPG) as an underlay.", + "tags": [ + "create", + "drawing" + ], + "annotations": { + "title": "Sheet: Attach Image", + "destructiveHint": false + } + }, { "name": "keyway_draw_keyed_bore", "group": "engineering", @@ -2325,6 +2410,20 @@ "readOnlyHint": true } }, + { + "name": "revision_add", + "group": "engineering", + "cost": "mutate", + "summary": "Append a revision row, with optional revision clouds and triangular tags.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: Add Revision", + "destructiveHint": false + } + }, { "name": "section_line", "group": "mech", @@ -2395,6 +2494,20 @@ "readOnlyHint": true } }, + { + "name": "sheet_frame", + "group": "engineering", + "cost": "mutate", + "summary": "Draw an ISO 5457 sheet frame with the zone grid and centring marks.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: ISO 5457 Frame", + "destructiveHint": false + } + }, { "name": "solid_boolean", "group": "solids", @@ -2780,6 +2893,20 @@ "readOnlyHint": false } }, + { + "name": "titleblock_apply", + "group": "engineering", + "cost": "mutate", + "summary": "Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.", + "tags": [ + "engineering", + "sheet" + ], + "annotations": { + "title": "Sheet: ISO 7200 Title Block", + "destructiveHint": false + } + }, { "name": "titleblock_apply_iso_a3", "group": "engineering", @@ -3100,6 +3227,34 @@ "title": "Weld Symbol (ISO 2553)", "destructiveHint": false } + }, + { + "name": "xref_attach", + "group": "blocks", + "cost": "mutate", + "summary": "Attach a DWG/DXF as an external reference (attachment or overlay).", + "tags": [ + "block", + "drawing" + ], + "annotations": { + "title": "Sheet: Attach Xref", + "destructiveHint": false + } + }, + { + "name": "xref_manage", + "group": "blocks", + "cost": "mutate", + "summary": "List, reload, bind, detach or repath an external reference.", + "tags": [ + "block", + "drawing" + ], + "annotations": { + "title": "Sheet: Manage Xrefs", + "destructiveHint": true + } } ], "resources": [ diff --git a/engineering/__init__.py b/engineering/__init__.py index 7d384f2..94aac30 100644 --- a/engineering/__init__.py +++ b/engineering/__init__.py @@ -45,6 +45,8 @@ from .preflight import ( preflight_drawing, ) from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing +from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims +from .sheet.titleblock import apply_titleblock, titleblock_prims from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock from .validator import DrawingValidator, ValidationFinding, ValidationResult @@ -92,4 +94,9 @@ __all__ = [ "RefineRound", "RepairAction", "refine_drawing", + "apply_titleblock", + "draw_sheet_frame", + "frame_metrics", + "frame_prims", + "titleblock_prims", ] diff --git a/engineering/sheet/__init__.py b/engineering/sheet/__init__.py new file mode 100644 index 0000000..e6a99c3 --- /dev/null +++ b/engineering/sheet/__init__.py @@ -0,0 +1,88 @@ +"""The sheet standard: ISO 5457 frames, ISO 7200 title blocks, ISO 7573 parts +lists, ISO 6433 balloons and the delivery formats.""" + +from engineering.sheet.bom import ( + BOM_COLUMNS, + COLUMN_LABELS, + DEFAULT_COLUMNS, + LIST_WIDTH, + ROW_HEIGHT, + add_balloon, + balloon_prims, + draw_bom_table, + extract_records, + read_balloons, + rows_from_records, + table_prims, +) +from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available +from engineering.sheet.frames import ( + CENTRING_OVERSHOOT, + EDGE_MARGIN, + FILING_MARGIN, + SHEET_LAYER, + SHEET_TEXT_LAYER, + SHEETS, + TRIM_MARK_LONG, + TRIM_MARK_SHORT, + ZONE_DIVISIONS, + ZONE_MODULE, + draw_sheet_frame, + draw_sheet_prims, + enter_layout, + frame_metrics, + frame_prims, + sheet_size, + zone_divisions, +) +from engineering.sheet.revision import ( + REVISION_COLUMNS, + add_revision, + read_revisions, + revision_block_prims, + revision_heading_prims, + revision_row_prims, + revision_tag_prims, +) + +__all__ = [ + "BOM_COLUMNS", + "CENTRING_OVERSHOOT", + "COLUMN_LABELS", + "DEFAULT_COLUMNS", + "EDGE_MARGIN", + "EXTRACT_FORMATS", + "FILING_MARGIN", + "LIST_WIDTH", + "REVISION_COLUMNS", + "ROW_HEIGHT", + "SHEETS", + "SHEET_LAYER", + "SHEET_TEXT_LAYER", + "TRIM_MARK_LONG", + "TRIM_MARK_SHORT", + "ZONE_DIVISIONS", + "ZONE_MODULE", + "add_balloon", + "add_revision", + "balloon_prims", + "draw_bom_table", + "draw_sheet_frame", + "draw_sheet_prims", + "enter_layout", + "extract_records", + "frame_metrics", + "frame_prims", + "read_balloons", + "read_revisions", + "revision_block_prims", + "revision_heading_prims", + "revision_row_prims", + "revision_tag_prims", + "rows_from_records", + "sheet_size", + "table_prims", + "write_rows", + "xlsx_available", + "zone_divisions", +] diff --git a/engineering/sheet/bom.py b/engineering/sheet/bom.py new file mode 100644 index 0000000..2bca393 --- /dev/null +++ b/engineering/sheet/bom.py @@ -0,0 +1,587 @@ +"""ISO 7573 parts lists and ISO 6433 item references. + +SOURCE +------ +Parts list ISO 7573:2008 -- an item list carries at least an item reference + (the position number), a quantity, a designation and a reference + to the document or standard that defines the item. When the list + is drawn on the drawing it is placed directly above the title + block, has the same width as it, and the item numbers ascend + upwards so the list can be extended -- which puts the heading row + at the bottom, against the title block. `direction="down"` draws + the same list the other way up, for a list on its own sheet. +Balloons ISO 6433:2012 -- an item reference is a numeral placed outside the + outline of the item and connected to it by a leader line that + terminates in a dot on the item. + +NOT TRANSCRIBED, and therefore not asserted anywhere as if it were the +standard's: + +* The column widths. ISO 7573 fixes neither the widths nor the order; the + shares below total the 180 mm title-block width, which *is* the standard's + rule, and any column set is rescaled to that total. +* The row height. 7 mm is this repository's `entity_create_table` default. +* ISO 6433's rule that the numeral is at least twice the height of the + dimension characters. The numeral here is 1.4 * the balloon radius, so the + default radius of 4 mm gives a 5.6 mm numeral -- which clears 2 x 2.5 mm + dimension text but not 2 x 3.5 mm. A drawing dimensioned at 3.5 mm should + pass `radius=5.0`, and the docstring of `balloon_prims` says so. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Prim, Pt, Text +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values +from engineering.sheet.frames import SHEET_TEXT_LAYER, draw_sheet_prims, enter_layout + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "BALLOON_DOT_RADIUS", + "BOM_COLUMNS", + "COLUMN_LABELS", + "COLUMN_SHARES", + "DEFAULT_COLUMNS", + "DIRECTIONS", + "LIST_STANDARDS", + "LIST_WIDTH", + "PAGE_SIZE", + "ROW_HEIGHT", + "add_balloon", + "all_entities", + "balloon_prims", + "draw_bom_table", + "extract_records", + "read_balloons", + "rows_from_records", + "table_layout", + "table_prims", +] + +LIST_WIDTH = 180.0 # ISO 7573: the item list is as wide as the title block. +ROW_HEIGHT = 7.0 +TEXT_HEIGHT = 2.5 +BALLOON_DOT_RADIUS = 0.5 +DIRECTIONS = ("up", "down") +LIST_STANDARDS = ("ISO 7573",) + +#: Every column this module knows how to print, in the order it prints them. +BOM_COLUMNS: tuple[str, ...] = ( + "item", + "qty", + "designation", + "standard", + "material", + "mass", + "remarks", +) + +#: The four ISO 7573 minimum columns plus the material this shop always wants. +DEFAULT_COLUMNS: tuple[str, ...] = ("item", "qty", "designation", "standard", "material") + +COLUMN_LABELS: dict[str, str] = { + "item": "POS", + "qty": "QTY", + "designation": "DESIGNATION", + "standard": "STANDARD", + "material": "MATERIAL", + "mass": "MASS", + "remarks": "REMARKS", +} + +#: Relative widths; `table_layout` rescales the chosen set to LIST_WIDTH. The +#: default five happen to total 180.0, so their scale factor is exactly 1. +COLUMN_SHARES: dict[str, float] = { + "item": 12.0, + "qty": 14.0, + "designation": 74.0, + "standard": 46.0, + "material": 34.0, + "mass": 22.0, + "remarks": 40.0, +} + + +#: How many entities one `entity_list` call asks for while paging. It is a page +#: size, never a cap: `all_entities` keeps asking until a short page comes back. +PAGE_SIZE = 1000 + + +async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None): + """Every entity of a type in the CURRENT space -- not the first 200 of them. + + `entity_list`'s `limit` defaults to 200 on both engines, so a single call is + a silent truncation on any real drawing: a parts list that stops at the + 200th INSERT prints a wrong QTY, and a duplicate-balloon guard that only + sees the first 200 CIRCLEs is blind on a drawing whose hole pattern fills + them. Everything in this module that *counts* or *guards* reads through + here instead. + + The loop stops on a short page, and also when a page brings nothing new -- + a backend that ignored `offset` would otherwise hand back the same page for + ever. Handles are unique per drawing, so that guard cannot drop a real + entity. + """ + found: list = [] + seen: set[str] = set() + offset = 0 + while True: + page = await backend.entity_list( + type_filter=type_filter, layer_filter=layer, limit=PAGE_SIZE, offset=offset + ) + fresh = [entity for entity in page if entity.handle not in seen] + if not fresh: + return tuple(found) + seen.update(entity.handle for entity in fresh) + found.extend(fresh) + if len(page) < PAGE_SIZE: + return tuple(found) + offset += len(page) + + +def _check_columns(columns) -> tuple[str, ...]: + chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) + unknown = [c for c in chosen if c not in BOM_COLUMNS] + if unknown: + raise ValueError( + f"unknown parts-list column(s) {', '.join(unknown)}; " + f"available: {', '.join(BOM_COLUMNS)}" + ) + if "item" not in chosen: + raise ValueError("ISO 7573: the item reference column 'item' cannot be dropped") + return chosen + + +def rows_from_records(records, *, columns=None, group_by: str | None = "designation"): + """Collapse extracted INSERT records into numbered parts-list rows. + + `group_by` is a record key (`"designation"`, `"standard"`, ...) or None for + one row per insert. Rows are sorted by the grouping value so a rerun on the + same drawing numbers the same parts the same way; `handles` carries every + INSERT the row stands for, which is what a balloon links to. + """ + chosen = _check_columns(columns) + if group_by is not None and group_by not in BOM_COLUMNS: + raise ValueError( + f"group_by must be None or one of {', '.join(BOM_COLUMNS)}; got {group_by!r}" + ) + buckets: dict[str, dict] = {} + order: list[str] = [] + for index, record in enumerate(records): + key = str(record.get(group_by, "")) if group_by is not None else f"#{index:06d}" + bucket = buckets.get(key) + if bucket is None: + bucket = {c: record.get(c, "") for c in chosen if c != "item"} + bucket["qty"] = 0 + bucket["handles"] = [] + bucket["_key"] = key + buckets[key] = bucket + order.append(key) + bucket["qty"] += int(record.get("qty", 1) or 1) + handle = record.get("handle") + if handle: + bucket["handles"].append(str(handle)) + if group_by is not None: + order.sort() + rows: list[dict] = [] + for item, key in enumerate(order, start=1): + bucket = buckets[key] + bucket.pop("_key", None) + bucket["handles"] = tuple(bucket["handles"]) + bucket["item"] = item + rows.append({c: bucket.get(c, "") for c in chosen} | {"handles": bucket["handles"]}) + return tuple(rows) + + +def table_layout( + rows, + *, + at: Pt, + direction: str = "up", + columns=None, + row_height: float = ROW_HEIGHT, +): + """Everything both renderers need, so the prims and the real TABLE agree. + + `at` is the anchor: the list's bottom-left corner for `direction="up"` (it + sits on the title block) and its top-left corner for `direction="down"`. + `insert` is where `entity_create_table` must start, since a TABLE is drawn + downwards from its insertion point. + """ + if direction not in DIRECTIONS: + raise ValueError(f"direction must be one of {DIRECTIONS}, got {direction!r}") + chosen = _check_columns(columns) + if row_height <= 0: + raise ValueError(f"row_height must be positive, got {row_height!r}") + raw = [COLUMN_SHARES[c] for c in chosen] + scale = LIST_WIDTH / sum(raw) + widths = [w * scale for w in raw] + heading = [COLUMN_LABELS[c] for c in chosen] + body = [[str(row.get(c, "")) for c in chosen] for row in rows] + cells = list(reversed(body)) + [heading] if direction == "up" else [heading] + body + height = row_height * len(cells) + ax, ay = float(at[0]), float(at[1]) + insert = (ax, ay + height) if direction == "up" else (ax, ay) + return { + "columns": chosen, + "labels": heading, + "widths": widths, + "width": sum(widths), + "height": height, + "row_height": row_height, + "direction": direction, + "anchor": (ax, ay), + "insert": insert, + "cells": cells, + } + + +def table_prims( + rows, + *, + at: Pt, + direction: str = "up", + standard: str = "ISO 7573", + height: float = ROW_HEIGHT, +) -> tuple[Prim, ...]: + """The parts list as primitive descriptions, for a caller that does not + want a real TABLE (and for the tests that pin the layout).""" + if standard not in LIST_STANDARDS: + raise ValueError( + f"parts-list standard {standard!r} is not transcribed; " + f"available: {', '.join(LIST_STANDARDS)}" + ) + layout = table_layout(rows, at=at, direction=direction, row_height=height) + ax, ay = layout["anchor"] + total_w, total_h = layout["width"], layout["height"] + prims: list[Prim] = [] + for index in range(len(layout["cells"]) + 1): + y = ay + index * layout["row_height"] + prims.append(Line(p1=(ax, y), p2=(ax + total_w, y))) + x = ax + prims.append(Line(p1=(x, ay), p2=(x, ay + total_h))) + for width in layout["widths"]: + x += width + prims.append(Line(p1=(x, ay), p2=(x, ay + total_h))) + pad = min(1.0, layout["row_height"] * 0.15) + for row_index, cells in enumerate(layout["cells"]): + # cells are top-to-bottom; the top row is the last one up the sheet + baseline = ay + (len(layout["cells"]) - row_index - 1) * layout["row_height"] + pad + x = ax + for column_index, value in enumerate(cells): + if value: + prims.append(Text(at=(x + pad, baseline), text=value, height=TEXT_HEIGHT)) + x += layout["widths"][column_index] + return tuple(prims) + + +def balloon_prims( + item: int, + at: Pt, + leader_to: Pt, + *, + radius: float = 4.0, + height: float | None = None, +) -> tuple[Prim, ...]: + """One ISO 6433 item reference: the balloon, the leader, the dot, the numeral. + + The numeral is 1.4 * `radius` high. ISO 6433 asks for at least twice the + dimension character height, so a sheet dimensioned at 3.5 mm wants + `radius=5.0` (a 7 mm numeral); the 4 mm default clears 2.5 mm dimensions. + """ + number = int(item) + if number <= 0: + raise ValueError(f"an ISO 6433 item reference is a positive integer; got {item!r}") + if radius <= 0: + raise ValueError(f"balloon radius must be positive; got {radius!r}") + text_height = float(radius) * 1.4 if height is None else float(height) + cx, cy = float(at[0]), float(at[1]) + tx, ty = float(leader_to[0]), float(leader_to[1]) + distance = math.hypot(tx - cx, ty - cy) + if distance <= radius: + raise ValueError( + f"balloon {number}: the leader target ({tx}, {ty}) is inside the balloon at " + f"({cx}, {cy}) r={radius}; ISO 6433 puts the reference outside the item" + ) + ux, uy = (tx - cx) / distance, (ty - cy) / distance + label = str(number) + return ( + Circle(center=(cx, cy), radius=float(radius)), + Line(p1=(cx + ux * radius, cy + uy * radius), p2=(tx, ty)), + Circle(center=(tx, ty), radius=BALLOON_DOT_RADIUS), + Text( + at=(cx - 0.3 * text_height * len(label), cy - 0.5 * text_height), + text=label, + height=text_height, + ), + ) + + +#: Block attributes read when an INSERT carries no ACADMCP_MECH payload. +_ATTRIBUTE_KEYS = { + "designation": ("DESIGNATION", "DESIG", "NAME", "TAG"), + "standard": ("STANDARD", "STD", "NORM"), + "material": ("MATERIAL", "MAT"), + "size": ("SIZE",), + "qty": ("QTY", "QUANTITY"), + "remarks": ("REMARKS", "NOTE"), +} + + +def _from_attributes(attributes: dict) -> dict: + upper = {str(k).upper(): v for k, v in (attributes or {}).items()} + record: dict = {} + for field, keys in _ATTRIBUTE_KEYS.items(): + for key in keys: + if key in upper and str(upper[key]).strip(): + record[field] = str(upper[key]).strip() + break + return record + + +async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | None = None): + """Walk the INSERTs and return one parts-list record per block reference. + + Reads only: the entity listing, each INSERT's attributes and its + ACADMCP_MECH payload. Nothing is written, so this is safe on a drawing that + is not ours. `source` is "xdata" when the payload supplied the row and + "attributes" when the block's ATTRIBs did; a block with neither is skipped + rather than guessed at. + + `limit` is None by default and the whole drawing is read, by paging + `entity_list` rather than taking its 200-entity default: a parts list that + stops at the 200th INSERT does not print a shorter list, it prints a wrong + QTY, and nothing on the sheet says so. A caller that wants a cap passes one + and reads `len(records)` back against it -- `bom_extract` asks for one more + record than the cap so it can report `truncated` exactly. + """ + if limit is not None and int(limit) <= 0: + raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}") + inserts = await all_entities(backend, type_filter="INSERT", layer=layer) + records: list[dict] = [] + for entity in inserts: + payload: dict = {} + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + payload = {} + if payload.get("kind") != "std_part": + payload = {} + # Measured on both engines: `block_get_attributes` returns the flat + # ``{tag: value}`` mapping itself, not ``{"attributes": {...}}`` + # (ezdxf_backend.py:6687 builds it from ``ent.attribs``, com_backend.py:5703 + # from ``ref.GetAttributes()``). Reading a wrapper key here would have + # made every attribute fallback silently empty. + attributes = {} + try: + attributes = await backend.block_get_attributes(entity.handle) + except Exception: # noqa: BLE001 - a block with no ATTRIBs is not an error here + attributes = {} + fallback = _from_attributes(attributes) + designation = str(payload.get("designation") or fallback.get("designation") or "").strip() + if not designation: + continue + records.append( + { + "handle": entity.handle, + "block": entity.properties.get("block_name", ""), + "layer": entity.layer, + "designation": designation, + "standard": str(payload.get("standard") or fallback.get("standard") or ""), + "size": str(payload.get("size") or fallback.get("size") or ""), + "material": str(payload.get("material") or fallback.get("material") or ""), + "mass": str(payload.get("mass") or ""), + "remarks": str(payload.get("remarks") or fallback.get("remarks") or ""), + "qty": int(payload.get("qty") or fallback.get("qty") or 1), + "source": "xdata" if payload else "attributes", + } + ) + if limit is not None and len(records) >= int(limit): + break + return tuple(records) + + +async def draw_bom_table( + backend: AutoCADBackend, + rows, + *, + at: Pt, + direction: str = "up", + columns=None, + row_height: float = ROW_HEIGHT, + layer: str = SHEET_TEXT_LAYER, + layout: str | None = None, +) -> dict: + """Draw the parts list as a real TABLE entity. + + `entity_create_table` grows downwards from its insertion point, so an + upward-growing ISO 7573 list is drawn from an insertion one table height + above the anchor, with the heading as the last (bottom) row. + + `representation` is reported rather than assumed: the live engine draws a + real ACAD_TABLE, while the headless one returns a *composite* whose + ``handle`` is its first child rule (``tests/test_table_leader.py`` pins + that contract). A caller that means to edit the table afterwards has to + know which of the two it got. + """ + if not rows: + raise ValueError("bom_table: there are no parts-list rows to draw") + spec = table_layout(rows, at=at, direction=direction, columns=columns, row_height=row_height) + previous = await enter_layout(backend, layout, caller="bom_table") + try: + entity = await backend.entity_create_table( + spec["insert"][0], + spec["insert"][1], + spec["cells"], + headers=None, + column_widths=spec["widths"], + row_height=spec["row_height"], + text_height=TEXT_HEIGHT, + layer=layer, + ) + finally: + if layout is not None: + await backend.layout_set_current(previous) + ax, ay = spec["anchor"] + return { + "ok": True, + "handle": entity.handle, + "entity_type": entity.type, + "representation": entity.properties.get("representation", "native"), + "child_handles": list(entity.properties.get("child_handles") or []), + "standard": "ISO 7573", + "direction": direction, + "columns": list(spec["columns"]), + "rows": len(rows), + "insert": spec["insert"], + "layout": layout or "Model", + "bbox": {"min": [ax, ay], "max": [ax + spec["width"], ay + spec["height"]]}, + } + + +async def read_balloons(backend: AutoCADBackend): + """Every ISO 6433 balloon this server drew in the CURRENT space, from its + ACADMCP_MECH payload. + + Paged, not capped: a drawing whose first 200 CIRCLEs are a hole pattern -- + routine -- used to read back no balloons at all, which made every guard in + `add_balloon` silently pass. + """ + found: list[dict] = [] + for entity in await all_entities(backend, type_filter="CIRCLE"): + try: + raw = await backend.entity_get_xdata(entity.handle, APP_ID) + payload = decode(raw.get("xdata", {}).get(APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + continue + if payload.get("kind") != "balloon": + continue + found.append( + { + "handle": entity.handle, + "item": int(payload.get("item", 0)), + "targets": list(payload.get("targets") or []), + "text": payload.get("text", ""), + } + ) + found.sort(key=lambda row: row["item"]) + return tuple(found) + + +async def add_balloon( + backend: AutoCADBackend, + *, + item: int, + at: Pt, + leader_to: Pt, + targets=(), + radius: float = 4.0, + layer: str = "DIM", + layout: str | None = None, +) -> dict: + """Draw (or renumber) one ISO 6433 item reference. + + A balloon already pointing at the same targets is *renumbered* -- its + numeral is edited and its payload rewritten -- so running the balloon pass + again after the parts list is regrouped does not litter the sheet with + duplicates. An item number already on the sheet is refused before anything + is drawn unless it is that renumber: ISO 129-1 and ISO 6433 both want one + reference per item. + + Both guards read the space the balloon is going ON. `read_balloons` lists + the current space, so reading it before `enter_layout` inspected Model + while the balloons lived on the layout -- and the layout is the sheet + workflow, so the guards were off exactly where they matter. + """ + number = int(item) + wanted = tuple(sorted(str(t) for t in targets)) + prims = balloon_prims(number, at, leader_to, radius=radius) # validates first + + previous = await enter_layout(backend, layout, caller="balloon_add") + try: + existing = await read_balloons(backend) + for balloon in existing: + if wanted and tuple(sorted(balloon["targets"])) == wanted: + if balloon["text"]: + await backend.entity_edit_text(balloon["text"], text=str(number)) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": balloon["text"], + } + await backend.entity_set_xdata(balloon["handle"], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": True, + "item": number, + "handle": balloon["handle"], + "text": balloon["text"], + "targets": list(wanted), + "layout": layout or "Model", + } + # Anything still carrying this number is a clash: the renumber arm above + # has already returned for the one balloon that stands for these + # targets. Comparing the target tuples here instead let two untargeted + # balloons -- `targets` is optional in the tool, so `() != ()` -- carry + # the same item number with no refusal at all. + clash = next((b for b in existing if b["item"] == number), None) + if clash is not None: + has_targets = f"targets {clash['targets']}" if clash["targets"] else "no targets" + raise ValueError( + f"item reference {number} is already used by balloon {clash['handle']} " + f"({has_targets}); ISO 6433 gives each item one reference. Pass that " + "balloon's targets to renumber it in place, or use a free number." + ) + + handles = await draw_sheet_prims( + backend, prims, layer_map={"visible": layer, "text": layer, "center": layer} + ) + payload = { + "v": PAYLOAD_VERSION, + "kind": "balloon", + "item": number, + "targets": list(wanted), + "text": handles[3], + } + await backend.entity_set_xdata(handles[0], APP_ID, to_values(payload)) + return { + "ok": True, + "renumbered": False, + "item": number, + "handle": handles[0], + "leader": handles[1], + "dot": handles[2], + "text": handles[3], + "targets": list(wanted), + "layout": layout or "Model", + } + finally: + if layout is not None: + await backend.layout_set_current(previous) diff --git a/engineering/sheet/extract.py b/engineering/sheet/extract.py new file mode 100644 index 0000000..be25a81 --- /dev/null +++ b/engineering/sheet/extract.py @@ -0,0 +1,81 @@ +"""Parts-list rows to a file: CSV always, XLSX when openpyxl is installed. + +CSV needs nothing but the standard library, so the deliverable is never blocked +-- which is exactly why `xlsx_write` can be a capability rather than a hard +dependency. The refusal names both the missing package and the format that +always works. +""" + +from __future__ import annotations + +import csv +from importlib.util import find_spec +from pathlib import Path + +from backends.capability import UnsupportedCapabilityError +from engineering.sheet.bom import BOM_COLUMNS, COLUMN_LABELS, DEFAULT_COLUMNS + +__all__ = ["EXTRACT_FORMATS", "write_rows", "xlsx_available"] + +EXTRACT_FORMATS = ("csv", "xlsx") + + +def xlsx_available() -> bool: + """Is openpyxl importable right now? Re-evaluated per call, so installing + the package changes the answer without a restart.""" + return find_spec("openpyxl") is not None + + +def _prepare(rows, columns): + chosen = tuple(DEFAULT_COLUMNS if columns is None else columns) + unknown = [c for c in chosen if c not in BOM_COLUMNS] + if unknown: + raise ValueError( + f"unknown parts-list column(s) {', '.join(unknown)}; " + f"available: {', '.join(BOM_COLUMNS)}" + ) + body = [[str(row.get(column, "")) for column in chosen] for row in rows] + if not body: + raise ValueError("data_extract: there are no parts-list rows to write") + return chosen, [COLUMN_LABELS[c] for c in chosen], body + + +def write_rows(rows, path: str, *, columns=None, fmt: str = "csv") -> dict: + """Write the rows to `path`. Refuses before the file is created.""" + if fmt not in EXTRACT_FORMATS: + raise ValueError(f"format must be one of {', '.join(EXTRACT_FORMATS)}; got {fmt!r}") + chosen, heading, body = _prepare(rows, columns) + if fmt == "xlsx" and not xlsx_available(): + raise UnsupportedCapabilityError( + "xlsx_write", + "data_extract(format='xlsx') needs openpyxl, which is not installed. " + 'Install it with: pip install openpyxl (or pip install -e ".[full]"), ' + "or ask for format='csv', which always works and carries the same rows.", + ) + + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + if fmt == "csv": + with open(target, "w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(heading) + writer.writerows(body) + else: + from openpyxl import Workbook + + book = Workbook() + sheet = book.active + sheet.title = "Parts list" + sheet.append(heading) + for row in body: + sheet.append(row) + book.save(target) + + return { + "ok": True, + "path": str(target), + "format": fmt, + "columns": list(chosen), + "rows": len(body), + "bytes": target.stat().st_size, + } diff --git a/engineering/sheet/frames.py b/engineering/sheet/frames.py new file mode 100644 index 0000000..e82b977 --- /dev/null +++ b/engineering/sheet/frames.py @@ -0,0 +1,342 @@ +"""ISO 5457 drawing sheets: sizes, margins, frame, zone grid, centring and trim marks. + +SOURCE +------ +Sheet sizes ISO 216:2007, A series, trimmed sizes in millimetres. +Frame and margins ISO 5457:1999, 4.2 -- the drawing frame that bounds the + drawing space leaves a 20 mm filing margin on the binding + edge and 10 mm on the other three, for every size A4 to A0. +Centring marks ISO 5457:1999, 4.3 -- four marks at the middle of each side + of the trimmed sheet, running from the trimmed edge to 5 mm + inside the frame. +Grid reference ISO 5457:1999, 4.4 -- the number of divisions along a side is + an even number obtained from the length of that side of the + frame divided by 50 mm. Columns are numbered from the left, + rows are lettered from the top. +Trimming marks ISO 5457:1999, 4.5 -- one mark in each corner of the trimmed + sheet, two overlapping 10 mm x 5 mm rectangles. + +COVERAGE: A4, A3, A2, A1, A0. The B series, the elongated sizes (A3.1, A4.2, +...) and the untrimmed sizes are not transcribed; `sheet_size` refuses an +unknown name and lists the five it has. + +NOT IMPLEMENTED, deliberately, so nobody reads more into the geometry than is +in it: + +* ISO 5457's allowance for shorter *corner* fields in the grid. The divisions + drawn here are equal (frame side / n). The division COUNT -- the number a + zone reference quotes, and the thing that is visible on the sheet -- is the + standard's; the two end fields' boundary sits up to 5 mm from where a + corner-field layout would put it. +* The grid is drawn for A4 too, although ISO 5457 does not require one there. + Pass `zones=False`. +""" + +from __future__ import annotations + +import string +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "CENTRING_OVERSHOOT", + "EDGE_MARGIN", + "FILING_MARGIN", + "ORIENTATIONS", + "SHEETS", + "SHEET_LAYER", + "SHEET_ROLE_LAYER", + "SHEET_TEXT_LAYER", + "TRIM_MARK_LONG", + "TRIM_MARK_SHORT", + "ZONE_DIVISIONS", + "ZONE_MODULE", + "ZONE_TEXT_HEIGHT", + "centred_text", + "draw_sheet_frame", + "draw_sheet_prims", + "enter_layout", + "frame_metrics", + "frame_prims", + "sheet_size", + "zone_divisions", +] + +#: Portrait width x height in millimetres (ISO 216). Landscape swaps the pair. +SHEETS: dict[str, tuple[float, float]] = { + "A4": (210.0, 297.0), + "A3": (297.0, 420.0), + "A2": (420.0, 594.0), + "A1": (594.0, 841.0), + "A0": (841.0, 1189.0), +} + +FILING_MARGIN = 20.0 +EDGE_MARGIN = 10.0 +ZONE_MODULE = 50.0 +ZONE_TEXT_HEIGHT = 3.5 +CENTRING_OVERSHOOT = 5.0 +TRIM_MARK_LONG = 10.0 +TRIM_MARK_SHORT = 5.0 + +#: The sheet is not part geometry: borders, rules and marks go on TITLEBLOCK and +#: the lettering on TEXT, which is where `titleblock_apply_iso_a3` has always put +#: them and what `tests/test_engineering_titleblock.py` pins. +SHEET_LAYER = "TITLEBLOCK" +SHEET_TEXT_LAYER = "TEXT" + +SHEET_ROLE_LAYER: dict[str, str] = { + "visible": SHEET_LAYER, + "center": SHEET_LAYER, + "text": SHEET_TEXT_LAYER, +} + +#: (divisions along the long side, divisions along the short side), ISO 5457 4.4. +#: Each row reproduces the even-division rule applied to that side of the frame +#: -- `test_zone_division_counts_match_the_authored_table` asserts both, so the +#: table and the rule can never drift apart. +ZONE_DIVISIONS: dict[str, tuple[int, int]] = { + "A4": (6, 4), + "A3": (8, 6), + "A2": (12, 8), + "A1": (16, 12), + "A0": (24, 16), +} + +ORIENTATIONS = ("landscape", "portrait") +_LETTERS = string.ascii_uppercase + + +def sheet_size(size: str, orientation: str = "landscape") -> tuple[float, float]: + """Trimmed width x height in millimetres for a sheet name and orientation.""" + key = str(size).strip().upper() + if key not in SHEETS: + raise ValueError( + f"ISO 5457 sheet {size!r} is not in the table; covered sizes: {', '.join(SHEETS)}" + ) + if orientation not in ORIENTATIONS: + raise ValueError(f"orientation must be one of {ORIENTATIONS}, got {orientation!r}") + w, h = SHEETS[key] + return (h, w) if orientation == "landscape" else (w, h) + + +def zone_divisions(side: float) -> int: + """ISO 5457 4.4: an even number of divisions, `side` / 50 mm rounded to it. + + `round()` is banker's at an exact .5; no frame side of A4..A0 lands there + (the closest is A2 landscape's 400 / 100 = 4.0, which is exact), and a side + that did would round down -- the safer half of the ambiguity, because it + makes the fields longer rather than shorter. + """ + return max(int(2 * round(float(side) / (2.0 * ZONE_MODULE))), 2) + + +def frame_metrics(size: str, *, orientation: str = "landscape") -> dict: + """Every number the frame is built from, for a caller that wants to place + geometry inside it without re-deriving the margins.""" + w, h = sheet_size(size, orientation) + x0, y0 = FILING_MARGIN, EDGE_MARGIN + x1, y1 = w - EDGE_MARGIN, h - EDGE_MARGIN + columns = zone_divisions(x1 - x0) + rows = zone_divisions(y1 - y0) + if rows > len(_LETTERS): + raise ValueError(f"{size}: {rows} grid rows need more than {len(_LETTERS)} letters") + return { + "size": str(size).strip().upper(), + "orientation": orientation, + "sheet": [w, h], + "margins": { + "left": FILING_MARGIN, + "right": EDGE_MARGIN, + "top": EDGE_MARGIN, + "bottom": EDGE_MARGIN, + }, + "frame": [x0, y0, x1, y1], + "drawing_area": [x1 - x0, y1 - y0], + "centre": [w / 2.0, h / 2.0], + "zones": { + "columns": columns, + "rows": rows, + "column_width": (x1 - x0) / columns, + "row_height": (y1 - y0) / rows, + "numbers": [str(i + 1) for i in range(columns)], + "letters": [_LETTERS[i] for i in range(rows)], + }, + } + + +def _rect(x0: float, y0: float, x1: float, y1: float, role: str = "visible") -> Poly: + return Poly(points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)), closed=True, role=role) + + +def centred_text(cx: float, cy: float, text: str, height: float) -> Text: + """`entity_create_text` carries no alignment parameter, so centring is done + by offsetting the left-baseline insertion point: 0.6 * height of advance per + character (the txt.shx / ISO 3098 width factor) and half a cap height.""" + return Text(at=(cx - 0.3 * height * len(text), cy - 0.5 * height), text=text, height=height) + + +def frame_prims( + size: str, + *, + orientation: str = "landscape", + zones: bool = True, + marks: bool = True, +) -> tuple[Prim, ...]: + """The ISO 5457 sheet as primitive descriptions, sheet-local, with (0, 0) at + the lower-left corner of the trimmed sheet. + + Order is pinned: [0] the trimmed sheet, [1] the drawing frame, then the zone + ticks, then the zone labels, then the four centring marks, then the eight + trimming rectangles. + """ + metrics = frame_metrics(size, orientation=orientation) + w, h = metrics["sheet"] + x0, y0, x1, y1 = metrics["frame"] + prims: list[Prim] = [_rect(0.0, 0.0, w, h), _rect(x0, y0, x1, y1)] + + if zones: + zone = metrics["zones"] + cw, rh = zone["column_width"], zone["row_height"] + for i in range(1, zone["columns"]): + x = x0 + i * cw + prims.append(Line(p1=(x, y1), p2=(x, h))) + prims.append(Line(p1=(x, 0.0), p2=(x, y0))) + for j in range(1, zone["rows"]): + y = y0 + j * rh + prims.append(Line(p1=(0.0, y), p2=(x0, y))) + prims.append(Line(p1=(x1, y), p2=(w, y))) + for i, label in enumerate(zone["numbers"]): + cx = x0 + (i + 0.5) * cw + prims.append(centred_text(cx, y1 + EDGE_MARGIN / 2.0, label, ZONE_TEXT_HEIGHT)) + prims.append(centred_text(cx, y0 / 2.0, label, ZONE_TEXT_HEIGHT)) + for j, label in enumerate(zone["letters"]): + # ISO 5457 4.4: rows are lettered from the top. + cy = y1 - (j + 0.5) * rh + prims.append(centred_text(FILING_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT)) + prims.append(centred_text(x1 + EDGE_MARGIN / 2.0, cy, label, ZONE_TEXT_HEIGHT)) + + if marks: + cx, cy = metrics["centre"] + prims.append(Line(p1=(0.0, cy), p2=(x0 + CENTRING_OVERSHOOT, cy), role="center")) + prims.append(Line(p1=(w, cy), p2=(x1 - CENTRING_OVERSHOOT, cy), role="center")) + prims.append(Line(p1=(cx, 0.0), p2=(cx, y0 + CENTRING_OVERSHOOT), role="center")) + prims.append(Line(p1=(cx, h), p2=(cx, y1 - CENTRING_OVERSHOOT), role="center")) + for sx, ox in ((1.0, 0.0), (-1.0, w)): + for sy, oy in ((1.0, 0.0), (-1.0, h)): + prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_LONG, oy + sy * TRIM_MARK_SHORT)) + prims.append(_rect(ox, oy, ox + sx * TRIM_MARK_SHORT, oy + sy * TRIM_MARK_LONG)) + + return tuple(prims) + + +async def enter_layout(backend: AutoCADBackend, layout: str | None, *, caller: str) -> str: + """Make `layout` current and return the space to restore afterwards. + + A sheet border frames the printed sheet rather than the model, so every + sheet tool takes a layout; asking for a border must not silently leave the + caller standing on it, which is why the caller's space comes back. + """ + if layout is None: + return "Model" + listing = await backend.layout_list() + names = {str(entry) for entry in (listing.get("layouts") or [])} + if layout not in names: + raise RuntimeError( + f"{caller}: layout {layout!r} does not exist " + f"(have: {', '.join(sorted(names))}). Create it with layout_create first." + ) + # The caller's tab comes from the listing that was just read -- both engines + # report it there (ezdxf from $TILEMODE / the active layout, COM from + # ``doc.ActiveLayout.Name``). A private attribute would not: only + # EzdxfBackend has ``_current_space``, so reading it left a live caller + # standing on Model after every border. + previous = str(listing.get("current") or "Model") + switched = await backend.layout_set_current(layout) + if not switched.get("ok", True): + raise RuntimeError(f"{caller}: {switched.get('error')}") + return previous + + +async def draw_sheet_prims( + backend: AutoCADBackend, + prims, + *, + origin: Pt = (0.0, 0.0), + layer_map: dict | None = None, +) -> list[str]: + """Draw sheet primitives at `origin` and return their handles, in order.""" + ox, oy = float(origin[0]), float(origin[1]) + layers = dict(SHEET_ROLE_LAYER if layer_map is None else layer_map) + handles: list[str] = [] + for prim in prims: + layer = layers.get(prim.role, SHEET_LAYER) + if isinstance(prim, Poly): + entity = await backend.entity_create_polyline( + points=[[ox + px, oy + py] for px, py in prim.points], + closed=prim.closed, + layer=layer, + ) + elif isinstance(prim, Line): + entity = await backend.entity_create_line( + ox + prim.p1[0], oy + prim.p1[1], ox + prim.p2[0], oy + prim.p2[1], layer=layer + ) + elif isinstance(prim, Circle): + entity = await backend.entity_create_circle( + ox + prim.center[0], oy + prim.center[1], prim.radius, layer=layer + ) + elif isinstance(prim, Text): + entity = await backend.entity_create_text( + text=prim.text, + x=ox + prim.at[0], + y=oy + prim.at[1], + height=prim.height, + rotation=prim.rotation, + layer=layer, + ) + else: + raise ValueError( + f"sheet primitives are Line, Circle, Poly and Text; got {type(prim).__name__}" + ) + handles.append(entity.handle) + return handles + + +async def draw_sheet_frame( + backend: AutoCADBackend, + size: str, + *, + orientation: str = "landscape", + zones: bool = True, + marks: bool = True, + origin: Pt = (0.0, 0.0), + layout: str | None = None, +) -> dict: + """Draw the ISO 5457 frame. Every refusal fires before the first entity.""" + metrics = frame_metrics(size, orientation=orientation) + prims = frame_prims(size, orientation=orientation, zones=zones, marks=marks) + previous = await enter_layout(backend, layout, caller="sheet_frame") + try: + handles = await draw_sheet_prims(backend, prims, origin=origin) + finally: + if layout is not None: + await backend.layout_set_current(previous) + ox, oy = float(origin[0]), float(origin[1]) + w, h = metrics["sheet"] + return { + "ok": True, + "size": metrics["size"], + "orientation": orientation, + "layout": layout or "Model", + "zones": zones, + "marks": marks, + "handles": handles, + "outer_border": handles[0], + "inner_border": handles[1], + "metrics": metrics, + "bbox": {"min": [ox, oy], "max": [ox + w, oy + h]}, + } diff --git a/engineering/sheet/revision.py b/engineering/sheet/revision.py new file mode 100644 index 0000000..005add9 --- /dev/null +++ b/engineering/sheet/revision.py @@ -0,0 +1,396 @@ +"""The revision block, revision clouds and the triangular revision tags. + +SOURCE +------ +ISO 7200:2004 lists `version` (the revision code) among the title block's data +fields, and a drawing that has been revised records each version with its +description, date and issuer. ISO 7200 does NOT standardise the revision +block's geometry, so the block here reuses the ISO 7573 item-list ruling: the +same 180 mm width as the title block and the same 7 mm rows. That is a layout +decision, and it is written down here rather than asserted as a standard. + +The triangular revision tag and the revision cloud are drawing-office practice, +not ISO geometry: the cloud marks the changed region and the triangle carries +the revision code that the block's row explains. `entity_create_revcloud` draws +the cloud, so a revision *with* clouds is refused up front on an engine that +has no revcloud (the COM backend: `revcloud` is false there, no ActiveX member, +command-line only) rather than half-written. + +Placement: the block defaults to the upper-right corner of the drawing frame +and grows downwards, so it does not fight the ISO 7573 item list for the space +directly above the title block. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +from backends.xdata_specs import MAX_BYTES, app_size +from engineering.mech.primitives import Line, Poly, Prim, Pt, Text +from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT, TEXT_HEIGHT, all_entities +from engineering.sheet.frames import ( + SHEET_LAYER, + draw_sheet_prims, + enter_layout, + frame_metrics, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "REVISION_COLUMNS", + "REVISION_LABELS", + "REVISION_SHARES", + "REV_APP_ID", + "REV_PAYLOAD_VERSION", + "REV_RE", + "TAG_SIDE", + "add_revision", + "read_revisions", + "rev_payload", + "rev_values", + "revision_block_prims", + "revision_heading_prims", + "revision_row_prims", + "revision_tag_prims", +] + +REVISION_COLUMNS: tuple[str, ...] = ("rev", "description", "date", "by") +REVISION_LABELS: dict[str, str] = { + "rev": "REV", + "description": "DESCRIPTION", + "date": "DATE", + "by": "BY", +} +#: Widths in millimetres; they total the 180 mm the title block is wide. +REVISION_SHARES: dict[str, float] = { + "rev": 16.0, + "description": 96.0, + "date": 40.0, + "by": 28.0, +} +TAG_SIDE = 7.0 +TAG_TEXT_HEIGHT = 3.5 + +#: A revision code: one to three capitals or digits (ISO 7200's `version` +#: field is free text; this is the shop's form and keeps the tag legible). +REV_RE = re.compile(r"^[A-Z0-9]{1,3}$") + + +def _check_rev(rev: str) -> str: + code = str(rev).strip().upper() + if not REV_RE.fullmatch(code): + raise ValueError( + f"revision code {rev!r} must be one to three capitals or digits (A, B, 01, ...)" + ) + return code + + +def revision_tag_prims(rev: str, at: Pt, *, side: float = TAG_SIDE) -> tuple[Prim, ...]: + """An equilateral triangle carrying the revision code, apex up, `at` its + lower-left corner.""" + code = _check_rev(rev) + if side <= 0: + raise ValueError(f"revision tag side must be positive; got {side!r}") + x, y = float(at[0]), float(at[1]) + height = side * (3.0**0.5) / 2.0 + triangle = Poly( + points=((x, y), (x + side, y), (x + side / 2.0, y + height)), + closed=True, + ) + label = Text( + at=(x + side / 2.0 - 0.3 * TAG_TEXT_HEIGHT * len(code), y + height * 0.25), + text=code, + height=TAG_TEXT_HEIGHT, + ) + return (triangle, label) + + +def _band(cells: dict, *, ax: float, top: float, height: float) -> list[Prim]: + """One horizontal band: its bottom rule, its five verticals and its text. + + The band's top rule belongs to the band above it (or to + `revision_heading_prims`), so bands stack without drawing a rule twice -- + which is what lets `add_revision` append one band at a time and still + produce the block `revision_block_prims` describes. + """ + bottom = top - height + widths = [REVISION_SHARES[c] for c in REVISION_COLUMNS] + prims: list[Prim] = [Line(p1=(ax, bottom), p2=(ax + LIST_WIDTH, bottom))] + x = ax + prims.append(Line(p1=(x, top), p2=(x, bottom))) + for width in widths: + x += width + prims.append(Line(p1=(x, top), p2=(x, bottom))) + pad = min(1.0, height * 0.15) + x = ax + for column_index, column in enumerate(REVISION_COLUMNS): + value = str(cells.get(column, "")) + if value: + prims.append(Text(at=(x + pad, bottom + pad), text=value, height=TEXT_HEIGHT)) + x += widths[column_index] + return prims + + +def revision_heading_prims(*, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]: + """The block's top rule and its heading band. `at` is the TOP-left corner.""" + if height <= 0: + raise ValueError(f"row height must be positive; got {height!r}") + ax, ay = float(at[0]), float(at[1]) + heading = {c: REVISION_LABELS[c] for c in REVISION_COLUMNS} + return ( + Line(p1=(ax, ay), p2=(ax + LIST_WIDTH, ay)), + *_band(heading, ax=ax, top=ay, height=height), + ) + + +def revision_row_prims( + row: dict, *, at: Pt, index: int, height: float = ROW_HEIGHT +) -> tuple[Prim, ...]: + """One revision band, `index` rows below the heading. Its first Text is the + REV cell -- the primitive `add_revision` hangs the payload on.""" + if height <= 0: + raise ValueError(f"row height must be positive; got {height!r}") + if int(index) < 0: + raise ValueError(f"row index must be zero or more; got {index!r}") + ax, ay = float(at[0]), float(at[1]) + return tuple(_band(row, ax=ax, top=ay - (int(index) + 1) * height, height=height)) + + +#: The revision row's own XDATA application. +#: +#: `engineering/mech/xdata.py::KINDS` is pinned to ("part", "std_part", +#: "balloon") and `engineering/mech/critique.py` scans ACADMCP_MECH for exactly +#: those three on INSERTs and CIRCLEs. A revision row is neither, so it carries +#: its own application rather than a fourth kind that `encode` would refuse. +#: The chunking is the same rule the mech codec follows -- ASCII JSON split at +#: 255 characters -- and `entity_set_xdata(handle, app_name, values)` supplies +#: the 1001 application tag itself, so only the chunks travel. +REV_APP_ID = "ACADMCP_SHEET_REV" +REV_PAYLOAD_VERSION = 1 +_REV_CHUNK = 255 + + +def rev_values(payload: dict) -> list[str]: + """The chunk strings for one revision record. Refused before anything is written.""" + if not isinstance(payload, dict): + raise TypeError(f"{REV_APP_ID}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", REV_PAYLOAD_VERSION) + if version != REV_PAYLOAD_VERSION: + raise ValueError( + f"{REV_APP_ID}: payload version {version!r}; this build writes and reads " + f"version {REV_PAYLOAD_VERSION}" + ) + text = json.dumps( + {**payload, "v": REV_PAYLOAD_VERSION}, + separators=(",", ":"), + sort_keys=True, + ensure_ascii=True, + ) + values = [text[i : i + _REV_CHUNK] for i in range(0, len(text), _REV_CHUNK)] + size = app_size(REV_APP_ID, [(1000, value) for value in values]) + if size > MAX_BYTES: + raise ValueError( + f"{REV_APP_ID}: the record encodes to {size} bytes; the limit is 16 KB per " + "entity across every application -- shorten the description" + ) + return values + + +def rev_payload(values) -> dict: + """The record back, or a ValueError. Never a plausible-looking guess.""" + parts = [value if isinstance(value, str) else str(value[1]) for value in values] + try: + payload = json.loads("".join(parts)) + except json.JSONDecodeError as exc: + raise ValueError(f"{REV_APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc + if not isinstance(payload, dict): + raise ValueError( + f"{REV_APP_ID}: the payload decoded to {type(payload).__name__}, not a dict" + ) + if payload.get("v") != REV_PAYLOAD_VERSION: + raise ValueError(f"{REV_APP_ID}: payload version {payload.get('v')!r} is not readable here") + return payload + + +def revision_block_prims(rows, *, at: Pt, height: float = ROW_HEIGHT) -> tuple[Prim, ...]: + """The whole revision block: `at` is its TOP-left corner, the heading is + the first band and the revisions follow downwards in the order given.""" + prims: list[Prim] = list(revision_heading_prims(at=at, height=height)) + for index, row in enumerate(rows): + prims.extend(revision_row_prims(row, at=at, index=index, height=height)) + return tuple(prims) + + +async def read_revisions(backend: AutoCADBackend): + """Every revision row this server wrote in the CURRENT space, from its + ACADMCP_SHEET_REV payload. + + Paged, not capped: `entity_list`'s 200-entity default made a lettered + drawing read back no revisions at all, which both broke the idempotency + guard and restarted `index = len(existing)` at 0 -- a second heading band + and a second row drawn exactly on top of the first. + """ + found: list[dict] = [] + for entity in await all_entities(backend, type_filter="TEXT"): + try: + raw = await backend.entity_get_xdata(entity.handle, REV_APP_ID) + payload = rev_payload(raw.get("xdata", {}).get(REV_APP_ID, [])) or {} + except (ValueError, KeyError, TypeError): + continue + if not payload.get("rev"): + continue + found.append( + { + "handle": entity.handle, + "rev": str(payload.get("rev", "")), + "description": str(payload.get("description", "")), + "date": str(payload.get("date", "")), + "by": str(payload.get("by", "")), + } + ) + found.sort(key=lambda row: row["rev"]) + return tuple(found) + + +async def add_revision( + backend: AutoCADBackend, + *, + rev: str, + description: str, + date: str = "", + by: str = "", + clouds=(), + tags=(), + size: str = "A3", + orientation: str = "landscape", + origin: Pt = (0.0, 0.0), + at: Pt | None = None, + cloud_segment: float = 8.0, + layout: str | None = None, +) -> dict: + """Append one revision row, and optionally cloud and tag what changed. + + Idempotent per revision code: a code the drawing already carries returns + `created=False` and draws nothing at all, so re-running a revision pass + cannot stack duplicate rows. Refusals fire before the first entity: a + malformed code, a cloud region that is not four finite numbers, and -- on + an engine whose `revcloud` capability is false -- any request for clouds. + + The idempotency guard reads the space the row is going ON. `read_revisions` + lists the current space, so reading it before `enter_layout` inspected + Model while the block lived on the layout, and every re-run of a + `layout=`-scoped revision pass stacked a second heading band and a second + row exactly on top of the first. + """ + code = _check_rev(rev) + regions = [[float(v) for v in region] for region in clouds] + for index, region in enumerate(regions): + if len(region) != 4: + raise ValueError(f"clouds[{index}] must be [x0, y0, x1, y1]; got {clouds[index]!r}") + points = [[float(p[0]), float(p[1])] for p in tags] + if regions: + feature = backend.capabilities().features.get("revcloud") + if feature is None or not feature.supported: + from backends.base import UnsupportedCapabilityError + + raise UnsupportedCapabilityError( + "revcloud", + f"add_revision: the {backend.name} backend cannot draw revision clouds " + "(no ActiveX member; REVCLOUD is command-line only). Re-run without " + "`clouds` to record the row, or draw the sheet on the ezdxf engine.", + ) + + previous = await enter_layout(backend, layout, caller="revision_add") + try: + existing = await read_revisions(backend) + match = next((row for row in existing if row["rev"] == code), None) + if match is not None: + return { + "ok": True, + "created": False, + "rev": code, + "handle": match["handle"], + "row": len(existing), + "clouds": [], + "tags": [], + "layout": layout or "Model", + } + + index = len(existing) + new_row = {"rev": code, "description": description, "date": date, "by": by} + metrics = frame_metrics(size, orientation=orientation) + _x0, _y0, x1, y1 = metrics["frame"] + anchor = ( + (float(origin[0]) + x1 - LIST_WIDTH, float(origin[1]) + y1) + if at is None + else (float(at[0]), float(at[1])) + ) + # Append-only: the heading band is drawn once, on the first revision, and + # every later revision adds exactly one band below the last. Nothing is + # redrawn, so a revision can never stack a second copy of the block. + head_prims = list(revision_heading_prims(at=anchor)) if index == 0 else [] + row_prims = list(revision_row_prims(new_row, at=anchor, index=index)) + + block_handles = await draw_sheet_prims(backend, head_prims + row_prims) + cloud_handles = [] + for region in regions: + x0, y0, x1r, y1r = region + cloud = await backend.entity_create_revcloud( + [[x0, y0], [x1r, y0], [x1r, y1r], [x0, y1r]], + cloud_segment, + layer=SHEET_LAYER, + closed=True, + ) + cloud_handles.append(cloud.get("handle")) + # One entry per tag *asked for*, not per entity drawn: a tag is two + # primitives (the triangle and its letter), so a flat handle list would + # report twice as many tags as the caller requested. + tag_handles = [] + for point in points: + drawn = await draw_sheet_prims(backend, revision_tag_prims(code, (point[0], point[1]))) + tag_handles.append({"at": [point[0], point[1]], "handles": drawn}) + + # The payload rides on the row band's first Text primitive, which is its + # REV cell -- `rev` is validated non-empty, so that cell is always drawn. + row_handles = block_handles[len(head_prims) :] + row_handle = next( + handle + for handle, prim in zip(row_handles, row_prims, strict=True) + if isinstance(prim, Text) + ) + await backend.entity_set_xdata( + row_handle, + REV_APP_ID, + rev_values( + { + "v": REV_PAYLOAD_VERSION, + "rev": code, + "description": description, + "date": date, + "by": by, + } + ), + ) + + return { + "ok": True, + "created": True, + "rev": code, + "handle": row_handle, + "row": index + 1, + "block_handles": block_handles, + "clouds": cloud_handles, + "tags": tag_handles, + "layout": layout or "Model", + "bbox": { + "min": [anchor[0], anchor[1] - ROW_HEIGHT * (index + 2)], + "max": [anchor[0] + LIST_WIDTH, anchor[1]], + }, + } + finally: + if layout is not None: + await backend.layout_set_current(previous) diff --git a/engineering/sheet/titleblock.py b/engineering/sheet/titleblock.py new file mode 100644 index 0000000..1f9e384 --- /dev/null +++ b/engineering/sheet/titleblock.py @@ -0,0 +1,376 @@ +"""ISO 7200 title blocks for every ISO 5457 sheet from A4 to A0. + +SOURCE +------ +Width ISO 7200:2004 -- the title block is 180 mm wide and sits in the + lower right-hand corner of the drawing space, against the + frame. 180 mm is exactly the drawing-space width of a portrait + A4, which is why one block fits every size. +Data fields ISO 7200:2004 -- the mandatory fields are the legal owner + (`company`), the identification number (`drawing_no`), the + date of issue (`date`), the segment/sheet number (`sheet`) and + the title (`title`). The rest carried here are optional + fields: creator (`drawn_by`), approval person (`checked_by`), + version (`revision`), and the supplementary item data this + shop draws (`part_no`, `material`, `scale`, `units`). ISO + 7200's full list of optional fields is NOT transcribed -- only + the fields this block has a cell for. +Projection angle The truncated-cone symbol of ISO 5456-2 / ISO 128-30. ISO's + own figure is paywalled and was NOT read here; the arrangement + is transcribed from reproductions of it, measured, and named + in `projection_symbol_prims`. + +The row heights (20 / 15 / 15 / 10 mm) and the cell splits are this +repository's layout, not ISO 7200 values: the standard fixes the *data*, not +the ruling. They are exactly the ones `titleblock_apply_iso_a3` has drawn since +v1.0, so the A3 sheet this server makes has not moved. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text +from engineering.sheet.frames import ( + draw_sheet_prims, + enter_layout, + frame_metrics, + frame_prims, +) + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +__all__ = [ + "LABEL_HEIGHT", + "LABEL_PAD_X", + "LABEL_PAD_Y", + "PROJECTION_ANGLES", + "ROW1_H", + "ROW2_H", + "ROW3_H", + "ROW4_H", + "ROW4_VALUE_PAD_Y", + "TB_HEIGHT", + "TB_WIDTH", + "TITLEBLOCK_FIELDS", + "TITLEBLOCK_RULE_COUNT", + "TITLE_HEIGHT", + "TITLE_TEXT_INDEX", + "VALUE_FIELDS", + "VALUE_HEIGHT", + "VALUE_PAD_X", + "VALUE_PAD_Y", + "TitleBlockMetadata", + "apply_titleblock", + "projection_symbol_prims", + "titleblock_origin", + "titleblock_prims", + "value_text_indices", +] + +TB_WIDTH = 180.0 +TB_HEIGHT = 60.0 + +ROW1_H = 20.0 # title +ROW2_H = 15.0 # drawing_no | revision | sheet +ROW3_H = 15.0 # part_no | material | scale +ROW4_H = 10.0 # drawn_by | checked_by | date | company + +LABEL_HEIGHT = 2.0 +VALUE_HEIGHT = 3.5 +TITLE_HEIGHT = 5.0 +LABEL_PAD_X = 1.5 +LABEL_PAD_Y = 1.5 +VALUE_PAD_X = 3.0 +VALUE_PAD_Y = 5.0 +ROW4_VALUE_PAD_Y = 1.5 + +PROJECTION_ANGLES = ("first", "third") +PROJECTION_CONE_LENGTH = 8.0 +PROJECTION_R = 4.0 +PROJECTION_SMALL_R = 2.0 +PROJECTION_GAP = 4.0 + +#: Field -> the label printed in its cell, in the order the cells are drawn. +VALUE_FIELDS: tuple[tuple[str, str], ...] = ( + ("drawing_no", "DWG NO"), + ("revision", "REV"), + ("sheet", "SHEET"), + ("part_no", "PART NO"), + ("material", "MATERIAL"), + ("scale", "SCALE"), + ("drawn_by", "DRAWN"), + ("checked_by", "CHECKED"), + ("date", "DATE"), + ("company", "COMPANY"), +) + +#: Every ISO 7200 data field this block carries. `units` has no cell of its own +#: (the sheet is millimetres and the drawing says so), but it round-trips in the +#: payload so a reader is never told the drawing is unitless. +TITLEBLOCK_FIELDS: tuple[str, ...] = ( + "title", + "drawing_no", + "part_no", + "material", + "scale", + "units", + "drawn_by", + "checked_by", + "date", + "sheet", + "revision", + "company", +) + +#: The block frame plus the ten rules, at the head of the primitive tuple. +TITLEBLOCK_RULE_COUNT = 11 +TITLE_TEXT_INDEX = 11 + + +@dataclass +class TitleBlockMetadata: + """ISO 7200 data fields. Strings, written verbatim -- no transformation.""" + + title: str + drawing_no: str + part_no: str = "" + material: str = "" + scale: str = "1:1" + units: str = "mm" + drawn_by: str = "" + checked_by: str = "" + date: str = "" + sheet: str = "1/1" + revision: str = "A" + company: str = "Anka-Makine" + + +def titleblock_origin(size: str, *, orientation: str = "landscape") -> Pt: + """Lower-left corner of the title block, sheet-local.""" + metrics = frame_metrics(size, orientation=orientation) + x0, y0, x1, _y1 = metrics["frame"] + if x1 - x0 < TB_WIDTH - 1e-9: + raise ValueError( + f"{metrics['size']} {orientation}: the drawing area is {x1 - x0:.1f} mm wide, " + f"narrower than the ISO 7200 title block ({TB_WIDTH:.0f} mm)" + ) + return (x1 - TB_WIDTH, y0) + + +def projection_symbol_prims(at: Pt, angle: str = "first") -> tuple[Prim, ...]: + """The ISO 5456-2 / ISO 128-30 projection-angle symbol, centred on `at`. + + ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone + drawn in two views: the cone in elevation (a trapezoid) and the end view + (two concentric circles). Which side the end view sits on is what makes the + symbol mean *first* or *third* angle, and it is a convention that has to be + read off the standard's figure: the projection rule alone cannot pick it, + because the same picture is a legal drawing of the mirrored object seen + from the other side. + + ISO 5456-2's own figure is behind ISO's paywall and was not read. Two + independent reproductions of it were downloaded and measured instead, and + they agree on every landmark used here: + + * FreeCAD's ISO 5457 sheet template, first-angle symbol + (`src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg`, + ids `first_angle_trapezoid` / `first_angle_*_circle`): the trapezoid is + `m 389,222 -10,2.5 v 5 l 10,2.5 z` -- a 5-unit side at x = 379 and a + 10-unit side at x = 389 -- and the circles (r 2.5 and r 5) are at + cx = 396. Short side LEFT; end view to the RIGHT of the cone. + * Wikimedia Commons `Convention placement vues dessin technique.svg`, which + draws both symbols side by side, labelled FR and US. FR (first angle): + trapezoid 35.856 units tall at x = 13.343 and 66.135 at x = 81.869, + circles at x = 148.005. US (third angle): trapezoid 35.856 at + x = 380.275 and 66.135 at x = 448.801, circles at x = 314.140. + + So, in both symbols alike, the trapezoid's SHORT side is on the left; only + the end view changes side: + + first angle cone, then the circles to its RIGHT + third angle the circles, then the cone to their RIGHT + + which is the same thing as the rule the standard is usually quoted by -- + the short side points AWAY from the circles in first angle and TOWARDS + them in third. `tests/test_sheet_titleblock.py` pins the ordering against + the measured figures above, so this cannot be "fixed" back by eye. + + The proportions (large circle diameter 8, small 4, cone 8 mm long) are this + module's; ISO 5456-2's proportion table is not transcribed. They keep the + 1:2 small-to-large ratio both reference figures' circles have. + """ + if angle not in PROJECTION_ANGLES: + raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}") + cx, cy = float(at[0]), float(at[1]) + half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0 + # The end view sits right of the cone in first angle, left of it in third. + sign = 1.0 if angle == "first" else -1.0 + circles_x = (half - PROJECTION_R) * sign + # The trapezoid itself is the same picture in both symbols: short side left. + cone_left = -half if angle == "first" else half - PROJECTION_CONE_LENGTH + cone_right = cone_left + PROJECTION_CONE_LENGTH + cone = Poly( + points=( + (cx + cone_left, cy - PROJECTION_SMALL_R), + (cx + cone_right, cy - PROJECTION_R), + (cx + cone_right, cy + PROJECTION_R), + (cx + cone_left, cy + PROJECTION_SMALL_R), + ), + closed=True, + ) + axis = Line(p1=(cx - half - 1.0, cy), p2=(cx + half + 1.0, cy), role="center") + return ( + cone, + Circle(center=(cx + circles_x, cy), radius=PROJECTION_R), + Circle(center=(cx + circles_x, cy), radius=PROJECTION_SMALL_R), + axis, + ) + + +def value_text_indices() -> dict[str, int]: + """Field -> its index in the tuple `titleblock_prims` returns. + + The cells are drawn label-then-value, starting right after the title, so + the value of the i-th field is at TITLE_TEXT_INDEX + 2i + 2. + """ + return {field: TITLE_TEXT_INDEX + 2 * i + 2 for i, (field, _label) in enumerate(VALUE_FIELDS)} + + +def titleblock_prims( + size: str, + fields, + *, + orientation: str = "landscape", + projection: str | None = "first", +) -> tuple[Prim, ...]: + """The title block as primitive descriptions, sheet-local. + + Order is pinned and read back by `value_text_indices`: the block frame, the + ten rules, the title, then (label, value) per cell in `VALUE_FIELDS` order, + then the four projection-symbol primitives when `projection` is not None. + """ + meta = fields if isinstance(fields, TitleBlockMetadata) else TitleBlockMetadata(**fields) + if projection is not None and projection not in PROJECTION_ANGLES: + raise ValueError( + f"projection must be one of {PROJECTION_ANGLES} or None, got {projection!r}" + ) + tb_x0, tb_y0 = titleblock_origin(size, orientation=orientation) + tb_x1, tb_y1 = tb_x0 + TB_WIDTH, tb_y0 + TB_HEIGHT + row4_top = tb_y0 + ROW4_H + row3_top = row4_top + ROW3_H + row2_top = row3_top + ROW2_H + r2_split1 = tb_x0 + TB_WIDTH * 0.5 + r2_split2 = tb_x0 + TB_WIDTH * 0.75 + r3_split1 = tb_x0 + TB_WIDTH / 3.0 + r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0 + r4_split1 = tb_x0 + TB_WIDTH * 0.25 + r4_split2 = tb_x0 + TB_WIDTH * 0.50 + r4_split3 = tb_x0 + TB_WIDTH * 0.75 + + prims: list[Prim] = [ + Poly( + points=((tb_x0, tb_y0), (tb_x1, tb_y0), (tb_x1, tb_y1), (tb_x0, tb_y1)), + closed=True, + ), + Line(p1=(tb_x0, row4_top), p2=(tb_x1, row4_top)), + Line(p1=(tb_x0, row3_top), p2=(tb_x1, row3_top)), + Line(p1=(tb_x0, row2_top), p2=(tb_x1, row2_top)), + Line(p1=(r2_split1, row3_top), p2=(r2_split1, row2_top)), + Line(p1=(r2_split2, row3_top), p2=(r2_split2, row2_top)), + Line(p1=(r3_split1, row4_top), p2=(r3_split1, row3_top)), + Line(p1=(r3_split2, row4_top), p2=(r3_split2, row3_top)), + Line(p1=(r4_split1, tb_y0), p2=(r4_split1, row4_top)), + Line(p1=(r4_split2, tb_y0), p2=(r4_split2, row4_top)), + Line(p1=(r4_split3, tb_y0), p2=(r4_split3, row4_top)), + ] + + title_x = tb_x0 + TB_WIDTH * 0.5 - (len(meta.title) * TITLE_HEIGHT * 0.3) + title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0 + prims.append(Text(at=(title_x, title_y), text=meta.title, height=TITLE_HEIGHT)) + + # field -> (cell left edge, the row's top rule, the value's baseline) + cells: dict[str, tuple[float, float, float]] = { + "drawing_no": (tb_x0, row2_top, row3_top + VALUE_PAD_Y), + "revision": (r2_split1, row2_top, row3_top + VALUE_PAD_Y), + "sheet": (r2_split2, row2_top, row3_top + VALUE_PAD_Y), + "part_no": (tb_x0, row3_top, row4_top + VALUE_PAD_Y), + "material": (r3_split1, row3_top, row4_top + VALUE_PAD_Y), + "scale": (r3_split2, row3_top, row4_top + VALUE_PAD_Y), + "drawn_by": (tb_x0, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "checked_by": (r4_split1, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "date": (r4_split2, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + "company": (r4_split3, row4_top, tb_y0 + ROW4_VALUE_PAD_Y), + } + for field, label in VALUE_FIELDS: + left, row_top, value_y = cells[field] + prims.append( + Text( + at=(left + LABEL_PAD_X, row_top - LABEL_HEIGHT - LABEL_PAD_Y), + text=label, + height=LABEL_HEIGHT, + ) + ) + prims.append( + Text(at=(left + VALUE_PAD_X, value_y), text=getattr(meta, field), height=VALUE_HEIGHT) + ) + + if projection is not None: + prims.extend(projection_symbol_prims((tb_x0 + 15.0, row2_top + ROW1_H / 2.0), projection)) + return tuple(prims) + + +async def apply_titleblock( + backend: AutoCADBackend, + *, + size: str = "A3", + metadata: TitleBlockMetadata, + origin: Pt = (0.0, 0.0), + orientation: str = "landscape", + projection: str | None = "first", + frame: bool = True, + zones: bool = False, + marks: bool = False, + layout: str | None = None, +) -> dict: + """Draw the ISO 5457 frame (optionally) and the ISO 7200 title block. + + `projection=None`, `zones=False` and `marks=False` reproduce exactly what + `titleblock_apply_iso_a3` has always drawn. Every refusal -- an unknown + size, an unknown orientation, an unknown projection angle, a missing layout + -- fires before the first entity reaches the drawing. + """ + metrics = frame_metrics(size, orientation=orientation) + tb_prims = titleblock_prims(size, metadata, orientation=orientation, projection=projection) + sheet_prims = ( + frame_prims(size, orientation=orientation, zones=zones, marks=marks) if frame else () + ) + previous = await enter_layout(backend, layout, caller="titleblock_apply") + try: + frame_handles = await draw_sheet_prims(backend, sheet_prims, origin=origin) + tb_handles = await draw_sheet_prims(backend, tb_prims, origin=origin) + finally: + if layout is not None: + await backend.layout_set_current(previous) + + ox, oy = float(origin[0]), float(origin[1]) + w, h = metrics["sheet"] + symbol_start = TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS) + return { + "ok": True, + "size": metrics["size"], + "orientation": orientation, + "layout": layout or "Model", + "projection": projection, + "outer_border": frame_handles[0] if frame else None, + "inner_border": frame_handles[1] if frame else None, + "frame_handles": frame_handles, + "titleblock_lines": tb_handles[:TITLEBLOCK_RULE_COUNT], + "title_text": tb_handles[TITLE_TEXT_INDEX], + "value_texts": {f: tb_handles[i] for f, i in value_text_indices().items()}, + "projection_symbol": tb_handles[symbol_start:], + "metadata": asdict(metadata), + "bbox": {"min": [ox, oy], "max": [ox + w, oy + h]}, + } diff --git a/engineering/titleblock.py b/engineering/titleblock.py index 5bc39d6..d2d233e 100644 --- a/engineering/titleblock.py +++ b/engineering/titleblock.py @@ -1,57 +1,71 @@ -"""ISO 7200 / A3 (420 x 297 mm) title block generator. +"""ISO 7200 / A3 title block -- the historical entry point. -Deterministic — the title text is written verbatim from metadata. +The layout moved to `engineering.sheet.titleblock`, which draws every ISO 5457 +size from A4 to A0. What is left here is the A3 alias: `apply_iso_a3_titleblock` +forwards to `apply_titleblock(size="A3", projection=None, zones=False, +marks=False)`, which is bit-for-bit the geometry this module drew before -- no +projection-angle symbol, no zone grid, no trimming marks -- so every caller and +every test that pinned it keeps working. The module-level constants are +re-exported for the same reason. """ from __future__ import annotations -from dataclasses import asdict, dataclass from typing import TYPE_CHECKING +from engineering.sheet.frames import ( + EDGE_MARGIN, + FILING_MARGIN, + SHEET_LAYER, + SHEET_TEXT_LAYER, + sheet_size, +) +from engineering.sheet.titleblock import ( + LABEL_HEIGHT, + ROW1_H, + ROW2_H, + ROW3_H, + ROW4_H, + TB_HEIGHT, + TB_WIDTH, + TITLE_HEIGHT, + VALUE_HEIGHT, + TitleBlockMetadata, + apply_titleblock, +) + if TYPE_CHECKING: from backends.base import AutoCADBackend +__all__ = [ + "BOTTOM_MARGIN", + "LABEL_HEIGHT", + "LAYER_BORDER", + "LAYER_TEXT", + "LEFT_MARGIN", + "RIGHT_MARGIN", + "ROW1_H", + "ROW2_H", + "ROW3_H", + "ROW4_H", + "SHEET_H", + "SHEET_W", + "TB_HEIGHT", + "TB_WIDTH", + "TITLE_HEIGHT", + "TOP_MARGIN", + "VALUE_HEIGHT", + "TitleBlockMetadata", + "apply_iso_a3_titleblock", +] -# A3 paper, ISO 7200 layout constants (mm) -SHEET_W = 420.0 -SHEET_H = 297.0 -LEFT_MARGIN = 20.0 -RIGHT_MARGIN = 10.0 -TOP_MARGIN = 10.0 -BOTTOM_MARGIN = 10.0 - -TB_WIDTH = 180.0 -TB_HEIGHT = 60.0 - -ROW1_H = 20.0 # title -ROW2_H = 15.0 # drawing_no | revision | sheet -ROW3_H = 15.0 # part_no | material | scale -ROW4_H = 10.0 # drawn_by | checked_by | date | company - -LABEL_HEIGHT = 2.0 -VALUE_HEIGHT = 3.5 -TITLE_HEIGHT = 5.0 - -LAYER_BORDER = "TITLEBLOCK" -LAYER_TEXT = "TEXT" - - -@dataclass -class TitleBlockMetadata: - """ISO 7200 title block fields (all strings — no LLM transformation).""" - - title: str - drawing_no: str - part_no: str = "" - material: str = "" - scale: str = "1:1" - units: str = "mm" - drawn_by: str = "" - checked_by: str = "" - date: str = "" - sheet: str = "1/1" - revision: str = "A" - company: str = "Anka-Makine" +SHEET_W, SHEET_H = sheet_size("A3", "landscape") +LEFT_MARGIN = FILING_MARGIN +RIGHT_MARGIN = EDGE_MARGIN +TOP_MARGIN = EDGE_MARGIN +BOTTOM_MARGIN = EDGE_MARGIN +LAYER_BORDER = SHEET_LAYER +LAYER_TEXT = SHEET_TEXT_LAYER async def apply_iso_a3_titleblock( @@ -63,255 +77,25 @@ async def apply_iso_a3_titleblock( ) -> dict: """Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet). - With `layout`, the sheet is drawn on that paper-space layout — which is + With `layout`, the sheet is drawn on that paper-space layout -- which is where a title block belongs, since the border frames the printed sheet rather than the model. The caller's current space is restored afterwards, so asking for a sheet border does not silently move them onto the sheet. Without `layout` it draws in whatever space is current, as it always did. + + Equivalent to `titleblock_apply(size="A3")` with the projection-angle + symbol, the zone grid and the trimming marks switched off. Reach for + `titleblock_apply` for any other size, or for the symbol. """ - - ox, oy = float(origin[0]), float(origin[1]) - - previous_space = "Model" - if layout is not None: - listing = await backend.layout_list() - names = {str(entry) for entry in (listing.get("layouts") or [])} - if layout not in names: - raise RuntimeError( - f"apply_iso_a3_titleblock: layout {layout!r} does not exist " - f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with " - "layout_create first." - ) - previous_space = getattr(backend, "_current_space", "Model") - switched = await backend.layout_set_current(layout) - if not switched.get("ok", True): - raise RuntimeError(f"apply_iso_a3_titleblock: {switched.get('error')}") - - try: - return await _draw_titleblock(backend, metadata=metadata, ox=ox, oy=oy, layout=layout) - finally: - if layout is not None: - await backend.layout_set_current(previous_space) - - -async def _draw_titleblock( - backend: AutoCADBackend, - *, - metadata: TitleBlockMetadata, - ox: float, - oy: float, - layout: str | None, -) -> dict: - - # ---- outer + inner borders ------------------------------------------------- - outer_pts = [ - [ox, oy], - [ox + SHEET_W, oy], - [ox + SHEET_W, oy + SHEET_H], - [ox, oy + SHEET_H], - ] - outer = await backend.entity_create_polyline( - points=outer_pts, - closed=True, - layer=LAYER_BORDER, + return await apply_titleblock( + backend, + size="A3", + metadata=metadata, + origin=origin, + orientation="landscape", + projection=None, + frame=True, + zones=False, + marks=False, + layout=layout, ) - - inner_x0 = ox + LEFT_MARGIN - inner_y0 = oy + BOTTOM_MARGIN - inner_x1 = ox + SHEET_W - RIGHT_MARGIN - inner_y1 = oy + SHEET_H - TOP_MARGIN - inner_pts = [ - [inner_x0, inner_y0], - [inner_x1, inner_y0], - [inner_x1, inner_y1], - [inner_x0, inner_y1], - ] - inner = await backend.entity_create_polyline( - points=inner_pts, - closed=True, - layer=LAYER_BORDER, - ) - - # ---- title block (lower-right corner of inner border) ---------------------- - tb_x0 = inner_x1 - TB_WIDTH - tb_y0 = inner_y0 - tb_x1 = inner_x1 - tb_y1 = inner_y0 + TB_HEIGHT - - # Title block frame (closed polyline) - tb_frame_pts = [ - [tb_x0, tb_y0], - [tb_x1, tb_y0], - [tb_x1, tb_y1], - [tb_x0, tb_y1], - ] - tb_frame = await backend.entity_create_polyline( - points=tb_frame_pts, - closed=True, - layer=LAYER_BORDER, - ) - - # Row baselines (from bottom up): row4_top, row3_top, row2_top - row4_top = tb_y0 + ROW4_H - row3_top = row4_top + ROW3_H - row2_top = row3_top + ROW2_H # == tb_y1 - ROW1_H - - titleblock_lines: list[str] = [tb_frame.handle] - - async def hline(y: float) -> str: - ent = await backend.entity_create_line( - tb_x0, - y, - tb_x1, - y, - layer=LAYER_BORDER, - ) - return ent.handle - - async def vline(x: float, y0: float, y1: float) -> str: - ent = await backend.entity_create_line( - x, - y0, - x, - y1, - layer=LAYER_BORDER, - ) - return ent.handle - - titleblock_lines.append(await hline(row4_top)) - titleblock_lines.append(await hline(row3_top)) - titleblock_lines.append(await hline(row2_top)) - - # Row 2 splits: left half = drawing_no, then quarter rev, quarter sheet - r2_split1 = tb_x0 + TB_WIDTH * 0.5 - r2_split2 = tb_x0 + TB_WIDTH * 0.75 - titleblock_lines.append(await vline(r2_split1, row3_top, row2_top)) - titleblock_lines.append(await vline(r2_split2, row3_top, row2_top)) - - # Row 3 splits: thirds — part_no | material | scale - r3_split1 = tb_x0 + TB_WIDTH / 3.0 - r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0 - titleblock_lines.append(await vline(r3_split1, row4_top, row3_top)) - titleblock_lines.append(await vline(r3_split2, row4_top, row3_top)) - - # Row 4 splits: quarters — drawn_by | checked_by | date | company - r4_split1 = tb_x0 + TB_WIDTH * 0.25 - r4_split2 = tb_x0 + TB_WIDTH * 0.50 - r4_split3 = tb_x0 + TB_WIDTH * 0.75 - titleblock_lines.append(await vline(r4_split1, tb_y0, row4_top)) - titleblock_lines.append(await vline(r4_split2, tb_y0, row4_top)) - titleblock_lines.append(await vline(r4_split3, tb_y0, row4_top)) - - # ---- text content ---------------------------------------------------------- - label_pad_x = 1.5 - label_pad_y = 1.5 - value_pad_x = 3.0 - value_pad_y = 5.0 - - async def label(text: str, x: float, y: float) -> str: - ent = await backend.entity_create_text( - text=text, - x=x, - y=y, - height=LABEL_HEIGHT, - layer=LAYER_TEXT, - ) - return ent.handle - - async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str: - ent = await backend.entity_create_text( - text=text, - x=x, - y=y, - height=height, - layer=LAYER_TEXT, - ) - return ent.handle - - # Row 1 — TITLE (verbatim) - title_x = tb_x0 + TB_WIDTH * 0.5 - (len(metadata.title) * TITLE_HEIGHT * 0.3) - title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0 - title_text_handle = await value(metadata.title, title_x, title_y, height=TITLE_HEIGHT) - - value_texts: dict[str, str] = {} - - # Row 2: drawing_no (left half) | revision (3rd qtr) | sheet (4th qtr) - await label("DWG NO", tb_x0 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y) - value_texts["drawing_no"] = await value( - metadata.drawing_no, - tb_x0 + value_pad_x, - row3_top + value_pad_y, - ) - await label("REV", r2_split1 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y) - value_texts["revision"] = await value( - metadata.revision, - r2_split1 + value_pad_x, - row3_top + value_pad_y, - ) - await label("SHEET", r2_split2 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y) - value_texts["sheet"] = await value( - metadata.sheet, - r2_split2 + value_pad_x, - row3_top + value_pad_y, - ) - - # Row 3: part_no | material | scale - await label("PART NO", tb_x0 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y) - value_texts["part_no"] = await value( - metadata.part_no, - tb_x0 + value_pad_x, - row4_top + value_pad_y, - ) - await label("MATERIAL", r3_split1 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y) - value_texts["material"] = await value( - metadata.material, - r3_split1 + value_pad_x, - row4_top + value_pad_y, - ) - await label("SCALE", r3_split2 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y) - value_texts["scale"] = await value( - metadata.scale, - r3_split2 + value_pad_x, - row4_top + value_pad_y, - ) - - # Row 4: drawn_by | checked_by | date | company - await label("DRAWN", tb_x0 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["drawn_by"] = await value( - metadata.drawn_by, - tb_x0 + value_pad_x, - tb_y0 + 1.5, - ) - await label("CHECKED", r4_split1 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["checked_by"] = await value( - metadata.checked_by, - r4_split1 + value_pad_x, - tb_y0 + 1.5, - ) - await label("DATE", r4_split2 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["date"] = await value( - metadata.date, - r4_split2 + value_pad_x, - tb_y0 + 1.5, - ) - await label("COMPANY", r4_split3 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y) - value_texts["company"] = await value( - metadata.company, - r4_split3 + value_pad_x, - tb_y0 + 1.5, - ) - - return { - "ok": True, - "layout": layout or "Model", - "outer_border": outer.handle, - "inner_border": inner.handle, - "titleblock_lines": titleblock_lines, - "title_text": title_text_handle, - "value_texts": value_texts, - "metadata": asdict(metadata), - "bbox": { - "min": [ox, oy], - "max": [ox + SHEET_W, oy + SHEET_H], - }, - } diff --git a/server.py b/server.py index 6a48a63..964a787 100644 --- a/server.py +++ b/server.py @@ -9120,6 +9120,635 @@ async def hatch_material( ) +# --------------------------------------------------------------------------- +# ── SECTION 24: Sheet & Delivery (11 tools) ───────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Draw an ISO 5457 sheet frame with the zone grid and centring marks.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: ISO 5457 Frame", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def sheet_frame( + size: Annotated[ + str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.") + ] = "A3", + orientation: Annotated[ + str, Field(default="landscape", description="'landscape' or 'portrait'.") + ] = "landscape", + zones: Annotated[ + bool, + Field( + default=True, + description="Draw the ISO 5457 grid reference system: letters down the vertical " + "edges from the top, numbers along the horizontal edges from the left.", + ), + ] = True, + marks: Annotated[ + bool, + Field( + default=True, + description="Draw the four centring marks and the eight corner trimming rectangles.", + ), + ] = True, + origin_x: Annotated[ + float, Field(default=0.0, description="X of the sheet's lower-left corner (WCS).") + ] = 0.0, + origin_y: Annotated[ + float, Field(default=0.0, description="Y of the sheet's lower-left corner (WCS).") + ] = 0.0, + layout: Annotated[ + str, + Field( + default="", + description="Paper-space layout to draw the sheet on (create it with " + "layout_create). Empty draws in the current space.", + ), + ] = "", + ctx: Context = None, +) -> dict: + """ISO 5457 drawing frame for A4-A0: a 20 mm filing margin, 10 mm on the + other three edges, the grid reference system and the centring/trimming marks. + + Borders, rules and marks land on TITLEBLOCK; the zone lettering on TEXT. + Refusals, all before the first entity is drawn: a size outside A4-A0 is + refused by name and the five covered sizes are listed; an orientation that + is neither 'landscape' nor 'portrait'; a `layout` that does not exist. The + zone divisions drawn are equal -- ISO 5457's shorter corner fields are not + implemented -- while the division counts are the standard's. + """ + from engineering.sheet.frames import draw_sheet_frame + + await ctx.info(f"Sheet frame {size} {orientation}") + return await draw_sheet_frame( + _backend(ctx), + size, + orientation=orientation, + zones=zones, + marks=marks, + origin=(origin_x, origin_y), + layout=layout or None, + ) + + +@cad_tool( + summary="Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: ISO 7200 Title Block", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def titleblock_apply( + title: Annotated[str, Field(description="Drawing title (verbatim, no transformation).")], + drawing_no: Annotated[str, Field(description="Identification number, e.g. 'AM-2026-001'.")], + size: Annotated[ + str, Field(default="A3", description="ISO 216 sheet: A4, A3, A2, A1 or A0.") + ] = "A3", + orientation: Annotated[ + str, Field(default="landscape", description="'landscape' or 'portrait'.") + ] = "landscape", + projection: Annotated[ + str, + Field( + default="first", + description="Projection-angle symbol: 'first' (ISO/European, the default), " + "'third' (ANSI), or '' for no symbol.", + ), + ] = "first", + part_no: Annotated[str, Field(default="")] = "", + material: Annotated[str, Field(default="")] = "", + scale: Annotated[str, Field(default="1:1")] = "1:1", + units: Annotated[str, Field(default="mm")] = "mm", + drawn_by: Annotated[str, Field(default="")] = "", + checked_by: Annotated[str, Field(default="")] = "", + date: Annotated[str, Field(default="")] = "", + sheet: Annotated[str, Field(default="1/1")] = "1/1", + revision: Annotated[str, Field(default="A")] = "A", + company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine", + frame: Annotated[ + bool, Field(default=True, description="Also draw the ISO 5457 frame around the block.") + ] = True, + zones: Annotated[ + bool, Field(default=False, description="Add the zone grid to that frame.") + ] = False, + marks: Annotated[ + bool, Field(default=False, description="Add the centring and trimming marks.") + ] = False, + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + layout: Annotated[ + str, + Field( + default="", + description="Paper-space layout to draw the sheet on; empty draws in the " + "current space.", + ), + ] = "", + ctx: Context = None, +) -> dict: + """ISO 7200 title block, 180 mm wide, in the lower-right corner of any + A4-A0 sheet. The title text is used verbatim. + + Carries the ISO 128-30 projection-angle symbol: 'first' draws the cone on + the left and the concentric circles on the right, 'third' is its mirror. + Refusals, all before the first entity: an unknown size (the five covered + sizes are listed), an unknown orientation, a projection that is not + 'first'/'third'/'', a `layout` that does not exist. `titleblock_apply_iso_a3` + is this tool with size='A3' and no symbol, kept so no caller breaks. + """ + from engineering.sheet.titleblock import TitleBlockMetadata, apply_titleblock + + await ctx.info(f"Title block {size} {orientation}: {title}") + metadata = TitleBlockMetadata( + title=title, + drawing_no=drawing_no, + part_no=part_no, + material=material, + scale=scale, + units=units, + drawn_by=drawn_by, + checked_by=checked_by, + date=date, + sheet=sheet, + revision=revision, + company=company, + ) + return await apply_titleblock( + _backend(ctx), + size=size, + metadata=metadata, + origin=(origin_x, origin_y), + orientation=orientation, + projection=projection or None, + frame=frame, + zones=zones, + marks=marks, + layout=layout or None, + ) + + +@cad_tool( + summary="Append a revision row, with optional revision clouds and triangular tags.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Sheet: Add Revision", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def revision_add( + rev: Annotated[ + str, Field(description="Revision code: one to three capitals or digits (A, B, 01).") + ], + description: Annotated[str, Field(description="What changed, verbatim.")], + date: Annotated[str, Field(default="", description="Issue date, verbatim.")] = "", + by: Annotated[str, Field(default="", description="Who issued it.")] = "", + clouds: Annotated[ + list[list[float]] | None, + Field( + default=None, + description="Regions to cloud, each [x0, y0, x1, y1] in WCS. Refused before " + "anything is written on an engine without the revcloud capability.", + ), + ] = None, + tags: Annotated[ + list[list[float]] | None, + Field(default=None, description="Points [x, y] to put a triangular revision tag at."), + ] = None, + size: Annotated[ + str, Field(default="A3", description="Sheet the block is placed on: A4-A0.") + ] = "A3", + orientation: Annotated[str, Field(default="landscape")] = "landscape", + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + cloud_segment: Annotated[ + float, Field(default=8.0, gt=0, description="Revision-cloud arc segment length (mm).") + ] = 8.0, + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """Append one row to the revision block, creating the block on first use. + + Idempotent per revision code: a code the drawing already carries returns + `created=false` and draws nothing, so re-running a revision pass cannot + stack duplicates. The block defaults to the upper-right corner of the + drawing frame, growing downwards, so it does not collide with the ISO 7573 + parts list above the title block. Refusals, all before the first entity: a + malformed code, a cloud region that is not [x0, y0, x1, y1], and any + request for clouds on a backend whose `revcloud` capability is false (the + COM engine — REVCLOUD has no ActiveX member) — that one carries + `capability: "revcloud"`. + """ + from engineering.sheet.revision import add_revision + + await ctx.info(f"Revision {rev}: {description}") + return await add_revision( + _backend(ctx), + rev=rev, + description=description, + date=date, + by=by, + clouds=clouds or (), + tags=tags or (), + size=size, + orientation=orientation, + origin=(origin_x, origin_y), + cloud_segment=cloud_segment, + layout=layout or None, + ) + + +@cad_tool( + summary="Read the bill of materials out of the drawing's block references.", + cost="read", +) +@mcp.tool( + annotations={"title": "Sheet: Extract Bill of Materials", "readOnlyHint": True}, + tags={"engineering", "sheet", "query"}, +) +async def bom_extract( + layer: Annotated[ + str, Field(default="", description="Only read INSERTs on this layer; empty reads all.") + ] = "", + group_by: Annotated[ + str, + Field( + default="designation", + description="Field to group identical parts by: designation, standard, material, " + "or '' for one row per insert.", + ), + ] = "designation", + columns: Annotated[ + list[str] | None, + Field( + default=None, + description="Columns to return; default item, qty, designation, standard, material.", + ), + ] = None, + limit: Annotated[ + int, + Field( + default=0, + ge=0, + description="Stop after this many item records; 0 (the default) reads the whole " + "drawing. `truncated` says whether the cap cut the list.", + ), + ] = 0, + ctx: Context = None, +) -> dict: + """Walk the INSERTs and return the ISO 7573 item rows. Never modifies the + drawing. `bom_table` is what draws them. + + Each row's source is reported: `xdata` when the ACADMCP_MECH payload + supplied it (what `std_part_insert` writes), `attributes` when the block's + own ATTRIBs did. A block with neither a payload nor a DESIGNATION attribute + is skipped rather than guessed at, and `skipped` counts them. + + The whole drawing is read by default — `entity_list`'s 200-entity page is + paged through, not taken as a cap, because an item list that stops at the + 200th INSERT prints a wrong QTY rather than a shorter one. Pass `limit` to + cap it deliberately; `truncated` is then true if and only if the cap cut + the list. Refusals: an unknown column or group_by field, named with the + list of the ones there are. + """ + from engineering.sheet.bom import extract_records, rows_from_records + + backend = _backend(ctx) + cap = int(limit) or None + # One record past the cap, so `truncated` is measured rather than guessed: + # exactly `cap` records on the drawing is not a truncation. + records = await extract_records( + backend, layer=layer or None, limit=None if cap is None else cap + 1 + ) + truncated = cap is not None and len(records) > cap + if truncated: + records = records[:cap] + rows = rows_from_records(records, columns=columns, group_by=group_by or None) + return { + "ok": True, + "standard": "ISO 7573", + "rows": [dict(row) | {"handles": list(row["handles"])} for row in rows], + "records": len(records), + "limit": cap, + "truncated": truncated, + "group_by": group_by or None, + } + + +@cad_tool(summary="Draw the parts list as a real TABLE above the title block.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Draw Parts List", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def bom_table( + rows: Annotated[ + list[dict] | None, + Field( + default=None, + description="Rows to draw; omit to extract them from the drawing first.", + ), + ] = None, + size: Annotated[str, Field(default="A3", description="Sheet the list is placed on.")] = "A3", + orientation: Annotated[str, Field(default="landscape")] = "landscape", + origin_x: Annotated[float, Field(default=0.0)] = 0.0, + origin_y: Annotated[float, Field(default=0.0)] = 0.0, + at_x: Annotated[ + float | None, + Field(default=None, description="Override the anchor X; default is the title block's."), + ] = None, + at_y: Annotated[float | None, Field(default=None)] = None, + direction: Annotated[ + str, + Field( + default="up", + description="'up' (ISO 7573 on a drawing: heading at the bottom, items ascending) " + "or 'down'.", + ), + ] = "up", + columns: Annotated[list[str] | None, Field(default=None)] = None, + row_height: Annotated[float, Field(default=7.0, gt=0)] = 7.0, + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """Draw the ISO 7573 item list as a real TABLE entity, 180 mm wide (the + title-block width), growing upwards from directly above the title block. + + With `direction="up"` the heading row sits against the title block and item + 1 is the row above it, so the list extends upwards as parts are added — + which is what ISO 7573 asks for on a drawing. Pass `rows` to draw a list + you already have, or omit it and the tool runs `bom_extract` first. + `representation` reports what was drawn: `native` (a real ACAD_TABLE on the + live engine) or `composite` (rules and MTEXT headlessly, whose `handle` is + the first child). Refusals: no rows to draw, an unknown column, an unknown + direction, a `layout` that does not exist. + """ + from engineering.sheet.bom import ( + draw_bom_table, + extract_records, + rows_from_records, + ) + from engineering.sheet.titleblock import TB_HEIGHT, titleblock_origin + + backend = _backend(ctx) + if rows is None: + rows = list(rows_from_records(await extract_records(backend), columns=columns)) + tb_x, tb_y = titleblock_origin(size, orientation=orientation) + anchor = ( + origin_x + tb_x if at_x is None else at_x, + origin_y + tb_y + TB_HEIGHT if at_y is None else at_y, + ) + await ctx.info(f"Parts list: {len(rows)} rows at {anchor}") + return await draw_bom_table( + backend, + rows, + at=anchor, + direction=direction, + columns=columns, + row_height=row_height, + layout=layout or None, + ) + + +@cad_tool(summary="Add an ISO 6433 balloon linked to its item row.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Add Balloon", "destructiveHint": False}, + tags={"engineering", "sheet"}, +) +async def balloon_add( + item: Annotated[ + int, Field(ge=1, description="Item reference number, as bom_extract numbers it.") + ], + x: Annotated[float, Field(description="Balloon centre X (WCS).")], + y: Annotated[float, Field(description="Balloon centre Y (WCS).")], + leader_x: Annotated[float, Field(description="X of the dot on the item.")], + leader_y: Annotated[float, Field(description="Y of the dot on the item.")], + targets: Annotated[ + list[str] | None, + Field( + default=None, + description="Handles of the INSERTs this item is; the link a rerun renumbers by.", + ), + ] = None, + radius: Annotated[ + float, + Field( + default=4.0, + gt=0, + description="Balloon radius (mm). The numeral is 1.4x this, so a sheet " + "dimensioned at 3.5 mm wants 5.0 to satisfy ISO 6433.", + ), + ] = 4.0, + layer: Annotated[str, Field(default="DIM")] = "DIM", + layout: Annotated[str, Field(default="")] = "", + ctx: Context = None, +) -> dict: + """One ISO 6433 item reference: a numbered balloon, a leader, and a dot on + the item. + + The balloon carries an ACADMCP_MECH payload naming its targets, so running + the balloon pass again after the bill of materials is regrouped *renumbers* + the existing balloon (`renumbered: true`) instead of drawing a second one. + Refusals, all before the first entity: an item number below 1, a leader + target inside the balloon, and an item number already used by another + balloon for different targets — named with that balloon's handle. + """ + from engineering.sheet.bom import add_balloon + + await ctx.info(f"Balloon {item} at ({x}, {y})") + return await add_balloon( + _backend(ctx), + item=item, + at=(x, y), + leader_to=(leader_x, leader_y), + targets=tuple(targets or ()), + radius=radius, + layer=layer, + layout=layout or None, + ) + + +@cad_tool( + summary="Attach a DWG/DXF as an external reference (attachment or overlay).", cost="mutate" +) +@mcp.tool( + annotations={"title": "Sheet: Attach Xref", "destructiveHint": False}, + tags={"drawing", "block"}, +) +async def xref_attach( + path: Annotated[str, Field(description="Full path of the drawing to reference.")], + x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0, + y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0, + scale: Annotated[float, Field(default=1.0, gt=0, description="Uniform scale.")] = 1.0, + rotation: Annotated[ + float, Field(default=0.0, description="Rotation in degrees, CCW from +X.") + ] = 0.0, + kind: Annotated[ + str, + Field( + default="attach", + description="'attach' (the reference's own xrefs come with it) or 'overlay' " + "(they do not, so a circular reference cannot form).", + ), + ] = "attach", + ctx: Context = None, +) -> dict: + """Attach an external reference and insert it once. Both engines. + + The block takes the referenced file's stem as its name. Refusals, all + before anything is written: a `kind` that is not 'attach' or 'overlay', a + file that does not exist, a non-positive or non-finite scale, and a block + name the drawing already holds — detach it first with + `xref_manage(action='detach')`. + """ + validated = validate_path(path) + await ctx.info(f"Xref {kind}: {validated}") + return await _backend(ctx).xref_attach(str(validated), (x, y), scale, rotation, kind) + + +@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True}, + tags={"drawing", "block"}, +) +async def xref_manage( + action: Annotated[ + str, + Field(description="list | reload | bind | detach | path."), + ], + name: Annotated[str, Field(default="", description="Xref name; ignored by 'list'.")] = "", + new_path: Annotated[ + str, Field(default="", description="Required by 'path': the reference's new location.") + ] = "", + ctx: Context = None, +) -> dict: + """One operation on one external reference. + + `list`, `detach` and `path` work on both engines. `reload` and `bind` need + a live AutoCAD seat's xref manager: headlessly they are refused with + `capability: "xref_live"` rather than pretended, and the refusal says which + engine does them. Other refusals: an action outside the five, an unknown + xref name, a 'path' action with no `new_path`. On the live engine a listed + row's `inserts` and `kind` are both null — ActiveX's block interface + carries neither a per-xref insert count nor an overlay indicator, and this + server does not invent them. Headlessly both are measured: `inserts` + counts every insert in the drawing, model space and paper space alike, and + `detach` removes exactly those. + """ + await ctx.info(f"Xref {action} {name or '(all)'}") + validated = str(validate_path(new_path)) if new_path else None + return await _backend(ctx).xref_manage(name, action, validated) + + +@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate") +@mcp.tool( + annotations={"title": "Sheet: Attach Image", "destructiveHint": False}, + tags={"drawing", "create"}, +) +async def image_attach( + path: Annotated[str, Field(description="Full path of the raster image.")], + x: Annotated[float, Field(default=0.0, description="Insertion X (WCS).")] = 0.0, + y: Annotated[float, Field(default=0.0, description="Insertion Y (WCS).")] = 0.0, + scale: Annotated[ + float, + Field( + default=1.0, + gt=0, + description="Drawing units per pixel: an 800x400 image at 0.1 is 80x40 mm.", + ), + ] = 1.0, + rotation: Annotated[float, Field(default=0.0, description="Rotation in degrees.")] = 0.0, + ctx: Context = None, +) -> dict: + """Attach a raster underlay at the file's own aspect ratio. Both engines. + + `scale` is drawing units per pixel, so the placed size is the image's pixel + size times `scale` and the payload reports both. Refusals, all before + anything is written: a file that does not exist, a file no image reader can + open (named), a non-positive or non-finite scale. + """ + validated = validate_path(path) + await ctx.info(f"Image underlay: {validated}") + return await _backend(ctx).image_attach(str(validated), (x, y), scale, rotation) + + +@cad_tool( + summary="Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Sheet: Extract Data", "destructiveHint": False}, + tags={"engineering", "sheet", "query"}, +) +async def data_extract( + path: Annotated[str, Field(description="File to write, ending .csv or .xlsx.")], + fmt: Annotated[ + str, + Field(default="csv", description="'csv' (always available) or 'xlsx' (needs openpyxl)."), + ] = "csv", + layer: Annotated[ + str, Field(default="", description="Only read INSERTs on this layer; empty reads all.") + ] = "", + group_by: Annotated[ + str, + Field(default="designation", description="Group identical parts by this field, or ''."), + ] = "designation", + columns: Annotated[list[str] | None, Field(default=None)] = None, + ctx: Context = None, +) -> dict: + """The same rows `bom_extract` returns, written to a file. + + Refusals: a format that is not 'csv' or 'xlsx'; a drawing with nothing to + itemise (nothing is written); and `fmt='xlsx'` without openpyxl, which + carries `capability: "xlsx_write"` and names both the package to install + and the CSV route that always works. + """ + from engineering.sheet.bom import extract_records, rows_from_records + from engineering.sheet.extract import write_rows + + backend = _backend(ctx) + validated = validate_path(path, allow_write=True) + records = await extract_records(backend, layer=layer or None) + rows = rows_from_records(records, columns=columns, group_by=group_by or None) + await ctx.info(f"Data extract: {len(rows)} rows -> {validated}") + return write_rows(rows, str(validated), columns=columns, fmt=fmt) + + +@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe") +@mcp.tool( + annotations={"title": "Drawing: Export DWG", "destructiveHint": False}, + tags={"drawing"}, +) +async def drawing_export_dwg( + path: Annotated[str, Field(description="Full path of the .dwg to write.")], + version: Annotated[ + str, + Field( + default="R2018", + description="R2000 | R2004 | R2007 | R2010 | R2013 | R2018.", + ), + ] = "R2018", + ctx: Context = None, +) -> dict: + """Write a real DWG — not a DXF under a .dwg name. + + On a live AutoCAD seat this is `Document.SaveAs` with the matching + AcSaveAsType, which (as AutoCAD's own SaveAs does) leaves the session bound + to the file it just wrote. Headlessly it needs the ODA File Converter; when + that is absent the call is refused with `capability: "dwg_write"` and the + refusal names the install route and `drawing_export_dxf`, which writes a + DXF AutoCAD opens unchanged. Check `system_capabilities` first: the ezdxf + flag is re-evaluated at call time, so installing the converter changes it + without a restart. An unknown version is refused by name with the six. + """ + validated = validate_path(path, allow_write=True) + await ctx.info(f"DWG export {version}: {validated}") + return await _backend(ctx).drawing_export_dwg(str(validated), version) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index 7a18051..b97ad6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,3 +40,31 @@ async def backend(): await b.drawing_new() yield b await b.disconnect() + + +class WithoutPrivateState: + """The real backend with its ezdxf-only private attributes taken away. + + ``EzdxfBackend`` tracks the current tab in ``self._current_space``; + ``ComBackend`` does not -- the live tab lives in AutoCAD + (``doc.ActiveLayout``), so there is nothing of that name on it. Shared code + that reaches for the private attribute therefore silently takes the + ``getattr`` default on every live seat, and no ezdxf-backed test can see + it. Wrapping the real backend (rather than writing a fake that owns members + the real object never had) keeps every other call honest. + """ + + def __init__(self, inner, hide=("_current_space",)): + object.__setattr__(self, "_inner", inner) + object.__setattr__(self, "_hidden", frozenset(hide)) + + def __getattr__(self, name): + if name in self._hidden: + raise AttributeError(f"{type(self._inner).__name__} has no attribute {name!r}") + return getattr(self._inner, name) + + +@pytest_asyncio.fixture +async def backend_without_private_state(backend): + """`backend`, minus the private attributes only the headless engine has.""" + return WithoutPrivateState(backend) diff --git a/tests/test_refs_contract.py b/tests/test_refs_contract.py new file mode 100644 index 0000000..990e47a --- /dev/null +++ b/tests/test_refs_contract.py @@ -0,0 +1,521 @@ +"""External references, raster images and DWG output on both engines. + +The three capability keys are the point of this file: a refusal the feature map +never mentions is a lie by omission, so every key raised here is asserted to be +declared on both backends. +""" + +from __future__ import annotations + +from importlib.util import find_spec +from pathlib import Path + +import pytest + +from backends.base import AutoCADBackend, UnsupportedCapabilityError +from backends.com_backend import ComBackend +from backends.contracts.refs import ( + DWG_VERSIONS, + XREF_ACTIONS, + XREF_KINDS, + RefsContract, +) +from backends.ezdxf_backend import EzdxfBackend + +TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write") + + +def _features(backend) -> dict: + return backend.capabilities().to_dict()["features"] + + +def test_the_contract_is_composed_into_both_backends(): + assert issubclass(AutoCADBackend, RefsContract) + assert issubclass(EzdxfBackend, RefsContract) + assert issubclass(ComBackend, RefsContract) + for name in ("xref_attach", "xref_manage", "image_attach", "drawing_export_dwg"): + assert callable(getattr(AutoCADBackend, name, None)), name + + +def test_the_three_keys_are_declared_in_both_capability_maps(): + ezdxf_features = _features(EzdxfBackend()) + com_features = _features(ComBackend()) + for key in TRACK_G_KEYS: + assert key in ezdxf_features, f"{key} undeclared on ezdxf" + assert key in com_features, f"{key} undeclared on com" + + +def test_com_declares_dwg_and_live_xrefs_and_ezdxf_does_not(): + com_features = _features(ComBackend()) + ezdxf_features = _features(EzdxfBackend()) + assert com_features["dwg_write"]["supported"] is True + assert com_features["xref_live"]["supported"] is True + assert ezdxf_features["xref_live"]["supported"] is False + assert "live" in (ezdxf_features["xref_live"]["reason"] or "") + + +def test_ezdxf_dwg_write_is_re_evaluated_from_the_converter_not_hardcoded(): + """The same shape as viewport_render's matplotlib check: the flag follows + the machine, and the reason names the way to change it.""" + from ezdxf.addons import odafc + + feature = _features(EzdxfBackend())["dwg_write"] + assert feature["supported"] is bool(odafc.is_installed()) + if not feature["supported"]: + assert "oda" in (feature["reason"] or "").lower() + + +def test_xlsx_write_follows_openpyxl_on_both_engines(): + present = find_spec("openpyxl") is not None + for backend in (EzdxfBackend(), ComBackend()): + feature = _features(backend)["xlsx_write"] + assert feature["supported"] is present, backend.name + if not present: + assert "openpyxl" in (feature["reason"] or "") + + +def test_the_vocabularies_are_closed(): + assert XREF_KINDS == ("attach", "overlay") + assert XREF_ACTIONS == ("list", "reload", "bind", "detach", "path") + assert DWG_VERSIONS == ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018") + + +# -- ezdxf ------------------------------------------------------------------- + + +@pytest.fixture +def external(tmp_path: Path) -> str: + """A real DXF on disk for the xref to point at.""" + import ezdxf + + doc = ezdxf.new("R2018") + doc.modelspace().add_circle((0, 0), 5) + target = tmp_path / "BASE.dxf" + doc.saveas(target) + return str(target) + + +@pytest.mark.asyncio +async def test_attach_creates_an_xref_block_and_an_insert(backend, external): + result = await backend.xref_attach(external, (10.0, 20.0), 2.0, 30.0, "attach") + assert result["ok"] is True + assert result["name"] == "BASE" + assert result["kind"] == "attach" + insert = await backend.entity_get(result["handle"]) + assert insert.type == "INSERT" + assert insert.properties["block_name"] == "BASE" + + listing = await backend.xref_manage("", "list", None) + assert [row["name"] for row in listing["xrefs"]] == ["BASE"] + assert listing["xrefs"][0]["kind"] == "attach" + assert Path(listing["xrefs"][0]["path"]).name == "BASE.dxf" + assert listing["xrefs"][0]["inserts"] == 1 + + +@pytest.mark.asyncio +async def test_overlay_is_flagged_as_an_overlay(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "overlay") + listing = await backend.xref_manage("", "list", None) + assert listing["xrefs"][0]["kind"] == "overlay" + + +@pytest.mark.asyncio +async def test_attaching_the_same_name_twice_is_refused_before_writing(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert "BASE" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_missing_file_and_a_bad_kind_are_refused(backend, tmp_path): + with pytest.raises(ValueError) as missing: + await backend.xref_attach(str(tmp_path / "nope.dxf"), (0.0, 0.0), 1.0, 0.0, "attach") + assert "nope.dxf" in str(missing.value) + with pytest.raises(ValueError) as kind: + await backend.xref_attach(str(tmp_path), (0.0, 0.0), 1.0, 0.0, "underlay") + assert "underlay" in str(kind.value) + + +@pytest.mark.asyncio +async def test_path_rewrites_the_saved_path_and_detach_removes_the_block( + backend, external, tmp_path +): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + moved = str(tmp_path / "moved" / "BASE.dxf") + changed = await backend.xref_manage("BASE", "path", moved) + assert changed["ok"] is True + assert changed["path"] == moved + assert (await backend.xref_manage("", "list", None))["xrefs"][0]["path"] == moved + + detached = await backend.xref_manage("BASE", "detach", None) + assert detached["ok"] is True and detached["inserts_removed"] == 1 + assert (await backend.xref_manage("", "list", None))["xrefs"] == [] + + +@pytest.mark.asyncio +async def test_an_xref_shown_on_a_sheet_is_counted_and_detached_with_it(backend, external): + """Model space is not the drawing. + + An xref is most often *shown* on a paper-space sheet. Counting or deleting + over `doc.modelspace()` alone reported 1 insert where the drawing held 2 + and then removed the definition with `safe=False`, leaving the sheet's + INSERT with no BLOCK behind it -- which ezdxf's own recover reports as + `FIX 103 UNDEFINED_BLOCK` and throws the geometry away. + """ + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + doc = backend._doc + doc.layout("Layout1").add_blockref("BASE", (10, 10)) + + listing = await backend.xref_manage("", "list", None) + assert listing["xrefs"][0]["inserts"] == 2 + + detached = await backend.xref_manage("BASE", "detach", None) + assert detached["inserts_removed"] == 2 + assert "BASE" not in doc.blocks + assert [e for e in doc.layout("Layout1") if e.dxftype() == "INSERT"] == [] + + +@pytest.mark.asyncio +async def test_detach_leaves_a_drawing_that_survives_a_round_trip(backend, external, tmp_path): + """The evidence a caller actually cares about: the saved file audits clean.""" + import ezdxf.recover + + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + backend._doc.layout("Layout1").add_blockref("BASE", (10, 10)) + await backend.xref_manage("BASE", "detach", None) + + out = tmp_path / "detached.dxf" + backend._doc.saveas(out) + _, auditor = ezdxf.recover.readfile(str(out)) + assert [f.message for f in auditor.fixes] == [] + assert [e.message for e in auditor.errors] == [] + + +@pytest.mark.asyncio +async def test_the_definition_is_dropped_with_the_in_use_check_on(backend, external): + """`safe=True` is the seatbelt, not decoration. + + The broken version passed `safe=False`, the flag that *suppresses* the + in-use check, so a missed insert became a corrupt file instead of an + error. This pins the check: with the model-space insert gone but the + sheet's still there -- exactly the state the old victim scan produced -- + `delete_block` refuses. + """ + from ezdxf.lldxf.const import DXFBlockInUseError + + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + doc = backend._doc + doc.layout("Layout1").add_blockref("BASE", (10, 10)) + msp = doc.modelspace() + for entity in [e for e in msp if e.dxftype() == "INSERT"]: + msp.delete_entity(entity) + + with pytest.raises(DXFBlockInUseError): + doc.blocks.delete_block("BASE", safe=True) + assert "BASE" in doc.blocks + + +@pytest.mark.asyncio +async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + for action in ("reload", "bind"): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.xref_manage("BASE", action, None) + assert excinfo.value.capability == "xref_live" + assert "com" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_an_unknown_action_and_an_unknown_name_are_refused(backend, external): + await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + with pytest.raises(ValueError) as action: + await backend.xref_manage("BASE", "unload", None) + assert "unload" in str(action.value) + with pytest.raises(ValueError) as name: + await backend.xref_manage("NOPE", "detach", None) + assert "NOPE" in str(name.value) + + +@pytest.mark.asyncio +async def test_image_attach_places_the_raster_at_its_pixel_aspect(backend, tmp_path): + from PIL import Image + + png = tmp_path / "logo.png" + Image.new("RGB", (800, 400), "white").save(png) + result = await backend.image_attach(str(png), (5.0, 5.0), 0.1, 0.0) + assert result["ok"] is True + assert result["pixels"] == [800, 400] + assert result["size_mm"] == [80.0, 40.0] + image = await backend.entity_get(result["handle"]) + assert image.type == "IMAGE" + + +@pytest.mark.asyncio +async def test_a_non_image_file_is_refused_before_writing(backend, external): + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await backend.image_attach(external, (0.0, 0.0), 1.0, 0.0) + assert "BASE.dxf" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_headless_dwg_export_refuses_with_dwg_write_when_the_converter_is_absent( + backend, tmp_path +): + from ezdxf.addons import odafc + + if odafc.is_installed(): + pytest.skip("the ODA File Converter is installed on this machine") + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R2018") + assert excinfo.value.capability == "dwg_write" + assert "oda" in str(excinfo.value).lower() + assert not (tmp_path / "out.dwg").exists() + + +@pytest.mark.asyncio +async def test_an_unknown_dwg_version_is_refused_on_both_engines(backend, tmp_path): + with pytest.raises(ValueError) as excinfo: + await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R14") + message = str(excinfo.value) + assert "R14" in message + assert "R2018" in message + + +# -- COM, against fakes that model the measured ActiveX members -------------- + + +def _com_backend(): + b = ComBackend() + + async def _run(func, *args, **kwargs): # bypass the single-thread executor + return func(*args, **kwargs) + + b._run = _run + return b + + +def _point(variant): + """The doubles a VARIANT point carries. + + ``_apoint`` hands ActiveX a ``win32com.client.VARIANT``, and that object is + *not* iterable -- measured here: + ``tuple(VARIANT(VT_ARRAY | VT_R8, [1.0, 2.0, 0.0]))`` raises + ``TypeError: 'VARIANT' object is not iterable``. The real ModelSpace reads + the array behind it, so the fakes read ``.value`` rather than pretending + the argument is a plain sequence. + """ + return tuple(getattr(variant, "value", variant)) + + +class _Block: + """Measured members of an AcadBlock that is an xref (AutoCAD 2026).""" + + def __init__(self, name, path="", is_xref=True): + self.Name = name + self.Path = path + self.IsXRef = is_xref + self.calls = [] + + def Reload(self): + self.calls.append("Reload") + + def Bind(self, bPrefixName): + self.calls.append(("Bind", bPrefixName)) + + def Detach(self): + self.calls.append("Detach") + + +class _Blocks: + def __init__(self, blocks): + self._blocks = list(blocks) + + def Item(self, index): + if isinstance(index, int): + return self._blocks[index] + for block in self._blocks: + if block.Name == index: + return block + raise KeyError(index) + + @property + def Count(self): + return len(self._blocks) + + def __iter__(self): + return iter(self._blocks) + + +class _Ref: + def __init__(self, handle="7F"): + self.Handle = handle + + +class _ModelSpace: + def __init__(self): + self.attached = [] + self.rasters = [] + + def AttachExternalReference( + self, PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay + ): + self.attached.append( + (PathName, Name, _point(InsertionPoint), Xscale, Yscale, Zscale, Rotation, bOverlay) + ) + return _Ref("A1") + + def AddRaster(self, imageFileName, InsertionPoint, ScaleFactor, RotationAngle): + self.rasters.append((imageFileName, _point(InsertionPoint), ScaleFactor, RotationAngle)) + return _Ref("B2") + + +class _ComDoc: + def __init__(self, blocks=()): + self.ModelSpace = _ModelSpace() + self.Blocks = _Blocks(blocks) + self.saved = [] + + def SaveAs(self, FullFileName, SaveAsType): + self.saved.append((FullFileName, SaveAsType)) + + +@pytest.mark.asyncio +async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external): + import math + + import backends.com_backend as cb + + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().xref_attach(external, (10.0, 20.0), 2.0, 90.0, "overlay") + assert result["handle"] == "A1" + path, name, insert, sx, sy, sz, rotation, overlay = doc.ModelSpace.attached[0] + assert name == "BASE" and overlay is True + assert (sx, sy, sz) == (2.0, 2.0, 2.0) + assert rotation == pytest.approx(math.pi / 2) + assert insert[:2] == (10.0, 20.0) + + +@pytest.mark.asyncio +async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch): + import backends.com_backend as cb + + block = _Block("BASE", path="C:/refs/BASE.dwg") + doc = _ComDoc([block]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _com_backend() + + assert (await b.xref_manage("BASE", "reload", None))["ok"] is True + assert (await b.xref_manage("BASE", "bind", None))["ok"] is True + assert (await b.xref_manage("BASE", "detach", None))["ok"] is True + assert block.calls == ["Reload", ("Bind", False), "Detach"] + + listing = await b.xref_manage("", "list", None) + assert listing["xrefs"][0]["name"] == "BASE" + assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg" + + +@pytest.mark.asyncio +async def test_com_list_reports_kind_as_unknown_rather_than_inventing_attach(monkeypatch): + """IAcadBlock has no overlay indicator. + + Measured from the seat's own registered type library (acax25*.tlb, + "AutoCAD 2025 Type Library", installed with AutoCAD 2026): the xref + members are IsXRef, Path, Name, Reload, Unload, Bind, Detach, + XRefDatabase -- nothing says attach-vs-overlay. The fake therefore has no + such member either (a fake that models one enshrines the bug), and the + honest row says `None`, the way `inserts` already does. + """ + import backends.com_backend as cb + + doc = _ComDoc([_Block("BASE", path="C:/refs/BASE.dwg")]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + row = (await _com_backend().xref_manage("", "list", None))["xrefs"][0] + assert row["kind"] is None + assert row["inserts"] is None + for invented in ("IsOverlay", "Overlay", "XRefType"): + assert not hasattr(doc.Blocks.Item("BASE"), invented) + + +@pytest.mark.asyncio +async def test_com_attach_refuses_a_name_the_drawing_already_holds(monkeypatch, external): + """The refusal the contract and the tool docstring both promise. + + It has to be on this engine too, and it has to fire *before* + AttachExternalReference is dispatched -- a second attach under a held name + re-points or half-writes the definition while the payload still claims the + new path was attached. + """ + import backends.com_backend as cb + + doc = _ComDoc([_Block("BASE", path="C:/other/BASE.dwg")]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + with pytest.raises(ValueError) as excinfo: + await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert "BASE" in str(excinfo.value) + assert "detach" in str(excinfo.value) + assert doc.ModelSpace.attached == [] + + +@pytest.mark.asyncio +async def test_com_attach_refuses_a_plain_block_of_the_same_name_case_insensitively( + monkeypatch, external +): + """AutoCAD symbol-table names are case-insensitive and a collision is a + collision whether or not the block in the way is itself an xref.""" + import backends.com_backend as cb + + doc = _ComDoc([_Block("Base", is_xref=False)]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + with pytest.raises(ValueError): + await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert doc.ModelSpace.attached == [] + + +@pytest.mark.asyncio +async def test_com_attach_still_works_when_no_block_of_that_name_exists(monkeypatch, external): + import backends.com_backend as cb + + doc = _ComDoc([_Block("OTHER", is_xref=True)]) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach") + assert result["ok"] is True and result["name"] == "BASE" + assert len(doc.ModelSpace.attached) == 1 + + +@pytest.mark.asyncio +async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path): + import math + + from PIL import Image + + import backends.com_backend as cb + + png = tmp_path / "logo.png" + Image.new("RGB", (400, 200), "white").save(png) + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + result = await _com_backend().image_attach(str(png), (1.0, 2.0), 0.5, 180.0) + assert result["handle"] == "B2" + name, insert, scale, rotation = doc.ModelSpace.rasters[0] + assert Path(name).name == "logo.png" + assert insert[:2] == (1.0, 2.0) + assert scale == 0.5 + assert rotation == pytest.approx(math.pi) + + +@pytest.mark.asyncio +async def test_com_dwg_export_uses_the_measured_acsaveastype_values(monkeypatch, tmp_path): + import backends.com_backend as cb + + doc = _ComDoc() + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _com_backend() + await b.drawing_export_dwg(str(tmp_path / "a.dwg"), "R2018") + await b.drawing_export_dwg(str(tmp_path / "b.dwg"), "R2000") + assert [entry[1] for entry in doc.saved] == [64, 12] diff --git a/tests/test_sheet_bom.py b/tests/test_sheet_bom.py new file mode 100644 index 0000000..bf95ada --- /dev/null +++ b/tests/test_sheet_bom.py @@ -0,0 +1,389 @@ +"""ISO 7573 parts lists and ISO 6433 item references (balloons).""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Circle, Line, Text +from engineering.sheet.bom import ( + COLUMN_LABELS, + DEFAULT_COLUMNS, + LIST_WIDTH, + ROW_HEIGHT, + balloon_prims, + rows_from_records, + table_layout, + table_prims, +) + +EPS = 1e-9 + + +def _records(): + return [ + { + "handle": "A1", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "material": "8.8", + "qty": 1, + }, + { + "handle": "A2", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "material": "8.8", + "qty": 1, + }, + { + "handle": "B1", + "designation": "SHAFT 40h6", + "standard": "", + "material": "C45", + "qty": 1, + }, + { + "handle": "C1", + "designation": "ISO 4032 - M12", + "standard": "ISO 4032", + "material": "8", + "qty": 1, + }, + ] + + +def test_default_columns_are_the_iso_7573_minimum_plus_material(): + assert DEFAULT_COLUMNS == ("item", "qty", "designation", "standard", "material") + for column in DEFAULT_COLUMNS: + assert column in COLUMN_LABELS + + +def test_rows_group_by_designation_sum_the_quantity_and_number_from_one(): + rows = rows_from_records(_records()) + assert [r["item"] for r in rows] == [1, 2, 3] + by_designation = {r["designation"]: r for r in rows} + assert by_designation["ISO 4014 - M12x60"]["qty"] == 2 + assert by_designation["ISO 4014 - M12x60"]["handles"] == ("A1", "A2") + assert by_designation["SHAFT 40h6"]["qty"] == 1 + assert set(rows[0]) >= set(DEFAULT_COLUMNS) | {"handles"} + + +def test_rows_are_ordered_deterministically_whatever_order_they_arrive_in(): + forward = rows_from_records(_records()) + backward = rows_from_records(list(reversed(_records()))) + assert [r["designation"] for r in forward] == [r["designation"] for r in backward] + assert [r["item"] for r in forward] == [r["item"] for r in backward] + + +def test_group_by_none_keeps_one_row_per_insert(): + rows = rows_from_records(_records(), group_by=None) + assert [r["item"] for r in rows] == [1, 2, 3, 4] + assert all(r["qty"] == 1 for r in rows) + + +def test_an_unknown_column_is_refused_with_the_list(): + with pytest.raises(ValueError) as excinfo: + rows_from_records(_records(), columns=("item", "colour")) + message = str(excinfo.value) + assert "colour" in message + assert "designation" in message + + +def test_the_list_is_the_title_block_width_and_the_default_shares_sum_to_it(): + assert LIST_WIDTH == 180.0 + assert ROW_HEIGHT == 7.0 + layout = table_layout(rows_from_records(_records()), at=(230.0, 70.0)) + assert layout["width"] == pytest.approx(180.0, abs=EPS) + assert sum(layout["widths"]) == pytest.approx(180.0, abs=EPS) + assert layout["height"] == pytest.approx(4 * 7.0, abs=EPS) # 3 items + the heading + + +def test_direction_up_puts_the_heading_at_the_bottom_and_item_1_above_it(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 70.0), direction="up") + assert layout["cells"][-1][0] == COLUMN_LABELS["item"] + assert layout["cells"][-2][0] == "1" + assert layout["cells"][0][0] == "3" + # the table is drawn downwards from its insertion point, so the insertion + # sits a full table height above the anchor and the list grows upwards + assert layout["insert"] == (230.0, 70.0 + 4 * 7.0) + + +def test_direction_down_puts_the_heading_at_the_top(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 200.0), direction="down") + assert layout["cells"][0][0] == COLUMN_LABELS["item"] + assert layout["cells"][1][0] == "1" + assert layout["insert"] == (230.0, 200.0) + + +def test_an_unknown_direction_is_refused(): + with pytest.raises(ValueError) as excinfo: + table_layout(rows_from_records(_records()), at=(0.0, 0.0), direction="sideways") + assert "sideways" in str(excinfo.value) + + +def test_table_prims_draw_the_same_rectangle_the_layout_declares(): + rows = rows_from_records(_records()) + layout = table_layout(rows, at=(230.0, 70.0)) + prims = table_prims(rows, at=(230.0, 70.0)) + xs = [p[0] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)] + ys = [p[1] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)] + assert min(xs) == pytest.approx(230.0, abs=EPS) + assert max(xs) == pytest.approx(230.0 + layout["width"], abs=EPS) + assert min(ys) == pytest.approx(70.0, abs=EPS) + assert max(ys) == pytest.approx(70.0 + layout["height"], abs=EPS) + assert any(isinstance(p, Text) and p.text == "ISO 4014 - M12x60" for p in prims) + + +def test_an_unknown_parts_list_standard_is_refused_by_name(): + with pytest.raises(ValueError) as excinfo: + table_prims(rows_from_records(_records()), at=(0.0, 0.0), standard="ASME Y14.34") + message = str(excinfo.value) + assert "ASME Y14.34" in message + assert "ISO 7573" in message + + +def test_a_balloon_is_a_circle_a_leader_a_dot_and_the_numeral(): + prims = balloon_prims(7, (100.0, 100.0), (80.0, 90.0)) + circles = [p for p in prims if isinstance(p, Circle)] + lines = [p for p in prims if isinstance(p, Line)] + texts = [p for p in prims if isinstance(p, Text)] + assert len(circles) == 2 and len(lines) == 1 and len(texts) == 1 + balloon = max(circles, key=lambda c: c.radius) + dot = min(circles, key=lambda c: c.radius) + assert balloon.center == (100.0, 100.0) and balloon.radius == 4.0 + assert dot.center == (80.0, 90.0) + assert texts[0].text == "7" + # ISO 6433: the leader starts on the balloon's boundary and ends on the item + start_distance = ((lines[0].p1[0] - 100.0) ** 2 + (lines[0].p1[1] - 100.0) ** 2) ** 0.5 + assert start_distance == pytest.approx(4.0, abs=1e-9) + assert lines[0].p2 == (80.0, 90.0) + + +def test_a_leader_target_inside_the_balloon_is_refused(): + with pytest.raises(ValueError) as excinfo: + balloon_prims(3, (100.0, 100.0), (101.0, 100.0)) + assert "inside" in str(excinfo.value) + + +def test_a_non_positive_item_number_is_refused(): + with pytest.raises(ValueError) as excinfo: + balloon_prims(0, (0.0, 0.0), (20.0, 0.0)) + assert "0" in str(excinfo.value) + + +# ── reading and drawing ───────────────────────────────────────────────────── + +from engineering.mech.xdata import APP_ID, to_values # noqa: E402 +from engineering.sheet.bom import ( # noqa: E402 + add_balloon, + draw_bom_table, + extract_records, + read_balloons, +) + + +async def _place_bolt(backend, tag: str, x: float) -> str: + await backend.block_define( + "ISO4014_M12", + [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0}], + [{"tag": "DESIGNATION", "x": 0.0, "y": -10.0, "height": 2.5, "default": ""}], + overwrite=True, + ) + insert = await backend.block_insert( + "ISO4014_M12", x, 0.0, attributes={"DESIGNATION": "ISO 4014 - M12x60"} + ) + await backend.entity_set_xdata( + insert.handle, + APP_ID, + to_values( + { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + ), + ) + return insert.handle + + +@pytest.mark.asyncio +async def test_extract_reads_the_xdata_payload_and_never_touches_the_drawing(backend): + handles = [await _place_bolt(backend, "a", 0.0), await _place_bolt(backend, "b", 50.0)] + before = len(await backend.entity_list()) + records = await extract_records(backend) + assert len(await backend.entity_list()) == before, "bom_extract must not modify the drawing" + assert {r["handle"] for r in records} == set(handles) + assert all(r["designation"] == "ISO 4014 - M12x60" for r in records) + assert all(r["standard"] == "ISO 4014" for r in records) + assert all(r["source"] == "xdata" for r in records) + + +@pytest.mark.asyncio +async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend): + await backend.block_define( + "PLATE", + [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}], + [{"tag": "DESIGNATION", "x": 0.0, "y": -6.0, "height": 2.5, "default": ""}], + overwrite=True, + ) + await backend.block_insert("PLATE", 10.0, 10.0, attributes={"DESIGNATION": "PLATE 10"}) + records = await extract_records(backend) + plate = next(r for r in records if r["designation"] == "PLATE 10") + assert plate["source"] == "attributes" + assert plate["standard"] == "" + + +@pytest.mark.asyncio +async def test_the_table_is_a_real_table_entity_sitting_on_its_anchor(backend): + await _place_bolt(backend, "a", 0.0) + rows = rows_from_records(await extract_records(backend)) + result = await draw_bom_table(backend, rows, at=(230.0, 70.0), direction="up") + assert result["ok"] is True + assert result["rows"] == 1 + assert result["insert"] == (230.0, 70.0 + 2 * 7.0) + assert result["bbox"] == {"min": [230.0, 70.0], "max": [410.0, 84.0]} + # Measured: `entity_create_table` returns EntityInfo(type="TABLE") whose + # `handle` is the FIRST CHILD of a composite on the headless engine -- so + # `entity_get(handle)` reads back a rule LINE, never an ACAD_TABLE. The + # contract is pinned by tests/test_table_leader.py; `representation` says + # which of the two renderings a caller got. + assert result["entity_type"] == "TABLE" + assert result["representation"] == "composite" + assert result["handle"] in result["child_handles"] + table = await backend.entity_get(result["handle"]) + assert table.layer == "TEXT" + + +@pytest.mark.asyncio +async def test_a_balloon_records_its_targets_and_a_rerun_renumbers_instead_of_duplicating(backend): + target = await _place_bolt(backend, "a", 0.0) + first = await add_balloon( + backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,) + ) + assert first["renumbered"] is False + circles_after_first = len(await backend.entity_list(type_filter="CIRCLE")) + + second = await add_balloon( + backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,) + ) + assert second["renumbered"] is True + assert second["handle"] == first["handle"] + assert len(await backend.entity_list(type_filter="CIRCLE")) == circles_after_first + + balloons = await read_balloons(backend) + assert len(balloons) == 1 + assert balloons[0]["item"] == 5 + assert balloons[0]["targets"] == [target] + numeral = await backend.entity_get(balloons[0]["text"]) + assert numeral.properties["text"] == "5" + + +@pytest.mark.asyncio +async def test_reusing_an_item_number_for_different_targets_is_refused(backend): + one = await _place_bolt(backend, "a", 0.0) + two = await _place_bolt(backend, "b", 60.0) + await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(one,)) + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await add_balloon(backend, item=1, at=(100.0, 40.0), leader_to=(60.0, 0.0), targets=(two,)) + assert "1" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_second_balloon_with_the_same_number_and_no_targets_is_refused(backend): + """`targets` is optional in the tool, so the clash test used to compare + `() != ()` -- False -- and never fire. Two balloons carrying item 1.""" + first = await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0)) + before = len(await backend.entity_list()) + with pytest.raises(ValueError) as excinfo: + await add_balloon(backend, item=1, at=(90.0, 40.0), leader_to=(60.0, 0.0)) + assert first["handle"] in str(excinfo.value) + assert len(await backend.entity_list()) == before + assert [b["item"] for b in await read_balloons(backend)] == [1] + + +@pytest.mark.asyncio +async def test_the_balloon_guards_read_past_the_entity_list_page(backend): + """A routine hole pattern fills `entity_list`'s 200-entity default, which + used to make `read_balloons` return nothing and every guard pass.""" + for index in range(200): + await backend.entity_create_circle(float(index) * 10.0, 0.0, 2.0) + await add_balloon(backend, item=1, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",)) + assert [b["item"] for b in await read_balloons(backend)] == [1] + again = await add_balloon( + backend, item=9, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",) + ) + assert again["renumbered"] is True, "the guard must see a balloon past the 200th circle" + with pytest.raises(ValueError): + await add_balloon( + backend, item=9, at=(200.0, 400.0), leader_to=(160.0, 380.0), targets=("BBB",) + ) + + +@pytest.mark.asyncio +async def test_extract_reads_every_insert_not_the_first_page(backend): + """250 bolts must report QTY 250. The 200-entity `entity_list` default used + to print 200 into a real TABLE with nothing saying it had been cut.""" + await backend.block_define("BOLT", [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}], []) + for index in range(250): + insert = await backend.block_insert("BOLT", float(index) * 10.0, 0.0) + await backend.entity_set_xdata( + insert.handle, + APP_ID, + to_values( + { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + ), + ) + records = await extract_records(backend) + assert len(records) == 250 + rows = rows_from_records(records) + assert [(r["designation"], r["qty"]) for r in rows] == [("ISO 4014 - M12x60", 250)] + # A cap is still honoured when one is asked for, explicitly. + assert len(await extract_records(backend, limit=17)) == 17 + with pytest.raises(ValueError): + await extract_records(backend, limit=0) + + +@pytest.mark.asyncio +async def test_the_balloon_guards_read_the_layout_they_draw_on(backend): + """`read_balloons` follows the current space, so reading it before + `enter_layout` inspected Model while the balloons went on the layout.""" + await backend.layout_create("SHEET1") + first = await add_balloon( + backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1" + ) + again = await add_balloon( + backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1" + ) + assert again["renumbered"] is True + assert again["handle"] == first["handle"] + with pytest.raises(ValueError): + await add_balloon( + backend, + item=5, + at=(90.0, 40.0), + leader_to=(60.0, 0.0), + targets=("BBB",), + layout="SHEET1", + ) + assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back" + await backend.layout_set_current("SHEET1") + try: + assert len(await backend.entity_list(type_filter="CIRCLE", limit=10_000)) == 2 + finally: + await backend.layout_set_current("Model") diff --git a/tests/test_sheet_extract.py b/tests/test_sheet_extract.py new file mode 100644 index 0000000..c2ed4a7 --- /dev/null +++ b/tests/test_sheet_extract.py @@ -0,0 +1,89 @@ +"""Parts-list rows to CSV (always) and XLSX (with openpyxl).""" + +from __future__ import annotations + +import csv +from importlib.util import find_spec + +import pytest + +from backends.base import UnsupportedCapabilityError +from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available + + +def _rows(): + return ( + { + "item": 1, + "qty": 2, + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "material": "8.8", + "handles": ("A1", "A2"), + }, + { + "item": 2, + "qty": 1, + "designation": "SHAFT 40h6", + "standard": "", + "material": "C45", + "handles": ("B1",), + }, + ) + + +def test_the_formats_are_closed_and_csv_is_always_one_of_them(): + assert EXTRACT_FORMATS == ("csv", "xlsx") + + +def test_csv_writes_the_heading_row_and_one_row_per_part(tmp_path): + target = tmp_path / "bom.csv" + result = write_rows(_rows(), str(target), fmt="csv") + assert result["ok"] is True and result["rows"] == 2 and result["format"] == "csv" + with open(target, newline="", encoding="utf-8") as handle: + written = list(csv.reader(handle)) + assert written[0] == ["POS", "QTY", "DESIGNATION", "STANDARD", "MATERIAL"] + assert written[1] == ["1", "2", "ISO 4014 - M12x60", "ISO 4014", "8.8"] + assert written[2][2] == "SHAFT 40h6" + assert len(written) == 3 + + +def test_a_chosen_column_set_is_honoured(tmp_path): + target = tmp_path / "short.csv" + write_rows(_rows(), str(target), columns=("item", "designation"), fmt="csv") + with open(target, newline="", encoding="utf-8") as handle: + written = list(csv.reader(handle)) + assert written[0] == ["POS", "DESIGNATION"] + assert all(len(row) == 2 for row in written) + + +def test_an_unknown_format_is_refused_with_the_list(tmp_path): + with pytest.raises(ValueError) as excinfo: + write_rows(_rows(), str(tmp_path / "x.ods"), fmt="ods") + message = str(excinfo.value) + assert "ods" in message and "csv" in message and "xlsx" in message + + +def test_no_rows_is_refused_rather_than_writing_an_empty_file(tmp_path): + target = tmp_path / "empty.csv" + with pytest.raises(ValueError): + write_rows((), str(target), fmt="csv") + assert not target.exists() + + +def test_xlsx_available_agrees_with_the_installed_package(): + assert xlsx_available() is (find_spec("openpyxl") is not None) + + +def test_xlsx_refuses_with_the_capability_key_and_names_the_extra(tmp_path): + if xlsx_available(): + result = write_rows(_rows(), str(tmp_path / "bom.xlsx"), fmt="xlsx") + assert result["ok"] is True and (tmp_path / "bom.xlsx").exists() + return + target = tmp_path / "bom.xlsx" + with pytest.raises(UnsupportedCapabilityError) as excinfo: + write_rows(_rows(), str(target), fmt="xlsx") + assert excinfo.value.capability == "xlsx_write" + assert "openpyxl" in str(excinfo.value) + assert "csv" in str(excinfo.value), "a refusal that does not name the way out is half a refusal" + assert not target.exists() diff --git a/tests/test_sheet_frames.py b/tests/test_sheet_frames.py new file mode 100644 index 0000000..94b6d40 --- /dev/null +++ b/tests/test_sheet_frames.py @@ -0,0 +1,301 @@ +"""ISO 5457 sheet frames: sizes, margins, the zone grid, centring and trim marks. + +The frame is the one thing every drawing this server makes carries, so a wrong +margin or a wrong division count is visible on all of them. Every number +asserted here is transcribed in `engineering/sheet/frames.py` with its clause. +""" + +from __future__ import annotations + +import pytest + +from engineering.sheet.frames import ( + CENTRING_OVERSHOOT, + EDGE_MARGIN, + FILING_MARGIN, + SHEETS, + TRIM_MARK_LONG, + TRIM_MARK_SHORT, + ZONE_DIVISIONS, + ZONE_MODULE, + frame_metrics, + sheet_size, + zone_divisions, +) + +EPS = 1e-9 + + +def test_sheet_sizes_are_the_iso_216_a_series(): + assert SHEETS == { + "A4": (210.0, 297.0), + "A3": (297.0, 420.0), + "A2": (420.0, 594.0), + "A1": (594.0, 841.0), + "A0": (841.0, 1189.0), + } + + +def test_sheet_sizes_agree_with_the_page_setup_table(): + """Two tables of one standard in one repository is one too many unless + they are pinned to each other.""" + from engineering.standards.papers import PAPER_SIZES + + for name, (w, h) in SHEETS.items(): + assert PAPER_SIZES[f"ISO_{name}"] == (int(w), int(h)) + + +def test_landscape_swaps_the_portrait_pair(): + assert sheet_size("A3", "landscape") == (420.0, 297.0) + assert sheet_size("A3", "portrait") == (297.0, 420.0) + assert sheet_size("a3") == (420.0, 297.0) + + +def test_an_unknown_sheet_is_refused_by_name_with_the_coverage(): + with pytest.raises(ValueError) as excinfo: + sheet_size("A5") + message = str(excinfo.value) + assert "A5" in message + for name in SHEETS: + assert name in message + + +def test_an_unknown_orientation_is_refused(): + with pytest.raises(ValueError) as excinfo: + sheet_size("A3", "diagonal") + assert "diagonal" in str(excinfo.value) + + +def test_margins_are_the_iso_5457_filing_20_and_edge_10(): + assert (FILING_MARGIN, EDGE_MARGIN) == (20.0, 10.0) + m = frame_metrics("A3") + assert m["margins"] == {"left": 20.0, "right": 10.0, "top": 10.0, "bottom": 10.0} + assert m["frame"] == [20.0, 10.0, 410.0, 287.0] + assert m["drawing_area"] == [390.0, 277.0] + assert m["centre"] == [210.0, 148.5] + + +def test_a4_portrait_drawing_area_is_exactly_the_titleblock_width(): + """ISO 7200's 180 mm title block has to fit the smallest sheet.""" + m = frame_metrics("A4", orientation="portrait") + assert m["drawing_area"][0] == pytest.approx(180.0, abs=EPS) + + +@pytest.mark.parametrize( + ("size", "orientation", "columns", "rows"), + [ + ("A4", "portrait", 4, 6), + ("A4", "landscape", 6, 4), + ("A3", "landscape", 8, 6), + ("A3", "portrait", 6, 8), + ("A2", "landscape", 12, 8), + ("A2", "portrait", 8, 12), + ("A1", "landscape", 16, 12), + ("A0", "landscape", 24, 16), + ("A0", "portrait", 16, 24), + ], +) +def test_zone_division_counts_match_the_authored_table(size, orientation, columns, rows): + z = frame_metrics(size, orientation=orientation)["zones"] + assert (z["columns"], z["rows"]) == (columns, rows) + long_side, short_side = ZONE_DIVISIONS[size] + if orientation == "landscape": + assert (z["columns"], z["rows"]) == (long_side, short_side) + else: + assert (z["columns"], z["rows"]) == (short_side, long_side) + + +def test_zone_divisions_follow_the_even_rule_over_50_mm(): + assert ZONE_MODULE == 50.0 + assert zone_divisions(390.0) == 8 # 7.80 -> 8 + assert zone_divisions(277.0) == 6 # 5.54 -> 6 + assert zone_divisions(564.0) == 12 # 11.28 -> 12 + assert zone_divisions(1159.0) == 24 # 23.18 -> 24 + assert zone_divisions(821.0) == 16 # 16.42 -> 16 + + +def test_zone_labels_cover_every_cell_and_letters_start_at_the_top(): + z = frame_metrics("A0")["zones"] + assert z["numbers"][0] == "1" and z["numbers"][-1] == "24" + assert len(z["numbers"]) == 24 + assert z["letters"][0] == "A" and z["letters"][-1] == "P" + assert len(z["letters"]) == 16 + assert z["column_width"] == pytest.approx(1159.0 / 24.0, abs=EPS) + assert z["row_height"] == pytest.approx(821.0 / 16.0, abs=EPS) + + +def test_the_mark_constants_are_the_standard_s(): + assert CENTRING_OVERSHOOT == 5.0 + assert (TRIM_MARK_LONG, TRIM_MARK_SHORT) == (10.0, 5.0) + + +# ── primitives ────────────────────────────────────────────────────────────── + +from engineering.mech.primitives import Line, Poly, Text # noqa: E402 +from engineering.sheet.frames import frame_prims # noqa: E402 + + +def _polys(prims): + return [p for p in prims if isinstance(p, Poly)] + + +def _lines(prims): + return [p for p in prims if isinstance(p, Line)] + + +def _texts(prims): + return [p for p in prims if isinstance(p, Text)] + + +def test_the_first_two_primitives_are_the_trimmed_sheet_and_the_frame(): + prims = frame_prims("A3", zones=False, marks=False) + assert len(prims) == 2 + sheet, frame = prims + assert isinstance(sheet, Poly) and sheet.closed and sheet.role == "visible" + assert sheet.points == ((0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)) + assert frame.points == ((20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)) + + +def test_the_zone_grid_has_a_tick_pair_per_interior_boundary_and_a_label_pair_per_cell(): + prims = frame_prims("A3", zones=True, marks=False) + # 7 interior column boundaries x 2 edges + 5 interior row boundaries x 2 edges + assert len(_lines(prims)) == (8 - 1) * 2 + (6 - 1) * 2 + # 8 columns x 2 edges + 6 rows x 2 edges + assert len(_texts(prims)) == 8 * 2 + 6 * 2 + assert {t.text for t in _texts(prims)} == { + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "A", + "B", + "C", + "D", + "E", + "F", + } + + +def test_zone_ticks_span_the_margin_band_only(): + prims = frame_prims("A3", zones=True, marks=False) + for line in [ln for ln in _lines(prims) if ln.p1[0] == ln.p2[0]]: + ys = sorted((line.p1[1], line.p2[1])) + assert ys in ([0.0, 10.0], [287.0, 297.0]), f"tick {line} leaves the margin band" + for line in [ln for ln in _lines(prims) if ln.p1[1] == ln.p2[1]]: + xs = sorted((line.p1[0], line.p2[0])) + assert xs in ([0.0, 20.0], [410.0, 420.0]), f"tick {line} leaves the margin band" + + +def test_the_first_zone_letter_is_at_the_top_of_the_sheet(): + prims = frame_prims("A3", zones=True, marks=False) + a_labels = [t for t in _texts(prims) if t.text == "A"] + f_labels = [t for t in _texts(prims) if t.text == "F"] + assert len(a_labels) == 2 and len(f_labels) == 2 + assert min(t.at[1] for t in a_labels) > max(t.at[1] for t in f_labels) + + +def test_centring_marks_run_from_the_trimmed_edge_to_5_mm_inside_the_frame(): + prims = frame_prims("A3", zones=False, marks=True) + centre_lines = [p for p in _lines(prims) if p.role == "center"] + assert len(centre_lines) == 4 + spans = {(tuple(c.p1), tuple(c.p2)) for c in centre_lines} + assert ((0.0, 148.5), (25.0, 148.5)) in spans + assert ((420.0, 148.5), (405.0, 148.5)) in spans + assert ((210.0, 0.0), (210.0, 15.0)) in spans + assert ((210.0, 297.0), (210.0, 282.0)) in spans + + +def test_each_corner_carries_two_overlapping_10_by_5_trim_rectangles(): + prims = frame_prims("A3", zones=False, marks=True) + rects = _polys(prims)[2:] # after the trimmed sheet and the frame + assert len(rects) == 8 + for rect in rects: + xs = [p[0] for p in rect.points] + ys = [p[1] for p in rect.points] + span = (abs(max(xs) - min(xs)), abs(max(ys) - min(ys))) + assert span in ((10.0, 5.0), (5.0, 10.0)), span + assert rect.closed + + +# ── drawing ───────────────────────────────────────────────────────────────── + +from engineering.sheet.frames import draw_sheet_frame # noqa: E402 + + +@pytest.mark.asyncio +async def test_draw_sheet_frame_puts_the_border_on_titleblock_and_the_zones_on_text(backend): + result = await draw_sheet_frame(backend, "A3", zones=True, marks=True) + assert result["ok"] is True + assert result["size"] == "A3" + assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]} + outer = await backend.entity_get(result["outer_border"]) + inner = await backend.entity_get(result["inner_border"]) + assert outer.layer == "TITLEBLOCK" and inner.layer == "TITLEBLOCK" + texts = [ + e for e in await backend.entity_list(type_filter="TEXT") if e.properties["text"] == "A" + ] + assert texts and all(e.layer == "TEXT" for e in texts) + + +@pytest.mark.asyncio +async def test_draw_sheet_frame_offsets_every_primitive_by_the_origin(backend): + result = await draw_sheet_frame( + backend, "A4", orientation="portrait", zones=False, marks=False, origin=(100.0, 50.0) + ) + assert result["bbox"] == {"min": [100.0, 50.0], "max": [310.0, 347.0]} + assert len(result["handles"]) == 2 + + +@pytest.mark.asyncio +async def test_a_bad_size_is_refused_before_anything_is_drawn(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError): + await draw_sheet_frame(backend, "A5") + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_a_missing_layout_is_refused_before_anything_is_drawn(backend): + before = len(await backend.entity_list()) + with pytest.raises(RuntimeError) as excinfo: + await draw_sheet_frame(backend, "A3", layout="NoSuchSheet") + assert "NoSuchSheet" in str(excinfo.value) + assert "layout_create" in str(excinfo.value) + assert len(await backend.entity_list()) == before + + +@pytest.mark.asyncio +async def test_the_frame_puts_the_caller_back_on_the_tab_it_found_them_on(backend): + await backend.layout_create("SheetA") + await backend.layout_create("SheetB") + await backend.layout_set_current("SheetB") + await draw_sheet_frame(backend, "A3", zones=False, marks=False, layout="SheetA") + assert (await backend.layout_list())["current"] == "SheetB" + + +@pytest.mark.asyncio +async def test_the_caller_s_tab_is_read_from_the_listing_not_a_private_attribute( + backend, backend_without_private_state +): + """`_current_space` exists on EzdxfBackend only. ComBackend keeps the live + tab in AutoCAD, so reading the private attribute there fell through to the + ``getattr`` default and dropped a live caller onto Model after every + border -- measured on AutoCAD 2026. `layout_list()["current"]` is the value + both engines really report, and `enter_layout` already had it in hand. + """ + await backend.layout_create("SheetA") + await backend.layout_create("SheetB") + await backend.layout_set_current("SheetB") + with pytest.raises(AttributeError): + _ = backend_without_private_state._current_space + + result = await draw_sheet_frame( + backend_without_private_state, "A3", zones=False, marks=False, layout="SheetA" + ) + + assert result["layout"] == "SheetA" + assert (await backend.layout_list())["current"] == "SheetB" diff --git a/tests/test_sheet_revision.py b/tests/test_sheet_revision.py new file mode 100644 index 0000000..516f72a --- /dev/null +++ b/tests/test_sheet_revision.py @@ -0,0 +1,185 @@ +"""Revision rows, revision clouds and the triangular revision tags.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Poly, Text +from engineering.sheet.revision import ( + REVISION_COLUMNS, + REVISION_LABELS, + TAG_SIDE, + add_revision, + read_revisions, + revision_block_prims, + revision_heading_prims, + revision_row_prims, + revision_tag_prims, +) + +EPS = 1e-9 + + +def test_the_revision_columns_are_the_iso_7200_version_fields(): + assert REVISION_COLUMNS == ("rev", "description", "date", "by") + for column in REVISION_COLUMNS: + assert column in REVISION_LABELS + + +def test_a_tag_is_an_equilateral_triangle_carrying_the_letter(): + prims = revision_tag_prims("B", (100.0, 200.0)) + triangle = next(p for p in prims if isinstance(p, Poly)) + text = next(p for p in prims if isinstance(p, Text)) + assert triangle.closed and len(triangle.points) == 3 + xs = [p[0] for p in triangle.points] + assert max(xs) - min(xs) == pytest.approx(TAG_SIDE, abs=EPS) + assert text.text == "B" + + +def test_the_block_is_180_mm_wide_and_grows_downwards_from_its_anchor(): + rows = [ + {"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"}, + {"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"}, + ] + prims = revision_block_prims(rows, at=(230.0, 287.0)) + xs = [c[0] for p in prims if isinstance(p, Poly) for c in p.points] + xs += [v for p in prims if hasattr(p, "p1") for v in (p.p1[0], p.p2[0])] + assert min(xs) == pytest.approx(230.0, abs=EPS) + assert max(xs) == pytest.approx(410.0, abs=EPS) + ys = [v for p in prims if hasattr(p, "p1") for v in (p.p1[1], p.p2[1])] + assert max(ys) == pytest.approx(287.0, abs=EPS) + assert min(ys) == pytest.approx(287.0 - 3 * 7.0, abs=EPS) # heading + 2 rows + + +def test_the_block_built_row_by_row_is_the_block_built_at_once(): + """`add_revision` appends one band at a time; the whole-block builder is + what the tests read. They must be the same geometry or the second revision + would not line up with the first.""" + rows = [ + {"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"}, + {"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"}, + ] + at = (230.0, 287.0) + incremental = list(revision_heading_prims(at=at)) + for index, row in enumerate(rows): + incremental.extend(revision_row_prims(row, at=at, index=index)) + assert tuple(incremental) == revision_block_prims(rows, at=at) + + +def test_a_rows_first_text_primitive_is_its_rev_cell(): + """`add_revision` hangs the payload on that primitive, so this is the link + between the drawn row and the row a reader gets back.""" + band = revision_row_prims( + {"rev": "B", "description": "", "date": "", "by": ""}, at=(230.0, 287.0), index=0 + ) + first_text = next(p for p in band if isinstance(p, Text)) + assert first_text.text == "B" + + +@pytest.mark.parametrize("bad", ["", "revision b", "AB CD", "b!"]) +def test_a_malformed_revision_code_is_refused(bad): + with pytest.raises(ValueError) as excinfo: + revision_tag_prims(bad, (0.0, 0.0)) + assert "revision" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_a_revision_row_is_written_once_per_letter(backend): + first = await add_revision( + backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC" + ) + assert first["created"] is True + count = len(await backend.entity_list()) + again = await add_revision( + backend, rev="B", description="bore 40H7 (again)", date="2026-03-12", by="UC" + ) + assert again["created"] is False + assert again["handle"] == first["handle"] + assert len(await backend.entity_list()) == count, "a repeated revision must draw nothing" + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["B"] + assert rows[0]["description"] == "bore 40H7" + + +@pytest.mark.asyncio +async def test_two_revisions_stack_and_read_back_in_order(backend): + await add_revision(backend, rev="A", description="first issue", date="2026-01-05", by="UC") + await add_revision(backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC") + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_clouds_and_tags_are_drawn_when_asked(backend): + result = await add_revision( + backend, + rev="C", + description="slot widened", + clouds=[[10.0, 10.0, 60.0, 40.0]], + tags=[[65.0, 45.0]], + ) + assert len(result["clouds"]) == 1 + assert len(result["tags"]) == 1 + tag_texts = [ + e for e in await backend.entity_list(type_filter="TEXT") if e.properties["text"] == "C" + ] + assert tag_texts + + +@pytest.mark.asyncio +async def test_clouds_are_refused_before_any_row_when_the_engine_has_no_revcloud( + backend, monkeypatch +): + from backends.base import CapabilityMap, FeatureCapability + + original = backend.capabilities() + features = dict(original.features) + features["revcloud"] = FeatureCapability(False, reason="no_activex_member_command_only") + monkeypatch.setattr( + backend, "capabilities", lambda: CapabilityMap(backend=original.backend, features=features) + ) + before = len(await backend.entity_list()) + from backends.base import UnsupportedCapabilityError + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await add_revision(backend, rev="D", description="x", clouds=[[0.0, 0.0, 10.0, 10.0]]) + assert excinfo.value.capability == "revcloud" + assert len(await backend.entity_list()) == before + assert (await read_revisions(backend)) == () + + +@pytest.mark.asyncio +async def test_a_revision_on_a_layout_is_still_written_once_per_letter(backend): + """`read_revisions` follows the current space, so reading it before + `enter_layout` inspected Model while the block went on the layout -- and + every rerun stacked a second heading band on top of the first.""" + await backend.layout_create("SHEET1") + first = await add_revision(backend, rev="B", description="bore 40H7", layout="SHEET1") + assert first["created"] is True + again = await add_revision(backend, rev="B", description="bore 40H7", layout="SHEET1") + assert again["created"] is False + assert again["handle"] == first["handle"] + assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back" + await backend.layout_set_current("SHEET1") + try: + texts = [e.properties["text"] for e in await backend.entity_list(type_filter="TEXT")] + assert texts == ["REV", "DESCRIPTION", "DATE", "BY", "B", "bore 40H7"] + rows = await read_revisions(backend) + assert [r["rev"] for r in rows] == ["B"] + finally: + await backend.layout_set_current("Model") + + +@pytest.mark.asyncio +async def test_revisions_are_read_past_the_entity_list_page(backend): + """200 notes fill `entity_list`'s default page. The idempotency guard used + to see no revisions at all, and `index = len(existing)` restarted at 0.""" + for index in range(200): + await backend.entity_create_text(text=f"NOTE {index}", x=float(index), y=500.0, height=2.5) + first = await add_revision(backend, rev="A", description="first issue") + assert first["row"] == 1 + assert (await add_revision(backend, rev="A", description="first issue"))["created"] is False + second = await add_revision(backend, rev="B", description="bore 40H7") + assert second["row"] == 2, "the second band must sit below the first, not on top of it" + assert second["bbox"]["min"][1] < first["bbox"]["min"][1] + assert [r["rev"] for r in await read_revisions(backend)] == ["A", "B"] diff --git a/tests/test_sheet_titleblock.py b/tests/test_sheet_titleblock.py new file mode 100644 index 0000000..f067192 --- /dev/null +++ b/tests/test_sheet_titleblock.py @@ -0,0 +1,264 @@ +"""ISO 7200 title blocks for A4-A0, and the A3 geometry that must not move.""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import Circle, Line, Poly, Text +from engineering.sheet.titleblock import ( + TB_HEIGHT, + TB_WIDTH, + TITLE_TEXT_INDEX, + TITLEBLOCK_FIELDS, + TITLEBLOCK_RULE_COUNT, + VALUE_FIELDS, + TitleBlockMetadata, + apply_titleblock, + projection_symbol_prims, + titleblock_origin, + titleblock_prims, + value_text_indices, +) + +EPS = 1e-9 + + +def _meta(**overrides) -> TitleBlockMetadata: + base = dict( + title="HELICAL GEAR", + drawing_no="AM-2026-001", + part_no="GR-24T-M3", + material="C45", + scale="1:1", + units="mm", + drawn_by="Umutcan", + checked_by="QA", + date="2026-04-28", + sheet="1/1", + revision="A", + company="Anka-Makine", + ) + base.update(overrides) + return TitleBlockMetadata(**base) + + +def test_the_block_is_the_iso_7200_180_mm_wide_and_sits_in_the_frame_corner(): + assert TB_WIDTH == 180.0 and TB_HEIGHT == 60.0 + assert titleblock_origin("A3") == (230.0, 10.0) + assert titleblock_origin("A4", orientation="portrait") == (20.0, 10.0) + assert titleblock_origin("A0") == (999.0, 10.0) + + +def test_every_declared_field_is_a_metadata_attribute(): + meta = _meta() + for field in TITLEBLOCK_FIELDS: + assert hasattr(meta, field), field + + +def test_the_a3_rules_are_exactly_where_they_have_always_been(): + prims = titleblock_prims("A3", _meta(), projection=None) + rules = prims[:TITLEBLOCK_RULE_COUNT] + assert isinstance(rules[0], Poly) and rules[0].closed + assert rules[0].points == ((230.0, 10.0), (410.0, 10.0), (410.0, 70.0), (230.0, 70.0)) + assert [(r.p1[1], r.p1[0], r.p2[0]) for r in rules[1:4]] == [ + (20.0, 230.0, 410.0), + (35.0, 230.0, 410.0), + (50.0, 230.0, 410.0), + ] + assert [(r.p1[0], r.p1[1], r.p2[1]) for r in rules[4:]] == [ + (320.0, 35.0, 50.0), + (365.0, 35.0, 50.0), + (290.0, 20.0, 35.0), + (350.0, 20.0, 35.0), + (275.0, 10.0, 20.0), + (320.0, 10.0, 20.0), + (365.0, 10.0, 20.0), + ] + + +def test_the_a3_value_texts_are_exactly_where_they_have_always_been(): + prims = titleblock_prims("A3", _meta(), projection=None) + index = value_text_indices() + assert prims[index["drawing_no"]] == Text(at=(233.0, 40.0), text="AM-2026-001", height=3.5) + assert prims[index["revision"]] == Text(at=(323.0, 40.0), text="A", height=3.5) + assert prims[index["sheet"]] == Text(at=(368.0, 40.0), text="1/1", height=3.5) + assert prims[index["part_no"]] == Text(at=(233.0, 25.0), text="GR-24T-M3", height=3.5) + assert prims[index["material"]] == Text(at=(293.0, 25.0), text="C45", height=3.5) + assert prims[index["scale"]] == Text(at=(353.0, 25.0), text="1:1", height=3.5) + assert prims[index["drawn_by"]] == Text(at=(233.0, 11.5), text="Umutcan", height=3.5) + assert prims[index["checked_by"]] == Text(at=(278.0, 11.5), text="QA", height=3.5) + assert prims[index["date"]] == Text(at=(323.0, 11.5), text="2026-04-28", height=3.5) + assert prims[index["company"]] == Text(at=(368.0, 11.5), text="Anka-Makine", height=3.5) + + +def test_the_title_sits_at_the_pinned_index_and_carries_the_text_verbatim(): + prims = titleblock_prims("A3", _meta(title="SPUR GEAR / M3 Z24"), projection=None) + title = prims[TITLE_TEXT_INDEX] + assert isinstance(title, Text) + assert title.text == "SPUR GEAR / M3 Z24" + assert title.height == 5.0 + assert title.at[1] == pytest.approx(57.5, abs=EPS) + + +def test_projection_none_draws_no_symbol_and_first_draws_one(): + plain = titleblock_prims("A3", _meta(), projection=None) + first = titleblock_prims("A3", _meta(), projection="first") + assert len(plain) == TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS) + assert len(first) == len(plain) + 4 + + +#: The ISO 5456-2 / ISO 128-30 projection symbol, transcribed from two +#: independent reproductions of the standard's figure (ISO's own is paywalled +#: and was not read). Each entry is that source's OWN coordinates, measured off +#: the file, and the landmark order left-to-right is what the symbol means. +#: +#: 1. FreeCAD's ISO 5457 sheet template, first-angle symbol -- +#: src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg, +#: ids `first_angle_trapezoid`, `first_angle_inner circle`, +#: `first_angle_outer circle`. +#: 2. Wikimedia Commons `Convention placement vues dessin technique.svg`, which +#: draws both symbols side by side, labelled FR (first) and US (third). +#: +#: A test that read these numbers out of `projection_symbol_prims` instead +#: would pass whichever way round the symbol was drawn, which is exactly how +#: the mirrored third-angle cone shipped in the first place. +REFERENCE_FIGURES = { + "first": ( + # source, short-side x, long-side x, end-view (circles) x + ("FreeCAD A3_Landscape_ISO5457_advanced.svg", 379.0, 389.0, 396.0), + ("Commons Convention placement vues dessin technique.svg (FR)", 13.343, 81.869, 148.005), + ), + "third": ( + ("Commons Convention placement vues dessin technique.svg (US)", 380.275, 448.801, 314.140), + ), +} + + +#: The three landmarks, in the order `_landmarks` and REFERENCE_FIGURES list them. +LANDMARKS = ("short", "long", "circles") + + +def _landmarks(angle: str) -> tuple[float, float, float]: + """(short-side x, long-side x, end-view x) of the symbol this module draws.""" + prims = projection_symbol_prims((0.0, 0.0), angle) + cone = next(p for p in prims if isinstance(p, Poly)) + circles = [p for p in prims if isinstance(p, Circle)] + left_x = min(x for x, _ in cone.points) + right_x = max(x for x, _ in cone.points) + left_h = max(y for x, y in cone.points if x == left_x) * 2 + right_h = max(y for x, y in cone.points if x == right_x) * 2 + short_x, long_x = (left_x, right_x) if left_h < right_h else (right_x, left_x) + assert len({c.center[0] for c in circles}) == 1 + return short_x, long_x, circles[0].center[0] + + +@pytest.mark.parametrize("angle", ["first", "third"]) +def test_the_symbol_lands_the_landmarks_in_the_order_the_reference_figures_do(angle): + """The meaning of the symbol is the left-to-right order of three landmarks: + the cone's short side, its long side and the end view. Assert that order + against the measured figures, not against what the module computes.""" + mine = _landmarks(angle) + for source, *reference in REFERENCE_FIGURES[angle]: + expected = [name for _x, name in sorted(zip(reference, LANDMARKS, strict=True))] + got = [name for _x, name in sorted(zip(mine, LANDMARKS, strict=True))] + assert got == expected, f"{angle}-angle symbol disagrees with {source}" + + +def test_the_short_side_points_away_from_the_circles_in_first_and_towards_in_third(): + """The mirror-invariant half of the rule, and the one every source states + in words: Wikipedia's Multiview orthographic projection, Symbol -- + "the first-angle symbol shows the trapezoid with its shortest side away + from the circles", "the third-angle symbol shows the trapezoid with its + shortest side towards the circles".""" + short_x, long_x, circles_x = _landmarks("first") + assert abs(short_x - circles_x) > abs(long_x - circles_x) + short_x, long_x, circles_x = _landmarks("third") + assert abs(short_x - circles_x) < abs(long_x - circles_x) + + +def test_the_symbol_keeps_the_reference_figures_proportions_and_its_axis(): + """Both reference figures draw the end view as two circles of ratio 1:2 + (FreeCAD r 2.5 / r 5) on a centre line; the absolute size is this + module's.""" + first = projection_symbol_prims((0.0, 0.0), "first") + circles = [p for p in first if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {4.0, 2.0} + assert any(isinstance(p, Line) and p.role == "center" for p in first) + + +def test_the_two_symbols_are_not_the_same_picture(): + assert _landmarks("first") != _landmarks("third") + + +def test_an_unknown_projection_angle_is_refused(): + with pytest.raises(ValueError) as excinfo: + titleblock_prims("A3", _meta(), projection="second") + assert "second" in str(excinfo.value) + + +def test_a_sheet_narrower_than_the_title_block_is_refused_by_name(): + """There is no such size in A4-A0; the guard exists so a future size cannot + ship a title block hanging off the paper.""" + from engineering.sheet import frames + + original = dict(frames.SHEETS) + frames.SHEETS["A9"] = (100.0, 140.0) + try: + with pytest.raises(ValueError) as excinfo: + titleblock_origin("A9", orientation="portrait") + assert "180" in str(excinfo.value) + finally: + frames.SHEETS.clear() + frames.SHEETS.update(original) + + +@pytest.mark.asyncio +async def test_apply_titleblock_returns_the_payload_the_a3_tool_always_returned(backend): + result = await apply_titleblock(backend, size="A3", metadata=_meta(), projection=None) + assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]} + assert set(result["value_texts"]) == { + "drawing_no", + "revision", + "sheet", + "part_no", + "material", + "scale", + "drawn_by", + "checked_by", + "date", + "company", + } + assert len(result["titleblock_lines"]) == TITLEBLOCK_RULE_COUNT + title = await backend.entity_get(result["title_text"]) + assert title.type == "TEXT" and title.properties["text"] == "HELICAL GEAR" + assert title.layer == "TEXT" + for handle in result["titleblock_lines"]: + assert (await backend.entity_get(handle)).layer == "TITLEBLOCK" + assert result["projection_symbol"] == [] + + +@pytest.mark.asyncio +async def test_apply_titleblock_draws_every_size_with_its_symbol(backend): + for size in ("A4", "A3", "A2", "A1", "A0"): + orientation = "portrait" if size == "A4" else "landscape" + result = await apply_titleblock( + backend, size=size, metadata=_meta(), orientation=orientation + ) + assert result["ok"] is True and result["size"] == size + assert len(result["projection_symbol"]) == 4 + + +@pytest.mark.asyncio +async def test_the_titleblock_puts_the_caller_back_on_the_tab_it_found_them_on( + backend, backend_without_private_state +): + """`apply_titleblock` routes through the same `enter_layout`, so the live + engine dropped the caller onto Model here too.""" + await backend.layout_create("SheetA") + await backend.layout_create("SheetB") + await backend.layout_set_current("SheetB") + result = await apply_titleblock( + backend_without_private_state, size="A3", metadata=_meta(), layout="SheetA" + ) + assert result["layout"] == "SheetA" + assert (await backend.layout_list())["current"] == "SheetB" diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 213391f..bafc58a 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 (209 tools, 23 groups). + """Frozen snapshot of the surface (229 tools, 24 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 @@ -527,7 +527,26 @@ async def test_tool_group_sizes_are_unchanged(): merge task tagged them, the four groups had filed those 28 tools under `layouts` / `templates` / `drawing` / `layers` / `view` / `system` by their secondary tags; those counts returned to their pre-track-E values - when the section tags landed.) Every other number here has been unchanged + when the section tags landed.) + `engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with + `sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200 + title block for every size A4-A0). Both are tagged `engineering` and + `sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under + `engineering` beside `titleblock_apply_iso_a3` rather than opening a + group of their own. + It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and + `balloon_add` joined the same section; `bom_extract` also carries `query`, + but `engineering` outranks it in `_GROUP_TAG_PRIORITY`. + The section's last five tools file elsewhere on purpose: they are + whole-drawing and entity operations, not sheet furniture, and + `_GROUP_TAG_PRIORITY` files them where a caller would look. + `xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block` + outranks `drawing`, so they file under `blocks` (9 -> 11) beside + `block_insert` -- which is where an xref belongs: it is a block definition + that lives in another file. `image_attach` carries `create` + (entity_creation 18 -> 19), `data_extract` carries `engineering` + (16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12). + Every other number here has been unchanged since the snapshot was taken. `mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with @@ -545,18 +564,18 @@ async def test_tool_group_sizes_are_unchanged(): `weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They are tagged `engineering` and `mech`; with `mech` ranked ahead of `engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all, - exactly `PACK_TOOL_NAMES["mech"]`), and `engineering` stays at 10. + exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { "analysis": 12, "batch": 3, - "blocks": 9, + "blocks": 11, "corner_ops": 4, "dimensions": 5, - "drawing": 11, - "engineering": 10, - "entity_creation": 18, + "drawing": 12, + "engineering": 17, + "entity_creation": 19, "entity_modification": 16, "entity_query": 9, "environment": 22, @@ -574,4 +593,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 218 + assert sum(sizes.values()) == 229