diff --git a/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md b/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md index 36d53243e7..5ff01cabcf 100644 --- a/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md +++ b/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md @@ -34,6 +34,31 @@ Operators covered: `bim.select_similar`, `bim.select_ifc_class`, `bim.select_decomposed_elements`, `bim.select_group_elements`, `bim.select_aggregate`, `bim.select_linked_aggregates`. +### Shared scaffolding (`bonsai.bim.helper`) + +All of the scheme's cross-operator plumbing lives in `bonsai/bim/helper.py`: + +- `decode_select_click(event)` → `SelectClickModifiers` named tuple (`unhide`, + `remove`, `filter`, `legacy`, `regex_dialog`) — the single place the modifier + scheme is defined; all nine `invoke`s use it. The per-operator + `event.type == "LEFTMOUSE"` guards were dropped in the process, so keyboard + invocation now honors modifiers uniformly. +- `SELECT_REMOVE_TOOLTIP` / `SELECT_FILTER_TOOLTIP` / `SELECT_UNHIDE_TOOLTIP` + + `select_regex_tooltip(subject)` — tooltip lines composed into every + `bl_description` / `description()`, ending the casing drift; `SelectIfcClass` and + `SelectSimilarType` switched from class docstrings to `bl_description` to allow + composition. +- `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 + matching and `select_regex_products(products)` for union-of-products operators. + Subclasses implement `apply_regex` + `get_regex_prefill` and set + `regex_clipboard_key` / `regex_count_noun`; `draw_regex_options` is the hook for + extra dialog rows (used by `select_aggregate`). + +`core.select_similar_container` was deleted — dead since the #7940 merge made +`SelectSimilarContainer` loop `Spatial.select_products` directly. + ### Key decisions and the why - **Remove/filter criteria come from the active object only** (not all selected diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index ab4a0875ab..b365539a6c 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -20,9 +20,10 @@ from __future__ import annotations import importlib import json +import re from collections.abc import Callable, Iterable, Sequence from types import EllipsisType -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Union import bpy import ifcopenshell @@ -56,6 +57,150 @@ if TYPE_CHECKING: ExportCallback = Callable[[dict[str, Any], bonsai.bim.prop.Attribute], bool] +# Shared scaffolding for the select-operator modifier scheme +# (see docs/dev-notes/Additional_Selection_and_Deselection_Tools.md): +# Click = select, SHIFT = remove from selection, CTRL = filter selection, +# CTRL+SHIFT = legacy CTRL function (one-level-deep / exclude-children / +# calculate-sum), ALT = also unhide, CTRL+ALT = regex-search dialog. + +SELECT_REMOVE_TOOLTIP = "SHIFT+Click to remove from selection set" +SELECT_FILTER_TOOLTIP = "CTRL+Click to filter selection to matching objects only" +SELECT_UNHIDE_TOOLTIP = "ALT+Click to also unhide hidden objects (viewport and local hide)" + + +def select_regex_tooltip(subject: str = "values") -> str: + return f"CTRL+ALT+Click to search {subject} by regex in a dialog" + + +class SelectClickModifiers(NamedTuple): + unhide: bool + remove: bool + filter: bool + legacy: bool # CTRL+SHIFT: one-level-deep / exclude-children / calculate-sum + regex_dialog: bool # CTRL+ALT + + +def decode_select_click(event: bpy.types.Event) -> SelectClickModifiers: + return SelectClickModifiers( + unhide=event.alt, + remove=event.shift and not event.ctrl, + filter=event.ctrl and not event.shift, + legacy=event.ctrl and event.shift, + regex_dialog=event.ctrl and event.alt and not event.shift, + ) + + +class RegexSelectMixin: + """Scaffold for select operators offering the CTRL+ALT+Click regex-search dialog. + + Subclasses must be Operators that also define a ``should_unhide`` BoolProperty, + implement :meth:`apply_regex`, and override :attr:`regex_clipboard_key` / + :attr:`regex_count_noun` as needed. From ``invoke``, return + :meth:`invoke_regex_dialog` when ``decode_select_click(event).regex_dialog`` is + set; from ``execute``, return :meth:`execute_regex` while ``use_regex`` is on. + """ + + use_regex: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + regex_pattern: bpy.props.StringProperty( + name="Pattern", + description='Python regular expression matched anywhere in the value, e.g. "foo" matches ".*foo.*"', + ) + regex_mode: bpy.props.EnumProperty( + name="Action", + items=[ + ("ADD", "Add to Selection", "Select objects whose value matches the pattern"), + ("REMOVE", "Remove from Selection", "Deselect objects whose value matches the pattern"), + ("FILTER", "Filter Selection", "Keep only already selected objects whose value matches the pattern"), + ], + default="ADD", + ) + + regex_clipboard_key = "Name" + regex_count_noun = "objects" + + def get_regex_prefill(self, context: bpy.types.Context) -> Union[str, None]: + """Initial pattern shown in the dialog; None keeps the previous pattern.""" + return None + + def draw_regex_options(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: + """Hook for extra dialog rows between the action dropdown and the unhide toggle.""" + + def get_regex_clipboard_key(self) -> str: + return self.regex_clipboard_key + + def invoke_regex_dialog(self, context: bpy.types.Context): + self.use_regex = True + prefill = self.get_regex_prefill(context) + if prefill is not None: + self.regex_pattern = prefill + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context: bpy.types.Context) -> None: + if not self.use_regex: + return + layout = self.layout + layout.prop(self, "regex_pattern") + layout.prop(self, "regex_mode") + self.draw_regex_options(context, layout) + layout.prop(self, "should_unhide", text="Also Unhide Hidden Objects") + + def execute_regex(self, context: bpy.types.Context): + try: + pattern = re.compile(self.regex_pattern) + except re.error as e: + self.report({"ERROR"}, f"Invalid regular expression: {e}") + return {"CANCELLED"} + count = self.apply_regex(context, pattern) + verb = {"ADD": "Selected", "REMOVE": "Deselected", "FILTER": "Filtered selection to"}[self.regex_mode] + result = f"{self.get_regex_clipboard_key()} = /.*{self.regex_pattern}.*/" + bpy.context.window_manager.clipboard = result + self.report( + {"INFO"}, f"{verb} {count} {self.regex_count_noun} matching ({result}); query copied to the clipboard." + ) + return {"FINISHED"} + + def apply_regex(self, context: bpy.types.Context, pattern: re.Pattern) -> int: + """Apply the pattern per regex_mode; return the matched count.""" + raise NotImplementedError + + def apply_regex_by_value( + self, + context: bpy.types.Context, + pattern: re.Pattern, + get_value: Callable[[bpy.types.Object], Union[str, None]], + ) -> int: + """Generic per-object matching against get_value(obj).""" + count = 0 + if self.regex_mode == "FILTER": + for obj in context.selected_objects: + value = get_value(obj) + if value is not None and pattern.search(value): + count += 1 + else: + obj.select_set(False) + return count + remove = self.regex_mode == "REMOVE" + objects = context.scene.objects if self.should_unhide else context.visible_objects + for obj in objects: + value = get_value(obj) + if value is not None and pattern.search(value): + if self.should_unhide: + obj.hide_viewport = False + obj.hide_set(False) + obj.select_set(not remove) + count += 1 + return count + + 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", + ) + + def draw_attributes( props: Union[bpy.types.bpy_prop_collection_idprop[Attribute], Sequence[Attribute]], layout: bpy.types.UILayout, diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 1527892899..9b391050ff 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -import re from typing import TYPE_CHECKING import bpy @@ -28,6 +27,14 @@ import ifcopenshell.util.element import bonsai.core.aggregate as core import bonsai.core.spatial import bonsai.tool as tool +from bonsai.bim.helper import ( + SELECT_FILTER_TOOLTIP, + SELECT_REMOVE_TOOLTIP, + SELECT_UNHIDE_TOOLTIP, + RegexSelectMixin, + decode_select_click, + select_regex_tooltip, +) class BIM_OT_aggregate_assign_object(bpy.types.Operator, tool.Ifc.Operator): @@ -255,7 +262,7 @@ class BIM_OT_select_parts(bpy.types.Operator): return {"FINISHED"} -class BIM_OT_select_aggregate(bpy.types.Operator): +class BIM_OT_select_aggregate(RegexSelectMixin, bpy.types.Operator): """Select Aggregate""" bl_idname = "bim.select_aggregate" @@ -270,61 +277,46 @@ class BIM_OT_select_aggregate(bpy.types.Operator): should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) filter_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - use_regex: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - regex_pattern: bpy.props.StringProperty( - name="Pattern", - description='Python regular expression matched anywhere in each aggregate\'s name, e.g. "foo" matches ".*foo.*"', - ) - regex_mode: bpy.props.EnumProperty( - name="Action", - items=[ - ("ADD", "Add to Selection", "Select aggregates whose name matches the pattern"), - ("REMOVE", "Remove from Selection", "Deselect aggregates whose name matches the pattern"), - ("FILTER", "Filter Selection", "Keep only already selected aggregates whose name matches the pattern"), - ], - default="ADD", - ) + + regex_clipboard_key = "parent" + regex_count_noun = "aggregates" @classmethod def description(cls, context, properties): - if properties.select_parts: - return "Select Aggregate and Parts.\n\nSHIFT+Click to remove from selection set\nCTRL+Click to filter selection to matching objects only\nCTRL+SHIFT+Click to select only one level deep\nCTRL+ALT+Click to search aggregate names by regex in a dialog\nALT+Click to also unhide hidden objects (viewport and local hide)" - else: - return "Select Aggregate\n\nSHIFT+Click to remove from selection set\nCTRL+Click to filter selection to matching objects only\nCTRL+ALT+Click to search aggregate names by regex in a dialog\nALT+Click to also unhide hidden objects (viewport and local hide)" + base = "Select Aggregate and Parts." if properties.select_parts else "Select Aggregate" + one_level_deep = "\nCTRL+SHIFT+Click to select only one level deep" if properties.select_parts else "" + return ( + base + + f"\n\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + one_level_deep + + f"\n{select_regex_tooltip('aggregate names')}" + + f"\n{SELECT_UNHIDE_TOOLTIP}" + ) def invoke(self, context, event): - if event.type == "LEFTMOUSE" and event.ctrl and event.alt and not event.shift: - self.use_regex = True - if context.active_object and (element := tool.Ifc.get_entity(context.active_object)): - aggregate = ifcopenshell.util.element.get_aggregate(element) - if aggregate and aggregate.Name: - self.regex_pattern = aggregate.Name - return context.window_manager.invoke_props_dialog(self) - if event.type == "LEFTMOUSE" and event.ctrl and event.shift: - self.one_level_deep = True - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + if mods.regex_dialog: + return self.invoke_regex_dialog(context) + self.one_level_deep = mods.legacy + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) - def draw(self, context): - layout = self.layout - if not self.use_regex: - return - layout.prop(self, "regex_pattern") - layout.prop(self, "regex_mode") + def get_regex_prefill(self, context): + if context.active_object and (element := tool.Ifc.get_entity(context.active_object)): + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate: + return aggregate.Name + return None + + def draw_regex_options(self, context, layout): layout.prop(self, "select_parts", text="Also Select Parts") if self.select_parts: layout.prop(self, "one_level_deep") - layout.prop(self, "should_unhide", text="Also Unhide Hidden Objects") - - def _execute_regex(self, context): - try: - pattern = re.compile(self.regex_pattern) - except re.error as e: - self.report({"ERROR"}, f"Invalid regular expression: {e}") - return {"CANCELLED"} + def apply_regex(self, context, pattern): aggregates = {} for rel in tool.Ifc.get().by_type("IfcRelAggregates"): aggregate = rel.RelatingObject @@ -343,30 +335,12 @@ class BIM_OT_select_aggregate(bpy.types.Operator): else: products.update(ifcopenshell.util.element.get_decomposition(aggregate)) - tool.Spatial.select_products( - products, - unhide=self.should_unhide, - remove=self.regex_mode == "REMOVE", - filter_selection=self.regex_mode == "FILTER", - ) - - if self.regex_mode == "FILTER": - verb = "Filtered selection to" - elif self.regex_mode == "REMOVE": - verb = "Deselected" - else: - verb = "Selected" - result = f"parent = /.*{self.regex_pattern}.*/" - bpy.context.window_manager.clipboard = result - self.report( - {"INFO"}, - f"{verb} {len(aggregates)} aggregates matching ({result}); query copied to the clipboard.", - ) - return {"FINISHED"} + self.select_regex_products(products) + return len(aggregates) def execute(self, context): if self.use_regex: - return self._execute_regex(context) + return self.execute_regex(context) keep_current_selection = self.remove_from_selection or self.filter_selection if keep_current_selection: objects = [context.active_object] if context.active_object else [] @@ -527,15 +501,18 @@ class BIM_OT_select_linked_aggregates(bpy.types.Operator): @classmethod def description(cls, context, properties): - if properties.select_parts: - return "Select all aggregates, subaggregates and all their parts\n\nSHIFT+Click to remove from selection set\nCTRL+Click to filter selection to matching objects only\nALT+Click to also unhide hidden objects (viewport and local hide)" - else: - return "Select all aggregates\n\nSHIFT+Click to remove from selection set\nCTRL+Click to filter selection to matching objects only\nALT+Click to also unhide hidden objects (viewport and local hide)" + base = ( + "Select all aggregates, subaggregates and all their parts" + if properties.select_parts + else "Select all aggregates" + ) + return base + f"\n\n{SELECT_REMOVE_TOOLTIP}" + f"\n{SELECT_FILTER_TOOLTIP}" + f"\n{SELECT_UNHIDE_TOOLTIP}" def invoke(self, context, event): - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) def execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index 0870c31d91..c24d09b7a2 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -import re from typing import TYPE_CHECKING, get_args import bpy @@ -25,6 +24,14 @@ import ifcopenshell.util.element import bonsai.bim.helper import bonsai.tool as tool +from bonsai.bim.helper import ( + SELECT_FILTER_TOOLTIP, + SELECT_REMOVE_TOOLTIP, + SELECT_UNHIDE_TOOLTIP, + RegexSelectMixin, + decode_select_click, + select_regex_tooltip, +) class LoadGroups(bpy.types.Operator, tool.Ifc.Operator): @@ -192,65 +199,43 @@ class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator): self.report({"INFO"}, f"Unassigned {len(products)} objects from group.") -class SelectGroupElements(bpy.types.Operator): +class SelectGroupElements(RegexSelectMixin, bpy.types.Operator): bl_idname = "bim.select_group_elements" bl_label = "Select Group elements" bl_options = {"REGISTER", "UNDO"} bl_description = ( "Select objects assigned to the selected group and all nested groups" - "\nSHIFT + CLICK to remove from selection set" - "\nCTRL + CLICK to filter selection to matching objects only" - "\nCTRL + SHIFT + CLICK to exclude children" - "\nCTRL + ALT + CLICK to search group names by regex in a dialog" - "\nALT + CLICK to also unhide hidden objects (viewport and local hide)" + + f"\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + "\nCTRL+SHIFT+Click to exclude children" + + f"\n{select_regex_tooltip('group names')}" + + f"\n{SELECT_UNHIDE_TOOLTIP}" ) group: bpy.props.IntProperty() is_recursive: bpy.props.BoolProperty(name="Is Recursive", default=True, options={"SKIP_SAVE"}) should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) filter_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - use_regex: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - regex_pattern: bpy.props.StringProperty( - name="Pattern", - description='Python regular expression matched anywhere in each group\'s name, e.g. "foo" matches ".*foo.*"', - ) - regex_mode: bpy.props.EnumProperty( - name="Action", - items=[ - ("ADD", "Add to Selection", "Select elements of groups whose name matches the pattern"), - ("REMOVE", "Remove from Selection", "Deselect elements of groups whose name matches the pattern"), - ("FILTER", "Filter Selection", "Keep only already selected elements of groups whose name matches the pattern"), - ], - default="ADD", - ) + + regex_clipboard_key = "group" + regex_count_noun = "groups" def invoke(self, context, event): - if event.type == "LEFTMOUSE" and event.ctrl and event.alt and not event.shift: - self.use_regex = True - if self.group and (name := tool.Ifc.get().by_id(self.group).Name): - self.regex_pattern = name - return context.window_manager.invoke_props_dialog(self) - self.is_recursive = not (event.ctrl and event.shift) - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + if mods.regex_dialog: + return self.invoke_regex_dialog(context) + self.is_recursive = not mods.legacy + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) - def draw(self, context): - layout = self.layout - if not self.use_regex: - return - layout.prop(self, "regex_pattern") - layout.prop(self, "regex_mode") - layout.prop(self, "should_unhide", text="Also Unhide Hidden Objects") - - def _execute_regex(self, context): - try: - pattern = re.compile(self.regex_pattern) - except re.error as e: - self.report({"ERROR"}, f"Invalid regular expression: {e}") - return {"CANCELLED"} + def get_regex_prefill(self, context): + if self.group: + return tool.Ifc.get().by_id(self.group).Name + return None + def apply_regex(self, context, pattern): products = set() matched_groups = 0 for group in tool.Ifc.get().by_type("IfcGroup"): @@ -258,31 +243,12 @@ class SelectGroupElements(bpy.types.Operator): continue matched_groups += 1 products.update(ifcopenshell.util.element.get_grouped_by(group, is_recursive=self.is_recursive)) - - tool.Spatial.select_products( - products, - unhide=self.should_unhide, - remove=self.regex_mode == "REMOVE", - filter_selection=self.regex_mode == "FILTER", - ) - - if self.regex_mode == "FILTER": - verb = "Filtered selection to" - elif self.regex_mode == "REMOVE": - verb = "Deselected" - else: - verb = "Selected" - result = f"group = /.*{self.regex_pattern}.*/" - bpy.context.window_manager.clipboard = result - self.report( - {"INFO"}, - f"{verb} elements of {matched_groups} groups matching ({result}); query copied to the clipboard.", - ) - return {"FINISHED"} + self.select_regex_products(products) + return matched_groups def execute(self, context): if self.use_regex: - return self._execute_regex(context) + return self.execute_regex(context) 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, diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index f412fc0fa3..59a6d1f7c6 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . import json -import re from typing import TYPE_CHECKING, Any, Literal, Union import bpy @@ -31,6 +30,14 @@ import bonsai.bim.helper import bonsai.bim.module.model.profile as model_profile import bonsai.core.material as core import bonsai.tool as tool +from bonsai.bim.helper import ( + SELECT_FILTER_TOOLTIP, + SELECT_REMOVE_TOOLTIP, + SELECT_UNHIDE_TOOLTIP, + RegexSelectMixin, + decode_select_click, + select_regex_tooltip, +) from bonsai.bim.module.model import slab, wall if TYPE_CHECKING: @@ -59,56 +66,45 @@ class DisableEditingMaterials(bpy.types.Operator): return {"FINISHED"} -class SelectByMaterial(bpy.types.Operator): +class SelectByMaterial(RegexSelectMixin, bpy.types.Operator): bl_idname = "bim.select_by_material" bl_label = "Select By Material" - bl_description = "Select objects using the provided material\n\nSHIFT+Click to remove from selection set\nCTRL+Click to filter selection to matching objects only\nCTRL+ALT+Click to search material names by regex in a dialog\nALT+Click to also unhide hidden objects (viewport and local hide)" + bl_description = ( + "Select objects using the provided material" + + f"\n\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + f"\n{select_regex_tooltip('material names')}" + + f"\n{SELECT_UNHIDE_TOOLTIP}" + ) bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) filter_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - use_regex: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - regex_pattern: bpy.props.StringProperty( - name="Pattern", - description='Python regular expression matched anywhere in each object\'s material name, e.g. "foo" matches ".*foo.*"', - ) - regex_mode: bpy.props.EnumProperty( - name="Action", - items=[ - ("ADD", "Add to Selection", "Select objects whose material name matches the pattern"), - ("REMOVE", "Remove from Selection", "Deselect objects whose material name matches the pattern"), - ("FILTER", "Filter Selection", "Keep only already selected objects whose material name matches the pattern"), - ], - default="ADD", - ) + + regex_clipboard_key = "material" def invoke(self, context, event): - if event.type == "LEFTMOUSE" and event.ctrl and event.alt and not event.shift: - self.use_regex = True - layer_index = None - if self.material: - layer_index = self._get_layer_index(tool.Ifc.get().by_id(self.material)) - name = None - if context.active_object: - name = self._get_material_name(context.active_object, layer_index) - if name is None and self.material: - name = self._get_name(tool.Ifc.get().by_id(self.material)) - if name is not None: - self.regex_pattern = name - return context.window_manager.invoke_props_dialog(self) - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + if mods.regex_dialog: + return self.invoke_regex_dialog(context) + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) - def draw(self, context): - layout = self.layout - if not self.use_regex: - return - layout.prop(self, "regex_pattern") - layout.prop(self, "regex_mode") - layout.prop(self, "should_unhide", text="Also Unhide Hidden Objects") + def get_regex_prefill(self, context): + name = None + if context.active_object: + name = self._get_material_name(context.active_object, self._get_reference_layer_index()) + if name is None and self.material: + name = self._get_name(tool.Ifc.get().by_id(self.material)) + return name + + def _get_reference_layer_index(self): + if not self.material: + return None + return self._get_layer_index(tool.Ifc.get().by_id(self.material)) def _get_material_name(self, obj, layer_index): element = tool.Ifc.get_entity(obj) @@ -122,51 +118,13 @@ class SelectByMaterial(bpy.types.Operator): return None return self._get_name(resolved) - def _execute_regex(self, context): - try: - pattern = re.compile(self.regex_pattern) - except re.error as e: - self.report({"ERROR"}, f"Invalid regular expression: {e}") - return {"CANCELLED"} - - layer_index = None - if self.material: - layer_index = self._get_layer_index(tool.Ifc.get().by_id(self.material)) - - count = 0 - if self.regex_mode == "FILTER": - for obj in context.selected_objects: - name = self._get_material_name(obj, layer_index) - if name is not None and pattern.search(name): - count += 1 - else: - obj.select_set(False) - else: - remove = self.regex_mode == "REMOVE" - objects = context.scene.objects if self.should_unhide else context.visible_objects - for obj in objects: - name = self._get_material_name(obj, layer_index) - if name is not None and pattern.search(name): - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - obj.select_set(not remove) - count += 1 - - if self.regex_mode == "FILTER": - verb = "Filtered selection to" - elif self.regex_mode == "REMOVE": - verb = "Deselected" - else: - verb = "Selected" - result = f"material = /.*{self.regex_pattern}.*/" - bpy.context.window_manager.clipboard = result - self.report({"INFO"}, f"{verb} {count} objects matching ({result}); query copied to the clipboard.") - return {"FINISHED"} + def apply_regex(self, context, pattern): + layer_index = self._get_reference_layer_index() + return self.apply_regex_by_value(context, pattern, lambda obj: self._get_material_name(obj, layer_index)) def execute(self, context): if self.use_regex: - return self._execute_regex(context) + return self.execute_regex(context) # 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 diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index d9930a2d4d..cd9522b981 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -18,7 +18,6 @@ import bisect import json -import re import traceback from typing import TYPE_CHECKING, Any, Literal, assert_never, get_args @@ -39,6 +38,14 @@ from natsort import natsorted import bonsai.core.search as core import bonsai.tool as tool +from bonsai.bim.helper import ( + SELECT_FILTER_TOOLTIP, + SELECT_REMOVE_TOOLTIP, + SELECT_UNHIDE_TOOLTIP, + RegexSelectMixin, + decode_select_click, + select_regex_tooltip, +) from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty @@ -1265,10 +1272,14 @@ class SelectGlobalId(Operator): class SelectIfcClass(Operator): - """Click to select all objects that match with the given IFC class\nSHIFT + Click to remove from selection set\nCTRL + Click to filter selection to matching objects only\nALT + Click to also unhide hidden objects (viewport and local hide)""" - bl_idname = "bim.select_ifc_class" bl_label = "Select IFC Class" + bl_description = ( + "Click to select all objects that match with the given IFC class" + + f"\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + f"\n{SELECT_UNHIDE_TOOLTIP}" + ) bl_options = {"REGISTER", "UNDO"} should_filter_predefined_type: BoolProperty(default=False) should_unhide: BoolProperty(default=False) @@ -1276,9 +1287,10 @@ class SelectIfcClass(Operator): filter_selection: BoolProperty(default=False, options={"SKIP_SAVE"}) def invoke(self, context, event): - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift - self.should_unhide = event.alt + mods = decode_select_click(event) + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter + self.should_unhide = mods.unhide return self.execute(context) def execute(self, context): @@ -1457,7 +1469,7 @@ class ShowAllElements(Operator): return {"FINISHED"} -class SelectSimilar(Operator): +class SelectSimilar(RegexSelectMixin, Operator): bl_idname = "bim.select_similar" bl_label = "Select Similar" bl_options = {"REGISTER", "UNDO"} @@ -1470,24 +1482,16 @@ class SelectSimilar(Operator): remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) filter_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - use_regex: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - regex_pattern: bpy.props.StringProperty( - name="Pattern", - description='Python regular expression matched anywhere in each object\'s value, e.g. "foo" matches ".*foo.*"', - ) - regex_mode: bpy.props.EnumProperty( - name="Action", - items=[ - ("ADD", "Add to Selection", "Select objects whose value matches the pattern"), - ("REMOVE", "Remove from Selection", "Deselect objects whose value matches the pattern"), - ("FILTER", "Filter Selection", "Keep only already selected objects whose value matches the pattern"), - ], - default="ADD", - ) @classmethod def description(cls, context, properties): - base = "Select objects with a similar value\n\nSHIFT+CLICK remove from selection set.\nCTRL+CLICK filter selection to matching objects only.\nCTRL+ALT+CLICK search by regex in a dialog.\nALT+CLICK also unhide hidden objects (viewport and local hide)." + base = ( + "Select objects with a similar value" + + f"\n\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + f"\n{select_regex_tooltip()}" + + f"\n{SELECT_UNHIDE_TOOLTIP}" + ) key = getattr(properties, "key", None) active = context.active_object @@ -1500,7 +1504,7 @@ class SelectSimilar(Operator): value = ifcopenshell.util.selector.get_element_value(element, key) if isinstance(value, (int, float)): - return base + ("\nCTRL+SHIFT+CLICK display the sum of all selected objects") + return base + ("\nCTRL+SHIFT+Click to display the sum of all selected objects") else: return base @@ -1512,26 +1516,33 @@ class SelectSimilar(Operator): return False def invoke(self, context, event): - if event.type == "LEFTMOUSE" and event.ctrl and event.alt and not event.shift: - self.use_regex = True - key = "predefined_type" if self.key == "PredefinedType" else self.key - value = self._get_value(context.active_object, key) if context.active_object else None - if value is not None: - self.regex_pattern = str(value) - return context.window_manager.invoke_props_dialog(self) - self.calculate_sum = event.ctrl and event.shift and event.type == "LEFTMOUSE" - self.remove_from_selection = event.shift and not event.ctrl and event.type == "LEFTMOUSE" - self.filter_selection = event.ctrl and not event.shift and event.type == "LEFTMOUSE" - self.should_unhide = event.alt + mods = decode_select_click(event) + if mods.regex_dialog: + return self.invoke_regex_dialog(context) + self.calculate_sum = mods.legacy + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter + self.should_unhide = mods.unhide return self.execute(context) - def draw(self, context): - layout = self.layout - if not self.use_regex: - return - layout.prop(self, "regex_pattern") - layout.prop(self, "regex_mode") - layout.prop(self, "should_unhide", text="Also Unhide Hidden Objects") + def get_regex_prefill(self, context): + if not context.active_object: + return None + key = "predefined_type" if self.key == "PredefinedType" else self.key + value = self._get_value(context.active_object, key) + return None if value is None else str(value) + + def get_regex_clipboard_key(self): + return self.key + + def apply_regex(self, context, pattern): + key = "predefined_type" if self.key == "PredefinedType" else self.key + + def get_value(obj): + value = self._get_value(obj, key) + return None if value is None else str(value) + + return self.apply_regex_by_value(context, pattern, get_value) def execute(self, context): self.calculated_sum = 0 # reset if run before @@ -1541,7 +1552,7 @@ class SelectSimilar(Operator): formatted_tolerance = f"{tolerance:.{max(0, -int(f'{tolerance:.1e}'.split('e')[-1])) if tolerance < 1 else 1}f}" if self.use_regex: - return self._execute_regex(context, key) + return self.execute_regex(context) if self.calculate_sum: self._calculate_sum(context, key) @@ -1580,45 +1591,6 @@ class SelectSimilar(Operator): return None return ifcopenshell.util.selector.get_element_value(element, key) - def _execute_regex(self, context, key): - try: - pattern = re.compile(self.regex_pattern) - except re.error as e: - self.report({"ERROR"}, f"Invalid regular expression: {e}") - return {"CANCELLED"} - - count = 0 - if self.regex_mode == "FILTER": - for obj in context.selected_objects: - obj_value = self._get_value(obj, key) - if obj_value is not None and pattern.search(str(obj_value)): - count += 1 - else: - obj.select_set(False) - else: - remove = self.regex_mode == "REMOVE" - 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 not None and pattern.search(str(obj_value)): - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - obj.select_set(not remove) - count += 1 - - if self.regex_mode == "FILTER": - verb = "Filtered selection to" - elif self.regex_mode == "REMOVE": - verb = "Deselected" - else: - verb = "Selected" - clip_key = "PredefinedType" if key == "predefined_type" else key - result = f"{clip_key} = /.*{self.regex_pattern}.*/" - bpy.context.window_manager.clipboard = result - self.report({"INFO"}, f"{verb} {count} objects matching ({result}); query copied to the clipboard.") - return {"FINISHED"} - def _get_reference_values(self, context, key): objects = ( [context.active_object] diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 101f9c3ab8..8c80045198 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -24,6 +24,12 @@ import ifcopenshell.util.element import bonsai.bim.handler import bonsai.core.spatial as core import bonsai.tool as tool +from bonsai.bim.helper import ( + SELECT_FILTER_TOOLTIP, + SELECT_REMOVE_TOOLTIP, + SELECT_UNHIDE_TOOLTIP, + decode_select_click, +) class ReferenceStructure(bpy.types.Operator, tool.Ifc.Operator): @@ -284,7 +290,13 @@ class SelectContainer(bpy.types.Operator): class SelectSimilarContainer(bpy.types.Operator): bl_idname = "bim.select_similar_container" bl_label = "Select Similar Container" - bl_description = "Recursively selects all objects in the container.\n\nShift+click to remove from selection set\nCtrl+click to filter selection to matching objects only\nCtrl+Shift+click to select only one level deep\nAlt+click to also unhide hidden objects (viewport and local hide)" + bl_description = ( + "Recursively selects all objects in the container." + + f"\n\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + "\nCTRL+SHIFT+Click to select only one level deep" + + f"\n{SELECT_UNHIDE_TOOLTIP}" + ) bl_options = {"REGISTER", "UNDO"} container: bpy.props.IntProperty(default=0) @@ -294,11 +306,11 @@ class SelectSimilarContainer(bpy.types.Operator): filter_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) def invoke(self, context, event): - if event.type == "LEFTMOUSE" and event.ctrl and event.shift: - self.is_recursive = False - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + self.is_recursive = not mods.legacy + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) def execute(self, context): @@ -469,18 +481,18 @@ class SelectDecomposedElements(bpy.types.Operator): def description(cls, context, operator): return ( "Select the active item" - + "\nSHIFT+CLICK to remove from selection set.\nCTRL+CLICK to filter selection to matching objects only" - + "\nCTRL+SHIFT+CLICK to select only one level deep" - + "\nALT+CLICK to also unhide hidden objects (viewport and local hide)" + + f"\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + "\nCTRL+SHIFT+Click to select only one level deep" + + f"\n{SELECT_UNHIDE_TOOLTIP}" ) def invoke(self, context, event): - if event.type == "LEFTMOUSE": - if event.ctrl and event.shift: - self.is_recursive = False - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + self.is_recursive = not mods.legacy + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) def execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 930d94f619..8295a9ff9d 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -import re from typing import TYPE_CHECKING import bpy @@ -30,6 +29,14 @@ import bonsai.core.geometry import bonsai.core.root import bonsai.core.type as core import bonsai.tool as tool +from bonsai.bim.helper import ( + SELECT_FILTER_TOOLTIP, + SELECT_REMOVE_TOOLTIP, + SELECT_UNHIDE_TOOLTIP, + RegexSelectMixin, + decode_select_click, + select_regex_tooltip, +) class AssignType(bpy.types.Operator, tool.Ifc.Operator): @@ -221,51 +228,39 @@ class SelectType(bpy.types.Operator): return collection_in_view_layer -class SelectSimilarType(bpy.types.Operator): - """Select Similar Type\nSHIFT+Click to remove from selection set\nCTRL+Click to filter selection to matching objects only\nCTRL+ALT+Click to search type names by regex in a dialog\nALT+Click to also unhide hidden objects (viewport and local hide)""" - +class SelectSimilarType(RegexSelectMixin, bpy.types.Operator): bl_idname = "bim.select_similar_type" bl_label = "Select Similar Type" + bl_description = ( + "Select Similar Type" + + f"\n{SELECT_REMOVE_TOOLTIP}" + + f"\n{SELECT_FILTER_TOOLTIP}" + + f"\n{select_regex_tooltip('type names')}" + + f"\n{SELECT_UNHIDE_TOOLTIP}" + ) bl_options = {"REGISTER", "UNDO"} related_object: bpy.props.StringProperty() should_unhide: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) filter_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - use_regex: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - regex_pattern: bpy.props.StringProperty( - name="Pattern", - description='Python regular expression matched anywhere in each type\'s name, e.g. "foo" matches ".*foo.*"', - ) - regex_mode: bpy.props.EnumProperty( - name="Action", - items=[ - ("ADD", "Add to Selection", "Select occurrences whose type name matches the pattern"), - ("REMOVE", "Remove from Selection", "Deselect occurrences whose type name matches the pattern"), - ("FILTER", "Filter Selection", "Keep only already selected occurrences whose type name matches the pattern"), - ], - default="ADD", - ) + + regex_clipboard_key = "type" def invoke(self, context, event): - if event.type == "LEFTMOUSE" and event.ctrl and event.alt and not event.shift: - self.use_regex = True - if context.active_object and (element := tool.Ifc.get_entity(context.active_object)): - relating_type = ifcopenshell.util.element.get_type(element) - if relating_type and relating_type.Name: - self.regex_pattern = relating_type.Name - return context.window_manager.invoke_props_dialog(self) - self.should_unhide = event.alt - self.remove_from_selection = event.shift and not event.ctrl - self.filter_selection = event.ctrl and not event.shift + mods = decode_select_click(event) + if mods.regex_dialog: + return self.invoke_regex_dialog(context) + self.should_unhide = mods.unhide + self.remove_from_selection = mods.remove + self.filter_selection = mods.filter return self.execute(context) - def draw(self, context): - layout = self.layout - if not self.use_regex: - return - layout.prop(self, "regex_pattern") - layout.prop(self, "regex_mode") - layout.prop(self, "should_unhide", text="Also Unhide Hidden Objects") + def get_regex_prefill(self, context): + if context.active_object and (element := tool.Ifc.get_entity(context.active_object)): + relating_type = ifcopenshell.util.element.get_type(element) + if relating_type: + return relating_type.Name + return None def _get_type_name(self, obj): element = tool.Ifc.get_entity(obj) @@ -276,48 +271,13 @@ class SelectSimilarType(bpy.types.Operator): return None return relating_type.Name - def _execute_regex(self, context): - try: - pattern = re.compile(self.regex_pattern) - except re.error as e: - self.report({"ERROR"}, f"Invalid regular expression: {e}") - return {"CANCELLED"} - - count = 0 - if self.regex_mode == "FILTER": - for obj in context.selected_objects: - name = self._get_type_name(obj) - if name is not None and pattern.search(name): - count += 1 - else: - obj.select_set(False) - else: - remove = self.regex_mode == "REMOVE" - objects = context.scene.objects if self.should_unhide else context.visible_objects - for obj in objects: - name = self._get_type_name(obj) - if name is not None and pattern.search(name): - if self.should_unhide: - obj.hide_viewport = False - obj.hide_set(False) - obj.select_set(not remove) - count += 1 - - if self.regex_mode == "FILTER": - verb = "Filtered selection to" - elif self.regex_mode == "REMOVE": - verb = "Deselected" - else: - verb = "Selected" - result = f"type = /.*{self.regex_pattern}.*/" - bpy.context.window_manager.clipboard = result - self.report({"INFO"}, f"{verb} {count} objects matching ({result}); query copied to the clipboard.") - return {"FINISHED"} + def apply_regex(self, context, pattern): + return self.apply_regex_by_value(context, pattern, self._get_type_name) def execute(self, context): self.file = tool.Ifc.get() if self.use_regex: - return self._execute_regex(context) + return self.execute_regex(context) if self.remove_from_selection or self.filter_selection: objects = [context.active_object] if context.active_object else [] else: diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 1539e22121..be9294d118 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -119,22 +119,6 @@ def select_container( spatial.set_active_object(ifc.get_object(container), selection_mode=selection_mode) -def select_similar_container( - spatial: type[tool.Spatial], - container: ifcopenshell.entity_instance, - is_recursive: bool = True, - should_unhide: bool = False, - remove_from_selection: bool = False, - filter_selection: bool = False, -) -> None: - spatial.select_products( - spatial.get_decomposed_elements(container, is_recursive), - unhide=should_unhide, - remove=remove_from_selection, - filter_selection=filter_selection, - ) - - def select_product(spatial: type[tool.Spatial], product: ifcopenshell.entity_instance) -> None: spatial.select_products([product])