feat(understand): drawing_diff - two revisions matched by geometric signature

engineering/understand/diff.py reads each revision once into entity
records and matches them in three stages: exact signature (type, layer,
geometry rounded to tol, text, block, rotation, scale, attributes;
handles ignored), then handle while at least half of the exact matches
kept theirs, then type + layer + nearest anchor within move_tol (default
2 % of the 1st-99th percentile diagonal). The report counts changes by
layer and type, says what changed per entity (moved by, text, attribute,
radius ... from -> to), compares named block definitions by content and
groups the changes into clusters.

drawing_diff opens group D's copy of SECTION 26. It is read-only unless
markup=True, which files revision clouds around the model-space clusters
on the current document through add_revision - refused before any read
with new_path, without a valid rev, or on an engine without revcloud.
A revision path that is neither .dxf nor .dwg is refused naming the
parameter (old_path: / new_path:), like every other path refusal.

Tests: the synthetic plant P&ID edited five ways (a move, a text, an
attribute, an add, a delete) under renumbered handles reports exactly
those five edits; renumbering alone reports nothing.

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-24 11:51:19 +03:00
co-authored by Claude Opus 5.5
parent 704a44b190
commit 54325e6de0
9 changed files with 1435 additions and 8 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Two revisions of the synthetic plant P&ID with known edits, for ``drawing_diff``.
Built from ``build_plant_pair`` (``tests/fixtures/plant_pair.py``) with
ezdxf. The older revision is the P&ID plus one tagged block reference; the
newer one moves a line by half the move tolerance, rewrites a text, changes
the block's attribute, deletes one line, adds a circle - and then gives every
model-space entity a new handle, the way a save through another program
renumbers them. What was edited is
picked from the older revision's own records (entities whose signature is
unique, so a double-drawn line can never be the one edited) and returned with
the paths: the expected answer is read off the fixture, never typed in.
"""
from __future__ import annotations
from collections import Counter
from pathlib import Path
import ezdxf
from engineering.understand.diff import diff_snapshots, read_revision, robust_box, signature
from engineering.understand.snapshot import records_from_doc
from tests.fixtures.plant_pair import build_plant_pair
TAG_BLOCK = "DIFF_TAG"
TAG_LAYER = "DIFF-TAGS"
ADDED_LAYER = "DIFF-ADDED"
NEW_TEXT = "REV-B NOTE"
TAG_BEFORE = "X-1"
TAG_AFTER = "X-2"
def renumber_modelspace(doc) -> None:
"""Every model-space entity replaced by a copy under a fresh handle."""
msp = doc.modelspace()
for entity in list(msp):
clone = entity.copy()
msp.add_entity(clone)
msp.delete_entity(entity)
def build_revision_pair(directory, *, edits: bool = True) -> dict:
directory = Path(directory)
truth = build_plant_pair(directory / "plant")
base = ezdxf.readfile(truth["pid"])
box = robust_box(records_from_doc(base, source="base"))
centre = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
block = base.blocks.new(TAG_BLOCK)
block.add_circle((0.0, 0.0), 5.0)
block.add_attdef("TAG", (0.0, -8.0), dxfattribs={"height": 2.5})
base.layers.add(TAG_LAYER)
tag = base.modelspace().add_blockref(TAG_BLOCK, centre, dxfattribs={"layer": TAG_LAYER})
tag.add_auto_attribs({"TAG": TAG_BEFORE})
old_path = directory / "rev_a.dxf"
base.saveas(old_path)
old_snap, _blocks = read_revision(str(old_path))
move_tol = diff_snapshots(old_snap, old_snap)["move_tol"]
counts = Counter(signature(r) for r in old_snap.records)
unique = [r for r in old_snap.records if r.space == "Model" and counts[signature(r)] == 1]
moved = next(r for r in unique if r.type == "LINE")
text = next(r for r in unique if r.type in ("TEXT", "MTEXT"))
insert = next(r for r in old_snap.records if r.block == TAG_BLOCK)
deleted = next(
r
for r in unique
if r.type in ("LINE", "LWPOLYLINE") and (r.type, r.layer) != (moved.type, moved.layer)
)
new = ezdxf.readfile(old_path)
msp = new.modelspace()
if edits:
new.entitydb[moved.handle].translate(move_tol / 2.0, 0.0, 0.0)
target = new.entitydb[text.handle]
if target.dxftype() == "MTEXT":
target.text = NEW_TEXT
else:
target.dxf.text = NEW_TEXT
new.entitydb[insert.handle].get_attrib("TAG").dxf.text = TAG_AFTER
msp.delete_entity(new.entitydb[deleted.handle])
new.layers.add(ADDED_LAYER)
msp.add_circle(centre, move_tol / 10.0, dxfattribs={"layer": ADDED_LAYER})
renumber_modelspace(new)
new_path = directory / "rev_b.dxf"
new.saveas(new_path)
return {
"old": str(old_path),
"new": str(new_path),
"move_tol": move_tol,
"old_count": len(old_snap.records),
"moved": moved.handle,
"text": text.handle,
"text_before": text.text,
"insert": insert.handle,
"deleted": deleted.handle,
}
+9 -3
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (241 tools, 26 groups).
"""Frozen snapshot of the surface (242 tools, 26 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -591,10 +591,16 @@ async def test_tool_group_sizes_are_unchanged():
exactly `PACK_TOOL_NAMES["arch"]`, and every group the track-F branches
had grown while the tag was unranked on them returned to its
pre-track-F value.
Track H group D opened its copy of SECTION 26 (Understanding & QA) with
`drawing_diff`, tagged `analysis` and `query`; `analysis` outranks `query`
in `_GROUP_TAG_PRIORITY`, so it files under `analysis` (12 -> 13). This
snapshot is branch-local: the track H merge recomputes it once the
understand, network and diff branches have landed.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
"analysis": 13,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -621,4 +627,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 241
assert sum(sizes.values()) == 242
+309
View File
@@ -0,0 +1,309 @@
"""drawing_diff's engine: signatures, the three matching stages, the report.
Pure tests build ``EntityRecord``s by hand, so every coordinate is the
answer. The known-edits tests build two revisions of the synthetic plant P&ID
(``tests/fixtures/revisions.py``) and assert exactly the edits made - a move,
a text change, an attribute change, an add, a delete, under renumbered
handles - and nothing else.
"""
from __future__ import annotations
import ezdxf
import pytest
import config
from engineering.understand.diff import (
block_changes,
block_signatures,
cap_report,
diff_snapshots,
read_revision,
robust_box,
signature,
)
from engineering.understand.snapshot import EntityRecord, Snapshot
from tests.fixtures.revisions import (
ADDED_LAYER,
NEW_TEXT,
TAG_AFTER,
TAG_BEFORE,
build_revision_pair,
)
def rec(handle, type_, layer="PIPE", points=(), **kw):
space = kw.pop("space", "Model")
return EntityRecord(
handle=handle, type=type_, layer=layer, space=space, points=tuple(points), **kw
)
def snap(*records):
return Snapshot(
source="test",
insunits=4,
extmin=None,
extmax=None,
layers={},
layouts=(),
records=tuple(records),
)
def line(handle, x1, y1, x2, y2, layer="PIPE"):
return rec(handle, "LINE", layer, ((x1, y1), (x2, y2)))
STILL = [line("A", 0, 0, 100, 0), line("B", 0, 50, 100, 50), line("C", 0, 100, 100, 100)]
RENUMBERED = [line("1A", 0, 0, 100, 0), line("1B", 0, 50, 100, 50), line("1C", 0, 100, 100, 100)]
# -- signatures ------------------------------------------------------------------------
def test_a_signature_is_rounded_to_tol_and_ignores_a_lines_direction():
a = line("A", 0.0, 0.0, 10.0, 0.0)
b = line("B", 10.001, 0.004, 0.0, 0.0)
assert signature(a, 0.01) == signature(b, 0.01)
assert signature(a, 0.01) != signature(line("C", 0.0, 0.0, 10.02, 0.0), 0.01)
def test_identical_revisions_have_no_change_and_no_cluster():
result = diff_snapshots(snap(*STILL), snap(*STILL))
assert result["summary"] == {
"old_entities": 3,
"new_entities": 3,
"unchanged": 3,
"changed": 0,
"added": 0,
"removed": 0,
}
assert result["clusters"] == [] and result["handles_stable"] is True
# -- the three stages ------------------------------------------------------------------
def test_a_move_under_renumbered_handles_is_matched_by_position():
old = snap(*STILL, line("D", 0, 200, 100, 200))
new = snap(*RENUMBERED, line("1D", 3, 200, 103, 200))
result = diff_snapshots(old, new, move_tol=10.0)
assert result["handles_stable"] is False
(row,) = result["changed"]
assert (row["old_handle"], row["new_handle"], row["matched_by"]) == ("D", "1D", "position")
assert row["changes"] == {"moved_by": [pytest.approx(3.0), pytest.approx(0.0)]}
assert result["summary"]["unchanged"] == 3
def test_a_text_change_is_reported_from_and_to():
old = snap(*STILL, rec("T", "TEXT", "NOTES", ((10, 10),), text="OLD", height=2.5))
new = snap(*STILL, rec("T", "TEXT", "NOTES", ((10, 10),), text="NEW", height=2.5))
(row,) = diff_snapshots(old, new)["changed"]
assert row["matched_by"] == "handle"
assert row["changes"] == {"text": {"from": "OLD", "to": "NEW"}}
def test_an_attribute_change_is_reported_by_tag():
before = (("TAG", "A-1"), ("SERVICE", "CIP"))
after = (("TAG", "A-2"), ("SERVICE", "CIP"))
old = snap(*STILL, rec("I", "INSERT", "TAGS", ((500, 500),), block="TB", attribs=before))
new = snap(*STILL, rec("I", "INSERT", "TAGS", ((500, 500),), block="TB", attribs=after))
(row,) = diff_snapshots(old, new)["changed"]
assert row["changes"] == {"attribs": [{"tag": "TAG", "from": "A-1", "to": "A-2"}]}
def test_a_radius_change_is_reported_from_and_to():
old = snap(*STILL, rec("R", "CIRCLE", "PIPE", ((0, 0),), radius=10.0))
new = snap(*STILL, rec("R", "CIRCLE", "PIPE", ((0, 0),), radius=12.0))
(row,) = diff_snapshots(old, new)["changed"]
assert row["changes"] == {"radius": {"from": 10.0, "to": 12.0}}
def test_the_handle_stage_catches_a_layer_change_the_position_stage_cannot():
old = snap(*STILL, line("L", 0, 300, 100, 300, layer="P1"))
new = snap(*STILL, line("L", 0, 300, 100, 300, layer="P2"))
(row,) = diff_snapshots(old, new)["changed"]
assert row["matched_by"] == "handle"
assert row["changes"] == {"layer": {"from": "P1", "to": "P2"}}
def test_stable_handles_pair_a_far_move_by_handle():
old = snap(*STILL, rec("20", "CIRCLE", "PIPE", ((0, 0),), radius=5.0))
new = snap(*STILL, rec("20", "CIRCLE", "PIPE", ((5000, 5000),), radius=5.0))
(row,) = diff_snapshots(old, new, move_tol=10.0)["changed"]
assert row["matched_by"] == "handle"
assert row["changes"] == {"moved_by": [pytest.approx(5000.0), pytest.approx(5000.0)]}
def test_renumbered_handles_never_pair_strangers_that_share_a_handle():
old = snap(*STILL, rec("20", "CIRCLE", "PIPE", ((0, 0),), radius=5.0))
new = snap(*RENUMBERED, rec("20", "CIRCLE", "PIPE", ((5000, 5000),), radius=5.0))
result = diff_snapshots(old, new, move_tol=10.0)
assert result["handles_stable"] is False
assert result["changed"] == []
assert [r["handle"] for r in result["removed"]] == ["20"]
assert [r["at"] for r in result["added"]] == [[5000.0, 5000.0]]
def test_beyond_move_tol_a_line_is_removed_and_another_added():
old = snap(*STILL, line("D", 0, 200, 100, 200))
new = snap(*STILL, line("E", 0, 700, 100, 700))
result = diff_snapshots(old, new, move_tol=10.0)
assert (result["summary"]["added"], result["summary"]["removed"]) == (1, 1)
assert result["changed"] == []
# -- the move tolerance ----------------------------------------------------------------
def test_the_default_move_tol_is_2_percent_of_the_robust_diagonal():
# 100 points on the diagonal of a 2970 x 3960 box, and one stray 10 km away:
# the 1st-99th percentile box is (30, 40)-(2970, 3960), diagonal 4900.
points = [rec(f"P{i}", "POINT", "PTS", ((i * 30.0, i * 40.0),)) for i in range(100)]
stray = rec("FAR", "POINT", "PTS", ((1.0e7, 1.0e7),))
drawing = snap(*points, stray)
assert robust_box(drawing) == (30.0, 40.0, 2970.0, 3960.0)
assert diff_snapshots(drawing, drawing)["move_tol"] == pytest.approx(98.0)
def test_a_non_positive_tolerance_is_refused_by_name():
with pytest.raises(ValueError, match=r"^tol must be a positive number; got 0"):
diff_snapshots(snap(), snap(), tol=0)
with pytest.raises(ValueError, match=r"^move_tol must be a positive number; got -1"):
diff_snapshots(snap(), snap(), move_tol=-1)
# -- the report ------------------------------------------------------------------------
def test_changes_are_counted_by_layer_and_type_and_clustered():
old = snap(
line("U", 0, -500, 100, -500),
line("D", 0, 200, 100, 200),
rec("T", "TEXT", "NOTES", ((10, 10),), text="OLD", height=2.5),
)
new = snap(
line("U", 0, -500, 100, -500),
line("D", 3, 200, 103, 200),
rec("T", "TEXT", "NOTES", ((10, 10),), text="NEW", height=2.5),
rec("N1", "CIRCLE", "PIPE", ((5000, 5000),), radius=5.0, bbox=(4995, 4995, 5005, 5005)),
)
result = diff_snapshots(old, new, move_tol=10.0)
assert result["by_layer"] == {
"NOTES": {"changed": 1, "added": 0, "removed": 0},
"PIPE": {"changed": 1, "added": 1, "removed": 0},
}
assert result["by_type"] == {
"CIRCLE": {"changed": 0, "added": 1, "removed": 0},
"LINE": {"changed": 1, "added": 0, "removed": 0},
"TEXT": {"changed": 1, "added": 0, "removed": 0},
}
# pad = max(0.25 x 10, 10 x 0.01) = 2.5 around each cluster
assert result["clusters"] == [
{"space": "Model", "box": [-2.5, 197.5, 105.5, 202.5], "count": 1},
{"space": "Model", "box": [7.5, 7.5, 12.5, 12.5], "count": 1},
{"space": "Model", "box": [4992.5, 4992.5, 5007.5, 5007.5], "count": 1},
]
def test_cap_report_cuts_every_list_and_says_how_much():
old = snap(*STILL)
new = snap(*STILL, *(line(f"N{i}", 0, 1000 * (i + 1), 100, 1000 * (i + 1)) for i in range(5)))
capped = cap_report(diff_snapshots(old, new, move_tol=10.0), limit=2)
assert len(capped["added"]) == 2
assert capped["truncated"] == {"changed": 0, "added": 3, "removed": 0}
assert capped["summary"]["added"] == 5
# -- block definitions -----------------------------------------------------------------
def _blocks(radius: float, extra: bool):
doc = ezdxf.new()
doc.blocks.new("B1").add_circle((0, 0), radius)
doc.blocks.new("B2").add_line((0, 0), (10, 0))
if extra:
doc.blocks.new("B3").add_line((0, 0), (0, 10))
doc.blocks.new_anonymous_block().add_line((0, 0), (1, 1))
return doc
def test_block_definitions_are_compared_by_content():
old = block_signatures(_blocks(5.0, extra=False))
new = block_signatures(_blocks(6.0, extra=True))
assert sorted(old) == ["B1", "B2"] # the anonymous *U block is left out
assert block_changes(old, new) == {
"compared": True,
"added": ["B3"],
"removed": [],
"changed": ["B1"],
}
assert block_changes(None, new)["compared"] is False
# -- reading a revision ----------------------------------------------------------------
def test_read_revision_refuses_a_file_over_the_size_limit(tmp_path, monkeypatch):
path = tmp_path / "big.dxf"
ezdxf.new().saveas(path)
monkeypatch.setattr(config.settings, "max_dxf_bytes", 10)
with pytest.raises(ValueError, match=r"over the MAX_DXF_BYTES limit of 10"):
read_revision(str(path))
def test_read_revision_refuses_a_file_that_is_not_a_dxf(tmp_path):
path = tmp_path / "notes.dxf"
path.write_text("not a drawing\n", encoding="utf-8")
with pytest.raises(ValueError, match=r"not a readable DXF"):
read_revision(str(path))
# -- the known edits on the synthetic plant --------------------------------------------
def test_the_known_edits_and_nothing_else(tmp_path):
pair = build_revision_pair(tmp_path)
old, old_blocks = read_revision(pair["old"])
new, new_blocks = read_revision(pair["new"])
result = diff_snapshots(old, new, move_tol=pair["move_tol"])
total = pair["old_count"]
assert result["summary"] == {
"old_entities": total,
"new_entities": total,
"unchanged": total - 4,
"changed": 3,
"added": 1,
"removed": 1,
}
changed = {row["old_handle"]: row for row in result["changed"]}
assert set(changed) == {pair["moved"], pair["text"], pair["insert"]}
assert {row["matched_by"] for row in result["changed"]} == {"position"}
moved = changed[pair["moved"]]["changes"]
assert list(moved) == ["moved_by"]
assert moved["moved_by"][0] == pytest.approx(pair["move_tol"] / 2.0, abs=1e-9)
assert moved["moved_by"][1] == pytest.approx(0.0, abs=1e-9)
assert changed[pair["text"]]["changes"] == {
"text": {"from": pair["text_before"], "to": NEW_TEXT}
}
assert changed[pair["insert"]]["changes"] == {
"attribs": [{"tag": "TAG", "from": TAG_BEFORE, "to": TAG_AFTER}]
}
assert [row["handle"] for row in result["removed"]] == [pair["deleted"]]
assert [(row["type"], row["layer"]) for row in result["added"]] == [("CIRCLE", ADDED_LAYER)]
assert block_changes(old_blocks, new_blocks) == {
"compared": True,
"added": [],
"removed": [],
"changed": [],
}
def test_renumbered_handles_alone_are_no_change(tmp_path):
pair = build_revision_pair(tmp_path, edits=False)
old, _ = read_revision(pair["old"])
new, _ = read_revision(pair["new"])
summary = diff_snapshots(old, new)["summary"]
assert (summary["changed"], summary["added"], summary["removed"]) == (0, 0, 0)
assert summary["unchanged"] == pair["old_count"]
+130
View File
@@ -0,0 +1,130 @@
"""drawing_diff over the MCP wire, on the headless engine, against the synthetic revisions."""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from engineering.sheet.frames import SHEET_LAYER
from tests.fixtures.revisions import build_revision_pair
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document - never the operator's AutoCAD."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def _total(client) -> int:
stats = (await client.call_tool("analysis_entity_stats", {})).structured_content
return stats["total_entities"]
async def test_the_current_document_against_its_older_revision(client, tmp_path):
pair = build_revision_pair(tmp_path)
await client.call_tool("drawing_open", {"path": pair["new"]})
before = await _total(client)
result = (
await client.call_tool(
"drawing_diff", {"old_path": pair["old"], "move_tol": pair["move_tol"]}
)
).structured_content
assert (result["summary"]["changed"], result["summary"]["added"]) == (3, 1)
assert result["summary"]["removed"] == 1
assert {row["old_handle"] for row in result["changed"]} == {
pair["moved"],
pair["text"],
pair["insert"],
}
assert result["blocks"]["compared"] is True and result["blocks"]["changed"] == []
assert result["truncated"] == {"changed": 0, "added": 0, "removed": 0}
assert await _total(client) == before, "a diff without markup writes nothing"
async def test_two_paths_and_the_list_cap(client, tmp_path):
pair = build_revision_pair(tmp_path)
result = (
await client.call_tool(
"drawing_diff",
{
"old_path": pair["old"],
"new_path": pair["new"],
"move_tol": pair["move_tol"],
"limit": 1,
},
)
).structured_content
assert len(result["changed"]) == 1
assert result["truncated"]["changed"] == 2
assert result["summary"]["changed"] == 3
async def test_markup_clouds_every_model_space_cluster(client, tmp_path):
pair = build_revision_pair(tmp_path)
await client.call_tool("drawing_open", {"path": pair["new"]})
result = (
await client.call_tool(
"drawing_diff",
{"old_path": pair["old"], "move_tol": pair["move_tol"], "markup": True, "rev": "b"},
)
).structured_content
model = [c for c in result["clusters"] if c["space"] == "Model"]
markup = result["markup"]
assert markup["created"] is True and markup["rev"] == "B"
assert len(markup["clouds"]) == len(model) >= 1
for handle in markup["clouds"]:
cloud = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert (cloud["type"], cloud["layer"]) == ("LWPOLYLINE", SHEET_LAYER)
@pytest.mark.parametrize(
("arguments", "fragment"),
[
(
{"markup": True, "rev": "B", "new_path": "NEW"},
"markup draws revision clouds on the current",
),
({"markup": True}, "markup needs `rev`"),
({"markup": True, "rev": "TOO-LONG"}, "markup needs `rev`"),
],
)
async def test_markup_refusals_come_before_any_read(client, tmp_path, arguments, fragment):
pair = build_revision_pair(tmp_path)
if arguments.get("new_path") == "NEW":
arguments = {**arguments, "new_path": pair["new"]}
result = await client.call_tool(
"drawing_diff", {"old_path": pair["old"], **arguments}, raise_on_error=False
)
assert result.is_error is True
assert fragment in result.content[0].text
async def test_a_missing_revision_is_refused_by_parameter(client, tmp_path):
result = await client.call_tool(
"drawing_diff", {"old_path": str(tmp_path / "nowhere.dxf")}, raise_on_error=False
)
assert result.is_error is True
assert "old_path:" in result.content[0].text and "does not exist" in result.content[0].text
async def test_a_revision_that_is_neither_dxf_nor_dwg_is_refused_by_parameter(client, tmp_path):
notes = tmp_path / "notes.txt"
notes.write_text("not a drawing\n", encoding="utf-8")
result = await client.call_tool("drawing_diff", {"old_path": str(notes)}, raise_on_error=False)
assert result.is_error is True
text = result.content[0].text
assert "old_path:" in text and "reads a .dxf or a .dwg" in text
async def test_the_diff_is_advertised_as_non_destructive(client):
tools = {tool.name: tool for tool in await client.list_tools()}
assert tools["drawing_diff"].annotations.destructiveHint is False