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:
Umut CAN
2026-09-22 20:48:50 +03:00
co-authored by Claude Opus 5
parent ffa8327770
commit 499b642fbd
10 changed files with 1777 additions and 10 deletions
+296
View File
@@ -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
+148
View File
@@ -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)) == ()
+6 -3
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (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