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

457 lines
20 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 math
import time
from ..rftool import RFTool
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
from ..rfwidgets.rfwidget_brushfalloff import RFWidget_BrushFalloff_Factory
from ...addon_common.common.maths import (
Vec, Vec2D,
Point, Point2D,
Direction,
Color,
closest_point_segment,
)
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
from ...addon_common.common.fsm import FSM
from ...addon_common.common.profiler import profiler
from ...addon_common.common.utils import iter_pairs, delay_exec
from ...config.options import options, themes
class Relax(RFTool):
name = 'Relax'
description = 'Relax the vertex positions to smooth out topology'
icon = 'relax-icon.png'
help = 'relax.md'
shortcut = 'relax tool'
quick_shortcut = 'relax quick'
statusbar = '{{brush}} Relax\t{{brush alt}} Relax selection\t{{brush radius}} Brush size\t{{brush strength}} Brush strength\t{{brush falloff}} Brush falloff'
ui_config = 'relax_options.html'
RFWidget_Default = RFWidget_Default_Factory.create()
RFWidget_BrushFalloff = RFWidget_BrushFalloff_Factory.create(
'Relax brush',
BoundInt('''options['relax radius']''', min_value=1),
BoundFloat('''options['relax falloff']''', min_value=0.00, max_value=100.0),
BoundFloat('''options['relax strength']''', min_value=0.01, max_value=1.0),
fill_color=themes['relax'],
)
@RFTool.on_init
def init(self):
self.rfwidgets = {
'default': self.RFWidget_Default(self),
'brushstroke': self.RFWidget_BrushFalloff(self),
}
self.rfwidget = None
def reset_algorithm_options(self):
options.reset(keys=[
'relax steps',
'relax force multiplier',
'relax edge length',
'relax face radius',
'relax face sides',
'relax face angles',
'relax correct flipped faces',
'relax straight edges',
])
def disable_all_options(self):
for key in [
'relax edge length',
'relax face radius',
'relax face sides',
'relax face angles',
'relax correct flipped faces',
'relax straight edges',
]:
options[key] = False
def reset_current_brush(self):
options.reset(keys={'relax radius', 'relax falloff', 'relax strength'})
self.document.body.getElementById(f'relax-current-radius').dirty(cause='copied preset to current brush')
self.document.body.getElementById(f'relax-current-strength').dirty(cause='copied preset to current brush')
self.document.body.getElementById(f'relax-current-falloff').dirty(cause='copied preset to current brush')
def update_preset_name(self, n):
name = options[f'relax preset {n} name']
self.document.body.getElementById(f'relax-preset-{n}-summary').innerText = f'Preset: {name}'
def copy_current_to_preset(self, n):
options[f'relax preset {n} radius'] = options['relax radius']
options[f'relax preset {n} strength'] = options['relax strength']
options[f'relax preset {n} falloff'] = options['relax falloff']
self.document.body.getElementById(f'relax-preset-{n}-radius').dirty(cause='copied current brush to preset')
self.document.body.getElementById(f'relax-preset-{n}-strength').dirty(cause='copied current brush to preset')
self.document.body.getElementById(f'relax-preset-{n}-falloff').dirty(cause='copied current brush to preset')
def copy_preset_to_current(self, n):
options['relax radius'] = options[f'relax preset {n} radius']
options['relax strength'] = options[f'relax preset {n} strength']
options['relax falloff'] = options[f'relax preset {n} falloff']
self.document.body.getElementById(f'relax-current-radius').dirty(cause='copied preset to current brush')
self.document.body.getElementById(f'relax-current-strength').dirty(cause='copied preset to current brush')
self.document.body.getElementById(f'relax-current-falloff').dirty(cause='copied preset to current brush')
@RFTool.on_ui_setup
def ui(self):
self.update_preset_name(1)
self.update_preset_name(2)
self.update_preset_name(3)
self.update_preset_name(4)
@RFTool.on_reset
def reset(self):
self.sel_only = False
@FSM.on_state('main')
def main(self):
if self.actions.using_onlymods(['brush', 'brush alt', 'brush radius', 'brush falloff', 'brush strength']):
self.set_widget('brushstroke')
else:
self.set_widget('default')
if self.rfcontext.actions.pressed(['brush', 'brush alt'], unpress=False):
self.sel_only = self.rfcontext.actions.using('brush alt')
self.rfcontext.actions.unpress()
self.rfcontext.undo_push('relax')
return 'relax'
if self.rfcontext.actions.pressed('pie menu alt0', unpress=False):
def callback(option):
if option is None: return
self.copy_preset_to_current(option)
self.rfcontext.show_pie_menu([
(f'Preset: {options["relax preset 1 name"]}', 1),
(f'Preset: {options["relax preset 2 name"]}', 2),
(f'Preset: {options["relax preset 3 name"]}', 3),
(f'Preset: {options["relax preset 4 name"]}', 4),
], callback)
return
# if self.rfcontext.actions.pressed('select single'):
# self.rfcontext.undo_push('select')
# self.rfcontext.deselect_all()
# return 'select'
# if self.rfcontext.actions.pressed('select single add'):
# face,_ = self.rfcontext.accel_nearest2D_face(max_dist=10)
# if not face: return
# if face.select:
# self.mousedown = self.rfcontext.actions.mouse
# return 'selectadd/deselect'
# return 'select'
# if self.rfcontext.actions.pressed({'select smart', 'select smart add'}, unpress=False):
# if self.rfcontext.actions.pressed('select smart'):
# self.rfcontext.deselect_all()
# self.rfcontext.actions.unpress()
# edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
# if not edge: return
# faces = set()
# walk = {edge}
# touched = set()
# while walk:
# edge = walk.pop()
# if edge in touched: continue
# touched.add(edge)
# nfaces = set(f for f in edge.link_faces if f not in faces and len(f.edges) == 4)
# walk |= {f.opposite_edge(edge) for f in nfaces}
# faces |= nfaces
# self.rfcontext.select(faces, only=False)
# return
# @FSM.on_state('selectadd/deselect')
# def selectadd_deselect(self):
# if not self.rfcontext.actions.using(['select single','select single add']):
# self.rfcontext.undo_push('deselect')
# face,_ = self.rfcontext.accel_nearest2D_face()
# if face and face.select: self.rfcontext.deselect(face)
# return 'main'
# delta = Vec2D(self.rfcontext.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.rfcontext.actions.using(['select single','select single add']):
# return 'main'
# bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=10)
# if not bmf or bmf.select: return
# self.rfcontext.select(bmf, supparts=False, only=False)
@FSM.on_state('relax', 'enter')
def relax_enter(self):
self._time = time.time()
opt_mask_boundary = options['relax mask boundary']
opt_mask_symmetry = options['relax mask symmetry']
opt_mask_occluded = options['relax mask occluded']
opt_mask_selected = options['relax mask selected']
opt_steps = options['relax steps']
opt_edge_length = options['relax edge length']
opt_face_radius = options['relax face radius']
opt_face_sides = options['relax face sides']
opt_face_angles = options['relax face angles']
opt_correct_flipped = options['relax correct flipped faces']
opt_straight_edges = options['relax straight edges']
opt_mult = options['relax force multiplier']
is_visible = self.rfcontext.gen_is_visible(occlusion_test_override=True)
is_bmvert_hidden = lambda bmv: not is_visible(bmv.co, bmv.normal)
self._bmverts = []
self._boundary = []
for bmv in self.rfcontext.iter_verts():
if self.sel_only and not bmv.select: continue
if opt_mask_boundary == 'exclude' and bmv.is_on_boundary(): continue
if opt_mask_symmetry == 'exclude' and bmv.is_on_symmetry_plane(): continue
if opt_mask_occluded == 'exclude' and is_bmvert_hidden(bmv): continue
if opt_mask_selected == 'exclude' and bmv.select: continue
if opt_mask_selected == 'only' and not bmv.select: continue
self._bmverts.append(bmv)
print(f'Relax {len(self._bmverts)} bmverts')
if opt_mask_boundary == 'slide':
# find all boundary edges
self._boundary = [(bme.verts[0].co, bme.verts[1].co) for bme in self.rfcontext.iter_edges() if not bme.is_manifold]
# print(f'Relaxing max of {len(self._bmverts)} bmverts')
self._timer = self.actions.start_timer(120)
self.rfcontext.split_target_visualization(verts=self._bmverts)
@FSM.on_state('relax', 'exit')
def relax_exit(self):
self.rfcontext.update_verts_faces(self._bmverts)
self.rfcontext.clear_split_target_visualization()
self._timer.done()
@FSM.on_state('relax')
def relax(self):
if self.rfcontext.actions.released(['brush','brush alt']):
return 'main'
if self.rfcontext.actions.pressed('cancel'):
self.rfcontext.undo_cancel()
self.actions.unuse('brush', ignoremods=True, ignoremulti=True)
self.actions.unuse('brush alt', ignoremods=True, ignoremulti=True)
return 'main'
@RFTool.on_new_frame
@FSM.onlyinstate('relax')
def relax_doit(self):
st = time.time()
hit_pos = self.rfcontext.actions.hit_pos
if not hit_pos: return
# collect data for smoothing
radius = self.rfwidgets['brushstroke'].get_scaled_radius()
nearest = self.rfcontext.nearest_verts_point(hit_pos, radius, bmverts=self._bmverts)
verts,edges,faces,vert_strength = set(),set(),set(),dict()
for bmv,d in nearest:
verts.add(bmv)
edges.update(bmv.link_edges)
faces.update(bmv.link_faces)
vert_strength[bmv] = self.rfwidgets['brushstroke'].get_strength_dist(d) / radius
# self.rfcontext.select(verts)
if not verts or not edges: return
vert_strength = vert_strength or {}
# gather options
opt_mask_boundary = options['relax mask boundary']
opt_mask_symmetry = options['relax mask symmetry']
# opt_mask_occluded = options['relax mask hidden']
# opt_mask_selected = options['relax mask selected']
opt_steps = options['relax steps']
opt_edge_length = options['relax edge length']
opt_face_radius = options['relax face radius']
opt_face_sides = options['relax face sides']
opt_face_angles = options['relax face angles']
opt_correct_flipped = options['relax correct flipped faces']
opt_straight_edges = options['relax straight edges']
opt_mult = options['relax force multiplier']
cur_time = time.time()
time_delta = cur_time - self._time
self._time = cur_time
strength = (5.0 / opt_steps) * self.rfwidgets['brushstroke'].strength * time_delta
radius = self.rfwidgets['brushstroke'].get_scaled_radius()
# capture all verts involved in relaxing
chk_verts = set(verts)
chk_verts.update(self.rfcontext.get_edges_verts(edges))
chk_verts.update(self.rfcontext.get_faces_verts(faces))
chk_edges = self.rfcontext.get_verts_link_edges(chk_verts)
chk_faces = self.rfcontext.get_verts_link_faces(chk_verts)
displace = {}
def reset_forces():
nonlocal displace
displace.clear()
def add_force(bmv, f):
nonlocal displace, verts, vert_strength
if bmv not in verts or bmv not in vert_strength: return
cur = displace[bmv] if bmv in displace else Vec((0,0,0))
displace[bmv] = cur + f
def relax_2d():
pass
def relax_3d():
reset_forces()
# compute average edge length
avg_edge_len = sum(bme.calc_length() for bme in edges) / len(edges)
# push edges closer to average edge length
if opt_edge_length:
for bme in chk_edges:
if bme not in edges: continue
bmv0,bmv1 = bme.verts
vec = bme.vector()
edge_len = vec.length
f = vec * (0.1 * (avg_edge_len - edge_len) * strength) #/ edge_len
add_force(bmv0, -f)
add_force(bmv1, +f)
# push verts if neighboring faces seem flipped (still WiP!)
if opt_correct_flipped:
bmf_flipped = { bmf for bmf in chk_faces if bmf.is_flipped() }
for bmf in bmf_flipped:
# find a non-flipped neighboring face
for bme in bmf.edges:
bmfs = set(bme.link_faces)
bmfs.discard(bmf)
if len(bmfs) != 1: continue
bmf_other = next(iter(bmfs))
if bmf_other not in chk_faces: continue
if bmf_other in bmf_flipped: continue
# pull edge toward bmf_other center
bmf_other_center = bmf_other.center()
bme_center = bme.calc_center()
vec = bmf_other_center - bme_center
bmv0,bmv1 = bme.verts
add_force(bmv0, vec * strength * 5)
add_force(bmv1, vec * strength * 5)
# push verts to straighten edges (still WiP!)
if opt_straight_edges:
for bmv in chk_verts:
if bmv.is_boundary: continue
bmes = bmv.link_edges
#if len(bmes) != 4: continue
center = Point.average(bme.other_vert(bmv).co for bme in bmes)
add_force(bmv, (center - bmv.co) * 0.1)
# attempt to "square" up the faces
for bmf in chk_faces:
if bmf not in faces: continue
bmvs = bmf.verts
cnt = len(bmvs)
ctr = Point.average(bmv.co for bmv in bmvs)
rels = [bmv.co - ctr for bmv in bmvs]
# push verts toward average dist from verts to face center
if opt_face_radius:
avg_rel_len = sum(rel.length for rel in rels) / cnt
for rel, bmv in zip(rels, bmvs):
rel_len = rel.length
f = rel * ((avg_rel_len - rel_len) * strength * 2) #/ rel_len
add_force(bmv, f)
# push verts toward equal edge lengths
if opt_face_sides:
avg_face_edge_len = sum(bme.length for bme in bmf.edges) / cnt
for bme in bmf.edges:
bmv0, bmv1 = bme.verts
vec = bme.vector()
edge_len = vec.length
f = vec * ((avg_face_edge_len - edge_len) * strength) / edge_len
add_force(bmv0, f * -0.5)
add_force(bmv1, f * 0.5)
# push verts toward equal spread
if opt_face_angles:
avg_angle = 2.0 * math.pi / cnt
for i0 in range(cnt):
i1 = (i0 + 1) % cnt
rel0,bmv0 = rels[i0],bmvs[i0]
rel1,bmv1 = rels[i1],bmvs[i1]
if rel0.length < 0.00001 or rel1.length < 0.00001: continue
vec = bmv1.co - bmv0.co
vec_len = vec.length
fvec0 = rel0.cross(vec).cross(rel0).normalize()
fvec1 = rel1.cross(rel1.cross(vec)).normalize()
angle = rel0.angle(rel1)
f_mag = (0.05 * (avg_angle - angle) * strength) / cnt #/ vec_len
add_force(bmv0, fvec0 * -f_mag)
add_force(bmv1, fvec1 * -f_mag)
# perform smoothing
for step in range(opt_steps):
if options['relax algorithm'] == '3D':
relax_3d()
elif options['relax algorithm'] == '2D':
relax_2d()
if len(displace) <= 1: continue
# compute max displacement length
displace_max = max(displace[bmv].length * (opt_mult * vert_strength[bmv]) for bmv in displace)
if displace_max > radius * 0.125:
# limit the displace_max
mult = radius * 0.125 / displace_max
else:
mult = 1.0
# update
for bmv in displace:
co = bmv.co + displace[bmv] * (opt_mult * vert_strength[bmv]) * mult
if opt_mask_symmetry == 'maintain' and bmv.is_on_symmetry_plane():
snap_to_symmetry = self.rfcontext.symmetry_planes_for_point(bmv.co)
co = self.rfcontext.snap_to_symmetry(co, snap_to_symmetry)
if opt_mask_boundary == 'slide' and bmv.is_on_boundary():
p, d = None, None
for (v0, v1) in self._boundary:
p_ = closest_point_segment(co, v0, v1)
d_ = (p_ - co).length
if p is None or d_ < d: p, d = p_, d_
if p is not None:
co = p
bmv.co = co
self.rfcontext.snap_vert(bmv)
self.rfcontext.update_verts_faces(displace)
# print(f'relaxed {len(verts)} ({len(chk_verts)}) in {time.time() - st} with {strength}')
self.rfcontext.dirty()
tag_redraw_all('Relax new frame')