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__ = []
@@ -0,0 +1,180 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson
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 bpy
import json
import time
from datetime import datetime
from mathutils import Matrix, Vector
from bpy_extras.object_utils import object_data_add
from ...config.options import (
options,
retopoflow_datablocks,
retopoflow_product,
)
from ...addon_common.common.globals import Globals
from ...addon_common.common.decorators import blender_version_wrapper
from ...addon_common.common.blender import (
set_object_selection,
get_active_object, set_active_object,
toggle_screen_header,
toggle_screen_toolbar,
toggle_screen_properties,
toggle_screen_lastop,
)
from ...addon_common.common.blender_preferences import get_preferences
from ...addon_common.common.maths import BBox
from ...addon_common.common.debug import dprint
class RetopoFlow_Blender_Objects:
@staticmethod
def is_valid_source(o, *, test_poly_count=True, context=None):
if not o: return False
context = context or bpy.context
mark = RetopoFlow_Blender_Objects.get_sources_target_mark(o)
if mark is not None: return mark == 'source'
# if o == get_active_object(): return False
if o == context.edit_object: return False
if type(o) is not bpy.types.Object: return False
if type(o.data) is not bpy.types.Mesh: return False
if not o.visible_get(): return False
if test_poly_count and not o.data.polygons: return False
return True
@staticmethod
def is_valid_target(o, *, ignore_edit_mode=False, context=None):
if not o: return False
context = context or bpy.context
mark = RetopoFlow_Blender_Objects.get_sources_target_mark(o)
if mark is not None: return mark == 'target'
# if o != get_active_object(): return False
if not ignore_edit_mode and o != context.edit_object: return False
if not o.visible_get(): return False
if type(o) is not bpy.types.Object: return False
if type(o.data) is not bpy.types.Mesh: return False
return True
@staticmethod
def has_valid_source():
return any(RetopoFlow_Blender_Objects.is_valid_source(o) for o in bpy.context.scene.objects)
@staticmethod
def has_valid_target():
return RetopoFlow_Blender_Objects.get_target() is not None
@staticmethod
def is_in_valid_mode():
for area in bpy.context.screen.areas:
if area.type != 'VIEW_3D': continue
if area.spaces[0].local_view:
# currently in local view
return False
return True
@staticmethod
def mark_sources_target():
for obj in bpy.data.objects:
if RetopoFlow_Blender_Objects.is_valid_source(obj):
# set as source
obj['RetopFlow'] = 'source'
elif RetopoFlow_Blender_Objects.is_valid_target(obj):
obj['RetopoFlow'] = 'target'
else:
obj['RetopoFlow'] = 'unused'
@staticmethod
def unmark_sources_target():
for obj in bpy.data.objects:
if 'RetopoFlow' not in obj: continue
del obj['RetopoFlow']
@staticmethod
def any_marked_sources_target():
return any('RetopoFlow' in obj for obj in bpy.data.objects)
@staticmethod
def get_sources_target_mark(obj):
if 'RetopoFlow' not in obj: return None
return obj['RetopoFlow']
@staticmethod
def get_sources(*, ignore_active=False):
is_valid = RetopoFlow_Blender_Objects.is_valid_source
active = bpy.context.active_object
is_ignored = lambda o: (ignore_active and o == active)
return [ o for o in bpy.data.objects if is_valid(o) and not is_ignored(o) ]
@staticmethod
def get_target():
is_valid = RetopoFlow_Blender_Objects.is_valid_target
return next(( o for o in bpy.data.objects if is_valid(o) ), None)
@staticmethod
def create_new_target(context, *, matrix_world=None):
auto_edit_mode = bpy.context.preferences.edit.use_enter_edit_mode # working around blender bug, see https://github.com/CGCookie/retopoflow/issues/786
bpy.context.preferences.edit.use_enter_edit_mode = False
for o in bpy.data.objects: o.select_set(False)
mesh = bpy.data.meshes.new('RetopoFlow')
obj = object_data_add(context, mesh, name='RetopoFlow')
obj.select_set(True)
context.view_layer.objects.active = obj
if matrix_world:
obj.matrix_world = matrix_world
bpy.ops.object.mode_set(mode='EDIT')
bpy.context.preferences.edit.use_enter_edit_mode = auto_edit_mode
####################################################
# methods for rotating about selection
def setup_rotate_about_active(self):
self.end_rotate_about_active() # clear out previous rotate-about object
auto_edit_mode = bpy.context.preferences.edit.use_enter_edit_mode # working around blender bug, see https://github.com/CGCookie/retopoflow/issues/786
bpy.context.preferences.edit.use_enter_edit_mode = False
o = object_data_add(bpy.context, None, name=retopoflow_datablocks['rotate object'])
bpy.context.preferences.edit.use_enter_edit_mode = auto_edit_mode
o.select_set(True)
o.scale = Vector((0.01, 0.01, 0.01))
bpy.context.view_layer.objects.active = o
self.update_rot_object()
@staticmethod
def end_rotate_about_active(*, reset_active=True):
# IMPORTANT: changes here should also go in rf_blender_save.backup_recover()
name = retopoflow_datablocks['rotate object']
if name not in bpy.data.objects: return
is_active = (bpy.context.view_layer.objects.active == bpy.data.objects[name])
# delete rotate object
bpy.data.objects.remove(bpy.data.objects[name], do_unlink=True)
if is_active and reset_active:
bpy.context.view_layer.objects.active = RetopoFlow_Blender_Objects.get_target()
@@ -0,0 +1,377 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson
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 bpy
import json
import time
from datetime import datetime
from itertools import chain
from mathutils import Matrix, Vector
from bpy_extras.object_utils import object_data_add
from bpy.app.handlers import persistent
from ...config.options import options, sessionoptions, retopoflow_datablocks
from ...addon_common.common.globals import Globals
from ...addon_common.common.boundvar import BoundBool
from ...addon_common.common.decorators import blender_version_wrapper
from ...addon_common.common.blender import (
set_object_selection,
set_active_object,
toggle_screen_header,
toggle_screen_toolbar,
toggle_screen_properties,
toggle_screen_lastop,
show_error_message,
BlenderSettings,
get_view3d_space,
)
from ...addon_common.common.blender_preferences import get_preferences
from ...addon_common.common.maths import BBox
from ...addon_common.common.debug import dprint
from .rf_blender_objects import RetopoFlow_Blender_Objects
@persistent
def revert_auto_save_after_load(*_, **__):
# remove recover handler
bpy.app.handlers.load_post.remove(revert_auto_save_after_load)
window = bpy.context.window
screen = window.screen
area = next((a for a in screen.areas if a.type == 'VIEW_3D'), None)
assert area
space_data = next((s for s in area.spaces if s.type == 'VIEW_3D'), None)
assert space_data
region = next((r for r in area.regions if r.type == 'WINDOW'), None)
assert region
with bpy.context.temp_override(window=window, screen=screen, area=area, space_data=space_data, region=region):
RetopoFlow_Blender_Save.recovery_revert()
class RetopoFlow_Blender_Save:
'''
backup / restore methods
'''
@staticmethod
def can_recover():
if retopoflow_datablocks['rotate object'] in bpy.data.objects: return True
if sessionoptions.has_active_session_data(): return True
if RetopoFlow_Blender_Objects.any_marked_sources_target(): return True
return False
@staticmethod
def recovery_revert():
print('RetopoFlow: recovering from auto save')
# the rotate object should not exist, but just in case
if retopoflow_datablocks['rotate object'] in bpy.data.objects:
bpy.data.objects.remove(
bpy.data.objects[retopoflow_datablocks['rotate object']],
do_unlink=True,
)
# restore blender settings
if sessionoptions.has_session_data():
data = dict(sessionoptions['blender'])
print(f'RetopoFlow: Restoring Session Data')
print(f' {data}')
bs = BlenderSettings(init_storage=data)
bs.restore_all()
space = get_view3d_space()
r3d = space.region_3d
normalize_opts = sessionoptions['normalize']
# scale view
orig_view = normalize_opts['view']
r3d.view_distance = orig_view['distance']
r3d.view_location = Vector(orig_view['location'])
# scale clip start and end
orig_clip = normalize_opts['clip distances']
space.clip_start = orig_clip['start']
space.clip_end = orig_clip['end']
# scale meshes
prev_factor = normalize_opts['mesh scaling factor']
M = (Matrix.Identity(3) * (1.0 / prev_factor)).to_4x4()
sources = RetopoFlow_Blender_Objects.get_sources()
target = RetopoFlow_Blender_Objects.get_target()
for obj in chain(sources, [target]):
if not obj: continue
obj.matrix_world = M @ obj.matrix_world
if target:
try:
# try to select object
target.select_set(True)
bpy.context.view_layer.objects.active = target
bpy.ops.object.mode_set(mode='EDIT')
except:
pass
sessionoptions.clear()
# unmark all objects
RetopoFlow_Blender_Objects.unmark_sources_target()
# # grab previous blender state
# if options['blender state'] in bpy.data.texts:
# data = json.loads(bpy.data.texts[options['blender state']].as_string())
# # get target object and reset settings
# tar_object = bpy.data.objects[data['active object']]
# tar_object.hide_viewport = False
# tar_object.hide_render = False
# bpy.context.view_layer.objects.active = tar_object
# tar_object.select_set(True)
# RetopoFlow_Normalize.end_normalize(bpy.context)
# bpy.data.texts.remove(
# bpy.data.texts[options['blender state']],
# do_unlink=True,
# )
# restore window state (mostly tool, properties, header, etc.)
# RetopoFlow_Blender_UI.restore_window_state(
# ignore_panels=False,
# ignore_mode=False,
# )
@staticmethod
def get_auto_save_settings(context):
prefs = get_preferences(context)
use_auto_save = prefs.filepaths.use_auto_save_temporary_files
path_blend = getattr(bpy.data, 'filepath', '')
path_autosave = options.get_auto_save_filepath()
good_auto_save = (not options['check auto save']) or use_auto_save
good_unsaved = (not options['check unsaved']) or path_blend
return {
'auto save': good_auto_save,
'auto save path': path_autosave,
'saved': good_unsaved,
}
def check_auto_save_warnings(self):
settings = RetopoFlow_Blender_Save.get_auto_save_settings(self.actions.context)
save = self.actions.to_human_readable('blender save')
good_auto_save = settings['auto save']
path_autosave = settings['auto save path']
good_unsaved = settings['saved']
if good_auto_save and good_unsaved: return
message = []
if not good_auto_save:
opt_autosave = '''options['check auto save']'''
message += ['\n'.join([
'The Auto Save option in Blender (Edit > Preferences > Save & Load > Auto Save) is currently disabled.',
'Your changes will _NOT_ be saved automatically!',
'',
'''<label><input type="checkbox" checked="BoundBool(opt_autosave)">Check Auto Save option when RetopoFlow starts</label>''',
])]
if not good_unsaved:
opt_unsaved = '''options['check unsaved']'''
message += ['\n'.join([
'You are currently working on an _UNSAVED_ Blender file.',
f'Your changes will be saved to `{path_autosave}` when you press `{save}`',
'',
'''<label><input type="checkbox" checked="BoundBool(opt_unsaved)">Run check for unsaved .blend file when RetopoFlow starts</label>''',
])]
else:
message += ['Press `%s` any time to save your changes.' % (save)]
self.alert_user(
title='Blender auto save / save file checker',
message='\n\n'.join(message),
level='warning',
)
def handle_auto_save(self):
prefs = get_preferences(self.actions.context)
use_auto_save = prefs.filepaths.use_auto_save_temporary_files
auto_save_time = prefs.filepaths.auto_save_time * 60
if not use_auto_save: return # Blender's auto save is disabled :(
if not hasattr(self, 'time_to_save'):
# RF just started, so do not save yet
self.last_change_count = None
# record the next time to save
self.time_to_save = time.time() + auto_save_time
elif time.time() > self.time_to_save:
# it is time to save, but only if current tool is in main and changes were made!
if self.rftool._fsm_in_main():
if self.save_backup():
# save was successful!
# record the next time to save
self.time_to_save = time.time() + auto_save_time
else:
# save was unsuccessful :(
# try again in 10secs
self.time_to_save = time.time() + 10
@staticmethod
def has_auto_save():
filepath = options['last auto save path']
return filepath and os.path.exists(filepath)
@staticmethod
def get_auto_save_filename():
return options['last auto save path']
@staticmethod
def recover_auto_save():
filepath = options['last auto save path']
print(f'backup recover: {filepath}')
if not filepath or not os.path.exists(filepath):
print(f' DOES NOT EXIST!')
return
bpy.app.handlers.load_post.append(revert_auto_save_after_load)
bpy.ops.wm.open_mainfile(filepath=filepath)
@staticmethod
def delete_auto_save():
filepath = options['last auto save path']
print(f'backup delete: {filepath}')
if not filepath or not os.path.exists(filepath):
print(f' DOES NOT EXIST!')
return
os.remove(filepath)
def save_emergency(self):
try:
filepath = options.get_auto_save_filepath(suffix='EMERGENCY', emergency=True)
bpy.ops.wm.save_as_mainfile(
filepath=filepath,
compress=True, # write compressed file
check_existing=False, # do not warn if file already exists
copy=True, # does not make saved file active
)
except:
self.done(emergency_bail=True)
show_error_message(
'\n'.join([
'RetopoFlow crashed unexpectedly.',
'Be sure to save your work, and report what happened so that we can try fixing it.',
]),
title='RetopoFlow Error',
)
def save_backup(self):
if hasattr(self, '_backup_broken'): return
if self.last_change_count == self.change_count:
print(f'RetopoFlow: skipping backup save (no changes detected)')
return True
if not hasattr(self, '_backup_save_attempts'): self._backup_save_attempts = 0
filepath = options.get_auto_save_filepath()
filepath1 = f'{filepath}1'
print(f'RetopoFlow: saving backup to {filepath}')
errors = {}
if os.path.exists(filepath):
if os.path.exists(filepath1):
try:
print(f' deleting old backup: {filepath1}')
os.remove(filepath1)
except Exception as e:
print(f' caught exception: {e}')
errors['delete old'] = e
try:
print(f' renaming prev backup: {filepath1}')
os.rename(filepath, filepath1)
except Exception as e:
print(f' caught exception: {e}')
errors['rename prev'] = e
if 'rename prev' not in errors:
try:
print(f' saving...')
bpy.ops.wm.save_as_mainfile(
filepath=filepath,
compress=True, # write compressed file
check_existing=False, # do not warn if file already exists
copy=True, # does not make saved file active
)
options['last auto save path'] = filepath
self.last_change_count = self.change_count
except Exception as e:
print(f' caught exception: {e}')
errors['saving'] = e
else:
'''
skipping normal save, because we might lose data!
'''
errors['skipped save'] = 'error while trying to rename prev'
if not errors:
# all went well!
self._backup_save_attempts = 0
return True
print(f' Something happened')
print(f' {errors=}')
self._backup_save_attempts += 1
if self._backup_save_attempts < 4:
print(f' Trying again soon...')
else:
print(f' Alerting user...')
self._backup_broken = True
self.alert_user(
title='Could not save backup',
level='assert',
message=(
f'Could not save backup file. '
f'Temporarily preventing further backup attempts. '
f'You might try saving file manually.\n\n'
f'File paths: `{filepath}`, `{filepath1}`\n\n'
f'Errors: {errors}\n\n'
),
)
return False
def save_normal(self):
with self.blender_ui_pause():
with sessionoptions.temp_disable():
try:
bpy.ops.wm.save_mainfile()
except Exception as e:
# could not save for some reason; let the artist know!
self.alert_user(
title='Could not save',
message=f'Could not save blend file.\n\nError message: "{e}"',
level='warning',
)
# note: filepath might not be set until after save
filepath = os.path.abspath(bpy.data.filepath)
print(f'RetopoFlow: saved to {filepath}')
@@ -0,0 +1,305 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson
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 bpy
import math
import time
import urllib
import gpu
from mathutils import Vector, Matrix
from gpu_extras.presets import draw_texture_2d
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
from ...addon_common.common import gpustate
from ...addon_common.common.drawing import DrawCallbacks
from ...addon_common.common.globals import Globals
from ...addon_common.common.profiler import profiler
from ...addon_common.common.debug import tprint
from ...addon_common.common.fsm import FSM
from ...addon_common.common.hasher import Hasher
from ...addon_common.common.maths import Point, Point2D, Vec2D, XForm, clamp
from ...addon_common.common.maths import matrix_normal, Direction
from ...addon_common.terminal.term_printer import sprint
from ...config.options import options, visualization
class RetopoFlow_Drawing:
def get_view_version(self):
return Hasher(self.actions.r3d.view_matrix, self.actions.space.lens, self.actions.r3d.view_distance, self.actions.area.width, self.actions.area.height)
def setup_drawing(self):
def callback():
Globals.drawing.update_dpi()
source_opts = visualization.get_source_settings()
target_opts = visualization.get_target_settings()
self.rftarget_draw.replace_opts(target_opts)
# self.document.body.dirty(cause='--> options changed', children=True)
for d in self.rfsources_draw: d.replace_opts(source_opts)
options.add_callback(callback)
self._draw_count = 0
@DrawCallbacks.on_predraw()
def predraw(self):
if not self.loading_done: return
self.update(timer=False)
self._draw_count += 1
def get_view_matrix(self):
return self.actions.r3d.view_matrix
def get_projection_matrix(self):
return None
# r3d = self.actions.r3d
# if r3d.view_perspective == 'ORTHO':
# xdelta = right - left
# ydelta = top - bottom
# zdelta = farclip - nearclip
# return Matrix([
# [2 / xdelta, 0, 0, -(right + left) / xdelta],
# [0, 2 / ydelta, 0, -(top + bottom) / ydelta],
# [0, 0, -2 / zdelta, -(farclip + nearclip) / zdelta],
# [0, 0, 0, 1],
# ])
# else:
# return
# @DrawCallbacks.on_draw('post2d')
def draw_selection_buffer(self):
return
if not self.scene.camera: return
sprint(hasattr(self, '_gpuoffscreen'))
if not hasattr(self, '_gpuoffscreen'):
self._gpuoffscreen = gpu.types.GPUOffScreen(1, 1) #, format='RGBA8')
self._gpuoffscreen_draw_count = -1
if not hasattr(self, '_draw_count'):
# setup not fully complete yet!
return
if self._gpuoffscreen_draw_count == self._draw_count: return
w, h = int(self.actions.size.x), int(self.actions.size.y)
if self._gpuoffscreen.width != w or self._gpuoffscreen.height != h:
sprint(f'RESIZING')
self._gpuoffscreen.free()
self._gpuoffscreen = gpu.types.GPUOffScreen(w, h) #, format='RGBA8')
sprint(f'DRAWING GPUOFFSCREEN')
self._gpuoffscreen_draw_count = self._draw_count
print(self.scene.camera)
print(self.scene.camera.matrix_world)
view_mat = self.scene.camera.matrix_world.inverted_safe()
depsgraph = self.context.view_layer.depsgraph # self.context.evaluated_depsgraph_get()
proj_mat = self.scene.camera.calc_matrix_camera(depsgraph, x=w, y=h)
print(view_mat)
print(proj_mat)
with self._gpuoffscreen.bind():
self._gpuoffscreen.draw_view3d(
self.scene, # scene to draw
self.view_layer, # view layer to draw
self.actions.space, # 3D view to get the drawing settings from
self.actions.region, # Region of the 3D view
view_mat, # view matrix
proj_mat, # projection matrix
do_color_management=False,
draw_background=False,
)
gpustate.depth_mask(False)
gpustate.blend('ALPHA')
draw_texture_2d(
self._gpuoffscreen.texture_color,
(0, 0),
w, h,
)
@DrawCallbacks.on_draw('post3d')
def draw_target_and_sources(self):
if not self.actions.r3d: return
if not self.loading_done: return
# if self.fps_low_warning: return # skip drawing if low FPS warning is showing
buf_matrix_target = self.rftarget_draw.rfmesh.xform.mx_p
buf_matrix_target_inv = self.rftarget_draw.rfmesh.xform.imx_p
buf_matrix_view = self.actions.r3d.view_matrix
buf_matrix_view_invtrans = matrix_normal(self.actions.r3d.view_matrix)
buf_matrix_proj = self.actions.r3d.window_matrix
view_forward = self.Vec_forward()
gpustate.blend('ALPHA')
if options['symmetry view'] != 'None' and self.rftarget.mirror_mod.xyz:
if options['symmetry view'] in {'Edge', 'Face'}:
# get frame of target, used for symmetry decorations on sources
ft = self.rftarget.get_frame()
# render sources
for rs,rfs in zip(self.rfsources, self.rfsources_draw):
rfs.draw(
view_forward, self.unit_scaling_factor,
buf_matrix_target, buf_matrix_target_inv,
buf_matrix_view, buf_matrix_view_invtrans, buf_matrix_proj,
1.00, 0.05, # alpha above, alpha below
False, 0.5, # cull backfaces, alpha_backfaces
False, # draw mirrored
symmetry=self.rftarget.mirror_mod.xyz,
symmetry_view=options['symmetry view'],
symmetry_effect=options['symmetry effect'],
symmetry_frame=ft,
)
elif options['symmetry view'] == 'Plane':
# draw symmetry planes
gpustate.depth_test('LESS_EQUAL')
gpustate.culling('NONE')
drawing = Globals.drawing
a = pow(options['symmetry effect'], 2.0) # fudge this value, because effect is different with plane than edge/face
r = (1.0, 0.2, 0.2, a)
g = (0.2, 1.0, 0.2, a)
b = (0.2, 0.2, 1.0, a)
w2l = self.rftarget_draw.rfmesh.xform.w2l_point
l2w = self.rftarget_draw.rfmesh.xform.l2w_point
# for rfs in self.rfsources:
# corners = [self.Point_to_Point2D(l2w(p)) for p in rfs.get_local_bbox(w2l).corners]
# drawing.draw2D_lines(corners, (1,1,1,1))
corners = [ c for s in self.rfsources for c in s.get_local_bbox(w2l).corners ]
mx, Mx = min(c.x for c in corners), max(c.x for c in corners)
my, My = min(c.y for c in corners), max(c.y for c in corners)
mz, Mz = min(c.z for c in corners), max(c.z for c in corners)
cx, cy, cz = mx + (Mx - mx) / 2, my + (My - my) / 2, mz + (Mz - mz) / 2
mx, Mx = cx + (mx - cx) * 1.2, cx + (Mx - cx) * 1.2
my, My = cy + (my - cy) * 1.2, cy + (My - cy) * 1.2
mz, Mz = cz + (mz - cz) * 1.2, cz + (Mz - cz) * 1.2
if self.rftarget.mirror_mod.x:
quad = [ l2w(Point((0, my, mz))), l2w(Point((0, my, Mz))), l2w(Point((0, My, Mz))), l2w(Point((0, My, mz))) ]
drawing.draw3D_triangles([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]], [r, r, r, r, r, r])
if self.rftarget.mirror_mod.y:
quad = [ l2w(Point((mx, 0, mz))), l2w(Point((mx, 0, Mz))), l2w(Point((Mx, 0, Mz))), l2w(Point((Mx, 0, mz))) ]
drawing.draw3D_triangles([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]], [g, g, g, g, g, g])
if self.rftarget.mirror_mod.z:
quad = [ l2w(Point((mx, my, 0))), l2w(Point((mx, My, 0))), l2w(Point((Mx, My, 0))), l2w(Point((Mx, my, 0))) ]
drawing.draw3D_triangles([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]], [b, b, b, b, b, b])
# render target
gpustate.blend('ALPHA')
if True:
alpha_above,alpha_below = options['target alpha'],options['target hidden alpha']
cull_backfaces = options['target cull backfaces']
alpha_backface = options['target alpha backface']
self.rftarget_draw.draw(
view_forward, self.unit_scaling_factor,
buf_matrix_target, buf_matrix_target_inv,
buf_matrix_view, buf_matrix_view_invtrans, buf_matrix_proj,
alpha_above, alpha_below,
cull_backfaces, alpha_backface,
True, # draw_mirrored
)
@DrawCallbacks.on_draw('post3d')
def draw_greasemarks(self):
return
# if not self.actions.r3d: return
# # THE FOLLOWING CODE NEEDS UPDATED TO NOT USE GLBEGIN!
# # grease marks
# b_g_l.glBegin(b_g_l.GL_QUADS)
# for stroke_data in self.grease_marks:
# b_g_l.glColor4f(*stroke_data['color'])
# t = stroke_data['thickness']
# s0,p0,n0,d0,d1 = None,None,None,None,None
# for s1 in stroke_data['marks']:
# p1,n1 = s1
# if p0 and p1:
# v01 = p1 - p0
# if d0 is None: d0 = Direction(v01.cross(n0))
# d1 = Direction(v01.cross(n1))
# b_g_l.glVertex3f(*(p0-d0*t+n0*0.001))
# b_g_l.glVertex3f(*(p0+d0*t+n0*0.001))
# b_g_l.glVertex3f(*(p1+d1*t+n1*0.001))
# b_g_l.glVertex3f(*(p1-d1*t+n1*0.001))
# s0,p0,n0,d0 = s1,p1,n1,d1
# b_g_l.glEnd()
##################################
# RFTool Drawing
@DrawCallbacks.on_draw('predraw')
@FSM.onlyinstate({'main', 'quick switch'})
def tool_new_frame(self):
if not self.loading_done: return
# if self.fsm.state == 'pie menu': return
self.rftool._new_frame()
@DrawCallbacks.on_draw('pre3d')
@FSM.onlyinstate({'main', 'quick switch'})
def draw_tool_pre3d(self):
if not self.loading_done: return
# if self.fsm.state == 'pie menu': return
self.rftool._draw_pre3d()
@DrawCallbacks.on_draw('post3d')
@FSM.onlyinstate({'main', 'quick switch'})
def draw_tool_post3d(self):
if not self.loading_done: return
# if self.fsm.state == 'pie menu': return
self.rftool._draw_post3d()
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate({'main', 'quick switch'})
def draw_tool_post2d(self):
if not self.loading_done: return
# if self.fsm.state == 'pie menu': return
self.rftool._draw_post2d()
#############################
# RFWidget Drawing
@DrawCallbacks.on_draw('pre3d')
@FSM.onlyinstate({'main', 'quick switch'})
def draw_widget_pre3d(self):
if not self.loading_done: return
if not self.rftool.rfwidget: return
if self._nav: return
if self._hover_ui: return
# if self.fsm.state == 'pie menu': return
self.rftool.rfwidget._draw_pre3d()
@DrawCallbacks.on_draw('post3d')
@FSM.onlyinstate({'main', 'quick switch'})
def draw_widget_post3d(self):
if not self.loading_done: return
if not self.rftool.rfwidget: return
if self._nav: return
if self._hover_ui: return
# if self.fsm.state == 'pie menu': return
self.rftool.rfwidget._draw_post3d()
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate({'main', 'quick switch'})
def draw_widget_post2d(self):
if not self.loading_done: return
if not self.rftool.rfwidget: return
if self._nav: return
if self._hover_ui: return
# if self.fsm.state == 'pie menu': return
self.rftool.rfwidget._draw_post2d()
@@ -0,0 +1,960 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
'''
import math
import time
import random
from itertools import chain
from functools import partial
from collections import deque
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
from ...addon_common.common import gpustate
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.decorators import timed_call
from ...addon_common.common.drawing import Cursors, DrawCallbacks, CC_DRAW, CC_2D_LINES
from ...addon_common.common.fsm import FSM
from ...addon_common.common.globals import Globals
from ...addon_common.common.maths import Vec2D, Point2D, RelPoint2D, Direction2D
from ...addon_common.common.profiler import profiler
from ...addon_common.common.ui_core import UI_Element
from ...addon_common.common.utils import normalize_triplequote, Dict
from ...config.options import options, retopoflow_files
from ...addon_common.common.timerhandler import StopwatchHandler, CallGovernor
class RetopoFlow_FSM(CookieCutter): # CookieCutter must be here in order to override fns
def setup_states(self):
self.view_version = None
self._last_rfwidget = None
self.fast_update_timer = self.actions.start_timer(120.0, enabled=False)
def update(self, timer=True):
if not self.loading_done:
# calling self.fsm.update() in case mouse is hovering over ui
self.fsm.update()
return
options.clean(raise_exception=False)
if options.write_error and not hasattr(self, '_write_error_reported'):
# could not write options to file for some reason
# issue #1070
self._write_error_reported = True
message = normalize_triplequote(f'''
Could not write options to file (incorrect permissions).
Check that you have permission to write to `{retopoflow_files["options filename"]}` to the RetopoFlow add-on folder.
Or, try: uninstall RetopoFlow from Blender, restart Blender, then install the latest version of RetopoFlow from the Blender Market.
Note: You can continue using RetopoFlow, but any changes to options will not be saved.
This error will not be reported again during the current RetopoFlow session.
''')
self.alert_user(message, level='error')
if timer:
self.rftool._callback('timer')
if self.rftool.rfwidget:
self.rftool.rfwidget._callback_widget('timer')
if self.rftool.rfwidget != self._last_rfwidget:
# force redraw when widget changes to clear out any widget drawing
self._last_rfwidget = self.rftool.rfwidget
tag_redraw_all('RFWidget change')
rftarget_version = self.rftarget.get_version()
if self.rftarget_version != rftarget_version:
self.rftarget_version = rftarget_version
self.update_rot_object()
self.callback_target_change()
tag_redraw_all('RF_FSM target change')
view_version = self.get_view_version()
if self.view_version != view_version:
self.update_view_sessionoptions(self.context)
self.update_clip_settings(rescale=False)
self.view_version = view_version
self.callback_view_change()
tag_redraw_all('RF_FSM view change')
self.actions.hit_pos,self.actions.hit_norm,_,_ = self.raycast_sources_mouse()
fpsdiv = self.document.body.getElementById('fpsdiv')
if fpsdiv: fpsdiv.innerText = f'UI FPS: {self.document._draw_fps:.2f}'
# @CallGovernor.limit(fn_delay=lambda:options['target change delay'])
def callback_target_change(self):
# throttling this fn will cause target_change and draw callbacks to get out-of-sync
# ex: contours depends on data collected in target change callback!
self.rftool._callback('target change')
if self.rftool.rfwidget:
self.rftool.rfwidget._callback_widget('target change')
self.update_ui_geometry()
tag_redraw_all('RF_FSM target change')
@CallGovernor.limit(fn_delay=lambda:options['view change delay'])
def callback_view_change(self):
self.rftool._callback('view change')
if self.rftool.rfwidget:
self.rftool.rfwidget._callback_widget('view change')
tag_redraw_all('RF_FSM view change')
def should_pass_through(self, context, event):
return self.actions.using('blender passthrough')
@FSM.on_state('main')
def modal_main(self):
# if self.actions.just_pressed: print('modal_main', self.actions.just_pressed)
if self.rftool._fsm_in_main() and (not self.rftool.rfwidget or self.rftool.rfwidget._fsm_in_main()):
# handle exit
if self.actions.pressed('done'):
if options['confirm tab quit']:
self.show_quit_dialog()
else:
self.done()
return
if options['escape to quit'] and self.actions.pressed('done alt0'):
self.done()
return
# handle help actions
if self.actions.pressed('all help'):
self.helpsystem_open('table_of_contents.md')
return
if self.actions.pressed('general help'):
self.helpsystem_open('general.md')
return
if self.actions.pressed('tool help'):
self.helpsystem_open(self.rftool.help)
return
# user wants to save?
if self.actions.pressed('blender save'):
self.save_normal()
return
# toggle ui
if self.actions.pressed('toggle ui'):
# hide ui if main (or minimized main, tiny) is visible
ui_hide = self.ui_main.is_visible or self.ui_tiny.is_visible
if ui_hide:
self.ui_hide = True
self.ui_main.is_visible = False
self.ui_tiny.is_visible = False
self.ui_options.is_visible = False
self.ui_options_min.is_visible = False
self.ui_geometry.is_visible = False
self.ui_geometry_min.is_visible = False
else:
self.ui_main.is_visible = options['show main window']
self.ui_tiny.is_visible = not options['show main window']
self.ui_options.is_visible = options['show options window']
self.ui_options_min.is_visible = not options['show options window']
self.ui_geometry.is_visible = options['show geometry window']
self.ui_geometry_min.is_visible = not options['show geometry window']
self.ui_hide = False
return
# handle pie menu
if self.actions.pressed('pie menu'):
self.show_pie_menu([
{'text':rftool.name, 'image':rftool.icon, 'value':rftool}
for rftool in self.rftools
], self.select_rftool, highlighted=self.rftool)
return
# debugging
if False:
if self.actions.pressed('SHIFT+F5'): breakit = 42 / 0
if self.actions.pressed('SHIFT+F6'): assert False
if self.actions.pressed('SHIFT+F7'): self.alert_user(message='Foo', level='exception', msghash='2ec5e386ae05c1abeb66dce8e1f1cb95')
if self.actions.pressed('F7'):
assert False, 'test exception throwing'
# self.alert_user(title='Test', message='foo bar', level='warning', msghash=None)
return
# if self.actions.just_pressed: print('modal_main', self.actions.just_pressed)
# profiler
if False:
if self.actions.pressed('SHIFT+F10'):
profiler.clear()
return
if self.actions.pressed('SHIFT+F11'):
profiler.printout()
self.document.debug_print()
return
# reload CSS
if self.actions.pressed('reload css'):
print('RetopoFlow: Reloading stylings')
self.reload_stylings()
return
# handle tool switching
for rftool in self.rftools:
if rftool == self.rftool: continue
if self.actions.pressed(rftool.shortcut):
self.select_rftool(rftool)
return
if self.actions.pressed(rftool.quick_shortcut, unpress=False):
self.quick_select_rftool(rftool)
return 'quick switch'
# handle undo/redo
if self.actions.pressed('blender undo'):
self.undo_pop()
if self.rftool: self.rftool._reset()
return
if self.actions.pressed('blender redo'):
self.redo_pop()
if self.rftool: self.rftool._reset()
return
# handle general selection (each tool will handle specific selection / selection painting)
if self.actions.pressed('select all'):
# print('modal_main:selecting all toggle')
self.undo_push('select all')
self.select_toggle()
return
if self.actions.pressed('deselect all'):
self.undo_push('deselect all')
self.deselect_all()
return
if self.actions.pressed('select invert'):
self.undo_push('select invert')
self.select_invert()
return
if self.actions.pressed('select linked'):
self.undo_push('select linked')
self.select_linked()
return
if self.actions.pressed({'select linked mouse', 'deselect linked mouse'}, unpress=False):
select = self.actions.pressed('select linked mouse')
self.actions.unpress()
bmv,_ = self.accel_nearest2D_vert(max_dist=options['select dist'])
bme,_ = self.accel_nearest2D_edge(max_dist=options['select dist'])
bmf,_ = self.accel_nearest2D_face(max_dist=options['select dist'])
connected_to = bmv or bme or bmf
if connected_to:
self.undo_push('select linked mouse')
self.select_linked(connected_to=connected_to, select=select)
return
# hide/reveal
if self.actions.pressed('hide selected'):
self.hide_selected()
return
if self.actions.pressed('hide unselected'):
self.hide_unselected()
return
if self.actions.pressed('reveal hidden'):
self.reveal_hidden()
return
# delete
if self.actions.pressed('delete'):
self.show_delete_dialog()
return
if self.actions.pressed('delete pie menu'):
def callback(option):
if not option: return
self.delete_dissolve_collapse_option(option)
self.show_pie_menu([
('Delete Verts', ('Delete', 'Vertices')),
('Delete Edges', ('Delete', 'Edges')),
('Delete Faces', ('Delete', 'Faces')),
('Dissolve Faces', ('Dissolve', 'Faces')),
('Dissolve Edges', ('Dissolve', 'Edges')),
('Dissolve Verts', ('Dissolve', 'Vertices')),
('Merge at Center', ('Merge', 'At Center')),
('Merge by Distance', ('Merge', 'By Distance')),
# ('Collapse Edges & Faces', ('Collapse', 'Edges & Faces')),
#'Dissolve Loops',
], callback, release='delete pie menu', always_callback=True, rotate=-60)
return
# merge
if self.actions.pressed('merge'):
self.show_merge_dialog()
return
# smoothing
if self.actions.pressed('smooth edge flow'):
self.smooth_edge_flow(iterations=options['smooth edge flow iterations'])
return
# pin/unpin
if self.actions.pressed('pin'):
self.pin_selected()
return
if self.actions.pressed('unpin'):
self.unpin_selected()
return
if self.actions.pressed('unpin all'):
self.unpin_all()
return
# seams
if self.actions.pressed('mark seam'):
self.mark_seam_selected()
return
if self.actions.pressed('clear seam'):
self.clear_seam_selected()
return
return self.modal_main_rest()
def modal_main_rest(self):
self.ignore_ui_events = False
self.normal_check() # this call is governed!
if not self.actions.is_navigating: self.rftool._done_navigating()
if self.rftool.rfwidget:
Cursors.set(self.rftool.rfwidget.rfw_cursor)
if self.rftool.rfwidget.redraw_on_mouse and self.actions.mousemove:
tag_redraw_all('RFTool.RFWidget.redraw_on_mouse')
ret = self.rftool.rfwidget._fsm_update()
if self.fsm.is_state(ret):
return ret
if self.rftool.rfwidget._fsm.state != 'main':
self.ignore_ui_events = True
return
ret = self.rftool._fsm_update()
if self.fsm.is_state(ret):
self.ignore_ui_events = True
return ret
if self.fsm.state != 'main':
self.ignore_ui_events = True
if not self.ignore_ui_events:
self.handle_auto_save()
if self.actions.pressed('rotate'):
return 'rotate selected'
if self.actions.pressed('scale'):
return 'scale selected'
if self.actions.pressed('rip'):
return self.rip(fill=False)
if self.actions.pressed('rip fill'):
return self.rip(fill=True)
@FSM.on_state('quick switch', 'enter')
def quick_switch_enter(self):
self._quick_switch_wait = 2
@FSM.on_state('quick switch')
def quick_switch(self):
self._quick_switch_wait -= 1
if self.rftool._fsm.state == 'main' and (not self.rftool.rfwidget or self.rftool.rfwidget._fsm.state == 'main'):
if self._quick_switch_wait < 0 and self.actions.released(self.rftool.quick_shortcut):
return 'main'
self.modal_main_rest()
@FSM.on_state('quick switch', 'exit')
def quick_switch_exit(self):
self.quick_restore_rftool()
def setup_action(self, pt0, pt1, fn_callback, done_pressed=None, done_released=None, cancel_pressed=None):
v01 = pt1 - pt0
self.action_data = {
'p0': pt0, 'p1': pt1, 'v01': v01,
'fn callback': fn_callback,
'done pressed': done_pressed, 'done released': done_released, 'cancel pressed': cancel_pressed,
'val': lambda p: v01.dot(p - pt0),
}
return 'action handler'
@FSM.on_state('action handler', 'enter')
def action_handler_enter(self):
assert self.action_data
self.undo_push('action handler')
self.fast_update_timer.start()
self.action_data['mouse'] = self.actions.mouse
self.action_data['val start'] = self.action_data['val'](self.actions.mouse)
@FSM.on_state('action handler')
def action_handler(self):
d = self.action_data
if self.actions.pressed(d['done pressed']) or self.actions.released(d['done released']):
self.actions_data = None
return 'main'
if self.actions.released(d['cancel pressed']):
self.undo_pop()
self.dirty()
return 'main'
if not self.actions.mousemove: return
val = self.action_data['val'](self.actions.mouse)
self.action_data['fn callback'](val - self.action_data['val start'])
self.dirty()
@FSM.on_state('action handler', 'exit')
def action_handler_exit(self):
self.fast_update_timer.stop()
@FSM.on_state('rotate selected', 'can enter')
# @profiler.function
def rotate_selected_canenter(self):
if not self.get_selected_verts(): return False
@FSM.on_state('rotate selected', 'enter')
def rotate_selected_enter(self):
bmverts = self.get_selected_verts()
opts = {}
opts['bmverts'] = [(bmv, self.Point_to_Point2D(bmv.co)) for bmv in bmverts]
opts['center'] = RelPoint2D.average(co for _,co in opts['bmverts'])
opts['rotate_x'] = Direction2D(self.actions.mouse - opts['center'])
opts['rotate_y'] = Direction2D((-opts['rotate_x'].y, opts['rotate_x'].x))
opts['move_done_pressed'] = 'confirm'
opts['move_done_released'] = None
opts['move_cancelled'] = 'cancel'
opts['mouselast'] = self.actions.mouse
opts['mousedown'] = self.actions.mouse
opts['lasttime'] = 0
self.rotate_selected_opts = opts
self.undo_push('rotate')
statusbar = self.substitute_keymaps('{{confirm}} Confirm\t{{cancel}} Cancel', wrap='', pre='', post=':', separator='/', onlyfirst=2)
statusbar = statusbar.replace('\t', ' ')
self.context.workspace.status_text_set(f'Rotating selected: {statusbar}')
self.fast_update_timer.start()
self.split_target_visualization_selected()
self.set_accel_defer(True)
tag_redraw_all('rotate init')
@FSM.on_state('rotate selected')
# @profiler.function
def rotate_selected(self):
opts = self.rotate_selected_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.undo_cancel()
self.actions.unuse(opts['move_done_released'], ignoremods=True, ignoremulti=True)
return 'main'
if (self.actions.mouse - opts['mouselast']).length == 0: return
if time.time() < opts['lasttime'] + 0.05: return
opts['mouselast'] = self.actions.mouse
opts['lasttime'] = time.time()
delta = Direction2D(self.actions.mouse - opts['center'])
dx,dy = opts['rotate_x'].dot(delta),opts['rotate_y'].dot(delta)
theta = math.atan2(dy, dx)
set2D_vert = self.set2D_vert
for bmv,xy in opts['bmverts']:
if not bmv.is_valid: continue
dxy = xy - opts['center']
nx = dxy.x * math.cos(theta) - dxy.y * math.sin(theta)
ny = dxy.x * math.sin(theta) + dxy.y * math.cos(theta)
nxy = Point2D((nx, ny)) + opts['center']
set2D_vert(bmv, nxy)
self.update_verts_faces(v for v,_ in opts['bmverts'])
self.dirty()
tag_redraw_all('rotate mouse move')
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('rotate selected')
def draw_rotate_post2d(self):
opts = self.rotate_selected_opts
gpustate.blend('ALPHA')
Globals.drawing.draw2D_line(
opts['mousedown'],
opts['center'],
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
width=2, stipple=[2, 2]
)
Globals.drawing.draw2D_line(
opts['center'],
self.actions.mouse,
(1.0, 1.0, 0.1, 1.0), color1=(1.0, 1.0, 0.1, 0.0),
width=2, stipple=[2, 2]
)
@FSM.on_state('rotate selected', 'exit')
def rotate_selected_exit(self):
self.fast_update_timer.stop()
self.clear_split_target_visualization()
self.set_accel_defer(False)
self._update_rftool_ui()
@FSM.on_state('scale selected', 'can enter')
# @profiler.function
def scale_selected_canenter(self):
if not self.get_selected_verts(): return False
@FSM.on_state('scale selected', 'enter')
def scale_selected_enter(self):
bmverts = self.get_selected_verts()
opts = {}
opts['bmverts'] = [(bmv, self.Point_to_Point2D(bmv.co)) for bmv in bmverts]
opts['center'] = RelPoint2D.average(co for _,co in opts['bmverts'])
opts['start_dist'] = (self.actions.mouse - opts['center']).length
opts['move_done_pressed'] = 'confirm'
opts['move_done_released'] = None
opts['move_cancelled'] = 'cancel'
opts['mouselast'] = self.actions.mouse
opts['mousedown'] = self.actions.mouse
opts['lasttime'] = 0
self.scale_selected_opts = opts
self.undo_push('scale')
statusbar = self.substitute_keymaps('{{confirm}} Confirm\t{{cancel}} Cancel', wrap='', pre='', post=':', separator='/', onlyfirst=2)
statusbar = statusbar.replace('\t', ' ')
self.context.workspace.status_text_set(f'Scaling selected: {statusbar}')
self.fast_update_timer.start()
self.split_target_visualization_selected()
self.set_accel_defer(True)
tag_redraw_all('scale init')
@FSM.on_state('scale selected')
# @profiler.function
def scale_selected(self):
opts = self.scale_selected_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.undo_cancel()
self.actions.unuse(opts['move_done_released'], ignoremods=True, ignoremulti=True)
return 'main'
if (self.actions.mouse - opts['mouselast']).length == 0: return
if time.time() < opts['lasttime'] + 0.05: return
opts['mouselast'] = self.actions.mouse
opts['lasttime'] = time.time()
dist = (self.actions.mouse - opts['center']).length
set2D_vert = self.set2D_vert
for bmv,xy in opts['bmverts']:
if not bmv.is_valid: continue
dxy = xy - opts['center']
nxy = dxy * dist / opts['start_dist'] + opts['center']
set2D_vert(bmv, nxy)
self.update_verts_faces(v for v,_ in opts['bmverts'])
self.dirty()
tag_redraw_all('scale mouse move')
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('scale selected')
def draw_rotate_post2d(self):
opts = self.scale_selected_opts
gpustate.blend('ALPHA')
Globals.drawing.draw2D_line(
opts['mousedown'],
opts['center'],
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
width=2, stipple=[2, 2]
)
Globals.drawing.draw2D_line(
opts['center'],
self.actions.mouse,
(1.0, 1.0, 0.1, 1.0), color1=(1.0, 1.0, 0.1, 0.0),
width=2, stipple=[2, 2]
)
@FSM.on_state('scale selected', 'exit')
def scale_selected_exit(self):
self.fast_update_timer.stop()
self.clear_split_target_visualization()
self.set_accel_defer(False)
self._update_rftool_ui()
def select_path(self, bmelem_types, fn_filter_bmelem=None, kwargs_select=None, kwargs_filter=None, **kwargs):
vis_accel = self.get_accel_visible()
nearest2D_vert = self.accel_nearest2D_vert
nearest2D_edge = self.accel_nearest2D_edge
nearest2D_face = self.accel_nearest2D_face
kwargs_filter = kwargs_filter or {}
def fn_filter(bmelem):
if not bmelem: return False
if not fn_filter_bmelem: return True
return fn_filter_bmelem(bmelem, **kwargs_filter)
def get_bmelem(*args, **kwargs):
if 'vert' in bmelem_types:
bmelem, _ = nearest2D_vert(*args, vis_accel=vis_accel, **kwargs)
if fn_filter(bmelem): return bmelem
if 'edge' in bmelem_types:
bmelem, _ = nearest2D_edge(*args, vis_accel=vis_accel, **kwargs)
if fn_filter(bmelem): return bmelem
if 'face' in bmelem_types:
bmelem, _ = nearest2D_face(*args, vis_accel=vis_accel, **kwargs)
if fn_filter(bmelem): return bmelem
return None
bmelem = get_bmelem(max_dist=options['select dist']) # find what's under the mouse
if not bmelem:
# print('found nothing under mouse')
return # nothing there; leave!
bmelem_types = { RFVert: {'vert'}, RFEdge: {'edge'}, RFFace: {'face'} }[type(bmelem)]
kwargs_select = kwargs_select or {}
kwargs.update(kwargs_select)
kwargs['only'] = False
# find all other visible elements
vis_elems = self.accel_vis_verts | self.accel_vis_edges | self.accel_vis_faces
# walk from bmelem to all other connected visible geometry
path = {}
working = deque()
working.append((bmelem, None))
def add(o, bme):
nonlocal vis_elems, path, working
if o not in vis_elems or o in path: return
if not fn_filter(o): return
working.append((o, bme))
closest = None
while working:
bme, from_bme = working.popleft()
if bme in path: continue
path[bme] = from_bme
if bme.select:
# found closest!
closest = bme
break
if 'vert' in bmelem_types:
for c in bme.link_edges:
o = c.other_vert(bme)
add(o, bme)
if 'edge' in bmelem_types:
for c in bme.verts:
for o in c.link_edges:
add(o, bme)
if 'face' in bmelem_types:
for c in bme.edges:
for o in c.link_faces:
add(o, bme)
if not closest:
# print('could not find closest element')
return
self.undo_push('select path')
while closest:
self.select(closest, **kwargs)
closest = path[closest]
def setup_smart_selection_painting(self, bmelem_types, *, use_select_tool=False, selecting=True, deselect_all=False, fn_filter_bmelem=None, kwargs_select=None, kwargs_deselect=None, kwargs_filter=None, **kwargs):
vis_accel = self.get_accel_visible()
nearest2D_vert = self.accel_nearest2D_vert
nearest2D_edge = self.accel_nearest2D_edge
nearest2D_face = self.accel_nearest2D_face
kwargs_filter = kwargs_filter or {}
kwargs_select = kwargs_select or {}
kwargs_deselect = kwargs_deselect or {}
def fn_filter(bmelem):
if not bmelem: return False
if not fn_filter_bmelem: return True
return fn_filter_bmelem(bmelem, **kwargs_filter)
def get_bmelem(*args, **kwargs):
if 'vert' in bmelem_types:
bmelem, _ = nearest2D_vert(*args, vis_accel=vis_accel, **kwargs)
if fn_filter(bmelem): return bmelem
if 'edge' in bmelem_types:
bmelem, _ = nearest2D_edge(*args, vis_accel=vis_accel, **kwargs)
if fn_filter(bmelem): return bmelem
if 'face' in bmelem_types:
bmelem, _ = nearest2D_face(*args, vis_accel=vis_accel, **kwargs)
if fn_filter(bmelem): return bmelem
return None
bmelem_first = get_bmelem(max_dist=options['select dist']) # find what's under the mouse
if not bmelem_first:
# nothing there; either leave or use select tool
if not use_select_tool:
return
rftool_select = next(rftool for rftool in self.rftools if rftool.name=='Select')
self.quick_select_rftool(rftool_select)
rftool_select._callback('quickselect start')
return 'quick switch'
bmelem_type, vis_elems = {
RFVert: ('vert', self.accel_vis_verts),
RFEdge: ('edge', self.accel_vis_edges),
RFFace: ('face', self.accel_vis_faces),
}[type(bmelem_first)]
bmelem_types = { bmelem_type } # needed so get_bmelem returns correct type
selecting |= not bmelem_first.select # if not explicitly selecting, start selecting only if elem under mouse is not selected
kwargs.update(kwargs_select if selecting else kwargs_deselect)
if selecting: kwargs['only'] = False
# walk from bmelem_first to all other connected visible geometry
path_to_first = {}
working = deque()
def add_to_working(from_bmelem, to_bmelem):
if to_bmelem not in vis_elems or to_bmelem in path_to_first: return
if not fn_filter(to_bmelem): return
working.append((from_bmelem, to_bmelem))
add_to_working(None, bmelem_first)
while working:
from_bmelem, bmelem = working.popleft()
if bmelem in path_to_first: continue
path_to_first[bmelem] = from_bmelem
match bmelem_type:
case 'vert':
for edge in bmelem.link_edges:
for vert in edge.verts:
add_to_working(bmelem, vert)
case 'edge':
for vert in bmelem.verts:
for edge in vert.link_edges:
add_to_working(bmelem, edge)
case 'face':
for edge in bmelem.edges:
for face in edge.link_faces:
add_to_working(bmelem, face)
fn_select = partial((self.select if selecting else self.deselect), **kwargs)
self.selection_painting_opts = Dict(
fn_get_bmelem = get_bmelem,
path_to_first = path_to_first,
fn_select = fn_select,
previous_selection = [],
last_bmelem = bmelem_first,
)
self.undo_push('smart select' if selecting else 'smart deselect')
if deselect_all: self.deselect_all()
fn_select(bmelem_first)
return 'smart selection painting'
@FSM.on_state('smart selection painting', 'enter')
def smart_selection_painting_enter(self):
self.fast_update_timer.start()
self.split_target_visualization_visible()
self.set_accel_defer(True)
@DrawCallbacks.on_draw('predraw')
@FSM.onlyinstate('smart selection painting')
def unpause_smart_selection_painting_update(self):
self.smart_selection_painting_update.unpause()
@CallGovernor.limit(pause_after_call=True)
def smart_selection_painting_update(self):
opts = self.selection_painting_opts
bmelem = opts.fn_get_bmelem()
if not bmelem or bmelem not in opts.path_to_first: return
# hovering over same bmelem
if bmelem == opts.last_bmelem: return
opts.last_bmelem = bmelem
# reset to previous selection
for (bme, s) in opts.previous_selection: bme.select = s
# get bmelems from hovered back to first
current_selection = []
while bmelem:
current_selection.append(bmelem)
bmelem = opts.path_to_first[bmelem]
opts.previous_selection = [(bmelem, bmelem.select) for bmelem in current_selection]
opts.fn_select(current_selection)
@FSM.on_state('smart selection painting')
def smart_selection_painting(self):
if self.actions.pressed('cancel'):
self.undo_cancel()
self.actions.unuse('select paint', ignoremods=True, ignoremulti=True)
self.actions.unuse('select paint add', ignoremods=True, ignoremulti=True)
return 'main'
if not self.actions.using({'select paint', 'select paint add'}, ignoremods=True):
return 'main'
if self.actions.mousemove:
self.smart_selection_painting_update()
tag_redraw_all('RF selection_painting') # needed to force perform update
@FSM.on_state('smart selection painting', 'exit')
def smart_selection_painting_exit(self):
self.selection_painting_opts = None
self.fast_update_timer.stop()
self.clear_split_target_visualization()
self.set_accel_defer(False)
def rip(self, *, fill=False):
# find highest order geometry selected
# - faces: error
# - edges: for each selected edge, find nearest adjacent face to mouse cursor and rip edge from other face
# - verts: for each selected vert, find nearest adjacent edge to mouse cursor and rip vert from faces not adjacent to that edge
sel_verts, sel_edges, sel_faces = self.get_selected_geom()
if sel_faces:
self.alert_user('Can only rip a single edge, but a face is selected')
return
if not sel_edges and not sel_verts:
self.alert_user('Can only rip a single edge, but none are selected')
return
if sel_verts and not sel_edges:
self.alert_user('Ripping vertices is not supported yet')
return
if sel_edges and len(sel_edges) > 1:
# a temporary limitation
self.alert_user('Ripping more than one selected edge is not supported yet')
return
if not sel_edges:
self.alert_user('Must have exactly one edge selected at the moment')
return
# working with first selected edge (current implementation limitation)
bme = next(iter(sel_edges))
adj_faces = set(bme.link_faces)
if len(adj_faces) < 2:
self.alert_user('Edge must have at least two adjacent faces')
return
bmv0, bmv1 = bme.verts
nearest_face, _ = self.accel_nearest2D_face(faces_only=adj_faces)
other_face = next(iter({bmf for bmf in bme.link_faces if bmf != nearest_face}), None)
self.undo_push('rip edge')
if True:
bmv2 = bmv0.face_separate(nearest_face)
bmv3 = bmv1.face_separate(nearest_face)
move_verts = [bmv2, bmv3]
else:
bmv2 = bmv0.face_separate(other_face)
bmv3 = bmv1.face_separate(other_face)
move_verts = [bmv0, bmv1]
self.select(move_verts, only=True)
if fill:
# only implemented simple fill for now
self.new_face([bmv0, bmv1, bmv3, bmv2])
# self.undo_push('move ripped edge')
self.prep_move(
bmverts=move_verts,
action_confirm=(lambda: self.actions.pressed({'confirm', 'confirm drag'})),
)
return 'move'
def prep_move(self, *, bmverts=None, action_confirm=None, action_cancel=None, defer_recomputing=True):
Point_to_Point2D = self.Point_to_Point2D
self.move_settings = Dict(
bmverts_xys = [
(bmv, xy)
for bmv in (bmverts if bmverts is not None else self.get_selected_verts())
if bmv and bmv.is_valid and (xy := Point_to_Point2D(bmv.co)) is not None
],
actions = Dict(
confirm=action_confirm or (lambda: self.actions.pressed('confirm')),
cancel=action_cancel or (lambda: self.actions.pressed('cancel')),
),
mousedown = self.actions.mouse,
last_delta = None,
vis_accel = self.get_accel_visible(selected_only=False),
)
self.move_settings.bmverts = [bmv for (bmv,_) in self.move_settings.bmverts_xys]
@FSM.on_state('move', 'enter')
def move_enter(self):
# if not self.move_done_released and options['hide cursor on tweak']: self.set_widget('hidden')
if options['hide cursor on tweak']: Cursors.set('NONE')
self.split_target_visualization_selected()
self.fast_update_timer.start()
self.set_accel_defer(True)
@FSM.on_state('move')
def modal_move(self):
if self.move_settings.actions['confirm']():
if options['automerge']:
self.merge_verts_by_dist(self.move_settings.bmverts, options['merge dist'])
return 'main'
if self.move_settings.actions['cancel']():
self.undo_cancel()
return 'main'
delta = Vec2D(self.actions.mouse - self.move_settings.mousedown)
if delta == self.move_settings.last_delta: return
self.move_settings.last_delta = delta
set2D_vert = self.set2D_vert
for bmv,xy in self.move_settings.bmverts_xys:
xy_updated = xy + delta
# check if xy_updated is "close" to any visible verts (in image plane)
# if so, snap xy_updated to vert position (in image plane)
if options['automerge']:
bmv1,d = self.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_settings.vis_accel, max_dist=options['merge dist'])
if bmv1 is None:
set2D_vert(bmv, xy_updated)
continue
xy1 = self.Point_to_Point2D(bmv1.co)
if not xy1:
set2D_vert(bmv, xy_updated)
continue
set2D_vert(bmv, xy1)
else:
set2D_vert(bmv, xy_updated)
self.update_verts_faces(self.move_settings.bmverts)
self.dirty()
tag_redraw_all('move update')
@FSM.on_state('move', 'exit')
def move_exit(self):
self.fast_update_timer.stop()
self.set_accel_defer(False)
self.clear_split_target_visualization()
@@ -0,0 +1,25 @@
'''
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 RetopoFlow_Grease:
def setup_grease(self):
self.grease_marks = []
@@ -0,0 +1,120 @@
'''
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 bpy
from ...addon_common.common.boundvar import BoundBool
from ...addon_common.common.blender import get_path_from_addon_root
from ...addon_common.common.globals import Globals
from ...addon_common.common.utils import delay_exec, abspath
from ...addon_common.common.ui_styling import load_defaultstylings
from ...addon_common.common.ui_core import UI_Element
from ...config.options import options, retopoflow_urls
from ...config.keymaps import get_keymaps
class RetopoFlow_HelpSystem:
@staticmethod
def reload_stylings():
load_defaultstylings()
path = get_path_from_addon_root('config', 'ui.css')
try:
Globals.ui_draw.load_stylesheet(path)
except AssertionError as e:
# TODO: show proper dialog to user here!!
print('could not load stylesheet "%s"' % path)
print(e)
Globals.ui_document.body.dirty(cause='Reloaded stylings', children=True)
Globals.ui_document.body.dirty_styling()
Globals.ui_document.body.dirty_flow()
def substitute_keymaps(self, mdown, wrap='`', pre='', post='', separator=', ', onlyfirst=None):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{{(?P<action>[^}]+)}}', mdown)
if not m: break
action = { s.strip() for s in m.group('action').split(',') }
sub = f'{pre}{wrap_pre}' + self.actions.to_human_readable(action, sep=f'{wrap_post}{separator}{wrap_pre}', onlyfirst=onlyfirst) + f'{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def substitute_options(self, mdown, wrap='', pre='', post='', separator=', ', onlyfirst=None):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{\[(?P<option>[^\]]+)\]}', mdown)
if not m: break
opts = { s.strip() for s in m.group('option').split(',') }
sub = f'{pre}{wrap_pre}' + separator.join(str(options[opt]) for opt in opts) + f'{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def substitute_python(self, mdown, wrap='', pre='', post=''):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{`(?P<python>[^`]+)`}', mdown)
if not m: break
pyret = eval(m.group('python'), globals(), locals())
sub = f'{pre}{wrap_pre}{pyret}{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def helpsystem_open(self, mdown_path, done_on_esc=False, closeable=True, *args, **kwargs):
ui_markdown = self.document.body.getElementById('helpsystem-mdown')
if not ui_markdown:
keymaps = get_keymaps()
def close():
nonlocal done_on_esc
if done_on_esc:
self.done()
else:
e = self.document.body.getElementById('helpsystem')
if not e: return
self.document.body.delete_child(e)
def key(e):
nonlocal keymaps, self
if e.key in keymaps['all help']:
self.helpsystem_open('table_of_contents.md')
elif e.key in keymaps['general help']:
self.helpsystem_open('general.md')
elif e.key in keymaps['tool help']:
if hasattr(self, 'rftool'):
self.helpsystem_open(self.rftool.help)
elif e.key == 'ESC':
close()
ui_help = UI_Element.fromHTMLFile(abspath('../html/help_dialog.html'))[0]
ui_markdown = ui_help.getElementById('helpsystem-mdown')
self.document.body.append_child(ui_help)
self.document.body.dirty()
ui_markdown.set_markdown(
mdown_path=mdown_path,
preprocess_fns=[
self.substitute_keymaps,
self.substitute_options,
self.substitute_python
],
)
@@ -0,0 +1,61 @@
'''
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 bpy
import json
from queue import Queue
from ...config.options import options
class RetopoFlow_Instrumentation:
instrument_queue = Queue()
instrument_thread = None
def instrument_write(self, action):
if not options['instrument']: return
tb_name = options.get_path('instrument_filename')
if tb_name not in bpy.data.texts: bpy.data.texts.new(tb_name)
tb = bpy.data.texts[tb_name]
target_json = self.rftarget.to_json()
data = {'action': action, 'target': target_json}
data_str = json.dumps(data, separators=[',',':'], indent=0)
self.instrument_queue.put(data_str)
# write data to end of textblock asynchronously
# TODO: try writing to file (text/binary), because writing to textblock is _very_ slow! :(
def write_out():
while True:
if self.instrument_queue.empty():
time.sleep(0.1)
continue
data_str = self.instrument_queue.get()
data_str = data_str.splitlines()
tb.write('') # position cursor to end
for line in data_str:
tb.write(line)
tb.write('\n')
if not self.instrument_thread:
# executor only needed to start the following instrument_thread
executor = ThreadPoolExecutor()
self.instrument_thread = executor.submit(write_out)
@@ -0,0 +1,420 @@
'''
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 bpy
from ..updater import updater
from ...addon_common.common.blender import get_path_from_addon_root
from ...addon_common.common.globals import Globals
from ...addon_common.common.utils import delay_exec, abspath
from ...addon_common.common.ui_styling import load_defaultstylings
from ...addon_common.common.ui_core import UI_Element
from ...addon_common.common.human_readable import convert_actions_to_human_readable
from ...config.options import options
from ...config.keymaps import get_keymaps, reset_all_keymaps, save_custom_keymaps, reset_keymap, default_rf_keymaps
class RetopoFlow_KeymapSystem:
@staticmethod
def reload_stylings():
load_defaultstylings()
path = get_path_from_addon_root('config', 'ui.css')
try:
Globals.ui_draw.load_stylesheet(path)
except AssertionError as e:
# TODO: show proper dialog to user here!!
print('could not load stylesheet "%s"' % path)
print(e)
Globals.ui_document.body.dirty(cause='Reloaded stylings', children=True)
Globals.ui_document.body.dirty_styling()
Globals.ui_document.body.dirty_flow()
def substitute_keymaps(self, mdown, wrap='`', pre='', post='', separator=', ', onlyfirst=None):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{{(?P<action>[^}]+)}}', mdown)
if not m: break
action = { s.strip() for s in m.group('action').split(',') }
sub = f'{pre}{wrap_pre}' + self.actions.to_human_readable(action, sep=f'{wrap_post}{separator}{wrap_pre}', onlyfirst=onlyfirst) + f'{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def substitute_options(self, mdown, wrap='', pre='', post='', separator=', ', onlyfirst=None):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{\[(?P<option>[^\]]+)\]}', mdown)
if not m: break
opts = { s.strip() for s in m.group('option').split(',') }
sub = f'{pre}{wrap_pre}' + separator.join(str(options[opt]) for opt in opts) + f'{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def substitute_python(self, mdown, wrap='', pre='', post=''):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{`(?P<python>[^`]+)`}', mdown)
if not m: break
pyret = eval(m.group('python'), globals(), locals())
sub = f'{pre}{wrap_pre}{pyret}{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def keymap_config_open(self): #, mdown_path, done_on_esc=False, closeable=True, *args, **kwargs):
newversion = ''
keymaps = get_keymaps(force_reload=True)
humanread = self.actions.to_human_readable
tokmi = self.actions.from_human_readable
def ok():
save_custom_keymaps()
self.done()
def cancel():
get_keymaps(force_reload=True)
self.done()
def reset_all():
reset_all_keymaps()
rebuild()
self.alert_user(
message='Keymaps reset, but changes will not be saved until OK is clicked',
title='Keymaps Config System',
level='warning',
)
def key(e):
nonlocal keymaps, self
pass
# if e.key == 'ESC': close()
def action_to_label(action):
for category,actions in keymap_details:
for a,al in actions:
if a == action: return al
return action
def action_to_id(a):
aid = a.replace(' ', '_')
return aid
def set_edit_key_span(hk, is_key=True):
if not is_key:
clear_edit_key_span()
return
span = self.document.body.getElementById('edit-key-span')
span.innerText = f'Key: {hk}'
# span.style = ''
def capture_edit_key_span():
span = self.document.body.getElementById('edit-key-span')
span.innerText = '(capturing... press key to capture)'
# span.style = 'color: rgba(255, 255, 255, 0.5)'
def clear_edit_key_span():
span = self.document.body.getElementById('edit-key-span')
span.innerText = '(click to start capture)'
# span.style = 'color: rgba(255, 255, 255, 0.5)'
edit_data = {}
def edit_capture():
ui_button = self.document.body.getElementById('edit-key-span')
ui_button.can_focus = True
self.document.focus(ui_button, full=True)
capture_edit_key_span()
def edit_capture_mouse(action):
clear_edit_key_span()
edit_data['key'] = action
def edit_capture_key(event):
if event.key is None or event.key == 'NONE': return
ui_button = self.document.body.getElementById('edit-key-span')
if self.document.activeElement != ui_button: return
has_ctrl = 'CTRL+' in event.key
has_shift = 'SHIFT+' in event.key
has_alt = 'ALT+' in event.key
has_oskey = 'OSKEY+' in event.key
key = event.key.replace('CTRL+','').replace('SHIFT+','').replace('ALT+','').replace('OSKEY+','')
set_edit_key_span(humanread([key], visible=True))
edit_data['key'] = key
editor = self.document.body.getElementById('keymapconfig')
editor.getElementById('edit-ctrl').checked = has_ctrl
editor.getElementById('edit-shift').checked = has_shift
editor.getElementById('edit-alt').checked = has_alt
editor.getElementById('edit-oskey').checked = has_oskey
self.document.blur()
def edit_ok():
nonlocal edit_data, keymaps, tokmi
if edit_data['key'] == '':
self.alert_user(
message='Must select a key or mouse interaction first',
title='Keymaps Config System',
level='warning',
)
return
editor = self.document.body.getElementById('keymapconfig')
editor.style = "display: none"
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
nk = ''
nk += 'CTRL+' if editor.getElementById('edit-ctrl').checked else ''
nk += 'SHIFT+' if editor.getElementById('edit-shift').checked else ''
nk += 'ALT+' if editor.getElementById('edit-alt').checked else ''
nk += 'OSKEY+' if editor.getElementById('edit-oskey').checked else ''
nk += tokmi(edit_data['key'])[0]
nk += '+CLICK' if editor.getElementById('edit-click').checked else ''
nk += '+DOUBLE' if editor.getElementById('edit-double').checked else ''
nk += '+DRAG' if editor.getElementById('edit-drag').checked else ''
a = edit_data['action']
al = edit_data['action label']
# do not change ordering of keymaps, just update
idx = keymaps[a].index(edit_data['keymap'])
keymaps[a][idx] = nk
rebuild_action(a, al)
def edit_cancel():
self.document.body.getElementById('keymapconfig').style = "display: none"
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
if edit_data['keymap'] == '':
keymaps[edit_data['action']].remove('')
def edit_delete():
self.document.body.getElementById('keymapconfig').style = "display: none"
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
delete_keymap(edit_data['action'], edit_data['keymap'])
def edit_start(a, al, k):
nonlocal edit_data, keymaps
aid = action_to_id(a)
ok = str(k)
hkctrl, k = 'CTRL+' in k, k.replace('CTRL+', '')
hkshift, k = 'SHIFT+' in k, k.replace('SHIFT+', '')
hkalt, k = 'ALT+' in k, k.replace('ALT+', '')
hkoskey, k = 'OSKEY+' in k, k.replace('OSKEY+', '')
hkclick, k = '+CLICK' in k, k.replace('+CLICK', '')
hkdouble, k = '+DOUBLE' in k, k.replace('+DOUBLE', '')
hkdrag, k = '+DRAG' in k, k.replace('+DRAG', '')
hk = humanread(k, visible=True)
is_key = hk not in {'LMB', 'MMB', 'RMB', 'WheelUp', 'WheelDown', ''}
if not is_key: hm, hk = hk, ''
else: hm = ''
edit_data['action'] = a
edit_data['action label'] = al
edit_data['keymap'] = ok
edit_data['key'] = k
self.document.body.getElementById('keymapsystem-cover').style = ""
editor = self.document.body.getElementById('keymapconfig')
editor.style = ''
editor.getElementById('edit-action').innerText = action_to_label(a)
set_edit_key_span(hk, is_key)
editor.getElementById('edit-key').checked = is_key
editor.getElementById('edit-lmb').checked = (hm == 'LMB')
editor.getElementById('edit-mmb').checked = (hm == 'MMB')
editor.getElementById('edit-rmb').checked = (hm == 'RMB')
editor.getElementById('edit-wu').checked = (hm == 'WheelUp')
editor.getElementById('edit-wd').checked = (hm == 'WheelDown')
editor.getElementById('edit-ctrl').checked = hkctrl
editor.getElementById('edit-shift').checked = hkshift
editor.getElementById('edit-alt').checked = hkalt
editor.getElementById('edit-oskey').checked = hkoskey
editor.getElementById('edit-press').checked = not (hkclick or hkdouble or hkdrag)
editor.getElementById('edit-click').checked = hkclick
editor.getElementById('edit-double').checked = hkdouble
editor.getElementById('edit-drag').checked = hkdrag
def add_keymap(a, al):
nonlocal keymaps
keymaps[a].append('')
edit_start(a, al, '')
def delete_keymap(a, al, k):
keymaps[a].remove(k)
rebuild_action(a, al)
def keymap_html(a, al):
nonlocal edit_start, delete_keymap, rebuild_action, add_keymap
aid = action_to_id(a)
html = ''
for k in keymaps[a]:
html += f'''<button id="keymap-{aid}-key" class="key" on_mouseclick="edit_start('{a}', '{al}', '{k}')" title="Click to edit this keymap for {al}">{humanread(k, visible=True)}</button>'''
html += f'''<button id="keymap-{aid}-del" class="delkey" on_mouseclick="delete_keymap('{a}', '{al}', '{k}')" title="Click to delete this keymap for {al}">✕</button>'''
html += f'''<button class="half-size" on_mouseclick="add_keymap('{a}', '{al}')" title="Click to add a new keymap for {al}">+ Add New Keymap</button>'''
html += f'''<button class="half-size" on_mouseclick="reset_keymap('{a}'); rebuild_action('{a}', '{al}')" title="Click to reset the keymaps for {al}">Reset Keymap</button>'''
return html
def rebuild_action(a, al):
# vvv this must be here so fromHTML() can see these fns!
nonlocal edit_start, delete_keymap, rebuild_action, add_keymap
# ^^^ this must be here so fromHTML() can see these fns!
aid = action_to_id(a)
ui_td = self.document.body.getElementById(f'keymap-{aid}')
ui_td.clear_children()
ui_td.append_children(UI_Element.fromHTML(keymap_html(a, al)))
def rebuild():
# vvv this must be here so fromHTML() can see these fns!
nonlocal edit_start, delete_keymap, rebuild_action, add_keymap
# ^^^ this must be here so fromHTML() can see these fns!
ui_keymaps = self.document.body.getElementById('keymaps')
html = ''
for category,actions in keymap_details:
html += f'<details>'
html += f'<summary>{category}</summary>'
html += f'<table>'
for a,al in actions:
aid = action_to_id(a)
html += f'<tr>'
html += f'<td class="action">{al}:</td>'
html += f'<td id="keymap-{aid}" class="keymap">{keymap_html(a, al)}</td>'
html += f'</tr>'
html += f'</table>'
html += f'</details>'
ui_keymaps.clear_children()
ui_keymaps.append_children(UI_Element.fromHTML(html))
ui_keymaps = UI_Element.fromHTMLFile(abspath('../html/keymaps_dialog.html'))
self.document.body.append_children(ui_keymaps)
self.document.body.getElementById('keymapconfig').style = 'display: none'
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
self.document.body.getElementById('edit-ctrl').innerText = convert_actions_to_human_readable('CTRL')
self.document.body.getElementById('edit-shift').innerText = convert_actions_to_human_readable('SHIFT')
self.document.body.getElementById('edit-oskey').innerText = convert_actions_to_human_readable('OSKEY')
self.document.body.getElementById('edit-alt').innerText = convert_actions_to_human_readable('ALT')
rebuild()
self.document.body.dirty()
keymap_details = [
('General', [
('confirm', 'Confirm'),
('confirm drag', 'Confirm with Drag (sometimes this is needed for certain actions)'),
('confirm quick', 'Confirm with quick switch tool'),
('cancel', 'Cancel'),
('done', 'Quit RetopoFlow'),
('done alt0', 'Quit RetopoFlow (alternative)'),
('toggle ui', 'Toggle UI visibility'),
('blender passthrough', 'Blender passthrough'),
]),
('Insert, Move, Rotate, Scale', [
('insert', 'Insert new geometry'),
('quick insert', 'Quick insert (Knife, Loops)'),
('increase count', 'Increase Count'),
('decrease count', 'Decrease Count'),
('action', 'Action'),
('action alt0', 'Action (alt0)'),
('action alt1', 'Action (alt1)'),
('grab', 'Grab / move'),
('rotate', 'Rotate'),
('scale', 'Scale'),
('delete', 'Show delete menu'),
('delete pie menu', 'Show delete/dissolve/merge pie menu'),
# ('merge', 'Show merge menu'),
('smooth edge flow', 'Smooth edge flow of selected geometry'),
('rotate plane', 'Contours: rotate plane'),
('rotate screen', 'Contours: rotate screen'),
('slide', 'Loops: slide loop'),
('fill', 'Patches: fill'),
('knife reset', 'Knife: reset'),
('rip', 'PolyPen: rip'),
('rip fill', 'PolyPen: rip fill'),
]),
('Selection', [
('select all', 'Select all'),
('select invert', 'Select invert'),
('select linked', 'Select linked'),
('select linked mouse', 'Select linked under mouse'),
('deselect linked mouse', 'Deselect linked under mouse'),
('deselect all', 'Deselect all'),
('select single', 'Select single item (default depends on Blender selection setting)'),
('select single add', 'Add single item to selection (default depends on Blender selection setting)'),
('select smart', 'Smart selection (default depends on Blender selection setting)'),
('select smart add', 'Smart add to selection (default depends on Blender selection setting)'),
('select paint', 'Selection painting (default depends on Blender selection setting)'),
('select paint add', 'Paint to add to selection (default depends on Blender selection setting)'),
('select path add', 'Select along shortest path (default depends on Blender selection setting)'),
]),
('Geometry Attributes', [
('hide selected', 'Hide selected geometry'),
('hide unselected', 'Hide unselected geometry'),
('reveal hidden', 'Reveal hidden geometry'),
('pin', 'Pin selected geometry'),
('unpin', 'Unpin selected geometry'),
('unpin all', 'Unpin all geometry'),
('mark seam', 'Mark selected edges as seam'),
('clear seam', 'Unmark selected edges as seam'),
]),
('Switching Between Tools', [
('contours tool', 'Switch to Contours'),
('polystrips tool', 'Switch to PolyStrips'),
('strokes tool', 'Switch to Strokes'),
('patches tool', 'Switch to Patches'),
('polypen tool', 'Switch to PolyPen'),
('knife tool', 'Switch to Knife'),
('knife quick', 'Quick switch to Knife'),
('loops tool', 'Switch to Loops'),
('loops quick', 'Quick switch to Loops'),
('tweak tool', 'Switch to Tweak'),
('tweak quick', 'Quick switch to Tweak'),
('relax tool', 'Switch to Relax'),
('relax quick', 'Quick switch to Relax'),
('select tool', 'Switch to Select'),
('select quick', 'Quick switch to Select'),
]),
('Brush Actions', [
('brush', 'Brush'),
('brush alt', 'Brush (alt)'),
('brush radius', 'Change brush radius'),
('brush radius increase', 'Increase brush radius'),
('brush radius decrease', 'Decrease brush radius'),
('brush falloff', 'Change brush falloff'),
('brush strength', 'Change brush strength'),
]),
('Pie Menus', [
('pie menu', 'Show pie menu'),
('pie menu alt0', 'Show tool/alt pie menu'),
('pie menu confirm', 'Confirm pie menu selection'),
]),
('Help', [
('all help', 'Show all help'),
('general help', 'Show general help'),
('tool help', 'Show help for selected tool'),
]),
]
ignored_keys = {
'autosave',
'grease clear', 'grease pencil tool',
'stretch tool',
'toggle full area',
'reload css',
'select box', 'select box del', 'select box add',
'merge',
}
# check that all keymaps are able to be edited
def check_keymap_editor():
flattened_details = { key for (_, keyset) in keymap_details for (key, _) in keyset }
default_keys = set(default_rf_keymaps.keys()) - ignored_keys
missing_keys = default_keys - flattened_details
extra_keys = flattened_details - default_keys
if not missing_keys and not extra_keys: return
print(f'Error detected in keymap editor')
if missing_keys: print(f'Missing Keys: {sorted(missing_keys)}\nEither add to keymap_details or ignored_keys in rf_keymapsystem.py')
if extra_keys: print(f'Extra Keys: {sorted(extra_keys)}\nRemove these from keymap_details')
assert False
check_keymap_editor()
@@ -0,0 +1,220 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson
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 bpy
import json
import time
import functools
from datetime import datetime
from itertools import chain
from mathutils import Matrix, Vector
from bpy_extras.object_utils import object_data_add
from .rf_blender_objects import RetopoFlow_Blender_Objects
from ...config.options import sessionoptions, options
from ...addon_common.cookiecutter.cookiecutter_blender import CookieCutter_Blender
from ...addon_common.common.globals import Globals
from ...addon_common.common.decorators import blender_version_wrapper
from ...addon_common.common.blender import set_object_selection, set_active_object, get_active_object, get_view3d_space
from ...addon_common.common.blender import toggle_screen_header, toggle_screen_toolbar, toggle_screen_properties, toggle_screen_lastop
from ...addon_common.common.maths import BBox, XForm, Point
from ...addon_common.common.debug import dprint
class RetopoFlow_Normalize:
'''
allows RetopoFlow to work with normalized lengths
'''
def update_view_sessionoptions(self, context):
space = get_view3d_space(context)
r3d = space.region_3d
normalize_opts = sessionoptions['normalize']
fac = normalize_opts['view scaling factor']
view_opts = normalize_opts['view']
view_opts['distance'] = r3d.view_distance / fac
view_opts['location'] = r3d.view_location / fac
@CookieCutter_Blender.blender_change_callback
def blenderui_change_callback(self, storage):
sessionoptions['blender'] = dict(storage)
@staticmethod
def _normalize_set(
*,
factor=None, # ignored if None or <= 0
context=None, space=None,
restore_all=False,
view='SCALE', # {'SCALE', 'OVERRIDE', 'RESTORE', 'IGNORE'}
view_distance=None, view_location=None, # ignored if view != 'OVERRIDE'
clip='SCALE', # {'SCALE', 'OVERRIDE', 'RESTORE', 'IGNORE'}
clip_start=None, clip_end=None, # ignored if clip != 'OVERRIDE'
mesh='SCALE', # {'SCALE', 'RESTORE', 'IGNORE'}
):
assert context or space, f'Must specify either context or space'
if not space: space = get_view3d_space(context)
assert space.type == 'VIEW_3D', f"space.type must be 'VIEW_3D', not '{space.type}'"
r3d = space.region_3d
normalize_opts = sessionoptions['normalize']
if restore_all:
view = clip = mesh = 'RESTORE'
factor = 1.0
rf_target = RetopoFlow_Blender_Objects.get_target()
sessionoptions['retopoflow']['target'] = rf_target.name
print(f'RetopoFlow: scaling to {factor=}, {view=}, {clip=}, {mesh=}')
# scale view
orig_view = normalize_opts['view']
if view in {'SCALE', 'RESTORE'}:
fac = factor if view == 'SCALE' else 1.0
if fac and fac > 0.0:
r3d.view_distance = orig_view['distance'] * fac
r3d.view_location = Vector(orig_view['location']) * fac
normalize_opts['view scaling factor'] = fac
elif view == 'OVERRIDE':
if view_distance is not None: r3d.view_distance = view_distance
if view_location is not None: r3d.view_location = view_location
elif view == 'IGNORE':
pass
else:
assert False, f'unexpected view ({view})'
# scale clip start and end
orig_clip = normalize_opts['clip distances']
if clip in {'SCALE', 'RESTORE'}:
fac = (factor if clip == 'SCALE' else 1.0) or 0.0
if fac > 0.0:
space.clip_start = orig_clip['start'] * fac
space.clip_end = orig_clip['end'] * fac
elif clip == 'OVERRIDE':
if clip_start is not None: space.clip_start = clip_start
if clip_end is not None: space.clip_end = clip_end
elif clip == 'IGNORE':
pass
else:
assert False, f'unexpected clip ({clip})'
# scale meshes
if mesh in {'SCALE', 'RESTORE'}:
fac = (factor if mesh == 'SCALE' else 1.0) or 0.0
if fac > 0.0:
prev_factor = normalize_opts['mesh scaling factor']
M = (Matrix.Identity(3) * (fac / prev_factor)).to_4x4()
sources = RetopoFlow_Blender_Objects.get_sources()
targets = [rf_target]
for obj in chain(sources, targets):
if not obj: continue
armature = next((mod.object for mod in obj.modifiers if mod.type == 'ARMATURE'), None)
if not armature:
obj.matrix_world = M @ obj.matrix_world
else:
print(f' {obj.name} has an armature modifier with object {armature.name}')
# armature.matrix_world = M @ armature.matrix_world
obj.matrix_world = M @ obj.matrix_world
normalize_opts['mesh scaling factor'] = fac
elif mesh == 'IGNORE':
pass
else:
assert False, f'unexpected mesh ({mesh})'
@property
def unit_scaling_factor(self):
normalize_opts = sessionoptions['normalize']
return normalize_opts['unit scaling factor']
@staticmethod
def end_normalize(context):
print('RetopoFlow: unscaling from unit box')
RetopoFlow_Normalize._normalize_set(context=context, restore_all=True)
def start_normalize(self):
print('RetopoFlow: scaling to unit box')
self._normalize_set(
factor=self.unit_scaling_factor,
space=self.context.space_data,
clip='OVERRIDE' if options['clip override'] else 'SCALE',
clip_start=options['clip start override'],
clip_end=options['clip end override'],
)
self.scene_scale_set(1.0)
def init_normalize(self):
'''
initializes normalize functions
call only once!
'''
self.blender_change_init(sessionoptions['blender'])
normalize_opts = sessionoptions['normalize']
space = self.context.space_data
assert space.type == 'VIEW_3D', f"space.type must be 'VIEW_3D', not '{space.type}'"
r3d = space.region_3d
# store original clip distances
print(f'RetopoFlow: storing clip distances: {space.clip_start} {space.clip_end}')
normalize_opts['clip distances'] = {
'start': space.clip_start,
'end': space.clip_end,
}
# store original view
print(f'RetopoFlow: storing view: {r3d.view_location} {r3d.view_distance}')
normalize_opts['view'] = {
'distance': r3d.view_distance,
'location': r3d.view_location,
}
print('RetopoFlow: computing unit scaling factor')
normalize_opts['unit scaling factor'] = self._compute_unit_scaling_factor()
print(f' Unit scaling factor: {self.unit_scaling_factor}')
self.start_normalize()
@staticmethod
def _compute_unit_scaling_factor():
def get_source_bbox(s):
verts = [s.matrix_world @ Vector((v[0], v[1], v[2], 1)) for v in s.bound_box]
verts = [(v[0] / v[3], v[1] / v[3], v[2] / v[3]) for v in verts]
return BBox(from_coords=verts)
sources = RetopoFlow_Blender_Objects.get_sources()
if not sources: return 1.0
bbox = BBox.merge( get_source_bbox(s) for s in sources )
max_length = bbox.get_max_dimension()
scene_scale = 1.0 # bpy.context.scene.unit_settings.scale_length
magic_scale = 10.0 # to make the unit box manageable
return (scene_scale * magic_scale) / max_length
@@ -0,0 +1,166 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
'''
import math
import time
import random
from itertools import chain
from collections import deque
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.decorators import timed_call
from ...addon_common.common.drawing import Cursors
from ...addon_common.common.fsm import FSM
from ...addon_common.common.maths import Vec2D, Point2D, RelPoint2D, Direction2D
from ...addon_common.common.profiler import profiler
from ...addon_common.common.ui_core import UI_Element
from ...addon_common.common.utils import normalize_triplequote, Dict
from ...config.options import options, retopoflow_files
from ...addon_common.common.timerhandler import StopwatchHandler
class RetopoFlow_PieMenu:
def which_pie_menu_section(self):
delta = self.actions.mouse - self.pie_menu_center
if delta.length < self.pie_menu_center_size / 2: return None
count = len(self.pie_menu_options)
clock_deg = (math.atan2(-delta.y, delta.x) * 180 / math.pi - self.pie_menu_rotation) % 360
section = math.floor((clock_deg + 360 / count / 2) % 360 / (360 / count))
return section
@staticmethod
def _estimate_text_height(text, wrap_width):
font_w, font_h = 6, 16 # very rough approximation!
line_count = 0
for line in text.splitlines():
text_w = max(1, len(line)) * font_w # approx width of text w/o wrapping
line_count += math.ceil(text_w / wrap_width) # approx num of lines w/ wrapping
text_h = max(1, line_count) * font_h # approx height of text w/ wrapping
return text_h
@FSM.on_state('pie menu', 'enter')
def pie_menu_enter(self):
scale = self.drawing.scale
doc_h = self.document.body.height_pixels
menu = Dict(
size = 512, # size of full menu
radius = 204, # size of option center ring (40% of menu)
option = 72, # size of option
inner = 100, # size of inner circle
)
center = self.actions.mouse - Vec2D((scale(menu.size) / 2, -scale(menu.size) / 2)) - Vec2D((0, doc_h))
ui_pie_menu_contents = self.ui_pie_menu.getElementById('pie-menu-contents')
ui_pie_menu_contents.clear_children()
ui_pie_menu_contents.style = ';'.join([
f'left:{center.x}px',
f'top:{center.y}px',
f'width:{menu.size}px',
f'height:{menu.size}px',
f'border-radius:{menu.size // 2}px',
f'padding:{menu.size // 2}px',
])
count = len(self.pie_menu_options)
self.ui_pie_sections = []
for i_option, option in enumerate(self.pie_menu_options):
if not option:
self.ui_pie_sections.append(None)
continue
if type(option) is str: option = (option,)
if type(option) is tuple: option = { k:v for k,v in zip(['text', 'value', 'image'], option) }
option.setdefault('value', option['text'])
option.setdefault('image', '')
self.pie_menu_options[i_option] = option['value']
r = ((i_option / count) * 360 + self.pie_menu_rotation) * (math.pi / 180)
left, top = scale(menu.radius) * math.cos(r) - (scale(menu.option)/2), -(scale(menu.radius) * math.sin(r) - (scale(menu.option)/2))
label = UI_Element.DIV(classes='pie-menu-option-text', innerText=option['text'])
image = None
highlight_class = 'highlighted' if option['value'] == self.pie_menu_highlighted else ''
if option['image']:
image = UI_Element.IMG(classes='pie-menu-option-image', src=option['image'], style=f'width:{menu.option}px')
else:
# TODO: actually handle vertical-align: middle!
text_h = self._estimate_text_height(option['text'], menu.option) # very rough approximation!
margin = (menu.option - text_h) // 2 # offset using margin
label.style = f'margin-top:{margin}px'
ui = UI_Element.DIV(
style=';'.join([
f'left:{int(left)}px',
f'top:{int(top)}px',
f'width:{menu.option}px',
f'height:{menu.option}px',
]),
classes=f"pie-menu-option {highlight_class}",
children=list(filter(None, [ label, image ])),
parent=ui_pie_menu_contents,
)
self.ui_pie_sections.append(ui)
UI_Element.DIV(
style=';'.join([
f'left:{-scale(menu.inner) // 2}px',
f'top:{scale(menu.inner) // 2}px',
f'width:{menu.inner}px',
f'height:{menu.inner}px',
f'border-radius:{menu.inner // 2}px',
]),
classes=f'pie-menu-inner',
parent=ui_pie_menu_contents,
)
self.ui_pie_menu.is_visible = True
self.pie_menu_center = self.actions.mouse
self.pie_menu_center_size = scale(menu.inner)
self.pie_menu_mouse = self.actions.mouse
self.document.focus(self.ui_pie_menu)
self.document.force_clean(self.actions.context)
@FSM.on_state('pie menu')
def pie_menu_main(self):
confirm_p = self.actions.pressed('pie menu confirm', ignoremods=True)
confirm_r = self.actions.released(self.pie_menu_release, ignoremods=True)
if confirm_p or confirm_r:
# setting display to none in case callback needs to show some UI
self.ui_pie_menu.is_visible = False
i_option = self.which_pie_menu_section()
option = self.pie_menu_options[i_option] if i_option is not None else None
if option is not None or self.pie_menu_always_callback:
self.pie_menu_callback(option)
return 'main' if confirm_r else 'pie menu wait'
if self.actions.pressed('cancel'):
return 'pie menu wait'
i_section = self.which_pie_menu_section()
for i_s,ui in enumerate(self.ui_pie_sections):
if not ui: continue
if i_s == i_section:
ui.add_pseudoclass('hover')
else:
ui.del_pseudoclass('hover')
@FSM.on_state('pie menu', 'exit')
def pie_menu_exit(self):
self.ui_pie_menu.is_visible = False
@FSM.on_state('pie menu wait')
def pie_menu_wait(self):
if self.actions.released(self.pie_menu_release, ignoremods=True):
return 'main'
@@ -0,0 +1,289 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson
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 bpy
import time
from math import isinf, isnan
from ...config.options import visualization, options
from ...addon_common.common.maths import BBox
from ...addon_common.common.profiler import profiler, time_it
from ...addon_common.common.debug import dprint
from ...addon_common.common.maths import Point, Vec, Direction, Normal, Ray, XForm, Plane
from ...addon_common.common.maths import Point2D
from ...addon_common.common.maths_accel import Accel2D
from ...addon_common.common.timerhandler import CallGovernor
from ..rfmesh.rfmesh import RFSource
from ..rfmesh.rfmesh_render import RFMeshRender
class RetopoFlow_Sources:
'''
functions to work on all source meshes (RFSource)
'''
# @profiler.function
def setup_sources(self):
''' find all valid source objects, which are mesh objects that are visible and not active '''
print(' rfsources...')
self.rfsources = [RFSource.new(src) for src in self.get_sources()]
print(' bboxes...')
self.sources_bbox = BBox.merge(rfs.get_bbox() for rfs in self.rfsources)
# dprint('%d sources found' % len(self.rfsources))
pass
opts = visualization.get_source_settings()
print(' drawing...')
self.rfsources_draw = [RFMeshRender.new(rfs, opts) for rfs in self.rfsources]
# dprint('%d sources found' % len(self.rfsources))
pass
print(' done!')
self._detected_bad_normals = False
self._warned_bad_normals = False
def done_sources(self):
for rfs in self.rfsources:
rfs.obj.to_mesh_clear()
del self.sources_bbox
del self.rfsources_draw
del self.rfsources
# @profiler.function
def setup_sources_symmetry(self):
xyplane,xzplane,yzplane = self.rftarget.get_xy_plane(),self.rftarget.get_xz_plane(),self.rftarget.get_yz_plane()
w2l_point = self.rftarget.w2l_point
rfsources_xyplanes = [e for rfs in self.rfsources for e in rfs.plane_intersection(xyplane)]
rfsources_xzplanes = [e for rfs in self.rfsources for e in rfs.plane_intersection(xzplane)]
rfsources_yzplanes = [e for rfs in self.rfsources for e in rfs.plane_intersection(yzplane)]
def gen_accel(edges, Point_to_Point2D):
nonlocal w2l_point
edges = [(w2l_point(v0), w2l_point(v1)) for (v0, v1) in edges]
return Accel2D.simple_edges('RFSource edges', edges, Point_to_Point2D)
self.rftarget.set_symmetry_accel(
gen_accel(rfsources_xyplanes, lambda p,_:[Point2D((p.x,p.y))]),
gen_accel(rfsources_xzplanes, lambda p,_:[Point2D((p.x,p.z))]),
gen_accel(rfsources_yzplanes, lambda p,_:[Point2D((p.y,p.z))]),
)
###################################################
# snap settings
snap_sources = {}
@staticmethod
def get_source_snap(name):
return RFContext_Sources.snap_sources.get(name, True)
@staticmethod
def set_source_snap(name, val):
RFContext_Sources.snap_sources[name] = val
def get_rfsource_snap(self, rfsource):
n = rfsource.get_obj_name()
return self.snap_sources.get(n, True)
###################################################
# ray casting functions
def raycast_sources_Ray(self, ray:Ray, *, correct_mirror=None, ignore_backface=None):
if correct_mirror is None: correct_mirror = options['symmetry mirror input']
ignore_backface = self.ray_ignore_backface_sources() if ignore_backface is None else ignore_backface
bp,bn,bi,bd,bo = None,None,None,None,None
for rfsource in self.rfsources:
if not self.get_rfsource_snap(rfsource): continue
hp,hn,hi,hd = rfsource.raycast(ray, ignore_backface=ignore_backface)
if hp is None: continue # did we miss?
if isinf(hd): continue # is distance infinitely far away?
if isnan(hd): continue # is distance NaN? (issue #1062)
if bp and bd < hd: continue # have we seen a closer hit already?
bp,bn,bi,bd,bo = hp,hn,hi,hd,rfsource
if correct_mirror and bp and bn: bp, bn = self.mirror_point_normal(bp, bn)
return (bp,bn,bi,bd)
def raycast_sources_Ray_all(self, ray:Ray):
return [
hit
for rfsource in self.rfsources
for hit in rfsource.raycast_all(ray)
if self.get_rfsource_snap(rfsource)
]
def raycast_sources_Point2D(self, xy:Point2D, *, correct_mirror=None, ignore_backface=None):
if xy is None: return None,None,None,None
return self.raycast_sources_Ray(self.Point2D_to_Ray(xy, min_dist=self.drawing.space.clip_start), correct_mirror=correct_mirror, ignore_backface=ignore_backface)
def raycast_sources_Point2D_all(self, xy:Point2D):
if xy is None: return None,None,None,None
return self.raycast_sources_Ray_all(self.Point2D_to_Ray(xy, min_dist=self.drawing.space.clip_start))
def raycast_sources_mouse(self, *, correct_mirror=None, ignore_backface=None):
return self.raycast_sources_Point2D(self.actions.mouse, correct_mirror=correct_mirror, ignore_backface=ignore_backface)
def raycast_sources_Point(self, xyz:Point, *, correct_mirror=None, ignore_backface=None):
if xyz is None: return None,None,None,None
xy = self.Point_to_Point2D(xyz)
return self.raycast_sources_Point2D(xy, correct_mirror=correct_mirror, ignore_backface=ignore_backface)
###################################################
# nearest surface point (snapping) functions
def nearest_sources_Point(self, point:Point, max_dist=float('inf')): #sys.float_info.max):
bp,bn,bi,bd = None,None,None,None
for rfsource in self.rfsources:
if not self.get_rfsource_snap(rfsource): continue
hp,hn,hi,hd = rfsource.nearest(point, max_dist=max_dist)
if bp is None or (hp is not None and hd < bd):
bp,bn,bi,bd = hp,hn,hi,hd
return (bp,bn,bi,bd)
###################################################
# plane intersection
def plane_intersection_crawl(self, ray:Ray, plane:Plane, walk_to_plane=False):
bp,bn,bi,bd,bo = None,None,None,None,None
for rfsource in self.rfsources:
if not self.get_rfsource_snap(rfsource): continue
hp,hn,hi,hd = rfsource.raycast(ray)
if bp is None or (hp is not None and hd < bd):
bp,bn,bi,bd,bo = hp,hn,hi,hd,rfsource
if not bo: return []
return bo.plane_intersection_crawl(ray, plane, walk_to_plane=walk_to_plane)
def plane_intersections_crawl(self, plane:Plane):
return [crawl for rfsource in self.rfsources for crawl in rfsource.plane_intersections_crawl(plane) if self.get_rfsource_snap(rfsource)]
###################################################
# visibility testing
def ray_ignore_backface_sources(self):
return self.shading_backface_get()
def _raycast_hit_any(self, ray, ignore_backface):
return any(
rfsource.raycast_hit(ray, ignore_backface=ignore_backface)
for rfsource in self.rfsources if self.get_rfsource_snap(rfsource)
)
def gen_is_visible(self, *, bbox_factor_override=None, dist_offset_override=None, occlusion_test_override=None, backface_test_override=None):
backface_test = options['selection backface test'] if backface_test_override is None else backface_test_override
occlusion_test = options['selection occlusion test'] if occlusion_test_override is None else occlusion_test_override
bbox_factor = options['visible bbox factor'] if bbox_factor_override is None else bbox_factor_override
dist_offset = options['visible dist offset'] if dist_offset_override is None else dist_offset_override
max_dist_offset = self.sources_bbox.get_min_dimension() * bbox_factor + dist_offset
Point_to_Point2D = self.Point_to_Point2D
Point_to_Ray = self.Point_to_Ray
raycast_hit_any = self._raycast_hit_any
ray_ignore_backface_sources = self.ray_ignore_backface_sources()
area_x, area_y = self.actions.size.x, self.actions.size.y
clip_start = self.drawing.space.clip_start
vec_fwd = self.Vec_forward()
def is_inside_area(point):
return (p2D := Point_to_Point2D(point)) and (0 <= p2D.x <= area_x) and (0 <= p2D.y <= area_y)
def is_facing_correctly(normal):
return not backface_test or (not normal) or vec_fwd.dot(normal) <= 0
def is_not_occluded(point):
return not occlusion_test or ((ray := Point_to_Ray(point, min_dist=clip_start, max_dist_offset=-max_dist_offset)) and not raycast_hit_any(ray, ray_ignore_backface_sources))
def is_visible(point:Point, normal:Normal=None):
return is_inside_area(point) and is_facing_correctly(normal) and is_not_occluded(point)
return is_visible
def gen_is_nonvisible(self, *args, **kwargs):
is_visible = self.gen_is_visible(*args, **kwargs)
def is_nonvisible(*args, **kwargs):
return not is_visible(*args, **kwargs)
return is_nonvisible
def is_visible(self, point:Point, normal:Normal=None, bbox_factor_override=None, dist_offset_override=None, occlusion_test_override=None, backface_test_override=None):
backface_test = options['selection backface test'] if backface_test_override is None else backface_test_override
occlusion_test = options['selection occlusion test'] if occlusion_test_override is None else occlusion_test_override
bbox_factor = options['visible bbox factor'] if bbox_factor_override is None else bbox_factor_override
dist_offset = options['visible dist offset'] if dist_offset_override is None else dist_offset_override
max_dist_offset = self.sources_bbox.get_min_dimension() * bbox_factor + dist_offset
# find where point projects to screen
p2D = self.Point_to_Point2D(point)
if not p2D: return False
if not (0 <= p2D.x <= self.actions.size.x) or not (0 <= p2D.y <= self.actions.size.y): return False
# compute ray through projection point
ray = self.Point_to_Ray(point, min_dist=self.drawing.space.clip_start, max_dist_offset=-max_dist_offset)
if not ray: return False
# run backfacing test if applicable
if backface_test and normal and normal.dot(ray.d) >= 0: return False
# run occlusion test if applicable
if occlusion_test and self._raycast_hit_any(ray, self.ray_ignore_backface_sources()): return False
# point is visible!
return True
def is_nonvisible(self, *args, **kwargs):
return not self.is_visible(*args, **kwargs)
def visibility_preset_normal(self):
options['visible bbox factor'] = 0.001
options['visible dist offset'] = 0.1
self.get_accel_visible()
def visibility_preset_tiny(self):
options['visible bbox factor'] = 0.0
options['visible dist offset'] = 0.0004
self.get_accel_visible()
###################################################
# normal check
@CallGovernor.limit(time_limit=0.25)
def normal_check(self):
if not options['warning normal check']: return # user wishes not to do this check :(
if self._warned_bad_normals: return # already warned this session
if not self._detected_bad_normals: return # no bad normals detected
# _,hn,_,_ = self.raycast_sources_mouse()
# vd = self.Point2D_to_Direction(self.actions.mouse)
# if not hn: return # did not hit source mesh
# if vd.dot(hn) < 0: return # facing correct direction (opposite of viewing direction)
self._warned_bad_normals = True # only warn once
message = ['\n'.join([
'One of the sources has inward facing normals.',
'Inward facing normals will cause new geometry to be created incorrectly or to prevent it from being selected.',
'',
'Possible fix: exit RetopoFlow, switch to Edit Mode on the source mesh, recalculate normals, then try RetopoFlow again.',
])]
self.alert_user(
title='Source(s) with inverted normals',
message='\n\n'.join(message),
level='warning',
)
@@ -0,0 +1,212 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson
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 bpy
from mathutils import Matrix, Vector
from bpy_extras.view3d_utils import (
location_3d_to_region_2d,
region_2d_to_vector_3d,
region_2d_to_location_3d,
region_2d_to_origin_3d,
)
from ...config.options import options
from ...addon_common.common.debug import dprint
from ...addon_common.common.profiler import profiler
from ...addon_common.common.maths import Point, Vec, Direction, Normal
from ...addon_common.common.maths import Ray, XForm, Plane
from ...addon_common.common.maths import Point2D, Vec2D, Direction2D
from ...addon_common.common.decorators import blender_version_wrapper
class RetopoFlow_Spaces:
'''
converts entities between screen space and world space
Note: if 2D is not specified, then it is a 1D or 3D entity (whichever is applicable)
'''
def update_clip_settings(self, *, rescale=True):
if options['clip auto adjust']:
# adjust clipping settings
view_origin = self.drawing.get_view_origin(orthographic_distance=1000)
view_focus = self.actions.r3d.view_location
bbox = self.sources_bbox
closest = bbox.closest_Point(view_origin)
farthest = bbox.farthest_Point(view_origin)
self.drawing.space.clip_start = max(
options['clip auto start min'],
(view_origin - closest).length * options['clip auto start mult'],
)
self.drawing.space.clip_end = min(
options['clip auto end max'],
(view_origin - farthest).length * options['clip auto end mult'],
)
# print(f'clip auto adjusting')
# print(f' origin: {view_origin}')
# print(f' focus: {view_focus}')
# print(f' closest: {closest}')
# print(f' farthest: {farthest}')
# print(f' dist from origin to closest: {(view_origin - closest).length}')
# print(f' dist from origin to farthest: {(view_origin - farthest).length}')
# print(f' dist from origin to focus: {(view_origin - view_focus).length}')
# print(f' clip_start: {self.drawing.space.clip_start}')
# print(f' clip_end: {self.drawing.space.clip_end}')
elif rescale:
self.end_normalize(self.context)
self.start_normalize()
# self.unscale_from_unit_box()
# self.scale_to_unit_box(
# clip_override=options['clip override'],
# clip_start=options['clip start override'],
# clip_end=options['clip end override'],
# )
def get_view_origin(self):
# does not work in ORTHO
view_loc = self.actions.r3d.view_location
view_dist = self.actions.r3d.view_distance
view_rot = self.actions.r3d.view_rotation
view_cam = Point(view_loc + (view_rot @ Vector((0,0,view_dist))))
return view_cam
def get_view_direction(self):
view_rot = self.actions.r3d.view_rotation
return Direction(view_rot @ Vector((0, 0, -1)))
def Point2D_to_Vec(self, xy:Point2D):
if xy is None: return None
v = region_2d_to_vector_3d(self.actions.region, self.actions.r3d, xy)
if v is None: return None
return Vec(v)
def Point2D_to_Direction(self, xy:Point2D):
if xy is None: return None
d = region_2d_to_vector_3d(self.actions.region, self.actions.r3d, xy)
if d is None: return None
return Direction(d)
def Point2D_to_Origin(self, xy:Point2D):
if xy is None: return None
o = region_2d_to_origin_3d(self.actions.region, self.actions.r3d, xy)
if o is None: return None
return Point(o)
def Point2D_to_Ray(self, xy:Point2D, *, min_dist=0.0):
if xy is None: return None
o, d = self.Point2D_to_Origin(xy), self.Point2D_to_Direction(xy)
if o is None or d is None: return None
return Ray(o, d, min_dist=min_dist)
def Point2D_to_Point(self, xy:Point2D, depth:float):
r = self.Point2D_to_Ray(xy)
if r is None or r.o is None or r.d is None or depth is None:
# dprint(r)
pass
# dprint(depth)
pass
return None
return r.eval(depth) # Point(r.o + depth * r.d)
#return Point(region_2d_to_location_3d(self.actions.region, self.actions.r3d, xy, depth))
def Point2D_to_Plane(self, xy0:Point2D, xy1:Point2D):
ray0,ray1 = self.Point2D_to_Ray(xy0),self.Point2D_to_Ray(xy1)
o = ray0.o + ray0.d
n = Normal((ray1.o + ray1.d - o).cross(ray0.d))
return Plane(o, n)
def Point_to_Point2D(self, xyz:Point):
if not xyz: return None
xy = location_3d_to_region_2d(self.actions.region, self.actions.r3d, xyz)
if xy is None: return None
return Point2D(xy)
alerted_small_clip_start = False
def Point_to_depth(self, xyz):
'''
computes the distance of point (xyz) from view camera
'''
if not xyz: return None
xy = self.Point_to_Point2D(xyz)
if xy is None: return None
oxyz = self.Point2D_to_Origin(xy)
return (xyz - oxyz).length
def Point_to_Direction(self, xyz:Point):
if not xyz: return None
xy = location_3d_to_region_2d(self.actions.region, self.actions.r3d, xyz)
return self.Point2D_to_Direction(xy)
# @profiler.function
def Point_to_Ray(self, xyz:Point, min_dist=0, max_dist_offset=0):
if not xyz: return None
xy = location_3d_to_region_2d(self.actions.region, self.actions.r3d, xyz)
if not xy: return None
o = self.Point2D_to_Origin(xy)
#return Ray.from_segment(o, xyz)
d = self.Point2D_to_Vec(xy)
if o is None or d is None: return None
dist = (o - xyz).length
return Ray(o, d, min_dist=min_dist, max_dist=dist+max_dist_offset)
def size2D_to_size(self, size2D:float, depth:float):
# computes size of 3D object at distance (depth) as it projects to 2D size
# TODO: there are more efficient methods of computing this!
# find center of screen
xy = Vec2D((self.actions.region.width, self.actions.region.height)) * 0.5
# note: scaling then unscaling helps with numerical instability when clip_start is small
scale = 1000.0
p3d0 = self.Point2D_to_Point(xy, depth)
p3d1 = self.Point2D_to_Point(xy + Vec2D((0, scale * size2D)), depth)
if not p3d0 or not p3d1: return None
return (p3d0 - p3d1).length / scale
def size_to_size2D(self, size:float, xyz:Point):
if not xyz: return None
xy = self.Point_to_Point2D(xyz)
if not xy: return None
pt2D = self.Point_to_Point2D(xyz - self.Vec_up() * size)
if not pt2D: return None
return abs(xy.y - pt2D.y)
def Point2D_in_area(self, p2D):
return p2D and (0 <= p2D.x <= self.actions.size.x) and (0 <= p2D.y <= self.actions.size.y)
#############################################
# return camera up and right vectors
def Vec_up(self):
# TODO: remove invert!
return self.actions.r3d.view_matrix.to_3x3().inverted_safe() @ Vector((0,1,0))
def Vec_right(self):
# TODO: remove invert!
return self.actions.r3d.view_matrix.to_3x3().inverted_safe() @ Vector((1,0,0))
def Vec_forward(self):
# TODO: remove invert!
return self.actions.r3d.view_matrix.to_3x3().inverted_safe() @ Vector((0,0,-1))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
'''
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/>.
'''
# the order of these tools dictates the order tools show in UI
from ..rftool_select.select import Select
from ..rftool_contours.contours import Contours
from ..rftool_polystrips.polystrips import PolyStrips
from ..rftool_strokes.strokes import Strokes
from ..rftool_patches.patches import Patches
from ..rftool_polypen.polypen import PolyPen
from ..rftool_knife.knife import Knife
from ..rftool_loops.loops import Loops
from ..rftool_tweak.tweak import Tweak
from ..rftool_relax.relax import Relax
from ..rftool import RFTool
from ...config.options import options
class RetopoFlow_Tools:
def setup_rftools(self):
self.rftool = None
self.rftools = [rftool(self) for rftool in RFTool.registry]
self._rftool_return = None
def reset_rftool(self):
self.rftool._reset()
def _select_rftool(self, rftool, *, reset=True, quick=False):
assert rftool in self.rftools
# return if tool already set
if rftool == self.rftool:
if reset: self.reset_rftool()
return False
self.rftool = rftool
if reset:
self.reset_rftool()
self._update_rftool_ui()
self.update_ui()
if quick:
self.rftool._callback('quickswitch start')
return True
def _update_rftool_ui(self):
rftool = self.rftool
self.ui_main.getElementById(f'tool-{rftool.name.lower()}').checked = True
self.ui_tiny.getElementById(f'ttool-{rftool.name.lower()}').checked = True
self.ui_main.dirty(cause='changed tools', children=True)
self.ui_tiny.dirty(cause='changed tools', children=True)
statusbar_keymap = self.substitute_keymaps(rftool.statusbar, wrap='', pre='', post=':', separator='/', onlyfirst=2)
statusbar_keymap = statusbar_keymap.replace('\t', ' ')
if self._rftool_return and self._rftool_return != rftool:
statusbar = f'{self._rftool_return.name}{rftool.name}: {statusbar_keymap}'
else:
statusbar = f'{rftool.name}: {statusbar_keymap}'
self.context.workspace.status_text_set(statusbar)
def select_rftool(self, rftool, *, reset=True):
self.rftool_return = None
if self._select_rftool(rftool, reset=reset):
# remember this tool as last used, so clicking diamond can start with this tool
options['starting tool'] = rftool.name
def quick_select_rftool(self, rftool, *, reset=True):
prev_tool = self.rftool
if self._select_rftool(rftool, reset=reset, quick=True):
self._rftool_return = prev_tool
self._update_rftool_ui()
def quick_restore_rftool(self, *, reset=True):
if not self._rftool_return: return
if self.select_rftool(self._rftool_return, reset=reset):
self._rftool_return = None
self._update_rftool_ui()
@@ -0,0 +1,491 @@
'''
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 gc
import os
import sys
import json
import time
import shutil
import inspect
from datetime import datetime
import contextlib
import urllib.request
import bpy
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
from ...addon_common.common.boundvar import BoundVar, BoundBool, BoundFloat, BoundString, BoundInt
from ...addon_common.common.utils import delay_exec
from ...addon_common.common.globals import Globals
from ...addon_common.common.blender import get_path_from_addon_root
from ...addon_common.common.blender_preferences import get_preferences
from ...addon_common.common.ui_core import UI_Element
from ...addon_common.common.ui_styling import load_defaultstylings
from ...addon_common.common.profiler import profiler
from ...config.options import (
options, themes, visualization,
retopoflow_urls, retopoflow_product, # these are needed for UI
build_platform,
platform_system, platform_node, platform_release, platform_version, platform_machine, platform_processor,
)
class RetopoFlow_UI:
@CookieCutter.Exception_Callback
def handle_exception(self, e):
print(f'RF_UI.handle_exception: {e}')
if False:
for entry in inspect.stack():
print(f' {entry}')
message,h = Globals.debugger.get_exception_info_and_hash()
message = '\n'.join(f'- {l}' for l in message.splitlines())
self.alert_user(title='Exception caught', message=message, level='exception', msghash=h)
self.rftool._reset()
#################################
# pie menu
def setup_pie_menu(self):
path_pie_menu_html = get_path_from_addon_root('retopoflow', 'html', 'pie_menu.html')
self.ui_pie_menu = UI_Element.fromHTMLFile(path_pie_menu_html)[0]
self.ui_pie_menu.can_hover = False
self.document.body.append_child(self.ui_pie_menu)
def show_pie_menu(self, options, fn_callback, highlighted=None, release=None, always_callback=False, rotate=0):
if len(options) == 0: return
self.pie_menu_rotation = rotate - 90
self.pie_menu_callback = fn_callback
self.pie_menu_options = list(options)
self.pie_menu_highlighted = highlighted
self.pie_menu_release = release or 'pie menu'
self.pie_menu_always_callback = always_callback
self.fsm.force_set_state('pie menu')
#################################
# ui
def blender_ui_set(self, scale_to_unit_box=True, add_rotate=True, hide_target=True):
# print('RetopoFlow: blender_ui_set', 'scale_to_unit_box='+str(scale_to_unit_box), 'add_rotate='+str(add_rotate))
bpy.ops.object.mode_set(mode='OBJECT')
if scale_to_unit_box:
self.start_normalize()
self.scene_scale_set(1.0)
self.viewaa_simplify()
if self.shading_type_get() in {'WIREFRAME', 'RENDERED'}:
self.shading_type_set('SOLID')
self.gizmo_hide()
if get_preferences().system.use_region_overlap or options['hide panels no overlap']:
ignore = None if options['hide header panel'] else {'header'}
self.panels_hide(ignore=ignore)
if options['hide overlays']:
self.overlays_hide()
self.blender_shading_update()
self.quadview_hide()
self.region_darken()
self.header_text_set('RetopoFlow')
self.statusbar_text_set('')
if add_rotate: self.setup_rotate_about_active()
if hide_target: self.hide_target()
def blender_shading_update(self):
if options['override shading'] == 'off':
self.shading_restore()
return
# common optimizations
self.shading_type_set(options['shading view'])
self.shading_backface_set(options['shading backface culling'])
self.shading_shadows_set(options['shading shadows'])
self.shading_xray_set(options['shading xray'])
self.shading_cavity_set(options['shading cavity'])
self.shading_outline_set(options['shading outline'])
# theme-based optimizations
matcap = None
if options['override shading'] == 'light':
self.shading_color_set(options['shading color light'])
self.shading_colortype_set(options['shading colortype'])
matcap = options['shading matcap light']
elif options['override shading'] == 'dark':
self.shading_color_set(options['shading color dark'])
self.shading_colortype_set(options['shading colortype'])
matcap = options['shading matcap dark']
if matcap:
if matcap not in bpy.context.preferences.studio_lights:
path_rf_matcap = os.path.join(get_path_from_addon_root('matcaps'), matcap)
print(f'RetopoFlow: Loading maptcap {matcap} {path_rf_matcap}')
ret = bpy.context.preferences.studio_lights.load(path_rf_matcap, 'MATCAP')
if not ret: matcap = None
if matcap:
self.shading_light_set(options['shading light'])
self.shading_matcap_set(matcap)
def blender_ui_reset(self, *, ignore_panels=False):
# IMPORTANT: changes here should also go in rf_blender_save.backup_recover()
self.end_rotate_about_active()
self.teardown_target()
self.end_normalize(self.context)
self._cc_blenderui_end(ignore=({'panels'} if ignore_panels else None))
bpy.ops.object.mode_set(mode='EDIT')
@contextlib.contextmanager
def blender_ui_pause(self, *, ignore_panels=False):
self.blender_ui_reset(ignore_panels=ignore_panels)
yield None
self.blender_ui_set()
self.update_clip_settings(rescale=False)
def setup_ui_blender(self):
self.blender_ui_set(scale_to_unit_box=False, add_rotate=False, hide_target=False)
def update_ui(self):
if not hasattr(self, 'rftools_ui'): return
autohide = options['tools autohide']
changed = False
for rftool in self.rftools_ui.keys():
show = not autohide or (rftool == self.rftool)
for ui_elem in self.rftools_ui[rftool]:
if ui_elem.get_is_visible() == show: continue
ui_elem.is_visible = show
changed = True
if changed:
self.ui_options.dirty(cause='update', parent=True, children=True)
def update_ui_geometry(self):
if not self.ui_geometry: return
vis = self.ui_geometry.is_visible
# TODO: FIX WORKAROUND HACK!
# toggle visibility as workaround hack for relaying out table :(
if vis: self.ui_geometry.is_visible = False
self.ui_geometry.getElementById('geometry-verts').innerText = f'{self.rftarget.get_vert_count()}'
self.ui_geometry.getElementById('geometry-edges').innerText = f'{self.rftarget.get_edge_count()}'
self.ui_geometry.getElementById('geometry-faces').innerText = f'{self.rftarget.get_face_count()}'
if vis: self.ui_geometry.is_visible = True
def minimize_geometry_window(self, target):
if target.id != 'geometrydialog': return
options['show geometry window'] = False
self.ui_geometry.is_visible = False
self.ui_geometry_min.is_visible = True
self.ui_geometry_min.left = self.ui_geometry.left
self.ui_geometry_min.top = self.ui_geometry.top
self.document.force_clean(self.actions.context)
def restore_geometry_window(self, target):
if target.id != 'geometrydialog-minimized': return
options['show geometry window'] = True
self.ui_geometry.is_visible = True
self.ui_geometry_min.is_visible = False
self.ui_geometry.left = self.ui_geometry_min.left
self.ui_geometry.top = self.ui_geometry_min.top
self.update_ui_geometry()
self.document.force_clean(self.actions.context)
def minimize_options_window(self, target):
if target.id != 'optionsdialog': return
options['show options window'] = False
self.ui_options.is_visible = False
self.ui_options_min.is_visible = True
self.ui_options_min.left = self.ui_options.left
self.ui_options_min.top = self.ui_options.top
self.document.force_clean(self.actions.context)
def restore_options_window(self, target):
if target.id != 'optionsdialog-minimized': return
options['show options window'] = True
self.ui_options.is_visible = True
self.ui_options_min.is_visible = False
self.ui_options.left = self.ui_options_min.left
self.ui_options.top = self.ui_options_min.top
self.document.force_clean(self.actions.context)
def show_options_window(self):
options['show options window'] = True
self.ui_options.is_visible = True
# self.ui_main.getElementById('show-options').disabled = True
def hide_options_window(self):
options['show options window'] = False
self.ui_options.is_visible = False
# self.ui_main.getElementById('show-options').disabled = False
def options_window_visibility_changed(self):
if self.ui_hide: return
visible = self.ui_options.is_visible
options['show options window'] = visible
# self.ui_main.getElementById('show-options').disabled = visible
def show_main_ui_window(self):
options['show main window'] = True
self.ui_tiny.is_visible = False
self.ui_main.is_visible = True
def show_tiny_ui_window(self):
options['show main window'] = False
self.ui_tiny.is_visible = True
self.ui_main.is_visible = False
def update_main_ui_window(self):
if self.ui_hide: return
if self._ui_windows_updating: return
pre = self._ui_windows_updating
self._ui_windows_updating = True
options['show main window'] = self.ui_main.is_visible
if not options['show main window']:
self.ui_tiny.is_visible = True
self.ui_tiny.left = self.ui_main.left
self.ui_tiny.top = self.ui_main.top
# self.ui_tiny.clean()
self._ui_windows_updating = pre
def update_tiny_ui_window(self):
if self.ui_hide: return
if self._ui_windows_updating: return
pre = self._ui_windows_updating
self._ui_windows_updating = True
options['show main window'] = not self.ui_tiny.is_visible
if options['show main window']:
self.ui_main.is_visible = True
self.ui_main.left = self.ui_tiny.left
self.ui_main.top = self.ui_tiny.top
# self.ui_main.clean()
self._ui_windows_updating = pre
def update_main_tiny_ui_windows(self):
if self.ui_hide: return
pre = self._ui_windows_updating
self._ui_windows_updating = True
self.ui_main.is_visible = options['show main window']
self.ui_tiny.is_visible = not options['show main window']
self._ui_windows_updating = pre
def setup_ui(self):
# NOTE: lambda is needed on next line so that RF keymaps are bound!
humanread = lambda x: self.actions.to_human_readable(x, sep=' / ')
self.hide_target()
# load ui.css
self.reload_stylings()
self.ui_hide = False
self._var_auto_hide_options = BoundBool('''options['tools autohide']''', on_change=self.update_ui)
rf_starting_tool = getattr(self, 'rf_starting_tool', None) or options['starting tool']
def setup_counts_ui():
self.document.body.append_children(UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'geometry.html')))
self.ui_geometry = self.document.body.getElementById('geometrydialog')
self.ui_geometry_min = self.document.body.getElementById('geometrydialog-minimized')
self.ui_geometry.is_visible = options['show geometry window']
self.ui_geometry_min.is_visible = not options['show geometry window']
self.update_ui_geometry()
def setup_tiny_ui():
nonlocal humanread
self.ui_tiny = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'main_tiny.html'))[0]
self.document.body.append_child(self.ui_tiny)
def setup_main_ui():
nonlocal humanread
self.ui_main = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'main_full.html'))[0]
self.document.body.append_child(self.ui_main)
def setup_tool_buttons():
ui_tools = self.ui_main.getElementById('tools')
ui_ttools = self.ui_tiny.getElementById('ttools')
def add_tool(rftool): # IMPORTANT: must be a fn so that local vars are unique and correctly captured
nonlocal self, humanread # IMPORTANT: need this so that these are captured
shortcut = humanread({rftool.shortcut})
quick = humanread({rftool.quick_shortcut}) if rftool.quick_shortcut else ''
title = f'{rftool.name}: {rftool.description}. Shortcut: {shortcut}.'
if quick: title += f' Quick: {quick}.'
val = f'{rftool.name.lower()}'
ui_tools.append_child(UI_Element.fromHTML(
f'<label title="{title}" class="tool">'
f'''<input type="radio" id="tool-{val}" value="{val}" name="tool" class="tool" on_input="if this.checked: self.select_rftool(rftool)">'''
f'<img src="{rftool.icon}" title="{title}">'
f'<span title="{title}">{rftool.name}</span>'
f'</label>'
)[0])
ui_ttools.append_child(UI_Element.fromHTML(
f'<label title="{title}" class="ttool">'
f'''<input type="radio" id="ttool-{val}" value="{val}" name="ttool" class="ttool" on_input="if this.checked: self.select_rftool(rftool)">'''
f'<img src="{rftool.icon}" title="{title}">'
f'</label>'
)[0])
for rftool in self.rftools: add_tool(rftool)
def setup_options():
nonlocal self, humanread
self.document.defer_cleaning = True
self.document.body.append_children(UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'options_dialog.html')))
self.ui_options = self.document.body.getElementById('optionsdialog')
self.ui_options_min = self.document.body.getElementById('optionsdialog-minimized')
self.ui_options.is_visible = options['show options window']
self.ui_options_min.is_visible = not options['show options window']
self.setup_pie_menu()
self.rftools_ui = {}
for rftool in self.rftools:
ui_elems = []
def add_elem(ui_elem):
if not ui_elem:
return
if type(ui_elem) is list:
for ui in ui_elem:
add_elem(ui)
return
ui_elems.append(ui_elem)
self.ui_options.getElementById('options-contents').append_child(ui_elem)
if rftool.ui_config:
path_folder = os.path.dirname(inspect.getfile(rftool.__class__))
path_html = os.path.join(path_folder, rftool.ui_config)
ret = rftool.call_with_self_in_context(UI_Element.fromHTMLFile, path_html)
add_elem(ret)
ret = rftool._callback('ui setup')
add_elem(ret)
self.rftools_ui[rftool] = ui_elems
for ui_elem in ui_elems:
self.ui_options.getElementById('options-contents').append_child(ui_elem)
# if options['show options window']:
# self.show_options_window()
# else:
# self.hide_options_window()
self.document.defer_cleaning = False
def setup_quit_ui():
def hide_ui_quit():
self.ui_quit.is_visible = False
self.document.sticky_element = None
self.document.clear_last_under()
def mouseleave_event():
if self.ui_quit.is_hovered: return
hide_ui_quit()
def key(e):
if e.key in {'ESC', 'TAB'}: hide_ui_quit()
if e.key in {'RET', 'NUMPAD_ENTER'}: self.done()
self.ui_quit = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'quit_dialog.html'))[0]
self.ui_quit.is_visible = False
self.document.body.append_child(self.ui_quit)
def setup_delete_ui():
def hide_ui_delete():
self.ui_delete.is_visible = False
self.document.sticky_element = None
self.document.clear_last_under()
def mouseleave_event():
if self.ui_delete.is_hovered: return
hide_ui_delete()
def key(e):
if e.key == 'ESC': hide_ui_delete()
def act(opt):
self.delete_dissolve_collapse_option(opt)
hide_ui_delete()
self.ui_delete = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'delete_dialog.html'))[0]
self.ui_delete.is_visible = False
self.document.body.append_child(self.ui_delete)
self._ui_windows_updating = True
setup_main_ui()
setup_tiny_ui()
setup_tool_buttons()
setup_options()
setup_quit_ui()
setup_delete_ui()
setup_counts_ui()
self.update_main_tiny_ui_windows()
self._ui_windows_updating = False
for rftool in self.rftools:
if rftool.name == rf_starting_tool:
self.select_rftool(rftool)
self.ui_tools = self.document.body.getElementsByName('tool')
self.update_ui()
def show_welcome_message(self):
show = options['welcome'] or options['version update']
if not show: return
options['version update'] = False
self.document.defer_cleaning = True
self.helpsystem_open('welcome.md')
self.document.defer_cleaning = False
def show_quit_dialog(self):
w,h = self.actions.region.width,self.actions.region.height
self.ui_quit.reposition(
left = self.actions.mouse.x - 100,
top = self.actions.mouse.y - h + 20,
)
self.ui_quit.is_visible = True
self.document.focus(self.ui_quit)
self.document.sticky_element = self.ui_quit
def show_delete_dialog(self):
if not self.any_selected():
self.alert_user('No geometry selected to delete/dissolve', title='Delete/Dissolve')
return
w,h = self.actions.region.width,self.actions.region.height
self.ui_delete.reposition(
left = self.actions.mouse.x - 100,
top = self.actions.mouse.y - h + 20,
)
self.ui_delete.is_visible = True
self.document.focus(self.ui_delete)
self.document.sticky_element = self.ui_delete
# # The following is what is done with dialogs
# self.document.force_clean(self.actions.context)
# self.document.center_on_mouse(win)
# self.document.sticky_element = win
def show_merge_dialog(self):
if not self.any_selected():
self.alert_user('No geometry selected to merge', title='Merge')
return
w,h = self.actions.region.width,self.actions.region.height
self.ui_delete.reposition(
left = self.actions.mouse.x - 100,
top = self.actions.mouse.y - h + 20,
)
self.ui_delete.is_visible = True
self.document.focus(self.ui_delete)
self.document.sticky_element = self.ui_delete
@@ -0,0 +1,350 @@
'''
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 gc
import os
import sys
import json
import time
import inspect
from datetime import datetime
import contextlib
from itertools import chain
import urllib.request
from concurrent.futures import ThreadPoolExecutor
import bpy
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
from ...addon_common.common.blender import get_path_from_addon_root
from ...addon_common.common.boundvar import BoundVar, BoundBool, BoundFloat, BoundString
from ...addon_common.common.globals import Globals
from ...addon_common.common.inspect import ScopeBuilder
from ...addon_common.common.profiler import profiler
from ...addon_common.common.ui_core import UI_Element
from ...addon_common.common.ui_styling import load_defaultstylings
from ...addon_common.common.utils import delay_exec
from ...addon_common.terminal.deepdebug import DeepDebug
from ...config.options import (
options, themes, visualization,
retopoflow_urls, retopoflow_product, retopoflow_files,
build_platform,
platform_system, platform_node, platform_release, platform_version, platform_machine, platform_processor,
gpu_info,
)
def get_environment_details():
blender_version = '%d.%02d.%d' % bpy.app.version
blender_branch = bpy.app.build_branch.decode('utf-8')
blender_date = bpy.app.build_commit_date.decode('utf-8')
if retopoflow_product['git version']: build_info = f'RF git: {retopoflow_product["git version"]}'
elif retopoflow_product['cgcookie built']:
if retopoflow_product['github']: build_info = f'CG Cookie built for GitHub'
elif retopoflow_product['blender market']: build_info = f'CG Cookie built for Blender Market'
else: build_info = f'CG Cookie built for ??'
else: build_info = f'Self built'
return [
f'Environment:',
f'',
f'- RetopoFlow: {retopoflow_product["version"]}',
f'- Build: {build_info}',
f'- Blender: {blender_version} {blender_branch} {blender_date}',
f'- Platform: {platform_system}, {platform_release}, {platform_version}, {platform_machine}, {platform_processor}',
f'- GPU: {gpu_info}',
f'- Timestamp: {datetime.today().isoformat(" ")}',
f'',
]
def get_trace_details(undo_stack, msghash=None, message=None):
trace_details = [f'Runtime:', f'']
trace_details += [f'- Undo: {", ".join(undo_stack[:10])}']
if msghash:
trace_details += [f'- Error Hash: {msghash}']
if message:
trace_details += ['', 'Trace:', '']
trace_details += [message]
trace_details += ['']
return trace_details
def get_debug_details():
debug = DeepDebug.read()
if not debug: return []
return ['Debug:', ''] + debug.splitlines()
class RetopoFlow_UI_Alert:
GitHub_checks = 0
GitHub_limit = 10
@CookieCutter.Exception_Callback
def handle_exception(self, e):
print('RetopoFlow_UI_Alert.handle_exception', e)
if False:
for entry in inspect.stack():
print(f' {entry}')
message, h = Globals.debugger.get_exception_info_and_hash()
message = '\n'.join(f'- {l}' for l in message.splitlines())
self.alert_user(
title='Exception caught',
message=message,
level='exception',
msghash=h,
)
if hasattr(self, 'rftool'): self.rftool._reset()
def alert_user(self, message=None, title=None, level=None, msghash=None):
scope = ScopeBuilder()
if not hasattr(self, '_msghashes'): self._msghashes = set()
if not hasattr(self, 'alert_windows'): self.alert_windows = 0
if msghash and msghash in self._msghashes: return # have already seen this error!!
self._msghashes.add(msghash)
show_quit = False
level = level.lower() if level else 'note'
blender_version = '%d.%02d.%d' % bpy.app.version
blender_branch = bpy.app.build_branch.decode('utf-8')
blender_date = bpy.app.build_commit_date.decode('utf-8')
darken = False
ui_checker = None
ui_show = None
message_orig = message
report_details = ''
msg_report = None
issue_body_report = None
if title is None and self.rftool: title = self.rftool.name
def screenshot():
ss_filename = retopoflow_files['screenshot filename']
if getattr(bpy.data, 'filepath', ''):
# loaded .blend file
filepath = os.path.split(os.path.abspath(bpy.data.filepath))[0]
filepath = os.path.join(filepath, ss_filename)
else:
# startup file
filepath = os.path.abspath(ss_filename)
bpy.ops.screen.screenshot(filepath=filepath)
self.alert_user(message=f'Saved screenshot to "{filepath}"')
def open_issues():
bpy.ops.wm.url_open(url=retopoflow_urls['github issues'])
def search():
url = f'https://github.com/CGCookie/retopoflow/issues?q=is%3Aissue+{msghash}'
bpy.ops.wm.url_open(url=url)
def report():
nonlocal issue_body_report
nonlocal report_details
path = get_path_from_addon_root('help', 'issue_template_simple.md')
issue_template = open(path, 'rt').read()
data = {
'title': f'{self.rftool.name}: {title}',
'body': f'{issue_template}\n\n```\n{issue_body_report}\n```',
}
url = f'{retopoflow_urls["new github issue"]}?{urllib.parse.urlencode(data)}'
bpy.ops.wm.url_open(url=url)
if msghash:
ui_checker = UI_Element.DETAILS(classes='issue-checker', open=True)
UI_Element.SUMMARY(innerText='Report an issue', parent=ui_checker)
ui_label = UI_Element.ARTICLE(classes='mdown', parent=ui_checker)
ui_buttons = UI_Element.DIV(parent=ui_checker, classes='action-buttons')
ui_label.set_markdown(mdown='Checking reported issues...')
def check_github():
nonlocal win, ui_buttons
buttons = 4
try:
if self.GitHub_checks < self.GitHub_limit:
self.GitHub_checks += 1
# attempt to see if this issue already exists!
# note: limited to 60 requests/hour! see
# https://developer.github.com/v3/#rate-limiting
# https://developer.github.com/v3/search/#rate-limit
# make it unsecure to work around SSL issue
# https://medium.com/@moreless/how-to-fix-python-ssl-certificate-verify-failed-97772d9dd14c
import ssl
if (not os.environ.get('PYTHONHTTPSVERIFY', '') and getattr(ssl, '_create_unverified_context', None)):
ssl._create_default_https_context = ssl._create_unverified_context
url = "https://api.github.com/repos/CGCookie/retopoflow/issues?state=all"
response = urllib.request.urlopen(url)
text = response.read().decode('utf-8')
issues = json.loads(text)
exists,solved,issueurl = False,False,None
for issue in issues:
if msghash not in issue['body']: continue
issueurl = issue['html_url']
exists = True
if issue['state'] == 'closed': solved = True
if not exists:
print('GitHub: Not reported, yet')
ui_label.set_markdown(mdown='This issue does not appear to be reported, yet.\n\nPlease consider reporting it so we can fix it.')
else:
if not solved:
print('GitHub: Already reported!')
ui_label.set_markdown('This issue appears to have been reported already.\n\nClick Open button to see the current status.')
else:
print('GitHub: Already solved!')
ui_label.set_markdown('This issue appears to have been solved already!\n\nAn updated RetopoFlow should fix this issue.')
def go():
bpy.ops.wm.url_open(url=issueurl)
UI_Element.BUTTON(innerText='Open', on_mouseclick=go, title='Open this issue on the RetopoFlow Issue Tracker', classes='fifth-size', parent=ui_buttons)
buttons = 5
else:
ui_label.set_markdown('Could not run the check.\n\nPlease consider reporting it so we can fix it.')
except Exception as e:
ui_label.set_markdown('Sorry, but we could not reach the RetopoFlow Isssues Tracker.\n\nClick the Similar button to search for similar issues.')
pass
print('Caught exception while trying to pull issues from GitHub')
print(f'URL: "{url}"')
print(e)
# ignore for now
pass
size = 'fourth-size' if buttons==4 else 'fifth-size'
UI_Element.BUTTON(innerText='Screenshot', classes=f'action {size}', parent=ui_buttons, on_mouseclick=screenshot, title='Save a screenshot of Blender')
UI_Element.BUTTON(innerText='Similar', classes=f'action {size}', parent=ui_buttons, on_mouseclick=search, title='Search the RetopoFlow Issue Tracker for similar issues')
UI_Element.BUTTON(innerText='All Issues', classes=f'action {size}', parent=ui_buttons, on_mouseclick=open_issues, title='Open RetopoFlow Issue Tracker')
UI_Element.BUTTON(innerText='Report', classes=f'action {size}', parent=ui_buttons, on_mouseclick=report, title='Report a new issue on the RetopoFlow Issue Tracker')
executor = ThreadPoolExecutor()
executor.submit(check_github)
msg_report = ''
issue_body_report = ''
if level in {'note'}:
title = 'Note' + (f': {title}' if title else '')
message = message or 'a note'
elif level in {'warning'}:
title = 'Warning' + (f': {title}' if title else '')
darken = True
elif level in {'error'}:
title = 'Error' + (f': {title}' if title else '!')
show_quit = True
darken = True
elif level in {'assert', 'exception'}:
self.save_emergency() # make an emergency save!
if level == 'assert':
title = 'Assert Error' + (f': {title}' if title else '!')
desc = 'An internal assertion has failed.'
else:
title = 'Unhandled Exception Caught' + (f': {title}' if title else '!')
desc = 'An unhandled exception was thrown.'
message = '\n'.join([
desc,
'This was unexpected.',
'',
'If this happens again, please report as bug so we can fix it.',
])
undo_stack_actions = self.undo_stack_actions() if hasattr(self, 'undo_stack_actions') else []
msg_report = '\n'.join(chain(
get_environment_details(),
get_trace_details(undo_stack_actions, msghash=msghash, message=message_orig),
get_debug_details(),
))
issue_body_report = '\n'.join(chain(
get_environment_details(),
get_trace_details(undo_stack_actions, msghash=msghash, message=message_orig),
))
show_quit = True
darken = True
else:
title = level.upper() + (f': {title}' if title else '')
message = message or 'a note'
@scope.capture_fn
def close():
nonlocal win
if win.parent:
self.document.body.delete_child(win)
self.alert_windows -= 1
if self.document.sticky_element == win:
self.document.sticky_element = None
self.document.clear_last_under()
@scope.capture_fn
def mouseleave_event(e):
nonlocal win
if not win.is_hovered: close()
@scope.capture_fn
def keypress_event(e):
if e.key == 'ESC': close()
@scope.capture_fn
def quit():
self.done()
@scope.capture_fn
def copy_to_clipboard():
nonlocal msg_report
try: bpy.context.window_manager.clipboard = msg_report
except: pass
if self.alert_windows >= 5:
return
#self.exit = True
scope.capture_var('level')
win = UI_Element.fromHTMLFile(
get_path_from_addon_root('retopoflow', 'html', 'alert_dialog.html'),
frame_depth=2,
**scope
)[0]
self.document.body.append_child(win)
win.getElementById('alert-title').innerText = title
win.getElementById('alert-message').set_markdown(mdown=message, frame_depth=2, **scope)
if not msg_report and not ui_checker:
win.getElementById('alert-details').is_visible = False
if msg_report: win.getElementById('alert-report').innerText = msg_report
else: win.getElementById('alert-report').is_visible = False
if ui_checker: win.getElementById('alert-checker').append_child(ui_checker)
else: win.getElementById('alert-checker').is_visible = False
if not show_quit:
win.getElementById('alert-close').style = 'width:100%'
win.getElementById('alert-quit').is_visible = False
self.document.focus(win)
self.alert_windows += 1
if level in {'warning', 'note', None}:
win.style = 'width:600px;'
self.document.force_clean(self.actions.context)
self.document.center_on_mouse(win)
# self.document.sticky_element = win
win.dirty(cause='new window', parent=False, children=True)
else:
self.document.force_clean(self.actions.context)
self.document.center_on_mouse(win)
win.dirty(cause='new window', parent=False, children=True)
if level in {'note', None}:
win.add_eventListener('on_mouseleave', mouseleave_event)
win.add_eventListener('on_keypress', keypress_event)
@@ -0,0 +1,93 @@
'''
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 copy
from collections import namedtuple
from ...config.options import options
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.undostack import UndoStack
class RetopoFlow_Undo:
def init_undo(self):
def create_state(action):
nonlocal self
self.instrument_write(action)
return {
'action': action,
'tool': self.rftool,
'rftarget': copy.deepcopy(self.rftarget),
'grease_marks': copy.deepcopy(self.grease_marks),
}
def restore_state(state, *, set_tool=True, reset_tool=True, instrument_action=None):
nonlocal self
self.rftarget = state['rftarget']
self.rftarget.rewrap()
self.rftarget.dirty()
self.rftarget_draw.replace_rfmesh(self.rftarget)
self.grease_marks = state['grease_marks']
if set_tool: self.select_rftool(state['tool'], reset=reset_tool)
elif reset_tool: self.reset_rftool()
if instrument_action: self.instrument_write(instrument_action)
tag_redraw_all('restoring state')
self._undostack = UndoStack(
create_state,
restore_state,
max_size=options['undo depth'],
)
@property
def change_count(self):
return self._undostack.changes
def undo_clear(self):
self._undostack.clear()
def get_last_action(self):
return self._undostack.top_key()
def undo_push(self, action, repeatable=False):
self._undostack.push(action, repeatable=repeatable)
def undo_repush(self, action):
### the restore method does not work?
# self._undostack.restore(reset_tool=False)
self._undostack.pop(reset_tool=False)
self._undostack.push(action)
def undo_pop(self):
self._undostack.pop(reset_tool=True, instrument_action='undo')
def undo_cancel(self):
self._undostack.cancel(reset_tool=False, instrument_action='cancel (undo)')
def redo_pop(self):
self._undostack.pop(undo=False, reset_tool=True, instrument_action='redo')
def undo_stack_actions(self):
return self._undostack.keys() if hasattr(self, '_undostack') else []
@@ -0,0 +1,196 @@
'''
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 bpy
from ..updater import updater
from ...addon_common.common.blender import get_path_from_addon_root
from ...addon_common.common.globals import Globals
from ...addon_common.common.utils import delay_exec
from ...addon_common.common.ui_styling import load_defaultstylings
from ...addon_common.common.ui_core import UI_Element
from ...config.options import options, retopoflow_product, retopoflow_urls
from ...config.keymaps import get_keymaps
class RetopoFlow_UpdaterSystem:
@staticmethod
def reload_stylings():
load_defaultstylings()
path = get_path_from_addon_root('config', 'ui.css')
try:
Globals.ui_draw.load_stylesheet(path)
except AssertionError as e:
# TODO: show proper dialog to user here!!
print('could not load stylesheet "%s"' % path)
print(e)
Globals.ui_document.body.dirty(cause='Reloaded stylings', children=True)
Globals.ui_document.body.dirty_styling()
Globals.ui_document.body.dirty_flow()
def substitute_keymaps(self, mdown, wrap='`', pre='', post='', separator=', ', onlyfirst=None):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{{(?P<action>[^}]+)}}', mdown)
if not m: break
action = { s.strip() for s in m.group('action').split(',') }
sub = f'{pre}{wrap_pre}' + self.actions.to_human_readable(action, sep=f'{wrap_post}{separator}{wrap_pre}', onlyfirst=onlyfirst) + f'{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def substitute_options(self, mdown, wrap='', pre='', post='', separator=', ', onlyfirst=None):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{\[(?P<option>[^\]]+)\]}', mdown)
if not m: break
opts = { s.strip() for s in m.group('option').split(',') }
sub = f'{pre}{wrap_pre}' + separator.join(str(options[opt]) for opt in opts) + f'{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def substitute_python(self, mdown, wrap='', pre='', post=''):
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
else: wrap_pre, wrap_post = wrap
while True:
m = re.search(r'{`(?P<python>[^`]+)`}', mdown)
if not m: break
pyret = eval(m.group('python'), globals(), locals())
sub = f'{pre}{wrap_pre}{pyret}{wrap_post}{post}'
mdown = mdown[:m.start()] + sub + mdown[m.end():]
return mdown
def updater_open(self): #, mdown_path, done_on_esc=False, closeable=True, *args, **kwargs):
newversion = ''
keymaps = get_keymaps()
def close():
self.done()
# e = self.document.body.getElementById('updaterdialog')
# if not e: return
# self.document.body.delete_child(e)
def key(e):
nonlocal keymaps, self
if e.key == 'ESC':
close()
def blendermarket():
bpy.ops.wm.url_open(url=retopoflow_urls['blender market'])
def open_staging_folder():
path = updater.stage_path
if not os.path.exists(path):
# updater stage path does not exist
# attempt to create it
os.makedirs(path)
bpy.ops.wm.path_open(filepath=path)
# path = opath
# while not os.path.exists(path):
# npath = os.path.abspath(os.path.join(path, '..'))
# assert npath != path, f'Could not open {opath}'
# path = npath
def done_updating(module_name, res=None):
ui_updater.getElementById('select-version').is_visible = False
if res is None:
# success!
ui_updater.getElementById('update-succeeded').is_visible = True
ui_updater.getElementById('new-version').innerText = newversion
else:
# error
ui_updater.getElementById('update-failed').is_visible = True
ui_updater.getElementById('fail-version').innerText = newversion
ui_updater.getElementById('fail-message').innerText = str(res)
ui_updater.dirty(children=True)
def try_again():
ui_updater.getElementById('update-succeeded').is_visible = False
ui_updater.getElementById('update-failed').is_visible = False
ui_updater.getElementById('select-version').is_visible = True
ui_updater.dirty(children=True)
def load():
nonlocal newversion
uis = self.document.body.getElementsByName('version')
tag = None
for ui in uis:
if ui.checked:
tag = ui.value
break
assert tag
if tag == 'none':
# do nothing (should never get here, though)
return
elif tag == 'custom':
# commit or branch specified
tag = ui_updater.getElementById('custom').value
newversion += tag
link = f'https://github.com/CGCookie/retopoflow/archive/{tag}.zip'
updater._update_ready = True
updater._update_version = None
updater._update_link = link
else:
# release/tag specified
newversion += tag
updater._update_ready = True
updater.set_tag(tag)
updater.run_update(callback=done_updating)
ui_updater = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'updater_dialog.html'))[0]
ui_updater.getElementById('current-version').innerText = retopoflow_product['version']
# ui_updater.getElementById('staging-folder').innerText = updater.stage_path
ui_updater.getElementById('update-succeeded').is_visible = False
ui_updater.getElementById('update-failed').is_visible = False
self.document.body.append_child(ui_updater)
self.document.body.dirty()
def version_on_input(this):
if this is None: return
if this.value == 'none':
self.document.body.getElementById('load-version').disabled = this.checked
def set_option(value):
for ui in ui_updater.getElementsByName('version'):
if ui.value == value: ui.checked = True
def add_version_options(update_status):
nonlocal version_on_input, set_option
ui_versions = ui_updater.getElementById('version-options')
ui_versions.append_children(UI_Element.fromHTML(
f'''<label><input type="radio" name="version" value="none" on_input="version_on_input(this)" checked>Keep current version</label>'''
))
# for tag in updater._tags:
# print(tag)
for tag in updater.tags:
tag = tag.replace('\n', '').replace('\r', '').replace('\t','')
ui_versions.append_children(UI_Element.fromHTML(
f'''<label><input type="radio" name="version" on_input="version_on_input(this)" value="{tag}">{tag}</label>'''
))
ui_versions.append_children(UI_Element.fromHTML(
f'''<label class="option-custom"><input type="radio" name="version" on_input="version_on_input(this)" value="custom">Advanced: Commit / Branch</label><input type="text" id="custom" value="" title="Enter commit hash or branch name" on_focus="set_option('custom')">'''
))
updater.include_branches = False
updater.get_tags()
add_version_options(None)
#updater.check_for_update_now(add_version_options)