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
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""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"]
|