diff --git a/src/bonsai/bonsai/bim/module/debug/__init__.py b/src/bonsai/bonsai/bim/module/debug/__init__.py index 1e19ff678a..23fd17eea5 100644 --- a/src/bonsai/bonsai/bim/module/debug/__init__.py +++ b/src/bonsai/bonsai/bim/module/debug/__init__.py @@ -27,6 +27,7 @@ classes = ( operator.DebugActiveDrawing, operator.InspectFromObject, operator.InspectFromStepId, + operator.MergeIdenticalObjects, operator.OverrideDisplayType, operator.ParseExpress, operator.PipInstall, diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index b967f8b332..4a276216ca 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -688,6 +688,52 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.load_materials() +class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.merge_identical_objects" + bl_label = "Merge Identical Objects" + bl_options = {"REGISTER", "UNDO"} + + object_type: bpy.props.EnumProperty( + name="Object Type", + items=( + ("TYPE", "Type", ""), + ("PROFILE", "Profile", ""), + ("STYLE", "Style", ""), + ("MATERIAL", "Material", ""), + ), + ) + + def _execute(self, context): + object_type = self.object_type + if object_type == "STYLE": + merged_data = tool.Debug.merge_identical_objects("style") + if merged_data: + print("Merged styles:") + for style_type, style_names in merged_data.items(): + print(f"- {style_type}: {', '.join(style_names)}") + merged = sum(len(v) for v in merged_data.values()) + else: + self.report({"ERROR"}, f"Invalid object type {object_type}.") + return {"CANCELLED"} + + msg = " See system console for details." if merged else "" + self.report({"INFO"}, f"{merged} identical {object_type.lower()}s were merged.{msg}") + + if merged == 0: + return + + scene = context.scene + if object_type == "PROFILE": + if scene.BIMProfileProperties.is_editing: + bpy.ops.bim.load_profiles() + elif object_type == "STYLE": + if scene.BIMStylesProperties.is_editing: + bpy.ops.bim.load_styles() + elif object_type == "MATERIAL": + if scene.BIMMaterialProperties.is_editing: + bpy.ops.bim.load_materials() + + class PipInstall(bpy.types.Operator): bl_idname = "bim.pip_install" bl_label = "Pip Install" diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index ac0e3341ff..a964115f6e 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -525,4 +525,7 @@ 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_objects", text="Purge Unused Materials").object_type = "MATERIAL" - layout.operator("bim.purge_unused_objects", text="Purge Unused Styles").object_type = "STYLE" + 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" diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 972ea37b08..57c6965d58 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -17,16 +17,20 @@ # along with Bonsai. If not, see . import os +import json import bpy import ifcopenshell.express import ifcopenshell.express.schema import ifcopenshell.express.schema_class import ifcopenshell.util.element +import ifcopenshell.util.schema +import bonsai.core.style import bonsai.core.tool import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from mathutils import Vector -from typing import Iterable +from collections import defaultdict +from typing import Iterable, Literal class Debug(bonsai.core.tool.Debug): @@ -98,3 +102,54 @@ class Debug(bonsai.core.tool.Debug): print(f"{class_string: <50} {unused[ifc_class]: >5}") return sum(unused.values()) + + @classmethod + def merge_identical_objects(cls, object_type: Literal["style"]) -> 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)) + + ifc_file = tool.Ifc.get() + + if object_type == "style": + declaration = tool.Ifc.schema().declaration_by_name("IfcPresentationStyle") + merged_element_types: dict[str, list[str]] = {} + element_types = [e.name() for e in ifcopenshell.util.schema.get_subtypes(declaration)] + for element_type in element_types: + elements = ifc_file.by_type(element_type) + + # Calculate hashes. + hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list) + for element in elements: + # Ignore unnamed styles as they may be not safe to merge. + if not element.Name: + continue + element_hash = get_hash(element) + hash_to_elements[element_hash].append(element) + + merged_elements_names: list[str] = [] + # Merge styles. + for elements in hash_to_elements.values(): + if len(elements) == 1: + continue + + main_element = elements[0] + if object_type == "style": + main_style_obj = tool.Ifc.get_object(main_element) + for style in elements[1:]: + ifcopenshell.util.element.replace_element(style, main_element) + style_obj = tool.Ifc.get_object(style) + # Only for surface styles. + if style_obj: + assert main_style_obj + style_obj.user_remap(main_style_obj) + merged_elements_names.append(style.Name) + bonsai.core.style.remove_style(tool.Ifc, tool.Style, style, reload_styles_ui=False) + + if merged_elements_names: + merged_element_types[element_type] = merged_elements_names + return merged_element_types diff --git a/src/bonsai/test/tool/test_debug.py b/src/bonsai/test/tool/test_debug.py index 582b1a63ef..cb5db3a1f7 100644 --- a/src/bonsai/test/tool/test_debug.py +++ b/src/bonsai/test/tool/test_debug.py @@ -19,6 +19,7 @@ import os import bpy import ifcopenshell +import ifcopenshell.util.schema import bonsai.core.tool import bonsai.tool as tool from test.bim.bootstrap import NewFile @@ -66,3 +67,23 @@ class TestPurgeHdf5Cache(NewFile): # On Unix loaded files are not locked. paths = [loaded_file_path] if os.name == "nt" else [] assert [f for f in cache_dir.iterdir() if f.suffix == ".h5"] == paths + + +class TestMergeIdenticalObject(NewFile): + def test_merge_identical_styles(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + declaration = tool.Ifc.schema().declaration_by_name("IfcPresentationStyle") + style_types = [d.name() for d in ifcopenshell.util.schema.get_subtypes(declaration)] + for style_type in style_types: + ifc.create_entity(style_type, Name=style_type) + ifc.create_entity(style_type, Name=style_type) + ifc.create_entity(style_type, Name="NotToMerge") + if style_type == "IfcSurfaceStyle": + for style in ifc.by_type(style_type): + style.Styles = (ifc.create_entity("IfcSurfaceStyleShading"),) + elif style_type == "IfcFillAreaStyle": + for style in ifc.by_type(style_type): + style.FillStyles = (ifc.create_entity("IfcFillAreaStyleHatching"),) + + merge_data = subject.merge_identical_objects("style") + assert merge_data == {style_type: [style_type] for style_type in style_types}