-
meta-androcto authoredmeta-androcto authored
mesh_offset_edges.py 26.17 KiB
# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
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": "",
"wiki_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 precedance.
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()