diff --git a/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md b/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md index 5ff01cabcf..6776bc5959 100644 --- a/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md +++ b/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md @@ -48,6 +48,8 @@ All of the scheme's cross-operator plumbing lives in `bonsai/bim/helper.py`: `bl_description` / `description()`, ending the casing drift; `SelectIfcClass` and `SelectSimilarType` switched from class docstrings to `bl_description` to allow composition. +- `selection_mode(remove, filter)` — maps the two operator flags to the + `select_products` mode enum (`"ADD"|"REMOVE"|"FILTER"`). - `RegexSelectMixin` — the whole regex-dialog scaffold (properties, `draw()`, compile-with-error-handling, verb/clipboard/report tail) plus two reusable strategies: `apply_regex_by_value(context, pattern, get_value)` for per-object @@ -69,10 +71,19 @@ All of the scheme's cross-operator plumbing lives in `bonsai/bim/helper.py`: clicked UI item (material, group, container row) are unaffected by this rule. - **Filter mode selects nothing new.** It computes the matched set and deselects already-selected objects outside it. Implemented centrally in - `Spatial.select_products(products, unhide=..., remove=..., filter_selection=...)` - (`tool/spatial.py`) for the UI-item operators, and as small per-operator branches - where selection is done with bespoke loops (`select_similar`, `select_ifc_class`, - `select_similar_type`, the two aggregate operators). + `Spatial.select_products(products, unhide=..., mode="ADD"|"REMOVE"|"FILTER")` + (`tool/spatial.py`); every operator except `select_similar` (whose per-value + tolerance matching stays bespoke) now collects elements and delegates to it, + computing the mode from its two flags via `helper.selection_mode()`. +- **Consolidation trade-offs** (routing `select_ifc_class`, `select_similar_type` + and the two aggregate operators through `select_products`): remove mode uses a + plain `select_set(False)` — `select_ifc_class` no longer re-anchors the active + object via `tool.Blender.deselect_object`; `select_similar_type` lost its + O(n²) `obj in context.visible_objects` gate, so like the other operators it may + latently select hidden occurrences (they appear selected when unhidden); the + aggregate parts walk uses `get_parts`/`get_decomposition` instead of a manual + `IsDecomposedBy` recursion; `select_ifc_class` and `select_similar_type` now + write the clipboard query in every mode (previously skipped in filter mode). - **CTRL = filter, CTRL+SHIFT = legacy CTRL function.** Originally implemented the other way around; swapped after review because the two selection-set operations (subtract, intersect) belong on the simple modifiers. The demoted plain-CTRL diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index b365539a6c..2fee21929a 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -90,6 +90,15 @@ def decode_select_click(event: bpy.types.Event) -> SelectClickModifiers: ) +def selection_mode(remove_from_selection: bool, filter_selection: bool) -> str: + """Map the two modifier flags to a Spatial.select_products mode.""" + if remove_from_selection: + return "REMOVE" + if filter_selection: + return "FILTER" + return "ADD" + + class RegexSelectMixin: """Scaffold for select operators offering the CTRL+ALT+Click regex-search dialog. @@ -193,12 +202,7 @@ class RegexSelectMixin: def select_regex_products(self, products: Iterable[ifcopenshell.entity_instance]) -> None: """Apply regex_mode + unhide to IFC products via Spatial.select_products.""" - tool.Spatial.select_products( - products, - unhide=self.should_unhide, - remove=self.regex_mode == "REMOVE", - filter_selection=self.regex_mode == "FILTER", - ) + tool.Spatial.select_products(products, unhide=self.should_unhide, mode=self.regex_mode) def draw_attributes( diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 9b391050ff..e9df91e10b 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -34,6 +34,7 @@ from bonsai.bim.helper import ( RegexSelectMixin, decode_select_click, select_regex_tooltip, + selection_mode, ) @@ -362,57 +363,24 @@ class BIM_OT_select_aggregate(RegexSelectMixin, bpy.types.Operator): all_parts = list(aggregates.values()) + products = set(all_parts) if self.select_parts: - selected_parts = [] + for aggregate in all_parts: + if self.one_level_deep: + products.update(ifcopenshell.util.element.get_parts(aggregate)) + else: + products.update(ifcopenshell.util.element.get_decomposition(aggregate)) - for part in all_parts: - if part.IsDecomposedBy: - for rel in part.IsDecomposedBy: - for subpart in rel.RelatedObjects: - selected_parts.append(subpart) + tool.Spatial.select_products( + products, + unhide=self.should_unhide, + mode=selection_mode(self.remove_from_selection, self.filter_selection), + ) - # If not limited to one level, traverse deeper - if not self.one_level_deep: - - def add_descendants(elem): - if elem.IsDecomposedBy: - for rel in elem.IsDecomposedBy: - for deeper in rel.RelatedObjects: - selected_parts.append(deeper) - add_descendants(deeper) - - add_descendants(subpart) - - if self.filter_selection: - matched_objs = {tool.Ifc.get_object(element) for element in set(selected_parts + all_parts)} - for obj in context.selected_objects: - if obj not in matched_objs: - obj.select_set(False) - else: - for element in set(selected_parts + all_parts): - obj = tool.Ifc.get_object(element) - if obj: - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - obj.select_set(not self.remove_from_selection) - - else: - if self.filter_selection: - matched_objs = {tool.Ifc.get_object(element) for element in all_parts} - for obj in context.selected_objects: - if obj not in matched_objs: - obj.select_set(False) - else: - for aggregate_element in all_parts: - aggregate_obj = tool.Ifc.get_object(aggregate_element) - if aggregate_obj: - if self.should_unhide: - aggregate_obj.hide_viewport = False - aggregate_obj.hide_set(False) - aggregate_obj.select_set(not self.remove_from_selection) - if not self.remove_from_selection: - bpy.context.view_layer.objects.active = aggregate_obj + if not self.select_parts and not keep_current_selection: + for aggregate_element in all_parts: + if aggregate_obj := tool.Ifc.get_object(aggregate_element): + bpy.context.view_layer.objects.active = aggregate_obj # copy selection query to clipboard result = "" @@ -521,7 +489,7 @@ class BIM_OT_select_linked_aggregates(bpy.types.Operator): objects = [context.active_object] if context.active_object else [] else: objects = context.selected_objects - matched_objs = set() + products = set() for obj in objects: if not keep_current_selection: obj.select_set(False) @@ -543,39 +511,17 @@ class BIM_OT_select_linked_aggregates(bpy.types.Operator): for group_link in group_rel: parts = list(group_link.RelatedObjects) if self.select_parts: - parts_objs = [] for part in parts: if part.IsDecomposedBy: for subpart in part.IsDecomposedBy[0].RelatedObjects: parts.append(subpart) - parts_objs.append(part) + products.update(parts) - for element in parts_objs: - obj = tool.Ifc.get_object(element) - if obj: - if self.filter_selection: - matched_objs.add(obj) - continue - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - obj.select_set(not self.remove_from_selection) - else: - for element in parts: - obj = tool.Ifc.get_object(element) - if obj: - if self.filter_selection: - matched_objs.add(obj) - continue - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - obj.select_set(not self.remove_from_selection) - - if self.filter_selection: - for obj in context.selected_objects: - if obj not in matched_objs: - obj.select_set(False) + tool.Spatial.select_products( + products, + unhide=self.should_unhide, + mode=selection_mode(self.remove_from_selection, self.filter_selection), + ) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index c24d09b7a2..b195af7b4a 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -31,6 +31,7 @@ from bonsai.bim.helper import ( RegexSelectMixin, decode_select_click, select_regex_tooltip, + selection_mode, ) @@ -252,8 +253,7 @@ class SelectGroupElements(RegexSelectMixin, bpy.types.Operator): tool.Spatial.select_products( ifcopenshell.util.element.get_grouped_by(tool.Ifc.get().by_id(self.group), is_recursive=self.is_recursive), unhide=self.should_unhide, - remove=self.remove_from_selection, - filter_selection=self.filter_selection, + mode=selection_mode(self.remove_from_selection, self.filter_selection), ) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 59a6d1f7c6..f65c3f3728 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -37,6 +37,7 @@ from bonsai.bim.helper import ( RegexSelectMixin, decode_select_click, select_regex_tooltip, + selection_mode, ) from bonsai.bim.module.model import slab, wall @@ -158,14 +159,10 @@ class SelectByMaterial(RegexSelectMixin, bpy.types.Operator): if not materials: return {"FINISHED"} + mode = selection_mode(self.remove_from_selection, self.filter_selection) for mat in materials.values(): core.select_by_material( - tool.Material, - tool.Spatial, - material=mat, - should_unhide=self.should_unhide, - remove_from_selection=self.remove_from_selection, - filter_selection=self.filter_selection, + tool.Material, tool.Spatial, material=mat, should_unhide=self.should_unhide, mode=mode ) result = " + ".join(f'material = "{self._get_name(m)}"' for m in materials.values()) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index cd9522b981..23d449ffe5 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -45,6 +45,7 @@ from bonsai.bim.helper import ( RegexSelectMixin, decode_select_click, select_regex_tooltip, + selection_mode, ) from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty @@ -1304,12 +1305,8 @@ 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)) - if self.filter_selection: - for obj in context.selected_objects: - element = tool.Ifc.get_entity(obj) - if not element or not any(element.is_a(cls) for cls in classes): - obj.select_set(False) - return {"FINISHED"} + + elements = [] for cls in classes: for element in tool.Ifc.get().by_type(cls): if ( @@ -1317,14 +1314,12 @@ class SelectIfcClass(Operator): and ifcopenshell.util.element.get_predefined_type(element) not in predefined_types ): continue - if obj := tool.Ifc.get_object(element): - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - if self.remove_from_selection: - tool.Blender.deselect_object(obj) - else: - tool.Blender.select_object(obj) + elements.append(element) + tool.Spatial.select_products( + elements, + unhide=self.should_unhide, + mode=selection_mode(self.remove_from_selection, self.filter_selection), + ) # copy selection query to clipboard result = " + ".join(classes) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 8c80045198..945c59a838 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -29,6 +29,7 @@ from bonsai.bim.helper import ( SELECT_REMOVE_TOOLTIP, SELECT_UNHIDE_TOOLTIP, decode_select_click, + selection_mode, ) @@ -337,12 +338,12 @@ class SelectSimilarContainer(bpy.types.Operator): if not containers: return {"CANCELLED"} + mode = selection_mode(self.remove_from_selection, self.filter_selection) for container in containers.values(): tool.Spatial.select_products( tool.Spatial.get_decomposed_elements(container, self.is_recursive), unhide=self.should_unhide, - remove=self.remove_from_selection, - filter_selection=self.filter_selection, + mode=mode, ) result = " + ".join(f'location = "{c.Name}"' for c in containers.values()) @@ -499,8 +500,7 @@ class SelectDecomposedElements(bpy.types.Operator): tool.Spatial.select_products( tool.Spatial.get_filtered_elements(self.should_filter, self.is_recursive), unhide=self.should_unhide, - remove=self.remove_from_selection, - filter_selection=self.filter_selection, + mode=selection_mode(self.remove_from_selection, self.filter_selection), ) # Make selected active element in list, the active object diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 8295a9ff9d..5d1830dc01 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -36,6 +36,7 @@ from bonsai.bim.helper import ( RegexSelectMixin, decode_select_click, select_regex_tooltip, + selection_mode, ) @@ -293,32 +294,27 @@ class SelectSimilarType(RegexSelectMixin, bpy.types.Operator): continue relating_types.add(relating_type) - if self.filter_selection: - for obj in context.selected_objects: - element = tool.Ifc.get_entity(obj) - if not element or ifcopenshell.util.element.get_type(element) not in relating_types: - obj.select_set(False) - return {"FINISHED"} - + elements = [] result = "" for relating_type in relating_types: related_objects = ifcopenshell.util.element.get_types(relating_type) + elements.extend(related_objects) - for element in related_objects: - obj = tool.Ifc.get_object(element) - 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(not self.remove_from_selection) - - # copy selection query to clipboard + # build selection query for the clipboard related_objects_class = related_objects[0].is_a() relating_type_name = relating_type.Name if not result: result = f'{related_objects_class}, type="{relating_type_name}"' else: result += f' + {related_objects_class}, type="{relating_type_name}"' + + tool.Spatial.select_products( + elements, + unhide=self.should_unhide, + mode=selection_mode(self.remove_from_selection, self.filter_selection), + ) + + if result: bpy.context.window_manager.clipboard = result self.report({"INFO"}, f"({result}) was copied to the clipboard.") diff --git a/src/bonsai/bonsai/core/material.py b/src/bonsai/bonsai/core/material.py index 2a49b5be39..1ce50271d2 100644 --- a/src/bonsai/bonsai/core/material.py +++ b/src/bonsai/bonsai/core/material.py @@ -86,15 +86,9 @@ def select_by_material( spatial: type[tool.Spatial], material: ifcopenshell.entity_instance, should_unhide: bool = False, - remove_from_selection: bool = False, - filter_selection: bool = False, + mode: str = "ADD", ) -> None: - spatial.select_products( - material_tool.get_elements_by_material(material), - unhide=should_unhide, - remove=remove_from_selection, - filter_selection=filter_selection, - ) + spatial.select_products(material_tool.get_elements_by_material(material), unhide=should_unhide, mode=mode) def enable_editing_material(material_tool: type[tool.Material], material: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index ce5ec47094..64edea74d4 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -975,7 +975,7 @@ class Spatial: def run_spatial_assign_container(cls, container, objs): pass def run_spatial_import_spatial_decomposition(cls): pass def select_object(cls, obj): pass - def select_products(cls, products, unhide=False, remove=False, filter_selection=False): pass + def select_products(cls, products, unhide=False, mode="ADD"): pass def set_active_object(cls, obj, selection_mode=None): pass def set_relative_object_matrix(cls, target_obj, relative_to_obj, matrix): pass def set_target_container_as_default(cls): pass diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 1498073854..1b19ed2ee7 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -196,13 +196,12 @@ class Spatial(bonsai.core.tool.Spatial): cls, products: Iterable[ifcopenshell.entity_instance], unhide: bool = False, - remove: bool = False, - filter_selection: bool = False, + mode: Literal["ADD", "REMOVE", "FILTER"] = "ADD", ) -> None: assert (view_layer := bpy.context.view_layer) # Update view layer, otherwise `objects` might be missing just created objects. view_layer.update() - if filter_selection: + if mode == "FILTER": # Keep only the already selected objects that match, select nothing new. matched_objs = set() for product in products: @@ -219,7 +218,7 @@ class Spatial(bonsai.core.tool.Spatial): if unhide: obj.hide_viewport = False obj.hide_set(False) - obj.select_set(not remove) + obj.select_set(mode != "REMOVE") @classmethod def filter_products(