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,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
@@ -0,0 +1,311 @@
<div align="center">
# Rokoko Studio Live Plugin for Blender
[![Blender](https://img.shields.io/badge/Blender-2.80%2B-orange?logo=blender&logoColor=white)](https://www.blender.org/)
[![Rokoko Studio](https://img.shields.io/badge/Rokoko%20Studio-2.4.8%2B-blue)](https://www.rokoko.com/en/products/studio)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
**Stream motion capture data from Rokoko Studio directly into Blender in real-time**
[📥 Download Latest](https://github.com/Rokoko/rokoko-studio-live-blender/archive/refs/heads/master.zip) •
[📖 Documentation](https://support.rokoko.com/hc/en-us/categories/4410420388113-Rokoko-Plugins) •
[💬 Support](https://support.rokoko.com/)
</div>
---
## 🎯 Overview
[Rokoko Studio](https://www.rokoko.com/en/products/studio) is a powerful and intuitive software for recording, visualizing and exporting motion capture data. This official Blender plugin enables seamless real-time streaming of animation data from Rokoko Studio directly into Blender.
**Key Benefits:**
- 🎭 **Real-time streaming** of motion capture data
- 🎮 **Remote control** of Rokoko Studio from Blender
- 🔄 **Easy retargeting** between different character rigs
- 👥 **Multi-actor support** for complex scenes
## 📖 Table of Contents
- [🎯 Overview](#-overview)
- [✨ Features](#-features)
- [📋 Requirements](#-requirements)
- [📦 Installation](#-installation)
- [🎬 Getting Started for Streaming](#-getting-started-for-streaming)
- [1. Prepare Your Model](#1-prepare-your-model)
- [2. Enable Rokoko Studio Live](#2-enable-rokoko-studio-live)
- [3. Receive Data in Blender](#3-receive-data-in-blender)
- [4. Stream Character Data](#4-stream-character-data)
- [5. Stream Face and Prop Data](#5-stream-face-and-prop-data)
- [🎯 Retargeting](#-retargeting)
- [💡 Tips & Troubleshooting](#-tips--troubleshooting)
- [🤝 Contributing](#-contributing)
- [📞 Support](#-support)
---
## ✨ Features
### 🎭 Live Data Streaming
- **Full body tracking**: Complete skeletal animation data
- **Facial animation**: 52 blendshapes for detailed facial expressions
- **Finger tracking**: Precise hand and finger movements with SmartGloves
- **Multi-actor support**: Up to 5 actors simultaneously
- **Camera data**: Live camera tracking information
- **Props tracking**: Real-time prop animation
### 🎮 Studio Integration
- **Remote control**: Control Rokoko Studio directly from Blender
- **Real-time preview**: See your animations as they're captured
- **Seamless workflow**: No need to switch between applications
### 🔄 Animation Tools
- **Smart retargeting**: Easily retarget animations between different rigs
- **Auto-detection**: Automatic bone mapping for faster setup
## 📋 Requirements
| Component | Version | Notes |
|-------------------------|------------------------------|-----------------------------------|
| **Blender** | 2.80 or higher | Required for plugin compatibility |
| **Rokoko Studio** | 2.4.8 or higher | For livestreaming functionality |
| **Internet Connection** | Required during installation | To download required libraries |
## 📦 Installation
### Step 1: Download
**[📥 Download the latest version here](https://github.com/Rokoko/rokoko-studio-live-blender/archive/refs/heads/master.zip)**
### Step 2: Install in Blender
1. Open Blender
2. Navigate to `Edit > Preferences > Addons`
3. Click `Install...`
4. Select the downloaded ZIP file
5. Enable the "Rokoko Studio Live" addon
> ⚠️ **Note**: First-time installation may take several minutes as required libraries are downloaded.
### Step 3: Access the Plugin
- In the 3D viewport, press `N` to open the sidebar
- Select the **"Rokoko"** tab to access all plugin features
## 🎬 Getting Started for Streaming
### 1. Prepare Your Model
#### Character Setup
Your character in Blender must be in **T-pose** for proper retargeting:
<div align="center">
<img src="https://i.imgur.com/p4uVZBx.png" height="450" alt="Character in T-pose"/>
</div>
#### SmartGloves Setup
For optimal finger animation retargeting, ensure your character's hands match this pose:
- All fingers should be straight
- Thumb rotated 45 degrees away from other fingers
<div align="center">
<img src="https://i.imgur.com/9I13bHI.png" alt="Hand pose for SmartGloves"/>
</div>
### 2. Enable Rokoko Studio Live
1. Open **Rokoko Studio** and open a project
2. On the right side, go to **Streaming**
3. In the Blender row, press the **wrench icon** to open settings
4. Enable **Include Connection**
- (optional) Customize Forward IP and Port
5. **Activate** the data stream to Blender
<div align="center">
<p float="left">
<img src="https://i.imgur.com/mkVr39B.gif" height="400" alt="Studio Live settings"/>
</p>
</div>
### 3. Receive Data in Blender
1. In the **3D viewport**, press `N` to open the sidebar
2. Select the **"Rokoko"** tab
3. Click **"Start Receiver"** to begin receiving data from Rokoko Studio
<div align="center">
<p float="left">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47010394035/original/F9BVdJ-P3GjPAqGsOno-it18A0lvyF3n3A.png" height="300" alt="Open sidebar"/>
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47010394045/original/1E4Pt708FhhoGngovjP7V3CYVaNgNG_J_w.png" height="300" alt="Rokoko tab"/>
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47010394056/original/Um5r_amKNoEJaF8vjF1JgQwVyjztGDtJ5w.png" height="300" alt="Start receiver"/>
</p>
</div>
### 4. Stream Character Data
1. **Select the armature** you want to animate
2. Go to **Object Properties****"Rokoko Studio Live Setup"** panel
3. In the **Actor field**, select the Smartsuit for this armature
4. Click **"Auto Detect"** to fill bone fields automatically
5. Verify all bones are correctly mapped (fill missing bones manually if needed)
6. Ensure the armature is in **T-Pose**, then click **"Set as T-Pose"**
<div align="center">
<img src="https://i.imgur.com/ydn6cAi.gif" alt="Character setup process"/>
</div>
7. **Done!** Your armature should now be animated by live data:
<div align="center">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47011948259/original/JDKx_BMV2iDNhqyEk1nsNqsm8zQt2YbT5g.gif" height="400" alt="Live animation"/>
</div>
#### Performance Tips
💡 **Optimize performance** by enabling "Hide Meshes during Play" in the receiver panel:
<div align="center">
<img src="https://i.imgur.com/HESveWD.png" alt="Hide meshes option"/>
</div>
⚠️ **Reduce lag** by closing keyframe windows (timeline, action editor) as they can consume significant resources in earlier Blender versions.
### 5. Stream Face and Prop Data
Face and prop data follow the same workflow as character data:
1. **For face data**: Select the face mesh
2. **For prop data**: Select the prop object
3. Follow the same setup steps as character data above
4. **Done!** Your face mesh or prop will be animated by live data
<div align="center">
<p float="left">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47011946440/original/-2ES8ffaPb-jANEBaZWpLzvoy6gDB_FPXQ.gif" height="350" alt="Face animation"/>
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47011950531/original/LB3AZ4q5IIPOX-WF1mYuuRqeNsWsGY_hgw.gif" height="350" alt="Prop animation"/>
</p>
</div>
#### Custom Scaling for Props
💡 **For prop data**: Enable "Use Custom Scale" to adjust animation scale for your Blender project:
<div align="center">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47011950790/original/vpwUqdfTZJcBryvKjJmUfV0BXKT3kX__eQ.PNG" alt="Custom scale option"/>
</div>
---
## 🎯 Retargeting
Easily retarget animations between different character rigs using the built-in retargeting system.
### Step-by-Step Process
1. **Open the Retargeting panel** in the Rokoko tab
<div align="center">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47029758599/original/gt30hHJ2JCfKDmmALDxjffiHbYjqFMQFmg.png" alt="Retargeting panel"/>
</div>
2. **Set up source and target armatures**:
- **Source armature**: Select the armature with existing animation
- **Target armature**: Select the armature that should receive the animation
- Click **"Build Bone List"**
<div align="center">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47029758649/original/AuSYaHVCMTAQmTYRX8JHohflx4B6tu7EVQ.png" alt="Build bone list"/>
</div>
3. **Verify bone mapping**: Check that bones are correctly mapped and fix any missing or incorrect mappings
<div align="center">
<img src="https://s3.amazonaws.com/cdn.freshdesk.com/data/helpdesk/attachments/production/47029758669/original/O_kTjk6qEKnNr_jOmvMXa2OI5d561ttBqA.png" alt="Bone mapping"/>
</div>
4. **Configure retargeting options**:
- Enable **"Auto Scale"** if armatures differ in size (or adjust manually)
- Select the appropriate pose in **"Use Pose"**
- ⚠️ **Important**: Ensure both armatures are in the same pose for accurate retargeting
5. **Execute retargeting**: Click **"Retarget Animation"**
6. **Done!** Your animation is now retargeted to the new armature
### 📺 Video Tutorial
<div align="center">
[![Retargeting Video Tutorial](https://img.youtube.com/vi/Od8Ecr70A4Q/maxresdefault.jpg)](https://youtu.be/Od8Ecr70A4Q)
*Click to watch the complete retargeting tutorial*
</div>
---
## 💡 Tips & Troubleshooting
### Performance Optimization
- **Hide meshes during playback**: Enable "Hide Meshes during Play" for better performance
- **Close keyframe panels**: Timeline and Action Editor consume significant resources
- **Reduce viewport complexity**: Hide unnecessary objects during streaming
### Common Issues
#### Connection Problems
- **Check network settings**: Ensure Rokoko Studio and Blender are on the same network
- **Verify port settings**: Default port is 14043, ensure it's not blocked by firewall
- **Restart both applications**: Sometimes a fresh start resolves connection issues
#### Animation Issues
- **T-pose requirement**: Always ensure your character is in T-pose before setup
- **Bone mapping**: Verify all bones are correctly mapped using "Auto Detect"
- **Scale differences**: Use "Auto Scale" for characters of different sizes
#### Performance Issues
- **System requirements**: Ensure your system meets minimum requirements
- **Background processes**: Close unnecessary applications during streaming
- **Blender settings**: Reduce viewport samples and disable unnecessary addons
---
## 🤝 Contributing
We welcome contributions to improve the Rokoko Studio Live Plugin! Here's how you can help:
### Reporting Issues
- Use the [GitHub Issues](https://github.com/Rokoko/rokoko-studio-live-blender/issues) page
- Provide detailed information about your setup and the issue
- Include steps to reproduce the problem
### Feature Requests
- Submit feature requests through GitHub Issues
- Describe the use case and expected behavior
- Check existing issues to avoid duplicates
### Development
- Fork the repository
- Create a feature branch
- Submit a pull request with detailed description
---
## 📞 Support
### Official Support Channels
- **📖 Documentation**: [docs.rokoko.com](https://support.rokoko.com/hc/en-us/categories/4410420388113-Rokoko-Plugins)
- **💬 Support Portal**: [support.rokoko.com](https://support.rokoko.com/)
- **🐛 Bug Reports**: [GitHub Issues](https://github.com/Rokoko/rokoko-studio-live-blender/issues)
### Community
- **Discord**: Join our [Discord community](https://discord.com/invite/AfCJBBQqRm)
- **YouTube**: [Rokoko YouTube Channel](https://www.youtube.com/@RokokoMotion) for tutorials
- **Social Media**: Follow [@rokoko](https://x.com/hellorokoko) for updates
---
<div align="center">
**Made with ❤️ by [Rokoko](https://www.rokoko.com/)**
*Bringing motion capture to everyone*
</div>
@@ -0,0 +1,199 @@
# Important plugin info for Blender
bl_info = {
'name': 'Rokoko Studio Live for Blender',
'author': 'Rokoko Electronics ApS',
'category': 'Animation',
'location': 'View 3D > Tool Shelf > Rokoko',
'description': 'Stream your Rokoko Studio animations directly into Blender',
'version': (1, 4, 2),
'blender': (2, 80, 0),
'wiki_url': 'https://github.com/Rokoko/rokoko-studio-live-blender#readme',
}
beta_branch = False
first_startup = "bpy" not in locals()
import bpy
import sys
# Load the updater. Important to do early, so it is always loaded even in case of an error
from . import updater_ops
from . import updater
if not first_startup:
import importlib
importlib.reload(updater_ops)
importlib.reload(updater)
# Register the updater
updater_ops.register()
# If first startup of this plugin, load all modules normally
# If reloading the plugin, use importlib to reload modules
# This lets you do adjustments to the plugin on the fly without having to restart Blender
from . import core
from . import panels
from . import operators
from . import properties
if first_startup:
pass
else:
import importlib
importlib.reload(core)
importlib.reload(panels)
importlib.reload(operators)
importlib.reload(properties)
absolute_min_ver = (2, 80, 75)
soft_min_ver = (4, 4, 0)
def check_unsupported_blender_versions():
# Don't allow Blender versions older than 2.80
if bpy.app.version < absolute_min_ver:
unregister()
sys.tracebacklimit = 0
raise ImportError('\n\nBlender versions older than 2.80 are not supported by Rokoko Studio Live. '
'\nPlease use Blender 2.80 or later.'
'\n')
# List of all buttons and panels
classes_logged_in = [ # These panels will only be loaded when the user is logged in
panels.main.ReceiverPanel,
panels.objects.ObjectsPanel,
panels.command_api.CommandPanel,
panels.retargeting.RetargetingPanel,
panels.updater.UpdaterPanel,
panels.info.InfoPanel,
]
classes_logged_out = [ # These panels will only be loaded when the user is logged out
panels.login.LoginPanel,
panels.updater.UpdaterPanel,
panels.info.InfoPanel,
]
classes_always_enable = [ # These non-panels will always be loaded, all non-panel ui should go in here
operators.login.LoginButton,
operators.login.LogoutButton,
operators.login.InstallLibsButton,
operators.receiver.ReceiverStart,
operators.receiver.ReceiverStop,
operators.recorder.RecorderStart,
operators.recorder.RecorderStop,
operators.detector.DetectFaceShapes,
operators.detector.DetectActorBones,
operators.detector.SaveCustomShapes,
operators.detector.SaveCustomBones,
operators.detector.SaveCustomBonesRetargeting,
operators.detector.ImportCustomBones,
operators.detector.ExportCustomBones,
operators.detector.ClearCustomBones,
operators.detector.ClearCustomShapes,
operators.actor.InitTPose,
operators.actor.ResetTPose,
operators.actor.PrintCurrentPose,
operators.command_api.CommandTest,
operators.command_api.StartCalibration,
operators.command_api.Restart,
operators.command_api.StartRecording,
operators.command_api.StopRecording,
operators.retargeting.BuildBoneList,
operators.retargeting.AddBoneListItem,
operators.retargeting.ClearBoneList,
operators.retargeting.RetargetAnimation,
panels.retargeting.RSL_UL_BoneList,
panels.retargeting.BoneListItem,
operators.info.LicenseButton,
operators.info.RokokoButton,
operators.info.DocumentationButton,
operators.info.ForumButton,
operators.info.ToggleRokokoIDButton,
]
def register_classes(classes, unregister_classes=[]):
# Unregister classes_logged_in first
for cls in reversed(unregister_classes):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
print("Error: Failed to unregister class", cls)
pass
register_count = 0
for cls in classes:
try:
bpy.utils.register_class(cls)
register_count += 1
except ValueError:
print("Error: Failed to register class", cls)
pass
if register_count < len(classes):
print('Skipped', len(classes) - register_count, 'ROKOKO classes_logged_in.')
def register():
print("\n### Loading Rokoko Studio Live for Blender...")
# Register the updater, but only if the plugin was unregistered and then registered again
updater_ops.register()
# Check for unsupported Blender versions
check_unsupported_blender_versions()
# Register logged out classes
register_classes(classes_logged_out + classes_always_enable)
# Register all custom properties
properties.register()
# Load custom icons
core.icon_manager.load_icons()
# Load bone detection list
core.detection_manager.load_detection_lists()
# Init fbx patcher
core.fbx_patcher.start_fbx_patch_timer()
# Add info to the login user and then login if all libraries are loaded
core.login_manager.user.set_info(classes_logged_in, classes_logged_out, bl_info)
if core.login_manager.loaded_all_libs:
core.login_manager.user.auto_login()
# Update updater info as late as possible, to ensure that errors are shown instead of being overwritten
updater_ops.update_info(bl_info, beta_branch)
print("### Loaded Rokoko Studio Live for Blender successfully!\n")
def unregister():
print("### Unloading Rokoko Studio Live for Blender...")
from . import updater_ops
from . import operators
from . import core
# Unregister updater
updater_ops.unregister()
# Shut down receiver if the plugin is disabled while it is running
if operators.receiver.receiver_enabled:
operators.receiver.ReceiverStart.force_disable()
# Unregister all classes
for cls in reversed(classes_logged_out + classes_logged_in + classes_always_enable):
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
pass
# Unload all custom icons
core.icon_manager.unload_icons()
# Exit the logged-in user
core.login_manager.user.quit()
print("### Unloaded Rokoko Studio Live for Blender successfully!\n")
if __name__ == '__main__':
register()
@@ -0,0 +1,36 @@
if "bpy" not in locals():
import bpy
from . import library_manager # has to be first, to add the lib folder to the sys.path
from . import receiver
from . import animations
from . import animation_lists
from . import utils
from . import state_manager
from . import icon_manager
from . import recorder
from . import retargeting
from . import detection_manager
from . import detection_manager_v2
from . import custom_schemes_manager
from . import fbx_patcher
from . import login_manager
from . import live_data_manager
else:
import importlib
importlib.reload(library_manager)
importlib.reload(receiver)
importlib.reload(animations)
importlib.reload(animation_lists)
importlib.reload(utils)
importlib.reload(state_manager)
importlib.reload(icon_manager)
importlib.reload(recorder)
importlib.reload(retargeting)
importlib.reload(detection_manager)
importlib.reload(detection_manager_v2)
importlib.reload(custom_schemes_manager)
importlib.reload(fbx_patcher)
importlib.reload(login_manager)
importlib.reload(live_data_manager)
@@ -0,0 +1,278 @@
from mathutils import Quaternion
from collections import OrderedDict
from . import animations
# Face shapekeys
face_shapes = [
'eyeBlinkLeft',
'eyeLookDownLeft',
'eyeLookInLeft',
'eyeLookOutLeft',
'eyeLookUpLeft',
'eyeSquintLeft',
'eyeWideLeft',
'eyeBlinkRight',
'eyeLookDownRight',
'eyeLookInRight',
'eyeLookOutRight',
'eyeLookUpRight',
'eyeSquintRight',
'eyeWideRight',
'jawForward',
'jawLeft',
'jawRight',
'jawOpen',
'mouthClose',
'mouthFunnel',
'mouthPucker',
'mouthLeft',
'mouthRight',
'mouthSmileLeft',
'mouthSmileRight',
'mouthFrownLeft',
'mouthFrownRight',
'mouthDimpleLeft',
'mouthDimpleRight',
'mouthStretchLeft',
'mouthStretchRight',
'mouthRollLower',
'mouthRollUpper',
'mouthShrugLower',
'mouthShrugUpper',
'mouthPressLeft',
'mouthPressRight',
'mouthLowerDownLeft',
'mouthLowerDownRight',
'mouthUpperUpLeft',
'mouthUpperUpRight',
'browDownLeft',
'browDownRight',
'browInnerUp',
'browOuterUpLeft',
'browOuterUpRight',
'cheekPuff',
'cheekSquintLeft',
'cheekSquintRight',
'noseSneerLeft',
'noseSneerRight',
'tongueOut'
]
# Tpose from Studio live
actor_bones = OrderedDict()
actor_bones['hip'] = Quaternion((-1.0, 0.0, -0.0, 0.0))
actor_bones['spine'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
actor_bones['chest'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
actor_bones['neck'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
actor_bones['head'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
actor_bones['leftShoulder'] = Quaternion((-0.70711, 0.0, 0.0, 0.70711))
actor_bones['leftUpperArm'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
actor_bones['leftLowerArm'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
actor_bones['leftHand'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
actor_bones['rightShoulder'] = Quaternion((0.70711, 0.0, -0.0, 0.70711))
actor_bones['rightUpperArm'] = Quaternion((0.5, 0.5, -0.5, 0.5))
actor_bones['rightLowerArm'] = Quaternion((0.5, 0.5, -0.5, 0.5))
actor_bones['rightHand'] = Quaternion((0.5, 0.5, -0.5, 0.5))
actor_bones['leftUpLeg'] = Quaternion((0.70711, -0.0, 0.70711, -0.0))
actor_bones['leftLeg'] = Quaternion((0.70711, -0.0, 0.70711, 0.0))
actor_bones['leftFoot'] = Quaternion((0.0, -0.0, 0.70711, -0.70711))
actor_bones['leftToe'] = Quaternion((0.0, -0.0, 0.70711, -0.70711))
# actor_bones['leftToeEnd'] = Quaternion((0.0, -0.0, 0.70711, -0.70711))
actor_bones['rightUpLeg'] = Quaternion((0.70711, -0.0, -0.70711, 0.0))
actor_bones['rightLeg'] = Quaternion((0.70711, -0.0, -0.70711, 0.0))
actor_bones['rightFoot'] = Quaternion((0.0, 0.0, -0.70711, 0.70711))
actor_bones['rightToe'] = Quaternion((0.0, 0.0, -0.70711, 0.70711))
# actor_bones['rightToeEnd'] = Quaternion((0.0, 0.0, -0.70711, 0.70711))
# actor_bones['leftThumbProximal'] = Quaternion((-0.0923, -0.56098, -0.70106, 0.43046))
# actor_bones['leftThumbMedial'] = Quaternion((-0.2706, -0.65328, -0.65328, 0.2706))
# actor_bones['leftThumbDistal'] = Quaternion((-0.2706, -0.65328, -0.65328, 0.2706))
# # actor_bones['leftThumbTip'] = Quaternion((-0.2706, -0.65328, -0.65328, 0.2706))
#
# actor_bones['leftIndexProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftIndexMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftIndexDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# # actor_bones['leftIndexTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
#
# actor_bones['leftMiddleProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftMiddleMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftMiddleDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# # actor_bones['leftMiddleTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
#
# actor_bones['leftRingProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftRingMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftRingDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# # actor_bones['leftRingTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
#
# actor_bones['leftLittleProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftLittleMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# actor_bones['leftLittleDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# # actor_bones['leftLittleTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
#
# actor_bones['rightThumbProximal'] = Quaternion((0.0923, 0.56099, -0.70106, 0.43046))
# actor_bones['rightThumbMedial'] = Quaternion((0.2706, 0.65328, -0.65328, 0.2706))
# actor_bones['rightThumbDistal'] = Quaternion((0.2706, 0.65328, -0.65328, 0.2706))
# # actor_bones['rightThumbTip'] = Quaternion((0.2706, 0.65328, -0.65328, 0.2706))
#
# actor_bones['rightIndexProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightIndexMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightIndexDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# # actor_bones['rightIndexTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
#
# actor_bones['rightMiddleProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightMiddleMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightMiddleDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# # actor_bones['rightMiddleTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
#
# actor_bones['rightRingProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightRingMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightRingDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# # actor_bones['rightRingTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
#
# actor_bones['rightLittleProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightLittleMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# actor_bones['rightLittleDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# # actor_bones['rightLittleTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones = OrderedDict()
glove_bones['hip'] = Quaternion((-1.0, 0.0, -0.0, 0.0))
glove_bones['spine'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
glove_bones['chest'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
glove_bones['neck'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
glove_bones['head'] = Quaternion((-0.0, -0.0, 0.0, -1.0))
glove_bones['leftShoulder'] = Quaternion((-0.70711, 0.0, 0.0, 0.70711))
glove_bones['leftUpperArm'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftLowerArm'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftHand'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['rightShoulder'] = Quaternion((0.70711, 0.0, -0.0, 0.70711))
glove_bones['rightUpperArm'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightLowerArm'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightHand'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['leftUpLeg'] = Quaternion((0.70711, -0.0, 0.70711, -0.0))
glove_bones['leftLeg'] = Quaternion((0.70711, -0.0, 0.70711, 0.0))
glove_bones['leftFoot'] = Quaternion((0.0, -0.0, 0.70711, -0.70711))
glove_bones['leftToe'] = Quaternion((0.0, -0.0, 0.70711, -0.70711))
# glove_bones['leftToeEnd'] = Quaternion((0.0, -0.0, 0.70711, -0.70711))
glove_bones['rightUpLeg'] = Quaternion((0.70711, -0.0, -0.70711, 0.0))
glove_bones['rightLeg'] = Quaternion((0.70711, -0.0, -0.70711, 0.0))
glove_bones['rightFoot'] = Quaternion((0.0, 0.0, -0.70711, 0.70711))
glove_bones['rightToe'] = Quaternion((0.0, 0.0, -0.70711, 0.70711))
# glove_bones['rightToeEnd'] = Quaternion((0.0, 0.0, -0.70711, 0.70711))
glove_bones['leftThumbProximal'] = Quaternion((-0.0923, -0.56098, -0.70106, 0.43046))
glove_bones['leftThumbMedial'] = Quaternion((-0.2706, -0.65328, -0.65328, 0.2706))
glove_bones['leftThumbDistal'] = Quaternion((-0.2706, -0.65328, -0.65328, 0.2706))
# glove_bones['leftThumbTip'] = Quaternion((-0.2706, -0.65328, -0.65328, 0.2706))
glove_bones['leftIndexProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftIndexMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftIndexDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# glove_bones['leftIndexTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftMiddleProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftMiddleMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftMiddleDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# glove_bones['leftMiddleTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftRingProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftRingMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftRingDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# glove_bones['leftRingTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftLittleProximal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftLittleMedial'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['leftLittleDistal'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
# glove_bones['leftLittleTip'] = Quaternion((-0.5, -0.5, -0.5, 0.5))
glove_bones['rightThumbProximal'] = Quaternion((0.0923, 0.56099, -0.70106, 0.43046))
glove_bones['rightThumbMedial'] = Quaternion((0.2706, 0.65328, -0.65328, 0.2706))
glove_bones['rightThumbDistal'] = Quaternion((0.2706, 0.65328, -0.65328, 0.2706))
# glove_bones['rightThumbTip'] = Quaternion((0.2706, 0.65328, -0.65328, 0.2706))
glove_bones['rightIndexProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightIndexMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightIndexDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# glove_bones['rightIndexTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightMiddleProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightMiddleMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightMiddleDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# glove_bones['rightMiddleTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightRingProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightRingMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightRingDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# glove_bones['rightRingTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightLittleProximal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightLittleMedial'] = Quaternion((0.5, 0.5, -0.5, 0.5))
glove_bones['rightLittleDistal'] = Quaternion((0.5, 0.5, -0.5, 0.5))
# glove_bones['rightLittleTip'] = Quaternion((0.5, 0.5, -0.5, 0.5))
def get_bones(with_gloves=True):
"""
Return all bones with their default positions. This represents the exact default position of the Studio reference avatar
:param with_gloves: Determines if the hand bones should be included or not
:return: dictionary with bone names and default positions
"""
# TODO: Remove redundant entries and create dicts at the start of the plugin
return glove_bones if with_gloves else actor_bones
# Creates the list of props and trackers for the objects panel
def get_props_trackers(self, context):
choices = [('None', '-None-', 'None')]
for prop in animations.live_data.props:
# 1. Will be returned by context.scene
# 2. Will be shown in lists
# 3. will be shown in the hover description (below description)
prop_name = animations.live_data.get_prop_name(prop)
choices.append((animations.live_data.get_prop_id(prop), prop_name, prop_name))
for tracker in animations.live_data.trackers:
tracker_name = animations.live_data.get_prop_name(tracker, is_tracker=True)
choices.append((animations.live_data.get_prop_id(tracker, is_tracker=True), tracker_name, tracker_name))
return choices
# Creates the list of faces for the objects panel
def get_faces(self, context):
choices = [('None', '-None-', 'None')]
for face in animations.live_data.faces:
# 1. Will be returned by context.scene
# 2. Will be shown in lists
# 3. will be shown in the hover description (below description)
face_id = animations.live_data.get_face_id(face)
choices.append((face_id, face_id, face_id))
return choices
# Creates the list of actors for the objects panel
def get_actors(self, context):
choices = [('None', '-None-', 'None')]
for actor in animations.live_data.actors:
# 1. Will be returned by context.scene
# 2. Will be shown in lists
# 3. will be shown in the hover description (below description)
actor_id = animations.live_data.get_actor_id(actor)
choices.append((actor_id, actor_id, actor_id))
return choices
@@ -0,0 +1,236 @@
import bpy
from mathutils import Quaternion, Matrix
from . import animation_lists, recorder
from .live_data_manager import LiveData
live_data: LiveData = LiveData()
def clear_animations():
live_data.clear_data()
def animate():
for obj in bpy.data.objects:
# Animate all trackers and props
if live_data.props or live_data.trackers:
animate_tracker_prop(obj)
# Animate all faces
if obj.type == 'MESH' and live_data.faces:
animate_face(obj)
# Animate all actors
elif obj.type == 'ARMATURE':
if live_data.actors:
animate_actor(obj)
def animate_tracker_prop(obj):
if not obj.rsl_animations_props_trackers or obj.rsl_animations_props_trackers == 'None':
return
# Get prop
prop = live_data.get_prop_by_obj(obj)
if not prop:
return
# Get the scene scaling
scene_scale = bpy.context.scene.rsl_scene_scaling
if obj.rsl_use_custom_scale:
scene_scale = obj.rsl_custom_scene_scale
# Set the transforms of the object
obj.rotation_mode = 'QUATERNION'
obj.location = pos_studio_to_blender(
prop['position']['x'] * scene_scale,
prop['position']['y'] * scene_scale,
prop['position']['z'] * scene_scale,
)
obj.rotation_quaternion = rot_studio_to_blender(
prop['rotation']['w'],
prop['rotation']['x'],
prop['rotation']['y'],
prop['rotation']['z'],
)
# Record data
if bpy.context.scene.rsl_recording:
recorder.record_object(live_data.timestamp, obj.name, obj.rotation_quaternion, obj.location)
def animate_face(obj):
if not hasattr(obj.data, 'shape_keys') or not hasattr(obj.data.shape_keys, 'key_blocks'):
return
if not obj.rsl_animations_faces or obj.rsl_animations_faces == 'None':
return
# Get the face live data
face = live_data.get_face_by_obj(obj)
if not face:
return
# Set each assigned shapekey to the value of it's according live data value
for shapekey_name in animation_lists.face_shapes:
# Get assigned shapekey
shapekey = obj.data.shape_keys.key_blocks.get(getattr(obj, 'rsl_face_' + shapekey_name))
if shapekey:
shapekey.slider_min = -1
shapekey.value = face[shapekey_name] / 100
if bpy.context.scene.rsl_recording:
# shapekey.keyframe_insert(data_path='value', group=obj.name)
recorder.record_face(live_data.timestamp, obj.name, shapekey_name, shapekey.value)
def animate_actor(obj):
# Return if no actor is assigned to this object
if not obj.rsl_animations_actors or obj.rsl_animations_actors == 'None':
return
# Get the actor data assigned to the object
actor = live_data.get_actor_by_obj(obj)
if not actor:
return
# Get current custom data from this object
# The models t-pose bone rotations and locations, which are set by the user, are stored inside this custom data
custom_data = obj.get('CUSTOM')
if not custom_data:
# print('NO CUSTOM DATA')
return
# Get tpose data from custom data
tpose_bones = custom_data.get('rsl_tpose_bones')
if not tpose_bones:
# print('NO TPOSE DATA')
return
# Go over every mapped bone and animate it
# bone_name: Name if the bone
# studio_reference_tpose_rot: Studios reference t-pose rotation (still in Studio space)
for bone_name, studio_reference_tpose_rot in animation_lists.get_bones(with_gloves=live_data.has_gloves(actor)).items():
# Gets the name of the bone assigned to this bone live data
bone_name_assigned = getattr(obj, 'rsl_actor_' + bone_name)
# Gets the assigned pose bone and it's tpose data set by the user
bone = obj.pose.bones.get(bone_name_assigned)
bone_data = obj.data.bones.get(bone_name_assigned)
bone_tpose_data = tpose_bones.get(bone_name_assigned)
try:
actor_bone_data = actor[bone_name] if live_data.version <= 2 else actor['body'][bone_name]
except KeyError:
print('Bone not found in live data:', bone_name)
continue
# Skip if there is no bone assigned to this live data or if there is no tpose data for this bone
if not bone or not bone_tpose_data:
continue
# Set the bones rotation mode to euler and disable inherit rotation
if bone.rotation_mode == 'QUATERNION':
bone.rotation_mode = 'XYZ'
bone_data.use_inherit_rotation = False
# The global rotation of the models t-pose, which was set by the user
bone_tpose_rot_global = Quaternion(bone_tpose_data['rotation_global'])
# The new pose in which the bone should be (still in Studio space)
studio_new_pose = Quaternion((
float(actor_bone_data['rotation']['w']),
float(actor_bone_data['rotation']['x']),
float(actor_bone_data['rotation']['y']),
float(actor_bone_data['rotation']['z']),
))
# Function to convert from Studio to Blender space
def rot_to_blender(rot):
return Quaternion((
rot.w,
rot.x,
-rot.y,
-rot.z,
)) @ Quaternion((0, 0, 0, 1))
mat_obj = obj.matrix_local.decompose()[1].to_matrix().to_4x4()
mat_default = Matrix((
(1, 0, 0, 0),
(0, 0, -1, 0),
(0, 1, 0, 0),
(0, 0, 0, 1)
))
rot_transform = (mat_default.inverted() @ mat_obj).to_quaternion()
def transform(rot):
return rot_transform @ rot
def transform_back(rot):
return rot_transform.inverted() @ rot
# Transform rotation matrix of tpose to target space
bone_tpose_rot_global = transform(bone_tpose_rot_global)
# Calculate bone offset from tpose and add it to live data rotation
rot_offset_ref = rot_to_blender(studio_reference_tpose_rot).inverted() @ bone_tpose_rot_global
final_rot = rot_to_blender(studio_new_pose) @ rot_offset_ref
# Transform rotation matrix back from target space
final_rot = transform_back(final_rot)
# Set new bone rotation
orig_loc, _, _ = bone.matrix.decompose()
orig_loc_mat = Matrix.Translation(orig_loc)
rotation_mat = final_rot.to_matrix().to_4x4()
# Set final bone matrix
bone.matrix = orig_loc_mat @ rotation_mat
# If hips bone, set its position
if bone_name == 'hip':
# Get correct space of hips location
axis = 0
multiplier = 1
if round(mat_obj[2][0], 0) == round(mat_obj[2][2], 0) == 0:
axis = 1
multiplier = mat_obj[2][1]
if round(mat_obj[2][0], 0) == round(mat_obj[2][1], 0) == 0:
axis = 2
multiplier = mat_obj[2][2]
# Get scale of studio model
studio_hip_height = actor.get('hipHeight') if live_data.version <= 2 else actor.get('dimensions').get('hipHeight')
if not studio_hip_height:
studio_hip_height = 1
tpose_hip_location_y = bone_tpose_data['location_object'][axis] * multiplier
location_new = pos_hips_studio_to_blender(
actor_bone_data['position']['x'] * tpose_hip_location_y / studio_hip_height,
actor_bone_data['position']['y'] * tpose_hip_location_y - tpose_hip_location_y * studio_hip_height,
actor_bone_data['position']['z'] * tpose_hip_location_y / studio_hip_height)
bone.location = location_new
# Record the data
if bpy.context.scene.rsl_recording:
recorder.record_bone(live_data.timestamp, obj.name, bone_name_assigned, bone.rotation_euler, location=bone.location if bone_name == 'hip' else None)
def animate_glove(obj):
pass
def pos_hips_studio_to_blender(x, y, z):
return -x, y, z
def pos_studio_to_blender(x, y, z):
return -x, -z, y
def rot_studio_to_blender(w, x, y, z):
return w, x, z, -y
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
shape_list = {
'eyeBlinkLeft': [],
'eyeLookDownLeft': [],
'eyeLookInLeft': [],
'eyeLookOutLeft': [],
'eyeLookUpLeft': [],
'eyeSquintLeft': [],
'eyeWideLeft': [],
'eyeBlinkRight': [],
'eyeLookDownRight': [],
'eyeLookInRight': [],
'eyeLookOutRight': [],
'eyeLookUpRight': [],
'eyeSquintRight': [],
'eyeWideRight': [],
'jawForward': [],
'jawLeft': [],
'jawRight': [],
'jawOpen': [],
'mouthClose': [],
'mouthFunnel': [],
'mouthPucker': [],
'mouthLeft': [],
'mouthRight': [],
'mouthSmileLeft': [],
'mouthSmileRight': [],
'mouthFrownLeft': [],
'mouthFrownRight': [],
'mouthDimpleLeft': [],
'mouthDimpleRight': [],
'mouthStretchLeft': [],
'mouthStretchRight': [],
'mouthRollLower': [],
'mouthRollUpper': [],
'mouthShrugLower': [],
'mouthShrugUpper': [],
'mouthPressLeft': [],
'mouthPressRight': [],
'mouthLowerDownLeft': [],
'mouthLowerDownRight': [],
'mouthUpperUpLeft': [],
'mouthUpperUpRight': [],
'browDownLeft': [],
'browDownRight': [],
'browInnerUp': [],
'browOuterUpLeft': [],
'browOuterUpRight': [],
'cheekPuff': [],
'cheekSquintLeft': [],
'cheekSquintRight': [],
'noseSneerLeft': [],
'noseSneerRight': [],
'tongueOut': [],
}
@@ -0,0 +1,237 @@
import os
import bpy
import json
import pathlib
from . import retargeting
from . import detection_manager
main_dir = str(pathlib.Path(os.path.dirname(__file__)).parent.resolve())
resources_dir = os.path.join(main_dir, "resources")
custom_bones_dir = os.path.join(resources_dir, "custom_bones")
custom_bone_list_file = os.path.join(custom_bones_dir, "custom_bone_list.json")
def save_retargeting_to_list():
armature_target = retargeting.get_target_armature()
retargeting_dict = detection_manager.detect_retarget_bones()
for bone_item in bpy.context.scene.rsl_retargeting_bone_list:
if not bone_item.bone_name_source or not bone_item.bone_name_target:
continue
bone_name_key = bone_item.bone_name_key
bone_name_source = bone_item.bone_name_source.lower()
bone_name_target = bone_item.bone_name_target.lower()
bone_name_target_detected, bone_name_key_detected = retargeting_dict[bone_item.bone_name_source]
if bone_name_target_detected == bone_item.bone_name_target:
continue
if bone_name_key_detected and bone_name_key_detected != 'spine':
if not detection_manager.bone_detection_list_custom.get(bone_name_key_detected):
detection_manager.bone_detection_list_custom[bone_name_key_detected] = []
# TODO Idea: If a target bone got detected but was removed and left empty, add it to an ignore list. So if that exact match-up gets detected again, leave it empty
# If the detected target is in the custom bones list but it got changed, remove it from the list. If the new bone gets detected automatically now, don't add it to the custom list
if bone_name_target_detected.lower() in detection_manager.bone_detection_list_custom[bone_name_key_detected]:
if bone_name_key_detected.startswith('custom_bone_') and len(detection_manager.bone_detection_list_custom[bone_name_key_detected]) == 2:
detection_manager.bone_detection_list_custom.pop(bone_name_key_detected)
else:
detection_manager.bone_detection_list_custom[bone_name_key_detected].remove(bone_name_target_detected.lower())
# Update the bone detection list in order to correctly figure out if the new selected bone needs to be saved
detection_manager.bone_detection_list = detection_manager.combine_lists(detection_manager.bone_detection_list_unmodified, detection_manager.bone_detection_list_custom)
retargeting_dict = detection_manager.detect_retarget_bones()
bone_name_detected_new, _ = retargeting_dict[bone_item.bone_name_source]
if bone_name_detected_new.lower() == bone_name_target:
# print('No need to add new bone to save')
continue
# If the source bone got detected but the target bone got changed, save the target bone into the custom list
if bone_name_target not in detection_manager.bone_detection_list_custom[bone_name_key_detected]:
detection_manager.bone_detection_list_custom[bone_name_key_detected] = [bone_name_target] + detection_manager.bone_detection_list_custom[bone_name_key_detected]
continue
# If it is a completely new pair of bones or a spine bone, add it as a new bone to the list
detection_manager.bone_detection_list_custom['custom_bone_' + bone_name_source] = [bone_name_source, bone_name_target]
# Save the updated custom list locally and update
save_to_file_and_update()
def save_live_data_bone_to_list(bone_key, bone_name, bone_name_previous):
if not detection_manager.bone_detection_list_custom.get(bone_key):
detection_manager.bone_detection_list_custom[bone_key] = []
# If the previously detected bone name is in the custom bones list but it got changed, remove it from the list. If the new bone gets detected automatically now, don't add it to the custom list
if bone_name_previous.lower() in detection_manager.bone_detection_list_custom[bone_key]:
detection_manager.bone_detection_list_custom[bone_key].remove(bone_name_previous.lower())
# print('Removed:', bone_name_previous)
# Update the bone detection list in order to correctly figure out if the new selected bone needs to be saved
detection_manager.bone_detection_list = detection_manager.combine_lists(detection_manager.bone_detection_list_unmodified, detection_manager.bone_detection_list_custom)
bone_name_detected_new = detection_manager.detect_bone(bpy.context.active_object, bone_key)
if bone_name_detected_new == bone_name:
# print('No need to add new bone to save')
return
detection_manager.bone_detection_list_custom[bone_key] = [bone_name] + detection_manager.bone_detection_list_custom[bone_key]
def save_live_data_shape_to_list(shape_key, shape_name, shape_name_previous):
if not detection_manager.shape_detection_list_custom.get(shape_key):
detection_manager.shape_detection_list_custom[shape_key] = []
# If the previously detected shape name is in the custom shapes list but it got changed, remove it from the list. If the new shapekey gets detected automatically now, don't add it to the custom list
if shape_name_previous.lower() in detection_manager.shape_detection_list_custom[shape_key]:
detection_manager.shape_detection_list_custom[shape_key].remove(shape_name_previous.lower())
# print('Removed:', shape_name_previous)
# Update the shapekey detection list in order to correctly figure out if the new selected shapekey needs to be saved
detection_manager.shape_detection_list = detection_manager.combine_lists(detection_manager.shape_detection_list_unmodified, detection_manager.shape_detection_list_custom)
shape_name_detected_new = detection_manager.detect_shape(bpy.context.active_object, shape_key)
if shape_name_detected_new == shape_name:
# print('No need to add new bone to save')
return
detection_manager.shape_detection_list_custom[shape_key] = [shape_name] + detection_manager.shape_detection_list_custom[shape_key]
def save_to_file_and_update():
save_custom_to_file()
detection_manager.load_detection_lists()
def save_custom_to_file(file_path=custom_bone_list_file):
new_custom_list = clean_custom_list()
print('To File:', new_custom_list)
if not os.path.isdir(custom_bones_dir):
os.mkdir(custom_bones_dir)
with open(file_path, 'w', encoding="utf8") as outfile:
json.dump(new_custom_list, outfile, ensure_ascii=False, indent=4)
def load_custom_lists_from_file(file_path=custom_bone_list_file):
custom_bone_list = {}
try:
with open(file_path, encoding="utf8") as file:
custom_bone_list = json.load(file)
except FileNotFoundError:
print('Custom bone list not found.')
except json.decoder.JSONDecodeError:
print("Custom bone list is not a valid json file!")
if custom_bone_list.get('rokoko_custom_names') is None or custom_bone_list.get('version') is None or custom_bone_list.get('bones') is None or custom_bone_list.get('shapes') is None:
print("Custom name list file is not a valid name list file")
return {}, {}
custom_bone_list.pop('rokoko_custom_names')
custom_bone_list.pop('version')
return custom_bone_list.get('bones'), custom_bone_list.get('shapes')
def clean_custom_list():
new_custom_list = {
'rokoko_custom_names': True,
'version': 1,
'bones': {},
'shapes': {}
}
new_bone_list = {}
new_shape_list = {}
# Remove all empty fields and make all custom fields lowercase
for key, values in detection_manager.bone_detection_list_custom.items():
if not values:
continue
for i in range(len(values)):
values[i] = values[i].lower()
new_bone_list[key] = values
# Remove all empty fields and make all custom fields lowercase
for key, values in detection_manager.shape_detection_list_custom.items():
if not values:
continue
for i in range(len(values)):
values[i] = values[i].lower()
new_shape_list[key] = values
new_custom_list['bones'] = new_bone_list
new_custom_list['shapes'] = new_shape_list
return new_custom_list
def import_custom_list(directory, file_name):
file_path = os.path.join(directory, file_name)
new_custom_bone_list, new_custom_shape_list = load_custom_lists_from_file(file_path=file_path)
# Merge the new and old custom bone lists
for key, bones in detection_manager.bone_detection_list_custom.items():
if not new_custom_bone_list.get(key):
new_custom_bone_list[key] = []
for bone in new_custom_bone_list[key]:
if bone in bones:
bones.remove(bone)
new_custom_bone_list[key] += bones
# Merge the new and old custom shape lists
for key, shapes in detection_manager.shape_detection_list_custom.items():
if not new_custom_shape_list.get(key):
new_custom_shape_list[key] = []
for shape in new_custom_shape_list[key]:
if shape in shapes:
shapes.remove(shape)
new_custom_shape_list[key] += shapes
detection_manager.bone_detection_list_custom = new_custom_bone_list
detection_manager.shape_detection_list_custom = new_custom_shape_list
def export_custom_list2(directory):
file_path = os.path.join(directory, 'custom_bone_list.json')
i = 1
while os.path.isfile(file_path):
file_path = os.path.join(directory, 'custom_bone_list' + str(i) + '.json')
i += 1
save_custom_to_file(file_path=file_path)
return os.path.basename(file_path)
def export_custom_list(file_path):
if not detection_manager.bone_detection_list_custom and not detection_manager.shape_detection_list_custom:
return None
save_custom_to_file(file_path=file_path)
return os.path.basename(file_path)
def delete_custom_bone_list():
detection_manager.bone_detection_list_custom = {}
save_to_file_and_update()
def delete_custom_shape_list():
detection_manager.shape_detection_list_custom = {}
save_to_file_and_update()
@@ -0,0 +1,386 @@
import os
import bpy
import json
import pathlib
from . import retargeting
from .auto_detect_lists.bones import bone_list, ignore_rokoko_retargeting_bones
from .auto_detect_lists.shapes import shape_list
from .custom_schemes_manager import load_custom_lists_from_file
bone_detection_list = {}
bone_detection_list_unmodified = {}
bone_detection_list_custom = {}
shape_detection_list = {}
shape_detection_list_unmodified = {}
shape_detection_list_custom = {}
main_dir = str(pathlib.Path(os.path.dirname(__file__)).parent.resolve())
resources_dir = os.path.join(main_dir, "resources")
custom_bones_dir = os.path.join(resources_dir, "custom_bones")
custom_bone_list_file = os.path.join(custom_bones_dir, "custom_bone_list.json")
def load_detection_lists():
global bone_detection_list, bone_detection_list_unmodified, bone_detection_list_custom, shape_detection_list, shape_detection_list_unmodified, shape_detection_list_custom
# Create the lists from the internal naming lists
bone_detection_list_unmodified = setup_bone_list(bone_list)
shape_detection_list_unmodified = setup_shape_list()
# Load the custom naming lists from the file
bone_detection_list_custom, shape_detection_list_custom = load_custom_lists_from_file()
# Combine custom and internal lists
bone_detection_list = combine_lists(bone_detection_list_unmodified, bone_detection_list_custom)
shape_detection_list = combine_lists(shape_detection_list_unmodified, shape_detection_list_custom)
# Print the whole bone list
# print_bone_detection_list()
def setup_bone_list(raw_bone_list):
new_bone_list = {}
for bone_key, bone_values in raw_bone_list.items():
# Add the bones to the list if no side indicator is found
if 'left' not in bone_key:
new_bone_list[bone_key] = [bone_value.lower() for bone_value in bone_values]
if bone_key == 'spine':
new_bone_list['chest'] = [bone_value.lower() for bone_value in bone_values]
continue
# Add bones to the list that are two sided
bone_values_left = []
bone_values_right = []
for bone_name in bone_values:
bone_name = bone_name.lower()
if '\l' in bone_name:
for replacement in ['l', 'left', 'r', 'right']:
bone_name_new = bone_name.replace('\l', replacement)
# Debug if duplicates are found
if bone_name_new in bone_values_left or bone_name_new in bone_values_right:
print('Duplicate autodetect bone entry:', bone_name, bone_name_new)
continue
if 'l' in replacement:
bone_values_left.append(bone_name_new)
else:
bone_values_right.append(bone_name_new)
bone_key_left = bone_key
bone_key_right = bone_key.replace('left', 'right')
new_bone_list[bone_key_left] = bone_values_left
new_bone_list[bone_key_right] = bone_values_right
return new_bone_list
def setup_shape_list():
new_shape_list = {}
for shape_key, shape_names in shape_list.items():
new_shape_list[shape_key] = [shape_key.lower()] + [shape_name.lower() for shape_name in shape_names]
return new_shape_list
def combine_lists(internal_list, custom_list):
"""
Creates a combined list with the second list put in first but with the structure of the first list
"""
combined_list = {}
# Set dictionary structure
for key in internal_list.keys():
combined_list[key] = []
# Load in custom values into the dictionary
for key, values in custom_list.items():
combined_list[key] = []
for value in values:
combined_list[key].append(value.lower())
# Load in internal values
for key, values in internal_list.items():
for value in values:
combined_list[key].append(value)
return combined_list
def print_bone_detection_list():
print('BONES')
for key, values in bone_detection_list.items():
print(key, values)
print()
print('CUSTOM BONES')
for key, values in bone_detection_list_custom.items():
print(key, values)
print('--> ', bone_detection_list[key])
print()
# print('SHAPES')
# for key, values in shape_detection_list.items():
# print(key, values)
print('CUSTOM SHAPES')
for key, values in shape_detection_list_custom.items():
print(key, values)
print('--> ', shape_detection_list[key])
print()
print()
# def get_bone_list():
# return bone_detection_list
#
#
# def get_custom_bone_list():
# return bone_detection_list_custom
#
#
# def get_shape_list():
# return shape_detection_list
#
#
# def get_custom_shape_list():
# return shape_detection_list_custom
def standardize_bone_name(name):
# List of chars to replace if they are at the start of a bone name
starts_with = [
('_', ''),
('ValveBiped_', ''),
('Valvebiped_', ''),
('Bip1_', 'Bip_'),
('Bip01_', 'Bip_'),
('Bip001_', 'Bip_'),
('Character1_', ''),
('HLP_', ''),
('JD_', ''),
('JU_', ''),
('Armature|', ''),
('Bone_', ''),
('C_', ''),
('Cf_S_', ''),
('Cf_J_', ''),
('G_', ''),
('Joint_', ''),
('DEF_', ''),
('CC_Base_', ''),
]
# Standardize names
# Make all the underscores!
name = name.replace(' ', '_') \
.replace('-', '_') \
.replace('.', '_') \
.replace('____', '_') \
.replace('___', '_') \
.replace('__', '_') \
# Replace if name starts with specified chars
for replacement in starts_with:
if name.startswith(replacement[0]):
name = replacement[1] + name[len(replacement[0]):]
# Remove digits from the start
name_split = name.split('_')
if len(name_split) > 1 and name_split[0].isdigit():
name = name_split[1]
# Specific condition
name_split = name.split('"')
if len(name_split) > 3:
name = name_split[1]
# Another specific condition
if ':' in name:
for i, split in enumerate(name.split(':')):
if i == 0:
name = ''
else:
name += split
# Remove S0 from the end
if name[-2:] == 'S0':
name = name[:-2]
if name[-4:] == '_Jnt':
name = name[:-4]
return name.lower()
def detect_shape(obj, shape_name_key):
# Go through the target mesh and search for shapekey that fit the main shapekey
found_shape_name = ''
is_custom = False
for shapekey in obj.data.shape_keys.key_blocks:
if is_custom: # If a custom shapekey name was found, stop searching. it has priority
break
if shape_detection_list_custom.get(shape_name_key):
for shape_name_detected in shape_detection_list_custom[shape_name_key]:
if shape_name_detected == shapekey.name.lower():
found_shape_name = shapekey.name
is_custom = True
break
if found_shape_name and shape_name_key != 'chest': # If a shape_name was found, only continue looking for custom shapekey names, they have priority
continue
for shape_name_detected in shape_detection_list[shape_name_key]:
if shape_name_detected == shapekey.name.lower():
found_shape_name = shapekey.name
break
# If nothing was found, check if the shapekey names match exactly
if not found_shape_name and shape_name_key.lower() == shapekey.name.lower():
found_shape_name = shapekey.name
return found_shape_name
def detect_bone(obj, bone_name_key, bone_name_source=None):
# Go through the target armature and search for bones that fit the main source bone
found_bone_name = ''
is_custom = False
if not bone_name_source:
bone_name_source = bone_name_key
for bone in obj.pose.bones:
if is_custom: # If a custom bone name was found, stop searching. it has priority
break
if bone_detection_list_custom.get(bone_name_key):
for bone_name_detected in bone_detection_list_custom[bone_name_key]:
if bone_name_detected == bone.name.lower():
found_bone_name = bone.name
is_custom = True
break
if found_bone_name and bone_name_key != 'chest': # If a bone_name was found, only continue looking for custom bone names, they have priority
continue
for bone_name_detected in bone_detection_list[bone_name_key]:
if bone_name_detected == standardize_bone_name(bone.name):
found_bone_name = bone.name
break
# If nothing was found, check if the bone names match exactly
if not found_bone_name and bone_name_source.lower() == bone.name.lower():
found_bone_name = bone.name
return found_bone_name
def detect_retarget_bones() -> {str: (str, str)}:
"""
Detects all matching bones in the target and source armatures
:return: A dictionary with the source bone name as key and a tuple of the target bone name and their shared key name as value
"""
bone_list_animated = []
retargeting_dict = {}
armature_source = retargeting.get_source_armature()
armature_target = retargeting.get_target_armature()
# Get all source bones from the animation and add them to bone_list_animated
for fc in armature_source.animation_data.action.fcurves:
bone_name = fc.data_path.split('"')
if len(bone_name) == 3 and bone_name[1] not in bone_list_animated:
bone_list_animated.append(bone_name[1])
# Check if this animation is from Rokoko Studio. Ignore certain bones in that case
is_rokoko_animation = False
if 'newton' in bone_list_animated and 'RightFinger1Tip' in bone_list_animated and 'HeadVertex' in bone_list_animated and 'LeftFinger2Metacarpal' in bone_list_animated:
is_rokoko_animation = True
spines_source = []
spines_target = []
found_main_bones = []
# Then add all the bones to the retargeting dictionary
for bone_name in bone_list_animated:
if is_rokoko_animation and bone_name in ignore_rokoko_retargeting_bones:
continue
bone_item_source = bone_name
bone_item_target = ''
main_bone_name = ''
standardized_bone_name_source = standardize_bone_name(bone_name)
# Find the main bone name (bone name key) of the source bone
for bone_main, bone_values in bone_detection_list.items():
if bone_main == 'chest': # Ignore chest bones, these are only used for live data
continue
if bone_main in found_main_bones: # Only find main bones once, except for spines
continue
# If the source bone name is found in the bone detection list, add its main bone name to the list of found main bones
if bone_name.lower() in bone_values or standardized_bone_name_source in bone_values or standardized_bone_name_source == bone_main.lower():
main_bone_name = bone_main
if main_bone_name != 'spine': # Ignore the spine bones for now, so that it can add the custom spine bones first
found_main_bones.append(main_bone_name)
break
# Add the source bone and the main bone name to the retargeting dict with an empty targeting bone name
retargeting_dict[bone_item_source] = ("", main_bone_name)
# If no main bone name was found, continue
if not main_bone_name:
continue
# If it's a spine bone, add it to the list for later fixing
if main_bone_name == 'spine':
spines_source.append(bone_name)
continue
# If it's a custom spine/chest bone, add it to the spine list nonetheless
custom_main_bone = main_bone_name.startswith('custom_bone_')
if custom_main_bone and standardize_bone_name(main_bone_name.replace('custom_bone_', '')) in bone_detection_list['spine']:
spines_source.append(bone_name)
# Go through the target armature and search for bones that fit the main source bone
bone_item_target = detect_bone(armature_target, main_bone_name, bone_name_source=bone_item_source)
# Add the bone to the retargeting list again
retargeting_dict[bone_item_source] = (bone_item_target, main_bone_name)
# Add target spines to list for later fixing
for bone in armature_target.pose.bones:
bone_name_standardized = standardize_bone_name(bone.name)
if bone_name_standardized in bone_detection_list['spine']:
spines_target.append(bone.name)
# Fix spine auto detection
if spines_target and spines_source:
# print(spines_source)
spine_dict = {}
i = 0
for spine in reversed(spines_source):
i += 1
if i == len(spines_target):
break
spine_dict[spine] = spines_target[-i]
spine_dict[spines_source[0]] = spines_target[0]
# Fill in fixed spines into unfilled matches
for spine_source, spine_target in spine_dict.items():
for bone_source, bone_values in retargeting_dict.items():
bone_target, bone_key = bone_values
if bone_source == spine_source and not bone_target:
retargeting_dict[bone_source] = (spine_target, bone_key)
break
return retargeting_dict
@@ -0,0 +1,74 @@
import bpy
from .auto_detect_lists.bones import bone_list, ignore_rokoko_retargeting_bones
from .auto_detect_lists.shapes import shape_list
class DetectionManager:
def __init__(self, name_dict):
self.name_dict_original = name_dict
self.name_dict = {}
self._setup_dict()
def _setup_dict(self):
for key, values in self.name_dict_original.items():
self._add_names(key, values)
def _add_names(self, key, values):
raise NotImplementedError
def print_dict(self):
for key, values in self.name_dict.items():
print(f"{key}: {values}")
class BoneDetectionManager(DetectionManager):
def _add_names(self, key, values):
# Add names to the list without changes if the key is not sided
if "left" not in key:
self.name_dict[key] = [name.lower() for name in values]
if key == "spine":
self.name_dict["chest"] = self.name_dict[key].copy()
return
# Add names to the list with changes if the key is sided
names_left = []
names_right = []
for name in values:
name = name.lower()
if "\l" not in name:
print(f"Warning: {name} from {key} does not contain a '\\l' marker")
continue
for replacement in ['l', 'left', 'r', 'right']:
name_new = name.replace("\l", replacement)
if name_new in names_left or name_new in names_right:
print(f"Warning: {name_new} from {key} is already in the list")
continue
if "l" in replacement:
names_left.append(name_new)
else:
names_right.append(name_new)
self.name_dict[key] = names_left
self.name_dict[key.replace("left", "right")] = names_right
class ShapeDetectionManager(DetectionManager):
def _add_names(self, key, values):
self.name_dict[key] = [key] + [name.lower() for name in values]
# bones = BoneDetectionManager(bone_list)
# bones.print_dict()
# shapes = ShapeDetectionManager(shape_list)
# shapes.print_dict()
@@ -0,0 +1,212 @@
import bpy
import time
import addon_utils
from threading import Thread
if bpy.app.version < (2, 83, 17):
from io_scene_fbx import import_fbx
from io_scene_fbx.import_fbx import blen_read_animations_curves_iter, blen_read_object_transform_do
def start_fbx_patch_timer():
if bpy.app.version >= (2, 83, 17): # This patch is officially accepted in Blender 2.83.17, so don't patch it
return
# Asynchronously start the timer looking for the right time to patch the fbx importer
thread = Thread(target=time_fbx_patch, args=[])
thread.start()
def time_fbx_patch():
# Wait for Blender to finish loading up
found_scene = False
while not found_scene:
if hasattr(bpy.context, 'scene'):
found_scene = True
else:
time.sleep(0.5)
# Enable fbx if it isn't enabled yet
fbx_is_enabled = addon_utils.check('io_scene_fbx')[1]
if not fbx_is_enabled:
addon_utils.enable('io_scene_fbx')
# Patch fbx importer
patch_fbx_importer()
def patch_fbx_importer():
import_fbx.blen_read_animations_action_item = blen_read_animations_action_item_patched
# This is the modified Blender function that will replace the original one
def blen_read_animations_action_item_patched(action, item, cnodes, fps, anim_offset):
"""
'Bake' loc/rot/scale into the action,
taking any pre_ and post_ matrix into account to transform from fbx into blender space.
"""
from bpy.types import Object, PoseBone, ShapeKey, Material, Camera
from itertools import chain
fbx_curves = []
for curves, fbxprop in cnodes.values():
for (fbx_acdata, _blen_data), channel in curves.values():
fbx_curves.append((fbxprop, channel, fbx_acdata))
# Leave if no curves are attached (if a blender curve is attached to scale but without keys it defaults to 0).
if len(fbx_curves) == 0:
return
blen_curves = []
props = []
keyframes = {}
# Add each keyframe to the keyframe dict
def store_keyframe(fc, frame, value):
fc_key = (fc.data_path, fc.array_index)
if not keyframes.get(fc_key):
keyframes[fc_key] = []
keyframes[fc_key].append((frame, value))
if isinstance(item, Material):
grpname = item.name
props = [("diffuse_color", 3, grpname or "Diffuse Color")]
elif isinstance(item, ShapeKey):
props = [(item.path_from_id("value"), 1, "Key")]
elif isinstance(item, Camera):
props = [(item.path_from_id("lens"), 1, "Camera")]
else: # Object or PoseBone:
if item.is_bone:
bl_obj = item.bl_obj.pose.bones[item.bl_bone]
else:
bl_obj = item.bl_obj
# We want to create actions for objects, but for bones we 'reuse' armatures' actions!
grpname = item.bl_obj.name
# Since we might get other channels animated in the end, due to all FBX transform magic,
# we need to add curves for whole loc/rot/scale in any case.
props = [(bl_obj.path_from_id("location"), 3, grpname or "Location"),
None,
(bl_obj.path_from_id("scale"), 3, grpname or "Scale")]
rot_mode = bl_obj.rotation_mode
if rot_mode == 'QUATERNION':
props[1] = (bl_obj.path_from_id("rotation_quaternion"), 4, grpname or "Quaternion Rotation")
elif rot_mode == 'AXIS_ANGLE':
props[1] = (bl_obj.path_from_id("rotation_axis_angle"), 4, grpname or "Axis Angle Rotation")
else: # Euler
props[1] = (bl_obj.path_from_id("rotation_euler"), 3, grpname or "Euler Rotation")
blen_curves = [action.fcurves.new(prop, index=channel, action_group=grpname)
for prop, nbr_channels, grpname in props for channel in range(nbr_channels)]
if isinstance(item, Material):
for frame, values in blen_read_animations_curves_iter(fbx_curves, anim_offset, 0, fps):
value = [0,0,0]
for v, (fbxprop, channel, _fbx_acdata) in values:
assert(fbxprop == b'DiffuseColor')
assert(channel in {0, 1, 2})
value[channel] = v
for fc, v in zip(blen_curves, value):
store_keyframe(fc, frame, v)
elif isinstance(item, ShapeKey):
for frame, values in blen_read_animations_curves_iter(fbx_curves, anim_offset, 0, fps):
value = 0.0
for v, (fbxprop, channel, _fbx_acdata) in values:
assert(fbxprop == b'DeformPercent')
assert(channel == 0)
value = v / 100.0
for fc, v in zip(blen_curves, (value,)):
store_keyframe(fc, frame, v)
elif isinstance(item, Camera):
for frame, values in blen_read_animations_curves_iter(fbx_curves, anim_offset, 0, fps):
value = 0.0
for v, (fbxprop, channel, _fbx_acdata) in values:
assert(fbxprop == b'FocalLength')
assert(channel == 0)
value = v
for fc, v in zip(blen_curves, (value,)):
store_keyframe(fc, frame, v)
else: # Object or PoseBone:
if item.is_bone:
bl_obj = item.bl_obj.pose.bones[item.bl_bone]
else:
bl_obj = item.bl_obj
transform_data = item.fbx_transform_data
rot_eul_prev = bl_obj.rotation_euler.copy()
rot_quat_prev = bl_obj.rotation_quaternion.copy()
# Pre-compute inverted local rest matrix of the bone, if relevant.
restmat_inv = item.get_bind_matrix().inverted_safe() if item.is_bone else None
# Create a dict to store all keyframes in order to add them later all at once
keyframes = {}
for frame, values in blen_read_animations_curves_iter(fbx_curves, anim_offset, 0, fps):
for v, (fbxprop, channel, _fbx_acdata) in values:
if fbxprop == b'Lcl Translation':
transform_data.loc[channel] = v
elif fbxprop == b'Lcl Rotation':
transform_data.rot[channel] = v
elif fbxprop == b'Lcl Scaling':
transform_data.sca[channel] = v
mat, _, _ = blen_read_object_transform_do(transform_data)
# compensate for changes in the local matrix during processing
if item.anim_compensation_matrix:
mat = mat @ item.anim_compensation_matrix
# apply pre- and post matrix
# post-matrix will contain any correction for lights, camera and bone orientation
# pre-matrix will contain any correction for a parent's correction matrix or the global matrix
if item.pre_matrix:
mat = item.pre_matrix @ mat
if item.post_matrix:
mat = mat @ item.post_matrix
# And now, remove that rest pose matrix from current mat (also in parent space).
if restmat_inv:
mat = restmat_inv @ mat
# Now we have a virtual matrix of transform from AnimCurves, we can insert keyframes!
loc, rot, sca = mat.decompose()
if rot_mode == 'QUATERNION':
if rot_quat_prev.dot(rot) < 0.0:
rot = -rot
rot_quat_prev = rot
elif rot_mode == 'AXIS_ANGLE':
vec, ang = rot.to_axis_angle()
rot = ang, vec.x, vec.y, vec.z
else: # Euler
rot = rot.to_euler(rot_mode, rot_eul_prev)
rot_eul_prev = rot
# Add each keyframe and its value to the keyframe dict
for fc, value in zip(blen_curves, chain(loc, rot, sca)):
store_keyframe(fc, frame, value)
# Add all keyframe points to the fcurves at once and modify them after
for fc_key, key_values in keyframes.items():
data_path, index = fc_key
# Add all keyframe points at once
fcurve = action.fcurves.find(data_path=data_path, index=index)
num_keys = len(key_values)
fcurve.keyframe_points.add(num_keys)
# Apply values to each keyframe point
for kf_point, v in zip(fcurve.keyframe_points, key_values):
kf_point.co = v
kf_point.interpolation = 'LINEAR'
# Since we inserted our keyframes in 'FAST' mode, we have to update the fcurves now.
for fc in blen_curves:
fc.update()
@@ -0,0 +1,53 @@
import os
import pathlib
from enum import Enum
from bpy.utils import previews
icons = None
class Icons(Enum):
FACE = 'FACE'
SUIT = 'SUIT'
VP = 'VP'
PAIRED = 'PAIRED'
ROKOKO = 'ROKOKO'
START_RECORDING = 'RECORD'
STOP_RECORDING = 'STOP'
RESTART = 'RESTART'
CALIBRATE = 'CALIBRATE'
STUDIO_LIVE_LOGO = 'STUDIO_LIVE_LOGO'
def get_icon(self):
return icons.get(self.value).icon_id
def load_icons():
# Path to the icons folder
# The path is calculated relative to this py file inside the addon folder
main_dir = pathlib.Path(os.path.dirname(__file__)).parent.resolve()
resources_dir = os.path.join(str(main_dir), "resources")
icons_dir = os.path.join(resources_dir, "icons")
pcoll = previews.new()
# Load a preview thumbnail of a file and store in the previews collection
pcoll.load('FACE', os.path.join(icons_dir, 'icon-row-face-32.png'), 'IMAGE')
pcoll.load('SUIT', os.path.join(icons_dir, 'icon-row-suit-32.png'), 'IMAGE')
pcoll.load('VP', os.path.join(icons_dir, 'icon-vp-32.png'), 'IMAGE')
pcoll.load('PAIRED', os.path.join(icons_dir, 'icon-paired-32.png'), 'IMAGE')
pcoll.load('ROKOKO', os.path.join(icons_dir, 'icon-rokoko-32.png'), 'IMAGE')
pcoll.load('RECORD', os.path.join(icons_dir, 'icon-record-32.png'), 'IMAGE')
pcoll.load('RESTART', os.path.join(icons_dir, 'icon-restart-32.png'), 'IMAGE')
pcoll.load('STOP', os.path.join(icons_dir, 'icon-stop-white-32.png'), 'IMAGE')
pcoll.load('CALIBRATE', os.path.join(icons_dir, 'icon-straight-pose-32.png'), 'IMAGE')
pcoll.load('STUDIO_LIVE_LOGO', os.path.join(icons_dir, 'icon-studio-live-32.png'), 'IMAGE')
global icons
icons = pcoll
def unload_icons():
global icons
if icons:
previews.remove(icons)
@@ -0,0 +1,155 @@
import os
import bpy
import sys
import json
import shutil
import pkgutil
import pathlib
import platform
import ensurepip
import subprocess
class LibraryManager:
os_name = platform.system()
system_info = {
"operating_system": os_name,
}
pip_is_updated = False
def __init__(self, libs_main_dir: pathlib.Path):
self.libs_main_dir = libs_main_dir
self.libs_info_file = self.libs_main_dir / ".lib_info"
python_ver_str = "".join([str(ver) for ver in sys.version_info[:2]])
self.libs_dir = os.path.join(self.libs_main_dir, "python" + python_ver_str)
# Set python path on older Blender versions
try:
self.python = bpy.app.binary_path_python
except AttributeError:
self.python = sys.executable
self.check_libs_info()
self._prepare_libraries()
def _prepare_libraries(self):
# Create main library directory
if not os.path.isdir(self.libs_main_dir):
os.mkdir(self.libs_main_dir)
# Create python specific library directory
if not os.path.isdir(self.libs_dir):
os.mkdir(self.libs_dir)
# Add the library path to the modules, so they can be loaded from the plugin
if self.libs_dir not in sys.path:
sys.path.append(self.libs_dir)
def install_libraries(self, required):
missing_after_install = []
# Install missing libraries
missing = [mod for mod in required if not pkgutil.find_loader(mod)]
if missing:
# Ensure and update pip
self._update_pip()
# Install the missing libraries into the library path
print("Installing missing libraries:", missing)
try:
# command = [self.python, '-m', 'pip', 'install', f"--target={str(self.libs_dir)}", "--index-url=http://pypi.python.org/simple/", "--trusted-host=pypi.python.org", *missing]
command = [self.python, '-m', 'pip', 'install', f"--target={str(self.libs_dir)}", *missing]
subprocess.check_call(command, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
print("PIP Error:", e)
print("Installing libraries failed.")
if self.os_name != "Windows":
print("Retrying with sudo..")
# command = ["sudo", self.python, '-m', 'pip', 'install', f"--target={str(self.libs_dir)}", "--index-url=http://pypi.python.org/simple/", "--trusted-host=pypi.python.org", *missing]
command = ["sudo", self.python, '-m', 'pip', 'install', f"--target={str(self.libs_dir)}", *missing]
subprocess.call(command, stdout=subprocess.DEVNULL)
finally:
# Reset console color, because it could still be colored after running pip
print('\033[39m')
# Check if all library installations were successful
missing_after_install = [mod for mod in required if not pkgutil.find_loader(mod)]
installed_libs = [lib for lib in missing if lib not in missing_after_install]
if missing_after_install:
print("WARNING: Could not install the following libraries:", missing_after_install)
if installed_libs:
print("Successfully installed missing libraries:", installed_libs)
# Create library info file after all libraries are installed to ensure everything is installed correctly
self.create_libs_info()
return missing_after_install
def check_libs_info(self):
if not os.path.isdir(self.libs_dir):
return
# If the library info file doesn't exist, delete the libs folder
if not os.path.isfile(self.libs_info_file):
print("Library info is missing, deleting library folder.")
shutil.rmtree(self.libs_main_dir)
return
# Read data from info file
current_data = self.system_info
with open(self.libs_info_file, 'r', encoding="utf8") as file:
loaded_data = json.load(file)
# Compare info and delete libs folder if it doesn't match
for key, val_current in current_data.items():
val_loaded = loaded_data.get(key)
if not val_loaded == val_current:
print("Current info:", current_data)
print("Loaded info: ", loaded_data)
print("Library info is not matching, deleting library folder.")
shutil.rmtree(self.libs_main_dir)
return
def create_libs_info(self):
# If the path doesn't exist or the info file already exists, don't create it
if not os.path.isdir(self.libs_dir) or os.path.isfile(self.libs_info_file):
return
# Write the current data to the info file
with open(self.libs_info_file, 'w', encoding="utf8") as file:
json.dump(self.system_info, file)
def _update_pip(self):
if self.pip_is_updated:
return
print("Ensuring pip")
try:
ensurepip.bootstrap()
except subprocess.CalledProcessError as e:
print("PIP Error:", e)
print("Ensuring pip failed.")
print("Updating pip")
try:
# subprocess.check_call([self.python, "-m", "pip", "install", "--upgrade", "--index-url=http://pypi.python.org/simple/", "--trusted-host=pypi.python.org", "pip"])
subprocess.check_call([self.python, "-m", "pip", "install", "--upgrade", "pip"])
except subprocess.CalledProcessError as e:
print("PIP Error:", e)
print("Updating pip failed.")
if self.os_name != "Windows":
print("Retrying with sudo..")
# subprocess.call(["sudo", self.python, "-m", "pip", "install", "--upgrade", "--index-url=http://pypi.python.org/simple/", "--trusted-host=pypi.python.org", "pip"])
subprocess.call(["sudo", self.python, "-m", "pip", "install", "--upgrade", "pip"])
finally:
# Reset console color, because it could still be colored after running pip
print('\033[39m')
self.pip_is_updated = True
# Setup library path in the Blender addons directory and start library manager
main_dir = pathlib.Path(os.path.dirname(__file__)).parent.parent
libs_dir = main_dir / "Rokoko Libraries"
lib_manager = LibraryManager(libs_dir)
@@ -0,0 +1,176 @@
import json
loaded_lz4 = False
unsupported_os = False
try:
from lz4 import frame
loaded_lz4 = True
except ModuleNotFoundError:
print("Error: LZ4 module didn't load. Unsupported OS or Python version!")
except ImportError:
print("Error: LZ4 module didn't load. Unsupported OS!")
unsupported_os = True
class LiveData:
data = None
version = 0
# JSON v2
timestamp = 0
props = []
trackers = []
faces = []
actors = []
# JSON v3
fps = 60
timestamp_prev = 0
timedelta_prev = 0
def init(self, data):
self.data = data
self._decode_data()
self.clear_data()
self._process_data()
def clear_data(self):
self.version = 0
# JSON v2
# self.timestamp = 0
self.props = []
self.trackers = []
self.faces = []
self.actors = []
# JSON v3
self.fps = 60
# self.timestamp_prev = 0
# self.timedelta_prev = 0
def _decode_data(self):
try:
self.data = frame.decompress(self.data)
except (RuntimeError, NameError):
pass
try:
self.data = json.loads(self.data)
except UnicodeDecodeError as e:
if loaded_lz4:
raise UnicodeDecodeError
# Raise an import error if the LZ4 module couldn't be loaded
raise ImportError("os" if unsupported_os else "")
if not self.data:
raise ValueError
def _process_data(self):
self.version = self.data.get('version')
ver_str = str(self.version).replace(".", ",")
if ',' in ver_str:
self.version = int(ver_str.split(',')[0])
# If the user selected JSON v2.5 in Studio 1, the version number is "3" but it contains the data from version 2
# This checks if this is the case and sets the version number accordingly
if self.version == 3 and self.data.get('trackers') is not None:
self.version = 2
if not self.version or self.version < 2:
raise TypeError
if self.version == 2:
self.timestamp = self.data['timestamp']
self.props = self.data['props']
self.trackers = self.data['trackers']
self.faces = self.data['faces']
self.actors = self.data['actors']
else:
self.fps = self.data['fps']
self.actors = self.data['scene']['actors']
self.props = self.data['scene']['props']
for actor in self.actors:
if actor['meta']["hasFace"]:
actor['face']['parentName'] = actor['name']
self.faces.append(actor['face'])
self._calc_timestamp()
def _calc_timestamp(self):
timestamp_new = self.data['scene']['timestamp']
delta = timestamp_new - self.timestamp_prev
if delta >= 0:
self.timestamp += delta
self.timestamp_prev = timestamp_new
self.timedelta_prev = delta
else:
self.timestamp += self.timedelta_prev
self.timestamp_prev = timestamp_new
def has_gloves(self, actor):
return self.version >= 3 and actor.get('meta') and actor.get('meta').get('hasGloves')
def supports_trackers(self):
return self.version <= 2
# Get data for and from the live data selection lists
def get_actor_by_obj(self, obj):
actors = [actor for actor in self.actors if actor['name'] == obj.rsl_animations_actors]
return actors[0] if actors else None
def get_actor_id(self, actor):
return actor['name']
def get_face_by_obj(self, obj):
face_id = 'faceId' if self.version <= 2 else 'parentName'
faces = [face for face in self.faces if face[face_id] == obj.rsl_animations_faces]
return faces[0] if faces else None
def get_face_id(self, face):
face_id = 'faceId' if self.version <= 2 else 'parentName'
return face[face_id]
def get_face_parent_id(self, face):
face_id = 'profileName' if self.version <= 2 else 'parentName'
return face[face_id]
def get_prop_by_obj(self, obj):
if self.version <= 2:
obj_id = obj.rsl_animations_props_trackers.split('|')
obj_type = obj_id[0]
obj_name = obj_id[1]
if obj_type == 'PR':
props = [prop for prop in self.props if prop['name'] == obj_name]
else:
props = [tracker for tracker in self.trackers if tracker['name'] == obj_name]
return props[0] if props else None
props = [prop for prop in self.props if prop['name'] == obj.rsl_animations_props_trackers]
return props[0] if props else None
def get_prop_id(self, prop, is_tracker=False):
if self.version <= 2:
return ('TR' if is_tracker else 'PR') + '|' + prop['name']
return prop['name']
def get_prop_name(self, prop, is_tracker=False):
if self.version <= 2:
return ('Tracker: ' if is_tracker else 'Prop: ') + prop['name']
return prop['name']
def get_prop_name_raw(self, prop, is_tracker=False):
return prop['name']
@@ -0,0 +1,520 @@
import os
import bpy
import ssl
import sys
import json
import pathlib
import asyncio
import logging
import datetime
import requests
import traceback
import webbrowser
from .. import updater
from .utils import ui_refresh_all, cancel_gen
from threading import Thread, Timer
from contextlib import suppress
from typing import AsyncGenerator
from urllib.parse import urlparse
# Import extra libraries
loaded_all_libs = False
try:
import boto3
from gql import Client, gql
from cryptography.fernet import Fernet
from gql.transport.appsync_websockets import AppSyncWebsocketsTransport
from gql.transport.appsync_auth import AppSyncApiKeyAuthentication
from gql.transport.websockets import log as websockets_logger
# Set logging levels
websockets_logger.setLevel(logging.CRITICAL)
logging.getLogger('boto').setLevel(logging.CRITICAL)
loaded_all_libs = True
except ImportError as e:
print(e)
# Disable SSL
ssl._create_default_https_context = ssl._create_unverified_context
class Login:
url = "https://rmp-gql-public.rokoko.com/graphql"
aws_url = "wss://a4rau2yngvb7hn3y6m37e3b53u.appsync-realtime-api.us-east-1.amazonaws.com/graphql"
api_key = "da2-pa7tlmpnvbcpdhe7l46q3eodvu"
login_url = "https://id.rokoko.com/?request_id="
timeout_duration = 60 # In seconds, how long the listener is waiting for the login event after opening the browser
def __init__(self):
self.request_id = None
self.session: Client
self.results: AsyncGenerator
self.timeout: Timer
def start(self):
user.logging_in = True
user.display_error = None
# Start the listener in a new thread so Blender can continue running
listener = Thread(target=self._start_async, args=[])
listener.start()
# Start the timeout thread which stops the listener after a few seconds if nothing happened
self.timeout = Timer(self.timeout_duration, self._timeout)
self.timeout.start()
def stop(self):
pass
def _start_async(self):
try:
# Get the request id from the server and run the listener
self._get_request_id()
asyncio.run(self._run_listener())
except Exception as e:
print(traceback.format_exc())
user.error("No internet connection..")
def _timeout(self):
# If the user no longer logging in, don't timeout
if not user.logging_in:
return
# Stop the login listener
print("Connection timeout, stopping listener..")
asyncio.run(cancel_gen(self.results))
# Stopping login and updating UI to show timeout error
user.error("Timeout, please try again.")
print("Stopped login listener")
def _get_request_id(self):
headers = {"x-api-key": self.api_key}
query = """
mutation {
createRequestToken(client_id: "blender") {
request_id
access_token
id_token
refresh_token
client_id
created_at
last_modified
email
username
given_name
family_name
ttl
}
}
"""
try:
request = requests.post(self.url, json={'query': query}, headers=headers)
except Exception as e:
user.logging_in = False
print("No connection to the server.")
return
if request.status_code != 200:
user.logging_in = False
print(f"Query failed to reach the server by returning code of {request.status_code}.")
return
data = request.json()
self.request_id = data.get("data").get("createRequestToken").get("request_id")
def _open_website(self):
webbrowser.open(self.login_url + self.request_id)
async def _run_listener(self):
# Extract host from aws_url and create auth
host = str(urlparse(self.aws_url).netloc)
auth = AppSyncApiKeyAuthentication(host=host, api_key=self.api_key)
transport = AppSyncWebsocketsTransport(url=self.aws_url, auth=auth, ssl=ssl._create_unverified_context())
async with Client(transport=transport) as session:
self.session = session
subscription = gql(
f"""
subscription {{
onTokenChange(request_id: "{self.request_id}") {{
request_id
access_token
id_token
refresh_token
client_id
created_at
last_modified
email
username
given_name
family_name
ttl
}}
}}
"""
)
print("Waiting for login event..")
# Subscribe to the login event
self.results = session.subscribe(subscription)
# Open the website to allow the user to login
self._open_website()
with suppress(asyncio.CancelledError):
# Wait for the login event
async for result in self.results:
# Check if the correct data was returned
data = result.get("onTokenChange")
if data:
if data.get("request_id") != self.request_id:
user.error("Error, please try again.")
print("Request ID not correct, please try again.")
break
print("Login successful, stopping listener..")
user.login(data)
user.login_cache.create_login_cache(data)
break
# If another event was returned (like maintenance), stop the login
user.error("Server error, please try again.")
print("Server error:", result)
break
# If the connection is closing by itself, cancel the timeout timer
self.timeout.cancel()
class LoginSilent:
region = 'us-east-1'
client_id = "39j3527cico5eicbtpjoc6627d"
def __init__(self):
logging.getLogger('boto').setLevel(logging.ERROR)
self.login()
def login(self):
# Start the listener in a new thread so Blender can continue running
thread = Thread(target=self._login_async, args=[])
thread.start()
def _login_async(self):
print("SILENT LOGIN")
if not user.refresh_token:
return
response = None
try:
sys.tracebacklimit = 0
client = boto3.client("cognito-idp", region_name=self.region)
response = client.initiate_auth(
ClientId=self.client_id,
AuthFlow='REFRESH_TOKEN',
AuthParameters={
'REFRESH_TOKEN': user.refresh_token
},
)
except Exception as e:
error_msg = str(e)
print("\nERROR:", error_msg, "\n")
if "NotAuthorizedException" in error_msg:
user.logout()
user.error("Logged out: Session expired")
finally:
del sys.tracebacklimit
# print("RESPONSE:", response)
if not response:
return
# Check response for challenge, logout if challenge detected
# See here for challenges:
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp.html#CognitoIdentityProvider.Client.initiate_auth
challenge_name = response.get("ChallengeName")
challenge_params = response.get("ChallengeParameters")
if challenge_name or challenge_params:
print("ERROR: Further account managing needed!")
user.logout()
user.error("Logged out:", challenge_name)
class User:
classes_logged_in = []
classes_logged_out = []
def __init__(self):
self.logging_in = False
self.login_cache = LoginCache()
self.logged_in = False
self.email = None
self.username = None # This is a unique id
self.access_token = None # Only gets used for the MixPanel API
self.refresh_token = None # Gets used to log in silently
self.display_email = False
self.display_error = None
self.login_time = None
self.version_str = "1.0.0"
self.classes_logged_in = []
self.classes_logged_out = []
def set_info(self, classes_logged_in, classes_logged_out, bl_info):
self.classes_logged_in = classes_logged_in
self.classes_logged_out = classes_logged_out
self.version_str = ".".join(map(str, bl_info.get("version")))
def auto_login(self):
# Check the login cache
data = self.login_cache.get_login_cache()
if not data:
return False
self.login(data, register_classes=True)
if self.logged_in:
LoginSilent()
return self.logged_in
def login(self, data, register_classes=True):
# Collect data
self.email = data.get("email")
self.username = data.get("username")
self.access_token = data.get("access_token")
self.refresh_token = data.get("refresh_token")
# Check data validity
self.logging_in = False
self.logged_in = self.email and self.username and self.refresh_token and self.access_token
if not self.logged_in:
print("ERROR: Not all fields are filled:", self.email, self.username, self.refresh_token, self.access_token)
self.error("Login failed, please try again")
return
self.display_error = None
self.login_time = datetime.datetime.utcnow().timestamp()
MixPanel.send_login_event()
if register_classes:
self.register_classes()
def logout(self):
if not self.logged_in:
return
MixPanel.send_logout_event()
self.logged_in = False
self.email = self.username = self.refresh_token = self.access_token = None
self.unregister_classes()
self.login_cache.delete_cache()
def quit(self):
MixPanel.send_logout_event()
def error(self, *msg):
# Update the UI if the user is still logging in or of the error message changes
update_ui = self.logging_in or msg != self.display_error
self.logging_in = False
self.display_error = msg
if update_ui and not self.logged_in:
ui_refresh_all()
def register_classes(self):
# Unregister logged out classes
for cls in reversed(self.classes_logged_out):
bpy.utils.unregister_class(cls)
# Register logged in classes
for cls in self.classes_logged_in:
bpy.utils.register_class(cls)
def unregister_classes(self):
# Unregister classes_logged_in
for cls in reversed(self.classes_logged_in):
bpy.utils.unregister_class(cls)
# Register classes_logged_out
for cls in self.classes_logged_out:
bpy.utils.register_class(cls)
class LoginCache:
main_dir = pathlib.Path(os.path.dirname(__file__)).parent.resolve()
resources_dir = os.path.join(main_dir, "resources")
cache_dir = os.path.join(resources_dir, "cache")
cache_file = os.path.join(cache_dir, ".cache")
key = 'p03Ab7CuvhUuwcbOU4nBAl_QkoaU8XxciKvHGb5Wfd0='
def __init__(self):
self.f = None
def create_login_cache(self, data):
if not self.f:
self.f = Fernet(self.key)
if not os.path.isdir(self.cache_dir):
os.mkdir(self.cache_dir)
data_str = json.dumps(data)
encoded_data = data_str.encode()
encrypted_data = self.f.encrypt(encoded_data)
with open(self.cache_file, 'wb') as file:
file.write(encrypted_data)
def get_login_cache(self):
if not self.f:
self.f = Fernet(self.key)
if not os.path.isfile(self.cache_file):
return None
with open(self.cache_file, 'rb') as file:
encrypted_data = file.read()
# Decrypt cache data and load it as json
encoded_data = self.f.decrypt(encrypted_data)
data_str = encoded_data.decode()
data = json.loads(data_str)
if not self.is_valid(data):
return None
return data
def delete_cache(self):
if os.path.isfile(self.cache_file):
os.remove(self.cache_file)
def is_valid(self, data):
if not data:
return False
# Check if the cache is too old
creation_date = data.get("created_at")
if not creation_date:
return False
duration_timestamp = int(datetime.datetime.now().timestamp()) - creation_date
duration = datetime.timedelta(seconds=duration_timestamp)
if duration.days > 90:
print("Cache too old, please login again")
self.delete_cache()
user.error("Login expired (90 days)")
return False
return True
class MixPanel:
# url = "https://rmp-team-gql.rokoko.com/graphql"
url = "https://rmp-gql-public.rokoko.com/graphql"
api_key = "da2-pa7tlmpnvbcpdhe7l46q3eodvu"
@staticmethod
def send_login_event():
if not user.username:
return
headers = {"x-api-key": MixPanel.api_key}
event_properties = {
"action": "login",
"blender_version": ".".join(map(str, bpy.app.version)),
"plugin_version": user.version_str,
}
event_properties = json.dumps(event_properties).replace("\"", "\\\"")
query = f"""
mutation {{
trackInMixpanel(input: {{
event_name: "session_start"
event_properties: "{event_properties}"
distinct_id: "{user.username}"
client_id: BLENDER
}}
)
}}
"""
try:
request = requests.post(MixPanel.url, json={'query': query}, headers=headers)
except Exception as e:
user.logging_in = False
print("No connection to the server.")
return
if request.status_code != 200:
user.logging_in = False
print(f"Query failed to reach the server by returning code of {request.status_code}.")
return
# data = request.json()
# print("MIXPANEL LOGIN RECEIVED DATA:", data)
@staticmethod
def send_logout_event():
if not user.username:
return
headers = {"x-api-key": MixPanel.api_key}
session_duration = 0
if user.login_time:
session_duration = datetime.datetime.utcnow().timestamp() - user.login_time
session_duration = round(session_duration, 2)
event_properties = {
"action": "logout",
"blender_version": ".".join(map(str, bpy.app.version)),
"plugin_version": user.version_str,
"session_duration": session_duration,
}
event_properties = json.dumps(event_properties).replace("\"", "\\\"")
query = f"""
mutation {{
trackInMixpanel(input: {{
event_name: "session_end"
event_properties: "{event_properties}"
distinct_id: "{user.username}"
client_id: BLENDER
}}
)
}}
"""
try:
request = requests.post(MixPanel.url, json={'query': query}, headers=headers)
except Exception as e:
user.logging_in = False
print("No connection to the server.")
return
if request.status_code != 200:
user.logging_in = False
print(f"Query failed to reach the server by returning code of {request.status_code}.")
return
user: User = User()
@@ -0,0 +1,161 @@
import bpy
import time
import socket
import traceback
from . import animations, utils
error_temp = ''
show_error = []
# Starts UPD server and handles data received from Rokoko Studio
class Receiver:
sock = None
# Redraw counters
i = -1 # Number of continuous received packets
i_np = 0 # Number of continuous no packets
# Error counters
error_temp = []
error_count = 0
def run(self):
data_raw = None
received = True
error = []
force_error = False
# Try to receive a packet
try:
data_raw, address = self.sock.recvfrom(81920) # Prev 65536
except BlockingIOError as e:
print('Blocking error:', e)
error = ['Receiving no data!']
except OSError as e:
print('Packet error:', e.strerror)
error = ['Packets too big!']
force_error = True
except AttributeError as e:
print('Socket error:', e)
error = ['Socket not running!']
force_error = True
# start_time = time.time()
# Process the packet
if data_raw:
# print('SIZE:', len(data_raw))
# print('DATA:', data_raw)
# Process animation data
error, force_error = self.process_data(data_raw)
# print(round((time.time() - start_time) * 1000, 4), 'ms')
self.handle_ui_updates(received)
self.handle_error(error, force_error)
def process_data(self, data_raw) -> ([str], bool):
"""
Processes the received data. If there was an error it returns a list of strings creating the error message
and if the error should be forced to show immediately instead of after a couple of packages
:param data_raw:
:return:
"""
try:
animations.live_data.init(data_raw)
except ValueError as e:
print('Packet contained no data')
print(e)
return ['Packets contain no data!'], False
except (UnicodeDecodeError, TypeError) as e:
print('Wrong live data format! Use JSON v2 or higher!')
print(e)
print(traceback.format_exc())
return ['Wrong data format!', 'Use JSON v2 or higher!'], True
except KeyError as e:
print('KeyError:', e)
return ['Incompatible JSON version!', 'Use the latest Studio', 'and plugin versions.'], True
except ImportError as e:
# This error occurs specifically when LZ4 isn't supported by the operating system
if "os" in e.msg:
print('LZ4 unsupported by OS!', 'Use "Json" in the', 'Custom panel in Studio.')
return ['LZ4 unsupported by OS!', 'Use "Json" in the', 'Custom panel in Studio.'], True
# This error occurs, when the LZ4 package could not be loaded while it was needed
print('LZ4 unsupported by OS or', 'Blender! Use "Json" in the', 'Custom panel in Studio.')
return ['LZ4 unsupported by OS or', 'Blender! Use "Json" in the', 'Custom panel in Studio.'], True
animations.animate()
return None, False
def handle_ui_updates(self, received):
# Update UI every 5 seconds when packets are received continuously
if received:
self.i += 1
self.i_np = 0
if self.i % (bpy.context.scene.rsl_receiver_fps * 5) == 0:
utils.ui_refresh_properties()
utils.ui_refresh_view_3d()
return
# If receiving a packet after one second of no packets, update UI with next packet
self.i_np += 1
if self.i_np == bpy.context.scene.rsl_receiver_fps:
self.i = -1
def handle_error(self, error, force_error):
global show_error
if not error:
self.error_count = 0
if not show_error:
return
self.error_temp = []
show_error = []
utils.ui_refresh_view_3d()
print('REFRESH')
return
if not self.error_temp:
self.error_temp = error
if force_error:
self.error_count = bpy.context.scene.rsl_receiver_fps - 1
return
if error == self.error_temp:
self.error_count += 1
else:
self.error_temp = error
if force_error:
self.error_count = bpy.context.scene.rsl_receiver_fps
else:
self.error_count = 0
if self.error_count == bpy.context.scene.rsl_receiver_fps:
show_error = self.error_temp
utils.ui_refresh_view_3d()
print('REFRESH')
def start(self, port):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.setblocking(False)
self.sock.bind(('', port))
self.i = -1
self.i_np = 0
self.error_temp = []
self.error_count = 0
global show_error
show_error = False
print("Rokoko Studio Live started listening on port " + str(port))
def stop(self):
self.sock.close()
print("Rokoko Studio Live stopped listening")
@@ -0,0 +1,323 @@
import bpy
import copy
from math import radians, degrees
from collections import OrderedDict
recorded_data = {}
recorded_timestamps = OrderedDict()
def toggle_recording(self, context):
new_state = context.scene.rsl_recording
if new_state:
start_recorder(context)
else:
stop_recorder(context)
def start_recorder(context):
if recorded_data:
return
# Here can be stuff done when starting the recorder
pass
def stop_recorder(context):
if not recorded_data:
return
# Set animation settings
context.scene.render.fps = context.scene.rsl_receiver_fps
# Convert timestamps to keyframes to have a shared time axis
convert_timestamps_to_keyframes()
# Process each type of recorded data
for data_type, objects in recorded_data.items():
if not objects:
continue
if data_type == 'actors':
for obj_name, data in objects.items():
process_actor_recording(obj_name, data)
elif data_type == 'faces':
for obj_name, data in objects.items():
process_face_recording(obj_name, data)
elif data_type == 'objects':
for obj_name, data in objects.items():
process_object_recording(obj_name, data)
# Clear recorded data
recorded_data.clear()
recorded_timestamps.clear()
print('\nSuccessfully saved the recording!')
def process_actor_recording(obj_name, data):
armature = bpy.data.objects.get(obj_name)
if not armature:
print('Armature', obj_name, 'not found!')
return
# Create new action
action = bpy.data.actions.new(name='Anim Arm ' + obj_name)
action.use_fake_user = True
armature.animation_data_create().action = action
# Handle recorded data
data_paths = OrderedDict()
prev_rotations = {}
rotation_modifiers = {}
for item in data:
bone_name = item["bone_name"]
if item['location']:
data_path = 'pose.bones["%s"].location' % bone_name
if not data_paths.get(data_path):
data_paths[data_path] = []
data_paths[data_path].append((item['timestamp'], item['location']))
if item['rotation']:
rotation = item['rotation']
data_path = 'pose.bones["%s"].rotation_euler' % bone_name
if not data_paths.get(data_path):
data_paths[data_path] = []
# Load previous rotation from dict
prev_rot = prev_rotations.get(bone_name)
if not prev_rot:
prev_rot = rotation
# Fix each rotation axis separately
for i in [0, 1, 2]:
# Load rotation modifier of the bone rotation axis
# The rotation modifier is used to cut down on processing time. It gets set when a axis had been normalized
# and then is used to apply the same fix to subsequent rotations. This prevents having to normalize subsequent rotations
rotation_mod = rotation_modifiers.get((bone_name, i))
if not rotation_mod:
rotation_mod = 0
# Get axis rotation in degrees, since they are stored as radians. Also add the rotation modifier to the rotation
axis = degrees(rotation[i]) + rotation_mod
axis_prev = degrees(prev_rot[i])
# Normalize the rotation
axis_normalized, rotation_mod_new = normalize_rotation(axis, axis_prev)
# Save the normalized axis to the rotation and save the rotation modifier
rotation[i] = radians(axis_normalized)
rotation_modifiers[bone_name, i] = rotation_mod + rotation_mod_new
# Save the rotation to the data paths and save the current rotation as the previous rotation
data_paths[data_path].append((item['timestamp'], rotation))
prev_rotations[bone_name] = rotation
use_inherit_rotation = False
data_path = 'data.bones["%s"].use_inherit_rotation' % bone_name
if not data_paths.get(data_path):
data_paths[data_path] = []
data_paths[data_path].append((item['timestamp'], [use_inherit_rotation]))
# Go through each datapath (fcurve) and add all keyframes at once
for data_path, values_tmp in data_paths.items():
frame_count = len(values_tmp)
values_tmp = list(zip(*values_tmp)) # This unzips the list of tuples into two separate lists
timestamps = list(values_tmp[0])
values = list(values_tmp[1])
index_len = len(values[0])
for axis_i in range(index_len):
curve = action.fcurves.new(data_path=data_path, index=axis_i)
keyframe_points = curve.keyframe_points
keyframe_points.add(frame_count)
for frame_i in range(frame_count):
timestamp = timestamps[frame_i]
transform = values[frame_i][axis_i]
keyframe_points[frame_i].co = (
recorded_timestamps[timestamp],
transform)
keyframe_points[frame_i].interpolation = 'LINEAR'
def process_object_recording(obj_name, data):
obj = bpy.data.objects.get(obj_name)
if not obj:
print('Object', obj_name, 'not found!')
return
# Create new action
action = bpy.data.actions.new(name='Anim Obj ' + obj_name)
action.use_fake_user = True
obj.animation_data_create().action = action
# Handle recording data
data_paths = OrderedDict()
for item in data:
if item['location']:
data_path = 'location'
if not data_paths.get(data_path):
data_paths[data_path] = []
data_paths[data_path].append((item['timestamp'], item['location']))
if item['rotation']:
data_path = 'rotation_quaternion'
if not data_paths.get(data_path):
data_paths[data_path] = []
data_paths[data_path].append((item['timestamp'], item['rotation']))
for data_path, values in data_paths.items():
# print(data_path)
frame_count = len(values)
index_len = 3 if data_path.endswith('location') else 4
for axis_i in range(index_len):
curve = action.fcurves.new(data_path=data_path, index=axis_i)
keyframe_points = curve.keyframe_points
keyframe_points.add(frame_count)
for frame_i in range(frame_count):
timestamp = values[frame_i][0]
transform = values[frame_i][1]
keyframe_points[frame_i].co = (
recorded_timestamps[timestamp],
transform[axis_i])
keyframe_points[frame_i].interpolation = 'LINEAR'
def process_face_recording(obj_name, data):
mesh = bpy.data.objects.get(obj_name)
if not mesh:
print('Object', obj_name, 'not found!')
return
# Create new action
action = bpy.data.actions.new(name='Anim Face ' + obj_name)
action.use_fake_user = True
mesh.animation_data_create().action = action
# Handle recording data
data_paths = OrderedDict()
for item in data:
data_path = 'data.shape_keys.key_blocks["%s"].value' % item['shapekey_name']
if not data_paths.get(data_path):
data_paths[data_path] = []
data_paths[data_path].append((item['timestamp'], item['value']))
for data_path, values in data_paths.items():
# print(data_path)
frame_count = len(values)
curve = action.fcurves.new(data_path=data_path, index=0)
keyframe_points = curve.keyframe_points
keyframe_points.add(frame_count)
for frame_i in range(frame_count):
timestamp = values[frame_i][0]
shapekey_value = values[frame_i][1]
keyframe_points[frame_i].co = (
recorded_timestamps[timestamp],
shapekey_value)
keyframe_points[frame_i].interpolation = 'LINEAR'
def normalize_rotation(axis, axis_prev):
rotation_mod = 0
if abs(axis - axis_prev) > 180:
desired_axis = axis
if axis_prev > axis:
while abs(desired_axis - axis_prev) > 180 and axis_prev > axis:
print(axis_prev, axis, desired_axis)
desired_axis += 360
rotation_mod += 360
axis = desired_axis
else:
while abs(desired_axis - axis_prev) > 180 and axis_prev < axis:
print(axis_prev, axis, desired_axis)
desired_axis -= 360
rotation_mod -= 360
axis = desired_axis
return axis, rotation_mod
def convert_timestamps_to_keyframes():
timestamps = list(recorded_timestamps.keys())
def get_frame(frame_number):
return int(round((timestamps[frame_number] - timestamps[0]) * bpy.context.scene.rsl_receiver_fps, 0))
# Fix frame numbers that are incorrect because of rounding errors
for i, timestamp in enumerate(timestamps):
curr_frame = get_frame(i)
if 0 < i < len(timestamps) - 1:
prev_frame = get_frame(i - 1)
next_frame = get_frame(i + 1)
if prev_frame == curr_frame and next == curr_frame + 2:
curr_frame += 1
if next_frame == curr_frame and prev_frame == curr_frame - 2:
curr_frame -= 1
if i == len(timestamps) - 1:
prev_frame = get_frame(i - 1)
if prev_frame == curr_frame:
curr_frame += 1
recorded_timestamps[timestamp] = curr_frame
def record_bone(timestamp, arm_name, bone_name, rotation, location=None):
if not recorded_data.get('actors'):
recorded_data['actors'] = {}
if not recorded_data['actors'].get(arm_name):
recorded_data['actors'][arm_name] = []
data = {
'timestamp': timestamp,
'bone_name': bone_name,
'rotation': copy.deepcopy(rotation),
'location': copy.deepcopy(location)
}
recorded_data['actors'][arm_name].append(data)
recorded_timestamps[timestamp] = 0
def record_face(timestamp, mesh_name, shapekey_name, value):
if not recorded_data.get('faces'):
recorded_data['faces'] = {}
if not recorded_data['faces'].get(mesh_name):
recorded_data['faces'][mesh_name] = []
data = {
'timestamp': timestamp,
'shapekey_name': shapekey_name,
'value': copy.deepcopy(value)
}
recorded_data['faces'][mesh_name].append(data)
recorded_timestamps[timestamp] = 0
def record_object(timestamp, obj_name, rotation, location):
if not recorded_data.get('objects'):
recorded_data['objects'] = {}
if not recorded_data['objects'].get(obj_name):
recorded_data['objects'][obj_name] = []
data = {
'timestamp': timestamp,
'rotation': copy.deepcopy(rotation),
'location': copy.deepcopy(location)
}
recorded_data['objects'][obj_name].append(data)
recorded_timestamps[timestamp] = 0
@@ -0,0 +1,23 @@
import bpy
# This filters the objects shown to only include armatures and under certain conditions
def poll_source_armatures(self, obj):
return obj.type == 'ARMATURE' and obj.animation_data and obj.animation_data.action
def poll_target_armatures(self, obj):
return obj.type == 'ARMATURE' and obj != get_source_armature()
# If the retargeting armatures get changed, clear the bone list
def clear_bone_list(self, context):
context.scene.rsl_retargeting_bone_list.clear()
def get_source_armature():
return bpy.context.scene.rsl_retargeting_armature_source
def get_target_armature():
return bpy.context.scene.rsl_retargeting_armature_target
@@ -0,0 +1,293 @@
import bpy
import copy
from . import utils
from ..operators import receiver
objects = {}
faces = {}
armatures = {}
hidden_meshes = {}
def save_scene():
for obj in bpy.context.scene.objects:
save_object(obj)
if obj.type == 'MESH':
save_face(obj)
elif obj.type == 'ARMATURE':
save_armature(obj)
def load_scene():
for obj in bpy.context.scene.objects:
load_object(obj)
if obj.type == 'MESH':
load_face(obj)
elif obj.type == 'ARMATURE':
load_armature(obj)
hidden_meshes.clear()
# Object handler
def save_object(obj):
if obj.rsl_animations_props_trackers == 'None':
return
global objects
rotation_mode = obj.rotation_mode
obj.rotation_mode = 'QUATERNION'
objects[obj.name] = copy.deepcopy({
'location': obj.location,
'rotation': obj.rotation_quaternion,
'rotation_mode': rotation_mode,
# 'hidden': obj.hide_get()
})
def load_object(obj):
if not bpy.context.scene.rsl_reset_scene_on_stop:
return
global objects
obj_data = objects.get(obj.name)
if not obj_data:
return
obj.rotation_mode = 'QUATERNION'
obj.location = obj_data['location']
obj.rotation_quaternion = obj_data['rotation']
# obj.rotation_mode = obj_data['rotation_mode']
# obj.hide_set(obj_data['hidden'])
# Remove element from dictionary
objects.pop(obj.name)
# Face mesh handler
def save_face(obj):
if not hasattr(obj.data, 'shape_keys') or not hasattr(obj.data.shape_keys, 'key_blocks'):
return
if obj.rsl_animations_faces == 'None':
return
global faces
shapekeys = {}
for shapekey in obj.data.shape_keys.key_blocks:
shapekeys[shapekey.name] = shapekey.value
faces[obj.name] = copy.deepcopy(shapekeys)
if obj.name in hidden_meshes.keys():
unhide_mesh(obj, hidden_meshes[obj.name])
def load_face(obj):
if not bpy.context.scene.rsl_reset_scene_on_stop:
return
global faces
shapekey_data = faces.get(obj.name)
if not shapekey_data or not hasattr(obj.data, 'shape_keys') or not hasattr(obj.data.shape_keys, 'key_blocks'):
return
for shapekey_name, value in shapekey_data.items():
shapekey = obj.data.shape_keys.key_blocks.get(shapekey_name)
if not shapekey:
continue
shapekey.value = value
# Remove element from dictionary
faces.pop(obj.name)
# Hide this mesh if it is animated by an armature and it should be hidden
if bpy.context.scene.rsl_hide_mesh_during_play:
for mod in obj.modifiers:
if mod.type == 'ARMATURE':
armature = mod.object
if armatures.get(armature.name):
hide_mesh(obj, armature)
# Armature handler
def save_armature(obj):
global armatures
# Return if no actor and no glove is assigned to this armature
# if not obj.rsl_animations_actors or obj.rsl_animations_actors == 'None': # <-- This should work but for some reason it doesn't
if obj.rsl_animations_actors == 'None':
print('NO ASSIGNED DATA:', obj.rsl_animations_actors)
return
utils.set_active(obj)
bpy.ops.object.mode_set(mode='OBJECT')
bones = {}
for bone in obj.pose.bones:
# Fix rotation mode
if bone.rotation_mode == 'QUATERNION':
bone.rotation_mode = 'XYZ'
bones[bone.name] = {
'location': bone.location,
'rotation': bone.rotation_euler,
'rotation_mode': bone.rotation_mode,
'inherit_rotation': obj.data.bones.get(bone.name).use_inherit_rotation
}
armatures[obj.name] = copy.deepcopy(bones)
hide_meshes_on_play(obj)
def load_armature(obj):
unhide_meshes_on_stop(obj)
if not bpy.context.scene.rsl_reset_scene_on_stop:
return
global armatures
bone_data = armatures.get(obj.name)
if not bone_data:
return
for bone_name, bone_data in bone_data.items():
bone = obj.pose.bones.get(bone_name)
if not bone:
continue
location = bone_data['location']
rotation = bone_data['rotation']
rotation_mode = bone_data['rotation_mode']
inherit_rotation = bone_data['inherit_rotation']
# Fix rotation mode
if rotation_mode == 'QUATERNION':
rotation_mode = 'XYZ'
bone.location = location
bone.rotation_mode = rotation_mode
bone.rotation_euler = rotation
obj.data.bones.get(bone_name).use_inherit_rotation = inherit_rotation
# Remove element from dictionary
armatures.pop(obj.name)
def update_object(self, context):
if not receiver.receiver_enabled:
return
obj = context.object
new_state = obj.rsl_animations_props_trackers
if new_state != 'None':
if not objects.get(obj.name):
save_object(obj)
else:
load_object(obj)
def update_face(self, context):
if not receiver.receiver_enabled:
return
obj = context.object
new_state = obj.rsl_animations_faces
if new_state != 'None':
if not faces.get(obj.name):
save_face(obj)
else:
load_face(obj)
def update_actor(self, context):
if not receiver.receiver_enabled:
return
obj = context.object
new_state = obj.rsl_animations_actors
if new_state != 'None':
if not armatures.get(obj.name):
save_armature(obj)
else:
load_armature(obj)
def update_glove(self, context):
if not receiver.receiver_enabled:
return
obj = context.object
new_state = obj.rsl_animations_gloves
if new_state != 'None':
if not armatures.get(obj.name):
save_armature(obj)
else:
load_armature(obj)
def hide_meshes_on_play(armature):
if not bpy.context.scene.rsl_hide_mesh_during_play:
return
global faces
for mesh in bpy.context.scene.objects:
if mesh.type != 'MESH':
continue
hide_mesh(mesh, armature)
def unhide_meshes_on_stop(armature):
for mesh_name in copy.copy(hidden_meshes).keys():
mesh = bpy.context.scene.objects.get(mesh_name)
if not mesh:
continue
unhide_mesh(mesh, armature)
def update_hidden_meshes(self, context):
if not receiver.receiver_enabled:
return
new_state = context.scene.rsl_hide_mesh_during_play
for armature_name in armatures.keys():
armature = bpy.context.scene.objects.get(armature_name)
if not armature:
continue
if new_state:
hide_meshes_on_play(armature)
else:
unhide_meshes_on_stop(armature)
def hide_mesh(mesh, armature):
if faces.get(mesh.name):
return
for mod in mesh.modifiers:
if mod.type == 'ARMATURE' and mod.object == armature:
mesh.hide_set(True)
mod.object = None
hidden_meshes[mesh.name] = armature
def unhide_mesh(mesh, armature):
mesh.hide_set(False)
for mod in mesh.modifiers:
if mod.type == 'ARMATURE' and hidden_meshes[mesh.name] == armature:
mod.object = hidden_meshes[mesh.name]
hidden_meshes.pop(mesh.name)
@@ -0,0 +1,85 @@
import asyncio
import sys
import bpy
import math
from mathutils import Vector, Matrix
from contextlib import suppress
def ui_refresh_properties():
# Refreshes the properties panel
for windowManager in bpy.data.window_managers:
for window in windowManager.windows:
for area in window.screen.areas:
if area.type == 'PROPERTIES':
area.tag_redraw()
def ui_refresh_view_3d():
# Refreshes the view 3D panel
for windowManager in bpy.data.window_managers:
for window in windowManager.windows:
for area in window.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
def ui_refresh_all():
if not hasattr(bpy.data, "window_managers"):
return
# Refreshes all panels
for windowManager in bpy.data.window_managers:
for window in windowManager.windows:
for area in window.screen.areas:
area.tag_redraw()
def reprint(*x):
# This prints a message in the same console line continuously
sys.stdout.write("\r" + " ".join(x))
sys.stdout.flush()
def set_active(obj):
obj.select_set(True)
obj.hide_set(False)
bpy.context.view_layer.objects.active = obj
def mat3_to_vec_roll(mat):
vecmat = vec_roll_to_mat3(mat.col[1], 0)
vecmatinv = vecmat.inverted()
rollmat = vecmatinv @ mat
roll = math.atan2(rollmat[0][2], rollmat[2][2])
return roll
def vec_roll_to_mat3(vec, roll):
target = Vector((0, 0.1, 0))
nor = vec.normalized()
axis = target.cross(nor)
if axis.dot(axis) > 0.0000000001:
axis.normalize()
theta = target.angle(nor)
bMatrix = Matrix.Rotation(theta, 3, axis)
else:
updown = 1 if target.dot(nor) > 0 else -1
bMatrix = Matrix.Scale(updown, 3)
bMatrix[2][2] = 1.0
rMatrix = Matrix.Rotation(roll, 3, nor)
mat = rMatrix @ bMatrix
return mat
async def cancel_gen(agen):
"""
Stops an asynchronous generator from outside.
:param agen: The asynchronous generator
:return:
"""
task = asyncio.create_task(agen.__anext__())
task.cancel()
with suppress(Exception):
await task
await agen.aclose()
@@ -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]
@@ -0,0 +1,19 @@
if "bpy" not in locals():
import bpy
from . import main
from . import objects
from . import command_api
from . import retargeting
from . import updater
from . import info
from . import login
else:
import importlib
importlib.reload(main)
importlib.reload(objects)
importlib.reload(command_api)
importlib.reload(retargeting)
importlib.reload(updater)
importlib.reload(info)
importlib.reload(login)
@@ -0,0 +1,37 @@
import bpy
from .main import ToolPanel
from ..operators import command_api
from ..core.icon_manager import Icons
# Main panel of the Rokoko panel
class CommandPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_rsl_command_api_v2'
bl_label = 'Studio Command API'
def draw(self, context):
layout = self.layout
layout.use_property_split = False
col = layout.column()
row = col.row(align=True)
row.label(text='Address:')
row.prop(context.scene, 'rsl_command_ip_address', text='')
row = col.row(align=True)
row.label(text='Port:')
row.prop(context.scene, 'rsl_command_ip_port', text='')
row = col.row(align=True)
row.label(text='Key:')
row.prop(context.scene, 'rsl_command_api_key', text='')
row = layout.row(align=True)
row.scale_y = 1.5
row.scale_x = 3
row.operator(command_api.StartCalibration.bl_idname, text='', icon_value=Icons.CALIBRATE.get_icon())
row.operator(command_api.Restart.bl_idname, text='', icon_value=Icons.RESTART.get_icon())
row.operator(command_api.StartRecording.bl_idname, text='', icon_value=Icons.START_RECORDING.get_icon())
row.operator(command_api.StopRecording.bl_idname, text='', icon='SNAP_FACE')
@@ -0,0 +1,64 @@
import bpy
from .main import ToolPanel, separator
from .. import updater
from ..operators import info
from ..core.icon_manager import Icons
from ..operators.login import LogoutButton
from ..core import login_manager as lm
class InfoPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_rsl_info_v2'
bl_label = 'Info'
def draw(self, context):
layout = self.layout
row = layout.row(align=True)
row.label(text='Rokoko Studio Live', icon_value=Icons.STUDIO_LIVE_LOGO.get_icon())
row = layout.row(align=True)
row.scale_y = 0.1
row.label(text='for Blender (v' + updater.current_version_str + ')', icon='BLANK1')
separator(layout, 0.01)
row = layout.row(align=True)
row.label(text='Developed by ', icon='BLANK1')
row.scale_y = 0.6
row = layout.row(align=True)
row.scale_y = 0.3
row.label(text='Rokoko Electronics ApS', icon='BLANK1')
separator(layout, 0.1)
col = layout.column(align=True)
row = col.row(align=True)
row.operator(info.LicenseButton.bl_idname)
row.operator(info.RokokoButton.bl_idname)
row = col.row(align=True)
row.operator(info.DocumentationButton.bl_idname)
# row = col.row(align=True)
# row.operator(info.ForumButton.bl_idname) # TODO: Add forums back with correct link
# If there is no email, the user is not logged in yet
if not lm.user.email:
return
separator(layout, 0.1)
subrow = layout.row(align=True)
row = subrow.row(align=True)
row.scale_y = 0.7
row.label(text='Rokoko ID:')
row = subrow.row(align=True)
row.scale_y = 0.7
row.alignment = 'RIGHT'
row.operator(info.ToggleRokokoIDButton.bl_idname, text='', icon='HIDE_OFF' if lm.user.display_email else 'HIDE_ON')
row = layout.row(align=True)
row.scale_y = 0.3
row.label(text=lm.user.email if lm.user.display_email else "***********")
row = layout.row(align=True)
row.operator(LogoutButton.bl_idname)
@@ -0,0 +1,44 @@
import bpy
from .main import ToolPanel, separator
from ..operators.login import LoginButton, InstallLibsButton
from ..core.icon_manager import Icons
from .. import updater, updater_ops
from ..core import login_manager as lm
class LoginPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_rsl_login_v2'
bl_label = 'Rokoko ID'
def draw(self, context):
layout = self.layout
updater.check_for_update_background(check_on_startup=True)
updater_ops.draw_update_notification_panel(layout)
if not lm.loaded_all_libs:
row = layout.row(align=True)
row.label(text="First time setup:", icon="INFO")
row = layout.row(align=True)
row.scale_y = 2
row.operator(InstallLibsButton.bl_idname, icon="TRIA_DOWN_BAR")
return
row = layout.row(align=True)
row.scale_y = 2
row.operator(LoginButton.bl_idname, text="Sign in to Rokoko" if not lm.user.logging_in else "Waiting for sign in..", icon_value=Icons.STUDIO_LIVE_LOGO.get_icon())
row = layout.row(align=True)
row.scale_y = 0.5
row.label(text='*Opens your browser')
errors = lm.user.display_error
if not errors:
return
separator(layout, scale=0.2)
for i, error in enumerate(errors):
row = layout.row(align=True)
row.scale_y = 0.5
row.label(text=error, icon="ERROR" if i == 0 else "BLANK1")
@@ -0,0 +1,329 @@
import bpy
import datetime
from .. import updater, updater_ops
from ..core import animations
from ..core import recorder as recorder_manager
from ..core import receiver as receiver_cls
from ..core.icon_manager import Icons
from ..operators import receiver, recorder
row_scale = 0.75
paired_inputs = {}
# Initializes the Rokoko panel in the toolbar
class ToolPanel(object):
bl_label = 'Rokoko'
bl_idname = 'VIEW3D_TS_rokoko'
bl_category = 'Rokoko'
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
def separator(layout, scale=1):
# Add small separator
row = layout.row(align=True)
row.scale_y = scale
row.label(text='')
# Main panel of the Rokoko panel
class ReceiverPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_rsl_receiver_v2'
bl_label = 'Rokoko Studio Live'
def draw(self, context):
layout = self.layout
layout.use_property_split = False
# box = layout.box()
updater.check_for_update_background(check_on_startup=True)
updater_ops.draw_update_notification_panel(layout)
col = layout.column()
row = col.row(align=True)
row.label(text='Port:')
row.enabled = not receiver.receiver_enabled
row.prop(context.scene, 'rsl_receiver_port', text='')
# row = col.row(align=True)
# row.label(text='FPS:')
# row.enabled = not receiver.receiver_enabled
# row.prop(context.scene, 'rsl_receiver_fps', text='')
row = col.row(align=True)
row.label(text='Scene Scale:')
row.prop(context.scene, 'rsl_scene_scaling', text='')
layout.separator()
row = layout.row(align=True)
row.prop(context.scene, 'rsl_reset_scene_on_stop')
row = layout.row(align=True)
row.prop(context.scene, 'rsl_hide_mesh_during_play')
row = layout.row(align=True)
row.scale_y = 1.3
if receiver.receiver_enabled:
row.operator(receiver.ReceiverStop.bl_idname, icon='PAUSE', depress=True)
else:
row.operator(receiver.ReceiverStart.bl_idname, icon='PLAY')
row = layout.row(align=True)
row.scale_y = 1.3
row.enabled = receiver.receiver_enabled
if not context.scene.rsl_recording:
row.operator(recorder.RecorderStart.bl_idname, icon_value=Icons.START_RECORDING.get_icon())
else:
row.operator(recorder.RecorderStop.bl_idname, icon='SNAP_FACE', depress=True)
# Calculate recording time
timestamps = list(recorder_manager.recorded_timestamps.keys())
if timestamps:
time_recorded = int(timestamps[-1] - timestamps[0])
row = layout.row(align=True)
row.label(text='Recording time: ' + str(datetime.timedelta(seconds=time_recorded)))
if receiver.receiver_enabled and receiver_cls.show_error:
for i, error in enumerate(receiver_cls.show_error):
if i == 0:
row = layout.row(align=True)
row.label(text=error, icon='ERROR')
else:
row = layout.row(align=True)
row.scale_y = 0.3
row.label(text=error, icon='BLANK1')
return
if animations.live_data.version <= 2:
show_connetions_v2(layout)
else:
show_connetions_v3(layout)
def show_connetions_v2(layout):
# Show all inputs
global paired_inputs
paired_inputs = {}
used_trackers = []
used_faces = []
# Get all paired inputs. Paired inputs are paired to an object in the scene
for obj in bpy.data.objects:
# Get paired props and trackers
if animations.live_data.props or animations.live_data.trackers:
if obj.rsl_animations_props_trackers and obj.rsl_animations_props_trackers != 'None':
paired = paired_inputs.get(obj.rsl_animations_props_trackers.split('|')[1])
if not paired:
paired_inputs[obj.rsl_animations_props_trackers.split('|')[1]] = [obj.name]
else:
paired.append(obj.name)
# Get paired faces
if animations.live_data.faces and obj.rsl_animations_faces and obj.rsl_animations_faces != 'None':
paired = paired_inputs.get(obj.rsl_animations_faces)
if not paired:
paired_inputs[obj.rsl_animations_faces] = [obj.name]
else:
paired.append(obj.name)
# Get paired actors
if animations.live_data.actors and obj.rsl_animations_actors and obj.rsl_animations_actors != 'None':
paired = paired_inputs.get(obj.rsl_animations_actors)
if not paired:
paired_inputs[obj.rsl_animations_actors] = [obj.name]
else:
paired.append(obj.name)
# This is used as a small spacer
row = layout.row(align=True)
row.scale_y = 0.01
row.label(text=' ')
# Display all paired and unpaired inputs
for actor in animations.live_data.actors:
if actor['profileName']:
row = layout.row(align=True)
row.scale_y = row_scale
row.label(text=actor['profileName'], icon='ANTIALIASED')
split = layout.row(align=True)
split.scale_y = row_scale
add_indent(split)
show_actor(split, actor)
for tracker in animations.live_data.trackers:
if tracker['connectionId'] == actor['name']:
split = layout.row(align=True)
split.scale_y = row_scale
add_indent(split, empty=True)
add_indent(split)
show_tracker(split, tracker)
used_trackers.append(tracker['name'])
for face in animations.live_data.faces:
if face.get('profileName') and face.get('profileName') == actor['profileName']:
split = layout.row(align=True)
split.scale_y = row_scale
add_indent(split)
show_face(split, face)
used_faces.append(face['faceId'])
# split = layout.row(align=True)
# add_indent(split)
# row = split.row(align=True)
# row.label(text='faceId', icon_value=Icons.FACE.get_icon())
for prop in animations.live_data.props:
show_prop(layout, prop, scale=True)
for tracker in animations.live_data.trackers:
if tracker['connectionId'] == prop['id']:
split = layout.row(align=True)
split.scale_y = row_scale
add_indent(split)
show_tracker(split, tracker)
used_trackers.append(tracker['name'])
for tracker in animations.live_data.trackers:
if tracker['name'] not in used_trackers:
show_tracker(layout, tracker, scale=True)
# row = layout.row(align=True)
# row.label(text='5', icon_value=Icons.VP.get_icon())
for face in animations.live_data.faces:
if face['faceId'] not in used_faces:
show_face(layout, face, scale=True)
def show_connetions_v3(layout):
# Show all inputs
global paired_inputs
paired_inputs = {}
for obj in bpy.data.objects:
# Get props
if obj.rsl_animations_props_trackers and obj.rsl_animations_props_trackers != 'None':
if animations.live_data.props:
prop = animations.live_data.get_prop_by_obj(obj)
if prop:
prop_id = animations.live_data.get_prop_id(prop)
if not paired_inputs.get(prop_id):
paired_inputs[prop_id] = [obj.name]
else:
paired_inputs[prop_id].append(obj.name)
# Get faces
if animations.live_data.faces and obj.rsl_animations_faces and obj.rsl_animations_faces != 'None':
face = animations.live_data.get_face_by_obj(obj)
if face:
face_id = animations.live_data.get_face_id(face)
if not paired_inputs.get(face_id):
paired_inputs[face_id] = [obj.name]
else:
paired_inputs[face_id].append(obj.name)
# Get actors
if animations.live_data.actors and obj.rsl_animations_actors and obj.rsl_animations_actors != 'None':
actor = animations.live_data.get_actor_by_obj(obj)
if actor:
actor_id = animations.live_data.get_actor_id(actor)
if not paired_inputs.get(actor_id):
paired_inputs[actor_id] = [obj.name]
else:
paired_inputs[actor_id].append(obj.name)
# This is used as a small spacer
row = layout.row(align=True)
row.scale_y = 0.01
row.label(text=' ')
# Display all paired and unpaired inputs
for actor in animations.live_data.actors:
show_actor(layout, actor)
for face in animations.live_data.faces:
if animations.live_data.get_face_parent_id(face) == animations.live_data.get_actor_id(actor):
split = layout.row(align=True)
split.scale_y = row_scale
add_indent(split)
show_face(split, face)
for prop in animations.live_data.props:
show_prop(layout, prop, scale=True)
def add_indent(split, empty=False):
row = split.row(align=True)
row.alignment = 'LEFT'
if empty:
row.label(text="", icon='BLANK1')
else:
row.label(text="", icon_value=Icons.PAIRED.get_icon())
def show_actor(layout, actor, scale=False):
row = layout.row(align=True)
if scale:
row.scale_y = row_scale
actor_id = animations.live_data.get_actor_id(actor)
if paired_inputs.get(actor_id):
row.label(text=actor_id + ' --> ' + ', '.join(paired_inputs.get(actor_id)), icon_value=Icons.SUIT.get_icon())
else:
row.enabled = False
row.label(text=actor_id, icon_value=Icons.SUIT.get_icon())
def show_glove(layout, glove, scale=False):
row = layout.row(align=True)
if scale:
row.scale_y = row_scale
if paired_inputs.get(glove['gloveID']):
row.label(text=glove['gloveID'] + ' --> ' + ', '.join(paired_inputs.get(glove['gloveID'])), icon='VIEW_PAN')
else:
row.enabled = False
row.label(text=glove['gloveID'], icon='VIEW_PAN')
def show_face(layout, face, scale=False):
row = layout.row(align=True)
if scale:
row.scale_y = row_scale
face_id = animations.live_data.get_face_id(face)
if paired_inputs.get(face_id):
row.label(text=face_id + ' --> ' + ', '.join(paired_inputs.get(face_id)), icon_value=Icons.FACE.get_icon())
else:
row.enabled = False
row.label(text=face_id, icon_value=Icons.FACE.get_icon())
def show_tracker(layout, tracker, scale=False):
row = layout.row(align=True)
if scale:
row.scale_y = row_scale
if paired_inputs.get(tracker['name']):
row.label(text=tracker['name'] + ' --> ' + ', '.join(paired_inputs.get(tracker['name'])), icon_value=Icons.VP.get_icon())
else:
row.enabled = False
row.label(text=tracker['name'], icon_value=Icons.VP.get_icon())
def show_prop(layout, prop, scale=False):
row = layout.row(align=True)
if scale:
row.scale_y = row_scale
prop_id = animations.live_data.get_prop_name_raw(prop)
if paired_inputs.get(prop_id):
row.label(text=prop_id + ' --> ' + ', '.join(paired_inputs.get(prop_id)), icon='FILE_3D')
else:
row.enabled = False
row.label(text=prop_id, icon='FILE_3D')
@@ -0,0 +1,167 @@
import bpy
from ..core import animations, animation_lists
from ..operators.actor import InitTPose, ResetTPose
from ..operators import detector
# Create a panel in the Object category of all objects
class ObjectsPanel(bpy.types.Panel):
bl_label = "Rokoko Studio Live Setup"
bl_idname = "OBJECT_PT_rsl_objects_v2"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "object"
def draw(self, context):
layout = self.layout
obj = context.object
self.draw_tracker(context, layout)
if obj.type == 'MESH':
self.draw_face(context, layout)
elif obj.type == 'ARMATURE':
self.draw_actor(context, layout)
@staticmethod
def draw_tracker(context, layout):
obj = context.object
props_string = 'Prop or Tracker' if animations.live_data.version <= 2 else 'prop'
row = layout.row(align=True)
row.label(text=f'Attach to {props_string}:')
if not animations.live_data.trackers and not animations.live_data.props:
row = layout.row(align=True)
row.label(text=f'No {props_string.lower()} data available.', icon='INFO')
return
row = layout.row(align=True)
row.prop(context.object, 'rsl_animations_props_trackers')
if obj.rsl_animations_props_trackers and obj.rsl_animations_props_trackers != 'None':
row = layout.row(align=True)
row.prop(context.object, 'rsl_use_custom_scale')
if obj.rsl_use_custom_scale:
row.prop(context.object, 'rsl_custom_scene_scale', text='')
@staticmethod
def draw_face(context, layout):
obj = context.object
layout.separator()
row = layout.row(align=True)
row.label(text='Attach to Face:')
if not animations.live_data.faces:
row = layout.row(align=True)
row.label(text='No face data available.', icon='INFO')
row = layout.row(align=True)
row.scale_y = 0.1
return
row = layout.row(align=True)
row.prop(obj, 'rsl_animations_faces')
if obj.rsl_animations_faces and obj.rsl_animations_faces != 'None':
layout.separator()
row = layout.row(align=True)
row.label(text='Select Shapekeys:')
row.operator(detector.DetectFaceShapes.bl_idname)
if not hasattr(obj.data, 'shape_keys') or not hasattr(obj.data.shape_keys, 'key_blocks'):
row = layout.row(align=True)
row.label(text='This mesh has no shapekeys!', icon='INFO')
return
draw_import_export(layout, shapes=True)
for shape in animation_lists.face_shapes:
row = layout.row(align=True)
row.prop_search(obj, 'rsl_face_' + shape, obj.data.shape_keys, "key_blocks", text=shape)
@staticmethod
def draw_actor(context, layout):
obj = context.object
layout.separator()
row = layout.row(align=True)
row.label(text='Attach to Actor:')
if not animations.live_data.actors:
row = layout.row(align=True)
row.label(text='No actor data available.', icon='INFO')
else:
row = layout.row(align=True)
row.prop(context.object, 'rsl_animations_actors')
if obj.rsl_animations_actors and obj.rsl_animations_actors != 'None':
layout.separator()
split = layout.row(align=True)
row = split.split(factor=0.16, align=True)
row.label(text='Bones:')
row.operator(detector.DetectActorBones.bl_idname)
row.operator(InitTPose.bl_idname)
row.operator(ResetTPose.bl_idname)
# if obj.rsl_animations_actors and obj.rsl_animations_actors != 'None':
if not obj.get('CUSTOM') or not obj.get('CUSTOM').get('rsl_tpose_bones'):
row = layout.row(align=True)
row.label(text='T-Pose is not set yet!', icon='ERROR')
draw_import_export(layout)
col = layout.column()
show_gloves = True
for actor_bone in animation_lists.get_bones().keys():
if not show_gloves:
continue
split = col.row(align=True)
row = split.split(factor=0.32, align=True)
row.label(text=actor_bone + ':')
row.prop_search(obj, 'rsl_actor_' + actor_bone, obj.pose, "bones", text='')
# Make a split after right toe to separate hands
if actor_bone == 'rightToe':
if not animations.live_data.has_gloves(animations.live_data.get_actor_by_obj(obj)): # Stop showing glove bones if they are not supported by the JSON version
show_gloves = False
continue
col.separator()
row = col.row(align=True)
row.label(text='Gloves:', icon='VIEW_PAN')
if actor_bone == 'leftLittleDistal':
col.separator()
def draw_import_export(layout, shapes=False):
layout.separator()
row = layout.row(align=True)
row.label(text='Custom Naming Schemes:')
if shapes:
row.operator(detector.SaveCustomShapes.bl_idname, text='Save Current Naming Scheme')
else:
row.operator(detector.SaveCustomBones.bl_idname, text='Save Current Naming Scheme')
subrow = layout.row(align=True)
row = subrow.row(align=True)
row.scale_y = 0.9
row.operator(detector.ImportCustomBones.bl_idname, text='Import')
row.operator(detector.ExportCustomBones.bl_idname, text='Export')
row = subrow.row(align=True)
row.scale_y = 0.9
row.alignment = 'RIGHT'
if shapes:
row.operator(detector.ClearCustomShapes.bl_idname, text='', icon='X')
else:
row.operator(detector.ClearCustomBones.bl_idname, text='', icon='X')
layout.separator()
@@ -0,0 +1,137 @@
import bpy
from .main import ToolPanel
from ..operators import retargeting, detector
from ..core.icon_manager import Icons
from ..core.retargeting import get_target_armature, get_source_armature
from bpy.types import PropertyGroup, UIList
from bpy.props import StringProperty, BoolProperty
# Retargeting panel
class RetargetingPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_rsl_retargeting_v2'
bl_label = 'Retargeting'
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
layout = self.layout
layout.use_property_split = False
row = layout.row(align=True)
row.label(text='Select the armatures:')
row = layout.row(align=True)
row.prop(context.scene, 'rsl_retargeting_armature_source', icon='ARMATURE_DATA')
row = layout.row(align=True)
row.prop(context.scene, 'rsl_retargeting_armature_target', icon='ARMATURE_DATA')
anim_exists = False
for obj in bpy.data.objects:
if obj.animation_data and obj.animation_data.action:
anim_exists = True
if not anim_exists:
row = layout.row(align=True)
row.label(text='No animated armature found!', icon='INFO')
return
if not context.scene.rsl_retargeting_armature_source or not context.scene.rsl_retargeting_armature_target:
self.draw_import_export(layout)
return
if not context.scene.rsl_retargeting_bone_list:
row = layout.row(align=True)
row.scale_y = 1.2
row.operator(retargeting.BuildBoneList.bl_idname, icon_value=Icons.CALIBRATE.get_icon())
self.draw_import_export(layout)
return
subrow = layout.row(align=True)
row = subrow.row(align=True)
row.scale_y = 1.2
row.operator(retargeting.BuildBoneList.bl_idname, text='Rebuild Bone List', icon_value=Icons.CALIBRATE.get_icon())
row = subrow.row(align=True)
row.scale_y = 1.2
row.alignment = 'RIGHT'
row.operator(retargeting.ClearBoneList.bl_idname, text="", icon='X')
layout.separator()
row = layout.row(align=True)
row.template_list("RSL_UL_BoneList", "Bone List", context.scene, "rsl_retargeting_bone_list", context.scene, "rsl_retargeting_bone_list_index", rows=1, maxrows=10)
row = layout.row(align=True)
row.operator(retargeting.AddBoneListItem.bl_idname, text="Add Custom Entry", icon='ADD')
row = layout.row(align=True)
row.prop(context.scene, 'rsl_retargeting_auto_scaling')
row = layout.row(align=True)
row.label(text='Use Pose:')
row.prop(context.scene, 'rsl_retargeting_use_pose', expand=True)
row = layout.row(align=True)
row.scale_y = 1.4
row.operator(retargeting.RetargetAnimation.bl_idname, icon_value=Icons.CALIBRATE.get_icon())
self.draw_import_export(layout)
def draw_import_export(self, layout):
layout.separator()
row = layout.row(align=True)
row.label(text='Custom Naming Schemes:')
subrow = layout.row(align=True)
row = subrow.row(align=True)
row.scale_y = 0.9
row.operator(detector.SaveCustomBonesRetargeting.bl_idname, text='Save')
row.operator(detector.ImportCustomBones.bl_idname, text='Import')
row.operator(detector.ExportCustomBones.bl_idname, text='Export')
row = subrow.row(align=True)
row.scale_y = 0.9
row.alignment = 'RIGHT'
row.operator(detector.ClearCustomBones.bl_idname, text='', icon='X')
class BoneListItem(PropertyGroup):
"""Properties of the bone list items"""
bone_name_source: StringProperty(
name="Source Bone",
description="The source bone name",
default="")
bone_name_target: StringProperty(
name="Target Bone",
description="The target bone name",
default="")
bone_name_key: StringProperty(
name="Auto Detection Key",
description="The automatically detected bone key",
default="")
is_custom: BoolProperty(
description="This determines if the field is a custom one source bone one",
default=False)
class RSL_UL_BoneList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
armature_target = get_target_armature()
armature_source = get_source_armature()
layout = layout.split(factor=0.36, align=True)
# Displays source bone
if item.is_custom:
layout.prop_search(item, 'bone_name_source', armature_source.pose, "bones", text='')
else:
layout.label(text=item.bone_name_source)
# Displays target bone
if armature_target:
layout.prop_search(item, 'bone_name_target', armature_target.pose, "bones", text='')
@@ -0,0 +1,13 @@
import bpy
from .main import ToolPanel
from .. import updater_ops
class UpdaterPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_rsl_updater_v2'
bl_label = 'Updater'
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
updater_ops.draw_updater_panel(context, self.layout)
@@ -0,0 +1,155 @@
from bpy.types import Scene, Object
from bpy.props import IntProperty, StringProperty, EnumProperty, BoolProperty, FloatProperty, CollectionProperty, PointerProperty
from .core import animation_lists, state_manager, recorder, retargeting
from .panels import retargeting as retargeting_ui
def register():
# Receiver
Scene.rsl_receiver_port = IntProperty(
name='Streaming Port',
description="The port defined in Rokoko Studio",
default=14043,
min=1,
max=65535
)
Scene.rsl_receiver_fps = IntProperty(
name='FPS',
description="How often is the data received",
default=60,
min=1,
max=100
)
Scene.rsl_scene_scaling = FloatProperty(
name='Scene Scaling',
description="This allows you to scale the position of props and trackers."
"\nUseful to align their positions with armatures",
default=1,
precision=3,
step=1
)
Scene.rsl_reset_scene_on_stop = BoolProperty(
name='Reset Scene on Stop',
description='This will reset the location and position of animated objects to the state of before starting the receiver',
default=True
)
Scene.rsl_hide_mesh_during_play = BoolProperty(
name='Hide Meshes during Play',
description='This will hide all meshes that are animated by armatures'
'\nto greatly reduce lag and increase performance.'
'\nThis will not hide animated faces',
default=False,
update=state_manager.update_hidden_meshes
)
Scene.rsl_recording = BoolProperty(
name='Toggle Recording',
description='Start and stop recording of the data from Rokoko Studio',
default=False,
update=recorder.toggle_recording
)
# Command API
Scene.rsl_command_ip_address = StringProperty(
name='IP Address',
description='Input the IP address of Rokoko Studio',
default='127.0.0.1',
maxlen=15
)
Scene.rsl_command_ip_port = IntProperty(
name='Command API Port',
description="The port defined in Rokoko Studio",
default=14053,
min=1,
max=65535
)
Scene.rsl_command_api_key = StringProperty(
name='API Key',
description='Input the API key displayed in Rokoko Studio',
default='1234',
maxlen=15
)
# Retargeting
Scene.rsl_retargeting_armature_source = PointerProperty(
name='Source',
description='Select the armature with the animation that you want to retarget',
type=Object,
poll=retargeting.poll_source_armatures,
update=retargeting.clear_bone_list
)
Scene.rsl_retargeting_armature_target = PointerProperty(
name='Target',
description='Select the armature that should receive the animation',
type=Object,
poll=retargeting.poll_target_armatures,
update=retargeting.clear_bone_list
)
Scene.rsl_retargeting_auto_scaling = BoolProperty(
name='Auto Scale',
description='This will scale the source armature to fit the height of the target armature.'
'\nBoth armatures have to be in T-pose for this to work correctly',
default=True
)
Scene.rsl_retargeting_use_pose = EnumProperty(
name="Use Pose",
description='Select which pose of the source and target armature to use to retarget the animation.'
'\nBoth armatures should be in the same pose before retargeting',
items=[
("REST", "Rest", "Select this to use the rest pose during retargeting."),
("CURRENT", "Current", "Select this to use the current pose during retargeting.")
]
)
Scene.rsl_retargeting_bone_list = CollectionProperty(
type=retargeting_ui.BoneListItem
)
Scene.rsl_retargeting_bone_list_index = IntProperty(
name="Index for the retargeting bone list",
default=0
)
# Objects
Object.rsl_animations_props_trackers = EnumProperty(
name='Tracker or Prop',
description='Select the prop or tracker that you want to attach this object to',
items=animation_lists.get_props_trackers,
update=state_manager.update_object
)
Object.rsl_animations_faces = EnumProperty(
name='Face',
description='Select the face that you want to attach this mesh to',
items=animation_lists.get_faces,
update=state_manager.update_face
)
Object.rsl_animations_actors = EnumProperty(
name='Actor',
description='Select the actor that you want to attach this armature to',
items=animation_lists.get_actors,
update=state_manager.update_actor
)
Object.rsl_use_custom_scale = BoolProperty(
name='Use Custom Scale',
description='Select this if the objects scene scaling should be overwritten',
default=False,
)
Object.rsl_custom_scene_scale = FloatProperty(
name='Custom Scene Scaling',
description="This allows you to scale the position independently from the scene scale.",
default=1,
precision=3,
step=1
)
# Face shapekeys
for shape in animation_lists.face_shapes:
setattr(Object, 'rsl_face_' + shape, StringProperty(
name=shape,
description='Select the shapekey that should be animated by this shape'
))
# Actor bones
for bone in animation_lists.get_bones().keys():
setattr(Object, 'rsl_actor_' + bone, StringProperty(
name=bone,
description='Select the bone that corresponds to the actors bone'
))
@@ -0,0 +1,6 @@
{
"rokoko_custom_names": true,
"version": 1,
"bones": {},
"shapes": {}
}
@@ -0,0 +1,748 @@
import os
import bpy
import ssl
import time
import json
import urllib
import shutil
import pathlib
import zipfile
import traceback
import addon_utils
from threading import Thread
from bpy.app.handlers import persistent
beta_branch = "beta"
GITHUB_URL = "https://api.github.com/repos/RokokoElectronics/rokoko-studio-live-blender/releases"
GITHUB_URL_BETA = f"https://github.com/RokokoElectronics/rokoko-studio-live-blender/archive/{beta_branch}.zip"
GITHUB_COMPATIBILITY_URL = "https://raw.githubusercontent.com/Rokoko/rokoko-studio-live-blender/master/version_compatibility.json"
downloads_dir_name = "updater_downloads"
path_names_to_keep = [
downloads_dir_name,
'resources/no_auto_ver_check.txt',
'resources/cache',
'resources/custom_bones',
]
# Dev testing variables
no_ver_check = False
fake_update = False
# Updater variables
version_list = []
is_checking_for_update = False
checked_on_startup = False
current_version = []
current_version_str = ''
update_needed = False
latest_version = None
latest_version_str = ''
used_updater_panel = False
update_finished = False
remind_me_later = False
is_ignored_version = False
confirm_update_to = ''
show_error = ''
file_replacement_extension = '.renamed'
main_dir = os.path.dirname(__file__)
downloads_dir = os.path.join(main_dir, downloads_dir_name)
resources_dir = os.path.join(main_dir, "resources")
ignore_ver_file = os.path.join(resources_dir, "ignore_version.txt")
no_auto_ver_check_file = os.path.join(resources_dir, "no_auto_ver_check.txt")
delete_files_on_startup_file = os.path.join(main_dir, "delete_files_on_startup.txt")
compatibility_file = os.path.join(main_dir, "version_compatibility.json")
# Compatibility checking variables
compatibility_data = {}
compatibility_loaded = False
# Get package name, important for panel in user preferences
package_name = ''
for mod in addon_utils.modules():
if mod.bl_info['name'] == 'Rokoko Studio Live for Blender':
package_name = mod.__name__
class Version:
def __init__(self, data):
# Set version string
version_string = data.get('tag_name').lower().replace('-', '.').replace('_', '.')
if version_string.startswith('v.'):
version_string = version_string[2:]
if version_string.startswith('v'):
version_string = version_string[1:]
# Set version number
version_number = []
for i in version_string.split('.'):
if i.isdigit():
version_number.append(int(i))
# Set version data
self.version_string = version_string
self.version_display_string = version_string
self.version_number = version_number
self.name = data.get('name')
self.download_link = data.get('zipball_url')
self.patch_notes = data.get('body')
self.release_date = data.get('published_at')
self.is_prerelease = data.get('prerelease')
if 'T' in data.get('published_at')[1:]:
self.release_date = data.get('published_at').split('T')[0]
# If the name of the release contains "yanked", ignore it
if 'yanked' in self.name.lower():
return
if self.is_prerelease:
self.version_display_string += ' (beta)'
version_list.append(self)
def get_version_by_string(version_string) -> Version:
for version in version_list:
if version.version_string == version_string:
return version
def get_latest_version() -> Version:
version_list_releases = [version for version in version_list if not version.is_prerelease and is_version_compatible(version.version_string)]
return version_list_releases[0] if version_list_releases else None
def load_compatibility_data():
"""Load the version compatibility JSON from GitHub."""
global compatibility_data, compatibility_loaded
if compatibility_loaded:
return True
try:
print("Fetching version compatibility data from GitHub...")
ssl._create_default_https_context = ssl._create_unverified_context
with urllib.request.urlopen(GITHUB_COMPATIBILITY_URL) as url:
data = url.read().decode('utf-8')
compatibility_data = json.loads(data)
compatibility_loaded = True
print("Loaded version compatibility data from GitHub")
return True
except urllib.error.URLError as e:
print(f"Failed to fetch compatibility data from GitHub: {e}")
# Try to load from local file as fallback
try:
if os.path.isfile(compatibility_file):
with open(compatibility_file, 'r', encoding='utf-8') as f:
compatibility_data = json.load(f)
compatibility_loaded = True
print("Loaded version compatibility data from local file as fallback")
return True
except Exception as local_e:
print(f"Failed to load local compatibility file: \n{traceback.format_exc()}")
print("No compatibility data available, all versions will be considered compatible")
compatibility_data = {}
compatibility_loaded = True
return False
except Exception as e:
print(f"Error loading compatibility data: \n{traceback.format_exc()}")
compatibility_data = {}
compatibility_loaded = True
return False
def get_compatibility_for_version(addon_version_string):
"""Get compatibility info for a specific addon version.
Since the JSON only contains versions where compatibility changed,
we need to find the highest version <= the requested version.
"""
if not compatibility_data:
return None
# Convert version string to tuple for comparison
def version_to_tuple(version_str):
try:
return tuple(int(x) for x in version_str.split('.'))
except:
return (0, 0, 0)
addon_version_tuple = version_to_tuple(addon_version_string)
# Find the highest version in compatibility data that is <= addon_version
best_match = None
best_match_tuple = (0, 0, 0)
for compat_version in compatibility_data.keys():
compat_tuple = version_to_tuple(compat_version)
if compat_tuple <= addon_version_tuple and compat_tuple > best_match_tuple:
best_match = compat_version
best_match_tuple = compat_tuple
if best_match:
return compatibility_data[best_match]
return None
def refresh_compatibility_data():
"""Force refresh of compatibility data from GitHub."""
global compatibility_loaded
compatibility_loaded = False
return load_compatibility_data()
def is_version_compatible(addon_version_string):
"""Check if an addon version is compatible with the current Blender version."""
# Load compatibility data if not already loaded
if not load_compatibility_data():
# If no compatibility file, assume all versions are compatible
return True
# Get current Blender version as string
blender_version = ".".join(str(x) for x in bpy.app.version)
blender_version_tuple = bpy.app.version
# Get compatibility info for this addon version
compat_info = get_compatibility_for_version(addon_version_string)
if not compat_info:
# No compatibility info found, assume compatible
return True
# Check minimum version
min_blender = compat_info.get('minimum_blender')
if min_blender:
try:
min_tuple = tuple(int(x) for x in min_blender.split('.'))
if blender_version_tuple < min_tuple:
return False
except:
pass
# Check maximum version
max_blender = compat_info.get('maximum_blender')
if max_blender:
try:
max_tuple = tuple(int(x) for x in max_blender.split('.'))
if blender_version_tuple > max_tuple:
return False
except:
pass
return True
def check_for_update_background(check_on_startup=False):
global is_checking_for_update, checked_on_startup
if check_on_startup and checked_on_startup:
# print('ALREADY CHECKED ON STARTUP')
return
if is_checking_for_update:
# print('ALREADY CHECKING')
return
checked_on_startup = True
if check_on_startup and os.path.isfile(no_auto_ver_check_file):
print('AUTO CHECK DISABLED VIA FILE')
return
is_checking_for_update = True
thread = Thread(target=check_for_update, args=[])
thread.start()
def check_for_update():
print('Checking for Rokoko Studio Live update...')
# Refresh compatibility data from GitHub
global compatibility_loaded
compatibility_loaded = False # Force reload
load_compatibility_data()
# Get all releases from Github
if not get_github_releases():
finish_update_checking(error='Could not check for updates,'
'\ntry again later.')
return
if not version_list:
finish_update_checking(error='No plugin versions available.')
return
# Check if an update is needed
global update_needed, is_ignored_version
update_needed = check_for_update_available()
is_ignored_version = check_ignored_version()
# Update needed, show the notification popup if it wasn't checked through the UI
if update_needed:
print('Update found!')
if not used_updater_panel and not is_ignored_version:
prepare_to_show_update_notification()
else:
print('No update found.')
# Finish update checking, update the UI
finish_update_checking()
def get_github_releases():
global version_list
version_list = []
if fake_update:
print('FAKE INSTALL!')
Version({
'tag_name': '100.1',
'name': 'Pre release!',
'zipball_url': '',
'body': 'Nothing new to see',
'published_at': 'Just now!!',
'prerelease': True
})
Version({
'tag_name': 'v-99-99',
'name': 'v-99-99',
'zipball_url': '',
'body': 'Put exiting new stuff here\nOr maybe there is?',
'published_at': 'Today',
'prerelease': False
})
Version({
'tag_name': '12.34.56',
'name': '12.34.56 Test Release',
'zipball_url': '',
'body': 'Nothing new to see',
'published_at': 'A week ago probably',
'prerelease': False
})
return True
try:
ssl._create_default_https_context = ssl._create_unverified_context
with urllib.request.urlopen(GITHUB_URL) as url:
data = json.loads(url.read().decode())
except urllib.error.URLError as e:
print('URL ERROR:', e)
return False
if not data:
if type(data) == list:
return True
return False
for version_data in data:
Version(version_data)
return True
def check_for_update_available() -> bool:
if not version_list:
return False
global latest_version, latest_version_str
latest_compatible_version = get_latest_version()
# No compatible versions found
if not latest_compatible_version:
return False
latest_version = latest_compatible_version.version_number
latest_version_str = latest_compatible_version.version_string
if latest_version > current_version:
return True
return False
def finish_update_checking(error=''):
global is_checking_for_update, show_error
is_checking_for_update = False
# Only show error if the update panel was used before
if used_updater_panel:
show_error = error
ui_refresh()
def ui_refresh():
# A way to refresh the ui
refreshed = False
while not refreshed:
if hasattr(bpy.data, 'window_managers'):
for windowManager in bpy.data.window_managers:
for window in windowManager.windows:
for area in window.screen.areas:
area.tag_redraw()
refreshed = True
# print('Refreshed UI')
else:
time.sleep(0.5)
def get_update_post():
if hasattr(bpy.app.handlers, 'scene_update_post'):
return bpy.app.handlers.scene_update_post
else:
return bpy.app.handlers.depsgraph_update_post
def prepare_to_show_update_notification():
return # TODO: Implement?
# This is necessary to show a popup directly after startup
# You will get a nasty error otherwise
# This will add the function to the scene_update_post and it will be executed every frame. that's why it needs to be removed again asap
# print('PREPARE TO SHOW UI')
if show_update_notification not in get_update_post():
get_update_post().append(show_update_notification)
@persistent
def show_update_notification(scene): # One argument in necessary for some reason
# print('SHOWING UI NOW!!!!')
# # Immediately remove this from handlers again
if show_update_notification in get_update_post():
get_update_post().remove(show_update_notification)
# Show notification popup
# atr = UpdateNotificationPopup.bl_idname.split(".")
# getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
bpy.ops.rsl_updater.update_notification_popup('INVOKE_DEFAULT')
def update_now(version=None, latest=False, beta=False):
if fake_update:
print('FAKE UPDATE TO VERSION:', version)
finish_update()
return
if beta:
print('UPDATE TO BETA')
update_link = GITHUB_URL_BETA
elif latest or not version:
print('UPDATE TO ' + latest_version_str)
update_link = get_latest_version().download_link
bpy.context.scene.rsl_updater_version_list = latest_version_str
else:
print('UPDATE TO ' + version)
update_link = get_version_by_string(version).download_link
download_file(update_link)
def download_file(update_url):
if not update_url:
finish_update()
return
# Load all the directories and files
update_zip_file = os.path.join(downloads_dir, "rokoko-update.zip")
# Remove existing download folder
if os.path.isdir(downloads_dir):
print("DOWNLOAD FOLDER EXISTED")
shutil.rmtree(downloads_dir)
# Create download folder
pathlib.Path(downloads_dir).mkdir(exist_ok=True)
# Download zip
print('DOWNLOAD FILE')
try:
ssl._create_default_https_context = ssl._create_unverified_context
urllib.request.urlretrieve(update_url, update_zip_file)
except urllib.error.URLError:
print("FILE COULD NOT BE DOWNLOADED")
shutil.rmtree(downloads_dir)
finish_update(error='Could not download update.')
return
print('DOWNLOAD FINISHED')
# If zip is not downloaded, abort
if not os.path.isfile(update_zip_file):
print("ZIP NOT FOUND!")
shutil.rmtree(downloads_dir)
finish_update(error='Could not find the'
'\ndownloaded zip.')
return
# Extract the downloaded zip
print('EXTRACTING ZIP')
with zipfile.ZipFile(update_zip_file, "r") as zip_ref:
zip_ref.extractall(downloads_dir)
print('EXTRACTED')
# Delete the extracted zip file
print('REMOVING ZIP FILE')
os.remove(update_zip_file)
# Detect the extracted folders and files
print('SEARCHING FOR INIT 1')
def search_init(path):
print('SEARCHING IN ' + path)
files = os.listdir(path)
if "__init__.py" in files:
print('FOUND')
return path
folders = [f for f in os.listdir(path) if os.path.isdir(os.path.join(path, f))]
if len(folders) != 1:
print(len(folders), 'FOLDERS DETECTED')
return None
print('GOING DEEPER')
return search_init(os.path.join(path, folders[0]))
print('SEARCHING FOR INIT 2')
extracted_zip_dir = search_init(downloads_dir)
if not extracted_zip_dir:
print("INIT NOT FOUND!")
shutil.rmtree(downloads_dir)
finish_update(error='Could not find Rokoko Studio'
'\nLive in the downloaded zip.')
return
# Remove old addon files
clean_addon_dir()
# Move the extracted files to their correct places
def move_files(from_dir, to_dir):
print('MOVE FILES TO DIR:', to_dir)
files = os.listdir(from_dir)
for file in files:
source_path = os.path.join(from_dir, file)
target_path = os.path.join(to_dir, file)
print('MOVE', source_path)
# If file exists, delete the target and move the new file over
if os.path.isfile(source_path) and os.path.isfile(target_path):
try:
os.remove(target_path)
except PermissionError as e:
# If removing the target file failed, rename the new file, add its name to a file and move it over
# It will re renamed on the next Blender startup
print(e)
source_path_renamed = os.path.join(from_dir, file) + file_replacement_extension
os.rename(source_path, source_path_renamed)
source_path = source_path_renamed
print('File was not deleted, it will be replaced on the next startup')
try:
shutil.move(source_path, to_dir)
except shutil.Error as e:
print('Moving still failed:', e)
print('REMOVED AND MOVED', file)
elif os.path.isdir(source_path) and os.path.isdir(target_path):
move_files(source_path, target_path)
else:
try:
shutil.move(source_path, to_dir)
except shutil.Error as e:
print(e)
print('MOVED', file)
move_files(extracted_zip_dir, main_dir)
# Delete download folder
print('DELETE DOWNLOADS DIR')
shutil.rmtree(downloads_dir)
# Finish the update
finish_update()
def finish_update(error=''):
global update_finished, show_error
show_error = error
if not error:
update_finished = True
bpy.ops.rsl_updater.update_complete_panel('INVOKE_DEFAULT')
ui_refresh()
print("UPDATE DONE!")
def clean_addon_dir():
print("CLEAN ADDON FOLDER")
# Convert paths to os specific paths
paths_to_keep = []
for path_name in path_names_to_keep:
path_parts = path_name.split('/')
paths_to_keep.append(os.path.join(*path_parts))
for root, dirs, files in os.walk(main_dir, topdown=False):
root_rel = os.path.relpath(root, main_dir)
# Ignore folders that start with a dot. If the relative path is a dot only, it means that it's the main path which shouldn't be ignored
if root_rel.startswith('.') and root_rel != '.':
continue
# Go over every file and decide whether to delete it or not
for file in files:
file_rel = os.path.join(root_rel, file)
file_abs = os.path.join(root, file)
if file_rel.startswith('.\\') or file_rel.startswith('./'):
file_rel = file_rel[2:]
# Keep the file if its exact name is on the ignore list
if file_rel in paths_to_keep:
continue
# Keep the file if part of its path is on the ignore list
keep_file = False
for path in paths_to_keep:
if file_rel.startswith(path):
keep_file = True
break
if keep_file:
continue
# Delete the file
try:
os.remove(file_abs)
print('Removed file', file_abs)
except OSError:
print('Failed to remove file', file_abs)
add_file_to_delete_on_startup(file_abs)
# Go over every folder and decide whether to delete it or not
for folder in dirs:
folder_rel = os.path.join(root_rel, folder)
folder_abs = os.path.join(root, folder)
if folder_rel.startswith('.\\'):
folder_rel = folder_rel[2:]
# Keep the folder if its exact name is on the ignore list
if folder_rel in paths_to_keep:
continue
# Delete the folder. It won't get deleted if it's not empty and that is on purpose.
# All files in the folder should be deleted already, so keep it if there are still files in it
try:
os.rmdir(folder_abs)
print('Removed folder', folder_abs)
except OSError:
print('Failed to remove folder', folder_abs)
def add_file_to_delete_on_startup(file_path):
# w = create and write
# a = append to end of file
write_type = 'a' if os.path.isfile(delete_files_on_startup_file) else 'w'
# Create or append "delete on startup" file
with open(delete_files_on_startup_file, write_type, encoding="utf8") as outfile:
outfile.write(file_path + '\n')
def delete_and_rename_files_on_startup():
if not os.path.isfile(delete_files_on_startup_file):
return
with open(delete_files_on_startup_file, 'r', encoding="utf8") as outfile:
lines = outfile.readlines()
# Delete the file immediately to allow it to be recreated if something fails
os.remove(delete_files_on_startup_file)
for path in lines:
if not path:
continue
# Remove the line separator from the end of the path
path = path[:-1]
if os.path.isfile(path):
try:
os.remove(path)
print('Removed file on startup', path)
except OSError:
print('Failed to remove file on startup', path)
add_file_to_delete_on_startup(path)
continue
path_renamed = path + file_replacement_extension
if os.path.isfile(path_renamed):
os.rename(path_renamed, path)
print('Renamed', path_renamed, 'to', path)
def set_ignored_version():
# Create resources folder
pathlib.Path(resources_dir).mkdir(exist_ok=True)
# Create ignore file
with open(ignore_ver_file, 'w', encoding="utf8") as outfile:
outfile.write(latest_version_str)
# Set ignored status
global is_ignored_version
is_ignored_version = True
print('IGNORE VERSION ' + latest_version_str)
def check_ignored_version():
if not os.path.isfile(ignore_ver_file):
# print('IGNORE FILE NOT FOUND')
return False
# Read ignore file
with open(ignore_ver_file, 'r', encoding="utf8") as outfile:
version = outfile.read()
# Check if the latest version matches the one in the ignore file
if latest_version_str == version:
print('Update ignored.')
return True
# Delete ignore version file if the latest version is not the version in the file
try:
os.remove(ignore_ver_file)
except OSError:
print("FAILED TO REMOVE IGNORE VERSION FILE")
return False
def get_version_list(self, context):
choices = []
for version in version_list:
# Only include compatible versions
if is_version_compatible(version.version_string):
# 1. Will be returned by context.scene
# 2. Will be shown in lists
# 3. will be shown in the hover description (below description)
choices.append((version.version_string, version.version_display_string, version.version_display_string))
else:
# Add incompatible versions with a warning
display_string = version.version_display_string + " (incompatible)"
description = f"Version {version.version_string} is not compatible with Blender {'.'.join(str(x) for x in bpy.app.version)}"
choices.append((version.version_string, display_string, description))
bpy.types.Object.Enum = choices
return bpy.types.Object.Enum
def get_user_preferences():
return bpy.context.user_preferences if hasattr(bpy.context, 'user_preferences') else bpy.context.preferences
@@ -0,0 +1,564 @@
import bpy
from . import updater
class CheckForUpdateButton(bpy.types.Operator):
bl_idname = 'rsl_updater.check_for_update'
bl_label = 'Check now for Update'
bl_description = 'Checks if a new update is available for Rokoko Studio Live'
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
return not updater.is_checking_for_update
def execute(self, context):
updater.used_updater_panel = True
updater.check_for_update_background()
return {'FINISHED'}
class UpdateToLatestButton(bpy.types.Operator):
bl_idname = 'rsl_updater.update_latest'
bl_label = 'Update Now'
bl_description = 'Updates Rokoko Studio Live to the latest compatible version'
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
return updater.update_needed
def execute(self, context):
updater.confirm_update_to = 'latest'
updater.used_updater_panel = True
bpy.ops.rsl_updater.confirm_update_panel('INVOKE_DEFAULT')
return {'FINISHED'}
class UpdateToSelectedButton(bpy.types.Operator):
bl_idname = 'rsl_updater.update_selected'
bl_label = 'Update to Selected version'
bl_description = 'Updates Rokoko Studio Live to the selected version'
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
if updater.is_checking_for_update or not updater.version_list:
return False
return True
def execute(self, context):
updater.confirm_update_to = context.scene.rsl_updater_version_list
updater.used_updater_panel = True
bpy.ops.rsl_updater.confirm_update_panel('INVOKE_DEFAULT')
return {'FINISHED'}
class UpdateToBetaButton(bpy.types.Operator):
bl_idname = 'rsl_updater.update_beta'
bl_label = 'Update to Beta version'
bl_description = 'Updates Rokoko Studio Live to the Beta version'
bl_options = {'INTERNAL'}
def execute(self, context):
updater.confirm_update_to = 'beta'
updater.used_updater_panel = True
bpy.ops.rsl_updater.confirm_update_panel('INVOKE_DEFAULT')
return {'FINISHED'}
class RemindMeLaterButton(bpy.types.Operator):
bl_idname = 'rsl_updater.remind_me_later'
bl_label = 'Remind me later'
bl_description = 'This hides the update notification til the next Blender restart'
bl_options = {'INTERNAL'}
def execute(self, context):
updater.remind_me_later = True
self.report({'INFO'}, 'You will be reminded later')
return {'FINISHED'}
class IgnoreThisVersionButton(bpy.types.Operator):
bl_idname = 'rsl_updater.ignore_this_version'
bl_label = 'Ignore this version'
bl_description = 'Ignores this version. You will be reminded again when the next version releases'
bl_options = {'INTERNAL'}
def execute(self, context):
updater.set_ignored_version()
self.report({'INFO'}, 'Version ' + updater.latest_version_str + ' will be ignored.')
return {'FINISHED'}
class ShowPatchnotesPanel(bpy.types.Operator):
bl_idname = 'rsl_updater.show_patchnotes'
bl_label = 'Patchnotes'
bl_description = 'Shows the patchnotes of the selected version'
bl_options = {'INTERNAL'}
@classmethod
def poll(cls, context):
if updater.is_checking_for_update or not updater.version_list:
return False
return True
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
updater.used_updater_panel = True
dpi_value = updater.get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 8.3))
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
row = col.row(align=True)
row.prop(context.scene, 'rsl_updater_version_list')
if context.scene.rsl_updater_version_list:
version = updater.get_version_by_string(context.scene.rsl_updater_version_list)
col.separator()
row = col.row(align=True)
row.label(text=version.name, icon='SOLO_ON')
col.separator()
for line in version.patch_notes.replace('**', '').split('\r\n'):
if line.startswith("["):
continue
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=line)
col.separator()
row = col.row(align=True)
row.label(text='Released: ' + version.release_date)
col.separator()
class ConfirmUpdatePanel(bpy.types.Operator):
bl_idname = 'rsl_updater.confirm_update_panel'
bl_label = 'Confirm Update'
bl_description = 'This shows you a panel in which you have to confirm your update choice'
bl_options = {'INTERNAL'}
show_patchnotes = False
def execute(self, context):
print('UPDATE TO ' + updater.confirm_update_to)
if updater.confirm_update_to == 'beta':
updater.update_now(beta=True)
elif updater.confirm_update_to == 'latest':
updater.update_now(latest=True)
else:
updater.update_now(version=updater.confirm_update_to)
return {'FINISHED'}
def invoke(self, context, event):
dpi_value = updater.get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 4.2))
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
version_str = updater.confirm_update_to
if updater.confirm_update_to == 'latest':
version_str = updater.latest_version_str
elif updater.confirm_update_to == 'beta':
version_str = 'Beta'
col.separator()
row = col.row(align=True)
row.label(text='Version: ' + version_str)
if updater.confirm_update_to == 'beta':
col.separator()
col.separator()
row = col.row(align=True)
row.scale_y = 0.75
row.label(text='Warning:')
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=' The beta version might be unstable, some features')
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=' might not work correctly, or it might only be')
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=' compatible with the latest Blender version.')
col.separator()
row = col.row(align=True)
row.scale_y = 0.75
row.label(text=' Check the Rokoko Github repo for more details.')
else:
row.operator(ShowPatchnotesPanel.bl_idname, text='Show Patchnotes')
col.separator()
col.separator()
row = col.row(align=True)
row.scale_y = 0.65
row.label(text='Update now:', icon='URL')
class UpdateCompletePanel(bpy.types.Operator):
bl_idname = 'rsl_updater.update_complete_panel'
bl_label = 'Installation Report'
bl_description = 'The update if now complete'
bl_options = {'INTERNAL'}
show_patchnotes = False
def execute(self, context):
return {'FINISHED'}
def invoke(self, context, event):
dpi_value = updater.get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 4.2))
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
if updater.update_finished:
row = col.row(align=True)
row.scale_y = 0.9
row.label(text='Rokoko Studio Live was successfully updated.', icon='FILE_TICK')
row = col.row(align=True)
row.scale_y = 0.9
row.label(text='Restart Blender to complete the update.', icon='BLANK1')
else:
row = col.row(align=True)
row.scale_y = 0.9
row.label(text='Update failed.', icon='CANCEL')
row = col.row(align=True)
row.scale_y = 0.9
row.label(text='See Updater Panel for more info.', icon='BLANK1')
class UpdateNotificationPopup(bpy.types.Operator):
bl_idname = 'rsl_updater.update_notification_popup'
bl_label = 'Update available'
bl_description = 'This shows you that an update is available'
bl_options = {'INTERNAL'}
def execute(self, context):
action = context.scene.rsl_update_action
if action == 'UPDATE':
updater.update_now(latest=True)
elif action == 'IGNORE':
updater.set_ignored_version()
else:
# Remind later aka defer
updater.remind_me_later = True
updater.ui_refresh()
return {'FINISHED'}
def invoke(self, context, event):
dpi_value = updater.get_user_preferences().system.dpi
return context.window_manager.invoke_props_dialog(self, width=int(dpi_value * 4.7))
# def invoke(self, context, event):
# return context.window_manager.invoke_props_dialog(self)
def check(self, context):
# Important for changing options
return True
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
row = col.split(factor=0.55, align=True)
row.scale_y = 1.05
row.label(text='Rokoko Studio Live v' + updater.latest_version_str + ' available!', icon='SOLO_ON')
row.operator(ShowPatchnotesPanel.bl_idname, text='Show Patchnotes')
col.separator()
col.separator()
col.separator()
row = col.row(align=True)
row.prop(context.scene, 'rsl_update_action', expand=True)
def draw_update_notification_panel(layout):
if not updater.update_needed or updater.remind_me_later or updater.is_ignored_version:
return
col = layout.column(align=True)
if updater.update_finished:
col.separator()
row = col.row(align=True)
row.label(text='Restart Blender to complete update!', icon='ERROR')
col.separator()
return
row = col.row(align=True)
row.scale_y = 0.75
row.label(text='Update v' + updater.latest_version_str + ' available!', icon='SOLO_ON')
col.separator()
row = col.row(align=True)
row.scale_y = 1.3
row.operator(UpdateToLatestButton.bl_idname, text='Update Now')
row = col.row(align=True)
row.scale_y = 1
row.operator(RemindMeLaterButton.bl_idname, text='Defer')
row.operator(IgnoreThisVersionButton.bl_idname, text='Ignore')
col.separator()
col.separator()
col.separator()
def draw_updater_panel(context, layout, user_preferences=False):
col = layout.column(align=True)
scale_big = 2
scale_small = 1.2
if user_preferences:
row = col.row(align=True)
row.scale_y = 0.8
row.label(text='Rokoko Studio Live Updater:', icon='URL')
col.separator()
if updater.update_finished:
col.separator()
row = col.row(align=True)
row.scale_y = 0.75
row.label(text='Restart Blender to', icon='ERROR')
row = col.row(align=True)
row.scale_y = 0.75
row.label(text='complete the update!', icon='BLANK1')
col.separator()
return
# If the plugin didn't load correctly, don't show the current version
if "error" in updater.current_version_str:
row = col.row(align=True)
row.scale_y = 0.85
row.label(text="Failed to load the plugin. Try updating to latest or beta version:", icon='ERROR')
col.separator()
if updater.show_error:
errors = updater.show_error.split('\n')
for i, error in enumerate(errors):
row = col.row(align=True)
row.scale_y = 0.85
row.label(text=error, icon='ERROR' if i == 0 else 'BLANK1')
col.separator()
if updater.is_checking_for_update:
if not updater.used_updater_panel:
row = col.row(align=True)
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text='Checking..')
else:
split = col.row(align=True)
row = split.row(align=True)
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text='Checking..')
row = split.row(align=True)
row.alignment = 'RIGHT'
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text="", icon='FILE_REFRESH')
elif updater.update_needed:
split = col.row(align=True)
row = split.row(align=True)
row.scale_y = scale_big
row.operator(UpdateToLatestButton.bl_idname, text='Update now to ' + updater.latest_version_str)
row = split.row(align=True)
row.alignment = 'RIGHT'
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text="", icon='FILE_REFRESH')
elif not updater.used_updater_panel or not updater.version_list:
row = col.row(align=True)
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text='Check now for Update')
else:
split = col.row(align=True)
row = split.row(align=True)
row.scale_y = scale_big
row.operator(UpdateToLatestButton.bl_idname, text='Up to Date!')
row = split.row(align=True)
row.alignment = 'RIGHT'
row.scale_y = scale_big
row.operator(CheckForUpdateButton.bl_idname, text="", icon='FILE_REFRESH')
col.separator()
col.separator()
split = col.row(align=True)
row = split.split(factor=0.3, align=True)
row.scale_y = scale_small
row.active = True if not updater.is_checking_for_update and updater.version_list else False
row.label(text='Version:')
row.prop(context.scene, 'rsl_updater_version_list', text='')
row = split.row(align=True)
row.scale_y = scale_small
row.operator(ShowPatchnotesPanel.bl_idname, text="", icon='WORDWRAP_ON')
row = col.row(align=True)
row.scale_y = scale_small
row.active = True if not updater.is_checking_for_update and updater.version_list else False
selected_version_compatible = updater.is_version_compatible(context.scene.rsl_updater_version_list)
row.operator(UpdateToSelectedButton.bl_idname, text='Install Selected Version' if selected_version_compatible else 'Force Install Selected Version')
row = col.row(align=True)
row.scale_y = scale_small
row.operator(UpdateToBetaButton.bl_idname, text='Install Beta Version')
# If version is default, don't show the current version
if "error" in updater.current_version_str:
return
col.separator()
row = col.row(align=True)
row.scale_y = 0.65
row.label(text='Add-on version: ' + updater.current_version_str)
# Show compatibility info
blender_version = ".".join(str(x) for x in bpy.app.version)
row = col.row(align=True)
row.scale_y = 0.65
row.label(text='Blender version: ' + blender_version)
# demo bare-bones preferences
class DemoPreferences(bpy.types.AddonPreferences):
bl_idname = updater.package_name
def draw(self, context):
layout = self.layout
draw_updater_panel(context, layout, user_preferences=True)
to_register = [
CheckForUpdateButton,
UpdateToLatestButton,
UpdateToSelectedButton,
UpdateToBetaButton,
RemindMeLaterButton,
IgnoreThisVersionButton,
ShowPatchnotesPanel,
ConfirmUpdatePanel,
UpdateCompletePanel,
UpdateNotificationPopup,
DemoPreferences,
]
registered = False
def register():
global registered
if registered:
return
# Set initial version
current_version = []
for i in (1, 0, 0):
current_version.append(str(i))
updater.current_version.append(i)
# Set current version string and add beta tag and increase version number
updater.current_version_str = '.'.join(current_version)
current_version[2] = str(int(current_version[2]) + 1)
updater.current_version_str = '.'.join(current_version) + ".error"
bpy.types.Scene.rsl_updater_version_list = bpy.props.EnumProperty(
name='Version',
description='Select the version you want to install\n',
items=updater.get_version_list
)
bpy.types.Scene.rsl_update_action = bpy.props.EnumProperty(
name="Choose action",
description="Action",
items=[
("UPDATE", "Update Now", "Updates now to the latest version"),
("IGNORE", "Ignore this version", "This ignores this version. You will be reminded again when the next version releases"),
("DEFER", "Remind me later", "Hides the update notification til the next Blender restart")
]
)
# Register all Updater classes
count = 0
for cls in to_register:
try:
bpy.utils.register_class(cls)
count += 1
except ValueError:
pass
# print('Registered', count, 'Rokoko Studio Live updater classes.')
if count < len(to_register):
print('Skipped', len(to_register) - count, 'Rokoko Studio Live updater classes.')
# Delete and rename files that didn't get deleted during the update process
updater.delete_and_rename_files_on_startup()
print("LOADED UPDATER!")
registered = True
def update_info(bl_info, beta_branch):
# If not beta branch, always disable fake updates and no version checks!
if not beta_branch:
updater.fake_update = False
updater.no_ver_check = False
# Set current version
current_version = []
updater.current_version = []
for i in bl_info['version']:
current_version.append(str(i))
updater.current_version.append(i)
# Set current version string (and add beta tag and increase version number if true)
updater.current_version_str = '.'.join(current_version)
if beta_branch:
current_version[2] = str(int(current_version[2]) + 1)
updater.current_version_str = '.'.join(current_version) + ".beta"
def unregister():
global registered
registered = False
# Unregister all Updater classes
for cls in reversed(to_register):
try:
bpy.utils.unregister_class(cls)
except RuntimeError as e:
print(f"Error unregistering {cls.__name__}: {e}")
if hasattr(bpy.types.Scene, 'rsl_updater_version_list'):
del bpy.types.Scene.rsl_updater_version_list
@@ -0,0 +1,22 @@
{
"1.0.0": {
"minimum_blender": "2.80.75",
"maximum_blender": "2.92.99"
},
"1.3.0": {
"minimum_blender": "2.80.75",
"maximum_blender": "2.99.99"
},
"1.4.0": {
"minimum_blender": "2.80.75",
"maximum_blender": "3.3.99"
},
"1.4.1": {
"minimum_blender": "2.80.75",
"maximum_blender": "4.0.99"
},
"1.4.2": {
"minimum_blender": "2.80.75",
"maximum_blender": null
}
}