diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 98bc6127845..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 = [] @@ -407,13 +409,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 +453,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"} diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 7587a7e4ed4..0801102fa83 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -61,25 +61,108 @@ 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) + # 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" diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index d5a6b9b1e66..ff458bd5ac3 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): @@ -1283,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 ( @@ -1292,15 +1293,15 @@ 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 - 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"} @@ -1451,10 +1452,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 @@ -1481,6 +1483,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): @@ -1512,7 +1515,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"} @@ -1538,11 +1541,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 @@ -1557,17 +1564,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.") diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index f1eb4ec4ee9..a95365b24e9 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -284,23 +284,48 @@ 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"} + container: bpy.props.IntProperty(default=0) 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): - core.select_similar_container( - tool.Ifc, - tool.Spatial, - obj=context.active_object, - is_recursive=self.is_recursive, - ) + if self.container: + # 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"} + + 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"} @@ -425,24 +450,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() @@ -525,6 +555,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"} 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/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 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/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: diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 5086a8a8722..3c0203b39ce 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -120,14 +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)) + 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: 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)