From a2ec5756188d13605151001945f1807df5edaf1a Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 16:53:46 -0500 Subject: [PATCH 1/5] 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 33b2d7672b..e6d3b08472 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1519,7 +1519,7 @@ class SelectSimilar(Operator): 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 @@ class SelectSimilar(Operator): 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 20aa3ff94ee9942369efd1c0c4b358d1ff51ac36 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 16:58:01 -0500 Subject: [PATCH 2/5] 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 e6d3b08472..ff458bd5ac 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1285,7 +1285,6 @@ class SelectIfcClass(Operator): 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 @@ class SelectIfcClass(Operator): 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 a14b02d32f9182d493af197770900ef3d167c747 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 17:07:38 -0500 Subject: [PATCH 3/5] 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 86614a6a79..a95365b24e 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -299,19 +299,33 @@ class SelectSimilarContainer(bpy.types.Operator): 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 2a91d212b3a2be5d8ab4584e27086b331132bb8f Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 17:12:22 -0500 Subject: [PATCH 4/5] 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 84fd4250b5..8bb658f274 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -282,19 +282,21 @@ class BIM_OT_select_aggregate(bpy.types.Operator): 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 7e3a9e4a75fa88d6c11092a58a79e1c6bdd69398 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 15 Apr 2026 18:11:41 -0500 Subject: [PATCH 5/5] 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 f4c983928e..0801102fa8 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -71,20 +71,98 @@ class SelectByMaterial(bpy.types.Operator): 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) - # copy selection query to clipboard - if material.is_a("IfcMaterialLayerSet"): - material_name = material.LayerSetName - else: - material_name = material.Name - result = f'material="{material_name}"' + 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"} + + 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"