Bonsai - merge identical IfcOrganizations, IfcApplications

Added an utility to merge organizations and application that have exactly the same data. Noticed working with different files that sometimes they have these data duplicated - I guess reasons vary (revit exporter, our bug - c58b84d), but it's nice to be able to clean up redundant data.
Though still need to add some UI to change IfcApplications, so user could also get rid of them in case if they have small differences.

Feature location - https://files.catbox.moe/pwymx1.png

@theoryshaw in file from #6784 there were 2481 identical organizations and 1234 identical applications 😅
This commit is contained in:
Andrej730
2025-06-17 17:23:47 +05:00
parent d1337cff02
commit 1063238479
3 changed files with 60 additions and 17 deletions
+13 -12
View File
@@ -42,7 +42,7 @@ from bpy_extras.io_utils import ImportHelper, ExportHelper
from pathlib import Path from pathlib import Path
from bonsai import get_debug_info, format_debug_info from bonsai import get_debug_info, format_debug_info
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from typing import get_args, Union, Any, TYPE_CHECKING from typing import get_args, Union, Any, TYPE_CHECKING, Literal, get_args
if TYPE_CHECKING: if TYPE_CHECKING:
from bonsai.bim.prop import Attribute from bonsai.bim.prop import Attribute
@@ -739,6 +739,9 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator, ExportHe
tool.Ifc.get().write(self.filepath) tool.Ifc.get().write(self.filepath)
PurgeObjectType = Literal["TYPE", "PROFILE", "STYLE", "MATERIAL", "ORGANIZATION", "APPLICATION"]
class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.purge_unused_objects" bl_idname = "bim.purge_unused_objects"
bl_label = "Purge Unused Objects" bl_label = "Purge Unused Objects"
@@ -793,23 +796,21 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "For materials currently only IfcMaterials are supported" bl_description = "For materials currently only IfcMaterials are supported"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Object Type", name="Object Type",
items=( items=((s, s.capitalize(), "") for s in get_args(PurgeObjectType)),
("TYPE", "Type", ""),
("PROFILE", "Profile", ""),
("STYLE", "Style", ""),
("MATERIAL", "Material", ""),
),
) )
if TYPE_CHECKING:
object_type: PurgeObjectType
def _execute(self, context): def _execute(self, context):
object_type: str = self.object_type object_type: str = self.object_type
if object_type in ("STYLE", "MATERIAL"): if object_type in ("PROFILE", "TYPE"):
merged_data = tool.Debug.merge_identical_objects(object_type) self.report({"ERROR"}, f"Unsupported object type {object_type}.")
else:
self.report({"ERROR"}, f"Invalid object type {object_type}.")
return {"CANCELLED"} return {"CANCELLED"}
merged_data = tool.Debug.merge_identical_objects(object_type)
plural_object_type = f"{object_type.lower()}s" plural_object_type = f"{object_type.lower()}s"
if merged_data: if merged_data:
for element_type, element_names in merged_data.items(): for element_type, element_names in merged_data.items():
@@ -634,11 +634,22 @@ class BIM_PT_purge(Panel):
layout.operator("bim.purge_unused_objects", text="Purge Unused Profiles").object_type = "PROFILE" layout.operator("bim.purge_unused_objects", text="Purge Unused Profiles").object_type = "PROFILE"
layout.operator("bim.purge_unused_objects", text="Purge Unused Types").object_type = "TYPE" layout.operator("bim.purge_unused_objects", text="Purge Unused Types").object_type = "TYPE"
layout.operator("bim.purge_unused_openings", text="Purge Unused Openings in Selected Objects") layout.operator("bim.purge_unused_openings", text="Purge Unused Openings in Selected Objects")
row = layout.row(align=True) row = layout.row(align=True)
row.label(text="Materials: ") row.label(text="Materials: ")
row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = "MATERIAL" row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = "MATERIAL"
row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = "MATERIAL" row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = "MATERIAL"
row = layout.row(align=True) row = layout.row(align=True)
row.label(text="Styles: ") row.label(text="Styles: ")
row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = "STYLE" row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = "STYLE"
row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = "STYLE" row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = "STYLE"
row = layout.row(align=True)
row.label(text="Organizations: ")
row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = "ORGANIZATION"
row = layout.row(align=True)
row.label(text="Applications: ")
row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = "APPLICATION"
+36 -5
View File
@@ -22,6 +22,7 @@ import json
import bpy import bpy
import ifcopenshell.ifcopenshell_wrapper as W import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.api.material import ifcopenshell.api.material
import ifcopenshell.api.owner
import ifcopenshell.express import ifcopenshell.express
import ifcopenshell.util.element import ifcopenshell.util.element
import ifcopenshell.util.schema import ifcopenshell.util.schema
@@ -31,7 +32,7 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from mathutils import Vector from mathutils import Vector
from collections import defaultdict from collections import defaultdict
from typing import Literal, TYPE_CHECKING from typing import Literal, TYPE_CHECKING, assert_never
from collections.abc import Iterable from collections.abc import Iterable
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -122,24 +123,38 @@ class Debug(bonsai.core.tool.Debug):
return sum(unused.values()) return sum(unused.values())
@classmethod @classmethod
def merge_identical_objects(cls, object_type: Literal["STYLE", "MATERIAL"]) -> dict[str, list[str]]: def merge_identical_objects(
cls,
object_type: Literal["STYLE", "MATERIAL", "ORGANIZATION", "APPLICATION"],
) -> dict[str, list[str]]:
"""Merge identical objects. """Merge identical objects.
Note that Styles UI (or other UI) should be updated manually after using this method. Note that Styles UI (or other UI) should be updated manually after using this method.
""" """
def get_hash(element: ifcopenshell.entity_instance) -> int: def get_hash(element: ifcopenshell.entity_instance) -> int:
return hash(json.dumps(element.get_info_2(include_identifier=False, recursive=True), sort_keys=True)) data = element.get_info_2(include_identifier=False, recursive=True)
if object_type == "APPLICATION":
# To avoid disruption let user merge organizations separately.
data["ApplicationDeveloper"] = element.ApplicationDeveloper.id()
return hash(json.dumps(data, sort_keys=True))
ifc_file = tool.Ifc.get() ifc_file = tool.Ifc.get()
merged_element_types: dict[str, list[str]] = {} merged_element_types: dict[str, list[str]] = {}
if object_type == "STYLE": if object_type == "STYLE":
declaration = tool.Ifc.schema().declaration_by_name("IfcPresentationStyle") declaration = tool.Ifc.schema().declaration_by_name("IfcPresentationStyle").as_entity()
assert declaration
element_types = [e.name() for e in ifcopenshell.util.schema.get_subtypes(declaration)] element_types = [e.name() for e in ifcopenshell.util.schema.get_subtypes(declaration)]
elif object_type == "MATERIAL": elif object_type == "MATERIAL":
# TODO: support other material types. # TODO: support other material types.
element_types = ["IfcMaterial"] element_types = ["IfcMaterial"]
elif object_type == "ORGANIZATION":
element_types = ["IfcOrganization"]
elif object_type == "APPLICATION":
element_types = ["IfcApplication"]
else:
assert_never(object_type)
for element_type in element_types: for element_type in element_types:
elements = ifc_file.by_type(element_type, include_subtypes=False) elements = ifc_file.by_type(element_type, include_subtypes=False)
@@ -148,7 +163,8 @@ class Debug(bonsai.core.tool.Debug):
hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list) hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list)
for element in elements: for element in elements:
# Except for styles, ignore unnamed elements as they may be not safe to merge # Except for styles, ignore unnamed elements as they may be not safe to merge
if object_type != "STYLE" and not element.Name: not_optional_name = ("APPLICATION", "ORGANIZATION")
if object_type != "STYLE" and object_type not in not_optional_name and not element.Name:
continue continue
element_hash = get_hash(element) element_hash = get_hash(element)
hash_to_elements[element_hash].append(element) hash_to_elements[element_hash].append(element)
@@ -178,6 +194,21 @@ class Debug(bonsai.core.tool.Debug):
merged_elements_names.append(material.Name) merged_elements_names.append(material.Name)
ifcopenshell.api.material.remove_material(ifc_file, material) ifcopenshell.api.material.remove_material(ifc_file, material)
elif object_type == "ORGANIZATION":
for organization in elements[1:]:
ifcopenshell.util.element.replace_element(organization, main_element)
merged_elements_names.append(organization.Name)
ifcopenshell.api.owner.remove_organisation(ifc_file, organization)
elif object_type == "APPLICATION":
for application in elements[1:]:
ifcopenshell.util.element.replace_element(application, main_element)
merged_elements_names.append(application.ApplicationFullName)
ifcopenshell.api.owner.remove_application(ifc_file, application)
else:
assert_never(object_type)
if merged_elements_names: if merged_elements_names:
merged_element_types[element_type] = merged_elements_names merged_element_types[element_type] = merged_elements_names
return merged_element_types return merged_element_types