merge: tracks B+G group G

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 12:25:45 +03:00
co-authored by Claude Opus 5.5
26 changed files with 5451 additions and 342 deletions
+4 -4
View File
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **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 |
<sub>218 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>229 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+2
View File
@@ -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.
+241
View File
@@ -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.
+2
View File
@@ -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",
+103
View File
@@ -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.
"""
...
+271
View File
@@ -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.
+157
View File
@@ -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"),
@@ -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,
)
+160 -5
View File
@@ -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": [
+7
View File
@@ -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",
]
+88
View File
@@ -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",
]
+587
View File
@@ -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)
+81
View File
@@ -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,
}
+342
View File
@@ -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]},
}
+396
View File
@@ -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)
+376
View File
@@ -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]},
}
+73 -289
View File
@@ -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],
},
}
+629
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+28
View File
@@ -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)
+521
View File
@@ -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]
+389
View File
@@ -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")
+89
View File
@@ -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()
+301
View File
@@ -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"
+185
View File
@@ -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"]
+264
View File
@@ -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"
+27 -8
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""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