Files
autocad-mcp-pro/benchmarks/correctness_suite.py
T
Umut CANandClaude Opus 5.5 0abbe55041 bench: matrix v6 adds arch_roundtrip; four architectural correctness checks; the live arch smoke
The evidence for track F, in the shape tracks A, E and B + G used.

- benchmarks/tasks_v6.py: 19 tasks, DEFAULT_MATRIX = "v6"; v5 to v2 stay
  addressable. arch_roundtrip draws the example plan with
  arch_plan_from_spec and reads it back without the spec: rooms_detect must
  find each labelled room within 0.1 % of its label and of the net floor
  computed by hand (4825 x 5750 and 3825 x 5750), the schedules must list
  D1, D2, W1, W2, and the plan is gated on a zero-issue critique and a
  finalize score of at least 90. A test removes one room label and the gate
  fails naming arch_room_unlabelled.
- Four correctness checks, every number worked out by hand in the docstring:
  L / T / X junction corners and wall areas, a 4 x 5 m room labelled 20.00 m2
  rather than its 21.84 m2 axis area, a door interrupting both faces, and a
  plan of plain lines read as 20 and 15 m2 at confidence 0.6. The published
  A/B report is re-run over 43 checks: 26 / 43 -> 43 / 43, zero regressed.
- scripts/smoke_arch_com.py runs the whole plan once on a live seat and
  closes every document it opens; the CHANGELOG records what it returned.
- The token suite measures a TOOL_PACKS=core,arch row; check_doc_numbers
  derives the task count from the v6 matrix; CLAUDE.md, both READMEs and
  the spec status line say what shipped.
- fix(com): the first live smoke exited 1 - the room reader found 0 wall
  segments. Measured on AutoCAD 2026 (2026-09-23): with a makepy cache,
  ModelSpace.Item(i) returns the gen_py IAcadEntity wrapper
  (type(item).__name__ == "IAcadEntity") and item.StartPoint on an
  AcDbLine raises AttributeError, so every entity_list row lost its
  type-specific properties (28 wall LINEs, 0 with start/end). _entity_info
  now re-dispatches exactly that wrapper through _com_unnarrow; after the
  fix 38 of 53 rows carry start/end and the smoke exits 0 with both rooms
  at 27 743 750 / 21 993 750 mm2 and 0 critique issues. Re-dispatch
  measured at ~1.7 ms per entity (dynamic vs gencache alike).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:35:46 +03:00

888 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Version-agnostic correctness checks for the AutoCAD MCP ezdxf backend.
Driven by ``compare_versions.py`` (the A/B runner). Run ONE check in isolation:
python benchmarks/correctness_suite.py <check_name>
python benchmarks/correctness_suite.py --list
Prints 'PASS' or 'FAIL' on the last line; a crash (e.g. SIGSEGV) or an
unsupported method => non-zero exit, which the runner counts as a miss.
The same file is executed against both the baseline worktree and the current
checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a
method the old version lacks simply raises AttributeError -> miss.
"""
from __future__ import annotations
import asyncio
import math
import os
import sys
import tempfile
async def _b():
from backends.ezdxf_backend import EzdxfBackend
be = EzdxfBackend()
await be.connect()
await be.drawing_new()
return be
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
async def core_line_length():
b = await _b()
ln = await b.entity_create_line(0, 0, 3, 4)
info = await b.entity_get(ln.handle)
return abs(float(info.properties["length"]) - 5.0) < 1e-6
async def core_circle_radius():
b = await _b()
c = await b.entity_create_circle(0, 0, 10)
info = await b.entity_get(c.handle)
return abs(float(info.properties["radius"]) - 10.0) < 1e-6
async def core_layer_create():
b = await _b()
await b.layer_create("BENCH", color=1)
names = {lyr.name for lyr in await b.layer_list()}
return "BENCH" in names
async def core_polyline_closed():
b = await _b()
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
info = await b.entity_get(p.handle)
return bool(info.properties.get("closed"))
async def core_linear_dim():
b = await _b()
d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
info = await b.entity_get(d.handle)
return "DIM" in info.type.upper()
async def core_save_dxf_roundtrip():
b = await _b()
await b.entity_create_line(0, 0, 10, 10)
path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
await b.drawing_save_as(path, "dxf")
import ezdxf
ezdxf.readfile(path) # raises if not a valid DXF
return os.path.getsize(path) > 0
# ── Dimensions (I16) ──────────────────────────────────────────────────────────
async def dim_aligned_no_error():
b = await _b()
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
return d is not None
async def dim_angular_no_error():
b = await _b()
d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60)
return d is not None
# ── Polar array full circle (R9) ──────────────────────────────────────────────
async def array_polar_360_distinct():
b = await _b()
c = await b.entity_create_circle(10, 0, 1)
res = await b.entity_array_polar(c.handle, 4, 360.0, 0, 0)
# 3 copies + original = 4 distinct centers, none coincident with the original.
centers = {(10.0, 0.0)}
for e in res:
info = await b.entity_get(e.handle if hasattr(e, "handle") else e)
cx, cy = info.properties["center"]
centers.add((round(cx, 3), round(cy, 3)))
return len(centers) == 4
# ── Deterministic geometry (I9) ────────────────────────────────────────────────
async def point_intersection_line_line():
b = await _b()
l1 = await b.entity_create_line(0, 0, 10, 0)
l2 = await b.entity_create_line(5, -5, 5, 5)
pt = await b.point_intersection(l1.handle, l2.handle)
return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6
async def point_tangent_external():
b = await _b()
c = await b.entity_create_circle(0, 0, 3)
tx, ty = await b.point_tangent(c.handle, 5, 0, ref_y=10)
# tangency: (T-F).(T-C) == 0 and |T-C| == r
fx, fy = 5.0, 0.0
dot = (tx - fx) * (tx - 0) + (ty - fy) * (ty - 0)
return abs(dot) < 1e-4 and abs(math.hypot(tx, ty) - 3.0) < 1e-4
# ── Selection / property parity (N3, N5) ───────────────────────────────────────
async def arc_has_length():
b = await _b()
a = await b.entity_create_arc(0, 0, 10, 0, 90)
info = await b.entity_get(a.handle)
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
async def arc_select_by_length():
b = await _b()
await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7
sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
return len(sel) == 1
async def ezdxf_bounding_box():
b = await _b()
ln = await b.entity_create_line(0, 0, 30, 40)
info = await b.entity_get(ln.handle)
bb = info.properties.get("bounding_box")
return isinstance(bb, dict) and set(bb) == {"min", "max"}
async def mtext_rotation_roundtrip():
b = await _b()
mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0)
info = await b.entity_get(mt.handle)
return abs(float(info.properties.get("rotation", 0.0)) - 30.0) < 1e-6
# ── Rendering (screenshot Agg / SIGSEGV) ───────────────────────────────────────
async def screenshot_png():
b = await _b()
await b.entity_create_line(0, 0, 100, 100)
png = await b.view_screenshot()
return png is not None and png[:4] == b"\x89PNG"
# ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
async def dim_overlap_critique_fires():
b = await _b()
await b.drawing_apply_iso_layers("mech")
await b.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
await b.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
issues = await b.drawing_critique(focus=["dim_overlap"])
return len(issues) >= 1
async def iso13567_dim_layer():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
ln = await b.entity_create_line(0, 0, 80, 0, layer="M-GEOMET-E-N")
dims = await b.dimension_auto([ln.handle], style="chain")
return dims[0].layer == "M-DIMEN-T-N"
async def construction_left_iso_caught():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
await b.construction_xline(0, 0, 90)
issues = await b.drawing_critique(focus=["construction_left"])
return len(issues) == 1
# ── Engineering (gear) ──────────────────────────────────────────────────────────
async def gear_no_self_overlap():
from engineering.gear import generate_full_gear_outline
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
root_r = (2 * 50 / 2.0) - 1.25 * 2
return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6
# ── Linetype on demand (R24) ───────────────────────────────────────────────────
async def center_linetype_applied():
b = await _b()
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
info = await b.entity_get(ln.handle)
return info.linetype.upper() == "CENTER"
# ── Measurement (v1.5.0) ───────────────────────────────────────────────────────
#
# These three are NEW capability, not repaired regressions: v1.4.0 has no
# `entity_measure` and no boundary tracing, so it misses them by not having the
# method rather than by getting it wrong. They are here because all three broke
# during v1.5.0 development and nothing outside their own unit tests would have
# noticed.
async def hatch_area_subtracts_its_island():
"""A section view is mostly holes; counting them is a 33% error here."""
b = await _b()
h = await b.entity_create_hatch("ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]])
await b.hatch_add_boundary(
h.handle,
[
{"type": "line", "start": [5, 5], "end": [15, 5]},
{"type": "line", "start": [15, 5], "end": [15, 15]},
{"type": "line", "start": [15, 15], "end": [5, 15]},
{"type": "line", "start": [5, 15], "end": [5, 5]},
],
)
return abs((await b.entity_measure(h.handle))["area"] - 300.0) < 1e-6
async def boundary_area_agrees_with_measure():
"""Two tools, one shape, one number."""
b = await _b()
await b.entity_create_line(0, 0, 10, 0)
await b.entity_create_line(10, 0, 10, 10)
await b.entity_create_line(0, 10, 0, 0)
await b.entity_create_arc(5, 10, 5, 0, 180)
loop = await b.boundary_trace(5.0, 5.0)
measured = await b.entity_measure(loop["handle"])
true_area = 100.0 + math.pi * 25.0 / 2.0
return abs(loop["area"] - measured["area"]) < 1e-9 and abs(measured["area"] - true_area) < 1e-5
async def two_vertex_circle_has_area():
"""DXF's most compact circle. The shoelace over its two points is 0."""
from engineering.measure import polygon_area_perimeter
area, _ = polygon_area_perimeter([(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)], closed=True)
return abs(area - math.pi * 100.0) < 1e-9
async def diameter_dim_measures_the_diameter():
"""The number on the callout is the whole point of the callout.
`add_diameter_dim` measures to `mpoint`; the leader is a text placement.
Feeding it centre + (radius + leader) made every diameter come back
2 x leader_length too large at DEFAULT settings - 60 on a true 40.
"""
b = await _b()
info = await b.dimension_diameter(-20, 0, 20, 0)
dim = b._doc.entitydb.get(info.handle)
return abs(dim.get_measurement() - 40.0) < 1e-6
async def radius_dim_ignores_the_leader_length():
b = await _b()
near = await b.dimension_radius(0, 0, 20, 0, leader_length=5)
far = await b.dimension_radius(0, 0, 20, 0, leader_length=40)
values = [
b._doc.entitydb.get(near.handle).get_measurement(),
b._doc.entitydb.get(far.handle).get_measurement(),
]
return all(abs(v - 20.0) < 1e-6 for v in values)
# ── P&ID (v1.6, track A) ──────────────────────────────────────────────────────
async def pid_block_define_attdef_roundtrip():
b = await _b()
await b.block_define(
"PID_T",
[{"type": "line", "x1": 0, "y1": 0, "x2": 8, "y2": 0}],
[{"tag": "TAG", "x": 0, "y": 3, "height": 2.5}],
)
ref = await b.block_insert("PID_T", 5, 5, attributes={"TAG": "HV-1"})
return await b.block_get_attributes(ref.handle) == {"TAG": "HV-1"}
async def pid_tag_parse_fic():
from engineering.pid.tags import parse_tag
return parse_tag("FIC-101")["description"] == "Flow Indicating Controller"
async def pid_graph_edge_count():
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
b = await _b()
result = await run_spec(b, EXAMPLE_SPEC)
return len(result["graph"]["edges"]) == 4 and result["graph"]["stats"]["dangling"] == 0
# ── Settings and environment (v1.6, track E) ─────────────────────────────────
async def settings_dimstyle_iso25_values():
"""The ISO-25 preset reaches the DIMSTYLE table with ISO 129-1's numbers.
Read back through ezdxf directly, not through `dimstyle_list`, so the
check cannot be satisfied by a list that echoes its own input.
"""
from engineering.standards.dimstyles import resolve_dimstyle
b = await _b()
await b.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
style = b._doc.dimstyles.get("ISO-25")
listed = {row["name"]: row for row in await b.dimstyle_list()}
return (
abs(float(style.dxf.dimtxt) - 2.5) < 1e-9
and abs(float(style.dxf.dimasz) - 2.5) < 1e-9
and abs(float(style.dxf.dimexe) - 1.25) < 1e-9
and abs(float(style.dxf.dimexo) - 0.625) < 1e-9
and abs(float(style.dxf.dimgap) - 0.625) < 1e-9
and int(style.dxf.dimtad) == 1
and int(style.dxf.dimdsep) == ord(",")
and str(style.dxf.dimtxsty) == "ISOCP"
and b._doc.header["$DIMSTYLE"] == "ISO-25"
and listed["ISO-25"]["current"] is True
and listed["ISO-25"]["values"]["DIMDSEP"] == ","
)
async def settings_layer_state_roundtrip():
"""Save → change → restore puts the table back, and the state survives a
save/reopen because it is an XRECORD in the file, not server memory."""
b = await _b()
await b.layer_create("LS_A", color=1)
await b.layer_create("LS_B", color=2)
saved = await b.layer_state_save("bench", description="A/B round trip")
await b.layer_modify("LS_A", color=5)
await b.layer_freeze("LS_B")
restored = await b.layer_state_restore("bench")
layers = {layer.name: layer for layer in await b.layer_list()}
path = os.path.join(tempfile.mkdtemp(), "ls.dxf")
await b.drawing_save_as(path, "dxf")
await b.drawing_open(path)
names_after_reopen = {row["name"] for row in await b.layer_state_list()}
return (
saved["ok"] is True
and restored["missing_layers"] == []
and restored["new_layers"] == []
and layers["LS_A"].color == 1
and layers["LS_B"].is_frozen is False
and "bench" in names_after_reopen
)
def _sheet_near(size_mm, width_mm: float, height_mm: float) -> bool:
"""Within the spec's ±0.5 mm: COM measures ANSI B as 431.8 × 279.4."""
try:
w, h = (float(v) for v in size_mm)
except (TypeError, ValueError):
return False
return abs(w - width_mm) < 0.5 and abs(h - height_mm) < 0.5
async def settings_pdf_mediabox_a3():
"""Page setup evidence is the PDF's own /MediaBox (spec §8.3).
A fresh document's Layout1 is already ISO A3 landscape, so an A3-only
read-back would pass with the setter stubbed out. The sheet is therefore
moved to ANSI B first — 432 × 279, a size no fresh document has (spec §9
names it) — and that PDF is read; then ISO A3 is applied and read again.
The A3 apply must also report the layout moved from B to A3, so the PDF
and the setter's own diff have to agree on where the sheet was.
"""
from engineering.standards.papers import resolve_page_setup
from engineering.standards.plot import batch_plot
b = await _b()
await b.page_setup_apply("Layout1", resolve_page_setup("ANSI_B"))
out_dir = tempfile.mkdtemp()
report_b = await batch_plot(b, ["Layout1"], out_dir, "{drawing}-{layout}-ansi_b.pdf")
width_b, height_b = report_b["sheets"][0]["mediabox_mm"]
applied = await b.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
report_a3 = await batch_plot(b, ["Layout1"], out_dir, "{drawing}-{layout}-iso_a3.pdf")
width_a3, height_a3 = report_a3["sheets"][0]["mediabox_mm"]
moved = applied["changed"].get("size_mm") or [[0.0, 0.0], [0.0, 0.0]]
return (
abs(width_b - 432.0) < 0.5
and abs(height_b - 279.0) < 0.5
and _sheet_near(moved[0], 432.0, 279.0)
and _sheet_near(moved[1], 420.0, 297.0)
and abs(width_a3 - 420.0) < 0.5
and abs(height_a3 - 297.0) < 0.5
)
# ── Mechanical parts and the sheet (tracks B + G) ───────────────────────────
def _field(row: dict, *names):
"""A row's value under whichever of these names it carries.
The parts-list column spelling belongs to the sheet module; what this
suite pins is the numbers, so it reads by meaning rather than by name.
"""
for name in names:
if name in row:
return row[name]
return None
def _inserted_handle(result: dict):
return result.get("handle") or (result.get("handles") or [None])[0]
async def mech_part_roundtrip():
"""Draw a part, read it back from its own ACADMCP_MECH XDATA, compare.
Segments and material only: a feature's parameter spelling belongs to
`engineering/mech/features.py` and its own tests, and a check that guessed
at it would fail for the wrong reason.
"""
from engineering.mech.draw import draw_part, read_part
from engineering.mech.part import build_part, part_to_dict
b = await _b()
spec = {
"kind": "revolved",
"name": "SHAFT",
"material": "steel",
"segments": [
{"length": 40.0, "d_outer": 30.0},
{"length": 20.0, "d_outer": 50.0, "d_inner": 10.0},
],
}
part = build_part(spec)
drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False)
back = await read_part(b, drawn["part_id"])
return part_to_dict(build_part(back["part"])) == part_to_dict(part)
async def mech_section_hatch_area():
"""The hatched cut area of a full section, measured against a hand number.
A plain sleeve: length 60, outside diameter 40, bore 20. A full
longitudinal section cuts two faces, each 60 x 10, so the hatch fills
60 * (40 - 20) = 1200 mm^2 exactly. The number is read out of the drawing
through entity_measure (the tool's analysis_measure_entity), never from the
code that drew it — which is why it cannot be faked.
"""
from engineering.mech.draw import add_view, draw_part
from engineering.mech.part import build_part
b = await _b()
part = build_part(
{
"kind": "revolved",
"name": "SLEEVE",
"material": "steel",
"segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}],
}
)
drawn = await draw_part(b, part, at=(0.0, 0.0), views=("front",), dimension=False)
await add_view(
b,
drawn["part_id"],
"section",
plane={"p1": [0.0, 0.0], "p2": [60.0, 0.0], "label": "A"},
style="full",
)
total = 0.0
for ent in await b.entity_list(type_filter="HATCH", limit=500):
total += float((await b.entity_measure(ent.handle))["area"])
return abs(total - 1200.0) < 0.01
async def mech_iso286_on_dimension():
"""A 40 H7 bore dimension carries ISO 286's +0.025 / 0 through the layout.
ISO 286-1 table 1: the 30-50 mm step has IT7 = 25 um, and H fixes the lower
deviation at 0. The fit has to survive `layout_dimensions`, or the number
is right in the table and wrong on the sheet.
"""
from engineering.fits import fit_lookup
from engineering.mech.dimension import layout_dimensions
from engineering.mech.primitives import Circle, DimIntent
from engineering.mech.views import View
deviation = fit_lookup("H7", 40.0)
if abs(deviation.upper_mm - 0.025) > 1e-9 or abs(deviation.lower_mm) > 1e-9:
return False
intent = DimIntent("diameter", (-20.0, 0.0), (20.0, 0.0), fit="H7", feature="bore")
view = View(
kind="front",
prims=(Circle((0.0, 0.0), 20.0),),
dims=(intent,),
omitted=(),
bbox=(-20.0, -20.0, 20.0, 20.0),
)
placed = layout_dimensions(view, [intent])
return len(placed) == 1 and placed[0].fit == "H7" and placed[0].feature == "bore"
async def mech_thread_unrepresented_is_caught():
"""The ISO 6410 focus fires on a thread drawn as a plain circle.
A quality gate that never fires is not a gate; this is the mechanical twin
of `dim_overlap_critique_fires`.
"""
from engineering.mech.critique import build_index, issues_for
from engineering.mech.xdata import APP_ID, to_values
b = await _b()
payload = {
"v": 1,
"kind": "part",
"id": "P1",
"view": "side",
"part": {
"name": "STUD",
"material": "steel",
"segments": [{"length": 60.0, "d_outer": 20.0}],
"features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5"}],
},
}
anchor = await b.entity_create_circle(0.0, 0.0, 0.5, layer="0")
await b.entity_set_xdata(anchor.handle, APP_ID, to_values(payload))
await b.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY")
before = issues_for("mech_thread_unrepresented", await build_index(b))
await b.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY")
after = issues_for("mech_thread_unrepresented", await build_index(b))
return len(before) == 1 and after == []
async def sheet_frame_iso5457_a3():
"""ISO 5457 A3: 420 x 297 trimmed, a 20 mm filing margin on the left and
10 mm on the other three edges, so the frame runs (20, 10) to (410, 287)
and the drawing space is 390 x 277. The trimmed sheet edge is drawn too
(ISO 5457 4.1), so the outermost extent is the sheet, 0,0 to 420,297 -
and nothing may reach past it."""
from engineering.mech.primitives import points_of
from engineering.sheet.frames import SHEETS, frame_prims
if tuple(float(v) for v in SHEETS["A3"]) != (297.0, 420.0):
return False
prims = frame_prims("A3", orientation="landscape", zones=False, marks=False)
points = {
(round(float(x), 6), round(float(y), 6)) for prim in prims for x, y in points_of(prim)
}
frame = {(20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0)}
sheet = {(0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0)}
xs = [x for x, _y in points]
ys = [y for _x, y in points]
extent = (min(xs), min(ys), max(xs), max(ys))
return frame <= points and sheet <= points and extent == (0.0, 0.0, 420.0, 297.0)
async def bom_balloon_link():
"""Two identical bolts collapse into one row of quantity 2, the item number
is stable across a rerun, and the balloon drawn for that row carries it."""
from engineering.mech.stdparts import insert_std_part
from engineering.mech.xdata import APP_ID, decode
from engineering.sheet.bom import balloon_prims, rows_from_records
b = await _b()
records = []
for x in (0.0, 60.0):
result = await insert_std_part(b, "ISO 4014 - M12x60", at=(x, 0.0), view="side")
raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID)
records.append(decode(raw["xdata"][APP_ID]))
rows = rows_from_records(records)
again = rows_from_records(records)
if len(rows) != 1 or len(again) != 1:
return False
item = _field(rows[0], "item", "no", "pos")
if item is None or item != _field(again[0], "item", "no", "pos"):
return False
if int(_field(rows[0], "qty", "quantity") or 0) != 2:
return False
texts = [
p.text for p in balloon_prims(int(item), (0.0, 40.0), (0.0, 5.0)) if hasattr(p, "text")
]
return texts == [str(int(item))]
async def std_part_iso4014_m12():
"""ISO 4014's M12 row and the block it becomes.
ISO 4014:2011 table 1, M12: width across flats s = 18 mm, head height
k = 7.5 mm. The row is matched by value rather than by column name — the
spelling is the catalogue's business, the numbers are the standard's — and
the inserted block has to carry its designation back in ACADMCP_MECH.
The catalogue nests a row's sizes one level down (``dims``), so the
values are read one level deep.
"""
from engineering.mech.stdparts import catalogue, insert_std_part
from engineering.mech.xdata import APP_ID, decode
rows = [row for row in catalogue("M12") if any("4014" in str(value) for value in row.values())]
if not rows:
return False
values = [
inner
for row in rows
for value in row.values()
for inner in (value.values() if isinstance(value, dict) else (value,))
]
numbers = {
round(float(value), 3)
for value in values
if isinstance(value, (int, float)) and not isinstance(value, bool)
}
if not {18.0, 7.5} <= numbers:
return False
b = await _b()
result = await insert_std_part(b, "ISO 4014 - M12x60", at=(0.0, 0.0), view="side")
raw = await b.entity_get_xdata(_inserted_handle(result), APP_ID)
payload = decode(raw["xdata"][APP_ID])
return payload["kind"] == "std_part" and "M12" in str(payload["designation"])
# ── Architecture (track F) ──────────────────────────────────────────────────
def _arch_points(geometry) -> set:
"""Every endpoint of a drawn face or jamb, rounded to a micron."""
return {
(round(float(x), 6), round(float(y), 6))
for line in (*geometry.faces, *geometry.jambs)
for x, y in (line.p1, line.p2)
}
def _arch_passes_through(geometry, point) -> bool:
"""Does a drawn face run *through* ``point`` (not merely end on it)?"""
px, py = point
for line in geometry.faces:
(x1, y1), (x2, y2) = line.p1, line.p2
length = math.hypot(x2 - x1, y2 - y1)
cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
along = ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / (length * length)
if abs(cross) <= 1e-6 * length and 1e-9 < along < 1.0 - 1e-9:
return True
return False
def _arch_region_area(geometry) -> float:
from engineering.measure import polygon_area_perimeter
return sum(
polygon_area_perimeter([(x, y, 0.0) for x, y in loop], True)[0] for loop in geometry.regions
)
async def arch_junction_l_t_x():
"""The three junction kinds, against coordinates worked out by hand.
L: one 200 mm centre wall turning at (4000, 0) - the outer corner is
(4100, -100), the inner (3900, 100), and a mitred corner keeps the area at
axis length x thickness: 7000 x 200 = 1 400 000 mm².
T: a 100 mm stem on x = 3000 ending on the axis of a 200 mm wall - its faces
stop on the near face at (2950, 100) and (3050, 100), the near face is open
between them, the far face runs through; 6000 x 200 + 2900 x 100 = 1 490 000.
X: two 200 mm walls crossing at (3000, 0) - the first stays whole, the second
stops on its faces, all four faces are cut at x = 2900 / 3100 and y = +-100;
6000 x 200 + 2 x 2900 x 200 = 2 360 000.
"""
from engineering.arch.model import wall_from_dict
from engineering.arch.walls import wall_geometry
def wall(wall_id, axis, thickness):
return wall_from_dict({"id": wall_id, "axis": axis, "thickness": thickness})
l_geo = wall_geometry([wall("L", [[0, 0], [4000, 0], [4000, 3000]], 200)], poche=False)
t_geo = wall_geometry(
[wall("A", [[0, 0], [6000, 0]], 200), wall("B", [[3000, 0], [3000, 3000]], 100)],
poche=False,
)
x_geo = wall_geometry(
[wall("A", [[0, 0], [6000, 0]], 200), wall("B", [[3000, -3000], [3000, 3000]], 200)],
poche=False,
)
l_ok = (
{(4100.0, -100.0), (3900.0, 100.0)} <= _arch_points(l_geo)
and not {(4100.0, 100.0), (3900.0, -100.0)} & _arch_points(l_geo)
and abs(_arch_region_area(l_geo) - 1_400_000.0) < 1e-6
)
t_ok = (
{(2950.0, 100.0), (3050.0, 100.0)} <= _arch_points(t_geo)
and not _arch_passes_through(t_geo, (3000.0, 100.0))
and _arch_passes_through(t_geo, (3000.0, -100.0))
and abs(_arch_region_area(t_geo) - 1_490_000.0) < 1e-6
)
x_ok = (
{(2900.0, 100.0), (3100.0, 100.0), (2900.0, -100.0), (3100.0, -100.0)}
<= _arch_points(x_geo)
and not any(
_arch_passes_through(x_geo, p)
for p in ((3000.0, 100.0), (3000.0, -100.0), (2900.0, 0.0), (3100.0, 0.0))
)
and abs(_arch_region_area(x_geo) - 2_360_000.0) < 1e-6
)
return l_ok and t_ok and x_ok
async def arch_room_area_net():
"""A 4 x 5 m room between 200 mm walls measures 20.00 m², not its axis area.
The ring's axis runs (-100, -100) to (4100, 5100), so its inner faces are
x = 0 / 4000 and y = 0 / 5000: the floor is 4000 x 5000 = 20 000 000 mm²,
while the axis encloses 4200 x 5200 = 21 840 000. The number is read back
out of the room record the label carries, and the label text must say it.
"""
from engineering.arch.draw import draw_walls, read_plan
from engineering.arch.lang import fmt_area_m2
from engineering.arch.model import wall_from_dict
from engineering.arch.rooms import label_room
b = await _b()
ring = wall_from_dict(
{
"id": "W1",
"axis": [[-100, -100], [4100, -100], [4100, 5100], [-100, 5100]],
"thickness": 200,
"closed": True,
}
)
await draw_walls(b, [ring])
await label_room(b, {"id": "R1", "name": "ROOM", "number": "01", "at": [2000, 2500]})
(room,) = (await read_plan(b))["rooms"]
texts = [e.properties.get("text", "") for e in await b.entity_list(type_filter="TEXT")]
return (
abs(float(room.area) - 20_000_000.0) < 0.5
and fmt_area_m2(room.area, "en") == "20.00 m²"
and any("20.00 m²" in str(text) for text in texts)
)
async def arch_opening_cuts_wall():
"""A 900 door at 1000 along a 5 m, 200 mm wall interrupts both faces.
No face runs through x = 1450 (the middle of the door) on either face line
y = +-100 while one still runs through x = 500, and two jambs close the cut
at x = 1000 and x = 1900.
"""
from engineering.arch.model import opening_from_dict, wall_from_dict
from engineering.arch.walls import wall_geometry
wall = wall_from_dict({"id": "W1", "axis": [[0, 0], [5000, 0]], "thickness": 200})
door = opening_from_dict(
{"id": "D1", "wall": "W1", "kind": "door", "offset": 1000, "width": 900}
)
geo = wall_geometry([wall], [door], poche=False)
jambs = {
round(float(line.p1[0]), 6)
for line in geo.jambs
if abs(line.p1[0] - line.p2[0]) < 1e-9 and abs(abs(line.p1[1] - line.p2[1]) - 200.0) < 1e-9
}
return (
not _arch_passes_through(geo, (1450.0, 100.0))
and not _arch_passes_through(geo, (1450.0, -100.0))
and _arch_passes_through(geo, (500.0, 100.0))
and {1000.0, 1900.0} <= jambs
and tuple(geo.omitted) == ()
)
async def arch_rooms_detect_foreign():
"""A plan of plain lines - no record of ours - yields the net areas.
Two rooms drawn as the inner outlines of 200 mm walls on a layer named
WALLS: 4000 x 5000 and 3000 x 5000, inside an outer outline (-200, -200)
to (7400, 5200). The reader must find exactly those two faces - 20 000 000
and 15 000 000 mm² - at the foreign-plan confidence 0.6, and must not have
written anything.
"""
from engineering.arch.rooms import rooms_detect
b = await _b()
for x0, y0, x1, y1 in (
(-200.0, -200.0, 7400.0, 5200.0),
(0.0, 0.0, 4000.0, 5000.0),
(4200.0, 0.0, 7200.0, 5000.0),
):
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
for a, c in zip(corners, corners[1:] + corners[:1], strict=True):
await b.entity_create_line(a[0], a[1], c[0], c[1], layer="WALLS")
before = len(await b.entity_list(limit=1000))
found = await rooms_detect(b)
areas = sorted(round(float(room["area"]), 3) for room in found["rooms"])
return (
areas == [15_000_000.0, 20_000_000.0]
and all(float(room["confidence"]) == 0.6 for room in found["rooms"])
and len(await b.entity_list(limit=1000)) == before
)
CHECKS = {
"core_line_length": (core_line_length, "Core"),
"core_circle_radius": (core_circle_radius, "Core"),
"core_layer_create": (core_layer_create, "Core"),
"core_polyline_closed": (core_polyline_closed, "Core"),
"core_linear_dim": (core_linear_dim, "Core"),
"core_save_dxf_roundtrip": (core_save_dxf_roundtrip, "Core"),
"dim_aligned_no_error": (dim_aligned_no_error, "Dimensions"),
"dim_angular_no_error": (dim_angular_no_error, "Dimensions"),
"array_polar_360_distinct": (array_polar_360_distinct, "Modify"),
"point_intersection_line_line": (point_intersection_line_line, "Geometry"),
"point_tangent_external": (point_tangent_external, "Geometry"),
"arc_has_length": (arc_has_length, "Query"),
"arc_select_by_length": (arc_select_by_length, "Query"),
"ezdxf_bounding_box": (ezdxf_bounding_box, "Query"),
"mtext_rotation_roundtrip": (mtext_rotation_roundtrip, "Entities"),
"screenshot_png": (screenshot_png, "Render"),
"dim_overlap_critique_fires": (dim_overlap_critique_fires, "Quality gate"),
"iso13567_dim_layer": (iso13567_dim_layer, "Quality gate"),
"construction_left_iso_caught": (construction_left_iso_caught, "Quality gate"),
"gear_no_self_overlap": (gear_no_self_overlap, "Engineering"),
"center_linetype_applied": (center_linetype_applied, "Entities"),
"hatch_area_subtracts_its_island": (hatch_area_subtracts_its_island, "Measurement"),
"boundary_area_agrees_with_measure": (boundary_area_agrees_with_measure, "Measurement"),
"two_vertex_circle_has_area": (two_vertex_circle_has_area, "Measurement"),
"diameter_dim_measures_the_diameter": (diameter_dim_measures_the_diameter, "Dimensions"),
"radius_dim_ignores_the_leader_length": (radius_dim_ignores_the_leader_length, "Dimensions"),
"pid_block_define_attdef_roundtrip": (pid_block_define_attdef_roundtrip, "P&ID"),
"pid_tag_parse_fic": (pid_tag_parse_fic, "P&ID"),
"pid_graph_edge_count": (pid_graph_edge_count, "P&ID"),
"settings_dimstyle_iso25_values": (settings_dimstyle_iso25_values, "Settings"),
"settings_layer_state_roundtrip": (settings_layer_state_roundtrip, "Settings"),
"settings_pdf_mediabox_a3": (settings_pdf_mediabox_a3, "Settings"),
"mech_part_roundtrip": (mech_part_roundtrip, "Mechanical"),
"mech_section_hatch_area": (mech_section_hatch_area, "Mechanical"),
"mech_iso286_on_dimension": (mech_iso286_on_dimension, "Mechanical"),
"mech_thread_unrepresented_is_caught": (mech_thread_unrepresented_is_caught, "Mechanical"),
"std_part_iso4014_m12": (std_part_iso4014_m12, "Mechanical"),
"sheet_frame_iso5457_a3": (sheet_frame_iso5457_a3, "Sheet"),
"bom_balloon_link": (bom_balloon_link, "Sheet"),
"arch_junction_l_t_x": (arch_junction_l_t_x, "Architecture"),
"arch_room_area_net": (arch_room_area_net, "Architecture"),
"arch_opening_cuts_wall": (arch_opening_cuts_wall, "Architecture"),
"arch_rooms_detect_foreign": (arch_rooms_detect_foreign, "Architecture"),
}
def main():
if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}:
print("usage: correctness_suite.py <check_name> | --list")
print("Run one deterministic correctness check, or list available checks.")
return
if len(sys.argv) == 2 and sys.argv[1] == "--list":
for k, (_fn, cat) in CHECKS.items():
print(f"{k}\t{cat}")
return
name = sys.argv[1]
if name not in CHECKS:
print(f"Unknown check: {name}", file=sys.stderr)
raise SystemExit(2)
fn, _cat = CHECKS[name]
ok = asyncio.run(fn())
print("PASS" if ok else "FAIL")
if __name__ == "__main__":
main()