The evidence for track H, in the shape tracks A, E, B + G and F used.
- benchmarks/tasks_v7.py: 21 tasks, DEFAULT_MATRIX = "v7"; v6 to v2 stay
addressable. Both new tasks read the synthetic plant pair and nothing
else. takeoff_roundtrip: the P&ID called schematic, every routable run's
layout length equal to the rectilinear MST of its tags, sizes and services
as drawn (a return drawn on the supply layer included), the cable metres
rounded up after the allowance, an unstated power left empty.
understand_foreign: inches over millimetres flagged, the stray entity found
by handle, two plan copies separated, five layers classified. A test breaks
each input and watches its gate fail by name.
- Five correctness checks, every number worked out by hand in its docstring;
the published A/B report is re-run over 48 checks: 26 / 48 -> 48 / 48,
zero regressed.
- scripts/smoke_understand_com.py reads the pair through the live engine -
snapshots, the DWG route, both takeoffs and a diff - and closes every
document it opens; the CHANGELOG records what it returned.
scripts/field_test_takeoff.py runs the takeoffs on a user's own files,
refuses to write inside the repository and prints no quantity by default.
- The README token table is remeasured; check_doc_numbers derives the task
count from the v7 matrix; CLAUDE.md, both READMEs, the CHANGELOG and the
spec status line say what shipped.
One reader defect found on the way: describe.tag_occurrences read a wiring
callout ("Wiring to CP-2") as a place its panel is written, so the scale
check called CP-2 ambiguous and read "Wiring to CP1" as a phantom tag CP1 -
10 tags and 45 pairs (13.3 % within 10 %) where the generator placed 11 and
55 (21.8 %). A callout is no longer a tag occurrence, the rule
network.tag_occurrences already kept; two tests failed before the fix.
The smoke works only in documents it opened from its own temporary folder,
known by full path; it attaches with GetActiveObject only (the backend's
Dispatch fallback would launch AutoCAD), checks in the same COM-thread call
that the active document is its own before each snapshot, the DWG save and
the move, closes its documents with Close(False) and re-activates the
document that was active when it started. No new ActiveX member: Documents,
Item, FullName, ActiveDocument, Activate and Close are the ones the backend
already calls. Measured on AutoCAD 2026 (25.1s): exit 0, model-space types
equal file / live / DWG, export 0.104 s, no document of the run left open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1101 lines
44 KiB
Python
1101 lines
44 KiB
Python
"""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
|
||
)
|
||
|
||
|
||
# ── Understanding foreign drawings (track H) ───────────────────────────────
|
||
|
||
|
||
def _plant_pair() -> dict:
|
||
"""The synthetic plant pair, written to a fresh temporary folder."""
|
||
from tests.fixtures.plant_pair import build_plant_pair
|
||
|
||
return build_plant_pair(tempfile.mkdtemp(prefix="acadmcp_plant_"))
|
||
|
||
|
||
def _quoted(handle: str) -> str:
|
||
"""A handle as it appears in a JSON dump - quoted, so '2A' never matches '12A3'."""
|
||
return f'"{handle}"'
|
||
|
||
|
||
async def scale_check_detects_schematic():
|
||
"""The synthetic P&ID is schematic against its layout; a uniform 2x copy is not.
|
||
|
||
The pair: room 1 of the P&ID is the layout stretched 1.3x, room 2 is
|
||
rearranged, and of the 55 pairs of the 11 shared tags 21.8 % lie within
|
||
±10 % of the median ratio - at most 50 % is ``schematic`` (spec §6).
|
||
The control: six tags at (0, 0), (4000, 0), (8000, 0), (0, 3000),
|
||
(4000, 3000), (8000, 6000) and the same tags at twice those coordinates -
|
||
the nearest pair is 3000 mm apart, every ratio is exactly 2.0, so the
|
||
doubled drawing is ``to_scale`` against the first with factor 2.
|
||
"""
|
||
import ezdxf
|
||
|
||
from engineering.understand.scale import scale_check
|
||
from engineering.understand.snapshot import read_snapshot
|
||
|
||
truth = _plant_pair()
|
||
pair = scale_check(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))
|
||
folder = tempfile.mkdtemp(prefix="acadmcp_scale_")
|
||
positions = [(0, 0), (4000, 0), (8000, 0), (0, 3000), (4000, 3000), (8000, 6000)]
|
||
paths = []
|
||
for factor in (1.0, 2.0):
|
||
doc = ezdxf.new("R2018")
|
||
msp = doc.modelspace()
|
||
for number, (x, y) in enumerate(positions, start=1):
|
||
msp.add_circle((x * factor, y * factor), 500.0)
|
||
msp.add_text(f"T{number}", dxfattribs={"insert": (x * factor, y * factor)})
|
||
path = os.path.join(folder, f"x{factor:g}.dxf")
|
||
doc.saveas(path)
|
||
paths.append(path)
|
||
control = scale_check(read_snapshot(paths[1]), read_snapshot(paths[0]))
|
||
return (
|
||
pair["verdict"] == "schematic"
|
||
and control["verdict"] == "to_scale"
|
||
and abs(float(control["factor"]) - 2.0) < 1e-9
|
||
)
|
||
|
||
|
||
async def pipe_takeoff_rmst_exact():
|
||
"""Every routable pipe run's layout length is the rectilinear MST of its tags.
|
||
|
||
Measured on the synthetic pair with ``length_source="layout"``. The truth
|
||
is the generator's own Manhattan / Prim arithmetic over the tag positions it
|
||
placed, hand-checked in tests/test_plant_pair.py - PR1 5500 (5000 + 500),
|
||
PR2 13200 (the three distances 6000 / 7200 / 13200, spanning tree 6000 +
|
||
7200), CS1 20700 (20500 + 200). All eight routable runs must agree to a
|
||
micron; the run ending on T105, which the layout lacks, is not routable.
|
||
"""
|
||
from engineering.understand.network import build_network
|
||
from engineering.understand.snapshot import read_snapshot
|
||
from engineering.understand.takeoff import pipe_rows
|
||
|
||
truth = _plant_pair()
|
||
pid, layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
|
||
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
|
||
result = pipe_rows(build_network(pid), pid, layout, services=services, length_source="layout")
|
||
found = {(r["service"], tuple(sorted(r["tags"]))): r for r in result["runs"]}
|
||
routable = [run for run in truth["pipe_runs"] if run["layout_length_mm"] is not None]
|
||
for run in routable:
|
||
got = found.get((run["service"], tuple(run["tags"])))
|
||
if got is None or got.get("layout_m") is None:
|
||
return False
|
||
if abs(float(got["layout_m"]) * 1000.0 - run["layout_length_mm"]) > 1e-6:
|
||
return False
|
||
return len(routable) == 8
|
||
|
||
|
||
async def cable_takeoff_roundup():
|
||
"""Cable rows: the panel from the callout, Manhattan length, metres rounded up.
|
||
|
||
The truth rows of the synthetic pair, by hand: M11 (5500, 8200) to CP-1
|
||
(1200, 10800) is 6900 mm, 6.9 m x 1.2 = 8.28 -> 9; T102 (8000, 3500) to CP-1
|
||
is 14100 mm -> 16.92 -> 17; M12 (26300, 8400) to CP-2 (35000, 1300) is
|
||
15800 mm -> 18.96 -> 19; T202 (28700, 3000) to CP-2 is 8000 mm -> 9.6 -> 10.
|
||
The heater on T202 states no power, and its row must keep an empty one.
|
||
"""
|
||
from engineering.understand.snapshot import read_snapshot
|
||
from engineering.understand.takeoff import cable_rows
|
||
|
||
truth = _plant_pair()
|
||
rows = {
|
||
row["tag"]: row
|
||
for row in cable_rows(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))["rows"]
|
||
}
|
||
expected = {"M11": ("CP-1", 9), "T102": ("CP-1", 17), "M12": ("CP-2", 19), "T202": ("CP-2", 10)}
|
||
for tag, (panel, metres) in expected.items():
|
||
row = rows.get(tag)
|
||
if row is None or row.get("panel") != panel or row.get("cable_m") != metres:
|
||
return False
|
||
return rows["T202"].get("kw") is None and rows["M11"].get("kw") == 5.5
|
||
|
||
|
||
async def diff_detects_known_edits():
|
||
"""Two revisions differing by exactly five edits - and the diff says so.
|
||
|
||
A move (a LINE shifted 10 mm), a text change (PUMP A -> PUMP B), an
|
||
attribute change (TAG T101 -> T102), an add (a new LINE) and a delete (a
|
||
LINE removed); a CIRCLE stays untouched. The edits are made on a copy read
|
||
back from the first file, so every surviving entity keeps its handle. The
|
||
diff must list the added line as added, the removed line as removed, the
|
||
three edited entities as changed - and nothing else, the circle included.
|
||
"""
|
||
import json
|
||
|
||
import ezdxf
|
||
|
||
from engineering.understand.diff import diff_snapshots
|
||
from engineering.understand.snapshot import read_snapshot
|
||
|
||
folder = tempfile.mkdtemp(prefix="acadmcp_diff_")
|
||
old_path, new_path = os.path.join(folder, "rev_a.dxf"), os.path.join(folder, "rev_b.dxf")
|
||
doc = ezdxf.new("R2018")
|
||
msp = doc.modelspace()
|
||
moved = msp.add_line((0, 0), (100, 0), dxfattribs={"layer": "PIPE"})
|
||
text = msp.add_text("PUMP A", dxfattribs={"insert": (0, 50), "height": 2.5})
|
||
block = doc.blocks.new("TAGGED")
|
||
block.add_circle((0, 0), 10)
|
||
block.add_attdef("TAG", (0, 0), dxfattribs={"height": 2.5})
|
||
insert = msp.add_blockref("TAGGED", (200, 0))
|
||
insert.add_auto_attribs({"TAG": "T101"})
|
||
gone = msp.add_line((0, 100), (100, 100), dxfattribs={"layer": "PIPE"})
|
||
kept = msp.add_circle((300, 300), 20)
|
||
doc.saveas(old_path)
|
||
|
||
revision = ezdxf.readfile(old_path)
|
||
revision.entitydb[moved.dxf.handle].translate(10, 0, 0)
|
||
revision.entitydb[text.dxf.handle].dxf.text = "PUMP B"
|
||
revision.entitydb[insert.dxf.handle].get_attrib("TAG").dxf.text = "T102"
|
||
revision.modelspace().delete_entity(revision.entitydb[gone.dxf.handle])
|
||
added = revision.modelspace().add_line((500, 500), (600, 500), dxfattribs={"layer": "PIPE"})
|
||
revision.saveas(new_path)
|
||
|
||
diff = diff_snapshots(read_snapshot(old_path), read_snapshot(new_path))
|
||
changed = json.dumps(diff["changed"])
|
||
dumped = changed + json.dumps(diff["added"]) + json.dumps(diff["removed"])
|
||
return (
|
||
len(diff["added"]) == 1
|
||
and len(diff["removed"]) == 1
|
||
and len(diff["changed"]) == 3
|
||
and _quoted(added.dxf.handle) in json.dumps(diff["added"])
|
||
and _quoted(gone.dxf.handle) in json.dumps(diff["removed"])
|
||
and all(_quoted(e.dxf.handle) in changed for e in (moved, text, insert))
|
||
and _quoted(kept.dxf.handle) not in dumped
|
||
)
|
||
|
||
|
||
async def topology_known_defects():
|
||
"""Dangling ends, a near miss and a crossing, placed on purpose - and nothing else.
|
||
|
||
On layer PIPE: a closed 1000 x 1000 square (no free end, no defect); a LINE
|
||
from (500, -500) that stops at (500, -5), 5 mm short of the square's bottom
|
||
edge (a near miss at gap 10, and a dangling end); two LINEs crossing at
|
||
(2500, 500), away from all four of their ends (one interior crossing); and a
|
||
clean T - a LINE ending on the middle of another at (4500, 0), which is a
|
||
junction, not a crossing and not a near miss.
|
||
"""
|
||
import json
|
||
|
||
import ezdxf
|
||
|
||
from engineering.understand.snapshot import read_snapshot
|
||
from engineering.understand.topology import topology_findings
|
||
|
||
doc = ezdxf.new("R2018")
|
||
msp = doc.modelspace()
|
||
pipe = {"layer": "PIPE"}
|
||
ring = msp.add_lwpolyline(
|
||
[(0, 0), (1000, 0), (1000, 1000), (0, 1000)], close=True, dxfattribs=pipe
|
||
)
|
||
short = msp.add_line((500, -500), (500, -5), dxfattribs=pipe)
|
||
cross_a = msp.add_line((2000, 0), (3000, 1000), dxfattribs=pipe)
|
||
cross_b = msp.add_line((2000, 1000), (3000, 0), dxfattribs=pipe)
|
||
bar = msp.add_line((4000, 0), (5000, 0), dxfattribs=pipe)
|
||
stem = msp.add_line((4500, 0), (4500, 500), dxfattribs=pipe)
|
||
path = os.path.join(tempfile.mkdtemp(prefix="acadmcp_topo_"), "topology.dxf")
|
||
doc.saveas(path)
|
||
|
||
found = topology_findings(read_snapshot(path), layers=["PIPE"], gap=10.0)
|
||
near = json.dumps(found["near_miss"])
|
||
crossing = json.dumps(found["crossing"])
|
||
dangling = json.dumps(found["dangling"])
|
||
return (
|
||
_quoted(short.dxf.handle) in near
|
||
and _quoted(short.dxf.handle) in dangling
|
||
and _quoted(ring.dxf.handle) not in dangling
|
||
and _quoted(ring.dxf.handle) not in crossing
|
||
and len(found["crossing"]) == 1
|
||
and _quoted(cross_a.dxf.handle) in crossing
|
||
and _quoted(cross_b.dxf.handle) in crossing
|
||
and all(_quoted(e.dxf.handle) not in crossing + near for e in (bar, stem))
|
||
)
|
||
|
||
|
||
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"),
|
||
"scale_check_detects_schematic": (scale_check_detects_schematic, "Understanding"),
|
||
"pipe_takeoff_rmst_exact": (pipe_takeoff_rmst_exact, "Understanding"),
|
||
"cable_takeoff_roundup": (cable_takeoff_roundup, "Understanding"),
|
||
"diff_detects_known_edits": (diff_detects_known_edits, "Understanding"),
|
||
"topology_known_defects": (topology_known_defects, "Understanding"),
|
||
}
|
||
|
||
|
||
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()
|