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