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])