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:
co-authored by
Claude Opus 5.5
parent
704a44b190
commit
54325e6de0
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **v1.5 release snapshot:** 241 tools · 8 resources · 5 prompt templates · 4516 collected tests.
|
> **v1.5 release snapshot:** 242 tools · 8 resources · 5 prompt templates · 4516 collected tests.
|
||||||
> 241 is the **registered** count; a default install advertises 236 over `tools/list`,
|
> 242 is the **registered** count; a default install advertises 237 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
@@ -122,7 +122,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
|||||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||||
|
|
||||||
<sub>241 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
<sub>242 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||||
|
|
||||||
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
|
||||||
|
|
||||||
|
|||||||
@@ -2465,6 +2465,19 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"mimari plan",
|
"mimari plan",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
# ── Understanding & QA (SECTION 26, track H group D) ────────────────────
|
||||||
|
"drawing_diff": ToolAliases(
|
||||||
|
acad=("COMPARE",),
|
||||||
|
synonyms=(
|
||||||
|
"compare two drawings",
|
||||||
|
"compare two revisions",
|
||||||
|
"what changed between revisions",
|
||||||
|
"drawing differences",
|
||||||
|
"cloud the changes",
|
||||||
|
"revizyonlar arası değişiklik",
|
||||||
|
"revizyonlar arasi degisiklik",
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 241,
|
"tools": 242,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 26
|
"groups": 26
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 13,
|
||||||
"architecture": 12,
|
"architecture": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 9,
|
||||||
@@ -897,6 +897,20 @@
|
|||||||
"destructiveHint": true
|
"destructiveHint": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "drawing_diff",
|
||||||
|
"group": "analysis",
|
||||||
|
"cost": "mutate",
|
||||||
|
"summary": "Compare two revisions of a drawing: what moved, changed, was added or removed.",
|
||||||
|
"tags": [
|
||||||
|
"analysis",
|
||||||
|
"query"
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"title": "Drawing: Diff Two Revisions",
|
||||||
|
"destructiveHint": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "drawing_export_dwg",
|
"name": "drawing_export_dwg",
|
||||||
"group": "sheet",
|
"group": "sheet",
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""``drawing_diff``: two revisions of one drawing, matched by geometric signature.
|
||||||
|
|
||||||
|
Spec §10. Each revision is read once into ``EntityRecord``s
|
||||||
|
(``engineering.understand.snapshot``) and the two are compared in memory.
|
||||||
|
Nothing in this module writes to a drawing; the revision clouds of
|
||||||
|
``markup=True`` are drawn by the tool through
|
||||||
|
``engineering.sheet.revision.add_revision``.
|
||||||
|
|
||||||
|
Matching runs in three stages, in this order, and a record leaves the pool the
|
||||||
|
moment it is matched:
|
||||||
|
|
||||||
|
1. **Exact.** Every record has a signature: its type, layer and space, its
|
||||||
|
geometry rounded to ``tol`` (points, bulges, closed flag, radius, arc
|
||||||
|
angles; the bounding box when it has no points), its text and text height,
|
||||||
|
its block name, rotation, scale and attributes. Equal signatures are the
|
||||||
|
same entity whatever their handles; where several old records share a
|
||||||
|
signature the one with the same handle is taken first. A LINE's two ends
|
||||||
|
are sorted, so a line drawn backwards is the same line.
|
||||||
|
2. **Handle.** A record left over on both sides with the same handle and the
|
||||||
|
same type is the same entity, edited. A save that renumbers handles would
|
||||||
|
make this stage pair strangers, so it runs only while handles look stable:
|
||||||
|
when at least half of the exact matches kept their handle. The report says
|
||||||
|
which way it went (``handles_stable``).
|
||||||
|
3. **Position.** Within one (type, layer, space), a leftover new record is
|
||||||
|
paired with the nearest leftover old record whose anchor (the mean of its
|
||||||
|
points, else the centre of its box) lies within ``move_tol``; the closest
|
||||||
|
pairs are taken first. ``move_tol`` defaults to 2 % of the drawing's robust
|
||||||
|
diagonal - the diagonal of the 1st-99th percentile box of every model-space
|
||||||
|
anchor of both revisions - so one stray entity kilometres away cannot
|
||||||
|
inflate it.
|
||||||
|
|
||||||
|
What is left is added (new side) or removed (old side). A matched pair that
|
||||||
|
is not identical is *changed*, and the row says what changed: ``moved_by``
|
||||||
|
(dx, dy) when the geometry is the old geometry translated, else ``geometry``
|
||||||
|
from -> to; ``text``, ``attribs`` (by tag), ``radius``, ``angles``,
|
||||||
|
``height``, ``rotation``, ``scale``, ``block``, ``layer``, ``space``,
|
||||||
|
``bulges``, ``closed`` from -> to.
|
||||||
|
|
||||||
|
The changes are grouped into clusters for the revision clouds: every changed,
|
||||||
|
added and removed record contributes its box (a changed one its old box and
|
||||||
|
its new box), boxes within ``move_tol`` of each other join one cluster, and a
|
||||||
|
cluster's box is padded by a quarter of ``move_tol`` (never less than ten
|
||||||
|
``tol``). ``count`` is the number of distinct changes in the cluster.
|
||||||
|
|
||||||
|
Block definitions are compared apart (``block_signatures`` /
|
||||||
|
``block_changes``): the entity records carry INSERTs, not the blocks they
|
||||||
|
reference, so a redrawn definition changes every drawn copy while every INSERT
|
||||||
|
record stays identical.
|
||||||
|
|
||||||
|
The 2 % move tolerance, the 1st/99th percentiles, the half-of-the-matches
|
||||||
|
handle rule and the cluster padding are this module's declared defaults, not
|
||||||
|
values from a standard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
|
import config
|
||||||
|
from engineering.understand.snapshot import EntityRecord, Pt, Snapshot, records_from_doc
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CATEGORIES",
|
||||||
|
"DEFAULT_LIMIT",
|
||||||
|
"DEFAULT_TOL",
|
||||||
|
"MOVE_FRACTION",
|
||||||
|
"PAD_FRACTION",
|
||||||
|
"ROBUST_QUANTILE",
|
||||||
|
"anchor",
|
||||||
|
"block_changes",
|
||||||
|
"block_signatures",
|
||||||
|
"cap_report",
|
||||||
|
"default_move_tol",
|
||||||
|
"diff_snapshots",
|
||||||
|
"read_revision",
|
||||||
|
"robust_box",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_TOL = 0.01
|
||||||
|
#: The default move tolerance, as a share of the robust diagonal.
|
||||||
|
MOVE_FRACTION = 0.02
|
||||||
|
#: The robust box runs from this quantile of the anchors to its complement.
|
||||||
|
ROBUST_QUANTILE = 0.01
|
||||||
|
#: A cluster's box is padded by this share of ``move_tol``.
|
||||||
|
PAD_FRACTION = 0.25
|
||||||
|
DEFAULT_LIMIT = 200
|
||||||
|
CATEGORIES = ("changed", "added", "removed")
|
||||||
|
|
||||||
|
Box = tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
def _positive(name: str, value) -> float:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"{name} must be a positive number; got {value!r}") from None
|
||||||
|
if not math.isfinite(number) or number <= 0:
|
||||||
|
raise ValueError(f"{name} must be a positive number; got {value!r}")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _q(value, tol: float):
|
||||||
|
"""``value`` on the ``tol`` grid; a non-finite value is kept as its repr."""
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
return repr(number)
|
||||||
|
return int(round(number / tol))
|
||||||
|
|
||||||
|
|
||||||
|
def _qp(point, tol: float) -> tuple:
|
||||||
|
return (_q(point[0], tol), _q(point[1], tol))
|
||||||
|
|
||||||
|
|
||||||
|
def signature(rec: EntityRecord, tol: float = DEFAULT_TOL) -> tuple:
|
||||||
|
"""Everything that makes two records the same entity, rounded to ``tol``."""
|
||||||
|
points = tuple(_qp(p, tol) for p in rec.points)
|
||||||
|
if rec.type == "LINE" and len(points) == 2:
|
||||||
|
points = tuple(sorted(points))
|
||||||
|
if points:
|
||||||
|
geometry = points
|
||||||
|
elif rec.bbox is not None:
|
||||||
|
geometry = tuple(_q(v, tol) for v in rec.bbox)
|
||||||
|
else:
|
||||||
|
geometry = ()
|
||||||
|
return (
|
||||||
|
rec.type,
|
||||||
|
rec.layer,
|
||||||
|
rec.space,
|
||||||
|
geometry,
|
||||||
|
tuple(_q(b, tol) for b in rec.bulges),
|
||||||
|
bool(rec.closed),
|
||||||
|
None if rec.radius is None else _q(rec.radius, tol),
|
||||||
|
None
|
||||||
|
if rec.angles is None
|
||||||
|
else (_q(rec.angles[0] % 360.0, tol), _q(rec.angles[1] % 360.0, tol)),
|
||||||
|
rec.text,
|
||||||
|
None if rec.height is None else _q(rec.height, tol),
|
||||||
|
rec.block,
|
||||||
|
tuple(sorted(rec.attribs)),
|
||||||
|
_q(rec.rotation % 360.0, tol),
|
||||||
|
(_q(rec.scale[0], tol), _q(rec.scale[1], tol)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def anchor(rec: EntityRecord) -> Pt | None:
|
||||||
|
"""The mean of the record's points, else the centre of its box, else None."""
|
||||||
|
if rec.points:
|
||||||
|
n = len(rec.points)
|
||||||
|
point = (sum(p[0] for p in rec.points) / n, sum(p[1] for p in rec.points) / n)
|
||||||
|
elif rec.bbox is not None:
|
||||||
|
x0, y0, x1, y1 = rec.bbox
|
||||||
|
point = ((x0 + x1) / 2.0, (y0 + y1) / 2.0)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if not (math.isfinite(point[0]) and math.isfinite(point[1])):
|
||||||
|
return None
|
||||||
|
return point
|
||||||
|
|
||||||
|
|
||||||
|
def _box(rec: EntityRecord) -> Box | None:
|
||||||
|
if rec.bbox is not None:
|
||||||
|
return tuple(float(v) for v in rec.bbox)
|
||||||
|
if rec.points:
|
||||||
|
xs = [p[0] for p in rec.points]
|
||||||
|
ys = [p[1] for p in rec.points]
|
||||||
|
return (min(xs), min(ys), max(xs), max(ys))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _quantile_pair(values: list[float], q: float) -> tuple[float, float]:
|
||||||
|
values = sorted(values)
|
||||||
|
last = len(values) - 1
|
||||||
|
low = int(math.floor(q * last))
|
||||||
|
return values[low], values[last - low]
|
||||||
|
|
||||||
|
|
||||||
|
def robust_box(*snaps: Snapshot) -> Box | None:
|
||||||
|
"""The 1st-99th percentile box of the model-space anchors of ``snaps``
|
||||||
|
(every space when model space is empty); None when there is no anchor."""
|
||||||
|
records = [r for s in snaps for r in s.records]
|
||||||
|
anchors = [a for r in records if r.space == "Model" and (a := anchor(r)) is not None]
|
||||||
|
if not anchors:
|
||||||
|
anchors = [a for r in records if (a := anchor(r)) is not None]
|
||||||
|
if not anchors:
|
||||||
|
return None
|
||||||
|
x0, x1 = _quantile_pair([a[0] for a in anchors], ROBUST_QUANTILE)
|
||||||
|
y0, y1 = _quantile_pair([a[1] for a in anchors], ROBUST_QUANTILE)
|
||||||
|
return (x0, y0, x1, y1)
|
||||||
|
|
||||||
|
|
||||||
|
def default_move_tol(old: Snapshot, new: Snapshot, tol: float = DEFAULT_TOL) -> float:
|
||||||
|
"""2 % of the robust diagonal of both revisions, never less than ``tol``."""
|
||||||
|
box = robust_box(old, new)
|
||||||
|
diagonal = 0.0 if box is None else math.hypot(box[2] - box[0], box[3] - box[1])
|
||||||
|
return max(MOVE_FRACTION * diagonal, tol)
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest_pairs(
|
||||||
|
olds: Sequence[EntityRecord],
|
||||||
|
news: Sequence[EntityRecord],
|
||||||
|
left_old: Iterable[int],
|
||||||
|
left_new: Iterable[int],
|
||||||
|
move_tol: float,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
"""Stage 3: closest pairs first, within one (type, layer, space)."""
|
||||||
|
groups: dict[tuple, tuple[list[int], list[int]]] = defaultdict(lambda: ([], []))
|
||||||
|
for i in left_old:
|
||||||
|
groups[(olds[i].type, olds[i].layer, olds[i].space)][0].append(i)
|
||||||
|
for j in left_new:
|
||||||
|
groups[(news[j].type, news[j].layer, news[j].space)][1].append(j)
|
||||||
|
candidates: list[tuple[float, str, str, int, int]] = []
|
||||||
|
for old_ids, new_ids in groups.values():
|
||||||
|
if not old_ids or not new_ids:
|
||||||
|
continue
|
||||||
|
grid: dict[tuple[int, int], list[tuple[int, Pt]]] = defaultdict(list)
|
||||||
|
for i in old_ids:
|
||||||
|
a = anchor(olds[i])
|
||||||
|
if a is not None:
|
||||||
|
cell = (math.floor(a[0] / move_tol), math.floor(a[1] / move_tol))
|
||||||
|
grid[cell].append((i, a))
|
||||||
|
for j in new_ids:
|
||||||
|
b = anchor(news[j])
|
||||||
|
if b is None:
|
||||||
|
continue
|
||||||
|
cx, cy = math.floor(b[0] / move_tol), math.floor(b[1] / move_tol)
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
for i, a in grid.get((cx + dx, cy + dy), ()):
|
||||||
|
distance = math.dist(a, b)
|
||||||
|
if distance <= move_tol:
|
||||||
|
candidates.append((distance, olds[i].handle, news[j].handle, i, j))
|
||||||
|
candidates.sort()
|
||||||
|
used_old: set[int] = set()
|
||||||
|
used_new: set[int] = set()
|
||||||
|
pairs: list[tuple[int, int]] = []
|
||||||
|
for _distance, _old_handle, _new_handle, i, j in candidates:
|
||||||
|
if i in used_old or j in used_new:
|
||||||
|
continue
|
||||||
|
used_old.add(i)
|
||||||
|
used_new.add(j)
|
||||||
|
pairs.append((i, j))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _number_changed(a, b, tol: float, *, modulo: float | None = None) -> bool:
|
||||||
|
if a is None or b is None:
|
||||||
|
return (a is None) != (b is None)
|
||||||
|
if modulo is not None:
|
||||||
|
a, b = a % modulo, b % modulo
|
||||||
|
return _q(a, tol) != _q(b, tol)
|
||||||
|
|
||||||
|
|
||||||
|
def _movement(o: EntityRecord, n: EntityRecord, tol: float) -> dict:
|
||||||
|
"""``moved_by`` for a pure translation, ``geometry`` for anything else."""
|
||||||
|
if o.points and n.points:
|
||||||
|
before, after = list(o.points), list(n.points)
|
||||||
|
elif not o.points and not n.points:
|
||||||
|
if o.bbox is None or n.bbox is None:
|
||||||
|
if (o.bbox is None) != (n.bbox is None):
|
||||||
|
return {"geometry": {"from": o.bbox, "to": n.bbox}}
|
||||||
|
return {}
|
||||||
|
before = [(o.bbox[0], o.bbox[1]), (o.bbox[2], o.bbox[3])]
|
||||||
|
after = [(n.bbox[0], n.bbox[1]), (n.bbox[2], n.bbox[3])]
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"geometry": {"from": [list(p) for p in o.points], "to": [list(p) for p in n.points]}
|
||||||
|
}
|
||||||
|
a, b = anchor(o), anchor(n)
|
||||||
|
if a is None or b is None:
|
||||||
|
return {"geometry": {"from": [list(p) for p in before], "to": [list(p) for p in after]}}
|
||||||
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||||
|
half = tol / 2.0
|
||||||
|
translated = len(before) == len(after) and all(
|
||||||
|
abs(p[0] + dx - q[0]) <= half and abs(p[1] + dy - q[1]) <= half
|
||||||
|
for p, q in zip(before, after, strict=True)
|
||||||
|
)
|
||||||
|
if not translated:
|
||||||
|
return {"geometry": {"from": [list(p) for p in before], "to": [list(p) for p in after]}}
|
||||||
|
if abs(dx) > half or abs(dy) > half:
|
||||||
|
return {"moved_by": [dx, dy]}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _attrib_changes(o: EntityRecord, n: EntityRecord) -> list[dict]:
|
||||||
|
before, after = dict(o.attribs), dict(n.attribs)
|
||||||
|
return [
|
||||||
|
{"tag": tag, "from": before.get(tag), "to": after.get(tag)}
|
||||||
|
for tag in sorted(set(before) | set(after))
|
||||||
|
if before.get(tag) != after.get(tag)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _changes(o: EntityRecord, n: EntityRecord, tol: float) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
if o.layer != n.layer:
|
||||||
|
out["layer"] = {"from": o.layer, "to": n.layer}
|
||||||
|
if o.space != n.space:
|
||||||
|
out["space"] = {"from": o.space, "to": n.space}
|
||||||
|
out.update(_movement(o, n, tol))
|
||||||
|
if tuple(_q(b, tol) for b in o.bulges) != tuple(_q(b, tol) for b in n.bulges):
|
||||||
|
out["bulges"] = {"from": list(o.bulges), "to": list(n.bulges)}
|
||||||
|
if bool(o.closed) != bool(n.closed):
|
||||||
|
out["closed"] = {"from": bool(o.closed), "to": bool(n.closed)}
|
||||||
|
if _number_changed(o.radius, n.radius, tol):
|
||||||
|
out["radius"] = {"from": o.radius, "to": n.radius}
|
||||||
|
if (o.angles is None) != (n.angles is None) or (
|
||||||
|
o.angles is not None
|
||||||
|
and n.angles is not None
|
||||||
|
and any(
|
||||||
|
_number_changed(a, b, tol, modulo=360.0)
|
||||||
|
for a, b in zip(o.angles, n.angles, strict=True)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
out["angles"] = {
|
||||||
|
"from": None if o.angles is None else list(o.angles),
|
||||||
|
"to": None if n.angles is None else list(n.angles),
|
||||||
|
}
|
||||||
|
if o.text != n.text:
|
||||||
|
out["text"] = {"from": o.text, "to": n.text}
|
||||||
|
if _number_changed(o.height, n.height, tol):
|
||||||
|
out["height"] = {"from": o.height, "to": n.height}
|
||||||
|
if o.block != n.block:
|
||||||
|
out["block"] = {"from": o.block, "to": n.block}
|
||||||
|
attribs = _attrib_changes(o, n)
|
||||||
|
if attribs:
|
||||||
|
out["attribs"] = attribs
|
||||||
|
if _number_changed(o.rotation, n.rotation, tol, modulo=360.0):
|
||||||
|
out["rotation"] = {"from": o.rotation, "to": n.rotation}
|
||||||
|
if any(_number_changed(a, b, tol) for a, b in zip(o.scale, n.scale, strict=True)):
|
||||||
|
out["scale"] = {"from": list(o.scale), "to": list(n.scale)}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _row(rec: EntityRecord) -> dict:
|
||||||
|
at = anchor(rec)
|
||||||
|
return {
|
||||||
|
"handle": rec.handle,
|
||||||
|
"type": rec.type,
|
||||||
|
"layer": rec.layer,
|
||||||
|
"space": rec.space,
|
||||||
|
"at": None if at is None else [at[0], at[1]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tally(changed: list[dict], added: list[dict], removed: list[dict]) -> tuple[dict, dict]:
|
||||||
|
by_layer: dict[str, dict[str, int]] = {}
|
||||||
|
by_type: dict[str, dict[str, int]] = {}
|
||||||
|
for category, rows in (("changed", changed), ("added", added), ("removed", removed)):
|
||||||
|
for row in rows:
|
||||||
|
for table, key in ((by_layer, row["layer"]), (by_type, row["type"])):
|
||||||
|
counts = table.setdefault(key, {"changed": 0, "added": 0, "removed": 0})
|
||||||
|
counts[category] += 1
|
||||||
|
return dict(sorted(by_layer.items())), dict(sorted(by_type.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _box_gap(a: Box, b: Box) -> float:
|
||||||
|
dx = max(0.0, b[0] - a[2], a[0] - b[2])
|
||||||
|
dy = max(0.0, b[1] - a[3], a[1] - b[3])
|
||||||
|
return math.hypot(dx, dy)
|
||||||
|
|
||||||
|
|
||||||
|
def _clusters(items: list[tuple[str, Box, int]], *, link: float, pad: float) -> list[dict]:
|
||||||
|
"""Single-linkage clusters of change boxes, per space, swept along x."""
|
||||||
|
parent = list(range(len(items)))
|
||||||
|
|
||||||
|
def find(k: int) -> int:
|
||||||
|
while parent[k] != k:
|
||||||
|
parent[k] = parent[parent[k]]
|
||||||
|
k = parent[k]
|
||||||
|
return k
|
||||||
|
|
||||||
|
by_space: dict[str, list[int]] = defaultdict(list)
|
||||||
|
for k, (space, _box_value, _key) in enumerate(items):
|
||||||
|
by_space[space].append(k)
|
||||||
|
for members in by_space.values():
|
||||||
|
members.sort(key=lambda k: items[k][1][0])
|
||||||
|
active: list[int] = []
|
||||||
|
for k in members:
|
||||||
|
box = items[k][1]
|
||||||
|
active = [m for m in active if items[m][1][2] + link >= box[0]]
|
||||||
|
for m in active:
|
||||||
|
if _box_gap(items[m][1], box) <= link:
|
||||||
|
parent[find(k)] = find(m)
|
||||||
|
active.append(k)
|
||||||
|
grouped: dict[int, list[int]] = defaultdict(list)
|
||||||
|
for k in range(len(items)):
|
||||||
|
grouped[find(k)].append(k)
|
||||||
|
clusters = []
|
||||||
|
for members in grouped.values():
|
||||||
|
boxes = [items[k][1] for k in members]
|
||||||
|
clusters.append(
|
||||||
|
{
|
||||||
|
"space": items[members[0]][0],
|
||||||
|
"box": [
|
||||||
|
min(b[0] for b in boxes) - pad,
|
||||||
|
min(b[1] for b in boxes) - pad,
|
||||||
|
max(b[2] for b in boxes) + pad,
|
||||||
|
max(b[3] for b in boxes) + pad,
|
||||||
|
],
|
||||||
|
"count": len({items[k][2] for k in members}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
clusters.sort(key=lambda c: (c["space"], c["box"]))
|
||||||
|
return clusters
|
||||||
|
|
||||||
|
|
||||||
|
def diff_snapshots(
|
||||||
|
old: Snapshot, new: Snapshot, *, tol: float = DEFAULT_TOL, move_tol: float | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Match ``new`` against ``old`` and report every change (spec §10).
|
||||||
|
|
||||||
|
Refuses a non-positive ``tol`` or ``move_tol`` before any work.
|
||||||
|
"""
|
||||||
|
tol = _positive("tol", tol)
|
||||||
|
move_tol = (
|
||||||
|
default_move_tol(old, new, tol) if move_tol is None else _positive("move_tol", move_tol)
|
||||||
|
)
|
||||||
|
olds = list(old.records)
|
||||||
|
news = list(new.records)
|
||||||
|
|
||||||
|
# 1. exact
|
||||||
|
pools: dict[tuple, list[int]] = defaultdict(list)
|
||||||
|
for i, rec in enumerate(olds):
|
||||||
|
pools[signature(rec, tol)].append(i)
|
||||||
|
used_old: set[int] = set()
|
||||||
|
left_new: list[int] = []
|
||||||
|
exact = kept_handle = 0
|
||||||
|
for j, rec in enumerate(news):
|
||||||
|
pool = pools.get(signature(rec, tol))
|
||||||
|
if not pool:
|
||||||
|
left_new.append(j)
|
||||||
|
continue
|
||||||
|
at = next((k for k, i in enumerate(pool) if olds[i].handle == rec.handle), 0)
|
||||||
|
i = pool.pop(at)
|
||||||
|
used_old.add(i)
|
||||||
|
exact += 1
|
||||||
|
kept_handle += int(olds[i].handle == rec.handle)
|
||||||
|
left_old = [i for i in range(len(olds)) if i not in used_old]
|
||||||
|
handles_stable = exact == 0 or 2 * kept_handle >= exact
|
||||||
|
|
||||||
|
pairs: list[tuple[int, int, str]] = []
|
||||||
|
# 2. handle
|
||||||
|
if handles_stable:
|
||||||
|
by_handle = {olds[i].handle: i for i in left_old}
|
||||||
|
still_new: list[int] = []
|
||||||
|
for j in left_new:
|
||||||
|
i = by_handle.get(news[j].handle)
|
||||||
|
if i is not None and olds[i].type == news[j].type:
|
||||||
|
pairs.append((i, j, "handle"))
|
||||||
|
del by_handle[news[j].handle]
|
||||||
|
else:
|
||||||
|
still_new.append(j)
|
||||||
|
left_new = still_new
|
||||||
|
taken = {i for i, _j, _how in pairs}
|
||||||
|
left_old = [i for i in left_old if i not in taken]
|
||||||
|
|
||||||
|
# 3. position
|
||||||
|
pairs.extend(
|
||||||
|
(i, j, "position") for i, j in _nearest_pairs(olds, news, left_old, left_new, move_tol)
|
||||||
|
)
|
||||||
|
taken_old = {i for i, _j, _how in pairs}
|
||||||
|
taken_new = {j for _i, j, _how in pairs}
|
||||||
|
|
||||||
|
changed: list[dict] = []
|
||||||
|
items: list[tuple[str, Box, int]] = []
|
||||||
|
for key, (i, j, how) in enumerate(
|
||||||
|
sorted(pairs, key=lambda p: (olds[p[0]].handle, news[p[1]].handle))
|
||||||
|
):
|
||||||
|
o, n = olds[i], news[j]
|
||||||
|
at = anchor(n)
|
||||||
|
changed.append(
|
||||||
|
{
|
||||||
|
"old_handle": o.handle,
|
||||||
|
"new_handle": n.handle,
|
||||||
|
"type": n.type,
|
||||||
|
"layer": n.layer,
|
||||||
|
"space": n.space,
|
||||||
|
"matched_by": how,
|
||||||
|
"at": None if at is None else [at[0], at[1]],
|
||||||
|
"changes": _changes(o, n, tol),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for rec in (o, n):
|
||||||
|
box = _box(rec)
|
||||||
|
if box is not None:
|
||||||
|
items.append((rec.space, box, key))
|
||||||
|
base = len(changed)
|
||||||
|
removed_recs = sorted((olds[i] for i in left_old if i not in taken_old), key=lambda r: r.handle)
|
||||||
|
added_recs = sorted((news[j] for j in left_new if j not in taken_new), key=lambda r: r.handle)
|
||||||
|
for offset, rec in enumerate(removed_recs + added_recs):
|
||||||
|
box = _box(rec)
|
||||||
|
if box is not None:
|
||||||
|
items.append((rec.space, box, base + offset))
|
||||||
|
removed = [_row(rec) for rec in removed_recs]
|
||||||
|
added = [_row(rec) for rec in added_recs]
|
||||||
|
by_layer, by_type = _tally(changed, added, removed)
|
||||||
|
pad = max(PAD_FRACTION * move_tol, 10.0 * tol)
|
||||||
|
return {
|
||||||
|
"tol": tol,
|
||||||
|
"move_tol": move_tol,
|
||||||
|
"handles_stable": handles_stable,
|
||||||
|
"summary": {
|
||||||
|
"old_entities": len(olds),
|
||||||
|
"new_entities": len(news),
|
||||||
|
"unchanged": exact,
|
||||||
|
"changed": len(changed),
|
||||||
|
"added": len(added),
|
||||||
|
"removed": len(removed),
|
||||||
|
},
|
||||||
|
"by_layer": by_layer,
|
||||||
|
"by_type": by_type,
|
||||||
|
"changed": changed,
|
||||||
|
"added": added,
|
||||||
|
"removed": removed,
|
||||||
|
"clusters": _clusters(items, link=move_tol, pad=pad),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cap_report(result: dict, limit: int = DEFAULT_LIMIT) -> dict:
|
||||||
|
"""``result`` with each list of ``CATEGORIES`` cut to ``limit`` rows and
|
||||||
|
``truncated`` saying how many rows each cut dropped."""
|
||||||
|
if int(limit) < 1:
|
||||||
|
raise ValueError(f"limit must be at least 1; got {limit!r}")
|
||||||
|
capped = dict(result)
|
||||||
|
capped["truncated"] = {}
|
||||||
|
for category in CATEGORIES:
|
||||||
|
rows = list(result.get(category) or [])
|
||||||
|
capped[category] = rows[: int(limit)]
|
||||||
|
capped["truncated"][category] = max(0, len(rows) - int(limit))
|
||||||
|
return capped
|
||||||
|
|
||||||
|
|
||||||
|
# -- block definitions -----------------------------------------------------------------
|
||||||
|
|
||||||
|
_BLOCK_ATTRS = (
|
||||||
|
"start",
|
||||||
|
"end",
|
||||||
|
"center",
|
||||||
|
"insert",
|
||||||
|
"radius",
|
||||||
|
"start_angle",
|
||||||
|
"end_angle",
|
||||||
|
"rotation",
|
||||||
|
"height",
|
||||||
|
"text",
|
||||||
|
"tag",
|
||||||
|
"name",
|
||||||
|
"xscale",
|
||||||
|
"yscale",
|
||||||
|
"major_axis",
|
||||||
|
"ratio",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _q_value(value, tol: float):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return _q(value, tol)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return tuple(_q(v, tol) for v in value)
|
||||||
|
except TypeError:
|
||||||
|
return repr(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_entity_key(entity, tol: float) -> str:
|
||||||
|
key: list = [entity.dxftype(), entity.dxf.get("layer", "0")]
|
||||||
|
for attr in _BLOCK_ATTRS:
|
||||||
|
if entity.dxf.hasattr(attr):
|
||||||
|
key.append((attr, _q_value(entity.dxf.get(attr), tol)))
|
||||||
|
kind = entity.dxftype()
|
||||||
|
if kind == "LWPOLYLINE":
|
||||||
|
key.append(("points", tuple(_q_value(p, tol) for p in entity.get_points("xyb"))))
|
||||||
|
key.append(("closed", bool(entity.closed)))
|
||||||
|
elif kind == "POLYLINE":
|
||||||
|
key.append(("points", tuple(_q_value(v.dxf.location, tol) for v in entity.vertices)))
|
||||||
|
elif kind == "MTEXT":
|
||||||
|
key.append(("mtext", entity.text))
|
||||||
|
return repr(tuple(key))
|
||||||
|
|
||||||
|
|
||||||
|
def block_signatures(doc, *, tol: float = DEFAULT_TOL) -> dict[str, str]:
|
||||||
|
"""``{block name: digest}`` for every named block definition of an ezdxf
|
||||||
|
document: its base point and, per entity, its type, layer and main DXF
|
||||||
|
attributes rounded to ``tol``. Layout blocks and anonymous blocks
|
||||||
|
(``*U``, ``*D`` ...) are left out - their names are not stable across saves."""
|
||||||
|
tol = _positive("tol", tol)
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for block in doc.blocks:
|
||||||
|
name = block.name
|
||||||
|
if block.block_record.is_any_layout or name.startswith("*"):
|
||||||
|
continue
|
||||||
|
base = _q_value(block.block.dxf.base_point, tol)
|
||||||
|
rows = sorted(_block_entity_key(entity, tol) for entity in block)
|
||||||
|
out[name] = hashlib.sha256(repr((base, rows)).encode("utf-8")).hexdigest()
|
||||||
|
return dict(sorted(out.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def block_changes(old: dict[str, str] | None, new: dict[str, str] | None) -> dict:
|
||||||
|
"""Block definitions added, removed and redrawn between two revisions."""
|
||||||
|
if old is None or new is None:
|
||||||
|
return {
|
||||||
|
"compared": False,
|
||||||
|
"reason": "block definitions are compared only when both revisions are DXF files "
|
||||||
|
"or the current document of the headless engine",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"compared": True,
|
||||||
|
"added": sorted(set(new) - set(old)),
|
||||||
|
"removed": sorted(set(old) - set(new)),
|
||||||
|
"changed": sorted(name for name in set(old) & set(new) if old[name] != new[name]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_revision(path: str, *, tol: float = DEFAULT_TOL) -> tuple[Snapshot, dict[str, str]]:
|
||||||
|
"""One read of a DXF revision: its records and its block signatures.
|
||||||
|
|
||||||
|
Refused before reading: a file that cannot be stat'ed and one over
|
||||||
|
``MAX_DXF_BYTES`` (the size and the variable named); a file ezdxf cannot
|
||||||
|
parse is refused with ezdxf's reason.
|
||||||
|
"""
|
||||||
|
import ezdxf
|
||||||
|
|
||||||
|
limit = int(config.settings.max_dxf_bytes)
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError(f"{path}: cannot be read ({exc.strerror or exc})") from exc
|
||||||
|
if limit > 0 and size > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path} is {size} bytes, over the MAX_DXF_BYTES limit of {limit}; "
|
||||||
|
"raise MAX_DXF_BYTES to read it"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
doc = ezdxf.readfile(path)
|
||||||
|
except (OSError, ezdxf.DXFError) as exc:
|
||||||
|
raise ValueError(f"{path}: not a readable DXF ({exc})") from exc
|
||||||
|
return records_from_doc(doc, source=str(path)), block_signatures(doc, tol=tol)
|
||||||
@@ -10554,6 +10554,220 @@ async def arch_plan_from_spec(
|
|||||||
raise ToolError(str(exc)) from exc
|
raise ToolError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ── SECTION 26: Understanding & QA (1 tool) ─────────────────────────────────
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _diff_revision(backend, path: str | None, *, tol: float, role: str):
|
||||||
|
"""One revision for ``drawing_diff``: its records and, where they can be
|
||||||
|
read, its block signatures (None otherwise).
|
||||||
|
|
||||||
|
A DXF path is read once, here, for both. The current document goes through
|
||||||
|
``take_snapshot``; its block table is read only on the headless engine,
|
||||||
|
under the backend's own lock. Any other path (a DWG) goes through the
|
||||||
|
snapshot too, which opens it read-only on the live engine.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from engineering.understand.diff import block_signatures, read_revision
|
||||||
|
from engineering.understand.snapshot import take_snapshot
|
||||||
|
|
||||||
|
if path is None:
|
||||||
|
snap = await take_snapshot(backend, None)
|
||||||
|
blocks = None
|
||||||
|
run = getattr(backend, "_async", None)
|
||||||
|
if backend.name == "ezdxf" and run is not None:
|
||||||
|
blocks = await run(lambda: block_signatures(backend._doc, tol=tol))
|
||||||
|
return snap, blocks
|
||||||
|
validated = validate_path(path)
|
||||||
|
if not validated.is_file():
|
||||||
|
raise ToolError(f"{role}: {validated} does not exist or is not a file")
|
||||||
|
if validated.suffix.lower() == ".dxf":
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(read_revision, str(validated), tol=tol)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ToolError(f"{role}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
return await take_snapshot(backend, str(validated)), None
|
||||||
|
except ValueError as exc: # an unreadable suffix; the dwg refusal is not a ValueError
|
||||||
|
raise ToolError(f"{role}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _diff_markup(backend, result: dict, new_snap, *, rev: str, description: str) -> dict:
|
||||||
|
"""Revision clouds around every model-space cluster of change, filed under
|
||||||
|
``rev`` through ``engineering.sheet.revision.add_revision`` - the only
|
||||||
|
write ``drawing_diff`` ever makes."""
|
||||||
|
from engineering.sheet.bom import LIST_WIDTH, ROW_HEIGHT
|
||||||
|
from engineering.sheet.revision import add_revision
|
||||||
|
from engineering.understand.diff import robust_box
|
||||||
|
|
||||||
|
clusters = [c for c in result["clusters"] if c["space"] == "Model"]
|
||||||
|
paper = len(result["clusters"]) - len(clusters)
|
||||||
|
if not clusters:
|
||||||
|
return {
|
||||||
|
"created": False,
|
||||||
|
"clouds": [],
|
||||||
|
"reason": "no change in model space to cloud",
|
||||||
|
"paper_space_clusters": paper,
|
||||||
|
}
|
||||||
|
boxes = [c["box"] for c in clusters]
|
||||||
|
shortest = min(min(b[2] - b[0], b[3] - b[1]) for b in boxes)
|
||||||
|
box = robust_box(new_snap) or (
|
||||||
|
min(b[0] for b in boxes),
|
||||||
|
min(b[1] for b in boxes),
|
||||||
|
max(b[2] for b in boxes),
|
||||||
|
max(b[3] for b in boxes),
|
||||||
|
)
|
||||||
|
summary = result["summary"]
|
||||||
|
text = description or (
|
||||||
|
f"drawing_diff: {summary['changed']} changed, {summary['added']} added, "
|
||||||
|
f"{summary['removed']} removed"
|
||||||
|
)
|
||||||
|
drawn = await add_revision(
|
||||||
|
backend,
|
||||||
|
rev=rev,
|
||||||
|
description=text,
|
||||||
|
clouds=boxes,
|
||||||
|
at=(box[2] - LIST_WIDTH, box[3] + 3.0 * ROW_HEIGHT),
|
||||||
|
cloud_segment=shortest / 4.0,
|
||||||
|
)
|
||||||
|
drawn["paper_space_clusters"] = paper
|
||||||
|
return drawn
|
||||||
|
|
||||||
|
|
||||||
|
@cad_tool(
|
||||||
|
summary="Compare two revisions of a drawing: what moved, changed, was added or removed.",
|
||||||
|
cost="mutate",
|
||||||
|
)
|
||||||
|
@mcp.tool(
|
||||||
|
annotations={"title": "Drawing: Diff Two Revisions", "destructiveHint": False},
|
||||||
|
tags={"analysis", "query"},
|
||||||
|
)
|
||||||
|
async def drawing_diff(
|
||||||
|
old_path: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="The OLDER revision: a DXF path (a DWG only on the live engine, which "
|
||||||
|
"exports it read-only)."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
new_path: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
default=None,
|
||||||
|
description="The NEWER revision: a DXF path, or omitted for the current document.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
tol: Annotated[
|
||||||
|
float,
|
||||||
|
Field(
|
||||||
|
default=0.01,
|
||||||
|
gt=0,
|
||||||
|
description="Geometry is compared rounded to this many drawing units.",
|
||||||
|
),
|
||||||
|
] = 0.01,
|
||||||
|
move_tol: Annotated[
|
||||||
|
float | None,
|
||||||
|
Field(
|
||||||
|
default=None,
|
||||||
|
gt=0,
|
||||||
|
description="An unmatched entity is paired with the nearest one of its type and layer "
|
||||||
|
"within this distance and reported as changed; default 2 % of the drawing's robust "
|
||||||
|
"diagonal.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
Field(
|
||||||
|
default=200, ge=1, le=5000, description="Rows kept per list (changed, added, removed)."
|
||||||
|
),
|
||||||
|
] = 200,
|
||||||
|
markup: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(
|
||||||
|
default=False,
|
||||||
|
description="Draw revision clouds around every cluster of change on the current "
|
||||||
|
"document (the newer revision) and file them under `rev`.",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
rev: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
default="",
|
||||||
|
description="Revision code for markup=True: one to three capitals or digits.",
|
||||||
|
),
|
||||||
|
] = "",
|
||||||
|
description: Annotated[
|
||||||
|
str,
|
||||||
|
Field(default="", description="Revision-row text for markup=True; default: the counts."),
|
||||||
|
] = "",
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Compare two revisions of one drawing by geometric signature (spec §10).
|
||||||
|
|
||||||
|
Entities are matched exactly first (type, layer, geometry rounded to `tol`,
|
||||||
|
text, block, attributes - handles do not matter), then by handle while
|
||||||
|
handles look stable, then by type + layer + nearest position within
|
||||||
|
`move_tol`; what is left is added or removed. The report counts changes by
|
||||||
|
layer and type, lists each changed entity with what changed (moved by, text
|
||||||
|
from -> to, attribute from -> to, radius from -> to, ...), compares the
|
||||||
|
block definitions when both revisions are DXF files or the headless current
|
||||||
|
document, and groups the changes into clusters.
|
||||||
|
|
||||||
|
Read-only unless `markup=True`: then revision clouds are drawn around the
|
||||||
|
model-space clusters on the current document, which must be the newer
|
||||||
|
revision, and filed as one revision row - the only write this tool makes.
|
||||||
|
|
||||||
|
Refused before anything is read: `markup=True` with `new_path` (the clouds go
|
||||||
|
on the current document), `markup=True` without a valid `rev`, `markup=True`
|
||||||
|
on an engine without the revcloud capability (the live engine -
|
||||||
|
`capability: "revcloud"`), a path that does not exist, a DXF over
|
||||||
|
MAX_DXF_BYTES (the size and the variable named), a file that is not a
|
||||||
|
readable DXF, and a non-positive `tol` or `move_tol`.
|
||||||
|
"""
|
||||||
|
from backends.base import UnsupportedCapabilityError
|
||||||
|
from engineering.sheet.revision import REV_RE
|
||||||
|
from engineering.understand.diff import block_changes, cap_report, diff_snapshots
|
||||||
|
|
||||||
|
backend = _backend(ctx)
|
||||||
|
if markup:
|
||||||
|
if new_path:
|
||||||
|
raise ToolError(
|
||||||
|
"markup draws revision clouds on the current document, which must be the newer "
|
||||||
|
"revision: open it with drawing_open and call again without new_path"
|
||||||
|
)
|
||||||
|
if not REV_RE.fullmatch(rev.strip().upper()):
|
||||||
|
raise ToolError(
|
||||||
|
f"markup needs `rev`, the revision code the clouds are filed under - one to "
|
||||||
|
f"three capitals or digits (A, B, 01); got {rev!r}"
|
||||||
|
)
|
||||||
|
feature = backend.capabilities().features.get("revcloud")
|
||||||
|
if feature is None or not feature.supported:
|
||||||
|
raise UnsupportedCapabilityError(
|
||||||
|
"revcloud",
|
||||||
|
f"drawing_diff(markup=True): the {backend.name} backend cannot draw revision "
|
||||||
|
"clouds (no ActiveX member; REVCLOUD is command-line only). Run the diff "
|
||||||
|
"without markup, or on the ezdxf engine.",
|
||||||
|
)
|
||||||
|
await ctx.info(f"drawing_diff: {old_path} -> {new_path or 'the current document'}")
|
||||||
|
old_snap, old_blocks = await _diff_revision(backend, old_path, tol=tol, role="old_path")
|
||||||
|
new_snap, new_blocks = await _diff_revision(backend, new_path, tol=tol, role="new_path")
|
||||||
|
try:
|
||||||
|
result = diff_snapshots(old_snap, new_snap, tol=tol, move_tol=move_tol)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ToolError(str(exc)) from exc
|
||||||
|
result["old"] = old_snap.source
|
||||||
|
result["new"] = new_snap.source
|
||||||
|
result["blocks"] = block_changes(old_blocks, new_blocks)
|
||||||
|
report = cap_report(result, limit)
|
||||||
|
if markup:
|
||||||
|
report["markup"] = await _diff_markup(
|
||||||
|
backend, result, new_snap, rev=rev.strip().upper(), description=description
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Vendored
+96
@@ -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,
|
||||||
|
}
|
||||||
@@ -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():
|
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
|
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
|
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
|
exactly `PACK_TOOL_NAMES["arch"]`, and every group the track-F branches
|
||||||
had grown while the tag was unranked on them returned to its
|
had grown while the tag was unranked on them returned to its
|
||||||
pre-track-F value.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
"analysis": 12,
|
"analysis": 13,
|
||||||
"architecture": 12,
|
"architecture": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 9,
|
"blocks": 9,
|
||||||
@@ -621,4 +627,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 241
|
assert sum(sizes.values()) == 242
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user