The evidence for tracks B and G, in the shape tracks A and E used.
- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
addressable so an earlier report reproduces exactly rather than being only
described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
built from the part model and the sheet standard, through the code the
tools call (apply_titleblock, draw_part/add_view/dimension_part,
insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
two numbers that cannot be negotiated: drawing_critique(focus=None) returns
zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
the v5 matrix). The adapter returns the focuses that fired, so a
regression names itself, and a test drops one balloon to prove the gate
fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
(25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
the drawing, a linked balloon, an xref attached / listed / detached, a
31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
the first call after Documents.Add is refused) and tracks its documents by
the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
code:
* ezdxf dimension_diameter put the text beyond the SECOND chord point;
ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
front-view diameter stood on its own chain dimension. ezdxf now follows
ActiveX.
* draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
every target layer before the first entity.
* bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
(ObjectName AcDbBlockReference) - the live parts list was empty.
* COM _entity_info reported no dimension text_position: ModelSpace.Item
narrows to IAcadEntity, which has no TextPosition (measured
AttributeError); read through _com_unnarrow (TextPosition measured on
AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
diameter collided with its own bore.
* pid graph raised KeyError 'start' mid-critique on a LINE whose point read
AutoCAD refused while busy; such a line is left out.
* the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
the task count from the v5 matrix, the token table is remeasured with a
TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
collected-tests figure is retaken (3990).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
414 lines
16 KiB
Python
414 lines
16 KiB
Python
"""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_reads_a_block_reference_under_the_live_type_name(backend, monkeypatch):
|
|
"""The live engine calls an INSERT by its ObjectName, BLOCKREFERENCE.
|
|
|
|
Measured on AutoCAD 2026 by scripts/smoke_mech_com.py: a filter on
|
|
"INSERT" found nothing, and the parts list of a sheet holding an ISO 4014
|
|
bolt came back empty. The listing is made to answer the way ActiveX does.
|
|
"""
|
|
handle = await _place_bolt(backend, "a", 0.0)
|
|
real = backend.entity_list
|
|
|
|
async def live_names(type_filter=None, **kwargs):
|
|
if type_filter and type_filter.upper() == "INSERT":
|
|
return []
|
|
wanted = (
|
|
"INSERT" if type_filter and type_filter.upper() == "BLOCKREFERENCE" else type_filter
|
|
)
|
|
return await real(type_filter=wanted, **kwargs)
|
|
|
|
monkeypatch.setattr(backend, "entity_list", live_names)
|
|
records = await extract_records(backend)
|
|
assert [r["handle"] for r in records] == [handle]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend):
|
|
await backend.block_define(
|
|
"PLATE",
|
|
[{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}],
|
|
[{"tag": "DESIGNATION", "x": 0.0, "y": -6.0, "height": 2.5, "default": ""}],
|
|
overwrite=True,
|
|
)
|
|
await backend.block_insert("PLATE", 10.0, 10.0, attributes={"DESIGNATION": "PLATE 10"})
|
|
records = await extract_records(backend)
|
|
plate = next(r for r in records if r["designation"] == "PLATE 10")
|
|
assert plate["source"] == "attributes"
|
|
assert plate["standard"] == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_table_is_a_real_table_entity_sitting_on_its_anchor(backend):
|
|
await _place_bolt(backend, "a", 0.0)
|
|
rows = rows_from_records(await extract_records(backend))
|
|
result = await draw_bom_table(backend, rows, at=(230.0, 70.0), direction="up")
|
|
assert result["ok"] is True
|
|
assert result["rows"] == 1
|
|
assert result["insert"] == (230.0, 70.0 + 2 * 7.0)
|
|
assert result["bbox"] == {"min": [230.0, 70.0], "max": [410.0, 84.0]}
|
|
# Measured: `entity_create_table` returns EntityInfo(type="TABLE") whose
|
|
# `handle` is the FIRST CHILD of a composite on the headless engine -- so
|
|
# `entity_get(handle)` reads back a rule LINE, never an ACAD_TABLE. The
|
|
# contract is pinned by tests/test_table_leader.py; `representation` says
|
|
# which of the two renderings a caller got.
|
|
assert result["entity_type"] == "TABLE"
|
|
assert result["representation"] == "composite"
|
|
assert result["handle"] in result["child_handles"]
|
|
table = await backend.entity_get(result["handle"])
|
|
assert table.layer == "TEXT"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_balloon_records_its_targets_and_a_rerun_renumbers_instead_of_duplicating(backend):
|
|
target = await _place_bolt(backend, "a", 0.0)
|
|
first = await add_balloon(
|
|
backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,)
|
|
)
|
|
assert first["renumbered"] is False
|
|
circles_after_first = len(await backend.entity_list(type_filter="CIRCLE"))
|
|
|
|
second = await add_balloon(
|
|
backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,)
|
|
)
|
|
assert second["renumbered"] is True
|
|
assert second["handle"] == first["handle"]
|
|
assert len(await backend.entity_list(type_filter="CIRCLE")) == circles_after_first
|
|
|
|
balloons = await read_balloons(backend)
|
|
assert len(balloons) == 1
|
|
assert balloons[0]["item"] == 5
|
|
assert balloons[0]["targets"] == [target]
|
|
numeral = await backend.entity_get(balloons[0]["text"])
|
|
assert numeral.properties["text"] == "5"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reusing_an_item_number_for_different_targets_is_refused(backend):
|
|
one = await _place_bolt(backend, "a", 0.0)
|
|
two = await _place_bolt(backend, "b", 60.0)
|
|
await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(one,))
|
|
before = len(await backend.entity_list())
|
|
with pytest.raises(ValueError) as excinfo:
|
|
await add_balloon(backend, item=1, at=(100.0, 40.0), leader_to=(60.0, 0.0), targets=(two,))
|
|
assert "1" in str(excinfo.value)
|
|
assert len(await backend.entity_list()) == before
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_second_balloon_with_the_same_number_and_no_targets_is_refused(backend):
|
|
"""`targets` is optional in the tool, so the clash test used to compare
|
|
`() != ()` -- False -- and never fire. Two balloons carrying item 1."""
|
|
first = await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0))
|
|
before = len(await backend.entity_list())
|
|
with pytest.raises(ValueError) as excinfo:
|
|
await add_balloon(backend, item=1, at=(90.0, 40.0), leader_to=(60.0, 0.0))
|
|
assert first["handle"] in str(excinfo.value)
|
|
assert len(await backend.entity_list()) == before
|
|
assert [b["item"] for b in await read_balloons(backend)] == [1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_balloon_guards_read_past_the_entity_list_page(backend):
|
|
"""A routine hole pattern fills `entity_list`'s 200-entity default, which
|
|
used to make `read_balloons` return nothing and every guard pass."""
|
|
for index in range(200):
|
|
await backend.entity_create_circle(float(index) * 10.0, 0.0, 2.0)
|
|
await add_balloon(backend, item=1, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",))
|
|
assert [b["item"] for b in await read_balloons(backend)] == [1]
|
|
again = await add_balloon(
|
|
backend, item=9, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",)
|
|
)
|
|
assert again["renumbered"] is True, "the guard must see a balloon past the 200th circle"
|
|
with pytest.raises(ValueError):
|
|
await add_balloon(
|
|
backend, item=9, at=(200.0, 400.0), leader_to=(160.0, 380.0), targets=("BBB",)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extract_reads_every_insert_not_the_first_page(backend):
|
|
"""250 bolts must report QTY 250. The 200-entity `entity_list` default used
|
|
to print 200 into a real TABLE with nothing saying it had been cut."""
|
|
await backend.block_define("BOLT", [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}], [])
|
|
for index in range(250):
|
|
insert = await backend.block_insert("BOLT", float(index) * 10.0, 0.0)
|
|
await backend.entity_set_xdata(
|
|
insert.handle,
|
|
APP_ID,
|
|
to_values(
|
|
{
|
|
"v": 1,
|
|
"kind": "std_part",
|
|
"designation": "ISO 4014 - M12x60",
|
|
"standard": "ISO 4014",
|
|
"size": "M12x60",
|
|
"qty": 1,
|
|
"material": "8.8",
|
|
}
|
|
),
|
|
)
|
|
records = await extract_records(backend)
|
|
assert len(records) == 250
|
|
rows = rows_from_records(records)
|
|
assert [(r["designation"], r["qty"]) for r in rows] == [("ISO 4014 - M12x60", 250)]
|
|
# A cap is still honoured when one is asked for, explicitly.
|
|
assert len(await extract_records(backend, limit=17)) == 17
|
|
with pytest.raises(ValueError):
|
|
await extract_records(backend, limit=0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_balloon_guards_read_the_layout_they_draw_on(backend):
|
|
"""`read_balloons` follows the current space, so reading it before
|
|
`enter_layout` inspected Model while the balloons went on the layout."""
|
|
await backend.layout_create("SHEET1")
|
|
first = await add_balloon(
|
|
backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1"
|
|
)
|
|
again = await add_balloon(
|
|
backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1"
|
|
)
|
|
assert again["renumbered"] is True
|
|
assert again["handle"] == first["handle"]
|
|
with pytest.raises(ValueError):
|
|
await add_balloon(
|
|
backend,
|
|
item=5,
|
|
at=(90.0, 40.0),
|
|
leader_to=(60.0, 0.0),
|
|
targets=("BBB",),
|
|
layout="SHEET1",
|
|
)
|
|
assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back"
|
|
await backend.layout_set_current("SHEET1")
|
|
try:
|
|
assert len(await backend.entity_list(type_filter="CIRCLE", limit=10_000)) == 2
|
|
finally:
|
|
await backend.layout_set_current("Model")
|