Refactor select operators onto shared scaffolding in bim.helper

- RegexSelectMixin owns the CTRL+ALT+Click regex dialog (properties,
  draw, pattern compilation, verb/clipboard/report tail) with two
  strategies: apply_regex_by_value for per-object matching and
  select_regex_products for union-of-products operators. The five
  regex operators now implement only apply_regex, get_regex_prefill
  and their clipboard key / count noun.
- decode_select_click(event) centralizes the modifier scheme (unhide /
  remove / filter / legacy / regex_dialog); all nine select operators
  use it. Per-operator LEFTMOUSE type guards were dropped, so keyboard
  invocation now honors modifiers uniformly.
- Tooltips compose from shared constants, fixing casing drift;
  SelectIfcClass and SelectSimilarType switch from docstrings to
  bl_description to allow composition.
- Delete core.select_similar_container, dead since the #7940 merge
  made SelectSimilarContainer call Spatial.select_products directly.

No behavior changes besides the keyboard-invocation and tooltip-text
normalization noted above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-15 08:52:58 -05:00
parent 7a7bc75cba
commit 69486b7d1d
9 changed files with 407 additions and 408 deletions
@@ -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
+146 -1
View File
@@ -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,
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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):
+32 -66
View File
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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,
@@ -17,7 +17,6 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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
+53 -81
View File
@@ -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]
@@ -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):
+33 -73
View File
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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:
-16
View File
@@ -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])