mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 14:41:25 +00:00
IfcDiff: optional content-signature matching to track real changes (#8412)
IfcDiff pairs elements purely by GlobalId, so when an authoring tool re-issues GlobalIds an unchanged element shows up as a spurious deleted (old GUID) plus added (new GUID) instead of unchanged or moved. Add an opt-in fallback (match_by_signature, default off, so existing behaviour and output are unchanged) with a signature_tolerance (default 0.05, the reporter's +-5cm). After the GlobalId diff, the leftover added and deleted sets are matched on a content signature (SHA1 of ifc class, Name, type Name, property sets with ids stripped, and material names, i.e. the hashing approach ignoring GlobalId/OwnerHistory) plus a placement tolerance: same content within tolerance is reported unchanged, same content beyond tolerance is reported moved. Matched pairs are removed from added/deleted and flow through the normal attribute/geometry/relationship diff, and a rematched section is added to the export. CLI flags -m/--match-by-signature and -t/--tolerance are wired in. Added elements are indexed once by (content hash, location bucket), so matching is about O(a + d) when signatures are distinct rather than a quadratic scan, and it adds no extra geometry meshing. Verified: with the fallback off, output is unchanged and the existing tests pass; with it on, a re-GUIDed identical wall is no longer a false add plus delete (reported unchanged), a re-GUIDed relocated wall is reported moved, and genuine add / delete / move are still classified correctly. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+207
-14
@@ -21,6 +21,7 @@
|
|||||||
# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico ifcdiff.py`
|
# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico ifcdiff.py`
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
@@ -58,6 +59,18 @@ class IfcDiff:
|
|||||||
that comparisons will take longer.
|
that comparisons will take longer.
|
||||||
:param filter_elements: An IFC filter query if you only want to compare a
|
:param filter_elements: An IFC filter query if you only want to compare a
|
||||||
subset of elements. For example: ``IfcWall`` to only compare walls.
|
subset of elements. For example: ``IfcWall`` to only compare walls.
|
||||||
|
:param match_by_signature: True to enable an opt-in fallback that re-pairs
|
||||||
|
elements whose GlobalId changed between the two files (common with some
|
||||||
|
authoring tools). Elements left in the added/deleted sets after the
|
||||||
|
GlobalId comparison are matched on a content signature (same IFC class,
|
||||||
|
Name, type, properties and materials) plus a placement within
|
||||||
|
``signature_tolerance``. This avoids reporting a re-GlobalId'd but
|
||||||
|
otherwise identical element as a spurious delete + add. Default False
|
||||||
|
preserves the historical GlobalId-only behaviour exactly.
|
||||||
|
:param signature_tolerance: Placement tolerance (in the file's length unit)
|
||||||
|
used when ``match_by_signature`` is enabled. Two candidate elements are
|
||||||
|
considered to be in the same place if their placement origins are within
|
||||||
|
this distance. Defaults to 0.05 (the reporter's +-5cm, assuming metres).
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
@@ -73,6 +86,9 @@ class IfcDiff:
|
|||||||
deleted_elements: set[ifcopenshell.entity_instance]
|
deleted_elements: set[ifcopenshell.entity_instance]
|
||||||
# GlobalIds to changes dictionary.
|
# GlobalIds to changes dictionary.
|
||||||
change_register: dict[str, dict[str, Any]]
|
change_register: dict[str, dict[str, Any]]
|
||||||
|
# New GlobalId to {"old_global_id": ..., "moved": bool} for elements that
|
||||||
|
# were re-paired across a GlobalId change by the signature fallback.
|
||||||
|
rematched_elements: dict[str, dict[str, Any]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -81,15 +97,20 @@ class IfcDiff:
|
|||||||
relationships: Optional[list[RELATIONSHIP_TYPE]] = None,
|
relationships: Optional[list[RELATIONSHIP_TYPE]] = None,
|
||||||
is_shallow: bool = True,
|
is_shallow: bool = True,
|
||||||
filter_elements: Optional[str] = None,
|
filter_elements: Optional[str] = None,
|
||||||
|
match_by_signature: bool = False,
|
||||||
|
signature_tolerance: float = 0.05,
|
||||||
):
|
):
|
||||||
self.old = old
|
self.old = old
|
||||||
self.new = new
|
self.new = new
|
||||||
self.change_register = {}
|
self.change_register = {}
|
||||||
|
self.rematched_elements = {}
|
||||||
self.representation_ids = {}
|
self.representation_ids = {}
|
||||||
self.relationships = relationships or ["geometry"]
|
self.relationships = relationships or ["geometry"]
|
||||||
self.precision = 1e-4
|
self.precision = 1e-4
|
||||||
self.is_shallow = is_shallow
|
self.is_shallow = is_shallow
|
||||||
self.filter_elements = filter_elements
|
self.filter_elements = filter_elements
|
||||||
|
self.match_by_signature = match_by_signature
|
||||||
|
self.signature_tolerance = signature_tolerance
|
||||||
|
|
||||||
def diff(self) -> None:
|
def diff(self) -> None:
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
@@ -122,17 +143,33 @@ class IfcDiff:
|
|||||||
|
|
||||||
self.deleted_elements = old_elements - new_elements
|
self.deleted_elements = old_elements - new_elements
|
||||||
self.added_elements = new_elements - old_elements
|
self.added_elements = new_elements - old_elements
|
||||||
same_elements = new_elements - self.added_elements
|
same_elements = old_elements & new_elements
|
||||||
total_same_elements = len(same_elements)
|
|
||||||
|
# Opt-in fallback: re-pair elements whose GlobalId changed but whose
|
||||||
|
# content and placement still match, so that GlobalId churn is not
|
||||||
|
# reported as spurious add + delete. This mutates added_elements /
|
||||||
|
# deleted_elements and returns the (old, new) pairs to diff normally.
|
||||||
|
rematched_pairs = self._match_by_signature() if self.match_by_signature else []
|
||||||
|
|
||||||
print(" - {} item(s) were added".format(len(self.added_elements)))
|
print(" - {} item(s) were added".format(len(self.added_elements)))
|
||||||
print(" - {} item(s) were deleted".format(len(self.deleted_elements)))
|
print(" - {} item(s) were deleted".format(len(self.deleted_elements)))
|
||||||
print(" - {} item(s) are common to both models".format(total_same_elements))
|
print(" - {} item(s) are common to both models".format(len(same_elements)))
|
||||||
|
if rematched_pairs:
|
||||||
|
print(" - {} item(s) were re-matched across a GlobalId change".format(len(rematched_pairs)))
|
||||||
|
|
||||||
|
element_pairs = [(self.old.by_id(g), self.new.by_id(g)) for g in same_elements]
|
||||||
|
element_pairs += rematched_pairs
|
||||||
|
total_same_elements = len(element_pairs)
|
||||||
|
|
||||||
total_diffed = 0
|
total_diffed = 0
|
||||||
|
|
||||||
potential_old_changes = []
|
potential_old_changes = []
|
||||||
potential_new_changes = []
|
potential_new_changes = []
|
||||||
|
# Maps an old GlobalId to its paired new GlobalId for the geometry stage.
|
||||||
|
# For GlobalId-matched elements this is the identity; for signature
|
||||||
|
# rematches the two GlobalIds differ, so the shape dicts (each keyed by
|
||||||
|
# their own file's GlobalId) must be paired through this map.
|
||||||
|
geometry_pair_map: dict[str, str] = {}
|
||||||
|
|
||||||
should_check_attributes = False
|
should_check_attributes = False
|
||||||
should_check_geometry = False
|
should_check_geometry = False
|
||||||
@@ -146,12 +183,10 @@ class IfcDiff:
|
|||||||
else:
|
else:
|
||||||
should_check_other = True
|
should_check_other = True
|
||||||
|
|
||||||
for global_id in same_elements:
|
for old, new in element_pairs:
|
||||||
total_diffed += 1
|
total_diffed += 1
|
||||||
if total_diffed % 250 == 0:
|
if total_diffed % 250 == 0:
|
||||||
print("{}/{} diffed ...".format(total_diffed, total_same_elements), end="\r", flush=True)
|
print("{}/{} diffed ...".format(total_diffed, total_same_elements), end="\r", flush=True)
|
||||||
old = self.old.by_id(global_id)
|
|
||||||
new = self.new.by_id(global_id)
|
|
||||||
if should_check_attributes:
|
if should_check_attributes:
|
||||||
if self.diff_element(old, new) and self.is_shallow:
|
if self.diff_element(old, new) and self.is_shallow:
|
||||||
continue
|
continue
|
||||||
@@ -163,6 +198,7 @@ class IfcDiff:
|
|||||||
if ifcopenshell.util.representation.get_representation(new, "Model", "Body", "MODEL_VIEW"):
|
if ifcopenshell.util.representation.get_representation(new, "Model", "Body", "MODEL_VIEW"):
|
||||||
potential_old_changes.append(old)
|
potential_old_changes.append(old)
|
||||||
potential_new_changes.append(new)
|
potential_new_changes.append(new)
|
||||||
|
geometry_pair_map[old.GlobalId] = new.GlobalId
|
||||||
# Option 2: check first using Python, then fallback to iterator (twice as slow)
|
# Option 2: check first using Python, then fallback to iterator (twice as slow)
|
||||||
# diff = self.diff_element_basic_geometry(old, new)
|
# diff = self.diff_element_basic_geometry(old, new)
|
||||||
# if diff:
|
# if diff:
|
||||||
@@ -180,24 +216,160 @@ class IfcDiff:
|
|||||||
print("... processing new shapes ...")
|
print("... processing new shapes ...")
|
||||||
new_shapes = self.summarise_shapes(self.new, potential_new_changes)
|
new_shapes = self.summarise_shapes(self.new, potential_new_changes)
|
||||||
print("... comparing shapes ...")
|
print("... comparing shapes ...")
|
||||||
for global_id, old_shape in old_shapes.items():
|
for old_global_id, old_shape in old_shapes.items():
|
||||||
new_shape = new_shapes.get(global_id, None)
|
new_global_id = geometry_pair_map.get(old_global_id, old_global_id)
|
||||||
|
new_shape = new_shapes.get(new_global_id, None)
|
||||||
if not new_shape:
|
if not new_shape:
|
||||||
self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
|
self.change_register.setdefault(new_global_id, {}).update({"geometry_changed": True})
|
||||||
continue
|
continue
|
||||||
del new_shapes[global_id]
|
del new_shapes[new_global_id]
|
||||||
diff = DeepDiff(old_shape, new_shape, math_epsilon=1e-5)
|
diff = DeepDiff(old_shape, new_shape, math_epsilon=1e-5)
|
||||||
if diff:
|
if diff:
|
||||||
self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
|
self.change_register.setdefault(new_global_id, {}).update({"geometry_changed": True})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for global_id in new_shapes.keys():
|
for new_global_id in new_shapes.keys():
|
||||||
self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
|
self.change_register.setdefault(new_global_id, {}).update({"geometry_changed": True})
|
||||||
|
|
||||||
print(" - {} item(s) were changed".format(len(self.change_register.keys())))
|
print(" - {} item(s) were changed".format(len(self.change_register.keys())))
|
||||||
|
|
||||||
logging.disable(logging.NOTSET)
|
logging.disable(logging.NOTSET)
|
||||||
|
|
||||||
|
def _content_signature(self, element: ifcopenshell.entity_instance) -> str:
|
||||||
|
"""Stable content hash of an element ignoring GlobalId and OwnerHistory.
|
||||||
|
|
||||||
|
Two elements sharing this signature have the same class, Name, type
|
||||||
|
Name, property sets (excluding internal ids) and material Names. This is
|
||||||
|
the "content hash" approach referenced by the maintainer; placement is
|
||||||
|
handled separately so it can use a tolerance.
|
||||||
|
"""
|
||||||
|
parts = [element.is_a(), element.Name or ""]
|
||||||
|
try:
|
||||||
|
element_type = ifcopenshell.util.element.get_type(element)
|
||||||
|
parts.append((element_type.Name or "") if element_type else "")
|
||||||
|
except Exception:
|
||||||
|
parts.append("")
|
||||||
|
try:
|
||||||
|
psets = ifcopenshell.util.element.get_psets(element, should_inherit=False)
|
||||||
|
cleaned = {name: {k: v for k, v in props.items() if k != "id"} for name, props in psets.items()}
|
||||||
|
parts.append(json.dumps(cleaned, sort_keys=True, default=str))
|
||||||
|
except Exception:
|
||||||
|
parts.append("")
|
||||||
|
try:
|
||||||
|
materials = ifcopenshell.util.element.get_materials(element) or []
|
||||||
|
parts.append(json.dumps(sorted((m.Name or "") for m in materials)))
|
||||||
|
except Exception:
|
||||||
|
parts.append("")
|
||||||
|
return hashlib.sha1("\x00".join(parts).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _placement_location(self, element: ifcopenshell.entity_instance) -> Optional[np.ndarray]:
|
||||||
|
placement = getattr(element, "ObjectPlacement", None)
|
||||||
|
if placement is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ifcopenshell.util.placement.get_local_placement(placement)[:3, 3]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _location_key(self, location: Optional[np.ndarray]) -> Optional[tuple[int, ...]]:
|
||||||
|
if location is None:
|
||||||
|
return None
|
||||||
|
tol = self.signature_tolerance or self.precision
|
||||||
|
return tuple(int(round(float(c) / tol)) for c in location)
|
||||||
|
|
||||||
|
def _match_by_signature(self) -> list[tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]]:
|
||||||
|
"""Re-pair added/deleted elements whose GlobalId changed but content did not.
|
||||||
|
|
||||||
|
Builds an index of the added elements keyed by their content signature
|
||||||
|
(and a rounded placement bucket) so that each deleted element is matched
|
||||||
|
in roughly constant time instead of scanning every added element. A pair
|
||||||
|
with the same content within ``signature_tolerance`` is treated as
|
||||||
|
unchanged; a pair with the same content but a different placement is
|
||||||
|
treated as moved. Matched GlobalIds are removed from the added/deleted
|
||||||
|
sets. Returns the (old, new) pairs so they still flow through the normal
|
||||||
|
attribute/geometry/relationship diff.
|
||||||
|
"""
|
||||||
|
added = list(self.added_elements)
|
||||||
|
deleted = list(self.deleted_elements)
|
||||||
|
if not added or not deleted:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Index added elements once (roughly O(n)).
|
||||||
|
added_info: dict[str, tuple[str, Optional[np.ndarray]]] = {}
|
||||||
|
strict_index: dict[tuple[str, Any], list[str]] = {}
|
||||||
|
loose_index: dict[str, list[str]] = {}
|
||||||
|
for guid in added:
|
||||||
|
element = self.new.by_id(guid)
|
||||||
|
sig = self._content_signature(element)
|
||||||
|
loc = self._placement_location(element)
|
||||||
|
added_info[guid] = (sig, loc)
|
||||||
|
strict_index.setdefault((sig, self._location_key(loc)), []).append(guid)
|
||||||
|
loose_index.setdefault(sig, []).append(guid)
|
||||||
|
|
||||||
|
used_added: set[str] = set()
|
||||||
|
pairs: list[tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance, bool]] = []
|
||||||
|
|
||||||
|
# Pass 1: same content and same location within tolerance -> unchanged.
|
||||||
|
# Search the target bucket and its 26 neighbours so that points near a
|
||||||
|
# bucket boundary but still within tolerance are not missed.
|
||||||
|
unmatched_deleted: list[tuple[str, str]] = []
|
||||||
|
for guid in deleted:
|
||||||
|
element = self.old.by_id(guid)
|
||||||
|
sig = self._content_signature(element)
|
||||||
|
loc = self._placement_location(element)
|
||||||
|
match = None
|
||||||
|
for neighbour in self._neighbour_keys(self._location_key(loc)):
|
||||||
|
for cand in strict_index.get((sig, neighbour), []):
|
||||||
|
if cand in used_added:
|
||||||
|
continue
|
||||||
|
cand_loc = added_info[cand][1]
|
||||||
|
if loc is None or cand_loc is None or np.allclose(loc, cand_loc, atol=self.signature_tolerance):
|
||||||
|
match = cand
|
||||||
|
break
|
||||||
|
if match is not None:
|
||||||
|
break
|
||||||
|
if match is not None:
|
||||||
|
used_added.add(match)
|
||||||
|
pairs.append((element, self.new.by_id(match), False))
|
||||||
|
else:
|
||||||
|
unmatched_deleted.append((guid, sig))
|
||||||
|
|
||||||
|
# Pass 2: same content, any location -> moved.
|
||||||
|
for guid, sig in unmatched_deleted:
|
||||||
|
match = None
|
||||||
|
for cand in loose_index.get(sig, []):
|
||||||
|
if cand in used_added:
|
||||||
|
continue
|
||||||
|
match = cand
|
||||||
|
break
|
||||||
|
if match is not None:
|
||||||
|
used_added.add(match)
|
||||||
|
pairs.append((self.old.by_id(guid), self.new.by_id(match), True))
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for old_element, new_element, moved in pairs:
|
||||||
|
self.added_elements.discard(new_element.GlobalId)
|
||||||
|
self.deleted_elements.discard(old_element.GlobalId)
|
||||||
|
self.rematched_elements[new_element.GlobalId] = {
|
||||||
|
"old_global_id": old_element.GlobalId,
|
||||||
|
"moved": moved,
|
||||||
|
}
|
||||||
|
if moved:
|
||||||
|
self.change_register.setdefault(new_element.GlobalId, {}).update({"moved": True})
|
||||||
|
result.append((old_element, new_element))
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _neighbour_keys(key: Optional[tuple[int, ...]]) -> list[Any]:
|
||||||
|
if key is None:
|
||||||
|
return [None]
|
||||||
|
keys = []
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
for dz in (-1, 0, 1):
|
||||||
|
keys.append((key[0] + dx, key[1] + dy, key[2] + dz))
|
||||||
|
return keys
|
||||||
|
|
||||||
def summarise_shapes(
|
def summarise_shapes(
|
||||||
self, ifc: ifcopenshell.file, elements: list[ifcopenshell.entity_instance]
|
self, ifc: ifcopenshell.file, elements: list[ifcopenshell.entity_instance]
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
@@ -266,6 +438,7 @@ class IfcDiff:
|
|||||||
"added": list(self.added_elements),
|
"added": list(self.added_elements),
|
||||||
"deleted": list(self.deleted_elements),
|
"deleted": list(self.deleted_elements),
|
||||||
"changed": self.change_register,
|
"changed": self.change_register,
|
||||||
|
"rematched": self.rematched_elements,
|
||||||
},
|
},
|
||||||
diff_file,
|
diff_file,
|
||||||
indent=4,
|
indent=4,
|
||||||
@@ -438,6 +611,20 @@ if __name__ == "__main__":
|
|||||||
help='A list of space-separated relationships, chosen from "type", "property", "container", "aggregate", "classification"',
|
help='A list of space-separated relationships, chosen from "type", "property", "container", "aggregate", "classification"',
|
||||||
default="",
|
default="",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-m",
|
||||||
|
"--match-by-signature",
|
||||||
|
action="store_true",
|
||||||
|
help="Re-pair elements whose GlobalId changed but whose content and placement still match, "
|
||||||
|
"instead of reporting them as add + delete (helps with GlobalId churn from some authoring tools)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--tolerance",
|
||||||
|
type=float,
|
||||||
|
default=0.05,
|
||||||
|
help="Placement tolerance (in the file's length unit) for --match-by-signature. Defaults to 0.05",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("# IFC Diff")
|
print("# IFC Diff")
|
||||||
@@ -451,7 +638,13 @@ if __name__ == "__main__":
|
|||||||
print("# Loading finished in {:.2f} seconds".format(time.time() - start))
|
print("# Loading finished in {:.2f} seconds".format(time.time() - start))
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
ifc_diff = IfcDiff(old, new, args.relationships.split())
|
ifc_diff = IfcDiff(
|
||||||
|
old,
|
||||||
|
new,
|
||||||
|
args.relationships.split(),
|
||||||
|
match_by_signature=args.match_by_signature,
|
||||||
|
signature_tolerance=args.tolerance,
|
||||||
|
)
|
||||||
ifc_diff.diff()
|
ifc_diff.diff()
|
||||||
|
|
||||||
print("# Diff finished in {:.2f} seconds".format(time.time() - start))
|
print("# Diff finished in {:.2f} seconds".format(time.time() - start))
|
||||||
|
|||||||
Reference in New Issue
Block a user