From 5921bcb63ddc2324a1b8224b5516567a78bc4de4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 27 Feb 2021 21:15:08 +1100 Subject: [PATCH 1/3] You can now automagically convert meshes into rectangular solid extrusions --- .../bim/module/geometry/add_representation.py | 16 ++ .../blenderbim/bim/module/geometry/helper.py | 210 ++++++++++++++++++ .../bim/module/geometry/operator.py | 155 ++++++------- .../blenderbim/bim/module/geometry/ui.py | 5 + 4 files changed, 309 insertions(+), 77 deletions(-) create mode 100644 src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py index 31c9113e61..0d8498e160 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py @@ -2,6 +2,7 @@ import bpy import bmesh import ifcopenshell.util.unit from mathutils import Vector +from blenderbim.bim.module.geometry.helper import Helper class Usecase: @@ -20,6 +21,7 @@ class Usecase: "is_wireframe": False, # If the geometry is a wireframe "is_curve": False, # If the geometry is a Blender curve "is_point_cloud": False, # If the geometry is a point cloud + "is_rectangular_extrusion": False, } self.ifc_vertices = [] for key, value in settings.items(): @@ -124,6 +126,8 @@ class Usecase: return self.create_curve_representation() elif self.settings["is_point_cloud"]: return self.create_point_cloud_representation() + elif self.settings["is_rectangular_extrusion"]: + return self.create_rectangular_extrusion_representation() return self.create_mesh_representation() def create_curve3d_representation(self): @@ -215,6 +219,18 @@ class Usecase: results.append(self.file.createIfcPolyline(points)) return results + def create_rectangular_extrusion_representation(self): + helper = Helper(self.file) + indices = helper.auto_detect_rectangle_profile_extruded_area_solid(self.settings["geometry"]) + profile_def = helper.create_rectangle_profile_def(self.settings["blender_object"], indices["profile"]) + item = helper.create_extruded_area_solid(self.settings["blender_object"], indices["extrusion"], profile_def) + return self.file.createIfcShapeRepresentation( + self.settings["context"], + self.settings["context"].ContextIdentifier, + "SweptSolid", + [item], + ) + def create_mesh_representation(self): if self.file.schema == "IFC2X3" or self.settings["should_force_faceted_brep"]: return self.create_faceted_brep() diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py new file mode 100644 index 0000000000..b2b28619e8 --- /dev/null +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py @@ -0,0 +1,210 @@ +import bpy +import bmesh +import ifcopenshell +import ifcopenshell.util.unit +from math import pi +from mathutils import Vector, Matrix + + +class Helper: + def __init__(self, file): + self.file = file + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) + + # We can detect a rectangular extrusion by picking any face, then find an + # edge that shares a single vertex only with that face to find the extrusion + # edge. A face with the normal facing down is prioritised. A limited + # dissolve ensure that faces are quads and not tris. + def auto_detect_rectangle_profile_extruded_area_solid(self, mesh): + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180, verts=bm.verts, edges=bm.edges) + + bm.faces.ensure_lookup_table() + face = None + for face in bm.faces: + if face.normal.z < -0.1: + break + profile = [l.vert.index for l in face.loops] + face_verts_set = set(face.verts) + + bm.edges.ensure_lookup_table() + extrusion = None + for edge in bm.edges: + unshared_verts = set(edge.verts) - face_verts_set + if len(unshared_verts) == 1: + if unshared_verts.pop() == edge.verts[1]: + extrusion = [edge.verts[0].index, edge.verts[1].index] + else: + extrusion = [edge.verts[1].index, edge.verts[0].index] + break + + bm.free() + + return {"profile": profile, "extrusion": extrusion} + + # After a limited dissolve, we detect the circle profile as it should be the + # only ngon. The extrusion direction is any edge that only shares a single + # vertex with the profile. We prioritise the profile that has a downwards + # normal. + def auto_detect_circle_profile_extruded_area_solid(self, obj): + # TODO + bm = bmesh.new() + + # After a limited dissolve, the arbitrary profile is any ngon or tri. + # Failing that, it is equivalent to a rectangular profile. The extrusion + # direction is any edge that only shares a single vertex with the profile. + # We prioritise the profile that has a downwards normal. + def auto_detect_arbitrary_closed_profile_extruded_area_solid(self, obj): + # TODO + bm = bmesh.new() + + def create_extruded_area_solid(self, obj, extrusion_vertex_indices, profile_def): + extrusion_edge = self.get_edges_in_v_indices(obj, extrusion_vertex_indices)[0] + position = self.create_ifc_axis_2_placement_3d( + profile_def["curve_ucs"]["center"], profile_def["curve_ucs"]["z_axis"], profile_def["curve_ucs"]["x_axis"] + ) + direction = self.get_extrusion_direction( + obj, profile_def["outer_curve_loop"], extrusion_edge, profile_def["curve_ucs"] + ) + unit_direction = direction.normalized() + return self.file.createIfcExtrudedAreaSolid( + profile_def["curve"], + position, + self.file.createIfcDirection((unit_direction.x, unit_direction.y, unit_direction.z)), + self.convert_si_to_unit(direction.length), + ) + + def create_arbitrary_closed_profile_def(self, obj, profile_vertex_indices): + outer_curve_loop = self.get_loop_from_v_indices(obj, profile_vertex_indices) + curve_ucs = self.get_curve_profile_coordinate_system(obj, outer_curve_loop) + outer_curve = self.create_polyline_from_loop(obj, outer_curve_loop, curve_ucs) + curve = self.file.createIfcArbitraryClosedProfileDef("AREA", None, outer_curve) + return {"outer_curve_loop": outer_curve_loop, "curve_ucs": curve_ucs, "curve": curve} + + def create_rectangle_profile_def(self, obj, profile_vertex_indices): + outer_curve_loop = self.get_loop_from_v_indices(obj, profile_vertex_indices) + curve_ucs = self.get_curve_profile_coordinate_system(obj, outer_curve_loop) + xdim = self.convert_si_to_unit( + (obj.data.vertices[outer_curve_loop[0]].co - obj.data.vertices[outer_curve_loop[1]].co).length + ) + ydim = self.convert_si_to_unit( + (obj.data.vertices[outer_curve_loop[1]].co - obj.data.vertices[outer_curve_loop[2]].co).length + ) + curve = self.file.createIfcRectangleProfileDef("AREA", None, None, xdim, ydim) + return {"outer_curve_loop": outer_curve_loop, "curve_ucs": curve_ucs, "curve": curve} + + def create_circle_profile_def(self, obj, profile_vertex_indices): + indices = profile_vertex_indices + outer_curve_loop = self.get_loop_from_v_indices(obj, indices) + curve_ucs = self.get_curve_profile_coordinate_system(obj, outer_curve_loop) + radius = self.convert_si_to_unit( + abs((obj.data.vertices[indices[0]].co - obj.data.vertices[indices[int(len(indices) / 2)]].co).length) / 2 + ) + center = Vector((0, 0)) + position = self.create_ifc_axis_2_placement_2d(center, Vector((1, 0))) + curve = self.file.createIfcCircleProfileDef("AREA", None, position, radius) + return {"outer_curve_loop": outer_curve_loop, "curve_ucs": curve_ucs, "curve": curve} + + def get_loop_from_v_indices(self, obj, indices): + edges = self.get_edges_in_v_indices(obj, indices) + loop = self.get_loop_from_edges(edges) + loop.pop(-1) + return loop + + def get_loop_from_edges(self, edges): + while edges: + currentEdge = edges.pop() + startVert = currentEdge.vertices[0] + endVert = currentEdge.vertices[1] + polyLine = [startVert, endVert] + ok = 1 + while ok: + ok = 0 + i = len(edges) + while i: + i -= 1 + ed = edges[i] + if ed.vertices[0] == endVert: + polyLine.append(ed.vertices[1]) + endVert = polyLine[-1] + ok = 1 + del edges[i] + elif ed.vertices[1] == endVert: + polyLine.append(ed.vertices[0]) + endVert = polyLine[-1] + ok = 1 + del edges[i] + elif ed.vertices[0] == startVert: + polyLine.insert(0, ed.vertices[1]) + startVert = polyLine[0] + ok = 1 + del edges[i] + elif ed.vertices[1] == startVert: + polyLine.insert(0, ed.vertices[0]) + startVert = polyLine[0] + ok = 1 + del edges[i] + return polyLine + + def get_edges_in_v_indices(self, obj, indices): + return [e for e in obj.data.edges if (e.vertices[0] in indices and e.vertices[1] in indices)] + + def get_curve_profile_coordinate_system(self, obj, loop): + profile_face = bpy.data.meshes.new("profile_face") + profile_verts = [ + (obj.data.vertices[p].co.x, obj.data.vertices[p].co.y, obj.data.vertices[p].co.z) for p in loop + ] + profile_faces = [tuple(range(0, len(profile_verts)))] + profile_face.from_pydata(profile_verts, [], profile_faces) + center = profile_face.polygons[0].center + if (obj.data.vertices[loop[1]].co - obj.data.vertices[loop[0]].co).length < 0.01: + x_axis = (obj.data.vertices[loop[0]].co - center).normalized() + else: + x_axis = (obj.data.vertices[loop[1]].co - obj.data.vertices[loop[0]].co).normalized() + z_axis = profile_face.polygons[0].normal.normalized() + y_axis = z_axis.cross(x_axis).normalized() + matrix = Matrix((x_axis, y_axis, z_axis)) + matrix.normalize() + return { + "center": center, + "x_axis": x_axis, + "y_axis": y_axis, + "z_axis": z_axis, + "matrix": matrix.to_4x4() @ Matrix.Translation(-center), + } + + def convert_si_to_unit(self, co): + return co / self.unit_scale + + def create_polyline_from_loop(self, obj, loop, curve_ucs): + points = [] + for point in loop: + transformed_point = curve_ucs["matrix"] @ obj.data.vertices[point].co + points.append(self.create_cartesian_point(transformed_point.x, transformed_point.y)) + points.append(points[0]) + return self.file.createIfcPolyline(points) + + def create_cartesian_point(self, x, y, z=None): + x = self.convert_si_to_unit(x) + y = self.convert_si_to_unit(y) + if z is None: + return self.file.createIfcCartesianPoint((x, y)) + z = self.convert_si_to_unit(z) + return self.file.createIfcCartesianPoint((x, y, z)) + + def get_extrusion_direction(self, obj, outer_curve_loop, extrusion_edge, curve_ucs): + start, end = self.get_start_and_end_of_extrusion(outer_curve_loop, extrusion_edge) + return curve_ucs["matrix"] @ (curve_ucs["center"] + (obj.data.vertices[end].co - obj.data.vertices[start].co)) + + def get_start_and_end_of_extrusion(self, profile_points, extrusion_edge): + if extrusion_edge.vertices[0] in profile_points: + return (extrusion_edge.vertices[0], extrusion_edge.vertices[1]) + return (extrusion_edge.vertices[1], extrusion_edge.vertices[0]) + + def create_ifc_axis_2_placement_3d(self, point, up, forward): + return self.file.createIfcAxis2Placement3D( + self.create_cartesian_point(point.x, point.y, point.z), + self.file.createIfcDirection((up.x, up.y, up.z)), + self.file.createIfcDirection((forward.x, forward.y, forward.z)), + ) diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py index cb76cceee8..4cfeffbb4b 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py @@ -19,11 +19,11 @@ from blenderbim.bim.module.void.data import Data as VoidData from mathutils import Vector -def get_box_context_id(): +def get_context_id(context_type, context_identifier, target_view): for context in ContextData.contexts.values(): - if context["ContextType"] == "Model": + if context["ContextType"] == context_type: for i, subcontext in context["HasSubContexts"].items(): - if subcontext["ContextIdentifier"] == "Box" and subcontext["TargetView"] == "MODEL_VIEW": + if subcontext["ContextIdentifier"] == context_identifier and subcontext["TargetView"] == target_view: return i @@ -120,7 +120,7 @@ class AddRepresentation(bpy.types.Operator): print("Failed to write shape representation") return {"FINISHED"} - box_context_id = get_box_context_id() + box_context_id = get_context_id("Model", "Box", "MODEL_VIEW") if ( box_context_id and context_of_items.ContextType == "Model" @@ -306,6 +306,7 @@ class UpdateMeshRepresentation(bpy.types.Operator): bl_idname = "bim.update_mesh_representation" bl_label = "Update Mesh Representation" obj: bpy.props.StringProperty() + ifc_representation_type: bpy.props.StringProperty() def execute(self, context): if not ContextData.is_loaded: @@ -315,81 +316,81 @@ class UpdateMeshRepresentation(bpy.types.Operator): self.file = IfcStore.get_file() for obj in objs: - bpy.ops.bim.edit_object_placement(obj=obj.name) - - product = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) - - if product.is_a("IfcGridAxis"): - create_axis_curve.Usecase(self.file, {"AxisCurve": obj, "grid_axis": product}).execute() - continue - - old_representation = self.file.by_id(obj.data.BIMMeshProperties.ifc_definition_id) - context_of_items = old_representation.ContextOfItems - - gprop = context.scene.BIMGeoreferenceProperties - coordinate_offset = None - if gprop.has_blender_offset and gprop.blender_offset_type == "CARTESIAN_POINT": - coordinate_offset = Vector( - ( - float(gprop.blender_eastings), - float(gprop.blender_northings), - float(gprop.blender_orthogonal_height), - ) - ) - - representation_data = { - "context": context_of_items, - "blender_object": obj, - "geometry": obj.data, - "coordinate_offset": coordinate_offset, - "total_items": max(1, len(obj.material_slots)), - "should_force_faceted_brep": context.scene.BIMGeometryProperties.should_force_faceted_brep, - "should_force_triangulation": context.scene.BIMGeometryProperties.should_force_triangulation, - } - - new_representation = add_representation.Usecase(self.file, representation_data).execute() - - if not new_representation: - print("Failed to write shape representation") - return {"FINISHED"} - - box_context_id = get_box_context_id() - old_box = ifcopenshell.util.element.get_representation(product, "Model", "Box", "MODEL_VIEW") - if ( - box_context_id - and old_box - and context_of_items.ContextType == "Model" - and context_of_items.ContextIdentifier - and context_of_items.ContextIdentifier == "Body" - ): - representation_data["context"] = self.file.by_id(box_context_id) - new_box = add_representation.Usecase(self.file, representation_data).execute() - for inverse in self.file.get_inverse(old_box): - ifcopenshell.util.element.replace_attribute(inverse, old_box, new_box) - - assign_styles.Usecase( - self.file, - { - "shape_representation": new_representation, - "styles": [ - self.file.by_id(s.material.BIMMaterialProperties.ifc_style_id) - for s in obj.material_slots - if s.material - ], - "should_use_presentation_style_assignment": context.scene.BIMGeometryProperties.should_use_presentation_style_assignment, - }, - ).execute() - - # TODO: move this into a replace_representation usecase or something - for inverse in self.file.get_inverse(old_representation): - ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation) - - obj.data.BIMMeshProperties.ifc_definition_id = int(new_representation.id()) - obj.data.name = f"{old_representation.ContextOfItems.id()}/{new_representation.id()}" - bpy.ops.bim.remove_representation(representation_id=old_representation.id()) - Data.load(obj.BIMObjectProperties.ifc_definition_id) + self.update_obj_mesh_representation(context, obj) return {"FINISHED"} + def update_obj_mesh_representation(self, context, obj): + product = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) + + if product.is_a("IfcGridAxis"): + create_axis_curve.Usecase(self.file, {"AxisCurve": obj, "grid_axis": product}).execute() + return + + bpy.ops.bim.edit_object_placement(obj=obj.name) + + old_representation = self.file.by_id(obj.data.BIMMeshProperties.ifc_definition_id) + context_of_items = old_representation.ContextOfItems + + gprop = context.scene.BIMGeoreferenceProperties + coordinate_offset = None + if gprop.has_blender_offset and gprop.blender_offset_type == "CARTESIAN_POINT": + coordinate_offset = Vector( + ( + float(gprop.blender_eastings), + float(gprop.blender_northings), + float(gprop.blender_orthogonal_height), + ) + ) + + representation_data = { + "context": context_of_items, + "blender_object": obj, + "geometry": obj.data, + "coordinate_offset": coordinate_offset, + "total_items": max(1, len(obj.material_slots)), + "should_force_faceted_brep": context.scene.BIMGeometryProperties.should_force_faceted_brep, + "should_force_triangulation": context.scene.BIMGeometryProperties.should_force_triangulation, + "is_rectangular_extrusion": self.ifc_representation_type == "IfcExtrudedAreaSolid/IfcRectangleProfileDef" + } + + new_representation = add_representation.Usecase(self.file, representation_data).execute() + + box_context_id = get_context_id("Model", "Box", "MODEL_VIEW") + old_box = ifcopenshell.util.element.get_representation(product, "Model", "Box", "MODEL_VIEW") + if ( + box_context_id + and old_box + and context_of_items.ContextType == "Model" + and context_of_items.ContextIdentifier + and context_of_items.ContextIdentifier == "Body" + ): + representation_data["context"] = self.file.by_id(box_context_id) + new_box = add_representation.Usecase(self.file, representation_data).execute() + for inverse in self.file.get_inverse(old_box): + ifcopenshell.util.element.replace_attribute(inverse, old_box, new_box) + + assign_styles.Usecase( + self.file, + { + "shape_representation": new_representation, + "styles": [ + self.file.by_id(s.material.BIMMaterialProperties.ifc_style_id) + for s in obj.material_slots + if s.material + ], + "should_use_presentation_style_assignment": context.scene.BIMGeometryProperties.should_use_presentation_style_assignment, + }, + ).execute() + + # TODO: move this into a replace_representation usecase or something + for inverse in self.file.get_inverse(old_representation): + ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation) + + obj.data.BIMMeshProperties.ifc_definition_id = int(new_representation.id()) + obj.data.name = f"{old_representation.ContextOfItems.id()}/{new_representation.id()}" + bpy.ops.bim.remove_representation(representation_id=old_representation.id()) + Data.load(obj.BIMObjectProperties.ifc_definition_id) + class UpdateParametricRepresentation(bpy.types.Operator): bl_idname = "bim.update_parametric_representation" diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py index da3804fcc6..17467eec09 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py @@ -80,6 +80,11 @@ class BIM_PT_mesh(Panel): row = layout.row() row.operator("bim.update_mesh_representation") + + row = layout.row() + op = row.operator("bim.update_mesh_representation", text="Convert Mesh to Rectangular Extrusion") + op.ifc_representation_type = "IfcExtrudedAreaSolid/IfcRectangleProfileDef" + row = layout.row() row.operator("bim.get_representation_ifc_parameters") for index, ifc_parameter in enumerate(props.ifc_parameters): From 36cfb0d3f58a74d57c4fc58c0b8305cead33ff59 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 28 Feb 2021 12:04:30 +1100 Subject: [PATCH 2/3] Meshes can now be automagically converted into circular and arbitrary profile extrusions (swept solids) --- .../bim/module/geometry/add_representation.py | 44 ++++- .../blenderbim/bim/module/geometry/helper.py | 151 +++++++++++------- .../bim/module/geometry/operator.py | 21 ++- .../blenderbim/bim/module/geometry/ui.py | 12 +- 4 files changed, 164 insertions(+), 64 deletions(-) diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py index 0d8498e160..0310719317 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/add_representation.py @@ -21,7 +21,11 @@ class Usecase: "is_wireframe": False, # If the geometry is a wireframe "is_curve": False, # If the geometry is a Blender curve "is_point_cloud": False, # If the geometry is a point cloud - "is_rectangular_extrusion": False, + # Possible IFC representation classes: + # IfcExtrudedAreaSolid/IfcRectangleProfileDef + # IfcExtrudedAreaSolid/IfcCircleProfileDef + # IfcExtrudedAreaSolid/IfcArbitraryClosedProfileDef + "ifc_representation_class": None, # Whether to cast a mesh into a particular class } self.ifc_vertices = [] for key, value in settings.items(): @@ -126,8 +130,12 @@ class Usecase: return self.create_curve_representation() elif self.settings["is_point_cloud"]: return self.create_point_cloud_representation() - elif self.settings["is_rectangular_extrusion"]: - return self.create_rectangular_extrusion_representation() + elif self.settings["ifc_representation_class"] == "IfcExtrudedAreaSolid/IfcRectangleProfileDef": + return self.create_rectangle_extrusion_representation() + elif self.settings["ifc_representation_class"] == "IfcExtrudedAreaSolid/IfcCircleProfileDef": + return self.create_circle_extrusion_representation() + elif self.settings["ifc_representation_class"] == "IfcExtrudedAreaSolid/IfcArbitraryClosedProfileDef": + return self.create_arbitrary_extrusion_representation() return self.create_mesh_representation() def create_curve3d_representation(self): @@ -219,11 +227,35 @@ class Usecase: results.append(self.file.createIfcPolyline(points)) return results - def create_rectangular_extrusion_representation(self): + def create_rectangle_extrusion_representation(self): helper = Helper(self.file) indices = helper.auto_detect_rectangle_profile_extruded_area_solid(self.settings["geometry"]) - profile_def = helper.create_rectangle_profile_def(self.settings["blender_object"], indices["profile"]) - item = helper.create_extruded_area_solid(self.settings["blender_object"], indices["extrusion"], profile_def) + profile_def = helper.create_rectangle_profile_def(self.settings["geometry"], indices["profile"]) + item = helper.create_extruded_area_solid(self.settings["geometry"], indices["extrusion"], profile_def) + return self.file.createIfcShapeRepresentation( + self.settings["context"], + self.settings["context"].ContextIdentifier, + "SweptSolid", + [item], + ) + + def create_circle_extrusion_representation(self): + helper = Helper(self.file) + indices = helper.auto_detect_circle_profile_extruded_area_solid(self.settings["geometry"]) + profile_def = helper.create_circle_profile_def(self.settings["geometry"], indices["profile"]) + item = helper.create_extruded_area_solid(self.settings["geometry"], indices["extrusion"], profile_def) + return self.file.createIfcShapeRepresentation( + self.settings["context"], + self.settings["context"].ContextIdentifier, + "SweptSolid", + [item], + ) + + def create_arbitrary_extrusion_representation(self): + helper = Helper(self.file) + indices = helper.auto_detect_arbitrary_closed_profile_extruded_area_solid(self.settings["geometry"]) + profile_def = helper.create_arbitrary_closed_profile_def(self.settings["geometry"], indices["profile"]) + item = helper.create_extruded_area_solid(self.settings["geometry"], indices["extrusion"], profile_def) return self.file.createIfcShapeRepresentation( self.settings["context"], self.settings["context"].ContextIdentifier, diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py index b2b28619e8..7cf1bb78ac 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/helper.py @@ -18,7 +18,7 @@ class Helper: def auto_detect_rectangle_profile_extruded_area_solid(self, mesh): bm = bmesh.new() bm.from_mesh(mesh) - bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180, verts=bm.verts, edges=bm.edges) + bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) bm.faces.ensure_lookup_table() face = None @@ -26,47 +26,89 @@ class Helper: if face.normal.z < -0.1: break profile = [l.vert.index for l in face.loops] - face_verts_set = set(face.verts) - - bm.edges.ensure_lookup_table() - extrusion = None - for edge in bm.edges: - unshared_verts = set(edge.verts) - face_verts_set - if len(unshared_verts) == 1: - if unshared_verts.pop() == edge.verts[1]: - extrusion = [edge.verts[0].index, edge.verts[1].index] - else: - extrusion = [edge.verts[1].index, edge.verts[0].index] - break + extrusion = self.detect_extrusion_edge(bm, face) + bm.to_mesh(mesh) + mesh.update() bm.free() return {"profile": profile, "extrusion": extrusion} # After a limited dissolve, we detect the circle profile as it should be the - # only ngon. The extrusion direction is any edge that only shares a single - # vertex with the profile. We prioritise the profile that has a downwards - # normal. - def auto_detect_circle_profile_extruded_area_solid(self, obj): - # TODO + # only ngon (this assumes the circle has at least a facetation of > 4 edges. + # The extrusion direction is any edge that only shares a single vertex with + # the profile. We prioritise the profile that has a downwards normal. + def auto_detect_circle_profile_extruded_area_solid(self, mesh): bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) + + bm.faces.ensure_lookup_table() + potential_faces = [] + for face in bm.faces: + if len(face.verts) > 4: + potential_faces.append(face) + for face in potential_faces: + if face.normal.z < -0.1: + break + + profile = [l.vert.index for l in face.loops] + extrusion = self.detect_extrusion_edge(bm, face) + + bm.to_mesh(mesh) + mesh.update() + bm.free() + + return {"profile": profile, "extrusion": extrusion} # After a limited dissolve, the arbitrary profile is any ngon or tri. # Failing that, it is equivalent to a rectangular profile. The extrusion # direction is any edge that only shares a single vertex with the profile. # We prioritise the profile that has a downwards normal. - def auto_detect_arbitrary_closed_profile_extruded_area_solid(self, obj): - # TODO + def auto_detect_arbitrary_closed_profile_extruded_area_solid(self, mesh): bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) - def create_extruded_area_solid(self, obj, extrusion_vertex_indices, profile_def): - extrusion_edge = self.get_edges_in_v_indices(obj, extrusion_vertex_indices)[0] + bm.faces.ensure_lookup_table() + potential_faces = [] + for face in bm.faces: + total_verts = len(face.verts) + if total_verts > 4 or total_verts == 3: + potential_faces.append(face) + + if not potential_faces: + potential_faces = bm.faces + + for face in potential_faces: + if face.normal.z < -0.1: + break + + profile = [l.vert.index for l in face.loops] + extrusion = self.detect_extrusion_edge(bm, face) + + bm.to_mesh(mesh) + mesh.update() + bm.free() + + return {"profile": profile, "extrusion": extrusion} + + def detect_extrusion_edge(self, bm, profile_face): + bm.edges.ensure_lookup_table() + extrusion = None + face_verts_set = set(profile_face.verts) + for edge in bm.edges: + unshared_verts = set(edge.verts) - face_verts_set + if len(unshared_verts) == 1: + if unshared_verts.pop() == edge.verts[1]: + return [edge.verts[0].index, edge.verts[1].index] + return [edge.verts[1].index, edge.verts[0].index] + + def create_extruded_area_solid(self, mesh, extrusion_indices, profile_def): position = self.create_ifc_axis_2_placement_3d( profile_def["curve_ucs"]["center"], profile_def["curve_ucs"]["z_axis"], profile_def["curve_ucs"]["x_axis"] ) - direction = self.get_extrusion_direction( - obj, profile_def["outer_curve_loop"], extrusion_edge, profile_def["curve_ucs"] - ) + direction = self.get_extrusion_direction(mesh, extrusion_indices, profile_def["curve_ucs"]) unit_direction = direction.normalized() return self.file.createIfcExtrudedAreaSolid( profile_def["curve"], @@ -75,37 +117,34 @@ class Helper: self.convert_si_to_unit(direction.length), ) - def create_arbitrary_closed_profile_def(self, obj, profile_vertex_indices): - outer_curve_loop = self.get_loop_from_v_indices(obj, profile_vertex_indices) - curve_ucs = self.get_curve_profile_coordinate_system(obj, outer_curve_loop) - outer_curve = self.create_polyline_from_loop(obj, outer_curve_loop, curve_ucs) + def create_arbitrary_closed_profile_def(self, mesh, profile_indices): + curve_ucs = self.get_curve_profile_coordinate_system(mesh, profile_indices) + outer_curve = self.create_polyline_from_loop(mesh, profile_indices, curve_ucs) curve = self.file.createIfcArbitraryClosedProfileDef("AREA", None, outer_curve) - return {"outer_curve_loop": outer_curve_loop, "curve_ucs": curve_ucs, "curve": curve} + return {"curve_ucs": curve_ucs, "curve": curve} - def create_rectangle_profile_def(self, obj, profile_vertex_indices): - outer_curve_loop = self.get_loop_from_v_indices(obj, profile_vertex_indices) - curve_ucs = self.get_curve_profile_coordinate_system(obj, outer_curve_loop) + def create_rectangle_profile_def(self, mesh, profile_indices): + curve_ucs = self.get_curve_profile_coordinate_system(mesh, profile_indices) xdim = self.convert_si_to_unit( - (obj.data.vertices[outer_curve_loop[0]].co - obj.data.vertices[outer_curve_loop[1]].co).length + (mesh.vertices[profile_indices[0]].co - mesh.vertices[profile_indices[1]].co).length ) ydim = self.convert_si_to_unit( - (obj.data.vertices[outer_curve_loop[1]].co - obj.data.vertices[outer_curve_loop[2]].co).length + (mesh.vertices[profile_indices[1]].co - mesh.vertices[profile_indices[2]].co).length ) curve = self.file.createIfcRectangleProfileDef("AREA", None, None, xdim, ydim) - return {"outer_curve_loop": outer_curve_loop, "curve_ucs": curve_ucs, "curve": curve} + return {"curve_ucs": curve_ucs, "curve": curve} - def create_circle_profile_def(self, obj, profile_vertex_indices): - indices = profile_vertex_indices - outer_curve_loop = self.get_loop_from_v_indices(obj, indices) - curve_ucs = self.get_curve_profile_coordinate_system(obj, outer_curve_loop) + def create_circle_profile_def(self, mesh, profile_indices): + curve_ucs = self.get_curve_profile_coordinate_system(mesh, profile_indices) radius = self.convert_si_to_unit( - abs((obj.data.vertices[indices[0]].co - obj.data.vertices[indices[int(len(indices) / 2)]].co).length) / 2 + abs((mesh.vertices[profile_indices[0]].co - mesh.vertices[profile_indices[int(len(profile_indices) / 2)]].co).length) / 2 ) center = Vector((0, 0)) position = self.create_ifc_axis_2_placement_2d(center, Vector((1, 0))) curve = self.file.createIfcCircleProfileDef("AREA", None, position, radius) - return {"outer_curve_loop": outer_curve_loop, "curve_ucs": curve_ucs, "curve": curve} + return {"curve_ucs": curve_ucs, "curve": curve} + # Not used anywhere, but probably useful in the future def get_loop_from_v_indices(self, obj, indices): edges = self.get_edges_in_v_indices(obj, indices) loop = self.get_loop_from_edges(edges) @@ -150,18 +189,16 @@ class Helper: def get_edges_in_v_indices(self, obj, indices): return [e for e in obj.data.edges if (e.vertices[0] in indices and e.vertices[1] in indices)] - def get_curve_profile_coordinate_system(self, obj, loop): + def get_curve_profile_coordinate_system(self, mesh, loop): profile_face = bpy.data.meshes.new("profile_face") - profile_verts = [ - (obj.data.vertices[p].co.x, obj.data.vertices[p].co.y, obj.data.vertices[p].co.z) for p in loop - ] + profile_verts = [(mesh.vertices[p].co.x, mesh.vertices[p].co.y, mesh.vertices[p].co.z) for p in loop] profile_faces = [tuple(range(0, len(profile_verts)))] profile_face.from_pydata(profile_verts, [], profile_faces) center = profile_face.polygons[0].center - if (obj.data.vertices[loop[1]].co - obj.data.vertices[loop[0]].co).length < 0.01: - x_axis = (obj.data.vertices[loop[0]].co - center).normalized() + if (mesh.vertices[loop[1]].co - mesh.vertices[loop[0]].co).length < 0.01: + x_axis = (mesh.vertices[loop[0]].co - center).normalized() else: - x_axis = (obj.data.vertices[loop[1]].co - obj.data.vertices[loop[0]].co).normalized() + x_axis = (mesh.vertices[loop[1]].co - mesh.vertices[loop[0]].co).normalized() z_axis = profile_face.polygons[0].normal.normalized() y_axis = z_axis.cross(x_axis).normalized() matrix = Matrix((x_axis, y_axis, z_axis)) @@ -177,10 +214,10 @@ class Helper: def convert_si_to_unit(self, co): return co / self.unit_scale - def create_polyline_from_loop(self, obj, loop, curve_ucs): + def create_polyline_from_loop(self, mesh, loop, curve_ucs): points = [] for point in loop: - transformed_point = curve_ucs["matrix"] @ obj.data.vertices[point].co + transformed_point = curve_ucs["matrix"] @ mesh.vertices[point].co points.append(self.create_cartesian_point(transformed_point.x, transformed_point.y)) points.append(points[0]) return self.file.createIfcPolyline(points) @@ -193,15 +230,21 @@ class Helper: z = self.convert_si_to_unit(z) return self.file.createIfcCartesianPoint((x, y, z)) - def get_extrusion_direction(self, obj, outer_curve_loop, extrusion_edge, curve_ucs): - start, end = self.get_start_and_end_of_extrusion(outer_curve_loop, extrusion_edge) - return curve_ucs["matrix"] @ (curve_ucs["center"] + (obj.data.vertices[end].co - obj.data.vertices[start].co)) + def get_extrusion_direction(self, mesh, extrusion_indices, curve_ucs): + return curve_ucs["matrix"] @ ( + curve_ucs["center"] + (mesh.vertices[extrusion_indices[1]].co - mesh.vertices[extrusion_indices[0]].co) + ) def get_start_and_end_of_extrusion(self, profile_points, extrusion_edge): if extrusion_edge.vertices[0] in profile_points: return (extrusion_edge.vertices[0], extrusion_edge.vertices[1]) return (extrusion_edge.vertices[1], extrusion_edge.vertices[0]) + def create_ifc_axis_2_placement_2d(self, point, forward): + return self.file.createIfcAxis2Placement2D( + self.create_cartesian_point(point.x, point.y), self.file.createIfcDirection((forward.x, forward.y)) + ) + def create_ifc_axis_2_placement_3d(self, point, up, forward): return self.file.createIfcAxis2Placement3D( self.create_cartesian_point(point.x, point.y, point.z), diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py index 4cfeffbb4b..60d6f0a86a 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/operator.py @@ -306,7 +306,7 @@ class UpdateMeshRepresentation(bpy.types.Operator): bl_idname = "bim.update_mesh_representation" bl_label = "Update Mesh Representation" obj: bpy.props.StringProperty() - ifc_representation_type: bpy.props.StringProperty() + ifc_representation_class: bpy.props.StringProperty() def execute(self, context): if not ContextData.is_loaded: @@ -350,11 +350,28 @@ class UpdateMeshRepresentation(bpy.types.Operator): "total_items": max(1, len(obj.material_slots)), "should_force_faceted_brep": context.scene.BIMGeometryProperties.should_force_faceted_brep, "should_force_triangulation": context.scene.BIMGeometryProperties.should_force_triangulation, - "is_rectangular_extrusion": self.ifc_representation_type == "IfcExtrudedAreaSolid/IfcRectangleProfileDef" + "ifc_representation_class": self.ifc_representation_class } new_representation = add_representation.Usecase(self.file, representation_data).execute() + #if product.is_a("IfcWall"): + # # Generate axis representation + # axis_context_id = get_context_id("Model", "Axis", "MODEL_VIEW") + # old_axis = ifcopenshell.util.element.get_representation(product, "Model", "Axis", "MODEL_VIEW") + # if ( + # axis_context_id + # and old_axis + # and context_of_items.ContextType == "Model" + # and context_of_items.ContextIdentifier + # and context_of_items.ContextIdentifier == "Body" + # ): + # has_axis_generator = False + # if has_axis_generator: + # # TODO, just pseudocode for now + # representation_data["geometry"] = axis_generator_function_call + # pass + box_context_id = get_context_id("Model", "Box", "MODEL_VIEW") old_box = ifcopenshell.util.element.get_representation(product, "Model", "Box", "MODEL_VIEW") if ( diff --git a/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py b/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py index 17467eec09..7f3762c1d4 100644 --- a/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py +++ b/src/ifcblenderexport/blenderbim/bim/module/geometry/ui.py @@ -82,8 +82,16 @@ class BIM_PT_mesh(Panel): row.operator("bim.update_mesh_representation") row = layout.row() - op = row.operator("bim.update_mesh_representation", text="Convert Mesh to Rectangular Extrusion") - op.ifc_representation_type = "IfcExtrudedAreaSolid/IfcRectangleProfileDef" + op = row.operator("bim.update_mesh_representation", text="Update Mesh As Rectangle Extrusion") + op.ifc_representation_class = "IfcExtrudedAreaSolid/IfcRectangleProfileDef" + + row = layout.row() + op = row.operator("bim.update_mesh_representation", text="Update Mesh As Circle Extrusion") + op.ifc_representation_class = "IfcExtrudedAreaSolid/IfcCircleProfileDef" + + row = layout.row() + op = row.operator("bim.update_mesh_representation", text="Update Mesh As Arbitrary Extrusion") + op.ifc_representation_class = "IfcExtrudedAreaSolid/IfcArbitraryClosedProfileDef" row = layout.row() row.operator("bim.get_representation_ifc_parameters") From 4e889b75d788def4d7209dc75d163e9bb02cb36e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 28 Feb 2021 14:35:21 +1100 Subject: [PATCH 3/3] BIMTester can now accept both IDS .xml as well as Gherkin .feature files and generate HTML reports --- src/ifcbimtester/bimtester/reports.py | 6 +- .../bimtester/resources/reports/template.html | 4 +- src/ifcbimtester/bimtester/run.py | 63 +++++++++++++++++++ src/ifcbimtester/cli.py | 2 +- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/ifcbimtester/bimtester/reports.py b/src/ifcbimtester/bimtester/reports.py index 2e4e2098c9..5a6683df0d 100644 --- a/src/ifcbimtester/bimtester/reports.py +++ b/src/ifcbimtester/bimtester/reports.py @@ -21,12 +21,12 @@ class ReportGenerator: self.generate_feature_report(feature, output_file) def generate_feature_report(self, feature, output_file): - file_name = os.path.basename(feature["location"]).split(":")[0] + # file_name = os.path.basename(feature["location"]).split(":")[0] data = { - "file_name": file_name, + # "file_name": file_name, "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "name": feature["name"], - "description": feature["description"], + "description": feature.get("description", ""), "is_success": feature["status"] == "passed", "scenarios": [], } diff --git a/src/ifcbimtester/bimtester/resources/reports/template.html b/src/ifcbimtester/bimtester/resources/reports/template.html index 98761cc5e1..94a0a31f34 100644 --- a/src/ifcbimtester/bimtester/resources/reports/template.html +++ b/src/ifcbimtester/bimtester/resources/reports/template.html @@ -35,11 +35,11 @@ {{#is_success}}{{_success}}{{/is_success}}{{^is_success}}{{_failure}}{{/is_success}} {{_tests_passed}}: {{total_passes}} / {{total_steps}} ({{pass_rate}}%)
+ {{#description}}

- {{#description}} {{.}}
- {{/description}}

+ {{/description}}
{{#scenarios}} diff --git a/src/ifcbimtester/bimtester/run.py b/src/ifcbimtester/bimtester/run.py index a95dd6af42..a80867ffeb 100644 --- a/src/ifcbimtester/bimtester/run.py +++ b/src/ifcbimtester/bimtester/run.py @@ -1,6 +1,8 @@ import os import sys +import json import shutil +import logging import tempfile import ifcopenshell @@ -14,6 +16,46 @@ from distutils.dir_util import copy_tree from behave.__main__ import main as behave_main +# TODO: refactor when this isn't super experimental +from logging import StreamHandler + +class IDSHandler(StreamHandler): + def __init__(self): + StreamHandler.__init__(self) + self.results = { + "name": "Specification name", + "status": "passed", + "location": "filename.xml", + "elements": [ + { + "keyword": "Scenario", + "name": "Checking IDS specifications", + "status": "passed", + "steps": [] + } + ] + } + + def emit(self, record): + msg = self.format(record) + # Obviously, not a final product + is_fail = "is compliant" not in msg + if is_fail: + self.results["status"] = "failed" + self.results["elements"][0]["status"] = "failed" + self.results["elements"][0]["steps"].append({ + "keyword": "*", + "match": {}, + "name": msg, + "result": { + "duration": 0.0, + "error_message": "Assertion Failed", + "status": "failed" if is_fail else "passed" + }, + "step_type": "given" + }) + + class TestRunner: def __init__(self, ifc_path, schema_path=None, ifc=None): IfcStore.path = ifc_path @@ -34,6 +76,27 @@ class TestRunner: self.locale_path = os.path.join(self.base_path, "locale") def run(self, args): + if args["feature"][-4:].lower() == ".xml": + return self.test_ids(args) + return self.test_feature(args) + + def test_ids(self, args): + # Local import whilst this is experimental + import ifcopenshell.ids + + logger = logging.getLogger("IDS") + logging.basicConfig(level=logging.INFO, format="%(message)s") + ids_handler = IDSHandler() + logger.addHandler(ids_handler) + ids_file = ifcopenshell.ids.ids(args["feature"]) + ids_file.validate(IfcStore.file, logger) + + tmpdir = tempfile.mkdtemp() + report_json = os.path.join(tmpdir, "report.json") + json.dump([ids_handler.results], open(report_json, "w")) + return report_json + + def test_feature(self, args): tmpdir = tempfile.mkdtemp() features_path = os.path.join(tmpdir, "features") steps_path = os.path.join(features_path, "steps") diff --git a/src/ifcbimtester/cli.py b/src/ifcbimtester/cli.py index 38179de6b6..b7609d2398 100644 --- a/src/ifcbimtester/cli.py +++ b/src/ifcbimtester/cli.py @@ -10,7 +10,7 @@ parser = argparse.ArgumentParser(description="Runs unit tests for BIM data") parser.add_argument("-a", "--action", type=str, help="Action to perform, from run/purge", default="run") parser.add_argument("--advanced-arguments", type=str, help="Specify arguments to Behave", default="") parser.add_argument("-c", "--console", action="store_true", help="Show results in the console") -parser.add_argument("-f", "--feature", type=str, help="Specify a feature file to test", required=True) +parser.add_argument("-f", "--feature", type=str, help="Specify a feature file or IDS to test", required=True) parser.add_argument("-i", "--ifc", type=str, help="Specify an IFC file to test", required=True) parser.add_argument("-p", "--path", type=str, help="Define a path for use in test steps that use relative paths") parser.add_argument("-r", "--report", type=str, help="Specify an output file for a HTML report")