2025-07-01
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
'''
|
||||
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')
|
||||
Reference in New Issue
Block a user