diff --git a/src/bonsai/bonsai/bim/module/search/data.py b/src/bonsai/bonsai/bim/module/search/data.py index c82f26e7d6..34f032c2b5 100644 --- a/src/bonsai/bonsai/bim/module/search/data.py +++ b/src/bonsai/bonsai/bim/module/search/data.py @@ -20,6 +20,7 @@ import bpy import json import ifcopenshell.util.element import bonsai.tool as tool +from natsort import natsorted def refresh(): @@ -39,11 +40,11 @@ class SearchData: cls.data["saved_searches"] = cls.saved_searches() @classmethod - def saved_searches(cls): + def saved_searches(cls) -> list[tuple[str, str, str]]: if not tool.Ifc.get(): return [] groups = tool.Ifc.get().by_type("IfcGroup") - results = [] + results: list[ifcopenshell.entity_instance] = [] for group in groups: try: data = json.loads(group.Description) @@ -51,7 +52,8 @@ class SearchData: results.append(group) except: pass - return [(str(g.id()), g.Name or "Unnamed", "") for g in sorted(results, key=lambda x: x.Name or "Unnamed")] + enum_items = [(str(g.id()), g.Name or "Unnamed", "") for g in results] + return natsorted(enum_items, key=lambda x: x[1]) class ColourByPropertyData: diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 223c80baa9..8ae7d52376 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -179,10 +179,49 @@ class Search(Operator): class SaveSearch(Operator, tool.Ifc.Operator): bl_idname = "bim.save_search" bl_label = "Save Search" - bl_description = "Save search filter to an IFC group" + bl_description = ( + "Save search filter to an IFC group.\n\n" + "Search query will be saved to group description, query elements will be assigned to the group." + ) bl_options = {"REGISTER", "UNDO"} - name: StringProperty(name="Name") - module: StringProperty() + + name_search_items: list[str] = [] + + def get_name_search_items(self, context: object, text: str) -> list[str]: + # Extra item so it will be easy to select current text. + return [text] + SaveSearch.name_search_items + + name: StringProperty( # pyright: ignore[reportRedeclaration] + name="Name", + search=get_name_search_items, + search_options={"SORT"}, + ) + module: StringProperty() # pyright: ignore[reportRedeclaration] + + def update_use_all_ifcgroups(self, context: object = None) -> None: + ifc_file = tool.Ifc.get() + groups = { + g.Name or "Unnamed" + for g in ifc_file.by_type("IfcGroup") + if self.use_all_ifcgroups or g.ObjectType == "SEARCH" + } + self.name_search_items[:] = natsorted(groups) + + use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration] + name="Use Any IfcGroup", + description=( + "By default we're targeting only IfcGroups with SEARCH ObjectType " + "to prevent breaking internal IfcGroups (e.g. IfcGroups used for Bonsai drawings).\n\n" + "Enabling this option allows saving search to any IfcGroup matched by the provided name.\n" + "Use with caution." + ), + update=update_use_all_ifcgroups, + ) + + if TYPE_CHECKING: + name: str + module: str + use_all_ifcgroups: bool def _execute(self, context): if not self.name: @@ -199,20 +238,32 @@ class SaveSearch(Operator, tool.Ifc.Operator): return description = json.dumps({"type": "BBIM_Search", "query": query}) - group = [g for g in tool.Ifc.get().by_type("IfcGroup") if g.Name == self.name] + ifc_file = tool.Ifc.get() + group = next( + ( + g + for g in ifc_file.by_type("IfcGroup") + if g.Name == self.name and (self.use_all_ifcgroups or g.ObjectType == "SEARCH") + ), + None, + ) if group: - group = group[0] group.Description = description else: group = ifcopenshell.api.group.add_group(tool.Ifc.get(), name=self.name, description=description) + group.ObjectType = "SEARCH" if results: ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=list(results), group=group) def draw(self, context): - row = self.layout.row() - row.prop(self, "name") + assert (layout := self.layout) + layout.prop(self, "name") + layout.prop(self, "use_all_ifcgroups") def invoke(self, context, event): + assert context.window_manager + tool.Search.patch_search_ifcgroups() + self.update_use_all_ifcgroups() return context.window_manager.invoke_props_dialog(self) @@ -230,11 +281,13 @@ class LoadSearch(Operator, tool.Ifc.Operator): tool.Search.import_filter_query(tool.Search.get_group_query(group), filter_groups) def draw(self, context): + assert self.layout props = tool.Search.get_search_props() row = self.layout.row() row.prop(props, "saved_searches", text="") def invoke(self, context, event): + tool.Search.patch_search_ifcgroups() return context.window_manager.invoke_props_dialog(self) diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index ce805a3bf3..b304fe7b81 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -311,6 +311,29 @@ class Search(bonsai.core.tool.Search): query = f"bpy.data.texts['{name}']" return query + @classmethod + def patch_search_ifcgroups(cls) -> None: + """Apply a patch trying to convert old search IfcGroups to SEARCH type. + + Previously we were saving search results to IfcGroup with ObjectType None + and allowing to write results to any IfcGroup by default, which could lead + to breaking by accident drawings or other internal IfcGroups. + + Added temporarily @25.07.04 + """ + ifc_file = tool.Ifc.get() + + for group in ifc_file.by_type("IfcGroup"): + # It's unsafe to change any other IfcGroup - e.g. it could be DRAWING. + if group.ObjectType is not None: + continue + try: + data = json.loads(group.Description) + if isinstance(data, dict) and data.get("type") == "BBIM_Search": + group.ObjectType = "SEARCH" + except: + pass + class ImportFilterQueryTransformer(lark.Transformer): def __init__(self, filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup]):