Compare commits

...

24 Commits

Author SHA1 Message Date
Ryan Schultz 03b3d3f627 Make Type Attributes panel values select_similar buttons
Each attribute value in BIM_PT_type_attributes is now a
bim.select_similar button with key "type.<Attribute>" (the selector
walks type. natively), mirroring the object Attributes panel pattern.
This gives type attributes the full modifier scheme - select, SHIFT
remove, CTRL filter, CTRL+SHIFT sum, ALT unhide, CTRL+ALT regex
dialog - with no operator changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:30:59 -05:00
Ryan Schultz e6697d0956 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>
2026-07-15 09:09:52 -05:00
Ryan Schultz 69486b7d1d 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>
2026-07-15 08:52:58 -05:00
Ryan Schultz 7a7bc75cba Add CTRL+ALT+Click regex-search dialog to five select operators
Opens a props dialog prefilled with the active object's value (key
value, type name, material name, group name, or aggregate name), then
matches it as an unanchored Python regex (entering foo behaves like
.*foo.*) with an Add / Remove / Filter action dropdown and an "Also
Unhide Hidden Objects" checkbox. Applied to select_similar,
select_similar_type, select_by_material, select_group_elements and
select_aggregate.

Multi-match operators (groups, aggregates) union their matches before
a single Spatial.select_products call so Filter cannot wrongly
intersect per-match. select_aggregate matches only IfcElement
aggregates, excluding spatial decomposition, and exposes Also Select
Parts / One Level Deep in the dialog. The equivalent selector query
(e.g. material = /.*foo.*/) is copied to the clipboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 08:21:27 -05:00
Ryan Schultz be8595e9a3 Merge PR #7940 (Concatenate_selections) to resolve build conflicts
Both branches grew from Unhide_with_alt_click and rewrote the same
select operators. This merge makes #7940 an ancestor so both PRs can
coexist in BonsaiPR builds, reconciling intents:

- select_aggregate: #7940 aggregate dedup dict + #8241
  keep_current_selection/active-object seeds
- SelectByMaterial: #7940 selection-derived materials + #8241
  remove/filter threading (active object only in those modes)
- SelectIfcClass: #7940 concatenated clipboard query + #8241 filter
  branch and deselect path
- SelectSimilarContainer: #7940 multi-container derivation and
  clipboard + #8241 remove/filter threading (active object only in
  those modes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:33:19 -05:00
Ryan Schultz 6c97b6fa5e Add dev-notes doc for Additional_Selection_and_Deselection_Tools
Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:38:58 -05:00
Ryan Schultz 11be7e2b62 Add CTRL+Click filter-selection mode to select operators
CTRL+Click keeps only the already selected objects that match the
criteria (from the active object, or the clicked UI item), selecting
nothing new. Applied to the same operators as SHIFT+Click remove:
select_similar, select_ifc_class, select_similar_type,
select_by_material, select_similar_container,
select_decomposed_elements, select_group_elements, select_aggregate
and select_linked_aggregates, threaded through
Spatial.select_products(filter_selection=...).

Modifier scheme is now uniform: Click selects, SHIFT removes,
CTRL filters, ALT unhides. Legacy plain-CTRL functions moved to
CTRL+SHIFT (one-level-deep, exclude-children, calculate-sum).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:25:29 -05:00
Ryan Schultz 3c6d08fabf Add SHIFT+Click remove-from-selection to select operators
Follows the bim.select_similar precedent: SHIFT+Click deselects the
matched objects instead of selecting them, with criteria taken from
the active object only. Applied to select_ifc_class,
select_similar_type, select_by_material, select_similar_container,
select_decomposed_elements, select_group_elements, select_aggregate
and select_linked_aggregates, threaded through
Spatial.select_products(remove=...).

SHIFT bindings this overwrites (to be reworked later):
- select_ifc_class: match Predefined Type
- select_decomposed_elements: select all listed elements

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:47:18 -05:00
Ryan Schultz 60ccd3ab7e Add ALT+Click unhide to select_group_elements
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:16:04 -05:00
Ryan Schultz 7e3a9e4a75 Derive materials from selected objects in select_by_material
Previously only the explicit material prop was used. Now all
selected objects' materials are collected, with layer set usages
resolved to the specific layer index matching the clicked material.
Results are joined with " + " in the clipboard query.

Generated with the assistance of an AI coding tool.
2026-04-19 06:53:46 -05:00
Ryan Schultz 2a91d212b3 Deduplicate aggregates in select_aggregate clipboard query
All selected objects sharing the same aggregate would produce
duplicate entries in the clipboard query. Aggregates are now
collected into a dict keyed by id before selection and query
generation.

Generated with the assistance of an AI coding tool.
2026-04-19 06:53:46 -05:00
Ryan Schultz a14b02d32f Select from multiple containers in select_similar_container
Previously only the active object's container was used.
Now all selected objects' containers are collected and their
decomposed elements selected, with the query copied to the
clipboard as location = "A" + location = "B".

Generated with the assistance of an AI coding tool.
2026-04-19 06:53:46 -05:00
Ryan Schultz 20aa3ff94e Fix select_ifc_class clipboard query output
Previously the clipboard was set inside the class loop,
overwriting on each iteration and reporting multiple times.
Now all classes are joined with " + " and the clipboard is
set once after selection completes.

