Files
blender-portable-repo/scripts/addons/RetopoFlow/retopoflow/rftool_loops/loops.py
T
2026-03-17 14:30:01 -06:00

437 lines
18 KiB
Python

'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
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 3 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, see <http://www.gnu.org/licenses/>.
'''
import bpy
import gpu
import math
import random
import itertools
from ..rftool import RFTool
from ..rfmesh.rfmesh import RFVert, RFEdge, RFFace
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
from ...addon_common.common import gpustate
from ...addon_common.common.maths import (
Point, Vec, Normal, Direction,
Point2D, Vec2D, Direction2D,
clamp, Color, Plane,
)
from ...addon_common.common.debug import dprint
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.decorators import timed_call
from ...addon_common.common.drawing import CC_2D_LINE_STRIP, CC_2D_LINE_LOOP, CC_DRAW, DrawCallbacks
from ...addon_common.common.fsm import FSM
from ...addon_common.common.globals import Globals
from ...addon_common.common.profiler import profiler
from ...addon_common.common.timerhandler import StopwatchHandler
from ...addon_common.common.utils import iter_pairs
from .loops_insert import Loops_Insert
from ...config.options import options, themes
class Loops(RFTool, Loops_Insert):
name = 'Loops'
description = 'Edge loops creation, shifting, and deletion'
icon = 'loops-icon.png'
help = 'loops.md'
shortcut = 'loops tool'
quick_shortcut = 'loops quick'
statusbar = '{{insert}} Insert edge loop\t{{smooth edge flow}} Smooth edge flow'
RFWidget_Default = RFWidget_Default_Factory.create()
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
RFWidget_Crosshair = RFWidget_Default_Factory.create(cursor='CROSSHAIR')
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
@RFTool.on_init
def init(self):
self.rfwidgets = {
'default': self.RFWidget_Default(self),
'cut': self.RFWidget_Crosshair(self),
'hover': self.RFWidget_Move(self),
'hidden': self.RFWidget_Hidden(self),
}
self.rfwidget = None
def _fsm_in_main(self):
# needed so main actions using Ctrl (ex: undo, redo, save) can still work
return self._fsm.state in {'main', 'insert'}
@RFTool.on_reset
def reset(self):
self.nearest_edge = None
self.set_next_state()
self.hovering_edge = None
self.hovering_sel_edge = None
self.update_hover()
self.quickswitch = False
def filter_edge_selection(self, bme, no_verts_select=True, ratio=0.33):
if bme.select:
# edge is already selected
return True
bmv0, bmv1 = bme.verts
s0, s1 = bmv0.select, bmv1.select
if s0 and s1:
# both verts are selected, so return True
return True
if not s0 and not s1:
if no_verts_select:
# neither are selected, so return True by default
return True
else:
# return True if none are selected; otherwise return False
return self.rfcontext.none_selected()
# if mouse is at least a ratio of the distance toward unselected vert, return True
if s1: bmv0, bmv1 = bmv1, bmv0
p = self.actions.mouse
p0 = self.rfcontext.Point_to_Point2D(bmv0.co)
p1 = self.rfcontext.Point_to_Point2D(bmv1.co)
v01 = p1 - p0
l01 = v01.length
d01 = v01 / l01
dot = d01.dot(p - p0)
return dot / l01 > ratio
@RFTool.on_events('mouse move', 'target change', 'view change')
@RFTool.not_while_navigating
@FSM.onlyinstate('main')
def update_hover(self):
self.hovering_edge, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'])
self.hovering_sel_edge, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'], selected_only=True)
@FSM.on_state('main')
def main(self):
# if self.actions.mousemove: return # ignore mouse moves
if self.hovering_edge and not self.hovering_edge.is_valid: self.hovering_edge = None
if self.hovering_sel_edge and not self.hovering_sel_edge.is_valid: self.hovering_sel_edge = None
if self.actions.using_onlymods('insert'):
return 'insert'
if self.hovering_edge:
self.set_widget('hover')
else:
self.set_widget('default')
if self.handle_inactive_passthrough(): return
if self.actions.using('action'):
self.hovering_edge, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'])
if self.hovering_edge:
#print(f'hovering edge {self.actions.using("action")} {self.hovering_edge} {self.hovering_sel_edge}')
#print('acting!')
self.rfcontext.undo_push('slide edge loop/strip')
if not self.hovering_sel_edge:
self.rfcontext.select_edge_loop(self.hovering_edge, supparts=False)
self.set_next_state()
self.prep_edit()
if not self.edit_ok:
self.rfcontext.undo_cancel()
return
self.move_done_pressed = None
self.move_done_released = 'action'
self.move_cancelled = 'cancel'
return 'slide'
if self.actions.pressed('slide'):
''' slide edge loop or strip between neighboring edges '''
self.rfcontext.undo_push('slide edge loop/strip')
self.prep_edit()
if not self.edit_ok:
self.rfcontext.undo_cancel()
return
self.move_done_pressed = 'confirm'
self.move_done_released = None
self.move_cancelled = 'cancel'
return 'slide'
if self.actions.pressed({'select path add'}):
return self.rfcontext.select_path(
{'edge'},
fn_filter_bmelem=self.filter_edge_selection,
kwargs_select={'supparts': False},
)
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
sel_only = self.actions.pressed('select paint')
self.actions.unpress()
return self.rfcontext.setup_smart_selection_painting(
{'edge'},
use_select_tool=True,
selecting=not sel_only,
deselect_all=sel_only,
# fn_filter_bmelem=self.filter_edge_selection,
kwargs_select={'supparts': False},
kwargs_deselect={'subparts': False},
)
if self.actions.pressed({'select smart', 'select smart add'}, unpress=False):
sel_only = self.actions.pressed('select smart')
self.actions.unpress()
if not sel_only and not self.hovering_edge: return
self.rfcontext.undo_push('select smart')
if sel_only: self.rfcontext.deselect_all()
if self.hovering_edge:
self.rfcontext.select_edge_loop(self.hovering_edge, supparts=False, only=sel_only)
return
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
sel_only = self.actions.pressed('select single')
self.actions.unpress()
if not sel_only and not self.hovering_edge: return
self.rfcontext.undo_push('select')
if sel_only: self.rfcontext.deselect_all()
if not self.hovering_edge: return
if self.hovering_edge.select: self.rfcontext.deselect(self.hovering_edge)
else: self.rfcontext.select(self.hovering_edge, supparts=False, only=sel_only)
return
@FSM.on_state('selectadd/deselect')
def selectadd_deselect(self):
if not self.actions.using(['select single','select single add']):
self.rfcontext.undo_push('deselect')
edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
if edge and edge.select: self.rfcontext.deselect(edge)
return 'main'
delta = Vec2D(self.actions.mouse - self.mousedown)
if delta.length > self.drawing.scale(5):
self.rfcontext.undo_push('select add')
return 'select'
@FSM.on_state('select')
def select(self):
if not self.actions.using(['select single','select single add']):
return 'main'
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
if not bme or bme.select: return
self.rfcontext.select(bme, supparts=False, only=False)
def prep_edit(self):
self.edit_ok = False
Point_to_Point2D = self.rfcontext.Point_to_Point2D
sel_verts = self.rfcontext.get_selected_verts()
sel_edges = self.rfcontext.get_selected_edges()
if len(sel_verts) == 0 or len(sel_edges) == 0: return
if True:
# use line perpendicular to average edge direction
vis_verts = self.rfcontext.visible_verts(verts=sel_verts)
vis_edges = self.rfcontext.visible_edges(verts=vis_verts, edges=sel_edges)
edge_d = None
for edge in vis_edges:
v0, v1 = edge.verts
p0, p1 = Point_to_Point2D(v0.co), Point_to_Point2D(v1.co)
if not p0 or not p1: continue
v = Direction2D(p1 - p0)
if not edge_d:
edge_d = v
else:
if edge_d.dot(v) < 0: edge_d -= v
else: edge_d += v
if not edge_d: return
pts = [Point_to_Point2D(v.co) for v in vis_verts]
pts = [pt for pt in pts if pt]
if not pts: return
self.slide_point = Point2D.average(pts)
self.slide_direction = Direction2D((-edge_d.y, edge_d.x))
else:
# try to fit plane to data
plane_o = Point.average(bmv.co for bmv in sel_verts)
plane_n = Vec((0,0,0))
for edge in sel_edges:
v0, v1 = edge.verts
en, ev = Normal(v0.normal + v1.normal), (v0.co - v1.co)
perp = Direction(en.cross(ev))
if plane_n.dot(perp) < 0: perp = -perp
plane_n += perp
plane_n = Normal(plane_n)
o2d, on2d = Point_to_Point2D(plane_o), Point_to_Point2D(plane_o + plane_n)
if not o2d or not on2d: return
self.slide_direction = Direction2D(on2d - o2d)
self.slide_point = o2d
self.slide_vector = self.slide_direction * self.drawing.scale(40)
# slide_data holds info on left,right vectors for moving
slide_data = {}
working = set(sel_edges)
while working:
crawl_set = { (next(iter(working)), 1) }
current_strip = set()
while crawl_set:
bme,side = crawl_set.pop()
v0,v1 = bme.verts
co0,co1 = v0.co,v1.co
if bme not in working: continue
working.discard(bme)
# add verts of edge if not already added
for bmv in bme.verts:
if bmv in slide_data: continue
slide_data[bmv] = { 'left':[], 'orig':bmv.co, 'right':[], 'other':set(), 'flip': False }
# process edge
bmfl,bmfr = bme.get_left_right_link_faces()
bmefln = bmfl.neighbor_edges(bme) if bmfl else None
bmefrn = bmfr.neighbor_edges(bme) if bmfr else None
bmel0,bmel1 = bmefln or (None, None)
bmer0,bmer1 = bmefrn or (None, None)
bmvl0 = bmel0.other_vert(v0) if bmel0 else None
bmvl1 = bmel1.other_vert(v1) if bmel1 else None
bmvr0 = bmer1.other_vert(v0) if bmer1 else None
bmvr1 = bmer0.other_vert(v1) if bmer0 else None
col0 = bmvl0.co if bmvl0 else None
col1 = bmvl1.co if bmvl1 else None
cor0 = bmvr0.co if bmvr0 else None
cor1 = bmvr1.co if bmvr1 else None
if col0 and cor0: pass # found left and right sides!
elif col0: cor0 = co0 + (co0 - col0) # cor0 is missing, guess
elif cor0: col0 = co0 + (co0 - cor0) # col0 is missing, guess
else: continue # both col0 and cor0 are missing
# instead of continuing, use edge perpendicular and length to guess at col0 and cor0
if col1 and cor1: pass # found left and right sides!
elif col1: cor1 = co1 + (co1 - col1) # cor1 is missing, guess
elif cor1: col1 = co1 + (co1 - cor1) # col1 is missing, guess
else: continue # both col1 and cor1 are missing
# instead of continuing, use edge perpendicular and length to guess at col1 and cor1
current_strip |= { v0, v1 }
if side < 0:
# edge direction is reversed, so swap left and right sides
col0,cor0 = cor0,col0
col1,cor1 = cor1,col1
if bmvl0 not in slide_data[v0]['other']:
slide_data[v0]['left'].append(col0-co0)
slide_data[v0]['other'].add(bmvl0)
if bmvr0 not in slide_data[v0]['other']:
slide_data[v0]['right'].append(co0-cor0)
slide_data[v0]['other'].add(bmvr0)
if bmvl1 not in slide_data[v1]['other']:
slide_data[v1]['left'].append(col1-co1)
slide_data[v1]['other'].add(bmvl1)
if bmvr1 not in slide_data[v1]['other']:
slide_data[v1]['right'].append(co1-cor1)
slide_data[v1]['other'].add(bmvr1)
# crawl to neighboring edges in strip/loop
bmes_next = { bme.get_next_edge_in_strip(bmv) for bmv in bme.verts }
for bme_next in bmes_next:
if bme_next not in working: continue # note: None will skipped, too
v0_next,v1_next = bme_next.verts
side_next = side * (1 if (v1 == v0_next or v0 == v1_next) else -1)
crawl_set.add((bme_next, side_next))
# check if we need to flip the strip
def fn(bmv, side):
if not self.rfcontext.is_visible(bmv.co, occlusion_test_override=True): return
p0 = Point_to_Point2D(bmv.co)
if not p0: return
m = 1 if side == 'left' else -1
for v in slide_data[bmv][side]:
p1 = Point_to_Point2D(bmv.co + v * m)
if p1: yield (p1 - p0)
l = [v for bmv in current_strip for v in fn(bmv, 'left')]
r = [v for bmv in current_strip for v in fn(bmv, 'right')]
wrong = [v for v in l if self.slide_direction.dot(v) < 0] + [v for v in r if self.slide_direction.dot(v) > 0]
if len(wrong) > (len(l) + len(r)) / 2:
for bmv in current_strip:
slide_data[bmv]['flip'] = not slide_data[bmv]['flip']
# nearest_vert,_ = self.rfcontext.nearest2D_vert(verts=sel_verts)
# if not nearest_vert: return
# if nearest_vert not in slide_data: return
self.slide_data = slide_data
self.mouse_down = self.actions.mouse
self.percent_start = 0.0
self.edit_ok = True
@FSM.on_state('slide', 'enter')
def slide_enter(self):
self.rfcontext.split_target_visualization_selected()
self.rfcontext.set_accel_defer(True)
self.set_widget('hidden' if options['hide cursor on tweak'] else 'hover')
tag_redraw_all('entering slide')
self.rfcontext.fast_update_timer.enable(True)
@FSM.on_state('slide')
# @profiler.function
def slide(self):
released = self.actions.released
if self.move_done_pressed and self.actions.pressed(self.move_done_pressed):
return 'main'
if self.move_done_released and self.actions.released(self.move_done_released, ignoremods=True):
return 'main'
if self.move_cancelled and self.actions.pressed('cancel'):
self.rfcontext.undo_cancel()
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
return 'main'
if not self.actions.mousemove_stop: return
# # only update loop on timer events and when mouse has moved
# if not self.actions.timer: return
# if self.actions.mouse_prev == self.actions.mouse: return
mouse_delta = self.actions.mouse - self.mouse_down
a,b = self.slide_vector, mouse_delta.project(self.slide_direction)
percent = clamp(self.percent_start + a.dot(b) / a.dot(a), -1, 1)
for bmv in self.slide_data.keys():
mp = percent if not self.slide_data[bmv]['flip'] else -percent
vecs = self.slide_data[bmv]['left' if mp > 0 else 'right']
if len(vecs) == 0: continue
co = self.slide_data[bmv]['orig']
delta = sum((v * mp for v in vecs), Vec((0,0,0))) / len(vecs)
bmv.co = co + delta
self.rfcontext.snap_vert(bmv)
self.rfcontext.dirty()
@FSM.on_state('slide', 'exit')
def slide_exit(self):
self.rfcontext.fast_update_timer.enable(False)
self.rfcontext.set_accel_defer(False)
self.rfcontext.clear_split_target_visualization()
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('slide')
def draw_postview_slide(self):
gpustate.blend('ALPHA')
Globals.drawing.draw2D_line(
self.slide_point + self.slide_vector * 1000,
self.slide_point - self.slide_vector * 1000,
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
width=2, stipple=[2,2],
)