From 67016d6f90c0fcd46e9afc94dfa22422aaa21882 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 19 May 2025 17:36:12 +1000 Subject: [PATCH] See #2999. Reimplement basic creation of structural items. --- src/bonsai/bonsai/bim/module/root/data.py | 40 +++++++------- src/bonsai/bonsai/bim/module/root/operator.py | 54 ++++++++++++++++++- .../test/bim/feature/structural.feature | 39 ++++++++++++++ src/bonsai/test/bim/test_feature.py | 6 +++ .../ifcopenshell/util/representation.py | 10 ++++ .../ifcopenshell/util/shape_builder.py | 43 ++++++++++++++- .../test/util/test_shape_builder.py | 25 +++++++++ 7 files changed, 194 insertions(+), 23 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index 4f6b234a19..271d966e3c 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -118,29 +118,31 @@ class IfcClassData: def representation_template(cls): rprops = tool.Root.get_root_props() ifc_class = rprops.ifc_class + if ifc_class.startswith("IfcStructuralPoint"): + return [("VERTEX", "Vertex", "A single 3D point")] + elif ifc_class.startswith("IfcStructuralCurve"): + return [("EDGE", "Edge", "A straight edge between two points")] + elif ifc_class.startswith("IfcStructuralSurface"): + return [("FACE", "Face", "A planar face surface")] templates = [ ("EMPTY", "No Geometry", "Start with an empty object"), None, + ( + "OBJ", + "Tessellation From Object", + "Use an object as a template to create a new tessellation", + ), + ( + "MESH", + "Custom Tessellation", + "Create a basic tessellated or faceted cube", + ), + ( + "EXTRUSION", + "Custom Extruded Solid", + "An extrusion from an arbitrary profile", + ), ] - templates.extend( - [ - ( - "OBJ", - "Tessellation From Object", - "Use an object as a template to create a new tessellation", - ), - ( - "MESH", - "Custom Tessellation", - "Create a basic tessellated or faceted cube", - ), - ( - "EXTRUSION", - "Custom Extruded Solid", - "An extrusion from an arbitrary profile", - ), - ] - ) if ifc_class.endswith("Type") or ifc_class.endswith("Style"): templates.extend( [ diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 66520df2fc..41297e822f 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -467,8 +467,15 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): props.representation_template = "EXTRUSION" props.representation_obj = None elif (obj := tool.Blender.get_active_object(is_selected=True)) and obj.type == "MESH": - props.representation_template = "OBJ" - props.representation_obj = obj + if ( + props.ifc_class.startswith("IfcStructuralPoint") + or props.ifc_class.startswith("IfcStructuralCurve") + or props.ifc_class.startswith("IfcStructuralSurface") + ): + pass # Implement auto association? + else: + props.representation_template = "OBJ" + props.representation_obj = obj # For convenience, preselect IFC class if self.ifc_product: props.ifc_product = self.ifc_product @@ -677,6 +684,49 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): elif representation_template == "ROOF": with context.temp_override(active_object=obj, selected_objects=[]): bpy.ops.bim.add_roof() + elif representation_template == "VERTEX": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + representation = builder.get_representation(ifc_context, [builder.vertex()]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + elif representation_template == "EDGE": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + end = Vector((1, 0, 0)) / unit_scale + representation = builder.get_representation(ifc_context, [builder.edge(end=end)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + elif representation_template == "FACE": + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + points = [Vector(p) / unit_scale for p in ((0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0))] + representation = builder.get_representation(ifc_context, [builder.face(points)]) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) bpy.context.view_layer.update() # Ensures obj.matrix_world is correct diff --git a/src/bonsai/test/bim/feature/structural.feature b/src/bonsai/test/bim/feature/structural.feature index feb62a3475..0ae8cdc686 100644 --- a/src/bonsai/test/bim/feature/structural.feature +++ b/src/bonsai/test/bim/feature/structural.feature @@ -1,6 +1,45 @@ @structural Feature: Structural +Scenario: Add element - a structural point connection + Given an empty IFC project + And I trigger "Add Element" + And I set the "Name" property to "Foo" + And I set the "Definition" property to "IfcStructuralItem" + And I set the "Class" property to "IfcStructuralPointConnection" + And I set the "Representation" property to "Vertex" + When I click "OK" + And I make the collection "IfcStructuralItem" visible + And I select the object "IfcStructuralPointConnection/Foo" + And I toggle edit mode + Then the object "Item/IfcVertexPoint/69" exists + +Scenario: Add element - a structural curve member + Given an empty IFC project + And I trigger "Add Element" + And I set the "Name" property to "Foo" + And I set the "Definition" property to "IfcStructuralItem" + And I set the "Class" property to "IfcStructuralCurveMember" + And I set the "Representation" property to "Edge" + When I click "OK" + And I make the collection "IfcStructuralItem" visible + And I select the object "IfcStructuralCurveMember/Foo" + And I toggle edit mode + Then the object "Item/IfcEdge/72" exists + +Scenario: Add element - a structural surface member + Given an empty IFC project + And I trigger "Add Element" + And I set the "Name" property to "Foo" + And I set the "Definition" property to "IfcStructuralItem" + And I set the "Class" property to "IfcStructuralSurfaceMember" + And I set the "Representation" property to "Face" + When I click "OK" + And I make the collection "IfcStructuralItem" visible + And I select the object "IfcStructuralSurfaceMember/Foo" + And I toggle edit mode + Then the object "Item/IfcFace/74" exists + Scenario: Load structural analysis models Given an empty IFC project When I press "bim.load_structural_analysis_models" diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index cca817418e..cfbd71940a 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -658,6 +658,12 @@ def i_add_a_new_collection_item(collection): assert False, "Collection does not exist" +@given(parsers.parse('I make the collection "{name}" visible')) +@when(parsers.parse('I make the collection "{name}" visible')) +def i_make_the_collection_name_visible(name): + tool.Blender.get_layer_collection(bpy.data.collections.get(name)).hide_viewport = False + + @given(parsers.parse('the material "{name}" colour is set to "{colour}"')) @when(parsers.parse('the material "{name}" colour is set to "{colour}"')) def the_material_name_colour_is_set_to_colour(name, colour): diff --git a/src/ifcopenshell-python/ifcopenshell/util/representation.py b/src/ifcopenshell-python/ifcopenshell/util/representation.py index 10ddf21329..7f71fc869b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/representation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/representation.py @@ -286,6 +286,16 @@ def guess_type(items: Sequence[ifcopenshell.entity_instance]) -> Union[str, None return "SectionedSpine" elif all([True if i.is_a("IfcLightSource") else False for i in items]): return "LightSource" + elif all([True if i.is_a("IfcVertex") else False for i in items]): + return "Vertex" + elif all([True if i.is_a("IfcEdge") else False for i in items]): + return "Edge" + elif all([True if i.is_a("IfcPath") else False for i in items]): + return "Path" + elif all([True if i.is_a("IfcFace") else False for i in items]): + return "Face" + elif all([True if i.is_a("IfcOpenShell") else False for i in items]): + return "Shell" def resolve_representation(representation: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index e352187843..4eab7844c5 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -785,6 +785,41 @@ class ShapeBuilder: RefDirection=ref_direction, ) + def vertex(self, position: VectorType = (0.0, 0.0, 0.0)) -> ifcopenshell.entity_instance: + """Create a topological vertex + + Commonly used in structural point elements. + + :param position: The 3D coordinate of the vertex + :return: IfcVertexPoint + """ + return self.file.create_entity( + "IfcVertexPoint", self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(position)) + ) + + def edge( + self, start: VectorType = (0.0, 0.0, 0.0), end: VectorType = (1.0, 0.0, 0.0) + ) -> ifcopenshell.entity_instance: + """Create a topological edge + + :param start: The start coordinates of the vertex. + :param end: The end coordinates of the vertex. + :return: IfcEdge + """ + return self.file.create_entity("IfcEdge", self.vertex(start), self.vertex(end)) + + def face(self, points: SequenceOfVectors) -> ifcopenshell.entity_instance: + """Create a single topological face + + There are many types of faces, but for now we only support planar + polyloop defined faces with an outer boundary. + + :param points: ordered list of 3d coordinates representing the outer boundary + :return: IfcFace + """ + verts = [self.file.createIfcCartesianPoint(p) for p in ifc_safe_vector_type(points)] + return self.file.createIfcFace([self.file.createIfcFaceOuterBound(self.file.createIfcPolyLoop(verts), True)]) + def mirror( self, curve_or_item: Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], @@ -1025,13 +1060,17 @@ class ShapeBuilder: if not representation_type: representation_type = ifcopenshell.util.representation.guess_type(items) - representation = self.file.createIfcShapeRepresentation( + return self.file.create_entity( + ( + "IfcTopologyRepresentation" + if representation_type in ("Vertex", "Edge", "Path", "Face", "Shell") + else "IfcShapeRepresentation" + ), ContextOfItems=context, RepresentationIdentifier=context.ContextIdentifier, RepresentationType=representation_type, Items=items, ) - return representation def deep_copy(self, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: return ifcopenshell.util.element.copy_deep(self.file, element) diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index fa7fb96fd6..1d1e00900e 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -203,6 +203,31 @@ class TestMirror(test.bootstrap.IFC4): assert np.allclose(rectangle.Points.CoordList, ((0.0, 0.0), (-100.0, 0.0), (-100.0, 100.0), (0.0, 100.0))) +class TestVertex(test.bootstrap.IFC4): + def test_run(self): + builder = ShapeBuilder(self.file) + vertex = builder.vertex((1, 2, 3)) + assert np.allclose(vertex.VertexGeometry.Coordinates, (1, 2, 3)) + + +class TestEdge(test.bootstrap.IFC4): + def test_run(self): + builder = ShapeBuilder(self.file) + edge = builder.edge((1, 0, 0), (1, 2, 3)) + assert np.allclose(edge.EdgeStart.VertexGeometry.Coordinates, (1, 0, 0)) + assert np.allclose(edge.EdgeEnd.VertexGeometry.Coordinates, (1, 2, 3)) + + +class TestFace(test.bootstrap.IFC4): + def test_run(self): + builder = ShapeBuilder(self.file) + face = builder.face(((0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0))) + assert np.allclose(face.Bounds[0].Bound.Polygon[0], (0, 0, 0)) + assert np.allclose(face.Bounds[0].Bound.Polygon[1], (1, 0, 0)) + assert np.allclose(face.Bounds[0].Bound.Polygon[2], (1, 1, 0)) + assert np.allclose(face.Bounds[0].Bound.Polygon[3], (0, 1, 0)) + + class TestCalculateTransitions(test.bootstrap.IFC4): def calculate_and_test(self, params: dict[str, Any], length: Union[float, None]): np_X, np_Y = 0, 1