Replace select_products flags with a mode enum and consolidate loops

Spatial.select_products now takes mode="ADD"|"REMOVE"|"FILTER" instead
of the mutually exclusive remove / filter_selection booleans, matching
the regex dialog's action enum; helper.selection_mode() maps each
operator's two flag properties to it. core.select_by_material updated
accordingly. Upstream callers pass only products/unhide and are
unaffected.

select_ifc_class, select_similar_type, select_aggregate and
select_linked_aggregates now collect elements and delegate to
select_products instead of hand-rolling unhide/select/filter loops;
select_aggregate's manual IsDecomposedBy recursion is replaced with
get_parts/get_decomposition. Only select_similar keeps a bespoke loop
(tolerance-based value matching).

Intentional behavior deltas (also in the dev note): remove mode uses
plain select_set(False), so select_ifc_class no longer re-anchors the
active object; select_similar_type drops its O(n^2) visible_objects
gate and can latently select hidden occurrences like the other
operators; select_ifc_class and select_similar_type write the
clipboard query in every mode.

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