2525import logging
2626import argparse
2727import numpy as np
28+ import multiprocessing
2829import ifcopenshell
30+ import ifcopenshell .geom
2931import ifcopenshell .util .element
3032import ifcopenshell .util .placement
3133import 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