# IfcOpenShell - IFC toolkit and geometry engine # Copyright (C) 2021 Dion Moult # # This file is part of IfcOpenShell. # # IfcOpenShell is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # IfcOpenShell is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import json import os import time from typing import Any, Literal, Union import ifcopenshell import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper import ifcopenshell.util.attribute # This is highly experimental and incomplete, however, it may work for simple datasets. cwd = os.path.dirname(os.path.realpath(__file__)) IFC_SCHEMA = Literal["IFC2X3", "IFC4", "IFC4X3"] def get_fallback_schema(version: str) -> IFC_SCHEMA: """Fallback to the schema version we do have docs and mapping for. Needed to support IFC versions like 4X3_RC1, 4X1 etc. :param version: Typically a string from ``ifcopenshell.file.schema_identifier``, e.g. IFC4X3_ADD2 """ if version.startswith("IFC4X3"): version = "IFC4X3" elif version.startswith("IFC4"): version = "IFC4" elif version.startswith("IFC2X3"): version = "IFC2X3" else: assert False, f"Unexpected schema version: {version}." return version def get_declaration(element: ifcopenshell.entity_instance): """Get the schema declaration of an actively used entity instance IFC models are made out of instances (e.g. with a STEP ID) of entities (e.g. IfcWall). Those entities are defined through a **Schema Declaration**. **Schema Declaration** objects can be used to query information about the IFC schema itself, such as data types, enumeration values, and inheritance. :param element: Any instance, typically from a loaded or created IFC model Example: .. code:: python wall = model.createIfcWall() declaration = ifcopenshell.util.schema.get_declaration(wall) print(declaration.name()) # IfcWall print(declaration.is_abstract()) # False print(declaration.supertype().name()) # IfcBuildingElement """ return element.wrapped_data.declaration().as_entity() def is_a(declaration: ifcopenshell.ifcopenshell_wrapper.declaration, ifc_class: str) -> bool: """Checks if a schema declaration is a class :param declaration: The declaration from the schema. :param ifc_class: A case insensitive IFC class name (e.g. IfcRoot) :return: True is the declaration is of that class Example: .. code:: python wall = model.createIfcWall() declaration = ifcopenshell.util.schema.get_declaration(wall) ifcopenshell.util.schema.is_a(declaration, "IfcRoot") # True """ return declaration._is(ifc_class) def get_supertypes( declaration: ifcopenshell.ifcopenshell_wrapper.entity, ) -> list[ifcopenshell.ifcopenshell_wrapper.entity]: """Gets a list of supertype declarations :param declaration: The declaration from the schema, as an entity. :return: A list of supertypes in order from parent to grandparent. Example: .. code:: python wall = model.createIfcWall() results = ifcopenshell.util.schema.get_supertypes(wall.wrapped_data.declaration().as_entity()) # [, , ..., ] """ results = [] while True: if not (declaration := declaration.supertype()): break results.append(declaration) return results def get_subtypes( declaration: ifcopenshell.ifcopenshell_wrapper.entity, ) -> list[ifcopenshell.ifcopenshell_wrapper.entity]: """Get a flat list of subtype declarations, recursively. Abstract classes are skipped. Inconsistently, the declaration itself is also added to this list. This should be fixed exclude the declaration itself. :param declaration: The declaration from the schema, as an entity. :return: A list of subtypes in order from child to grandchild. .. code:: python schema = ifcopenshell.schema_by_name("IFC4") declaration = schema.declaration_by_name("IfcFlowSegment") print(ifcopenshell.util.schema.get_subtypes(declaration)) [, , ..., ] """ def get_classes(decl: ifcopenshell_wrapper.entity) -> list[ifcopenshell_wrapper.entity]: results: list[ifcopenshell_wrapper.entity] = [] if not decl.is_abstract(): results.append(decl) for subtype in decl.subtypes(): results.extend(get_classes(subtype)) return results return get_classes(declaration) def reassign_class( ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str ) -> ifcopenshell.entity_instance: """ Attempts to change the class (entity name) of `element` to `new_class` by removing element and recreating a similar instance of type `new_class` with the same id. In certain cases it may affect the structure of inversely related instances: - Multiple occurrences of reassigned instance within the same aggregate (such as start and end-point of polyline) - Occurrences of reassigned instance within an ordered aggregate (such as IfcRelNests) It's unlikely that this affects real-world usage of this function. :raises ValueError: If ``new_class`` does not exist in the provided file schema. """ if element.is_a() == new_class: return element if not ifc_file: ifc_file = element.file schema = ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier) try: declaration = schema.declaration_by_name(new_class) except RuntimeError: raise ValueError( f"Class of {element} could not be changed to {new_class} as the class does not exist in schema {ifc_file.schema_identifier}." ) info = element.get_info() new_attributes = {} for attribute in declaration.all_attributes(): name = attribute.name() old_attribute = info.get(name, None) if old_attribute: if ifcopenshell.util.attribute.get_primitive_type(attribute) == "enum": if old_attribute in ifcopenshell.util.attribute.get_enum_items(attribute): new_attributes[name] = old_attribute else: new_attributes[name] = old_attribute inverse_pairs = ifc_file.get_inverse(element, allow_duplicate=True, with_attribute_indices=True) ifc_file.remove(element) try: new_element = ifc_file.create_entity(new_class, id=info["id"], **new_attributes) except: print(f"Class of {element} could not be changed to {new_class}") old_class = info.pop("type") return ifc_file.create_entity(old_class, **info) for inverse_pair in inverse_pairs: inverse, index = inverse_pair if inverse[index] is None: inverse[index] = new_element elif isinstance(inverse[index], tuple): item = list(inverse[index]) item.append(new_element) inverse[index] = item return new_element class BatchReassignClass: def __init__(self, file: ifcopenshell.file): self.file = file self.purge() def reassign(self, element: ifcopenshell.entity_instance, new_class: str) -> ifcopenshell.entity_instance: try: new_element = self.file.create_entity(new_class) except: print(f"Class of {element} could not be changed to {new_class}") return element new_attributes = [new_element.attribute_name(i) for i, attribute in enumerate(new_element)] for i, attribute in enumerate(element): try: new_element[new_attributes.index(element.attribute_name(i))] = attribute except: continue for inverse_pair in self.file.get_inverse(element, allow_duplicate=True, with_attribute_indices=True): inverse, index = inverse_pair self.replacements.setdefault(inverse, {}).setdefault(index, {})[element] = new_element self.to_delete.add(element) return new_element def unbatch(self): for inverse, replacements in self.replacements.items(): for index, element_map in replacements.items(): value = inverse[index] new = inverse.walk(lambda x: True, lambda v: element_map.get(v, v), value) if value != new: inverse[index] = new for element in self.to_delete: self.file.remove(element) self.purge() def purge(self) -> None: # mapping {inverse: {attribute_index: {old_element: new_element} } } self.replacements: dict[ ifcopenshell.entity_instance, dict[int, dict[ifcopenshell.entity_instance, ifcopenshell.entity_instance]] ] = {} self.to_delete: set[ifcopenshell.entity_instance] = set() class Migrator: migrated_ids: dict[int, int] attribute_overrides: dict[int, dict[int, str]] def __init__(self): self.migrated_ids = {} self.attribute_overrides = {} self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r")) self.class_2x3_to_4 = json.load(open(os.path.join(cwd, "class_2x3_to_4.json"), "r")) # IFC classes, and their IFC attributes mapping self.attributes_mapping = { ("IFC4", "IFC2X3"): json.load(open(os.path.join(cwd, "attribute_4_to_2x3.json"), "r")), ("IFC4X3", "IFC4"): json.load(open(os.path.join(cwd, "attribute_4x3_to_4.json"), "r")), } self.default_values = { "ChangeAction": "NOCHANGE", "CompositionType": "ELEMENT", "CrossSectionArea": 1, "DataValue": 0, "DefinedValues": [0], "DefiningValues": [0], "DestabilizingLoad": False, "Edition": "", "EndParam": 1.0, "EnumerationValues": [0], "GeodeticDatum": "", "Intent": "", "IsHeading": False, "ListValues": [0], "LongitudinalBarCrossSectionArea": 1, "LongitudinalBarNominalDiameter": 1, "LongitudinalBarSpacing": 1, "Name": "", "NominalDiameter": 1, "PredefinedType": "NOTDEFINED", "RowCells": [0], "SequenceType": "NOTDEFINED", "Source": "", "StartParam": 0.0, "TransverseBarCrossSectionArea": 1, "TransverseBarNominalDiameter": 1, "TransverseBarSpacing": 1, # Manual additions from experience "InteriorOrExteriorSpace": "NOTDEFINED", "AssemblyPlace": "NOTDEFINED", # See bug https://github.com/Autodesk/revit-ifc/issues/395 } self.default_entities = { "CurrentValue": None, "DepreciatedValue": None, "Jurisdiction": None, "OriginalValue": None, "Owner": None, "OwnerHistory": None, "Position": None, "PropertyReference": None, "RepresentationContexts": None, "ResponsiblePerson": None, "ResponsiblePersons": None, "Rows": None, "TotalReplacementCost": None, "UnitsInContext": None, "User": None, } def preprocess(self, old_file: ifcopenshell.file, new_file: ifcopenshell.file) -> None: new_file.assign_header_from(old_file) to_delete = set() if old_file.schema == "IFC2X3" and new_file.schema == "IFC4": # IfcCalendarDate is deprecated in IFC4 for element in old_file.by_type("IfcCalendarDate"): for inverse, attribute_index in old_file.get_inverse( element, allow_duplicate=True, with_attribute_indices=True ): self.attribute_overrides.setdefault(inverse.id(), {})[ attribute_index ] = f"{element[2]}-{element[1]}-{element[0]}" to_delete.add(element) if old_file.schema == "IFC4" and new_file.schema == "IFC4X3": # IfcPresentationStyleAssignment is deprecated for assignment in old_file.by_type("IfcPresentationStyleAssignment"): for styled_item in old_file.get_inverse(assignment): if not styled_item.is_a("IfcStyledItem"): continue styled_item.Styles = [s for s in styled_item.Styles if s.is_a("IfcPresentationStyle")] + list( assignment.Styles ) to_delete.add(assignment) for element in to_delete: old_file.remove(element) def migrate( self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file ) -> ifcopenshell.entity_instance: if element.id() == 0: ifc_class = element.is_a() if ifc_class == "IfcCountMeasure" and new_file.schema == "IFC4X3": value = element.wrappedValue if isinstance(value, float): ifc_class = "IfcNumericMeasure" return new_file.create_entity(ifc_class, element.wrappedValue) try: return new_file.by_id(self.migrated_ids[element.id()]) except: pass # print("Migrating", element) schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(new_file.schema_identifier) new_element = self.migrate_class(element, new_file) # print("Migrated class from {} to {}".format(element, new_element)) new_element_schema = schema.declaration_by_name(new_element.is_a()) if not hasattr(new_element_schema, "all_attributes"): return element # The element has no attributes, so migration is done new_element = self.migrate_attributes(element, new_file, new_element, new_element_schema) self.migrated_ids[element.id()] = new_element.id() return new_element def migrate_class( self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file ) -> ifcopenshell.entity_instance: ifc_class = element.is_a() if ifc_class == "IfcQuantityCount" and new_file.schema == "IFC4X3": # 3 IfcPhysicalSimpleQuantity Value value = element[3] if isinstance(value, float): ifc_class = "IfcQuantityNumber" try: new_element = new_file.create_entity(ifc_class) except: # The element does not exist in this schema # Complex migration is not yet supported (e.g. polygonal face set to faceted brep) if new_file.schema == "IFC2X3": new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class]) elif new_file.schema == "IFC4": new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class]) return new_element def migrate_attributes( self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file, new_element: ifcopenshell.entity_instance, new_element_schema: ifcopenshell_wrapper.declaration, ) -> ifcopenshell.entity_instance: for attribute_index, value in self.attribute_overrides.get(element.id(), {}).items(): new_element[attribute_index] = value for i, attribute in enumerate(new_element_schema.all_attributes()): if new_element_schema.derived()[i]: continue self.migrate_attribute(attribute, element, new_file, new_element, new_element_schema) return new_element def find_equivalent_attribute( self, new_element: ifcopenshell.entity_instance, attribute: ifcopenshell_wrapper.attribute, element: ifcopenshell.entity_instance, attributes_mapping: dict[str, dict[str, str]], reverse_mapping: bool = False, ) -> Union[Any, None]: # print("Searching for an equivalent", element, new_element, attribute.name()) ifc_class = new_element.is_a() attr_name = attribute.name() try: if reverse_mapping: equivalent_map = attributes_mapping[ifc_class] equivalent = list(equivalent_map.keys())[list(equivalent_map.values()).index(attr_name)] else: equivalent = attributes_mapping[ifc_class][attr_name] if hasattr(element, equivalent): # print("Equivalent found", equivalent) return getattr(element, equivalent) else: return except Exception as e: if ( ifc_class == "IfcQuantityNumber" and attr_name == "NumberValue" and new_element.file.schema == "IFC4X3" and element.is_a("IfcQuantityCount") ): # 3 IfcPhysicalSimpleQuantity Value return element[3] print( "Unable to find equivalent attribute of {} to migrate from {} to {}".format( attr_name, element, new_element ) ) raise e def migrate_attribute( self, attribute: ifcopenshell_wrapper.attribute, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file, new_element: ifcopenshell.entity_instance, new_element_schema: ifcopenshell_wrapper.declaration, ) -> None: # NOTE: `attribute` is an attribute in new file schema # print("Migrating attribute", element, new_element, attribute.name()) old_file = element.wrapped_data.file if hasattr(element, attribute.name()): value = getattr(element, attribute.name()) # print("Attribute names matched", value) elif new_file.schema == "IFC2X3" and old_file.schema == "IFC4": # IFC4 to IFC2X3: We know the IFC2X3 attribute name, but not its IFC4 equivalent try: value = self.find_equivalent_attribute( new_element, attribute, element, self.attributes_mapping[("IFC4", "IFC2X3")], reverse_mapping=True ) except: # We tried our best return elif new_file.schema == "IFC4" and old_file.schema == "IFC2X3": # IFC2X3 to IFC4: We know the IFC4 attribute name, but not its IFC2X3 equivalent try: value = self.find_equivalent_attribute( new_element, attribute, element, self.attributes_mapping[("IFC4", "IFC2X3")] ) except: # We tried our best return elif new_file.schema == "IFC4X3" and old_file.schema == "IFC4": try: value = self.find_equivalent_attribute( new_element, attribute, element, self.attributes_mapping[("IFC4X3", "IFC4")] ) except: # We tried our best return elif new_file.schema == "IFC4" and old_file.schema == "IFC4X3": try: value = self.find_equivalent_attribute( new_element, attribute, element, self.attributes_mapping[("IFC4X3", "IFC4")], reverse_mapping=True ) except: # We tried our best return try: value except UnboundLocalError: print( f"Couldn't match attribute {attribute.name()} by name to migrate from {element} " f"to {new_element} and there is no special mapping to handle migration " f"from {old_file.schema} -> {new_file.schema}" ) return # print("Continuing migration of {} to migrate from {} to {}".format(attribute.name(), element, new_element)) if value is None and not attribute.optional(): value = self.generate_default_value(attribute, new_file) if value is None: print("Failed to generate default value for {} on {}".format(attribute.name(), element)) elif isinstance(value, ifcopenshell.entity_instance): value = self.migrate(value, new_file) elif isinstance(value, (list, tuple)): if value and isinstance(value[0], ifcopenshell.entity_instance): new_value = [] for item in value: new_value.append(self.migrate(item, new_file)) value = new_value if value is not None: setattr(new_element, attribute.name(), value) def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any: if attribute.name() in self.default_values: return self.default_values[attribute.name()] elif attribute.name() == "OwnerHistory": self.default_entities[attribute.name()] = new_file.create_entity( "IfcOwnerHistory", **{ "OwningUser": new_file.create_entity( "IfcPersonAndOrganization", **{ "ThePerson": new_file.create_entity("IfcPerson"), "TheOrganization": new_file.create_entity( "IfcOrganization", **{"Name": "IfcOpenShell Migrator"} ), }, ), "OwningApplication": new_file.create_entity( "IfcApplication", **{ "ApplicationDeveloper": new_file.create_entity( "IfcOrganization", **{"Name": "IfcOpenShell Migrator"} ), "Version": "Works for me", "ApplicationFullName": "IfcOpenShell Migrator", "ApplicationIdentifier": "IfcOpenShell Migrator", }, ), "ChangeAction": "NOCHANGE", "CreationDate": int(time.time()), }, ) return self.default_entities.get(attribute.name(), None)