diff --git a/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md b/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md
index 89e675cdd2..36d53243e7 100644
--- a/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md
+++ b/docs/dev-notes/Additional_Selection_and_Deselection_Tools.md
@@ -27,6 +27,7 @@ One modifier scheme, applied uniformly across nine operators:
| CTRL+Click | **filter** the selection to matches only (selects nothing new) |
| CTRL+SHIFT+Click | legacy plain-CTRL function, where one existed |
| ALT+Click | also unhide matches (viewport + local hide) — from the base branch |
+| CTRL+ALT+Click | `select_similar`, `select_similar_type`, `select_by_material`, `select_group_elements`, `select_aggregate`: regex-search dialog (see below) |
Operators covered: `bim.select_similar`, `bim.select_ifc_class`,
`bim.select_similar_type`, `bim.select_by_material`, `bim.select_similar_container`,
@@ -63,6 +64,40 @@ Operators covered: `bim.select_similar`, `bim.select_ifc_class`,
- **`select_ifc_class` filter matches subtypes** (`element.is_a(cls)`), consistent
with normal select mode which uses `file.by_type(cls)` (also subtype-inclusive).
+### Regex-search dialog on `select_similar`, `select_similar_type`, `select_by_material`, `select_group_elements`, `select_aggregate` (CTRL+ALT+Click)
+
+On `select_aggregate` the pattern is prefilled with the active object's **aggregate
+name** and matched against every `IfcRelAggregates.RelatingObject` that `is_a
+IfcElement` (spatial decomposition — project/site/storey — deliberately excluded);
+the dialog additionally exposes "Also Select Parts" (+ "One Level Deep") since the
+panel's two button variants collapse into one dialog. Union of matched aggregates
+(+ parts via `get_parts`/`get_decomposition`) through one `Spatial.select_products`
+call; clipboard query `parent = /.*foo.*/`.
+
+On `select_similar_type` the pattern is prefilled with (and matched against) the active
+object's **type name**; the clipboard query is `type = /.*foo.*/`. On
+`select_by_material` it is the active object's **resolved material name** (via the
+#7940 helpers: usage → set, clicked-layer index as hint, `_get_name`), falling back to
+the clicked material row's name; clipboard query `material = /.*foo.*/`. On
+`select_group_elements` it is the clicked group row's **name**, matched against all
+`IfcGroup` names in the file (unnamed groups never match); the union of the matching
+groups' elements (recursive by default) goes through a single
+`Spatial.select_products` call — union first, so FILTER cannot wrongly intersect
+per-group; clipboard query `group = /.*foo.*/`. Otherwise identical to the
+`select_similar` behavior below.
+
+Opens a props dialog prefilled with the active object's value for the clicked key; the
+(possibly edited) text is compiled as an unanchored Python regex (`re.search`, so
+entering `foo` behaves like `.*foo.*`) and applied via an Add / Remove / Filter
+dropdown, plus an "Also Unhide Hidden Objects" checkbox (reuses `should_unhide`; in
+Add/Remove it sweeps `scene.objects` instead of `visible_objects` and clears both
+hide flags on matches; a no-op in Filter since selected objects are visible). CTRL+ALT was free in practice: ALT (unhide) is a no-op in filter mode, which
+plain CTRL triggers. Invalid patterns error out and cancel. The equivalent selector
+query (`Key = /.*foo.*/`) is copied to the clipboard. Note the prefill is the raw
+value — values containing regex metacharacters (e.g. `(`) need escaping before OK.
+Overriding `draw()` for the dialog means the F9 redo panel no longer auto-lists the
+operator's internal properties for normal runs (it was exposing internals anyway).
+
### Deliberately overwritten SHIFT bindings (to be reworked later)
Two operators already used SHIFT; the owner chose to overwrite them and revisit with
diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py
index 64ad5005ba..1527892899 100644
--- a/src/bonsai/bonsai/bim/module/aggregate/operator.py
+++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py
@@ -16,6 +16,7 @@
# 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
@@ -269,15 +270,36 @@ 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",
+ )
@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\nALT+Click to also unhide hidden objects (viewport and local hide)"
+ 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\nALT+Click to also unhide hidden objects (viewport and local hide)"
+ 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)"
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
@@ -285,7 +307,66 @@ class BIM_OT_select_aggregate(bpy.types.Operator):
self.filter_selection = event.ctrl and not event.shift
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, "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"}
+
+ aggregates = {}
+ for rel in tool.Ifc.get().by_type("IfcRelAggregates"):
+ aggregate = rel.RelatingObject
+ if not aggregate.is_a("IfcElement"):
+ continue
+ if not aggregate.Name or not pattern.search(aggregate.Name):
+ continue
+ aggregates[aggregate.id()] = aggregate
+
+ products = set()
+ for aggregate in aggregates.values():
+ products.add(aggregate)
+ if self.select_parts:
+ if self.one_level_deep:
+ products.update(ifcopenshell.util.element.get_parts(aggregate))
+ 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"}
+
def execute(self, context):
+ if self.use_regex:
+ 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 []
diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py
index ed7d0d0499..0870c31d91 100644
--- a/src/bonsai/bonsai/bim/module/group/operator.py
+++ b/src/bonsai/bonsai/bim/module/group/operator.py
@@ -16,6 +16,7 @@
# 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
@@ -200,6 +201,7 @@ class SelectGroupElements(bpy.types.Operator):
"\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)"
)
group: bpy.props.IntProperty()
@@ -207,15 +209,80 @@ class SelectGroupElements(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 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",
+ )
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
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"}
+
+ products = set()
+ matched_groups = 0
+ for group in tool.Ifc.get().by_type("IfcGroup"):
+ if not group.Name or not pattern.search(group.Name):
+ 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"}
+
def execute(self, context):
+ if self.use_regex:
+ 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 5cd03f6b3d..f412fc0fa3 100644
--- a/src/bonsai/bonsai/bim/module/material/operator.py
+++ b/src/bonsai/bonsai/bim/module/material/operator.py
@@ -17,6 +17,7 @@
# along with Bonsai. If not, see .
import json
+import re
from typing import TYPE_CHECKING, Any, Literal, Union
import bpy
@@ -61,20 +62,111 @@ class DisableEditingMaterials(bpy.types.Operator):
class SelectByMaterial(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\nALT+Click to also unhide hidden objects (viewport and local hide)"
+ 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_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",
+ )
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
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_material_name(self, obj, layer_index):
+ element = tool.Ifc.get_entity(obj)
+ if not element:
+ return None
+ mat = ifcopenshell.util.element.get_material(element)
+ if not mat:
+ return None
+ resolved = self._resolve_material(mat, layer_index)
+ if not resolved:
+ 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 execute(self, context):
+ if self.use_regex:
+ 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 02612db198..d9930a2d4d 100644
--- a/src/bonsai/bonsai/bim/module/search/operator.py
+++ b/src/bonsai/bonsai/bim/module/search/operator.py
@@ -18,6 +18,7 @@
import bisect
import json
+import re
import traceback
from typing import TYPE_CHECKING, Any, Literal, assert_never, get_args
@@ -1469,10 +1470,24 @@ 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.\nALT+CLICK also unhide hidden objects (viewport and local hide)."
+ 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)."
key = getattr(properties, "key", None)
active = context.active_object
@@ -1497,12 +1512,27 @@ 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
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(self, context):
self.calculated_sum = 0 # reset if run before
key = "predefined_type" if self.key == "PredefinedType" else self.key
@@ -1510,6 +1540,9 @@ class SelectSimilar(Operator):
tolerance = prefs.doc.tolerance
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)
+
if self.calculate_sum:
self._calculate_sum(context, key)
else:
@@ -1547,6 +1580,45 @@ 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/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py
index aac3c146bd..930d94f619 100644
--- a/src/bonsai/bonsai/bim/module/type/operator.py
+++ b/src/bonsai/bonsai/bim/module/type/operator.py
@@ -16,6 +16,7 @@
# 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
@@ -221,7 +222,7 @@ class SelectType(bpy.types.Operator):
class SelectSimilarType(bpy.types.Operator):
- """Select Similar Type\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)"""
+ """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)"""
bl_idname = "bim.select_similar_type"
bl_label = "Select Similar Type"
@@ -230,15 +231,93 @@ class SelectSimilarType(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 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",
+ )
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
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_type_name(self, obj):
+ element = tool.Ifc.get_entity(obj)
+ if not element:
+ return None
+ relating_type = ifcopenshell.util.element.get_type(element)
+ if not relating_type:
+ 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 execute(self, context):
self.file = tool.Ifc.get()
+ if self.use_regex:
+ return self._execute_regex(context)
if self.remove_from_selection or self.filter_selection:
objects = [context.active_object] if context.active_object else []
else: