From 823f27cd6a859470377221d3e36a0a00b1070ee9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 10 Mar 2025 22:53:55 +1100 Subject: [PATCH] Add tests for new element add feature --- src/bonsai/test/bim/feature/geometry.feature | 3 +- src/bonsai/test/bim/feature/root.feature | 68 ++++++- src/bonsai/test/bim/test_feature.py | 168 +++++++++++++----- .../ifcopenshell/util/representation.py | 4 +- 4 files changed, 195 insertions(+), 48 deletions(-) diff --git a/src/bonsai/test/bim/feature/geometry.feature b/src/bonsai/test/bim/feature/geometry.feature index eeda85681f..16dddb4a39 100644 --- a/src/bonsai/test/bim/feature/geometry.feature +++ b/src/bonsai/test/bim/feature/geometry.feature @@ -189,8 +189,7 @@ Scenario: Update representation - updating a layered extrusion And I press "bim.edit_material_set_item(material_set_item={layer})" And I press "bim.edit_assigned_material(material_set={layer_set})" And the variable "type" is "{ifc}.by_type('IfcWallType')[0].id()" - And I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" - When I press "bim.update_representation(obj='IfcWall/Cube')" + When I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" Then the object "IfcWall/Cube" has a "SweptSolid" representation of "Model/Body/MODEL_VIEW" Scenario: Update representation - updating a profiled extrusion diff --git a/src/bonsai/test/bim/feature/root.feature b/src/bonsai/test/bim/feature/root.feature index f8cd1c14bf..9a5ad5d698 100644 --- a/src/bonsai/test/bim/feature/root.feature +++ b/src/bonsai/test/bim/feature/root.feature @@ -1,6 +1,70 @@ @root Feature: Root +Scenario: Add element - a type with no geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Name" property to "Foo" + And I set the "Description" property to "Bar" + And I set the "Definition" property to "IfcElementType" + And I set the "Class" property to "IfcFurnitureType" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "No Geometry" + When I click "OK" + Then the object "IfcFurnitureType/Foo" exists + And the object "IfcFurnitureType/Foo" has no data + +Scenario: Add element - an element with no geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Custom Extruded Solid" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcExtrudedAreaSolid/77" exists + +Scenario: Add element - an element with extrusion geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Custom Extruded Solid" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcExtrudedAreaSolid/77" exists + +Scenario: Add element - an element with custom tessellation geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Custom Tessellation" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcPolygonalFaceSet/76" exists + +Scenario: Add element - an element with tessellation geometry from an object + Given an empty IFC project + And I add a cube + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Tessellation From Object" + And I set the "Object" property to "Cube" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcPolygonalFaceSet/76" exists + And the object "Item/IfcPolygonalFaceSet/76" dimensions are "2,2,2" + Scenario: Reassign class Given an empty IFC project And I add a cube @@ -77,7 +141,7 @@ Scenario: Assign a spatial class to a cube already in a collection And I set "scene.BIMRootProperties.ifc_class" to "IfcSpace" And I press "bim.assign_class" Then the object "IfcSpace/Cube" is an "IfcSpace" - And the object "IfcSpace/Cube" is in the collection "IfcSpace/Cube" + And the object "IfcSpace/Cube" is in the collection "IfcSpace" And the object "IfcSpace/Cube" has a "Tessellation" representation of "Model/Body/MODEL_VIEW" Scenario: Assign a class to a cube in a collection @@ -103,7 +167,7 @@ Scenario: Copy a wall Scenario: Copy a storey - when locked Given an empty IFC project And the object "IfcBuildingStorey/My Storey" is selected - When I duplicate the selected objects + Then I expect an error "Error: 'IfcBuildingStorey/My Storey' is locked. Unlock it via the Spatial panel in the Project Overview tab." when "i_duplicate_the_selected_objects()" Then the object "IfcBuildingStorey/My Storey.001" does not exist Scenario: Copy a storey - when unlocked diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index c5e084e031..94ab5db19f 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -65,12 +65,14 @@ class PanelSpy: def __getattr__(self, attr): self.spied_attr = attr + if annotation := self.panel.__annotations__.get(attr, None): + return annotation.keywords.get("default", None) # An operator property if attr == "layout": return self return self def __call__(self, *args, **kwargs): - if self.spied_attr in ("row", "column", "box", "separator"): + if self.spied_attr in ("row", "column", "box", "separator", "menu", "operator_menu_enum"): return self elif self.spied_attr == "template_list": listtype_name, list_id, dataptr, propname, active_dataptr, active_propname = args @@ -123,7 +125,10 @@ class PanelSpy: prefix, op_name = operator.split(".") operator = getattr(getattr(bpy.ops, prefix), op_name) bl_idname = operator.idname() - bl_label = getattr(bpy.types, bl_idname).bl_label + try: + bl_label = getattr(bpy.types, bl_idname).bl_label + except: # Doesn't work on built-ins, I don't know what to do + bl_label = bl_idname text = kwargs.get("text", bl_label) icon = kwargs.get("icon", None) if text: @@ -152,10 +157,29 @@ class TemplateListSpy: self.spied_data = spied_data -panel_name_cache = {} +ui_name_cache = {} panel_spy: PanelSpy = None +def create_ui_name_cache(): + global ui_name_cache + if ui_name_cache: + return + for bl_idname in dir(bpy.types): + try: + panel_type = getattr(bpy.types, bl_idname) + if panel_type.bl_rna.base.name == "Panel": + ui_name_cache[panel_type.bl_label] = panel_type.bl_idname + elif panel_type.bl_rna.base.name == "Operator": + ui_name_cache[panel_type.bl_label] = bl_idname + elif panel_type.bl_rna.base.name == "Menu": + if panel_type.bl_label == "Add" and bl_idname != "VIEW3D_MT_add": + continue # Non-unique, but "VIEW3D_MT_add" is the one we care about + ui_name_cache[panel_type.bl_label] = bl_idname + except: + pass + + def replace_variables(value): for key, new_value in variables.items(): value = value.replace("{" + key + "}", str(new_value)) @@ -218,22 +242,40 @@ def the_brickschema_is_stubbed(): @given(parsers.parse('I look at the "{panel}" panel')) @when(parsers.parse('I look at the "{panel}" panel')) -@when(parsers.parse('I look at the "{panel}" panel')) +@then(parsers.parse('I look at the "{panel}" panel')) def i_look_at_the_panel_panel(panel): - global panel_name_cache + global ui_name_cache global panel_spy - if not panel_name_cache: - for bl_idname in dir(bpy.types): - try: - panel_type = getattr(bpy.types, bl_idname) - if panel_type.bl_rna.base.name != "Panel": - continue - panel_name_cache[panel_type.bl_label] = panel_type.bl_idname - except: - pass - if panel not in panel_name_cache: - assert False, f"Panel {panel} not found in {panel_name_cache}" - panel_spy = PanelSpy(getattr(bpy.types, panel_name_cache[panel])) + create_ui_name_cache() + if panel not in ui_name_cache: + assert False, f"Panel {panel} not found in {ui_name_cache}" + panel_spy = PanelSpy(getattr(bpy.types, ui_name_cache[panel])) + panel_spy.refresh_spy() + + +@given(parsers.parse('I open the "{name}" menu')) +@when(parsers.parse('I open the "{name}" menu')) +@then(parsers.parse('I open the "{name}" menu')) +def i_open_the_name_menu(name): + global ui_name_cache + global panel_spy + create_ui_name_cache() + if name not in ui_name_cache: + assert False, f"Menu {name} not found in {ui_name_cache}" + panel_spy = PanelSpy(getattr(bpy.types, ui_name_cache[name])) + panel_spy.refresh_spy() + + +@given(parsers.parse('I trigger "{operator}"')) +@when(parsers.parse('I trigger "{operator}"')) +@then(parsers.parse('I trigger "{operator}"')) +def i_trigger_operator(operator): + global ui_name_cache + global panel_spy + create_ui_name_cache() + if operator not in ui_name_cache: + assert False, f"Operator {operator} not found in {ui_name_cache}" + panel_spy = PanelSpy(getattr(bpy.types, ui_name_cache[operator])) panel_spy.refresh_spy() @@ -301,27 +343,36 @@ def i_see_the_prop_property_is_value(prop, value): def i_set_the_prop_property_to_value(prop, value): value = value.strip() panel_spy.refresh_spy() - for spied_prop in panel_spy.spied_props: - if prop in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): - if spied_prop["prop_type"] == "BOOLEAN": - if value == "TRUE": - setattr(spied_prop["props"], spied_prop["name"], True) - elif value == "FALSE": - setattr(spied_prop["props"], spied_prop["name"], False) - elif spied_prop["prop_type"] == "FLOAT": - setattr(spied_prop["props"], spied_prop["name"], float(value)) - elif spied_prop["prop_type"] == "INT": - setattr(spied_prop["props"], spied_prop["name"], int(value)) - elif spied_prop["prop_type"] == "ENUM": - enum_identifier = [i for i in spied_prop["enum_items"] if i is not None and i[1] == value] - if not enum_identifier: - assert False, f"Could not find value {value} in enum {spied_prop['enum_items']}" - setattr(spied_prop["props"], spied_prop["name"], enum_identifier[0][0]) - else: - setattr(spied_prop["props"], spied_prop["name"], value) - panel_spy.is_spy_dirty = True - return - assert False, f"Property {prop} not found in {panel_spy.spied_props}" + is_nth = False + if prop[0].isnumeric() and (prop.endswith("st") or prop.endswith("nd") or prop.endswith("th")): + is_nth = True + for nth, spied_prop in enumerate(panel_spy.spied_props): + if is_nth and nth != int(prop[:-2]) - 1: + continue + if not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): + continue + if spied_prop["prop_type"] == "BOOLEAN": + if value == "TRUE": + setattr(spied_prop["props"], spied_prop["name"], True) + elif value == "FALSE": + setattr(spied_prop["props"], spied_prop["name"], False) + elif spied_prop["prop_type"] == "FLOAT": + setattr(spied_prop["props"], spied_prop["name"], float(value)) + elif spied_prop["prop_type"] == "INT": + setattr(spied_prop["props"], spied_prop["name"], int(value)) + elif spied_prop["prop_type"] == "ENUM": + enum_identifier = [i for i in spied_prop["enum_items"] if i is not None and i[1] == value] + if not enum_identifier: + assert False, f"Could not find value {value} in enum {spied_prop['enum_items']}" + setattr(spied_prop["props"], spied_prop["name"], enum_identifier[0][0]) + elif spied_prop["prop_type"] == "POINTER": + setattr(spied_prop["props"], spied_prop["name"], bpy.data.objects.get(value)) + else: + setattr(spied_prop["props"], spied_prop["name"], value) + panel_spy.is_spy_dirty = True + return + debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_props)]) + assert False, f"Property {prop} not found in:\n{debug}" @then(parsers.parse('The "{name}" list has {total} items')) @@ -443,6 +494,20 @@ def i_add_a_plane_of_size_size_at_location(size, location): bpy.ops.mesh.primitive_plane_add(size=float(size), location=[float(co) for co in location.split(",")]) +@then(parsers.parse('I expect an error "{error_msg}" when "{function}"')) +def i_expect_an_error_msg_when_function(error_msg, function): + try: + exec(function) + except Exception as e: + actual_error_msg = str(e).strip() + if str(e).strip() != error_msg: + traceback.print_exc() + msg = f"Got different exception running {function} - '{actual_error_msg}' instead of '{error_msg}'" + assert False, msg + return + assert False, f"Function {function} ran without exception '{error_msg}'" + + @then(parsers.parse('I press "{operator}" and expect error "{error_msg}"')) def i_press_operator_and_expect_error(operator, error_msg): operator = replace_variables(operator) @@ -451,13 +516,14 @@ def i_press_operator_and_expect_error(operator, error_msg): exec(f"bpy.ops.{operator}") else: exec(f"bpy.ops.{operator}()") - assert False, f"Operator bpy.ops.{operator} ran without exception '{error_msg}'" except Exception as e: actual_error_msg = str(e).strip() if str(e).strip() != error_msg: traceback.print_exc() msg = f"Got different exception running bpy.ops.{operator} - '{actual_error_msg}' instead of '{error_msg}'" assert False, msg + return + assert False, f"Operator bpy.ops.{operator} ran without exception '{error_msg}'" @given(parsers.parse('I press "{operator}"')) @@ -489,7 +555,11 @@ def i_click_button(button): val = getattr(spied_prop["props"], spied_prop["name"]) setattr(spied_prop["props"], spied_prop["name"], not bool(val)) return - assert False, f"Could not find {button} in {panel_spy.spied_operators}" + if button == "OK" and panel_spy.panel.bl_rna.base.name == "Operator": + # Clicked confirm on an operator's draw dialog + return i_press_operator(panel_spy.panel.bl_idname) + debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)]) + assert False, f"Could not find {button}:\n{debug}" @given(parsers.parse('I click "{button}" and expect error "{error_msg}"')) @@ -541,6 +611,8 @@ def i_deselect_all_objects(): @given(parsers.parse('the object "{name}" is selected')) @when(parsers.parse('the object "{name}" is selected')) +@given(parsers.parse('I select the object "{name}"')) +@when(parsers.parse('I select the object "{name}"')) def the_object_name_is_selected(name): i_deselect_all_objects() additionally_the_object_name_is_selected(name) @@ -666,7 +738,8 @@ def the_object_name_exists(name: str) -> bpy.types.Object: else: obj = bpy.data.objects.get(name) if not obj: - assert False, f'The object "{name}" does not exist' + debug = "\n".join([o.name for o in bpy.data.objects]) + assert False, f'The object "{name}" does not exist:\n{debug}' return obj @@ -1258,6 +1331,16 @@ def construction_type(relating_type_name): assert relating_type == relating_type_name, f"Construction Type is a {relating_type}, not a {relating_type_name}" +@given("I toggle edit mode") +@when("I toggle edit mode") +@then("I toggle edit mode") +def i_toggle_edit_mode(): + if bpy.context.mode == "OBJECT": + bpy.ops.bim.override_mode_set_edit() + else: + bpy.ops.bim.override_mode_set_object() + + @when("I move the cursor to the bottom left corner") def move_cursor_bottom_left(): bpy.context.window.cursor_warp(10, 10) @@ -1330,7 +1413,7 @@ def the_obj1_and_obj2_belong_the_same_linked_aggregate_group(obj_name1, obj_name def the_obj_layer_lenght_is_set_to(value): value = float(value) try: - eval(f"bpy.context.scene.BIMModelProperties.length") + eval("bpy.context.scene.BIMModelProperties.length") except: assert False, f"Property BIMModelProperties.length does not exist when trying to set to value {value}" @@ -1356,6 +1439,7 @@ def run_test_code(): @then(parsers.parse("I save sample test files")) def saving_sample_test_files(and_open_in_blender=None): filepath = f"{variables['cwd']}/test/files/temp/sample_test_file" + print(f"Saved to {filepath}") bpy.ops.bim.save_project(filepath=f"{filepath}.ifc", should_save_as=True) bpy.ops.wm.save_as_mainfile(filepath=f"{filepath}.blend") diff --git a/src/ifcopenshell-python/ifcopenshell/util/representation.py b/src/ifcopenshell-python/ifcopenshell/util/representation.py index 31ba5a7bee..f5233005af 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/representation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/representation.py @@ -235,12 +235,12 @@ def guess_type(items: Sequence[ifcopenshell.entity_instance]) -> Union[str, None ] ): return "SurfaceModel" - elif all([True if i.is_a("IfcSolidModel") else False for i in items]): - return "SolidModel" elif all( [True if i.is_a() == "IfcExtrudedAreaSolid" or i.is_a() == "IfcRevolvedAreaSolid" else False for i in items] ): return "SweptSolid" + elif all([True if i.is_a("IfcSolidModel") else False for i in items]): + return "SolidModel" elif all( [ (