Skip to content

Commit 2f76434

Browse files
committed
IfcDiff now uses the iterator to compare geometry, which seems significantly faster.
1 parent 226c943 commit 2f76434

File tree

1 file changed

+153
-34
lines changed

1 file changed

+153
-34
lines changed

src/ifcdiff/ifcdiff.py

Lines changed: 153 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
import logging
2626
import argparse
2727
import numpy as np
28+
import multiprocessing
2829
import ifcopenshell
30+
import ifcopenshell.geom
2931
import ifcopenshell.util.element
3032
import ifcopenshell.util.placement
3133
import ifcopenshell.util.classification
@@ -42,8 +44,8 @@ class IfcDiff:
4244
:type old: ifcopenshell.file.file
4345
:param new: IFC file object for the old model
4446
:type new: ifcopenshell.file.file
45-
:param relationships: List of relationships to check. An empty list means
46-
that only geometry and attributes are compared.
47+
:param relationships: List of relationships to check. None means that only
48+
geometry is compared.
4749
:type relationships: list[string]
4850
:param is_shallow: True if you want only the first difference to be listed.
4951
False if you want all differences to be checked. Choosing False means
@@ -68,7 +70,7 @@ def __init__(self, old, new, relationships=None, is_shallow=True, filter_element
6870
self.new = new
6971
self.change_register = {}
7072
self.representation_ids = {}
71-
self.relationships = relationships
73+
self.relationships = relationships or ["geometry"]
7274
self.precision = 1e-4
7375
self.is_shallow = is_shallow
7476
self.filter_elements = filter_elements
@@ -83,32 +85,152 @@ def diff(self):
8385
old_elements = set(e.GlobalId for e in selector.parse(self.old, self.filter_elements))
8486
new_elements = set(e.GlobalId for e in selector.parse(self.new, self.filter_elements))
8587
else:
86-
old_elements = set(e.GlobalId for e in self.old.by_type("IfcProduct"))
87-
new_elements = set(e.GlobalId for e in self.new.by_type("IfcProduct"))
88+
old_elements = self.old.by_type("IfcElement")
89+
if self.old.schema == "IFC2X3":
90+
old_elements += self.old.by_type("IfcSpatialStructureElement")
91+
else:
92+
old_elements += self.old.by_type("IfcSpatialElement")
93+
old_elements = set(e.GlobalId for e in old_elements if not e.is_a("IfcFeatureElement"))
94+
new_elements = self.new.by_type("IfcElement")
95+
if self.new.schema == "IFC2X3":
96+
new_elements += self.new.by_type("IfcSpatialStructureElement")
97+
else:
98+
new_elements += self.new.by_type("IfcSpatialElement")
99+
new_elements = set(e.GlobalId for e in new_elements if not e.is_a("IfcFeatureElement"))
100+
101+
print(" - {} item(s) are in the old model".format(len(old_elements)))
102+
print(" - {} item(s) are in the new model".format(len(new_elements)))
88103

89104
self.deleted_elements = old_elements - new_elements
90105
self.added_elements = new_elements - old_elements
91106
same_elements = new_elements - self.added_elements
92107
total_same_elements = len(same_elements)
93108

109+
print(" - {} item(s) were added".format(len(self.added_elements)))
110+
print(" - {} item(s) were deleted".format(len(self.deleted_elements)))
111+
print(" - {} item(s) are common to both models".format(total_same_elements))
112+
94113
total_diffed = 0
95114

115+
potential_old_changes = []
116+
potential_new_changes = []
117+
118+
should_check_attributes = False
119+
should_check_geometry = False
120+
should_check_other = False
121+
122+
for relationship in self.relationships:
123+
if relationship == "attributes":
124+
should_check_attributes = True
125+
elif relationship == "geometry":
126+
should_check_geometry = True
127+
else:
128+
should_check_other = True
129+
96130
for global_id in same_elements:
97131
total_diffed += 1
98132
if total_diffed % 250 == 0:
99133
print("{}/{} diffed ...".format(total_diffed, total_same_elements), end="\r", flush=True)
100134
old = self.old.by_id(global_id)
101135
new = self.new.by_id(global_id)
102-
if self.diff_element(old, new) and self.is_shallow:
103-
continue
104-
if self.diff_element_relationships(old, new) and self.is_shallow:
105-
continue
106-
diff = self.diff_element_geometry(old, new)
107-
if diff:
108-
self.change_register.setdefault(new.GlobalId, {}).update({"geometry_changed": True})
136+
if should_check_attributes:
137+
if self.diff_element(old, new) and self.is_shallow:
138+
continue
139+
if should_check_other:
140+
if self.diff_element_relationships(old, new) and self.is_shallow:
141+
continue
142+
if should_check_geometry:
143+
# Option 1: check everything heuristically using the iterator (seems faster)
144+
potential_old_changes.append(old)
145+
potential_new_changes.append(new)
146+
# Option 2: check first using Python, then fallback to iterator (twice as slow)
147+
# diff = self.diff_element_basic_geometry(old, new)
148+
# if diff:
149+
# self.change_register.setdefault(new.GlobalId, {}).update({"geometry_changed": True})
150+
# else:
151+
# potential_old_changes.append(old)
152+
# potential_new_changes.append(new)
153+
154+
print(" - {} item(s) had simple changes".format(len(self.change_register.keys())))
155+
156+
if potential_old_changes:
157+
print(" - {} item(s) are queued for a detailed geometry check".format(len(potential_old_changes)))
158+
print("... processing old shapes ...")
159+
old_shapes = self.summarise_shapes(self.old, potential_old_changes)
160+
print("... processing new shapes ...")
161+
new_shapes = self.summarise_shapes(self.new, potential_new_changes)
162+
print("... comparing shapes ...")
163+
for global_id, old_shape in old_shapes.items():
164+
new_shape = new_shapes.get(global_id, None)
165+
if not new_shape:
166+
self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
167+
continue
168+
del new_shapes[global_id]
169+
diff = DeepDiff(old_shape, new_shape, math_epsilon=1e-5)
170+
if diff:
171+
self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
172+
continue
173+
174+
for global_id in new_shapes.keys():
175+
self.change_register.setdefault(global_id, {}).update({"geometry_changed": True})
176+
177+
print(" - {} item(s) were changed".format(len(self.change_register.keys())))
109178

110179
logging.disable(logging.NOTSET)
111180

181+
def summarise_shapes(self, ifc, elements):
182+
shapes = {}
183+
iterator = ifcopenshell.geom.iterator(
184+
self.get_settings(ifc), ifc, multiprocessing.cpu_count(), include=elements
185+
)
186+
valid_file = iterator.initialize()
187+
while True:
188+
shape = iterator.get()
189+
element = ifc.by_id(shape.id)
190+
geometry = shape.geometry
191+
shapes[element.GlobalId] = {
192+
"total_verts": len(geometry.verts),
193+
"sum_verts": sum(geometry.verts),
194+
"min_vert": min(geometry.verts),
195+
"max_vert": max(geometry.verts),
196+
"matrix": tuple(shape.transformation.matrix.data),
197+
"openings": sorted(
198+
[o.RelatedOpeningElement.GlobalId for o in getattr(element, "HasOpenings", []) or []]
199+
),
200+
"projections": sorted(
201+
[o.RelatedFeatureElement.GlobalId for o in getattr(element, "HasProjections", []) or []]
202+
),
203+
}
204+
if not iterator.next():
205+
break
206+
return shapes
207+
208+
def get_settings(self, ifc):
209+
settings = ifcopenshell.geom.settings()
210+
settings.set(settings.STRICT_TOLERANCE, True)
211+
# Are you feeling lucky?
212+
settings.set(settings.DISABLE_BOOLEAN_RESULT, True)
213+
# Are you feeling very lucky?
214+
settings.set(settings.DISABLE_OPENING_SUBTRACTIONS, True)
215+
# Facetation is to accommodate broken Revit files
216+
# See https://forums.buildingsmart.org/t/suggestions-on-how-to-improve-clarity-of-representation-context-usage-in-documentation/3663/6?u=moult
217+
body_contexts = [
218+
c.id()
219+
for c in ifc.by_type("IfcGeometricRepresentationSubContext")
220+
if c.ContextIdentifier in ["Body", "Facetation"]
221+
]
222+
# Ideally, all representations should be in a subcontext, but some BIM programs don't do this correctly
223+
body_contexts.extend(
224+
[
225+
c.id()
226+
for c in ifc.by_type("IfcGeometricRepresentationContext", include_subtypes=False)
227+
if c.ContextType == "Model"
228+
]
229+
)
230+
if body_contexts:
231+
settings.set_context_ids(body_contexts)
232+
return settings
233+
112234
def export(self, path):
113235
with open(path, "w", encoding="utf-8") as diff_file:
114236
json.dump(
@@ -181,34 +303,35 @@ def diff_element_relationships(self, old, new):
181303
self.change_register.setdefault(new.GlobalId, {}).update({"classification_changed": True})
182304
return True
183305

184-
def diff_element_geometry(self, old, new):
306+
def diff_element_basic_geometry(self, old, new):
185307
old_placement = ifcopenshell.util.placement.get_local_placement(old.ObjectPlacement)
186308
new_placement = ifcopenshell.util.placement.get_local_placement(new.ObjectPlacement)
187309
if not np.allclose(old_placement[:, 3], new_placement[:, 3], atol=self.precision):
188310
return True
189311
if not np.allclose(old_placement[0:3, 0:3], new_placement[0:3, 0:3], atol=1e-2):
190312
return True
191-
old_openings = [o.RelatedOpeningElement.GlobalId for o in getattr(old, "HasOpenings", []) or []]
192-
new_openings = [o.RelatedOpeningElement.GlobalId for o in getattr(new, "HasOpenings", []) or []]
313+
old_openings = sorted([o.RelatedOpeningElement.GlobalId for o in getattr(old, "HasOpenings", []) or []])
314+
new_openings = sorted([o.RelatedOpeningElement.GlobalId for o in getattr(new, "HasOpenings", []) or []])
193315
if old_openings != new_openings:
194316
return True
195-
old_projections = [o.RelatedFeatureElement.GlobalId for o in getattr(old, "HasProjections", []) or []]
196-
new_projections = [o.RelatedFeatureElement.GlobalId for o in getattr(new, "HasProjections", []) or []]
317+
old_projections = sorted([o.RelatedFeatureElement.GlobalId for o in getattr(old, "HasProjections", []) or []])
318+
new_projections = sorted([o.RelatedFeatureElement.GlobalId for o in getattr(new, "HasProjections", []) or []])
197319
if old_projections != new_projections:
198320
return True
199-
old_rep_id = self.get_representation_id(old)
200-
new_rep_id = self.get_representation_id(new)
201-
rep_result = self.representation_ids.get(new_rep_id, None)
202-
if rep_result is not None:
203-
return rep_result
204-
if type(old_rep_id) != type(new_rep_id):
205-
self.representation_ids[new_rep_id] = True
206-
return True
207-
if new_rep_id is None:
208-
return
209-
result = self.diff_representation(old_rep_id, new_rep_id) or False
210-
self.representation_ids[new_rep_id] = result
211-
return result
321+
# Option 3: check completely using Python with get_info_2 (extremely slow, not worth it)
322+
# old_rep_id = self.get_representation_id(old)
323+
# new_rep_id = self.get_representation_id(new)
324+
# rep_result = self.representation_ids.get(new_rep_id, None)
325+
# if rep_result is not None:
326+
# return rep_result
327+
# if type(old_rep_id) != type(new_rep_id):
328+
# self.representation_ids[new_rep_id] = True
329+
# return True
330+
# if new_rep_id is None:
331+
# return
332+
# result = self.diff_representation(old_rep_id, new_rep_id) or False
333+
# self.representation_ids[new_rep_id] = result
334+
# return result
212335

213336
def diff_representation(self, old_rep_id, new_rep_id):
214337
old_rep = self.old.by_id(old_rep_id)
@@ -290,10 +413,6 @@ def give_up_diffing(self, level, diff_instance) -> bool:
290413
ifc_diff = IfcDiff(old, new, args.relationships.split())
291414
ifc_diff.diff()
292415

293-
print(" - {} item(s) were deleted".format(len(ifc_diff.deleted_elements)))
294-
print(" - {} item(s) were added".format(len(ifc_diff.added_elements)))
295-
print(" - {} item(s) were changed".format(len(ifc_diff.change_register.keys())))
296-
297416
print("# Diff finished in {:.2f} seconds".format(time.time() - start))
298417

299418
ifc_diff.export(args.output)

0 commit comments

Comments
 (0)