This commit is contained in:
Jeffreytsai1004 2025-01-14 03:06:35 +08:00
parent 4e5c33f861
commit 5c8bc871af
27 changed files with 6780 additions and 0 deletions

View File

@ -0,0 +1,99 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import traceback
from functools import partial
from maya import cmds
from epic_pose_wrangler.log import LOG
from epic_pose_wrangler.v2.model import base_extension, pose_blender
class BakePosesToTimeline(base_extension.PoseWranglerExtension):
__category__ = "Core Extensions"
@property
def view(self):
if self._view is not None:
return self._view
from PySide2 import QtWidgets
self._view = QtWidgets.QPushButton("Bake Poses To Timeline")
self._view.clicked.connect(partial(self.execute, None))
return self._view
def execute(self, context=None, **kwargs):
if context is None:
context = self.api.get_context()
if context.current_solver is not None:
bake_poses_to_timeline(solver=context.current_solver, view=self._display_view)
def bake_poses_to_timeline(start_frame=0, anim_layer=None, solver=None, view=False):
"""
Bakes the poses to the timeline and sets the time range to the given animation.
:param start_frame :type int: start frame of he baked animation
:param anim_layer :type str: if given the animations will be baked on that layer or created if it doesn't exist
:param solver :type api.RBFNode: solver reference
:param view :type bool: is the view present
"""
# Grab all the transforms for the solver
transforms = solver.drivers()
transforms.extend(solver.driven_nodes(pose_blender.UEPoseBlenderNode.node_type))
bake_enabled = True
pose_list = []
# Set default anim layer if one isn't specified
if not anim_layer:
anim_layer = "BaseAnimation"
# If the layer doesnt exist, create it
if not cmds.animLayer(anim_layer, query=True, exists=True):
cmds.animLayer(anim_layer)
try:
cmds.autoKeyframe(e=1, st=0)
# If we are running with the view, provide a popup
if view:
from PySide2 import QtWidgets
msg = ("This will bake the poses to the timeline, change your time range, "
"and delete inputs on driving and driven transforms.\n"
"Do you want this to happen?")
ret = QtWidgets.QMessageBox.warning(
None, "WARNING: DESTRUCTIVE FUNCTION", msg,
QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Cancel
)
if ret == QtWidgets.QMessageBox.StandardButton.Ok:
bake_enabled = True
cmds.undoInfo(openChunk=True, undoName='Bake poses to timeline')
else:
bake_enabled = False
# If we are baking, do the bake
if bake_enabled:
i = start_frame
for pose_name in solver.poses():
# let's key it on the previous and next frames before we pose it
cmds.select(transforms)
cmds.animLayer(anim_layer, addSelectedObjects=True, e=True)
cmds.setKeyframe(transforms, t=[(i - 1), (i + 1)], animLayer=anim_layer)
# assume the pose
solver.go_to_pose(pose_name)
cmds.setKeyframe(transforms, t=[i], animLayer=anim_layer)
pose_list.append(pose_name)
# increment to next keyframe
i += 1
# set the range to the number of keyframes
cmds.playbackOptions(minTime=0, maxTime=i, animationStartTime=0, animationEndTime=i - 1)
cmds.dgdirty(a=1)
return pose_list
cmds.dgdirty(a=1)
except Exception as e:
LOG.error(traceback.format_exc())
finally:
cmds.undoInfo(closeChunk=True)

View File

@ -0,0 +1,224 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import traceback
from functools import partial
from maya import cmds
from epic_pose_wrangler.log import LOG
from epic_pose_wrangler.v2.model import base_extension, exceptions, pose_blender
class BakePosesToTimeline(base_extension.PoseWranglerExtension):
__category__ = "Core Extensions"
@property
def view(self):
if self._view is not None:
return self._view
from PySide2 import QtWidgets
self._view = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout()
self._view.setLayout(layout)
label = QtWidgets.QLabel("Copy/Paste Driven Transforms")
layout.addWidget(label)
button_layout = QtWidgets.QHBoxLayout()
layout.addLayout(button_layout)
copy_button = QtWidgets.QPushButton("Copy")
copy_button.clicked.connect(partial(self.copy_driven_trs, None))
button_layout.addWidget(copy_button)
paste_button = QtWidgets.QPushButton("Paste")
button_layout.addWidget(paste_button)
spin_label = QtWidgets.QLabel("Multiplier: ")
button_layout.addWidget(spin_label)
spin_box = QtWidgets.QDoubleSpinBox()
spin_box.setMinimum(0.01)
spin_box.setSingleStep(0.1)
spin_box.setValue(1.0)
button_layout.addWidget(spin_box)
paste_button.clicked.connect(partial(self.paste_driven_trs, lambda: spin_box.value(), None))
return self._view
def execute(self, context=None, **kwargs):
copy = kwargs.get('copy', False)
driver = kwargs.get('driver', True)
multiplier = kwargs.get('multiplier', 1.0)
if context is None:
context = self.api.get_context()
if context.current_solver is not None:
if copy:
if driver:
self.copy_driver_trs()
else:
self.copy_driven_trs()
else:
if driver:
self.paste_driver_trs(multiplier=multiplier)
else:
self.paste_driven_trs(multiplier=multiplier)
def copy_driven_trs(self, solver=None):
"""
Copy the driven transforms translate, rotate and scale for the specified solver
:param solver :type api.RBFNode: solver reference
"""
context = self.api.get_context()
solver = solver or context.current_solver
CopyPasteTRS.copy_driven(solver)
def paste_driven_trs(self, multiplier=1.0, solver=None):
"""
Paste the driven transforms translate, rotate and scale based on the multiplier specified
:param multiplier :type float: scale multiplier
:param solver :type api.RBFNode: solver reference
"""
# Get the solver if it hasn't been specified
if callable(multiplier):
multiplier = multiplier()
context = self.api.get_context()
solver = solver or context.current_solver
edit_status = self.api.get_solver_edit_status(solver=solver)
if not edit_status:
self.api.edit_solver(edit=True, solver=solver)
CopyPasteTRS.paste_driven(multiplier=multiplier)
def copy_driver_trs(self, solver=None):
"""
Copy the driver transforms translate, rotate and scale for the specified solver
:param solver :type api.RBFNode: solver reference
"""
context = self.api.get_context()
# Get the solver if it hasn't been specified
solver = solver or context.current_solver
# Copy the driver transforms
CopyPasteTRS.copy_driver(solver)
def paste_driver_trs(self, multiplier=1.0):
"""
Paste the driver transforms rotate and scale based on the multiplier specified
:param multiplier :type float: scale multiplier
"""
if callable(multiplier):
multiplier = multiplier()
CopyPasteTRS.paste_driver(multiplier=multiplier)
class TRSError(exceptions.exceptions.PoseWranglerException):
pass
class CopyPasteTRS(object):
"""
Class wrapper for copying and pasting TRS data
"""
# Dicts to store the driver and driven data. Driver data is only used when auto generating poses
TRS_DRIVEN_DATA = {}
TRS_DRIVER_DATA = {}
@classmethod
def copy_driven(cls, solver):
"""
Copy the driven transforms TRS data for the specified solver
:param solver :type api.RBFNode: solver reference
"""
cls._copy(
transforms=solver.driven_nodes(pose_blender.UEPoseBlenderNode.node_type),
target_datastore=cls.TRS_DRIVEN_DATA
)
@classmethod
def paste_driven(cls, multiplier=1.0):
"""
Paste the copied driven values onto the driven with the specified modifier
:param multiplier :type float: multiplier value
"""
cls._paste(multiplier=multiplier, target_datastore=cls.TRS_DRIVEN_DATA)
@classmethod
def copy_driver(cls, solver):
"""
Copy the driver transforms TRS data for the specified solver
:param solver :type api.RBFNode: solver reference
"""
cls._copy(transforms=solver.drivers(), target_datastore=cls.TRS_DRIVER_DATA, attributes=['rotate', 'scale'])
@classmethod
def paste_driver(cls, multiplier=1.0):
"""
Paste the copied driver values onto the driver with the specified modifier
:param multiplier :type float: multiplier value
"""
cls._paste(multiplier=multiplier, target_datastore=cls.TRS_DRIVER_DATA)
@classmethod
def _copy(cls, transforms, target_datastore, attributes=None):
"""
Copy the specified transforms attributes to the specified datastore
:param transforms :type list: list of maya transform nodes
:param target_datastore :type dict: reference to cls.TRS_DRIVER_DATA or cls.TRS_DRIVEN_DATA
:param attributes :type list: list of attribute names to store
"""
# If no attributes are specified, use TRS
if not attributes:
attributes = ['translate', 'rotate', 'scale']
# Clear the datastore before we copy
target_datastore.clear()
# For each transform, get the specified attributes and store them in a dictionary
for transform in transforms:
target_datastore[transform] = {attr: cmds.getAttr(
"{transform}.{attr}".format(
transform=transform,
attr=attr
)
)[0]
for attr in attributes}
LOG.info("Successfully copied TRS for {transforms}".format(transforms=transforms))
@classmethod
def _paste(cls, multiplier, target_datastore):
"""
Paste the attributes from the target_datastore multiplied by the given multiplier
:param multiplier :type float: multiplier value
:param target_datastore : type dict: reference to cls.TRS_DRIVER_DATA or cls.TRS_DRIVEN_DATA
"""
# If the target datastore is empty, we can't paste
if not target_datastore:
raise TRSError("No data has been copied. Unable to paste")
# Iterate through the items in the datastore
for transform, data in target_datastore.items():
# Iterate through the attribute names and values in the datastore
for attr, values in data.items():
# Set the attribute to the multiplied value
try:
cmds.setAttr(
"{transform}.{attr}".format(transform=transform, attr=attr), values[0] * multiplier,
values[1] * multiplier,
values[2] * multiplier
)
except:
traceback.print_exc()
# Calculate and set the scale
scale = [((s - 1.0) * multiplier) + 1 for s in data['scale']]
try:
cmds.setAttr("{driven}.scale".format(driven=transform), *scale)
except:
traceback.print_exc()
LOG.info(
"Successfully pasted TRS with multiplier: {multiplier} for {transforms}".format(
multiplier=multiplier,
transforms=list(target_datastore.keys())
)
)

View File

@ -0,0 +1,71 @@
# Copyright Epic Games, Inc. All Rights Reserved.
from functools import partial
from epic_pose_wrangler.v2.model import base_extension
from epic_pose_wrangler.v2.extensions import copy_paste_trs
class GenerateInbetweens(base_extension.PoseWranglerExtension):
__category__ = "Core Extensions"
@property
def view(self):
if self._view is not None:
return self._view
from PySide2 import QtWidgets
self._view = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout()
self._view.setLayout(layout)
label = QtWidgets.QLabel("Copy/Paste Driven Transforms")
layout.addWidget(label)
button_layout = QtWidgets.QHBoxLayout()
layout.addLayout(button_layout)
generate_inbetweens_button = QtWidgets.QPushButton("Generate Inbetweens")
button_layout.addWidget(generate_inbetweens_button)
spin_label = QtWidgets.QLabel("Pose Count: ")
button_layout.addWidget(spin_label)
spin_box = QtWidgets.QSpinBox()
spin_box.setMinimum(1)
spin_box.setSingleStep(1)
spin_box.setValue(1)
button_layout.addWidget(spin_box)
generate_inbetweens_button.clicked.connect(partial(self.generate_inbetweens, lambda: spin_box.value()))
return self._view
def generate_inbetweens(self, count=1, pose_prefix="pose"):
"""
Generate inbetween poses between the current position and the default pose
:param count :type int: number of poses to generate
:param pose_prefix :type: name of the pose
"""
context = self.api.get_context()
if callable(count):
count = count()
solver = context.current_solver
copy_paste_trs_action = self.api.get_extension_by_type(copy_paste_trs.BakePosesToTimeline)
copy_paste_trs_action.copy_driven_trs(solver=solver)
copy_paste_trs_action.copy_driver_trs(solver=solver)
# Calculate the multiplier increment based on the number of desired poses
multiplier_increment = 1.0 / float((count + 1))
# Set the start multiplier
multiplier = 1.0
# Iterate through the number of desired poses
for i in range(count):
# Generate the new multiplier
multiplier -= multiplier_increment
# Paste the driver and driven translate, rotate and scale based on the new multiplier
# Note: Driver only gets rotate and scale applied to it.
copy_paste_trs_action.paste_driven_trs(multiplier)
copy_paste_trs_action.paste_driver_trs(multiplier)
# Create a new pose at this position
self.api.create_pose(pose_name="{pose_prefix}_{i}".format(pose_prefix=pose_prefix, i=i), solver=solver)

View File

