Files
autocad-mcp-pro/engineering/understand/diff.py
T
Umut CANandClaude Opus 5.5 1795dc7f2c fix(understand): drawing_diff minors from the review — direction, repeated tags, bounded output
The review of Task 9 left four minor findings. Three change what a user reads:

- A LINE drawn back to front and moved was reported as a geometry change.
  The signature already ignores a LINE's direction, so its movement now
  does too: the reversed order is tried before the entity is called
  reshaped.
- An INSERT carrying one attribute tag twice was read as a dict. An edit
  of the first value could disappear, and since the pair then had nothing
  to report, it counted as unchanged. A repeated tag is now compared as
  the list of its values; a tag carried once keeps plain from / to.
- cap_report capped the three lists but not the clusters. A widespread
  edit of a large drawing could return an unbounded cluster list. It is
  capped like the others, with truncated["clusters"]; the markup still
  clouds every cluster.

The fourth bounds work: when the whole drawing moves, every entity reaches
the position stage, and a generous move_tol gave each one hundreds of
candidates, which is n x m memory. Only the 32 nearest per entity are kept
now. A 200-line test shows each line still pairs with its own moved copy.

The first three tests failed against the previous module (the attribute
test with no changed row at all). The whole diff suite passes: 38 tests.

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

728 lines
28 KiB
Python

"""``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 there is at least one exact match and at least half of the exact
matches kept their handle. With no exact match there is no evidence either
way - two fresh files hand out handles from the same seed, and a drawing
moved as a whole matches nothing exactly - so the stage does not run. The
report says which way it went (``handles_stable``) and on what evidence
(``handle_evidence``: the exact matches and how many kept their handle).
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 is
compared field by field, each number within half a ``tol`` of its old value
counting as equal - the same test a move uses. A pair with nothing to report
is *unchanged*: the signature snaps every value to the ``tol`` grid, so a
value one ulp either side of a half-grid point (AutoCAD writes 0.145 for the
0.14500000000000002 it was given) lands in another bucket, misses the exact
stage and is paired later, yet nothing was edited. Such a pair counts under
``unchanged`` and gets no row and no cloud. Any other matched pair 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
(and at least one) handle rule, the half-a-``tol`` equality and the cluster
padding are this module's declared defaults, not values from a standard.
"""
from __future__ import annotations
import hashlib
import heapq
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
#: The position stage keeps at most this many nearest old candidates per new
#: entity, so a dense drawing that moved as a whole (every entity left for this
#: stage, hundreds of neighbours within ``move_tol``) cannot build n x m pairs.
NEAREST_PER_ENTITY = 32
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)
near: list[tuple[float, str, str, int, int]] = []
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:
near.append((distance, olds[i].handle, news[j].handle, i, j))
candidates.extend(heapq.nsmallest(NEAREST_PER_ENTITY, near))
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:
"""More than half a ``tol`` apart (around the circle when ``modulo``).
A difference, never two grid buckets: ``_q`` puts 0.14500000000000002
and 0.145 in different buckets at ``tol=0.01``.
"""
if a is None or b is None:
return (a is None) != (b is None)
a, b = float(a), float(b)
if not (math.isfinite(a) and math.isfinite(b)):
return repr(a) != repr(b)
difference = abs(a - b)
if modulo is not None:
difference %= modulo
difference = min(difference, modulo - difference)
return difference > tol / 2.0
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
def translates(start: list, end: list) -> bool:
return len(start) == len(end) and all(
abs(p[0] + dx - q[0]) <= half and abs(p[1] + dy - q[1]) <= half
for p, q in zip(start, end, strict=True)
)
translated = translates(before, after)
if not translated and o.type == "LINE" and len(after) == 2:
# the signature ignores a LINE's direction, so its movement must too:
# the same line drawn the other way round and moved is a move
translated = translates(before, after[::-1])
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]:
"""One row per attribute tag whose values differ. A tag an INSERT carries
twice is compared as the list of its values, so one change cannot hide
behind the other; a tag carried once keeps plain ``from`` / ``to``."""
def by_tag(attribs) -> dict[str, list]:
values: dict[str, list] = defaultdict(list)
for tag, value in attribs:
values[tag].append(value)
return values
before, after = by_tag(o.attribs), by_tag(n.attribs)
rows = []
for tag in sorted(set(before) | set(after)):
old_values, new_values = before.get(tag, []), after.get(tag, [])
if old_values == new_values:
continue
if len(old_values) <= 1 and len(new_values) <= 1:
rows.append(
{
"tag": tag,
"from": old_values[0] if old_values else None,
"to": new_values[0] if new_values else None,
}
)
else:
rows.append({"tag": tag, "from": old_values, "to": new_values})
return rows
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 len(o.bulges) != len(n.bulges) or any(
_number_changed(a, b, tol) for a, b in zip(o.bulges, n.bulges, strict=False)
):
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]
# No exact match is no evidence: strangers from two fresh files share
# handles, and so do the entities of a drawing that moved as a whole.
handles_stable = exact > 0 and 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]] = []
same = 0
for i, j, how in sorted(pairs, key=lambda p: (olds[p[0]].handle, news[p[1]].handle)):
o, n = olds[i], news[j]
changes = _changes(o, n, tol)
if not changes:
# the signature grid split an unedited pair (a value 1 ulp across a
# half-grid point): unchanged, no row and no cloud
same += 1
continue
key = len(changed)
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,
}
)
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,
"handle_evidence": {"exact_matches": exact, "kept_handle": kept_handle},
"summary": {
"old_entities": len(olds),
"new_entities": len(news),
"unchanged": exact + same,
"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`` and the ``clusters`` cut to
``limit`` rows and ``truncated`` saying how many rows each cut dropped.
The clusters are capped in the report only: a markup still clouds every one.
"""
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, "clusters"):
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 or walk is refused as ``not a readable DXF`` with the reason -
whatever ezdxf raised: a truncated file (an interrupted save, a partial
copy) raises ``StopIteration`` from inside its tag reader, which
``asyncio.to_thread`` cannot hand back to an awaiting caller, so letting it
through would hang ``drawing_diff`` instead of refusing.
"""
import ezdxf
tol = _positive("tol", tol)
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)
return records_from_doc(doc, source=str(path)), block_signatures(doc, tol=tol)
except MemoryError:
raise
except Exception as exc: # noqa: BLE001 - every parse failure is one refusal
if isinstance(exc, StopIteration):
reason = "the file ends early - an interrupted save or a partial copy?"
else:
reason = str(exc) or type(exc).__name__
raise ValueError(f"{path}: not a readable DXF ({reason})") from exc