bench: matrix v5 adds mech_assembly; the correctness suite grows with the mechanical and sheet checks

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
This commit is contained in:
Umut CAN
2026-09-23 13:30:02 +03:00
co-authored by Claude Opus 5.5
parent 3b033033f9
commit 898e775f93
30 changed files with 1355 additions and 50 deletions
+7
View File
@@ -423,6 +423,13 @@ def segment_key(index: int, *, bore: bool = False) -> str:
def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
"""Each segment's outside diameter and bore, measured across the axis.
``p1`` is the upper chord end: a diameter's text stands beyond ``p1`` (the
ActiveX ``AddDimDiametric`` ChordPoint rule, which both engines follow), so
every front-view diameter is annotated above the view, clear of the axial
chain rows below it.
"""
intents: list[DimIntent] = []
bounds = zip(segment_bounds(part), part.segments, strict=True)
for index, ((x0, x1), segment) in enumerate(bounds):
+21
View File
@@ -109,6 +109,7 @@ async def draw_prims(
"""Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``."""
placed = tuple(prims)
_refuse_islands_without_edge_paths(backend, placed)
await _ensure_layers(backend, sorted({f"{layer_prefix}{layer_for(p)}" for p in placed}))
if abs(float(rotation)) > 1e-12:
placed = rotate(placed, float(rotation), (0.0, 0.0))
if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12:
@@ -195,6 +196,23 @@ async def draw_prims(
return {"handles": handles, "counts": counts}
async def _ensure_layers(backend, names) -> tuple[str, ...]:
"""Create whichever target layers the drawing lacks, before the first entity.
MEASURED on AutoCAD 2026 by ``scripts/smoke_mech_com.py``: ``draw_part``
put its anchor POINT on ``MECH``, a layer no layer set creates, and
ActiveX answered ``entity.Layer = "MECH"`` with ``-0x7ffdfff7 ('Key not
found')`` - after every view had already been drawn. The headless engine
creates a missing layer on assignment, so the same call passed every test.
Group A measured the same refusal for annotation layers; this is the same
cure (``ensure_annotation_layers``) at the places every mechanical drawing
path passes through.
"""
from engineering.mech.annotate import ensure_annotation_layers
return await ensure_annotation_layers(backend, [str(name) for name in names if name])
def _refuse_islands_without_edge_paths(backend, prims) -> None:
"""Refuse a hatch with islands on an engine that cannot attach them - before drawing.
@@ -515,6 +533,7 @@ async def draw_part(
if index == 0:
parent, parent_offset = view, offset
await _ensure_layers(backend, [ANCHOR_LAYER])
anchor = await backend.entity_create_point(origin[0], origin[1], layer=ANCHOR_LAYER)
payload = {
"v": PAYLOAD_VERSION,
@@ -839,6 +858,7 @@ async def dimension_part(
skipped: list[dict] = []
table_handle: str | None = None
await _ensure_layers(backend, [ROLE_LAYER["dim"]])
for index, view, placed in planned:
record = records[index]
offset = record["offset"]
@@ -963,6 +983,7 @@ async def draw_hole_pattern(
prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center"))
prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center"))
drawn = await draw_prims(backend, prims)
await _ensure_layers(backend, [layer, ROLE_LAYER["dim"] if dimension else None])
if layer:
# the role map owns the layers; an explicit override moves the circles
# only, so the ISO 128-23 centre marks stay on CENTER.
+12 -1
View File
@@ -209,8 +209,19 @@ class _Grid:
def _vertices(info: EntityInfo) -> list[tuple[float, float]]:
"""The entity's vertices, or ``[]`` when the engine could not report them.
MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine's
entity reader drops a LINE's ``start``/``end`` when AutoCAD refuses the
point read (``RPC_E_CALL_REJECTED`` while busy) and keeps the entity, so
a subscript here crashed ``drawing_critique(focus=None)`` on a sheet with
no P&ID on it. A line without coordinates cannot be an edge; it is left
out rather than guessed at.
"""
if info.type in LINE_TYPES:
s, e = info.properties["start"], info.properties["end"]
s, e = info.properties.get("start"), info.properties.get("end")
if not s or not e:
return []
return [(float(s[0]), float(s[1])), (float(e[0]), float(e[1]))]
return [(float(p[0]), float(p[1])) for p in info.properties.get("points") or []]
+17 -1
View File
@@ -111,6 +111,11 @@ COLUMN_SHARES: dict[str, float] = {
PAGE_SIZE = 1000
#: A block reference under either engine's type name: the DXF name headlessly,
#: the ActiveX ObjectName (AcDbBlockReference) live.
INSERT_TYPES = ("INSERT", "BLOCKREFERENCE")
async def all_entities(backend: AutoCADBackend, *, type_filter=None, layer=None):
"""Every entity of a type in the CURRENT space -- not the first 200 of them.
@@ -361,7 +366,18 @@ async def extract_records(backend: AutoCADBackend, *, layer=None, limit: int | N
"""
if limit is not None and int(limit) <= 0:
raise ValueError(f"bom_extract: limit must be a positive number of records; got {limit!r}")
inserts = await all_entities(backend, type_filter="INSERT", layer=layer)
# MEASURED on AutoCAD 2026 by scripts/smoke_mech_com.py: the live engine
# names a block reference by its ObjectName, BLOCKREFERENCE, and filters on
# that name, so a filter on the DXF name INSERT found nothing and the parts
# list came back empty on a sheet holding an ISO 4014 bolt. Both names are
# read, the way engineering/pid/graph.py already does.
inserts: list = []
seen: set[str] = set()
for type_name in INSERT_TYPES:
for entity in await all_entities(backend, type_filter=type_name, layer=layer):
if entity.handle not in seen:
seen.add(entity.handle)
inserts.append(entity)
records: list[dict] = []
for entity in inserts:
payload: dict = {}
+17 -1
View File
@@ -27,6 +27,22 @@ DIMENSION_LIKE_TEXT = re.compile(
re.IGNORECASE,
)
# A standard-part designation (ISO 4014 - M12x60, DIN 912 - M8x20) names a
# part, it does not measure one: the "12x60" in it is the thread and the
# nominal length the standard's designation grammar spells that way. A parts
# list drawn as text carries one per row, so the designation is taken out
# before DIMENSION_LIKE_TEXT looks - a hand-typed "Ø78" beside it still flags.
STANDARD_DESIGNATION = re.compile(
r"\b(?:ISO|DIN|EN)\s*\d+(?:-\d+)?\s*-\s*M\d+(?:\.\d+)?(?:\s*[x×]\s*\d+(?:\.\d+)?)?",
re.IGNORECASE,
)
def looks_like_dimension(text: str) -> bool:
"""Is this TEXT/MTEXT content masquerading as a dimension?"""
return bool(DIMENSION_LIKE_TEXT.search(STANDARD_DESIGNATION.sub(" ", text or "")))
# R27 — match SPUR / HELICAL as whole words (not substrings of e.g.
# "SPURIOUS") when checking title/helix consistency.
_SPUR_RE = re.compile(r"\bSPUR\b", re.IGNORECASE)
@@ -92,7 +108,7 @@ class DrawingValidator:
e
for e in ents
if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
and looks_like_dimension(getattr(e, "properties", {}).get("text", "") or "")
]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes: