feat(sheet): revisions, the ISO 7573 parts list and ISO 6433 balloons
SECTION 24 grows to six tools: revision_add, bom_extract, bom_table,
balloon_add.
bom_extract reads. It walks the INSERTs through the generic members already in
the contract -- entity_list, block_get_attributes, entity_get_xdata -- and
returns parts-list rows from the ACADMCP_MECH std_part payload, falling back to
the block's own ATTRIBs and reporting which of the two each row came from. A
block with neither is skipped rather than guessed at, and the tool never writes
to the drawing.
bom_table draws the rows as a real TABLE, 180 mm wide (ISO 7573: the item list
is as wide as the title block), growing upwards from directly above it with the
heading row against the block and item 1 the row above -- which is what ISO 7573
asks for on a drawing. A TABLE is drawn downwards from its insertion point, so
the upward list is inserted a table height above its anchor with the heading as
the last row; one table_layout feeds both the real TABLE and the pure
table_prims, so the two renderings cannot disagree.
balloon_add draws the ISO 6433 reference -- numeral, leader, dot on the item --
and writes the balloon payload naming its targets. A rerun over the same
targets renumbers that balloon instead of drawing a second one, and an item
number already used for different targets is refused by name with the other
balloon's handle.
revision_add appends a row to the revision block, is idempotent per revision
code (a repeat draws nothing at all), and optionally clouds the changed regions
and tags them with the triangle. Clouds are refused up front, carrying
capability "revcloud", on an engine that has no revcloud member -- the COM
backend -- rather than leaving half a revision behind.
Declared: ISO 7200 does not standardise the revision block's geometry and
ISO 7573 does not fix the item list's column widths or row height, so both
reuse this repository's 180 mm / 7 mm ruling and say so in their SOURCE
docstrings. ISO 6433's "numeral at least twice the dimension character height"
is met by the numeral being 1.4x the balloon radius: the 4 mm default clears
2.5 mm dimension text, and balloon_add's docstring tells a sheet dimensioned at
3.5 mm to pass radius=5.0.
Three corrections to what the plan wrote, each measured rather than assumed:
* extract_records read `block_get_attributes(...)["attributes"]`. Both engines
return the flat {tag: value} mapping itself (ezdxf_backend.py:6687 builds it
from `ent.attribs`, com_backend.py:5703 from `ref.GetAttributes()` as
{TagString: TextString}), so every attribute fallback would have been
silently empty -- measured headlessly, where a PLATE insert carrying only a
DESIGNATION ATTRIB extracted to nothing.
* draw_bom_table now reports `entity_type` / `representation` /
`child_handles`. `entity_create_table` returns EntityInfo(type="TABLE") whose
handle is the first CHILD of a composite on the headless engine, so reading
that handle back gives a rule LINE, not an ACAD_TABLE -- the contract
tests/test_table_leader.py already pins. A caller that means to edit the
table afterwards has to be told which of the two it got.
* add_revision reports one `tags` entry per tag asked for ({at, handles}), not
one per entity: a tag is two primitives, so a flat handle list claimed twice
as many tags as the caller requested.
Discovery: `bom_extract` / `bom_table` / `balloon_add` each entered the index
heavy with "parts"/"list" tokens and took top-1 for the bare query "parts list"
away from `entity_create_table` (a BM25 tie broken alphabetically). Narrowed on
my side only -- the reader speaks of the bill of materials, the drawer names a
table, the balloon names an item -- never by touching the golden case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
ffa8327770
commit
499b642fbd
@@ -27,15 +27,15 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **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 |
|
||||
|
||||
<sub>206 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
<sub>210 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
|
||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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]],
|
||||
},
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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)) == ()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user