diff --git a/README.md b/README.md
index 9e50eda..2e3fd90 100644
--- a/README.md
+++ b/README.md
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 206 tools · 8 resources · 5 prompt templates · 3284 collected tests.
-> 206 is the **registered** count; a default install advertises 201 over `tools/list`,
+> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
**A big MCP server is expensive to be connected to.** The full catalog costs a client **59,315 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
-**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **187 AutoCAD command names and 958 synonym phrases** covering all 206 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 **187 AutoCAD command names and 990 synonym phrases** covering all 210 tools. A test refuses to let a tool exist without one.
| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-206 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+210 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index 1b2dd45..f3c6d72 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -91,6 +91,11 @@ class ToolAliases:
# OPTIONS -- one dialog both reads and writes a preference; the server
# splits read (system_preferences_get) and write (system_preferences_set),
# the SETVAR precedent.
+# REVCLOUD -- one command draws a bare cloud (entity_create_revcloud) or a
+# revision cloud that also files a revision row (revision_add); a
+# drafter typing it could mean either.
+# TABLE -- one command makes an empty table (entity_create_table) or the
+# ISO 7573 parts list (bom_table); both are correct destinations.
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -112,9 +117,11 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"PLINE",
"PLOT",
"QSELECT",
+ "REVCLOUD",
"SAVEAS",
"SETVAR",
"STYLE",
+ "TABLE",
"UCS",
"VIEW",
"ZOOM",
@@ -964,6 +971,62 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"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",
+ ),
+ ),
# ── Layers & linetypes ──────────────────────────────────────────────────
"layer_create": ToolAliases(
acad=("LAYER", "LA"),
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 7615cd9..e25b609 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 206,
+ "tools": 210,
"resources": 8,
"prompts": 5,
"groups": 23
@@ -15,7 +15,7 @@
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
- "engineering": 12,
+ "engineering": 16,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
@@ -178,6 +178,20 @@
"readOnlyHint": true
}
},
+ {
+ "name": "balloon_add",
+ "group": "engineering",
+ "cost": "mutate",
+ "summary": "Add an ISO 6433 balloon linked to its item row.",
+ "tags": [
+ "engineering",
+ "sheet"
+ ],
+ "annotations": {
+ "title": "Sheet: Add Balloon",
+ "destructiveHint": false
+ }
+ },
{
"name": "batch_plot",
"group": "page_setup",
@@ -303,6 +317,35 @@
"readOnlyHint": false
}
},
+ {
+ "name": "bom_extract",
+ "group": "engineering",
+ "cost": "read",
+ "summary": "Read the bill of materials out of the drawing's block references.",
+ "tags": [
+ "engineering",
+ "query",
+ "sheet"
+ ],
+ "annotations": {
+ "title": "Sheet: Extract Bill of Materials",
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "bom_table",
+ "group": "engineering",
+ "cost": "mutate",
+ "summary": "Draw the parts list as a real TABLE above the title block.",
+ "tags": [
+ "engineering",
+ "sheet"
+ ],
+ "annotations": {
+ "title": "Sheet: Draw Parts List",
+ "destructiveHint": false
+ }
+ },
{
"name": "boundary_from_entities",
"group": "analysis",
@@ -2212,6 +2255,20 @@
"readOnlyHint": true
}
},
+ {
+ "name": "revision_add",
+ "group": "engineering",
+ "cost": "mutate",
+ "summary": "Append a revision row, with optional revision clouds and triangular tags.",
+ "tags": [
+ "engineering",
+ "sheet"
+ ],
+ "annotations": {
+ "title": "Sheet: Add Revision",
+ "destructiveHint": false
+ }
+ },
{
"name": "selection_filter",
"group": "entity_query",
diff --git a/engineering/sheet/__init__.py b/engineering/sheet/__init__.py
index bd227a3..76f1438 100644
--- a/engineering/sheet/__init__.py
+++ b/engineering/sheet/__init__.py
@@ -1,6 +1,20 @@
"""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.frames import (
CENTRING_OVERSHOOT,
EDGE_MARGIN,
@@ -20,11 +34,26 @@ from engineering.sheet.frames import (
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",
"FILING_MARGIN",
+ "LIST_WIDTH",
+ "REVISION_COLUMNS",
+ "ROW_HEIGHT",
"SHEETS",
"SHEET_LAYER",
"SHEET_TEXT_LAYER",
@@ -32,11 +61,24 @@ __all__ = [
"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",
"zone_divisions",
]
diff --git a/engineering/sheet/bom.py b/engineering/sheet/bom.py
new file mode 100644
index 0000000..073bdfd
--- /dev/null
+++ b/engineering/sheet/bom.py
@@ -0,0 +1,521 @@
+"""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",
+ "ROW_HEIGHT",
+ "add_balloon",
+ "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,
+}
+
+
+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 = 200):
+ """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.
+ """
+ inserts = await backend.entity_list(type_filter="INSERT", layer_filter=layer, limit=limit)
+ 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",
+ }
+ )
+ 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, from its ACADMCP_MECH payload."""
+ found: list[dict] = []
+ for entity in await backend.entity_list(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 used for a different set of targets is
+ refused before anything is drawn: ISO 129-1 and ISO 6433 both want one
+ reference per item.
+ """
+ number = int(item)
+ wanted = tuple(sorted(str(t) for t in targets))
+ prims = balloon_prims(number, at, leader_to, radius=radius) # validates first
+ existing = await read_balloons(backend)
+ for balloon in existing:
+ if tuple(sorted(balloon["targets"])) == wanted and 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",
+ }
+ clash = next(
+ (b for b in existing if b["item"] == number and tuple(sorted(b["targets"])) != wanted),
+ None,
+ )
+ if clash is not None:
+ raise ValueError(
+ f"item reference {number} is already used by balloon {clash['handle']} for "
+ f"targets {clash['targets']}; ISO 6433 gives each item one reference"
+ )
+
+ previous = await enter_layout(backend, layout, caller="balloon_add")
+ try:
+ handles = await draw_sheet_prims(
+ backend, prims, layer_map={"visible": layer, "text": layer, "center": layer}
+ )
+ finally:
+ if layout is not None:
+ await backend.layout_set_current(previous)
+ 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",
+ }
diff --git a/engineering/sheet/revision.py b/engineering/sheet/revision.py
new file mode 100644
index 0000000..cd2a748
--- /dev/null
+++ b/engineering/sheet/revision.py
@@ -0,0 +1,383 @@
+"""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
+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, from its ACADMCP_SHEET_REV payload."""
+ found: list[dict] = []
+ for entity in await backend.entity_list(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.
+ """
+ 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.",
+ )
+
+ 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))
+
+ previous = await enter_layout(backend, layout, caller="revision_add")
+ try:
+ 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})
+ finally:
+ if layout is not None:
+ await backend.layout_set_current(previous)
+
+ # 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]],
+ },
+ }
diff --git a/server.py b/server.py
index 82b37bc..649b981 100644
--- a/server.py
+++ b/server.py
@@ -8124,7 +8124,7 @@ async def drawing_properties_set(
# ---------------------------------------------------------------------------
-# ── SECTION 24: Sheet & Delivery (2 tools) ──────────────────────────────────
+# ── SECTION 24: Sheet & Delivery (6 tools) ──────────────────────────────────
# ---------------------------------------------------------------------------
@@ -8295,6 +8295,260 @@ async def titleblock_apply(
)
+@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,
+ 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. 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)
+ records = await extract_records(backend, layer=layer or None)
+ 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),
+ "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,
+ )
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_sheet_bom.py b/tests/test_sheet_bom.py
new file mode 100644
index 0000000..d52e250
--- /dev/null
+++ b/tests/test_sheet_bom.py
@@ -0,0 +1,296 @@
+"""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
diff --git a/tests/test_sheet_revision.py b/tests/test_sheet_revision.py
new file mode 100644
index 0000000..a13eaa6
--- /dev/null
+++ b/tests/test_sheet_revision.py
@@ -0,0 +1,148 @@
+"""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)) == ()
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index ebe17b7..b1cff77 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
- """Frozen snapshot of the surface (206 tools, 23 groups).
+ """Frozen snapshot of the surface (210 tools, 23 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -534,6 +534,9 @@ async def test_tool_group_sizes_are_unchanged():
`sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under
`engineering` beside `titleblock_apply_iso_a3` rather than opening a
24th group.
+ 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`.
Every other number here has been unchanged
since the snapshot was taken.
"""
@@ -545,7 +548,7 @@ async def test_tool_group_sizes_are_unchanged():
"corner_ops": 4,
"dimensions": 5,
"drawing": 11,
- "engineering": 12,
+ "engineering": 16,
"entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
@@ -563,4 +566,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 206
+ assert sum(sizes.values()) == 210