From 2f7643424df6cfbfc0a7172e49860ebb8d970a0f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 24 Oct 2022 13:06:03 +1100 Subject: [PATCH] IfcDiff now uses the iterator to compare geometry, which seems significantly faster. --- src/ifcdiff/ifcdiff.py | 187 +++++++++++++++++++++++++++++++++-------- 1 file changed, 153 insertions(+), 34 deletions(-) diff --git a/src/ifcdiff/ifcdiff.py b/src/ifcdiff/ifcdiff.py index 13a47cf619..e372c7b18c 100755 --- a/src/ifcdiff/ifcdiff.py +++ b/src/ifcdiff/ifcdiff.py @@ -25,7 +25,9 @@ import json import logging import argparse import numpy as np +import multiprocessing import ifcopenshell +import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.classification @@ -42,8 +44,8 @@ class IfcDiff: :type old: ifcopenshell.file.file :param new: IFC file object for the old model :type new: ifcopenshell.file.file - :param relationships: List of relationships to check. An empty list means - that only geometry and attributes are compared. + :param relationships: List of relationships to check. None means that only + geometry is compared. :type relationships: list[string] :param is_shallow: True if you want only the first difference to be listed. False if you want all differences to be checked. Choosing False means @@ -68,7 +70,7 @@ class IfcDiff: self.new = new self.change_register = {} self.representation_ids = {} - self.relationships = relationships + self.relationships = relationships or ["geometry"] self.precision = 1e-4 self.is_shallow = is_shallow self.filter_elements = filter_elements @@ -83,32 +85,152 @@ class IfcDiff: old_elements = set(e.GlobalId for e in selector.parse(self.old, self.filter_elements)) new_elements = set(e.GlobalId for e in selector.parse(self.new, self.filter_elements)) else: - old_elements = set(e.GlobalId for e in self.old.by_type("IfcProduct")) - new_elements = set(e.GlobalId for e in self.new.by_type("IfcProduct")) + old_elements = self.old.by_type("IfcElement") + if self.old.schema == "IFC2X3": + old_elements += self.old.by_type("IfcSpatialStructureElement") + else: + old_elements += self.old.by_type("IfcSpatialElement") + old_elements = set(e.GlobalId for e in old_elements if not e.is_a("IfcFeatureElement")) + new_elements = self.new.by_type("IfcElement") + if self.new.schema == "IFC2X3": + new_elements += self.new.by_type("IfcSpatialStructureElement") + else: + new_elements += self.new.by_type("IfcSpatialElement") + new_elements = set(e.GlobalId for e in new_elements if not e.is_a("IfcFeatureElement")) + + print(" - {} item(s) are in the old model".format(len(old_elements))) + print(" - {} item(s) are in the new model".format(len(new_elements))) self.deleted_elements = old_elements - new_elements self.added_elements = new_elements - old_elements same_elements = new_elements - self.added_elements total_same_elements = len(same_elements) + print(" - {} item(s) were added".format(len(self.added_elements))) + print(" - {} item(s) were deleted".format(len(self.deleted_elements))) + print(" - {} item(s) are common to both models".format(total_same_elements)) + total_diffed = 0 + potential_old_changes = [] + potential_new_changes = [] + + should_check_attributes = False + should_check_geometry = False + should_check_other = False + + for relationship in self.relationships: + if relationship == "attributes": + should_check_attributes = True + elif relationship == "geometry": + should_check_geometry = True + else: + should_check_other = True + for global_id in same_elements: total_diffed += 1 if total_diffed % 250 == 0: 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 self.diff_element(old, new) and self.is_shallow: - continue - if self.diff_element_relationships(old, new) and self.is_shallow: - continue - diff = self.diff_element_geometry(old, new) - if diff: - self.change_register.setdefault(new.GlobalId, {}).update({"geometry_changed": True}) + if should_check_attributes: + if self.diff_element(old, new) and self.is_shallow: + continue + if should_check_other: + if self.diff_element_relationships(old, new) and self.is_shallow: + continue + if should_check_geometry: + # Option 1: check everything heuristically using the iterator (seems faster) + potential_old_changes.append(old) + potential_new_changes.append(new) + # Option 2: check first using Python, then fallback to iterator (twice as slow) + # diff = self.diff_element_basic_geometry(old, new) + # if diff: + # self.change_register.setdefault(new.GlobalId, {}).update({"geometry_changed": True}) + # else: + # potential_old_changes.append(old) + # potential_new_changes.append(new) + + print(" - {} item(s) had simple changes".format(len(self.change_register.keys()))) + + if potential_old_changes: + print(" - {} item(s) are queued for a detailed geometry check".format(len(potential_old_changes))) + print("... processing old shapes ...") + old_shapes = self.summarise_shapes(self.old, potential_old_changes) + print("... processing new shapes ...") + new_shapes = self.summarise_shapes(self.new, potential_new_changes) + print("... comparing shapes ...") + for global_id, old_shape in old_shapes.items(): + new_shape = new_shapes.get(global_id, None) + if not new_shape: + self.change_register.setdefault(global_id, {}).update({"geometry_changed": True}) + continue + del new_shapes[global_id] + diff = DeepDiff(old_shape, new_shape, math_epsilon=1e-5) + if diff: + self.change_register.setdefault(global_id, {}).update({"geometry_changed": True}) + continue + + for global_id in new_shapes.keys(): + self.change_register.setdefault(global_id, {}).update({"geometry_changed": True}) + + print(" - {} item(s) were changed".format(len(self.change_register.keys()))) logging.disable(logging.NOTSET) + def summarise_shapes(self, ifc, elements): + shapes = {} + iterator = ifcopenshell.geom.iterator( + self.get_settings(ifc), ifc, multiprocessing.cpu_count(), include=elements + ) + valid_file = iterator.initialize() + while True: + shape = iterator.get() + element = ifc.by_id(shape.id) + geometry = shape.geometry + shapes[element.GlobalId] = { + "total_verts": len(geometry.verts), + "sum_verts": sum(geometry.verts), + "min_vert": min(geometry.verts), + "max_vert": max(geometry.verts), + "matrix": tuple(shape.transformation.matrix.data), + "openings": sorted( + [o.RelatedOpeningElement.GlobalId for o in getattr(element, "HasOpenings", []) or []] + ), + "projections": sorted( + [o.RelatedFeatureElement.GlobalId for o in getattr(element, "HasProjections", []) or []] + ), + } + if not iterator.next(): + break + return shapes + + def get_settings(self, ifc): + settings = ifcopenshell.geom.settings() + settings.set(settings.STRICT_TOLERANCE, True) + # Are you feeling lucky? + settings.set(settings.DISABLE_BOOLEAN_RESULT, True) + # Are you feeling very lucky? + settings.set(settings.DISABLE_OPENING_SUBTRACTIONS, True) + # Facetation is to accommodate broken Revit files + # See https://forums.buildingsmart.org/t/suggestions-on-how-to-improve-clarity-of-representation-context-usage-in-documentation/3663/6?u=moult + body_contexts = [ + c.id() + for c in ifc.by_type("IfcGeometricRepresentationSubContext") + if c.ContextIdentifier in ["Body", "Facetation"] + ] + # Ideally, all representations should be in a subcontext, but some BIM programs don't do this correctly + body_contexts.extend( + [ + c.id() + for c in ifc.by_type("IfcGeometricRepresentationContext", include_subtypes=False) + if c.ContextType == "Model" + ] + ) + if body_contexts: + settings.set_context_ids(body_contexts) + return settings + def export(self, path): with open(path, "w", encoding="utf-8") as diff_file: json.dump( @@ -181,34 +303,35 @@ class IfcDiff: self.change_register.setdefault(new.GlobalId, {}).update({"classification_changed": True}) return True - def diff_element_geometry(self, old, new): + def diff_element_basic_geometry(self, old, new): old_placement = ifcopenshell.util.placement.get_local_placement(old.ObjectPlacement) new_placement = ifcopenshell.util.placement.get_local_placement(new.ObjectPlacement) if not np.allclose(old_placement[:, 3], new_placement[:, 3], atol=self.precision): return True if not np.allclose(old_placement[0:3, 0:3], new_placement[0:3, 0:3], atol=1e-2): return True - old_openings = [o.RelatedOpeningElement.GlobalId for o in getattr(old, "HasOpenings", []) or []] - new_openings = [o.RelatedOpeningElement.GlobalId for o in getattr(new, "HasOpenings", []) or []] + old_openings = sorted([o.RelatedOpeningElement.GlobalId for o in getattr(old, "HasOpenings", []) or []]) + new_openings = sorted([o.RelatedOpeningElement.GlobalId for o in getattr(new, "HasOpenings", []) or []]) if old_openings != new_openings: return True - old_projections = [o.RelatedFeatureElement.GlobalId for o in getattr(old, "HasProjections", []) or []] - new_projections = [o.RelatedFeatureElement.GlobalId for o in getattr(new, "HasProjections", []) or []] + old_projections = sorted([o.RelatedFeatureElement.GlobalId for o in getattr(old, "HasProjections", []) or []]) + new_projections = sorted([o.RelatedFeatureElement.GlobalId for o in getattr(new, "HasProjections", []) or []]) if old_projections != new_projections: return True - old_rep_id = self.get_representation_id(old) - new_rep_id = self.get_representation_id(new) - rep_result = self.representation_ids.get(new_rep_id, None) - if rep_result is not None: - return rep_result - if type(old_rep_id) != type(new_rep_id): - self.representation_ids[new_rep_id] = True - return True - if new_rep_id is None: - return - result = self.diff_representation(old_rep_id, new_rep_id) or False - self.representation_ids[new_rep_id] = result - return result + # Option 3: check completely using Python with get_info_2 (extremely slow, not worth it) + # old_rep_id = self.get_representation_id(old) + # new_rep_id = self.get_representation_id(new) + # rep_result = self.representation_ids.get(new_rep_id, None) + # if rep_result is not None: + # return rep_result + # if type(old_rep_id) != type(new_rep_id): + # self.representation_ids[new_rep_id] = True + # return True + # if new_rep_id is None: + # return + # result = self.diff_representation(old_rep_id, new_rep_id) or False + # self.representation_ids[new_rep_id] = result + # return result def diff_representation(self, old_rep_id, new_rep_id): old_rep = self.old.by_id(old_rep_id) @@ -290,10 +413,6 @@ if __name__ == "__main__": ifc_diff = IfcDiff(old, new, args.relationships.split()) ifc_diff.diff() - print(" - {} item(s) were deleted".format(len(ifc_diff.deleted_elements))) - print(" - {} item(s) were added".format(len(ifc_diff.added_elements))) - print(" - {} item(s) were changed".format(len(ifc_diff.change_register.keys()))) - print("# Diff finished in {:.2f} seconds".format(time.time() - start)) ifc_diff.export(args.output)