diff --git a/src/blenderbim/blenderbim/bim/module/cad/__init__.py b/src/blenderbim/blenderbim/bim/module/cad/__init__.py index 6a32d53c5a..480e8585d6 100644 --- a/src/blenderbim/blenderbim/bim/module/cad/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/cad/__init__.py @@ -25,6 +25,7 @@ classes = ( operator.CadArcFrom3Points, operator.CadFillet, operator.CadMitre, + operator.CadOffset, operator.CadTrimExtend, workspace.CadHotkey, ) diff --git a/src/blenderbim/blenderbim/bim/module/cad/operator.py b/src/blenderbim/blenderbim/bim/module/cad/operator.py index f57114ba75..62f8b51b2f 100644 --- a/src/blenderbim/blenderbim/bim/module/cad/operator.py +++ b/src/blenderbim/blenderbim/bim/module/cad/operator.py @@ -22,6 +22,8 @@ import bmesh import mathutils import bpy_extras import blenderbim.tool as tool +from mathutils import Vector, Matrix +from math import pi, radians, sin, cos, sqrt messages = { "SHARED_VERTEX": "Shared Vertex, no intersection possible", @@ -40,7 +42,6 @@ class CadTrimExtend(bpy.types.Operator): return bool(obj) and obj.type == "MESH" def cancel_message(self, msg): - print(msg) self.report({"WARNING"}, msg) return {"CANCELLED"} @@ -73,7 +74,6 @@ class CadTrimExtend(bpy.types.Operator): bm.verts.index_update() bm.edges.index_update() bmesh.update_edit_mesh(me, loop_triangles=True) - return {"FINISHED"} @@ -87,7 +87,6 @@ class CadMitre(bpy.types.Operator): return bool(obj) and obj.type == "MESH" def cancel_message(self, msg): - print(msg) self.report({"WARNING"}, msg) return {"CANCELLED"} @@ -120,7 +119,6 @@ class CadMitre(bpy.types.Operator): bm.verts.index_update() bm.edges.index_update() bmesh.update_edit_mesh(me, loop_triangles=True) - return {"FINISHED"} @@ -257,7 +255,7 @@ class CadArcFrom2Points(bpy.types.Operator): v = selected_verts[0] bm.verts.remove(selected_verts[1]) - axis = region_3d.view_rotation @ mathutils.Vector((0, 0, 1)) + axis = region_3d.view_rotation @ Vector((0, 0, 1)) bmesh.ops.spin( bm, geom=[v], @@ -334,6 +332,202 @@ class CadArcFrom3Points(bpy.types.Operator): return {"FINISHED"} +class CadOffset(bpy.types.Operator): + bl_idname = "bim.cad_offset" + bl_label = "CAD Offset" + bl_options = {"REGISTER", "UNDO"} + distance: bpy.props.FloatProperty(name="Distance", default=0.1) + + @classmethod + def poll(self, context): + return context.mode == 'EDIT_MESH' + + def draw(self, context): + layout = self.layout + for prop in self.__class__.__annotations__.keys(): + layout.prop(self, prop) + + def cancel_message(self, msg): + self.report({"WARNING"}, msg) + return {"CANCELLED"} + + def execute(self, context): + # This is not the same as Mesh Tools or Sverchok. Mesh Tools is based on + # a 3D "rail" which allows fancy 3D edge offsets, but will get different + # results to traditional CAD, which expects offsets to occur on a + # defined 2D workplane. This CAD offset does a pure 2D operation, so it + # always looks correct from the desired "2D workplane". This also + # reduces the settings down to one parameter instead of the huge config + # dialog that Mesh Tools has. Sverchok is 2D based, but has a weird side + # effect of converting an unclosed loop into a closed loop which is also + # not what users expect. + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.mode_set(mode="EDIT") + + obj = context.active_object + mw = obj.matrix_world + + bm = bmesh.from_edit_mesh(obj.data) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + + edges = [e for e in bm.edges if e.select and not e.hide] + + if len(edges) < 1: + return self.cancel_message("NO_EDGES") + + verts = set() + [verts.update(e.verts) for e in edges] + + # Use the viewport angle to determine the offset direction + for area in bpy.context.screen.areas: + if area.type == 'VIEW_3D': + # Don't ask me, I don't know. + # view_matrix = area.spaces.active.region_3d.view_matrix + x = area.spaces.active.region_3d.view_rotation @ Vector((1,0,0)) + y = area.spaces.active.region_3d.view_rotation @ Vector((0,1,0)) + z = area.spaces.active.region_3d.view_rotation @ Vector((0,0,1)) + wp = Matrix([x,y,z,Vector((0,0,0))]).to_4x4().transposed() + break + + rotation = Matrix.Rotation(pi / 2, 2, 'Z') + rotation_i = Matrix.Rotation(- pi / 2, 2, 'Z') + + # Create loops from edges + loop_edges = set(edges) + loops = [] + while loop_edges: + edge = loop_edges.pop() + loop = [edge] + has_found_connected_edge = True + while has_found_connected_edge: + has_found_connected_edge = False + for edge in loop_edges.copy(): + edge_verts = set(edge.verts) + if edge_verts & set(loop[0].verts): + loop.insert(0, edge) + loop_edges.remove(edge) + has_found_connected_edge = True + elif edge_verts & set(loop[-1].verts): + loop.append(edge) + loop_edges.remove(edge) + has_found_connected_edge = True + loops.append(loop) + + for loop in loops: + all_verts = {v.index for e in loop for v in e.verts} + possible_v1s = [] + is_closed = True + for edge in loop: + # If we have an open loop, start at either end instead + v = edge.verts[0] + if len(v.link_edges) == 1: + possible_v1s.append(v) + is_closed = False + else: + for link_edge in v.link_edges: + if link_edge.other_vert(v).index not in all_verts: + possible_v1s.append(v) + is_closed = False + break + v = edge.verts[1] + if len(v.link_edges) == 1: + possible_v1s.append(v) + is_closed = False + else: + for link_edge in v.link_edges: + if link_edge.other_vert(v).index not in all_verts: + possible_v1s.append(v) + is_closed = False + break + + # Always start at the same vertex, so that when the operator is + # refreshed with new parameters the axes don't randomly get flipped. + if is_closed: + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + start = bm.verts[min(all_verts)] + else: + start = possible_v1s[0] if possible_v1s[0].index < possible_v1s[1].index else possible_v1s[1] + + v1 = start + + new_verts = [] + processed_verts = set() + # [v0] --> v1 --> v2 + while v1: + if v1.index in processed_verts: + break + + link_verts = [] + if len(v1.link_edges) == 1: + v = v1.link_edges[0].other_vert(v1) + link_verts.append(v) if v.index in all_verts else None + else: + v = v1.link_edges[0].other_vert(v1) + link_verts.append(v) if v.index in all_verts else None + v = v1.link_edges[1].other_vert(v1) + link_verts.append(v) if v.index in all_verts else None + + if len(link_verts) == 1: + v2 = link_verts[0] + v0 = None if v2.index not in processed_verts else v2 + v2 = None if v2.index in processed_verts else v2 + else: + v2 = link_verts[0] + v0 = link_verts[1] + if v2.index in processed_verts: + if is_closed and v0.index in processed_verts: + v3 = start + v0 = v0 if v2 == v3 else v2 + v2 = v3 + else: + v0, v2 = v2, v0 + + normals = [] + v1co = (wp.inverted() @ mw @ v1.co).to_2d() + if v2: + v2co = (wp.inverted() @ mw @ v2.co).to_2d() + direction = (v2co - v1co).normalized() + local_direction = (rotation @ direction).normalized() + normals.append(local_direction) + + if v0: + v0co = (wp.inverted() @ mw @ v0.co).to_2d() + direction = (v0co - v1co).normalized() + local_direction = (rotation_i @ direction).normalized() + normals.append(local_direction) + + if len(normals) == 2: + # https://stackoverflow.com/a/54042831/9627415 + new_normal = (normals[0].lerp(normals[1], 0.5)).normalized() + offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1]))/2) + offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d()) + new_vert = v1.co + offset + new_verts.append(bm.verts.new(new_vert)) + else: + normal = (normals[0] * self.distance).to_3d() + offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal) + new_vert = v1.co + offset + new_verts.append(bm.verts.new(new_vert)) + + processed_verts.add(v1.index) + + if v2 in processed_verts: + break + + v1 = v2 + + [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + if is_closed: + bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) + + bm.verts.index_update() + bm.edges.index_update() + bmesh.update_edit_mesh(obj.data) + return {"FINISHED"} + + class AddIfcCircle(bpy.types.Operator): bl_idname = "bim.add_ifccircle" bl_label = "Add IfcCircle" diff --git a/src/blenderbim/blenderbim/bim/module/cad/workspace.py b/src/blenderbim/blenderbim/bim/module/cad/workspace.py index 27e79ae533..ae27ec4c13 100644 --- a/src/blenderbim/blenderbim/bim/module/cad/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/cad/workspace.py @@ -20,7 +20,6 @@ import os import bpy import blenderbim.bim.module.type.prop as type_prop from bpy.types import WorkSpaceTool -from blenderbim.bim.ifc import IfcStore from blenderbim.bim.module.model.data import AuthoringData @@ -35,13 +34,12 @@ class CadTool(WorkSpaceTool): bl_widget = None # https://docs.blender.org/api/current/bpy.types.KeyMapItems.html bl_keymap = ( - ("bim.cad_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}), - ("bim.cad_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}), - ("bim.cad_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}), - ("bim.cad_fillet", {"type": "F", "value": "PRESS", "shift": True}, {"properties": []}), - # Enable Mesh Tools add-on to get this amazing tool - ("mesh.offset_edges", {"type": "O", "value": "PRESS", "shift": True}, {"properties": []}), ("bim.cad_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}), + ("bim.cad_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}), + ("bim.cad_hotkey", {"type": "F", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_F")]}), + ("bim.cad_hotkey", {"type": "O", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_O")]}), + ("bim.cad_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}), + ("bim.cad_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}), ("bim.cad_hotkey", {"type": "V", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_V")]}), ) @@ -57,12 +55,12 @@ class CadTool(WorkSpaceTool): row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_E") - row.operator("bim.hotkey", text="Extend").hotkey = "S_E" + row.operator("bim.cad_hotkey", text="Extend").hotkey = "S_E" row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_T") - row.operator("bim.hotkey", text="Mitre").hotkey = "S_T" + row.operator("bim.cad_hotkey", text="Mitre").hotkey = "S_T" row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") @@ -73,25 +71,31 @@ class CadTool(WorkSpaceTool): row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_V") row.operator("bim.set_arc_index", text="3-Point Arc") + + row = layout.row(align=True) + row.label(text="", icon="EVENT_SHIFT") + row.label(text="", icon="EVENT_O") + row.operator("bim.cad_hotkey", text="Offset").hotkey = "S_O" else: row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_E") - row.operator("bim.hotkey", text="Extend").hotkey = "S_E" + row.operator("bim.cad_hotkey", text="Extend").hotkey = "S_E" row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_T") - row.operator("bim.hotkey", text="Mitre").hotkey = "S_T" + row.operator("bim.cad_hotkey", text="Mitre").hotkey = "S_T" row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_F") - row.operator("bim.hotkey", text="Fillet").hotkey = "S_F" + row.operator("bim.cad_hotkey", text="Fillet").hotkey = "S_F" row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") - row.label(text="Offset", icon="EVENT_O") + row.label(text="", icon="EVENT_O") + row.operator("bim.cad_hotkey", text="Offset").hotkey = "S_O" row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") @@ -99,7 +103,8 @@ class CadTool(WorkSpaceTool): row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") - row.label(text="3-Point Arc", icon="EVENT_V") + row.label(text="", icon="EVENT_V") + row.operator("bim.cad_hotkey", text="3-Point Arc").hotkey = "S_V" class CadHotkey(bpy.types.Operator): @@ -107,17 +112,28 @@ class CadHotkey(bpy.types.Operator): bl_label = "CAD Hotkey" bl_options = {"REGISTER", "UNDO"} hotkey: bpy.props.StringProperty() + resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1) + radius: bpy.props.FloatProperty(name="Radius", default=0.1) + distance: bpy.props.FloatProperty(name="Distance", default=0.1) def execute(self, context): - self.props = context.scene.BIMModelProperties - self.has_ifc_class = True - try: - self.has_ifc_class = bool(self.props.ifc_class) - except: - pass getattr(self, f"hotkey_{self.hotkey}")() return {"FINISHED"} + def draw(self, context): + if self.hotkey == "S_V": + if not self.is_profile(): + row = self.layout.row() + row.prop(self, "resolution") + elif self.hotkey == "S_F": + row = self.layout.row() + row.prop(self, "resolution") + row = self.layout.row() + row.prop(self, "radius") + elif self.hotkey == "S_O": + row = self.layout.row() + row.prop(self, "distance") + def hotkey_S_C(self): if self.is_profile(): bpy.ops.bim.add_ifccircle() @@ -127,6 +143,12 @@ class CadHotkey(bpy.types.Operator): def hotkey_S_E(self): bpy.ops.bim.cad_trim_extend() + def hotkey_S_F(self): + bpy.ops.bim.cad_fillet(resolution=self.resolution, radius=self.radius) + + def hotkey_S_O(self): + bpy.ops.bim.cad_offset(distance=self.distance) + def hotkey_S_Q(self): bpy.ops.bim.edit_extrusion_profile() @@ -137,7 +159,7 @@ class CadHotkey(bpy.types.Operator): if self.is_profile(): bpy.ops.bim.set_arc_index() else: - bpy.ops.bim.cad_arc_from_3_points() + bpy.ops.bim.cad_arc_from_3_points(resolution=self.resolution) def is_profile(self): obj = bpy.context.active_object