2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,21 @@
if "bpy" not in locals():
import bpy
from . import receiver
from . import detector
from . import recorder
from . import actor
from . import command_api
from . import info
from . import retargeting
from . import login
else:
import importlib
importlib.reload(receiver)
importlib.reload(detector)
importlib.reload(recorder)
importlib.reload(actor)
importlib.reload(command_api)
importlib.reload(info)
importlib.reload(retargeting)
importlib.reload(login)
@@ -0,0 +1,134 @@
import bpy
import copy
from . import receiver
class InitTPose(bpy.types.Operator):
bl_idname = "rsl.init_tpose"
bl_label = "Set as T-Pose"
bl_description = "Press this button if you have this armature in T-Pose"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
obj = context.object
if obj.type != 'ARMATURE':
self.report({'ERROR'}, 'This is not an armature!')
return {'CANCELLED'}
bpy.ops.object.mode_set(mode='OBJECT')
# Get current custom data
custom_data = obj.get('CUSTOM')
if not custom_data:
custom_data = {}
bones = {}
# Save local and global space rotations and local and object space locations for each bone
for bone in obj.pose.bones:
# Save rotation mode
rotation_mode = bone.rotation_mode
# Save bone pose data
bone.rotation_mode = 'QUATERNION'
bones[bone.name] = {
'location_local': bone.location,
'location_object': bone.matrix @ bone.location,
'rotation_local': bone.rotation_quaternion,
'rotation_global': bone.matrix.to_quaternion(),
'inherit_rotation': obj.data.bones.get(bone.name).use_inherit_rotation,
}
# Load rotation mode
bone.rotation_mode = rotation_mode
# Save tpose data to custom data
custom_data['rsl_tpose_bones'] = copy.deepcopy(bones)
obj['CUSTOM'] = custom_data
self.report({'INFO'}, 'T-Pose successfully saved!')
return {'FINISHED'}
class ResetTPose(bpy.types.Operator):
bl_idname = "rsl.reset_tpose"
bl_label = "Reset to T-Pose"
bl_description = "Use this to reset the armature to it's T-Pose"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
if receiver.receiver_enabled:
self.report({'ERROR'}, 'Receiver is currently running. Please stop it first.')
return {'CANCELLED'}
obj = context.object
if obj.type != 'ARMATURE':
self.report({'ERROR'}, 'This is not an armature!')
return {'CANCELLED'}
# Get current custom data
custom_data = obj.get('CUSTOM')
if not custom_data:
self.report({'ERROR'}, 'Please set the T-Pose first.')
return {'CANCELLED'}
# Get tpose data from custom data
tpose_bones = custom_data.get('rsl_tpose_bones')
if not tpose_bones:
self.report({'ERROR'}, 'Please set the T-Pose first.')
return {'CANCELLED'}
# Apply locations and rotations to bones
for bone_name, data in tpose_bones.items():
bone = obj.pose.bones.get(bone_name)
if bone:
# Save rotation mode
rotation_mode = bone.rotation_mode
if rotation_mode == 'QUATERNION':
rotation_mode = 'XYZ'
bone.rotation_mode = 'QUATERNION'
bone.rotation_quaternion = data['rotation_local']
bone.location = data['location_local']
obj.data.bones.get(bone_name).use_inherit_rotation = data['inherit_rotation']
# Load rotation mode
bone.rotation_mode = rotation_mode
self.report({'INFO'}, 'T-Pose successfully restored!')
return {'FINISHED'}
class PrintCurrentPose(bpy.types.Operator):
bl_idname = "rsl.print_current_pose"
bl_label = "Print"
bl_description = "Debugging. Prints world rotation of armature bones"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
obj = context.object
if obj.type != 'ARMATURE':
self.report({'ERROR'}, 'This is not an armature!')
return {'CANCELLED'}
for bone in obj.pose.bones:
# Save rotation mode
rotation_mode = bone.rotation_mode
if rotation_mode == 'QUATERNION':
rotation_mode = 'XYZ'
bone.rotation_mode = 'QUATERNION'
rot = bone.matrix.to_euler().to_quaternion().copy()
i = 5
print('actor_bones[\'' + bone.name + '\'] = Quaternion(('
+ str(round(rot[0], i)) + ', '
+ str(round(rot[1], i)) + ', '
+ str(round(rot[2], i)) + ', '
+ str(round(rot[3], i))
+ '))')
# Load rotation mode
bone.rotation_mode = rotation_mode
return {'FINISHED'}
@@ -0,0 +1,146 @@
import bpy
import requests
import traceback
class CommandTest(bpy.types.Operator):
bl_idname = 'rsl.command_test'
bl_label = 'Test Command API'
bl_description = 'Testing'
bl_options = {'INTERNAL'}
def execute(self, context):
try:
request = get_request('')
except requests.exceptions.ConnectionError:
self.report({'ERROR'}, 'Could not connect to Rokoko Studio!')
return {'CANCELLED'}
data = request.json()
if is_error(self, data):
return {'CANCELLED'}
self.report({'INFO'}, 'Successfully tested!')
return {'FINISHED'}
class StartCalibration(bpy.types.Operator):
bl_idname = 'rsl.command_start_calibration'
bl_label = 'Start Calibration'
bl_description = 'Starts calibration of a Smartsuit Pro'
bl_options = {'INTERNAL'}
def execute(self, context):
try:
request = post_request('/calibrate')
except requests.exceptions.ConnectionError:
print(traceback.format_exc())
self.report({'ERROR'}, 'Could not connect to Rokoko Studio!')
return {'CANCELLED'}
data = request.json()
if is_error(self, data):
return {'CANCELLED'}
self.report({'INFO'}, 'Calibration started successfully!')
return {'FINISHED'}
class Restart(bpy.types.Operator):
bl_idname = 'rsl.command_restart'
bl_label = 'Restart Smartsuits'
bl_description = 'Restarts all Smartsuit Pro\'s'
bl_options = {'INTERNAL'}
def execute(self, context):
try:
request = post_request('/restart')
except requests.exceptions.ConnectionError:
print(traceback.format_exc())
self.report({'ERROR'}, 'Could not connect to Rokoko Studio!')
return {'CANCELLED'}
data = request.json()
if is_error(self, data):
return {'CANCELLED'}
self.report({'INFO'}, 'Smartsuits restarted successfully!!')
return {'FINISHED'}
class StartRecording(bpy.types.Operator):
bl_idname = 'rsl.command_start_recording'
bl_label = 'Start Recording'
bl_description = 'Starts recording all connected Smartsuit Pro\'s'
bl_options = {'INTERNAL'}
def execute(self, context):
try:
request = post_request('/recording/start')
except requests.exceptions.ConnectionError:
print(traceback.format_exc())
self.report({'ERROR'}, 'Could not connect to Rokoko Studio!')
return {'CANCELLED'}
data = request.json()
if is_error(self, data):
return {'CANCELLED'}
self.report({'INFO'}, 'Recording started successfully!')
return {'FINISHED'}
class StopRecording(bpy.types.Operator):
bl_idname = 'rsl.command_stop_recording'
bl_label = 'Stop Recording'
bl_description = 'Stops recording all connected Smartsuit Pro\'s'
bl_options = {'INTERNAL'}
def execute(self, context):
try:
request = post_request('/recording/stop')
except requests.exceptions.ConnectionError:
print(traceback.format_exc())
self.report({'ERROR'}, 'Could not connect to Rokoko Studio!')
return {'CANCELLED'}
data = request.json()
if is_error(self, data):
return {'CANCELLED'}
self.report({'INFO'}, 'Recording stopped successfully!')
return {'FINISHED'}
def get_request(additions):
scn = bpy.context.scene
return requests.get(f'http://{scn.rsl_command_ip_address}:{scn.rsl_command_ip_port}/v1/{scn.rsl_command_api_key}' + additions)
def post_request(additions, json=None):
if json is None:
json = {}
scn = bpy.context.scene
url = f'http://{scn.rsl_command_ip_address}:{scn.rsl_command_ip_port}/v1/{scn.rsl_command_api_key}{additions}'
print(url, json)
request = requests.post(url, json=json)
return request
def is_error(self, data):
print(data)
if not data.get('response_code'):
self.report({'ERROR'}, 'No response from Studio!')
return True
if data.get('response_code') != 'OK':
if data.get('response_code') == 'INVALID_REQUEST':
self.report({'ERROR'}, data.get('response_code') + '\n' + data.get('description') + ' Check your API key.')
return True
self.report({'ERROR'}, data.get('response_code') + '\n' + data.get('description'))
return True
return False
@@ -0,0 +1,227 @@
import os
import bpy
import bpy_extras
from ..core import animation_lists
from ..core import detection_manager
from ..core import custom_schemes_manager
class DetectFaceShapes(bpy.types.Operator):
bl_idname = "rsl.detect_face_shapes"
bl_label = "Auto Detect"
bl_description = "Automatically detect face shape keys for supported naming schemes"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
obj = context.object
if not hasattr(obj.data, 'shape_keys') or not hasattr(obj.data.shape_keys, 'key_blocks'):
self.report({'ERROR'}, 'This mesh has no shapekeys!')
return {'CANCELLED'}
for shape_name_key in animation_lists.face_shapes:
setattr(obj, 'rsl_face_' + shape_name_key, detection_manager.detect_shape(obj, shape_name_key))
return {'FINISHED'}
class DetectActorBones(bpy.types.Operator):
bl_idname = "rsl.detect_actor_bones"
bl_label = "Auto Detect"
bl_description = "Automatically detect actor bones for supported naming schemes"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
obj = context.object
for bone_name_key in animation_lists.get_bones().keys():
setattr(obj, 'rsl_actor_' + bone_name_key, detection_manager.detect_bone(obj, bone_name_key))
return {'FINISHED'}
class SaveCustomShapes(bpy.types.Operator):
bl_idname = "rsl.save_custom_shapes"
bl_label = "Save Custom Shapes"
bl_description = "This saves the currently selected shapekeys and they will then get automatically detected"
bl_options = {'INTERNAL'}
def execute(self, context):
obj = context.object
# Go over all face shapekeys and see if the user changed the detected shapekey. If yes, save that new shapekey
for shape_name_key in animation_lists.face_shapes:
shape_name_selected = getattr(obj, 'rsl_face_' + shape_name_key)
if not shape_name_selected:
continue # TODO idea: maybe save these unselected choices as well
shape_name_detected = detection_manager.detect_shape(obj, shape_name_key)
if shape_name_detected == shape_name_selected: # This means that the user changed nothing, so don't save this
continue
custom_schemes_manager.save_live_data_shape_to_list(shape_name_key, shape_name_selected, shape_name_detected)
# At the end save all custom shapes to the file
custom_schemes_manager.save_to_file_and_update()
return {'FINISHED'}
class SaveCustomBones(bpy.types.Operator):
bl_idname = "rsl.save_custom_bones"
bl_label = "Save Custom Bones"
bl_description = "This saves the currently selected bones and they will then get automatically detected"
bl_options = {'INTERNAL'}
def execute(self, context):
obj = context.object
# Go over all actor bones and see if the user changed the detected bone. If yes, save that new bone
for bone_name_key in animation_lists.get_bones().keys():
bone_name_selected = getattr(obj, 'rsl_actor_' + bone_name_key)
if not bone_name_selected:
continue # TODO idea: maybe save these unselected choices as well
bone_name_detected = detection_manager.detect_bone(obj, bone_name_key)
if bone_name_detected == bone_name_selected: # This means that the user changed nothing, so don't save this
continue
custom_schemes_manager.save_live_data_bone_to_list(bone_name_key, bone_name_selected, bone_name_detected)
# At the end save all custom bones to the file
custom_schemes_manager.save_to_file_and_update()
return {'FINISHED'}
class SaveCustomBonesRetargeting(bpy.types.Operator):
bl_idname = "rsl.save_custom_bones_retargeting"
bl_label = "Save Custom Bones"
bl_description = "This saves the currently selected bones and they will then get automatically detected"
bl_options = {'INTERNAL'}
def execute(self, context):
# Save the bone list if the user changed anything
custom_schemes_manager.save_retargeting_to_list()
return {'FINISHED'}
class ImportCustomBones(bpy.types.Operator, bpy_extras.io_utils.ImportHelper):
bl_idname = "rsl.import_custom_schemes"
bl_label = "Import Custom Scheme"
bl_description = "Import a custom naming scheme"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
directory: bpy.props.StringProperty(maxlen=1024, subtype='FILE_PATH', options={'HIDDEN', 'SKIP_SAVE'})
filter_glob: bpy.props.StringProperty(default='*.json;', options={'HIDDEN'})
def execute(self, context):
import_count = 0
if self.directory:
for f in self.files:
file_name = f.name
if not file_name.endswith('.json'):
continue
custom_schemes_manager.import_custom_list(self.directory, file_name)
import_count += 1
# If this operator is called with no directory but a filepath argument, import that
elif self.filepath:
custom_schemes_manager.import_custom_list(os.path.dirname(self.filepath), os.path.basename(self.filepath))
import_count += 1
custom_schemes_manager.save_to_file_and_update()
if not import_count:
self.report({'ERROR'}, 'No files were imported.')
return {'FINISHED'}
self.report({'INFO'}, 'Successfully imported new naming schemes.')
return {'FINISHED'}
class ExportCustomBones(bpy.types.Operator, bpy_extras.io_utils.ExportHelper):
bl_idname = "rsl.export_custom_schemes"
bl_label = "Export Custom Scheme"
bl_description = "Export your custom naming schemes"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default='*.json;', options={'HIDDEN'})
def execute(self, context):
file_name = custom_schemes_manager.export_custom_list(self.filepath)
if not file_name:
self.report({'ERROR'}, 'You don\'t have any custom naming schemes!')
return {'FINISHED'}
self.report({'INFO'}, 'Exported custom naming schemes as "' + file_name + '".')
return {'FINISHED'}
class ClearCustomBones(bpy.types.Operator):
bl_idname = "rsl.clear_custom_bones"
bl_label = "Clear Custom Bones"
bl_description = "Clear all custom bone naming schemes"
bl_options = {'INTERNAL'}
def draw(self, context):
layout = self.layout
layout.separator()
row = layout.row(align=True)
row.scale_y = 0.5
row.label(text='You are about to delete all stored custom bone naming schemes.', icon='ERROR')
row = layout.row(align=True)
row.scale_y = 0.5
row.label(text='Continue?', icon='BLANK1')
layout.separator()
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=400)
def execute(self, context):
custom_schemes_manager.delete_custom_bone_list()
self.report({'INFO'}, 'Cleared all custom bone naming schemes!')
return {'FINISHED'}
class ClearCustomShapes(bpy.types.Operator):
bl_idname = "rsl.clear_custom_shapes"
bl_label = "Clear Custom Shapekeys"
bl_description = "Clear all custom shape naming schemes"
bl_options = {'INTERNAL'}
def draw(self, context):
layout = self.layout
layout.separator()
row = layout.row(align=True)
row.scale_y = 0.5
row.label(text='You are about to delete all stored custom shape naming schemes.', icon='ERROR')
row = layout.row(align=True)
row.scale_y = 0.5
row.label(text='Continue?', icon='BLANK1')
layout.separator()
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=400)
def execute(self, context):
custom_schemes_manager.delete_custom_shape_list()
self.report({'INFO'}, 'Cleared all custom shape naming schemes!')
return {'FINISHED'}
@@ -0,0 +1,63 @@
import bpy
import webbrowser
from ..core import login_manager as lm
class LicenseButton(bpy.types.Operator):
bl_idname = 'rsl.info_license'
bl_label = 'License'
bl_description = 'Opens the license in the browser'
bl_options = {'INTERNAL'}
def execute(self, context):
webbrowser.open('https://github.com/RokokoElectronics/rokoko-studio-live-blender/blob/master/LICENSE.md')
self.report({'INFO'}, 'Opened license.')
return {'FINISHED'}
class RokokoButton(bpy.types.Operator):
bl_idname = 'rsl.info_rokoko'
bl_label = 'Website'
bl_description = 'Opens the Rokoko website in the browser'
bl_options = {'INTERNAL'}
def execute(self, context):
webbrowser.open('https://www.rokoko.com/en')
self.report({'INFO'}, 'Opened Rokoko website.')
return {'FINISHED'}
class DocumentationButton(bpy.types.Operator):
bl_idname = 'rsl.info_documentation'
bl_label = 'Documentation'
bl_description = 'Opens the documentation in the browser'
bl_options = {'INTERNAL'}
def execute(self, context):
webbrowser.open('https://github.com/Rokoko/rokoko-studio-live-blender#readme')
self.report({'INFO'}, 'Opened documentation.')
return {'FINISHED'}
class ForumButton(bpy.types.Operator):
bl_idname = 'rsl.info_forum'
bl_label = 'Join our Forums'
bl_description = 'Opens the Rokoko Blender forum in the browser'
bl_options = {'INTERNAL'}
def execute(self, context):
webbrowser.open('https://rokoko.freshdesk.com/support/discussions/forums/47000399880')
self.report({'INFO'}, 'Opened forums.')
return {'FINISHED'}
class ToggleRokokoIDButton(bpy.types.Operator):
bl_idname = 'rsl.toggle_rokoko_id'
bl_label = 'Toggle Rokoko ID'
bl_description = 'Toggles the visibility of your Rokoko ID'
bl_options = {'INTERNAL'}
def execute(self, context):
lm.user.display_email = not lm.user.display_email
return {'FINISHED'}
@@ -0,0 +1,87 @@
import traceback
import bpy
import importlib
from ..core import login_manager
from ..core import library_manager
from ..core import live_data_manager
class LoginButton(bpy.types.Operator):
bl_idname = "rsl.login_login"
bl_label = "Sign in"
bl_description = "Sign into your Rokoko account with your browser"
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
return not login_manager.user.logging_in
def execute(self, context):
login = login_manager.Login()
login.start()
self.report({'INFO'}, 'Opened Rokoko ID website in your browser.')
return {'FINISHED'}
class LogoutButton(bpy.types.Operator):
bl_idname = "rsl.login_logout"
bl_label = "Sign out"
bl_description = "Sign out of your Rokoko account"
bl_options = {'INTERNAL'}
def execute(self, context):
login_manager.user.logout()
self.report({'INFO'}, 'Logout successful.')
return {'FINISHED'}
class InstallLibsButton(bpy.types.Operator):
bl_idname = 'rsl.login_install_libs'
bl_label = 'Install Required Libraries'
bl_description = 'Installs the required libraries for this plugin to work'
bl_options = {'INTERNAL'}
def execute(self, context):
# Install the libraries
try:
self.install_libs()
except ImportError as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
except Exception as e:
trace = traceback.format_exc()
error_str = f"Unable to install the libraries!" \
f"\nTry running Blender as an admin and install the libraries again." \
f"\n\nFull Error: \n\n{trace}"
self.report({'ERROR'}, error_str)
return {'CANCELLED'}
# Save login manager data
classes_logged_in = login_manager.user.classes_logged_in
classes_logged_out = login_manager.user.classes_logged_out
version_str = login_manager.user.version_str
# Reload files to load the libraries
importlib.reload(login_manager)
importlib.reload(live_data_manager)
# Load the login manager data
login_manager.user.classes_logged_in = classes_logged_in
login_manager.user.classes_logged_out = classes_logged_out
login_manager.user.version_str = version_str
# Attempt to auto login the user
login_manager.user.auto_login()
self.report({'INFO'}, 'Installed libraries successfully!')
return {'FINISHED'}
def install_libs(self):
missing = library_manager.lib_manager.install_libraries(["websockets", "gql", "cryptography", "boto3"])
if missing:
raise ImportError("The following libraries could not be installed: "
"\n- " + " \n- ".join(missing) +
" \n\nTry running Blender as an admin and install the libraries again."
" \nSee console for more information.")
library_manager.lib_manager.install_libraries(["lz4"])
@@ -0,0 +1,100 @@
import bpy
import time
from threading import Thread
from ..core import state_manager
from ..core.receiver import Receiver
from ..core.utils import ui_refresh_all
from ..core.animations import clear_animations
timer = None
receiver: Receiver = Receiver()
receiver_enabled = False
class ReceiverStart(bpy.types.Operator):
bl_idname = "rsl.receiver_start"
bl_label = "Start Receiver"
bl_description = "Start receiving data from Rokoko Studio"
bl_options = {'INTERNAL'}
def modal(self, context, event):
# If ECS or F8 is pressed, cancel
if event.type == 'ESC' or event.type == 'F8' or not receiver_enabled:
return self.cancel(context)
# This gets run every frame
if event.type == 'TIMER':
if bpy.context.screen.is_animation_playing:
return self.cancel(context)
receiver.run()
return {'PASS_THROUGH'}
def execute(self, context):
global receiver_enabled, receiver, timer
# Start the receiver
try:
receiver.start(context.scene.rsl_receiver_port)
except OSError as e:
print('Socket error:', e.strerror)
self.report({'ERROR'}, 'This port is already in use!')
return {'CANCELLED'}
receiver_enabled = True
# If animation is currently playing, stop it
if context.screen.is_animation_playing:
bpy.ops.screen.animation_play()
# Clear current live data
clear_animations()
# Save the scene
state_manager.save_scene()
# Register this classes modal operator in Blenders event handling system and execute it at the specified fps
context.window_manager.modal_handler_add(self)
timer = context.window_manager.event_timer_add(1 / context.scene.rsl_receiver_fps, window=bpy.context.window)
return {'RUNNING_MODAL'}
def cancel(self, context):
ReceiverStart.force_disable()
ui_refresh_all()
return {'CANCELLED'}
@classmethod
def force_disable(cls):
global receiver_enabled, receiver, timer
receiver_enabled = False
receiver.stop()
bpy.context.window_manager.event_timer_remove(timer)
# If the recording is still running, let it load the scene afterwards with a delay
if bpy.context.scene.rsl_recording:
bpy.context.scene.rsl_recording = False
thread = Thread(target=load_scene_later, args=[])
thread.start()
else:
state_manager.load_scene()
def load_scene_later():
time.sleep(0.04)
state_manager.load_scene()
class ReceiverStop(bpy.types.Operator):
bl_idname = "rsl.receiver_stop"
bl_label = "Stop Receiver"
bl_description = "Stop receiving data from Rokoko Studio"
bl_options = {'INTERNAL'}
def execute(self, context):
global receiver_enabled
receiver_enabled = False
return {'FINISHED'}
@@ -0,0 +1,32 @@
import bpy
class RecorderStart(bpy.types.Operator):
bl_idname = "rsl.recorder_start"
bl_label = "Start Recording"
bl_description = "Start recording data from Rokoko Studio"
bl_options = {'INTERNAL'}
def execute(self, context):
if context.scene.rsl_recording:
self.report({'ERROR'}, 'Already recording')
return {'CANCELLED'}
context.scene.rsl_recording = True
return {'FINISHED'}
class RecorderStop(bpy.types.Operator):
bl_idname = "rsl.recorder_stop"
bl_label = "Stop Recording"
bl_description = "Stop recording data from Rokoko Studio" \
"\nThe processing of the recording can take a couple minutes, depending on the length of the recording"
bl_options = {'INTERNAL'}
def execute(self, context):
if not context.scene.rsl_recording:
self.report({'ERROR'}, 'Not recording')
return {'CANCELLED'}
context.scene.rsl_recording = False
return {'FINISHED'}
@@ -0,0 +1,536 @@
import bpy
import copy
from . import detector
from ..core import utils
from ..core.retargeting import get_source_armature, get_target_armature
from ..core import detection_manager as detector
from ..core import custom_schemes_manager
from ..panels.retargeting import BoneListItem
RETARGET_ID = '_RSL_RETARGET'
class BuildBoneList(bpy.types.Operator):
bl_idname = "rsl.build_bone_list"
bl_label = "Build Bone List"
bl_description = "Builds the bone list from the animation and tries to automatically detect and match bones"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
armature_source = get_source_armature()
armature_target = get_target_armature()
if not armature_source.animation_data or not armature_source.animation_data.action:
self.report({'ERROR'}, 'No animation on the source armature found!'
'\nSelect an armature with an animation as source.')
return {'CANCELLED'}
if armature_source.name == armature_target.name:
self.report({'ERROR'}, 'Source and target armature are the same!'
'\nPlease select different armatures.')
return {'CANCELLED'}
retargeting_dict = detector.detect_retarget_bones()
# Clear the bone retargeting list
context.scene.rsl_retargeting_bone_list.clear()
for bone_source, bone_values in retargeting_dict.items():
bone_target, bone_key = bone_values
bone_item = context.scene.rsl_retargeting_bone_list.add()
bone_item.bone_name_key = bone_key
bone_item.bone_name_source = bone_source
bone_item.bone_name_target = bone_target
return {'FINISHED'}
class AddBoneListItem(bpy.types.Operator):
bl_idname = "rsl.add_bone_list_item"
bl_label = "Add Bone List Item"
bl_description = "Adds a customizable bone list item"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
bone_item = context.scene.rsl_retargeting_bone_list.add()
bone_item.is_custom = True
context.scene.rsl_retargeting_bone_list_index = len(context.scene.rsl_retargeting_bone_list) - 1
return {'FINISHED'}
class ClearBoneList(bpy.types.Operator):
bl_idname = "rsl.clear_bone_list"
bl_label = "Clear Bone List"
bl_description = "Clears the bone list so that you can manually fill in all bones"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
def execute(self, context):
for bone_item in context.scene.rsl_retargeting_bone_list:
bone_item.bone_name_target = ''
return {'FINISHED'}
class RetargetAnimation(bpy.types.Operator):
bl_idname = "rsl.retarget_animation"
bl_label = "Retarget Animation"
bl_description = "Retargets the animation from the source armature to the target armature"
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
retarget_bone_list: [BoneListItem] = []
def execute(self, context):
armature_source = get_source_armature()
armature_target = get_target_armature()
if not armature_source.animation_data or not armature_source.animation_data.action:
self.report({'ERROR'}, 'No animation on the source armature found!'
'\nSelect an armature with an animation as source.')
return {'CANCELLED'}
if armature_source.name == armature_target.name:
self.report({'ERROR'}, 'Source and target armature are the same!'
'\nPlease select different armatures.')
return {'CANCELLED'}
# Build retargeting bone list
self.retarget_bone_list.clear()
for item in context.scene.rsl_retargeting_bone_list:
if not item.bone_name_source or not item.bone_name_target \
or not armature_source.pose.bones.get(item.bone_name_source) \
or not armature_target.pose.bones.get(item.bone_name_target):
continue
self.retarget_bone_list.append(item)
# Find the root bones and cancel if none are found
root_bones = self.find_root_bones(context, armature_source, armature_target)
if not root_bones:
self.report({'ERROR'}, 'No root bone found!'
'\nCheck if the bones are mapped correctly or try rebuilding the bone list.')
return {'CANCELLED'}
# Check for duplicate target bone entries
seen = {}
for item in self.retarget_bone_list:
count = seen.get(item.bone_name_target)
if not count:
count = 0
seen[item.bone_name_target] = count + 1
duplicates = [key for key, value in seen.items() if value > 1]
if duplicates:
self.report({'ERROR'}, 'Duplicate target bone entries found! Please use each target bone only once:'
f'\n{", ".join(duplicates)}')
return {'CANCELLED'}
# Save the bone list if the user changed anything
custom_schemes_manager.save_retargeting_to_list()
# Prepare armatures
utils.set_active(armature_target)
bpy.ops.object.mode_set(mode='OBJECT')
utils.set_active(armature_source)
bpy.ops.object.mode_set(mode='OBJECT')
# Set armatures into pose mode
armature_source.data.pose_position = 'POSE'
armature_target.data.pose_position = 'POSE'
# Save and reset the current pose position of both armatures if rest position should be used
pose_source, pose_target = {}, {}
if bpy.context.scene.rsl_retargeting_use_pose == 'REST':
pose_source = self.get_and_reset_pose_rotations(armature_source)
pose_target = self.get_and_reset_pose_rotations(armature_target)
# Auto scaling
source_scale = None
if context.scene.rsl_retargeting_auto_scaling:
# Clean source animation
# TODO: This causes issues when all Hip bone data is on the armature itself
self.clean_animation(armature_source)
# Scale the source armature to fit the target armature
source_scale = copy.deepcopy(armature_source.scale)
self.scale_armature(context, armature_source, armature_target, root_bones)
# Duplicate source armature to apply transforms to the animation
armature_source_original = armature_source
armature_source = self.copy_rest_pose(context, armature_source)
# Save transforms of target armature
rotation_mode = armature_target.rotation_mode
armature_target.rotation_mode = 'QUATERNION'
rotation = copy.deepcopy(armature_target.rotation_quaternion)
location = copy.deepcopy(armature_target.location)
# Apply transforms of the target armature
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature_target)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.object.mode_set(mode='EDIT')
# Create a transformation dict of all bones of the target armature and unselect all bones
bone_transforms = {}
for bone in context.object.data.edit_bones:
bone.select = False
bone_transforms[bone.name] = armature_source.matrix_world.inverted() @ bone.head.copy(), \
armature_source.matrix_world.inverted() @ bone.tail.copy(), \
utils.mat3_to_vec_roll(armature_source.matrix_world.inverted().to_3x3() @ bone.matrix.to_3x3()) # Head loc, tail loc, bone roll
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature_source)
bpy.ops.object.mode_set(mode='EDIT')
# Recreate bones from target armature in source armature
for item in self.retarget_bone_list:
bone_source = armature_source.data.edit_bones.get(item.bone_name_source)
# Recreate target bone
bone_new = armature_source.data.edit_bones.new(item.bone_name_target + RETARGET_ID)
bone_new.head, bone_new.tail, bone_new.roll = bone_transforms[item.bone_name_target]
bone_new.parent = bone_source
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
# Add constraints to target armature and select the bones for animation
for item in self.retarget_bone_list:
bone_target = armature_target.pose.bones.get(item.bone_name_target)
# Add constraints
constraint = bone_target.constraints.new('COPY_ROTATION')
constraint.name += RETARGET_ID
constraint.target = armature_source
constraint.subtarget = item.bone_name_target + RETARGET_ID
if bone_target.name in root_bones:
constraint = bone_target.constraints.new('COPY_LOCATION')
constraint.name += RETARGET_ID
constraint.target = armature_source
constraint.subtarget = item.bone_name_source
# Select the bone for animation
armature_target.data.bones.get(item.bone_name_target).select = True
# Bake the animation to the target armature
self.bake_animation(armature_source, armature_target, root_bones)
# Delete the duplicate helper armature
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature_source)
bpy.data.actions.remove(armature_source.animation_data.action)
bpy.ops.object.delete()
# Change armature source back to original
armature_source = armature_source_original
# Change action name
armature_target.animation_data.action.name = armature_source.animation_data.action.name + ' Retarget'
# Remove constraints from target armature
for bone in armature_target.pose.bones:
for constraint in bone.constraints:
if RETARGET_ID in constraint.name:
bone.constraints.remove(constraint)
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature_target)
# Reset target armature transforms to old state
armature_target.rotation_quaternion = rotation
armature_target.location = location
armature_target.rotation_quaternion.w = -armature_target.rotation_quaternion.w
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
armature_target.rotation_quaternion = rotation
armature_target.rotation_mode = rotation_mode
# Reset source armature scale
if source_scale:
armature_source.scale = source_scale
# Reset pose positions to old state
# self.load_pose_rotations(armature_source, pose_source)
# self.load_pose_rotations(armature_target, pose_target)
bpy.ops.object.select_all(action='DESELECT')
self.report({'INFO'}, 'Retargeted animation.')
return {'FINISHED'}
def find_root_bones(self, context, armature_source, armature_target):
# Find all root bones
root_bones = []
for bone in armature_target.pose.bones:
if not bone.parent:
root_bones.append(bone)
# Find animated root bones
root_bones_animated = []
target_bones = [item.bone_name_target for item in self.retarget_bone_list]
while root_bones:
for bone in copy.copy(root_bones):
root_bones.remove(bone)
if bone.name in target_bones:
root_bones_animated.append(bone.name)
else:
for bone_child in bone.children:
root_bones.append(bone_child)
return root_bones_animated
def clean_animation(self, armature_source):
deletable_fcurves = ['location', 'rotation_euler', 'rotation_quaternion', 'scale']
for fcurve in armature_source.animation_data.action.fcurves:
if fcurve.data_path in deletable_fcurves:
armature_source.animation_data.action.fcurves.remove(fcurve)
def get_and_reset_pose_rotations(self, armature):
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature)
bpy.ops.object.mode_set(mode='POSE')
# Save rotations
pose_rotations = {}
for bone in armature.pose.bones:
if bone.rotation_mode == 'QUATERNION':
pose_rotations[bone.name] = copy.deepcopy(bone.rotation_quaternion)
bone.rotation_quaternion = (1, 0, 0, 0)
else:
pose_rotations[bone.name] = copy.deepcopy(bone.rotation_euler)
bone.rotation_euler = (0, 0, 0)
# Reset rotations
# bpy.ops.pose.rot_clear()
bpy.ops.object.mode_set(mode='OBJECT')
return pose_rotations
def load_pose_rotations(self, armature, pose_rotations):
if not pose_rotations:
return
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature)
bpy.ops.object.mode_set(mode='POSE')
# Load rotations
for bone in armature.pose.bones:
rot = pose_rotations.get(bone.name)
if rot:
if bone.rotation_mode == 'QUATERNION':
bone.rotation_quaternion = rot
else:
bone.rotation_euler = rot
bpy.ops.object.mode_set(mode='OBJECT')
def scale_armature(self, context, armature_source, armature_target, root_bones):
source_min = None
source_min_root = None
target_min = None
target_min_root = None
for item in self.retarget_bone_list:
bone_source = armature_source.pose.bones.get(item.bone_name_source)
bone_target = armature_target.pose.bones.get(item.bone_name_target)
bone_source_z = (armature_source.matrix_world @ bone_source.head)[2]
bone_target_z = (armature_target.matrix_world @ bone_target.head)[2]
if item.bone_name_target in root_bones:
if source_min_root is None or source_min_root > bone_source_z:
source_min_root = bone_source_z
if target_min_root is None or target_min_root > bone_target_z:
target_min_root = bone_target_z
if source_min is None or source_min > bone_source_z:
source_min = bone_source_z
if target_min is None or target_min > bone_target_z:
target_min = bone_target_z
source_height = source_min_root - source_min
target_height = target_min_root - target_min
if not source_height or not target_height:
print('No scaling needed')
return
scale_factor = target_height / source_height
armature_source.scale *= scale_factor
def read_anim_start_end(self, armature):
frame_start = None
frame_end = None
for fcurve in armature.animation_data.action.fcurves:
for key in fcurve.keyframe_points:
keyframe = key.co.x
if frame_start is None:
frame_start = keyframe
if frame_end is None:
frame_end = keyframe
if keyframe < frame_start:
frame_start = keyframe
if keyframe > frame_end:
frame_end = keyframe
return frame_start, frame_end
def copy_rest_pose(self, context, armature_source):
# make sure auto keyframe is disabled, leads to issues
context.scene.tool_settings.use_keyframe_insert_auto = False
# ensure the source armature selection
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(armature_source)
bpy.ops.object.mode_set(mode='OBJECT')
# Duplicate the source armature
bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'},
TRANSFORM_OT_translate={"value": (0, 0, 0), "constraint_axis": (False, True, False), "mirror": False, "snap": False, "remove_on_cancel": False,
"release_confirm": False})
# Set name of the copied source armature
source_armature_copy = context.object
source_armature_copy.name = armature_source.name + "_copy"
bpy.ops.object.select_all(action='DESELECT')
utils.set_active(source_armature_copy)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='POSE')
# Apply transforms of the new source armature. Unlink action temporarily to prevent warning in console
action_tmp = source_armature_copy.animation_data.action
source_armature_copy.animation_data.action = None
bpy.ops.pose.armature_apply()
source_armature_copy.animation_data.action = action_tmp
# Mimic the animation of the original source armature by adding constraints to the bones.
# -> the new armature has the exact same animation but with applied transforms
for bone in source_armature_copy.pose.bones:
constraint = bone.constraints.new('COPY_TRANSFORMS')
constraint.name = bone.name
constraint.target = armature_source
constraint.subtarget = bone.name
bpy.ops.object.mode_set(mode='OBJECT')
return source_armature_copy
def bake_animation(self, armature_source, armature_target, root_bones):
frame_split = 25
frame_start, frame_end = self.read_anim_start_end(armature_source)
frame_start, frame_end = int(frame_start), int(frame_end)
utils.set_active(armature_target)
actions_all = []
# Setup loading bar
current_step = 0
steps = int((frame_end - frame_start) / frame_split) + 1
wm = bpy.context.window_manager
wm.progress_begin(current_step, steps)
import time
start_time = time.time()
# Bake the animation in parts because multiple short parts are processed much faster than one long animation
bpy.ops.object.mode_set(mode='POSE')
for frame in range(frame_start, frame_end + 2, frame_split):
start = frame
end = frame + frame_split - 1
if end > frame_end:
end = frame_end
if start > end:
continue
# Bake animation part
bpy.ops.nla.bake(frame_start=start, frame_end=end, visual_keying=True, only_selected=True, use_current_action=False, bake_types={'POSE'})
# Rename animation part
armature_target.animation_data.action.name = 'RSL_RETARGETING_' + str(frame)
actions_all.append(armature_target.animation_data.action)
current_step += 1
if steps != current_step:
wm.progress_update(current_step)
bpy.ops.object.mode_set(mode='OBJECT')
if not actions_all:
return
# Count all keys for all data_paths
key_counts = {}
for action in actions_all:
for fcurve in action.fcurves:
key = fcurve.data_path + str(fcurve.array_index)
if not key_counts.get(key):
key_counts[key] = 0
key_counts[key] += len(fcurve.keyframe_points)
# Create new action
action_final = bpy.data.actions.new(name='RSL_RETARGETING_FINAL')
action_final.use_fake_user = True
armature_target.animation_data_create().action = action_final
# Put all baked animations parts back together into one
print_i = 0
for fcurve in actions_all[0].fcurves:
if fcurve.data_path.endswith('scale'):
continue
if fcurve.data_path.endswith('location'):
bone_name = fcurve.data_path.split('"')
if len(bone_name) != 3:
continue
if bone_name[1] not in root_bones:
continue
curve_final = action_final.fcurves.new(data_path=fcurve.data_path, index=fcurve.array_index, action_group=fcurve.group.name)
keyframe_points = curve_final.keyframe_points
keyframe_points.add(key_counts[fcurve.data_path + str(fcurve.array_index)])
index = 0
for action in actions_all:
fcruve_to_add = action.fcurves.find(data_path=fcurve.data_path, index=fcurve.array_index)
for kp in fcruve_to_add.keyframe_points:
keyframe_points[index].co.x = kp.co.x
keyframe_points[index].co.y = kp.co.y
keyframe_points[index].interpolation = 'LINEAR'
index += 1
print_i += 1
# Clean up animation. Delete all keyframes the use the same value as the previous and next one
for fcurve in action_final.fcurves:
if len(fcurve.keyframe_points) <= 2:
continue
kp_pre_pre = fcurve.keyframe_points[0]
kp_pre = fcurve.keyframe_points[1]
kp_to_delete = []
for kp in fcurve.keyframe_points[2:]:
if round(kp_pre_pre.co.y, 5) == round(kp_pre.co.y, 5) == round(kp.co.y, 5):
kp_to_delete.append(kp_pre)
kp_pre_pre = kp_pre
kp_pre = kp
for kp in reversed(kp_to_delete):
fcurve.keyframe_points.remove(kp)
# Delete all baked animation parts, only the combined one is needed
for action in actions_all:
bpy.data.actions.remove(action)
print('Retargeting Time:', round(time.time() - start_time, 2), 'seconds')
wm.progress_end()
# Set the action slot sub action
if hasattr(armature_target.animation_data, "action_slot"):
armature_target.animation_data.action_slot = armature_target.animation_data.action_suitable_slots[0]