2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,22 @@
'''
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/>.
'''
__all__ = ['contours']
@@ -0,0 +1,757 @@
'''
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 os
import math
import time
import random
from mathutils import Matrix
from ..rftool import RFTool
from ..rfwidget import RFWidget
from ...addon_common.common import gpustate
from ...addon_common.common.globals import Globals
from ...addon_common.common.debug import dprint
from ...addon_common.common.fsm import FSM
from ...addon_common.common.drawing import Drawing, DrawCallbacks
from ...addon_common.common.maths import Point, Normal, Vec2D, Plane, Vec
from ...addon_common.common.profiler import profiler
from ...addon_common.common.timerhandler import CallGovernor, StopwatchHandler
from ...addon_common.common.utils import iter_pairs
from ...addon_common.common import blender_preferences as bprefs
from ...addon_common.common.blender import tag_redraw_all
from ...config.options import options
from .contours_ops import Contours_Ops
from .contours_props import Contours_Props
from .contours_utils import (
find_loops,
find_strings,
loop_plane, loop_radius,
Contours_Loop,
Contours_Utils,
)
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
from ..rfwidgets.rfwidget_linecut import RFWidget_LineCut_Factory
class Contours(RFTool, Contours_Ops, Contours_Props, Contours_Utils):
name = 'Contours'
description = 'Retopologize cylindrical forms, like arms and legs'
icon = 'contours-icon.png'
help = 'contours.md'
shortcut = 'contours tool'
statusbar = '{{insert}} Insert contour\t{{increase count}} Increase segments\t{{decrease count}} Decrease segments\t{{fill}} Bridge'
ui_config = 'contours_options.html'
RFWidget_Default = RFWidget_Default_Factory.create()
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
RFWidget_LineCut = RFWidget_LineCut_Factory.create('Contours line cut')
@RFTool.on_init
def init(self):
self.rfwidgets = {
'default': self.RFWidget_Default(self),
'cut': self.RFWidget_LineCut(self),
'hover': self.RFWidget_Move(self),
}
self.clear_widget()
@RFTool.on_reset
def reset(self):
self.show_cut = False
self.show_arrows = False
self.pts = []
self.cut_pts = []
self.connected = False
self.cuts = []
self.crawl_viz = [] # for debugging
self.hovering_sel_edge = None
self.ui_initial_count = None
@RFTool.on_target_change
#@FSM.onlyinstate('main')
def update_target(self):
self.sel_edges = set(self.rfcontext.get_selected_edges())
#sel_faces = self.rfcontext.get_selected_faces()
# disable initial count input box if anything is selected
if not self.ui_initial_count:
self.ui_initial_count = self.document.body.getElementById('contours-initial-count')
if self.ui_initial_count:
self.ui_initial_count.disabled = bool(self.sel_edges)
# find verts along selected loops and strings
sel_loops = find_loops(self.sel_edges)
sel_strings = find_strings(self.sel_edges)
# filter out any loops or strings that are in the middle of a selected patch
def in_middle(bmvs, is_loop):
return any(len(bmv0.shared_edge(bmv1).link_faces) > 1 for bmv0,bmv1 in iter_pairs(bmvs, is_loop))
sel_loops = [loop for loop in sel_loops if not in_middle(loop, True)]
sel_strings = [string for string in sel_strings if not in_middle(string, False)]
# filter out long loops that wrap around patches, sharing edges with other strings
bmes = {bmv0.shared_edge(bmv1) for string in sel_strings for bmv0,bmv1 in iter_pairs(string,False)}
sel_loops = [loop for loop in sel_loops if not any(bmv0.shared_edge(bmv1) in bmes for bmv0,bmv1 in iter_pairs(loop,True))]
mirror_mod = self.rfcontext.rftarget.mirror_mod
symmetry_threshold = mirror_mod.symmetry_threshold
def get_string_length(string):
nonlocal mirror_mod, symmetry_threshold
c = len(string)
if c == 0: return 0
touches_mirror = False
(x0,y0,z0),(x1,y1,z1) = string[0].co,string[-1].co
if mirror_mod.x:
if abs(x0) < symmetry_threshold or abs(x1) < symmetry_threshold:
c = (c - 1) * 2
touches_mirror = True
if mirror_mod.y:
if abs(y0) < symmetry_threshold or abs(y1) < symmetry_threshold:
c = (c - 1) * 2
touches_mirror = True
if mirror_mod.z:
if abs(z0) < symmetry_threshold or abs(z1) < symmetry_threshold:
c = (c - 1) * 2
touches_mirror = True
if not touches_mirror: c -= 1
return c
self.loops_data = [{
'loop': loop,
'plane': loop_plane(loop),
'count': len(loop),
'radius': loop_radius(loop),
'cl': Contours_Loop(loop, True),
} for loop in sel_loops]
self.strings_data = [{
'string': string,
'plane': loop_plane(string),
'count': get_string_length(string),
'cl': Contours_Loop(string, False),
} for string in sel_strings]
self.sel_loops = [Contours_Loop(loop, True) for loop in sel_loops]
self._var_cut_count.disabled = True
if len(self.loops_data) == 1 and len(self.strings_data) == 0:
self._var_cut_count_value = self.loops_data[0]['count']
self._var_cut_count.disabled = any(len(e.link_edges)!=2 for e in self.loops_data[0]['loop'])
if len(self.strings_data) == 1 and len(self.loops_data) == 0:
self._var_cut_count_value = self.strings_data[0]['count']
self._var_cut_count.disabled = False
@FSM.on_state('main')
def main(self):
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_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'], selected_only=True)
if self.actions.using_onlymods('insert'):
self.set_widget('cut')
elif self.hovering_sel_edge:
self.set_widget('hover')
else:
self.set_widget('default')
if self.handle_inactive_passthrough(): return
if self.actions.pressed('grab'):
''' grab for translations '''
self.move_done_pressed = 'confirm'
self.move_done_released = None
return 'grab'
if self.hovering_sel_edge:
if self.actions.pressed('action'):
# return self.action_setup()
self.move_done_pressed = None
self.move_done_released = 'action'
return 'grab'
if self.rfcontext.actions.pressed('rotate plane'):
''' rotation of loops (NOT strips) about plane normal '''
return 'rotate plane'
if self.rfcontext.actions.pressed('rotate screen'):
''' screen-space rotation of loops about plane origin '''
return 'rotate screen'
if self.rfcontext.actions.pressed('fill'):
self.fill()
return
if self.rfcontext.actions.pressed({'increase count', 'decrease count'}, unpress=False):
delta = 1 if self.rfcontext.actions.pressed('increase count') else -1
self.rfcontext.undo_push('change segment count', repeatable=True)
self.change_count(delta=delta)
return
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(
{'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 path add'}):
return self.rfcontext.select_path(
{'edge'},
fn_filter_bmelem=self.filter_edge_selection,
kwargs_select={'supparts': False},
)
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
self.sel_only = self.actions.pressed('select single')
self.actions.unpress()
self.select_single()
return
if self.rfcontext.actions.pressed({'select smart', 'select smart add'}, unpress=False):
self.select_single.cancel()
sel_only = self.rfcontext.actions.pressed('select smart')
self.rfcontext.actions.unpress()
self.rfcontext.undo_push('select smart')
edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
if not edge:
if sel_only: self.rfcontext.deselect_all()
return
self.rfcontext.select_edge_loop(edge, only=sel_only, supparts=False)
return
@StopwatchHandler.delayed(time_delay=0.1)
def select_single(self):
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['select dist'])
if not bme and not self.sel_only: return
self.rfcontext.undo_push('select')
if self.sel_only: self.rfcontext.deselect_all()
if not bme: return
if bme.select: self.rfcontext.deselect(bme, subparts=False)
else: self.rfcontext.select(bme, supparts=False, only=self.sel_only)
self.rfcontext.dirty(selectionOnly=True)
@FSM.on_state('rotate plane', 'can enter')
def rotateplane_can_enter(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
if not sel_loops:
if self.strings_data:
self.rfcontext.alert_user('Can only plane-rotate complete loops that do not cross the symmetry plane')
else:
self.rfcontext.alert_user('Could not find valid loops to plane-rotate')
return False
self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
self.move_cuts = []
for cloop in self.move_cloops:
xy = self.rfcontext.Point_to_Point2D(cloop.plane.o)
ray = self.rfcontext.Point2D_to_Ray(xy)
crawl = self.rfcontext.plane_intersection_crawl(ray, cloop.plane, walk_to_plane=True)
if not crawl:
# dprint('could not crawl around sources for loop')
pass
self.move_cuts += [None]
continue
crawl_pts = [c for _,c,_ in crawl]
connected = cloop.connected # XXX why was `crawl[0][0] is not None` here?
crawl_pts,connected = self.rfcontext.clip_pointloop(crawl_pts, connected)
if not crawl_pts or connected != cloop.connected:
# dprint('could not clip loop to symmetry')
pass
self.move_cuts += [None]
continue
cl_cut = Contours_Loop(crawl_pts, connected)
cl_cut.align_to(cloop)
self.move_cuts += [cl_cut]
if not any(self.move_cuts):
self.rfcontext.alert_user('Could not find valid loops to plane-rotate')
# dprint('Found no loops to shift')
pass
return False
@FSM.on_state('rotate plane', 'enter')
def rotateplane_enter(self):
self.rot_axis = Vec((0,0,0))
self.rot_origin = Point.average(cut.get_origin() for cut in self.move_cuts if cut)
self.shift_about = self.rfcontext.Point_to_Point2D(self.rot_origin)
for cut in self.move_cuts:
if not cut: continue
a = cut.get_normal()
o = cut.get_origin()
if self.rot_axis.dot(a) < 0: a = -a
self.rot_axis += a
self.rot_axis.normalize()
p0 = next(iter(cut.get_origin() for cut in self.move_cuts if cut))
p1 = p0 + self.rot_axis * 0.001
self.rot_axis2D = (self.rfcontext.Point_to_Point2D(p1) - self.rfcontext.Point_to_Point2D(p0))
self.rot_axis2D.normalize()
self.rot_perp2D = Vec2D((self.rot_axis2D.y, -self.rot_axis2D.x))
# print(self.rot_axis, self.rot_axis2D, self.rot_perp2D)
self.rfcontext.undo_push('rotate plane contours')
self.mousedown = self.rfcontext.actions.mouse
self._timer = self.actions.start_timer(120.0)
self.rfcontext.split_target_visualization(verts=[v for vs in self.move_verts for v in vs])
self.rfcontext.set_accel_defer(True)
@FSM.on_state('rotate plane')
# @profiler.function
def rotateplane_main(self):
if self.rfcontext.actions.pressed('confirm'):
return 'main'
if self.rfcontext.actions.pressed('cancel'):
self.rfcontext.undo_cancel()
return 'main'
if self.rfcontext.actions.pressed('rotate screen'):
self.rfcontext.undo_cancel()
return 'rotate screen'
if not self.actions.mousemove_stop: return
# # only update cut on timer events and when mouse has moved
# if not self.rfcontext.actions.timer: return
delta = Vec2D(self.rfcontext.actions.mouse - self.mousedown)
shift_offset = self.rfcontext.drawing.unscale(self.rot_perp2D.dot(delta)) / 1000
up_dir = self.rfcontext.Vec_up()
raycast,project = self.rfcontext.raycast_sources_Point2D,self.rfcontext.Point_to_Point2D
for i_cloop in range(len(self.move_cloops)):
cloop = self.move_cloops[i_cloop]
cl_cut = self.move_cuts[i_cloop]
if not cl_cut: continue
shift_dir = 1 if cl_cut.get_normal().dot(self.rot_axis) > 0 else -1
verts = self.move_verts[i_cloop]
dists = self.move_dists[i_cloop]
proj_dists = self.move_proj_dists[i_cloop]
circumference = self.move_circumferences[i_cloop]
lc = cl_cut.circumference
shft = (cl_cut.offset + shift_offset * shift_dir * lc) % lc
ndists = [shft] + [0.999 * lc * (d/circumference) for d in dists]
i,dist = 0,ndists[0]
l = len(ndists)-1 if cloop.connected else len(ndists)
for c0,c1 in cl_cut.iter_pts(repeat=True):
d = (c1-c0).length
while dist - d <= 0:
# create new vert between c0 and c1
p = c0 + (c1 - c0) * (dist / d) + (cloop.plane.n * proj_dists[i])
p,n,_,_ = self.rfcontext.nearest_sources_Point(p)
verts[i].co = p
verts[i].normal = n
i += 1
if i == l: break
dist += ndists[i]
dist -= d
if i == l: break
self.rfcontext.update_verts_faces(verts)
self.rfcontext.dirty()
@FSM.on_state('rotate plane', 'exit')
def rotateplane_exit(self):
self._timer.done()
self.rfcontext.clear_split_target_visualization()
self.rfcontext.set_accel_defer(False)
tag_redraw_all('Contours finish rotate')
def action_setup(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
sel_strings = find_strings(sel_edges, min_length=2)
if not sel_loops or sel_strings: return
# prefer to move loops over strings
if sel_loops: self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
else: self.move_cloops = [Contours_Loop(string, False) for string in sel_strings]
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
self.move_orig_origins = [Point(p) for p in self.move_origins]
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
#self.grab_along = self.rfcontext.Point_to_Point2D(sum(self.move_origins, Vec((0,0,0))) / len(self.move_origins))
#self.rotate_start = math.atan2(self.rotate_about.y - self.mousedown.y, self.rotate_about.x - self.mousedown.x)
self.mousedown = self.actions.mouse
self.move_prevmouse = None
return self.rfcontext.setup_action()
def action_callback(self, val):
pass
@FSM.on_state('grab', 'can enter')
def grab_can_enter(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
sel_strings = find_strings(sel_edges, min_length=2)
return bool(sel_loops or sel_strings)
@FSM.on_state('grab', 'enter')
def grab_enter(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
sel_strings = find_strings(sel_edges, min_length=2)
# prefer to move loops over strings
if sel_loops: self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
else: self.move_cloops = [Contours_Loop(string, False) for string in sel_strings]
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
self.move_orig_origins = [Point(p) for p in self.move_origins]
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
self.rfcontext.undo_push('grab contours')
#self.grab_along = self.rfcontext.Point_to_Point2D(sum(self.move_origins, Vec((0,0,0))) / len(self.move_origins))
#self.rotate_start = math.atan2(self.rotate_about.y - self.mousedown.y, self.rotate_about.x - self.mousedown.x)
self.grab_opts = {
'mousedown': self.actions.mouse,
'timer': self.actions.start_timer(120.0),
}
self.rfcontext.split_target_visualization(verts=[v for vs in self.move_verts for v in vs])
self.rfcontext.set_accel_defer(True)
@FSM.on_state('grab')
# @profiler.function
def grab(self):
opts = self.grab_opts
if self.rfcontext.actions.pressed(self.move_done_pressed):
return 'main'
if self.rfcontext.actions.released(self.move_done_released, ignoredrag=True):
return 'main'
if self.rfcontext.actions.pressed('cancel'):
self.rfcontext.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
self.rfcontext.undo_cancel()
return 'main'
if not self.actions.mousemove_stop: return
# # only update cut on timer events and when mouse has moved
# if not self.rfcontext.actions.timer: return
delta = Vec2D(self.actions.mouse - opts['mousedown'])
# print(f'contours.grab: {delta}')
# self.crawl_viz = []
raycast,project = self.rfcontext.raycast_sources_Point2D,self.rfcontext.Point_to_Point2D
for i_cloop in range(len(self.move_cloops)):
cloop = self.move_cloops[i_cloop]
verts = self.move_verts[i_cloop]
pts = self.move_pts[i_cloop]
dists = self.move_dists[i_cloop]
origin = self.move_origins[i_cloop]
proj_dists = self.move_proj_dists[i_cloop]
circumference = self.move_circumferences[i_cloop]
depth = self.rfcontext.Point_to_depth(origin)
if depth is None: continue
origin2D_new = self.rfcontext.Point_to_Point2D(origin) + delta
origin_new = self.rfcontext.Point2D_to_Point(origin2D_new, depth)
plane_new = Plane(origin_new, cloop.plane.n)
ray_new = self.rfcontext.Point2D_to_Ray(origin2D_new)
crawl = self.rfcontext.plane_intersection_crawl(ray_new, plane_new, walk_to_plane=True)
if not crawl: continue
crawl_pts = [c for _,c,_ in crawl]
# self.crawl_viz += [crawl_pts]
connected = crawl[0][0] is not None
crawl_pts,nconnected = self.rfcontext.clip_pointloop(crawl_pts, connected)
connected = nconnected
if not crawl_pts or connected != cloop.connected: continue
cl_cut = Contours_Loop(crawl_pts, connected)
cl_cut.align_to(cloop)
lc = cl_cut.circumference
ndists = [cl_cut.offset] + [0.999 * lc * (d/circumference) for d in dists]
i,dist = 0,ndists[0]
l = len(ndists)-1 if cloop.connected else len(ndists)
for c0,c1 in cl_cut.iter_pts(repeat=True):
d = max(0.000001, (c1-c0).length)
while dist - d <= 0:
# create new vert between c0 and c1
p = c0 + (c1 - c0) * (dist / d) + (cloop.plane.n * proj_dists[i])
p,_,_,_ = self.rfcontext.nearest_sources_Point(p)
verts[i].co = p
i += 1
if i == l: break
dist += ndists[i]
dist -= d
if i == l: break
self.rfcontext.update_verts_faces(verts)
self.rfcontext.dirty()
@FSM.on_state('grab', 'exit')
def grab_exit(self):
self.grab_opts['timer'].done()
self.rfcontext.set_accel_defer(False)
self.rfcontext.clear_split_target_visualization()
tag_redraw_all('Contours finish grab')
@FSM.on_state('rotate screen', 'can enter')
def rotatescreen_can_enter(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
sel_strings = find_strings(sel_edges, min_length=2)
return sel_loops or sel_strings
@FSM.on_state('rotate screen', 'enter')
def rotatescreen_enter(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
sel_strings = find_strings(sel_edges, min_length=2)
# prefer to move loops over strings
if sel_loops: self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
else: self.move_cloops = [Contours_Loop(string, False) for string in sel_strings]
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
self.rfcontext.undo_push('rotate screen contours')
self.mousedown = self.rfcontext.actions.mouse
self.rotate_about = self.rfcontext.Point_to_Point2D(sum(self.move_origins, Vec((0,0,0))) / len(self.move_origins))
self.rotate_start = math.atan2(self.rotate_about.y - self.mousedown.y, self.rotate_about.x - self.mousedown.x)
self._timer = self.actions.start_timer(120.0)
self.rfcontext.split_target_visualization(verts=[v for vs in self.move_verts for v in vs])
self.rfcontext.set_accel_defer(True)
@FSM.on_state('rotate screen')
# @profiler.function
def rotatescreen_main(self):
if self.rfcontext.actions.pressed('confirm'):
return 'main'
if self.rfcontext.actions.pressed('cancel'):
self.rfcontext.undo_cancel()
return 'main'
if self.rfcontext.actions.pressed('rotate plane'):
self.rfcontext.undo_cancel()
return 'rotate plane'
if not self.actions.mousemove_stop: return
# # only update cut on timer events and when mouse has moved
# if not self.rfcontext.actions.timer: return
delta = Vec2D(self.rfcontext.actions.mouse - self.rotate_about)
rotate = (math.atan2(delta.y, delta.x) - self.rotate_start + math.pi) % (math.pi * 2)
raycast,project = self.rfcontext.raycast_sources_Point2D,self.rfcontext.Point_to_Point2D
for i_cloop in range(len(self.move_cloops)):
cloop = self.move_cloops[i_cloop]
verts = self.move_verts[i_cloop]
pts = self.move_pts[i_cloop]
dists = self.move_dists[i_cloop]
origin = self.move_origins[i_cloop]
proj_dists = self.move_proj_dists[i_cloop]
circumference = self.move_circumferences[i_cloop]
origin2D = self.rfcontext.Point_to_Point2D(origin)
ray = self.rfcontext.Point_to_Ray(origin)
rmat = Matrix.Rotation(rotate, 4, -ray.d)
normal = rmat @ cloop.plane.n
plane = Plane(cloop.plane.o, normal)
ray = self.rfcontext.Point2D_to_Ray(origin2D)
crawl = self.rfcontext.plane_intersection_crawl(ray, plane, walk_to_plane=True)
if not crawl: continue
crawl_pts = [c for _,c,_ in crawl]
connected = crawl[0][0] is not None
crawl_pts,connected = self.rfcontext.clip_pointloop(crawl_pts, connected)
if not crawl_pts or connected != cloop.connected: continue
cl_cut = Contours_Loop(crawl_pts, connected)
cl_cut.align_to(cloop)
lc = cl_cut.circumference
ndists = [cl_cut.offset] + [0.999 * lc * (d/circumference) for d in dists]
i,dist = 0,ndists[0]
l = len(ndists)-1 if cloop.connected else len(ndists)
for c0,c1 in cl_cut.iter_pts(repeat=True):
d = (c1-c0).length
d = max(0.00000001, d)
while dist - d <= 0:
# create new vert between c0 and c1
p = c0 + (c1 - c0) * (dist / d) + (cloop.plane.n * proj_dists[i])
p,_,_,_ = self.rfcontext.nearest_sources_Point(p)
verts[i].co = p
i += 1
if i == l: break
dist += ndists[i]
dist -= d
if i == l: break
self.rfcontext.update_verts_faces(verts)
self.rfcontext.dirty()
@FSM.on_state('rotate screen', 'exit')
def rotatescreen_exit(self):
self._timer.done()
self.rfcontext.clear_split_target_visualization()
self.rfcontext.set_accel_defer(False)
tag_redraw_all('Contours finish rotate')
@RFWidget.on_action('Contours line cut')
def new_line(self):
xy0,xy1 = self.rfwidgets['cut'].line2D
if not xy0 or not xy1: return
if (xy1-xy0).length < 0.001: return
xy01 = xy0 + (xy1-xy0) / 2
plane = self.rfcontext.Point2D_to_Plane(xy0, xy1)
ray = self.rfcontext.Point2D_to_Ray(xy01)
self.new_cut(ray, plane, walk_to_plane=False, check_hit=xy01)
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('rotate screen')
def draw_post2d_rotate_screenspace(self):
gpustate.blend('ALPHA')
Globals.drawing.draw2D_line(
self.rotate_about,
self.rfcontext.actions.mouse,
(1.0, 1.0, 0.1, 1.0), color1=(1.0, 1.0, 0.1, 0.0),
width=2, stipple=[2, 2]
)
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('rotate plane')
def draw_post2d_rotate_plane(self):
gpustate.blend('ALPHA')
Globals.drawing.draw2D_line(
self.shift_about + self.rot_axis2D * 1000,
self.shift_about - self.rot_axis2D * 1000,
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
width=2, stipple=[2,2],
)
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('grab')
def draw_post2d_grab(self):
project = self.rfcontext.Point_to_Point2D
intersect = self.rfcontext.raycast_sources_Point2D
delta = Vec2D(self.actions.mouse - self.grab_opts['mousedown'])
c0_good, c1_good = (1.0, 0.1, 1.0, 0.5), (1.0, 0.1, 1.0, 0.0)
c0_bad, c1_bad = (1.0, 0.1, 0.1, 1.0), (1.0, 0.1, 0.1, 0.0)
gpustate.blend('ALPHA')
for o in self.move_origins:
p0, p1 = project(o), project(o) + delta
_p,_,_,_ = intersect(p1)
Globals.drawing.draw2D_line(
p0, p1,
(c0_good if _p else c0_bad), color1=(c1_good if _p else c1_bad),
width=2, stipple=[2,2],
)
@DrawCallbacks.on_draw('post2d')
def draw_post2d(self):
point_to_point2d = self.rfcontext.Point_to_Point2D
is_visible = lambda p: self.rfcontext.is_visible(p, occlusion_test_override=True)
up = self.rfcontext.Vec_up()
size_to_size2D = self.rfcontext.size_to_size2D
text_draw2D = self.rfcontext.drawing.text_draw2D
self.rfcontext.drawing.set_font_size(12)
bmv_count = set()
bmv_count_loops = {}
bmv_count_strings = {}
for loop_data in self.loops_data:
loop = loop_data['loop']
radius = loop_data['radius']
count = loop_data['count']
plane = loop_data['plane']
cl = loop_data['cl']
# draw segment count label
loop = [vert for vert in loop if vert.is_valid]
loop = [(vert, point_to_point2d(vert.co)) for vert in loop if is_visible(vert.co)]
if loop:
bmv = max(loop, key=lambda bmvp2d:bmvp2d[1].y)[0]
if bmv not in bmv_count_loops: bmv_count_loops[bmv] = []
bmv_count_loops[bmv].append(count)
bmv_count.add(bmv)
for string_data in self.strings_data:
string = string_data['string']
count = string_data['count']
plane = string_data['plane']
# draw segment count label
string = [vert for vert in string if vert.is_valid]
string = [(vert, point_to_point2d(vert.co)) for vert in string if is_visible(vert.co)]
if string:
bmv = max(string, key=lambda bmvp2d:bmvp2d[1].y)[0]
if bmv not in bmv_count_strings: bmv_count_strings[bmv] = []
bmv_count_strings[bmv].append(count)
bmv_count.add(bmv)
for bmv in bmv_count:
counts_loops = sorted(bmv_count_loops.get(bmv, []))
counts_strings = sorted(bmv_count_strings.get(bmv, []))
s_loops = ','.join(map(str, counts_loops))
s_strings = ','.join(map(str, counts_strings))
xy = point_to_point2d(bmv.co)
xy.y += 10
if s_loops:
text_draw2D('O: ' + s_loops, xy, color=(1,1,0,1), dropshadow=(0,0,0,0.5))
xy.y += 10
if s_strings:
text_draw2D('C: ' + s_strings, xy, color=(0,1,1,1), dropshadow=(0,0,0,0.5))
@@ -0,0 +1,391 @@
'''
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 os
import math
from itertools import chain
import bpy
from mathutils import Matrix
from ..rftool import RFTool
from ...addon_common.common.boundvar import BoundBool
from ...addon_common.common.profiler import profiler
from ...addon_common.common.utils import max_index, iter_pairs
from ...addon_common.common.maths import Point,Point2D,Vec2D,Vec,Plane
from ...config.options import options
from .contours_utils import (
Contours_Loop,
find_loops, find_strings, find_parallel_loops,
loop_plane, loop_length, string_length,
edges_between_loops,
)
class Contours_Ops:
@RFTool.dirty_when_done
def new_cut(self, ray, plane, count=None, walk_to_plane=True, check_hit=None, perform_nonmanifold_check=None):
self.pts = []
self.cut_pts = []
self.cuts = []
self.connected = False
crawl = self.rfcontext.plane_intersection_crawl(ray, plane, walk_to_plane=walk_to_plane)
if not crawl: return
# get crawl data (over source)
pts = [c for (f0,c,f1) in crawl]
connected_preclip = crawl[0][0] is not None
center = Point.average(pts)
pts,connected = self.rfcontext.clip_pointloop(pts, connected_preclip)
if not pts: return
self.rfcontext.undo_push('cut')
cl_cut = Contours_Loop(pts, connected)
self.cuts = [cl_cut]
self.cut_pts = pts
self.connected = connected
sel_edges = self.rfcontext.get_selected_edges()
if check_hit:
# if ray hits target, include the loops, too!
visible_faces = self.rfcontext.visible_faces()
hit_face,_ = self.rfcontext.nearest2D_face(point=check_hit, faces=visible_faces)
if hit_face and hit_face.is_quad():
# considering loops only at the moment
edges = hit_face.edges
eseqs = [self.rfcontext.get_quadwalk_edgesequence(edge) for edge in edges]
eloops = [eseq.get_edges() if len(eseq) else None for eseq in eseqs]
cloops = [Contours_Loop(eseq.get_verts(), eseq.is_loop()) if eseq else None for eseq in eseqs]
# use loop that is most parallel to cut
norm = cl_cut.plane.n
idx0 = max_index([abs(norm.dot(cloop.plane.n)) if cloop else -1 for cloop in cloops])
idx1 = (idx0 + 2) % 4
sel_edges |= set(eloops[idx0]) | set(eloops[idx1])
sel_loop_pos,sel_loop_neg = None,None
sel_string_pos,sel_string_neg = None,None
if connected:
# find two closest selected loops, one on each side
sel_loops = find_loops(sel_edges)
# find loops running parallel to selection
par_loops = [ploop for loop in sel_loops for ploop in find_parallel_loops(loop)]
sel_loop_planes = [(loop, loop_plane(loop)) for loop in sel_loops]
par_loop_planes = [(loop, loop_plane(loop)) for loop in par_loops]
def get_closest(loop_planes, positive):
nonlocal center, plane
mult = 1 if positive else -1
loops = sorted([
# loop, distance to loop, segment count of loop, loop circumference
#(loop, plane.distance_to(p.o), len(loop), loop_length(loop))
(loop, (loop_plane.o - center).length, len(loop), loop_length(loop))
for loop,loop_plane in loop_planes if plane.side(loop_plane.o)*mult > 0
], key=lambda data:data[1])
return next(iter(loops), None)
# (loop, plane.distance_to(p.o), len(loop), loop_length(loop))
# for loop,p in zip(sel_loops, sel_loop_planes) if plane.side(p.o) > 0
sel_loop_pos = get_closest(sel_loop_planes, True)
sel_loop_neg = get_closest(sel_loop_planes, False)
par_loop_pos = get_closest(par_loop_planes, True)
par_loop_neg = get_closest(par_loop_planes, False)
# if we've got only one selected loop, see if any parallel loops are closer
if sel_loop_pos and par_loop_pos:
if par_loop_pos[1] < sel_loop_pos[1]:
sel_loop_pos = par_loop_pos
if sel_loop_neg and par_loop_neg:
if par_loop_neg[1] < sel_loop_neg[1]:
sel_loop_neg = par_loop_neg
if sel_loop_pos and sel_loop_neg:
if sel_loop_pos[2] != sel_loop_neg[2]:
# selected loops do not have same count of vertices
# choosing the closer loop
if sel_loop_pos[1] < sel_loop_neg[1]:
sel_loop_neg = None
else:
sel_loop_pos = None
else:
# find two closest selected strings, one on each side
sel_strings = find_strings(sel_edges)
parallel_strings = [pstring for string in sel_strings for pstring in find_parallel_loops(string, False)]
sel_strings += parallel_strings
sel_string_planes = [loop_plane(string) for string in sel_strings]
sel_strings_pos = sorted([
(string, plane.distance_to(p.o), len(string), string_length(string))
for string,p in zip(sel_strings, sel_string_planes) if plane.side(p.o) > 0
], key=lambda data:data[1])
sel_strings_neg = sorted([
(string, plane.distance_to(p.o), len(string), string_length(string))
for string,p in zip(sel_strings, sel_string_planes) if plane.side(p.o) < 0
], key=lambda data:data[1])
sel_string_pos = next(iter(sel_strings_pos), None)
sel_string_neg = next(iter(sel_strings_neg), None)
if sel_string_pos and sel_string_neg:
if sel_string_pos[2] != sel_string_neg[2]:
# selected strings do not have same count of vertices
# choosing the closer string
if sel_string_pos[1] < sel_string_neg[1]:
sel_string_neg = None
else:
sel_string_pos = None
if not count:
count = self._var_init_count.value
if connected != connected_preclip:
count = int(math.ceil(count / 2)) + 1
#count = count or self.get_count()
count = sel_loop_pos[2] if sel_loop_pos else sel_loop_neg[2] if sel_loop_neg else count
count = sel_string_pos[2] if sel_string_pos else sel_string_neg[2] if sel_string_neg else count
if count <= 2:
# too few verts for a cut! need at least 3
# possible fix for issue #856
return
if connected:
cl_pos = Contours_Loop(sel_loop_pos[0], True) if sel_loop_pos else None
cl_neg = Contours_Loop(sel_loop_neg[0], True) if sel_loop_neg else None
else:
cl_pos = Contours_Loop(sel_string_pos[0], False) if sel_string_pos else None
cl_neg = Contours_Loop(sel_string_neg[0], False) if sel_string_neg else None
if cl_pos: self.cuts += [cl_pos]
if cl_neg: self.cuts += [cl_neg]
if connected:
if cl_pos and cl_neg:
verts0 = list(cl_pos.verts)
verts1 = list(cl_neg.verts)
v0 = verts0[0]
offset = None
for i,v1 in enumerate(verts1):
if v0.share_edge(v1): offset = i
if offset is not None:
verts1 = verts1[offset:] + verts1[:offset]
if verts0[1].share_edge(verts1[-1]):
verts1 = [verts1[0]] + list(reversed(verts1[1:]))
new_edges = []
def split_face(v0, v1):
nonlocal new_edges
f0 = next(iter(v0.shared_faces(v1)), None)
if not f0:
self.rfcontext.alert_user('Something unexpected happened in trying to create a new cut', level='warning')
self.rfcontext.undo_cancel()
return
f1 = f0.split(v0, v1)
new_edges.append(f0.shared_edge(f1))
nvs = []
for v0,v2 in zip(verts0, verts1):
e1 = v0.shared_edge(v2)
assert e1
intersection = cl_cut.plane.line_intersection(v0.co, v2.co)
v0,v2 = e1.verts
e0,v1 = e1.split()
assert v0 in e0.verts
assert v2 in e1.verts
v1.co = intersection
self.rfcontext.snap_vert(v1)
nvs.append(v1)
for v0,v1 in iter_pairs(nvs, wrap=True):
split_face(v0, v1)
self.rfcontext.select(new_edges)
#self.update()
return
cl_neg.align_to(cl_pos)
cl_cut.align_to(cl_pos)
if options['contours uniform']:
step_size = cl_cut.circumference / count
dists = [0] + [step_size for i in range(count-1)]
else:
lc,lp,ln = cl_cut.circumference,cl_pos.circumference,cl_neg.circumference
dists = [0] + [lc * (d0/lp + d1/ln)/2 for d0,d1 in zip(cl_pos.dists,cl_neg.dists)]
dists = dists[:-1]
elif cl_pos:
cl_cut.align_to(cl_pos)
if options['contours uniform']:
step_size = cl_cut.circumference / count
dists = [0] + [step_size for i in range(count-1)]
else:
lc,lp = cl_cut.circumference,cl_pos.circumference
dists = [0] + [lc * (d/lp) for d in cl_pos.dists]
dists = dists[:-1]
elif cl_neg:
cl_cut.align_to(cl_neg)
if options['contours uniform']:
step_size = cl_cut.circumference / count
dists = [0] + [step_size for i in range(count-1)]
else:
lc,ln = cl_cut.circumference,cl_neg.circumference
dists = [0] + [lc * (d/ln) for d in cl_neg.dists]
dists = dists[:-1]
else:
step_size = cl_cut.circumference / count
dists = [0] + [step_size for i in range(count-1)]
else:
if cl_pos and cl_neg:
cl_neg.align_to(cl_pos)
cl_cut.align_to(cl_pos)
lc,lp,ln = cl_cut.circumference,cl_pos.circumference,cl_neg.circumference
dists = [0] + [0.999 * lc * (d0/lp + d1/ln)/2 for d0,d1 in zip(cl_pos.dists,cl_neg.dists)]
elif cl_pos:
cl_cut.align_to(cl_pos)
lc,lp = cl_cut.circumference,cl_pos.circumference
dists = [0] + [0.999 * lc * (d/lp) for d in cl_pos.dists]
elif cl_neg:
cl_cut.align_to(cl_neg)
lc,ln = cl_cut.circumference,cl_neg.circumference
dists = [0] + [0.999 * lc * (d/ln) for d in cl_neg.dists]
else:
step_size = cl_cut.circumference / (count-1)
dists = [0] + [0.999 * step_size for i in range(count-1)]
dists[0] = cl_cut.offset
# where new verts, edges, and faces are stored
verts,edges,faces = [],[],[]
if sel_loop_pos and sel_loop_neg:
edges_between = edges_between_loops(sel_loop_pos[0], sel_loop_neg[0])
self.rfcontext.delete_edges(edges_between)
if sel_string_pos and sel_string_neg:
edges_between = edges_between_loops(sel_string_pos[0], sel_string_neg[0])
self.rfcontext.delete_edges(edges_between)
i,dist = 0,dists[0]
for c0,c1 in cl_cut.iter_pts(repeat=True):
if c0 == c1: continue
d = (c1 - c0).length
while dist - d <= 0:
# create new vert between c0 and c1
p = c0 + (c1 - c0) * (dist / d)
self.pts += [p]
verts += [self.rfcontext.new_vert_point(p)]
i += 1
if i == len(dists): break
dist += dists[i]
dist -= d
if i == len(dists): break
assert len(dists)==len(verts), '%d != %d' % (len(dists), len(verts))
for v0,v1 in iter_pairs(verts, connected):
edges += [self.rfcontext.new_edge((v0, v1))]
if cl_pos: self.rfcontext.bridge_vertloop(verts, cl_pos.verts, connected)
if cl_neg: self.rfcontext.bridge_vertloop(verts, cl_neg.verts, connected)
self.rfcontext.select(edges)
if perform_nonmanifold_check is None or perform_nonmanifold_check:
if options['contours non-manifold check'] and not connected and (verts[0].co - verts[-1].co).length < 0.01:
opt_nonmanifold = '''options['contours non-manifold check']'''
self.rfcontext.alert_user('\n'.join([
'The stroke has cut across a non-manifold edge in the source mesh and results may not be as expected. Please double check your source for duplicate vertices, un-merged symmetry, and holes.',
'',
'''<label><input type="checkbox" checked="BoundBool(opt_nonmanifold)">Perform this check</label>'''
]), level='warning')
@RFTool.dirty_when_done
def fill(self):
sel_edges = self.rfcontext.get_selected_edges()
sel_loops = find_loops(sel_edges)
if len(sel_loops) != 2:
self.rfcontext.alert_user('In order to fill, select exactly 2 loops of the same edge count')
return
loop0, loop1 = sel_loops
if len(loop0) != len(loop1):
self.rfcontext.alert_user('In order to fill, select exactly 2 loops of the same edge count')
return
if any(v0.share_edge(v1) for v0 in loop0 for v1 in loop1):
self.rfcontext.alert_user('In order to fill, the 2 selected loops cannot share an edge')
return
self.rfcontext.undo_push('fill')
cl_pos = Contours_Loop(loop0, True)
cl_neg = Contours_Loop(loop1, True)
cl_neg.align_to(cl_pos)
faces = self.rfcontext.bridge_vertloop(cl_neg.verts, cl_pos.verts, True)
#self.dirty()
#self.rfcontext.select(faces)
@RFTool.dirty_when_done
def change_count(self, *, count=None, delta=None):
assert count is not None or delta is not None, 'Contours.change_count: Must specify either count or delta!'
sel_edges = self.rfcontext.get_selected_edges()
loops = find_loops(sel_edges)
strings = find_strings(sel_edges)
if len(loops) == 1 and len(strings) == 0:
self._change_loop_count(loops[0], count=count, delta=delta)
elif len(strings) == 1 and len(loops) == 0:
self._change_string_count(strings[0], count=count, delta=delta)
else:
print('Contours.change_count: expected either 1 loop+0 strings or 1 string+0 loops, but found %d loops and %d strings' % (len(loops), len(strings)))
def _change_loop_count(self, loop, *, count=None, delta=None):
count_cur = len(loop)
if count is not None: count_new = count
else: count_new = count_cur + delta
count_new = max(3, count_new)
if count_cur == count_new: return
if any(len(v.link_edges) != 2 for v in loop): return
cl = Contours_Loop(loop, True)
avg = Point.average(v.co for v in loop)
plane = cl.plane
ray = self.rfcontext.Point2D_to_Ray(self.rfcontext.Point_to_Point2D(avg))
self.rfcontext.delete_edges(e for v in loop for e in v.link_edges)
self.new_cut(ray, plane, walk_to_plane=True, count=count_new)
def _change_string_count(self, string, *, count=None, delta=None):
count_cur = len(string)
if count is not None: count_new = count
else: count_new = count_cur + delta
count_new = max(3, count_new)
if count_cur == count_new: return
if any(len(v.link_edges) != 2 for v in string[1:-1]):
print('Contours._change_string_count: string is connected to other geometry')
return
if any(len(v.link_edges) != 1 for v in string[:1] + string[-1:]):
print('Contours._change_string_count: string is connected to other geometry')
return
cl = Contours_Loop(string, False)
avg = Point.average(v.co for v in string)
plane = cl.plane
ray = self.rfcontext.Point2D_to_Ray(self.rfcontext.Point_to_Point2D(avg))
self.rfcontext.delete_edges(e for v in string for e in v.link_edges)
self.new_cut(ray, plane, walk_to_plane=True, count=count_new, perform_nonmanifold_check=False)
@@ -0,0 +1,29 @@
<details>
<summary>Contours</summary>
<div class="contents">
<label>
<input type="checkbox" checked="BoundBool('''options['contours uniform']''')" title="If enabled, all new vertices will be spread uniformly (equal distance) around the circumference of the new cut. If disabled, new vertices will try to match distances between vertices of the extended cut.">
Uniform Cut
</label>
<label>
<input type="checkbox" checked="BoundBool('''options['contours non-manifold check']''')" title="If enabled, a warning message will appear if a new cut crosses a non-manifold edge in the source mesh. This check is useful when the source mesh is not water tight.">
Non-manifold Check
</label>
<div class="labeled-input-text">
<label for="contours-initial-count">
Initial Count
</label>
<input id="contours-initial-count" type="number" value="BoundInt('''options['contours count']''', min_value=3, max_value=500)" title="Number of vertices to create in a new cut.">
</div>
<div class="labeled-input-text">
<label for="contours-current-count">
Cut Count
</label>
<input id="contours-current-count" type="number" value="self._var_cut_count" title="Number of vertices in currently selected cut.">
</div>
<!--<label title="Number of vertices in currently selected cut." class="input-text">
Cut Count
<input type="text" value="BoundInt('''self.var_cut_count''', min_value=3, max_value=500)">
</label>-->
</div>
</details>
@@ -0,0 +1,55 @@
'''
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 os
import re
import math
from itertools import chain
import bpy
from mathutils import Matrix
from ..rftool import RFTool
from ...addon_common.common.boundvar import BoundBool, BoundInt
from ...addon_common.common.utils import delay_exec
from ...config.options import options
class Contours_Props:
@RFTool.on_init
def init_props(self):
self._var_init_count = BoundInt('''options['contours count']''', min_value=3, max_value=500)
self._var_cut_count = BoundInt('''self.var_cut_count''', min_value=3, max_value=500)
self._var_uniform_cut = BoundBool('''options['contours uniform']''')
self._var_nonmanifold = BoundBool('''options['contours non-manifold check']''')
@property
def var_cut_count(self):
return getattr(self, '_var_cut_count_value', 0)
@var_cut_count.setter
def var_cut_count(self, v):
if self.var_cut_count == v: return
self._var_cut_count_value = v
if self._var_cut_count.disabled: return
self.rfcontext.undo_push('change segment count', repeatable=True)
self.change_count(count=v)
@@ -0,0 +1,379 @@
'''
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/>.
'''
class Contours_Utils:
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
#def get_count(self): return
import math
from itertools import chain
from mathutils import Vector, Quaternion
import bpy
from ..rfmesh.rfmesh import RFVert
from ...addon_common.common.utils import iter_pairs, max_index
from ...addon_common.common.hasher import hash_cycle
from ...addon_common.common.maths import (
Point, Vec, Normal, Direction,
Point2D, Vec2D,
Plane, Frame,
)
from ...addon_common.common.profiler import profiler
def to_point(item):
t = type(item)
if t is RFVert: return item.co
if t is Point or t is Vector or t is Vec: return item
if t is tuple: return Point(item)
return item.co
def next_edge_in_string(edge0, vert01, ignore_two_faced=False):
faces0 = edge0.link_faces
edges1 = vert01.link_edges
# ignore edge0
edges1 = [edge for edge in edges1 if edge != edge0]
if ignore_two_faced:
# ignore edges that have two faces already
edges1 = [edge for edge in edges1 if len(edge.link_faces) <= 1]
# ignore edges that share face with previous edge
edges1 = [edge for edge in edges1 if not faces0 or not any(f in faces0 for f in edge.link_faces)]
return edges1[0] if len(edges1) == 1 else []
def find_loops(edges):
if not edges: return []
edges = set(edges)
touched, loops = set(), []
def crawl(v0, edge01):
nonlocal edges, touched, loops
vert_list = []
while True:
# ... -- v0 -- edge01 -- v1 -- edge12 -- ...
# > came-^-from-^ ^-going-^-to >
vert_list.append(v0)
touched.add(edge01)
v1 = edge01.other_vert(v0)
if v1 == vert_list[0]:
# found a loop!
loops.append(vert_list)
return
next_edges = [e for e in v1.link_edges if e in edges and e != edge01]
if not next_edges:
# could not find a loop
return
if len(next_edges) == 1: edge12 = next_edges[0]
else: edge12 = next_edge_in_string(edge01, v1)
if not edge12 or edge12 in touched or edge12 not in edges:
# could not find a loop
return
v0, edge01 = v1, edge12
for edge in edges:
if edge in touched: continue
crawl(edge.verts[0], edge)
return loops
def find_parallel_loops(loop, wrap=True):
def find_opposite_loop(loop, bmf):
# find edge loop on opposite side of given face from given edge
bmv0,bmv1 = loop[:2]
bme01 = bmv0.shared_edge(bmv1)
bme03 = next((bme for bme in bmf.neighbor_edges(bme01) if bmv0 in bme.verts), None)
if not bme03: return None
bmv_opposite = bme03.other_vert(bmv0)
ploop = []
for bmv0,bmv1 in iter_pairs(loop, wrap):
if not bmf: return None
if len(bmf.verts) != 4: return None
ploop.append(bmv_opposite)
bmv_opposite = next(iter(set(bmf.verts)-{bmv0,bmv1,bmv_opposite}), None)
if not bmv_opposite: return None
bme = bmv1.shared_edge(bmv_opposite)
if not bme: return None
bmf = next(iter(set(bme.link_faces) - {bmf}), None)
if not ploop: return None
if not wrap: ploop.append(bmv_opposite)
return ploop
ploops = []
bmv0,bmv1 = loop[:2]
bme01 = bmv0.shared_edge(bmv1)
bmfs = [bmf for bmf in bme01.link_faces]
touched = set()
for bmf in bmfs:
bme0 = bme01
lloop = loop
while bmf:
if bmf in touched: break
touched.add(bmf)
ploop = find_opposite_loop(lloop, bmf)
if not ploop: break
ploops.append(ploop)
bme1 = bmf.opposite_edge(bme0)
if not bme1: break
bmf = next((bmf_ for bmf_ in bme1.link_faces if bmf_ != bmf), None)
bme0 = bme1
lloop = ploop
return ploops
def find_strings(edges, min_length=3):
if not edges: return []
touched,strings = set(),[]
def crawl(v0, edge01, vert_list):
nonlocal edges, touched
# ... -- v0 -- edge01 -- v1 -- edge12 -- ...
# came ^ from ^
vert_list.append(v0)
touched.add(edge01)
v1 = edge01.other_vert(v0)
if v1 == vert_list[0]: return []
edge12 = next_edge_in_string(edge01, v1)
if not edge12 or edge12 not in edges: return vert_list + [v1]
return crawl(v1, edge12, vert_list)
for edge in edges:
if edge in touched: continue
vert_list0 = crawl(edge.verts[0], edge, [])
vert_list1 = crawl(edge.verts[1], edge, [])
vert_list = list(reversed(vert_list0)) + vert_list1[2:]
if len(vert_list) >= min_length: strings.append(vert_list)
return strings
def find_cycles(edges, max_loops=10):
# searches through edges to find loops
# first, break into connected components
# then, find all the junctions (verts with more than two connected edges)
# sequence of edges between junctions can be reduced to single edge
# find cycles in graph
if not edges: return []
vert_edges = {}
for edge in edges:
v0,v1 = edge.verts
vert_edges[v0] = vert_edges.get(v0, []) + [(edge,v1)]
vert_edges[v1] = vert_edges.get(v1, []) + [(edge,v0)]
touched_edges = set()
touched_verts = set()
cycles = []
cycle_hashes = set()
def crawl(v0, vert_list):
touched_verts.add(v0)
vert_list.append(v0)
for edge,v1 in vert_edges[v0]:
if edge in touched_edges: continue
touched_edges.add(edge)
if v1 in vert_list:
# found cycle!
cycle = list(reversed(vert_list))
while cycle[-1] != v1: cycle.pop()
h = hash_cycle(cycle)
if h not in cycle_hashes:
cycle_hashes.add(h)
cycles.append(cycle)
else:
crawl(v1, vert_list)
touched_edges.remove(edge)
if len(cycles) == max_loops: return
vert_list.pop()
for v in vert_edges.keys():
if v in touched_verts: continue
crawl(v, [])
if len(cycles) == max_loops: print('max loop count reached')
return cycles
def edges_of_loop(vert_loop):
edges = []
for v0,v1 in iter_pairs(vert_loop, True):
e0 = set(v0.link_edges)
e1 = set(v1.link_edges)
edges += list(e0 & e1)
return edges
def verts_of_loop(edge_loop):
verts = []
for e0,e1 in iter_pairs(edge_loop, False):
if not verts:
v0 = e0.shared_vert(e1)
verts += [e0.other_vert(v0), v0]
verts += [e1.other_vert(verts[-1])]
if len(verts) > 1 and verts[0] == verts[-1]: return verts[:-1]
return verts
def loop_plane(vert_loop):
# average co is pt on plane
# average cross product (point in same direction) is normal
if not vert_loop: return None
vert_loop = [to_point(v) for v in vert_loop]
pt = sum((Vector(vert) for vert in vert_loop), Vector()) / len(vert_loop)
n,cnt = None,0
for vert0,vert1 in zip(vert_loop[:-1], vert_loop[1:]):
c = Vec((vert0-pt).cross(vert1-pt)).normalize()
n = n+c if n else c
if not n: return Plane(pt, Normal())
return Plane(pt, Normal(n).normalize())
def loop_radius(vert_loop):
pt = sum((Vector(to_point(vert)) for vert in vert_loop), Vector()) / len(vert_loop)
rad = sum((to_point(vert) - pt).length for vert in vert_loop) / len(vert_loop)
return rad
def loop_length(vert_loop):
return sum((to_point(v0)-to_point(v1)).length for v0,v1 in zip(vert_loop, chain(vert_loop[1:], vert_loop[:1])))
def loops_connected(vert_loop0, vert_loop1):
if not vert_loop0 or not vert_loop1: return False
v0 = vert_loop0
v0_connected = { e.other_vert(v0) for e in v0.link_edges }
return any(v1 in v0_connected for v1 in vert_loop1)
def edges_between_loops(vert_loop0, vert_loop1):
loop1 = set(vert_loop1)
return [e for v0 in vert_loop0 for e in v0.link_edges if e.other_vert(v0) in loop1]
def faces_between_loops(vert_loop0, vert_loop1):
loop1 = set(vert_loop1)
return [f for v0 in vert_loop0 for f in v0.link_faces if any(fv in loop1 for fv in f.verts)]
def string_length(vert_loop):
return sum((to_point(v0)-to_point(v1)).length for v0,v1 in zip(vert_loop[:-1], vert_loop[1:]))
def project_loop_to_plane(vert_loop, plane):
return [plane.project(to_point(v)) for v in vert_loop]
class Contours_Loop:
def __init__(self, vert_loop, connected, offset=0):
self.connected = connected
self.set_vert_loop(vert_loop, offset)
def __repr__(self):
return '<Contours_Loop: %d,%s,%s>' % (len(self.verts), str(self.connected), str(self.verts))
# @profiler.function
def set_vert_loop(self, vert_loop, offset):
self.verts = vert_loop
self.offset = offset
self.pts = [to_point(bmv) for bmv in self.verts]
self.count = len(self.pts)
self.plane = loop_plane(self.pts)
if not self.connected:
self.plane.o = self.pts[0] + (self.pts[-1] - self.pts[0]) / 2
self.up_dir = Direction(self.pts[0] - self.plane.o)
self.frame = Frame.from_plane(self.plane, y=self.up_dir)
proj = self.plane.project
self.dists = [(proj(p0)-proj(p1)).length for p0,p1 in iter_pairs(self.pts, self.connected)]
self.proj_dists = [self.plane.signed_distance_to(p) for p in self.pts]
self.circumference = sum(self.dists)
self.radius = sum(self.w2l_point(pt).length for pt in self.pts) / self.count
def get_origin(self): return self.plane.o
def get_normal(self): return self.plane.n
def get_local_by_index(self, idx): return self.w2l_point(self.pts[idx])
def w2l_point(self, co): return self.frame.w2l_point(to_point(co))
def l2w_point(self, co): return self.frame.l2w_point(to_point(co))
def get_index_of_top(self, pts):
pts_local = [self.w2l_point(pt+self.frame.o) for pt in pts]
idx = max_index(pts_local, key=lambda pt:pt.y)
t = pts_local[idx]
#print(pts_local, idx, t)
offset = ((math.pi/2 - math.atan2(t.y, t.x)) * self.circumference / (math.pi*2)) % self.circumference
return (idx,offset)
def align_to(self, other):
n0, n1 = self.get_normal(), other.get_normal()
is_opposite = n0.dot(n1) < 0
vert_loop = list(reversed(self.verts)) if is_opposite else self.verts
if not self.connected:
self.set_vert_loop(vert_loop, 0)
return
if is_opposite: n0 = -n0
# issue #659
angle = 0 if n0.length_squared == 0 or n1.length_squared == 0 else n0.angle(n1)
q = Quaternion(n0.cross(n1), angle)
# rotate to align "topmost" vertex
rel_pos = [Vec(q @ (to_point(p) - self.frame.o)) for p in vert_loop]
rot_by,offset = other.get_index_of_top(rel_pos)
vert_loop = vert_loop[rot_by:] + vert_loop[:rot_by]
offset = (offset * self.circumference / other.circumference)
self.set_vert_loop(vert_loop, offset)
def get_closest_point(self, point):
point = to_point(point)
cp,cd = None,None
for p0,p1 in iter_pairs(self.pts, self.connected):
diff = p1 - p0
l = diff.length
d = diff / l
pp = p0 + d * max(0, min(l, (point - p0).dot(d)))
dist = (point - pp).length
if not cp or dist < cd: cp,cd = pp,dist
return cp
def get_points_relative_to(self, other):
scale = other.radius / self.radius
return [other.l2w_point(Vector(self.w2l_point(pt)) * scale) for pt in self.pts]
def iter_pts(self, repeat=False):
return iter_pairs(self.pts, self.connected, repeat=repeat)
def move_2D(self, xy_delta:Vec2D):
pass