2020
2121# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico ifcdiff.py`
2222
23- import ifcopenshell
24- from deepdiff import DeepDiff
2523import time
2624import json
25+ import logging
2726import argparse
28- import decimal
27+ import numpy as np
28+ import ifcopenshell
29+ import ifcopenshell .util .element
30+ import ifcopenshell .util .placement
31+ import ifcopenshell .util .classification
32+ from deepdiff import DeepDiff
2933
3034
3135class IfcDiff :
32- def __init__ (self , old_file , new_file , output_file , inverse_classes = None , is_shallow = False ):
36+ def __init__ (self , old_file , new_file , output_file , relationships = None , is_shallow = True ):
3337 self .old_file = old_file
3438 self .new_file = new_file
3539 self .output_file = output_file
3640 self .change_register = {}
37- self .representation_ids = set ()
38- self .inverse_classes = inverse_classes
39- self .precision = 2
41+ self .representation_ids = {}
42+ self .relationships = relationships
43+ self .precision = 1e-4
4044 self .is_shallow = is_shallow
4145
4246 def diff (self ):
4347 print ("# IFC Diff" )
48+ logging .disable (logging .CRITICAL )
4449 self .load ()
4550
4651 self .precision = self .get_precision ()
@@ -64,20 +69,19 @@ def diff(self):
6469 total_diffed += 1
6570 if total_diffed % 250 == 0 :
6671 print ("{}/{} diffed ..." .format (total_diffed , total_same_elements ), end = "\r " , flush = True )
67- old_element = self .old .by_id (global_id )
68- new_element = self .new .by_id (global_id )
69- if self .diff_element (old_element , new_element ) and self .is_shallow :
72+ old = self .old .by_id (global_id )
73+ new = self .new .by_id (global_id )
74+ if self .diff_element (old , new ) and self .is_shallow :
7075 continue
71- if self .diff_element_inverse_relationships ( old_element , new_element ) and self .is_shallow :
76+ if self .diff_element_relationships ( old , new ) and self .is_shallow :
7277 continue
73- representation_id = self .get_representation_id (new_element )
74- if representation_id in self .representation_ids :
75- continue
76- self .representation_ids .add (representation_id )
77- self .diff_element_geometry (old_element , new_element )
78+ diff = self .diff_element_geometry (old , new )
79+ if diff :
80+ self .change_register .setdefault (new .GlobalId , {}).update ({"geometry_changed" : True })
7881
7982 print (" - {} item(s) were changed either geometrically or with data" .format (len (self .change_register .keys ())))
8083 print ("# Diff finished in {:.2f} seconds" .format (time .time () - start ))
84+ logging .disable (logging .NOTSET )
8185
8286 def export (self ):
8387 with open (self .output_file , "w" , encoding = "utf-8" ) as diff_file :
@@ -99,116 +103,123 @@ def load(self):
99103 self .new = ifcopenshell .open (self .new_file )
100104
101105 def get_precision (self ):
102- try :
103- precision = [c for c in self .new .by_type ("IfcGeometricRepresentationContext" ) if c .ContextType == "Model" ][
104- 0
105- ].Precision
106- exponent = decimal .Decimal (str (precision )).as_tuple ().exponent
107- if exponent < 0 :
108- return abs (exponent )
109- return 0
110- except :
111- return 2
106+ contexts = [c for c in self .new .by_type ("IfcGeometricRepresentationContext" ) if c .ContextType == "Model" ]
107+ if contexts :
108+ return contexts [0 ].Precision or 1e-4
109+ return 1e-4
112110
113- def diff_element (self , old_element , new_element ):
111+ def diff_element (self , old , new ):
114112 diff = DeepDiff (
115- old_element ,
116- new_element ,
117- significant_digits = self .precision ,
113+ [ a for a in old if not isinstance ( a , ( ifcopenshell . entity_instance , tuple ))] ,
114+ [ a for a in new if not isinstance ( a , ( ifcopenshell . entity_instance , tuple ))] ,
115+ math_epsilon = self .precision ,
118116 ignore_string_type_changes = True ,
119117 ignore_numeric_type_changes = True ,
120- exclude_regex_paths = {
121- r"root.*id$" ,
122- r".*Representation.*" ,
123- r".*OwnerHistory.*" ,
124- r".*ObjectPlacement.*" ,
125- },
126118 )
127- if diff and new_element .GlobalId :
128- self .change_register .setdefault (new_element .GlobalId , {}).update (diff )
119+ if diff and new .GlobalId :
120+ self .change_register .setdefault (new .GlobalId , {}).update ({ "attributes_changed" : True } )
129121 return True
130122
131- def diff_element_inverse_relationships (self , old_element , new_element ):
132- if not self .inverse_classes :
123+ def diff_element_relationships (self , old , new ):
124+ if not self .relationships :
133125 return
134- old_relationships_all = self .old .get_inverse (old_element )
135- new_relationships_all = self .new .get_inverse (new_element )
136- if self .inverse_classes [0 ] == "all" :
137- old_relationships = old_relationships_all
138- new_relationships = new_relationships_all
139- else :
140- old_relationships = [x for x in old_relationships_all if x .is_a () in self .inverse_classes ]
141- new_relationships = [x for x in new_relationships_all if x .is_a () in self .inverse_classes ]
126+ for relationship in self .relationships :
127+ if relationship == "type" :
128+ if ifcopenshell .util .element .get_type (old ) != ifcopenshell .util .element .get_type (new ):
129+ self .change_register .setdefault (new .GlobalId , {}).update ({"type_changed" : True })
130+ return True
131+ elif relationship == "property" :
132+ old_psets = ifcopenshell .util .element .get_psets (old )
133+ new_psets = ifcopenshell .util .element .get_psets (new )
134+ try :
135+ diff = DeepDiff (
136+ old_psets ,
137+ new_psets ,
138+ math_epsilon = self .precision ,
139+ ignore_string_type_changes = True ,
140+ ignore_numeric_type_changes = True ,
141+ exclude_regex_paths = [r".*id$" ],
142+ )
143+ except :
144+ diff = True
145+ if diff and new .GlobalId :
146+ self .change_register .setdefault (new .GlobalId , {}).update ({"properties_changed" : diff })
147+ return True
148+ elif relationship == "container" :
149+ if ifcopenshell .util .element .get_container (old ) != ifcopenshell .util .element .get_container (new ):
150+ self .change_register .setdefault (new .GlobalId , {}).update ({"container_changed" : True })
151+ return True
152+ elif relationship == "aggregate" :
153+ if ifcopenshell .util .element .get_aggregate (old ) != ifcopenshell .util .element .get_aggregate (new ):
154+ self .change_register .setdefault (new .GlobalId , {}).update ({"aggregate_changed" : True })
155+ return True
156+ elif relationship == "classification" :
157+ old_id = "ItemReference" if self .old .schema == "IFC2X3" else "Identification"
158+ new_id = "ItemReference" if self .new .schema == "IFC2X3" else "Identification"
159+ old_refs = [getattr (r , old_id ) for r in ifcopenshell .util .classification .get_references (old )]
160+ new_refs = [getattr (r , new_id ) for r in ifcopenshell .util .classification .get_references (new )]
161+ if old_refs != new_refs :
162+ self .change_register .setdefault (new .GlobalId , {}).update ({"classification_changed" : True })
163+ return True
142164
143- diff = DeepDiff (
144- old_relationships ,
145- new_relationships ,
146- significant_digits = self .precision ,
147- ignore_string_type_changes = True ,
148- ignore_numeric_type_changes = True ,
149- exclude_regex_paths = [
150- r"root.*id$" ,
151- r".*GlobalId.*" ,
152- r".*OwnerHistory.*" ,
153- r".*RelatedObjects.*" ,
154- r".*RelatingObject.*" ,
155- r".*RelatingDefinitions.*" ,
156- r".*RelatedObjectsType.*" , # Deprecated in IFC4 anyway
157- ],
158- )
159- if diff and new_element .GlobalId :
160- self .change_register .setdefault (new_element .GlobalId , {}).update (diff )
165+ def diff_element_geometry (self , old , new ):
166+ old_placement = ifcopenshell .util .placement .get_local_placement (old .ObjectPlacement )
167+ new_placement = ifcopenshell .util .placement .get_local_placement (new .ObjectPlacement )
168+ if not np .allclose (old_placement [:,3 ], new_placement [:,3 ], atol = self .precision ):
169+ return True
170+ if not np .allclose (old_placement [0 :3 ,0 :3 ], new_placement [0 :3 ,0 :3 ], atol = 1e-2 ):
171+ return True
172+ old_openings = [o .RelatedOpeningElement .GlobalId for o in getattr (old , "HasOpenings" , []) or []]
173+ new_openings = [o .RelatedOpeningElement .GlobalId for o in getattr (new , "HasOpenings" , []) or []]
174+ if old_openings != new_openings :
175+ return True
176+ old_projections = [o .RelatedFeatureElement .GlobalId for o in getattr (old , "HasProjections" , []) or []]
177+ new_projections = [o .RelatedFeatureElement .GlobalId for o in getattr (new , "HasProjections" , []) or []]
178+ if old_projections != new_projections :
161179 return True
180+ old_rep_id = self .get_representation_id (old )
181+ new_rep_id = self .get_representation_id (new )
182+ rep_result = self .representation_ids .get (new_rep_id , None )
183+ if rep_result is not None :
184+ return rep_result
185+ if type (old_rep_id ) != type (new_rep_id ):
186+ self .representation_ids [new_rep_id ] = True
187+ return True
188+ if new_rep_id is None :
189+ return
190+ result = self .diff_representation (old_rep_id , new_rep_id ) or False
191+ self .representation_ids [new_rep_id ] = result
192+ return result
193+
194+ def diff_representation (self , old_rep_id , new_rep_id ):
195+ old_rep = self .old .by_id (old_rep_id )
196+ new_rep = self .new .by_id (new_rep_id )
197+ if len (old_rep .Items ) != len (new_rep .Items ):
198+ return True
199+ for i , old_item in enumerate (old_rep .Items ):
200+ result = self .diff_representation_item (old_item , new_rep .Items [i ])
201+ if result is True :
202+ return True
162203
163- def diff_element_geometry (self , old_element , new_element ):
204+ def diff_representation_item (self , old_item , new_item ):
205+ if old_item .is_a () != new_item .is_a ():
206+ return True
164207 try :
165- DeepDiff (
166- old_element .ObjectPlacement ,
167- new_element .ObjectPlacement ,
168- terminate_on_first = True ,
169- significant_digits = self .precision ,
170- ignore_string_type_changes = True ,
171- ignore_numeric_type_changes = True ,
172- exclude_regex_paths = r"root.*id$" ,
173- )
174- DeepDiff (
175- old_element .HasOpenings ,
176- new_element .HasOpenings ,
177- terminate_on_first = True ,
178- significant_digits = self .precision ,
179- ignore_string_type_changes = True ,
180- ignore_numeric_type_changes = True ,
181- exclude_regex_paths = r"root.*id$" ,
182- )
183- DeepDiff (
184- old_element .HasProjections ,
185- new_element .HasProjections ,
186- terminate_on_first = True ,
187- significant_digits = self .precision ,
188- ignore_string_type_changes = True ,
189- ignore_numeric_type_changes = True ,
190- exclude_regex_paths = r"root.*id$" ,
191- )
192- DeepDiff (
193- old_element .Representation .get_info_2 (recursive = True ),
194- new_element .Representation .get_info_2 (recursive = True ),
195- terminate_on_first = True ,
196- skip_after_n = 500 , # Arbitrary value to "skim" check
197- significant_digits = self .precision ,
198- ignore_string_type_changes = True ,
199- ignore_numeric_type_changes = True ,
200- exclude_regex_paths = [
201- r"root.*id']$" ,
202- r".*ContextOfItems.*" ,
203- ],
208+ diff = DeepDiff (
209+ old_item .get_info_2 (recursive = True ),
210+ new_item .get_info_2 (recursive = True ),
211+ custom_operators = [DiffTerminator ()] if self .is_shallow else [],
212+ math_epsilon = self .precision ,
213+ exclude_regex_paths = [r".*id']$" ]
204214 )
205215 except :
206- if new_element .GlobalId :
207- return self .change_register .setdefault (new_element .GlobalId , {}).update ({"has_geometry_change" : True })
216+ return True
217+ if diff :
218+ return True
208219
209220 def get_representation_id (self , element ):
210221 if not element .Representation :
211- return None
222+ return
212223 for representation in element .Representation .Representations :
213224 if not representation .is_a ("IfcShapeRepresentation" ):
214225 continue
@@ -221,6 +232,15 @@ def get_representation_id(self, element):
221232 return representation .Items [0 ].MappingSource .MappedRepresentation .id ()
222233
223234
235+ class DiffTerminator :
236+ def match (self , level ) -> bool :
237+ return True
238+
239+ def give_up_diffing (self , level , diff_instance ) -> bool :
240+ if any (diff_instance .tree .values ()):
241+ raise Exception ("Terminated" )
242+
243+
224244class DiffEncoder (json .JSONEncoder ):
225245 def default (self , obj ):
226246 try :
@@ -240,7 +260,7 @@ def default(self, obj):
240260 "-r" ,
241261 "--relationships" ,
242262 type = str ,
243- help = 'A list of IFC classes to check in inverse relationships, like "IfcRelDefinesByProperties ", or "all". ' ,
263+ help = 'A list of space-separated relationships, chosen from "type", "property", "container", "aggregate ", "classification" ' ,
244264 default = "" ,
245265 )
246266 args = parser .parse_args ()
0 commit comments