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:
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 218 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||||
> 218 is the **registered** count; a default install advertises 213 over `tools/list`,
|
> 229 is the **registered** count; a default install advertises 224 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
## Why this exists
|
## 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 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 |
|
| 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) |
|
| 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 |
|
| 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from backends.contracts import (
|
|||||||
LinetypeContract,
|
LinetypeContract,
|
||||||
PageSetupContract,
|
PageSetupContract,
|
||||||
PremiumContract,
|
PremiumContract,
|
||||||
|
RefsContract,
|
||||||
SettingsContract,
|
SettingsContract,
|
||||||
SolidContract,
|
SolidContract,
|
||||||
StylesContract,
|
StylesContract,
|
||||||
@@ -190,6 +191,7 @@ class AutoCADBackend(
|
|||||||
SettingsContract,
|
SettingsContract,
|
||||||
StylesContract,
|
StylesContract,
|
||||||
EnvironmentContract,
|
EnvironmentContract,
|
||||||
|
RefsContract,
|
||||||
):
|
):
|
||||||
"""All backends implement this interface.
|
"""All backends implement this interface.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from importlib.util import find_spec
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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]):
|
def _av(values: list[float]):
|
||||||
"""Create a VARIANT double-array from a flat list."""
|
"""Create a VARIANT double-array from a flat list."""
|
||||||
return win32com.client.VARIANT(
|
return win32com.client.VARIANT(
|
||||||
@@ -371,6 +394,24 @@ def _require_block_defined(doc, name):
|
|||||||
return block
|
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"}
|
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
|
||||||
|
|
||||||
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
|
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
|
||||||
@@ -1536,6 +1577,16 @@ class ComBackend(AutoCADBackend):
|
|||||||
"interactive_prompt": FeatureCapability(
|
"interactive_prompt": FeatureCapability(
|
||||||
True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
|
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:
|
async def drawing_export_dxf(self, path: str) -> dict:
|
||||||
return await self.drawing_save_as(path, "dxf")
|
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:
|
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.
|
"""Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from backends.contracts.layouts import LayoutContract
|
|||||||
from backends.contracts.linetypes import LinetypeContract
|
from backends.contracts.linetypes import LinetypeContract
|
||||||
from backends.contracts.pagesetup import PageSetupContract
|
from backends.contracts.pagesetup import PageSetupContract
|
||||||
from backends.contracts.premium import PremiumContract
|
from backends.contracts.premium import PremiumContract
|
||||||
|
from backends.contracts.refs import RefsContract
|
||||||
from backends.contracts.settings import SettingsContract
|
from backends.contracts.settings import SettingsContract
|
||||||
from backends.contracts.solids import SolidContract
|
from backends.contracts.solids import SolidContract
|
||||||
from backends.contracts.styles import StylesContract
|
from backends.contracts.styles import StylesContract
|
||||||
@@ -48,6 +49,7 @@ __all__ = [
|
|||||||
"LinetypeContract",
|
"LinetypeContract",
|
||||||
"PageSetupContract",
|
"PageSetupContract",
|
||||||
"PremiumContract",
|
"PremiumContract",
|
||||||
|
"RefsContract",
|
||||||
"SettingsContract",
|
"SettingsContract",
|
||||||
"SolidContract",
|
"SolidContract",
|
||||||
"StylesContract",
|
"StylesContract",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -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:
|
def _audit_entry(entry) -> dict:
|
||||||
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
|
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
|
||||||
|
|
||||||
@@ -1676,6 +1709,30 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"interactive_prompt": FeatureCapability(
|
"interactive_prompt": FeatureCapability(
|
||||||
False, reason="no_operator_to_prompt_headlessly"
|
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:
|
async def drawing_export_dxf(self, path: str) -> dict:
|
||||||
return await self.drawing_save_as(path, "dxf")
|
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:
|
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
|
||||||
"""Export to PDF via ezdxf's matplotlib backend.
|
"""Export to PDF via ezdxf's matplotlib backend.
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ class ToolAliases:
|
|||||||
# OPTIONS -- one dialog both reads and writes a preference; the server
|
# OPTIONS -- one dialog both reads and writes a preference; the server
|
||||||
# splits read (system_preferences_get) and write (system_preferences_set),
|
# splits read (system_preferences_get) and write (system_preferences_set),
|
||||||
# the SETVAR precedent.
|
# 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
|
# 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
|
# 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
|
# 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",
|
"PLINE",
|
||||||
"PLOT",
|
"PLOT",
|
||||||
"QSELECT",
|
"QSELECT",
|
||||||
|
"REVCLOUD",
|
||||||
"SAVEAS",
|
"SAVEAS",
|
||||||
"SECTIONPLANE",
|
"SECTIONPLANE",
|
||||||
"SETVAR",
|
"SETVAR",
|
||||||
"STYLE",
|
"STYLE",
|
||||||
|
"TABLE",
|
||||||
"UCS",
|
"UCS",
|
||||||
"VIEW",
|
"VIEW",
|
||||||
"ZOOM",
|
"ZOOM",
|
||||||
@@ -994,6 +1001,156 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"drawing header",
|
"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 ──────────────────────────────────────────────────
|
# ── Layers & linetypes ──────────────────────────────────────────────────
|
||||||
"layer_create": ToolAliases(
|
"layer_create": ToolAliases(
|
||||||
acad=("LAYER", "LA"),
|
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"{caller}: layout {layout!r} does not exist "
|
||||||
f"(have: {', '.join(sorted(names))}). Create it with layout_create first."
|
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)
|
switched = await backend.layout_set_current(layout)
|
||||||
if not switched.get("ok", True):
|
if not switched.get("ok", True):
|
||||||
raise RuntimeError(f"{caller}: {switched.get('error')}")
|
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
|
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")
|
first = projection_symbol_prims((0.0, 0.0), "first")
|
||||||
third = projection_symbol_prims((0.0, 0.0), "third")
|
circles = [p for p in first if isinstance(p, Circle)]
|
||||||
cone_first = next(p for p in first if isinstance(p, Poly))
|
assert {round(c.radius, 6) for c in circles} == {4.0, 2.0}
|
||||||
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}
|
|
||||||
assert any(isinstance(p, Line) and p.role == "center" for p in first)
|
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():
|
def test_the_two_symbols_are_not_the_same_picture():
|
||||||
"""The derivation only holds if the view drawn as circles really is the
|
assert _landmarks("first") != _landmarks("third")
|
||||||
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_an_unknown_projection_angle_is_refused():
|
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, ...]:
|
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
|
ARRANGEMENT -- transcribed, not derived. The symbol is a truncated cone
|
||||||
horizontal axis and its small end facing left. The first- and third-angle
|
drawn in two views: the cone in elevation (a trapezoid) and the end view
|
||||||
symbols are mirror images about a vertical axis, and which is which follows
|
(two concentric circles). Which side the end view sits on is what makes the
|
||||||
from the projection rule itself: first-angle projection puts the view seen
|
symbol mean *first* or *third* angle, and it is a convention that has to be
|
||||||
from the left ON THE RIGHT, and the view of this cone seen from the left is
|
read off the standard's figure: the projection rule alone cannot pick it,
|
||||||
the small circle inside the large one. So FIRST angle draws the cone on the
|
because the same picture is a legal drawing of the mirrored object seen
|
||||||
left and the concentric circles on the right; THIRD angle is its mirror.
|
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
|
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
|
module's; ISO 5456-2's proportion table is not transcribed. They keep the
|
||||||
is the part that can be *wrong* on a drawing, and it is derived above.
|
1:2 small-to-large ratio both reference figures' circles have.
|
||||||
"""
|
"""
|
||||||
if angle not in PROJECTION_ANGLES:
|
if angle not in PROJECTION_ANGLES:
|
||||||
raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}")
|
raise ValueError(f"projection angle must be one of {PROJECTION_ANGLES}, got {angle!r}")
|
||||||
cx, cy = float(at[0]), float(at[1])
|
cx, cy = float(at[0]), float(at[1])
|
||||||
half = (PROJECTION_CONE_LENGTH + PROJECTION_GAP + 2 * PROJECTION_R) / 2.0
|
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
|
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
|
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(
|
cone = Poly(
|
||||||
points=(
|
points=(
|
||||||
(cx + cone_far, cy - PROJECTION_SMALL_R),
|
(cx + cone_left, cy - PROJECTION_SMALL_R),
|
||||||
(cx + cone_near, cy - PROJECTION_R),
|
(cx + cone_right, cy - PROJECTION_R),
|
||||||
(cx + cone_near, cy + PROJECTION_R),
|
(cx + cone_right, cy + PROJECTION_R),
|
||||||
(cx + cone_far, cy + PROJECTION_SMALL_R),
|
(cx + cone_left, cy + PROJECTION_SMALL_R),
|
||||||
),
|
),
|
||||||
closed=True,
|
closed=True,
|
||||||
)
|
)
|
||||||
|
|||||||
+160
-5
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 218,
|
"tools": 229,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 24
|
"groups": 24
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 11,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 12,
|
||||||
"engineering": 10,
|
"engineering": 17,
|
||||||
"entity_creation": 18,
|
"entity_creation": 19,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"environment": 22,
|
"environment": 22,
|
||||||
@@ -179,6 +179,20 @@
|
|||||||
"readOnlyHint": true
|
"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",
|
"name": "batch_plot",
|
||||||
"group": "page_setup",
|
"group": "page_setup",
|
||||||
@@ -304,6 +318,35 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "boundary_from_entities",
|
||||||
"group": "analysis",
|
"group": "analysis",
|
||||||
@@ -386,6 +429,21 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "datum_feature",
|
||||||
"group": "engineering",
|
"group": "engineering",
|
||||||
@@ -669,6 +727,19 @@
|
|||||||
"destructiveHint": true
|
"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",
|
"name": "drawing_export_dxf",
|
||||||
"group": "drawing",
|
"group": "drawing",
|
||||||
@@ -1635,6 +1706,20 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "keyway_draw_keyed_bore",
|
||||||
"group": "engineering",
|
"group": "engineering",
|
||||||
@@ -2325,6 +2410,20 @@
|
|||||||
"readOnlyHint": true
|
"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",
|
"name": "section_line",
|
||||||
"group": "mech",
|
"group": "mech",
|
||||||
@@ -2395,6 +2494,20 @@
|
|||||||
"readOnlyHint": true
|
"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",
|
"name": "solid_boolean",
|
||||||
"group": "solids",
|
"group": "solids",
|
||||||
@@ -2780,6 +2893,20 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "titleblock_apply_iso_a3",
|
||||||
"group": "engineering",
|
"group": "engineering",
|
||||||
@@ -3100,6 +3227,34 @@
|
|||||||
"title": "Weld Symbol (ISO 2553)",
|
"title": "Weld Symbol (ISO 2553)",
|
||||||
"destructiveHint": false
|
"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": [
|
"resources": [
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ from .preflight import (
|
|||||||
preflight_drawing,
|
preflight_drawing,
|
||||||
)
|
)
|
||||||
from .refiner import RefineResult, RefineRound, RepairAction, refine_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 .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||||
from .validator import DrawingValidator, ValidationFinding, ValidationResult
|
from .validator import DrawingValidator, ValidationFinding, ValidationResult
|
||||||
|
|
||||||
@@ -92,4 +94,9 @@ __all__ = [
|
|||||||
"RefineRound",
|
"RefineRound",
|
||||||
"RepairAction",
|
"RepairAction",
|
||||||
"refine_drawing",
|
"refine_drawing",
|
||||||
|
"apply_titleblock",
|
||||||
|
"draw_sheet_frame",
|
||||||
|
"frame_metrics",
|
||||||
|
"frame_prims",
|
||||||
|
"titleblock_prims",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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]},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from typing import TYPE_CHECKING
|
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:
|
if TYPE_CHECKING:
|
||||||
from backends.base import AutoCADBackend
|
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, SHEET_H = sheet_size("A3", "landscape")
|
||||||
SHEET_W = 420.0
|
LEFT_MARGIN = FILING_MARGIN
|
||||||
SHEET_H = 297.0
|
RIGHT_MARGIN = EDGE_MARGIN
|
||||||
LEFT_MARGIN = 20.0
|
TOP_MARGIN = EDGE_MARGIN
|
||||||
RIGHT_MARGIN = 10.0
|
BOTTOM_MARGIN = EDGE_MARGIN
|
||||||
TOP_MARGIN = 10.0
|
LAYER_BORDER = SHEET_LAYER
|
||||||
BOTTOM_MARGIN = 10.0
|
LAYER_TEXT = SHEET_TEXT_LAYER
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_iso_a3_titleblock(
|
async def apply_iso_a3_titleblock(
|
||||||
@@ -63,255 +77,25 @@ async def apply_iso_a3_titleblock(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet).
|
"""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
|
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
|
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.
|
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.
|
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.
|
||||||
"""
|
"""
|
||||||
|
return await apply_titleblock(
|
||||||
ox, oy = float(origin[0]), float(origin[1])
|
backend,
|
||||||
|
size="A3",
|
||||||
previous_space = "Model"
|
metadata=metadata,
|
||||||
if layout is not None:
|
origin=origin,
|
||||||
listing = await backend.layout_list()
|
orientation="landscape",
|
||||||
names = {str(entry) for entry in (listing.get("layouts") or [])}
|
projection=None,
|
||||||
if layout not in names:
|
frame=True,
|
||||||
raise RuntimeError(
|
zones=False,
|
||||||
f"apply_iso_a3_titleblock: layout {layout!r} does not exist "
|
marks=False,
|
||||||
f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with "
|
layout=layout,
|
||||||
"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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -40,3 +40,31 @@ async def backend():
|
|||||||
await b.drawing_new()
|
await b.drawing_new()
|
||||||
yield b
|
yield b
|
||||||
await b.disconnect()
|
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)
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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():
|
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
|
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
|
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
|
merge task tagged them, the four groups had filed those 28 tools under
|
||||||
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
||||||
their secondary tags; those counts returned to their pre-track-E values
|
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.
|
since the snapshot was taken.
|
||||||
|
|
||||||
`mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with
|
`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
|
`weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They
|
||||||
are tagged `engineering` and `mech`; with `mech` ranked ahead of
|
are tagged `engineering` and `mech`; with `mech` ranked ahead of
|
||||||
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
|
`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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 11,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 12,
|
||||||
"engineering": 10,
|
"engineering": 17,
|
||||||
"entity_creation": 18,
|
"entity_creation": 19,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
"entity_query": 9,
|
"entity_query": 9,
|
||||||
"environment": 22,
|
"environment": 22,
|
||||||
@@ -574,4 +593,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 218
|
assert sum(sizes.values()) == 229
|
||||||
|
|||||||
Reference in New Issue
Block a user