diff --git a/src/blenderbim/blenderbim/bim/helper.py b/src/blenderbim/blenderbim/bim/helper.py index db2c706c2e..06508b50fa 100644 --- a/src/blenderbim/blenderbim/bim/helper.py +++ b/src/blenderbim/blenderbim/bim/helper.py @@ -132,6 +132,15 @@ def get_enum_items(data, prop_name, context): return items +# hack to close popup +# https://blender.stackexchange.com/a/202576/130742 +def close_operator_panel(event): + x, y = event.mouse_x, event.mouse_y + bpy.context.window.cursor_warp(10, 10) + move_back = lambda: bpy.context.window.cursor_warp(x, y) + bpy.app.timers.register(move_back, first_interval=0.01) + + class IfcHeaderExtractor: def __init__(self, filepath: str): self.filepath = filepath diff --git a/src/blenderbim/blenderbim/bim/module/group/__init__.py b/src/blenderbim/blenderbim/bim/module/group/__init__.py index ddfc4ff48c..1b765a0a94 100644 --- a/src/blenderbim/blenderbim/bim/module/group/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/group/__init__.py @@ -31,6 +31,7 @@ classes = ( operator.EnableEditingGroup, operator.DisableEditingGroup, operator.SelectGroupProducts, + operator.UpdateGroup, prop.Group, prop.BIMGroupProperties, ui.BIM_PT_groups, diff --git a/src/blenderbim/blenderbim/bim/module/group/operator.py b/src/blenderbim/blenderbim/bim/module/group/operator.py index fdf7548826..c48ea81bcd 100644 --- a/src/blenderbim/blenderbim/bim/module/group/operator.py +++ b/src/blenderbim/blenderbim/bim/module/group/operator.py @@ -22,6 +22,7 @@ import ifcopenshell.api import blenderbim.bim.helper from blenderbim.bim.ifc import IfcStore from ifcopenshell.api.group.data import Data +from ifcopenshell.util.selector import Selector class LoadGroups(bpy.types.Operator): @@ -36,6 +37,7 @@ class LoadGroups(bpy.types.Operator): new = props.groups.add() new.ifc_definition_id = ifc_definition_id new.name = group["Name"] + new.selection_query = group["Description"].split("*selector*")[1] if group["Description"] else "" props.is_editing = True bpy.ops.bim.disable_editing_group() return {"FINISHED"} @@ -167,7 +169,7 @@ class AssignGroup(bpy.types.Operator): "group.assign_group", self.file, **{ - "product": self.file.by_id(product.BIMObjectProperties.ifc_definition_id), + "product": [self.file.by_id(product.BIMObjectProperties.ifc_definition_id)], "group": self.file.by_id(self.group), } ) @@ -219,3 +221,31 @@ class SelectGroupProducts(bpy.types.Operator): if self.group in product_groups: obj.select_set(True) return {"FINISHED"} + +class UpdateGroup(bpy.types.Operator): + bl_idname = "bim.update_group" + bl_label = "Update Group" + bl_options = {"REGISTER", "UNDO"} + query: bpy.props.StringProperty() + group_id: bpy.props.IntProperty() + + def execute(self, context): + return IfcStore.execute_ifc_operator(self, context) + + def _execute(self, context): + self.file = IfcStore.get_file() + group = self.file.by_id(self.group_id) + query = self.query + + new_products = Selector.parse(self.file, query) + ifcopenshell.api.run( + "group.update_group_products", + self.file, + **{ + "products": new_products, + "group": group, + } + ) + Data.load(IfcStore.get_file()) + bpy.ops.bim.load_groups() + return {"FINISHED"} \ No newline at end of file diff --git a/src/blenderbim/blenderbim/bim/module/group/prop.py b/src/blenderbim/blenderbim/bim/module/group/prop.py index 03011af513..e97f024584 100644 --- a/src/blenderbim/blenderbim/bim/module/group/prop.py +++ b/src/blenderbim/blenderbim/bim/module/group/prop.py @@ -34,7 +34,8 @@ from bpy.props import ( class Group(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") - + selection_query: StringProperty(name="Selection Query") + class BIMGroupProperties(PropertyGroup): group_attributes: CollectionProperty(name="Group Attributes", type=Attribute) diff --git a/src/blenderbim/blenderbim/bim/module/group/ui.py b/src/blenderbim/blenderbim/bim/module/group/ui.py index ce24d514cc..2c7525132d 100644 --- a/src/blenderbim/blenderbim/bim/module/group/ui.py +++ b/src/blenderbim/blenderbim/bim/module/group/ui.py @@ -40,7 +40,7 @@ class BIM_PT_groups(Panel): self.props = context.scene.BIMGroupProperties row = self.layout.row(align=True) - row.label(text="{} Groups Found".format(len(Data.groups)), icon="OUTLINER") + row.label(text=f"{len(Data.groups)} Groups Found", icon="OUTLINER") if self.props.is_editing: row.operator("bim.add_group", text="", icon="ADD") row.operator("bim.disable_group_editing_ui", text="", icon="CANCEL") @@ -118,7 +118,7 @@ class BIM_UL_groups(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) - row.label(text=item.name) + row.label(text=f"*{item.name}") if item.selection_query != "" else row.label(text=item.name) group_id = item.ifc_definition_id if context.scene.BIMGroupProperties.active_group_id == group_id: op = row.operator("bim.select_group_products", text="", icon="RESTRICT_SELECT_OFF") @@ -130,6 +130,10 @@ class BIM_UL_groups(UIList): op.group = group_id op = row.operator("bim.remove_group", text="", icon="X") op.group = group_id + if item.selection_query != "": + op = row.operator("bim.update_group", text="", icon="FILE_REFRESH") + op.group_id = item.ifc_definition_id + op.query = item.selection_query else: op = row.operator("bim.select_group_products", text="", icon="RESTRICT_SELECT_OFF") op.group = group_id @@ -137,12 +141,18 @@ class BIM_UL_groups(UIList): op.group = group_id op = row.operator("bim.remove_group", text="", icon="X") op.group = group_id + if item.selection_query != "": + op = row.operator("bim.update_group", text="", icon="FILE_REFRESH") + op.group_id = item.ifc_definition_id + op.query = item.selection_query class BIM_UL_object_groups(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) - row.label(text=item.name) + row.label(text=f"*{item.name}") if item.selection_query != "" else row.label(text=item.name) + op = row.operator("bim.remove_group", text="", icon="X") + op.group = item.ifc_definition_id op = row.operator("bim.assign_group", text="", icon="ADD") op.group = item.ifc_definition_id diff --git a/src/blenderbim/blenderbim/bim/module/project/operator.py b/src/blenderbim/blenderbim/bim/module/project/operator.py index dd4c00d9c4..f78f1858e1 100644 --- a/src/blenderbim/blenderbim/bim/module/project/operator.py +++ b/src/blenderbim/blenderbim/bim/module/project/operator.py @@ -315,12 +315,16 @@ class AppendLibraryElement(bpy.types.Operator): bl_idname = "bim.append_library_element" bl_label = "Append Library Element" bl_options = {"REGISTER", "UNDO"} + bl_description = "Append element to the current project" definition: bpy.props.IntProperty() prop_index: bpy.props.IntProperty() @classmethod def poll(cls, context): - return IfcStore.get_file() + poll = bool(IfcStore.get_file()) + if bpy.app.version > (3, 0, 0) and not poll: + cls.poll_message_set("Please create or load a project first.") + return poll def execute(self, context): return IfcStore.execute_ifc_operator(self, context) diff --git a/src/blenderbim/blenderbim/bim/module/search/__init__.py b/src/blenderbim/blenderbim/bim/module/search/__init__.py index 0eadf9a42d..57379f90a1 100644 --- a/src/blenderbim/blenderbim/bim/module/search/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/search/__init__.py @@ -38,6 +38,7 @@ classes = ( operator.SaveSelectorQuery, operator.OpenQueryLibrary, operator.LoadQuery, + operator.AddToIfcGroup, prop.BIMFilterClasses, prop.BIMFilterBuildingStoreys, prop.BIMSearchProperties, diff --git a/src/blenderbim/blenderbim/bim/module/search/operator.py b/src/blenderbim/blenderbim/bim/module/search/operator.py index f1736d85f0..5ff9547041 100644 --- a/src/blenderbim/blenderbim/bim/module/search/operator.py +++ b/src/blenderbim/blenderbim/bim/module/search/operator.py @@ -20,9 +20,11 @@ import re import bpy import ifcopenshell import ifcopenshell.util.element +from ifcopenshell.api.group.data import Data from ifcopenshell.util.selector import Selector import blenderbim.tool as tool from blenderbim.bim.ifc import IfcStore +from blenderbim.bim.helper import close_operator_panel from itertools import cycle from bpy.types import PropertyGroup, Operator from bpy.props import ( @@ -462,7 +464,6 @@ class ActivateIfcBuildingStoreyFilter(Operator): row.operator("bim.toggle_filter_selection", text="Select All").action = "SELECT" row.operator("bim.toggle_filter_selection", text="Deselect All").action = "DESELECT" - class UnhideAllElements(Operator): """Filter model elements based on selection""" @@ -479,7 +480,6 @@ class UnhideAllElements(Operator): class FilterModelElements(Operator): """Filter model elements based on selection""" - bl_idname = "bim.filter_model_elements" bl_label = "Filter Model Elements" option: StringProperty("select|isolate|hide") @@ -513,7 +513,7 @@ class FilterModelElements(Operator): selection = self.add_filters(selection, query) elif query.selector == "GlobalId": - selection += f"#{query.global_id}" + selection += f"#{query.value}" elif query.selector == "IfcElementType": index = int(query.active_sub_option.split(":")[0]) @@ -562,7 +562,6 @@ class FilterModelElements(Operator): class IfcSelector(Operator): """Select elements in model with IFC Selector""" - bl_idname = "bim.ifc_selector" bl_label = "Select elements with IFC Selector" @@ -603,19 +602,11 @@ class SaveSelectorQuery(Operator): class OpenQueryLibrary(Operator): """Open Query Library""" - bl_idname = "bim.open_query_library" bl_label = "Open Query Library" def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self, width=400) - - def close_panel(event): - x, y = event.mouse_x, event.mouse_y - bpy.context.window.cursor_warp(10, 10) - - move_back = lambda: bpy.context.window.cursor_warp(x, y) - bpy.app.timers.register(move_back, first_interval=0.001) + return context.window_manager.invoke_popup(self, width=400) def draw(self, context): layout = self.layout @@ -641,8 +632,37 @@ class LoadQuery(Operator): bl_idname = "bim.load_query" bl_label = "Load Query" index: IntProperty() - + + def invoke(self, context, event): + close_operator_panel(event) + return self.execute(context) + def execute(self, context): ifc_selector = context.scene.IfcSelectorProperties ifc_selector.selector_query_syntax = ifc_selector.query_library[self.index].query return {"FINISHED"} + +class AddToIfcGroup(Operator): + bl_idname = "bim.add_to_ifc_group" + bl_label = "Add to IFC Group" + group_name: StringProperty(name="Group Name") + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self, width=400) + + def draw(self, context): + layout = self.layout + layout.prop(self, "group_name") + + def execute(self, context): + self.file = IfcStore.get_file() + ifc_selector = context.scene.IfcSelectorProperties + selector_query_syntax = ifc_selector.selector_query_syntax + + group = ifcopenshell.api.run("group.add_group", self.file, **{"Name": self.group_name, "Description": f'*selector*{selector_query_syntax}*selector*'}) + objects = Selector.parse(self.file, selector_query_syntax) + + ifcopenshell.api.run("group.assign_group", self.file, **{"product": objects, "group": group}) + Data.load(IfcStore.get_file()) + + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/search/ui.py b/src/blenderbim/blenderbim/bim/module/search/ui.py index 01669337d0..0d0b1be7ca 100644 --- a/src/blenderbim/blenderbim/bim/module/search/ui.py +++ b/src/blenderbim/blenderbim/bim/module/search/ui.py @@ -153,7 +153,8 @@ class IfcSelectorUI: row = layout.row(align=True) row.alignment = "CENTER" row.operator("bim.save_selector_query", text="Save Query") - row.operator("bim.open_query_library", text="Load Query") + op = row.operator("bim.open_query_library", text="Load Query") + row.operator("bim.add_to_ifc_group", text="Add to IFC Group") def draw_query_group_ui(self, ifc_selector, layout): for index, group in enumerate(ifc_selector.groups): diff --git a/src/blenderbim/blenderbim/bim/module/structural/operator.py b/src/blenderbim/blenderbim/bim/module/structural/operator.py index f9ca9d17a8..bbe1a4a016 100644 --- a/src/blenderbim/blenderbim/bim/module/structural/operator.py +++ b/src/blenderbim/blenderbim/bim/module/structural/operator.py @@ -662,7 +662,7 @@ class AddStructuralLoadGroup(bpy.types.Operator): def _execute(self, context): self.file = IfcStore.get_file() load_group = ifcopenshell.api.run("structural.add_structural_load_group", self.file) - ifcopenshell.api.run("group.assign_group", self.file, product=load_group, group=self.file.by_id(self.load_case)) + ifcopenshell.api.run("group.assign_group", self.file, product=[load_group], group=self.file.by_id(self.load_case)) Data.load(IfcStore.get_file()) return {"FINISHED"} @@ -754,7 +754,7 @@ class AddStructuralActivity(bpy.types.Operator): structural_member=element, ) ifcopenshell.api.run( - "group.assign_group", self.file, product=activity, group=self.file.by_id(self.load_group) + "group.assign_group", self.file, product=[activity], group=self.file.by_id(self.load_group) ) Data.load(IfcStore.get_file()) bpy.ops.bim.enable_editing_structural_load_group_activities(load_group=self.load_group) diff --git a/src/blenderbim/blenderbim/bim/operator.py b/src/blenderbim/blenderbim/bim/operator.py index ad1ab0553a..83859a2bc0 100644 --- a/src/blenderbim/blenderbim/bim/operator.py +++ b/src/blenderbim/blenderbim/bim/operator.py @@ -27,7 +27,7 @@ from . import schema from blenderbim.bim.ifc import IfcStore from blenderbim.bim.prop import StrProperty from blenderbim.bim.ui import IFCFileSelector -from blenderbim.bim.helper import get_enum_items +from blenderbim.bim.helper import get_enum_items, close_operator_panel from mathutils import Vector, Matrix, Euler from math import radians @@ -531,6 +531,7 @@ def update_enum_property_search_prop(self, context): for i, prop in enumerate(self.collection_names): if prop.name == self.dummy_name: setattr(context.data, self.prop_name, self.collection_identifiers[i].name) + close_operator_panel(self) break @@ -542,8 +543,11 @@ class BIM_OT_enum_property_search(bpy.types.Operator): collection_names: bpy.props.CollectionProperty(type=StrProperty) collection_identifiers: bpy.props.CollectionProperty(type=StrProperty) prop_name: bpy.props.StringProperty() + mouse_x: bpy.props.IntProperty() + mouse_y: bpy.props.IntProperty() def invoke(self, context, event): + self.mouse_x, self.mouse_y = event.mouse_x, event.mouse_y self.clear_collections() self.data = context.data items = get_enum_items(self.data, self.prop_name, context) @@ -551,7 +555,9 @@ class BIM_OT_enum_property_search(bpy.types.Operator): return {"FINISHED"} self.add_items_regular(items) self.add_items_suggestions() - return context.window_manager.invoke_props_dialog(self) + # Cursor is moved when we update dummy_name, set it back + context.window.cursor_warp(self.mouse_x, self.mouse_y) + return context.window_manager.invoke_props_popup(self, event) def draw(self, context): # Mandatory to access context.data in update : diff --git a/src/blenderbim/blenderbim/core/drawing.py b/src/blenderbim/blenderbim/core/drawing.py index bb836e2908..ab58f5407a 100644 --- a/src/blenderbim/blenderbim/core/drawing.py +++ b/src/blenderbim/blenderbim/core/drawing.py @@ -148,7 +148,7 @@ def add_drawing(ifc, collector, drawing, target_view=None, location_hint=None): ) group = ifc.run("group.add_group") ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"}) - ifc.run("group.assign_group", group=group, product=element) + ifc.run("group.assign_group", group=group, product=[element]) collector.assign(camera) pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing") ifc.run( @@ -203,7 +203,7 @@ def add_annotation(ifc, collector, drawing_tool, drawing=None, object_type=None) context=context, ifc_representation_class=drawing_tool.get_ifc_representation_class(object_type), ) - ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), product=element) + ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), product=[element]) collector.assign(obj) drawing_tool.enable_editing(obj) @@ -251,7 +251,7 @@ def sync_references(ifc, collector, drawing_tool, drawing=None): annotation = drawing_tool.generate_reference_annotation(drawing, reference_element, context) if annotation: ifc.run("drawing.assign_product", relating_product=reference_element, related_object=annotation) - ifc.run("group.assign_group", group=group, product=annotation) + ifc.run("group.assign_group", group=group, product=[annotation]) collector.assign(ifc.get_object(annotation)) if reference_obj and ifc.is_moved(reference_obj): diff --git a/src/blenderbim/docs/devs/running_tests.rst b/src/blenderbim/docs/devs/running_tests.rst index 165ddd1027..e2559750c5 100644 --- a/src/blenderbim/docs/devs/running_tests.rst +++ b/src/blenderbim/docs/devs/running_tests.rst @@ -6,7 +6,8 @@ Running tests The BlenderBIM Add-on has three layers of tests for each of its three technology layers. These roughly form a test pyramid, moving from many abstract domain logic tests, to low-level concrete unit tests, to a minimal number of UI and -smoke tests. These tests use ``pytest`` as the test framework and runner. +smoke tests. These tests use ``pytest`` as the test framework and runner, so you +will need to install ``pytest``. All development is expected to use test driven development, and so we expect test coverage to be 100% where it is technically possible to test. @@ -39,8 +40,8 @@ similar. Tool tests ---------- -The tool layer tests actual concrete functions. These have the following -dependencies: +The tool layer tests actual concrete functions. You will need to install the +following dependencies: * pytest-blender, accessible to your system's Python * Blender executable, accessible to pytest-blender on your system's Python @@ -51,6 +52,16 @@ dependencies: You can install the dependencies by running the ``scripts/setup_pytest.py`` script in Blender. +.. warning:: + + The ``scripts/setup_pytest.py`` may not work for all operating systems and + installation environments. In this case, you may be required to install the + dependencies manually. + + Please be aware that some Blender may come packaged with its own Python, + which may be separate to the Python installation on your system. Be sure to + install the dependencies to the correct Python environment. + Then, run the tests. This will launch Blender headlessly and check the behaviour of all concrete functions. diff --git a/src/blenderbim/scripts/setup_pytest.py b/src/blenderbim/scripts/setup_pytest.py index 262f97587f..2e2f6eea51 100644 --- a/src/blenderbim/scripts/setup_pytest.py +++ b/src/blenderbim/scripts/setup_pytest.py @@ -16,16 +16,40 @@ # You should have received a copy of the GNU General Public License # along with BlenderBIM Add-on. If not, see . -import subprocess import sys -from pathlib import Path +import subprocess + +print("Here are the detected system paths:") +print(sys.path) py_exec = str(sys.executable) -lib = Path(py_exec).parent.parent / "lib" + +print("Detected executable:", py_exec) subprocess.call([py_exec, "-m", "ensurepip", "--user"]) -subprocess.call([py_exec, "-m", "pip", "install", "--upgrade", "pip"]) -subprocess.call([py_exec, "-m", "pip", "install", f"--target={str(lib)}", "pytest"]) -subprocess.call([py_exec, "-m", "pip", "install", f"--target={str(lib)}", "pytest-blender"]) -subprocess.call([py_exec, "-m", "pip", "install", f"--target={str(lib)}", "pytest-bdd"]) -subprocess.call([py_exec, "-m", "pip", "install", f"--target={str(lib)}", "pygments"]) +subprocess.call([py_exec, "-m", "pip", "install", "--upgrade", "pip"]) + +sys_paths = [p for p in sys.path if "site-packages" in p] +if sys_paths: + print("Detected installation directory:", sys_paths[-1]) + subprocess.call([py_exec, "-m", "pip", "install", f"--target={sys_paths[-1]}", "pytest"]) + subprocess.call([py_exec, "-m", "pip", "install", f"--target={sys_paths[-1]}", "pytest-bdd"]) + subprocess.call([py_exec, "-m", "pip", "install", f"--target={sys_paths[-1]}", "pytest-blender"]) + subprocess.call([py_exec, "-m", "pip", "install", f"--target={sys_paths[-1]}", "pygments"]) +else: + print("Could not detect installation directory. Good luck.") + subprocess.call([py_exec, "-m", "pip", "install", "pytest"]) + subprocess.call([py_exec, "-m", "pip", "install", "pytest-bdd"]) + subprocess.call([py_exec, "-m", "pip", "install", "pytest-blender"]) + subprocess.call([py_exec, "-m", "pip", "install", "pygments"]) + +try: + import pytest + import pytest_bdd + import pytest_blender + import pygments + + print("Test dependency installation was successful!") +except Exception as e: + print("Installation failed :(") + print(e) diff --git a/src/blenderbim/test/bim/feature/search.feature b/src/blenderbim/test/bim/feature/search.feature new file mode 100644 index 0000000000..b4070cc05a --- /dev/null +++ b/src/blenderbim/test/bim/feature/search.feature @@ -0,0 +1,17 @@ +@search +Feature: Search + +Scenario: Select all walls + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + And I add a new group to IfcSelector + And I add a new query to IfcSelector + And I set "scene.IfcSelectorProperties.groups[0].queries[0].selector" to "IFC Class" + And I set "scene.IfcSelectorProperties.groups[0].queries[0].active_option" to "124: IfcWall" + When I press "bim.filter_model_elements(option='select')" + Then the object "IfcWall/Cube" is selected + + diff --git a/src/blenderbim/test/bim/test_feature.py b/src/blenderbim/test/bim/test_feature.py index bfd8a8bc87..07249bb456 100644 --- a/src/blenderbim/test/bim/test_feature.py +++ b/src/blenderbim/test/bim/test_feature.py @@ -99,6 +99,15 @@ def i_add_a_sun(): def i_add_a_material(): bpy.context.active_object.active_material = bpy.data.materials.new("Material") +@given("I add a new group to IfcSelector") +@when("I add a new group to IfcSelector") +def i_add_a_new_collection_item(): + bpy.data.scenes["Scene"].IfcSelectorProperties.groups.add() + +@given("I add a new query to IfcSelector") +@when("I add a new query to IfcSelector") +def i_add_a_new_collection_item(): + bpy.data.scenes["Scene"].IfcSelectorProperties.groups[0].queries.add() @given(parsers.parse('the material "{name}" colour is set to "{colour}"')) @when(parsers.parse('the material "{name}" colour is set to "{colour}"')) diff --git a/src/blenderbim/test/core/test_drawing.py b/src/blenderbim/test/core/test_drawing.py index 3746379740..d6cd58d45e 100644 --- a/src/blenderbim/test/core/test_drawing.py +++ b/src/blenderbim/test/core/test_drawing.py @@ -223,7 +223,7 @@ class TestAddDrawing: ifc.run( "group.edit_group", group="group", attributes={"Name": "name", "ObjectType": "DRAWING"} ).should_be_called() - ifc.run("group.assign_group", group="group", product="element").should_be_called() + ifc.run("group.assign_group", group="group", product=["element"]).should_be_called() collector.assign("obj").should_be_called() ifc.run("pset.add_pset", product="element", name="EPset_Drawing").should_be_called().will_return("pset") ifc.run( @@ -293,7 +293,7 @@ class TestAddAnnotation: ifc_representation_class="ifc_representation_class", ).should_be_called().will_return("element") drawing.get_drawing_group("drawing").should_be_called().will_return("group") - ifc.run("group.assign_group", group="group", product="element").should_be_called() + ifc.run("group.assign_group", group="group", product=["element"]).should_be_called() collector.assign("obj").should_be_called() drawing.enable_editing("obj").should_be_called() subject.add_annotation(ifc, collector, drawing, drawing="drawing", object_type="object_type") diff --git a/src/blenderbim/test/tool/test_collector.py b/src/blenderbim/test/tool/test_collector.py index 496d3fc929..f9b7c76b08 100644 --- a/src/blenderbim/test/tool/test_collector.py +++ b/src/blenderbim/test/tool/test_collector.py @@ -222,7 +222,7 @@ class TestAssign(NewFile): tool.Ifc.link(element, element_obj) group = ifcopenshell.api.run("group.add_group", tool.Ifc.get()) group.ObjectType = "DRAWING" - ifcopenshell.api.run("group.assign_group", tool.Ifc.get(), product=element, group=group) + ifcopenshell.api.run("group.assign_group", tool.Ifc.get(), product=[element], group=group) subject.assign(element_obj) assert element_obj.users_collection[0].name == "IfcGroup/Unnamed" assert bpy.data.collections.get("Views").children.get("IfcGroup/Unnamed") @@ -235,7 +235,7 @@ class TestAssign(NewFile): tool.Ifc.link(element, element_obj) group = ifcopenshell.api.run("group.add_group", tool.Ifc.get()) group.ObjectType = "DRAWING" - ifcopenshell.api.run("group.assign_group", tool.Ifc.get(), product=element, group=group) + ifcopenshell.api.run("group.assign_group", tool.Ifc.get(), product=[element], group=group) subject.assign(element_obj) assert element_obj.users_collection[0].name == "IfcGroup/Unnamed" assert bpy.data.collections.get("Views").children.get("IfcGroup/Unnamed") diff --git a/src/blenderbim/test/tool/test_drawing.py b/src/blenderbim/test/tool/test_drawing.py index 64048c8192..b55459df99 100644 --- a/src/blenderbim/test/tool/test_drawing.py +++ b/src/blenderbim/test/tool/test_drawing.py @@ -260,7 +260,7 @@ class TestGetDrawingGroup(NewFile): tool.Ifc.set(ifc) element = ifc.createIfcAnnotation() group = ifcopenshell.api.run("group.add_group", ifc) - ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + ifcopenshell.api.run("group.assign_group", ifc, product=[element], group=group) assert subject.get_drawing_group(element) == group @@ -280,7 +280,7 @@ class TestGetGroupElements(NewFile): tool.Ifc.set(ifc) element = ifc.createIfcAnnotation() group = ifcopenshell.api.run("group.add_group", ifc) - ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + ifcopenshell.api.run("group.assign_group", ifc, product=[element], group=group) assert subject.get_group_elements(group) == (element,) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py index 6a4ebfc2f5..4014fbae97 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py @@ -23,7 +23,10 @@ import ifcopenshell.api class Usecase: def __init__(self, file, **settings): self.file = file - self.settings = {} + self.settings = { + "Name": "Unnamed", + "Description": "", + } for key, value in settings.items(): self.settings[key] = value @@ -33,6 +36,7 @@ class Usecase: **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.run("owner.create_owner_history", self.file), - "Name": "Unnamed", + "Name": self.settings["Name"], + "Description": self.settings["Description"], } ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py index b8cdea2d9f..0318caba09 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py @@ -37,12 +37,13 @@ class Usecase: **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.run("owner.create_owner_history", self.file), - "RelatedObjects": [self.settings["product"]], + "RelatedObjects": self.settings["product"], "RelatingGroup": self.settings["group"], } ) rel = self.settings["group"].IsGroupedBy[0] related_objects = set(rel.RelatedObjects) or set() - related_objects.add(self.settings["product"]) + for obj in self.settings["product"]: + related_objects.add(obj) rel.RelatedObjects = list(related_objects) ifcopenshell.api.run("owner.update_owner_history", self.file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py index a47d571ca6..2851b85029 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py @@ -20,7 +20,8 @@ class Usecase: def __init__(self, file, **settings): self.file = file - self.settings = {"group": None, "attributes": {}} + self.settings = { + "group": None, "attributes": {}} for key, value in settings.items(): self.settings[key] = value diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py b/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py new file mode 100644 index 0000000000..f7caac2885 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py @@ -0,0 +1,33 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +class Usecase: + def __init__(self, file, **settings): + self.file = file + self.settings = { + "group": None, + "products": None, + } + for key, value in settings.items(): + self.settings[key] = value + + def execute(self): + rel = self.settings["group"].IsGroupedBy[0] + rel.RelatedObjects = self.settings["products"] + diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py index e2d663cf8f..6f2cdfec05 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py @@ -41,6 +41,8 @@ class Usecase: properties = self.settings["pset"].HasProperties or [] elif self.settings["pset"].is_a("IfcQuantitySet"): properties = self.settings["pset"].Quantities or [] + elif self.settings["pset"].is_a() in ("IfcMaterialProperties", "IfcProfileProperties"): + properties = self.settings["pset"].Properties or [] for prop in properties: self.file.remove(prop) self.file.remove(self.settings["pset"]) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index fb98c7a87d..fd4b790350 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -189,7 +189,7 @@ class Selector: value = None for element in elements: element_value = cls.get_element_value(element, key) - if element_value is None and value is not None: + if element_value is None and value is not None and "not" not in comparison: continue if comparison and cls.filter_element(element, element_value, comparison, value): results.append(element) diff --git a/src/ifcopenshell-python/test/api/pset/test_remove_pset.py b/src/ifcopenshell-python/test/api/pset/test_remove_pset.py index 4f5291a3ef..e06ebf1a86 100644 --- a/src/ifcopenshell-python/test/api/pset/test_remove_pset.py +++ b/src/ifcopenshell-python/test/api/pset/test_remove_pset.py @@ -29,6 +29,22 @@ class TestRemovePset(test.bootstrap.IFC4): assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 assert len(self.file.by_type("IfcPropertySet")) == 0 + def test_removing_material_psets(self): + element = self.file.createIfcMaterial() + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + assert len(element.HasProperties) == 1 + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert len(element.HasProperties) == 0 + assert len(self.file.by_type("IfcMaterialProperties")) == 0 + + def test_removing_profile_psets(self): + element = self.file.createIfcProfileDef() + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + assert len(element.HasProperties) == 1 + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert len(element.HasProperties) == 0 + assert len(self.file.by_type("IfcMaterialProperties")) == 0 + def test_only_unassigning_if_pset_is_used_by_other_elements(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") diff --git a/src/ifcopenshell-python/test/test_ids.py b/src/ifcopenshell-python/test/test_ids.py index ccd7e20fac..55ac1aa55e 100644 --- a/src/ifcopenshell-python/test/test_ids.py +++ b/src/ifcopenshell-python/test/test_ids.py @@ -1111,7 +1111,7 @@ class TestIdsAuthoring(unittest.TestCase): group = ifcopenshell.api.run("group.add_group", ifc) facet = ids.partOf.create(entity="IfcGroup") run("", facet=facet, inst=element, expected=False) - ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + ifcopenshell.api.run("group.assign_group", ifc, product=[element], group=group) run("", facet=facet, inst=element, expected=True) # An IfcGroup can be passed by subtypes @@ -1119,7 +1119,7 @@ class TestIdsAuthoring(unittest.TestCase): element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") group = ifc.createIfcInventory() facet = ids.partOf.create(entity="IfcGroup") - ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + ifcopenshell.api.run("group.assign_group", ifc, product=[element], group=group) run("", facet=facet, inst=element, expected=True) # An IfcSystem only checks that a system is assigned without any other logic diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 37a744e6e4..787b4b2b60 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -112,6 +112,27 @@ class TestSelector(test.bootstrap.IFC4): assert subject.Selector.parse(self.file, '.IfcElement[Name*="oba"]') == [element] assert subject.Selector.parse(self.file, '.IfcElement[Name*="abc"]') == [] + def test_selecting_if_value_not_matching(self): + element_1 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element_2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset_1 = ifcopenshell.api.run("pset.add_pset", self.file, product=element_1, name="Foo_Bar") + pset_2 = ifcopenshell.api.run("pset.add_pset", self.file, product=element_2, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset_1, properties={"Foo": "Bar"}) + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset_2, properties={"Foo": "BOO"}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo != "Bar"]') == [element_2] + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo != "BOO"]') == [element_1] + + + def test_selecting_when_attribute_is_none(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + assert subject.Selector.parse(self.file, '.IfcElement[PredefinedType !="non-existent predefined type"]') == [element] + + def test_selecting_a_property_which_includes_non_standard_characters(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="a !%$§&/()?|*-+,€~#@µ^°a") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"a !%$§&/()?|*-+,€~#@µ^°a": "Bar"}) + assert subject.Selector.parse(self.file, '.IfcElement[a !%$§&/()?|*-+,€~#@µ^°a.a !%$§&/()?|*-+,€~#@µ^°a="Bar"]') == [element] + def test_comparing_if_value_is_in_a_list(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") element.Name = "Foobar" diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index bec92041f9..9b3187ab39 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -42,6 +42,7 @@ def cast_to_value(from_value, to_value): class Facet: def __init__(self, *parameters): + self.status = None self.failed_entities = [] self.failed_reasons = [] for i, name in enumerate(self.parameters): diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index 51fb043a8d..4b8afaf3c5 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -217,7 +217,8 @@ class Specification: self.applicable_entities.append(element) for facet in self.requirements: result = facet(element) - if not bool(result): + facet.status = bool(result) + if not facet.status: self.failed_entities.add(element) facet.failed_entities.append(element) facet.failed_reasons.append(str(result)) @@ -227,5 +228,7 @@ class Specification: self.status = False elif self.minOccurs != 0 and not self.applicable_entities: self.status = False + for facet in self.requirements: + facet.status = False elif len(self.applicable_entities) > (self.maxOccurs or 1): self.status = False diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index 6148526033..6a98ecc0c0 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -28,10 +28,10 @@ class Reporter: def report(self, ids): pass - def to_string(): + def to_string(self): return "" - def write(filepath): + def write(self, filepath): pass @@ -142,7 +142,7 @@ class Json(Reporter): requirements.append( { "description": requirement.to_string("requirement"), - "success": not requirement.failed_entities, + "status": requirement.status, "failed_entities": [ {"reason": requirement.failed_reasons[i], "element": str(e)} for i, e in enumerate(requirement.failed_entities[0:10]) diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 9980eb685b..9f0c7c582e 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -1157,7 +1157,7 @@ class TestPartOf: group = ifcopenshell.api.run("group.add_group", ifc) facet = PartOf(entity="IfcGroup") run("", facet=facet, inst=element, expected=False) - ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + ifcopenshell.api.run("group.assign_group", ifc, product=[element], group=group) run("", facet=facet, inst=element, expected=True) # An IfcGroup can be passed by subtypes @@ -1165,7 +1165,7 @@ class TestPartOf: element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") group = ifc.createIfcInventory() facet = PartOf(entity="IfcGroup") - ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + ifcopenshell.api.run("group.assign_group", ifc, product=[element], group=group) run("", facet=facet, inst=element, expected=True) # An IfcSystem only checks that a system is assigned without any other logic diff --git a/src/ifctester/webapp/app.py b/src/ifctester/webapp/app.py new file mode 100644 index 0000000000..ca79f7848d --- /dev/null +++ b/src/ifctester/webapp/app.py @@ -0,0 +1,77 @@ +# IfcTester - IDS based model auditing +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcTester. +# +# IfcTester is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcTester 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcTester. If not, see . + + +import os +import time +import ifctester +import ifctester.reporter +import ifcopenshell +from flask import Flask, request, send_from_directory + +app = Flask(__name__) + + +class Ifc: + ifc = None + filepath = None + + @classmethod + def get(cls, filepath=None): + if filepath is None or filepath == cls.filepath: + return cls.ifc + cls.filepath = filepath + cls.ifc = ifcopenshell.open(filepath) + return cls.ifc + + +@app.route("/") +def index(): + with open("www/index.html") as template: + return template.read() + + +@app.route("/.") +def get_asset(asset, ext): + if ext in ("js", "css"): + return send_from_directory("www", asset + "." + ext) + + +@app.route("/audit", methods=["POST"]) +def audit(): + filename = ifcopenshell.guid.new() + ids_filepath = os.path.join("uploads", filename + ".ids") + ifc_filepath = os.path.join("uploads", filename + ".ifc") + request.files.get("ids").save(ids_filepath) + request.files.get("ifc").save(ifc_filepath) + + start = time.time() + specs = ifctester.open(ids_filepath) + ifc = Ifc.get(ifc_filepath) + print("Finished loading:", time.time() - start) + start = time.time() + specs.validate(ifc) + print("Finished validating:", time.time() - start) + start = time.time() + + os.remove(ids_filepath) + os.remove(ifc_filepath) + + engine = ifctester.reporter.Json(specs) + engine.report() + return engine.to_string() diff --git a/src/ifctester/webapp/www/app.js b/src/ifctester/webapp/www/app.js index fe56432c2a..9514820818 100644 --- a/src/ifctester/webapp/www/app.js +++ b/src/ifctester/webapp/www/app.js @@ -10,6 +10,7 @@ class IDSContainer extends HTMLElement { this.filename = 'specifications.ids'; this.ids = null; this.containerId = crypto.randomUUID(); + this.isEditing = true; } } @@ -315,6 +316,13 @@ class IDSFacets extends HTMLElement { } feather.replace(); } + + showResults(requirements) { + var facetElements = this.getElementsByTagName('ids-facet'); + for (var i=0; i SAVE - - AUDIT MODEL - + + + AUDIT MODEL + + CLOSE @@ -139,6 +141,13 @@ + + Optionally write instructions about how to achieve this requirement. @@ -163,7 +172,7 @@ - + diff --git a/src/ifctester/webapp/www/style.css b/src/ifctester/webapp/www/style.css index 382aa1f57b..fd86d02296 100644 --- a/src/ifctester/webapp/www/style.css +++ b/src/ifctester/webapp/www/style.css @@ -270,20 +270,30 @@ ids-spec ids-spec-handle .snippet { bottom: -5px; position: relative; } -.result-icon { +ids-result { display: inline-block; position: absolute; margin-left: -30px; - margin-top: -2px; + margin-top: -3px; cursor: help; } -.result-icon.pass { +ids-result span { + position: absolute; + float: right; + right: 30px; + background-color: #333; + color: white; + font-family: 'Nova Mono'; + padding: 3px; + border-radius: 5px; +} +ids-result[name="pass"] { color: var(--green); } -.result-icon.fail { +ids-result[name="fail"] { color: var(--red); } -.result-icon:hover { +ids-result:hover { color: var(--blue); } .hidden {