Generated with the assistance of an AI coding tool.
2026-04-19 06:53:46 -05:00
Ryan Schultz a2ec575618 Concatenate all selected values in select_similar
When multiple objects are selected, _generate_clipboard_query
previously only used the first reference value. Now all values
are joined with " + " so the clipboard query reflects every
selected object (e.g. GlobalId = "A" + GlobalId = "B").

Generated with the assistance of an AI coding tool.
2026-04-19 06:53:46 -05:00
Ryan Schultz f0d8a6f4f4 Add ALT+Click unhide to select_aggregate
When ALT is held, BIM_OT_select_aggregate now clears
hide_viewport and hide_set on matched objects before
selecting, consistent with the pattern used across other
selection operators on this branch.

Generated with the assistance of an AI coding tool.
2026-04-19 06:49:12 -05:00
Ryan Schultz 2ad30a00d7 Add container tools to spatial decomposition panel
- Add bim.select_similar_container button to the spatial
  decomposition panel, gated behind the new
  show_container_tools preference (renamed from
  container_hide_show_isolate)
- Fix select_similar_container to resolve container from
  the active list item rather than the active object
- Unhide individual objects when showing container
  visibility (set_container_visibility SHOW/ISOLATE)

Generated with the assistance of an AI coding tool.
2026-03-20 20:06:53 -05:00
Ryan Schultz e3d5e1d1fe Unhide objects when showing container visibility
When mode is SHOW or ISOLATE, also unhide individual
objects (hide_viewport and hide_set) within each
container, not just the collection-level visibility.

Generated with the assistance of an AI coding tool.
2026-03-20 19:38:57 -05:00
Ryan Schultz 15228ac8b5 Add ALT+Click unhide to select_decomposed_elements
Moves "select all listed elements" from ALT to SHIFT,
freeing ALT+Click to unhide hidden objects (viewport
and local hide) before selecting.

Generated with the assistance of an AI coding tool.
2026-03-20 19:20:43 -05:00
Ryan Schultz 011fb6660a Add ALT+Click unhide to select_linked_aggregates
ALT+Click now unhides hidden objects (viewport and local
hide) before selecting, matching the behavior added to
other select operators.

Generated with the assistance of an AI coding tool.
2026-03-20 19:06:04 -05:00
Ryan Schultz cd458780e6 Add ALT+Click unhide to select_by_material
ALT+Click now unhides hidden objects (viewport and local
hide) before selecting, matching the behavior added to
other select operators.

Generated with the assistance of an AI coding tool.
2026-03-20 19:00:47 -05:00
Ryan Schultz 873f9f061f Add ALT+Click unhide to select_similar_container
ALT+Click now unhides hidden objects (viewport and local
hide) before selecting. Also fixes select_products to set
hide_viewport in addition to hide_set when unhiding.

Generated with the assistance of an AI coding tool.
2026-03-20 18:54:43 -05:00
Ryan Schultz ea27b4f3ae Add ALT+Click unhide to select_similar_type
ALT+Click now unhides hidden objects (viewport and local
hide) before selecting, matching the behavior added to
select_ifc_class and select_similar.

Generated with the assistance of an AI coding tool.
2026-03-20 12:33:51 -05:00
Ryan Schultz 1ce62c948c Add ALT+Click unhide to select_similar
ALT+Click now unhides hidden objects (viewport and local
hide) before selecting, matching the same behavior added
to select_ifc_class.

Generated with the assistance of an AI coding tool.
2026-03-20 12:30:11 -05:00
Ryan Schultz f327a999d0 Closes #7780 - Add ALT+Click unhide to select_ifc_class
Clear hide_viewport and hide_set on matched objects
when the operator is invoked with Alt held, so hidden
objects of the target IFC class are revealed and selected.

