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