diff --git a/src/blenderbim/blenderbim/bim/module/cad/prop.py b/src/blenderbim/blenderbim/bim/module/cad/prop.py index f26207224d..5ad09cf61d 100644 --- a/src/blenderbim/blenderbim/bim/module/cad/prop.py +++ b/src/blenderbim/blenderbim/bim/module/cad/prop.py @@ -20,6 +20,7 @@ import bpy from blenderbim.bim.module.model.data import AuthoringData from blenderbim.bim.module.model.root import ConstrTypeEntityNotFound from bpy.types import PropertyGroup +from math import pi class BIMCadProperties(PropertyGroup): @@ -28,3 +29,6 @@ class BIMCadProperties(PropertyGroup): distance: bpy.props.FloatProperty(name="Distance", default=0.1) x: bpy.props.FloatProperty(name="X", default=0.2) y: bpy.props.FloatProperty(name="Y", default=0.1) + gable_roof_edge_angle: bpy.props.FloatProperty( + name="Gable Roof Edge Angle", default=pi / 2, soft_min=0, soft_max=pi / 2, subtype="ANGLE" + ) diff --git a/src/blenderbim/blenderbim/bim/module/cad/workspace.py b/src/blenderbim/blenderbim/bim/module/cad/workspace.py index 79ca04938f..729c505423 100644 --- a/src/blenderbim/blenderbim/bim/module/cad/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/cad/workspace.py @@ -153,6 +153,11 @@ class CadTool(WorkSpaceTool): row.operator("bim.cad_hotkey", text="Apply Roof Path").hotkey = "S_Q" row.operator("bim.cancel_editing_roof_path", icon="CANCEL", text="") + row = layout.row(align=True) + row.label(text="", icon="EVENT_SHIFT") + row.label(text="", icon="EVENT_R") + row.operator("bim.hotkey", text="Set gable roof angle").hotkey = "S_R" + row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_E") @@ -200,20 +205,31 @@ class CadHotkey(bpy.types.Operator): if self.is_profile(): row = self.layout.row() row.prop(props, "radius") + elif self.hotkey == "S_F": if not self.is_profile(): row = self.layout.row() row.prop(props, "resolution") row = self.layout.row() row.prop(props, "radius") + elif self.hotkey == "S_O": row = self.layout.row() row.prop(props, "distance") + elif self.hotkey == "S_R": - row = self.layout.row() - row.prop(props, "x") - row = self.layout.row() - row.prop(props, "y") + if self.is_profile(): + row = self.layout.row() + row.prop(props, "x") + row = self.layout.row() + row.prop(props, "y") + elif ( + (RoofData.is_loaded or not RoofData.load()) + and RoofData.data["parameters"] + and bpy.context.active_object.BIMRoofProperties.is_editing_path + ): + self.layout.row().prop(props, "gable_roof_edge_angle") + elif self.hotkey == "S_V": if not self.is_profile(): row = self.layout.row() @@ -264,6 +280,12 @@ class CadHotkey(bpy.types.Operator): def hotkey_S_R(self): if self.is_profile(): bpy.ops.bim.add_rectangle(x=self.props.x, y=self.props.y) + elif ( + (RoofData.is_loaded or not RoofData.load()) + and RoofData.data["parameters"] + and bpy.context.active_object.BIMRoofProperties.is_editing_path + ): + bpy.ops.bim.set_gable_roof_edge_angle(angle=self.props.gable_roof_edge_angle) def hotkey_S_T(self): bpy.ops.bim.cad_mitre() diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index f29b99ab32..2528a547bd 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -163,6 +163,7 @@ classes = ( roof.FinishEditingRoofPath, roof.EnableEditingRoofPath, roof.RemoveRoof, + roof.SetGableRoofEdgeAngle, ) addon_keymaps = [] diff --git a/src/blenderbim/blenderbim/bim/module/model/decorator.py b/src/blenderbim/blenderbim/bim/module/model/decorator.py index a5379621ad..43ddffdd7c 100644 --- a/src/blenderbim/blenderbim/bim/module/model/decorator.py +++ b/src/blenderbim/blenderbim/bim/module/model/decorator.py @@ -27,15 +27,28 @@ from gpu.types import GPUShader, GPUBatch, GPUIndexBuf, GPUVertBuf, GPUVertForma from gpu_extras.batch import batch_for_shader +white = (1, 1, 1, 1) +lightgrey = (0.7, 0.7, 0.7, 1) +green = (0.545, 0.863, 0, 1) +red = (1, 0.2, 0.322, 1) +blue = (0.157, 0.565, 1, 1) +grey = (0.2, 0.2, 0.2, 1) + +faces_color = (0.494, 0.540, 0.593, 1) +preview_edges_color = (0.130, 0.141, 0.371, 1) + + class ProfileDecorator: installed = None @classmethod - def install(cls, context): + def install(cls, context, get_custom_bmesh=None, draw_faces=False): if cls.installed: cls.uninstall() handler = cls() - cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW") + cls.installed = SpaceView3D.draw_handler_add( + handler, (context, get_custom_bmesh, draw_faces), "WINDOW", "POST_VIEW" + ) @classmethod def uninstall(cls): @@ -45,25 +58,63 @@ class ProfileDecorator: pass cls.installed = None - def __call__(self, context): + def create_batch(self, shader_type, content_pos, color, indices=None, bind=True): + batch = batch_for_shader(self.shader, shader_type, {"pos": content_pos}, indices=indices) + # TODO: what's bind is for? + if bind: + self.shader.bind() + self.shader.uniform_float("color", color) + batch.draw(self.shader) + + def draw_faces(self, bm, vertices_coords): + """mutates original bm (triangulates it) + so the triangulation edges will be shown too + """ + traingulated_bm = bm + bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces) + + face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces] + self.create_batch("TRIS", vertices_coords, faces_color, face_indices) + + def __call__(self, context, get_custom_bmesh=None, draw_faces=False): obj = context.active_object + if obj.mode != "EDIT": return - bgl.glLineWidth(2) - bgl.glPointSize(6) - bgl.glEnable(bgl.GL_BLEND) - bgl.glEnable(bgl.GL_LINE_SMOOTH) + if get_custom_bmesh: + bm = get_custom_bmesh() + else: + bm = bmesh.from_edit_mesh(obj.data) + def gl_init(use_bgl=False): + # TODO: remove as deprecated? + if use_bgl: + bgl.glLineWidth(2) + bgl.glPointSize(6) + bgl.glEnable(bgl.GL_BLEND) + bgl.glEnable(bgl.GL_LINE_SMOOTH) + else: + gpu.state.line_width_set(2) + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + bgl.glEnable(bgl.GL_LINE_SMOOTH) + + gl_init(True) + + ### Actually drawing all_vertices = [] error_vertices = [] selected_vertices = [] unselected_vertices = [] + # special = associated with arcs/circles special_vertices = [] special_vertex_indices = {} selected_edges = [] unselected_edges = [] - special_edges = [] # edges that have a circle or an arc associated with them + arc_edges = [] + roof_angle_edges = [] + preview_edges = [] arc_groups = [] circle_groups = [] @@ -76,11 +127,11 @@ class ProfileDecorator: arcs = {} circles = {} - bm = bmesh.from_edit_mesh(obj.data) - # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess # This is how we access vertex groups via bmesh, apparently, it's not very intuitive deform_layer = bm.verts.layers.deform.active + angle_layer = bm.edges.layers.float.get("BBIM_gable_roof_angles") + preview_layer = bm.edges.layers.int.get("BBIM_preview") for vertex in bm.verts: co = tuple(obj.matrix_world @ vertex.co) @@ -88,6 +139,8 @@ class ProfileDecorator: if vertex.hide: continue + # TODO: iterate over deform layers instead of all vertex groups? + # move to separate function `bm_check_vertex_in_groups` is_arc = False for group_index in arc_groups: if group_index in vertex[deform_layer]: @@ -128,51 +181,35 @@ class ProfileDecorator: selected_edges.append(edge_indices) else: i1, i2 = edge.verts[0].index, edge.verts[1].index + # making sure that both vertices are in the same group if i1 in special_vertex_indices and special_vertex_indices[i1] == special_vertex_indices.get(i2, None): - special_edges.append(edge_indices) + arc_edges.append(edge_indices) + elif angle_layer and edge[angle_layer] > 0: + roof_angle_edges.append(edge_indices) + elif preview_layer and edge[preview_layer] == 1: + preview_edges.append(edge_indices) else: unselected_edges.append(edge_indices) - indices = [[v.index for v in e.verts] for e in bm.edges] - - white = (1, 1, 1, 1) - green = (0.545, 0.863, 0, 1) - red = (1, 0.2, 0.322, 1) - blue = (0.157, 0.565, 1, 1) - grey = (0.2, 0.2, 0.2, 1) + ### Actually drawing self.shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR") - batch = batch_for_shader(self.shader, "LINES", {"pos": all_vertices}, indices=unselected_edges) - self.shader.bind() - self.shader.uniform_float("color", white) - batch.draw(self.shader) + # Draw faces + if draw_faces: + self.draw_faces(bm, all_vertices) - batch = batch_for_shader(self.shader, "LINES", {"pos": all_vertices}, indices=selected_edges) - self.shader.uniform_float("color", green) - batch.draw(self.shader) + self.create_batch("LINES", all_vertices, lightgrey, unselected_edges) + self.create_batch("LINES", all_vertices, green, selected_edges) + self.create_batch("LINES", all_vertices, grey, arc_edges) + self.create_batch("LINES", all_vertices, preview_edges_color, preview_edges) + self.create_batch("LINES", all_vertices, blue, roof_angle_edges) - batch = batch_for_shader(self.shader, "LINES", {"pos": all_vertices}, indices=special_edges) - self.shader.uniform_float("color", grey) - batch.draw(self.shader) - - batch = batch_for_shader(self.shader, "POINTS", {"pos": unselected_vertices}) - self.shader.uniform_float("color", white) - batch.draw(self.shader) - - batch = batch_for_shader(self.shader, "POINTS", {"pos": error_vertices}) - self.shader.uniform_float("color", red) - batch.draw(self.shader) - - batch = batch_for_shader(self.shader, "POINTS", {"pos": special_vertices}) - self.shader.uniform_float("color", blue) - batch.draw(self.shader) - - batch = batch_for_shader(self.shader, "POINTS", {"pos": selected_vertices}) - self.shader.uniform_float("color", green) - batch.draw(self.shader) + self.create_batch("POINTS", unselected_vertices, lightgrey) + self.create_batch("POINTS", error_vertices, red) + self.create_batch("POINTS", special_vertices, blue) + self.create_batch("POINTS", selected_vertices, green) # Draw arcs - arc_centroids = [] arc_segments = [] for arc in arcs.values(): @@ -195,17 +232,11 @@ class ProfileDecorator: arc_centroids.append(tuple(centroid)) arc_segments.append(tool.Cad.create_arc_segments(pts=points, num_verts=17, make_edges=True)) - batch = batch_for_shader(self.shader, "POINTS", {"pos": arc_centroids}) - self.shader.uniform_float("color", (0.2, 0.2, 0.2, 1)) - batch.draw(self.shader) - + self.create_batch("POINTS", arc_centroids, grey, bind=False) for verts, edges in arc_segments: - batch = batch_for_shader(self.shader, "LINES", {"pos": verts}, indices=edges) - self.shader.uniform_float("color", (0.157, 0.565, 1, 1)) - batch.draw(self.shader) + self.create_batch("LINES", verts, blue, edges, bind=False) # Draw circles - circle_centroids = [] circle_segments = [] for circle in circles.values(): @@ -222,14 +253,9 @@ class ProfileDecorator: segments = [[list(matrix @ Vector(v)) for v in segments[0]], segments[1]] circle_segments.append(segments) - batch = batch_for_shader(self.shader, "POINTS", {"pos": circle_centroids}) - self.shader.uniform_float("color", (0.2, 0.2, 0.2, 1)) - batch.draw(self.shader) - + self.create_batch("POINTS", circle_centroids, grey, bind=False) for verts, edges in circle_segments: - batch = batch_for_shader(self.shader, "LINES", {"pos": verts}, indices=edges) - self.shader.uniform_float("color", (0.157, 0.565, 1, 1)) - batch.draw(self.shader) + self.create_batch("LINES", verts, blue, edges, bind=False) def create_matrix(self, p, x, y, z): return Matrix([x, y, z, p]).to_4x4().transposed() diff --git a/src/blenderbim/blenderbim/bim/module/model/profile.py b/src/blenderbim/blenderbim/bim/module/model/profile.py index 32202678ae..5e267ce3b7 100644 --- a/src/blenderbim/blenderbim/bim/module/model/profile.py +++ b/src/blenderbim/blenderbim/bim/module/model/profile.py @@ -800,14 +800,7 @@ class DumbProfileJoiner: return self.create_matrix(p, x_axis, y_axis, z_axis) def create_matrix(self, p, x, y, z): - return Matrix( - ( - (x[0], y[0], z[0], p[0]), - (x[1], y[1], z[1], p[1]), - (x[2], y[2], z[2], p[2]), - (0.0, 0.0, 0.0, 1.0), - ) - ) + return Matrix([x, y, z, p]).to_4x4().transposed() def get_profile_axis(self, obj): z_values = [v[2] for v in obj.bound_box] diff --git a/src/blenderbim/blenderbim/bim/module/model/prop.py b/src/blenderbim/blenderbim/bim/module/model/prop.py index 38b030b5b0..4fc12357df 100644 --- a/src/blenderbim/blenderbim/bim/module/model/prop.py +++ b/src/blenderbim/blenderbim/bim/module/model/prop.py @@ -23,6 +23,7 @@ from blenderbim.bim.prop import ObjProperty from blenderbim.bim.module.model.data import AuthoringData from blenderbim.bim.module.model.root import ConstrTypeEntityNotFound from bpy.types import PropertyGroup, NodeTree +from math import pi def get_ifc_class(self, context): @@ -569,7 +570,7 @@ class BIMRailingProperties(PropertyGroup): class BIMRoofProperties(PropertyGroup): - roof_types = (("HIP_ROOF", "HIP_ROOF", ""),) + roof_types = (("HIP/GABLE ROOF", "HIP/GABLE ROOF", ""),) roof_generation_methods = ( ("HEIGHT", "HEIGHT", ""), ("ANGLE", "ANGLE", ""), @@ -579,12 +580,14 @@ class BIMRoofProperties(PropertyGroup): is_editing: bpy.props.IntProperty(default=-1) is_editing_path: bpy.props.BoolProperty(default=False) - roof_type: bpy.props.EnumProperty(name="Roof Type", items=roof_types, default="HIP_ROOF") + roof_type: bpy.props.EnumProperty(name="Roof Type", items=roof_types, default="HIP/GABLE ROOF") generation_method: bpy.props.EnumProperty( - name="Roof Generation Method", items=roof_generation_methods, default="HEIGHT" + name="Roof Generation Method", items=roof_generation_methods, default="ANGLE" ) - height: bpy.props.FloatProperty(name="Height", default=1.0) - angle: bpy.props.FloatProperty(name="Slope Angle", default=10, description="In degrees") + height: bpy.props.FloatProperty( + name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE" + ) + angle: bpy.props.FloatProperty(name="Slope Angle", default=pi / 18, subtype="ANGLE") def get_general_kwargs(self): kwargs = { diff --git a/src/blenderbim/blenderbim/bim/module/model/roof.py b/src/blenderbim/blenderbim/bim/module/model/roof.py index 591f84ec14..76e74d170a 100644 --- a/src/blenderbim/blenderbim/bim/module/model/roof.py +++ b/src/blenderbim/blenderbim/bim/module/model/roof.py @@ -29,23 +29,59 @@ from blenderbim.bim.module.model.data import RoofData, refresh from blenderbim.bim.module.model.decorator import ProfileDecorator import json -from math import tan, radians +from math import tan, radians, degrees, atan from mathutils import Vector, Matrix from bpypolyskel import bpypolyskel import shapely +from pprint import pprint +from itertools import chain +from math import pi # reference: # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoofType.htm +# create read only property in blender operator + + +def float_is_zero(f): + return 0.0001 >= f >= -0.0001 + + +def bm_mesh_clean_up(bm): + # remove internal edges and faces + # adding missing faces so we could rely on `e.is_boundary` later + bmesh.ops.contextual_create(bm, geom=bm.edges[:]) + edges_to_dissolve = [e for e in bm.edges if not e.is_boundary] + bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) + bmesh.ops.delete(bm, geom=bm.faces[:], context="FACES_ONLY") + bmesh.ops.dissolve_limit( + bm, + angle_limit=0.0872665, + use_dissolve_boundaries=False, + delimit={"NORMAL"}, + edges=bm.edges[:], + verts=bm.verts[:], + ) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) + + class GenerateHippedRoof(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.generate_hipped_roof" bl_label = "Generate Hipped Roof" - bl_options = {"REGISTER"} - mode: bpy.props.StringProperty(default="ANGLE") - height: bpy.props.FloatProperty(default=1) - angle: bpy.props.FloatProperty(default=10) + bl_options = {"REGISTER", "UNDO"} + + roof_generation_methods = ( + ("HEIGHT", "HEIGHT", ""), + ("ANGLE", "ANGLE", ""), + ) + + mode: bpy.props.EnumProperty(name="Roof Generation Method", items=roof_generation_methods, default="ANGLE") + height: bpy.props.FloatProperty( + name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE" + ) + angle: bpy.props.FloatProperty(name="Slope Angle", default=pi / 18, subtype="ANGLE") def _execute(self, context): obj = bpy.context.active_object @@ -54,90 +90,190 @@ class GenerateHippedRoof(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} bm = tool.Blender.get_bmesh_for_mesh(obj.data) - # argument values are the defaults for `bpy.ops.mesh.dissolve_limited` - bmesh.ops.dissolve_limit( - bm, angle_limit=0.0872665, use_dissolve_boundaries=False, delimit={"NORMAL"}, edges=bm.edges[:], verts=bm.verts[:] - ) + generate_hiped_roof_bmesh(bm, self.mode, self.height, self.angle) tool.Blender.apply_bmesh(obj.data, bm) - - generate_hipped_roof(obj, self.mode, self.height, self.angle) return {"FINISHED"} -def generate_hipped_roof(obj, mode="ANGLE", height=1.0, angle=10): +def generate_hiped_roof_bmesh(bm, mode="ANGLE", height=1.0, angle=pi / 18, mutate_current_bmesh=True): + """return bmesh with gable roof geometry + + `mutate_current_bmesh` is a flag to indicate whether the input bmesh + should be mutated or a new bmesh should be created and returned. + + If the object is in EDIT mode then it will be the only way to change it. + + If roof bmesh needed only to supply into decorator then there is no reason to mutate it. + """ + + if not mutate_current_bmesh: + bm = bm.copy() + + # CLEAN UP + bm_mesh_clean_up(bm) + boundary_lines = [] - for edge in obj.data.edges: - boundary_lines.append( - shapely.LineString([obj.data.vertices[edge.vertices[0]].co, obj.data.vertices[edge.vertices[1]].co]) - ) - - unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) - closed_polygons = shapely.polygonize(unioned_boundaries.geoms) - - roof_polygon = None - biggest_area = 0 - for polygon in closed_polygons.geoms: - area = polygon.area - if area > biggest_area: - roof_polygon = polygon - biggest_area = area - - roof_polygon = shapely.force_3d(roof_polygon) - - if not shapely.is_ccw(roof_polygon): - roof_polygon = roof_polygon.reverse() - - # Define vertices for the base footprint of the building at height 0.0 - # counterclockwise order - verts = [Vector(v) for v in roof_polygon.exterior.coords[0:-1]] - total_exterior_verts = len(verts) - next_index = total_exterior_verts - - inner_loops = None - for interior in roof_polygon.interiors: - if inner_loops is None: - inner_loops = [] - loop = interior.coords[0:-1] - total_verts = len(loop) - verts.extend([Vector(v) for v in loop]) - inner_loops.append((next_index, total_verts)) - next_index += total_verts - - unit_vectors = None # we have no unit vectors, let them computed by polygonize() - start_exterior_index = 0 - - faces = [] - - if mode == "HEIGHT": - height = height - angle = 0.0 + original_geometry_data = dict() + angle_layer = bm.edges.layers.float.get("BBIM_gable_roof_angles") + if angle_layer: + original_geometry_data["edges"] = [(set(bm_get_indices(e.verts)), e[angle_layer]) for e in bm.edges] else: - angle = tan(radians(round(angle, 4))) - height = 0.0 + original_geometry_data["edges"] = [(set(bm_get_indices(e.verts)), None) for e in bm.edges] - faces = bpypolyskel.polygonize( - verts, start_exterior_index, total_exterior_verts, inner_loops, height, angle, faces, unit_vectors - ) + original_geometry_data["verts"] = {v.index: v.co.copy() for v in bm.verts} + footprint_z = bm.verts[:][0].co.z - edges = [] + def calculate_hiped_roof(): + for edge in bm.edges: + boundary_lines.append(shapely.LineString([v.co for v in edge.verts])) + + unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) + closed_polygons = shapely.polygonize(unioned_boundaries.geoms) + + # find the polygon with the biggest area + roof_polygon = max(closed_polygons.geoms, key=lambda polygon: polygon.area) + + # add z coordinate if not present + roof_polygon = shapely.force_3d(roof_polygon) + + # make sure the polygon is counter-clockwise + if not shapely.is_ccw(roof_polygon): + roof_polygon = roof_polygon.reverse() + + # Define vertices for the base footprint of the building at height 0.0 + # counterclockwise order + verts = [Vector(v) for v in roof_polygon.exterior.coords[0:-1]] + total_exterior_verts = len(verts) + next_index = total_exterior_verts + + inner_loops = None # in case when there is no .interiors + for interior in roof_polygon.interiors: + if inner_loops is None: + inner_loops = [] + loop = interior.coords[0:-1] + total_verts = len(loop) + verts.extend([Vector(v) for v in loop]) + inner_loops.append((next_index, total_verts)) + next_index += total_verts + + unit_vectors = None # we have no unit vectors, let them computed by polygonize() + start_exterior_index = 0 + + faces = [] + nonlocal height, angle + if mode == "HEIGHT": + height = height + angle = 0.0 + else: + angle = tan(angle) + height = 0.0 + + faces = bpypolyskel.polygonize( + verts, start_exterior_index, total_exterior_verts, inner_loops, height, angle, faces, unit_vectors + ) + edges = [] + return verts, edges, faces + + verts, edges, faces = calculate_hiped_roof() + bm.clear() - bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True) new_verts = [bm.verts.new(v) for v in verts] new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges] new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces] + def find_identical_new_vert(co): + for v in bm.verts: + if float_is_zero((co - v.co).length): + return v + + def find_other_polygon_verts(edge): + polygon = edge.link_faces[0] + return [v for v in polygon.verts if v not in edge.verts] + + def change_angle(projected_vert_co, edge_verts, new_angle): + A, B = [v.co for v in edge_verts] + P = projected_vert_co + + AP = P - A + AB = B - A + AB_dir = AB.normalized() + proj_length = AP.dot(AB_dir) + C = A + AB_dir * proj_length + Pp = P * Vector([1, 1, 0]) + Vector([0, 0, C.z]) + + Pp = P * Vector([1, 1, 0]) + Vector([0, 0, C.z]) + angle_tan = tan(new_angle) + dist = (P.z - Pp.z) / angle_tan + PPnew = C + (Pp - C).normalized() * dist + Pnew = PPnew * Vector([1, 1, 0]) + Vector([0, 0, P.z]) + return Pnew + + footprint_edges = [] + footprint_verts = set() + verts_to_change = {} + + # find footprint edges + for edge in bm.edges: + if all(float_is_zero(v.co.z-footprint_z) for v in edge.verts): + footprint_edges.append(edge) + footprint_verts.update(edge.verts) + + old_verts_remap = {} + for old_vert in original_geometry_data["verts"]: + old_vert_co = original_geometry_data["verts"][old_vert] + old_verts_remap[old_vert] = find_identical_new_vert(old_vert_co) + + # iterate over edges from original geometry + # if their angle was redefined by user - apply the changes to the related vertices + # to match the requested angle + for old_edge_verts, defined_angle in original_geometry_data["edges"]: + if not defined_angle: + continue + + edge_verts_remaped = set(old_verts_remap[old_vert] for old_vert in old_edge_verts) + + for edge in footprint_edges: + if set(edge.verts) == edge_verts_remaped: + identical_edge = edge + break + + verts_to_move = find_other_polygon_verts(identical_edge) + for v in verts_to_move: + vert_co = verts_to_change.get(v, v.co) + new_vert_co = change_angle(vert_co, edge_verts_remaped, defined_angle) + verts_to_change[v] = new_vert_co + + # apply all changes once at the end + for v in verts_to_change: + v.co = verts_to_change[v] + extrusion_geom = bmesh.ops.extrude_face_region(bm, geom=bm.faces)["geom"] extruded_verts = bm_sort_out_geom(extrusion_geom)["verts"] bmesh.ops.translate(bm, vec=[0.0, 0.0, 0.1], verts=extruded_verts) - tool.Blender.apply_bmesh(obj.data, bm) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) + return bm def bm_get_indices(sequence): return [i.index for i in sequence] +def roof_is_gabled(): + if not RoofData.is_loaded: + RoofData.load() + + path_data = RoofData.parameters()["data_dict"]["path_data"] + angle_layer = path_data.get("gable_roof_angles", None) + if not angle_layer: + return False + for edge_angle in angle_layer: + if float_is_zero(edge_angle - pi / 2): + return True + return False + + def update_roof_modifier_ifc_data(context): """should be called after new geometry settled since it's going to update ifc representation @@ -147,7 +283,9 @@ def update_roof_modifier_ifc_data(context): element = tool.Ifc.get_entity(obj) # type attributes - element.PredefinedType = props.roof_type + if props.roof_type == "HIP/GABLE ROOF": + element.PredefinedType = "GABLE_ROOF" if roof_is_gabled() else "HIP_ROOF" + # occurences attributes # occurences = tool.Ifc.get_all_element_occurences(element) @@ -172,34 +310,34 @@ def update_roof_modifier_bmesh(context): if not RoofData.is_loaded: RoofData.load() path_data = RoofData.data["parameters"]["data_dict"]["path_data"] + angle_layer_data = path_data.get("gable_roof_angles", None) si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) # need to make sure we support edit mode # since users will probably be in edit mode when they'll be changing roof path bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True) + angle_layer = bm.edges.layers.float.new("BBIM_gable_roof_angles") # generating roof path new_verts = [bm.verts.new(Vector(v) * si_conversion) for v in path_data["verts"]] - new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in path_data["edges"]] + new_edges = [] + for i in range(len(path_data["edges"])): + e = path_data["edges"][i] + edge = bm.edges.new((new_verts[e[0]], new_verts[e[1]])) + edge[angle_layer] = angle_layer_data[i] if angle_layer_data else 0 + new_edges.append(edge) if props.is_editing_path: tool.Blender.apply_bmesh(obj.data, bm) return - # apply dissolve limit seems to get more correct results with `generate_hipped_roof` - # argument values are the defaults for `bpy.ops.mesh.dissolve_limited` - bmesh.ops.dissolve_limit( - bm, angle_limit=0.0872665, use_dissolve_boundaries=False, delimit={"NORMAL"}, edges=bm.edges[:], verts=bm.verts[:] - ) - tool.Blender.apply_bmesh(obj.data, bm) - height = props.height * si_conversion - angle = props.angle * si_conversion - generation_method = props.generation_method - generate_hipped_roof(obj, generation_method, height, angle) + generate_hiped_roof_bmesh(bm, props.generation_method, height, props.angle, mutate_current_bmesh=True) + tool.Blender.apply_bmesh(obj.data, bm) def get_path_data(obj): + """get path data for current mesh, path data is cleaned up""" si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) if obj.mode == "EDIT": @@ -208,18 +346,15 @@ def get_path_data(obj): obj.update_from_editmode() bm = tool.Blender.get_bmesh_for_mesh(obj.data) + bm_mesh_clean_up(bm) - # remove internal edges and faces - # adding missing faces so we could rely on `e.is_boundary` later - bmesh.ops.contextual_create(bm, geom=bm.edges[:]) - - edges_to_dissolve = [e for e in bm.edges if not e.is_boundary] - bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) - bmesh.ops.delete(bm, geom=bm.faces[:], context="FACES_ONLY") + angle_layer = bm.edges.layers.float.get("BBIM_gable_roof_angles") path_data = dict() path_data["edges"] = [bm_get_indices(e.verts) for e in bm.edges] path_data["verts"] = [v.co / si_conversion for v in bm.verts] + if angle_layer: + path_data["gable_roof_angles"] = [e[angle_layer] for e in bm.edges] if not path_data["edges"] or not path_data["verts"]: return None @@ -399,7 +534,30 @@ class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): if bpy.context.object.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT") bpy.ops.wm.tool_set_by_id(tool.Blender.get_viewport_context(), name="bim.cad_tool") - ProfileDecorator.install(context) + + def mark_preview_edges(bm, bew_verts, new_edges, new_faces): + preview_layer = bm.edges.layers.int["BBIM_preview"] + # can't create layer in callback because it kill all the bm edge references + for edge in new_edges: + edge[preview_layer] = 1 + + def get_custom_bmesh(): + # copying to make sure not to mutate the edit mode bmesh + bm = tool.Blender.get_bmesh_for_mesh(obj.data) + main_bm = bm.copy() + main_bm.edges.layers.int.new("BBIM_preview") + + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + height = props.height * si_conversion + second_bm = generate_hiped_roof_bmesh( + bm, props.generation_method, height, props.angle, mutate_current_bmesh=False + ) + bmesh.ops.translate(second_bm, verts=second_bm.verts, vec=Vector((0, 0, 1))) + + tool.Blender.bmesh_join(main_bm, second_bm, callback=mark_preview_edges) + return main_bm + + ProfileDecorator.install(context, get_custom_bmesh, draw_faces=True) return {"FINISHED"} @@ -440,6 +598,7 @@ class FinishEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): update_bbim_roof_pset(element, roof_data) refresh() # RoofData has to be updated before run update_roof_modifier_bmesh update_roof_modifier_bmesh(context) + update_roof_modifier_ifc_data(context) if bpy.context.object.mode == "EDIT": bpy.ops.object.mode_set(mode="OBJECT") update_roof_modifier_ifc_data(context) @@ -463,5 +622,43 @@ class RemoveRoof(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class SetGableRoofEdgeAngle(bpy.types.Operator): + bl_idname = "bim.set_gable_roof_edge_angle" + bl_label = "Set gable roof edge angle" + bl_options = {"REGISTER", "UNDO"} + angle: bpy.props.FloatProperty(name="Angle", default=90) + + @classmethod + def poll(cls, context): + obj = context.active_object + return obj and obj.type == "MESH" and context.mode == "EDIT_MESH" + + def draw(self, context): + layout = self.layout + for prop in self.__class__.__annotations__.keys(): + layout.prop(self, prop) + + def execute(self, context): + # tried to avoid bmesh with foreach_get and foreach_set + # but in EDIT mode it's only possible to change attributes by working with bmesh + + me = context.object.data + bm = tool.Blender.get_bmesh_for_mesh(me) + + # check if attribute exists or create one + if "BBIM_gable_roof_angles" not in me.attributes: + me.attributes.new("BBIM_gable_roof_angles", type="FLOAT", domain="EDGE") + + angles_layer = bm.edges.layers.float["BBIM_gable_roof_angles"] + + for e in bm.edges: + if not e.select: + continue + e[angles_layer] = self.angle + + tool.Blender.apply_bmesh(me, bm) + return {"FINISHED"} + + def add_object_button(self, context): self.layout.operator(BIM_OT_add_roof.bl_idname, icon="PLUGIN") diff --git a/src/blenderbim/blenderbim/bim/module/model/wall.py b/src/blenderbim/blenderbim/bim/module/model/wall.py index 0a873b2f77..68f416b38e 100644 --- a/src/blenderbim/blenderbim/bim/module/model/wall.py +++ b/src/blenderbim/blenderbim/bim/module/model/wall.py @@ -1234,14 +1234,7 @@ class DumbWallJoiner: tool.Geometry.record_object_materials(obj) def create_matrix(self, p, x, y, z): - return Matrix( - ( - (x[0], y[0], z[0], p[0]), - (x[1], y[1], z[1], p[1]), - (x[2], y[2], z[2], p[2]), - (0.0, 0.0, 0.0, 1.0), - ) - ) + return Matrix([x, y, z, p]).to_4x4().transposed() def get_extrusion_data(self, representation): results = {"item": None, "height": 3.0, "x_angle": 0, "is_sloped": False, "direction": Vector((0, 0, 1))} diff --git a/src/blenderbim/blenderbim/tool/blender.py b/src/blenderbim/blenderbim/tool/blender.py index 4292f07132..78bbc21aea 100644 --- a/src/blenderbim/blenderbim/tool/blender.py +++ b/src/blenderbim/blenderbim/tool/blender.py @@ -93,6 +93,28 @@ class Blender: return False return False + @classmethod + def get_viewport_context(cls): + """Get viewport area context for context overriding. + + Useful for calling operators outside viewport context. + + It's a bit naive since it's just taking the first available `VIEW_3D` area + when in real life you can have a couple of those but should work for the most cases. + """ + area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") + context_override = {"area": area} + return context_override + + @classmethod + def update_viewport(cls): + # if it stops working in future Blender versions + # there is an alternative: + # bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + + tool.Blender.get_viewport_context()['area'].tag_redraw() + + ## BMESH UTILS ## @classmethod def apply_bmesh(cls, mesh, bm): import bmesh @@ -131,24 +153,18 @@ class Blender: if not clean: bm.from_mesh(mesh) return bm - + @classmethod - def get_viewport_context(cls): - """Get viewport area context for context overriding. + def bmesh_join(cls, bm_a, bm_b, callback=None): + """Join two meshes into single one, store it in `bm_a`""" + import bmesh - Useful for calling operators outside viewport context. + new_verts = [bm_a.verts.new(v.co) for v in bm_b.verts] + new_edges = [bm_a.edges.new([new_verts[v.index] for v in edge.verts]) for edge in bm_b.edges] + new_faces = [bm_a.faces.new([new_verts[v.index] for v in face.verts]) for face in bm_b.faces] + bmesh.ops.recalc_face_normals(bm_a, faces=bm_a.faces[:]) - It's a bit naive since it's just taking the first available `VIEW_3D` area - when in real life you can have a couple of those but should work for the most cases. - """ - area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") - context_override = {"area": area} - return context_override - - @classmethod - def update_viewport(cls): - # if it stops working in future Blender versions - # there is an alternative: - # bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + if callback: + callback(bm_a, new_verts, new_edges, new_faces) - tool.Blender.get_viewport_context()['area'].tag_redraw() \ No newline at end of file + return bm_a \ No newline at end of file