@ -0,0 +1,832 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import inspect
import json
import os
import sys
from maya.api import OpenMaya as om
from epic_pose_wrangler.log import LOG
from epic_pose_wrangler.model import mirror_mapping, settings, exceptions
from epic_pose_wrangler.model.api import RBFAPI
from epic_pose_wrangler.v2.model import api, base_action, base_extension, pose_blender, context, utils
from epic_pose_wrangler.v2.model import exceptions as api_exceptions
class UERBFAPI(RBFAPI):
"""
Main entry point for interacting with the UERBFSolverNode and UEPoseBlenderNode
>>> from epic_pose_wrangler.v2 import main
>>> rbf_api = main.UERBFAPI(view=False)
>>> rbf_api.create_rbf_solver(solver_name="ExampleSolver", drivers=['leg_l'])
"""
VERSION = "2.0.0"
def __init__(self, view=False, parent=None, file_path=None):
super(UERBFAPI, self).__init__(view=view, parent=parent)
self._view = None
# Set up a default mirror mapping
self._mirror_mapping = mirror_mapping.MirrorMapping()
# Instantiate a settings manager to restore settings from previous sessions
self._settings_manager = settings.SettingsManager()
# Empty var to store the current solver for convenience so that you don't have to pass the solver through
# every time you call a function
self._current_solver = None
# Empty list to store any core and custom actions used to extend the functionality of PoseWrangler
self._extensions = []
# If view is requested, import and build the UI. We use a local import so that any QtWidget dependencies
# won't be loaded if the user is running from mayapy
if view:
from epic_pose_wrangler.v2.view import pose_wrangler_window
self._view = pose_wrangler_window.PoseWranglerWindow()
# Connect up the views events to the appropriate functions
self._setup_view_events()
# Load the contents of the current scene
LOG.info("Loading PoseWrangler...")
self.load()
# If a file path is specified, deserialize and load it
if file_path:
self.deserialize_from_file(file_path=file_path)
@property
def extensions(self):
"""
:return: list of pose wrangler extensions currently loaded
:rtype: list[pose_wrangler.v2.model.base_extension.PoseWranglerExtension]
"""
return self._extensions
@property
def view(self):
"""
:return: reference to the ui QWidget
:rtype: QtWidgets.QWidget or None
"""
return self._view
@property
def current_solver(self):
"""
:return: reference to the current solver
:rtype: api.RBFNode or None
"""
return self._current_solver
@current_solver.setter
def current_solver(self, solver):
# If the specified solver is not an RBFNode class, raise exception
if not isinstance(solver, api.RBFNode):
raise api_exceptions.InvalidSolverError(
"Solver is not a valid {node_type} node".format(node_type=api.RBFNode)
)
# Set the current solver
self._current_solver = solver
solvers = self.rbf_solvers
for action in self._extensions:
action.on_context_changed(
context.PoseWranglerContext(current_solver=solver, solvers=solvers)
)
# If we are displaying the view, update it with the current selection
if self._view:
# Generate a kwarg dict.
kwargs = {
"solver": solver,
# We want to store a reference to the MObject in case the name of the node is changed and the
# user doesn't refresh the UI
"drivers": {driver: om.MGlobal.getSelectionListByName(driver).getDependNode(0) for driver in
solver.drivers()},
# We want to display both the driven transforms and connected blendshapes.
# NOTE: Blendshapes will only be found if connected up via this API. For more info see:
# self.create_blendshape or self.add_blendshape
"driven_transforms": {
'transform': solver.driven_nodes(pose_blender.UEPoseBlenderNode.node_type),
'blendshape': {mesh: solver.get_pose_for_blendshape_mesh(mesh) for mesh in
solver.driven_nodes(type='blendShape')}
},
"poses": solver.poses()
}
# Load the solver settings in the view with the given kwargs
self._view.load_solver_settings(**kwargs)
@property
def mirror_mapping(self):
"""
:return: reference to the currently loaded mirror mapping
:rtype: mirror_mapping.MirrorMapping object
"""
return self._mirror_mapping
@property
def rbf_solvers(self):
"""
:return: list of rbf solvers in the scene
:rtype: list
"""
return api.RBFNode.find_all()
# ================================================ Solvers ======================================================= #
def create_rbf_solver(self, solver_name, drivers=None):
"""
Create an rbf solver node with the given name and the specified driver transforms
:param solver_name: name of the solver node
:type solver_name: str
:param drivers: list of driver transform node names
:type drivers: list
:return: RBFNode ref
:rtype: api.RBFNode
"""
# If no drivers are specified, grab the current selection
drivers = drivers or utils.get_selection(_type='transform')
LOG.debug("Creating RBF solver '{name}' with drivers: {drivers}".format(name=solver_name, drivers=drivers))
# Create the solver
solver = api.RBFNode.create(solver_name)
# If drivers have been specified, add them
if drivers:
self.add_drivers(drivers=drivers, solver=solver)
# If we are in UI mode, add the solver to the view
if self._view:
self._view.add_rbf_solver(solver)
# Set the current solver
self.current_solver = solver
# Set the current solver to edit mode
self.edit_solver(edit=True, solver=solver)
# Return the new solver
return solver
def delete_rbf_solver(self, solver=None):
"""
Delete the specified solver
:param solver: solver reference
:type solver: api.RBFNode
"""
# If no solver is specified, grab the current solver
solver = solver or self._current_solver
# Delete the solver
solver.delete()
# If we are using the UI, delete the solver
if self._view:
self._view.delete_solver(solver)
# If the current solver is this solver, set current to None
if self._current_solver == solver:
self._current_solver = None
def edit_solver(self, edit=True, solver=None):
"""
Edit or finish editing the specified solver. Enables pose creation/driven node changes via the ui
:param edit: set edit mode on or off
:type edit: bool
:param solver: solver reference
:type solver: api.RBFNode
"""
# If no solver is specified, grab the current solver
solver = solver or self._current_solver
# Edit the solver
solver.edit_solver(edit=edit)
# If we have a ui, update the edit mode status
if self._view:
self._view.edit_solver(solver=solver, edit=edit)
LOG.debug("Setting edit status: {status} for solver: {solver}".format(status=edit, solver=solver))
self.current_solver = solver
def mirror_rbf_solver(self, solver=None):
"""
Mirror the current solver
:param solver: solver reference
:type solver: api.RBFNode
:return: mirrored solver reference
:rtype: api.RBFNode
"""
# If no solver is specified, grab the current solver
solver = solver or self._current_solver
mirrored_solver = solver.mirror(self.mirror_mapping)
if self._view:
self._load_view()
# Set the current solver to the new solver
self.current_solver = mirrored_solver
# Return the new solver
return mirrored_solver
def get_rbf_solver_by_name(self, solver_name):
"""
Searches the scene for an rbf solver with the given name. Case insensitive
:param solver_name: Solver node name
:type solver_name: str
:return: found node or None
:rtype: api.RBFNode or None
"""
for solver in api.RBFNode.find_all():
if str(solver).lower() == solver_name.lower():
return solver
# ================================================ Drivers ======================================================= #
def add_drivers(self, drivers=None, solver=None):
"""
Add the specified drivers to the specified solver
:param drivers: list of transform nodes
:type drivers: list
:param solver: solver reference
:type solver: api.RBFNode
"""
# If no solver is specified, grab the current solver
solver = solver or self._current_solver
# If we already have more than one pose, we can't add a new driver (would require manually updating every pose)
if solver.num_poses() > 1:
raise api_exceptions.InvalidPoseIndex("Too many poses have been created, unable to add a new driver.")
# Check if we have a default pose
if solver.has_pose(pose_name='default'):
# Delete the current rest pose if found
solver.delete_pose(pose_name='default')
# Add new drivers
solver.add_driver(transform_nodes=drivers)
# Create the new rest pose with all the drivers
solver.add_pose_from_current(pose_name='default')
# Set the current solver
self.current_solver = solver
def remove_drivers(self, drivers, solver=None):
"""
Remove the specified drivers from the specified solver
:param drivers: list of driver transform nodes
:type drivers: list
:param solver: solver reference
:type solver: api.RBFNode
"""
# Get the solver if it hasn't been specified
solver = solver or self._current_solver
# Remove the drivers from the solver
solver.remove_drivers(drivers)
# Have to re-wrap solver due to a bug with targets array indexing
self.current_solver = api.RBFNode(str(solver))
# ================================================ Driven ======================================================== #
def add_driven_transforms(self, driven_nodes=None, solver=None, edit=False):
"""
Add driven transforms to the specified solver
:param driven_nodes: list of transform nodes
:type driven_nodes: list
:param solver: solver reference
:type solver: api.RBFNode
:param edit: should this transform not be connected to the pose blender output upon creation
:type edit: bool
"""
# Get the solver if it hasn't been specified
solver = solver or self._current_solver
solver.add_driven_transforms(driven_nodes, edit=edit)
# Update the current solver
self.current_solver = solver
def remove_driven(self, driven_nodes, solver=None):
"""
Remove driven transforms from the specified solver
:param driven_nodes: list of transform nodes
:type driven_nodes: list
:param solver: solver reference
:type solver: api.RBFNode
"""
# Get the solver if it hasn't been specified
solver = solver or self._current_solver
solver.remove_driven_transforms(driven_nodes)
# Update the current solver
self.current_solver = solver
# ============================================== Blendshapes ===================================================== #
def add_blendshape(self, pose_name, mesh_name, base_mesh, solver=None):
"""
Add an existing blendshape for the current pose
:param pose_name: name of the pose the blendshape is associated with
:type pose_name: str
:param mesh_name: name of the existing blendshape mesh
:type mesh_name: str
:param base_mesh: name of the mesh the blendshape mesh is derived from
:type base_mesh: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Add the blendshape
solver.add_existing_blendshape(pose_name, mesh_name, base_mesh)
self.current_solver = solver
def create_blendshape(self, pose_name, mesh_name=None, edit=False, solver=None):
"""
Create a new blendshape for the given pose and mesh
:param pose_name: name of the pose to create this blendshape for
:type pose_name: str
:param mesh_name: name of the mesh to create the blendshape from
:type mesh_name: str
:param edit: should this blendshape be edited straight away
:type edit: bool
:param solver: solver reference
:type solver: api.RBFNode
:return: name of the newly created blendshape mesh
:rtype: str
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Create a new blendshape mesh at the specified pose
blendshape_mesh_name = solver.create_blendshape(pose_name, mesh_name)
# If we are editing, change the mode
if edit:
self.edit_blendshape(pose_name=pose_name, edit=True, solver=solver)
else:
# If we aren't editing, update the current solver
self.current_solver = solver
# Return the name of the new blendshape mesh
return blendshape_mesh_name
def delete_blendshape(self, pose_name, solver=None):
"""
Delete the blendshape associated with the specified pose
:param pose_name: name of the pose to delete blendshapes for
:type pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Delete the blendshape mesh at the specified pose
solver.delete_blendshape(pose_name, delete_mesh=True)
# Set the current solver
self.current_solver = solver
def edit_blendshape(self, pose_name, edit=True, solver=None):
"""
Edit or finish editing the blendshape associated with the specified pose name
:param pose_name: name of the pose the blendshape is associated with
:type pose_name: str
:param edit: True = enable editing, False = finish editing
:type edit: bool
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Edit or finish editing the blendshape
solver.edit_blendshape(pose_name, edit=edit)
# Set the current solver
self.current_solver = solver
# If we have a ui, update the blendshapes status
if self._view:
self._view.edit_blendshape(pose_name, edit=edit)
def isolate_blendshape(self, pose_name, isolate=True, solver=None):
"""
Isolate the blendshape associated with the specified pose name, disabling all other blendshapes.
:param pose_name: name of the pose the blendshape is associated with
:type pose_name: str
:param isolate: True = isolate the blendshape, False = reconnect all disconnected blendshapes
:type isolate: bool
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Isolate or un-isolate the blendshape
solver.isolate_blendshape(pose_name=pose_name, isolate=isolate)
# Set the current solver
self.current_solver = solver
# ================================================= Poses ======================================================== #
def create_pose(self, pose_name, solver=None):
"""
Create a new pose for the specified solver
:param pose_name: name of the new pose
:type pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Create a new pose from the current position
solver.add_pose_from_current(pose_name=pose_name)
# Set the current solver
self.current_solver = solver
def delete_pose(self, pose_name, solver=None):
"""
Remove a pose from the given solver
:param pose_name: name of the pose to remove
:type pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Delete the pose
solver.delete_pose(pose_name)
# Set the current solver
self.current_solver = solver
def go_to_pose(self, pose_name, solver=None):
"""
Move the driver/driven transforms to the given pose
:param pose_name: name of the pose
:type pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Go to the pose
solver.go_to_pose(pose_name=pose_name)
def mirror_pose(self, pose_name, solver=None):
"""
Mirror a pose to the mirror of the current solver
:param pose_name: name of the pose
:type pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Mirror the pose
solver.mirror_pose(pose_name=pose_name, mirror_mapping=self.mirror_mapping)
# Reload all the solvers
self.load()
# Set the current solver
self.current_solver = solver
def mute_pose(self, pose_name, mute=True, solver=None):
"""
Mute or unmute the specified pose, removing all influences of the pose from the solver.
NOTE: This will affect the solver radius if automatic radius is enabled.
:param pose_name: name of the pose
:type pose_name: str
:param mute: mute or unmute the pose
:type mute: bool
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Mute or unmute the pose
solver.mute_pose(pose_name=pose_name, mute=mute)
# Set the current solver
self.current_solver = solver
def rename_pose(self, pose_name, new_pose_name, solver=None):
"""
Rename a pose on the given solver
:param pose_name: name of the pose
:type pose_name: str
:param new_pose_name: new name of the pose
:type new_pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Get the current pose index
pose_index = solver.pose_index(pose_name=pose_name)
# Rename the pose at the given index
solver.rename_pose(pose_index=pose_index, pose_name=new_pose_name)
# Update the current solver
self.current_solver = solver
def update_pose(self, pose_name, solver=None):
"""
Update the pose for the given solver
:param pose_name: name of the pose to update
:type pose_name: str
:param solver: solver reference
:type solver: api.RBFNode
"""
# Ensure we have a solver
solver = solver or self._current_solver
# Update the pose with the current driver/driven positions
solver.add_pose_from_current(pose_name, update=True)
LOG.info("Updated {pose_name}".format(pose_name=pose_name))
self.current_solver = solver
# ================================================== IO ========================================================== #
def deserialize_from_file(self, file_path, solver_names=None):
"""
Deserialize solvers from a specific file.
:param file_path: json file to load
:type file_path: str
"""
# Check the path exists
if not os.path.exists(file_path):
raise exceptions.PoseWranglerIOError(
"Unable to deserialize from {file_path}, path does not exist".format(file_path=file_path)
)
if solver_names is None:
solver_names = []
# Load the json file and deserialize
with open(file_path, 'r') as f:
data = json.loads(f.read())
self.deserialize(data, solver_names=solver_names)
LOG.debug("Successfully loaded solvers: {solvers}".format(solvers=solver_names or list(data.keys())))
LOG.info(
"Successfully loaded {num_solvers} solver(s) from {file_path}".format(
num_solvers=len(data),
file_path=file_path
)
)
def serialize_to_file(self, file_path, solvers=None):
"""
Serialize the specified solvers to a file
:param file_path: json file to serialize
:type file_path: str
:param solvers: list of api.RBFNode to serialize
:type solvers: list
"""
# Check that the directory exists before writing to it
if not os.path.exists(os.path.dirname(file_path)):
os.makedirs(file_path)
# Dump the serialized data to the json file
with open(file_path, 'w') as f:
data = self.serialize(solvers=solvers)
LOG.debug("Successfully serialized solvers: {solvers}".format(solvers=list(data.keys())))
f.write(json.dumps(data))
LOG.info(
"Successfully exported {num_solvers} solver(s) to {file_path}".format(
num_solvers=len(data),
file_path=file_path
)
)
def deserialize(self, data, solver_names=None):
"""
Deserialize and load the solvers from the data specified
:param data: serialized solver data
:type data: dict
:param solver_names: list of solver names to load from the data
:type solver_names: list, optional
"""
for solver_name, solver_data in data.items():
if solver_names and solver_name in solver_names or not solver_names:
api.RBFNode.create_from_data(solver_data)
self.load()
def serialize(self, solvers=None):
"""
Serialize the specified solvers
:param solvers: list of api.RBFNode to serialize
:type solvers: list
:return: serialized solver data
:rtype: dict
"""
return {str(solver): solver.data() for solver in solvers or self.rbf_solvers}
def load(self):
"""
Load the default pose wrangler settings
"""
# Set the mirror mapping from user settings
self.set_mirror_mapping()
self._load_extensions()
if self._view:
self._load_view()
# =============================================== Utilities ====================================================== #
def get_context(self):
"""
Get the current solver context
:return: pose wrangler context containing the current solver and all rbf solvers
:rtype: context.PoseWranglerContext
"""
return context.PoseWranglerContext(current_solver=self.current_solver, solvers=self.rbf_solvers)
def get_ui_context(self):
"""
If the ui is available, return the ui context
:return: ui context containing the current state of the ui
:rtype: ui_context.PoseWranglerUIContext or None
"""
if self._view:
return self._view.get_context()
def get_extension_by_type(self, class_ref):
"""
Get a reference to one of the loaded extensions from a class type
:param class_ref: reference to an extension class
:type class_ref: base_extension.PoseWranglerExtension
:return: reference to a loaded extension if one is loaded
:rtype: base_extension.PoseWranglerExtension instance or None
"""
extensions = [extension for extension in self._extensions if isinstance(extension, class_ref)]
if not extensions:
return
return extensions[0]
def set_mirror_mapping(self, path=None):
"""
Set the mirror mapping from a file
:param path: path to json mirror mapping file
:type path: str
"""
# If no path is specified, get the path stored in the settings manager
if path is None:
path = self._settings_manager.get_setting("MirrorMappingFile")
if path:
# If the path doesnt exist, raise an exception
if not os.path.exists(path):
raise exceptions.InvalidMirrorMapping("Unable to find mapping file: {file}".format(file=path))
# Set the new mirror mapping
self._mirror_mapping = mirror_mapping.MirrorMapping(file_path=path)
self._settings_manager.set_setting("MirrorMappingFile", path)
# If we are using the UI, update the mirror mapping file path
if self._view:
self._view.update_mirror_mapping_file(self._mirror_mapping.file_path)
def get_solver_edit_status(self, solver):
"""
Check if the current solver is in 'Edit' mode
:param solver: solver reference
:type solver: api.RBFNode
:return: True = in edit mode, False = not in edit mode
:rtype: bool
"""
# Get the solver if it hasn't been specified
solver = solver or self._current_solver
return solver.get_solver_edit_status()
def _get_valid_actions(self, ui_context):
"""
Gets the valid actions base on the ui context
:param ui_context :type PoseWranglerUIContext: current selection state of the UI
"""
if not self._view:
return
# Find all the available actions
found_actions = []
# Iterate through all the modules currently loaded
for module in list(sys.modules.values()):
try:
# Find all the classes in the module that inherit from BaseAction
found_actions.extend(
[obj for name, obj in inspect.getmembers(module, inspect.isclass) if
issubclass(
obj, base_action.BaseAction
) and obj != base_action.BaseAction
and obj not in found_actions]
)
except ImportError:
continue
# Create the actions
self._view.valid_actions.clear()
self._view.valid_actions.extend([action(api=self) for action in found_actions])
def _load_extensions(self):
"""
Loads any core and custom extensions found in the sys.modules
"""
# Find all the available extensions
found_extensions = []
# Iterate through all the modules currently loaded
for module in list(sys.modules.values()):
try:
# Find all the classes in the module that inherit from BaseAction
found_extensions.extend(
[obj for name, obj in inspect.getmembers(module, inspect.isclass) if
issubclass(
obj, base_extension.PoseWranglerExtension
) and obj != base_extension.PoseWranglerExtension
and obj not in found_extensions]
)
except ImportError:
continue
# Create the extensions
self._extensions = [extension(display_view=bool(self._view), api=self) for extension in found_extensions]
# If we are using the UI, show the actions
if self._view:
self._view.display_extensions(self._extensions)
def _load_view(self):
"""
Refresh the UI with the latest solvers
"""
if not self._view:
return
# If we have a ui, clear it before we load fresh data
self._view.clear()
# Bool to keep track if we have a solver that has edits to it
existing_edit = None
# Iterate through all the solvers in the scene
for solver in self.rbf_solvers:
# Get the solvers edit status
edit = self.get_solver_edit_status(solver)
# If we have an existing edit and this solver has also been edited
if existing_edit is not None and edit:
# Finish editing this solver
self.edit_solver(edit=False, solver=solver)
# Update the edit status accordingly
edit = False
# Otherwise if no existing edit and we have an edit
elif edit:
# Store the existing edit
existing_edit = solver
# Add the solver to the view with its edit status
self._view.add_rbf_solver(solver, edit=edit)
def _set_current_solver(self, solver):
"""
UI Event to update the current solver
:param solver :type api.RBFNode: solver reference
"""
self.current_solver = solver
# noinspection DuplicatedCode
def _setup_view_events(self):
"""
Connect up all of the ui signals to their corresponding functions
"""
# Only works if we have a view
if not self._view:
return
# Connect up all the ui events
# Solver Events
self._view.event_create_solver.connect(self.create_rbf_solver)
self._view.event_delete_solver.connect(self.delete_rbf_solver)
self._view.event_edit_solver.connect(self.edit_solver)
self._view.event_mirror_solver.connect(self.mirror_rbf_solver)
self._view.event_refresh_solvers.connect(self._load_view)
self._view.event_set_current_solver.connect(self._set_current_solver)
# Driver Events
self._view.event_add_drivers.connect(self.add_drivers)
self._view.event_remove_drivers.connect(self.remove_drivers)
self._view.event_export_drivers.connect(self.serialize_to_file)
self._view.event_import_drivers.connect(self.deserialize_from_file)
# Driven Events
self._view.event_add_driven.connect(self.add_driven_transforms)
self._view.event_remove_driven.connect(self.remove_driven)
# Pose Events
self._view.event_add_pose.connect(self.create_pose)
self._view.event_delete_pose.connect(self.delete_pose)
self._view.event_go_to_pose.connect(self.go_to_pose)
self._view.event_mirror_pose.connect(self.mirror_pose)
self._view.event_rename_pose.connect(self.rename_pose)
self._view.event_update_pose.connect(self.update_pose)
self._view.event_mute_pose.connect(self.mute_pose)
# Blendshape Events
self._view.event_create_blendshape.connect(self.create_blendshape)
self._view.event_add_blendshape.connect(self.add_blendshape)
self._view.event_edit_blendshape.connect(self.edit_blendshape)
# Utility Events
self._view.event_get_valid_actions.connect(self._get_valid_actions)
self._view.event_select.connect(utils.set_selection)
self._view.event_set_mirror_mapping.connect(self.set_mirror_mapping)
if __name__ == '__main__':
import ctypes
from PySide2 import QtWidgets
myappid = 'EpicGames.PoseWrangler'
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
app = QtWidgets.QApplication(sys.argv)
tool = UERBFAPI(view=True)
app.exec_()

View File

@ -0,0 +1,5 @@
from .actions import (
selection,
io,
zero_pose
)

View File

@ -0,0 +1,71 @@
# Copyright Epic Games, Inc. All Rights Reserved.
from epic_pose_wrangler.v2.model import base_action
class ExportSelectedAction(base_action.BaseAction):
__display_name__ = "Export Selected Solvers"
__tooltip__ = "Exports the currently selected solver nodes in the scene to a JSON file"
__category__ = "IO"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.current_solvers)
def execute(self, ui_context=None, **kwargs):
from PySide2 import QtWidgets
if not ui_context:
ui_context = self.api.get_ui_context()
if not ui_context:
return
file_path = QtWidgets.QFileDialog.getSaveFileName(None, "Pose Wrangler File", "", "*.json")[0]
# If no path is specified, exit early
if file_path == "":
return
self.api.serialize_to_file(file_path, ui_context.current_solvers)
class ExportAllAction(base_action.BaseAction):
__display_name__ = "Export All Solvers"
__tooltip__ = "Exports all solver nodes in the scene to a JSON file"
__category__ = "IO"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.current_solvers)
def execute(self, ui_context=None, **kwargs):
from PySide2 import QtWidgets
if not ui_context:
ui_context = self.api.get_ui_context()
if not ui_context:
return
file_path = QtWidgets.QFileDialog.getSaveFileName(None, "Pose Wrangler File", "", "*.json")[0]
# If no path is specified, exit early
if file_path == "":
return
self.api.serialize_to_file(file_path, None)
class ImportFromFileAction(base_action.BaseAction):
__display_name__ = "Import Solvers"
__tooltip__ = "Imports solver nodes into the scene from a JSON file"
__category__ = "IO"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.current_solvers)
def execute(self, ui_context=None, **kwargs):
from PySide2 import QtWidgets
if not ui_context:
ui_context = self.api.get_ui_context()
if not ui_context:
return
file_path = QtWidgets.QFileDialog.getOpenFileName(None, "Pose Wrangler File", "", "*.json")[0]
# If no path is specified, exit early
if file_path == "":
return
self.api.deserialize_from_file(file_path)

View File

@ -0,0 +1,55 @@
# Copyright Epic Games, Inc. All Rights Reserved.
from maya import cmds
from epic_pose_wrangler.v2.model import base_action
class SelectSolverAction(base_action.BaseAction):
__display_name__ = "Select Solver Node(s)"
__tooltip__ = "Selects the currently selected solver nodes in the scene"
__category__ = "Select"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.current_solvers)
def execute(self, ui_context=None, **kwargs):
if not ui_context:
ui_context = self.api.get_ui_context()
if not ui_context:
return
cmds.select(ui_context.current_solvers, replace=True)
class SelectDriverAction(base_action.BaseAction):
__display_name__ = "Select Driver Node(s)"
__tooltip__ = "Selects the driver nodes in the scene"
__category__ = "Select"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.drivers)
def execute(self, ui_context=None, **kwargs):
if not ui_context:
ui_context = self.api.get_ui_context()
if not ui_context:
return
cmds.select(ui_context.drivers, replace=True)
class SelectDrivenAction(base_action.BaseAction):
__display_name__ = "Select Driven Node(s)"
__tooltip__ = "Selects the driven nodes in the scene"
__category__ = "Select"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.driven)
def execute(self, ui_context=None, **kwargs):
if not ui_context:
ui_context = self.api.get_ui_context()
if not ui_context:
return
cmds.select(ui_context.driven, replace=True)

View File

@ -0,0 +1,42 @@
# Copyright Epic Games, Inc. All Rights Reserved.
from maya import cmds
from epic_pose_wrangler.v2.model import base_action, pose_blender
class ZeroDefaultPoseAction(base_action.BaseAction):
__display_name__ = "Zero Default Pose Transforms"
__tooltip__ = ""
__category__ = "Utilities"
@classmethod
def validate(cls, ui_context):
return bool(ui_context.current_solvers)
def execute(self, ui_context=None, solver=None, **kwargs):
from PySide2 import QtWidgets
if not ui_context:
ui_context = self.api.get_ui_context()
if ui_context:
solver = self.api.get_rbf_solver_by_name(ui_context.current_solvers[-1])
if not solver:
solver = self.api.current_solver
# Go to the default pose
solver.go_to_pose('default')
# Assume edit is enabled
edit = True
if not self.api.get_solver_edit_status(solver):
# If edit isn't enabled, store the current enabled state and enable editing
edit = False
self.api.edit_solver(edit=True, solver=solver)
# Reset the driven transforms
for node in solver.driven_nodes(type=pose_blender.UEPoseBlenderNode.node_type):
cmds.setAttr('{node}.translate'.format(node=node), 0.0, 0.0, 0.0)
cmds.setAttr('{node}.rotate'.format(node=node), 0.0, 0.0, 0.0)
cmds.setAttr('{node}.scale'.format(node=node), 1.0, 1.0, 1.0)
# Update the pose
self.api.update_pose(pose_name='default', solver=solver)
# Restore edit status
self.api.edit_solver(edit=edit, solver=solver)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import abc
class BaseAction(object):
__display_name__ = "BaseAction"
__tooltip__ = ""
__category__ = ""
@classmethod
@abc.abstractmethod
def validate(cls, ui_context):
raise NotImplementedError
@abc.abstractmethod
def execute(self, ui_context=None, **kwargs):
raise NotImplementedError
def __init__(self, api=None):
self._api = api
@property
def api(self):
return self._api

View File

@ -0,0 +1,59 @@
# Copyright Epic Games, Inc. All Rights Reserved.
from epic_pose_wrangler.model import exceptions
class PoseWranglerExtension(object):
"""
Base class for extending pose wrangler with custom utilities that can be dynamically added to the UI
"""
__category__ = ""
def __init__(self, display_view=False, api=None):
super(PoseWranglerExtension, self).__init__()
self._display_view = display_view
self._view = None
self._api = api
@property
def api(self):
"""
Get the current API
:return: Reference to the main API interface
:rtype: pose_wrangler.v2.main.UERBFAPI
"""
return self._api
@property
def view(self):
"""
Get the current view widget. This should be overridden by custom extensions if you wish to embed a UI for this
extension into the main PoseWrangler UI.
:return: Reference to the PySide widget associated with this extension
:rtype: QWidget or None
"""
return self._view
def execute(self, context=None, **kwargs):
"""
Generic entrypoint for executing the extension.
:param: context: pose wrangler context containing current solver and all solvers
:type context: pose_wrangler.v2.model.context.PoseWranglerContext or None
"""
raise exceptions.PoseWranglerFunctionalityNotImplemented(
"'execute' function has not been implemented for {class_name}".format(
class_name=self.__class__.__name__
)
)
def on_context_changed(self, new_context):
"""
Context event called when the current solver is set via the API
:param new_context: pose wrangler context containing current solver and all solvers
:type new_context: pose_wrangler.v2.model.context.PoseWranglerContext or None
"""
pass

View File

@ -0,0 +1,13 @@
# Copyright Epic Games, Inc. All Rights Reserved.
class PoseWranglerContext(object):
def __init__(self, current_solver, solvers):
self._current_solver = current_solver
self._solvers = solvers
@property
def current_solver(self):
return self._current_solver
@property
def solvers(self):
return self._solvers

View File

@ -0,0 +1,53 @@
# Copyright Epic Games, Inc. All Rights Reserved.
"""
API v2 Specific exceptions
"""
from epic_pose_wrangler.model import exceptions
class MessageConnectionError(exceptions.PoseWranglerException):
"""
Raised when message connections fail.
"""
class InvalidSolverError(exceptions.PoseWranglerException):
"""
Raised when the incorrect solver type is specified
"""
class InvalidNodeType(exceptions.PoseWranglerException, TypeError):
"""
Raised when the incorrect node type is specified
"""
class PoseWranglerAttributeError(exceptions.PoseWranglerException, AttributeError):
"""
Raised when there is an issue getting/setting an attribute
"""
class PoseBlenderPoseError(exceptions.PoseWranglerException):
"""
Generic error for issues with poses
"""
class InvalidPose(exceptions.PoseWranglerException):
"""
Generic error for incorrect poses
"""
class InvalidPoseIndex(exceptions.PoseWranglerException):
"""
Raised when issues arise surrounding the poses index
"""
class BlendshapeError(exceptions.PoseWranglerException):
"""
Generic error for blendshape issues
"""

View File

@ -0,0 +1,327 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import os
import json
from maya import cmds
from special_projects.publish_tools.fbx_cmd import fbx_export
from special_projects.publish_tools.utils import find_top_joints
from special_projects.rigging.rbf_node import RBFNode
class RBFNodeExporter(object):
"""
Utility class to export a RBFsolver node, exports a JSON and FBX
>>> from special_projects.rigging.rbf_node.export import RBFNodeExporter
>>> node = 'my_UE4RBFSolver_node'
>>> asset_name = 'my_asset_name'
>>> export_directory = 'my:/export/directory'
>>> exporter = RBFNodeExporter(node, asset_name, export_directory)
>>> exporter.export()
Result:
export_directory/node.json
export_directory/node.fbx
"""
def __init__(self, node, asset_name, export_directory):
"""
Initialize RBF Exporter
"""
self.set_node(node)
self.set_asset_name(asset_name)
self.set_export_directory(export_directory)
self._set_fbx_export_path()
self._set_json_export_path()
def node(self):
return self._node
def set_node(self, node):
if not cmds.objectType(node, isAType=RBFNode.node_type):
raise TypeError('Invalid "{}" node: "{}"'.format(RBFNode.node_type, node))
self._node = RBFNode(node)
def asset_name(self):
return self._asset_name
def set_asset_name(self, asset_name):
self._asset_name = asset_name
def export_directory(self):
return self._export_directory
def set_export_directory(self, directory):
if os.path.isdir(directory) and os.path.exists(directory):
self._export_directory = directory
else:
raise IOError('Export directory "{}" does not exists'.format(directory))
def fbx_export_path(self):
return self._fbx_export_path
def _set_fbx_export_path(self):
self._fbx_export_path = os.path.join(self._export_directory, '{}.fbx'.format(self._node))
def json_export_path(self):
return self._json_export_path
def _set_json_export_path(self):
self._json_export_path = os.path.join(self._export_directory, '{}.json'.format(self._node))
# -------------------------------------------------------------------------------------
def export(self):
"""
Exports FBX and sidecar JSON file
"""
self.fbx_export()
self.json_export()
def json_export(self):
"""
Exports JSON sidecar
"""
self.node().name_unnamed_poses()
config = self._node.data()
config['export_fbx'] = self.fbx_export_path()
config['asset_name'] = self.asset_name()
with open(self.json_export_path(), 'w') as outfile:
json.dump(config, outfile, sort_keys=0, indent=4, separators=(",", ":"))
def fbx_export(self):
"""
Exports baked poses to FBX
"""
self.node().name_unnamed_poses()
self.bake_poses()
cmds.select(self.root_joint())
fbx_export(self.fbx_export_path(),
animation=True,
bake_complex_animation=True,
bake_complex_start=0,
bake_complex_end=cmds.playbackOptions(q=True, maxTime=True),
up_axis='z')
# -------------------------------------------------------------------------------------
def blendshape_nodes(self):
"""
Finds blendshape nodes from driven attributes
"""
driven_attributes = self._node.driven_attributes(type='blendShape')
blendshape_nodes = list()
for output in driven_attributes:
for attribute in output:
blendshape_nodes.append(attribute.split('.')[0])
return list(set(blendshape_nodes))
def meshes(self):
"""
Finds meshes from blendshape nodes
"""
meshes = list()
blendShapes = self.blendshape_nodes()
if blendShapes:
for blendShape in blendShapes:
meshes.extend(cmds.deformer(blendShape, q=True, geometry=True))
meshes = list(set(meshes))
meshes = cmds.listRelatives(meshes, parent=True)
return meshes
def root_joint(self):
"""
Finds root joint from meshes
"""
meshes = self.meshes()
if meshes:
skeleton = list()
for mesh in meshes:
skin_cluster = cmds.ls(cmds.findDeformers(mesh), type='skinCluster')
if skin_cluster:
skin_cluster = skin_cluster[0]
influences = cmds.skinCluster(skin_cluster, q=True, inf=True)
if influences:
skeleton.extend(influences)
else:
raise RuntimeError('No influences found for "{}"'.format(skin_cluster))
else:
cmds.warning('No skinCluster found for "{}"'.format(mesh))
root_joints = find_top_joints(skeleton)
else:
skeleton = self._node.drivers()
# for driver in self._node.drivers():
# driven = driver.replace('_drv', '')
# if cmds.objExists(driven):
# skeleton.append(driven)
root_joints = find_top_joints(skeleton)
if not root_joints:
raise RuntimeError('No root joint found for "{}"'.format(self._node))
return root_joints[0]
def add_root_attributes(self, root_joint):
"""
Adds RBFNode driven attributes to root_joint
"""
pose_root_attributes = dict()
poses = self._node.poses()
for pose in poses:
driven_attributes = self._node.pose_driven_attributes(pose) # add type flag!
current_pose = list()
for attribute in driven_attributes:
node, target = attribute.split('.')
root_attribute = '{}.{}'.format(root_joint, target)
if root_attribute not in current_pose:
if not cmds.objExists(root_attribute):
cmds.addAttr(root_joint, ln=target, at='double', k=True)
else:
input_connection = cmds.listConnections(root_attribute, s=True, d=False, plugs=True)
if input_connection:
cmds.disconnectAttr(input_connection[0], root_attribute)
# cmds.connectAttr(attribute, root_attribute)
current_pose.append(root_attribute)
pose_root_attributes[pose] = current_pose
return pose_root_attributes
def bake_poses(self):
"""
Bakes the RBFNode poses in the timeline for FBX export
"""
for anim_curve_type in ['animCurveTL', 'animCurveTA', 'animCurveTU']:
cmds.delete(cmds.ls(type=anim_curve_type))
pose_root_attributes = self.add_root_attributes(self.root_joint())
for frame, pose in enumerate(self._node.poses()):
# go to pose
self._node.go_to_pose(pose)
# key controllers
if self._node.num_controllers():
for controller in self._node.controllers():
cmds.setKeyframe(controller, t=frame, inTangentType='linear', outTangentType='step')
# or key drivers
else:
for driver in self._node.drivers():
cmds.setKeyframe(driver, t=frame, inTangentType='linear', outTangentType='step')
root_attributes = pose_root_attributes.get(pose, [])
for root_attribute in root_attributes:
input_connection = cmds.listConnections(root_attribute, s=True, d=False, plugs=True)
if input_connection:
cmds.disconnectAttr(input_connection[0], root_attribute)
# Key Driven Before/After
cmds.setAttr(root_attribute, 0)
if frame == len(self._node.poses()) - 1:
cmds.setKeyframe(root_attribute, t=(frame - 1), inTangentType='linear', outTangentType='linear')
else:
cmds.setKeyframe(root_attribute, t=((frame - 1), (frame + 1)), inTangentType='linear',
outTangentType='linear')
# Key Driven
cmds.setAttr(root_attribute, 1)
cmds.setKeyframe(root_attribute, t=frame, inTangentType='linear', outTangentType='linear')
# set start-end frames
end_frame = len(self._node.poses()) - 1
cmds.playbackOptions(minTime=0, maxTime=end_frame, animationStartTime=0, animationEndTime=end_frame)
cmds.dgdirty(a=True)
class RBFPoseExporterBatch(object):
"""
Utility class to export multiple RBFsolver nodes, exports a JSON and FBX for each solver
>>> solvers = cmds.ls(type='UE4RBFSolverNode')
>>> rig_scene = r'D:\Build\UE5_Main\Collaboration\Frosty\ArtSource\Character\Hero\Kenny\Rig\Kenny_Rig.ma'
>>> export_directory = r'D:\test\class'
>>> asset_name = 'Kenny'
>>> rbf_exporter_batch = RBFPoseExporterBatch(solvers, asset_name, export_directory, rig_scene)
Result:
export_directory/node1.json
export_directory/node1.fbx
export_directory/node2.json
export_directory/node2.fbx
"""
def __init__(self, nodes, asset_name, export_directory, rig_scene):
self.set_pose_exporter(nodes, asset_name, export_directory)
self.set_rig_scene(rig_scene)
def pose_exporter(self):
return self._poseExporter
def asset_name(self):
return self._asset_name
def export_directory(self):
return self._export_directory
def set_pose_exporter(self, nodes, asset_name, export_directory):
if not hasattr(nodes, '__iter__'):
nodes = [nodes]
exporters = list()
for node in nodes:
if cmds.objectType(node, isAType=RBFNodeExporter.node_type):
exporters.append(RBFNodeExporter(node, asset_name, export_directory))
if not len(exporters):
raise RuntimeError('No valid {} objects found'.format(RBFNodeExporter.node_type))
self._poseExporter = exporters
self._asset_name = asset_name
self._export_directory = export_directory
def rig_scene(self):
return self._rig_scene
def set_rig_scene(self, rig_scene):
if not os.path.exists(rig_scene):
raise IOError('Rig scene "{}" does not exists'.format(rig_scene))
self._rig_scene = rig_scene
# -------------------------------------------------------------------------------------
def export(self, run_in_subprocess=True):
# TO-DO: Implement run_in_subprocess
for exporter in self.pose_exporter():
cmds.file(self.rig_scene(), open=True, force=True)
exporter.export()

View File

@ -0,0 +1,502 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import copy
from maya import cmds
from maya.api import OpenMaya as om
from epic_pose_wrangler.log import LOG
from epic_pose_wrangler.v2.model import exceptions, utils
class UEPoseBlenderNode(object):
"""
Class wrapper for UEPoseBlenderNode
"""
node_type = 'UEPoseBlenderNode'
@classmethod
def create(cls, driven_transform=None):
"""
Create Pose Blender Node
>>> new_node = UEPoseBlenderNode.create()
"""
name = driven_transform
# If no name is specified, generate unique name
if name is None:
name = '{name}#'.format(name=cls.node_type)
name += "_{class_name}".format(class_name=cls.__name__)
# Create node
node = cmds.createNode(cls.node_type, name=name)
node_ref = cls(node)
# If a driving transform is specified, connect it via a message attribute and set the base pose
if driven_transform is not None:
utils.message_connect(
from_attribute="{node}.drivenTransform".format(node=node),
to_attribute="{transform}.poseBlender".format(transform=driven_transform)
)
# Set the base pose
node_ref.base_pose = cmds.xform(driven_transform, query=True, objectSpace=True, matrix=True)
# Return the new UEPoseBlenderNode reference
return node_ref
@classmethod
def find_all(cls):
"""
Returns all PoseBlender nodes in scene
"""
return [cls(node) for node in cmds.ls(type=cls.node_type)]
@classmethod
def find_by_name(cls, name):
"""
Find a UEPoseBlenderNode class with the specified name
:param name :type str: name of the node
:return :type UEPoseBlenderNode or None:
"""
if not name.endswith(cls.node_type):
name += "_{node_type}".format(node_type=cls.node_type)
match = cmds.ls(name, type=cls.node_type)
return cls(match[0]) if match else None
@classmethod
def find_by_transform(cls, transform):
"""
Finds a UEPoseBlenderNode class connected to the specified joint
:param transform :type str: name of the transform
:return :type UEPoseBlenderNode or None
"""
if not cmds.attributeQuery('poseBlender', node=transform, exists=True):
return
connections = cmds.listConnections('{transform}.poseBlender'.format(transform=transform))
if not connections:
return
return cls(connections[0])
def __init__(self, node):
# If the node doesn't exist, raise an exception
if not cmds.objectType(node, isAType=self.node_type):
raise exceptions.InvalidNodeType(
'Invalid "{node_type}" node: "{node_name}"'.format(
node_type=self.node_type,
node_name=node
)
)
# Store a reference ot the MObject in case the name of the node is changed whilst this class is still in use
self._node = om.MFnDependencyNode(om.MGlobal.getSelectionListByName(node).getDependNode(0))
def __repr__(self):
"""
Returns class string representation
"""
return '<{node_type}>: {object}'.format(node_type=self.node_type, object=self)
def __str__(self):
"""
Returns class as string
"""
return str(self.node)
def __eq__(self, other):
"""
Returns if two objects are the same, allows for comparing two different UEPoseBlenderNode references that wrap
the same MObject
"""
return str(self) == str(other)
def __enter__(self):
"""
Override the __enter__ to allow for this class to be used as a context manager to toggle edit mode
>>> pose_blender = UEPoseBlenderNode('TestPoseBlenderNode')
>>> with pose_blender:
>>> # Edit mode enabled so changes can be made
>>> pass
>>> # Edit mode is now disabled
"""
self.edit = True
def __exit__(self, exc_type, exc_val, exc_tb):
"""
On exit, disable edit mode
"""
self.edit = not self.edit
@property
def node(self):
return self._node.name()
@property
def rbf_solver_attr(self):
return '{node}.rbfSolver'.format(node=self.node)
@property
def base_pose_attr(self):
return '{node}.basePose'.format(node=self.node)
@property
def envelope_attr(self):
return '{node}.envelope'.format(node=self.node)
@property
def in_matrix_attr(self):
return '{node}.inMatrix'.format(node=self.node)
@property
def out_matrix_attr(self):
return '{node}.outMatrix'.format(node=self.node)
@property
def poses_attr(self):
return '{node}.poses'.format(node=self.node)
@property
def weights_attr(self):
return '{node}.weights'.format(node=self.node)
@property
def driven_transform(self):
"""
Get the current driven transform
:return :type str: transform node name
"""
if cmds.attributeQuery('drivenTransform', node=self.node, exists=True):
transforms = cmds.listConnections("{node}.drivenTransform".format(node=self.node))
if transforms:
return transforms[0]
@property
def edit(self):
"""
:return :type bool: are the driven transforms connected to this node editable?
"""
transform = self.driven_transform
# If no transform, we can edit!
if not transform:
return True
# False if we have any matrix connections, otherwise we have no connections and can edit
return False if cmds.listConnections(self.out_matrix_attr) else True
@edit.setter
def edit(self, value):
"""
Allow the driven transforms to be edited (or not)
:param value :type bool: edit mode enabled True/False
"""
# If we don't have a driven transform we can't do anything
transform = self.driven_transform
if not transform:
return
# If we are in edit mode, disable out connections
if bool(value):
self.out_matrix = None
# Otherwise connect up the transform
else:
self.out_matrix = transform
@property
def rbf_solver(self):
"""
:return:The connected rbf solver node if it exists
"""
from epic_pose_wrangler.v2.model import api
# Check the attr exists
exists = cmds.attributeQuery(self.rbf_solver_attr.split('.')[-1], node=self.node, exists=True)
# Query the connections
connections = cmds.listConnections(self.rbf_solver_attr)
# Return the node if the attr exists and a connection is found
return api.RBFNode(connections[0]) if exists and connections else None
@rbf_solver.setter
def rbf_solver(self, rbf_solver_attr):
"""
Connects up this node to an rbf solver node
:param rbf_solver_attr: the attribute on the rbf solver to connect this to i.e my_rbf_solver.poseBlend_back_120
"""
utils.message_connect(rbf_solver_attr, self.rbf_solver_attr)
@property
def base_pose(self):
"""
:return :type list: matrix
"""
return self.get_pose(index=0)
@base_pose.setter
def base_pose(self, matrix):
"""
Set the base pose to a specific pose.PoseNode
:param matrix :type list: Matrix to set as base pose
"""
# Connect up the pose's outputLocalMatrix to the basePose plug
utils.set_attr_or_connect(source_attr_name=self.base_pose_attr, value=matrix, attr_type='matrix')
# Set the inMatrix to the basePose plug
self.in_matrix = matrix
# Set the first pose in the pose list to the new base pose
self.set_pose(index=0, overwrite=True, matrix=matrix)
@property
def envelope(self):
"""
Get the value of the envelope attr from this node
"""
return utils.get_attr(self.envelope_attr, as_value=True)
@envelope.setter
def envelope(self, value):
"""
Sets the envelope
:param value: float, int or string (i.e node.attributeName). Float/Int will set the value, whilst
passing an attribute will connect the plugs
"""
if isinstance(value, float) or isinstance(value, int) and 0 > value > 1:
value = min(max(0.0, value), 1.0)
utils.set_attr_or_connect(source_attr_name=self.envelope_attr, value=value)
@property
def in_matrix(self):
"""
Get the inMatrix value
:return: matrix or attributeName
"""
# Get the current value of the attribute
return utils.get_attr(self.in_matrix_attr, as_value=True)
@in_matrix.setter
def in_matrix(self, value):
"""
Sets the inMatrix attribute to a value or connects it to another matrix plug
:param value: matrix (list) or string node.attributeName
"""
utils.set_attr_or_connect(source_attr_name=self.in_matrix_attr, value=value, attr_type='matrix')
@property
def out_matrix(self):
"""
:return:
"""
return cmds.getAttr(self.out_matrix_attr)
@out_matrix.setter
def out_matrix(self, transform_name=None):
"""
Connect the output matrix up to a given transform node
:param transform_name: name of a transform node to connect to
"""
# If a transform is specified, connect it
current_selection = cmds.ls(selection=True)
if transform_name:
# Check that the node type is a transform
if not cmds.ls(transform_name, type='transform'):
# If its not a transform raise an error
raise exceptions.InvalidNodeType(
"Invalid node type. Expected 'transform', received '{node_type}'".format(
node_type=cmds.objectType(transform_name)
)
)
# Create a new decomposeMatrix node to convert the outMatrix to T,R,S
mx_decompose_node = cmds.createNode('decomposeMatrix', name="{node}_mx_decompose#".format(node=self.node))
# Connect the outMatrix up to the decomposeMatrix's input
cmds.connectAttr(self.out_matrix_attr, '{node}.inputMatrix'.format(node=mx_decompose_node))
# Create an iterator with TRS attributes
attributes = ('translate', 'rotate', 'scale')
# Iterate through each attr and connect it
for attr in attributes:
out_attr = '{mx_decompose_node}.output{attr}'.format(
mx_decompose_node=mx_decompose_node,
attr=attr.capitalize()
)
in_attr = '{node_name}.{attr}'.format(node_name=transform_name, attr=attr)
cmds.connectAttr(out_attr, in_attr, force=True)
else:
# No transform was specified, disconnect all existing connections
connections = cmds.listConnections(self.out_matrix_attr, type='decomposeMatrix')
current_matrix = cmds.xform(self.driven_transform, query=True, matrix=True, objectSpace=True)
if connections:
cmds.delete(connections)
for connection in cmds.listConnections(self.out_matrix_attr, plugs=True) or []:
cmds.disconnectAttr(self.out_matrix_attr, connection)
cmds.xform(self.driven_transform, matrix=current_matrix, objectSpace=True)
cmds.select(current_selection, replace=True)
def get_pose(self, index=-1):
"""
Get the pose at the specified index
:param index :type int: index to query
:return :type list matrix or None
"""
# Get the value of the pose_attr at the specified index
return utils.get_attr(
'{poses_attr}[{index}]'.format(poses_attr=self.poses_attr, index=index),
as_value=True
)
def get_poses(self):
"""
:return :type list of matrices
"""
# TODO update this when the weight + name get added
return utils.get_attr_array(attr_name=self.poses_attr, as_value=True)
def add_pose_from_current(self, pose_name, index=-1):
"""
Add a pose from the current transforms positions
:param pose_name :type str: name of the pose
:param index :type int: target index for the pose
"""
# Find the next index if no index is specified
if index < 0:
# If the index is less than 0 find the next available index
index = len(cmds.getAttr(self.poses_attr, multiIndices=True) or [0]) - 1
# Store driven transform in var to reduce number of cmds calls
driven_transform = self.driven_transform
if not driven_transform:
raise exceptions.PoseBlenderPoseError(
"No driven transform associated with this node, "
"unable to get the current matrix"
)
# Get the local matrix for the driven transform
local_matrix = cmds.xform(driven_transform, query=True, objectSpace=True, matrix=True)
# Set the pose
self.set_pose(index=index, pose_name=pose_name, matrix=local_matrix)
def set_pose(self, index=-1, pose_name="", overwrite=True, matrix=None):
"""
Set a pose for the specified index
:param index: int value of the index to set
:param pose_name: name of the pose as a string
:param overwrite: should it overwrite any existing pose at the index or insert
:param matrix: matrix value to set
"""
# Find the next index if no index is specified
if index < 0:
if pose_name:
LOG.warning("Set Pose has not been implemented to support a pose name. FIXME")
return
else:
# If the index is less than 0 find the next available index
index = len(cmds.getAttr(self.poses_attr, multiIndices=True) or [0]) - 1
# If no matrix is specified, grab the matrix for the driven transform
if matrix is None:
matrix = cmds.xform(self.driven_transform, query=True, matrix=True, objectSpace=True)
# TODO add in support for inserting into the pose list
if not overwrite:
pose_count = len(cmds.getAttr(self.poses_attr, multiIndices=True) or [])
if pose_count - 1 > index:
pass
# Generate the source attribute (the attribute on this node) from the default attr and given index
source_attr_name = '{poses_attr}[{index}]'.format(poses_attr=self.poses_attr, index=index)
# Set the pose
utils.set_attr_or_connect(source_attr_name=source_attr_name, value=matrix, attr_type='matrix')
def go_to_pose(self, index=-1):
"""
Move the driven transform to the matrix stored in the pose list at the specified index
:param index :type index: index to go to
"""
# Get the matrix at the specified index
pose_matrix = self.get_pose(index=index)
# Assume the position
cmds.xform(self.driven_transform, matrix=pose_matrix)
def delete_pose(self, index=-1, pose_name=""):
"""
Delete a pose at the specified index or with the given name
:param index :type int: index to delete or -1 to use pose_name
:param pose_name :type str: pose name to delete
"""
# TODO Delete pose will need to remember enabled state + reconnect up pose_name connection when the changes are
# made to the solver. Currently can't delete by pose name because there is nothing tying an index to a pose
# name
# If no index and no pose name, raise exception
if index < 0 and not pose_name:
raise exceptions.InvalidPoseIndex("Unable to delete pose")
# Copy a list of the poses
poses = copy.deepcopy(self.get_poses())
# Iterate through the existing poses in reverse
for i in reversed(range(len(poses))):
# Remove the pose from the list of targets
cmds.removeMultiInstance('{blender}.poses[{index}]'.format(blender=self, index=i), b=True)
# Remove the pose at the given index
poses.pop(index)
# Re-add all the deleted poses (minus the one we popped)
for pose_index, matrix in enumerate(poses):
self.set_pose(index=pose_index, matrix=matrix)
def get_weight(self, index, as_float=True):
"""
Get the current weight for the specified index
:param index :type int
:param as_float :type bool: return as float or as attribute name of the connected plug
:return: float or plug
"""
return utils.get_attr('{weights}[{index}]'.format(weights=self.weights_attr, index=index), as_value=as_float)
def get_weights(self, as_float=True):
"""
Get all the weights associated with this node
:param as_float :type bool: as list of floats or list of plugs
:return: list of floats or plugs
"""
return utils.get_attr_array(attr_name=self.weights_attr, as_value=as_float)
def set_weight(self, index=-1, in_float_attr="", float_value=0.0):
"""
Set the weight at a given index either by connecting an attribute or specifying a float value
:param index :type int: index to set
:param in_float_attr :type str: node.attributeName to connect to this plug
:param float_value :type float: float value to set
"""
if index < 0:
# If the index is less than 0 find the next available index
index = len(cmds.getAttr(self.weights_attr, multiIndices=True) or [0]) - 1
# Generate the correct source attr name for the index specified
source_attr_name = '{weights}[{index}]'.format(weights=self.weights_attr, index=index)
# Set the array element to either the attr or the float value
utils.set_attr_or_connect(source_attr_name=source_attr_name, value=in_float_attr or float_value)
def set_weights(self, in_float_array_attr="", floats=None):
"""
Set multiple weights either by an attribute array string or a float list.Will overwrite existing values
:param in_float_array_attr :type str: node.attributeName array attribute to connect all indices with
:param floats :type list: list of float values to set for each corresponding index
"""
# Prioritize plugs over setting floats
if in_float_array_attr:
# Iterate through all the indices in the array
for i in range(len(cmds.getAttr(in_float_array_attr, multiIndices=True) or [0])):
# Generate the source attr name
in_float_attr = "{array_attr}[{index}]".format(array_attr=in_float_array_attr, index=i)
# Set the weight for the current index
self.set_weight(index=i, in_float_attr=in_float_attr)
elif floats:
# Iterate through the floats
for index, float_value in enumerate(floats):
# Set the weight for the current index
self.set_weight(index=index, float_value=float_value)
def delete(self):
"""
Delete the node associated with this wrapper
"""
# If we aren't in edit mode we still have connections made to decomposeMatrix nodes that we want to delete
if not self.edit:
# Enable edit mode to delete those decomposeMatrix nodes
self.edit = True
# Disconnect all attrs
destination_conns = cmds.listConnections(self, plugs=True, connections=True, source=False) or []
for i in range(0, len(destination_conns), 2):
cmds.disconnectAttr(destination_conns[i], destination_conns[i + 1])
source_conns = cmds.listConnections(self, plugs=True, connections=True, destination=False) or []
for i in range(0, len(source_conns), 2):
# we have to flip these because the output is always node centric and not connection centric
cmds.disconnectAttr(source_conns[i + 1], source_conns[i])
# Delete the node
cmds.delete(self.node)

View File

@ -0,0 +1,311 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import math
import traceback
from maya import OpenMaya, cmds
from epic_pose_wrangler.log import LOG
from epic_pose_wrangler.v2.model import exceptions
# NOTE: MTransformationMatrix & MEulerRotation have different values for the same axis.
XFORM_ROTATION_ORDER = {
'xyz': OpenMaya.MTransformationMatrix.kXYZ,
'yzx': OpenMaya.MTransformationMatrix.kYZX,
'zxy': OpenMaya.MTransformationMatrix.kZXY,
'xzy': OpenMaya.MTransformationMatrix.kXZY,
'yxz': OpenMaya.MTransformationMatrix.kYXZ,
'zyx': OpenMaya.MTransformationMatrix.kZYX
}
EULER_ROTATION_ORDER = {
'xyz': OpenMaya.MEulerRotation.kXYZ,
'yzx': OpenMaya.MEulerRotation.kYZX,
'zxy': OpenMaya.MEulerRotation.kZXY,
'xzy': OpenMaya.MEulerRotation.kXZY,
'yxz': OpenMaya.MEulerRotation.kYXZ,
'zyx': OpenMaya.MEulerRotation.kZYX
}
def compose_matrix(position, rotation, rotation_order='xyz'):
"""
Compose a 4x4 matrix with given transformation.
>>> compose_matrix((0.0, 0.0, 0.0), (90.0, 0.0, 0.0))
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]
"""
# create rotation ptr
rot_script_util = OpenMaya.MScriptUtil()
rot_script_util.createFromDouble(*[deg * math.pi / 180.0 for deg in rotation])
rot_double_ptr = rot_script_util.asDoublePtr()
# construct transformation matrix
xform_matrix = OpenMaya.MTransformationMatrix()
xform_matrix.setTranslation(OpenMaya.MVector(*position), OpenMaya.MSpace.kTransform)
xform_matrix.setRotation(rot_double_ptr, XFORM_ROTATION_ORDER[rotation_order], OpenMaya.MSpace.kTransform)
matrix = xform_matrix.asMatrix()
return [matrix(m, n) for m in range(4) for n in range(4)]
def decompose_matrix(matrix, rotation_order='xyz'):
"""
Decomposes a 4x4 matrix into translation and rotation.
>>> decompose_matrix([1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0])
((0.0, 0.0, 0.0), (90.0, 0.0, 0.0))
"""
mmatrix = OpenMaya.MMatrix()
OpenMaya.MScriptUtil.createMatrixFromList(matrix, mmatrix)
# create transformation matrix
xform_matrix = OpenMaya.MTransformationMatrix(mmatrix)
# get translation
translation = xform_matrix.getTranslation(OpenMaya.MSpace.kTransform)
# get rotation
# @ref: https://github.com/LumaPictures/pymel/blob/master/pymel/core/datatypes.py
# The apicls getRotation needs a "RotationOrder &" object, which is impossible to make in python...
euler_rotation = xform_matrix.eulerRotation()
euler_rotation.reorderIt(EULER_ROTATION_ORDER[rotation_order])
rotation = euler_rotation.asVector()
return (
(translation.x, translation.y, translation.z),
(rotation.x * 180.0 / math.pi, rotation.y * 180.0 / math.pi, rotation.z * 180.0 / math.pi)
)
def euler_to_quaternion(rotation, rotation_order='xyz'):
"""
Returns Euler Rotation as Quaternion
>>> euler_to_quaternion((90, 0, 0))
(0.7071, 0.0, 0.0, 0.70710))
"""
euler_rotation = OpenMaya.MEulerRotation(
rotation[0] * math.pi / 180.0,
rotation[1] * math.pi / 180.0,
rotation[2] * math.pi / 180.0
)
euler_rotation.reorderIt(EULER_ROTATION_ORDER[rotation_order])
quat = euler_rotation.asQuaternion()
return quat.x, quat.y, quat.z, quat.w
def quaternion_to_euler(rotation, rotation_order='xyz'):
"""
Returns Quaternion Rotation as Euler
quaternion_to_euler((0.7071, 0.0, 0.0, 0.70710))
(90, 0, 0)
"""
quat = OpenMaya.MQuaternion(*rotation)
euler_rotation = quat.asEulerRotation()
euler_rotation.reorderIt(EULER_ROTATION_ORDER[rotation_order])
return euler_rotation.x * 180.0 / math.pi, euler_rotation.y * 180.0 / math.pi, euler_rotation.z * 180.0 / math.pi
def is_connected_to_array(attribute, array_attr):
"""
Check if the attribute is connected to the specified array
:param attribute :type str: attribute
:param array_attr :type str array attribute
:return :type int or None: int of index in the array or None
"""
try:
indices = cmds.getAttr(array_attr, multiIndices=True) or []
except ValueError:
return None
for i in indices:
attr = '{from_attr}[{index}]'.format(from_attr=array_attr, index=i)
if attribute in (cmds.listConnections(attr, plugs=True) or []):
return i
return None
def get_next_available_index_in_array(attribute):
# Get the next available index
indices = cmds.getAttr(attribute, multiIndices=True) or []
i = 0
for index in indices:
if index != i and i not in indices:
indices.append(i)
i += 1
indices.sort()
attrs = ['{from_attr}[{index}]'.format(from_attr=attribute, index=i) for i in indices]
connections = [cmds.listConnections(attr, plugs=True) or [] for attr in attrs]
target_index = len(indices)
for index, conn in enumerate(connections):
if not conn:
target_index = index
break
return target_index
def message_connect(from_attribute, to_attribute, in_array=False, out_array=False):
"""
Create and connect a message attribute between two nodes
"""
# Generate the object and attr names
from_object, from_attribute_name = from_attribute.split('.', 1)
to_object, to_attribute_name = to_attribute.split('.', 1)
# If the attributes don't exist, create them
if not cmds.attributeQuery(from_attribute_name, node=from_object, exists=True):
cmds.addAttr(from_object, longName=from_attribute_name, attributeType='message', multi=in_array)
if not cmds.attributeQuery(to_attribute_name, node=to_object, exists=True):
cmds.addAttr(to_object, longName=to_attribute_name, attributeType='message', multi=out_array)
# Check that both attributes, if existing are message attributes
for a in (from_attribute, to_attribute):
if cmds.getAttr(a, type=1) != 'message':
raise exceptions.MessageConnectionError(
'Message Connect: Attribute {attr} is not a message attribute. CONNECTION ABORTED.'.format(
attr=a
)
)
# Connect up the attributes
try:
if in_array:
from_attribute = "{from_attribute}[{index}]".format(
from_attribute=from_attribute,
index=get_next_available_index_in_array(from_attribute)
)
if out_array:
to_attribute = "{to_attribute}[{index}]".format(
to_attribute=to_attribute,
index=get_next_available_index_in_array(to_attribute)
)
return cmds.connectAttr(from_attribute, to_attribute, force=True)
except Exception as e:
LOG.error(traceback.format_exc())
return False
def connect_attr(from_attr, to_attr):
if not cmds.isConnected(from_attr, to_attr):
cmds.connectAttr(from_attr, to_attr)
def get_attr(attr_name, as_value=True):
"""
Get the specified attribute
:param attr_name :type str: attribute name i.e node.translate
:param as_value :type bool: return as value or connected plug name
:return :type list or any: either returns a list of connections or the value of the attribute
"""
# Check if the attribute is connected
connections = cmds.listConnections(attr_name, plugs=True)
if connections and not as_value:
# If the attribute is connected and we don't want the value, return the connections
return connections
elif as_value:
# Return the value
return cmds.getAttr(attr_name)
def get_attr_array(attr_name, as_value=True):
"""
Get the specified array attr
:param attr_name :type str: attribute name i.e node.translate
:param as_value :type bool: return as value or connected plug name
:return :type list or any: either returns a list of connections or the value of the attribute
"""
# Get the number of indices in the array
indices = cmds.getAttr(attr_name, multiIndices=True) or []
# Empty list to store the connected plugs
connected_plugs = []
# Empty list to store values
values = []
# Iterate through the indices
for i in indices:
# Get all the connected plugs for this index
connections = cmds.listConnections('{attr_name}[{index}]'.format(attr_name=attr_name, index=i), plugs=True)
# If we want the plugs and not values, store connections
if connections and not as_value:
connected_plugs.extend(connections)
# If we want values, get the value at the index
elif as_value:
values.append(cmds.getAttr('{attr_name}[{index}]'.format(attr_name=attr_name, index=i)))
# Return plugs or values, depending on which one has data
return connected_plugs or values
def set_attr_or_connect(source_attr_name, value=None, attr_type=None, output=False):
"""
Set an attribute or connect it to another attribute
:param source_attr_name :type str: attribute name
:param value : type any: value to set the attribute to
:param attr_type :type str: name of the attribute type i.e matrix
:param output :type bool: is this plug an output (True) or input (False)
"""
# Type conversion from maya: python
attr_types = {
'matrix': list
}
# Check if we have a matching type
matching_type = attr_types.get(attr_type, None)
# If we have a matching type and the value matches that type, set the attr
if matching_type is not None and isinstance(value, matching_type):
cmds.setAttr(source_attr_name, value, type=attr_type)
# If the value is a string and no type is matched, we want to connect the attributes
elif isinstance(value, str):
try:
# Connect from left->right depending on if the source is output or input
if output:
if not cmds.isConnected(source_attr_name, value):
cmds.connectAttr(source_attr_name, value)
else:
if not cmds.isConnected(value, source_attr_name):
cmds.connectAttr(value, source_attr_name)
except Exception as e:
raise exceptions.PoseWranglerAttributeError(
"Unable to {direction} {input} to '{output}'".format(
direction="connect" if value else "disconnect",
input=source_attr_name if output else value,
output=value if output else source_attr_name
)
)
else:
cmds.setAttr(source_attr_name, value)
def disconnect_attr(attr_name, array=False):
"""
Disconnect the specified attribute
:param attr_name :type str: attribute name to disconnect
:param array :type bool: is this attribute an array?
"""
attrs = []
# If we are disconnecting an array, get the names of all the attributes
if array:
attrs.extend(cmds.getAttr(attr_name, multiIndices=True) or [])
# Otherwise append the attr name specified
else:
attrs.append(attr_name)
# Iterate through all the attrs listed
for attr in attrs:
# Find their connections and disconnect them
for plug in cmds.listConnections(attr, plugs=True) or []:
cmds.disconnectAttr(attr, plug)
def get_selection(_type=""):
"""
Returns the current selection
"""
return cmds.ls(selection=True, type=_type)
def set_selection(selection_list):
"""
Sets the active selection
"""
cmds.select(selection_list, replace=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,16 @@
QListWidget[Log=true]{
background-color: #2A2A2A;
background-image: url("PoseWrangler:epic.png");
background-repeat: no-repeat;
background-position: bottom right;
}
QPushButton[Category=true] {
text-align: left;
background-color: #707070;
}
QPushButton::hover[Category=true] {
text-align: left;
background-color: #707070;
}

View File

@ -0,0 +1,839 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>883</width>
<height>693</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QSplitter" name="splitter_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QSplitter" name="splitter_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QWidget" name="verticalLayoutWidget">
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>RBF SOLVERS:</string>
</property>
<property name="heading" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="solver_LIST">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>RMB Solver for options</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_14">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QPushButton" name="toggle_edit_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Edit Selected Solver</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer_6">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>2</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_13">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QPushButton" name="create_solver_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Create Solver</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="refresh_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Refresh Solvers</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>2</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_11">
<property name="spacing">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="delete_solver_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Delete Solvers</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="mirror_solver_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Mirror Solver</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>2</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="Line" name="line_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="verticalLayoutWidget_2">
<layout class="QVBoxLayout" name="poses_layout">
<item>
<widget class="QLabel" name="label_2">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>POSES:</string>
</property>
<property name="heading" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="pose_LIST">
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_8">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QPushButton" name="add_pose_BTN">
<property name="enabled">
<bool>true</bool>
</property>
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Add Pose</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="edit_pose_BTN">
<property name="enabled">
<bool>true</bool>
</property>
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Update Pose</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="delete_pose_BTN">
<property name="enabled">
<bool>true</bool>
</property>
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Delete Pose</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_10">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QPushButton" name="mute_pose_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Mute Pose</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="mirror_pose_BTN">
<property name="enabled">
<bool>true</bool>
</property>
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Mirror Pose</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="rename_pose_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Rename Pose</string>
</property>
<property name="checkable">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QWidget" name="layoutWidget">
<layout class="QVBoxLayout" name="driver_layout">
<property name="spacing">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label_4">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>DRIVER TRANSFORMS:</string>
</property>
<property name="heading" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="driver_transforms_LIST">
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QPushButton" name="add_driver_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Add Driver Transforms</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="remove_driver_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Remove Driver Transforms</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="layoutWidget">
<layout class="QVBoxLayout" name="driven_layout">
<item>
<widget class="QLabel" name="label_5">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>DRIVEN TRANSFORMS/BLENDSHAPES</string>
</property>
<property name="heading" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="driven_transforms_LIST">
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="spacing">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="add_driven_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Add Driven Transforms</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="remove_driven_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Remove Selected</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="create_blendshape_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Create Blendshape</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QPushButton" name="add_existing_blendshape_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Add Existing Blendshape</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="edit_blendshape_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Edit Blendshape</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QDockWidget" name="utilities_DOCK">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>306</width>
<height>99</height>
</size>
</property>
<property name="allowedAreas">
<set>Qt::LeftDockWidgetArea|Qt::RightDockWidgetArea</set>
</property>
<property name="windowTitle">
<string>Utilities</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="spacing">
<number>2</number>
</property>
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QScrollArea" name="action_scroll_area">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>300</width>
<height>0</height>
</size>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContentsOnFirstShow</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="scrollAreaWidgetContents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>298</width>
<height>642</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3" stretch="0">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<layout class="QVBoxLayout" name="extension_layout"/>
</item>
<item>
<widget class="QLabel" name="label_9">
<property name="font">
<font>
<pointsize>10</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Settings:</string>
</property>
<property name="heading" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_8">
<property name="text">
<string>Mirror Mapping:</string>
</property>
<property name="heading" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<property name="spacing">
<number>2</number>
</property>
<item>
<widget class="QLineEdit" name="mirror_mapping_LINE">
<property name="readOnly">
<bool>true</bool>
</property>
<property name="placeholderText">
<string>Mirror Mapping File</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="mirror_mapping_BTN">
<property name="font">
<font>
<pointsize>10</pointsize>
</font>
</property>
<property name="text">
<string>Select File</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>585</height>
</size>
</property>
</spacer>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>1</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<widget class="QMenuBar" name="menuBar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>883</width>
<height>21</height>
</rect>
</property>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="documentation_ACT"/>
</widget>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="import_drivers_ACT"/>
<addaction name="separator"/>
<addaction name="export_drivers_ACT"/>
<addaction name="export_selected_drivers_ACT"/>
</widget>
<widget class="QMenu" name="menuEdit">
<property name="title">
<string>Edit</string>
</property>
<widget class="QMenu" name="menuStyle">
<property name="title">
<string>Style</string>
</property>
<addaction name="use_maya_style_ACT"/>
</widget>
<addaction name="menuStyle"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuEdit"/>
<addaction name="menuHelp"/>
</widget>
<action name="connectSkinNode_action">
<property name="text">
<string>Connect Selected Skin Nodes</string>
</property>
</action>
<action name="createGeometry_action">
<property name="text">
<string>Create Geometry From SkinNode</string>
</property>
</action>
<action name="unbindGeometry_action">
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="text">
<string>Unbind Geometry When Saving</string>
</property>
</action>
<action name="documentation_ACT">
<property name="text">
<string>Documentation</string>
</property>
</action>
<action name="actionGithub">
<property name="text">
<string>Github</string>
</property>
</action>
<action name="import_drivers_ACT">
<property name="text">
<string>Import Drivers</string>
</property>
</action>
<action name="export_drivers_ACT">
<property name="text">
<string>Export All Drivers</string>
</property>
</action>
<action name="export_selected_drivers_ACT">
<property name="text">
<string>Export Selected Drivers</string>
</property>
</action>
<action name="use_maya_style_ACT">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Use Maya Style</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,978 @@
# Copyright Epic Games, Inc. All Rights Reserved.
import os
import webbrowser
from functools import partial
from maya.app.general.mayaMixin import MayaQWidgetDockableMixin
from PySide2 import QtWidgets, QtGui
from PySide2 import QtCore
from PySide2 import QtUiTools
from epic_pose_wrangler.v2.view import ui_context
from epic_pose_wrangler.v2.view.widget import category
from epic_pose_wrangler.log import LOG
from epic_pose_wrangler.view import log_widget
from epic_pose_wrangler.model import settings
class PoseWranglerWindow(MayaQWidgetDockableMixin, QtWidgets.QMainWindow):
"""
class for the pose wranglerUI
"""
# Solver Signals
event_create_solver = QtCore.Signal(str)
event_delete_solver = QtCore.Signal(str)
event_edit_solver = QtCore.Signal(bool, object)
event_mirror_solver = QtCore.Signal(object)
event_refresh_solvers = QtCore.Signal()
event_set_current_solver = QtCore.Signal(object)
# Driver Signals
event_add_drivers = QtCore.Signal()
event_remove_drivers = QtCore.Signal(list)
event_import_drivers = QtCore.Signal(str)
event_export_drivers = QtCore.Signal(str, list)
# Driven Signals
event_add_driven = QtCore.Signal(list, object, bool)
event_remove_driven = QtCore.Signal(list, object)
# Pose Signals
event_add_pose = QtCore.Signal(str, object)
event_delete_pose = QtCore.Signal(str, object)
event_go_to_pose = QtCore.Signal(str, object)
event_mirror_pose = QtCore.Signal(str, object)
event_rename_pose = QtCore.Signal(str, str, object)
event_update_pose = QtCore.Signal(str, object)
event_mute_pose = QtCore.Signal(str, object, object)
# Blendshape Signals
event_create_blendshape = QtCore.Signal(str, str, bool, object)
event_add_blendshape = QtCore.Signal(str, str, str, object)
event_edit_blendshape = QtCore.Signal(str, bool, object)
# Utility Signals
event_get_valid_actions = QtCore.Signal(object)
event_select = QtCore.Signal(list)
event_set_mirror_mapping = QtCore.Signal(str)
def __init__(self):
super(PoseWranglerWindow, self).__init__()
# Load the UI file
file_path = os.path.dirname(__file__) + "/pose_wrangler_ui.ui"
if os.path.exists(file_path):
ui_file = QtCore.QFile(file_path)
# Attempt to open and load the UI
try:
ui_file.open(QtCore.QFile.ReadOnly)
loader = QtUiTools.QUiLoader()
self.win = loader.load(ui_file)
finally:
# Always close the UI file regardless of loader result
ui_file.close()
else:
raise ValueError('UI File does not exist on disk at path: {}'.format(file_path))
icon_folder = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'resources', 'icons')
QtCore.QDir.setSearchPaths("PoseWrangler", [icon_folder])
self.setWindowTitle("Pose Wrangler 2.0.0")
# Embed the UI window inside this window
self.setCentralWidget(self.win)
self.setWindowIcon(QtGui.QIcon(QtGui.QPixmap("PoseWrangler:unreal.png")))
# Solver Connections
self.win.create_solver_BTN.pressed.connect(self._create_solver)
self.win.delete_solver_BTN.pressed.connect(self._delete_solver)
self.win.toggle_edit_BTN.clicked.connect(self._edit_solver_toggle)
self.win.mirror_solver_BTN.pressed.connect(self._mirror_solver)
self.win.refresh_BTN.clicked.connect(self._refresh_solvers)
self.win.solver_LIST.itemSelectionChanged.connect(self._solver_selection_changed)
self.win.solver_LIST.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.win.solver_LIST.customContextMenuRequested.connect(self._solver_context_menu_requested)
# Driver Connections
self.win.add_driver_BTN.pressed.connect(self._add_drivers)
self.win.remove_driver_BTN.pressed.connect(self._remove_drivers)
self.win.import_drivers_ACT.triggered.connect(self._import_drivers)
self.win.export_drivers_ACT.triggered.connect(partial(self._export_drivers, True))
self.win.export_selected_drivers_ACT.triggered.connect(self._export_drivers)
self.win.driver_transforms_LIST.itemDoubleClicked.connect(
partial(
self._select_in_scene,
self.win.driver_transforms_LIST
)
)
self.win.driven_transforms_LIST.itemDoubleClicked.connect(
partial(
self._select_in_scene,
self.win.driven_transforms_LIST
)
)
# Driven Connections
self.win.add_driven_BTN.pressed.connect(self._add_driven)
self.win.remove_driven_BTN.pressed.connect(self._remove_driven)
# Pose Connections
self.win.add_pose_BTN.pressed.connect(self._create_pose)
self.win.delete_pose_BTN.pressed.connect(self._delete_pose)
self.win.edit_pose_BTN.pressed.connect(self._update_pose)
self.win.mirror_pose_BTN.pressed.connect(self._mirror_pose)
self.win.rename_pose_BTN.pressed.connect(self._rename_pose)
self.win.mute_pose_BTN.pressed.connect(self._mute_pose)
self.win.pose_LIST.itemSelectionChanged.connect(self._pose_selection_changed)
# Blendshape Connections
self.win.create_blendshape_BTN.pressed.connect(self._create_blendshape)
self.win.add_existing_blendshape_BTN.pressed.connect(self._add_blendshape)
self.win.edit_blendshape_BTN.pressed.connect(self._edit_blendshape)
# Utility Connections
self.win.mirror_mapping_BTN.clicked.connect(self.set_mirror_file)
self.win.use_maya_style_ACT.triggered.connect(self._toggle_stylesheet)
self.win.documentation_ACT.triggered.connect(self._open_documentation)
self.win.use_maya_style_ACT.setChecked(bool(int(settings.SettingsManager.get_setting("UseMayaStyle") or 0)))
# Create a log dock widget
self._log_widget = log_widget.LogWidget()
# Add the log widget as a handler for the current log to display log messages in the UI
LOG.addHandler(self._log_widget)
# Add the dock to the window
self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self._log_widget.log_dock)
# Set the stylesheet
self._set_stylesheet()
# Empty vars to store the currently edited solver and blendshape, None if not editing either
self._editing_solver = None
self._editing_blendshape = None
self._valid_actions = []
# Generate lists of UI elements for different UI states.
# Solver context enables/disables the specified widgets when a solver is in edit mode or not
self._solver_context_ui_elements = [self.win.add_pose_BTN, self.win.delete_pose_BTN,
self.win.edit_pose_BTN, self.win.mirror_pose_BTN, self.win.mute_pose_BTN,
self.win.add_driven_BTN, self.win.remove_driven_BTN,
self.win.create_blendshape_BTN,
self.win.add_existing_blendshape_BTN, self.win.edit_blendshape_BTN,
self.win.add_driver_BTN, self.win.remove_driver_BTN,
self.win.rename_pose_BTN]
# Blendshape editing context enables/disables the specified widgets when a blendshape is being edited or not
self._blendshape_editing_context_ui_elements = [self.win.add_pose_BTN, self.win.delete_pose_BTN,
self.win.edit_pose_BTN, self.win.mirror_pose_BTN,
self.win.mute_pose_BTN,
self.win.add_driven_BTN, self.win.remove_driven_BTN,
self.win.create_blendshape_BTN,
self.win.add_existing_blendshape_BTN,
self.win.add_driver_BTN, self.win.remove_driver_BTN,
self.win.create_solver_BTN, self.win.delete_solver_BTN,
self.win.toggle_edit_BTN, self.win.rename_pose_BTN]
# Disable all the solver elements by default
for element in self._solver_context_ui_elements:
element.setEnabled(False)
self.show(dockable=True)
@property
def valid_actions(self):
return self._valid_actions
def add_rbf_solver(self, solver, edit=False):
"""
Add a solver to the view
:param solver :type api.RBFNode: solver reference
:param edit :type bool: is this solver in edit mode?
"""
# Create a new widget item with the solver name
item = QtWidgets.QListWidgetItem(str(solver))
# Add the solver and the edit status as custom data on the item
item.setData(QtCore.Qt.UserRole, {"solver": solver, "edit": edit})
# If we are editing, keep track of it
if edit:
self._editing_solver = solver
# Add the item to the solver list
self.win.solver_LIST.addItem(item)
def clear(self):
"""
Clear all the lists in the UI
"""
self.win.solver_LIST.clear()
self.win.driver_transforms_LIST.clear()
self.win.driven_transforms_LIST.clear()
self.win.pose_LIST.clear()
def delete_solver(self, solver):
"""
Delete the specified solver from the view
:param solver :type api.RBFNode: solver ref
"""
# Iterate through the solvers
for i in range(self.win.solver_LIST.count()):
# Get the item
item = self.win.solver_LIST.item(i)
# If the solver matches the solver from the item data
if item.data(QtCore.Qt.UserRole)['solver'] == solver:
# Remove the item from the list and break early
self.win.solver_LIST.takeItem(self.win.solver_LIST.row(item))
break
def display_extensions(self, extensions):
"""
Adds any found extensions to the utilities dock
:param extensions :type list: list of PoseWranglerExtension instances
"""
# Clear the existing extension layout
for i in reversed(range(0, self.win.extension_layout.count())):
item = self.win.extension_layout.itemAt(i)
if isinstance(item, QtWidgets.QWidgetItem):
widget = item.widget()
widget.deleteLater()
del widget
self.win.extension_layout.removeItem(item)
categories = {}
for extension in extensions:
if extension.view:
category_name = extension.__category__ or "Default"
if category_name in categories:
category_widget = categories[category_name]
else:
category_widget = category.CategoryWidget(category_name)
categories[category_name] = category_widget
self.win.extension_layout.addWidget(category_widget)
category_widget.add_extension(extension.view)
self.win.utilities_DOCK.setMinimumWidth(
self.win.extension_layout.sizeHint().width() +
self.win.action_scroll_area.verticalScrollBar().sizeHint().width()
)
def edit_blendshape(self, pose_name, edit=False):
"""
Set the UI in blendshape edit mode or finish editing.
:param pose_name :type str: name of the pose to edit or finish editing
:param edit :type bool: True = edit mode, False = finish editing
"""
# Iterate through all of the driven transforms in the list
for i in range(self.win.driven_transforms_LIST.count()):
# Get the item
item = self.win.driven_transforms_LIST.item(i)
# Find the custom data stored on the item
item_data = item.data(QtCore.Qt.UserRole)
# If the item type is a blendshape and the pose matches the current pose, break
if item_data['type'] == 'blendshape' and item_data['pose_name'] == pose_name:
break
# If no break was hit, exit early because no match was found
else:
return
# If we are editing
if edit:
# If we are already editing a blendshape, finish editing the previous one first
if self._editing_blendshape is not None:
# Get the existing item data from the currently edited blendshape
existing_item_data = self._editing_blendshape.data(QtCore.Qt.UserRole)
# Finish editing
self.event_edit_blendshape.emit(
existing_item_data['pose_name'], False,
existing_item_data['solver']
)
# Update the icon
self._editing_blendshape.setIcon(QtGui.QIcon(":/blendShape.png"))
# Store a ref to the new blendshape being edited
self._editing_blendshape = item
# Update the button text
self.win.edit_blendshape_BTN.setText("Finish Editing Blendshape")
# Update the icon to show edit mode
item.setIcon(QtGui.QIcon(":/fileTextureEdit.png"))
# Disable all the non blendshape related ui elements
for element in self._blendshape_editing_context_ui_elements:
element.setEnabled(False)
# If we are not editing
else:
# Clear the ref to the edited blendshape
self._editing_blendshape = None
# Revert the button text
self.win.edit_blendshape_BTN.setText("Edit Blendshape")
# Revert the icon
item.setIcon(QtGui.QIcon(":/blendShape.png"))
# Re-enable all the ui elements
for element in self._blendshape_editing_context_ui_elements:
element.setEnabled(True)
# Store the edit status in the item data
item_data['edit'] = edit
# Update the item data on the widget
item.setData(QtCore.Qt.UserRole, item_data)
def edit_solver(self, solver, edit=False):
"""
Set the UI in solver edit mode or finish editing.
:param solver :type str: name of the solver to edit or finish editing
:param edit :type bool: True = edit mode, False = finish editing
"""
# Iterate through the solvers
for i in range(self.win.solver_LIST.count()):
# Get the widget item
item = self.win.solver_LIST.item(i)
# If the solver matches the item data, break
if item.data(QtCore.Qt.UserRole)['solver'] == solver:
break
# No match found, return early
else:
return
# Grab the item data for the item
item_data = item.data(QtCore.Qt.UserRole)
# If edit mode
if edit:
# If we are already editing a solver, finish editing that one first
if self._editing_solver is not None:
# Update the icon
self._editing_solver.setIcon(QtGui.QIcon())
# Finish editing
self.event_edit_solver.emit(False, self._editing_solver)
# Store the new item as the current edited solver
self._editing_solver = item
# Update the button text
self.win.toggle_edit_BTN.setText("Finish Editing '{solver}'".format(solver=solver))
# Set the edit icon
item.setIcon(QtGui.QIcon(":/fileTextureEdit.png"))
else:
# Clear the current solver
self._editing_solver = None
# Revert the button text
self.win.toggle_edit_BTN.setText("Edit Selected Driver")
# Clear the edit icon
item.setIcon(QtGui.QIcon())
# Update the item data with the new edit status
item_data['edit'] = edit
# Set the item data
item.setData(QtCore.Qt.UserRole, item_data)
# Update the solver related ui elements with the current edit status
for element in self._solver_context_ui_elements:
element.setEnabled(edit)
self.win.delete_solver_BTN.setEnabled(not edit)
self.win.create_solver_BTN.setEnabled(not edit)
self.win.refresh_BTN.setEnabled(not edit)
self.win.mirror_solver_BTN.setEnabled(not edit)
# If a pose is selected, go to it. This will cause transforms to pop if edits weren't saved
self._pose_selection_changed()
def get_context(self):
"""
Gets the current state of the ui
:return: PoseWranglerUIContext
"""
return ui_context.PoseWranglerUIContext(
current_solvers=[i.text() for i in self.win.solver_LIST.selectedItems()],
current_poses=[i.text() for i in self.win.pose_LIST.selectedItems()],
current_drivers=[i.text() for i in self.win.driver_transforms_LIST.selectedItems()],
current_driven=[i.text() for i in self.win.driven_transforms_LIST.selectedItems()],
solvers=[self.win.solver_LIST.item(i).text() for i in range(0, self.win.solver_LIST.count())],
poses=[self.win.pose_LIST.item(i).text() for i in range(0, self.win.pose_LIST.count())],
drivers=[self.win.driver_transforms_LIST.item(i).text() for i in
range(0, self.win.driver_transforms_LIST.count())],
driven=[self.win.driven_transforms_LIST.item(i).text() for i in
range(0, self.win.driven_transforms_LIST.count())],
)
def load_solver_settings(self, solver, drivers, driven_transforms, poses):
"""
Display the settings for the specified solver
:param solver :type RBFNode: solver reference
:param drivers :type dict: dictionary of driver name: driver data
:param driven_transforms :type dict: dictionary of driven transform type: driven transforms
:param poses :type dict: dictionary of pose data
"""
# Grab the current selection from the driver, driven and pose list
current_drivers = [i.text() for i in self.win.driver_transforms_LIST.selectedItems()]
current_driven = [i.text() for i in self.win.driven_transforms_LIST.selectedItems()]
current_poses = [i.text() for i in self.win.pose_LIST.selectedItems()]
# Clear the driver, driven and pose lists
self.win.driver_transforms_LIST.clear()
self.win.driven_transforms_LIST.clear()
self.win.pose_LIST.clear()
# Iterate through each driver
for driver_name, driver_data in drivers.items():
# Create new item widget
item = QtWidgets.QListWidgetItem(driver_name)
# Set the icon to a joint
item.setIcon(QtGui.QIcon(":/kinJoint.png"))
# Add item to the list
self.win.driver_transforms_LIST.addItem(item)
# Set the item data so it can be referenced later
item.setData(QtCore.Qt.UserRole, driver_data)
# Iterate through the driven transforms transform nodes
for driven_transform in driven_transforms['transform']:
# Create new item widget
item = QtWidgets.QListWidgetItem(driven_transform)
# Store the solver and item type
item.setData(QtCore.Qt.UserRole, {'type': 'transform', 'solver': solver})
# Set the icon to a joint
item.setIcon(QtGui.QIcon(":/kinJoint.png"))
# Add item to the list
self.win.driven_transforms_LIST.addItem(item)
# Iterate through all the driven transforms blendshapes
for blendshape_mesh, pose_name in driven_transforms['blendshape'].items():
# Create new item widget
item = QtWidgets.QListWidgetItem(blendshape_mesh)
# Store the solver, pose associated with the blendshape and the item type
item.setData(QtCore.Qt.UserRole, {'type': 'blendshape', 'pose_name': pose_name, 'solver': solver})
# Set the icon to blendshape
item.setIcon(QtGui.QIcon(":/blendShape.png"))
# Add item to the list
self.win.driven_transforms_LIST.addItem(item)
# Iterate through the poses
for pose, pose_data in poses.items():
# Create item and add it to the list
item = QtWidgets.QListWidgetItem(pose)
self.win.pose_LIST.addItem(item)
muted = not pose_data.get('target_enable', True)
if muted:
font = item.font()
font.setStrikeOut(True)
item.setFont(font)
# Set icon to a pose
item.setIcon(QtGui.QIcon(":/p-head.png"))
# Iterate through the solvers
for row in range(self.win.solver_LIST.count()):
# Find the item
item = self.win.solver_LIST.item(row)
# If the current solver matches this item, select it
if solver == item.data(QtCore.Qt.UserRole)['solver']:
item.setSelected(True)
break
# Create a map between widgets: names of previously selected items
existing_selection_to_list_map = {
self.win.driver_transforms_LIST: current_drivers,
self.win.driven_transforms_LIST: current_driven,
self.win.pose_LIST: current_poses
}
# Iterate through the widgets: target selections
for list_ref, current in existing_selection_to_list_map.items():
# Block signals so we don't fire any selection changed events
list_ref.blockSignals(True)
# Iterate through the list widget
for i in range(list_ref.count()):
# Get the item
item = list_ref.item(i)
# Check if the text is in the list of currently selected items (case sensitive) and select/deselect
# as appropriate
if item.text() in current:
item.setSelected(True)
else:
item.setSelected(False)
# Unblock the signals
list_ref.blockSignals(False)
def set_mirror_file(self):
"""
Open up a dialog to get a new mirror mapping file
"""
# Open dialog
result = QtWidgets.QFileDialog.getOpenFileName(
self, "Mirror Mapping File",
os.path.join(os.path.dirname(__file__), 'mirror_mappings'),
"Mirror Mapping File (*.json)"
)
# If no file specified, exit early
if not result[0]:
return
# Emit the event
self.event_set_mirror_mapping.emit(result[0])
def update_mirror_mapping_file(self, path):
"""
Update the mirror mapping widget with the specified path
:param path :type str: file path to the mirror mapping file
"""
# Set the text
self.win.mirror_mapping_LINE.setText(path)
# ================================================ Solvers ======================================================= #
def _create_solver(self):
"""
Create a new solver with the given name
"""
# Popup input widget to get the solver name
interp_name, ok = QtWidgets.QInputDialog.getText(self, 'Create Solver', 'Solver Name:')
# Hack to fix broken styling caused by using a QInputDialog
self.win.create_solver_BTN.setEnabled(False)
self.win.create_solver_BTN.setEnabled(True)
# If no name, exit
if not interp_name:
return
if not interp_name.lower().endswith('_uerbfsolver'):
interp_name += "_UERBFSolver"
# Trigger solver creation
self.event_create_solver.emit(interp_name)
def _delete_solver(self):
"""
Delete selected solvers
"""
# Get the current solver selection
selected_items = self.win.solver_LIST.selectedItems()
selected_solvers = []
# Iterate through the selection in reverse
for item in reversed(selected_items):
# Grab the solver from the item data
selected_solvers.append(item.data(QtCore.Qt.UserRole)['solver'])
# Delete the solvers from the backend first
for solver in selected_solvers:
self.event_delete_solver.emit(solver)
def _edit_solver_toggle(self):
"""
Toggle edit mode for the currently selected driver
"""
# Grab the current selection
selection = self.win.solver_LIST.selectedItems()
if selection:
# Grab the last item
item = selection[-1]
# Get the item data
item_data = item.data(QtCore.Qt.UserRole)
# Get the solver
solver = item_data['solver']
# Trigger edit mode enabled/disabled depending on the solvers current edit state
self.event_edit_solver.emit(not item_data.get('edit', False), solver)
def _get_item_from_solver(self, solver):
"""
Get the widget associated with this solver
:param solver :type api.RBFNode: solver ref
:return :type QtWidgets.QListWidgetItem or None: widget
"""
# Iterate through the solver list
for i in range(self.win.solver_LIST.count()):
# Get the item
item = self.win.solver_LIST.item(i)
# If the solver stored in the item data matches, return it
if item.data(QtCore.Qt.UserRole)['solver'] == solver:
return item
# No match found, returning None
def _get_selected_solvers(self):
"""
:return: List of current RBFNodes
"""
# Get the solver from the item data for each item in the current selection
return [item.data(QtCore.Qt.UserRole)['solver'] for item in self.win.solver_LIST.selectedItems()]
def _mirror_solver(self):
"""
Mirror the selected solvers
"""
# For each solver, trigger mirroring
for solver in self._get_selected_solvers():
self.event_mirror_solver.emit(solver)
def _refresh_solvers(self):
"""
Refresh the solver list
"""
self.event_refresh_solvers.emit()
def _solver_selection_changed(self):
"""
When the solver selection changes, update the driver/driven and poses list accordingly
"""
# Get the current selection
items = self.win.solver_LIST.selectedItems()
# If we have a selection
if items:
# Grab the last selected item
item = items[-1]
# Grab the item data
item_data = item.data(QtCore.Qt.UserRole)
# Set the current solver to the solver associated with this widget
self.event_set_current_solver.emit(item_data['solver'])
# Get the solvers edit status
edit = item_data.get('edit', False)
# Enable/disable the ui elements accordingly
for element in self._solver_context_ui_elements:
element.setEnabled(edit)
# If we are in edit mode
if edit:
# Update the button
self.win.toggle_edit_BTN.setText("Finish Editing '{solver}'".format(solver=item_data['solver']))
# Set the icon
item.setIcon(QtGui.QIcon(":/fileTextureEdit.png"))
# No selection, clear all the lists
else:
self.win.driver_transforms_LIST.clear()
self.win.driven_transforms_LIST.clear()
self.win.pose_LIST.clear()
# ================================================ Drivers ======================================================= #
def _add_drivers(self):
"""
Add drivers.
"""
# Emit nothing so that the current scene selection will be used
self.event_add_drivers.emit()
def _remove_drivers(self):
"""
Remove the specified drivers
"""
# Emit the selected drivers to be deleted
self.event_remove_drivers.emit(
[i.data(QtCore.Qt.UserRole)
for i in self.win.driver_transforms_LIST.selectedItems()]
)
# ================================================ Driven ======================================================== #
def _add_driven(self):
"""
Add driven nodes to the solver
"""
# Emit no driven nodes so that it uses scene selection, no solver so that it uses the current solver and set
# edit mode to true
self.event_add_driven.emit(None, None, True)
def _remove_driven(self):
"""
Remove driven transforms from the specified solver
"""
# Get currently selected driven items
items = self.win.driven_transforms_LIST.selectedItems()
# Exit early if nothing is selected
if not items:
return
# Get the solver for the last item (they should all have the same solver)
solver = items[-1].data(QtCore.Qt.UserRole)['solver']
# Trigger event to remove specified nodes for the solver
self.event_remove_driven.emit([i.text() for i in items], solver)
# ============================================== Blendshapes ===================================================== #
def _add_blendshape(self):
"""
Add an existing blendshape to the solver for the current pose
"""
# Get current solver
solver = self._get_selected_solvers()
# Get current pose
poses = self._get_selected_poses()
if not solver or not poses:
LOG.warning("Unable to add blendshape, please select a solver and a pose and try again")
return
self.event_add_blendshape.emit(poses[-1], "", "", solver[-1])
def _create_blendshape(self):
"""
Create a blendshape for the current solver at the current pose
"""
# Get current solver
solver = self._get_selected_solvers()
# Get current pose
poses = self._get_selected_poses()
if not solver or not poses:
LOG.warning("Unable to create blendshape, please select a solver and a pose and try again")
return
# Create a blendshape for the last pose selected, with the current mesh selection, in edit mode enabled and
# for the last solver selected
self.event_create_blendshape.emit(poses[-1], None, True, solver[-1])
def _edit_blendshape(self):
"""
Edit or finish editing the selected blendshape
"""
# Get the current blendshape being edited
blendshape = self._editing_blendshape
# If none is being edited, see if we have a blendshape selected
if blendshape is None:
# Get all the blendshapes selected
selection = [sel for sel in self.win.driven_transforms_LIST.selectedItems() if
sel.data(QtCore.Qt.UserRole)['type'] == 'blendshape']
# Update the blendshape var
if selection:
blendshape = selection[-1]
# If we have a blendshape
if blendshape:
# Get the item data
item_data = blendshape.data(QtCore.Qt.UserRole)
# Get the associated pose name and solver
pose_name = item_data['pose_name']
solver = item_data['solver']
# Trigger editing the blendshape for the pose name, using the opposite to the current edit status and for
# the associated solver
self.event_edit_blendshape.emit(pose_name, not item_data.get('edit', False), solver)
# ================================================= Poses ======================================================== #
def _create_pose(self):
"""
Create a new pose
"""
# Get the current solver
selected_solvers = self._get_selected_solvers()
# Get the last selection
solver = selected_solvers[-1]
# Display popup to get pose name
pose_name, ok = QtWidgets.QInputDialog.getText(self, 'Create Pose', 'Pose Name:')
# Hack to fix broken styling caused by using a QInputDialog
self.win.add_pose_BTN.setEnabled(False)
self.win.add_pose_BTN.setEnabled(True)
if pose_name:
# If a pose name is specified, create pose
self.event_add_pose.emit(pose_name, solver)
def _delete_pose(self):
"""
Delete the selected poses
"""
# Get the currently selected poses
poses = self._get_selected_poses()
# Exit early if nothing is selected
if not poses:
return
# Get the selected solver
solver = self._get_selected_solvers()[-1]
# Block the signals to stop UI event updates
self.win.pose_LIST.blockSignals(True)
# For each selected pose, delete it
for pose_name in poses:
self.event_delete_pose.emit(pose_name, solver)
self.win.pose_LIST.blockSignals(False)
def _get_selected_poses(self):
"""
Get the selected poses
:return :type list: list of selected pose names
"""
return [item.text() for item in self.win.pose_LIST.selectedItems()]
def _mirror_pose(self):
"""
Mirror the selected poses
"""
# Get the pose selection
poses = self._get_selected_poses()
# Exit early if nothing is selected
if not poses:
return
# Get the selected solver
solver = self._get_selected_solvers()[-1]
# For each pose, mirror it. This will create a mirrored solver if it doesn't already exist
for pose_name in poses:
self.event_mirror_pose.emit(pose_name, solver)
def _mute_pose(self):
"""
Toggles the muted status for the poses the selected poses
"""
# Get the pose selection
poses = self._get_selected_poses()
# Exit early if nothing is selected
if not poses:
return
solver = self._get_selected_solvers()[-1]
# For each pose, mirror it. This will create a mirrored solver if it doesn't already exist
for pose_name in poses:
self.event_mute_pose.emit(pose_name, None, solver)
def _pose_selection_changed(self):
"""
On pose selection changed, go to the new pose
"""
# Get the current pose selection
selected_poses = self._get_selected_poses()
if selected_poses:
# Get the last pose
selected_pose = selected_poses[-1]
# Get the selected solver
solver = self._get_selected_solvers()[-1]
# Go to pose
self.event_go_to_pose.emit(selected_pose, solver)
def _rename_pose(self):
"""
Rename the currently selected pose
"""
# Get the current pose selection
poses = self._get_selected_poses()
# Exit early if nothing is selected
if not poses:
return
# Get the last selected pose name
pose_name = poses[-1]
# Get the current solver
solver = self._get_selected_solvers()[-1]
# Create a popup to get the new pose name
new_name, ok = QtWidgets.QInputDialog.getText(self, 'Rename Pose', 'New Pose Name:')
# Hack to fix broken styling caused by using a QInputDialog
self.win.rename_pose_BTN.setEnabled(False)
self.win.rename_pose_BTN.setEnabled(True)
# Exit if no new name is specified
if not new_name:
return
# Get the existing pose names
existing_names = [self.win.pose_LIST.item(i).text().lower() for i in range(self.win.pose_LIST.count())]
# If the pose name already exists, log it and exit
if new_name.lower() in existing_names:
LOG.error("Pose '{pose_name}' already exists".format(pose_name=new_name))
return
# Pose doesn't already exist, rename it
self.event_rename_pose.emit(pose_name, new_name, solver)
def _update_pose(self):
"""
Update the pose for the given solver
"""
# Get the current pose selection
poses = self._get_selected_poses()
# Exit early if nothing is selected
if not poses:
return
# Get the last selected pose name
pose_name = poses[-1]
# Get the current solver
solver = self._get_selected_solvers()[-1]
# Update the pose
self.event_update_pose.emit(pose_name, solver)
# ================================================== IO ========================================================== #
def _import_drivers(self):
"""
Generate a popup to find a json file to import serialized solver data from
"""
# Get a json file path
file_path = QtWidgets.QFileDialog.getOpenFileName(None, "Pose Wrangler Format", "", "*.json")[0]
# If no path is specified, exit early
if file_path == "":
return
# Import the drivers
self.event_import_drivers.emit(file_path)
def _export_drivers(self, all_drivers=False):
"""
Export drivers to a file
:param all_drivers :type bool: should all drivers be exported or just the current selection?
"""
# Get the export file path
file_path = QtWidgets.QFileDialog.getSaveFileName(None, "Pose Wrangler Format", "", "*.json")[0]
# If no path is specified, exit early
if file_path == "":
return
# If all drivers is specified, get all solvers
if all_drivers:
target_solvers = []
else:
# Get the currently selected solvers
target_solvers = self._get_selected_solvers()
# If no solvers found, skip export
if not target_solvers:
LOG.warning("Unable to export. No solvers selected")
return
# Export drivers
self.event_export_drivers.emit(file_path, target_solvers)
# =============================================== Utilities ====================================================== #
def _open_documentation(self):
"""
Open the documentation
"""
index_html = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../..', 'docs', 'site', 'html', 'index.html')
)
webbrowser.open(index_html)
def _set_stylesheet(self):
"""
Load the style.qss file and set the stylesheet
:return:
"""
if bool(int(settings.SettingsManager.get_setting("UseMayaStyle") or 0)):
styles_path = os.path.join(os.path.dirname(__file__), 'maya_style.qss')
else:
styles_path = os.path.join(os.path.dirname(__file__), 'style.qss')
with open(styles_path) as style:
self.setStyleSheet(style.read())
def _toggle_stylesheet(self, use_maya_style=False):
"""
Toggles between the maya and UE stylesheet
:param use_maya_style :type bool: on or off
"""
settings.SettingsManager.set_setting("UseMayaStyle", int(use_maya_style))
self._set_stylesheet()
def _select_in_scene(self, list_widget=None, item=None):
"""
Select the specified items in the scene
:param list_widget :type QtWidgets.QListWidget or None: optional list widget to select from
:param item :type QtWidgets.QListWidgetItem or None: optional item to select
"""
# If no item is provided, select all the items from the list widget specified
if item is None:
items = [list_widget.item(i).text() for i in range(list_widget.count())]
# Otherwise select the current item
else:
items = [item.text()]
# If we have items, select them
if items:
self.event_select.emit(items)
def _solver_context_menu_requested(self, point):
"""
Create a context menu for the solver list widget
:param point :type QtCore.QPoint: screen position of the request
"""
# Create a new menu
menu = QtWidgets.QMenu(parent=self)
self.event_get_valid_actions.emit(self.get_context())
# Dict to store sub menu name: sub menu
sub_menus = {}
# Iterate through the valid actions
for action in self._valid_actions:
# Set the current menu to the base menu
current_menu = menu
# If the action has a category
if action.__category__:
# If the category doesn't already exist, create it
if action.__category__ not in sub_menus:
sub_menus[action.__category__] = menu.addMenu(action.__category__)
# Set the current menu to the category menu
current_menu = sub_menus[action.__category__]
# Create a new action on the current menu
action_widget = current_menu.addAction(action.__display_name__)
action_widget.setToolTip(action.__tooltip__)
# Connect the action trigger to execute the action, passing through the action and data to execute
action_widget.triggered.connect(partial(self._trigger_context_menu_action, action))
# Draw the menu at the current cursor pos
menu.exec_(QtGui.QCursor.pos())
def _trigger_context_menu_action(self, action):
"""
Execute the specified action with the given data
:param action :type base_action.BaseAction: action class
"""
# Execute the action with the data
action.execute(ui_context=self.get_context())

View File

@ -0,0 +1,204 @@
QWidget {
background-color: rgb(27, 28, 30);
color: rgb(200, 200, 200);
}
QMenu {
background-color: rgb(4, 4, 4);
}
QMenu::item:selected {
background-color: rgb(50, 50, 50);
}
QWidget:disabled {
color: rgb(112, 117, 120);
}
QLineEdit {
border-radius: 2px;
padding-left: 7px;
padding-right: 7px;
padding-top: 5px;
padding-bottom: 5px;
selection-background-color: rgb(5, 127, 255);
background: rgb(4, 4, 4);
}
QDoubleSpinBox {
border-radius: 2px;
selection-background-color: rgb(5, 127, 255);
background: rgb(4, 4, 4);
}
QSpinBox {
border-radius: 2px;
selection-background-color: rgb(5, 127, 255);
background: rgb(4, 4, 4);
}
QComboBox {
background-color: #2c2d2f;
border-radius: 5px;
}
QScrollArea {
border: 2px solid rgb(9, 10, 12);
}
QFrame#background {
border: 2px solid rgb(9, 10, 12);
}
QListWidget {
background-color: rgb(15, 15, 15);
}
QListWidget[Log=true] {
background-color: rgb(15, 15, 15);
background-image: url("PoseWrangler:epic.png");
background-repeat: no-repeat;
background-position: bottom right;
background-attachment: fixed;
}
QFrame {
border-radius: 5px;
margin-bottom: 5px;
}
QTreeWidget QHeaderView:section {
border-style: none;
padding: 8px;
color: rgb(0, 255, 0);
background-color: #1e2126;
}
QPushButton {
border-radius: 2px;
padding-top: 5px;
padding-bottom: 5px;
padding-left: 10px;
padding-right: 10px;
color: rgb(255, 255, 255);
background-color: rgb(40, 40, 40);
}
QPushButton:hover {
background: rgb(100, 100, 100);
}
QPushButton:pressed {
background: rgb(41, 164, 255);
}
QProgressBar {
background: rgb(50, 50, 50);
color: rgb(10, 10, 10);
border-radius: 5px;
border: 1px solid rgb(100, 100, 100);
text-align: center;
font-size: 11px;
font-weight: bold;
}
QProgressBar::chunk {
border-radius: 5px;
background: QLinearGradient(
x1:1,
y1: 0,
x2: 0,
y2: 0,
stop: 1 rgb(41, 164, 255),
stop: 0.4 rgb(5, 127, 255));
text-align: center;
font-weight: bold;
}
QScrollBar::handle:vertical {
background: rgb(100, 100, 100);
border-radius: 5px;
}
QScrollBar::sub-page:vertical {
border-radius: -5px;
background: rgb(15, 15, 15);
}
QScrollBar::add-page:vertical {
border-radius: -5px;
background: rgb(15, 15, 15);
}
QScrollBar::handle:horizontal {
background: rgb(100, 100, 100);
border-radius: 5px;
}
QScrollBar::sub-page:horizontal {
border-radius: -5px;
background: rgb(15, 15, 15);
}
QScrollBar::add-page:horizontal {
border-radius: -5px;
background: rgb(15, 15, 15);
}
QToolTip {
border-radius: 10px;
color: rgb(255, 255, 255);
background-color: rgb(10, 10, 10);
padding: 5px;
border: 0px;
}
QCheckBox {
width: 13px;
height: 13px;
}
QCheckBox::indicator {
width: 13px;
height: 13px;
}
QGroupBox {
border: 0px solid rgb(100, 100, 100);
border-radius: 0px;
padding: 10px;
}
QLabel[heading=true] {
background-color: rgb(50, 50, 50);
border-radius: 0px;
padding: 4px;
}
QPushButton[Category=true] {
text-align: left;
background-color: #323232;
}
QPushButton::hover[Category=true] {
text-align: left;
background-color: #323232;
}
QPushButton[LogButton=true] {
background-color: #323232;
}
QPushButton::hover[LogButton=true] {
background-color: #474646;
}
QPushButton::checked[LogButton=true] {
background-color: #202328;
}

View File

@ -0,0 +1,45 @@
# Copyright Epic Games, Inc. All Rights Reserved.
class PoseWranglerUIContext(object):
def __init__(
self, current_solvers, current_poses, current_drivers, current_driven, solvers, poses, drivers, driven
):
self._current_solvers = current_solvers
self._current_poses = current_poses
self._current_drivers = current_drivers
self._current_driven = current_driven
self._solvers = solvers
self._poses = poses
self._drivers = drivers
self._driven = driven
@property
def current_solvers(self):
return self._current_solvers
@property
def current_poses(self):
return self._current_poses
@property
def current_drivers(self):
return self._current_drivers
@property
def current_driven(self):
return self._current_driven
@property
def solvers(self):
return self._solvers
@property
def poses(self):
return self._poses
@property
def drivers(self):
return self._drivers
@property
def driven(self):
return self._driven

View File

@ -0,0 +1,45 @@
# Copyright Epic Games, Inc. All Rights Reserved.
from PySide2 import QtCore, QtGui, QtWidgets
class CategoryWidget(QtWidgets.QWidget):
def __init__(self, name):
super(CategoryWidget, self).__init__()
self.setContentsMargins(0, 0, 0, 0)
main_layout = QtWidgets.QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(main_layout)
self._category_button = QtWidgets.QPushButton(name)
font = self._category_button.font()
font.setBold(True)
font.setPointSize(10)
self._category_button.setFont(font)
self._category_button.setIcon(QtGui.QIcon(QtGui.QPixmap("PoseWrangler:frame_open.png")))
self._category_button.setIconSize(QtCore.QSize(16, 16))
self._category_button.clicked.connect(self._toggle_category_visibility)
self._category_button.setCheckable(True)
self._category_button.setChecked(True)
self._category_button.setProperty("Category", True)
main_layout.addWidget(self._category_button)
self._category_container = QtWidgets.QWidget()
self._category_container.setContentsMargins(0, 0, 0, 0)
self._category_layout = QtWidgets.QVBoxLayout()
self._category_layout.setContentsMargins(0, 0, 0, 0)
self._category_layout.setSpacing(0)
self._category_container.setLayout(self._category_layout)
main_layout.addWidget(self._category_container)
def _toggle_category_visibility(self):
self._category_container.setVisible(self._category_button.isChecked())
self._category_button.setIcon(
QtGui.QIcon(QtGui.QPixmap("PoseWrangler:frame_open.png"))
if self._category_button.isChecked() else QtGui.QIcon(
QtGui.QPixmap("PoseWrangler:frame_closed.png")
)
)
def add_extension(self, widget):
self._category_layout.addWidget(widget)