diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index 49f49582b4..873d368fad 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -498,6 +498,8 @@ def draw_filter( if data.data["saved_searches"]: row.operator("bim.load_search", text="", icon="IMPORT").module = module row.operator("bim.save_search", text="", icon="EXPORT").module = module + if data.data["saved_searches"]: + row.operator("bim.remove_search", text="", icon="REMOVE").module = module if module != "search": if module == "drawing_include": row.operator("bim.edit_element_filter", icon="CHECKMARK", text="").filter_mode = "INCLUDE" @@ -505,8 +507,17 @@ def draw_filter( row.operator("bim.edit_element_filter", icon="CHECKMARK", text="").filter_mode = "EXCLUDE" row.operator("bim.enable_editing_element_filter", icon="CANCEL", text="").filter_mode = "NONE" row = layout.row(align=True) - row.operator("bim.add_filter_group", text="Add Search Group", icon="ADD").module = module - row.operator("bim.edit_filter_query", text="", icon="FILTER").module = module + if not tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + row.operator("bim.add_filter_group", text="Add Search Group", icon="ADD").module = module + else: + if not filter_groups or not any(fg.filters for fg in filter_groups): + op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") + op.type = "entity" + op.index = 0 + op.module = module + op = row.operator("bim.edit_filter_query", text="", icon="FILTER") + if "module" in op.bl_rna.properties: + op.module = module for i, filter_group in enumerate(filter_groups): box = layout.box() @@ -524,43 +535,247 @@ def draw_filter( for j, ifc_filter in enumerate(filter_group.filters): if ifc_filter.type == "entity": row = box.row(align=True) + preferences = tool.Blender.get_addon_preferences() + if preferences.chain_filter_with_set_operations: + show_mode_toggle = j > 0 + else: + show_mode_toggle = preferences.default_filter_with_set_operations_for_globalid_and_class and j > 0 # PR 7315 mode + if show_mode_toggle: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="FILE_3D") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "attribute": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "name", text="", icon="COPY_ID") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type + op.suggestion_type = "attribute_name" row.prop(ifc_filter, "value", text="") + if ifc_filter.name: + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type + op.suggestion_type = "attribute_value" elif ifc_filter.type == "type": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="FILE_VOLUME") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "material": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="MATERIAL") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "property": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "pset", text="", icon="PROPERTIES") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type + op.suggestion_type = "pset" row.prop(ifc_filter, "name", text="") + if ifc_filter.pset: + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type + op.suggestion_type = "property_name" row.prop(ifc_filter, "comparison", text="") row.prop(ifc_filter, "value", text="") + if ifc_filter.pset and ifc_filter.name: + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type + op.suggestion_type = "property_value" elif ifc_filter.type == "classification": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="OUTLINER") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "location": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="PACKAGE") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "group": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="OUTLINER_COLLECTION") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "parent": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="FILE_PARENT") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type elif ifc_filter.type == "query": row = box.row(align=True) + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and j > 0: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "name", text="", icon="POINTCLOUD_DATA") row.prop(ifc_filter, "comparison", text="") row.prop(ifc_filter, "value", text="") elif ifc_filter.type == "instance": row = box.row(align=True) + preferences = tool.Blender.get_addon_preferences() + if preferences.chain_filter_with_set_operations: + show_mode_toggle = j > 0 + else: + show_mode_toggle = preferences.default_filter_with_set_operations_for_globalid_and_class and j > 0 # PR 7315 mode + if show_mode_toggle: + mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} + op = row.operator( + "bim.toggle_filter_inclusion", + icon=mode_icons.get(ifc_filter.filter_mode, "ADD"), + text="", + depress=ifc_filter.filter_mode != "ADD", + ) + op.group_index = i + op.filter_index = j + op.module = module row.prop(ifc_filter, "value", text="", icon="GRIP") + op = row.operator("bim.filter_value_suggestions", text="", icon="VIEWZOOM") + op.group_index = i + op.filter_index = j + op.module = module + op.filter_type = ifc_filter.type op = row.operator("bim.select_filter_elements", text="", icon="EYEDROPPER") op.group_index = i op.index = j diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index aa8d0a317d..e4f258c6e1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3680,8 +3680,12 @@ class EnableEditingElementFilter(bpy.types.Operator, tool.Ifc.Operator): if query := ifcopenshell.util.element.get_pset(element, "EPset_Drawing", self.filter_mode.title()): filter_groups = tool.Search.get_filter_groups(f"drawing_{self.filter_mode.lower()}") try: - tool.Search.import_filter_query(query, filter_groups) - except: + data = json.loads(query) + if isinstance(data, dict) and "filter_structure" in data: + tool.Search.import_filter_structure(data["filter_structure"], filter_groups) + else: + tool.Search.import_filter_query(query, filter_groups) + except Exception: pass @@ -3701,12 +3705,45 @@ class EditElementFilter(bpy.types.Operator, tool.Ifc.Operator): assert element pset = tool.Pset.get_element_pset(element, "EPset_Drawing") assert pset + if self.filter_mode == "INCLUDE": - query = tool.Search.export_filter_query(props.include_filter_groups) or None - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Include": query}) + filter_groups = props.include_filter_groups elif self.filter_mode == "EXCLUDE": - query = tool.Search.export_filter_query(props.exclude_filter_groups) or None - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": query}) + filter_groups = props.exclude_filter_groups + else: + return + + query = tool.Search.export_filter_query(filter_groups) or None + + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and query: + filter_structure = [] + for filter_group in filter_groups: + group_data = [] + for ifc_filter in filter_group.filters: + filter_data = { + "type": ifc_filter.type, + "name": ifc_filter.name, + "value": ifc_filter.value, + "pset": ifc_filter.pset, + "comparison": ifc_filter.comparison, + "filter_mode": ifc_filter.filter_mode, + } + group_data.append(filter_data) + filter_structure.append(group_data) + + value = json.dumps({ + "type": "BBIM_Search", + "query": query, + "filter_structure": filter_structure + }) + else: + value = query + + if self.filter_mode == "INCLUDE": + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Include": value}) + elif self.filter_mode == "EXCLUDE": + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": value}) + props.filter_mode = "NONE" bpy.ops.bim.activate_drawing(drawing=element.id(), should_view_from_camera=False) diff --git a/src/bonsai/bonsai/bim/module/search/__init__.py b/src/bonsai/bonsai/bim/module/search/__init__.py index e1849c63ae..88cb2f6ed4 100644 --- a/src/bonsai/bonsai/bim/module/search/__init__.py +++ b/src/bonsai/bonsai/bim/module/search/__init__.py @@ -24,12 +24,15 @@ classes = ( operator.ActivateIfcClassFilter, operator.AddFilter, operator.AddFilterGroup, + operator.ApplyFilterFromText, operator.ColourByProperty, operator.EditFilterQuery, + operator.FilterValueSuggestions, operator.LoadColourscheme, operator.LoadSearch, operator.RemoveFilter, operator.RemoveFilterGroup, + operator.RemoveSearch, operator.ResetObjectColours, operator.SaveColourscheme, operator.SaveSearch, @@ -40,6 +43,7 @@ classes = ( operator.SelectIfcClass, operator.SelectSimilar, operator.ShowAllElements, + operator.ToggleFilterInclusion, operator.ToggleFilterSelection, prop.BIMColour, prop.BIMFilterItem, @@ -55,7 +59,9 @@ classes = ( def register(): bpy.types.Scene.BIMSearchProperties = bpy.props.PointerProperty(type=prop.BIMSearchProperties) + bpy.types.TEXT_HT_header.append(operator.draw_text_editor_header) def unregister(): del bpy.types.Scene.BIMSearchProperties + bpy.types.TEXT_HT_header.remove(operator.draw_text_editor_header) diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 1fb164047b..58c773af17 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -40,9 +40,472 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from bonsai.bim.prop import StrProperty from typing import TYPE_CHECKING, Literal, get_args, assert_never +def draw_text_editor_header(self, context): + if context.space_data.text and context.space_data.text.name.startswith("FilterQuery_"): + layout = self.layout + layout.separator() + op = layout.operator("bim.apply_filter_from_text", text="Apply Filter Configuration", icon="CHECKMARK") + + +def update_filter_search_value(self: "FilterValueSuggestions", context: bpy.types.Context) -> None: + filter_groups = tool.Search.get_filter_groups(self.module) + ifc_filter = filter_groups[self.group_index].filters[self.filter_index] + + value = self.search_value + + if " < " in value: + value = value.split(" < ")[-1] + + if ifc_filter.type == "entity": + if " (superclass)" in value: + value = value.replace(" (superclass)", "") + if " > " in value: + value = value.split(" > ")[-1] + + elif ifc_filter.type == "instance": + if ": " in value: + value = value.split(": ")[-1] + elif " (" in value: + hierarchy_class = value.split(" (") + element_class = hierarchy_class[-1].rstrip(")") + element_name = hierarchy_class[0] if len(hierarchy_class) > 1 else None + + ifc_file = tool.Ifc.get() + if ifc_file: + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + if element.is_a() == element_class: + if element_name is None or (hasattr(element, 'Name') and element.Name == element_name): + value = element.GlobalId + break + except: + continue + + elif ifc_filter.type == "parent": + if " (" in value: + value = value.split(" (")[0] + + if ifc_filter.type == "property": + if self.suggestion_type == "pset": + ifc_filter.pset = value + elif self.suggestion_type == "property_name": + ifc_filter.name = value + else: + ifc_filter.value = value + elif ifc_filter.type == "attribute": + if self.suggestion_type == "attribute_name": + ifc_filter.name = value + else: + ifc_filter.value = value + else: + ifc_filter.value = value + + if self.first_launch: + self.first_launch = False + else: + context.window.screen = context.window.screen + + +class FilterValueSuggestions(Operator): + bl_idname = "bim.filter_value_suggestions" + bl_label = "Filter Value Suggestions" + bl_description = "Get suggestions for filter values from the current IFC file" + bl_options = {"REGISTER", "UNDO"} + + group_index: IntProperty() + filter_index: IntProperty() + module: StringProperty(default="search") + filter_type: StringProperty() + suggestion_type: StringProperty(default="value") + + first_launch: BoolProperty(default=True, options={"SKIP_SAVE"}) + search_value: StringProperty( + name="Search", + description="Search for filter values", + update=update_filter_search_value, + default="", + options={"SKIP_SAVE"}, + ) + collection_values: CollectionProperty(type=StrProperty, options={"SKIP_SAVE"}) + + def execute(self, context): + return {"FINISHED"} + + def invoke(self, context, event): + ifc_file = tool.Ifc.get() + if not ifc_file: + self.report({"WARNING"}, "No IFC file loaded") + return {"CANCELLED"} + + filter_groups = tool.Search.get_filter_groups(self.module) + ifc_filter = filter_groups[self.group_index].filters[self.filter_index] + + string_suggestions = self.get_suggestions(ifc_file, ifc_filter) + if not string_suggestions: + self.report({"INFO"}, f"No suggestions available") + return {"CANCELLED"} + + self.collection_values.clear() + for suggestion in natsorted(string_suggestions): + self.collection_values.add().name = suggestion + + return context.window_manager.invoke_props_dialog(self, width=800) + + def draw(self, context): + layout = self.layout + + filter_groups = tool.Search.get_filter_groups(self.module) + ifc_filter = filter_groups[self.group_index].filters[self.filter_index] + + label_map = { + "entity": "Select Class", + "type": "Select Type", + "material": "Select Material", + "location": "Select Location", + "group": "Select Group", + "classification": "Select Classification", + "parent": "Select Parent", + "instance": "Select GlobalId", + } + + if ifc_filter.type == "attribute": + if self.suggestion_type == "attribute_value": + label = f"Select Value for {ifc_filter.name}" + else: + label = "Select Attribute Name" + elif ifc_filter.type == "property": + if self.suggestion_type == "property_value": + label = f"Select Value for {ifc_filter.pset}.{ifc_filter.name}" + elif self.suggestion_type == "property_name": + label = f"Select Property from {ifc_filter.pset}" + else: + label = "Select Property Set" + else: + label = label_map.get(ifc_filter.type, "Select Value") + + row = layout.row() + row.label(text=label) + row = layout.row() + row.prop_search( + self, + "search_value", + self, + "collection_values", + text="", + results_are_suggestions=True, + ) + + def get_suggestions(self, ifc_file, ifc_filter): + suggestions = set() + + if ifc_filter.type == "entity": + suggestions = self.get_entity_suggestions(ifc_file) + elif ifc_filter.type == "type": + suggestions = self.get_type_suggestions(ifc_file) + elif ifc_filter.type == "material": + suggestions = self.get_material_suggestions(ifc_file) + elif ifc_filter.type == "location": + suggestions = self.get_location_suggestions(ifc_file) + elif ifc_filter.type == "group": + suggestions = self.get_group_suggestions(ifc_file) + elif ifc_filter.type == "classification": + suggestions = self.get_classification_suggestions(ifc_file) + elif ifc_filter.type == "parent": + suggestions = self.get_parent_suggestions(ifc_file) + elif ifc_filter.type == "instance": + suggestions = self.get_instance_suggestions(ifc_file) + elif ifc_filter.type == "attribute": + if self.suggestion_type == "attribute_name": + suggestions = self.get_attribute_names(ifc_file) + else: + suggestions = self.get_attribute_values(ifc_file, ifc_filter.name) + elif ifc_filter.type == "property": + if self.suggestion_type == "pset": + suggestions = self.get_property_sets(ifc_file) + elif self.suggestion_type == "property_name": + suggestions = self.get_property_names(ifc_file, ifc_filter.pset) + else: + suggestions = self.get_property_values(ifc_file, ifc_filter.pset, ifc_filter.name) + + return suggestions + + def build_hierarchy_path(self, element): + path = [] + current = element + + while current: + if hasattr(current, 'Name') and current.Name: + path.insert(0, current.Name) + + parent = None + + if hasattr(current, 'Decomposes') and current.Decomposes: + for rel in current.Decomposes: + if hasattr(rel, 'RelatingObject'): + parent = rel.RelatingObject + break + + if not parent and hasattr(current, 'ContainedInStructure') and current.ContainedInStructure: + for rel in current.ContainedInStructure: + if hasattr(rel, 'RelatingStructure'): + parent = rel.RelatingStructure + break + + current = parent + + return path + + def get_entity_suggestions(self, ifc_file): + all_classes = set() + schema = tool.Ifc.schema() + + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + class_name = element.is_a() + + try: + entity = schema.declaration_by_name(class_name).as_entity() + current = entity + + chain_names = [class_name] + while current.supertype(): + supertype = current.supertype() + chain_names.insert(0, supertype.name()) + current = supertype + + if len(chain_names) > 1: + all_classes.add(" > ".join(chain_names)) + for i in range(len(chain_names) - 1): + superclass_chain = " > ".join(chain_names[:i+1]) + all_classes.add(f"{superclass_chain} (superclass)") + else: + all_classes.add(class_name) + except: + all_classes.add(class_name) + except: + continue + + return all_classes + + def get_type_suggestions(self, ifc_file): + suggestions = set() + for element_type in ifc_file.by_type("IfcTypeObject"): + if element_type.Name: + hierarchy_path = self.build_hierarchy_path(element_type) + if len(hierarchy_path) > 1: + suggestions.add(" < ".join(hierarchy_path)) + else: + suggestions.add(element_type.Name) + return suggestions + + def get_material_suggestions(self, ifc_file): + suggestions = set() + for material in ifc_file.by_type("IfcMaterial"): + if material.Name: + suggestions.add(material.Name) + return suggestions + + def get_location_suggestions(self, ifc_file): + suggestions = set() + for spatial in ifc_file.by_type("IfcSpatialStructureElement"): + if spatial.Name: + hierarchy_path = self.build_hierarchy_path(spatial) + if len(hierarchy_path) > 1: + suggestions.add(" < ".join(hierarchy_path)) + else: + suggestions.add(spatial.Name) + return suggestions + + def get_group_suggestions(self, ifc_file): + suggestions = set() + for group in ifc_file.by_type("IfcGroup"): + if group.Name: + hierarchy_path = self.build_hierarchy_path(group) + if len(hierarchy_path) > 1: + suggestions.add(" < ".join(hierarchy_path)) + else: + suggestions.add(group.Name) + return suggestions + + def get_classification_suggestions(self, ifc_file): + suggestions = set() + for ref in ifc_file.by_type("IfcClassificationReference"): + if ref.Identification: + suggestions.add(ref.Identification) + return suggestions + + def get_parent_suggestions(self, ifc_file): + suggestions = set() + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + has_children = False + + if hasattr(element, 'IsDecomposedBy') and element.IsDecomposedBy: + has_children = True + elif hasattr(element, 'ContainsElements') and element.ContainsElements: + has_children = True + elif hasattr(element, 'HasOpenings') and element.HasOpenings: + has_children = True + + if has_children: + hierarchy_path = self.build_hierarchy_path(element) + element_class = element.is_a() + + if len(hierarchy_path) > 0: + hierarchy_str = " < ".join(hierarchy_path) + suggestions.add(f"{hierarchy_str} ({element_class})") + else: + element_name = element.Name if hasattr(element, 'Name') and element.Name else element_class + suggestions.add(f"{element_name} ({element_class})") + except: + continue + + return suggestions + + def get_instance_suggestions(self, ifc_file): + suggestions = set() + element_data = [] + + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + if hasattr(element, 'GlobalId') and element.GlobalId: + hierarchy_path = self.build_hierarchy_path(element) + element_class = element.is_a() + + if len(hierarchy_path) > 0: + hierarchy_str = " < ".join(hierarchy_path) + display_str = f"{hierarchy_str} ({element_class})" + else: + display_str = f"({element_class})" + + element_data.append((display_str, element.GlobalId)) + except: + continue + + display_counts = {} + for display_str, global_id in element_data: + display_counts[display_str] = display_counts.get(display_str, 0) + 1 + + for display_str, global_id in element_data: + if display_counts[display_str] > 1: + suggestions.add(f"{display_str}: {global_id}") + else: + suggestions.add(display_str) + + return suggestions + + def get_property_sets(self, ifc_file): + psets = set() + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + for definition in getattr(element, 'IsDefinedBy', []): + if definition.is_a('IfcRelDefinesByProperties'): + pset = definition.RelatingPropertyDefinition + if pset.is_a("IfcPropertySet") and pset.Name: + psets.add(pset.Name) + except: + continue + return psets + + def get_property_names(self, ifc_file, pset_name): + property_names = set() + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + for definition in getattr(element, 'IsDefinedBy', []): + if definition.is_a('IfcRelDefinesByProperties'): + pset = definition.RelatingPropertyDefinition + if pset.is_a("IfcPropertySet") and pset.Name == pset_name: + if pset.HasProperties: + for prop in pset.HasProperties: + if hasattr(prop, "Name") and prop.Name: + property_names.add(prop.Name) + except: + continue + return property_names + + def get_property_values(self, ifc_file, pset_name, property_name): + property_values = set() + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + for definition in getattr(element, 'IsDefinedBy', []): + if definition.is_a('IfcRelDefinesByProperties'): + pset = definition.RelatingPropertyDefinition + if pset.is_a("IfcPropertySet") and pset.Name == pset_name: + if pset.HasProperties: + for prop in pset.HasProperties: + if hasattr(prop, "Name") and prop.Name == property_name: + if hasattr(prop, "NominalValue") and prop.NominalValue: + try: + value = prop.NominalValue.wrappedValue + if value is not None and value != "": + if not hasattr(value, 'is_a') and not isinstance(value, (tuple, list)): + str_value = str(value) + if not str_value.startswith("#") and not str_value.startswith("("): + property_values.add(str_value) + except: + continue + except: + continue + return property_values + + def get_attribute_names(self, ifc_file): + attribute_names = set() + schema = tool.Ifc.schema() + + ifc_classes = set() + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + ifc_classes.add(element.is_a()) + except: + continue + + for ifc_class in ifc_classes: + try: + entity = schema.declaration_by_name(ifc_class).as_entity() + attributes = entity.all_attributes() + for attr in attributes: + attribute_names.add(attr.name()) + except: + continue + + return attribute_names + + def get_attribute_values(self, ifc_file, attribute_name): + attribute_values = set() + + for element_id in IfcStore.id_map.keys(): + try: + element = ifc_file.by_id(element_id) + + if element.is_a("IfcRelationship") or element.is_a("IfcTypeObject"): + continue + + if hasattr(element, attribute_name): + value = getattr(element, attribute_name, None) + + if value is not None and value != "": + if not hasattr(value, 'is_a') and not isinstance(value, (tuple, list)): + str_value = str(value) + if not str_value.startswith("#") and not str_value.startswith("("): + attribute_values.add(str_value) + except: + continue + + return attribute_values + + class AddFilterGroup(Operator): bl_idname = "bim.add_filter_group" bl_label = "Add Filter Group" @@ -96,11 +559,38 @@ class AddFilter(Operator): def execute(self, context): filter_groups = tool.Search.get_filter_groups(self.module) + if self.index >= len(filter_groups): + filter_groups.add() new = filter_groups[self.index].filters.add() new.type = self.type return {"FINISHED"} +class ToggleFilterInclusion(Operator): + bl_idname = "bim.toggle_filter_inclusion" + bl_label = "Toggle Filter Mode" + bl_description = "Cycle between Add (+), Subtract (-), and Filter modes for this filter" + bl_options = {"REGISTER", "UNDO"} + + group_index: IntProperty() + filter_index: IntProperty() + module: StringProperty() + + def execute(self, context): + filter_groups = tool.Search.get_filter_groups(self.module) + filter_group = filter_groups[self.group_index] + ifc_filter = filter_group.filters[self.filter_index] + + if ifc_filter.filter_mode == "ADD": + ifc_filter.filter_mode = "SUBTRACT" + elif ifc_filter.filter_mode == "SUBTRACT": + ifc_filter.filter_mode = "FILTER" + else: + ifc_filter.filter_mode = "ADD" + + return {"FINISHED"} + + class SelectFilterElements(bpy.types.Operator): bl_idname = "bim.select_filter_elements" bl_label = "Select Filter Elements" @@ -120,6 +610,47 @@ class SelectFilterElements(bpy.types.Operator): return {"FINISHED"} +class ApplyFilterFromText(Operator, tool.Ifc.Operator): + bl_idname = "bim.apply_filter_from_text" + bl_label = "Apply Filter Configuration" + bl_description = "Apply the JSON filter configuration from the current text block" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if context.area and context.area.type == 'TEXT_EDITOR': + space = context.space_data + if space.text and space.text.name.startswith("FilterQuery_"): + return True + return False + + def execute(self, context): + space = context.space_data + text = space.text + + if not text or not text.name.startswith("FilterQuery_"): + self.report({"ERROR"}, "No valid filter configuration text block") + return {'CANCELLED'} + + module = text.name.replace("FilterQuery_", "") + + try: + json_data = json.loads(text.as_string()) + filter_structure = json_data.get("filter_structure", []) + filter_groups = tool.Search.get_filter_groups(module) + tool.Search.import_filter_structure(filter_structure, filter_groups) + self.report({"INFO"}, "Filter configuration applied successfully") + + if len(context.window_manager.windows) > 1: + bpy.ops.wm.window_close() + + except Exception as e: + self.report({"ERROR"}, f"Invalid JSON: {str(e)}") + return {'CANCELLED'} + + return {'FINISHED'} + + class EditFilterQuery(Operator, tool.Ifc.Operator): bl_idname = "bim.edit_filter_query" bl_label = "Edit Filter Query" @@ -127,29 +658,84 @@ class EditFilterQuery(Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} query: StringProperty(name="Query") old_query: StringProperty(name="Old Query") - module: StringProperty() + module: StringProperty(default="search") def _execute(self, context): - if self.query == self.old_query: - return + module = getattr(self, "module", "search") + + if not tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + if self.query == self.old_query: + return - filter_groups = tool.Search.get_filter_groups(self.module) - try: - tool.Search.import_filter_query(self.query, filter_groups) - except: - return + filter_groups = tool.Search.get_filter_groups(module) + try: + tool.Search.import_filter_query(self.query, filter_groups) + except: + return def draw(self, context): - row = self.layout.row() - row.prop(self, "query", text="") + + if not tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + row = self.layout.row() + row.prop(self, "query", text="") def invoke(self, context, event): - filter_groups = tool.Search.get_filter_groups(self.module) + module = getattr(self, "module", "search") + filter_groups = tool.Search.get_filter_groups(module) - self.query = tool.Search.export_filter_query(filter_groups) - self.old_query = self.query + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + filter_structure = [] + for filter_group in filter_groups: + group_data = [] + for ifc_filter in filter_group.filters: + filter_data = { + "type": ifc_filter.type, + "name": ifc_filter.name, + "value": ifc_filter.value, + "pset": ifc_filter.pset, + "comparison": ifc_filter.comparison, + "filter_mode": ifc_filter.filter_mode, + } + group_data.append(filter_data) + filter_structure.append(group_data) + + query = tool.Search.export_filter_query(filter_groups) + json_data = { + "type": "BBIM_Search", + "query": query, + "filter_structure": filter_structure + } + + text_block_name = f"FilterQuery_{module}" + text = bpy.data.texts.get(text_block_name) + if not text: + text = bpy.data.texts.new(text_block_name) + + text.clear() + text.write(json.dumps(json_data, indent=2)) + + bpy.ops.wm.window_new() + new_window = context.window_manager.windows[-1] + + new_area = new_window.screen.areas[0] + new_area.type = 'TEXT_EDITOR' + + text_space = None + for space in new_area.spaces: + if space.type == 'TEXT_EDITOR': + text_space = space + break + + if text_space: + text_space.text = text + + self.report({"INFO"}, "Compact text editor opened. Edit JSON and click 'Apply Filter Configuration' in header") + return {'FINISHED'} - return context.window_manager.invoke_props_dialog(self) + else: + self.query = tool.Search.export_filter_query(filter_groups) + self.old_query = self.query + return context.window_manager.invoke_props_dialog(self, width=400) class Search(Operator): @@ -174,9 +760,23 @@ class Search(Operator): else: assert_never(self.property_group) - results = ifcopenshell.util.selector.filter_elements( - tool.Ifc.get(), tool.Search.export_filter_query(props.filter_groups) - ) + preferences = tool.Blender.get_addon_preferences() + + # Migrate old ! prefix filters to new filter_mode system when preferences are enabled + if preferences.chain_filter_with_set_operations or preferences.default_filter_with_set_operations_for_globalid_and_class: + for filter_group in props.filter_groups: + for ifc_filter in filter_group.filters: + if ifc_filter.type not in ["entity", "instance"]: + continue + if ifc_filter.value.startswith("!"): + ifc_filter.value = ifc_filter.value[1:] + ifc_filter.filter_mode = "SUBTRACT" + + results = tool.Search.execute_filter_groups(props.filter_groups) + else: + results = ifcopenshell.util.selector.filter_elements( + tool.Ifc.get(), tool.Search.export_filter_query(props.filter_groups) + ) objs = [obj for e in results if isinstance(obj := tool.Ifc.get_object(e), bpy.types.Object)] for obj in objs: @@ -242,13 +842,32 @@ class SaveSearch(Operator, tool.Ifc.Operator): try: query = tool.Search.export_filter_query(filter_groups) - results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) + results = tool.Search.execute_filter_groups(filter_groups) + + filter_structure = [] + for filter_group in filter_groups: + group_data = [] + for ifc_filter in filter_group.filters: + filter_data = { + "type": ifc_filter.type, + "name": ifc_filter.name, + "value": ifc_filter.value, + "pset": ifc_filter.pset, + "comparison": ifc_filter.comparison, + "filter_mode": ifc_filter.filter_mode, + } + group_data.append(filter_data) + filter_structure.append(group_data) except: print(traceback.format_exc()) self.report({"ERROR"}, "Error occurred trying save search.") return - description = json.dumps({"type": "BBIM_Search", "query": query}) + description = json.dumps({ + "type": "BBIM_Search", + "query": query, + "filter_structure": filter_structure + }) ifc_file = tool.Ifc.get() group = next( ( @@ -289,7 +908,12 @@ class LoadSearch(Operator, tool.Ifc.Operator): filter_groups = tool.Search.get_filter_groups(self.module) props = tool.Search.get_search_props() group = tool.Ifc.get().by_id(int(props.saved_searches)) - tool.Search.import_filter_query(tool.Search.get_group_query(group), filter_groups) + + group_data = tool.Search.get_group_data(group) + if group_data and "filter_structure" in group_data: + tool.Search.import_filter_structure(group_data["filter_structure"], filter_groups) + else: + tool.Search.import_filter_query(tool.Search.get_group_query(group), filter_groups) def draw(self, context): assert self.layout @@ -302,6 +926,40 @@ class LoadSearch(Operator, tool.Ifc.Operator): return context.window_manager.invoke_props_dialog(self) +class RemoveSearch(Operator, tool.Ifc.Operator): + bl_idname = "bim.remove_search" + bl_label = "Remove Search" + bl_description = "Remove a saved search filter" + bl_options = {"REGISTER", "UNDO"} + module: StringProperty() + + def _execute(self, context): + props = tool.Search.get_search_props() + group_id = props.saved_searches + if not group_id: + self.report({"ERROR"}, "No search selected for removal") + return + + group = tool.Ifc.get().by_id(int(group_id)) + group_name = group.Name or "Unnamed" + ifcopenshell.api.group.remove_group(tool.Ifc.get(), group=group) + tool.Search.patch_search_ifcgroups() + self.report({"INFO"}, f"Removed saved search: {group_name}") + + def draw(self, context): + self.layout.label(text="Select search to remove:", icon="ERROR") + row = self.layout.row() + props = tool.Search.get_search_props() + row.prop(props, "saved_searches", text="") + + def invoke(self, context, event): + tool.Search.patch_search_ifcgroups() + from bonsai.bim.module.search.data import SearchData + if not SearchData.is_loaded: + SearchData.load() + return context.window_manager.invoke_props_dialog(self) + + class ColourByProperty(Operator): bl_idname = "bim.colour_by_property" bl_label = "Colour by Property" diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 08137a133e..3ca57b27cc 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -748,6 +748,15 @@ class BIMFacet(PropertyGroup): pset: StringProperty(name="Pset") value: StringProperty(name="Value") type: StringProperty(name="Type") + filter_mode: EnumProperty( + name="Filter Mode", + items=[ + ("ADD", "Add", "Add elements to the result set (query entire IFC file)", "ADD", 0), + ("SUBTRACT", "Subtract", "Subtract matching elements from previous results", "REMOVE", 1), + ("FILTER", "Filter", "Filter down previous results to matching elements", "FILTER", 2), + ], + default="ADD", + ) comparison: EnumProperty( items=[ ("=", "equal to", ""), @@ -765,6 +774,7 @@ class BIMFacet(PropertyGroup): pset: str value: str type: str + filter_mode: Literal["ADD", "SUBTRACT", "FILTER"] comparison: Literal["=", "!=", ">=", "<=", ">", "<", "*=", "!*="] diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 4a1114d831..05c836ca93 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -689,6 +689,17 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): description="Show mass and time units section in the new project wizard panel", default=False, ) + + chain_filter_with_set_operations: BoolProperty( + name="NEW filter mode: Enable chained filters with set operations", + description="Enable chaining search filters with set operations: ADD (union: combine sets), SUBTRACT (difference: remove from set), FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values", + default=False, + ) + default_filter_with_set_operations_for_globalid_and_class: BoolProperty( + name="DEFAULT filter mode: Enable set operations for GlobalId/Class", + description="Enable ADD/SUBTRACT/FILTER toggle buttons on entity (Class) and instance (GlobalId) filters for the DEFAULT filter mode", + default=False, + ) if TYPE_CHECKING: svg2pdf_command: str @@ -727,6 +738,8 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): default_parameters: DefaultParameters container_hide_show_isolate: bool mass_time_units_in_wizard: bool + chain_filter_with_set_operations: bool + default_filter_with_set_operations_for_globalid_and_class: bool def draw(self, context: bpy.types.Context) -> None: layout = self.layout @@ -909,6 +922,14 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") layout.prop(self, "mass_time_units_in_wizard") + layout.label(text="Filtering modes:") + box = layout.box() + row = box.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" + row = box.row(align=True) + row.prop(self, "default_filter_with_set_operations_for_globalid_and_class") + row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/comment/27030" # Scene panel groups diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 8fc500a223..0f063b8473 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -2277,7 +2277,16 @@ class Drawing(bonsai.core.tool.Drawing): pset = ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {}) include = pset.get("Include", None) if include: - elements = ifcopenshell.util.selector.filter_elements(ifc_file, include) + try: + data = json.loads(include) + if isinstance(data, dict) and "filter_structure" in data: + elements = tool.Search.execute_filter_groups_from_json(data, ifc_file) + elif isinstance(data, dict) and "query" in data: + elements = ifcopenshell.util.selector.filter_elements(ifc_file, data["query"]) + else: + elements = ifcopenshell.util.selector.filter_elements(ifc_file, include) + except (json.JSONDecodeError, ValueError): + elements = ifcopenshell.util.selector.filter_elements(ifc_file, include) else: if ifc_file.schema == "IFC2X3": base_elements = set(ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcSpatialStructureElement")) @@ -2291,7 +2300,7 @@ class Drawing(bonsai.core.tool.Drawing): if not i.is_a("IfcAnnotation"): updated_set.add(i) # add aggregate too, if element is host by one - if decomposes := i.Decomposes: + if hasattr(i, "Decomposes") and (decomposes := i.Decomposes): aggregate = decomposes[0].RelatingObject # remove IfcProject for class iterator. See https://github.com/IfcOpenShell/IfcOpenShell/issues/4361#issuecomment-2081223615 if aggregate.is_a("IfcProduct"): @@ -2304,7 +2313,18 @@ class Drawing(bonsai.core.tool.Drawing): exclude = pset.get("Exclude", None) if exclude: - elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) + try: + data = json.loads(exclude) + if isinstance(data, dict) and "filter_structure" in data: + exclude_elements = tool.Search.execute_filter_groups_from_json(data, ifc_file) + elements -= exclude_elements + elif isinstance(data, dict) and "query" in data: + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, data["query"]) + else: + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) + except (json.JSONDecodeError, ValueError): + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) elements -= set(ifc_file.by_type("IfcOpeningElement")) return elements @@ -2318,7 +2338,18 @@ class Drawing(bonsai.core.tool.Drawing): # NOTE: EPset_Drawing.Include is not used to avoid adding other elements besides spaces exclude = pset.get("Exclude", None) if exclude: - elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) + try: + data = json.loads(exclude) + if isinstance(data, dict) and "filter_structure" in data: + exclude_elements = tool.Search.execute_filter_groups_from_json(data, ifc_file) + elements -= exclude_elements + elif isinstance(data, dict) and "query" in data: + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, data["query"]) + else: + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) + except (json.JSONDecodeError, ValueError): + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) + elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude) return elements @classmethod diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index 29e862b6b0..36536f5a1a 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -42,6 +42,38 @@ class Search(bonsai.core.tool.Search): def get_group_query(cls, group: ifcopenshell.entity_instance) -> str: return json.loads(group.Description)["query"] + @classmethod + def get_group_data(cls, group: ifcopenshell.entity_instance) -> dict: + return json.loads(group.Description) + + @classmethod + def import_filter_structure( + cls, filter_structure: list, filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] + ) -> None: + filter_groups.clear() + + for group_data in filter_structure: + if not isinstance(group_data, list): + continue + + filter_group = filter_groups.add() + + for filter_data in group_data: + if not isinstance(filter_data, dict): + continue + + ifc_filter = filter_group.filters.add() + ifc_filter.type = filter_data.get("type", "") + ifc_filter.name = filter_data.get("name", "") + ifc_filter.value = filter_data.get("value", "") + ifc_filter.pset = filter_data.get("pset", "") + ifc_filter.comparison = filter_data.get("comparison", "=") + filter_mode = filter_data.get("filter_mode", "ADD") + if filter_mode in ["ADD", "SUBTRACT", "FILTER"]: + ifc_filter.filter_mode = filter_mode + else: + ifc_filter.filter_mode = "ADD" + FilterModule = Union[Literal["search", "csv", "diff", "drawing_include", "drawing_exclude"], str] @classmethod @@ -80,53 +112,244 @@ class Search(bonsai.core.tool.Search): for ifc_filter in filter_group.filters: if not ifc_filter.value: continue - if ifc_filter.type == "instance": - if "bpy.data.texts" in ifc_filter.value: - data_name = ifc_filter.value.split("bpy.data.texts")[1][2:-2] - filter_group_query.append(bpy.data.texts[data_name].as_string()) - else: - filter_group_query.append(ifc_filter.value) - elif ifc_filter.type == "entity": - 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 = ifc_filter.comparison - value = cls.wrap_value(ifc_filter, ifc_filter.value) - 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}") - elif ifc_filter.type == "group": - comparison, value = cls.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"group{comparison}{value}") - elif ifc_filter.type == "parent": - comparison, value = cls.get_comparison_and_value(ifc_filter) - filter_group_query.append(f"parent{comparison}{value}") - elif ifc_filter.type == "query": - keys = cls.wrap_value(ifc_filter, ifc_filter.name) - comparison = ifc_filter.comparison or "=" - value = cls.wrap_value(ifc_filter, ifc_filter.value) - filter_group_query.append(f"query:{keys}{comparison}{value}") - query.append(", ".join(filter_group_query)) + + query_part = cls._export_single_filter(ifc_filter) + if query_part: + filter_group_query.append(query_part) + + if filter_group_query: + query.append(", ".join(filter_group_query)) return " + ".join(query) + @classmethod + def _export_single_filter(cls, ifc_filter: BIMFacet) -> str: + preferences = tool.Blender.get_addon_preferences() + + if ifc_filter.type == "instance": + if "bpy.data.texts" in ifc_filter.value: + data_name = ifc_filter.value.split("bpy.data.texts")[1][2:-2] + value = bpy.data.texts[data_name].as_string() + else: + value = ifc_filter.value + + if preferences.chain_filter_with_set_operations or preferences.default_filter_with_set_operations_for_globalid_and_class: + value = value.lstrip("!") + if ifc_filter.filter_mode == "SUBTRACT": + value = f"!{value}" + return value + + elif ifc_filter.type == "entity": + value = ifc_filter.value + + if preferences.chain_filter_with_set_operations or preferences.default_filter_with_set_operations_for_globalid_and_class: + value = value.lstrip("!") + if ifc_filter.filter_mode == "SUBTRACT": + value = f"!{value}" + return value + elif ifc_filter.type == "attribute": + if not ifc_filter.name: + return "" + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"{ifc_filter.name}{comparison}{value}" + elif ifc_filter.type == "type": + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"type{comparison}{value}" + elif ifc_filter.type == "material": + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"material{comparison}{value}" + elif ifc_filter.type == "property": + if not ifc_filter.pset or not ifc_filter.name: + return "" + pset = cls.wrap_value(ifc_filter, ifc_filter.pset) + name = cls.wrap_value(ifc_filter, ifc_filter.name) + comparison = ifc_filter.comparison + value = cls.wrap_value(ifc_filter, ifc_filter.value) + return f"{pset}.{name} {comparison} {value}" + elif ifc_filter.type == "classification": + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"classification{comparison}{value}" + elif ifc_filter.type == "location": + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"location{comparison}{value}" + elif ifc_filter.type == "group": + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"group{comparison}{value}" + elif ifc_filter.type == "parent": + comparison, value = cls.get_comparison_and_value(ifc_filter) + return f"parent{comparison}{value}" + elif ifc_filter.type == "query": + keys = cls.wrap_value(ifc_filter, ifc_filter.name) + comparison = ifc_filter.comparison or "=" + value = cls.wrap_value(ifc_filter, ifc_filter.value) + return f"query:{keys}{comparison}{value}" + return "" + + @classmethod + def execute_filter_groups(cls, filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup]) -> set: + """ + Execute filter groups with simplified chaining support. + Within a single group chain, all filters chain sequentially with ADD/SUBTRACT/FILTER modes. + Groups are combined with union (same as original " + " behavior). + """ + preferences = tool.Blender.get_addon_preferences() + + all_group_results = [] + + for group_idx, filter_group in enumerate(filter_groups): + group_results = set() + + for filter_index, ifc_filter in enumerate(filter_group.filters): + if not ifc_filter.value: + continue + + query = cls._export_single_filter(ifc_filter) + if not query: + continue + + if filter_index == 0: + mode = "ADD" + else: + if ifc_filter.type in ["entity", "instance"]: + if preferences.chain_filter_with_set_operations or preferences.default_filter_with_set_operations_for_globalid_and_class: + mode = ifc_filter.filter_mode + else: + mode = "FILTER" if group_results else "ADD" + else: + if preferences.chain_filter_with_set_operations: + mode = ifc_filter.filter_mode + else: + mode = "FILTER" if group_results else "ADD" + + if mode == "ADD": + results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) + group_results.update(results) + + elif mode == "SUBTRACT": + if group_results: + query_without_prefix = query[1:] if query.startswith("!") else query + elements_to_remove = ifcopenshell.util.selector.filter_elements( + tool.Ifc.get(), query_without_prefix, elements=group_results + ) + group_results -= elements_to_remove + else: + results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) + group_results.update(results) + + elif mode == "FILTER": + if group_results: + results = ifcopenshell.util.selector.filter_elements( + tool.Ifc.get(), query, elements=group_results + ) + group_results = results + else: + results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) + group_results.update(results) + + if group_results: + all_group_results.append(group_results) + + final_results = set() + for group_results in all_group_results: + final_results.update(group_results) + + return final_results + + @classmethod + def execute_filter_groups_from_json( + cls, data: dict, ifc_file: ifcopenshell.file + ) -> set[ifcopenshell.entity_instance]: + """Execute filter groups from JSON data with filter_structure + + This is used by drawing include/exclude to properly handle ADD/SUBTRACT/FILTER modes + without needing to instantiate Blender property groups. + """ + filter_structure = data.get("filter_structure", []) + + all_group_results = [] + for group_data in filter_structure: + group_results = set() + + for filter_data in group_data: + filter_mode = filter_data.get("filter_mode", "ADD") + filter_type = filter_data.get("type", "") + value = filter_data.get("value", "") + + if not filter_type or not value: + continue + + query_part = None + if filter_type == "entity": + query_part = value + elif filter_type == "attribute": + name = filter_data.get("name", "") + if not name: + continue + comparison = filter_data.get("comparison", "=") + query_part = f"{name}{comparison}{cls._wrap_json_value(value)}" + elif filter_type == "property": + pset = filter_data.get("pset", "") + name = filter_data.get("name", "") + if not pset or not name: + continue + comparison = filter_data.get("comparison", " = ") + wrapped_pset = cls._wrap_json_value(pset) + wrapped_name = cls._wrap_json_value(name) + wrapped_value = cls._wrap_json_value(value) + query_part = f"{wrapped_pset}.{wrapped_name} {comparison} {wrapped_value}" + elif filter_type == "type": + query_part = f"type={cls._wrap_json_value(value)}" + elif filter_type == "material": + query_part = f"material={cls._wrap_json_value(value)}" + elif filter_type == "classification": + query_part = f"classification={cls._wrap_json_value(value)}" + elif filter_type == "location": + query_part = f"location={cls._wrap_json_value(value)}" + elif filter_type == "group": + query_part = f"group={cls._wrap_json_value(value)}" + elif filter_type == "parent": + query_part = f"parent={cls._wrap_json_value(value)}" + elif filter_type == "query": + name = filter_data.get("name", "") + if not name: + continue + keys = cls._wrap_json_value(name) + comparison = filter_data.get("comparison", "=") + wrapped_value = cls._wrap_json_value(value) + query_part = f"query:{keys}{comparison}{wrapped_value}" + elif filter_type == "instance": + query_part = value + + if not query_part: + continue + + if filter_mode == "FILTER" and group_results: + results = ifcopenshell.util.selector.filter_elements(ifc_file, query_part, elements=group_results) + group_results = results + elif filter_mode == "SUBTRACT": + results = ifcopenshell.util.selector.filter_elements(ifc_file, query_part) + group_results -= results + else: # ADD + results = ifcopenshell.util.selector.filter_elements(ifc_file, query_part) + group_results.update(results) + + if group_results: + all_group_results.append(group_results) + + final_results = set() + for group_results in all_group_results: + final_results.update(group_results) + + return final_results + + @classmethod + def _wrap_json_value(cls, value: str) -> str: + """Wrap value for use in query string""" + if value.startswith("/") and value.endswith("/"): + return value + elif value in ("NULL", "TRUE", "FALSE"): + return value + return '"' + value.replace('"', '\\"') + '"' + @classmethod def get_comparison_and_value( cls, ifc_filter: BIMFacet @@ -349,7 +572,8 @@ class ImportFilterQueryTransformer(lark.Transformer): def facet_list(self, args): new = self.filter_groups.add() global_ids = [] - for arg in args: + is_first_group = len(self.filter_groups) == 1 + for filter_index, arg in enumerate(args): if arg["type"] == "instance" and global_ids: if "bpy.data.texts" in new2.value: data_name = new2.value.split("bpy.data.texts")[1][2:-2] @@ -374,21 +598,26 @@ class ImportFilterQueryTransformer(lark.Transformer): new2.pset = arg["pset"] if "comparison" in arg: new2.comparison = arg["comparison"] or "=" + if "filter_mode" in arg: + new2.filter_mode = arg["filter_mode"] + elif not is_first_group and filter_index == 0 and arg["type"] in ("entity", "instance"): + if arg.get("filter_mode", "ADD") != "SUBTRACT": + new2.filter_mode = "FILTER" def facet(self, args): return args[0] def instance(self, args): if args[0].data == "not": - return {"type": "instance", "value": "!" + args[1].children[0].value} + return {"type": "instance", "value": args[1].children[0].value, "filter_mode": "SUBTRACT"} else: - return {"type": "instance", "value": args[0].children[0].value} + return {"type": "instance", "value": args[0].children[0].value, "filter_mode": "ADD"} def entity(self, args): if args[0].data == "not": - return {"type": "entity", "value": "!" + args[1].children[0].value} + return {"type": "entity", "value": args[1].children[0].value, "filter_mode": "SUBTRACT"} else: - return {"type": "entity", "value": args[0].children[0].value} + return {"type": "entity", "value": args[0].children[0].value, "filter_mode": "ADD"} def attribute(self, args): name, comparison, value = args