diff --git a/src/blenderbim/blenderbim/bim/module/group/operator.py b/src/blenderbim/blenderbim/bim/module/group/operator.py index 27aa6ec8e7..eefcd97e8f 100644 --- a/src/blenderbim/blenderbim/bim/module/group/operator.py +++ b/src/blenderbim/blenderbim/bim/module/group/operator.py @@ -48,7 +48,7 @@ class LoadGroups(bpy.types.Operator, tool.Ifc.Operator): new = self.props.groups.add() new.ifc_definition_id = group.id() new.name = group.Name or "Unnamed" - new.selection_query = group.Description.split("*selector*")[1] if group.Description else "" + new.selection_query = "" new.tree_depth = tree_depth new.has_children = False new.is_expanded = group.id() in self.expanded_groups diff --git a/src/blenderbim/blenderbim/bim/module/search/__init__.py b/src/blenderbim/blenderbim/bim/module/search/__init__.py index 6797a0df44..599fdf12ca 100644 --- a/src/blenderbim/blenderbim/bim/module/search/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/search/__init__.py @@ -31,10 +31,12 @@ classes = ( operator.FilterModelElements, operator.IfcSelector, operator.LoadQuery, + operator.LoadSearch, operator.OpenQueryLibrary, operator.RemoveFilter, operator.RemoveFilterGroup, operator.ResetObjectColours, + operator.SaveSearch, operator.SaveSelectorQuery, operator.Search, operator.SelectAttribute, diff --git a/src/blenderbim/blenderbim/bim/module/search/data.py b/src/blenderbim/blenderbim/bim/module/search/data.py new file mode 100644 index 0000000000..63c25b4e08 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/search/data.py @@ -0,0 +1,49 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + +import bpy +import json +import blenderbim.tool as tool + + +def refresh(): + SearchData.is_loaded = False + + +class SearchData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.is_loaded = True + cls.data = {} + cls.data["saved_searches"] = cls.saved_searches() + + @classmethod + def saved_searches(cls): + groups = tool.Ifc.get().by_type("IfcGroup") + results = [] + for group in groups: + try: + data = json.loads(group.Description) + if isinstance(data, dict) and data.get("type", None) == "BBIM_Search": + 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")] diff --git a/src/blenderbim/blenderbim/bim/module/search/operator.py b/src/blenderbim/blenderbim/bim/module/search/operator.py index 4e8d123092..1875dffed3 100644 --- a/src/blenderbim/blenderbim/bim/module/search/operator.py +++ b/src/blenderbim/blenderbim/bim/module/search/operator.py @@ -18,6 +18,7 @@ import re import bpy +import json import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.selector @@ -120,48 +121,9 @@ class Search(Operator): def execute(self, context): props = context.scene.BIMSearchProperties - query = [] - for filter_group in props.filter_groups: - filter_group_query = [] - has_instance_or_entity_filter = False - for ifc_filter in filter_group.filters: - if not ifc_filter.value: - continue - if ifc_filter.type == "instance": - has_instance_or_entity_filter = True - filter_group_query.append(ifc_filter.value) - elif ifc_filter.type == "entity": - has_instance_or_entity_filter = True - filter_group_query.append(ifc_filter.value) - elif ifc_filter.type == "attribute": - if not ifc_filter.name: - continue - comparison, value = self.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"{ifc_filter.name}{comparison}{value}") - elif ifc_filter.type == "type": - comparison, value = self.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"type{comparison}{value}") - elif ifc_filter.type == "material": - comparison, value = self.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"material{comparison}{value}") - elif ifc_filter.type == "property": - if not ifc_filter.pset or not ifc_filter.name: - continue - pset = self.wrap_value(ifc_filter, ifc_filter.pset) - name = self.wrap_value(ifc_filter, ifc_filter.name) - comparison, value = self.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"{pset}.{name}{comparison}{value}") - elif ifc_filter.type == "classification": - comparison, value = self.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"classification{comparison}{value}") - elif ifc_filter.type == "location": - comparison, value = self.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"location{comparison}{value}") - if not has_instance_or_entity_filter: - filter_group_query.insert(0, "IfcElement") - query.append(", ".join(filter_group_query)) - query = " + ".join(query) - results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) + results = ifcopenshell.util.selector.filter_elements( + tool.Ifc.get(), tool.Search.export_filter_query(props.filter_groups) + ) total_selected = 0 for element in results: @@ -171,17 +133,47 @@ class Search(Operator): self.report({"INFO"}, f"{len(results)} Results") return {"FINISHED"} - def get_comparison_and_value(self, ifc_filter): - if ifc_filter.value.startswith("!="): - return ("!=", self.wrap_value(ifc_filter, ifc_filter.value[2:].strip())) - return ("=", self.wrap_value(ifc_filter, ifc_filter.value.strip())) - def wrap_value(self, ifc_filter, value): - if value.startswith("/") and value.endswith("/"): - return value - elif value in ("NULL", "TRUE", "FALSE"): - return value - return '"' + value + '"' +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_options = {"REGISTER", "UNDO"} + name: StringProperty(name="Name") + + def _execute(self, context): + if not self.name: + return + + query = tool.Search.export_filter_query(context.scene.BIMSearchProperties.filter_groups) + results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) + + description = json.dumps({"type": "BBIM_Search", "query": query}) + group = ifcopenshell.api.run("group.add_group", tool.Ifc.get(), Name=self.name, Description=description) + ifcopenshell.api.run("group.assign_group", tool.Ifc.get(), products=results, group=group) + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + +class LoadSearch(Operator, tool.Ifc.Operator): + bl_idname = "bim.load_search" + bl_label = "Load Search" + bl_description = "Load search filter from an IFC group" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + props = context.scene.BIMSearchProperties + group = tool.Ifc.get().by_id(int(props.saved_searches)) + query = tool.Search.import_filter_query(group, context.scene.BIMSearchProperties.filter_groups) + + def draw(self, context): + props = context.scene.BIMSearchProperties + row = self.layout.row() + row.prop(props, "saved_searches", text="") + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) class SelectGlobalId(Operator): diff --git a/src/blenderbim/blenderbim/bim/module/search/prop.py b/src/blenderbim/blenderbim/bim/module/search/prop.py index c071b5284b..42ce5a5216 100644 --- a/src/blenderbim/blenderbim/bim/module/search/prop.py +++ b/src/blenderbim/blenderbim/bim/module/search/prop.py @@ -22,6 +22,7 @@ from ifcopenshell.util.selector import Selector import blenderbim.tool as tool from blenderbim.bim.prop import ObjProperty, StrProperty from blenderbim.bim.ifc import IfcStore +from blenderbim.bim.module.search.data import SearchData from bpy.types import PropertyGroup from blenderbim.tool.ifc import Ifc from . import ui, prop, operator @@ -37,6 +38,12 @@ from bpy.props import ( ) +def get_saved_searches(self, context): + if not SearchData.is_loaded: + SearchData.load() + return SearchData.data["saved_searches"] + + def update_is_class_selected(self, context): if self.is_selected: for obj in self.unselected_objects: @@ -106,6 +113,7 @@ class BIMSearchProperties(PropertyGroup): ("instance", "GlobalId", "", "GRIP", 7), ], ) + saved_searches: EnumProperty(items=get_saved_searches, name="Saved Searches") should_use_regex: BoolProperty(name="Search With Regex", default=False) should_ignorecase: BoolProperty(name="Search Ignoring Case", default=True) global_id: StringProperty(name="GlobalId") diff --git a/src/blenderbim/blenderbim/bim/module/search/ui.py b/src/blenderbim/blenderbim/bim/module/search/ui.py index ead9999e24..4785fd14bb 100644 --- a/src/blenderbim/blenderbim/bim/module/search/ui.py +++ b/src/blenderbim/blenderbim/bim/module/search/ui.py @@ -18,7 +18,7 @@ import bpy from bpy.types import Panel -from blenderbim.bim.ifc import IfcStore +from blenderbim.bim.module.search.data import SearchData class BIM_PT_search(Panel): @@ -30,8 +30,18 @@ class BIM_PT_search(Panel): bl_parent_id = "BIM_PT_selection" def draw(self, context): + if not SearchData.is_loaded: + SearchData.load() + props = context.scene.BIMSearchProperties + row = self.layout.row(align=True) + row.label(text=f"{len(SearchData.data['saved_searches'])} Saved Searches") + + if SearchData.data["saved_searches"]: + row.operator("bim.load_search", text="", icon="IMPORT") + row.operator("bim.save_search", text="", icon="EXPORT") + row = self.layout.row(align=True) row.operator("bim.add_filter_group", text="Add Search Group", icon="ADD") diff --git a/src/blenderbim/blenderbim/tool/search.py b/src/blenderbim/blenderbim/tool/search.py index 3448ac1ee4..f5569a2931 100644 --- a/src/blenderbim/blenderbim/tool/search.py +++ b/src/blenderbim/blenderbim/tool/search.py @@ -1,11 +1,173 @@ import bpy +import json +import lark import blenderbim.core.tool import blenderbim.tool as tool +import ifcopenshell.util.selector from ifcopenshell.util.selector import Selector class Search(blenderbim.core.tool.Search): + @classmethod + def import_filter_query(cls, group, filter_groups): + query = json.loads(group.Description)["query"] + filter_groups.clear() + l = lark.Lark(ifcopenshell.util.selector.filter_elements_grammar) + transformer = ImportFilterQueryTransformer(filter_groups) + transformer.transform(l.parse(query)) + + @classmethod + def export_filter_query(cls, filter_groups): + query = [] + for filter_group in filter_groups: + filter_group_query = [] + has_instance_or_entity_filter = False + for ifc_filter in filter_group.filters: + if not ifc_filter.value: + continue + if ifc_filter.type == "instance": + has_instance_or_entity_filter = True + filter_group_query.append(ifc_filter.value) + elif ifc_filter.type == "entity": + has_instance_or_entity_filter = True + filter_group_query.append(ifc_filter.value) + elif ifc_filter.type == "attribute": + if not ifc_filter.name: + continue + comparison, value = cls.get_comparison_and_value(ifc_filter) + filter_group_query.append(f"{ifc_filter.name}{comparison}{value}") + elif ifc_filter.type == "type": + comparison, value = cls.get_comparison_and_value(ifc_filter) + filter_group_query.append(f"type{comparison}{value}") + elif ifc_filter.type == "material": + comparison, value = cls.get_comparison_and_value(ifc_filter) + filter_group_query.append(f"material{comparison}{value}") + elif ifc_filter.type == "property": + if not ifc_filter.pset or not ifc_filter.name: + continue + pset = cls.wrap_value(ifc_filter, ifc_filter.pset) + name = cls.wrap_value(ifc_filter, ifc_filter.name) + comparison, value = cls.get_comparison_and_value(ifc_filter) + filter_group_query.append(f"{pset}.{name}{comparison}{value}") + elif ifc_filter.type == "classification": + comparison, value = cls.get_comparison_and_value(ifc_filter) + filter_group_query.append(f"classification{comparison}{value}") + elif ifc_filter.type == "location": + comparison, value = cls.get_comparison_and_value(ifc_filter) + filter_group_query.append(f"location{comparison}{value}") + if not has_instance_or_entity_filter: + filter_group_query.insert(0, "IfcElement") + query.append(", ".join(filter_group_query)) + return " + ".join(query) + + @classmethod + def get_comparison_and_value(cls, ifc_filter): + if ifc_filter.value.startswith("!="): + return ("!=", cls.wrap_value(ifc_filter, ifc_filter.value[2:].strip())) + return ("=", cls.wrap_value(ifc_filter, ifc_filter.value.strip())) + + @classmethod + def wrap_value(cls, ifc_filter, value): + if value.startswith("/") and value.endswith("/"): + return value + elif value in ("NULL", "TRUE", "FALSE"): + return value + return '"' + value.replace('"', '\\"') + '"' + @classmethod def from_selector_query(cls, query): """Returns a list of products from a selector query""" return Selector().parse(tool.Ifc.get(), query) + + +class ImportFilterQueryTransformer(lark.Transformer): + def __init__(self, filter_groups): + self.filter_groups = filter_groups + + def get_results(self): + results = set() + for r in self.results: + results |= r + return results + + def facet_list(self, args): + new = self.filter_groups.add() + for arg in args: + new2 = new.filters.add() + new2.type = arg["type"] + new2.value = arg["value"] + if "name" in arg: + new2.name = arg["name"] + if "pset" in arg: + new2.pset = arg["pset"] + + def facet(self, args): + return args[0] + + def instance(self, args): + return {"type": "instance", "value": " ".join([a.children[0].value for a in args])} + + def entity(self, args): + return {"type": "entity", "value": " ".join([a.children[0].value for a in args])} + + def attribute(self, args): + name, comparison, value = args + name = name.children[0].value + return {"type": "attribute", "name": name, "value": f"{comparison}{value}"} + + def type(self, args): + comparison, value = args + return {"type": "type", "value": f"{comparison}{value}"} + + def material(self, args): + comparison, value = args + return {"type": "material", "value": f"{comparison}{value}"} + + def property(self, args): + pset, prop, comparison, value = args + return {"type": "property", "pset": pset, "name": prop, "value": f"{comparison}{value}"} + + def classification(self, args): + comparison, value = args + return {"type": "classification", "value": f"{comparison}{value}"} + + def location(self, args): + comparison, value = args + return {"type": "location", "value": f"{comparison}{value}"} + + def comparison(self, args): + return "" if args[0].data == "equals" else "!=" + + def pset(self, args): + return self.value(args) + + def prop(self, args): + return self.value(args) + + def value(self, args): + if args[0].data == "unquoted_string": + return args[0].children[0].value + elif args[0].data == "quoted_string": + return args[0].children[0].value[1:-1].replace('\\"', '"') + elif args[0].data == "regex_string": + return args[0].children[0].value + elif args[0].data == "special": + if args[0].children[0].data == "null": + return "NULL" + elif args[0].children[0].data == "true": + return "TRUE" + elif args[0].children[0].data == "false": + return "FALSE" + + def compare(self, element_value, comparison, value): + if isinstance(value, str): + if isinstance(element_value, int): + value = int(value) + elif isinstance(element_value, float): + value = float(value) + result = element_value == value + elif isinstance(value, re.Pattern): + result = bool(value.match(element_value)) + elif value in (None, True, False): + result = element_value is value + return result if comparison == "=" else not result diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 8e43ee5571..6a09fae999 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -24,6 +24,71 @@ import ifcopenshell.util.element import ifcopenshell.util.classification +filter_elements_grammar = """start: filter_group + filter_group: facet_list ("+" facet_list)* + facet_list: facet ("," facet)* + + facet: instance | entity | attribute | type | material | property | classification | location + + instance: not? globalid + globalid: /[0-3][a-zA-Z0-9_$]{21}/ + entity: not? ifc_class + attribute: attribute_name comparison value + type: "type" comparison value + material: "material" comparison value + property: pset "." prop comparison value + classification: "classification" comparison value + location: "location" comparison value + + pset: quoted_string | unquoted_string | regex_string + prop: quoted_string | unquoted_string | regex_string + + attribute_name: /[A-Z]\\w+/ + ifc_class: /Ifc\\w+/ + + value: special | quoted_string | unquoted_string | regex_string + unquoted_string: /[^.=\\s]+/ + quoted_string: ESCAPED_STRING + regex_string: "/" /[^\\/]+/ "/" + + special: null | true | false + + comparison: not? equals + not: "!" + equals: "=" + null: "NULL" + true: "TRUE" + false: "FALSE" + + // Embed common.lark for packaging + DIGIT: "0".."9" + HEXDIGIT: "a".."f"|"A".."F"|DIGIT + INT: DIGIT+ + SIGNED_INT: ["+"|"-"] INT + DECIMAL: INT "." INT? | "." INT + _EXP: ("e"|"E") SIGNED_INT + FLOAT: INT _EXP | DECIMAL _EXP? + SIGNED_FLOAT: ["+"|"-"] FLOAT + NUMBER: FLOAT | INT + SIGNED_NUMBER: ["+"|"-"] NUMBER + _STRING_INNER: /.*?/ + _STRING_ESC_INNER: _STRING_INNER /(?