From f327a999d0c8ea1a82cf78608cd10d72fa814ba5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 12 Mar 2026 14:00:08 -0500 Subject: [PATCH 01/14] Closes #7780 - Add ALT+Click unhide to select_ifc_class Clear hide_viewport and hide_set on matched objects when the operator is invoked with Alt held, so hidden objects of the target IFC class are revealed and selected. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/search/operator.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index d5a6b9b1e66..b7dd669157c 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1264,15 +1264,17 @@ def execute(self, context): class SelectIfcClass(Operator): - """Click to select all objects that match with the given IFC class\nSHIFT + Click to also match Predefined Type""" + """Click to select all objects that match with the given IFC class\nSHIFT + Click to also match Predefined Type\nALT + Click to also unhide hidden objects (viewport and local hide)""" bl_idname = "bim.select_ifc_class" bl_label = "Select IFC Class" bl_options = {"REGISTER", "UNDO"} should_filter_predefined_type: BoolProperty(default=False) + should_unhide: BoolProperty(default=False) def invoke(self, context, event): self.should_filter_predefined_type = event.shift + self.should_unhide = event.alt return self.execute(context) def execute(self, context): @@ -1292,6 +1294,9 @@ def execute(self, context): ): continue if obj := tool.Ifc.get_object(element): + if self.should_unhide: + obj.hide_viewport = False + obj.hide_set(False) tool.Blender.select_object(obj) # copy selection query to clipboard From 1ce62c948c2ae303ca40b5ef948db15c5ff34dc5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 12:30:11 -0500 Subject: [PATCH 02/14] Add ALT+Click unhide to select_similar ALT+Click now unhides hidden objects (viewport and local hide) before selecting, matching the same behavior added to select_ifc_class. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/search/operator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index b7dd669157c..33b2d7672be 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1456,10 +1456,11 @@ class SelectSimilar(Operator): ) calculated_sum: bpy.props.FloatProperty(name="Calculated Sum", default=0.0) remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) @classmethod def description(cls, context, properties): - base = "Select objects with a similar value\n\n" "SHIFT+CLICK remove from selection set." + base = "Select objects with a similar value\n\nSHIFT+CLICK remove from selection set.\nALT+CLICK also unhide hidden objects (viewport and local hide)." key = getattr(properties, "key", None) active = context.active_object @@ -1486,6 +1487,7 @@ def poll(cls, context): def invoke(self, context, event): self.calculate_sum = event.ctrl and event.type == "LEFTMOUSE" self.remove_from_selection = event.shift and event.type == "LEFTMOUSE" + self.should_unhide = event.alt return self.execute(context) def execute(self, context): @@ -1543,11 +1545,15 @@ def _compare_values(self, val1, val2, tolerance): def _select_objects(self, context, key, reference_values, tolerance): count = 0 - for obj in context.visible_objects: + objects = context.scene.objects if self.should_unhide else context.visible_objects + for obj in objects: obj_value = self._get_value(obj, key) if obj_value is None: continue if any(self._compare_values(obj_value, ref_value, tolerance) for ref_value in reference_values): + if self.should_unhide: + obj.hide_viewport = False + obj.hide_set(False) obj.select_set(not self.remove_from_selection) count += 1 return count From ea27b4f3aec7e9583f12ddbbdf24d89f519d6d5e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 12:33:51 -0500 Subject: [PATCH 03/14] Add ALT+Click unhide to select_similar_type ALT+Click now unhides hidden objects (viewport and local hide) before selecting, matching the behavior added to select_ifc_class and select_similar. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/type/operator.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 4a6ce053fc8..868aa0c9907 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -221,10 +221,17 @@ def find_collection_in_ifcproject(self, context, collection_name): class SelectSimilarType(bpy.types.Operator): + """Select Similar Type\nALT+Click to also unhide hidden objects (viewport and local hide)""" + bl_idname = "bim.select_similar_type" bl_label = "Select Similar Type" bl_options = {"REGISTER", "UNDO"} related_object: bpy.props.StringProperty() + should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + self.should_unhide = event.alt + return self.execute(context) def execute(self, context): self.file = tool.Ifc.get() @@ -246,7 +253,10 @@ def execute(self, context): for element in related_objects: obj = tool.Ifc.get_object(element) - if obj and obj in context.visible_objects: + if obj and (self.should_unhide or obj in context.visible_objects): + if self.should_unhide: + obj.hide_viewport = False + obj.hide_set(False) obj.select_set(True) # copy selection query to clipboard From 873f9f061f8a96d9831f109f3bddd09086546526 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 18:54:43 -0500 Subject: [PATCH 04/14] Add ALT+Click unhide to select_similar_container ALT+Click now unhides hidden objects (viewport and local hide) before selecting. Also fixes select_products to set hide_viewport in addition to hide_set when unhiding. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/spatial/operator.py | 5 ++++- src/bonsai/bonsai/core/spatial.py | 5 ++++- src/bonsai/bonsai/tool/spatial.py | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index f1eb4ec4ee9..0a1b5134ddd 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -284,14 +284,16 @@ def execute(self, context): class SelectSimilarContainer(bpy.types.Operator): bl_idname = "bim.select_similar_container" bl_label = "Select Similar Container" - bl_description = "Recurvisevly selects all objects in the container.\n\nCtrl+click to select only one level deep" + bl_description = "Recursively selects all objects in the container.\n\nCtrl+click to select only one level deep\nAlt+click to also unhide hidden objects (viewport and local hide)" bl_options = {"REGISTER", "UNDO"} is_recursive: bpy.props.BoolProperty(default=True) + should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) def invoke(self, context, event): if event.type == "LEFTMOUSE" and event.ctrl: self.is_recursive = False + self.should_unhide = event.alt return self.execute(context) def execute(self, context): @@ -300,6 +302,7 @@ def execute(self, context): tool.Spatial, obj=context.active_object, is_recursive=self.is_recursive, + should_unhide=self.should_unhide, ) self.is_recursive = True # <-- forcibly reset return {"FINISHED"} diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 5086a8a8722..56c07f5e24a 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -124,10 +124,13 @@ def select_similar_container( spatial: type[tool.Spatial], obj: bpy.types.Object, is_recursive: bool = True, + should_unhide: bool = False, ) -> None: element = ifc.get_entity(obj) if element: - spatial.select_products(spatial.get_decomposed_elements(spatial.get_container(element), is_recursive)) + spatial.select_products( + spatial.get_decomposed_elements(spatial.get_container(element), is_recursive), unhide=should_unhide + ) def select_product(spatial: type[tool.Spatial], product: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index b185b9ab596..f69ed31dbc3 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -200,6 +200,7 @@ def select_products(cls, products: Iterable[ifcopenshell.entity_instance], unhid obj = tool.Ifc.get_object(product) if obj and view_layer.objects.get(obj.name): if unhide: + obj.hide_viewport = False obj.hide_set(False) obj.select_set(True) From cd458780e642b44b1a5a80d9b1c6dd26c54fc9ce Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 19:00:47 -0500 Subject: [PATCH 05/14] Add ALT+Click unhide to select_by_material ALT+Click now unhides hidden objects (viewport and local hide) before selecting, matching the behavior added to other select operators. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/material/operator.py | 9 +++++++-- src/bonsai/bonsai/core/material.py | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 7587a7e4ed4..f4c983928e2 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -61,13 +61,18 @@ def execute(self, context): class SelectByMaterial(bpy.types.Operator): bl_idname = "bim.select_by_material" bl_label = "Select By Material" - bl_description = "Select objects using the provided material" + bl_description = "Select objects using the provided material\n\nALT+Click to also unhide hidden objects (viewport and local hide)" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() + should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + self.should_unhide = event.alt + return self.execute(context) def execute(self, context): material = tool.Ifc.get().by_id(self.material) - core.select_by_material(tool.Material, tool.Spatial, material=material) + core.select_by_material(tool.Material, tool.Spatial, material=material, should_unhide=self.should_unhide) # copy selection query to clipboard if material.is_a("IfcMaterialLayerSet"): diff --git a/src/bonsai/bonsai/core/material.py b/src/bonsai/bonsai/core/material.py index 0a08f5e0bf5..4aea28d93d4 100644 --- a/src/bonsai/bonsai/core/material.py +++ b/src/bonsai/bonsai/core/material.py @@ -82,9 +82,12 @@ def disable_editing_materials(material: type[tool.Material]) -> None: def select_by_material( - material_tool: type[tool.Material], spatial: type[tool.Spatial], material: ifcopenshell.entity_instance + material_tool: type[tool.Material], + spatial: type[tool.Spatial], + material: ifcopenshell.entity_instance, + should_unhide: bool = False, ) -> None: - spatial.select_products(material_tool.get_elements_by_material(material)) + spatial.select_products(material_tool.get_elements_by_material(material), unhide=should_unhide) def enable_editing_material(material_tool: type[tool.Material], material: ifcopenshell.entity_instance) -> None: From 011fb6660ac36607a7e9685d9e99937aaa1b95bf Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 19:06:04 -0500 Subject: [PATCH 06/14] Add ALT+Click unhide to select_linked_aggregates ALT+Click now unhides hidden objects (viewport and local hide) before selecting, matching the behavior added to other select operators. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/aggregate/operator.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 98bc6127845..91e438f2ffa 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -407,13 +407,18 @@ class BIM_OT_select_linked_aggregates(bpy.types.Operator): bl_label = "Select linked aggregates" bl_options = {"REGISTER", "UNDO"} select_parts: bpy.props.BoolProperty(default=False) + should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) @classmethod def description(cls, context, properties): if properties.select_parts: - return "Select all aggregates, subaggregates and all their parts" + return "Select all aggregates, subaggregates and all their parts\n\nALT+Click to also unhide hidden objects (viewport and local hide)" else: - return "Select all aggregates" + return "Select all aggregates\n\nALT+Click to also unhide hidden objects (viewport and local hide)" + + def invoke(self, context, event): + self.should_unhide = event.alt + return self.execute(context) def execute(self, context): for obj in context.selected_objects: @@ -446,11 +451,18 @@ def execute(self, context): for element in parts_objs: obj = tool.Ifc.get_object(element) if obj: + if self.should_unhide: + obj.hide_viewport = False + obj.hide_set(False) obj.select_set(True) else: for element in parts: obj = tool.Ifc.get_object(element) - obj.select_set(True) + if obj: + if self.should_unhide: + obj.hide_viewport = False + obj.hide_set(False) + obj.select_set(True) return {"FINISHED"} From 15228ac8b598666a61755d32eb6ed672457732e7 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 19:20:43 -0500 Subject: [PATCH 07/14] Add ALT+Click unhide to select_decomposed_elements Moves "select all listed elements" from ALT to SHIFT, freeing ALT+Click to unhide hidden objects (viewport and local hide) before selecting. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/spatial/operator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 0a1b5134ddd..7d4a3f6d6e9 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -428,24 +428,29 @@ class SelectDecomposedElements(bpy.types.Operator): should_filter: bpy.props.BoolProperty(name="Should Filter", default=True, options={"SKIP_SAVE"}) container: bpy.props.IntProperty() is_recursive: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) @classmethod def description(cls, context, operator): return ( "Select the active item" - + "\nALT+CLICK to select all listed elements.\nCTRL + CLICK to select only one level deep" + + "\nSHIFT+CLICK to select all listed elements.\nCTRL+CLICK to select only one level deep" + + "\nALT+CLICK to also unhide hidden objects (viewport and local hide)" ) def invoke(self, context, event): if event.type == "LEFTMOUSE": - if event.alt: + if event.shift: self.should_filter = False if event.ctrl: self.is_recursive = False + self.should_unhide = event.alt return self.execute(context) def execute(self, context): - tool.Spatial.select_products(tool.Spatial.get_filtered_elements(self.should_filter, self.is_recursive)) + tool.Spatial.select_products( + tool.Spatial.get_filtered_elements(self.should_filter, self.is_recursive), unhide=self.should_unhide + ) # Make selected active element in list, the active object props = tool.Spatial.get_spatial_props() From e3d5e1d1fef9c026e6461c2c1ff450e5cb31afc5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 19:38:57 -0500 Subject: [PATCH 08/14] Unhide objects when showing container visibility When mode is SHOW or ISOLATE, also unhide individual objects (hide_viewport and hide_set) within each container, not just the collection-level visibility. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/spatial/operator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 7d4a3f6d6e9..f127040b29b 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -533,6 +533,11 @@ def execute(self, context): if obj := tool.Ifc.get_object(container): if collection := tool.Blender.get_object_bim_props(obj).collection: collection.hide_viewport = should_hide + if not should_hide: + for element in tool.Spatial.get_decomposed_elements(container, is_recursive=False): + if element_obj := tool.Ifc.get_object(element): + element_obj.hide_viewport = False + element_obj.hide_set(False) if self.should_include_children: queue.extend(ifcopenshell.util.element.get_parts(container)) return {"FINISHED"} From 2ad30a00d72fab0ce59c103539dd357eed1d697c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 20 Mar 2026 20:06:53 -0500 Subject: [PATCH 09/14] Add container tools to spatial decomposition panel - Add bim.select_similar_container button to the spatial decomposition panel, gated behind the new show_container_tools preference (renamed from container_hide_show_isolate) - Fix select_similar_container to resolve container from the active list item rather than the active object - Unhide individual objects when showing container visibility (set_container_visibility SHOW/ISOLATE) Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/spatial/operator.py | 12 ++++++++++-- src/bonsai/bonsai/bim/module/spatial/ui.py | 7 +++++-- src/bonsai/bonsai/bim/ui.py | 10 +++++----- src/bonsai/bonsai/core/spatial.py | 9 ++------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index f127040b29b..86614a6a798 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -287,6 +287,7 @@ class SelectSimilarContainer(bpy.types.Operator): bl_description = "Recursively selects all objects in the container.\n\nCtrl+click to select only one level deep\nAlt+click to also unhide hidden objects (viewport and local hide)" bl_options = {"REGISTER", "UNDO"} + container: bpy.props.IntProperty(default=0) is_recursive: bpy.props.BoolProperty(default=True) should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) @@ -297,10 +298,17 @@ def invoke(self, context, event): return self.execute(context) def execute(self, context): + if self.container: + container = tool.Ifc.get().by_id(self.container) + elif element := tool.Ifc.get_entity(context.active_object): + container = ifcopenshell.util.element.get_container(element) + else: + return {"CANCELLED"} + if not container: + return {"CANCELLED"} core.select_similar_container( - tool.Ifc, tool.Spatial, - obj=context.active_object, + container=container, is_recursive=self.is_recursive, should_unhide=self.should_unhide, ) diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 3a4a771e018..051fcaab779 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -139,7 +139,7 @@ def draw(self, context): op = col.operator("bim.set_default_container", icon="OUTLINER_COLLECTION", text="Set Default") op.container = ifc_definition_id - if tool.Blender.get_addon_preferences().container_hide_show_isolate: + if tool.Blender.get_addon_preferences().show_container_tools: op = row.operator("bim.set_container_visibility", icon="FULLSCREEN_EXIT", text="") op.mode = "ISOLATE" op.container = ifc_definition_id @@ -152,7 +152,10 @@ def draw(self, context): # The only operator that's enabled for IfcProject. col = row.column(align=True) - col.operator("bim.select_container", icon="OBJECT_DATA", text="").container = ifc_definition_id + row_ = col.row(align=True) + row_.operator("bim.select_container", icon="OBJECT_DATA", text="").container = ifc_definition_id + if tool.Blender.get_addon_preferences().show_container_tools: + row_.operator("bim.select_similar_container", icon="RESTRICT_SELECT_OFF", text="").container = ifc_definition_id col = row.column(align=True) op = col.operator("bim.delete_container", icon="X", text="") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 09eee514017..ff904fdcdd3 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -714,9 +714,9 @@ def update_cache_dir(self, context: bpy.types.Context) -> None: description="Default parameters for BIM elements", ) - container_hide_show_isolate: BoolProperty( - name="Container hide/show/isolate", - description="Enable container hide/show/isolate feature in the UI", + show_container_tools: BoolProperty( + name="Show Container Tools (Select, Hide, Show, Isolate)", + description="Enable container select, hide/show/isolate tools in the UI", default=False, ) @@ -786,7 +786,7 @@ def update_cache_dir(self, context: bpy.types.Context) -> None: pset_dir: str doc: DocPreferences default_parameters: DefaultParameters - container_hide_show_isolate: bool + show_container_tools: bool chain_filter_with_set_operations: bool save_metadata_blend_file: bool metadata_blend_file_suffix: str @@ -984,7 +984,7 @@ def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Con layout.prop(self, "bsdd_baseurl") def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - layout.prop(self, "container_hide_show_isolate") + layout.prop(self, "show_container_tools") row = layout.row(align=True) row.prop(self, "chain_filter_with_set_operations") row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270" diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 56c07f5e24a..3c0203b39ce 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -120,17 +120,12 @@ def select_container( def select_similar_container( - ifc: type[tool.Ifc], spatial: type[tool.Spatial], - obj: bpy.types.Object, + container: ifcopenshell.entity_instance, is_recursive: bool = True, should_unhide: bool = False, ) -> None: - element = ifc.get_entity(obj) - if element: - spatial.select_products( - spatial.get_decomposed_elements(spatial.get_container(element), is_recursive), unhide=should_unhide - ) + spatial.select_products(spatial.get_decomposed_elements(container, is_recursive), unhide=should_unhide) def select_product(spatial: type[tool.Spatial], product: ifcopenshell.entity_instance) -> None: From 88daa83217054e65724f8eed19a7638678160a21 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 16:53:46 -0500 Subject: [PATCH 10/14] Concatenate all selected values in select_similar When multiple objects are selected, _generate_clipboard_query previously only used the first reference value. Now all values are joined with " + " so the clipboard query reflects every selected object (e.g. GlobalId = "A" + GlobalId = "B"). Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/search/operator.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 33b2d7672be..e6d3b084725 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1519,7 +1519,7 @@ def execute(self, context): f"{verb} all objects that share the same ({self.key}) value(s) from {len(reference_values)} reference object(s).", ) - self._generate_clipboard_query(reference_values[0] if reference_values else None, key) + self._generate_clipboard_query(reference_values, key) return {"FINISHED"} @@ -1568,17 +1568,22 @@ def _calculate_sum(self, context, key): bpy.context.window_manager.clipboard = str(total) self.report({"INFO"}, f"({total}) was copied to the clipboard.") - def _generate_clipboard_query(self, value, key): + def _generate_clipboard_query(self, values, key): key = "PredefinedType" if key == "predefined_type" else key - if value is True: - value = "TRUE" - elif value is False: - value = "FALSE" + if not values: + return - if isinstance(value, list) and value: - result = ", ".join(f'{key} = "{item}"' for item in value) - else: - result = f'{key} = "{value}"' + def format_value(value): + if value is True: + return f'{key} = "TRUE"' + elif value is False: + return f'{key} = "FALSE"' + elif isinstance(value, list) and value: + return ", ".join(f'{key} = "{item}"' for item in value) + else: + return f'{key} = "{value}"' + + result = " + ".join(format_value(v) for v in values) bpy.context.window_manager.clipboard = result self.report({"INFO"}, f"({result}) was copied to the clipboard.") From 8acc238900976b905a8644e9c9e6207fac41ca40 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 16:58:01 -0500 Subject: [PATCH 11/14] Fix select_ifc_class clipboard query output Previously the clipboard was set inside the class loop, overwriting on each iteration and reporting multiple times. Now all classes are joined with " + " and the clipboard is set once after selection completes. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/search/operator.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index e6d3b084725..ff458bd5ac3 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1285,7 +1285,6 @@ def execute(self, context): if element := tool.Ifc.get_entity(obj): classes.add(element.is_a()) predefined_types.add(ifcopenshell.util.element.get_predefined_type(element)) - result = "" for cls in classes: for element in tool.Ifc.get().by_type(cls): if ( @@ -1299,13 +1298,10 @@ def execute(self, context): obj.hide_set(False) tool.Blender.select_object(obj) - # copy selection query to clipboard - if not result: - result = f"{cls}" - else: - result += f", {cls}" - bpy.context.window_manager.clipboard = result - self.report({"INFO"}, f"({result}) was copied to the clipboard.") + # copy selection query to clipboard + result = " + ".join(classes) + bpy.context.window_manager.clipboard = result + self.report({"INFO"}, f"({result}) was copied to the clipboard.") return {"FINISHED"} From 51edb20adb448f9c6f213dc78cc661bf4da815bb Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 17:07:38 -0500 Subject: [PATCH 12/14] Select from multiple containers in select_similar_container Previously only the active object's container was used. Now all selected objects' containers are collected and their decomposed elements selected, with the query copied to the clipboard as location = "A" + location = "B". Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/spatial/operator.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 86614a6a798..a95365b24e9 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -299,19 +299,33 @@ def invoke(self, context, event): def execute(self, context): if self.container: - container = tool.Ifc.get().by_id(self.container) - elif element := tool.Ifc.get_entity(context.active_object): - container = ifcopenshell.util.element.get_container(element) + # Called from container manager panel with explicit container + ifc_container = tool.Ifc.get().by_id(self.container) + containers = {ifc_container.id(): ifc_container} if ifc_container else {} else: + # Called from 3D viewport — derive containers from all selected objects + containers = {} + for obj in context.selected_objects or [context.active_object]: + element = tool.Ifc.get_entity(obj) + if not element: + continue + container = tool.Spatial.get_container(element) + if container: + containers[container.id()] = container + + if not containers: return {"CANCELLED"} - if not container: - return {"CANCELLED"} - core.select_similar_container( - tool.Spatial, - container=container, - is_recursive=self.is_recursive, - should_unhide=self.should_unhide, - ) + + for container in containers.values(): + tool.Spatial.select_products( + tool.Spatial.get_decomposed_elements(container, self.is_recursive), + unhide=self.should_unhide, + ) + + result = " + ".join(f'location = "{c.Name}"' for c in containers.values()) + bpy.context.window_manager.clipboard = result + self.report({"INFO"}, f"({result}) was copied to the clipboard.") + self.is_recursive = True # <-- forcibly reset return {"FINISHED"} From 9ac1f6115827b8c82eb23087c82f35a8d6c7bf7a Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 17:12:22 -0500 Subject: [PATCH 13/14] Deduplicate aggregates in select_aggregate clipboard query All selected objects sharing the same aggregate would produce duplicate entries in the clipboard query. Aggregates are now collected into a dict keyed by id before selection and query generation. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/aggregate/operator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 91e438f2ffa..11009377434 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -280,19 +280,21 @@ def invoke(self, context, event): return self.execute(context) def execute(self, context): - all_parts = [] + aggregates = {} for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) if element: aggregate = ifcopenshell.util.element.get_aggregate(element) if aggregate: - all_parts.append(aggregate) + aggregates[aggregate.id()] = aggregate obj.select_set(False) else: pass if not element: obj.select_set(False) + all_parts = list(aggregates.values()) + if self.select_parts: selected_parts = [] From 9de86613940e7040745806d82f47aa9abe922695 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 18:11:41 -0500 Subject: [PATCH 14/14] Derive materials from selected objects in select_by_material Previously only the explicit material prop was used. Now all selected objects' materials are collected, with layer set usages resolved to the specific layer index matching the clicked material. Results are joined with " + " in the clipboard query. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/material/operator.py | 94 +++++++++++++++++-- 1 file changed, 86 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index f4c983928e2..0801102fa83 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -71,20 +71,98 @@ def invoke(self, context, event): return self.execute(context) def execute(self, context): - material = tool.Ifc.get().by_id(self.material) - core.select_by_material(tool.Material, tool.Spatial, material=material, should_unhide=self.should_unhide) + # Determine the layer index hint from the explicit material prop, if any. + # When the user clicks a specific layer in the UI, self.material is that + # layer's IfcMaterial. We find its index so we can pull the same layer + # from every other selected object's layer set. + layer_index = None + if self.material: + ref_mat = tool.Ifc.get().by_id(self.material) + layer_index = self._get_layer_index(ref_mat) + + materials = {} + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element: + continue + mat = ifcopenshell.util.element.get_material(element) + if not mat: + continue + + resolved = self._resolve_material(mat, layer_index) + if resolved: + materials[resolved.id()] = resolved + + # Fall back to the explicit material prop if selection yields nothing + if not materials and self.material: + materials = {self.material: tool.Ifc.get().by_id(self.material)} + + if not materials: + return {"FINISHED"} - # copy selection query to clipboard - if material.is_a("IfcMaterialLayerSet"): - material_name = material.LayerSetName - else: - material_name = material.Name - result = f'material="{material_name}"' + for mat in materials.values(): + core.select_by_material(tool.Material, tool.Spatial, material=mat, should_unhide=self.should_unhide) + + result = " + ".join(f'material = "{self._get_name(m)}"' for m in materials.values()) bpy.context.window_manager.clipboard = result self.report({"INFO"}, f"({result}) was copied to the clipboard.") return {"FINISHED"} + def _get_layer_index(self, material): + """Return the 0-based layer index if material is an IfcMaterial inside a layer set.""" + if not material.is_a("IfcMaterial"): + return None + ifc = tool.Ifc.get() + for layer in ifc.get_inverse(material): + if not layer.is_a("IfcMaterialLayer"): + continue + for layer_set in ifc.get_inverse(layer): + if not layer_set.is_a("IfcMaterialLayerSet"): + continue + layers = list(layer_set.MaterialLayers) + if layer in layers: + return layers.index(layer) + return None + + def _resolve_material(self, mat, layer_index): + """Resolve an assigned material to the specific entity to select/name by. + + When layer_index is set, drills into the layer set and returns the + IfcMaterial at that index (or None if the set has fewer layers). + Otherwise returns the layer set / profile set / constituent set itself. + """ + if mat.is_a("IfcMaterialLayerSetUsage"): + mat = mat.ForLayerSet + elif mat.is_a("IfcMaterialProfileSetUsage"): + mat = mat.ForProfileSet + + if layer_index is not None and mat.is_a("IfcMaterialLayerSet"): + layers = list(mat.MaterialLayers) + if layer_index < len(layers): + return layers[layer_index].Material + return None + + return mat + + def _get_name(self, material): + if material.is_a("IfcMaterialLayerSet"): + if material.LayerSetName: + return material.LayerSetName + names = [l.Material.Name for l in (material.MaterialLayers or []) if l.Material and l.Material.Name] + return ", ".join(names) if names else material.is_a() + if material.is_a("IfcMaterialProfileSet"): + if material.Name: + return material.Name + names = [p.Material.Name for p in (material.MaterialProfiles or []) if p.Material and p.Material.Name] + return ", ".join(names) if names else material.is_a() + if material.is_a("IfcMaterialConstituentSet"): + if material.Name: + return material.Name + names = [c.Material.Name for c in (material.MaterialConstituents or []) if c.Material and c.Material.Name] + return ", ".join(names) if names else material.is_a() + return getattr(material, "Name", None) or material.is_a() + class EnableEditingMaterial(bpy.types.Operator): bl_idname = "bim.enable_editing_material"