''' 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 . ''' 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')