# SPDX-License-Identifier: GPL-2.0-or-later bl_info = { "name": "Offset Edges", "author": "Hidesato Ikeya, Veezen fix 2.8 (temporary)", #i tried edit newest version, but got some errors, works only on 0,2,6 "version": (0, 2, 6), "blender": (2, 80, 0), "location": "VIEW3D > Edge menu(CTRL-E) > Offset Edges", "description": "Offset Edges", "warning": "", "doc_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Modeling/offset_edges", "tracker_url": "", "category": "Mesh", } import math from math import sin, cos, pi, copysign, radians import bpy from bpy_extras import view3d_utils import bmesh from mathutils import Vector from time import perf_counter X_UP = Vector((1.0, .0, .0)) Y_UP = Vector((.0, 1.0, .0)) Z_UP = Vector((.0, .0, 1.0)) ZERO_VEC = Vector((.0, .0, .0)) ANGLE_90 = pi / 2 ANGLE_180 = pi ANGLE_360 = 2 * pi def calc_loop_normal(verts, fallback=Z_UP): # Calculate normal from verts using Newell's method. normal = ZERO_VEC.copy() if verts[0] is verts[-1]: # Perfect loop range_verts = range(1, len(verts)) else: # Half loop range_verts = range(0, len(verts)) for i in range_verts: v1co, v2co = verts[i-1].co, verts[i].co normal.x += (v1co.y - v2co.y) * (v1co.z + v2co.z) normal.y += (v1co.z - v2co.z) * (v1co.x + v2co.x) normal.z += (v1co.x - v2co.x) * (v1co.y + v2co.y) if normal != ZERO_VEC: normal.normalize() else: normal = fallback return normal def collect_edges(bm): set_edges_orig = set() for e in bm.edges: if e.select: co_faces_selected = 0 for f in e.link_faces: if f.select: co_faces_selected += 1 if co_faces_selected == 2: break else: set_edges_orig.add(e) if not set_edges_orig: return None return set_edges_orig def collect_loops(set_edges_orig): set_edges_copy = set_edges_orig.copy() loops = [] # [v, e, v, e, ... , e, v] while set_edges_copy: edge_start = set_edges_copy.pop() v_left, v_right = edge_start.verts lp = [v_left, edge_start, v_right] reverse = False while True: edge = None for e in v_right.link_edges: if e in set_edges_copy: if edge: # Overlap detected. return None edge = e set_edges_copy.remove(e) if edge: v_right = edge.other_vert(v_right) lp.extend((edge, v_right)) continue else: if v_right is v_left: # Real loop. loops.append(lp) break elif reverse is False: # Right side of half loop. # Reversing the loop to operate same procedure on the left side. lp.reverse() v_right, v_left = v_left, v_right reverse = True continue else: # Half loop, completed. loops.append(lp) break return loops def get_adj_ix(ix_start, vec_edges, half_loop): # Get adjacent edge index, skipping zero length edges len_edges = len(vec_edges) if half_loop: range_right = range(ix_start, len_edges) range_left = range(ix_start-1, -1, -1) else: range_right = range(ix_start, ix_start+len_edges) range_left = range(ix_start-1, ix_start-1-len_edges, -1) ix_right = ix_left = None for i in range_right: # Right i %= len_edges if vec_edges[i] != ZERO_VEC: ix_right = i break for i in range_left: # Left i %= len_edges if vec_edges[i] != ZERO_VEC: ix_left = i break if half_loop: # If index of one side is None, assign another index. if ix_right is None: ix_right = ix_left if ix_left is None: ix_left = ix_right return ix_right, ix_left def get_adj_faces(edges): adj_faces = [] for e in edges: adj_f = None co_adj = 0 for f in e.link_faces: # Search an adjacent face. # Selected face has precedence. if not f.hide and f.normal != ZERO_VEC: adj_exist = True adj_f = f co_adj += 1 if f.select: adj_faces.append(adj_f) break else: if co_adj == 1: adj_faces.append(adj_f) else: adj_faces.append(None) return adj_faces def get_edge_rail(vert, set_edges_orig): co_edges = co_edges_selected = 0 vec_inner = None for e in vert.link_edges: if (e not in set_edges_orig and (e.select or (co_edges_selected == 0 and not e.hide))): v_other = e.other_vert(vert) vec = v_other.co - vert.co if vec != ZERO_VEC: vec_inner = vec if e.select: co_edges_selected += 1 if co_edges_selected == 2: return None else: co_edges += 1 if co_edges_selected == 1: vec_inner.normalize() return vec_inner elif co_edges == 1: # No selected edges, one unselected edge. vec_inner.normalize() return vec_inner else: return None def get_cross_rail(vec_tan, vec_edge_r, vec_edge_l, normal_r, normal_l): # Cross rail is a cross vector between normal_r and normal_l. vec_cross = normal_r.cross(normal_l) if vec_cross.dot(vec_tan) < .0: vec_cross *= -1 cos_min = min(vec_tan.dot(vec_edge_r), vec_tan.dot(-vec_edge_l)) cos = vec_tan.dot(vec_cross) if cos >= cos_min: vec_cross.normalize() return vec_cross else: return None def move_verts(width, depth, verts, directions, geom_ex): if geom_ex: geom_s = geom_ex['side'] verts_ex = [] for v in verts: for e in v.link_edges: if e in geom_s: verts_ex.append(e.other_vert(v)) break #assert len(verts) == len(verts_ex) verts = verts_ex for v, (vec_width, vec_depth) in zip(verts, directions): v.co += width * vec_width + depth * vec_depth def extrude_edges(bm, edges_orig): extruded = bmesh.ops.extrude_edge_only(bm, edges=edges_orig)['geom'] n_edges = n_faces = len(edges_orig) n_verts = len(extruded) - n_edges - n_faces geom = dict() geom['verts'] = verts = set(extruded[:n_verts]) geom['edges'] = edges = set(extruded[n_verts:n_verts + n_edges]) geom['faces'] = set(extruded[n_verts + n_edges:]) geom['side'] = set(e for v in verts for e in v.link_edges if e not in edges) return geom def clean(bm, mode, edges_orig, geom_ex=None): for f in bm.faces: f.select = False if geom_ex: for e in geom_ex['edges']: e.select = True if mode == 'offset': lis_geom = list(geom_ex['side']) + list(geom_ex['faces']) bmesh.ops.delete(bm, geom=lis_geom, context='EDGES') else: for e in edges_orig: e.select = True def collect_mirror_planes(edit_object): mirror_planes = [] eob_mat_inv = edit_object.matrix_world.inverted() for m in edit_object.modifiers: if (m.type == 'MIRROR' and m.use_mirror_merge): merge_limit = m.merge_threshold if not m.mirror_object: loc = ZERO_VEC norm_x, norm_y, norm_z = X_UP, Y_UP, Z_UP else: mirror_mat_local = eob_mat_inv @ m.mirror_object.matrix_world loc = mirror_mat_local.to_translation() norm_x, norm_y, norm_z, _ = mirror_mat_local.adjugated() norm_x = norm_x.to_3d().normalized() norm_y = norm_y.to_3d().normalized() norm_z = norm_z.to_3d().normalized() if m.use_axis[0]: mirror_planes.append((loc, norm_x, merge_limit)) if m.use_axis[1]: mirror_planes.append((loc, norm_y, merge_limit)) if m.use_axis[2]: mirror_planes.append((loc, norm_z, merge_limit)) return mirror_planes def get_vert_mirror_pairs(set_edges_orig, mirror_planes): if mirror_planes: set_edges_copy = set_edges_orig.copy() vert_mirror_pairs = dict() for e in set_edges_orig: v1, v2 = e.verts for mp in mirror_planes: p_co, p_norm, mlimit = mp v1_dist = abs(p_norm.dot(v1.co - p_co)) v2_dist = abs(p_norm.dot(v2.co - p_co)) if v1_dist <= mlimit: # v1 is on a mirror plane. vert_mirror_pairs[v1] = mp if v2_dist <= mlimit: # v2 is on a mirror plane. vert_mirror_pairs[v2] = mp if v1_dist <= mlimit and v2_dist <= mlimit: # This edge is on a mirror_plane, so should not be offsetted. set_edges_copy.remove(e) return vert_mirror_pairs, set_edges_copy else: return None, set_edges_orig def get_mirror_rail(mirror_plane, vec_up): p_norm = mirror_plane[1] mirror_rail = vec_up.cross(p_norm) if mirror_rail != ZERO_VEC: mirror_rail.normalize() # Project vec_up to mirror_plane vec_up = vec_up - vec_up.project(p_norm) vec_up.normalize() return mirror_rail, vec_up else: return None, vec_up def reorder_loop(verts, edges, lp_normal, adj_faces): for i, adj_f in enumerate(adj_faces): if adj_f is None: continue v1, v2 = verts[i], verts[i+1] e = edges[i] fv = tuple(adj_f.verts) if fv[fv.index(v1)-1] is v2: # Align loop direction verts.reverse() edges.reverse() adj_faces.reverse() if lp_normal.dot(adj_f.normal) < .0: lp_normal *= -1 break else: # All elements in adj_faces are None for v in verts: if v.normal != ZERO_VEC: if lp_normal.dot(v.normal) < .0: verts.reverse() edges.reverse() lp_normal *= -1 break return verts, edges, lp_normal, adj_faces def get_directions(lp, vec_upward, normal_fallback, vert_mirror_pairs, **options): opt_follow_face = options['follow_face'] opt_edge_rail = options['edge_rail'] opt_er_only_end = options['edge_rail_only_end'] opt_threshold = options['threshold'] verts, edges = lp[::2], lp[1::2] set_edges = set(edges) lp_normal = calc_loop_normal(verts, fallback=normal_fallback) ##### Loop order might be changed below. if lp_normal.dot(vec_upward) < .0: # Make this loop's normal towards vec_upward. verts.reverse() edges.reverse() lp_normal *= -1 if opt_follow_face: adj_faces = get_adj_faces(edges) verts, edges, lp_normal, adj_faces = \ reorder_loop(verts, edges, lp_normal, adj_faces) else: adj_faces = (None, ) * len(edges) ##### Loop order might be changed above. vec_edges = tuple((e.other_vert(v).co - v.co).normalized() for v, e in zip(verts, edges)) if verts[0] is verts[-1]: # Real loop. Popping last vertex. verts.pop() HALF_LOOP = False else: # Half loop HALF_LOOP = True len_verts = len(verts) directions = [] for i in range(len_verts): vert = verts[i] ix_right, ix_left = i, i-1 VERT_END = False if HALF_LOOP: if i == 0: # First vert ix_left = ix_right VERT_END = True elif i == len_verts - 1: # Last vert ix_right = ix_left VERT_END = True edge_right, edge_left = vec_edges[ix_right], vec_edges[ix_left] face_right, face_left = adj_faces[ix_right], adj_faces[ix_left] norm_right = face_right.normal if face_right else lp_normal norm_left = face_left.normal if face_left else lp_normal if norm_right.angle(norm_left) > opt_threshold: # Two faces are not flat. two_normals = True else: two_normals = False tan_right = edge_right.cross(norm_right).normalized() tan_left = edge_left.cross(norm_left).normalized() tan_avr = (tan_right + tan_left).normalized() norm_avr = (norm_right + norm_left).normalized() rail = None if two_normals or opt_edge_rail: # Get edge rail. # edge rail is a vector of an inner edge. if two_normals or (not opt_er_only_end) or VERT_END: rail = get_edge_rail(vert, set_edges) if vert_mirror_pairs and VERT_END: if vert in vert_mirror_pairs: rail, norm_avr = \ get_mirror_rail(vert_mirror_pairs[vert], norm_avr) if (not rail) and two_normals: # Get cross rail. # Cross rail is a cross vector between norm_right and norm_left. rail = get_cross_rail( tan_avr, edge_right, edge_left, norm_right, norm_left) if rail: dot = tan_avr.dot(rail) if dot > .0: tan_avr = rail elif dot < .0: tan_avr = -rail vec_plane = norm_avr.cross(tan_avr) e_dot_p_r = edge_right.dot(vec_plane) e_dot_p_l = edge_left.dot(vec_plane) if e_dot_p_r or e_dot_p_l: if e_dot_p_r > e_dot_p_l: vec_edge, e_dot_p = edge_right, e_dot_p_r else: vec_edge, e_dot_p = edge_left, e_dot_p_l vec_tan = (tan_avr - tan_avr.project(vec_edge)).normalized() # Make vec_tan perpendicular to vec_edge vec_up = vec_tan.cross(vec_edge) vec_width = vec_tan - (vec_tan.dot(vec_plane) / e_dot_p) * vec_edge vec_depth = vec_up - (vec_up.dot(vec_plane) / e_dot_p) * vec_edge else: vec_width = tan_avr vec_depth = norm_avr directions.append((vec_width, vec_depth)) return verts, directions def use_cashes(self, context): self.caches_valid = True angle_presets = {'0°': 0, '15°': radians(15), '30°': radians(30), '45°': radians(45), '60°': radians(60), '75°': radians(75), '90°': radians(90),} def assign_angle_presets(self, context): use_cashes(self, context) self.angle = angle_presets[self.angle_presets] class OffsetEdges(bpy.types.Operator): """Offset Edges""" bl_idname = "mesh.offset_edges" bl_label = "Offset Edges" bl_options = {'REGISTER', 'UNDO'} geometry_mode: bpy.props.EnumProperty( items=[('offset', "Offset", "Offset edges"), ('extrude', "Extrude", "Extrude edges"), ('move', "Move", "Move selected edges")], name="Geometory mode", default='offset', update=use_cashes) width: bpy.props.FloatProperty( name="Width", default=.2, precision=4, step=1, update=use_cashes) flip_width: bpy.props.BoolProperty( name="Flip Width", default=False, description="Flip width direction", update=use_cashes) depth: bpy.props.FloatProperty( name="Depth", default=.0, precision=4, step=1, update=use_cashes) flip_depth: bpy.props.BoolProperty( name="Flip Depth", default=False, description="Flip depth direction", update=use_cashes) depth_mode: bpy.props.EnumProperty( items=[('angle', "Angle", "Angle"), ('depth', "Depth", "Depth")], name="Depth mode", default='angle', update=use_cashes) angle: bpy.props.FloatProperty( name="Angle", default=0, precision=3, step=.1, min=-2*pi, max=2*pi, subtype='ANGLE', description="Angle", update=use_cashes) flip_angle: bpy.props.BoolProperty( name="Flip Angle", default=False, description="Flip Angle", update=use_cashes) follow_face: bpy.props.BoolProperty( name="Follow Face", default=False, description="Offset along faces around") mirror_modifier: bpy.props.BoolProperty( name="Mirror Modifier", default=False, description="Take into account of Mirror modifier") edge_rail: bpy.props.BoolProperty( name="Edge Rail", default=False, description="Align vertices along inner edges") edge_rail_only_end: bpy.props.BoolProperty( name="Edge Rail Only End", default=False, description="Apply edge rail to end verts only") threshold: bpy.props.FloatProperty( name="Flat Face Threshold", default=radians(0.05), precision=5, step=1.0e-4, subtype='ANGLE', description="If difference of angle between two adjacent faces is " "below this value, those faces are regarded as flat", options={'HIDDEN'}) caches_valid: bpy.props.BoolProperty( name="Caches Valid", default=False, options={'HIDDEN'}) angle_presets: bpy.props.EnumProperty( items=[('0°', "0°", "0°"), ('15°', "15°", "15°"), ('30°', "30°", "30°"), ('45°', "45°", "45°"), ('60°', "60°", "60°"), ('75°', "75°", "75°"), ('90°', "90°", "90°"), ], name="Angle Presets", default='0°', update=assign_angle_presets) _cache_offset_infos = None _cache_edges_orig_ixs = None @classmethod def poll(self, context): return context.mode == 'EDIT_MESH' def draw(self, context): layout = self.layout layout.prop(self, 'geometry_mode', text="") #layout.prop(self, 'geometry_mode', expand=True) row = layout.row(align=True) row.prop(self, 'width') row.prop(self, 'flip_width', icon='ARROW_LEFTRIGHT', icon_only=True) layout.prop(self, 'depth_mode', expand=True) if self.depth_mode == 'angle': d_mode = 'angle' flip = 'flip_angle' else: d_mode = 'depth' flip = 'flip_depth' row = layout.row(align=True) row.prop(self, d_mode) row.prop(self, flip, icon='ARROW_LEFTRIGHT', icon_only=True) if self.depth_mode == 'angle': layout.prop(self, 'angle_presets', text="Presets", expand=True) layout.separator() layout.prop(self, 'follow_face') row = layout.row() row.prop(self, 'edge_rail') if self.edge_rail: row.prop(self, 'edge_rail_only_end', text="OnlyEnd", toggle=True) layout.prop(self, 'mirror_modifier') #layout.operator('mesh.offset_edges', text='Repeat') if self.follow_face: layout.separator() layout.prop(self, 'threshold', text='Threshold') def get_offset_infos(self, bm, edit_object): if self.caches_valid and self._cache_offset_infos is not None: # Return None, indicating to use cache. return None, None time = perf_counter() set_edges_orig = collect_edges(bm) if set_edges_orig is None: self.report({'WARNING'}, "No edges selected.") return False, False if self.mirror_modifier: mirror_planes = collect_mirror_planes(edit_object) vert_mirror_pairs, set_edges = \ get_vert_mirror_pairs(set_edges_orig, mirror_planes) if set_edges: set_edges_orig = set_edges else: #self.report({'WARNING'}, # "All selected edges are on mirror planes.") vert_mirror_pairs = None else: vert_mirror_pairs = None loops = collect_loops(set_edges_orig) if loops is None: self.report({'WARNING'}, "Overlap detected. Select non-overlap edge loops") return False, False vec_upward = (X_UP + Y_UP + Z_UP).normalized() # vec_upward is used to unify loop normals when follow_face is off. normal_fallback = Z_UP #normal_fallback = Vector(context.region_data.view_matrix[2][:3]) # normal_fallback is used when loop normal cannot be calculated. follow_face = self.follow_face edge_rail = self.edge_rail er_only_end = self.edge_rail_only_end threshold = self.threshold offset_infos = [] for lp in loops: verts, directions = get_directions( lp, vec_upward, normal_fallback, vert_mirror_pairs, follow_face=follow_face, edge_rail=edge_rail, edge_rail_only_end=er_only_end, threshold=threshold) if verts: offset_infos.append((verts, directions)) # Saving caches. self._cache_offset_infos = _cache_offset_infos = [] for verts, directions in offset_infos: v_ixs = tuple(v.index for v in verts) _cache_offset_infos.append((v_ixs, directions)) self._cache_edges_orig_ixs = tuple(e.index for e in set_edges_orig) print("Preparing OffsetEdges: ", perf_counter() - time) return offset_infos, set_edges_orig def do_offset_and_free(self, bm, me, offset_infos=None, set_edges_orig=None): # If offset_infos is None, use caches. # Makes caches invalid after offset. #time = perf_counter() if offset_infos is None: # using cache bmverts = tuple(bm.verts) bmedges = tuple(bm.edges) edges_orig = [bmedges[ix] for ix in self._cache_edges_orig_ixs] verts_directions = [] for ix_vs, directions in self._cache_offset_infos: verts = tuple(bmverts[ix] for ix in ix_vs) verts_directions.append((verts, directions)) else: verts_directions = offset_infos edges_orig = list(set_edges_orig) if self.depth_mode == 'angle': w = self.width if not self.flip_width else -self.width angle = self.angle if not self.flip_angle else -self.angle width = w * cos(angle) depth = w * sin(angle) else: width = self.width if not self.flip_width else -self.width depth = self.depth if not self.flip_depth else -self.depth # Extrude if self.geometry_mode == 'move': geom_ex = None else: geom_ex = extrude_edges(bm, edges_orig) for verts, directions in verts_directions: move_verts(width, depth, verts, directions, geom_ex) clean(bm, self.geometry_mode, edges_orig, geom_ex) bpy.ops.object.mode_set(mode="OBJECT") bm.to_mesh(me) bpy.ops.object.mode_set(mode="EDIT") bm.free() self.caches_valid = False # Make caches invalid. #print("OffsetEdges offset: ", perf_counter() - time) def execute(self, context): # In edit mode edit_object = context.edit_object bpy.ops.object.mode_set(mode="OBJECT") me = edit_object.data bm = bmesh.new() bm.from_mesh(me) offset_infos, edges_orig = self.get_offset_infos(bm, edit_object) if offset_infos is False: bpy.ops.object.mode_set(mode="EDIT") return {'CANCELLED'} self.do_offset_and_free(bm, me, offset_infos, edges_orig) return {'FINISHED'} def restore_original_and_free(self, context): self.caches_valid = False # Make caches invalid. context.area.header_text_set() me = context.edit_object.data bpy.ops.object.mode_set(mode="OBJECT") self._bm_orig.to_mesh(me) bpy.ops.object.mode_set(mode="EDIT") self._bm_orig.free() context.area.header_text_set() def invoke(self, context, event): # In edit mode edit_object = context.edit_object me = edit_object.data bpy.ops.object.mode_set(mode="OBJECT") for p in me.polygons: if p.select: self.follow_face = True break self.caches_valid = False bpy.ops.object.mode_set(mode="EDIT") return self.execute(context) class OffsetEdgesMenu(bpy.types.Menu): bl_idname = "VIEW3D_MT_edit_mesh_offset_edges" bl_label = "Offset Edges" def draw(self, context): layout = self.layout layout.operator_context = 'INVOKE_DEFAULT' off = layout.operator('mesh.offset_edges', text='Offset') off.geometry_mode = 'offset' ext = layout.operator('mesh.offset_edges', text='Extrude') ext.geometry_mode = 'extrude' mov = layout.operator('mesh.offset_edges', text='Move') mov.geometry_mode = 'move' classes = ( OffsetEdges, OffsetEdgesMenu, ) def draw_item(self, context): self.layout.menu("VIEW3D_MT_edit_mesh_offset_edges") def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.VIEW3D_MT_edit_mesh_edges.prepend(draw_item) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) bpy.types.VIEW3D_MT_edit_mesh_edges.remove(draw_item) if __name__ == '__main__': register()