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 bonsai import get_debug_info, format_debug_info
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:
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)
PurgeObjectType = Literal["TYPE", "PROFILE", "STYLE", "MATERIAL", "ORGANIZATION", "APPLICATION"]
class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.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_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty(
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Object Type",
items=(
("TYPE", "Type", ""),
("PROFILE", "Profile", ""),
("STYLE", "Style", ""),
("MATERIAL", "Material", ""),
),
items=((s, s.capitalize(), "") for s in get_args(PurgeObjectType)),
)
if TYPE_CHECKING:
object_type: PurgeObjectType
def _execute(self, context):
object_type: str = self.object_type
if object_type in ("STYLE", "MATERIAL"):
merged_data = tool.Debug.merge_identical_objects(object_type)
else:
self.report({"ERROR"}, f"Invalid object type {object_type}.")
if object_type in ("PROFILE", "TYPE"):
self.report({"ERROR"}, f"Unsupported object type {object_type}.")
return {"CANCELLED"}
merged_data = tool.Debug.merge_identical_objects(object_type)
plural_object_type = f"{object_type.lower()}s"
if merged_data:
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 Types").object_type = "TYPE"
layout.operator("bim.purge_unused_openings", text="Purge Unused Openings in Selected Objects")
row = layout.row(align=True)
row.label(text="Materials: ")
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 = layout.row(align=True)
row.label(text="Styles: ")
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 = 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 ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.api.material
import ifcopenshell.api.owner
import ifcopenshell.express
import ifcopenshell.util.element
import ifcopenshell.util.schema
@@ -31,7 +32,7 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from mathutils import Vector
from collections import defaultdict
from typing import Literal, TYPE_CHECKING
from typing import Literal, TYPE_CHECKING, assert_never
from collections.abc import Iterable
if TYPE_CHECKING:
@@ -122,24 +123,38 @@ class Debug(bonsai.core.tool.Debug):
return sum(unused.values())
@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.
Note that Styles UI (or other UI) should be updated manually after using this method.
"""
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()
merged_element_types: dict[str, list[str]] = {}
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)]
elif object_type == "MATERIAL":
# TODO: support other material types.
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:
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)
for element in elements:
# 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
element_hash = get_hash(element)
hash_to_elements[element_hash].append(element)
@@ -178,6 +194,21 @@ class Debug(bonsai.core.tool.Debug):
merged_elements_names.append(material.Name)
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:
merged_element_types[element_type] = merged_elements_names
return merged_element_types