''' 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 import random import bpy import gpu from mathutils import Matrix, Vector from mathutils.geometry import intersect_point_tri_2d, intersect_point_tri_2d from ..rftool import RFTool from ...addon_common.common.decorators import timed_call from ...addon_common.common import gpustate ################################################################################################ # following imports must happen *after* the above class, because each subclass depends on # above class to be defined from .polystrips_ops import PolyStrips_Ops from .polystrips_props import PolyStrips_Props from .polystrips_utils import ( RFTool_PolyStrips_Strip, hash_face_pair, crawl_strip, is_boundaryvert, is_boundaryedge, process_stroke_filter, process_stroke_source, process_stroke_get_next, process_stroke_get_marks, mark_info, ) from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier from ...addon_common.common.blender import tag_redraw_all from ...addon_common.common.debug import dprint from ...addon_common.common.drawing import Drawing, Cursors, DrawCallbacks from ...addon_common.common.fsm import FSM from ...addon_common.common.maths import Vec2D, Point, rotate2D, Direction2D, Point2D, RelPoint2D from ...addon_common.common.profiler import profiler from ...addon_common.common.utils import iter_pairs from ...config.options import options, themes from ..rfwidgets.rfwidget_brushstroke import RFWidget_BrushStroke_Factory from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString class PolyStrips(RFTool, PolyStrips_Props, PolyStrips_Ops): name = 'PolyStrips' description = 'Create and edit strips of quads' icon = 'polystrips-icon.png' help = 'polystrips.md' shortcut = 'polystrips tool' statusbar = '{{insert}} Insert strip of quads\t{{brush radius}} Brush size\t{{action}} Grab selection\t{{increase count}} Increase segments\t{{decrease count}} Decrease segments' ui_config = 'polystrips_options.html' RFWidget_Default = RFWidget_Default_Factory.create() RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND') RFWidget_Hidden = RFWidget_Hidden_Factory.create() RFWidget_BrushStroke = RFWidget_BrushStroke_Factory.create( 'PolyStrips stroke', BoundInt('''options['polystrips radius']''', min_value=1), outer_border_color=themes['polystrips'] ) @RFTool.on_init def init(self): self.rfwidgets = { 'default': self.RFWidget_Default(self), 'brushstroke': self.RFWidget_BrushStroke(self), 'move': self.RFWidget_Move(self), 'hidden': self.RFWidget_Hidden(self), } self.rfwidget = None @RFTool.on_reset def reset(self): self.strips = [] self.strip_pts = [] self.hovering_strips = set() self.hovering_handles = [] self.hovering_sel_face = None self.sel_cbpts = [] self.stroke_cbs = CubicBezierSpline() self.clear_count_data() @RFTool.on_target_change # @profiler.function def update_target(self, force=False): if not force and self._fsm.state in {'move handle', 'rotate', 'scale'}: return self.strips = [] self._var_cut_count.disabled = True # get selected quads bmquads = set(bmf for bmf in self.rfcontext.get_selected_faces() if len(bmf.verts) == 4) if not bmquads: return # find junctions at corners junctions = set() for bmf in bmquads: # skip if in middle of a selection if not any(is_boundaryvert(bmv, bmquads) for bmv in bmf.verts): continue # skip if in middle of possible strip edge0,edge1,edge2,edge3 = [is_boundaryedge(bme, bmquads) for bme in bmf.edges] if (edge0 or edge2) and not (edge1 or edge3): continue if (edge1 or edge3) and not (edge0 or edge2): continue junctions.add(bmf) # find junctions that might be in middle of strip but are ends to other strips boundaries = set((bme,bmf) for bmf in bmquads for bme in bmf.edges if is_boundaryedge(bme, bmquads)) while boundaries: bme,bmf = boundaries.pop() for bme_ in bmf.neighbor_edges(bme): strip = crawl_strip(bmf, bme_, bmquads, junctions) if strip is None: continue junctions.add(strip[-1]) # find strips between junctions touched = set() for bmf0 in junctions: bme0,bme1,bme2,bme3 = bmf0.edges edge0,edge1,edge2,edge3 = [is_boundaryedge(bme, bmquads) for bme in bmf0.edges] def add_strip(bme): strip = crawl_strip(bmf0, bme, bmquads, junctions) if not strip: return bmf1 = strip[-1] if len(strip) > 1 and hash_face_pair(bmf0, bmf1) not in touched: touched.add(hash_face_pair(bmf0,bmf1)) touched.add(hash_face_pair(bmf1,bmf0)) self.strips.append(RFTool_PolyStrips_Strip(strip)) if not edge0: add_strip(bme0) if not edge1: add_strip(bme1) if not edge2: add_strip(bme2) if not edge3: add_strip(bme3) if options['polystrips max strips'] and len(self.strips) > options['polystrips max strips']: self.strips = [] break self.update_strip_viz() if len(self.strips) == 1: self._var_cut_count.set(len(self.strips[0])) self._var_cut_count.disabled = False if self.rfcontext.get_last_action() != 'change segment count': self.setup_change_count() # @profiler.function def update_strip_viz(self): self.strip_pts = [[strip.curve.eval(i/10) for i in range(10+1)] for strip in self.strips] @FSM.on_state('main') def main(self): Point_to_Point2D = self.rfcontext.Point_to_Point2D mouse = self.actions.mouse if not self.actions.using('action', ignoredrag=True): # only update while not pressing action, because action includes drag, and # the artist might move mouse off selected edge before drag kicks in! self.hovering_sel_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['action dist'], selected_only=True) self.hovering_handles.clear() self.hovering_strips.clear() for strip in self.strips: for i,cbpt in enumerate(strip.curve): v = Point_to_Point2D(cbpt) if v is None: continue if (mouse - v).length > self.drawing.scale(options['select dist']): continue # do not filter out non-visible handles, because otherwise # they might not be movable if they are inside the model self.hovering_handles.append(cbpt) self.hovering_strips.add(strip) if self.actions.using_onlymods('insert'): self.set_widget('brushstroke') elif self.hovering_handles: self.set_widget('move') elif self.hovering_sel_face: self.set_widget('move') else: self.set_widget('default') if self.handle_inactive_passthrough(): return # handle edits if self.hovering_handles: if self.actions.pressed('action'): return 'move handle' if self.actions.pressed('action alt0'): return 'rotate' if self.actions.pressed('action alt1'): return 'scale' if self.hovering_sel_face: if self.actions.pressed('action', unpress=False): return 'move all' if self.actions.pressed('grab', unpress=False): return 'move all' if self.actions.pressed('increase count'): self.change_count(delta=1) return if self.actions.pressed('decrease count'): self.change_count(delta=-1) return if self.actions.pressed({'select path add'}): return self.rfcontext.select_path( {'face'}, kwargs_select={'supparts': False}, ) if self.actions.pressed({'select paint', 'select paint add'}, unpress=False): sel_only = self.actions.pressed('select paint') return self.rfcontext.setup_smart_selection_painting( {'face'}, 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 single', 'select single add'}, unpress=False): sel_only = self.actions.pressed('select single') self.actions.unpress() bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['select dist']) if not sel_only and not bmf: return self.rfcontext.undo_push('select') if sel_only: self.rfcontext.deselect_all() if not bmf: return if bmf.select: self.rfcontext.deselect(bmf, subparts=False) else: self.rfcontext.select(bmf, supparts=False, only=sel_only) return @FSM.on_state('move handle', 'can enter') def movehandle_canenter(self): return len(self.hovering_handles) > 0 @FSM.on_state('move handle', 'enter') def movehandle_enter(self): self.sel_cbpts = [] self.mod_strips = set() cbpts = list(self.hovering_handles) self.mod_strips |= self.hovering_strips for strip in self.strips: p0,p1,p2,p3 = strip.curve.points() if p0 in cbpts and p1 not in cbpts: cbpts.append(p1) self.mod_strips.add(strip) if p3 in cbpts and p2 not in cbpts: cbpts.append(p2) self.mod_strips.add(strip) for strip in self.mod_strips: strip.capture_edges() inners = [ p for strip in self.strips for p in strip.curve.points()[1:3] ] self.sel_cbpts = [(cbpt, cbpt in inners, Point(cbpt), self.rfcontext.Point_to_Point2D(cbpt)) for cbpt in cbpts] self.mousedown = self.actions.mouse self.move_done_pressed = 'confirm' self.move_done_released = 'action' self.move_cancelled = 'cancel' self.rfcontext.undo_push('manipulate bezier') self.set_widget('hidden' if options['hide cursor on tweak'] else 'move') self._timer = self.actions.start_timer(120.0) self.rfcontext.split_target_visualization(verts=self.rfcontext.get_selected_verts()) self.rfcontext.set_accel_defer(True) @FSM.on_state('move handle') def movehandle(self): if self.actions.pressed(self.move_done_pressed): return 'main' if self.actions.released(self.move_done_released): return 'main' if self.actions.pressed(self.move_cancelled): self.rfcontext.undo_cancel() self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True) return 'main' if self.actions.mousemove or not self.actions.mousemove_prev: return delta = Vec2D(self.actions.mouse - self.mousedown) up,rt,fw = self.rfcontext.Vec_up(),self.rfcontext.Vec_right(),self.rfcontext.Vec_forward() for cbpt,inner,oco,oco2D in self.sel_cbpts: nco2D = oco2D + delta if not inner: xyz,_,_,_ = self.rfcontext.raycast_sources_Point2D(nco2D) if xyz: cbpt.xyz = xyz else: ov = self.rfcontext.Point2D_to_Vec(oco2D) nr = self.rfcontext.Point2D_to_Ray(nco2D) od = self.rfcontext.Point_to_depth(oco) cbpt.xyz = nr.eval(od / ov.dot(nr.d)) for strip in self.hovering_strips: strip.update(self.rfcontext.nearest_sources_Point, self.rfcontext.raycast_sources_Point, self.rfcontext.update_face_normal) self.update_strip_viz() self.rfcontext.dirty() @FSM.on_state('move handle', 'exit') def movehandle_exit(self): self._timer.done() self.rfcontext.clear_split_target_visualization() self.rfcontext.set_accel_defer(False) self.update_target(force=True) tag_redraw_all('PolyStrips done moving handles') @FSM.on_state('rotate', 'can enter') def rotate_canenter(self): if not self.hovering_handles: return False self.sel_cbpts = [] self.mod_strips = set() Point_to_Point2D = self.rfcontext.Point_to_Point2D # find hovered inner point, the corresponding outer point and its face innerP,outerP,outerF = None,None,None for strip in self.strips: bmf0,bmf1 = strip.end_faces() p0,p1,p2,p3 = strip.curve.points() if p1 in self.hovering_handles: innerP,outerP,outerF = p1,p0,bmf0 if p2 in self.hovering_handles: innerP,outerP,outerF = p2,p3,bmf1 if not innerP or not outerP or not outerF: return False # scan through all selected strips and collect all inner points next to outerP for strip in self.strips: bmf0,bmf3 = strip.end_faces() if outerF != bmf0 and outerF != bmf3: continue p0,p1,p2,p3 = strip.curve.points() if outerF == bmf0: self.sel_cbpts.append( (p1, Point(p1), Point_to_Point2D(p1)) ) else: self.sel_cbpts.append( (p2, Point(p2), Point_to_Point2D(p2)) ) self.mod_strips.add(strip) self.rotate_about = Point_to_Point2D(outerP) if not self.rotate_about: return False @FSM.on_state('rotate', 'enter') def rotate_enter(self): for strip in self.mod_strips: strip.capture_edges() self.mousedown = self.actions.mouse self.move_done_pressed = 'confirm' self.move_done_released = 'action alt0' self.move_cancelled = 'cancel' self.rfcontext.undo_push('rotate') self.set_widget('hidden' if options['hide cursor on tweak'] else 'move') self._timer = self.actions.start_timer(120.0) self.rfcontext.split_target_visualization(verts=self.rfcontext.get_selected_verts()) self.rfcontext.set_accel_defer(True) @FSM.on_state('rotate') # @profiler.function def rotate(self): if not self.rotate_about: return 'main' if self.actions.pressed(self.move_done_pressed): return 'main' if self.actions.released(self.move_done_released): return 'main' if self.actions.pressed(self.move_cancelled): self.rfcontext.undo_cancel() self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True) return 'main' if self.actions.mousemove or not self.actions.mousemove_prev: return prev_diff = self.mousedown - self.rotate_about prev_rot = math.atan2(prev_diff.x, prev_diff.y) cur_diff = self.actions.mouse - self.rotate_about cur_rot = math.atan2(cur_diff.x, cur_diff.y) angle = prev_rot - cur_rot for cbpt,oco,oco2D in self.sel_cbpts: xy = rotate2D(oco2D, angle, origin=self.rotate_about) xyz,_,_,_ = self.rfcontext.raycast_sources_Point2D(xy) if xyz: cbpt.xyz = xyz for strip in self.mod_strips: strip.update(self.rfcontext.nearest_sources_Point, self.rfcontext.raycast_sources_Point, self.rfcontext.update_face_normal) self.update_strip_viz() self.rfcontext.dirty() @FSM.on_state('rotate', 'exit') def rotate_exit(self): self._timer.done() self.rfcontext.set_accel_defer(False) self.rfcontext.clear_split_target_visualization() self.update_target(force=True) @FSM.on_state('scale', 'can enter') # @profiler.function def scale_canenter(self): if not self.hovering_handles: return False self.mod_strips = set() Point_to_Point2D = self.rfcontext.Point_to_Point2D innerP,outerP,outerF = None,None,None for strip in self.strips: bmf0,bmf1 = strip.end_faces() p0,p1,p2,p3 = strip.curve.points() if p1 in self.hovering_handles: innerP,outerP,outerF = p1,p0,bmf0 if p2 in self.hovering_handles: innerP,outerP,outerF = p2,p3,bmf1 if not innerP or not outerP or not outerF: return False self.scale_strips = [] for strip in self.strips: bmf0,bmf1 = strip.end_faces() if bmf0 == outerF: self.scale_strips.append((strip, 1)) self.mod_strips.add(strip) if bmf1 == outerF: self.scale_strips.append((strip, 2)) self.mod_strips.add(strip) for strip in self.mod_strips: strip.capture_edges() if not self.scale_strips: return False self.scale_from = Point_to_Point2D(outerP) @FSM.on_state('scale', 'enter') def scale_enter(self): self.mousedown = self.actions.mouse self.rfcontext.undo_push('scale') self.move_done_pressed = None self.move_done_released = 'action' self.move_cancelled = 'cancel' falloff = options['polystrips scale falloff'] self.scale_bmf = {} self.scale_bmv = {} for strip,iinner in self.scale_strips: iend = 0 if iinner == 1 else 3 s0,s1 = (1,0) if iend == 0 else (0,1) l = len(strip.bmf_strip) for ibmf,bmf in enumerate(strip.bmf_strip): if bmf in self.scale_bmf: continue p = ibmf/(l-1) s = (s0 + (s1-s0) * p) ** falloff self.scale_bmf[bmf] = s for bmf in self.scale_bmf.keys(): c = bmf.center() s = self.scale_bmf[bmf] for bmv in bmf.verts: if bmv not in self.scale_bmv: self.scale_bmv[bmv] = [] self.scale_bmv[bmv] += [(c, bmv.co-c, s)] self.set_widget('hidden' if options['hide cursor on tweak'] else 'default') # None self._timer = self.actions.start_timer(120.0) self.rfcontext.split_target_visualization(verts=self.rfcontext.get_selected_verts()) self.rfcontext.set_accel_defer(True) @FSM.on_state('scale') # @profiler.function def scale(self): if self.actions.pressed(self.move_done_pressed): return 'main' if self.actions.released(self.move_done_released, ignoremods=True): return 'main' if self.actions.pressed(self.move_cancelled): self.rfcontext.undo_cancel() self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True) return 'main' if self.actions.mousemove or not self.actions.mousemove_prev: return vec0 = self.mousedown - self.scale_from vec1 = self.actions.mouse - self.scale_from scale = vec1.length / vec0.length snap2D_vert = self.rfcontext.snap2D_vert snap_vert = self.rfcontext.snap_vert for bmv in self.scale_bmv.keys(): l = self.scale_bmv[bmv] n = Vector() for c,v,sc in l: n += c + v * max(0, 1 + (scale-1) * sc) bmv.co = n / len(l) snap_vert(bmv) self.rfcontext.dirty() @FSM.on_state('scale', 'exit') def scale_exit(self): self._timer.done() self.rfcontext.set_accel_defer(False) self.rfcontext.clear_split_target_visualization() self.update_target(force=True) @FSM.on_state('move all', 'can enter') # @profiler.function def moveall_canenter(self): bmfaces = self.rfcontext.get_selected_faces() if not bmfaces: return False bmverts = set(bmv for bmf in bmfaces for bmv in bmf.verts) self.bmverts = [(bmv, self.rfcontext.Point_to_Point2D(bmv.co)) for bmv in bmverts] @FSM.on_state('move all', 'enter') def moveall_enter(self): lmb_drag = self.actions.using('action') self.actions.unpress() self.rfcontext.undo_push('move grabbed') self.moveall_opts = { 'mousedown': self.actions.mouse, 'move_done_pressed': None if lmb_drag else 'confirm', 'move_done_released': 'action' if lmb_drag else None, 'move_cancelled': 'cancel', 'timer': self.actions.start_timer(120.0), } self.rfcontext.split_target_visualization_selected() self.rfcontext.set_accel_defer(True) self.set_widget('hidden' if options['hide cursor on tweak'] else 'default') # None @FSM.on_state('move all') # @profiler.function def moveall(self): opts = self.moveall_opts if self.actions.pressed(opts['move_done_pressed']): return 'main' if self.actions.released(opts['move_done_released']): return 'main' if self.actions.pressed(opts['move_cancelled']): self.rfcontext.undo_cancel() self.actions.unuse(opts['move_done_released'], ignoremods=True, ignoremulti=True) return 'main' if self.actions.mousemove or not self.actions.mousemove_prev: return delta = Vec2D(self.actions.mouse - opts['mousedown']) set2D_vert = self.rfcontext.set2D_vert for bmv,xy in self.bmverts: if not bmv.is_valid: continue set2D_vert(bmv, xy + delta) self.rfcontext.update_verts_faces(v for v,_ in self.bmverts) self.rfcontext.dirty() #self.update() @FSM.on_state('move all', 'exit') def moveall_exit(self): self.moveall_opts['timer'].done() self.rfcontext.set_accel_defer(False) self.rfcontext.clear_split_target_visualization() self.update_target(force=True) @DrawCallbacks.on_draw('post3d') @FSM.onlyinstate({'main', 'move handle', 'rotate', 'scale'}) def draw_post3d_spline(self): if not self.strips: return strips = self.strips hov_strips = self.hovering_strips Point_to_Point2D = self.rfcontext.Point_to_Point2D def is_visible(v): return True # self.rfcontext.is_visible(v, None) def draw(alphamult, hov_alphamult, hover): nonlocal strips if not hover: hov_alphamult = alphamult size_outer = options['polystrips handle outer size'] size_inner = options['polystrips handle inner size'] border_outer = options['polystrips handle border'] border_inner = options['polystrips handle border'] gpustate.blend('ALPHA') # draw outer-inner lines pts = [Point_to_Point2D(p) for strip in strips for p in strip.curve.points()] self.rfcontext.drawing.draw2D_lines(pts, (1,1,1,0.45), width=2) # draw junction handles (outer control points of curve) faces_drawn = set() # keep track of faces, so don't draw same handles 2+ times pts_outer,pts_inner = [],[] for strip in strips: bmf0,bmf1 = strip.end_faces() p0,p1,p2,p3 = strip.curve.points() if bmf0 not in faces_drawn: if is_visible(p0): pts_outer += [Point_to_Point2D(p0)] faces_drawn.add(bmf0) if bmf1 not in faces_drawn: if is_visible(p3): pts_outer += [Point_to_Point2D(p3)] faces_drawn.add(bmf1) if is_visible(p1): pts_inner += [Point_to_Point2D(p1)] if is_visible(p2): pts_inner += [Point_to_Point2D(p2)] pts_outer = [p for p in pts_outer if p] pts_inner = [p for p in pts_inner if p] self.rfcontext.drawing.draw2D_points(pts_outer, (1.00,1.00,1.00,1.0), radius=size_outer, border=border_outer, borderColor=(0.00,0.00,0.00,0.5)) self.rfcontext.drawing.draw2D_points(pts_inner, (0.25,0.25,0.25,0.8), radius=size_inner, border=border_inner, borderColor=(0.75,0.75,0.75,0.4)) gpustate.blend('ALPHA') gpustate.depth_test('NONE') gpustate.depth_mask(False) draw(1.0, 1.0, False) gpustate.depth_mask(True) gpustate.depth_test('LESS_EQUAL') @DrawCallbacks.on_draw('post2d') @FSM.onlyinstate({'main', 'move handle', 'rotate', 'scale'}) def draw_post2d(self): self.rfcontext.drawing.set_font_size(12) Point_to_Point2D = self.rfcontext.Point_to_Point2D text_draw2D = self.rfcontext.drawing.text_draw2D for strip in self.strips: strip = [f for f in strip if f.is_valid] c = len(strip) vs = [Point_to_Point2D(f.center()) for f in strip] vs = [Vec2D(v) for v in vs if v] if not vs: continue ctr = sum(vs, Vec2D((0,0))) / len(vs) text_draw2D('%d' % c, ctr+Vec2D((2,14)), color=(1,1,0,1), dropshadow=(0,0,0,0.5))