Generated with the assistance of an AI coding tool.
2026-03-20 12:20:15 -05:00
15 changed files with 967 additions and 150 deletions
@@ -0,0 +1,184 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Additional selection and deselection tools — uniform modifier scheme for select operators
> **Living dev note** for the `Additional_Selection_and_Deselection_Tools` branch/PR.
> Read before working on the feature; append decisions and findings as the PR is
> refined. This is *not* user documentation — at merge it is removed or its durable
> parts promoted to code comments. See [README.md](README.md) for the convention.
## Problem
Bonsai's many "select …" buttons (by class, by type, by material, by container, by
group, by aggregate, by similar value) could only **add** to the selection. There was
no way to subtract matches from a large selection, or to narrow a selection down to
just the matches. `bim.select_similar` had grown a SHIFT+Click
`remove_from_selection`, but nothing else had it, and each operator had accumulated
its own ad-hoc modifier bindings.
## Design
One modifier scheme, applied uniformly across nine operators:
| Modifier | Action |
|---|---|
| Click | select matches (additive) |
| SHIFT+Click | **remove** matches from the selection set |
| CTRL+Click | **filter** the selection to matches only (selects nothing new) |
| CTRL+SHIFT+Click | legacy plain-CTRL function, where one existed |
| ALT+Click | also unhide matches (viewport + local hide) — from the base branch |
| CTRL+ALT+Click | `select_similar`, `select_similar_type`, `select_by_material`, `select_group_elements`, `select_aggregate`: regex-search dialog (see below) |
Operators covered: `bim.select_similar`, `bim.select_ifc_class`,
`bim.select_similar_type`, `bim.select_by_material`, `bim.select_similar_container`,
`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.
- `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
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
objects), following `SelectSimilar._get_reference_values`. If criteria came from
every selected object, a SHIFT/CTRL click would typically match — and wipe or keep —
the entire selection, which is useless. Plain Click keeps the old behavior
(criteria from all selected objects). Operators whose criteria come from the
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=..., 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
functions are: one-level-deep (`select_aggregate`, `select_similar_container`,
`select_decomposed_elements`), exclude-children (`select_group_elements`), and
calculate-sum (`select_similar`). Existing muscle memory for those will now hit
the filter instead — deliberate trade-off.
- **Modifiers resolve exclusively** in every `invoke`: SHIFT means remove only when
CTRL is up, CTRL means filter only when SHIFT is up, so combos are unambiguous.
- **Aggregate operators keep the current selection in remove/filter mode.** Their
normal flow deselects the seed selection before selecting targets; doing that in
remove/filter mode would destroy the very selection being edited
(`keep_current_selection` in `aggregate/operator.py`).
- **`select_ifc_class` filter matches subtypes** (`element.is_a(cls)`), consistent
with normal select mode which uses `file.by_type(cls)` (also subtype-inclusive).
### Regex-search dialog on `select_similar`, `select_similar_type`, `select_by_material`, `select_group_elements`, `select_aggregate` (CTRL+ALT+Click)
On `select_aggregate` the pattern is prefilled with the active object's **aggregate
name** and matched against every `IfcRelAggregates.RelatingObject` that `is_a
IfcElement` (spatial decomposition — project/site/storey — deliberately excluded);
the dialog additionally exposes "Also Select Parts" (+ "One Level Deep") since the
panel's two button variants collapse into one dialog. Union of matched aggregates
(+ parts via `get_parts`/`get_decomposition`) through one `Spatial.select_products`
call; clipboard query `parent = /.*foo.*/`.
On `select_similar_type` the pattern is prefilled with (and matched against) the active
object's **type name**; the clipboard query is `type = /.*foo.*/`. On
`select_by_material` it is the active object's **resolved material name** (via the
#7940 helpers: usage → set, clicked-layer index as hint, `_get_name`), falling back to
the clicked material row's name; clipboard query `material = /.*foo.*/`. On
`select_group_elements` it is the clicked group row's **name**, matched against all
`IfcGroup` names in the file (unnamed groups never match); the union of the matching
groups' elements (recursive by default) goes through a single
`Spatial.select_products` call — union first, so FILTER cannot wrongly intersect
per-group; clipboard query `group = /.*foo.*/`. Otherwise identical to the
`select_similar` behavior below.
Opens a props dialog prefilled with the active object's value for the clicked key; the
(possibly edited) text is compiled as an unanchored Python regex (`re.search`, so
entering `foo` behaves like `.*foo.*`) and applied via an Add / Remove / Filter
dropdown, plus an "Also Unhide Hidden Objects" checkbox (reuses `should_unhide`; in
Add/Remove it sweeps `scene.objects` instead of `visible_objects` and clears both
hide flags on matches; a no-op in Filter since selected objects are visible). CTRL+ALT was free in practice: ALT (unhide) is a no-op in filter mode, which
plain CTRL triggers. Invalid patterns error out and cancel. The equivalent selector
query (`Key = /.*foo.*/`) is copied to the clipboard. Note the prefill is the raw
value — values containing regex metacharacters (e.g. `(`) need escaping before OK.
Overriding `draw()` for the dialog means the F9 redo panel no longer auto-lists the
operator's internal properties for normal runs (it was exposing internals anyway).
### Type Attributes panel hooks into the scheme
`BIM_PT_type_attributes` (type/ui.py) now renders each attribute value as a
`bim.select_similar` button with `key = "type.<Attribute>"` — the same
label-as-button pattern the object Attributes panel uses (attribute/ui.py). The
selector walks `type.` natively, so the full scheme (SHIFT/CTRL/CTRL+SHIFT
sum/ALT/CTRL+ALT regex) applies to type attributes with no operator changes.
### Deliberately overwritten SHIFT bindings (to be reworked later)
Two operators already used SHIFT; the owner chose to overwrite them and revisit with
another approach:
- `bim.select_ifc_class`: SHIFT used to mean "also match Predefined Type". The
`should_filter_predefined_type` property still exists but has **no key binding**.
- `bim.select_decomposed_elements`: SHIFT used to mean "select all listed elements"
(itself moved from ALT when ALT became unhide). `should_filter` still exists,
default True, **no key binding**.
## Status — implemented
Branched from `Unhide_with_alt_click` (ALT+Click unhide across the same operators,
plus container tools in the spatial decomposition panel). Commits so far:
- `3c6d08fabf` SHIFT+Click remove-from-selection (criteria from active object).
- `11be7e2b62` CTRL+Click filter-selection + the CTRL / CTRL+SHIFT swap.
Files: `tool/spatial.py`, `core/tool.py`, `core/material.py`, `core/spatial.py`, and
`bim/module/{search,spatial,type,material,group,aggregate}/operator.py`. All
tooltips document the scheme. Syntax-checked; not yet exercised in Blender.
## Things to test / verify
- Each operator × each modifier, but especially:
- SHIFT with a large selection: only objects matching the *active* object's
criteria are removed; the rest of the selection survives.
- CTRL filter: nothing new gets selected (hidden matches must not appear).
- CTRL+SHIFT still triggers the legacy behavior (one-level-deep etc.) and does
**not** also remove/filter.
- `select_ifc_class` remove/filter with a subclass selected (subtype matching).
- Aggregate operators in remove/filter mode: seed selection intact; in normal mode
behavior unchanged (seeds deselected, aggregates/parts selected).
- `select_decomposed_elements`: the trailing "make active list item the active
object" block must not re-add it in remove mode nor add it in filter mode.
- `select_similar` calculate-sum on numeric keys now requires CTRL+SHIFT; its
tooltip line renders only for numeric values.
- Redo-panel (F9) interaction: all new props are `SKIP_SAVE`, so re-running from the
panel starts clean.
+150 -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,154 @@ 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,
)
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.
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, mode=self.regex_mode)
def draw_attributes(
props: Union[bpy.types.bpy_prop_collection_idprop[Attribute], Sequence[Attribute]],
layout: bpy.types.UILayout,
@@ -27,6 +27,15 @@ 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,
selection_mode,
)
class BIM_OT_aggregate_assign_object(bpy.types.Operator, tool.Ifc.Operator):
@@ -254,7 +263,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"
@@ -266,64 +275,111 @@ class BIM_OT_select_aggregate(bpy.types.Operator):
one_level_deep: bpy.props.BoolProperty(
name="One Level Deep", description="Select only immediate children, not recursively", default=False
)
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"})
regex_clipboard_key = "parent"
regex_count_noun = "aggregates"
@classmethod
def description(cls, context, properties):
if properties.select_parts:
return "Select Aggregate and Parts.\n\nCtrl+click to select only one level deep"
else:
return "Select Aggregate"
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:
self.one_level_deep = True
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 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")
def apply_regex(self, context, pattern):
aggregates = {}
for rel in tool.Ifc.get().by_type("IfcRelAggregates"):
aggregate = rel.RelatingObject
if not aggregate.is_a("IfcElement"):
continue
if not aggregate.Name or not pattern.search(aggregate.Name):
continue
aggregates[aggregate.id()] = aggregate
products = set()
for aggregate in aggregates.values():
products.add(aggregate)
if self.select_parts:
if self.one_level_deep:
products.update(ifcopenshell.util.element.get_parts(aggregate))
else:
products.update(ifcopenshell.util.element.get_decomposition(aggregate))
self.select_regex_products(products)
return len(aggregates)
def execute(self, context):
all_parts = []
for obj in context.selected_objects:
if self.use_regex:
return self.execute_regex(context)
keep_current_selection = self.remove_from_selection or self.filter_selection
if keep_current_selection:
objects = [context.active_object] if context.active_object else []
else:
objects = context.selected_objects
aggregates = {}
for obj in objects:
element = tool.Ifc.get_entity(obj)
if element:
aggregate = ifcopenshell.util.element.get_aggregate(element)
if aggregate:
all_parts.append(aggregate)
obj.select_set(False)
aggregates[aggregate.id()] = aggregate
if not keep_current_selection:
obj.select_set(False)
else:
pass
if not element:
if not element and not keep_current_selection:
obj.select_set(False)
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)
for element in set(selected_parts + all_parts):
obj = tool.Ifc.get_object(element)
if obj:
obj.select_set(True)
else:
if not self.select_parts and not keep_current_selection:
for aggregate_element in all_parts:
aggregate_obj = tool.Ifc.get_object(aggregate_element)
if aggregate_obj:
aggregate_obj.select_set(True)
if aggregate_obj := tool.Ifc.get_object(aggregate_element):
bpy.context.view_layer.objects.active = aggregate_obj
# copy selection query to clipboard
@@ -407,17 +463,36 @@ class BIM_OT_select_linked_aggregates(bpy.types.Operator):
bl_label = "Select linked aggregates"
bl_options = {"REGISTER", "UNDO"}
select_parts: bpy.props.BoolProperty(default=False)
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"})
@classmethod
def description(cls, context, properties):
if properties.select_parts:
return "Select all aggregates, subaggregates and all their parts"
else:
return "Select all aggregates"
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):
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):
for obj in context.selected_objects:
obj.select_set(False)
keep_current_selection = self.remove_from_selection or self.filter_selection
if keep_current_selection:
objects = [context.active_object] if context.active_object else []
else:
objects = context.selected_objects
products = set()
for obj in objects:
if not keep_current_selection:
obj.select_set(False)
element = tool.Ifc.get_entity(obj)
aggregate = ifcopenshell.util.element.get_aggregate(element)
if not aggregate:
@@ -436,21 +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:
obj.select_set(True)
else:
for element in parts:
obj = tool.Ifc.get_object(element)
obj.select_set(True)
tool.Spatial.select_products(
products,
unhide=self.should_unhide,
mode=selection_mode(self.remove_from_selection, self.filter_selection),
)
return {"FINISHED"}
+50 -4
View File
@@ -24,6 +24,15 @@ 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,
selection_mode,
)
class LoadGroups(bpy.types.Operator, tool.Ifc.Operator):
@@ -191,23 +200,60 @@ 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\nALT + CLICK to exclude children"
"Select objects assigned to the selected group and all nested groups"
+ 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"})
regex_clipboard_key = "group"
regex_count_noun = "groups"
def invoke(self, context, event):
self.is_recursive = not event.alt
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 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"):
if not group.Name or not pattern.search(group.Name):
continue
matched_groups += 1
products.update(ifcopenshell.util.element.get_grouped_by(group, is_recursive=self.is_recursive))
self.select_regex_products(products)
return matched_groups
def execute(self, context):
if self.use_regex:
return self.execute_regex(context)
tool.Spatial.select_products(
ifcopenshell.util.element.get_grouped_by(tool.Ifc.get().by_id(self.group), is_recursive=self.is_recursive)
ifcopenshell.util.element.get_grouped_by(tool.Ifc.get().by_id(self.group), is_recursive=self.is_recursive),
unhide=self.should_unhide,
mode=selection_mode(self.remove_from_selection, self.filter_selection),
)
return {"FINISHED"}
@@ -30,6 +30,15 @@ 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,
selection_mode,
)
from bonsai.bim.module.model import slab, wall
if TYPE_CHECKING:
@@ -58,28 +67,164 @@ 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"
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"})
regex_clipboard_key = "material"
def invoke(self, context, event):
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 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)
if not element:
return None
mat = ifcopenshell.util.element.get_material(element)
if not mat:
return None
resolved = self._resolve_material(mat, layer_index)
if not resolved:
return None
return self._get_name(resolved)
def 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):
material = tool.Ifc.get().by_id(self.material)
core.select_by_material(tool.Material, tool.Spatial, material=material)
if self.use_regex:
return self.execute_regex(context)
# Determine the layer index hint from the explicit material prop, if any.
# When the user clicks a specific layer in the UI, self.material is that
# layer's IfcMaterial. We find its index so we can pull the same layer
# from every other selected object's layer set.
layer_index = None
if self.material:
ref_mat = tool.Ifc.get().by_id(self.material)
layer_index = self._get_layer_index(ref_mat)
# copy selection query to clipboard
if material.is_a("IfcMaterialLayerSet"):
material_name = material.LayerSetName
if self.remove_from_selection or self.filter_selection:
objects = [context.active_object] if context.active_object else []
else:
material_name = material.Name
result = f'material="{material_name}"'
objects = context.selected_objects
materials = {}
for obj in objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
mat = ifcopenshell.util.element.get_material(element)
if not mat:
continue
resolved = self._resolve_material(mat, layer_index)
if resolved:
materials[resolved.id()] = resolved
# Fall back to the explicit material prop if selection yields nothing
if not materials and self.material:
materials = {self.material: tool.Ifc.get().by_id(self.material)}
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, mode=mode
)
result = " + ".join(f'material = "{self._get_name(m)}"' for m in materials.values())
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
return {"FINISHED"}
def _get_layer_index(self, material):
"""Return the 0-based layer index if material is an IfcMaterial inside a layer set."""
if not material.is_a("IfcMaterial"):
return None
ifc = tool.Ifc.get()
for layer in ifc.get_inverse(material):
if not layer.is_a("IfcMaterialLayer"):
continue
for layer_set in ifc.get_inverse(layer):
if not layer_set.is_a("IfcMaterialLayerSet"):
continue
layers = list(layer_set.MaterialLayers)
if layer in layers:
return layers.index(layer)
return None
def _resolve_material(self, mat, layer_index):
"""Resolve an assigned material to the specific entity to select/name by.
When layer_index is set, drills into the layer set and returns the
IfcMaterial at that index (or None if the set has fewer layers).
Otherwise returns the layer set / profile set / constituent set itself.
"""
if mat.is_a("IfcMaterialLayerSetUsage"):
mat = mat.ForLayerSet
elif mat.is_a("IfcMaterialProfileSetUsage"):
mat = mat.ForProfileSet
if layer_index is not None and mat.is_a("IfcMaterialLayerSet"):
layers = list(mat.MaterialLayers)
if layer_index < len(layers):
return layers[layer_index].Material
return None
return mat
def _get_name(self, material):
if material.is_a("IfcMaterialLayerSet"):
if material.LayerSetName:
return material.LayerSetName
names = [l.Material.Name for l in (material.MaterialLayers or []) if l.Material and l.Material.Name]
return ", ".join(names) if names else material.is_a()
if material.is_a("IfcMaterialProfileSet"):
if material.Name:
return material.Name
names = [p.Material.Name for p in (material.MaterialProfiles or []) if p.Material and p.Material.Name]
return ", ".join(names) if names else material.is_a()
if material.is_a("IfcMaterialConstituentSet"):
if material.Name:
return material.Name
names = [c.Material.Name for c in (material.MaterialConstituents or []) if c.Material and c.Material.Name]
return ", ".join(names) if names else material.is_a()
return getattr(material, "Name", None) or material.is_a()
class EnableEditingMaterial(bpy.types.Operator):
bl_idname = "bim.enable_editing_material"
+116 -32
View File
@@ -38,6 +38,15 @@ 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,
selection_mode,
)
from bonsai.bim.ifc import IfcStore
from bonsai.bim.prop import StrProperty
@@ -1264,26 +1273,40 @@ class SelectGlobalId(Operator):
class SelectIfcClass(Operator):
"""Click to select all objects that match with the given IFC class\nSHIFT + Click to also match Predefined Type"""
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)
remove_from_selection: BoolProperty(default=False, options={"SKIP_SAVE"})
filter_selection: BoolProperty(default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
self.should_filter_predefined_type = event.shift
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):
objects = context.selected_objects
if self.remove_from_selection or self.filter_selection:
objects = [context.active_object] if context.active_object else []
else:
objects = context.selected_objects
classes = set()
predefined_types = set()
for obj in objects:
if element := tool.Ifc.get_entity(obj):
classes.add(element.is_a())
predefined_types.add(ifcopenshell.util.element.get_predefined_type(element))
result = ""
elements = []
for cls in classes:
for element in tool.Ifc.get().by_type(cls):
if (
@@ -1291,16 +1314,17 @@ class SelectIfcClass(Operator):
and ifcopenshell.util.element.get_predefined_type(element) not in predefined_types
):
continue
if obj := tool.Ifc.get_object(element):
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
if not result:
result = f"{cls}"
else:
result += f", {cls}"
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
# copy selection query to clipboard
result = " + ".join(classes)
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
return {"FINISHED"}
@@ -1440,7 +1464,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"}
@@ -1451,10 +1475,18 @@ class SelectSimilar(Operator):
)
calculated_sum: bpy.props.FloatProperty(name="Calculated Sum", default=0.0)
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"})
@classmethod
def description(cls, context, properties):
base = "Select objects with a similar value\n\n" "SHIFT+CLICK remove from selection set."
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
@@ -1467,7 +1499,7 @@ class SelectSimilar(Operator):
value = ifcopenshell.util.selector.get_element_value(element, key)
if isinstance(value, (int, float)):
return base + ("\nCTRL+CLICK display the sum of all selected objects")
return base + ("\nCTRL+SHIFT+Click to display the sum of all selected objects")
else:
return base
@@ -1479,10 +1511,34 @@ class SelectSimilar(Operator):
return False
def invoke(self, context, event):
self.calculate_sum = event.ctrl and event.type == "LEFTMOUSE"
self.remove_from_selection = event.shift and event.type == "LEFTMOUSE"
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 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
key = "predefined_type" if self.key == "PredefinedType" else self.key
@@ -1490,6 +1546,9 @@ class SelectSimilar(Operator):
tolerance = prefs.doc.tolerance
formatted_tolerance = f"{tolerance:.{max(0, -int(f'{tolerance:.1e}'.split('e')[-1])) if tolerance < 1 else 1}f}"
if self.use_regex:
return self.execute_regex(context)
if self.calculate_sum:
self._calculate_sum(context, key)
else:
@@ -1499,7 +1558,12 @@ class SelectSimilar(Operator):
return {"CANCELLED"}
matched_count = self._select_objects(context, key, reference_values, tolerance)
verb = "Deselected" if self.remove_from_selection else "Selected"
if self.filter_selection:
verb = "Filtered selection to"
elif self.remove_from_selection:
verb = "Deselected"
else:
verb = "Selected"
if all(isinstance(v, (int, float)) for v in reference_values):
self.report(
@@ -1512,7 +1576,7 @@ class SelectSimilar(Operator):
f"{verb} all objects that share the same ({self.key}) value(s) from {len(reference_values)} reference object(s).",
)
self._generate_clipboard_query(reference_values[0] if reference_values else None, key)
self._generate_clipboard_query(reference_values, key)
return {"FINISHED"}
@@ -1525,7 +1589,7 @@ class SelectSimilar(Operator):
def _get_reference_values(self, context, key):
objects = (
[context.active_object]
if self.remove_from_selection
if self.remove_from_selection or self.filter_selection
else (context.selected_objects or [context.active_object])
)
values = [self._get_value(obj, key) for obj in objects]
@@ -1538,11 +1602,26 @@ class SelectSimilar(Operator):
def _select_objects(self, context, key, reference_values, tolerance):
count = 0
for obj in context.visible_objects:
if self.filter_selection:
# Keep only the already selected objects that match, select nothing new.
for obj in context.selected_objects:
obj_value = self._get_value(obj, key)
if obj_value is not None and any(
self._compare_values(obj_value, ref_value, tolerance) for ref_value in reference_values
):
count += 1
else:
obj.select_set(False)
return count
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 None:
continue
if any(self._compare_values(obj_value, ref_value, tolerance) for ref_value in reference_values):
if self.should_unhide:
obj.hide_viewport = False
obj.hide_set(False)
obj.select_set(not self.remove_from_selection)
count += 1
return count
@@ -1557,17 +1636,22 @@ class SelectSimilar(Operator):
bpy.context.window_manager.clipboard = str(total)
self.report({"INFO"}, f"({total}) was copied to the clipboard.")
def _generate_clipboard_query(self, value, key):
def _generate_clipboard_query(self, values, key):
key = "PredefinedType" if key == "predefined_type" else key
if value is True:
value = "TRUE"
elif value is False:
value = "FALSE"
if not values:
return
if isinstance(value, list) and value:
result = ", ".join(f'{key} = "{item}"' for item in value)
else:
result = f'{key} = "{value}"'
def format_value(value):
if value is True:
return f'{key} = "TRUE"'
elif value is False:
return f'{key} = "FALSE"'
elif isinstance(value, list) and value:
return ", ".join(f'{key} = "{item}"' for item in value)
else:
return f'{key} = "{value}"'
result = " + ".join(format_value(v) for v in values)
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
@@ -24,6 +24,13 @@ 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,
selection_mode,
)
class ReferenceStructure(bpy.types.Operator, tool.Ifc.Operator):
@@ -284,23 +291,65 @@ class SelectContainer(bpy.types.Operator):
class SelectSimilarContainer(bpy.types.Operator):
bl_idname = "bim.select_similar_container"
bl_label = "Select Similar Container"
bl_description = "Recurvisevly selects all objects in the container.\n\nCtrl+click to select only one level deep"
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)
is_recursive: bpy.props.BoolProperty(default=True)
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"})
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.ctrl:
self.is_recursive = False
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):
core.select_similar_container(
tool.Ifc,
tool.Spatial,
obj=context.active_object,
is_recursive=self.is_recursive,
)
if self.container:
# Called from container manager panel with explicit container
ifc_container = tool.Ifc.get().by_id(self.container)
containers = {ifc_container.id(): ifc_container} if ifc_container else {}
else:
# Called from 3D viewport — derive containers from the selected objects
# (active object only in remove/filter mode, so a single criteria source)
if self.remove_from_selection or self.filter_selection:
objects = [context.active_object] if context.active_object else []
else:
objects = context.selected_objects or [context.active_object]
containers = {}
for obj in objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
container = tool.Spatial.get_container(element)
if container:
containers[container.id()] = container
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,
mode=mode,
)
result = " + ".join(f'location = "{c.Name}"' for c in containers.values())
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
self.is_recursive = True # <-- forcibly reset
return {"FINISHED"}
@@ -425,24 +474,34 @@ class SelectDecomposedElements(bpy.types.Operator):
should_filter: bpy.props.BoolProperty(name="Should Filter", default=True, options={"SKIP_SAVE"})
container: bpy.props.IntProperty()
is_recursive: bpy.props.BoolProperty(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"})
@classmethod
def description(cls, context, operator):
return (
"Select the active item"
+ "\nALT+CLICK to select all listed elements.\nCTRL + CLICK to select only one level deep"
+ 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.alt:
self.should_filter = False
if event.ctrl:
self.is_recursive = False
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):
tool.Spatial.select_products(tool.Spatial.get_filtered_elements(self.should_filter, self.is_recursive))
tool.Spatial.select_products(
tool.Spatial.get_filtered_elements(self.should_filter, self.is_recursive),
unhide=self.should_unhide,
mode=selection_mode(self.remove_from_selection, self.filter_selection),
)
# Make selected active element in list, the active object
props = tool.Spatial.get_spatial_props()
@@ -453,7 +512,8 @@ class SelectDecomposedElements(bpy.types.Operator):
obj = tool.Ifc.get_object(ifc_entity)
if obj:
context.view_layer.objects.active = obj
obj.select_set(True)
if not self.filter_selection:
obj.select_set(not self.remove_from_selection)
return {"FINISHED"}
@@ -525,6 +585,11 @@ class SetContainerVisibility(bpy.types.Operator):
if obj := tool.Ifc.get_object(container):
if collection := tool.Blender.get_object_bim_props(obj).collection:
collection.hide_viewport = should_hide
if not should_hide:
for element in tool.Spatial.get_decomposed_elements(container, is_recursive=False):
if element_obj := tool.Ifc.get_object(element):
element_obj.hide_viewport = False
element_obj.hide_set(False)
if self.should_include_children:
queue.extend(ifcopenshell.util.element.get_parts(container))
return {"FINISHED"}
+5 -2
View File
@@ -139,7 +139,7 @@ class BIM_PT_spatial_decomposition(Panel):
op = col.operator("bim.set_default_container", icon="OUTLINER_COLLECTION", text="Set Default")
op.container = ifc_definition_id
if tool.Blender.get_addon_preferences().container_hide_show_isolate:
if tool.Blender.get_addon_preferences().show_container_tools:
op = row.operator("bim.set_container_visibility", icon="FULLSCREEN_EXIT", text="")
op.mode = "ISOLATE"
op.container = ifc_definition_id
@@ -152,7 +152,10 @@ class BIM_PT_spatial_decomposition(Panel):
# The only operator that's enabled for IfcProject.
col = row.column(align=True)
col.operator("bim.select_container", icon="OBJECT_DATA", text="").container = ifc_definition_id
row_ = col.row(align=True)
row_.operator("bim.select_container", icon="OBJECT_DATA", text="").container = ifc_definition_id
if tool.Blender.get_addon_preferences().show_container_tools:
row_.operator("bim.select_similar_container", icon="RESTRICT_SELECT_OFF", text="").container = ifc_definition_id
col = row.column(align=True)
op = col.operator("bim.delete_container", icon="X", text="")
+67 -8
View File
@@ -29,6 +29,15 @@ 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,
selection_mode,
)
class AssignType(bpy.types.Operator, tool.Ifc.Operator):
@@ -220,15 +229,60 @@ class SelectType(bpy.types.Operator):
return collection_in_view_layer
class SelectSimilarType(bpy.types.Operator):
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"})
regex_clipboard_key = "type"
def invoke(self, context, event):
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 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)
if not element:
return None
relating_type = ifcopenshell.util.element.get_type(element)
if not relating_type:
return None
return relating_type.Name
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()
objects = bpy.context.selected_objects
if self.use_regex:
return self.execute_regex(context)
if self.remove_from_selection or self.filter_selection:
objects = [context.active_object] if context.active_object else []
else:
objects = bpy.context.selected_objects
# store relating types to avoid selecting same elements multiple times
relating_types = set()
@@ -240,22 +294,27 @@ class SelectSimilarType(bpy.types.Operator):
continue
relating_types.add(relating_type)
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 obj in context.visible_objects:
obj.select_set(True)
# 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 -1
View File
@@ -151,7 +151,8 @@ class BIM_PT_type_attributes(Panel):
row = layout.row(align=True)
row.label(text=attribute["name"])
value = get_display_value(attribute["value"])
row.label(text=value)
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
op.key = f"type.{attribute['name']}"
def add_object_button(self, context):
+5 -5
View File
@@ -714,9 +714,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
description="Default parameters for BIM elements",
)
container_hide_show_isolate: BoolProperty(
name="Container hide/show/isolate",
description="Enable container hide/show/isolate feature in the UI",
show_container_tools: BoolProperty(
name="Show Container Tools (Select, Hide, Show, Isolate)",
description="Enable container select, hide/show/isolate tools in the UI",
default=False,
)
@@ -786,7 +786,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
pset_dir: str
doc: DocPreferences
default_parameters: DefaultParameters
container_hide_show_isolate: bool
show_container_tools: bool
chain_filter_with_set_operations: bool
save_metadata_blend_file: bool
metadata_blend_file_suffix: str
@@ -984,7 +984,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.prop(self, "bsdd_baseurl")
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
layout.prop(self, "show_container_tools")
row = layout.row(align=True)
row.prop(self, "chain_filter_with_set_operations")
row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270"
+6 -2
View File
@@ -82,9 +82,13 @@ def disable_editing_materials(material: type[tool.Material]) -> None:
def select_by_material(
material_tool: type[tool.Material], spatial: type[tool.Spatial], material: ifcopenshell.entity_instance
material_tool: type[tool.Material],
spatial: type[tool.Spatial],
material: ifcopenshell.entity_instance,
should_unhide: bool = False,
mode: str = "ADD",
) -> None:
spatial.select_products(material_tool.get_elements_by_material(material))
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:
-11
View File
@@ -119,17 +119,6 @@ def select_container(
spatial.set_active_object(ifc.get_object(container), selection_mode=selection_mode)
def select_similar_container(
ifc: type[tool.Ifc],
spatial: type[tool.Spatial],
obj: bpy.types.Object,
is_recursive: bool = True,
) -> None:
element = ifc.get_entity(obj)
if element:
spatial.select_products(spatial.get_decomposed_elements(spatial.get_container(element), is_recursive))
def select_product(spatial: type[tool.Spatial], product: ifcopenshell.entity_instance) -> None:
spatial.select_products([product])
+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): 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
+19 -2
View File
@@ -192,16 +192,33 @@ class Spatial(bonsai.core.tool.Spatial):
target_obj.matrix_world = relative_to_obj.matrix_world @ matrix
@classmethod
def select_products(cls, products: Iterable[ifcopenshell.entity_instance], unhide: bool = False) -> None:
def select_products(
cls,
products: Iterable[ifcopenshell.entity_instance],
unhide: 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 mode == "FILTER":
# Keep only the already selected objects that match, select nothing new.
matched_objs = set()
for product in products:
obj = tool.Ifc.get_object(product)
if obj and view_layer.objects.get(obj.name):
matched_objs.add(obj)
for obj in bpy.context.selected_objects:
if obj not in matched_objs:
obj.select_set(False)
return
for product in products:
obj = tool.Ifc.get_object(product)
if obj and view_layer.objects.get(obj.name):
if unhide:
obj.hide_viewport = False
obj.hide_set(False)
obj.select_set(True)
obj.select_set(mode != "REMOVE")
@classmethod
def filter_products(