├── mayaModule ├── skeleposer │ ├── scripts │ │ └── skeleposerEditor │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── icons │ │ │ ├── new.png │ │ │ ├── bone.png │ │ │ ├── layer.png │ │ │ ├── mirror.png │ │ │ ├── pose.png │ │ │ ├── reset.png │ │ │ ├── directory.png │ │ │ └── removeBone.png │ │ │ ├── HowToMakeFacialRigs.txt │ │ │ ├── utils.py │ │ │ ├── core.py │ │ │ └── ui.py │ └── platforms │ │ ├── 2019 │ │ └── win64 │ │ │ └── plug-ins │ │ │ └── skeleposer.mll │ │ ├── 2020 │ │ └── win64 │ │ │ └── plug-ins │ │ │ └── skeleposer.mll │ │ ├── 2022 │ │ └── win64 │ │ │ └── plug-ins │ │ │ └── skeleposer.mll │ │ ├── 2023 │ │ └── win64 │ │ │ └── plug-ins │ │ │ └── skeleposer.mll │ │ ├── 2024 │ │ └── win64 │ │ │ └── plug-ins │ │ │ └── skeleposer.mll │ │ └── 2025 │ │ └── win64 │ │ └── plug-ins │ │ └── skeleposer.mll └── skeleposer.mod ├── UE5Plugin └── Skeleposer │ ├── Resources │ └── Icon128.png │ ├── Source │ └── Skeleposer │ │ ├── Public │ │ ├── Skeleposer.h │ │ └── RigUnit_Skeleposer.h │ │ ├── Private │ │ ├── Skeleposer.cpp │ │ └── RigUnit_Skeleposer.cpp │ │ └── Skeleposer.Build.cs │ └── Skeleposer.uplugin ├── CMakeLists.txt ├── source ├── blendMatrix.h ├── main.cpp ├── stickyMatrix.h ├── blendMatrix.cpp ├── skeleposer.h ├── utils.hpp ├── stickyMatrix.cpp └── skeleposer.cpp ├── README.md ├── cmake └── FindMaya.cmake ├── CLAUDE.md ├── LICENSE └── Unity └── Skeleposer.cs /mayaModule/skeleposer/scripts/skeleposerEditor/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/__init__.py: -------------------------------------------------------------------------------- 1 | from .ui import * 2 | from .core import * 3 | -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Resources/Icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/UE5Plugin/Skeleposer/Resources/Icon128.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/new.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/bone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/bone.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/layer.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/mirror.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/mirror.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/pose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/pose.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/reset.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/platforms/2019/win64/plug-ins/skeleposer.mll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/platforms/2019/win64/plug-ins/skeleposer.mll -------------------------------------------------------------------------------- /mayaModule/skeleposer/platforms/2020/win64/plug-ins/skeleposer.mll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/platforms/2020/win64/plug-ins/skeleposer.mll -------------------------------------------------------------------------------- /mayaModule/skeleposer/platforms/2022/win64/plug-ins/skeleposer.mll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/platforms/2022/win64/plug-ins/skeleposer.mll -------------------------------------------------------------------------------- /mayaModule/skeleposer/platforms/2023/win64/plug-ins/skeleposer.mll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/platforms/2023/win64/plug-ins/skeleposer.mll -------------------------------------------------------------------------------- /mayaModule/skeleposer/platforms/2024/win64/plug-ins/skeleposer.mll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/platforms/2024/win64/plug-ins/skeleposer.mll -------------------------------------------------------------------------------- /mayaModule/skeleposer/platforms/2025/win64/plug-ins/skeleposer.mll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/platforms/2025/win64/plug-ins/skeleposer.mll -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/directory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/directory.png -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/icons/removeBone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azagoruyko/skeleposer/HEAD/mayaModule/skeleposer/scripts/skeleposerEditor/icons/removeBone.png -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Source/Skeleposer/Public/Skeleposer.h: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | #pragma once 4 | 5 | #include "CoreMinimal.h" 6 | #include "Modules/ModuleManager.h" 7 | 8 | class FSkeleposerModule : public IModuleInterface 9 | { 10 | public: 11 | 12 | /** IModuleInterface implementation */ 13 | virtual void StartupModule() override; 14 | virtual void ShutdownModule() override; 15 | }; 16 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | 3 | project (skeleposer) 4 | set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake) 5 | 6 | find_package(Maya REQUIRED) 7 | 8 | set(source 9 | source/main.cpp 10 | source/skeleposer.cpp 11 | source/skeleposer.h 12 | source/blendMatrix.cpp 13 | source/blendMatrix.h 14 | source/stickyMatrix.cpp 15 | source/stickyMatrix.h 16 | source/utils.hpp) 17 | 18 | add_library(skeleposer SHARED ${source}) 19 | 20 | MAYA_PLUGIN( skeleposer ) 21 | -------------------------------------------------------------------------------- /source/blendMatrix.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | using namespace std; 9 | 10 | class BlendMatrix : public MPxNode 11 | { 12 | public: 13 | static MTypeId typeId; 14 | 15 | static MObject attr_matrixA; 16 | static MObject attr_matrixB; 17 | static MObject attr_weight; 18 | static MObject attr_outMatrix; 19 | 20 | static void* creator() { return new BlendMatrix(); } 21 | static MStatus initialize(); 22 | 23 | MStatus compute(const MPlug&, MDataBlock&); 24 | }; 25 | -------------------------------------------------------------------------------- /mayaModule/skeleposer.mod: -------------------------------------------------------------------------------- 1 | + MAYAVERSION:2019 PLATFORM:win64 Skeleposer 1.0.0 ./skeleposer/platforms/2019/win64 2 | scripts: ../../../scripts 3 | 4 | + MAYAVERSION:2020 PLATFORM:win64 Skeleposer 1.0.0 ./skeleposer/platforms/2020/win64 5 | scripts: ../../../scripts 6 | 7 | + MAYAVERSION:2022 PLATFORM:win64 Skeleposer 1.0.0 ./skeleposer/platforms/2022/win64 8 | scripts: ../../../scripts 9 | 10 | + MAYAVERSION:2023 PLATFORM:win64 Skeleposer 1.0.0 ./skeleposer/platforms/2023/win64 11 | scripts: ../../../scripts 12 | 13 | + MAYAVERSION:2024 PLATFORM:win64 Skeleposer 1.0.0 ./skeleposer/platforms/2024/win64 14 | scripts: ../../../scripts 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 |
8 | 9 | **Skeleposer** is a tool for Maya and game engines for working with skeletons. It's nicely suitable for customization systems and facial rigs. 10 | 11 | 📖 **[Documentation](https://github.com/azagoruyko/skeleposer/wiki)** 12 | 13 | image 14 | -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Source/Skeleposer/Private/Skeleposer.cpp: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | #include "Skeleposer.h" 4 | 5 | #define LOCTEXT_NAMESPACE "FSkeleposerModule" 6 | 7 | void FSkeleposerModule::StartupModule() 8 | { 9 | // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module 10 | } 11 | 12 | void FSkeleposerModule::ShutdownModule() 13 | { 14 | // This function may be called during shutdown to clean up your module. For modules that support dynamic reloading, 15 | // we call this function before unloading the module. 16 | } 17 | 18 | #undef LOCTEXT_NAMESPACE 19 | 20 | IMPLEMENT_MODULE(FSkeleposerModule, Skeleposer) 21 | -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Skeleposer.uplugin: -------------------------------------------------------------------------------- 1 | { 2 | "FileVersion": 3, 3 | "Version": 1, 4 | "EngineVersion": "5.1.0", 5 | "VersionName": "1.0", 6 | "FriendlyName": "Skeleposer", 7 | "Description": "Skeleposer is like Shape Editor in Maya but works for skeleton", 8 | "Category": "Animation", 9 | "CreatedBy": "Alexander Zagoruyko", 10 | "CreatedByURL": "", 11 | "DocsURL": "", 12 | "EnabledByDefault": true, 13 | "MarketplaceURL": "com.epicgames.launcher://ue/marketplace/product/3024f3d0608f433c95a6d0ccf86b94e9", 14 | "SupportURL": "", 15 | "CanContainContent": true, 16 | "IsBetaVersion": false, 17 | "IsExperimentalVersion": false, 18 | "Installed": false, 19 | "Modules": 20 | [ 21 | { 22 | "Name": "Skeleposer", 23 | "Type": "Runtime", 24 | "LoadingPhase": "Default", 25 | "WhitelistPlatforms": [ "Win64", "Android" ] 26 | } 27 | ], 28 | "Plugins": 29 | [ 30 | { 31 | "Name": "ControlRig", 32 | "Enabled": true 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/HowToMakeFacialRigs.txt: -------------------------------------------------------------------------------- 1 | How to make facial rigs with Skeleposer. 2 | 3 | Each skeleposer's joint's hierarchy should be like this: 4 | 5 | joint # skeleposer joint 6 | minor_joint # controlled by a constraint and a scale from a minor control 7 | 8 | control_null # controlled by a constraint from the joint above 9 | control # controls minor_joint above 10 | 11 | Make left joints global oriented. It's easier to work with. 12 | 13 | head_joint 14 | upper-head-joints # brows, eyes, cheeks 15 | head_lower_squash_joint # used for stretching/squashing 16 | M_jaw_joint 17 | M_teeth_upper_joint 18 | M_teeth_lower_joint 19 | M_mouth_joint # controlled by M_mouth_control 20 | M_lip_upper_base_joint # skeleposer joint 21 | M_lip_upper_base_minor_joint # controlled by an upper lip control 22 | M_lip_upper_joint # skeleposer upper lip joints 23 | M_lip_upper_minor_joint # controlled by a minor control 24 | L_lip_upper_1_joint 25 | L_lip_upper_1_minor_joint 26 | ... 27 | 28 | Right controls should have sx=-1. 29 | -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Source/Skeleposer/Skeleposer.Build.cs: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | using UnrealBuildTool; 4 | 5 | public class Skeleposer : ModuleRules 6 | { 7 | public Skeleposer(ReadOnlyTargetRules Target) : base(Target) 8 | { 9 | PublicDependencyModuleNames.AddRange( 10 | new string[] 11 | { 12 | "Core", 13 | // ... add other public dependencies that you statically link with here ... 14 | "AnimGraphRuntime", "ControlRig", "RigVM", "Json", "JsonUtilities" 15 | } 16 | ); 17 | 18 | 19 | PrivateDependencyModuleNames.AddRange( 20 | new string[] 21 | { 22 | "CoreUObject" 23 | // ... add private dependencies that you statically link with here ... 24 | } 25 | ); 26 | 27 | if (Target.bBuildEditor) 28 | { 29 | PrivateDependencyModuleNames.AddRange( 30 | new string[] 31 | { 32 | "Engine" 33 | } 34 | ); 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /source/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "skeleposer.h" 4 | #include "blendMatrix.h" // blendMatrix already defined in Maya 2022+ 5 | #include "stickyMatrix.h" 6 | 7 | #if MAYA_APP_VERSION >= 2020 // blendMatrix node is defined in Maya 2020+ 8 | #define BLEND_MATRIX_NAME "sblendMatrix" 9 | #else 10 | #define BLEND_MATRIX_NAME "blendMatrix" 11 | #endif 12 | 13 | MStatus initializePlugin(MObject plugin) 14 | { 15 | MStatus stat; 16 | MFnPlugin pluginFn(plugin); 17 | 18 | stat = pluginFn.registerNode("skeleposer", Skeleposer::typeId, Skeleposer::creator, Skeleposer::initialize); 19 | CHECK_MSTATUS_AND_RETURN_IT(stat); 20 | 21 | stat = pluginFn.registerNode(MString(BLEND_MATRIX_NAME), BlendMatrix::typeId, BlendMatrix::creator, BlendMatrix::initialize); 22 | CHECK_MSTATUS_AND_RETURN_IT(stat); 23 | 24 | stat = pluginFn.registerNode("stickyMatrix", StickyMatrix::typeId, StickyMatrix::creator, StickyMatrix::initialize); 25 | CHECK_MSTATUS_AND_RETURN_IT(stat); 26 | 27 | return MS::kSuccess; 28 | } 29 | 30 | MStatus uninitializePlugin(MObject plugin) 31 | { 32 | MStatus stat; 33 | MFnPlugin pluginFn(plugin); 34 | 35 | stat = pluginFn.deregisterNode(Skeleposer::typeId); 36 | CHECK_MSTATUS_AND_RETURN_IT(stat); 37 | 38 | stat = pluginFn.deregisterNode(BlendMatrix::typeId); 39 | CHECK_MSTATUS_AND_RETURN_IT(stat); 40 | 41 | stat = pluginFn.deregisterNode(StickyMatrix::typeId); 42 | CHECK_MSTATUS_AND_RETURN_IT(stat); 43 | 44 | return MS::kSuccess; 45 | } -------------------------------------------------------------------------------- /source/stickyMatrix.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | using namespace std; 9 | 10 | class StickyMatrix : public MPxNode 11 | { 12 | public: 13 | static MTypeId typeId; 14 | 15 | static MObject attr_parent1; 16 | static MObject attr_parent1WithoutScale; 17 | static MObject attr_translate1; 18 | static MObject attr_rotate1X; 19 | static MObject attr_rotate1Y; 20 | static MObject attr_rotate1Z; 21 | static MObject attr_rotate1; 22 | static MObject attr_scale1; 23 | static MObject attr_jointOrient1X; 24 | static MObject attr_jointOrient1Y; 25 | static MObject attr_jointOrient1Z; 26 | static MObject attr_jointOrient1; 27 | static MObject attr_offset1; 28 | 29 | static MObject attr_parent2; 30 | static MObject attr_parent2WithoutScale; 31 | static MObject attr_translate2; 32 | static MObject attr_rotate2X; 33 | static MObject attr_rotate2Y; 34 | static MObject attr_rotate2Z; 35 | static MObject attr_rotate2; 36 | static MObject attr_scale2; 37 | static MObject attr_jointOrient2X; 38 | static MObject attr_jointOrient2Y; 39 | static MObject attr_jointOrient2Z; 40 | static MObject attr_jointOrient2; 41 | static MObject attr_offset2; 42 | 43 | static MObject attr_sticky; 44 | static MObject attr_blendAt; 45 | static MObject attr_spherical; 46 | static MObject attr_sphereCenter; 47 | static MObject attr_sphericalLengthFactor; 48 | 49 | static MObject attr_outMatrix1; 50 | static MObject attr_outMatrix2; 51 | 52 | static void* creator() { return new StickyMatrix(); } 53 | static MStatus initialize(); 54 | 55 | MStatus compute(const MPlug&, MDataBlock&); 56 | }; 57 | -------------------------------------------------------------------------------- /cmake/FindMaya.cmake: -------------------------------------------------------------------------------- 1 | # By Alexander Zagoruyko. No rights reserved! 2 | 3 | if(NOT DEFINED MAYA_DEVKIT_DIR) 4 | set(MAYA_DEVKIT_DIR "" CACHE PATH "Maya devkit directory (where include/lib reside)") 5 | endif() 6 | 7 | # OS Specific environment setup 8 | set(MAYA_COMPILE_DEFINITIONS "REQUIRE_IOSTREAM;_BOOL;_TBB_NO_IMPLICIT_LINKAGE;_TBB_MAKE_EXCEPTION_PTR_PRESENT") 9 | set(MAYA_LIB_PREFIX "") 10 | set(MAYA_LIB_SUFFIX ".lib") 11 | 12 | if(WIN32) 13 | # Windows 14 | set(MAYA_COMPILE_DEFINITIONS "${MAYA_COMPILE_DEFINITIONS};NT_PLUGIN") 15 | set(MAYA_PLUGIN_EXTENSION ".mll") 16 | 17 | elseif(APPLE) 18 | # Apple 19 | set(MAYA_COMPILE_DEFINITIONS "${MAYA_COMPILE_DEFINITIONS};OSMac_") 20 | set(MAYA_PLUGIN_EXTENSION ".bundle") 21 | set(MAYA_LIB_SUFFIX ".dylib") 22 | else() 23 | # Linux 24 | set(MAYA_COMPILE_DEFINITIONS "${MAYA_COMPILE_DEFINITIONS};LINUX") 25 | set(MAYA_PLUGIN_EXTENSION ".so") 26 | set(MAYA_LIB_SUFFIX ".so") 27 | set(MAYA_LIB_PREFIX "lib") 28 | endif() 29 | 30 | include(FindPackageHandleStandardArgs) 31 | find_package_handle_standard_args(Maya DEFAULT_MSG MAYA_DEVKIT_DIR) 32 | 33 | function(MAYA_PLUGIN target) 34 | target_include_directories(${target} PUBLIC ${MAYA_DEVKIT_DIR}/include) 35 | 36 | set(MAYA_LIBS Foundation OpenMaya OpenMayaAnim OpenMayaFX OpenMayaRender OpenMayaUI clew tbb) 37 | foreach(MAYA_LIB ${MAYA_LIBS}) 38 | target_link_libraries(${target} PUBLIC ${MAYA_DEVKIT_DIR}/lib/${MAYA_LIB_PREFIX}${MAYA_LIB}${MAYA_LIB_SUFFIX}) 39 | endforeach() 40 | 41 | set_target_properties(${target} PROPERTIES 42 | COMPILE_DEFINITIONS "${MAYA_COMPILE_DEFINITIONS}" 43 | LINK_FLAGS "/export:initializePlugin /export:uninitializePlugin" 44 | PREFIX "" 45 | SUFFIX ${MAYA_PLUGIN_EXTENSION}) 46 | endfunction() -------------------------------------------------------------------------------- /source/blendMatrix.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | 10 | #include "blendMatrix.h" 11 | #include "utils.hpp" 12 | 13 | using namespace std; 14 | 15 | MTypeId BlendMatrix::typeId(1274441); 16 | 17 | MObject BlendMatrix::attr_matrixA; 18 | MObject BlendMatrix::attr_matrixB; 19 | MObject BlendMatrix::attr_weight; 20 | MObject BlendMatrix::attr_outMatrix; 21 | 22 | 23 | MStatus BlendMatrix::compute(const MPlug &plug, MDataBlock &dataBlock) 24 | { 25 | if (plug != attr_outMatrix) 26 | return MS::kFailure; 27 | 28 | const MMatrix matrixA = dataBlock.inputValue(attr_matrixA).asMatrix(); 29 | const MMatrix matrixB = dataBlock.inputValue(attr_matrixB).asMatrix(); 30 | const float weight = dataBlock.inputValue(attr_weight).asFloat(); 31 | 32 | const MMatrix m = blendMatrices(matrixA, matrixB, weight); 33 | dataBlock.outputValue(attr_outMatrix).setMMatrix(m); 34 | 35 | return MS::kSuccess; 36 | } 37 | 38 | MStatus BlendMatrix::initialize() 39 | { 40 | MFnNumericAttribute nAttr; 41 | MFnMatrixAttribute mAttr; 42 | 43 | attr_matrixA = mAttr.create("matrixA", "matrixA"); 44 | addAttribute(attr_matrixA); 45 | 46 | attr_matrixB = mAttr.create("matrixB", "matrixB"); 47 | addAttribute(attr_matrixB); 48 | 49 | attr_weight = nAttr.create("weight", "weight", MFnNumericData::kFloat, 0.5); 50 | nAttr.setMin(0); 51 | nAttr.setMax(1); 52 | nAttr.setKeyable(true); 53 | addAttribute(attr_weight); 54 | 55 | attr_outMatrix = mAttr.create("outMatrix", "outMatrix"); 56 | addAttribute(attr_outMatrix); 57 | 58 | attributeAffects(attr_matrixA, attr_outMatrix); 59 | attributeAffects(attr_matrixB, attr_outMatrix); 60 | attributeAffects(attr_weight, attr_outMatrix); 61 | 62 | return MS::kSuccess; 63 | } 64 | -------------------------------------------------------------------------------- /source/skeleposer.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | using namespace std; 10 | 11 | struct DirectoryItem 12 | { 13 | float weight; 14 | int parentIndex; 15 | vector childrenIndices; // positive - poses, negative - directories 16 | }; 17 | 18 | class Directory 19 | { 20 | public: 21 | Directory() {} 22 | 23 | void clear() { _items.clear(); } 24 | bool empty() const { return _items.empty(); } 25 | 26 | DirectoryItem& operator[](int idx) { return _items[idx]; } 27 | 28 | float getRecursiveWeight(int idx) const 29 | { 30 | auto& found = _items.find(idx); 31 | if (found == _items.end()) 32 | return 1; 33 | 34 | auto& item = found->second; 35 | 36 | if (idx == item.parentIndex) 37 | return item.weight; 38 | 39 | return item.weight * getRecursiveWeight(item.parentIndex); 40 | } 41 | 42 | void getPosesOrder(int idx, vector &poseIndices) const 43 | { 44 | auto& found = _items.find(idx); 45 | if (found == _items.end()) 46 | return; 47 | 48 | for (auto& idx : found->second.childrenIndices) 49 | { 50 | if (idx >= 0) 51 | poseIndices.push_back(idx); 52 | else 53 | getPosesOrder(-idx, poseIndices); 54 | } 55 | } 56 | 57 | private: 58 | map _items; 59 | }; 60 | 61 | typedef enum 62 | { 63 | ADDIVITE, 64 | REPLACE 65 | } PoseBlendMode; 66 | 67 | struct Pose 68 | { 69 | float weight; 70 | PoseBlendMode blendMode; 71 | MMatrix deltaMatrix; 72 | }; 73 | 74 | struct Joint 75 | { 76 | MMatrix baseMatrix; 77 | MQuaternion jointOrient; 78 | vector poses; 79 | }; 80 | 81 | class Skeleposer : public MPxNode 82 | { 83 | public: 84 | static MTypeId typeId; 85 | 86 | static MObject attr_joints; 87 | static MObject attr_baseMatrices; 88 | static MObject attr_jointOrientX; 89 | static MObject attr_jointOrientY; 90 | static MObject attr_jointOrientZ; 91 | static MObject attr_jointOrients; 92 | static MObject attr_poses; 93 | static MObject attr_poseName; 94 | static MObject attr_poseWeight; 95 | static MObject attr_poseEnabled; 96 | static MObject attr_poseBlendMode; 97 | static MObject attr_poseDirectoryIndex; 98 | static MObject attr_poseDeltaMatrices; 99 | 100 | static MObject attr_directories; 101 | static MObject attr_directoryName; 102 | static MObject attr_directoryWeight; 103 | static MObject attr_directoryParentIndex; 104 | static MObject attr_directoryChildrenIndices; // positive - poses, negative - directories 105 | 106 | static MObject attr_outputTranslates; 107 | 108 | static MObject attr_outputRotateX; 109 | static MObject attr_outputRotateY; 110 | static MObject attr_outputRotateZ; 111 | static MObject attr_outputRotates; 112 | 113 | static MObject attr_outputScales; 114 | 115 | static void* creator() { return new Skeleposer(); } 116 | static MStatus initialize(); 117 | 118 | MStatus compute(const MPlug&, MDataBlock&); 119 | 120 | MStatus shouldSave(const MPlug& plug, bool& isSaving) 121 | { 122 | isSaving = true; // save everything 123 | return MS::kSuccess; 124 | } 125 | 126 | void postConstructor() 127 | { 128 | setExistWithoutInConnections(true); 129 | setExistWithoutOutConnections(true); 130 | } 131 | 132 | private: 133 | }; 134 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Skeleposer is a cross-platform pose and transform management tool for Maya, Unity, and Unreal Engine. It's similar to Maya's Shape Editor but works with transforms and joints instead of blend shapes. Designed for character customization systems and complex facial rigs. 8 | 9 | **Key Features:** 10 | - Pose duplication, mirroring, and flipping 11 | - Corrective and inbetween poses 12 | - Two blend modes: additive (default) and replace 13 | - Split poses tool for breaking complex poses into combinable chunks 14 | - Layering capabilities with joint hierarchies 15 | - Very lightweight single-node architecture 16 | - Easily transferable between characters regardless of topology 17 | - Optimized for complex facial rigs 18 | 19 | **Documentation:** https://github.com/azagoruyko/skeleposer/wiki 20 | 21 | ## Build Commands 22 | 23 | ### Maya Plugin (C++) 24 | ```bash 25 | # Build Maya plugin using CMake 26 | mkdir build 27 | cd build 28 | cmake -DMAYA_VERSION=2024 .. 29 | cmake --build . --config Release 30 | ``` 31 | 32 | ### UE5 Plugin 33 | - Build through Unreal Engine's build system 34 | - Plugin location: `UE5Plugin/Skeleposer/` 35 | 36 | ### Unity Component 37 | - No build required - Unity script in `Unity/Skeleposer.cs` 38 | 39 | ## Architecture 40 | 41 | ### Core Components 42 | 43 | **Maya C++ Plugin (`source/`)**: 44 | - `skeleposer.h/cpp` - Main MPxNode implementing pose blending system 45 | - `blendMatrix.h/cpp` - Matrix blending node (version-aware for Maya 2020+) 46 | - `stickyMatrix.h/cpp` - Matrix constraint utility 47 | - `main.cpp` - Plugin registration/deregistration 48 | 49 | **Maya Python Interface (`mayaModule/skeleposer/scripts/skeleposerEditor/`)**: 50 | - `skeleposer.py` - Main Python API wrapper for the C++ node 51 | - `ui.py` - PySide2-based UI for pose editing and management 52 | - `utils.py` - Utility functions for Maya operations 53 | 54 | **UE5 Integration (`UE5Plugin/Skeleposer/`)**: 55 | - `RigUnit_Skeleposer.h/cpp` - Control Rig unit implementation 56 | - Uses FLinearCurve for Maya RemapValue node functionality 57 | 58 | **Unity Integration (`Unity/`)**: 59 | - `Skeleposer.cs` - Unity component for pose-based skeletal animation 60 | 61 | ### Key Data Structures 62 | 63 | **Directory System** - Hierarchical organization of poses: 64 | - `Directory` class manages pose organization with recursive weight calculation 65 | - Each directory has weight, parent index, and children indices 66 | - Negative indices represent subdirectories, positive represent poses 67 | 68 | **Pose System**: 69 | - `Pose` struct contains weight, blend mode (ADDITIVE/REPLACE), and delta matrices 70 | - `Joint` struct stores base matrix, joint orient, and associated poses 71 | - Matrix-based transformations for skeletal animation 72 | 73 | **Cross-Platform Consistency**: 74 | - Same core data structures replicated in C++, C#, and Python 75 | - JSON serialization for pose data exchange between platforms 76 | - Consistent blend mode enums across all implementations 77 | 78 | ## Development Notes 79 | 80 | - Maya plugin supports versions 2019-2024 with version-specific builds 81 | - BlendMatrix node naming changes based on Maya version (2020+ uses "sblendMatrix") 82 | - UI uses PySide2 for Maya integration 83 | - All platforms support additive and replace blend modes for poses -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Source/Skeleposer/Public/RigUnit_Skeleposer.h: -------------------------------------------------------------------------------- 1 | // Created by Alexander Zagoruyko. Published in 2023 2 | 3 | #pragma once 4 | 5 | #include "CoreMinimal.h" 6 | #include "Units/RigUnit.h" 7 | #include "RigUnit_Skeleposer.generated.h" 8 | 9 | /* RemapValue Maya node implemenation. Interp type is linear*/ 10 | struct FLinearCurve 11 | { 12 | TArray Points; 13 | 14 | FLinearCurve() {}; 15 | FLinearCurve(const TArray& OtherPoints) : Points(OtherPoints) {} 16 | FLinearCurve(const TArray&& OtherPoints) { Move(Points, OtherPoints); } 17 | 18 | bool IsEmpty() const { return Points.IsEmpty(); } 19 | 20 | FVector2D Evaluate(float Param) const 21 | { 22 | const double AbsParam = (Points.Num() - 1) * Param * 0.99999; 23 | const int32 Idx = floor(AbsParam); 24 | const float Perc = AbsParam - Idx; 25 | return FMath::Lerp(Points[Idx], Points[Idx + 1], Perc); 26 | } 27 | 28 | /* Return such a point where point.x = X */ 29 | FVector2D EvaluateFromX(float X) const 30 | { 31 | const float Step = 0.05; 32 | float CurrentParam = 0; 33 | FVector2D Pnt = Evaluate(0); 34 | FVector2D PrevPnt(Pnt); 35 | 36 | while (Pnt.X < X && CurrentParam < 1.0) 37 | { 38 | PrevPnt = Pnt; 39 | CurrentParam += Step; 40 | Pnt = Evaluate(FMath::Clamp(CurrentParam, 0.0, 1.0)); 41 | } 42 | 43 | const float Perc = (X - PrevPnt.X) / (Pnt.X - PrevPnt.X + 1e-5); 44 | return FMath::Lerp(PrevPnt, Pnt, Perc); 45 | } 46 | }; 47 | 48 | /** Each Directory contains other directories and poses as indices*/ 49 | struct FDirectory 50 | { 51 | /** 0 by default */ 52 | int32 ParentIndex; 53 | 54 | /** Positive index is Pose, Negative index is Directory */ 55 | TArray ChildrenIndices; 56 | }; 57 | 58 | typedef enum 59 | { 60 | ADDITIVE, /** Add the pose to the current transformations */ 61 | REPLACE /** Replace current transformations by the pose */ 62 | } FPoseBlendMode; 63 | 64 | /** Pose got from Skeleposer in Maya */ 65 | struct FPose 66 | { 67 | /** Pose name */ 68 | FString Name; 69 | 70 | /** Blending type, ADDITIVE or REPLACE */ 71 | FPoseBlendMode BlendMode; 72 | 73 | /** Matrices per bone index */ 74 | TMap Deltas; 75 | 76 | /** Indices of the correct poses */ 77 | TArray Corrects; 78 | 79 | /* Source pose index and an interpolation curve */ 80 | TPair Inbetween; 81 | }; 82 | 83 | /** Poses per bone. Used for perfomance reasons */ 84 | struct FBonePose 85 | { 86 | /** Pose name */ 87 | FString PoseName; 88 | 89 | /** Blending type, ADDITIVE or REPLACE */ 90 | FPoseBlendMode BlendMode; 91 | 92 | /** Pose delta matrix */ 93 | FTransform Delta; 94 | 95 | /** Correct poses names */ 96 | TArray Corrects; 97 | 98 | /* Source pose name and an interpolation curve */ 99 | TPair Inbetween; 100 | }; 101 | 102 | USTRUCT() 103 | struct FRigUnit_Skeleposer_WorkData 104 | { 105 | GENERATED_BODY() 106 | 107 | /** Poses be bone name */ 108 | TMap> BonePoses; 109 | FString FilePathCache; 110 | 111 | void Reset() 112 | { 113 | BonePoses.Reset(); 114 | FilePathCache = ""; 115 | } 116 | 117 | void ReadJsonFile(const FString &FilePath, TArray &PoseNameList); 118 | }; 119 | 120 | /** Poses set in Unreal by a user */ 121 | USTRUCT() 122 | struct FUserPose 123 | { 124 | GENERATED_BODY() 125 | 126 | UPROPERTY(meta = (Input)) 127 | FString PoseName; 128 | 129 | UPROPERTY(meta = (Input, ClampMin = 0.f, ClampMax = 1.f)) 130 | float PoseWeight; 131 | }; 132 | 133 | /** 134 | * 135 | */ 136 | USTRUCT(meta = (DisplayName = "Skeleposer", Category = "Others", NodeColor = "1.0, 0.0, 1.0")) 137 | struct SKELEPOSER_API FRigUnit_Skeleposer : public FRigUnitMutable 138 | { 139 | GENERATED_BODY() 140 | 141 | FRigUnit_Skeleposer(); 142 | 143 | RIGVM_METHOD() 144 | virtual void Execute(const FRigUnitContext& Context) override; 145 | 146 | RIGVM_METHOD() 147 | virtual FRigVMStructUpgradeInfo GetUpgradeInfo() const override; 148 | 149 | /** Relative to Contents directory json file path */ 150 | UPROPERTY(meta = (Input, DetailsOnly)) 151 | FString FilePath; 152 | 153 | UPROPERTY(meta = (Input)) 154 | bool bUseMorphTargets; 155 | 156 | UPROPERTY(meta = (Input, DisplayName="Poses", TitleProperty="PoseName")) 157 | TArray UserPoses; // TMap is better here, but not supported by ControlRig 158 | 159 | // List of all poses, read only 160 | UPROPERTY(meta = (Output, DetailsOnly)) 161 | TArray PoseNameList; 162 | 163 | // Cache 164 | UPROPERTY(Transient) 165 | FRigUnit_Skeleposer_WorkData WorkData; 166 | }; 167 | -------------------------------------------------------------------------------- /source/utils.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | #define MSTR(v) MString(to_string(v).c_str()) 11 | #define EPSILON 1e-6 12 | #define RAD2DEG 57.2958 13 | #define DEG2RAD 0.0174532862 14 | 15 | inline double rbf_gaussian(double r, double c = 1) { return exp(-pow(r * c, 2)); } 16 | inline double rbf_multiquadric(double r, double c = 1) { return sqrt(1 + pow(r*c, 2)); } 17 | inline double rbf_inverse_quadric(double r, double c = 1) { return 1.0 / (1 + pow(r*c, 2)); } 18 | inline double rbf_inverse_multiquadric(double r, double c = 1) { return 1.0 / sqrt(1 + pow(r*c, 2)); } 19 | inline double rbf_polyharmonic_spline(double r, double c = 1) { return pow(r, 2 * c - 1); } 20 | inline double rbf_sigmoid(double r, double c = 1) { return 1.0 / (1 + exp(-r*c)); } 21 | 22 | inline chrono::steady_clock::time_point getMeasureTime() { return chrono::steady_clock::now(); } 23 | 24 | template 25 | inline string formatString(const char* format, Args... args) 26 | { 27 | int nsize = snprintf(NULL, 0, format, args...) + 1; 28 | char* buffer = new char[nsize]; 29 | snprintf(buffer, nsize, format, args...); 30 | string s(buffer); 31 | delete[] buffer; 32 | return s; 33 | } 34 | 35 | inline void measureTime(const string& name, const chrono::steady_clock::time_point& startTime) 36 | { 37 | const auto count = chrono::duration_cast(chrono::steady_clock::now() - startTime).count(); 38 | MGlobal::displayInfo(MString(formatString("%s: %.2fms", name.c_str(), count / 1000.0).c_str())); 39 | } 40 | 41 | inline MVector maxis(const MMatrix& mat, unsigned int index) { return MVector(mat[index][0], mat[index][1], mat[index][2]); } 42 | inline MVector xaxis(const MMatrix& mat) { return maxis(mat, 0); } 43 | inline MVector yaxis(const MMatrix& mat) { return maxis(mat, 1); } 44 | inline MVector zaxis(const MMatrix& mat) { return maxis(mat, 2); } 45 | inline MVector taxis(const MMatrix& mat) { return maxis(mat, 3); } 46 | 47 | inline MMatrix& set_maxis(MMatrix& mat, unsigned int a, const MVector& v) 48 | { 49 | mat[a][0] = v.x; 50 | mat[a][1] = v.y; 51 | mat[a][2] = v.z; 52 | return mat; 53 | } 54 | 55 | inline MVector mscale(const MMatrix& mat) 56 | { 57 | return MVector(xaxis(mat).length(), yaxis(mat).length(), zaxis(mat).length()); 58 | } 59 | 60 | inline MMatrix& set_mscale(MMatrix& mat, const MVector& scale) 61 | { 62 | set_maxis(mat, 0, maxis(mat, 0).normal() * scale.x); 63 | set_maxis(mat, 1, maxis(mat, 1).normal() * scale.y); 64 | set_maxis(mat, 2, maxis(mat, 2).normal() * scale.z); 65 | return mat; 66 | } 67 | 68 | inline MQuaternion mat2quat(const MMatrix& inputMat) 69 | { 70 | MMatrix mat(inputMat); 71 | set_mscale(mat, MVector(1,1,1)); // reset scale 72 | 73 | float tr, s; 74 | float q[4]; 75 | int i, j, k; 76 | MQuaternion quat; 77 | 78 | int nxt[3] = { 1, 2, 0 }; 79 | tr = mat[0][0] + mat[1][1] + mat[2][2]; 80 | 81 | // check the diagonal 82 | if (tr > 0.0) 83 | { 84 | s = sqrt(tr + float(1.0)); 85 | quat.w = s / float(2.0); 86 | s = float(0.5) / s; 87 | 88 | quat.x = (mat[1][2] - mat[2][1]) * s; 89 | quat.y = (mat[2][0] - mat[0][2]) * s; 90 | quat.z = (mat[0][1] - mat[1][0]) * s; 91 | } 92 | else 93 | { 94 | // diagonal is negative 95 | i = 0; 96 | if (mat[1][1] > mat[0][0]) 97 | i = 1; 98 | if (mat[2][2] > mat[i][i]) 99 | i = 2; 100 | 101 | j = nxt[i]; 102 | k = nxt[j]; 103 | s = sqrt((mat[i][i] - (mat[j][j] + mat[k][k])) + float(1.0)); 104 | 105 | q[i] = s * float(0.5); 106 | if (s != float(0.0)) 107 | s = float(0.5) / s; 108 | 109 | q[3] = (mat[j][k] - mat[k][j]) * s; 110 | q[j] = (mat[i][j] + mat[j][i]) * s; 111 | q[k] = (mat[i][k] + mat[k][i]) * s; 112 | 113 | quat.x = q[0]; 114 | quat.y = q[1]; 115 | quat.z = q[2]; 116 | quat.w = q[3]; 117 | } 118 | 119 | return quat; 120 | } 121 | 122 | inline MVector vectorMult(const MVector& v1, const MVector &v2) 123 | { 124 | return MVector(v1.x * v2.x, v1.y * v2.y, v1.z * v2.z); 125 | } 126 | 127 | inline MVector vectorLerp(const MVector& v1, const MVector& v2, double w) 128 | { 129 | return MVector( 130 | v1.x * (1 - w) + v2.x * w, 131 | v1.y * (1 - w) + v2.y * w, 132 | v1.z * (1 - w) + v2.z * w); 133 | } 134 | 135 | inline MMatrix blendMatrices(const MMatrix& m1, const MMatrix& m2, double w) 136 | { 137 | const MQuaternion q = slerp(mat2quat(m1), mat2quat(m2), w); 138 | MMatrix m = q.asMatrix(); 139 | set_maxis(m, 3, vectorLerp(taxis(m1), taxis(m2), w)); 140 | set_mscale(m, vectorLerp(mscale(m1), mscale(m2), w)); 141 | return m; 142 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /UE5Plugin/Skeleposer/Source/Skeleposer/Private/RigUnit_Skeleposer.cpp: -------------------------------------------------------------------------------- 1 | // Created by Alexander Zagoruyko. Published in 2023 2 | 3 | #include "RigUnit_Skeleposer.h" 4 | 5 | #include "Serialization/JsonReader.h" 6 | #include "Serialization/JsonSerializer.h" 7 | #include "Misc/FileHelper.h" 8 | #include "Units/RigUnitContext.h" 9 | 10 | #include UE_INLINE_GENERATED_CPP_BY_NAME(RigUnit_Skeleposer) 11 | 12 | #define LOCTEXT_NAMESPACE "Skeleposer" 13 | 14 | inline void DisplayError(const FString& s, const FColor &Color=FColor::Red) 15 | { 16 | #if WITH_EDITOR 17 | if (GEngine) 18 | GEngine->AddOnScreenDebugMessage(-1, 5.0, Color, *s); 19 | UE_LOG(LogTemp, Warning, TEXT("%s"), *s); 20 | #endif 21 | } 22 | 23 | inline FMatrix ConvertMatrixFromMayaToUE(const FMatrix& Mat) 24 | { 25 | FMatrix Out; 26 | Out.M[0][0] = Mat.M[0][0]; 27 | Out.M[0][1] = Mat.M[0][2]; 28 | Out.M[0][2] = Mat.M[0][1]; 29 | Out.M[0][3] = Mat.M[0][3]; 30 | 31 | Out.M[1][0] = Mat.M[2][0]; 32 | Out.M[1][1] = Mat.M[2][2]; 33 | Out.M[1][2] = Mat.M[2][1]; 34 | Out.M[1][3] = Mat.M[2][3]; 35 | 36 | Out.M[2][0] = Mat.M[1][0]; 37 | Out.M[2][1] = Mat.M[1][2]; 38 | Out.M[2][2] = Mat.M[1][1]; 39 | Out.M[2][3] = Mat.M[1][3]; 40 | 41 | Out.M[3][0] = Mat.M[3][0]; 42 | Out.M[3][1] = Mat.M[3][2]; 43 | Out.M[3][2] = Mat.M[3][1]; 44 | Out.M[3][3] = Mat.M[3][3]; 45 | return Out; 46 | } 47 | 48 | FString StripNamespace(const FString& Name) 49 | { 50 | // Find the last occurrence of ':' 51 | int32 LastColonIndex; 52 | if (Name.FindLastChar(':', LastColonIndex)) 53 | { 54 | // Extract the substring starting from the character after the last ':' 55 | return Name.Mid(LastColonIndex + 1); 56 | } 57 | return Name; 58 | } 59 | 60 | void GetPosesOrder(const TMap& Directories, int32 DirectoryIdx, TArray& PoseIndices) 61 | { 62 | const FDirectory* Found = Directories.Find(DirectoryIdx); 63 | if (Found) 64 | { 65 | for (auto& Idx : Found->ChildrenIndices) 66 | { 67 | if (Idx >= 0) 68 | PoseIndices.Add(Idx); 69 | else 70 | GetPosesOrder(Directories, -Idx, PoseIndices); 71 | } 72 | } 73 | } 74 | 75 | FRigUnit_Skeleposer_Execute() 76 | { 77 | DECLARE_SCOPE_HIERARCHICAL_COUNTER_RIGUNIT() 78 | URigHierarchy* Hierarchy = Context.Hierarchy; 79 | 80 | if (!Hierarchy) 81 | return; 82 | 83 | if (Context.State == EControlRigState::Init) 84 | { 85 | WorkData.Reset(); 86 | return; 87 | } 88 | 89 | else if (Context.State == EControlRigState::Update) 90 | { 91 | // Read JSON file only once unless the path is changed 92 | if (FilePath != WorkData.FilePathCache) 93 | { 94 | WorkData.FilePathCache = FilePath; // cache file path 95 | 96 | const FString FullFilePath = FPaths::Combine(FPaths::ProjectContentDir(), FilePath); 97 | if (!FPaths::FileExists(FullFilePath)) 98 | { 99 | DisplayError("Cannot find '" + FilePath + "'. FilePath must be relative to the project's content directory", FColor::Red); 100 | return; 101 | } 102 | 103 | WorkData.ReadJsonFile(FullFilePath, PoseNameList); 104 | } 105 | 106 | if (WorkData.BonePoses.IsEmpty()) 107 | { 108 | UE_CONTROLRIG_RIGUNIT_REPORT_WARNING(TEXT("No poses loaded from the file")); 109 | return; 110 | } 111 | 112 | TMap PosesWeights; // user poses' weights and weights that are calculated during the computation 113 | for (const auto &UserPose : UserPoses) 114 | PosesWeights.Add(UserPose.PoseName, UserPose.PoseWeight); 115 | 116 | for (auto& [BoneKey, BonePoses] : WorkData.BonePoses) // go through bones 117 | { 118 | if (!Hierarchy->Find(BoneKey)) 119 | continue; 120 | 121 | FTransform Local = Hierarchy->GetLocalTransform(BoneKey, false); 122 | 123 | FVector Translation = Local.GetTranslation(); 124 | FQuat Rotation = Local.GetRotation(); 125 | FVector Scale = Local.GetScale3D(); 126 | 127 | for (const auto& BonePose : BonePoses) 128 | { 129 | float PoseWeight = PosesWeights.FindOrAdd(BonePose.PoseName, 0); 130 | 131 | if (!BonePose.Corrects.IsEmpty()) // if corrects found 132 | { 133 | PoseWeight = FLT_MAX; // find lowest weight 134 | for (const FString& CorrectName : BonePose.Corrects) 135 | { 136 | const float w = PosesWeights.FindOrAdd(CorrectName, 0); 137 | if (w < PoseWeight) 138 | PoseWeight = w; 139 | } 140 | } 141 | 142 | if (!BonePose.Inbetween.Value.IsEmpty()) 143 | { 144 | const float SourcePoseWeight = PosesWeights.FindOrAdd(BonePose.Inbetween.Key, 0); 145 | PoseWeight = BonePose.Inbetween.Value.EvaluateFromX(SourcePoseWeight).Y; 146 | } 147 | 148 | PosesWeights[BonePose.PoseName] = PoseWeight; // save weight for the pose 149 | 150 | if (PoseWeight > 1e-3) 151 | { 152 | if (BonePose.BlendMode == FPoseBlendMode::ADDITIVE) 153 | { 154 | Translation += BonePose.Delta.GetTranslation() * PoseWeight; 155 | Rotation = FQuat::Slerp(Rotation, Rotation * BonePose.Delta.GetRotation(), PoseWeight); 156 | Scale = FMath::Lerp(Scale, BonePose.Delta.GetScale3D() * Scale, PoseWeight); 157 | } 158 | 159 | else if (BonePose.BlendMode == FPoseBlendMode::REPLACE) 160 | { 161 | Translation = FMath::Lerp(Translation, BonePose.Delta.GetTranslation(), PoseWeight); 162 | Rotation = FQuat::Slerp(Rotation, BonePose.Delta.GetRotation(), PoseWeight); 163 | Scale = FMath::Lerp(Scale, BonePose.Delta.GetScale3D(), PoseWeight); 164 | } 165 | } 166 | } 167 | 168 | Local.SetTranslation(Translation); 169 | Local.SetRotation(Rotation); 170 | Local.SetScale3D(Scale); 171 | 172 | Hierarchy->SetLocalTransform(BoneKey, Local); 173 | } 174 | 175 | // activate poses' morph targets 176 | if (bUseMorphTargets) 177 | { 178 | for (const auto& [PoseName, PoseWeight] : PosesWeights) 179 | { 180 | FRigElementKey CurveKey(FName(PoseName), ERigElementType::Curve); 181 | 182 | if (Hierarchy->Find(CurveKey)) 183 | Hierarchy->SetCurveValue(CurveKey, PoseWeight); 184 | } 185 | } 186 | } 187 | } 188 | 189 | void FRigUnit_Skeleposer_WorkData::ReadJsonFile(const FString& FilePath, TArray& PoseNameList) 190 | { 191 | BonePoses.Reset(); 192 | PoseNameList.Reset(); 193 | 194 | const FMatrix RootMatrix = FQuat(-0.707106769, 0, 0, 0.707106709).ToMatrix(); // Q = FQuat::MakeFromEuler(FVector(90, 0, 0)); 195 | const FMatrix RootMatrixInverse = FQuat(0.707106769, 0, 0, 0.707106709).ToMatrix(); // Q.Inverse 196 | 197 | FString JsonContent; 198 | FFileHelper::LoadFileToString(JsonContent, *FilePath); 199 | 200 | TSharedPtr JsonObject; 201 | auto Reader = TJsonReaderFactory<>::Create(JsonContent); 202 | if (FJsonSerializer::Deserialize(Reader, JsonObject)) 203 | { 204 | // get joints and indices used in poseDeltaMatrices 205 | const TSharedPtr JointsObject = JsonObject->Values["joints"]->AsObject(); 206 | 207 | TMap Bones; // per index 208 | for (const auto& [JointIdxStr, JointValue] : JointsObject->Values) 209 | { 210 | const int32 Idx = FCString::Atoi(*JointIdxStr); 211 | 212 | const FString BoneName = StripNamespace(JointValue->AsString()); // UE remove namespace for bones automatically 213 | 214 | const FRigElementKey BoneKey(FName(*BoneName), ERigElementType::Bone); 215 | Bones.Add(Idx, BoneKey); 216 | BonePoses.Add(BoneKey); 217 | } 218 | 219 | // get directories 220 | TMap Directories; 221 | 222 | const TSharedPtr DirectoriesObject = JsonObject->Values["directories"]->AsObject(); 223 | 224 | for (const auto& [DirIdxStr, DirObject] : DirectoriesObject->Values) 225 | { 226 | const int32 Idx = FCString::Atoi(*DirIdxStr); 227 | const auto& DirValue = DirObject->AsObject()->Values; 228 | 229 | FDirectory& Directory = Directories.Add(Idx); 230 | Directory.ParentIndex = DirValue["directoryParentIndex"]->AsNumber(); 231 | 232 | for (const auto& ChildIndex : DirValue["directoryChildrenIndices"]->AsArray()) 233 | Directory.ChildrenIndices.Add(ChildIndex->AsNumber()); 234 | } 235 | 236 | // get poses 237 | const TSharedPtr PosesObject = JsonObject->Values["poses"]->AsObject(); 238 | 239 | TMap Poses; // pose per index 240 | for (const auto& [PoseName, PoseObject] : PosesObject->Values) 241 | { 242 | const int32 PoseIdx = FCString::Atoi(*PoseName); 243 | const auto& PoseValue = PoseObject->AsObject()->Values; 244 | 245 | FPose& Pose = Poses.FindOrAdd(PoseIdx); 246 | 247 | Pose.Name = PoseValue["poseName"]->AsString(); 248 | Pose.BlendMode = (FPoseBlendMode)PoseValue["poseBlendMode"]->AsNumber(); 249 | 250 | PoseNameList.Add(Pose.Name); 251 | 252 | if (PoseValue.Contains("corrects")) 253 | { 254 | for (const auto& CorrectValue : PoseValue["corrects"]->AsArray()) 255 | Pose.Corrects.Add(CorrectValue->AsNumber()); 256 | } 257 | 258 | if (PoseValue.Contains("inbetween")) // idx, [(x,y), (x,y)] 259 | { 260 | const TArray> &InbetweenArray = PoseValue["inbetween"]->AsArray(); 261 | 262 | Pose.Inbetween.Key = InbetweenArray[0]->AsNumber(); // source pose index 263 | 264 | for (const auto& PointItem : InbetweenArray[1]->AsArray()) 265 | { 266 | const TArray>& PointArray = PointItem->AsArray(); 267 | const double X = PointArray[0]->AsNumber(); 268 | const double Y = PointArray[1]->AsNumber(); 269 | Pose.Inbetween.Value.Points.Add(FVector2D(X,Y)); 270 | } 271 | 272 | // pin both ends 273 | FVector2D FirstPoint = Pose.Inbetween.Value.Points[0]; 274 | FVector2D LastPoint = Pose.Inbetween.Value.Points.Top(); 275 | FirstPoint.X = 0; 276 | LastPoint.X = 1; 277 | 278 | Pose.Inbetween.Value.Points.Insert(FirstPoint, 0); 279 | Pose.Inbetween.Value.Points.Add(LastPoint); 280 | } 281 | 282 | for (const auto& [DeltaMatrixIdxStr, DeltaMatrixArrayValue] : PoseValue["poseDeltaMatrices"]->AsObject()->Values) 283 | { 284 | const int32 BoneIdx = FCString::Atoi(*DeltaMatrixIdxStr); 285 | 286 | FMatrix Mat; 287 | auto& DeltaMatrixValue = DeltaMatrixArrayValue->AsArray(); 288 | for (int32 i = 0; i < 4; i++) 289 | for (int32 j = 0; j < 4; j++) 290 | Mat.M[i][j] = DeltaMatrixValue[i * 4 + j]->AsNumber(); 291 | 292 | // convert axes from Maya to UE 293 | FTransform PoseDelta(RootMatrix * ConvertMatrixFromMayaToUE(Mat) * RootMatrixInverse); 294 | 295 | // revert scale axes 296 | FMatrix NoScaleMatrix = PoseDelta.ToMatrixNoScale(); 297 | FVector Scale = PoseDelta.GetScale3D(); 298 | PoseDelta.SetFromMatrix(NoScaleMatrix); 299 | PoseDelta.SetScale3D(FVector(Scale.X, Scale.Z, Scale.Y)); 300 | 301 | Pose.Deltas.Add(BoneIdx, PoseDelta); 302 | } 303 | } 304 | 305 | // get all poses indices in a correct order 306 | TArray PoseIndices; 307 | GetPosesOrder(Directories, 0, PoseIndices); 308 | 309 | // get pose per bone list, like bone1:[pose1, pose2, pose3], bone2:[pose1, pose4] 310 | for (int32 PoseIdx : PoseIndices) 311 | { 312 | const FPose& Pose = Poses[PoseIdx]; 313 | 314 | for (const auto& [DeltaIndex, DeltaTransform] : Pose.Deltas) 315 | { 316 | FBonePose BonePose; 317 | BonePose.PoseName = Pose.Name; 318 | BonePose.BlendMode = Pose.BlendMode; 319 | BonePose.Delta = DeltaTransform; 320 | 321 | for (int32 CorrectIdx : Pose.Corrects) 322 | BonePose.Corrects.Add(Poses[CorrectIdx].Name); 323 | 324 | if (!Pose.Inbetween.Value.IsEmpty()) 325 | { 326 | BonePose.Inbetween.Key = Poses[Pose.Inbetween.Key].Name; 327 | BonePose.Inbetween.Value = Pose.Inbetween.Value; 328 | } 329 | 330 | const FRigElementKey *BoneKey = Bones.Find(DeltaIndex); 331 | if (BoneKey) 332 | BonePoses[*BoneKey].Add(BonePose); 333 | else 334 | DisplayError("Cannot find bone at index "+FString::FromInt(DeltaIndex), FColor::Red); 335 | } 336 | } 337 | } 338 | } 339 | 340 | FRigUnit_Skeleposer::FRigUnit_Skeleposer() 341 | { 342 | bUseMorphTargets = false; 343 | } 344 | 345 | FRigVMStructUpgradeInfo FRigUnit_Skeleposer::GetUpgradeInfo() const 346 | { 347 | return FRigVMStructUpgradeInfo(); 348 | } 349 | 350 | #undef LOCTEXT_NAMESPACE -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/utils.py: -------------------------------------------------------------------------------- 1 | import maya.api.OpenMaya as om 2 | import maya.cmds as cmds 3 | import pymel.core as pm 4 | 5 | NamingScheme = { 6 | "LeftStart": {"L_": "R_", "l_": "r_", "Left":"Right", "left_": "right_"}, 7 | "LeftEnd": {"_L": "_R", "_l": "_r", "Left": "Right", "_left":"_right"}, 8 | "RightStart": {"R_": "L_", "r_": "l_", "Right":"Left", "right_":"left_"}, 9 | "RightEnd": {"_R": "_L", "_r": "_l", "Right":"Left", "_right":"_left"}, 10 | "LeftMiddle": {"Left":"Right", "_L_":"_R_", "_l_":"_r_"}, 11 | "RightMiddle": {"Right":"Left", "_R_":"_L_", "_r_":"_l_"}, 12 | } 13 | 14 | def findSymmetricName(name: str, left: bool = True, right: bool = True) -> str: 15 | """Find the symmetric equivalent of a given name based on naming conventions.""" 16 | leftData = (left, NamingScheme["LeftStart"], NamingScheme["LeftMiddle"], NamingScheme["LeftEnd"]) 17 | rightData = (right, NamingScheme["RightStart"], NamingScheme["RightMiddle"], NamingScheme["RightEnd"]) 18 | 19 | for enable, starts, mids, ends in [leftData, rightData]: 20 | if enable: 21 | for s in starts: 22 | if name.startswith(s): 23 | return starts[s] + name[len(s):] 24 | 25 | for s in ends: 26 | if name.endswith(s): 27 | return name[:-len(s)] + ends[s] 28 | 29 | for s in mids: 30 | if s in name: 31 | idx = name.index(s) 32 | return name[:idx] + mids[s] + name[idx+len(s):] 33 | 34 | def isLeftSide(name: str) -> bool: 35 | """Check if a name belongs to the left side based on naming conventions.""" 36 | for s in NamingScheme["LeftStart"]: 37 | if name.startswith(s): 38 | return True 39 | 40 | for s in NamingScheme["LeftEnd"]: 41 | if name.endswith(s): 42 | return True 43 | 44 | for s in NamingScheme["LeftMiddle"]: 45 | if s in name: 46 | return True 47 | 48 | def isRightSide(name: str) -> bool: 49 | """Check if a name belongs to the right side based on naming conventions.""" 50 | for s in NamingScheme["RightStart"]: 51 | if name.startswith(s): 52 | return True 53 | 54 | for s in NamingScheme["RightEnd"]: 55 | if name.endswith(s): 56 | return True 57 | 58 | for s in NamingScheme["RightMiddle"]: 59 | if s in name: 60 | return True 61 | 62 | def undoBlock(f): 63 | """Decorator that wraps a function in Maya's undo block for atomic undo operations.""" 64 | def inner(*args,**kwargs): 65 | pm.undoInfo(ock=True, cn=f.__name__) 66 | try: 67 | out = f(*args, **kwargs) 68 | finally: 69 | pm.undoInfo(cck=True) 70 | return out 71 | return inner 72 | 73 | def clamp(v: float, mn: float = 0.0, mx: float = 1.0) -> float: 74 | """Clamp a value between minimum and maximum bounds.""" 75 | if v > mx: 76 | return mx 77 | elif v < mn: 78 | return mn 79 | return v 80 | 81 | def shortenValue(v: float, epsilon: float = 1e-5) -> float: 82 | """Round a value to the nearest integer if within epsilon tolerance.""" 83 | roundedValue = round(v) 84 | return roundedValue if abs(v - roundedValue) < epsilon else v 85 | 86 | def maxis(m: om.MMatrix, a: int) -> om.MVector: 87 | """Extract axis vector from a 4x4 matrix (0=X, 1=Y, 2=Z, 3=Translation).""" 88 | return om.MVector(m[a*4+0], m[a*4+1], m[a*4+2]) 89 | 90 | def set_maxis(m: om.MMatrix, a: int, v: om.MVector): 91 | """Set axis vector in a 4x4 matrix (0=X, 1=Y, 2=Z, 3=Translation).""" 92 | m[a*4+0] = v[0] 93 | m[a*4+1] = v[1] 94 | m[a*4+2] = v[2] 95 | 96 | def mscale(m: om.MMatrix) -> om.MVector: 97 | """Extract scale values from a transformation matrix.""" 98 | return om.MVector(maxis(m,0).length(), maxis(m,1).length(), maxis(m,2).length()) 99 | 100 | def set_mscale(m: om.MMatrix, s: om.MVector): 101 | """Apply scale values to a transformation matrix preserving rotation.""" 102 | set_maxis(m, 0, maxis(m, 0).normal()*s[0]) 103 | set_maxis(m, 1, maxis(m, 1).normal()*s[1]) 104 | set_maxis(m, 2, maxis(m, 2).normal()*s[2]) 105 | 106 | def mscaled(m: om.MMatrix, s: om.MVector = om.MVector(1,1,1)) -> om.MMatrix: 107 | """Return a copy of the matrix with modified scale values.""" 108 | m = om.MMatrix(m) 109 | set_maxis(m, 0, maxis(m, 0).normal()*s[0]) 110 | set_maxis(m, 1, maxis(m, 1).normal()*s[1]) 111 | set_maxis(m, 2, maxis(m, 2).normal()*s[2]) 112 | return m 113 | 114 | def slerp(q1: tuple, q2: tuple, w: float) -> om.MQuaternion: 115 | """Perform spherical linear interpolation between two quaternions.""" 116 | q1 = om.MQuaternion(q1[0], q1[1], q1[2], q1[3]) 117 | q2 = om.MQuaternion(q2[0], q2[1], q2[2], q2[3]) 118 | return om.MQuaternion.slerp(q1, q2, w) 119 | 120 | def blendMatrices(m1: om.MMatrix, m2: om.MMatrix, w: float) -> om.MMatrix: 121 | """Blend two transformation matrices using linear and spherical interpolation.""" 122 | m1 = om.MMatrix(m1) 123 | m2 = om.MMatrix(m2) 124 | 125 | q1 = om.MTransformationMatrix(mscaled(m1)).rotation().asQuaternion() 126 | q2 = om.MTransformationMatrix(mscaled(m2)).rotation().asQuaternion() 127 | 128 | s = mscale(m1) * (1-w) + mscale(m2) * w 129 | m = om.MMatrix(mscaled(slerp(q1, q2, w).asMatrix(), s)) 130 | 131 | set_maxis(m, 3, maxis(m1, 3)*(1-w) + maxis(m2, 3)*w) 132 | return m 133 | 134 | def getLocalMatrix(joint: pm.PyNode) -> om.MMatrix: 135 | """Get joint's local transformation matrix including joint orient.""" 136 | q = om.MQuaternion(joint.getRotation().asQuaternion()) 137 | 138 | if cmds.objectType(str(joint)) == "joint": 139 | q *= om.MQuaternion(joint.getOrientation()) 140 | 141 | t = cmds.getAttr(joint+".t")[0] 142 | s = cmds.getAttr(joint+".s")[0] 143 | 144 | qm = q.asMatrix() 145 | 146 | sm = om.MMatrix() 147 | sm[0] = s[0] 148 | sm[5] = s[1] 149 | sm[10] = s[2] 150 | 151 | m = sm * qm 152 | m[12] = t[0] 153 | m[13] = t[1] 154 | m[14] = t[2] 155 | 156 | return om.MMatrix(m) 157 | 158 | def getDelta(m: om.MMatrix, bm: om.MMatrix) -> om.MMatrix: 159 | """Calculate delta transformation between pose matrix and base matrix.""" 160 | m = om.MMatrix(m) 161 | bm = om.MMatrix(bm) 162 | 163 | s = mscale(m) 164 | bs = mscale(bm) 165 | 166 | d = m * bm.inverse() 167 | 168 | # translation is simple as well as scale 169 | d[12] = m[12]-bm[12] 170 | d[13] = m[13]-bm[13] 171 | d[14] = m[14]-bm[14] 172 | 173 | sx = s[0]/bs[0] 174 | sy = s[1]/bs[1] 175 | sz = s[2]/bs[2] 176 | set_mscale(d, [sx,sy,sz]) 177 | return d 178 | 179 | def applyDelta(dm: om.MMatrix, bm: om.MMatrix) -> om.MMatrix: 180 | """Apply delta transformation to a base matrix.""" 181 | dm = om.MMatrix(dm) 182 | bm = om.MMatrix(bm) 183 | 184 | ds = mscale(dm) 185 | bms = mscale(bm) 186 | 187 | m = dm * bm # get rotation matrix 188 | 189 | # translation is simple as well as scale 190 | m[12] = bm[12]+dm[12] 191 | m[13] = bm[13]+dm[13] 192 | m[14] = bm[14]+dm[14] 193 | 194 | sx = ds[0]*bms[0] 195 | sy = ds[1]*bms[1] 196 | sz = ds[2]*bms[2] 197 | 198 | set_mscale(m, [sx, sy, sz]) 199 | return m 200 | 201 | def symmat(m: om.MMatrix) -> om.MMatrix: 202 | """Create a symmetric matrix by flipping the X axis.""" 203 | out = om.MMatrix(m) 204 | out[0] *= -1 205 | out[4] *= -1 206 | out[8] *= -1 207 | out[12] *= -1 208 | return out 209 | 210 | def parentConstraintMatrix(destBase: om.MMatrix, srcBase: om.MMatrix, src: om.MMatrix) -> om.MMatrix: 211 | """Calculate resulting matrix as in Maya's parentConstraint transformation.""" 212 | return destBase * srcBase.inverse() * src 213 | 214 | def mirrorMatrix(base: om.MMatrix, srcBase: om.MMatrix, src: om.MMatrix) -> om.MMatrix: 215 | """Mirror a transformation matrix across the YZ plane.""" 216 | return parentConstraintMatrix(base, symmat(srcBase), symmat(src)) 217 | 218 | def mirrorMatrixByDelta(srcBase: om.MMatrix, src: om.MMatrix, destBase: om.MMatrix) -> om.MMatrix: 219 | """Mirror matrix using delta transformation between source and destination.""" 220 | mirroredSrcBase = mirrorMatrix(om.MMatrix(), om.MMatrix(), srcBase) 221 | mirroredSrc = mirrorMatrix(om.MMatrix(), om.MMatrix(), src) 222 | 223 | # set translation the same, used for rotation 224 | dt = maxis(mirroredSrcBase,3) - maxis(destBase,3) 225 | set_maxis(mirroredSrc, 3, maxis(mirroredSrc, 3) - dt) 226 | set_maxis(mirroredSrcBase, 3, maxis(mirroredSrcBase, 3) - dt) 227 | 228 | return parentConstraintMatrix(destBase, mirroredSrcBase, mirroredSrc) 229 | 230 | def dagPose_findIndex(dagPose: pm.PyNode, j: pm.PyNode) -> int: 231 | """Find the index of a joint in a dagPose node.""" 232 | for m in dagPose.members: 233 | inputs = m.inputs(sh=True) 234 | if inputs and inputs[0] == j: 235 | return m.index() 236 | 237 | def dagPose_getWorldMatrix(dagPose: pm.PyNode, j: pm.PyNode) -> om.MMatrix: 238 | """Get world matrix of a joint from a dagPose node.""" 239 | idx = dagPose_findIndex(dagPose, j) 240 | if idx is not None: 241 | return om.MMatrix(dagPose.worldMatrix[idx].get()) 242 | 243 | def dagPose_getParentMatrix(dagPose: pm.PyNode, j: pm.PyNode) -> om.MMatrix: 244 | """Get parent world matrix of a joint from a dagPose node.""" 245 | idx = dagPose_findIndex(dagPose, j) 246 | if idx is not None: 247 | parent = dagPose.parents[idx].inputs(p=True, sh=True) 248 | if parent and parent[0] != dagPose.world: 249 | return om.MMatrix(dagPose.worldMatrix[parent[0].index()].get()) 250 | return om.MMatrix() 251 | 252 | def getRemapInputPlug(remap: pm.PyNode) -> pm.Attribute: 253 | """Get the actual input plug connected to a remapValue node.""" 254 | inputs = remap.inputValue.inputs(p=True) 255 | if inputs: 256 | inputPlug = inputs[0] 257 | if pm.objectType(inputPlug.node()) == "unitConversion": 258 | inputs = inputPlug.node().input.inputs(p=True) 259 | if inputs: 260 | return inputs[0] 261 | else: 262 | return inputPlug 263 | 264 | def getRemapActualWeightInput(plug: pm.Attribute) -> pm.Attribute: 265 | """Get the actual weight input plug bypassing remapValue and unitConversion nodes.""" 266 | inputs = plug.inputs(p=True) 267 | if inputs: 268 | inputPlug = inputs[0] 269 | if pm.objectType(inputPlug.node()) == "remapValue": 270 | return getRemapInputPlug(inputPlug.node()) 271 | 272 | elif pm.objectType(inputPlug.node()) == "unitConversion": 273 | inputs = inputPlug.node().input.inputs(p=True) 274 | if inputs: 275 | return inputs[0] 276 | 277 | else: 278 | return inputPlug 279 | 280 | def clearUnusedRemapValue(): 281 | """Delete all remapValue nodes that have no output connections.""" 282 | pm.delete([n for n in pm.ls(type="remapValue") if not n.outValue.isConnected() and not n.outColor.isConnected()]) 283 | 284 | def getBlendShapeTargetDelta(blendShape: pm.PyNode, targetIndex: int) -> tuple: 285 | """Get vertex indices and delta positions for a blend shape target.""" 286 | targetDeltas = blendShape.inputTarget[0].inputTargetGroup[targetIndex].inputTargetItem[6000].inputPointsTarget.get() 287 | targetComponentsPlug = blendShape.inputTarget[0].inputTargetGroup[targetIndex].inputTargetItem[6000].inputComponentsTarget.__apimplug__() 288 | 289 | targetIndices = [] 290 | componentList = pm.api.MFnComponentListData(targetComponentsPlug.asMObject()) 291 | for i in range(componentList.length()): 292 | compTargetIndices = pm.api.MIntArray() 293 | singleIndexFn = pm.api.MFnSingleIndexedComponent(componentList[i]) 294 | singleIndexFn.getElements(compTargetIndices) 295 | targetIndices += compTargetIndices 296 | 297 | return targetIndices, targetDeltas 298 | 299 | def matchJoint(j: pm.PyNode, name: str = None) -> pm.PyNode: 300 | """Create a new joint matching the world transformation of an existing joint.""" 301 | newj = pm.createNode("joint", n=name or j.name()) 302 | pm.xform(newj, ws=True, m=pm.xform(j, q=True, ws=True, m=True)) 303 | newj.setOrientation(newj.getOrientation()*newj.getRotation().asQuaternion()) # freeze 304 | newj.setRotation([0,0,0]) 305 | return newj 306 | 307 | def transferSkin(src: pm.PyNode, dest: pm.PyNode): 308 | """Transfer skin cluster connections from source joint to destination joint.""" 309 | for p in src.wm.outputs(p=True, type="skinCluster"): 310 | dest.wm >> p 311 | 312 | if not dest.hasAttr("lockInfluenceWeights"): 313 | dest.addAttr("lockInfluenceWeights", at="bool", dv=False) 314 | 315 | dest.lockInfluenceWeights >> p.node().lockWeights[p.index()] 316 | #p.node().bindPreMatrix[p.index()].set(dest.wim.get()) -------------------------------------------------------------------------------- /source/stickyMatrix.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | #include 11 | 12 | #include "stickyMatrix.h" 13 | #include "utils.hpp" 14 | 15 | using namespace std; 16 | 17 | MTypeId StickyMatrix::typeId(1274442); 18 | 19 | MObject StickyMatrix::attr_parent1; 20 | MObject StickyMatrix::attr_parent1WithoutScale; 21 | MObject StickyMatrix::attr_parent2; 22 | MObject StickyMatrix::attr_parent2WithoutScale; 23 | MObject StickyMatrix::attr_translate1; 24 | MObject StickyMatrix::attr_rotate1X; 25 | MObject StickyMatrix::attr_rotate1Y; 26 | MObject StickyMatrix::attr_rotate1Z; 27 | MObject StickyMatrix::attr_rotate1; 28 | MObject StickyMatrix::attr_scale1; 29 | MObject StickyMatrix::attr_jointOrient1X; 30 | MObject StickyMatrix::attr_jointOrient1Y; 31 | MObject StickyMatrix::attr_jointOrient1Z; 32 | MObject StickyMatrix::attr_jointOrient1; 33 | MObject StickyMatrix::attr_offset1; 34 | 35 | MObject StickyMatrix::attr_translate2; 36 | MObject StickyMatrix::attr_rotate2X; 37 | MObject StickyMatrix::attr_rotate2Y; 38 | MObject StickyMatrix::attr_rotate2Z; 39 | MObject StickyMatrix::attr_rotate2; 40 | MObject StickyMatrix::attr_scale2; 41 | MObject StickyMatrix::attr_jointOrient2X; 42 | MObject StickyMatrix::attr_jointOrient2Y; 43 | MObject StickyMatrix::attr_jointOrient2Z; 44 | MObject StickyMatrix::attr_jointOrient2; 45 | MObject StickyMatrix::attr_offset2; 46 | 47 | MObject StickyMatrix::attr_sticky; 48 | MObject StickyMatrix::attr_blendAt; 49 | MObject StickyMatrix::attr_spherical; 50 | MObject StickyMatrix::attr_sphereCenter; 51 | MObject StickyMatrix::attr_sphericalLengthFactor; 52 | 53 | MObject StickyMatrix::attr_outMatrix1; 54 | MObject StickyMatrix::attr_outMatrix2; 55 | 56 | MStatus StickyMatrix::compute(const MPlug &plug, MDataBlock &dataBlock) 57 | { 58 | if (plug != attr_outMatrix1 && plug != attr_outMatrix2) 59 | return MS::kUnknownParameter; 60 | 61 | const bool parent1WithoutScale = dataBlock.inputValue(attr_parent1WithoutScale).asBool(); 62 | const bool parent2WithoutScale = dataBlock.inputValue(attr_parent2WithoutScale).asBool(); 63 | 64 | MMatrix parent1 = dataBlock.inputValue(attr_parent1).asMatrix(); 65 | MMatrix parent2 = dataBlock.inputValue(attr_parent2).asMatrix(); 66 | 67 | if (parent1WithoutScale) 68 | set_mscale(parent1, MVector(1, 1, 1)); 69 | 70 | if (parent2WithoutScale) 71 | set_mscale(parent2, MVector(1, 1, 1)); 72 | 73 | const MVector translate1 = dataBlock.inputValue(attr_translate1).asVector(); 74 | const MVector translate2 = dataBlock.inputValue(attr_translate2).asVector(); 75 | const MVector rotate1 = dataBlock.inputValue(attr_rotate1).asVector(); 76 | const MVector rotate2 = dataBlock.inputValue(attr_rotate2).asVector(); 77 | const MVector scale1 = dataBlock.inputValue(attr_scale1).asVector(); 78 | const MVector scale2 = dataBlock.inputValue(attr_scale2).asVector(); 79 | const MVector jointOrient1 = dataBlock.inputValue(attr_jointOrient1).asVector(); 80 | const MVector jointOrient2 = dataBlock.inputValue(attr_jointOrient2).asVector(); 81 | const MMatrix offset1 = dataBlock.inputValue(attr_offset1).asMatrix(); 82 | const MMatrix offset2 = dataBlock.inputValue(attr_offset2).asMatrix(); 83 | 84 | const float blendAt = dataBlock.inputValue(attr_blendAt).asFloat(); 85 | const float sticky = dataBlock.inputValue(attr_sticky).asFloat(); 86 | 87 | const float spherical = dataBlock.inputValue(attr_spherical).asFloat(); 88 | const MVector sphereCenter = dataBlock.inputValue(attr_sphereCenter).asVector(); 89 | const float sphericalLengthFactor = dataBlock.inputValue(attr_sphericalLengthFactor).asFloat(); 90 | 91 | const MMatrix joMat1(MEulerRotation(jointOrient1).asMatrix()); 92 | const MMatrix joMat2(MEulerRotation(jointOrient2).asMatrix()); 93 | 94 | MMatrix inputMatrix1(MEulerRotation(rotate1).asMatrix() * joMat1); 95 | set_maxis(inputMatrix1, 3, translate1); 96 | set_mscale(inputMatrix1, scale1); 97 | 98 | MMatrix inputMatrix2(MEulerRotation(rotate2).asMatrix() * joMat2); 99 | set_maxis(inputMatrix2, 3, translate2); 100 | set_mscale(inputMatrix2, scale2); 101 | 102 | const MMatrix inputMatrixWorld1 = inputMatrix1 * parent1; 103 | const MMatrix inputMatrixWorld2 = inputMatrix2 * parent2; 104 | 105 | const MMatrix avg = blendMatrices(inputMatrixWorld1, inputMatrixWorld2, blendAt); 106 | 107 | MMatrix avgBlend1 = blendMatrices(inputMatrixWorld1, offset1*avg, sticky); 108 | MMatrix avgBlend2 = blendMatrices(inputMatrixWorld2, offset2*avg, sticky); 109 | 110 | /* 111 | tm = loc_m * j_pm.inverse() 112 | rm = loc_m * (jo * j_pm).inverse() 113 | 114 | m = pm.dt.Matrix(rm) 115 | m.a30 = tm.a30 116 | m.a31 = tm.a31 117 | m.a32 = tm.a32 118 | */ 119 | 120 | // spherical 121 | const double d1 = (taxis(inputMatrixWorld1) - sphereCenter).length() * sphericalLengthFactor; 122 | const double d2 = (taxis(inputMatrixWorld2) - sphereCenter).length() * sphericalLengthFactor; 123 | 124 | const MPoint sp1 = sphereCenter + (taxis(avgBlend1) - sphereCenter).normal() * d1; 125 | const MPoint sp2 = sphereCenter + (taxis(avgBlend2) - sphereCenter).normal() * d2; 126 | 127 | const MVector p1 = taxis(avgBlend1) * (1 - spherical * sticky) + sp1 * spherical * sticky; 128 | const MVector p2 = taxis(avgBlend2) * (1 - spherical * sticky) + sp2 * spherical * sticky; 129 | 130 | set_maxis(avgBlend1, 3, p1); 131 | set_maxis(avgBlend2, 3, p2); 132 | 133 | // output 134 | const MMatrix tm1 = avgBlend1 * parent1.inverse(); 135 | const MMatrix tm2 = avgBlend2 * parent2.inverse(); 136 | 137 | const MMatrix rm1 = avgBlend1 * (joMat1 * parent1).inverse(); 138 | const MMatrix rm2 = avgBlend2 * (joMat2 * parent2).inverse(); 139 | 140 | MMatrix m1(rm1); 141 | MMatrix m2(rm2); 142 | set_maxis(m1, 3, taxis(tm1)); 143 | set_maxis(m2, 3, taxis(tm2)); 144 | 145 | dataBlock.outputValue(attr_outMatrix1).setMMatrix(m1); 146 | dataBlock.outputValue(attr_outMatrix2).setMMatrix(m2); 147 | 148 | dataBlock.setClean(attr_outMatrix1); 149 | dataBlock.setClean(attr_outMatrix2); 150 | 151 | return MS::kSuccess; 152 | } 153 | 154 | MStatus StickyMatrix::initialize() 155 | { 156 | MFnNumericAttribute nAttr; 157 | MFnMatrixAttribute mAttr; 158 | MFnUnitAttribute uAttr; 159 | 160 | attr_parent1 = mAttr.create("parent1", "p1"); 161 | mAttr.setHidden(true); 162 | addAttribute(attr_parent1); 163 | 164 | attr_parent1WithoutScale = nAttr.create("parent1WithoutScale", "p1wos", MFnNumericData::kBoolean, false); 165 | addAttribute(attr_parent1WithoutScale); 166 | 167 | attr_translate1 = nAttr.create("translate1", "t1", MFnNumericData::k3Double); 168 | nAttr.setKeyable(true); 169 | addAttribute(attr_translate1); 170 | 171 | attr_rotate1X = uAttr.create("rotate1X", "r1x", MFnUnitAttribute::kAngle); 172 | attr_rotate1Y = uAttr.create("rotate1Y", "r1y", MFnUnitAttribute::kAngle); 173 | attr_rotate1Z = uAttr.create("rotate1Z", "r1z", MFnUnitAttribute::kAngle); 174 | 175 | attr_rotate1 = nAttr.create("rotate1", "r1", attr_rotate1X, attr_rotate1Y, attr_rotate1Z); 176 | nAttr.setKeyable(true); 177 | addAttribute(attr_rotate1); 178 | 179 | attr_scale1 = nAttr.create("scale1", "s1", MFnNumericData::k3Double, 1); 180 | nAttr.setKeyable(true); 181 | addAttribute(attr_scale1); 182 | 183 | attr_jointOrient1X = uAttr.create("jointOrient1X", "jo1x", MFnUnitAttribute::kAngle); 184 | attr_jointOrient1Y = uAttr.create("jointOrient1Y", "jo1y", MFnUnitAttribute::kAngle); 185 | attr_jointOrient1Z = uAttr.create("jointOrient1Z", "jo1z", MFnUnitAttribute::kAngle); 186 | 187 | attr_jointOrient1 = nAttr.create("jointOrient1", "jo1", attr_jointOrient1X, attr_jointOrient1Y, attr_jointOrient1Z); 188 | nAttr.setKeyable(true); 189 | addAttribute(attr_jointOrient1); 190 | 191 | attr_offset1 = mAttr.create("offset1", "o1"); 192 | mAttr.setHidden(true); 193 | addAttribute(attr_offset1); 194 | 195 | 196 | attr_parent2 = mAttr.create("parent2", "p2"); 197 | mAttr.setHidden(true); 198 | addAttribute(attr_parent2); 199 | 200 | attr_parent2WithoutScale = nAttr.create("parent2WithoutScale", "p2wos", MFnNumericData::kBoolean, false); 201 | addAttribute(attr_parent2WithoutScale); 202 | 203 | attr_translate2 = nAttr.create("translate2", "t2", MFnNumericData::k3Double); 204 | nAttr.setKeyable(true); 205 | addAttribute(attr_translate2); 206 | 207 | 208 | attr_rotate2X = uAttr.create("rotate2X", "r2x", MFnUnitAttribute::kAngle); 209 | attr_rotate2Y = uAttr.create("rotate2Y", "r2y", MFnUnitAttribute::kAngle); 210 | attr_rotate2Z = uAttr.create("rotate2Z", "r2z", MFnUnitAttribute::kAngle); 211 | 212 | attr_rotate2 = nAttr.create("rotate2", "r2", attr_rotate2X, attr_rotate2Y, attr_rotate2Z); 213 | nAttr.setKeyable(true); 214 | addAttribute(attr_rotate2); 215 | 216 | 217 | attr_scale2 = nAttr.create("scale2", "s2", MFnNumericData::k3Double, 1); 218 | nAttr.setKeyable(true); 219 | addAttribute(attr_scale2); 220 | 221 | attr_jointOrient2X = uAttr.create("jointOrient2X", "jo2x", MFnUnitAttribute::kAngle); 222 | attr_jointOrient2Y = uAttr.create("jointOrient2Y", "jo2y", MFnUnitAttribute::kAngle); 223 | attr_jointOrient2Z = uAttr.create("jointOrient2Z", "jo2z", MFnUnitAttribute::kAngle); 224 | 225 | attr_jointOrient2 = nAttr.create("jointOrient2", "jo2", attr_jointOrient2X, attr_jointOrient2Y, attr_jointOrient2Z); 226 | nAttr.setKeyable(true); 227 | addAttribute(attr_jointOrient2); 228 | 229 | attr_offset2 = mAttr.create("offset2", "o2"); 230 | mAttr.setHidden(true); 231 | addAttribute(attr_offset2); 232 | 233 | 234 | attr_blendAt = nAttr.create("blendAt", "bl", MFnNumericData::kFloat, 0.5); 235 | nAttr.setMin(0); 236 | nAttr.setMax(1); 237 | nAttr.setKeyable(true); 238 | addAttribute(attr_blendAt); 239 | 240 | attr_sticky = nAttr.create("sticky", "st", MFnNumericData::kFloat, 0); 241 | nAttr.setMin(0); 242 | nAttr.setMax(1); 243 | nAttr.setKeyable(true); 244 | addAttribute(attr_sticky); 245 | 246 | attr_spherical = nAttr.create("spherical", "sph", MFnNumericData::kFloat, 0); 247 | nAttr.setMin(0); 248 | nAttr.setMax(1); 249 | nAttr.setKeyable(true); 250 | addAttribute(attr_spherical); 251 | 252 | attr_sphereCenter = nAttr.create("sphereCenter", "sphc", MFnNumericData::k3Double); 253 | nAttr.setKeyable(true); 254 | addAttribute(attr_sphereCenter); 255 | 256 | attr_sphericalLengthFactor = nAttr.create("sphericalLengthFactor", "sphlf", MFnNumericData::kFloat, 1); 257 | nAttr.setMin(0); 258 | nAttr.setMax(10); 259 | nAttr.setKeyable(true); 260 | addAttribute(attr_sphericalLengthFactor); 261 | 262 | 263 | attr_outMatrix1 = mAttr.create("outMatrix1", "om1"); 264 | mAttr.setHidden(true); 265 | addAttribute(attr_outMatrix1); 266 | 267 | attr_outMatrix2 = mAttr.create("outMatrix2", "om2"); 268 | mAttr.setHidden(true); 269 | addAttribute(attr_outMatrix2); 270 | 271 | attributeAffects(attr_parent1, attr_outMatrix1); 272 | attributeAffects(attr_parent1WithoutScale, attr_outMatrix1); 273 | attributeAffects(attr_parent2, attr_outMatrix1); 274 | attributeAffects(attr_parent2WithoutScale, attr_outMatrix1); 275 | attributeAffects(attr_translate1, attr_outMatrix1); 276 | attributeAffects(attr_translate2, attr_outMatrix1); 277 | attributeAffects(attr_rotate1, attr_outMatrix1); 278 | attributeAffects(attr_rotate2, attr_outMatrix1); 279 | attributeAffects(attr_scale1, attr_outMatrix1); 280 | attributeAffects(attr_scale2, attr_outMatrix1); 281 | attributeAffects(attr_jointOrient1, attr_outMatrix1); 282 | attributeAffects(attr_jointOrient2, attr_outMatrix1); 283 | attributeAffects(attr_offset1, attr_outMatrix1); 284 | attributeAffects(attr_offset2, attr_outMatrix1); 285 | attributeAffects(attr_blendAt, attr_outMatrix1); 286 | attributeAffects(attr_sticky, attr_outMatrix1); 287 | attributeAffects(attr_spherical, attr_outMatrix1); 288 | attributeAffects(attr_sphereCenter, attr_outMatrix1); 289 | attributeAffects(attr_sphericalLengthFactor, attr_outMatrix1); 290 | 291 | attributeAffects(attr_parent1, attr_outMatrix2); 292 | attributeAffects(attr_parent1WithoutScale, attr_outMatrix2); 293 | attributeAffects(attr_parent2, attr_outMatrix2); 294 | attributeAffects(attr_parent2WithoutScale, attr_outMatrix2); 295 | attributeAffects(attr_translate1, attr_outMatrix2); 296 | attributeAffects(attr_translate2, attr_outMatrix2); 297 | attributeAffects(attr_rotate1, attr_outMatrix2); 298 | attributeAffects(attr_rotate2, attr_outMatrix2); 299 | attributeAffects(attr_scale1, attr_outMatrix2); 300 | attributeAffects(attr_scale2, attr_outMatrix2); 301 | attributeAffects(attr_jointOrient1, attr_outMatrix2); 302 | attributeAffects(attr_jointOrient2, attr_outMatrix2); 303 | attributeAffects(attr_offset1, attr_outMatrix2); 304 | attributeAffects(attr_offset2, attr_outMatrix2); 305 | attributeAffects(attr_blendAt, attr_outMatrix2); 306 | attributeAffects(attr_sticky, attr_outMatrix2); 307 | attributeAffects(attr_spherical, attr_outMatrix2); 308 | attributeAffects(attr_sphereCenter, attr_outMatrix2); 309 | attributeAffects(attr_sphericalLengthFactor, attr_outMatrix2); 310 | 311 | return MS::kSuccess; 312 | } 313 | -------------------------------------------------------------------------------- /source/skeleposer.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | 26 | #include "skeleposer.h" 27 | #include "utils.hpp" 28 | 29 | using namespace std; 30 | 31 | MTypeId Skeleposer::typeId(1274440); 32 | 33 | MObject Skeleposer::attr_joints; 34 | MObject Skeleposer::attr_baseMatrices; 35 | MObject Skeleposer::attr_jointOrientX; 36 | MObject Skeleposer::attr_jointOrientY; 37 | MObject Skeleposer::attr_jointOrientZ; 38 | MObject Skeleposer::attr_jointOrients; 39 | MObject Skeleposer::attr_poses; 40 | MObject Skeleposer::attr_poseName; 41 | MObject Skeleposer::attr_poseWeight; 42 | MObject Skeleposer::attr_poseEnabled; 43 | MObject Skeleposer::attr_poseBlendMode; 44 | MObject Skeleposer::attr_poseDirectoryIndex; 45 | MObject Skeleposer::attr_poseDeltaMatrices; 46 | MObject Skeleposer::attr_directories; 47 | MObject Skeleposer::attr_directoryName; 48 | MObject Skeleposer::attr_directoryWeight; 49 | MObject Skeleposer::attr_directoryParentIndex; 50 | MObject Skeleposer::attr_directoryChildrenIndices; 51 | MObject Skeleposer::attr_outputTranslates; 52 | MObject Skeleposer::attr_outputRotateX; 53 | MObject Skeleposer::attr_outputRotateY; 54 | MObject Skeleposer::attr_outputRotateZ; 55 | MObject Skeleposer::attr_outputRotates; 56 | MObject Skeleposer::attr_outputScales; 57 | 58 | MStatus Skeleposer::compute(const MPlug &plug, MDataBlock &dataBlock) 59 | { 60 | if (plug != attr_outputTranslates && plug != attr_outputRotates && plug != attr_outputScales) 61 | return MS::kUnknownParameter; 62 | 63 | MArrayDataHandle posesHandle = dataBlock.outputArrayValue(attr_poses); 64 | const int N_POSES = posesHandle.elementCount(); 65 | 66 | MArrayDataHandle jointsHandle = dataBlock.outputArrayValue(attr_joints); 67 | MArrayDataHandle baseMatricesHandle = dataBlock.outputArrayValue(attr_baseMatrices); 68 | MArrayDataHandle jointOrientsHandle = dataBlock.outputArrayValue(attr_jointOrients); 69 | 70 | map joints; 71 | for (int i = 0; i < jointsHandle.elementCount(); i++) 72 | { 73 | jointsHandle.jumpToArrayElement(i); 74 | const int idx = jointsHandle.elementIndex(); 75 | 76 | Joint& jnt = joints[idx]; 77 | jnt.poses.reserve(N_POSES); 78 | 79 | if (baseMatricesHandle.jumpToElement(idx) == MS::kSuccess) // if there is an element exists in the idx, get the matrix 80 | jnt.baseMatrix = baseMatricesHandle.inputValue().asMatrix(); 81 | 82 | if (jointOrientsHandle.jumpToElement(idx) == MS::kSuccess) // if there is an element exists in the idx, get the matrix 83 | { 84 | MDataHandle joh = jointOrientsHandle.inputValue(); 85 | const MVector jo( 86 | joh.child(attr_jointOrientX).asAngle().asRadians(), 87 | joh.child(attr_jointOrientY).asAngle().asRadians(), 88 | joh.child(attr_jointOrientZ).asAngle().asRadians()); 89 | 90 | jnt.jointOrient = MEulerRotation(jo).asQuaternion(); 91 | } 92 | } 93 | 94 | Directory directory; 95 | 96 | MArrayDataHandle directoriesHandle = dataBlock.outputArrayValue(attr_directories); 97 | for (int i = 0; i < directoriesHandle.elementCount(); i++) 98 | { 99 | directoriesHandle.jumpToArrayElement(i); 100 | const int idx = directoriesHandle.elementIndex(); 101 | 102 | DirectoryItem& item = directory[idx]; 103 | item.weight = directoriesHandle.inputValue().child(attr_directoryWeight).asFloat(); 104 | item.parentIndex = directoriesHandle.inputValue().child(attr_directoryParentIndex).asInt(); 105 | 106 | const MFnIntArrayData intArrayData(directoriesHandle.inputValue().child(attr_directoryChildrenIndices).data()); 107 | item.childrenIndices.resize(intArrayData.length()); 108 | for (int k = 0; k < intArrayData.length(); k++) 109 | item.childrenIndices[k] = intArrayData[k]; 110 | } 111 | 112 | vector posesIndicesInOrder; 113 | posesIndicesInOrder.reserve(N_POSES); 114 | directory.getPosesOrder(0, posesIndicesInOrder); 115 | 116 | for (auto &idx : posesIndicesInOrder) 117 | { 118 | if (posesHandle.jumpToElement(idx) != MS::kSuccess) 119 | { 120 | MGlobal::displayWarning(".poses["+MSTR(idx)+"] doesn't exist"); 121 | continue; 122 | } 123 | 124 | const int poseDirectoryIndex = posesHandle.inputValue().child(attr_poseDirectoryIndex).asInt(); 125 | 126 | const bool poseEnabled = posesHandle.inputValue().child(attr_poseEnabled).asBool(); 127 | 128 | const float poseWeight = posesHandle.inputValue().child(attr_poseWeight).asFloat(); 129 | const short poseBlendMode = posesHandle.inputValue().child(attr_poseBlendMode).asShort(); 130 | const float directoryWeight = directory.getRecursiveWeight(poseDirectoryIndex); 131 | const float fullPoseWeight = poseWeight * directoryWeight; 132 | 133 | if (fullPoseWeight > EPSILON && poseEnabled) 134 | { 135 | MArrayDataHandle poseDeltaMatrices(posesHandle.outputValue().child(attr_poseDeltaMatrices)); 136 | for (int k = 0; k < poseDeltaMatrices.elementCount(); k++) 137 | { 138 | poseDeltaMatrices.jumpToArrayElement(k); 139 | const int jointIndex = poseDeltaMatrices.elementIndex(); 140 | 141 | Pose p; 142 | p.weight = fullPoseWeight; 143 | p.blendMode = (PoseBlendMode)poseBlendMode; 144 | p.deltaMatrix = poseDeltaMatrices.inputValue().asMatrix(); 145 | joints[jointIndex].poses.push_back(std::move(p)); 146 | } 147 | } 148 | } 149 | 150 | MArrayDataHandle outputTranslatesHandle = dataBlock.outputArrayValue(attr_outputTranslates); 151 | MArrayDataHandle outputRotatesHandle = dataBlock.outputArrayValue(attr_outputRotates); 152 | MArrayDataHandle outputScalesHandle = dataBlock.outputArrayValue(attr_outputScales); 153 | 154 | MArrayDataBuilder outputTranslatesBuilder(&dataBlock, attr_outputTranslates, joints.size()); 155 | MArrayDataBuilder outputRotatesBuilder(&dataBlock, attr_outputRotates, joints.size()); 156 | MArrayDataBuilder outputScalesBuilder(&dataBlock, attr_outputScales, joints.size()); 157 | 158 | // apply poses 159 | for (auto &item : joints) 160 | { 161 | MVector translation = taxis(item.second.baseMatrix); 162 | MQuaternion rotation = mat2quat(item.second.baseMatrix); 163 | MVector scale = mscale(item.second.baseMatrix); 164 | 165 | for (const auto& p : item.second.poses) 166 | { 167 | if (p.weight > EPSILON) 168 | { 169 | MMatrix delta = p.deltaMatrix; 170 | const MVector deltaScale(mscale(delta)); 171 | set_mscale(delta, MVector(1, 1, 1)); 172 | 173 | if (p.blendMode == PoseBlendMode::ADDIVITE) 174 | { 175 | translation += taxis(delta) * p.weight; 176 | rotation = slerp(rotation, mat2quat(delta) * rotation, p.weight); 177 | scale = vectorLerp(scale, vectorMult(deltaScale, scale), p.weight); 178 | } 179 | 180 | else if (p.blendMode == PoseBlendMode::REPLACE) 181 | { 182 | translation = vectorLerp(translation, taxis(delta), p.weight); 183 | rotation = slerp(rotation, mat2quat(delta), p.weight); 184 | scale = vectorLerp(scale, deltaScale, p.weight); 185 | } 186 | } 187 | } 188 | 189 | const int& idx = item.first; 190 | 191 | auto tHandle = outputTranslatesBuilder.addElement(idx); 192 | auto rHandle = outputRotatesBuilder.addElement(idx); 193 | auto sHandle = outputScalesBuilder.addElement(idx); 194 | 195 | const MEulerRotation euler = mat2quat(rotation * item.second.jointOrient.inverse()).asEulerRotation(); 196 | 197 | tHandle.setMVector(translation); 198 | 199 | rHandle.child(attr_outputRotateX).setMAngle(MAngle(euler.x)); 200 | rHandle.child(attr_outputRotateY).setMAngle(MAngle(euler.y)); 201 | rHandle.child(attr_outputRotateZ).setMAngle(MAngle(euler.z)); 202 | 203 | sHandle.setMVector(scale); 204 | } 205 | 206 | outputTranslatesHandle.set(outputTranslatesBuilder); 207 | outputRotatesHandle.set(outputRotatesBuilder); 208 | outputScalesHandle.set(outputScalesBuilder); 209 | 210 | outputTranslatesHandle.setAllClean(); 211 | outputRotatesHandle.setAllClean(); 212 | outputScalesHandle.setAllClean(); 213 | 214 | dataBlock.setClean(attr_outputTranslates); 215 | dataBlock.setClean(attr_outputRotates); 216 | dataBlock.setClean(attr_outputScales); 217 | 218 | return MS::kSuccess; 219 | } 220 | 221 | MStatus Skeleposer::initialize() 222 | { 223 | MFnNumericAttribute nAttr; 224 | MFnMessageAttribute msgAttr; 225 | MFnCompoundAttribute cAttr; 226 | MFnMatrixAttribute mAttr; 227 | MFnTypedAttribute tAttr; 228 | MFnUnitAttribute uAttr; 229 | MFnEnumAttribute eAttr; 230 | 231 | attr_joints = msgAttr.create("joints", "joints"); 232 | msgAttr.setArray(true); 233 | msgAttr.setHidden(true); 234 | addAttribute(attr_joints); 235 | 236 | 237 | attr_baseMatrices = mAttr.create("baseMatrices", "baseMatrices"); 238 | mAttr.setArray(true); 239 | mAttr.setHidden(true); 240 | addAttribute(attr_baseMatrices); 241 | 242 | 243 | attr_jointOrientX = uAttr.create("jointOrientX", "jointOrientX", MFnUnitAttribute::kAngle); 244 | attr_jointOrientY = uAttr.create("jointOrientY", "jointOrientY", MFnUnitAttribute::kAngle); 245 | attr_jointOrientZ = uAttr.create("jointOrientZ", "jointOrientZ", MFnUnitAttribute::kAngle); 246 | 247 | attr_jointOrients = nAttr.create("jointOrients", "jointOrients", attr_jointOrientX, attr_jointOrientY, attr_jointOrientZ); 248 | nAttr.setArray(true); 249 | nAttr.setHidden(true); 250 | addAttribute(attr_jointOrients); 251 | 252 | 253 | attr_poseName = tAttr.create("poseName", "poseName", MFnData::kString); 254 | attr_poseWeight = nAttr.create("poseWeight", "poseWeight", MFnNumericData::kFloat, 1); 255 | nAttr.setMin(0); 256 | nAttr.setMax(1); 257 | nAttr.setKeyable(true); 258 | 259 | attr_poseEnabled = nAttr.create("poseEnabled", "poseEnabled", MFnNumericData::kBoolean, true); 260 | nAttr.setHidden(true); 261 | 262 | attr_poseBlendMode = eAttr.create("poseBlendMode", "poseBlendMode", 0); 263 | eAttr.addField("Additive", 0); 264 | eAttr.addField("Replace", 1); 265 | 266 | attr_poseDirectoryIndex = nAttr.create("poseDirectoryIndex", "poseDirectoryIndex", MFnNumericData::kInt, 0); 267 | nAttr.setMin(0); 268 | nAttr.setHidden(true); 269 | 270 | attr_poseDeltaMatrices = mAttr.create("poseDeltaMatrices", "poseDeltaMatrices"); 271 | mAttr.setHidden(true); 272 | mAttr.setArray(true); 273 | 274 | attr_poses = cAttr.create("poses", "poses"); 275 | cAttr.addChild(attr_poseName); 276 | cAttr.addChild(attr_poseWeight); 277 | cAttr.addChild(attr_poseEnabled); 278 | cAttr.addChild(attr_poseDirectoryIndex); 279 | cAttr.addChild(attr_poseBlendMode); 280 | cAttr.addChild(attr_poseDeltaMatrices); 281 | cAttr.setArray(true); 282 | addAttribute(attr_poses); 283 | 284 | 285 | attr_directoryName = tAttr.create("directoryName", "directoryName", MFnData::kString); 286 | attr_directoryWeight = nAttr.create("directoryWeight", "directoryWeight", MFnNumericData::kFloat, 1); 287 | nAttr.setMin(0); 288 | nAttr.setMax(1); 289 | nAttr.setKeyable(true); 290 | 291 | attr_directoryParentIndex = nAttr.create("directoryParentIndex", "directoryParentIndex", MFnNumericData::kInt, 0); 292 | nAttr.setMin(0); 293 | nAttr.setHidden(true); 294 | 295 | attr_directoryChildrenIndices = tAttr.create("directoryChildrenIndices", "directoryChildrenIndices", MFnData::kIntArray); 296 | tAttr.setHidden(true); 297 | 298 | attr_directories = cAttr.create("directories", "directories"); 299 | cAttr.addChild(attr_directoryName); 300 | cAttr.addChild(attr_directoryWeight); 301 | cAttr.addChild(attr_directoryParentIndex); 302 | cAttr.addChild(attr_directoryChildrenIndices); 303 | cAttr.setArray(true); 304 | addAttribute(attr_directories); 305 | 306 | 307 | attr_outputTranslates = nAttr.create("outputTranslates", "outputTranslates", MFnNumericData::k3Double); 308 | nAttr.setArray(true); 309 | nAttr.setHidden(true); 310 | nAttr.setUsesArrayDataBuilder(true); 311 | addAttribute(attr_outputTranslates); 312 | 313 | 314 | attr_outputRotateX = uAttr.create("outputRotateX", "outputRotateX", MFnUnitAttribute::kAngle); 315 | attr_outputRotateY = uAttr.create("outputRotateY", "outputRotateY", MFnUnitAttribute::kAngle); 316 | attr_outputRotateZ = uAttr.create("outputRotateZ", "outputRotateZ", MFnUnitAttribute::kAngle); 317 | 318 | attr_outputRotates = nAttr.create("outputRotates", "outputRotates", attr_outputRotateX, attr_outputRotateY, attr_outputRotateZ); 319 | nAttr.setArray(true); 320 | nAttr.setUsesArrayDataBuilder(true); 321 | nAttr.setHidden(true); 322 | addAttribute(attr_outputRotates); 323 | 324 | 325 | attr_outputScales = nAttr.create("outputScales", "outputScales", MFnNumericData::k3Double); 326 | nAttr.setArray(true); 327 | nAttr.setHidden(true); 328 | nAttr.setUsesArrayDataBuilder(true); 329 | addAttribute(attr_outputScales); 330 | 331 | 332 | attributeAffects(attr_baseMatrices, attr_outputTranslates); 333 | attributeAffects(attr_jointOrients, attr_outputTranslates); 334 | attributeAffects(attr_poses, attr_outputTranslates); 335 | attributeAffects(attr_poseWeight, attr_outputTranslates); 336 | attributeAffects(attr_poseBlendMode, attr_outputTranslates); 337 | attributeAffects(attr_poseDirectoryIndex, attr_outputTranslates); 338 | attributeAffects(attr_poseDeltaMatrices, attr_outputTranslates); 339 | attributeAffects(attr_directories, attr_outputTranslates); 340 | attributeAffects(attr_directoryWeight, attr_outputTranslates); 341 | attributeAffects(attr_directoryChildrenIndices, attr_outputTranslates); 342 | 343 | attributeAffects(attr_baseMatrices, attr_outputRotates); 344 | attributeAffects(attr_jointOrients, attr_outputRotates); 345 | attributeAffects(attr_poses, attr_outputRotates); 346 | attributeAffects(attr_poseWeight, attr_outputRotates); 347 | attributeAffects(attr_poseBlendMode, attr_outputRotates); 348 | attributeAffects(attr_poseDirectoryIndex, attr_outputRotates); 349 | attributeAffects(attr_poseDeltaMatrices, attr_outputRotates); 350 | attributeAffects(attr_directories, attr_outputRotates); 351 | attributeAffects(attr_directoryWeight, attr_outputRotates); 352 | attributeAffects(attr_directoryChildrenIndices, attr_outputRotates); 353 | 354 | attributeAffects(attr_baseMatrices, attr_outputScales); 355 | attributeAffects(attr_jointOrients, attr_outputScales); 356 | attributeAffects(attr_poses, attr_outputScales); 357 | attributeAffects(attr_poseWeight, attr_outputScales); 358 | attributeAffects(attr_poseBlendMode, attr_outputScales); 359 | attributeAffects(attr_poseDirectoryIndex, attr_outputScales); 360 | attributeAffects(attr_poseDeltaMatrices, attr_outputScales); 361 | attributeAffects(attr_directories, attr_outputScales); 362 | attributeAffects(attr_directoryWeight, attr_outputScales); 363 | attributeAffects(attr_directoryChildrenIndices, attr_outputScales); 364 | 365 | return MS::kSuccess; 366 | } 367 | -------------------------------------------------------------------------------- /Unity/Skeleposer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using UnityEngine; 4 | using System; 5 | using SimpleJSON; 6 | using System.Linq; 7 | using Unity.VisualScripting; 8 | 9 | public enum BlendMode 10 | { 11 | Additive, 12 | Replace 13 | } 14 | 15 | struct Directory 16 | { 17 | public int parentIndex; 18 | public List childrenIndices; 19 | } 20 | 21 | struct Pose 22 | { 23 | public string name; 24 | public BlendMode blendMode; 25 | public List corrects; 26 | public Dictionary deltaMatrices; 27 | } 28 | struct BonePose 29 | { 30 | public string poseName; 31 | public Matrix4x4 matrix; 32 | public BlendMode blendMode; 33 | public List corrects; 34 | } 35 | 36 | [Serializable] 37 | public struct UserPose 38 | { 39 | public string poseName; 40 | [Range(0, 1)] 41 | public float poseWeight; 42 | } 43 | /* 44 | [Serializable] 45 | public struct IKTriple 46 | { 47 | public Transform root; 48 | public Transform mid; 49 | public Transform tip; 50 | public bool enableIK; 51 | }*/ 52 | 53 | [RequireComponent(typeof(Animator))] 54 | public class Skeleposer : MonoBehaviour 55 | { 56 | public string filePath; 57 | public SkinnedMeshRenderer meshWithBlendShapes; 58 | public List userPoses; 59 | //public List iks; 60 | 61 | Dictionary> bonePoses; // per bone 62 | Dictionary blendShapes; // name: index 63 | 64 | Dictionary initialMatrices; 65 | Animator animator; 66 | 67 | Matrix4x4 convertMayaMatrixToUnity(Matrix4x4 m) 68 | { 69 | // translation 70 | var unityPosition = new Vector3(-m.GetPosition().x, m.GetPosition().y, m.GetPosition().z); 71 | 72 | // rotation 73 | var flippedRotation = new Vector3(m.rotation.eulerAngles.x, -m.rotation.eulerAngles.y, -m.rotation.eulerAngles.z); 74 | var qx = Quaternion.AngleAxis(flippedRotation.x, Vector3.right); 75 | var qy = Quaternion.AngleAxis(flippedRotation.y, Vector3.up); 76 | var qz = Quaternion.AngleAxis(flippedRotation.z, Vector3.forward); 77 | var unityRotation = qz * qy * qx; // exact order! 78 | 79 | // scale 80 | var unityScaling = new Vector3(m.lossyScale.x, m.lossyScale.y, m.lossyScale.z); 81 | 82 | return Matrix4x4.TRS(unityPosition, unityRotation, unityScaling); 83 | } 84 | 85 | static void getPosesOrder(Dictionary directories, int directoryIdx, ref List poseIndices) 86 | { 87 | Directory dir; 88 | if (directories.TryGetValue(directoryIdx, out dir)) 89 | { 90 | foreach (int childIndex in dir.childrenIndices) 91 | { 92 | if (childIndex >= 0) 93 | poseIndices.Add(childIndex); 94 | else 95 | getPosesOrder(directories, -childIndex, ref poseIndices); 96 | } 97 | } 98 | } 99 | 100 | void loadFromJson(string filePath) 101 | { 102 | Debug.Log("Loading json"); 103 | 104 | bonePoses = new(); 105 | 106 | string jsonStr = System.IO.File.ReadAllText(filePath); 107 | JSONNode json = JSON.Parse(jsonStr); 108 | 109 | // get bone names 110 | Dictionary bones = new(); // per index 111 | JSONObject jointsObject = json["joints"].AsObject; 112 | foreach (string jointIdxStr in jointsObject.Keys) 113 | { 114 | int jointIdx; 115 | int.TryParse(jointIdxStr, out jointIdx); 116 | string boneName = jointsObject[jointIdxStr]; 117 | 118 | GameObject bone = GameObject.Find(boneName); 119 | if (bone != null) 120 | { 121 | bones.Add(jointIdx, bone); 122 | bonePoses.Add(bone, new List()); 123 | } 124 | } 125 | 126 | // get directories 127 | Dictionary directories = new(); 128 | 129 | JSONObject directoriesObject = json["directories"].AsObject; 130 | foreach (string dirIdxStr in directoriesObject.Keys) 131 | { 132 | int dirIdx; 133 | int.TryParse(dirIdxStr, out dirIdx); 134 | 135 | JSONObject dirObject = directoriesObject[dirIdxStr].AsObject; 136 | 137 | Directory dir = new(); 138 | dir.parentIndex = dirObject["directoryParentIndex"].AsInt; 139 | 140 | dir.childrenIndices = new(); 141 | foreach (int childIndex in dirObject["directoryChildrenIndices"].AsArray.Values) 142 | dir.childrenIndices.Add(childIndex); 143 | 144 | directories.Add(dirIdx, dir); 145 | } 146 | 147 | // get poses 148 | Dictionary poses = new(); 149 | 150 | JSONObject posesObject = json["poses"].AsObject; 151 | foreach (string poseIdxStr in posesObject.Keys) 152 | { 153 | int poseIdx; 154 | int.TryParse(poseIdxStr, out poseIdx); 155 | 156 | JSONObject poseObject = posesObject[poseIdxStr].AsObject; 157 | 158 | Pose pose = new(); 159 | pose.name = poseObject["poseName"]; 160 | pose.blendMode = (BlendMode)poseObject["poseBlendMode"].AsInt; 161 | 162 | // get corrects 163 | pose.corrects = new(); 164 | if (poseObject.HasKey("corrects")) 165 | { 166 | JSONArray correctsArray = poseObject["corrects"].AsArray; 167 | for (int i = 0; i < correctsArray.Count; i++) 168 | pose.corrects.Add(correctsArray[i]); 169 | } 170 | 171 | // get delta matrices 172 | JSONObject deltaMatricesObject = poseObject["poseDeltaMatrices"].AsObject; 173 | 174 | pose.deltaMatrices = new(); 175 | foreach (string deltaMatrixIdx in deltaMatricesObject.Keys) 176 | { 177 | int deltaIdx; 178 | int.TryParse(deltaMatrixIdx, out deltaIdx); 179 | 180 | Matrix4x4 deltaMatrix = new(); 181 | 182 | JSONArray deltaMatrixArray = deltaMatricesObject[deltaMatrixIdx].AsArray; 183 | for (int i = 0; i < deltaMatrixArray.Count; i++) 184 | deltaMatrix[i] = deltaMatrixArray[i].AsFloat; 185 | 186 | deltaMatrix[12] /= 100.0f; // default unit in Unity is meter, cm is in Maya 187 | deltaMatrix[13] /= 100.0f; 188 | deltaMatrix[14] /= 100.0f; 189 | 190 | pose.deltaMatrices.Add(deltaIdx, convertMayaMatrixToUnity(deltaMatrix)); 191 | } 192 | 193 | poses.Add(poseIdx, pose); 194 | } 195 | 196 | // get all poses indices in a correct order 197 | List poseIndices = new(); 198 | getPosesOrder(directories, 0, ref poseIndices); 199 | 200 | // get pose per bone list, like bone1:[pose1, pose2, pose3], bone2:[pose1, pose4] 201 | foreach (int poseIdx in poseIndices) 202 | { 203 | Pose pose = poses[poseIdx]; 204 | 205 | foreach (int deltaIdx in pose.deltaMatrices.Keys) 206 | { 207 | GameObject bone; 208 | if (bones.TryGetValue(deltaIdx, out bone)) 209 | { 210 | BonePose bpose = new(); 211 | bpose.poseName = pose.name; 212 | bpose.matrix = pose.deltaMatrices[deltaIdx]; 213 | bpose.blendMode = pose.blendMode; 214 | 215 | bpose.corrects = new(); 216 | foreach (int correctPoseIdx in pose.corrects) // corrects are pose names 217 | bpose.corrects.Add(poses[correctPoseIdx].name); 218 | 219 | bonePoses[bone].Add(bpose); 220 | } 221 | else 222 | Debug.Log("Cannot find bone at index "+deltaIdx); 223 | } 224 | } 225 | } 226 | 227 | 228 | void getBlendShapes() 229 | { 230 | blendShapes = new(); 231 | 232 | if (meshWithBlendShapes == null) 233 | return; 234 | 235 | Mesh mesh = meshWithBlendShapes.sharedMesh; 236 | for (int i = 0; i < mesh.blendShapeCount; i++) 237 | { 238 | string targetName = mesh.GetBlendShapeName(i); 239 | string localTargetName = targetName.Split(".").Last(); // strip blendShape node name, like blendShape1.targetName 240 | blendShapes.Add(localTargetName, i); 241 | } 242 | } 243 | 244 | // Start is called before the first frame update 245 | public void Start() 246 | { 247 | initialMatrices = new(); 248 | 249 | string fullFilePath = Path.Combine(Application.streamingAssetsPath, filePath); 250 | if (File.Exists(fullFilePath)) 251 | { 252 | loadFromJson(fullFilePath); 253 | getBlendShapes(); 254 | 255 | // save matrices 256 | foreach (var (bone, _) in bonePoses) 257 | { 258 | Matrix4x4 m = Matrix4x4.TRS(bone.transform.localPosition, bone.transform.localRotation, bone.transform.localScale); 259 | initialMatrices.Add(bone, m); 260 | } 261 | } 262 | else 263 | Debug.LogError("Cannot find " + fullFilePath); 264 | 265 | animator = GetComponent(); 266 | animator.enabled = false; 267 | } 268 | 269 | public void Update() 270 | { 271 | // restore initial matrices 272 | foreach (var (bone, m) in initialMatrices) 273 | { 274 | bone.transform.SetLocalPositionAndRotation(m.GetPosition(), m.rotation); 275 | bone.transform.localScale = m.lossyScale; 276 | } 277 | 278 | animator.Update(Time.deltaTime); 279 | /* 280 | // keep tip ik positions 281 | List ikTipPositions = new(); 282 | foreach (var ikItem in iks) 283 | ikTipPositions.Add(ikItem.tip.position); 284 | */ 285 | UpdateSkeleposer(); 286 | /* 287 | for (int i = 0; i < iks.Count; i++) 288 | { 289 | if (iks[i].enableIK && iks[i].tip.position != ikTipPositions[i]) 290 | solveTwoBoneIK(iks[i].root, iks[i].mid, iks[i].tip, ikTipPositions[i], Vector3.zero, 1, 0); 291 | }*/ 292 | } 293 | 294 | void UpdateSkeleposer() 295 | { 296 | if (userPoses.Count == 0) 297 | return; 298 | 299 | Dictionary posesWeights = new(); // user poses' weights and weights that are calculated during the computation 300 | foreach (var userPose in userPoses) 301 | posesWeights.Add(userPose.poseName, userPose.poseWeight); 302 | 303 | foreach (var (bone, bonePoses) in bonePoses) 304 | { 305 | Transform boneTransform = bone.transform; 306 | 307 | Vector3 translation = boneTransform.localPosition; 308 | Quaternion rotation = boneTransform.localRotation; 309 | Vector3 scale = Vector3.one;// we consider scale is not animated! 310 | 311 | foreach (var bonePose in bonePoses) 312 | { 313 | float poseWeight = posesWeights.GetValueOrDefault(bonePose.poseName, 0.0f); 314 | 315 | if (bonePose.corrects.Count > 0) // if corrects found 316 | { 317 | poseWeight = float.MaxValue; // find lowest weight 318 | foreach (string correctPoseName in bonePose.corrects) 319 | { 320 | float w = posesWeights.GetValueOrDefault(correctPoseName, 0.0f); 321 | if (w < poseWeight) 322 | poseWeight = w; 323 | } 324 | } 325 | 326 | posesWeights[bonePose.poseName] = poseWeight; // save user pose weight 327 | 328 | if (poseWeight > 0.001) 329 | { 330 | if (bonePose.blendMode == BlendMode.Additive) 331 | { 332 | translation += bonePose.matrix.GetPosition() * poseWeight; 333 | rotation = Quaternion.Slerp(rotation, rotation * bonePose.matrix.rotation, poseWeight); 334 | scale = Vector3.Lerp(scale, Vector3.Scale(scale, bonePose.matrix.lossyScale), poseWeight); 335 | } 336 | 337 | else if (bonePose.blendMode == BlendMode.Replace) 338 | { 339 | translation = Vector3.Lerp(translation, bonePose.matrix.GetPosition(), poseWeight); 340 | rotation = Quaternion.Slerp(rotation, bonePose.matrix.rotation, poseWeight); 341 | scale = Vector3.Lerp(scale, bonePose.matrix.lossyScale, poseWeight); 342 | } 343 | } 344 | } 345 | 346 | boneTransform.localPosition = translation; 347 | boneTransform.localRotation = rotation; 348 | boneTransform.localScale = scale; 349 | } 350 | 351 | // activate blend shapes 352 | if (blendShapes.Count > 0) 353 | { 354 | foreach (var (poseName, poseWeight) in posesWeights) 355 | { 356 | int blendShapeIdx; 357 | if (blendShapes.TryGetValue(poseName, out blendShapeIdx)) 358 | meshWithBlendShapes.SetBlendShapeWeight(blendShapeIdx, poseWeight * 100); 359 | } 360 | } 361 | } 362 | /* 363 | static float TriangleAngle(float a, float b, float c) 364 | { 365 | float v = Mathf.Clamp((b * b + c * c - a * a) / (b * c) / 2.0f, -1.0f, 1.0f); 366 | return Mathf.Acos(v); 367 | } 368 | 369 | // Implementation is taken from AnimationRuntimeUtils.SolveTwoBoneIK 370 | void solveTwoBoneIK( 371 | Transform root, 372 | Transform mid, 373 | Transform tip, 374 | Vector3 targetPos, 375 | Vector3 hintPos, 376 | float targetWeight = 1, 377 | float hintWeight = 1) 378 | { 379 | const float k_SqrEpsilon = 1e-8f; 380 | 381 | Vector3 aPosition = root.position; 382 | Vector3 bPosition = mid.position; 383 | Vector3 cPosition = tip.position; 384 | Vector3 tPosition = Vector3.Lerp(cPosition, targetPos, targetWeight); 385 | Quaternion tRotation = new Quaternion(tip.rotation.x, tip.rotation.y, tip.rotation.z, tip.rotation.w); 386 | 387 | bool hasHint = hintWeight > 0f; 388 | 389 | Vector3 ab = bPosition - aPosition; 390 | Vector3 bc = cPosition - bPosition; 391 | Vector3 ac = cPosition - aPosition; 392 | Vector3 at = tPosition - aPosition; 393 | 394 | float abLen = ab.magnitude; 395 | float bcLen = bc.magnitude; 396 | float acLen = ac.magnitude; 397 | float atLen = at.magnitude; 398 | 399 | float oldAbcAngle = TriangleAngle(acLen, abLen, bcLen); 400 | float newAbcAngle = TriangleAngle(atLen, abLen, bcLen); 401 | 402 | // Bend normal strategy is to take whatever has been provided in the animation 403 | // stream to minimize configuration changes, however if this is collinear 404 | // try computing a bend normal given the desired target position. 405 | // If this also fails, try resolving axis using hint if provided. 406 | Vector3 axis = Vector3.Cross(ab, bc); 407 | if (axis.sqrMagnitude < k_SqrEpsilon) 408 | { 409 | axis = hasHint ? Vector3.Cross(hintPos - aPosition, bc) : Vector3.zero; 410 | 411 | if (axis.sqrMagnitude < k_SqrEpsilon) 412 | axis = Vector3.Cross(at, bc); 413 | 414 | if (axis.sqrMagnitude < k_SqrEpsilon) 415 | axis = Vector3.up; 416 | } 417 | axis = Vector3.Normalize(axis); 418 | 419 | float a = 0.5f * (oldAbcAngle - newAbcAngle); 420 | float sin = Mathf.Sin(a); 421 | float cos = Mathf.Cos(a); 422 | Quaternion deltaR = new Quaternion(axis.x * sin, axis.y * sin, axis.z * sin, cos); 423 | mid.rotation = deltaR * mid.rotation; 424 | 425 | cPosition = tip.position; 426 | ac = cPosition - aPosition; 427 | root.rotation = Quaternion.FromToRotation(ac, at) * root.rotation; 428 | 429 | if (hasHint) 430 | { 431 | float acSqrMag = ac.sqrMagnitude; 432 | if (acSqrMag > 0f) 433 | { 434 | bPosition = mid.position; 435 | cPosition = tip.position; 436 | ab = bPosition - aPosition; 437 | ac = cPosition - aPosition; 438 | 439 | Vector3 acNorm = ac / Mathf.Sqrt(acSqrMag); 440 | Vector3 ah = hintPos - aPosition; 441 | Vector3 abProj = ab - acNorm * Vector3.Dot(ab, acNorm); 442 | Vector3 ahProj = ah - acNorm * Vector3.Dot(ah, acNorm); 443 | 444 | float maxReach = abLen + bcLen; 445 | if (abProj.sqrMagnitude > (maxReach * maxReach * 0.001f) && ahProj.sqrMagnitude > 0f) 446 | { 447 | Quaternion hintR = Quaternion.FromToRotation(abProj, ahProj); 448 | hintR.x *= hintWeight; 449 | hintR.y *= hintWeight; 450 | hintR.z *= hintWeight; 451 | hintR = Quaternion.Normalize(hintR); 452 | root.rotation = hintR * root.rotation; 453 | } 454 | } 455 | } 456 | 457 | tip.rotation = tRotation; 458 | }*/ 459 | } 460 | -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/core.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import List, Optional, Dict, Any, Union 4 | 5 | import maya.api.OpenMaya as om 6 | import pymel.core as pm 7 | import maya.cmds as cmds 8 | 9 | from . import utils 10 | 11 | class Skeleposer: 12 | """Main class for managing skeletal poses and transformations in Maya.""" 13 | 14 | TrackAttrs = ["t","tx","ty","tz","r","rx","ry","rz","s","sx","sy","sz"] 15 | 16 | def __init__(self, node: Optional[str] = None): 17 | """Initialize Skeleposer with existing or new node.""" 18 | self._editPoseData = {} 19 | 20 | if pm.objExists(node): 21 | if pm.objectType(node) == "skeleposer": 22 | self.node = pm.PyNode(node) 23 | self.removeEmptyJoints() 24 | else: 25 | pm.error(f"{node} is not a skeleposer node") 26 | else: 27 | self.node = pm.createNode("skeleposer", n=node) 28 | 29 | self.addInternalAttributes() 30 | 31 | def addInternalAttributes(self): 32 | """Add required internal attributes to the skeleposer node.""" 33 | if not self.node.hasAttr("dagPose"): 34 | self.node.addAttr("dagPose", at="message") 35 | 36 | if not self.node.hasAttr("connectionsData"): 37 | self.node.addAttr("connectionsData", dt="string") 38 | 39 | if not self.node.hasAttr("splitPosesData"): 40 | self.node.addAttr("splitPosesData", dt="string") 41 | 42 | def findAvailableDirectoryIndex(self) -> int: 43 | """Find next available directory index.""" 44 | idx = 0 45 | while self.node.directories[idx].exists(): 46 | idx += 1 47 | return idx 48 | 49 | def findAvailablePoseIndex(self) -> int: 50 | """Find next available pose index.""" 51 | idx = 0 52 | while self.node.poses[idx].exists(): 53 | idx += 1 54 | return idx 55 | 56 | def findAvailableJointIndex(self) -> int: 57 | """Find next available joint index.""" 58 | idx = 0 59 | while self.node.joints[idx].exists() and self.node.joints[idx].isConnected(): 60 | idx += 1 61 | return idx 62 | 63 | def getJointIndex(self, joint) -> Optional[int]: 64 | """Get index of joint in skeleposer node.""" 65 | plugs = [p for p in joint.message.outputs(p=True) if p.node() == self.node] 66 | if plugs: 67 | return plugs[0].index() 68 | 69 | def getJointByIndex(self, idx: int) -> Optional[pm.nt.Joint]: 70 | if self.node.joints[idx].exists(): 71 | inputs = self.node.joints[idx].inputs() 72 | if inputs: 73 | return inputs[0] 74 | 75 | @utils.undoBlock 76 | def clearAll(self): 77 | """Clear all joints, poses and directories from the node.""" 78 | for a in self.node.joints: 79 | pm.removeMultiInstance(a, b=True) 80 | 81 | for a in self.node.jointOrients: 82 | pm.removeMultiInstance(a, b=True) 83 | 84 | for a in self.node.baseMatrices: 85 | pm.removeMultiInstance(a, b=True) 86 | 87 | for a in self.node.directories: 88 | pm.removeMultiInstance(a, b=True) 89 | 90 | for a in self.node.poses: 91 | for aa in a.poseDeltaMatrices: 92 | pm.removeMultiInstance(aa, b=True) 93 | 94 | pm.removeMultiInstance(a, b=True) 95 | 96 | @utils.undoBlock 97 | def resetToBase(self, joints): 98 | for jnt in joints: 99 | idx = self.getJointIndex(jnt) 100 | if idx is not None: 101 | jnt.setMatrix(self.node.baseMatrices[idx].get()) 102 | 103 | @utils.undoBlock 104 | def resetDelta(self, poseIndex, joints): 105 | for j in joints: 106 | idx = self.getJointIndex(j) 107 | if idx is not None: 108 | pm.removeMultiInstance(self.node.poses[poseIndex].poseDeltaMatrices[idx], b=True) 109 | j.setMatrix(self.node.baseMatrices[idx].get()) 110 | 111 | @utils.undoBlock 112 | def updateBaseMatrices(self): 113 | """Update base matrices for all connected joints.""" 114 | for ja in self.node.joints: 115 | inputs = ja.inputs() 116 | bm = self.node.baseMatrices[ja.index()] 117 | if inputs and bm.isSettable(): 118 | bm.set(utils.getLocalMatrix(inputs[0])) 119 | else: 120 | pm.warning(f"updateBaseMatrices: {bm.name()} is not writable. Skipped") 121 | 122 | self.updateDagPose() 123 | 124 | @utils.undoBlock 125 | def makeCorrectNode(self, drivenIndex, driverIndexList): 126 | c = pm.createNode("combinationShape", n=f"{self.node.name()}_{drivenIndex}_combinationShape") 127 | c.combinationMethod.set(1) # lowest weighting 128 | for i, idx in enumerate(driverIndexList): 129 | self.node.poses[idx].poseWeight >> c.inputWeight[i] 130 | c.outputWeight >> self.node.poses[drivenIndex].poseWeight 131 | return c 132 | 133 | @utils.undoBlock 134 | def makeInbetweenNode(self, drivenIndex, driverIndex): 135 | rv = pm.createNode("remapValue", n=f"{self.node.name()}_{drivenIndex}_remapValue") 136 | self.node.poses[driverIndex].poseWeight >> rv.inputValue 137 | rv.outValue >> self.node.poses[drivenIndex].poseWeight 138 | return rv 139 | 140 | @utils.undoBlock 141 | def addJoints(self, joints): 142 | """Add joints to the skeleposer system.""" 143 | for j in joints: 144 | if self.getJointIndex(j) is None: 145 | idx = self.findAvailableJointIndex() 146 | j.message >> self.node.joints[idx] 147 | 148 | if isinstance(j, pm.nt.Joint): 149 | j.jo >> self.node.jointOrients[idx] 150 | else: 151 | self.node.jointOrients[idx].set([0,0,0]) 152 | 153 | self.node.baseMatrices[idx].set(utils.getLocalMatrix(j)) 154 | 155 | self.node.outputTranslates[idx] >> j.t 156 | self.node.outputRotates[idx] >> j.r 157 | self.node.outputScales[idx] >> j.s 158 | else: 159 | pm.warning(f"addJoints: {j} is already connected") 160 | 161 | self.updateDagPose() 162 | 163 | @utils.undoBlock 164 | def removeJoints(self, joints): 165 | """Remove joints from the skeleposer system.""" 166 | for jnt in joints: 167 | idx = self.getJointIndex(jnt) 168 | if idx is not None: 169 | self.removeJointByIndex(idx) 170 | 171 | for a in Skeleposer.TrackAttrs: 172 | inp = jnt.attr(a).inputs(p=True) 173 | if inp: 174 | inp[0] // jnt.attr(a) 175 | 176 | self.updateDagPose() 177 | 178 | @utils.undoBlock 179 | def removeJointByIndex(self, jointIndex): 180 | pm.removeMultiInstance(self.node.joints[jointIndex], b=True) 181 | pm.removeMultiInstance(self.node.baseMatrices[jointIndex], b=True) 182 | pm.removeMultiInstance(self.node.jointOrients[jointIndex], b=True) 183 | 184 | # remove joint's matrices in all poses 185 | for p in self.node.poses: 186 | for m in p.poseDeltaMatrices: 187 | if m.index() == jointIndex: 188 | pm.removeMultiInstance(m, b=True) 189 | break 190 | 191 | @utils.undoBlock 192 | def removeEmptyJoints(self): 193 | for ja in self.node.joints: 194 | inputs = ja.inputs() 195 | if not inputs: 196 | self.removeJointByIndex(ja.index()) 197 | pm.warning(f"removeEmptyJoints: removing {ja.name()} as empty") 198 | 199 | @utils.undoBlock 200 | def updateDagPose(self): 201 | if self.node.dagPose.inputs(): 202 | pm.delete(self.node.dagPose.inputs()) 203 | 204 | joints = self.getJoints() 205 | if joints: 206 | dp = pm.dagPose(joints, s=True, sl=True, n=self.node.name()+"_world_dagPose") 207 | dp.message >> self.node.dagPose 208 | else: 209 | pm.warning("updateDagPose: no joints found attached") 210 | 211 | def getJoints(self) -> List[Any]: 212 | """Get all connected joints.""" 213 | joints = [] 214 | for ja in self.node.joints: 215 | inputs = ja.inputs(type=["joint", "transform"]) 216 | if inputs: 217 | joints.append(inputs[0]) 218 | else: 219 | pm.warning(f"getJoints: {ja.name()} is not connected") 220 | return joints 221 | 222 | def getPoseJoints(self, poseIndex): 223 | joints = [] 224 | for m in self.node.poses[poseIndex].poseDeltaMatrices: 225 | ja = self.node.joints[m.index()] 226 | inputs = ja.inputs() 227 | if inputs: 228 | joints.append(inputs[0]) 229 | else: 230 | pm.warning(f"getPoseJoints: {ja.name()} is not connected") 231 | return joints 232 | 233 | def findPoseIndexByName(self, poseName: str) -> Optional[int]: 234 | """Find pose index by name.""" 235 | for p in self.node.poses: 236 | if p.poseName.get() == poseName: 237 | return p.index() 238 | 239 | @utils.undoBlock 240 | def makePose(self, name: str) -> int: 241 | """Create new pose with given name.""" 242 | idx = self.findAvailablePoseIndex() 243 | self.node.poses[idx].poseName.set(name) 244 | 245 | indices = self.node.directories[0].directoryChildrenIndices.get() or [] 246 | indices.append(idx) 247 | self.node.directories[0].directoryChildrenIndices.set(indices, type="Int32Array") 248 | return idx 249 | 250 | @utils.undoBlock 251 | def makeDirectory(self, name: str, parentIndex: int = 0) -> int: 252 | """Create new directory with given name and parent.""" 253 | idx = self.findAvailableDirectoryIndex() 254 | directory = self.node.directories[idx] 255 | directory.directoryName.set(name) 256 | directory.directoryParentIndex.set(parentIndex) 257 | 258 | indices = self.node.directories[parentIndex].directoryChildrenIndices.get() or [] 259 | indices.append(-idx) # negative indices are directories 260 | self.node.directories[parentIndex].directoryChildrenIndices.set(indices, type="Int32Array") 261 | 262 | return idx 263 | 264 | @utils.undoBlock 265 | def removePose(self, poseIndex): 266 | """Remove pose by index.""" 267 | directoryIndex = self.node.poses[poseIndex].poseDirectoryIndex.get() 268 | 269 | indices = self.node.directories[directoryIndex].directoryChildrenIndices.get() or [] 270 | if poseIndex in indices: 271 | indices.remove(poseIndex) 272 | self.node.directories[directoryIndex].directoryChildrenIndices.set(indices, type="Int32Array") 273 | 274 | for m in self.node.poses[poseIndex].poseDeltaMatrices: 275 | pm.removeMultiInstance(m, b=True) 276 | 277 | pm.removeMultiInstance(self.node.poses[poseIndex], b=True) 278 | 279 | @utils.undoBlock 280 | def removeDirectory(self, directoryIndex): 281 | """Remove directory and all its children recursively.""" 282 | for ch in self.node.directories[directoryIndex].directoryChildrenIndices.get() or []: 283 | if ch >= 0: 284 | self.removePose(ch) 285 | else: 286 | self.removeDirectory(-ch) 287 | 288 | parentIndex = self.node.directories[directoryIndex].directoryParentIndex.get() 289 | 290 | indices = self.node.directories[parentIndex].directoryChildrenIndices.get() or [] 291 | if -directoryIndex in indices: # negative indices are directories 292 | indices.remove(-directoryIndex) 293 | self.node.directories[parentIndex].directoryChildrenIndices.set(indices, type="Int32Array") 294 | 295 | pm.removeMultiInstance(self.node.directories[directoryIndex], b=True) 296 | 297 | @utils.undoBlock 298 | def parentDirectory(self, directoryIndex, newParentIndex, insertIndex=None): 299 | """Move directory to new parent.""" 300 | oldParentIndex = self.node.directories[directoryIndex].directoryParentIndex.get() 301 | self.node.directories[directoryIndex].directoryParentIndex.set(newParentIndex) 302 | 303 | oldIndices = self.node.directories[oldParentIndex].directoryChildrenIndices.get() or [] 304 | if -directoryIndex in oldIndices: # negative indices are directories 305 | oldIndices.remove(-directoryIndex) 306 | self.node.directories[oldParentIndex].directoryChildrenIndices.set(oldIndices, type="Int32Array") 307 | 308 | newIndices = self.node.directories[newParentIndex].directoryChildrenIndices.get() or [] 309 | if insertIndex is None: 310 | newIndices.append(-directoryIndex) 311 | else: 312 | newIndices.insert(insertIndex, -directoryIndex) 313 | 314 | self.node.directories[newParentIndex].directoryChildrenIndices.set(newIndices, type="Int32Array") 315 | 316 | @utils.undoBlock 317 | def parentPose(self, poseIndex, newDirectoryIndex, insertIndex=None): 318 | """Move pose to new directory.""" 319 | oldDirectoryIndex = self.node.poses[poseIndex].poseDirectoryIndex.get() 320 | self.node.poses[poseIndex].poseDirectoryIndex.set(newDirectoryIndex) 321 | 322 | oldIndices = self.node.directories[oldDirectoryIndex].directoryChildrenIndices.get() or [] 323 | if poseIndex in oldIndices: 324 | oldIndices.remove(poseIndex) 325 | self.node.directories[oldDirectoryIndex].directoryChildrenIndices.set(oldIndices, type="Int32Array") 326 | 327 | newIndices = self.node.directories[newDirectoryIndex].directoryChildrenIndices.get() or [] 328 | if insertIndex is None: 329 | newIndices.append(poseIndex) 330 | else: 331 | newIndices.insert(insertIndex, poseIndex) 332 | 333 | self.node.directories[newDirectoryIndex].directoryChildrenIndices.set(newIndices, type="Int32Array") 334 | 335 | def dagPose(self): 336 | dagPoseInputs = self.node.dagPose.inputs(type="dagPose") 337 | if dagPoseInputs: 338 | return dagPoseInputs[0] 339 | else: 340 | pm.warning("dagPose: no dagPose found attached") 341 | 342 | @utils.undoBlock 343 | def removeEmptyDeltas(self, poseIndex): 344 | for m in self.node.poses[poseIndex].poseDeltaMatrices: 345 | if m.get().isEquivalent(pm.dt.Matrix(), 1e-4): 346 | pm.removeMultiInstance(m, b=True) 347 | 348 | @utils.undoBlock 349 | def copyPose(self, fromIndex, toIndex, joints=None): 350 | """Copy pose data from one pose to another.""" 351 | self.resetDelta(toIndex, joints or self.getPoseJoints(toIndex)) 352 | 353 | srcPose = self.node.poses[fromIndex] 354 | srcBlendMode = srcPose.poseBlendMode.get() 355 | 356 | joints = joints or self.getPoseJoints(fromIndex) 357 | indices = set([self.getJointIndex(j) for j in joints]) 358 | 359 | destPose = self.node.poses[toIndex] 360 | destPose.poseBlendMode.set(srcBlendMode) 361 | 362 | for mattr in srcPose.poseDeltaMatrices: 363 | if mattr.index() in indices: 364 | destPose.poseDeltaMatrices[mattr.index()].set(mattr.get()) 365 | 366 | @utils.undoBlock 367 | def mirrorPose(self, poseIndex): 368 | """Mirror pose across YZ plane using joint naming convention.""" 369 | dagPose = self.dagPose() 370 | 371 | blendMode = self.node.poses[poseIndex].poseBlendMode.get() 372 | 373 | joints = sorted(self.getPoseJoints(poseIndex), key=lambda j: len(j.getAllParents())) # sort by parents number, process parents first 374 | for j in joints: 375 | idx = self.getJointIndex(j) 376 | 377 | if utils.isRightSide(j): # skip right side 378 | continue 379 | 380 | if utils.isLeftSide(j): 381 | j_mirrored = utils.findSymmetricName(str(j), right=False) 382 | if not j_mirrored or not cmds.objExists(j_mirrored): # skip non-existing joints 383 | continue 384 | 385 | j_mirrored = pm.PyNode(j_mirrored) 386 | 387 | else: # middle joints 388 | j_mirrored = j 389 | 390 | mirror_idx = self.getJointIndex(j_mirrored) 391 | if mirror_idx is None: # if mirror joint is not connected, skip 392 | continue 393 | 394 | j_pmat = utils.dagPose_getParentMatrix(dagPose, j) 395 | j_mbase = utils.dagPose_getWorldMatrix(dagPose, j) 396 | 397 | mirrored_pmat = utils.dagPose_getParentMatrix(dagPose, j_mirrored) 398 | mirrored_pmatInv = mirrored_pmat.inverse() 399 | mirrored_mbase = utils.dagPose_getWorldMatrix(dagPose, j_mirrored) 400 | 401 | delta = self.node.poses[poseIndex].poseDeltaMatrices[idx].get() 402 | jm = utils.applyDelta(delta, j_mbase * j_pmat.inverse()) if blendMode == 0 else om.MMatrix(delta) 403 | jm *= j_pmat 404 | 405 | mirrored_m = utils.mirrorMatrixByDelta(j_mbase, jm, mirrored_mbase) 406 | 407 | if j == j_mirrored: 408 | mirrored_m = utils.blendMatrices(jm, mirrored_m, 0.5) 409 | 410 | mirrored_delta = utils.getDelta(mirrored_m * mirrored_pmatInv, mirrored_mbase * mirrored_pmatInv) if blendMode == 0 else mirrored_m * mirrored_pmatInv 411 | self.node.poses[poseIndex].poseDeltaMatrices[mirror_idx].set(mirrored_delta) 412 | 413 | @utils.undoBlock 414 | def flipPose(self, poseIndex): 415 | """Flip pose by swapping left and right side joints.""" 416 | dagPose = self.dagPose() 417 | 418 | blendMode = self.node.poses[poseIndex].poseBlendMode.get() 419 | 420 | output = {} 421 | for j in self.getPoseJoints(poseIndex): 422 | idx = self.getJointIndex(j) 423 | 424 | if utils.isLeftSide(j) or utils.isRightSide(j): 425 | j_mirrored = utils.findSymmetricName(str(j)) 426 | if not j_mirrored or not cmds.objExists(j_mirrored): # skip non-existing joints 427 | continue 428 | 429 | j_mirrored = pm.PyNode(j_mirrored) 430 | 431 | else: # middle joints 432 | j_mirrored = j 433 | 434 | mirror_idx = self.getJointIndex(j_mirrored) 435 | if mirror_idx is None: # if mirror joint is not connected, skip 436 | continue 437 | 438 | j_pmat = utils.dagPose_getParentMatrix(dagPose, j) 439 | j_pmatInv = j_pmat.inverse() 440 | j_mbase = utils.dagPose_getWorldMatrix(dagPose, j) 441 | 442 | mirrored_pmat = utils.dagPose_getParentMatrix(dagPose, j_mirrored) 443 | mirrored_pmatInv = mirrored_pmat.inverse() 444 | mirrored_mbase = utils.dagPose_getWorldMatrix(dagPose, j_mirrored) 445 | 446 | j_delta = self.node.poses[poseIndex].poseDeltaMatrices[idx].get() 447 | mirrored_delta = self.node.poses[poseIndex].poseDeltaMatrices[mirror_idx].get() 448 | 449 | jm = utils.applyDelta(j_delta, j_mbase * j_pmatInv) if blendMode == 0 else om.MMatrix(j_delta) 450 | jm *= j_pmat 451 | 452 | mirrored_jm = utils.applyDelta(mirrored_delta, mirrored_mbase * mirrored_pmatInv) if blendMode == 0 else om.MMatrix(mirrored_delta) 453 | mirrored_jm *= mirrored_pmat 454 | 455 | m = utils.mirrorMatrixByDelta(mirrored_mbase, mirrored_jm, j_mbase) 456 | mirrored_m = utils.mirrorMatrixByDelta(j_mbase, jm, mirrored_mbase) 457 | 458 | output[idx] = utils.getDelta(m * j_pmatInv, j_mbase * j_pmatInv) if blendMode == 0 else m * j_pmatInv 459 | output[mirror_idx] = utils.getDelta(mirrored_m * mirrored_pmatInv, mirrored_mbase * mirrored_pmatInv) if blendMode == 0 else mirrored_m * mirrored_pmatInv 460 | 461 | for idx in output: 462 | self.node.poses[poseIndex].poseDeltaMatrices[idx].set(output[idx]) 463 | 464 | self.removeEmptyDeltas(poseIndex) 465 | 466 | @utils.undoBlock 467 | def changePoseBlendMode(self, poseIndex, blend): 468 | """Change pose blend mode between additive (0) and replace (1).""" 469 | dagPose = self.dagPose() 470 | 471 | pose = self.node.poses[poseIndex] 472 | poseBlend = pose.poseBlendMode.get() 473 | 474 | for j in self.getPoseJoints(poseIndex): 475 | idx = self.getJointIndex(j) 476 | 477 | delta = om.MMatrix(pose.poseDeltaMatrices[idx].get()) 478 | bmat = utils.dagPose_getWorldMatrix(dagPose, j) 479 | pmat = utils.dagPose_getParentMatrix(dagPose, j) 480 | wm = utils.applyDelta(delta, bmat) if poseBlend == 0 else delta * pmat 481 | pose.poseDeltaMatrices[idx].set(utils.getDelta(wm, bmat) if blend == 0 else wm * pmat.inverse()) 482 | 483 | pose.poseBlendMode.set(blend) 484 | 485 | @utils.undoBlock 486 | def disconnectOutputs(self): 487 | """Disconnect all outputs and store connection data.""" 488 | if self.node.connectionsData.get(): # if connectionsData exists 489 | pm.warning("Disconnection is skipped") 490 | return 491 | 492 | connectionsData = {} 493 | for ja in self.node.joints: 494 | j = ja.inputs()[0] 495 | 496 | connections = {} 497 | for a in Skeleposer.TrackAttrs: 498 | inp = j.attr(a).inputs(p=True) 499 | if inp: 500 | connections[a] = inp[0].name() 501 | inp[0] // j.attr(a) 502 | 503 | connectionsData[ja.index()] = connections 504 | 505 | self.node.connectionsData.set(json.dumps(connectionsData)) 506 | 507 | @utils.undoBlock 508 | def reconnectOutputs(self): 509 | """Restore previously disconnected outputs.""" 510 | connectionsData = self.node.connectionsData.get() 511 | if not connectionsData: # if connectionsData doesn't exist 512 | pm.warning("Connection is skipped") 513 | return 514 | 515 | connectionsData = json.loads(connectionsData) 516 | for idx in connectionsData: 517 | for a in connectionsData[idx]: 518 | j = self.getJointByIndex(int(idx)) 519 | pm.connectAttr(connectionsData[idx][a], j+"."+a, f=True) 520 | 521 | self.node.connectionsData.set("") 522 | 523 | @utils.undoBlock 524 | def setupAliases(self, install=True): 525 | """Setup Maya attribute aliases for easier access.""" 526 | def setupAlias(attr, name, *, prefix=""): 527 | name = str(name) 528 | isValidName = lambda n: re.match("^\\w[\\w\\d_]*$", n) 529 | 530 | if pm.aliasAttr(attr, q=True): 531 | pm.aliasAttr(attr, rm=True) 532 | 533 | if isValidName(name) and install: 534 | pm.aliasAttr(prefix+name, attr) 535 | 536 | for plug in self.node.joints: 537 | idx = plug.index() 538 | n = self.getJointByIndex(idx) 539 | setupAlias(self.node.joints[idx], n, prefix="j_") 540 | setupAlias(self.node.baseMatrices[idx], n, prefix="bm_") 541 | setupAlias(self.node.jointOrients[idx], n, prefix="jo_") 542 | setupAlias(self.node.outputTranslates[idx], n, prefix="t_") 543 | setupAlias(self.node.outputRotates[idx], n, prefix="r_") 544 | setupAlias(self.node.outputScales[idx], n, prefix="s_") 545 | 546 | for plug in self.node.poses: 547 | n = plug.poseName.get() or "" 548 | setupAlias(plug, n, prefix="p_") 549 | setupAlias(plug.poseWeight, n) 550 | 551 | for plug in self.node.directories: 552 | n = plug.directoryName.get() or "" 553 | setupAlias(plug, n, prefix="d_") 554 | 555 | @utils.undoBlock 556 | def beginEditPose(self, idx): 557 | """Start editing pose by disconnecting outputs and storing current state.""" 558 | if self._editPoseData: 559 | pm.warning("Already in edit mode") 560 | return 561 | 562 | self._editPoseData = {"joints":{}, "poseIndex":idx, "input": None} 563 | 564 | inputs = self.node.poses[idx].poseWeight.inputs(p=True) 565 | if inputs: 566 | inputs[0] // self.node.poses[idx].poseWeight 567 | self._editPoseData["input"] = inputs[0] 568 | 569 | self.node.poses[idx].poseWeight.set(1) 570 | 571 | poseEnabled = self.node.poses[idx].poseEnabled.get() 572 | self.node.poses[idx].poseEnabled.set(False) # disable pose 573 | 574 | for j in self.getJoints(): 575 | self._editPoseData["joints"][j.name()] = utils.getLocalMatrix(j) 576 | 577 | self.node.poses[idx].poseEnabled.set(poseEnabled) # restore pose state 578 | 579 | self.disconnectOutputs() 580 | 581 | @utils.undoBlock 582 | def endEditPose(self): 583 | """Finish editing pose and save changes as deltas.""" 584 | if not self._editPoseData: 585 | pm.warning("Not in edit mode") 586 | return 587 | 588 | pose = self.node.poses[self._editPoseData["poseIndex"]] 589 | 590 | for j in self.getJoints(): 591 | jointIndex = self.getJointIndex(j) 592 | 593 | bmat = self._editPoseData["joints"][j.name()] 594 | 595 | mat = utils.getLocalMatrix(j) 596 | if not mat.isEquivalent(bmat, 1e-4): 597 | poseBlendMode = pose.poseBlendMode.get() 598 | 599 | if poseBlendMode == 0: # additive 600 | pose.poseDeltaMatrices[jointIndex].set(utils.getDelta(mat, bmat)) 601 | 602 | elif poseBlendMode == 1: # replace 603 | pose.poseDeltaMatrices[jointIndex].set(mat) 604 | 605 | else: 606 | pm.removeMultiInstance(pose.poseDeltaMatrices[jointIndex], b=True) 607 | 608 | if self._editPoseData["input"]: 609 | self._editPoseData["input"] >> pose.poseWeight 610 | 611 | self.reconnectOutputs() 612 | self._editPoseData = {} 613 | 614 | def findActivePoseIndex(self, value=0.01): 615 | """Find poses with weight above threshold.""" 616 | return [p.index() for p in self.node.poses if p.poseWeight.get() > value] 617 | 618 | def getDirectoryData(self, idx=0): 619 | """Get directory tree structure as nested dict.""" 620 | data = {"directoryIndex":idx, "children":[]} 621 | for chIdx in self.node.directories[idx].directoryChildrenIndices.get() or []: 622 | if chIdx >= 0: 623 | data["children"].append(chIdx) 624 | else: 625 | data["children"].append(self.getDirectoryData(-chIdx)) 626 | return data 627 | 628 | @utils.undoBlock 629 | def addSplitPose(self, srcPoseName, destPoseName, **kwargs): 630 | """Split pose into multiple poses based on joint name patterns.""" # addSplitPose("brows_up", "L_brow_up_inner", R_=0, M_=0.5, L_brow_2=0.3, L_brow_3=0, L_brow_4=0) 631 | srcPose = None 632 | destPose = None 633 | for p in self.node.poses: 634 | if p.poseName.get() == srcPoseName: 635 | srcPose = p 636 | 637 | if p.poseName.get() == destPoseName: 638 | destPose = p 639 | 640 | if not srcPose: 641 | pm.warning("Cannot find source pose: "+srcPoseName) 642 | return 643 | 644 | if not destPose: 645 | idx = self.makePose(destPoseName) 646 | destPose = self.node.poses[idx] 647 | 648 | self.copyPose(srcPose.index(), destPose.index()) 649 | if destPose.poseWeight.isSettable(): 650 | destPose.poseWeight.set(0) 651 | 652 | for j in self.getPoseJoints(destPose.index()): 653 | j_idx = self.getJointIndex(j) 654 | 655 | for pattern in kwargs: 656 | if re.search(pattern, j.name()): 657 | w = kwargs[pattern] 658 | pdm = destPose.poseDeltaMatrices[j_idx] 659 | if w > 1e-3: 660 | pdm.set( utils.blendMatrices(om.MMatrix(), om.MMatrix(pdm.get()), w) ) 661 | else: 662 | pm.removeMultiInstance(pdm, b=True) 663 | 664 | @utils.undoBlock 665 | def addSplitBlends(self, blendShape, targetName, poses): 666 | """Split blendShape target into multiple targets driven by poses.""" 667 | def findTargetIndexByName(blend, name): 668 | for aw in blend.w: 669 | if pm.aliasAttr(aw, q=True)==name: 670 | return aw.index() 671 | 672 | def findAvailableTargetIndex(blend): 673 | idx = 0 674 | while blend.w[idx].exists(): 675 | idx += 1 676 | return idx 677 | 678 | blendShape = pm.PyNode(blendShape) 679 | 680 | targetIndex = findTargetIndexByName(blendShape, targetName) 681 | if targetIndex is None: 682 | pm.warning(f"Cannot find '{targetName}' target in {blendShape}") 683 | return 684 | 685 | mesh = blendShape.getOutputGeometry()[0] 686 | 687 | blendShape.envelope.set(0) # turn off blendShapes 688 | 689 | basePoints = pm.api.MPointArray() 690 | meshFn = pm.api.MFnMesh(mesh.__apimdagpath__()) 691 | meshFn.getPoints(basePoints) 692 | 693 | offsetsList = [] 694 | sumOffsets = [1e-5] * basePoints.length() 695 | for poseName in poses: 696 | poseIndex = self.findPoseIndexByName(poseName) 697 | if poseIndex is not None: 698 | pose = self.node.poses[poseIndex] 699 | 700 | inputs = pose.poseWeight.inputs(p=True) 701 | if inputs: 702 | inputs[0] // pose.poseWeight 703 | pose.poseWeight.set(1) 704 | 705 | points = pm.api.MPointArray() 706 | meshFn.getPoints(points) 707 | 708 | offsets = [0]*points.length() 709 | for i in range(points.length()): 710 | offsets[i] = (points[i] - basePoints[i]).length() 711 | sumOffsets[i] += offsets[i]**2 712 | 713 | offsetsList.append(offsets) 714 | 715 | if inputs: 716 | inputs[0] >> pose.poseWeight 717 | else: 718 | pose.poseWeight.set(0) 719 | 720 | else: 721 | pm.warning(f"Cannot find '{poseName}' pose") 722 | 723 | blendShape.envelope.set(1) 724 | 725 | targetGeo = pm.PyNode(pm.sculptTarget(blendShape, e=True, regenerate=True, target=targetIndex)[0]) 726 | targetIndices, targetDeltas = utils.getBlendShapeTargetDelta(blendShape, targetIndex) 727 | targetComponents = [f"vtx[{v}]" for v in targetIndices] 728 | 729 | targetDeltaList = [] 730 | for poseName in poses: # per pose 731 | poseTargetIndex = findTargetIndexByName(blendShape, poseName) 732 | if poseTargetIndex is None: 733 | poseTargetIndex = findAvailableTargetIndex(blendShape) 734 | tmp = pm.duplicate(targetGeo)[0] 735 | tmp.rename(poseName) 736 | pm.blendShape(blendShape, e=True, t=[mesh, poseTargetIndex, tmp, 1]) 737 | pm.delete(tmp) 738 | 739 | poseTargetDeltas = [pm.dt.Point(p) for p in targetDeltas] # copy delta for each pose target, indices won't be changed 740 | targetDeltaList.append((poseTargetIndex, poseTargetDeltas)) 741 | 742 | poseIndex = self.findPoseIndexByName(poseName) 743 | if poseIndex is not None: 744 | self.node.poses[poseIndex].poseWeight >> blendShape.w[poseTargetIndex] 745 | 746 | pm.delete(targetGeo) 747 | 748 | for i, (poseTargetIndex, targetDeltas) in enumerate(targetDeltaList): # i - 0..len(poses) 749 | for k, idx in enumerate(targetIndices): 750 | w = offsetsList[i][idx]**2 / sumOffsets[idx] 751 | targetDeltas[k] *= w 752 | 753 | blendShape.inputTarget[0].inputTargetGroup[poseTargetIndex].inputTargetItem[6000].inputPointsTarget.set(len(targetDeltas), *targetDeltas, type="pointArray") 754 | blendShape.inputTarget[0].inputTargetGroup[poseTargetIndex].inputTargetItem[6000].inputComponentsTarget.set(len(targetComponents), *targetComponents, type="componentList") 755 | 756 | @utils.undoBlock 757 | def addJointsAsLayer(self, rootJoint, shouldTransferSkin=True): 758 | """Add joint hierarchy as layer with skin transfer option.""" 759 | rootJoint = pm.PyNode(rootJoint) 760 | joints = [rootJoint] + rootJoint.listRelatives(type="joint", ad=True, c=True) 761 | 762 | skelJoints = {j: utils.matchJoint(j) for j in joints} 763 | 764 | # set corresponding parents 765 | for j in skelJoints: 766 | parent = j.getParent() 767 | if parent in skelJoints: 768 | skelJoints[parent] | skelJoints[j] 769 | 770 | if rootJoint.getParent(): 771 | rootLocalName = rootJoint.name().split("|")[-1] 772 | grp = pm.createNode("transform", n=rootLocalName + "_parent_transform") 773 | pm.parentConstraint(rootJoint.getParent(), grp) 774 | grp | skelJoints[rootJoint] 775 | 776 | self.addJoints(skelJoints.values()) 777 | 778 | # set base matrices 779 | for old, new in skelJoints.items(): 780 | idx = self.getJointIndex(new) 781 | old.m >> self.node.baseMatrices[idx] 782 | 783 | if shouldTransferSkin: 784 | utils.transferSkin(old, new) 785 | 786 | # update skin clusters 787 | if shouldTransferSkin: 788 | skinClusters = pm.ls(type="skinCluster") 789 | if skinClusters: 790 | pm.dgdirty(skinClusters) 791 | 792 | return skelJoints[rootJoint] 793 | 794 | def toJson(self) -> Dict[str, Any]: 795 | """Export skeleposer data to JSON format.""" 796 | data = {"joints":{}, "baseMatrices":{}, "poses": {}, "directories": {}, "splitPosesData": {}} 797 | 798 | for j in self.node.joints: 799 | inputs = j.inputs() 800 | if inputs: 801 | data["joints"][j.index()] = inputs[0].name() 802 | 803 | for bm in self.node.baseMatrices: 804 | a = f"{self.node}.baseMatrices[{bm.index()}]" 805 | data["baseMatrices"][bm.index()] = [utils.shortenValue(v) for v in cmds.getAttr(a)] 806 | 807 | for d in self.node.directories: 808 | data["directories"][d.index()] = {} 809 | directoryData = data["directories"][d.index()] 810 | 811 | directoryData["directoryName"] = d.directoryName.get() or "" 812 | directoryData["directoryWeight"] = d.directoryWeight.get() 813 | directoryData["directoryParentIndex"] = d.directoryParentIndex.get() 814 | directoryData["directoryChildrenIndices"] = d.directoryChildrenIndices.get() 815 | 816 | for p in self.node.poses: 817 | data["poses"][p.index()] = {} 818 | poseData = data["poses"][p.index()] 819 | 820 | poseData["poseName"] = p.poseName.get() 821 | poseData["poseWeight"] = p.poseWeight.get() 822 | poseData["poseDirectoryIndex"] = p.poseDirectoryIndex.get() 823 | poseData["poseBlendMode"] = p.poseBlendMode.get() 824 | 825 | # corrects 826 | poseWeightInputs = p.poseWeight.inputs(type="combinationShape") 827 | if poseWeightInputs: 828 | combinationShapeNode = poseWeightInputs[0] 829 | poseData["corrects"] = [iw.getParent().index() for iw in combinationShapeNode.inputWeight.inputs(p=True) if iw.getParent()] 830 | 831 | # inbetween 832 | poseWeightInputs = p.poseWeight.inputs(type="remapValue") 833 | if poseWeightInputs: 834 | remapNode = poseWeightInputs[0] 835 | inputValueInputs = remapNode.inputValue.inputs(p=True) 836 | if inputValueInputs and inputValueInputs[0].getParent() and inputValueInputs[0].node() == self.node: 837 | sourcePoseIndex = inputValueInputs[0].getParent().index() 838 | 839 | points = [] 840 | for va in remapNode.value: 841 | x, y, _ = va.get() 842 | points.append((x,y)) 843 | points = sorted(points, key=lambda p: p[0]) # sort by X 844 | poseData["inbetween"] = [sourcePoseIndex, points] 845 | 846 | poseData["poseDeltaMatrices"] = {} 847 | 848 | for m in p.poseDeltaMatrices: 849 | a = f"{self.node}.poses[{p.index()}].poseDeltaMatrices[{m.index()}]" 850 | poseData["poseDeltaMatrices"][m.index()] = [utils.shortenValue(v) for v in cmds.getAttr(a)] 851 | 852 | data["splitPosesData"] = json.loads(self.node.splitPosesData.get() or "") 853 | 854 | return data 855 | 856 | def fromJson(self, data: Dict[str, Any]): 857 | """Import skeleposer data from JSON format.""" 858 | self.clearAll() 859 | 860 | for idx in data["joints"]: 861 | j = data["joints"][idx] 862 | if pm.objExists(j): 863 | j = pm.PyNode(j) 864 | j.message >> self.node.joints[idx] 865 | j.jo >> self.node.jointOrients[idx] 866 | else: 867 | pm.warning("fromJson: cannot find "+j) 868 | 869 | for idx, m in data["baseMatrices"].items(): 870 | a = f"{self.node}.baseMatrices[{idx}]" 871 | cmds.setAttr(a, m, type="matrix") 872 | 873 | for idx, d in data["directories"].items(): 874 | a = self.node.directories[idx] 875 | a.directoryName.set(str(d["directoryName"])) 876 | a.directoryWeight.set(d["directoryWeight"]) 877 | a.directoryParentIndex.set(d["directoryParentIndex"]) 878 | a.directoryChildrenIndices.set(d["directoryChildrenIndices"], type="Int32Array") 879 | 880 | for idx, p in data["poses"].items(): 881 | a = self.node.poses[idx] 882 | a.poseName.set(str(p["poseName"])) 883 | a.poseWeight.set(p["poseWeight"]) 884 | a.poseDirectoryIndex.set(p["poseDirectoryIndex"]) 885 | a.poseBlendMode.set(p["poseBlendMode"]) 886 | 887 | for m_idx, m in p["poseDeltaMatrices"].items(): 888 | a = f"{self.node}.poses[{idx}].poseDeltaMatrices[{m_idx}]" 889 | cmds.setAttr(a, m, type="matrix") 890 | 891 | if "corrects" in p: # when corrects found 892 | self.makeCorrectNode(idx, p["corrects"]) 893 | 894 | if "inbetween" in p: # setup inbetween 895 | sourcePoseIndex, points = p["inbetween"] 896 | remapValue = self.makeInbetweenNode(idx, sourcePoseIndex) 897 | for i, pnt in enumerate(points): 898 | remapValue.value[i].set(pnt[0], pnt[1], 1) # linear interpolation 899 | 900 | self.node.splitPosesData.set(json.dumps(data.get("splitPosesData", ""))) 901 | 902 | def getWorldPoses(self, joints=None): 903 | """Get poses in world space coordinates.""" 904 | dagPose = self.dagPose() 905 | 906 | # cache joints matrices 907 | jointsData = {} 908 | for j in self.getJoints(): 909 | idx = self.getJointIndex(j) 910 | 911 | bmat = utils.dagPose_getWorldMatrix(dagPose, j) 912 | pmat = utils.dagPose_getParentMatrix(dagPose, j) 913 | jointsData[idx] = {"joint":j, "baseMatrix":bmat, "parentMatrix":pmat} 914 | 915 | data = {} 916 | for pose in self.node.poses: 917 | blendMode = pose.poseBlendMode.get() 918 | 919 | deltas = {} 920 | for delta in pose.poseDeltaMatrices: 921 | jdata = jointsData[delta.index()] 922 | 923 | if not joints or jdata["joint"] in joints: 924 | dm = delta.get() 925 | wm = utils.applyDelta(dm, jdata["baseMatrix"]) if blendMode == 0 else om.MMatrix(dm) * jdata["parentMatrix"] 926 | deltas[delta.index()] = wm.tolist() 927 | 928 | if deltas: 929 | data[pose.index()] = deltas 930 | 931 | return data 932 | 933 | @utils.undoBlock 934 | def setWorldPoses(self, poses): 935 | """Set poses from world space coordinates.""" 936 | dagPose = self.dagPose() 937 | 938 | # cache joints matrices 939 | jointsData = {} 940 | for j in self.getJoints(): 941 | idx = self.getJointIndex(j) 942 | 943 | bmat = utils.dagPose_getWorldMatrix(dagPose, j) 944 | pmat = utils.dagPose_getParentMatrix(dagPose, j) 945 | jointsData[idx] = {"joint":j, "baseMatrix":bmat, "parentInverseMatrix":pmat.inverse()} 946 | 947 | for pi in poses: 948 | blendMode = self.node.poses[pi].poseBlendMode.get() 949 | 950 | for di in poses[pi]: 951 | jdata = jointsData[di] 952 | delta = utils.getDelta(poses[pi][di], jdata["baseMatrix"]) if blendMode == 0 else om.MMatrix(poses[pi][di]) * jdata["parentInverseMatrix"] 953 | self.node.poses[pi].poseDeltaMatrices[di].set(delta) 954 | -------------------------------------------------------------------------------- /mayaModule/skeleposer/scripts/skeleposerEditor/ui.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import json 4 | from contextlib import contextmanager 5 | 6 | from PySide6.QtGui import * 7 | from PySide6.QtCore import * 8 | from PySide6.QtWidgets import * 9 | 10 | import maya.api.OpenMaya as om 11 | import pymel.core as pm 12 | import maya.cmds as cmds 13 | 14 | from . import utils 15 | from .core import Skeleposer 16 | 17 | from shiboken6 import wrapInstance 18 | mayaMainWindow = wrapInstance(int(pm.api.MQtUtil.mainWindow()), QMainWindow) 19 | 20 | RootDirectory = os.path.dirname(__file__) 21 | 22 | def getQWidgetFromMelControl(ctrl): 23 | ptr = pm.api.MQtUtil.findControl(ctrl) 24 | return wrapInstance(int(ptr), QWidget) 25 | 26 | @utils.undoBlock 27 | def editButtonClicked(btn, item): 28 | global editPoseIndex 29 | 30 | w = skel.node.poses[item.poseIndex].poseWeight.get() 31 | 32 | if editPoseIndex is None: 33 | skel.beginEditPose(item.poseIndex) 34 | btn.setStyleSheet("background-color: #aaaa55") 35 | mainWindow.toolsWidget.show() 36 | 37 | editPoseIndex = item.poseIndex 38 | 39 | elif editPoseIndex == item.poseIndex: 40 | skel.endEditPose() 41 | btn.setStyleSheet("") 42 | mainWindow.toolsWidget.hide() 43 | 44 | editPoseIndex = None 45 | 46 | def setItemWidgets(item): 47 | tw = item.treeWidget() 48 | 49 | if item.directoryIndex is not None: 50 | attrWidget = getQWidgetFromMelControl(cmds.attrFieldSliderGrp(at=skel.node.directories[item.directoryIndex].directoryWeight.name(), min=0, max=1, l="", pre=2, cw3=[0,40,100])) 51 | attrWidget.children()[3].setStyleSheet("background-color: #333333; border: 1px solid #555555") 52 | tw.setItemWidget(item, 1, attrWidget) 53 | 54 | elif item.poseIndex is not None: 55 | attrWidget = getQWidgetFromMelControl(cmds.attrFieldSliderGrp(at=skel.node.poses[item.poseIndex].poseWeight.name(),min=0, max=2, smn=0, smx=1, l="", pre=2, cw3=[0,40,100])) 56 | attrWidget.children()[3].setStyleSheet("background-color: #333333; border: 1px solid #555555") 57 | tw.setItemWidget(item, 1, attrWidget) 58 | 59 | editBtn = QPushButton("Edit", parent=tw) 60 | editBtn.setFixedWidth(50) 61 | editBtn.clicked.connect(lambda btn=editBtn, item=item: editButtonClicked(btn, item)) 62 | tw.setItemWidget(item, 2, editBtn) 63 | 64 | driver = utils.getRemapActualWeightInput(skel.node.poses[item.poseIndex].poseWeight) 65 | if driver: 66 | if pm.objectType(driver) == "combinationShape": 67 | names = [p.parent().poseName.get() for p in driver.node().inputWeight.inputs(p=True, type="skeleposer")] 68 | label = "correct: " + ", ".join(names) 69 | 70 | elif pm.objectType(driver) == "skeleposer": 71 | if driver.longName().endswith(".poseWeight"): 72 | label = "inbetween: "+driver.parent().poseName.get() 73 | else: 74 | label = driver.longName() 75 | else: 76 | label = driver.name() 77 | else: 78 | label = "" 79 | 80 | changeDriverBtn = ChangeButtonWidget(item, label, parent=tw) 81 | tw.setItemWidget(item, 3, changeDriverBtn) 82 | 83 | def getAllParents(item): 84 | allParents = [] 85 | 86 | parent = item.parent() 87 | if parent: 88 | allParents.append(parent) 89 | allParents += getAllParents(parent) 90 | 91 | return allParents[::-1] 92 | 93 | def centerWindow(w): 94 | # center the window on the screen 95 | qr = w.frameGeometry() 96 | cp = QApplication.primaryScreen().availableGeometry().center() 97 | qr.moveCenter(cp) 98 | w.move(qr.topLeft()) 99 | 100 | def updateItemVisuals(item): 101 | if item.poseIndex is not None: 102 | enabled = skel.node.poses[item.poseIndex].poseEnabled.get() 103 | blendMode = skel.node.poses[item.poseIndex].poseBlendMode.get() 104 | if blendMode == 0: # relative 105 | item.setForeground(0, QColor(200, 200, 200) if enabled else QColor(110, 110,110)) 106 | 107 | elif blendMode == 1: # replace 108 | item.setForeground(0, QColor(128,128,255) if enabled else QColor(110, 110, 110)) 109 | 110 | font = item.font(0) 111 | font.setStrikeOut(False if enabled else True) 112 | item.setFont(0,font) 113 | 114 | elif item.directoryIndex is not None: 115 | font = item.font(0) 116 | font.setBold(True) 117 | item.setFont(0,font) 118 | 119 | def makePoseItem(poseIndex): 120 | item = QTreeWidgetItem([skel.node.poses[poseIndex].poseName.get() or ""]) 121 | item.setIcon(0, QIcon(RootDirectory+"/icons/pose.png")) 122 | item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled) 123 | item.setToolTip(0, f".poses[{poseIndex}]") 124 | item.poseIndex = poseIndex 125 | item.directoryIndex = None 126 | 127 | updateItemVisuals(item) 128 | return item 129 | 130 | def makeDirectoryItem(directoryIndex): 131 | item = QTreeWidgetItem([skel.node.directories[directoryIndex].directoryName.get() or ""]) 132 | item.setIcon(0, QIcon(RootDirectory+"/icons/directory.png")) 133 | item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled) 134 | item.setToolTip(0, f".directories[{directoryIndex}]") 135 | item.poseIndex = None 136 | item.directoryIndex = directoryIndex 137 | 138 | updateItemVisuals(item) 139 | return item 140 | 141 | class ChangeButtonWidget(QWidget): 142 | def __init__(self, item, label=" ", **kwargs): 143 | super().__init__(**kwargs) 144 | 145 | self.item = item 146 | 147 | layout = QHBoxLayout() 148 | layout.setContentsMargins(5,0,0,0) 149 | self.setLayout(layout) 150 | 151 | self.labelWidget = QLabel(label) 152 | 153 | changeBtn = QPushButton("Change") 154 | changeBtn.clicked.connect(self.changeDriver) 155 | 156 | layout.addWidget(changeBtn) 157 | layout.addWidget(self.labelWidget) 158 | layout.addStretch() 159 | 160 | def changeDriver(self): 161 | driver = utils.getRemapActualWeightInput(skel.node.poses[self.item.poseIndex].poseWeight) 162 | 163 | remapNode = skel.node.poses[self.item.poseIndex].poseWeight.inputs(type="remapValue") 164 | limit = remapNode[0].inputMax.get() if remapNode else 1 165 | 166 | changeDialog = ChangeDriverDialog(driver, limit, parent=mainWindow) 167 | changeDialog.accepted.connect(self.updateDriver) 168 | changeDialog.cleared.connect(self.clearDriver) 169 | changeDialog.show() 170 | 171 | def clearDriver(self): 172 | inputs = skel.node.poses[self.item.poseIndex].poseWeight.inputs(p=True) 173 | if inputs: 174 | driver = inputs[0] 175 | 176 | if pm.objectType(driver.node()) in ["remapValue", "unitConversion", "combinationShape"]: 177 | pm.delete(driver.node()) 178 | else: 179 | pm.disconnectAttr(driver, skel.node.poses[self.item.poseIndex].poseWeight) 180 | 181 | self.labelWidget.setText("") 182 | 183 | def updateDriver(self, newDriver): 184 | self.clearDriver() 185 | newDriver >> skel.node.poses[self.item.poseIndex].poseWeight 186 | self.labelWidget.setText(utils.getRemapActualWeightInput(skel.node.poses[self.item.poseIndex].poseWeight).name()) 187 | 188 | class SearchReplaceWindow(QDialog): 189 | replaceClicked = Signal(str, str) 190 | 191 | def __init__(self, **kwargs): 192 | super().__init__(**kwargs) 193 | self.setWindowTitle("Search/Replace") 194 | layout = QGridLayout() 195 | layout.setDefaultPositioning(2, Qt.Horizontal) 196 | self.setLayout(layout) 197 | 198 | self.searchWidget = QLineEdit("L_") 199 | self.replaceWidget = QLineEdit("R_") 200 | 201 | btn = QPushButton("Replace") 202 | btn.clicked.connect(self.btnClicked) 203 | 204 | layout.addWidget(QLabel("Search")) 205 | layout.addWidget(self.searchWidget) 206 | layout.addWidget(QLabel("Replace")) 207 | layout.addWidget(self.replaceWidget) 208 | layout.addWidget(QLabel("")) 209 | layout.addWidget(btn) 210 | 211 | def btnClicked(self): 212 | self.replaceClicked.emit(self.searchWidget.text(), self.replaceWidget.text()) 213 | self.accept() 214 | 215 | class TreeWidget(QTreeWidget): 216 | def __init__(self, **kwargs): 217 | super().__init__(**kwargs) 218 | 219 | self.clipboard = [] 220 | 221 | self.searchWindow = SearchReplaceWindow(parent=self) 222 | self.searchWindow.replaceClicked.connect(self.searchAndReplace) 223 | 224 | self.setHeaderLabels(["Name", "Value", "Edit", "Driver"]) 225 | self.header().setSectionResizeMode(QHeaderView.ResizeToContents) 226 | 227 | self.setSelectionMode(QAbstractItemView.ExtendedSelection) 228 | self.setDragEnabled(True) 229 | self.setDragDropMode(QAbstractItemView.InternalMove) 230 | self.setDropIndicatorShown(True) 231 | self.setAcceptDrops(True) 232 | 233 | self.itemChanged.connect(lambda item, idx=None:self.treeItemChanged(item)) 234 | 235 | def checkSkeleposer(self): 236 | if not skel or not skel.node.exists(): 237 | pm.warning("Select skeleposer node") 238 | return False 239 | return True 240 | 241 | def addItemsFromSkeleposerData(self, parentItem, skelData): 242 | for ch in skelData["children"]: 243 | if isinstance(ch, dict): 244 | item = makeDirectoryItem(ch["directoryIndex"]) 245 | parentItem.addChild(item) 246 | self.addItemsFromSkeleposerData(item, ch) 247 | 248 | else: 249 | item = makePoseItem(ch) 250 | parentItem.addChild(item) 251 | 252 | def updateTree(self): 253 | self.clear() 254 | self.addItemsFromSkeleposerData(self.invisibleRootItem(), skel.getDirectoryData()) 255 | for ch in self.getChildrenRecursively(self.invisibleRootItem()): 256 | setItemWidgets(ch) 257 | 258 | def getChildrenRecursively(self, item, pose=True, directory=True): 259 | children = [] 260 | for i in range(item.childCount()): 261 | ch = item.child(i) 262 | 263 | if ch.poseIndex is not None and not pose: 264 | continue 265 | 266 | if ch.directoryIndex is not None and not directory: 267 | continue 268 | 269 | children.append(ch) 270 | children += self.getChildrenRecursively(ch, pose, directory) 271 | 272 | return children 273 | 274 | def getValidParent(self): 275 | selectedItems = self.selectedItems() 276 | if selectedItems: 277 | last = selectedItems[-1] 278 | return last if last.directoryIndex is not None else last.parent() 279 | 280 | @contextmanager 281 | def keepState(self): 282 | selectedIndices = [] # poses > 0, directories < 0 283 | for sel in self.selectedItems(): 284 | if sel.poseIndex is not None: 285 | selectedIndices.append(sel.poseIndex) 286 | elif sel.directoryIndex is not None: 287 | selectedIndices.append(-sel.directoryIndex) 288 | 289 | expanded = {} 290 | for ch in self.getChildrenRecursively(self.invisibleRootItem(), pose=False): 291 | expanded[ch.directoryIndex] = ch.isExpanded() 292 | 293 | yield 294 | 295 | for ch in self.getChildrenRecursively(self.invisibleRootItem()): 296 | setItemWidgets(ch) 297 | 298 | if ch.directoryIndex in expanded: 299 | ch.setExpanded(expanded[ch.directoryIndex]) 300 | 301 | if (ch.poseIndex is not None and ch.poseIndex in selectedIndices) or\ 302 | (ch.directoryIndex is not None and -ch.directoryIndex in selectedIndices): 303 | ch.setSelected(True) 304 | 305 | def importSkeleposer(self): 306 | if not self.checkSkeleposer(): 307 | return 308 | 309 | path, _ = QFileDialog.getOpenFileName(self, "Import skeleposer", "", "*.json") 310 | if path: 311 | with open(path, "r") as f: 312 | data = json.load(f) 313 | skel.fromJson(data) 314 | self.updateTree() 315 | mainWindow.splitPoseWidget.loadFromSkeleposer() 316 | 317 | def saveSkeleposer(self): 318 | if not self.checkSkeleposer(): 319 | return 320 | 321 | path, _ = QFileDialog.getSaveFileName(self, "Export skeleposer", "", "*.json") 322 | if path: 323 | with open(path, "w") as f: 324 | json.dump(skel.toJson(), f) 325 | 326 | @utils.undoBlock 327 | def muteItems(self): 328 | if not self.checkSkeleposer(): 329 | return 330 | 331 | for sel in self.selectedItems(): 332 | if sel.poseIndex is not None: 333 | a = skel.node.poses[sel.poseIndex].poseEnabled 334 | a.set(not a.get()) 335 | updateItemVisuals(sel) 336 | 337 | def searchAndReplace(self, searchText, replaceText): 338 | if not self.checkSkeleposer(): 339 | return 340 | 341 | for sel in self.selectedItems(): 342 | sel.setText(0, sel.text(0).replace(searchText, replaceText)) 343 | 344 | @utils.undoBlock 345 | def addInbetweenPose(self): 346 | if not self.checkSkeleposer(): 347 | return 348 | 349 | for sel in self.selectedItems(): 350 | if sel.poseIndex is not None: 351 | item = self.makePose(sel.text(0)+"_inbtw", self.getValidParent()) 352 | skel.makeInbetweenNode(item.poseIndex, sel.poseIndex) 353 | setItemWidgets(item) 354 | 355 | @utils.undoBlock 356 | def setPoseBlendMode(self, blend): 357 | if not self.checkSkeleposer(): 358 | return 359 | 360 | for sel in self.selectedItems(): 361 | if sel.poseIndex is not None: 362 | skel.changePoseBlendMode(sel.poseIndex, blend) 363 | updateItemVisuals(sel) 364 | 365 | def collapseOthers(self): 366 | selectedItems = self.selectedItems() 367 | if not selectedItems: 368 | return 369 | 370 | allParents = [] 371 | for sel in selectedItems: 372 | allParents += getAllParents(sel) 373 | 374 | allParents = set(allParents) 375 | for ch in self.getChildrenRecursively(self.invisibleRootItem()): 376 | if ch not in allParents: 377 | ch.setExpanded(False) 378 | 379 | @utils.undoBlock 380 | def groupSelected(self): 381 | if not self.checkSkeleposer(): 382 | return 383 | 384 | dirItem = self.makeDirectory(parent=self.getValidParent()) 385 | 386 | for sel in self.selectedItems(): 387 | (sel.parent() or self.invisibleRootItem()).removeChild(sel) 388 | dirItem.addChild(sel) 389 | self.treeItemChanged(sel) 390 | 391 | dirItem.setSelected(True) 392 | 393 | def copyPoseJointsDelta(self, joints=None): 394 | if not self.checkSkeleposer(): 395 | return 396 | 397 | currentItem = self.currentItem() 398 | if currentItem and currentItem.poseIndex is not None: 399 | self.clipboard = {"poseIndex": currentItem.poseIndex, "joints":joints} 400 | 401 | @utils.undoBlock 402 | def pastePoseDelta(self): 403 | if not self.checkSkeleposer(): 404 | return 405 | 406 | if self.clipboard: 407 | currentItem = self.currentItem() 408 | if currentItem and currentItem.poseIndex is not None: 409 | skel.copyPose(self.clipboard["poseIndex"], currentItem.poseIndex, self.clipboard["joints"]) 410 | 411 | @utils.undoBlock 412 | def flipItemsOnOppositePose(self, items=None): 413 | if not self.checkSkeleposer(): 414 | return 415 | 416 | selectedItems = self.selectedItems() 417 | if not selectedItems and not items: 418 | return 419 | 420 | doUpdateUI = False 421 | 422 | for sel in items or selectedItems: 423 | if sel.poseIndex is not None: 424 | sourcePoseIndex = sel.poseIndex 425 | sourcePoseName = sel.text(0) 426 | 427 | destPoseName = utils.findSymmetricName(sourcePoseName) 428 | if destPoseName != sourcePoseName: 429 | destPoseIndex = skel.findPoseIndexByName(destPoseName) 430 | if not destPoseIndex: 431 | destPoseIndex = skel.makePose(destPoseName) 432 | doUpdateUI = True 433 | 434 | skel.copyPose(sourcePoseIndex, destPoseIndex) 435 | skel.flipPose(destPoseIndex) 436 | 437 | elif sel.directoryIndex is not None: 438 | self.flipItemsOnOppositePose(self.getChildrenRecursively(sel)) 439 | 440 | if doUpdateUI: 441 | mainWindow.treeWidget.updateTree() 442 | 443 | @utils.undoBlock 444 | def mirrorItems(self, items=None): 445 | if not self.checkSkeleposer(): 446 | return 447 | 448 | for sel in items or self.selectedItems(): 449 | if sel.poseIndex is not None: 450 | skel.mirrorPose(sel.poseIndex) 451 | 452 | elif sel.directoryIndex is not None: 453 | self.mirrorItems(self.getChildrenRecursively(sel)) 454 | 455 | @utils.undoBlock 456 | def flipItems(self, items=None): 457 | if not self.checkSkeleposer(): 458 | return 459 | 460 | for sel in items or self.selectedItems(): 461 | if sel.poseIndex is not None: 462 | skel.flipPose(sel.poseIndex) 463 | 464 | elif sel.directoryIndex is not None: 465 | self.flipItems(self.getChildrenRecursively(sel)) 466 | 467 | @utils.undoBlock 468 | def resetWeights(self): 469 | if not self.checkSkeleposer(): 470 | return 471 | 472 | for p in skel.node.poses: 473 | if p.poseWeight.isSettable(): 474 | p.poseWeight.set(0) 475 | 476 | @utils.undoBlock 477 | def resetJoints(self): 478 | if not self.checkSkeleposer(): 479 | return 480 | 481 | joints = pm.ls(sl=True, type=["joint", "transform"]) 482 | for sel in self.selectedItems(): 483 | if sel.poseIndex is not None: 484 | skel.resetDelta(sel.poseIndex, joints) 485 | 486 | @utils.undoBlock 487 | def duplicateItems(self, items=None, parent=None): 488 | if not self.checkSkeleposer(): 489 | return 490 | 491 | parent = parent or self.getValidParent() 492 | for item in items or self.selectedItems(): 493 | if item.poseIndex is not None: 494 | newItem = self.makePose(item.text(0), parent) 495 | skel.copyPose(item.poseIndex, newItem.poseIndex) 496 | 497 | elif item.directoryIndex is not None: 498 | newItem = self.makeDirectory(item.text(0), parent) 499 | 500 | for i in range(item.childCount()): 501 | self.duplicateItems([item.child(i)], newItem) 502 | 503 | item.setSelected(False) 504 | newItem.setSelected(True) 505 | 506 | @utils.undoBlock 507 | def setupWeightFromSelection(self): 508 | if not self.checkSkeleposer(): 509 | return 510 | 511 | currentItem = self.currentItem() 512 | if currentItem and currentItem.poseIndex is not None: 513 | indices = [item.poseIndex for item in self.selectedItems() if item.poseIndex is not None and item is not currentItem] 514 | skel.makeCorrectNode(currentItem.poseIndex, indices) 515 | setItemWidgets(currentItem) 516 | 517 | @utils.undoBlock 518 | def createInbetweenFromSelection(self): 519 | if not self.checkSkeleposer(): 520 | return 521 | 522 | currentItem = self.currentItem() 523 | if currentItem and currentItem.poseIndex is not None: 524 | indices = [item.poseIndex for item in self.selectedItems() if item.poseIndex is not None and item is not currentItem] 525 | skel.makeInbetweenNode(currentItem.poseIndex, indices[-1]) 526 | setItemWidgets(currentItem) 527 | 528 | @utils.undoBlock 529 | def addCorrectivePose(self): 530 | if not self.checkSkeleposer(): 531 | return 532 | 533 | selectedItems = self.selectedItems() 534 | if selectedItems: 535 | indices = [item.poseIndex for item in selectedItems if item.poseIndex is not None] 536 | names = [item.text(0) for item in selectedItems if item.poseIndex is not None] 537 | 538 | item = self.makePose("_".join(names)+"_correct", self.getValidParent()) 539 | skel.makeCorrectNode(item.poseIndex, indices) 540 | setItemWidgets(item) 541 | 542 | @utils.undoBlock 543 | def removeItems(self): 544 | if not self.checkSkeleposer(): 545 | return 546 | 547 | for item in self.selectedItems(): 548 | if item.directoryIndex is not None: # remove directory 549 | skel.removeDirectory(item.directoryIndex) 550 | (item.parent() or self.invisibleRootItem()).removeChild(item) 551 | 552 | elif item.poseIndex is not None: 553 | skel.removePose(item.poseIndex) 554 | (item.parent() or self.invisibleRootItem()).removeChild(item) 555 | 556 | def makePose(self, name="Pose", parent=None): 557 | if not self.checkSkeleposer(): 558 | return 559 | 560 | idx = skel.makePose(name) 561 | item = makePoseItem(idx) 562 | 563 | if parent: 564 | parent.addChild(item) 565 | skel.parentPose(idx, parent.directoryIndex) 566 | else: 567 | self.invisibleRootItem().addChild(item) 568 | 569 | setItemWidgets(item) 570 | return item 571 | 572 | def makeDirectory(self, name="Group", parent=None): 573 | if not self.checkSkeleposer(): 574 | return 575 | 576 | parentIndex = parent.directoryIndex if parent else 0 577 | idx = skel.makeDirectory(name, parentIndex) 578 | 579 | item = makeDirectoryItem(idx) 580 | (parent or self.invisibleRootItem()).addChild(item) 581 | 582 | setItemWidgets(item) 583 | return item 584 | 585 | @utils.undoBlock 586 | def addJoints(self): 587 | if not self.checkSkeleposer(): 588 | return 589 | 590 | ls = pm.ls(sl=True, type=["joint", "transform"]) 591 | if ls: 592 | skel.addJoints(ls) 593 | else: 594 | pm.warning("Select joints to add") 595 | 596 | @utils.undoBlock 597 | def removeJoints(self): 598 | if not self.checkSkeleposer(): 599 | return 600 | 601 | ls = pm.ls(sl=True, type=["joint", "transform"]) 602 | if ls: 603 | skel.removeJoints(ls) 604 | else: 605 | pm.warning("Select joints to remove") 606 | 607 | def addJointsAsLayer(self): 608 | if not self.checkSkeleposer(): 609 | return 610 | 611 | ls = pm.ls(sl=True, type=["joint", "transform"]) 612 | if ls: 613 | skel.addJointsAsLayer(ls[0]) 614 | else: 615 | pm.warning("Select root joint to add as a layer") 616 | 617 | def reconnectOutputs(self): 618 | if not self.checkSkeleposer(): 619 | return 620 | 621 | skel.reconnectOutputs() 622 | 623 | def disconnectOutputs(self): 624 | if not self.checkSkeleposer(): 625 | return 626 | 627 | skel.disconnectOutputs() 628 | 629 | def updateBaseMatrices(self): 630 | if not self.checkSkeleposer(): 631 | return 632 | 633 | skel.updateBaseMatrices() 634 | 635 | @utils.undoBlock 636 | def treeItemChanged(self, item): 637 | if item.directoryIndex is not None: # directory 638 | skel.node.directories[item.directoryIndex].directoryName.set(item.text(0).strip()) 639 | 640 | parent = item.parent() 641 | realParent = parent or self.invisibleRootItem() 642 | skel.parentDirectory(item.directoryIndex, parent.directoryIndex if parent else 0, realParent.indexOfChild(item)) 643 | 644 | elif item.poseIndex is not None: 645 | skel.node.poses[item.poseIndex].poseName.set(item.text(0).strip()) 646 | 647 | parent = item.parent() 648 | realParent = parent or self.invisibleRootItem() 649 | skel.parentPose(item.poseIndex, parent.directoryIndex if parent else 0, realParent.indexOfChild(item)) 650 | 651 | setItemWidgets(item) 652 | 653 | def dragEnterEvent(self, event): 654 | if event.mouseButtons() == Qt.MiddleButton: 655 | QTreeWidget.dragEnterEvent(self, event) 656 | self.dragItems = self.selectedItems() 657 | 658 | def dragMoveEvent(self, event): 659 | QTreeWidget.dragMoveEvent(self, event) 660 | 661 | @utils.undoBlock 662 | def dropEvent(self, event): 663 | QTreeWidget.dropEvent(self, event) 664 | 665 | for item in sorted(self.dragItems, key=lambda x: -(x.parent() or self.invisibleRootItem()).indexOfChild(x)): # greater index first 666 | self.treeItemChanged(item) 667 | 668 | if item.directoryIndex is not None: # update widgets for all children 669 | for ch in self.getChildrenRecursively(item): 670 | setItemWidgets(ch) 671 | 672 | class BlendSliderWidget(QWidget): 673 | valueChanged = Signal(float) 674 | 675 | def __init__(self, **kwargs): 676 | super().__init__(**kwargs) 677 | 678 | layout = QHBoxLayout() 679 | layout.setMargin(0) 680 | self.setLayout(layout) 681 | 682 | self.textWidget = QLineEdit("1") 683 | self.textWidget.setFixedWidth(40) 684 | self.textWidget.setValidator(QDoubleValidator()) 685 | self.textWidget.editingFinished.connect(self.textChanged) 686 | 687 | self.sliderWidget = QSlider(Qt.Horizontal) 688 | self.sliderWidget.setValue(100) 689 | self.sliderWidget.setMinimum(0) 690 | self.sliderWidget.setMaximum(100) 691 | self.sliderWidget.setTracking(True) 692 | self.sliderWidget.sliderReleased.connect(self.sliderValueChanged) 693 | 694 | layout.addWidget(self.textWidget) 695 | layout.addWidget(self.sliderWidget) 696 | layout.addStretch() 697 | 698 | def textChanged(self): 699 | value = float(self.textWidget.text()) 700 | self.sliderWidget.setValue(value*100) 701 | self.valueChanged.emit(value) 702 | 703 | def sliderValueChanged(self): 704 | value = self.sliderWidget.value()/100.0 705 | self.textWidget.setText(str(value)) 706 | self.valueChanged.emit(value) 707 | 708 | class ToolsWidget(QWidget): 709 | def __init__(self, **kwargs): 710 | super().__init__(**kwargs) 711 | 712 | layout = QHBoxLayout() 713 | layout.setMargin(0) 714 | self.setLayout(layout) 715 | 716 | mirrorJointsBtn = QToolButton() 717 | mirrorJointsBtn.setToolTip("Mirror joints") 718 | mirrorJointsBtn.setAutoRaise(True) 719 | mirrorJointsBtn.clicked.connect(self.mirrorJoints) 720 | mirrorJointsBtn.setIcon(QIcon(RootDirectory+"/icons/mirror.png")) 721 | 722 | resetJointsBtn = QToolButton() 723 | resetJointsBtn.setToolTip("Reset to default") 724 | resetJointsBtn.setAutoRaise(True) 725 | resetJointsBtn.clicked.connect(lambda: skel.resetToBase(pm.ls(sl=True, type=["joint", "transform"]))) 726 | resetJointsBtn.setIcon(QIcon(RootDirectory+"/icons/reset.png")) 727 | 728 | self.blendSliderWidget = BlendSliderWidget() 729 | self.blendSliderWidget.valueChanged.connect(self.blendValueChanged) 730 | 731 | layout.addWidget(mirrorJointsBtn) 732 | layout.addWidget(resetJointsBtn) 733 | layout.addWidget(self.blendSliderWidget) 734 | layout.addStretch() 735 | 736 | @utils.undoBlock 737 | def blendValueChanged(self, v): 738 | for j in pm.ls(sl=True, type="transform"): 739 | if j in self.matrices: 740 | bm, m = self.matrices[j] 741 | j.setMatrix(utils.blendMatrices(bm, m, v)) 742 | 743 | def showEvent(self, event): 744 | self.matrices = {} 745 | poseIndex = skel._editPoseData["poseIndex"] 746 | for j in skel.getPoseJoints(poseIndex): 747 | self.matrices[j] = (skel.node.baseMatrices[skel.getJointIndex(j)].get(), utils.getLocalMatrix(j)) 748 | 749 | def mirrorJoints(self): 750 | dagPose = skel.dagPose() 751 | 752 | joints = sorted(pm.ls(sl=True, type=["joint", "transform"]), key=lambda j: len(j.getAllParents())) # sort by parents number, process parents first 753 | 754 | for L_joint in joints: 755 | R_joint = utils.findSymmetricName(str(L_joint), right=False) # skip right joints 756 | if not R_joint or not cmds.objExists(R_joint): 757 | continue 758 | 759 | R_joint = pm.PyNode(R_joint) 760 | 761 | L_m = om.MMatrix(L_joint.wm.get()) 762 | 763 | L_base = utils.dagPose_getWorldMatrix(dagPose, L_joint) 764 | R_base = utils.dagPose_getWorldMatrix(dagPose, R_joint) 765 | 766 | R_m = utils.mirrorMatrixByDelta(L_base, L_m, R_base) 767 | pm.xform(R_joint, ws=True, m=R_m) 768 | 769 | class NodeSelectorWidget(QWidget): 770 | nodeChanged = Signal(object) 771 | 772 | def __init__(self, **kwargs): 773 | super().__init__(**kwargs) 774 | 775 | layout = QHBoxLayout() 776 | layout.setMargin(0) 777 | self.setLayout(layout) 778 | 779 | self.lineEditWidget = QLineEdit() 780 | self.lineEditWidget.editingFinished.connect(lambda: self.nodeChanged.emit(self.getNode())) 781 | 782 | btn = QPushButton("<<") 783 | btn.setFixedWidth(30) 784 | btn.clicked.connect(self.getSelectedNode) 785 | 786 | layout.addWidget(self.lineEditWidget) 787 | layout.addWidget(btn) 788 | layout.setStretch(0,1) 789 | layout.setStretch(1,0) 790 | 791 | def getSelectedNode(self): 792 | ls = pm.ls(sl=True) 793 | if ls: 794 | self.lineEditWidget.setText(ls[0].name()) 795 | self.nodeChanged.emit(self.getNode()) 796 | 797 | def setNode(self, node): 798 | self.lineEditWidget.setText(str(node)) 799 | 800 | def getNode(self): 801 | n = self.lineEditWidget.text() 802 | return pm.PyNode(n) if pm.objExists(n) else "" 803 | 804 | class ChangeDriverDialog(QDialog): 805 | accepted = Signal(object) 806 | cleared = Signal() 807 | 808 | def __init__(self, plug=None, limit="1", **kwargs): 809 | super().__init__(**kwargs) 810 | 811 | self.setWindowTitle("Change driver") 812 | 813 | layout = QVBoxLayout() 814 | self.setLayout(layout) 815 | 816 | gridLayout = QGridLayout() 817 | gridLayout.setDefaultPositioning(2, Qt.Horizontal) 818 | 819 | self.nodeWidget = NodeSelectorWidget() 820 | self.nodeWidget.nodeChanged.connect(self.updateAttributes) 821 | 822 | self.attrsWidget = QComboBox() 823 | self.attrsWidget.setEditable(True) 824 | 825 | self.limitWidget = QLineEdit(str(limit)) 826 | self.limitWidget.setValidator(QDoubleValidator()) 827 | 828 | okBtn = QPushButton("Ok") 829 | okBtn.clicked.connect(self.createNode) 830 | 831 | clearBtn = QPushButton("Clear") 832 | clearBtn.clicked.connect(self.clearNode) 833 | 834 | gridLayout.addWidget(QLabel("Node")) 835 | gridLayout.addWidget(self.nodeWidget) 836 | 837 | gridLayout.addWidget(QLabel("Attribute")) 838 | gridLayout.addWidget(self.attrsWidget) 839 | 840 | gridLayout.addWidget(QLabel("Limit")) 841 | gridLayout.addWidget(self.limitWidget) 842 | 843 | hlayout = QHBoxLayout() 844 | hlayout.addWidget(okBtn) 845 | hlayout.addWidget(clearBtn) 846 | 847 | layout.addLayout(gridLayout) 848 | layout.addLayout(hlayout) 849 | 850 | self.updateAttributes() 851 | 852 | if plug: 853 | self.nodeWidget.setNode(plug.node().name()) 854 | self.attrsWidget.setCurrentText(plug.longName()) 855 | 856 | def clearNode(self): 857 | self.cleared.emit() 858 | self.close() 859 | 860 | def createNode(self): 861 | node = self.nodeWidget.getNode() 862 | attr = self.attrsWidget.currentText() 863 | 864 | if node and pm.objExists(node+"."+attr): 865 | ls = pm.ls(sl=True) 866 | 867 | limit = float(self.limitWidget.text()) 868 | suffix = "pos" if limit > 0 else "neg" 869 | 870 | n = pm.createNode("remapValue", n=node+"_"+attr+"_"+suffix+"_remapValue") 871 | node.attr(attr) >> n.inputValue 872 | n.inputMax.set(limit) 873 | self.accepted.emit(n.outValue) 874 | self.accept() 875 | 876 | pm.select(ls) 877 | else: 878 | pm.warning("createNode: "+node+"."+attr+" doesn't exist") 879 | 880 | def updateAttributes(self, node=None): 881 | currentText = self.attrsWidget.currentText() 882 | self.attrsWidget.clear() 883 | if node: 884 | attrs = ["translateX","translateY","translateZ","rotateX","rotateY","rotateZ","scaleX","scaleY","scaleZ"] 885 | attrs += [a.longName() for a in node.listAttr(s=True, se=True, ud=True)] 886 | self.attrsWidget.addItems(attrs) 887 | self.attrsWidget.setCurrentText(currentText) 888 | 889 | class WideSplitterHandle(QSplitterHandle): 890 | def __init__(self, orientation, parent, **kwargs): 891 | super().__init__(orientation, parent, **kwargs) 892 | 893 | def paintEvent(self, event): 894 | painter = QPainter(self) 895 | brush = QBrush() 896 | brush.setStyle(Qt.Dense6Pattern) 897 | brush.setColor(QColor(150, 150, 150)) 898 | painter.fillRect(event.rect(), QBrush(brush)) 899 | 900 | class WideSplitter(QSplitter): 901 | def __init__(self, orientation, **kwargs): 902 | super().__init__(orientation, **kwargs) 903 | self.setHandleWidth(7) 904 | 905 | def createHandle(self): 906 | return WideSplitterHandle(self.orientation(), self) 907 | 908 | class ListWithFilterWidget(QWidget): 909 | def __init__(self, **kwargs): 910 | super().__init__(**kwargs) 911 | 912 | layout = QVBoxLayout() 913 | layout.setContentsMargins(0,0,0,0) 914 | self.setLayout(layout) 915 | 916 | self.filterWidget = QLineEdit() 917 | self.filterWidget.textChanged.connect(self.filterChanged) 918 | 919 | self.listWidget = QListWidget() 920 | self.listWidget.itemSelectionChanged.connect(self.itemSelectionChanged) 921 | self.listWidget.setSelectionMode(QAbstractItemView.ExtendedSelection) 922 | 923 | layout.addWidget(self.filterWidget) 924 | layout.addWidget(self.listWidget) 925 | 926 | def filterChanged(self, text=None): 927 | tx = re.escape(str(text or self.filterWidget.text())) 928 | 929 | for i in range(self.listWidget.count()): 930 | item = self.listWidget.item(i) 931 | b = re.search(tx, str(item.text())) 932 | self.listWidget.setItemHidden(item, False if b else True) 933 | 934 | def itemSelectionChanged(self): 935 | pm.select([item.text() for item in self.listWidget.selectedItems()]) 936 | 937 | def clearItems(self): 938 | self.listWidget.clear() 939 | 940 | def addItems(self, items, bold=False, foreground=QColor(200, 200, 200)): 941 | font = QListWidgetItem().font() 942 | font.setBold(bold) 943 | 944 | for it in items: 945 | item = QListWidgetItem(it) 946 | item.setFont(font) 947 | item.setForeground(foreground) 948 | self.listWidget.addItem(item) 949 | self.filterChanged() 950 | 951 | class PoseTreeWidget(QTreeWidget): 952 | somethingChanged = Signal() 953 | 954 | def __init__(self, **kwargs): 955 | super().__init__(**kwargs) 956 | 957 | self.setHeaderLabels(["Name"]) 958 | self.header().setSectionResizeMode(QHeaderView.ResizeToContents) 959 | 960 | self.setSelectionMode(QAbstractItemView.ExtendedSelection) 961 | self.setDragEnabled(True) 962 | self.setDragDropMode(QAbstractItemView.InternalMove) 963 | self.setDropIndicatorShown(True) 964 | self.setAcceptDrops(True) 965 | 966 | def contextMenuEvent(self, event): 967 | if not skel or not skel.node.exists(): 968 | return 969 | 970 | menu = QMenu(self) 971 | menu.addAction("Add", self.addPoseItem) 972 | if self.selectedItems(): 973 | menu.addAction("Duplicate", self.duplicatePoseItem) 974 | menu.addSeparator() 975 | menu.addAction("Remove", self.removePoseItem) 976 | 977 | menu.popup(event.globalPos()) 978 | 979 | def keyPressEvent(self, event): 980 | ctrl = event.modifiers() & Qt.ControlModifier 981 | 982 | if ctrl: 983 | if event.key() == Qt.Key_D: 984 | 985 | self.duplicatePoseItem() 986 | 987 | elif event.key() == Qt.Key_Insert: 988 | self.addPoseItem() 989 | 990 | elif event.key() == Qt.Key_Delete: 991 | self.removePoseItem() 992 | 993 | else: 994 | super().keyPressEvent(event) 995 | 996 | def dragEnterEvent(self, event): 997 | if event.mouseButtons() == Qt.MiddleButton: 998 | QTreeWidget.dragEnterEvent(self, event) 999 | 1000 | def dragMoveEvent(self, event): 1001 | QTreeWidget.dragMoveEvent(self, event) 1002 | 1003 | def dropEvent(self, event): 1004 | QTreeWidget.dropEvent(self, event) 1005 | self.somethingChanged.emit() 1006 | 1007 | def makePoseItem(self, label="Pose"): 1008 | item = QTreeWidgetItem([label]) 1009 | item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled) 1010 | item.setData(0, Qt.UserRole, []) # patterns, data can be cloned 1011 | return item 1012 | 1013 | def addPoseItem(self): 1014 | selectedItems = self.selectedItems() 1015 | selectedItem = selectedItems[0] if selectedItems else None 1016 | 1017 | item = self.makePoseItem() 1018 | (selectedItem or self.invisibleRootItem()).addChild(item) 1019 | 1020 | self.somethingChanged.emit() 1021 | 1022 | def duplicatePoseItem(self): 1023 | for item in self.selectedItems(): 1024 | (item.parent() or self.invisibleRootItem()).addChild(item.clone()) 1025 | self.somethingChanged.emit() 1026 | 1027 | def removePoseItem(self): 1028 | for item in self.selectedItems(): 1029 | (item.parent() or self.invisibleRootItem()).removeChild(item) 1030 | self.somethingChanged.emit() 1031 | 1032 | def toList(self, item=None): # hierarchy to list like [[a, [b, [c, d]]] => a|bc|d 1033 | out = [] 1034 | 1035 | if not item: 1036 | item = self.invisibleRootItem() 1037 | else: 1038 | value = (item.text(0), item.data(0, Qt.UserRole)) 1039 | out.append(value) 1040 | 1041 | for i in range(item.childCount()): 1042 | ch = item.child(i) 1043 | lst = self.toList(ch) 1044 | 1045 | if ch.childCount() > 0: 1046 | out.append(lst) 1047 | else: 1048 | out.extend(lst) 1049 | 1050 | return out 1051 | 1052 | def fromList(self, data): # [[a, [b, [c, d]]]] => a|bc|d 1053 | def addItems(data, parent=None): 1054 | for ch in data: 1055 | itemLabel = ch[0][0] if isinstance(ch[0], list) else ch[0] 1056 | itemData = ch[0][1] if isinstance(ch[0], list) else ch[1] 1057 | 1058 | item = self.makePoseItem(itemLabel) 1059 | item.setData(0, Qt.UserRole, itemData) 1060 | (parent or self.invisibleRootItem()).addChild(item) 1061 | 1062 | if isinstance(ch[0], list): # item with children 1063 | addItems(ch[1:], item) 1064 | item.setExpanded(True) 1065 | 1066 | self.blockSignals(True) 1067 | self.clear() 1068 | addItems(data) 1069 | self.blockSignals(False) 1070 | 1071 | class PatternTableWidget(QTableWidget): 1072 | somethingChanged = Signal() 1073 | 1074 | def __init__(self, **kwargs): 1075 | super().__init__(**kwargs) 1076 | 1077 | self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) 1078 | self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) 1079 | self.setColumnCount(2) 1080 | self.setHorizontalHeaderLabels(["Pattern", "Value"]) 1081 | self.verticalHeader().hide() 1082 | 1083 | self.itemChanged.connect(self.validateItem) 1084 | 1085 | def contextMenuEvent(self, event): 1086 | menu = QMenu(self) 1087 | 1088 | menu.addAction("Add", self.addPatternItem) 1089 | if self.selectedItems(): 1090 | menu.addAction("Duplicate", self.duplicatePatternItem) 1091 | menu.addSeparator() 1092 | menu.addAction("Remove", self.removePatternItem) 1093 | 1094 | menu.popup(event.globalPos()) 1095 | 1096 | def validateItem(self, item): 1097 | self.blockSignals(True) 1098 | if item.column() == 1: 1099 | try: 1100 | v = float(item.text()) 1101 | except: 1102 | v = 0 1103 | item.setText(str(utils.clamp(v))) 1104 | self.blockSignals(False) 1105 | 1106 | def addPatternItem(self, name="R_", value=0): 1107 | row = self.rowCount() 1108 | self.insertRow(row) 1109 | self.setItem(row,0, QTableWidgetItem(name)) 1110 | self.setItem(row,1, QTableWidgetItem(str(value))) 1111 | self.somethingChanged.emit() 1112 | 1113 | def duplicatePatternItem(self): 1114 | for item in self.selectedItems(): 1115 | nameItem = self.item(item.row(), 0) 1116 | valueItem = self.item(item.row(), 1) 1117 | if nameItem and valueItem: 1118 | self.addPatternItem(nameItem.text(), valueItem.text()) 1119 | 1120 | def removePatternItem(self): 1121 | for item in self.selectedItems(): 1122 | row = item.row() 1123 | self.removeRow(row) 1124 | self.somethingChanged.emit() 1125 | 1126 | def fromJson(self, data): 1127 | self.blockSignals(True) 1128 | self.clearContents() 1129 | self.setRowCount(0) 1130 | for p in sorted(data): 1131 | self.addPatternItem(p, data[p]) 1132 | self.blockSignals(False) 1133 | 1134 | def toJson(self): 1135 | data = {} 1136 | for i in range(self.rowCount()): 1137 | nameItem = self.item(i, 0) 1138 | if nameItem: 1139 | valueItem = self.item(i, 1) 1140 | data[nameItem.text()] = float(valueItem.text()) if valueItem else 0 1141 | return data 1142 | 1143 | class SplitPoseWidget(QWidget): 1144 | def __init__(self, **kwargs): 1145 | super().__init__(**kwargs) 1146 | 1147 | layout = QVBoxLayout() 1148 | self.setLayout(layout) 1149 | 1150 | hsplitter = WideSplitter(Qt.Horizontal) 1151 | 1152 | self.posesWidget = PoseTreeWidget() 1153 | self.posesWidget.itemSelectionChanged.connect(self.posesSelectionChanged) 1154 | self.posesWidget.itemChanged.connect(lambda _=None:self.patternsItemChanged()) 1155 | self.posesWidget.somethingChanged.connect(self.patternsItemChanged) 1156 | 1157 | self.patternsWidget = PatternTableWidget() 1158 | self.patternsWidget.itemChanged.connect(lambda _=None:self.patternsItemChanged()) 1159 | self.patternsWidget.somethingChanged.connect(self.patternsItemChanged) 1160 | self.patternsWidget.setEnabled(False) 1161 | 1162 | self.blendShapeWidget = QLineEdit() 1163 | getBlendshapeBtn = QPushButton("<<") 1164 | getBlendshapeBtn.clicked.connect(self.getBlendShapeNode) 1165 | 1166 | blendLayout = QHBoxLayout() 1167 | blendLayout.addWidget(QLabel("Split blend shapes (target names must match pose names)")) 1168 | blendLayout.addWidget(self.blendShapeWidget) 1169 | blendLayout.addWidget(getBlendshapeBtn) 1170 | 1171 | applyBtn = QPushButton("Apply") 1172 | applyBtn.clicked.connect(self.apply) 1173 | 1174 | self.applySelectedWidget = QCheckBox("Apply selected") 1175 | applyLayout = QHBoxLayout() 1176 | applyLayout.addWidget(self.applySelectedWidget) 1177 | applyLayout.addWidget(applyBtn) 1178 | applyLayout.setStretch(1, 1) 1179 | 1180 | hsplitter.addWidget(self.posesWidget) 1181 | hsplitter.addWidget(self.patternsWidget) 1182 | layout.addWidget(hsplitter) 1183 | layout.addLayout(blendLayout) 1184 | layout.addLayout(applyLayout) 1185 | 1186 | def getBlendShapeNode(self): 1187 | ls = pm.ls(sl=True) 1188 | if ls: 1189 | node = ls[0] 1190 | if isinstance(node, pm.nt.BlendShape): 1191 | self.blendShapeWidget.setText(node.name()) 1192 | else: 1193 | blends = [n for n in pm.listHistory(node) if isinstance(n, pm.nt.BlendShape)] 1194 | if blends: 1195 | self.blendShapeWidget.setText(blends[0].name()) 1196 | 1197 | def posesSelectionChanged(self): 1198 | selectedItems = self.posesWidget.selectedItems() 1199 | self.patternsWidget.setEnabled(True if selectedItems else False) 1200 | 1201 | for item in selectedItems: 1202 | patterns = item.data(0, Qt.UserRole) 1203 | self.patternsWidget.fromJson(patterns) 1204 | 1205 | def patternsItemChanged(self): 1206 | # update all patterns 1207 | for item in self.posesWidget.selectedItems(): 1208 | data = self.patternsWidget.toJson() 1209 | item.setData(0, Qt.UserRole, data) 1210 | 1211 | self.saveToSkeleposer() 1212 | 1213 | @utils.undoBlock 1214 | def apply(self): 1215 | blendShape = self.blendShapeWidget.text() 1216 | applySelected = self.applySelectedWidget.isChecked() 1217 | 1218 | def splitPoses(item, sourcePose=None): 1219 | for i in range(item.childCount()): 1220 | ch = item.child(i) 1221 | destPose = ch.text(0) 1222 | 1223 | if sourcePose: 1224 | data = dict(ch.data(0, Qt.UserRole)) 1225 | print(f"Split pose '{sourcePose}' into '{destPose}' with {data}") 1226 | skel.addSplitPose(sourcePose, destPose, **data) 1227 | 1228 | splitPoses(ch, destPose) 1229 | 1230 | def splitBlends(item, sourcePose=None): 1231 | children = [] 1232 | for i in range(item.childCount()): 1233 | children.append(item.child(i).text(0)) 1234 | 1235 | if sourcePose and children: 1236 | print(f"Split blend '{sourcePose}' into '{' '.join(children)}'") 1237 | skel.addSplitBlends(blendShape, sourcePose, children) 1238 | 1239 | for i in range(item.childCount()): 1240 | ch = item.child(i) 1241 | splitBlends(ch, ch.text(0)) 1242 | 1243 | if not skel or not skel.node.exists(): 1244 | return 1245 | 1246 | if applySelected: 1247 | for item in self.posesWidget.selectedItems(): 1248 | sourceItem = item.parent() or item 1249 | 1250 | sourcePose = sourceItem.text(0) 1251 | splitPoses(sourceItem, sourcePose) 1252 | if pm.objExists(blendShape): 1253 | splitBlends(sourceItem, sourcePose) 1254 | else: 1255 | rootItem = self.posesWidget.invisibleRootItem() 1256 | 1257 | splitPoses(rootItem) 1258 | if pm.objExists(blendShape): 1259 | splitBlends(rootItem) 1260 | 1261 | with mainWindow.treeWidget.keepState(): 1262 | mainWindow.treeWidget.updateTree() 1263 | 1264 | def fromJson(self, data): # [[a, [b, c]]] => a | b | c 1265 | self.posesWidget.fromList(data) 1266 | self.patternsWidget.fromJson([]) 1267 | 1268 | def toJson(self): 1269 | return self.posesWidget.toList() 1270 | 1271 | def saveToSkeleposer(self): 1272 | if skel and skel.node.exists(): 1273 | skel.node.splitPosesData.set(json.dumps(self.toJson())) 1274 | 1275 | def loadFromSkeleposer(self): 1276 | if skel and skel.node.exists(): 1277 | data = skel.node.splitPosesData.get() 1278 | self.fromJson(json.loads(data) if data else []) 1279 | 1280 | class SkeleposerSelectorWidget(QLineEdit): 1281 | nodeChanged = Signal(str) 1282 | 1283 | def __init__(self, **kwargs): 1284 | super().__init__(**kwargs) 1285 | self.setPlaceholderText("Right click to select skeleposer from scene") 1286 | self.setReadOnly(True) 1287 | 1288 | def contextMenuEvent(self, event): 1289 | menu = QMenu(self) 1290 | for n in cmds.ls(type="skeleposer"): 1291 | menu.addAction(n, lambda name=n: self.nodeChanged.emit(name)) 1292 | menu.popup(event.globalPos()) 1293 | 1294 | def mouseDoubleClickEvent(self, event): 1295 | if event.button() in [Qt.LeftButton]: 1296 | oldName = self.text() 1297 | if pm.objExists(oldName): 1298 | newName, ok = QInputDialog.getText(None, "Skeleposer", "New name", QLineEdit.Normal, oldName) 1299 | if ok: 1300 | pm.rename(oldName, newName) 1301 | self.setText(skel.node.name()) 1302 | else: 1303 | super().mouseDoubleClickEvent(event) 1304 | 1305 | class MainWindow(QFrame): 1306 | def __init__(self, **kwargs): 1307 | super().__init__(**kwargs) 1308 | 1309 | self._callbacks = [] 1310 | 1311 | self.setWindowTitle("Skeleposer Editor") 1312 | self.setGeometry(600,300, 600, 500) 1313 | centerWindow(self) 1314 | self.setWindowFlags(self.windowFlags() | Qt.Dialog) 1315 | 1316 | layout = QVBoxLayout() 1317 | self.setLayout(layout) 1318 | 1319 | self.skeleposerSelectorWidget = SkeleposerSelectorWidget() 1320 | self.skeleposerSelectorWidget.nodeChanged.connect(self.selectSkeleposer) 1321 | 1322 | self.treeWidget = TreeWidget() 1323 | self.toolsWidget = ToolsWidget() 1324 | self.toolsWidget.hide() 1325 | 1326 | self.jointsListWidget = ListWithFilterWidget() 1327 | self.treeWidget.itemSelectionChanged.connect(self.treeSelectionChanged) 1328 | 1329 | self.splitPoseWidget = SplitPoseWidget() 1330 | self.splitPoseWidget.setEnabled(False) 1331 | 1332 | hsplitter = WideSplitter(Qt.Horizontal) 1333 | hsplitter.addWidget(self.jointsListWidget) 1334 | hsplitter.addWidget(self.treeWidget) 1335 | hsplitter.setStretchFactor(1,100) 1336 | hsplitter.setSizes([100, 400]) 1337 | 1338 | tabWidget = QTabWidget() 1339 | tabWidget.addTab(hsplitter, "Pose") 1340 | tabWidget.addTab(self.splitPoseWidget, "Split") 1341 | 1342 | layout.setMenuBar(self.getMenu()) 1343 | layout.addWidget(self.skeleposerSelectorWidget) 1344 | layout.addWidget(self.toolsWidget) 1345 | layout.addWidget(tabWidget) 1346 | 1347 | def getMenu(self): 1348 | menu = QMenuBar() 1349 | 1350 | fileMenu = QMenu("File", self) 1351 | fileMenu.addAction(QIcon(RootDirectory+"/icons/new.png"), "New", self.newNode) 1352 | fileMenu.addAction("Save", self.treeWidget.saveSkeleposer) 1353 | fileMenu.addAction("Load", self.treeWidget.importSkeleposer) 1354 | menu.addMenu(fileMenu) 1355 | 1356 | createMenu = QMenu("Create", self) 1357 | createMenu.addAction(QIcon(RootDirectory+"/icons/pose.png"), "Add pose", lambda: self.treeWidget.makePose("Pose", self.treeWidget.getValidParent()), "Insert") 1358 | createMenu.addAction(QIcon(RootDirectory+"/icons/directory.png"), "Group", self.treeWidget.groupSelected, "Ctrl+G") 1359 | createMenu.addSeparator() 1360 | createMenu.addAction("Add corrective pose", self.treeWidget.addCorrectivePose) 1361 | createMenu.addAction("Weight from selection", self.treeWidget.setupWeightFromSelection) 1362 | createMenu.addAction("Inbetween from selection", self.treeWidget.createInbetweenFromSelection) 1363 | createMenu.addAction("Add inbetween pose", self.treeWidget.addInbetweenPose) 1364 | createMenu.addSeparator() 1365 | 1366 | menu.addMenu(createMenu) 1367 | 1368 | editMenu = QMenu("Edit", self) 1369 | editMenu.addAction("Duplicate", self.treeWidget.duplicateItems, "Ctrl+D") 1370 | editMenu.addAction(QIcon(RootDirectory+"/icons/reset.png"), "Remove", self.treeWidget.removeItems, "Delete") 1371 | 1372 | editMenu.addSeparator() 1373 | 1374 | editMenu.addAction(QIcon(RootDirectory+"/icons/mirror.png"), "Mirror", self.treeWidget.mirrorItems, "Ctrl+M") 1375 | editMenu.addAction("Flip", self.treeWidget.flipItems, "Ctrl+F") 1376 | editMenu.addAction("Flip on opposite pose", self.treeWidget.flipItemsOnOppositePose, "Ctrl+Alt+F") 1377 | 1378 | deltaMenu = QMenu("Delta", self) 1379 | deltaMenu.addAction("Copy", self.treeWidget.copyPoseJointsDelta, "Ctrl+C") 1380 | deltaMenu.addAction("Copy selected joints", lambda: self.treeWidget.copyPoseJointsDelta(pm.ls(sl=True, type=["joint", "transform"]))) 1381 | deltaMenu.addAction("Paste", self.treeWidget.pastePoseDelta, "Ctrl+V") 1382 | deltaMenu.addSeparator() 1383 | deltaMenu.addAction("Reset selected joints", self.treeWidget.resetJoints) 1384 | editMenu.addMenu(deltaMenu) 1385 | 1386 | blendMenu = QMenu("Blend mode", self) 1387 | blendMenu.addAction("Additive", lambda: self.treeWidget.setPoseBlendMode(0)) 1388 | blendMenu.addAction("Replace", lambda: self.treeWidget.setPoseBlendMode(1)) 1389 | editMenu.addMenu(blendMenu) 1390 | 1391 | editMenu.addSeparator() 1392 | editMenu.addAction("Mute", self.treeWidget.muteItems, "m") 1393 | editMenu.addAction("Reset weights", self.treeWidget.resetWeights) 1394 | menu.addMenu(editMenu) 1395 | 1396 | bonesMenu = QMenu("Bones", self) 1397 | bonesMenu.addAction(QIcon(RootDirectory+"/icons/bone.png"), "Add", self.treeWidget.addJoints) 1398 | bonesMenu.addAction(QIcon(RootDirectory+"/icons/removeBone.png"), "Remove", self.treeWidget.removeJoints) 1399 | bonesMenu.addSeparator() 1400 | bonesMenu.addAction("Update base matrices", self.treeWidget.updateBaseMatrices) 1401 | bonesMenu.addSeparator() 1402 | menu.addMenu(bonesMenu) 1403 | 1404 | toolsMenu = QMenu("Tools", self) 1405 | toolsMenu.addAction("Setup aliases", self.setupAliases) 1406 | toolsMenu.addAction("Replace in names", self.treeWidget.searchWindow.show, "Ctrl+R") 1407 | toolsMenu.addAction("Collapse others", self.treeWidget.collapseOthers, "Ctrl+Space") 1408 | toolsMenu.addAction(QIcon(RootDirectory+"/icons/layer.png"), "Add joint hierarchy as layer", self.treeWidget.addJointsAsLayer) 1409 | 1410 | connectionsMenu = QMenu("Output connections", self) 1411 | connectionsMenu.addAction("Connect", self.treeWidget.reconnectOutputs) 1412 | connectionsMenu.addAction("Disonnect", self.treeWidget.disconnectOutputs) 1413 | toolsMenu.addMenu(connectionsMenu) 1414 | 1415 | toolsMenu.addSeparator() 1416 | toolsMenu.addAction("Select node", lambda: pm.select(skel.node)) 1417 | menu.addMenu(toolsMenu) 1418 | 1419 | return menu 1420 | 1421 | def treeSelectionChanged(self): 1422 | joints = [] 1423 | for sel in self.treeWidget.selectedItems(): 1424 | if sel.poseIndex is not None: 1425 | joints += skel.getPoseJoints(sel.poseIndex) 1426 | 1427 | allJoints = set([j.name() for j in skel.getJoints()]) 1428 | poseJoints = set([j.name() for j in joints]) 1429 | 1430 | self.jointsListWidget.clearItems() 1431 | self.jointsListWidget.addItems(sorted(poseJoints), bold=True) # pose joints 1432 | self.jointsListWidget.addItems(sorted(allJoints-poseJoints), foreground=QColor(100, 100, 100)) # all joints 1433 | 1434 | def setupAliases(self): 1435 | if not skel or not skel.node.exists(): 1436 | return 1437 | skel.setupAliases(False) # uninstall 1438 | skel.setupAliases(True) 1439 | 1440 | def newNode(self): 1441 | self.selectSkeleposer(pm.createNode("skeleposer")) 1442 | 1443 | def selectSkeleposer(self, node): 1444 | global skel 1445 | if node: 1446 | skel = Skeleposer(node) 1447 | self.treeWidget.updateTree() 1448 | self.skeleposerSelectorWidget.setText(str(node)) 1449 | 1450 | self.splitPoseWidget.setEnabled(True) 1451 | self.splitPoseWidget.loadFromSkeleposer() 1452 | 1453 | self.registerCallbacks() 1454 | pm.select(node) 1455 | else: 1456 | skel = None 1457 | self.skeleposerSelectorWidget.setText("") 1458 | self.treeWidget.clear() 1459 | self.splitPoseWidget.setEnabled(False) 1460 | self.deregisterCallbacks() 1461 | 1462 | self.toolsWidget.hide() 1463 | utils.clearUnusedRemapValue() 1464 | 1465 | def registerCallbacks(self): 1466 | def preRemovalCallback(node, clientData): 1467 | self.selectSkeleposer(None) 1468 | def nameChangedCallback(node, name, clientData): 1469 | self.skeleposerSelectorWidget.setText(skel.node.name()) 1470 | 1471 | self.deregisterCallbacks() 1472 | nodeObject = skel.node.__apimobject__() 1473 | self._callbacks.append( pm.api.MNodeMessage.addNodePreRemovalCallback(nodeObject, preRemovalCallback) ) 1474 | self._callbacks.append( pm.api.MNodeMessage.addNameChangedCallback(nodeObject, nameChangedCallback) ) 1475 | 1476 | def deregisterCallbacks(self): 1477 | for cb in self._callbacks: 1478 | pm.api.MMessage.removeCallback(cb) 1479 | self._callbacks = [] 1480 | 1481 | def undoRedoCallback(): 1482 | if not skel or not skel.node.exists(): 1483 | return 1484 | 1485 | tree = mainWindow.treeWidget 1486 | 1487 | def getSkeleposerState(idx=0): 1488 | data = {"d":idx, "l":skel.node.directories[idx].directoryName.get() or "", "ch":[]} 1489 | 1490 | for chIdx in skel.node.directories[idx].directoryChildrenIndices.get() or []: 1491 | if chIdx >= 0: 1492 | data["ch"].append([chIdx, skel.node.poses[chIdx].poseName.get()]) # [idx, poseName] 1493 | else: 1494 | data["ch"].append(getSkeleposerState(-chIdx)) # directories are negative 1495 | return data 1496 | 1497 | def getItemsState(item=tree.invisibleRootItem(), idx=0): 1498 | data = {"d":idx, "l":item.text(0), "ch":[]} 1499 | 1500 | for i in range(item.childCount()): 1501 | ch = item.child(i) 1502 | if ch.poseIndex is not None: 1503 | data["ch"].append([ch.poseIndex, ch.text(0)]) 1504 | elif ch.directoryIndex is not None: 1505 | data["ch"].append(getItemsState(ch, ch.directoryIndex)) 1506 | return data 1507 | 1508 | if getItemsState() == getSkeleposerState(): 1509 | return 1510 | 1511 | with tree.keepState(): 1512 | print("SkeleposerEditor undo") 1513 | tree.clear() 1514 | tree.addItemsFromSkeleposerData(tree.invisibleRootItem(), skel.getDirectoryData()) 1515 | 1516 | mainWindow.splitPoseWidget.loadFromSkeleposer() 1517 | 1518 | pm.scriptJob(e=["Undo", undoRedoCallback]) 1519 | pm.scriptJob(e=["Redo", undoRedoCallback]) 1520 | 1521 | skel = None 1522 | editPoseIndex = None 1523 | 1524 | mainWindow = MainWindow(parent=mayaMainWindow) --------------------------------------------------------------------------------